History

Merge remote-tracking branch 'origin/main' into changelog-page

Rhys Sullivan committed 2 months ago

Commit d1203972 parents 5b90890 d5946f11099 files · +113836 −11239Browse files

Unified · Split

889 files
  1. .changeset/cli-credential-store-permissions.md +11 −0
  2. .changeset/concurrent-data-migration-stamps.md +5 −0
  3. .changeset/config.json +2 −2
  4. .changeset/connection-edit-sheet-agent-preview.md +9 −0
  5. .changeset/desktop-settings-owner-only.md +9 −0
  6. .changeset/graph-slice-urls-first-class.md +5 −0
  7. .changeset/graph-spec-slices.md +5 −0
  8. .changeset/host-evaluate-timeout.md +0 −5
  9. .changeset/lazy-mcp-client-module.md +5 −0
  10. .changeset/mcp-abort-releases-response-body.md +7 −0
  11. .changeset/mcp-health-missing-credential.md +11 −0
  12. .changeset/mcp-namespace-search-tools.md +8 −0
  13. .changeset/mcp-pool-key-credential-retention.md +11 −0
  14. .changeset/mcp-priming-reconnect.md +0 −5
  15. .changeset/mcp-session-idle-runtime-reclaim.md +15 −0
  16. .changeset/mcp-sse-result-replay.md +0 −5
  17. .changeset/oauth-dcr-application-type.md +9 −0
  18. .changeset/oauth-popup-localstorage-residue.md +9 −0
  19. .changeset/onepassword-service-account-token-lifetime.md +11 −0
  20. .changeset/openapi-streaming-preview.md +5 −0
  21. .changeset/slim-search-tools.md +6 −0
  22. .changeset/storage-error-shaping.md +8 −0
  23. .changeset/tools-proxy-enumeration-hint.md +0 −5
  24. .changeset/worker-bundler-staging-contract.md +11 −0
  25. .changeset/workos-blip-session-survival.md +0 −5
  26. .claude/skills/emulate/SKILL.md +15 −12
  27. .claude/skills/prod-telemetry/SKILL.md +18 −1
  28. .github/workflows/ci.yml +144 −57
  29. .github/workflows/deploy.yml +29 −1
  30. .github/workflows/graph-slices.yml +47 −0
  31. .github/workflows/pkg-pr-new.yml +10 −0
  32. .github/workflows/preview-sweep.yml +10 −0
  33. .github/workflows/preview.yml +10 −0
  34. .github/workflows/publish-desktop.yml +12 −0
  35. .github/workflows/publish-executor-package.yml +13 −1
  36. .github/workflows/publish-selfhost-docker.yml +142 −4
  37. .github/workflows/release.yml +15 −1
  38. .oxfmtrc.json +1 −0
  39. .oxlintrc.jsonc +26 −0
  40. AGENTS.md +43 −68
  41. README.md +8 −8
  42. TELEMETRY.md +101 −0
  43. apps/cli/CHANGELOG.md +239 −0
  44. apps/cli/package.json +4 −1
  45. apps/cli/src/build.ts +163 −1
  46. apps/cli/src/device-login.test.ts +118 −2
  47. apps/cli/src/device-login.ts +57 −8
  48. apps/cli/src/main.ts +263 −69
  49. apps/cli/src/native-bindings.ts +36 −5
  50. apps/cli/src/server-connection.test.ts +84 −0
  51. apps/cli/src/server-connection.ts +49 −0
  52. apps/cli/src/server-profile.test.ts +86 −5
  53. apps/cli/src/server-profile.ts +22 −4
  54. apps/cli/src/service.test.ts +30 −0
  55. apps/cli/src/service.ts +11 −0
  56. apps/cli/src/tooling.ts +2 −2
  57. apps/cli/src/worker-bundler-artifact.test.ts +59 −0
  58. apps/cli/src/worker-bundler-artifact.ts +55 −0
  59. apps/cli/tsconfig.json +1 −1
  60. apps/cloud/CHANGELOG.md +293 −0
  61. apps/cloud/drizzle/0010_ndjson_output_arrays.sql +23 −0
  62. apps/cloud/drizzle/0011_tense_caretaker.sql +1 −0
  63. apps/cloud/drizzle/0012_woozy_shiva.sql +10 −0
  64. apps/cloud/drizzle/0013_curly_rocket_racer.sql +14 −0
  65. apps/cloud/drizzle/0014_new_karma.sql +1 −0
  66. apps/cloud/drizzle/0015_equal_the_leader.sql +1 −0
  67. apps/cloud/drizzle/meta/0010_snapshot.json +1290 −0
  68. apps/cloud/drizzle/meta/0011_snapshot.json +1296 −0
  69. apps/cloud/drizzle/meta/0012_snapshot.json +1367 −0
  70. apps/cloud/drizzle/meta/0013_snapshot.json +1474 −0
  71. apps/cloud/drizzle/meta/0014_snapshot.json +1480 −0
  72. apps/cloud/drizzle/meta/0015_snapshot.json +1486 −0
  73. apps/cloud/drizzle/meta/_journal.json +42 −0
  74. apps/cloud/executor.config.ts +13 −15
  75. apps/cloud/package.json +9 −4
  76. apps/cloud/scripts/backfill-subjects.ts +87 −0
  77. apps/cloud/scripts/build.mjs +20 −1
  78. apps/cloud/scripts/dev-db.ts +145 −14
  79. apps/cloud/scripts/start-closure.mjs +124 −0
  80. apps/cloud/scripts/test-globalsetup.ts +20 −1
  81. apps/cloud/scripts/vendor-quickjs-wasm.ts +18 −0
  82. apps/cloud/src/account/org-api-key-revoke.node.test.ts +195 −0
  83. apps/cloud/src/account/workos-account-service.ts +65 −8
  84. apps/cloud/src/admin/admin-users-api.ts +354 −0
  85. apps/cloud/src/admin/admin-users-email.node.test.ts +127 −0
  86. apps/cloud/src/analytics/ema-rollout.test.ts +394 −0
  87. apps/cloud/src/analytics/ema-rollout.ts +255 −0
  88. apps/cloud/src/api/protected-api-key-auth.node.test.ts +6 −1
  89. apps/cloud/src/api/protected-jwt-auth.node.test.ts +5 −1
  90. apps/cloud/src/api/protected.test.ts +2 −0
  91. apps/cloud/src/app-paths.test.ts +37 −1
  92. apps/cloud/src/app-paths.ts +28 −0
  93. apps/cloud/src/app.ts +2 −7
  94. apps/cloud/src/auth/api-keys.node.test.ts +23 −8
  95. apps/cloud/src/auth/api-keys.ts +214 −12
  96. apps/cloud/src/auth/api.ts +9 −1
  97. apps/cloud/src/auth/context.ts +9 −4
  98. apps/cloud/src/auth/doc-gate.ts +71 −23
  99. apps/cloud/src/auth/errors.ts +95 −2
  100. apps/cloud/src/auth/handlers.ts +88 −25
  101. apps/cloud/src/auth/jwks-cache.node.test.ts +182 −1
  102. apps/cloud/src/auth/jwks-cache.ts +256 −26
  103. apps/cloud/src/auth/last-org-cookie.ts +32 −0
  104. apps/cloud/src/auth/org-api-key-auth.node.test.ts +159 −0
  105. apps/cloud/src/auth/org-api-key-mint.node.test.ts +316 −0
  106. apps/cloud/src/auth/org-selector-auth.node.test.ts +17 −7
  107. apps/cloud/src/auth/organization.ts +5 −3
  108. apps/cloud/src/auth/user-store-error.node.test.ts +128 −0
  109. apps/cloud/src/auth/workos-auth-provider.ts +132 −19
  110. apps/cloud/src/auth/workos.node.test.ts +282 −1
  111. apps/cloud/src/auth/workos.ts +394 −44
  112. apps/cloud/src/db/db.schema.test.ts +2 −2
  113. apps/cloud/src/db/db.ts +1 −1
  114. apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts +272 −8
  115. apps/cloud/src/db/executor-schema.ts +40 −1
  116. apps/cloud/src/db/org-deletion.test.ts +113 −1
  117. apps/cloud/src/db/org-deletion.ts +4 −0
  118. apps/cloud/src/edge/docs.ts +9 −29
  119. apps/cloud/src/edge/index.ts +4 −5
  120. apps/cloud/src/edge/marketing.test.ts +55 −1
  121. apps/cloud/src/edge/marketing.ts +25 −34
  122. apps/cloud/src/edge/passthrough.test.ts +85 −0
  123. apps/cloud/src/edge/passthrough.ts +138 −0
  124. apps/cloud/src/edge/posthog.ts +8 −21
  125. apps/cloud/src/engine/execution-gate.ts +7 −0
  126. apps/cloud/src/engine/execution-rate-limit.node.test.ts +435 −0
  127. apps/cloud/src/engine/execution-rate-limit.ts +335 −37
  128. apps/cloud/src/engine/execution-stack-metered.ts +15 −2
  129. apps/cloud/src/engine/execution-stack.ts +116 −2
  130. apps/cloud/src/engine/execution-usage.ts +8 −2
  131. apps/cloud/src/engine/first-party-oauth-clients.test.ts +87 −0
  132. apps/cloud/src/env-augment.d.ts +35 −0
  133. apps/cloud/src/extensions/billing/route.node.test.ts +9 −14
  134. apps/cloud/src/extensions/billing/route.ts +5 −2
  135. apps/cloud/src/extensions/billing/service.ts +95 −10
  136. apps/cloud/src/extensions/docs.ts +7 −1
  137. apps/cloud/src/extensions/routes.ts +18 −1
  138. apps/cloud/src/mcp-session.e2e.node.test.ts +1 −1
  139. apps/cloud/src/mcp/agent-handler.ts +130 −11
  140. apps/cloud/src/mcp/auth-provider.test.ts +4 −2
  141. apps/cloud/src/mcp/auth-provider.ts +23 −13
  142. apps/cloud/src/mcp/auth.ts +32 −4
  143. apps/cloud/src/mcp/session-durable-object.ts +95 −53
  144. apps/cloud/src/mcp/session-meta.node.test.ts +167 −0
  145. apps/cloud/src/mcp/session-meta.ts +209 −0
  146. apps/cloud/src/mcp/telemetry.test.ts +160 −0
  147. apps/cloud/src/mcp/telemetry.ts +51 −3
  148. apps/cloud/src/mcp/traceparent.ts +29 −0
  149. apps/cloud/src/observability/header-redaction.ts +19 −0
  150. apps/cloud/src/observability/index.ts +195 −2
  151. apps/cloud/src/observability/observability.test.ts +309 −0
  152. apps/cloud/src/observability/telemetry.ts +52 −11
  153. apps/cloud/src/org/auth-middleware.ts +5 −1
  154. apps/cloud/src/org/handlers.test.ts +1 −0
  155. apps/cloud/src/org/handlers.ts +5 −0
  156. apps/cloud/src/quickjs-engine.wasm +0 −0
  157. apps/cloud/src/quickjs.ts +35 −0
  158. apps/cloud/src/routeTree.gen.ts +109 −0
  159. apps/cloud/src/routes/__root.tsx +58 −57
  160. apps/cloud/src/routes/app/api-keys.tsx +9 −3
  161. apps/cloud/src/routes/app/billing_.plans.tsx +28 −9
  162. apps/cloud/src/routes/bare/login.tsx +1 −1
  163. apps/cloud/src/server.ts +289 −71
  164. apps/cloud/src/start.ts +25 −18
  165. apps/cloud/src/test-globalsetup-exit.node.test.ts +41 −0
  166. apps/cloud/src/wasm.d.ts +6 −0
  167. apps/cloud/src/web/auth.tsx +11 −0
  168. apps/cloud/src/web/pages/login.tsx +3 −1
  169. apps/cloud/src/web/shell.tsx +20 −5
  170. apps/cloud/test-fixtures/test-globalsetup-exit/fixture.test.ts +7 −0
  171. apps/cloud/test-fixtures/test-globalsetup-exit/vitest.config.ts +12 −0
  172. apps/cloud/tsconfig.json +1 −0
  173. apps/cloud/vite.config.ts +19 −1
  174. apps/cloud/wrangler.jsonc +30 −0
  175. apps/desktop/CHANGELOG.md +28 −0
  176. apps/desktop/build/entitlements.mac.plist +0 −21
  177. apps/desktop/build/icon.png +0 −0
  178. apps/desktop/package.json +1 −3
  179. apps/desktop/src/main/app-quit.ts +23 −0
  180. apps/desktop/src/main/crash-fingerprint.test.ts +143 −0
  181. apps/desktop/src/main/crash-fingerprint.ts +80 −0
  182. apps/desktop/src/main/crash-screen.test.ts +26 −0
  183. apps/desktop/src/main/diagnostics.ts +9 −12
  184. apps/desktop/src/main/index.ts +51 −6
  185. apps/desktop/src/main/navigation-errors.test.ts +40 −0
  186. apps/desktop/src/main/navigation-errors.ts +21 −0
  187. apps/desktop/src/main/settings-permissions.test.ts +92 −0
  188. apps/desktop/src/main/settings.ts +10 −0
  189. apps/desktop/src/main/sidecar-exit-wiring.test.ts +163 −0
  190. apps/desktop/src/main/sidecar-exit.test.ts +70 −0
  191. apps/desktop/src/main/sidecar-exit.ts +50 −0
  192. apps/desktop/src/main/sidecar.ts +31 −8
  193. apps/desktop/src/sidecar/native-bindings.ts +18 −5
  194. apps/docs/concepts/integrations.mdx +1 −1
  195. apps/docs/docs.json +1 −1
  196. apps/docs/hosted/docker.mdx +3 −0
  197. apps/docs/hosted/tracing.mdx +115 −0
  198. apps/docs/local/cli.mdx +3 −3
  199. apps/host-cloudflare/README.md +13 −9
  200. apps/host-cloudflare/executor.config.ts +12 −5
  201. apps/host-cloudflare/package.json +6 −4
  202. apps/host-cloudflare/scripts/assert-shell-asset.mjs +17 −0
  203. apps/host-cloudflare/scripts/deploy.sh +11 −7
  204. apps/host-cloudflare/src/account/account-provider.ts +5 −0
  205. apps/host-cloudflare/src/auth/cloudflare-access.ts +6 −1
  206. apps/host-cloudflare/src/config.test.ts +74 −0
  207. apps/host-cloudflare/src/config.ts +48 −5
  208. apps/host-cloudflare/src/db/d1.test.ts +207 −0
  209. apps/host-cloudflare/src/db/d1.ts +84 −5
  210. apps/host-cloudflare/src/db/data-migrations.test.ts +60 −5
  211. apps/host-cloudflare/src/db/data-migrations.ts +101 −7
  212. apps/host-cloudflare/src/execution.ts +10 −5
  213. apps/host-cloudflare/src/mcp/agent-handler.ts +4 −0
  214. apps/host-cloudflare/src/mcp/session-durable-object.ts +44 −3
  215. apps/host-cloudflare/src/plugins.ts +12 −5
  216. apps/host-cloudflare/src/worker.e2e.node.test.ts +127 −13
  217. apps/host-cloudflare/src/worker.ts +19 −1
  218. apps/host-cloudflare/test-stubs/cloudflare-workers.ts +7 −0
  219. apps/host-cloudflare/tsconfig.json +4 −0
  220. apps/host-cloudflare/tsr.routes.ts +5 −1
  221. apps/host-cloudflare/vite.config.ts +8 −1
  222. apps/host-cloudflare/vitest.config.ts +7 −0
  223. apps/host-cloudflare/web/routeTree.gen.ts +86 −0
  224. apps/host-cloudflare/web/routes/__root.tsx +15 −1
  225. apps/host-cloudflare/wrangler.jsonc +22 −7
  226. apps/host-selfhost/.env.example +6 −0
  227. apps/host-selfhost/CHANGELOG.md +290 −0
  228. apps/host-selfhost/Dockerfile +1 −1
  229. apps/host-selfhost/executor.config.ts +22 −14
  230. apps/host-selfhost/package.json +9 −5
  231. apps/host-selfhost/scripts/assert-shell-asset.mjs +18 −0
  232. apps/host-selfhost/scripts/package-runtime.ts +55 −1
  233. apps/host-selfhost/src/account/better-auth-account-provider.ts +27 −0
  234. apps/host-selfhost/src/admin/admin-escalation.node.test.ts +184 −0
  235. apps/host-selfhost/src/admin/admin-users-api.ts +225 −0
  236. apps/host-selfhost/src/admin/admin-users.node.test.ts +234 −0
  237. apps/host-selfhost/src/admin/handlers.ts +14 −14
  238. apps/host-selfhost/src/admin/require-admin.ts +126 −0
  239. apps/host-selfhost/src/analytics.ts +83 −0
  240. apps/host-selfhost/src/app.ts +28 −4
  241. apps/host-selfhost/src/auth/better-auth.ts +20 −1
  242. apps/host-selfhost/src/auth/identity.ts +1 −0
  243. apps/host-selfhost/src/auth/return-to.test.ts +45 −1
  244. apps/host-selfhost/src/auth/return-to.ts +29 −0
  245. apps/host-selfhost/src/config.ts +25 −0
  246. apps/host-selfhost/src/db/data-migrations.ts +11 −1
  247. apps/host-selfhost/src/execution.ts +20 −9
  248. apps/host-selfhost/src/executor-config.test.ts +59 −0
  249. apps/host-selfhost/src/integrations-mcp.test.ts +2 −2
  250. apps/host-selfhost/src/integrations.test.ts +0 −0
  251. apps/host-selfhost/src/mcp/auth.ts +7 −2
  252. apps/host-selfhost/src/mcp/mcp.test.ts +1 −0
  253. apps/host-selfhost/src/mcp/org-path.test.ts +18 −1
  254. apps/host-selfhost/src/mcp/org-path.ts +12 −0
  255. apps/host-selfhost/src/mcp/session-store.ts +11 −0
  256. apps/host-selfhost/src/platform-credential.test.ts +210 −0
  257. apps/host-selfhost/src/serve.ts +37 −2
  258. apps/host-selfhost/src/subject-sightings.test.ts +82 −0
  259. apps/host-selfhost/src/telemetry.test.ts +142 −0
  260. apps/host-selfhost/src/telemetry.ts +122 −0
  261. apps/host-selfhost/src/testing/test-app.ts +43 −2
  262. apps/host-selfhost/tsconfig.json +4 −0
  263. apps/host-selfhost/vite.config.ts +7 −0
  264. apps/host-selfhost/web/login.tsx +5 −10
  265. apps/host-selfhost/web/routeTree.gen.ts +109 −0
  266. apps/host-selfhost/web/routes/__root.tsx +25 −2
  267. apps/local/CHANGELOG.md +110 −0
  268. apps/local/drizzle/0004_cultured_shadowcat.sql +1 −0
  269. apps/local/drizzle/meta/0004_snapshot.json +927 −0
  270. apps/local/drizzle/meta/_journal.json +7 −0
  271. apps/local/executor.config.ts +18 −6
  272. apps/local/package.json +5 −4
  273. apps/local/src/analytics.ts +68 −0
  274. apps/local/src/app.ts +23 −6
  275. apps/local/src/auth-tool-failures.test.ts +2 −2
  276. apps/local/src/db/data-migrations.ts +7 −1
  277. apps/local/src/db/db-file-permissions.test.ts +99 −0
  278. apps/local/src/db/executor-schema.ts +1 −0
  279. apps/local/src/db/libsql.ts +38 −0
  280. apps/local/src/db/sqlite-fumadb.ts +6 −0
  281. apps/local/src/executor.ts +48 −18
  282. apps/local/src/identity.ts +1 −0
  283. apps/local/src/installation.ts +5 −4
  284. apps/local/src/main.ts +72 −12
  285. apps/local/src/mcp-browser-resume.test.ts +1 −1
  286. apps/local/src/mcp.ts +13 −1
  287. apps/local/src/serve.test.ts +1 −1
  288. apps/local/src/serve.ts +42 −11
  289. apps/marketing/src/assets/yc-backed-by.svg +1 −15
  290. apps/marketing/src/components/self-host-contact-modal.tsx +0 −126
  291. apps/marketing/src/layouts/Layout.astro +11 −4
  292. apps/marketing/src/pages/about-executor.astro +158 −0
  293. apps/marketing/src/pages/google-oauth.astro +554 −0
  294. apps/marketing/src/pages/google-workspace.astro +218 −0
  295. apps/marketing/src/pages/index.astro +8 −4
  296. apps/marketing/src/pages/privacy.astro +35 −2
  297. assets/executor-demo.gif +0 −0
  298. autumn.config.ts +2 −2
  299. bun.lock +363 −215
  300. design.md +6 −6
  301. e2e/AGENTS.md +42 −280
  302. e2e/CHANGELOG.md +168 −0
  303. e2e/RUNNING.md +283 −0
  304. e2e/cli/service-lifecycle.test.ts +2 −2
  305. e2e/cloud/admin-users-console.test.ts +390 −0
  306. e2e/cloud/admin-users.test.ts +322 −0
  307. e2e/cloud/auth-hint.test.ts +21 −3
  308. e2e/cloud/auth-routing-flow.test.ts +3 −2
  309. e2e/cloud/auth-session.test.ts +21 −3
  310. e2e/cloud/billing-customer-provisioning.test.ts +281 −0
  311. e2e/cloud/billing-trial-checkout-stale.test.ts +3 −2
  312. e2e/cloud/cli-device-login.test.ts +3 −3
  313. e2e/cloud/connect-card-origin.test.ts +55 −0
  314. e2e/cloud/connect-card-ssr-origin.test.ts +0 −79
  315. e2e/cloud/connect-link-multi-org.test.ts +232 −0
  316. e2e/cloud/connect-panel.test.ts +4 −3
  317. e2e/cloud/connection-modal-oauth-abandon.test.ts +3 −2
  318. e2e/cloud/connection-owner-isolation.test.ts +37 −15
  319. e2e/cloud/credential-write-durability.test.ts +570 −0
  320. e2e/cloud/frontend-error-reporting.test.ts +132 −0
  321. e2e/cloud/integrations-api.test.ts +0 −0
  322. e2e/cloud/integrations-refresh.test.ts +0 −0
  323. e2e/cloud/logout-stale-session.test.ts +166 −0
  324. e2e/cloud/mcp-browser-approval-org-scope.test.ts +4 −3
  325. e2e/cloud/mcp-browser-resume-page.test.ts +64 −57
  326. e2e/cloud/mcp-destroyed-session-envelope.test.ts +365 −0
  327. e2e/cloud/mcp-execution-limits.test.ts +179 −2
  328. e2e/cloud/mcp-oversize-response.test.ts +136 −0
  329. e2e/cloud/mcp-session-cold-init.test.ts +191 −0
  330. e2e/cloud/mcp-session-idle-runtime-disposal.test.ts +252 −0
  331. e2e/cloud/mcp-telemetry-contract.test.ts +106 −0
  332. e2e/cloud/member-invite-seat-limit.test.ts +3 −2
  333. e2e/cloud/oauth-callback-org-scope.test.ts +5 −6
  334. e2e/cloud/oauth-callback-unauthenticated.test.ts +1 −1
  335. e2e/cloud/onboarding-mcp-url.test.ts +2 −1
  336. e2e/cloud/org-api-keys-console.test.ts +142 −0
  337. e2e/cloud/org-delete.test.ts +4 −3
  338. e2e/cloud/org-last-visited.test.ts +96 −0
  339. e2e/cloud/org-limit.test.ts +3 −2
  340. e2e/cloud/org-multitab-cookie.test.ts +4 −3
  341. e2e/cloud/org-slug-foreign.test.ts +3 −2
  342. e2e/cloud/org-switcher.test.ts +4 −3
  343. e2e/cloud/repro-listener-supersede.test.ts +130 −0
  344. e2e/cloud/spec-update-convergence.test.ts +20 −6
  345. e2e/cloud/storage-error-report-shape.test.ts +280 −0
  346. e2e/cloud/support/session.ts +205 −0
  347. e2e/cloud/surface-reachability.test.ts +21 −0
  348. e2e/cloud/toolkit-opencode-real.test.ts +3 −3
  349. e2e/cloud/toolkit-policy-perf.test.ts +8 −8
  350. e2e/cloud/unauthenticated-skeleton.test.ts +14 −5
  351. e2e/cloudflare/mcp-native-elicitation.test.ts +108 −0
  352. e2e/local/auth.test.ts +6 −4
  353. e2e/local/boot-process.test.ts +101 −0
  354. e2e/local/cli-call-logged-out.test.ts +160 −0
  355. e2e/local/cli-mcp-daemon-attach-stress.test.ts +52 −62
  356. e2e/local/cli-telemetry-optout.test.ts +27 −0
  357. e2e/local/daemon-process.ts +55 −0
  358. e2e/local/local-server.ts +25 −5
  359. e2e/local/mcp-native-elicitation.test.ts +128 −0
  360. e2e/local/oauth-token-durability.test.ts +252 −0
  361. e2e/local/server-profile-header-env.test.ts +109 −0
  362. e2e/local/stdio-mcp.test.ts +30 −0
  363. e2e/local/update-notice.test.ts +1 −1
  364. e2e/local/vite-dev-routing.test.ts +3 −2
  365. e2e/mcp-apps/.gitignore +5 −0
  366. e2e/mcp-apps/README.md +67 −0
  367. e2e/mcp-apps/package.json +17 −0
  368. e2e/mcp-apps/playwright.config.ts +31 −0
  369. e2e/mcp-apps/scripts/check-sunpeak.mjs +45 −0
  370. e2e/mcp-apps/tests/create-artifact.spec.ts +87 −0
  371. e2e/package.json +3 −4
  372. e2e/recordings/custom-tools-console.mp4 +0 −0
  373. e2e/scenarios/api-tools.test.ts +1 −1
  374. e2e/scenarios/artifact-approval.test.ts +228 −0
  375. e2e/scenarios/artifact-loading-surface.test.ts +331 −0
  376. e2e/scenarios/artifact-preview-gallery.test.ts +282 −0
  377. e2e/scenarios/artifacts.test.ts +854 −0
  378. e2e/scenarios/auth-methods-ui.test.ts +2 −3
  379. e2e/scenarios/auth-methods.test.ts +4 −4
  380. e2e/scenarios/browser-approval.test.ts +2 −1
  381. e2e/scenarios/connect-deep-link.test.ts +169 −0
  382. e2e/scenarios/connect-handoff-session.test.ts +26 −23
  383. e2e/scenarios/connect-handoff.test.ts +37 −24
  384. e2e/scenarios/connection-health-verdict.test.ts +531 −0
  385. e2e/scenarios/connection-remove-confirm.test.ts +122 −0
  386. e2e/scenarios/docs-link.test.ts +2 −1
  387. e2e/scenarios/first-party-oauth.test.ts +382 −0
  388. e2e/scenarios/google-health-checks.test.ts +425 −0
  389. e2e/scenarios/google-photos-preset-ui.test.ts +22 −21
  390. e2e/scenarios/graphql-introspection-health.test.ts +146 −0
  391. e2e/scenarios/health-checks-ui.test.ts +12 −11
  392. e2e/scenarios/integrations-catalog-search.test.ts +77 −0
  393. e2e/scenarios/mcp-catalog-sync-ui.test.ts +3 −4
  394. e2e/scenarios/mcp-cloudflare-codemode-warning.test.ts +41 −0
  395. e2e/scenarios/mcp-session-state.test.ts +157 −0
  396. e2e/scenarios/microsoft-emulator.test.ts +25 −22
  397. e2e/scenarios/microsoft-graph-default.test.ts +65 −59
  398. e2e/scenarios/microsoft-graph-full.test.ts +56 −48
  399. e2e/scenarios/namespace-enumeration.test.ts +167 −0
  400. e2e/scenarios/namespace-search-tools.test.ts +146 −0
  401. e2e/scenarios/oauth-callback-url.test.ts +2 −3
  402. e2e/scenarios/oauth-client-handoff.test.ts +31 −28
  403. e2e/scenarios/oauth-refresh-on-401.test.ts +326 −0
  404. e2e/scenarios/oauth-refresh-rejected-non-json.test.ts +503 −0
  405. e2e/scenarios/oauth-refresh-rejected.test.ts +291 −0
  406. e2e/scenarios/oauth-scope-insufficient.test.ts +336 −0
  407. e2e/scenarios/oauth-second-connection.test.ts +233 −0
  408. e2e/scenarios/openapi-add-integration-action-bar.test.ts +6 −5
  409. e2e/scenarios/openapi-integration.test.ts +0 −0
  410. e2e/scenarios/openapi-multi-key-auth-ui.test.ts +2 −1
  411. e2e/scenarios/openapi-ndjson-output.test.ts +250 −0
  412. e2e/scenarios/openapi-server-selection-ui.test.ts +4 −3
  413. e2e/scenarios/openapi-unknown-args.test.ts +162 −0
  414. e2e/scenarios/org-slug-routing.test.ts +5 −4
  415. e2e/scenarios/policies-ui.test.ts +61 −5
  416. e2e/scenarios/provider-plugins-ui.test.ts +37 −42
  417. e2e/scenarios/resume-after-sandbox-deadline.test.ts +165 −0
  418. e2e/scenarios/run-panel-auto-approve.test.ts +8 −21
  419. e2e/scenarios/shape-memory.test.ts +178 −0
  420. e2e/scenarios/support/large-catalog.ts +29 −29
  421. e2e/scenarios/tool-descriptions.test.ts +2 −2
  422. e2e/scenarios/toolkits-mcp.test.ts +67 −0
  423. e2e/scripts/ci-shard-durations.ts +94 −0
  424. e2e/scripts/ci-shard.test.ts +126 −0
  425. e2e/scripts/ci-shard.ts +231 −0
  426. e2e/scripts/ci-shard.vitest.config.ts +9 −0
  427. e2e/scripts/cli.ts +5 −0
  428. e2e/scripts/record-connect-card.ts +2 −1
  429. e2e/scripts/run-ci-shard.ts +37 −0
  430. e2e/selfhost/admin-users-console.test.ts +331 −0
  431. e2e/selfhost/admin-users-pager.test.ts +167 −0
  432. e2e/selfhost/api-keys-feedback.test.ts +4 −3
  433. e2e/selfhost/auth-methods-ui.test.ts +5 −12
  434. e2e/selfhost/cli-device-login.test.ts +3 −3
  435. e2e/selfhost/connect-modal-credential-ux.test.ts +46 −2
  436. e2e/selfhost/detected-auth-immutable-ui.test.ts +3 −4
  437. e2e/selfhost/mcp-auth-required-add.test.ts +2 −3
  438. e2e/selfhost/mcp-browser-approval-ownership.test.ts +4 −45
  439. e2e/selfhost/mcp-connect-card-url.test.ts +2 −1
  440. e2e/selfhost/mcp-enterprise-managed-auth.test.ts +443 −0
  441. e2e/selfhost/mcp-oauth-cimd-connect.test.ts +109 −0
  442. e2e/selfhost/mcp-oauth-consent.test.ts +3 −2
  443. e2e/selfhost/mcp-oauth-reconnect-health.test.ts +222 −19
  444. e2e/selfhost/mcp-oauth-slow-connect.test.ts +133 −0
  445. e2e/selfhost/mcp-opencode-consent.test.ts +2 −1
  446. e2e/selfhost/oauth-app-modal.test.ts +29 −1
  447. e2e/selfhost/oauth-callback-unauthenticated.test.ts +2 −1
  448. e2e/selfhost/oauth-org-connection-cross-principal.test.ts +275 −0
  449. e2e/selfhost/posthog-mcp-oauth.test.ts +45 −21
  450. e2e/selfhost/toolkits-ui.test.ts +6 −1
  451. e2e/setup/boot.ts +122 −13
  452. e2e/setup/cloud.boot.ts +41 −4
  453. e2e/setup/cloud.globalsetup.ts +16 −3
  454. e2e/setup/cloudflare.boot.ts +7 −2
  455. e2e/setup/desktop-linux.globalsetup.ts +1 −0
  456. e2e/setup/desktop-macos.globalsetup.ts +1 −0
  457. e2e/setup/execution-limits.ts +15 −0
  458. e2e/setup/mcp-session-timeouts.ts +3 −1
  459. e2e/setup/motel.ts +3 −3
  460. e2e/setup/sandbox-timeout.ts +28 −0
  461. e2e/setup/selfhost.boot.ts +10 −2
  462. e2e/setup/selfhost.globalsetup.ts +8 −2
  463. e2e/src/emulator-instance.ts +71 −0
  464. e2e/src/ports.ts +31 −7
  465. e2e/src/surfaces/autumn.ts +80 −6
  466. e2e/src/surfaces/browser.ts +123 −1
  467. e2e/src/surfaces/cli.ts +10 −1
  468. e2e/src/surfaces/mcp.ts +24 −7
  469. e2e/src/update-card-render.ts +2 −1
  470. e2e/targets/cloud.ts +9 −1
  471. e2e/targets/selfhost-docker.ts +7 −6
  472. e2e/targets/selfhost.ts +50 −1
  473. examples/all-plugins/CHANGELOG.md +196 −0
  474. examples/all-plugins/package.json +1 −2
  475. examples/all-plugins/src/main.ts +13 −4
  476. examples/docs-sdk-quickstart/CHANGELOG.md +112 −0
  477. examples/docs-sdk-quickstart/package.json +1 −1
  478. knip.config.ts +0 −3
  479. package.json +5 −2
  480. packages/app/package.json +6 −1
  481. packages/app/src/crash-reporting.test.ts +115 −0
  482. packages/app/src/crash-reporting.ts +35 −3
  483. packages/app/src/entry-client.tsx +5 −0
  484. packages/app/src/preload-error.test.ts +132 −0
  485. packages/app/src/preload-error.ts +90 −0
  486. packages/app/src/routeTree.gen.ts +81 −0
  487. packages/app/src/routes/__root.tsx +15 −4
  488. packages/app/src/server-disconnected.test.ts +184 −0
  489. packages/app/src/server-disconnected.ts +113 −0
  490. packages/app/src/web/shell.tsx +22 −7
  491. packages/app/tsr.routes.ts +6 −4
  492. packages/app/vite.ts +8 −1
  493. packages/app/vitest.config.ts +8 −0
  494. packages/core/analytics/CHANGELOG.md +52 −0
  495. packages/core/analytics/package.json +51 −0
  496. packages/core/analytics/src/analytics.test.ts +233 −0
  497. packages/core/analytics/src/analytics.ts +240 −0
  498. packages/core/analytics/src/engine.test.ts +159 −0
  499. packages/core/analytics/src/engine.ts +63 −0
  500. packages/core/analytics/src/events.ts +71 −0
  501. packages/core/analytics/src/index.ts +12 −0
  502. packages/core/analytics/tsconfig.json +24 −0
  503. packages/core/analytics/tsup.config.ts +12 −0
  504. packages/core/analytics/vitest.config.ts +7 −0
  505. packages/core/api/CHANGELOG.md +128 −0
  506. packages/core/api/package.json +1 −1
  507. packages/core/api/src/account/api.ts +37 −0
  508. packages/core/api/src/account/handlers.ts +18 −0
  509. packages/core/api/src/account/org-slug.test.ts +28 −1
  510. packages/core/api/src/account/org-slug.ts +7 −2
  511. packages/core/api/src/account/service.ts +12 −0
  512. packages/core/api/src/admin/admin-users.test.ts +1194 −0
  513. packages/core/api/src/admin/api.ts +362 −0
  514. packages/core/api/src/admin/handlers.ts +69 −0
  515. packages/core/api/src/admin/reads.ts +387 −0
  516. packages/core/api/src/admin/service.ts +81 −0
  517. packages/core/api/src/api.ts +2 −0
  518. packages/core/api/src/artifacts/api.ts +165 −0
  519. packages/core/api/src/client.ts +13 −0
  520. packages/core/api/src/connections/api.ts +1 −0
  521. packages/core/api/src/executions/api.ts +51 −2
  522. packages/core/api/src/handlers/artifacts.ts +119 −0
  523. packages/core/api/src/handlers/connections.ts +1 −0
  524. packages/core/api/src/handlers/executions.ts +194 −3
  525. packages/core/api/src/handlers/index.ts +3 −0
  526. packages/core/api/src/handlers/integrations.ts +1 −0
  527. packages/core/api/src/handlers/oauth.ts +6 −0
  528. packages/core/api/src/index.ts +17 −0
  529. packages/core/api/src/integrations/api.ts +8 −0
  530. packages/core/api/src/oauth-popup.test.ts +114 −3
  531. packages/core/api/src/oauth-popup.ts +15 −4
  532. packages/core/api/src/oauth/api.ts +22 −2
  533. packages/core/api/src/observability.test.ts +25 −1
  534. packages/core/api/src/observability.ts +13 −2
  535. packages/core/api/src/plugin-routes.ts +1 −1
  536. packages/core/api/src/server.ts +35 −1
  537. packages/core/api/src/server/execution-stack-middleware.ts +148 −18
  538. packages/core/api/src/server/execution-stack.ts +58 −9
  539. packages/core/api/src/server/executor-app.ts +7 −2
  540. packages/core/api/src/server/fixed-execution-middleware.ts +28 −5
  541. packages/core/api/src/server/host-foundation.ts +42 −0
  542. packages/core/api/src/server/identity.ts +71 −4
  543. packages/core/api/src/server/mcp-build.ts +57 −6
  544. packages/core/api/src/server/scoped-executor.ts +114 −5
  545. packages/core/api/src/services.ts +18 −1
  546. packages/core/cli/CHANGELOG.md +98 −0
  547. packages/core/cli/package.json +1 −1
  548. packages/core/config/CHANGELOG.md +98 −0
  549. packages/core/config/package.json +1 −1
  550. packages/core/config/src/config.test.ts +37 −35
  551. packages/core/config/src/index.ts +7 −7
  552. packages/core/config/src/schema.ts +16 −16
  553. packages/core/config/src/sink.ts +10 −10
  554. packages/core/config/src/write.ts +24 −24
  555. packages/core/execution/CHANGELOG.md +114 −0
  556. packages/core/execution/package.json +1 −1
  557. packages/core/execution/src/description.test.ts +50 −1
  558. packages/core/execution/src/description.ts +22 −0
  559. packages/core/execution/src/engine.test.ts +1 −1
  560. packages/core/execution/src/engine.ts +119 −12
  561. packages/core/execution/src/index.ts +16 −3
  562. packages/core/execution/src/provided-globals.ts +274 −0
  563. packages/core/execution/src/skills.test.ts +27 −1
  564. packages/core/execution/src/skills.ts +579 −11
  565. packages/core/execution/src/tool-invoker.leak.test.ts +1 −1
  566. packages/core/execution/src/tool-invoker.test.ts +434 −54
  567. packages/core/execution/src/tool-invoker.ts +108 −34
  568. packages/core/integrations-registry/src/registry.test.ts +10 −2
  569. packages/core/integrations-registry/src/registry.ts +1 −1
  570. packages/core/sdk/CHANGELOG.md +87 −0
  571. packages/core/sdk/README.md +3 −3
  572. packages/core/sdk/package.json +9 −2
  573. packages/core/sdk/src/artifact-preview.test.ts +304 −0
  574. packages/core/sdk/src/artifact-preview.ts +574 −0
  575. packages/core/sdk/src/artifact-table.test.ts +132 −0
  576. packages/core/sdk/src/artifact.ts +211 −0
  577. packages/core/sdk/src/artifacts.test.ts +353 −0
  578. packages/core/sdk/src/auth-tool-failure.ts +55 −17
  579. packages/core/sdk/src/blob.ts +1 −1
  580. packages/core/sdk/src/client.test.ts +2 −2
  581. packages/core/sdk/src/client.ts +34 −5
  582. packages/core/sdk/src/connection-name-identifier.ts +4 −0
  583. packages/core/sdk/src/connection.ts +7 −4
  584. packages/core/sdk/src/connections.test.ts +212 −2
  585. packages/core/sdk/src/core-schema.ts +129 −4
  586. packages/core/sdk/src/core-tools.ts +78 −17
  587. packages/core/sdk/src/errors.ts +47 −4
  588. packages/core/sdk/src/executor.test.ts +259 −1
  589. packages/core/sdk/src/executor.ts +1724 −194
  590. packages/core/sdk/src/fuma-runtime.test.ts +265 −0
  591. packages/core/sdk/src/fuma-runtime.ts +167 −12
  592. packages/core/sdk/src/host-internal.ts +15 −0
  593. packages/core/sdk/src/hosted-http-client.test.ts +68 −0
  594. packages/core/sdk/src/hosted-http-client.ts +71 −2
  595. packages/core/sdk/src/ids.ts +4 −0
  596. packages/core/sdk/src/index.ts +91 −2
  597. packages/core/sdk/src/insufficient-scope.test.ts +377 −0
  598. packages/core/sdk/src/insufficient-scope.ts +274 −0
  599. packages/core/sdk/src/integration.ts +26 −0
  600. packages/core/sdk/src/migration-spec.test.ts +2 −2
  601. packages/core/sdk/src/migration-spec.ts +2 −2
  602. packages/core/sdk/src/oauth-client.ts +199 −3
  603. packages/core/sdk/src/oauth-discovery.test.ts +26 −0
  604. packages/core/sdk/src/oauth-discovery.ts +58 −9
  605. packages/core/sdk/src/oauth-ema-lifecycle.test.ts +336 −0
  606. packages/core/sdk/src/oauth-ema-rollout.test.ts +467 −0
  607. packages/core/sdk/src/oauth-ema.test.ts +502 −0
  608. packages/core/sdk/src/oauth-ema.ts +476 −0
  609. packages/core/sdk/src/oauth-first-party.test.ts +471 −0
  610. packages/core/sdk/src/oauth-flow.test.ts +985 −8
  611. packages/core/sdk/src/oauth-helpers.test.ts +340 −0
  612. packages/core/sdk/src/oauth-helpers.ts +731 −49
  613. packages/core/sdk/src/oauth-register-dynamic.test.ts +211 −0
  614. packages/core/sdk/src/oauth-remove-client.test.ts +66 −1
  615. packages/core/sdk/src/oauth-scope-union.test.ts +93 −2
  616. packages/core/sdk/src/oauth-service.ts +773 −68
  617. packages/core/sdk/src/owner-policy-reach.test.ts +318 −0
  618. packages/core/sdk/src/owner-policy.ts +78 −1
  619. packages/core/sdk/src/pending-approval.test.ts +142 −0
  620. packages/core/sdk/src/pending-approval.ts +110 −0
  621. packages/core/sdk/src/platform-view.test.ts +901 −0
  622. packages/core/sdk/src/plugin.ts +114 −11
  623. packages/core/sdk/src/policies.test.ts +43 −2
  624. packages/core/sdk/src/policies.ts +41 −0
  625. packages/core/sdk/src/promise-executor.ts +1 −1
  626. packages/core/sdk/src/promise.test.ts +2 −2
  627. packages/core/sdk/src/provider-call-timeout.test.ts +212 −0
  628. packages/core/sdk/src/provider-item-owner.ts +47 −0
  629. packages/core/sdk/src/sentry-grouping.test.ts +269 −0
  630. packages/core/sdk/src/sentry-grouping.ts +169 −0
  631. packages/core/sdk/src/server-connection.test.ts +54 −0
  632. packages/core/sdk/src/server-connection.ts +69 −1
  633. packages/core/sdk/src/shape-inference.test.ts +136 −0
  634. packages/core/sdk/src/shape-inference.ts +219 −0
  635. packages/core/sdk/src/shape-memory.ts +90 −0
  636. packages/core/sdk/src/shared.ts +34 −0
  637. packages/core/sdk/src/sqlite-config-blob-migration.test.ts +2 −2
  638. packages/core/sdk/src/sqlite-data-migrations.test.ts +61 −0
  639. packages/core/sdk/src/sqlite-data-migrations.ts +12 −6
  640. packages/core/sdk/src/subject-registry.test.ts +418 −0
  641. packages/core/sdk/src/subject-registry.ts +0 −0
  642. packages/core/sdk/src/subject-table.test.ts +103 −0
  643. packages/core/sdk/src/test-config.ts +6 −0
  644. packages/core/sdk/src/testing/id-jag-test-support.ts +251 −0
  645. packages/core/sdk/src/testing/oauth-test-server.ts +340 −2
  646. packages/core/sdk/src/tool-not-found-empty-catalog.test.ts +113 −0
  647. packages/core/sdk/src/types.ts +4 −0
  648. packages/core/sdk/tsup.config.ts +1 −0
  649. packages/core/vite-plugin/CHANGELOG.md +98 −0
  650. packages/core/vite-plugin/package.json +1 −1
  651. packages/hosts/cloudflare/CHANGELOG.md +144 −0
  652. packages/hosts/cloudflare/package.json +6 −2
  653. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +582 −4
  654. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +439 −37
  655. packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts +2 −0
  656. packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts +41 −12
  657. packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts +11 −4
  658. packages/hosts/cloudflare/src/mcp/do-headers.test.ts +49 −0
  659. packages/hosts/cloudflare/src/mcp/do-headers.ts +41 −6
  660. packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts +275 −0
  661. packages/hosts/cloudflare/src/mcp/durable-object-errors.ts +237 −0
  662. packages/hosts/cloudflare/src/mcp/session-runtime-residency.ts +96 −0
  663. packages/hosts/mcp-apps-shell/.gitignore +1 −0
  664. packages/hosts/mcp-apps-shell/CHANGELOG.md +57 −0
  665. packages/hosts/mcp-apps-shell/package.json +102 −0
  666. packages/hosts/mcp-apps-shell/scripts/gen-provided-globals.ts +23 −0
  667. packages/hosts/mcp-apps-shell/scripts/gen-smoke-harness.ts +45 −0
  668. packages/hosts/mcp-apps-shell/scripts/subset-fonts.ts +96 −0
  669. packages/hosts/mcp-apps-shell/src/index.ts +13 −0
  670. packages/hosts/mcp-apps-shell/src/shell-asset-path.ts +35 −0
  671. packages/hosts/mcp-apps-shell/src/shell-dev-html.d.ts +14 −0
  672. packages/hosts/mcp-apps-shell/src/shell-html.ts +67 −0
  673. packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts +98 −0
  674. packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx +432 −0
  675. packages/hosts/mcp-apps-shell/src/shell/artifact-states.tsx +217 −0
  676. packages/hosts/mcp-apps-shell/src/shell/component-runtime.test.ts +53 −0
  677. packages/hosts/mcp-apps-shell/src/shell/component-runtime.ts +190 −0
  678. packages/hosts/mcp-apps-shell/src/shell/components.ts +283 −0
  679. packages/hosts/mcp-apps-shell/src/shell/display-mode.ts +104 −0
  680. packages/hosts/mcp-apps-shell/src/shell/fonts/README.md +29 −0
  681. packages/hosts/mcp-apps-shell/src/shell/fonts/geist-mono.woff2 +0 −0
  682. packages/hosts/mcp-apps-shell/src/shell/fonts/geist-sans.woff2 +0 −0
  683. packages/hosts/mcp-apps-shell/src/shell/globals.css +53 −0
  684. packages/hosts/mcp-apps-shell/src/shell/hooks.ts +158 −0
  685. packages/hosts/mcp-apps-shell/src/shell/inner-renderer-source.d.ts +14 −0
  686. packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx +432 −0
  687. packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts +2649 −0
  688. packages/hosts/mcp-apps-shell/src/shell/mcp-app.html +17 −0
  689. packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx +101 −0
  690. packages/hosts/mcp-apps-shell/src/shell/preview-capture.ts +36 −0
  691. packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts +40 −0
  692. packages/hosts/mcp-apps-shell/src/shell/proxy.ts +181 −0
  693. packages/hosts/mcp-apps-shell/src/shell/render-signal.ts +60 −0
  694. packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx +1098 −0
  695. packages/hosts/mcp-apps-shell/src/shell/shell-html-url.d.ts +13 −0
  696. packages/hosts/mcp-apps-shell/src/shell/smoke-harness-bundle.gen.ts +17 −0
  697. packages/hosts/mcp-apps-shell/src/shell/smoke-harness-shims.ts +274 −0
  698. packages/hosts/mcp-apps-shell/src/shell/smoke-harness.tsx +168 −0
  699. packages/hosts/mcp-apps-shell/src/shell/smoke-render-result.ts +36 −0
  700. packages/hosts/mcp-apps-shell/src/shell/smoke-render.test.tsx +388 −0
  701. packages/hosts/mcp-apps-shell/src/shell/smoke-render.ts +189 −0
  702. packages/hosts/mcp-apps-shell/src/shell/theme-tokens.pin.test.ts +196 −0
  703. packages/hosts/mcp-apps-shell/src/shell/theme.css +317 −0
  704. packages/hosts/mcp-apps-shell/src/shell/tool-call-grammar.pin.test.ts +110 −0
  705. packages/hosts/mcp-apps-shell/src/shell/tools-proxy.ts +261 −0
  706. packages/hosts/mcp-apps-shell/src/smoke-render-entry.ts +43 −0
  707. packages/hosts/mcp-apps-shell/src/vite.test.ts +28 −0
  708. packages/hosts/mcp-apps-shell/src/vite.ts +411 −0
  709. packages/hosts/mcp-apps-shell/src/worker.test.ts +85 −0
  710. packages/hosts/mcp-apps-shell/src/worker.ts +75 −0
  711. packages/hosts/mcp-apps-shell/tsconfig.json +16 −0
  712. packages/hosts/mcp-apps-shell/vite.config.shell.ts +46 −0
  713. packages/hosts/mcp-apps-shell/vitest.config.ts +12 −0
  714. packages/hosts/mcp/package.json +14 −1
  715. packages/hosts/mcp/src/artifact-action.ts +87 −0
  716. packages/hosts/mcp/src/artifact-bindings.test.ts +398 −0
  717. packages/hosts/mcp/src/artifact-bindings.ts +419 −0
  718. packages/hosts/mcp/src/artifacts-tools.test.ts +2557 −0
  719. packages/hosts/mcp/src/browser-approval.ts +28 −0
  720. packages/hosts/mcp/src/create-artifact.ts +489 −0
  721. packages/hosts/mcp/src/envelope.test.ts +7 −0
  722. packages/hosts/mcp/src/envelope.ts +53 −19
  723. packages/hosts/mcp/src/in-memory-session-store.test.ts +54 −0
  724. packages/hosts/mcp/src/in-memory-session-store.ts +17 −1
  725. packages/hosts/mcp/src/namespace-search-tools.test.ts +222 −0
  726. packages/hosts/mcp/src/tool-call-code.ts +119 −0
  727. packages/hosts/mcp/src/tool-server.test.ts +228 −13
  728. packages/hosts/mcp/src/tool-server.ts +1364 −54
  729. packages/kernel/core/CHANGELOG.md +28 −0
  730. packages/kernel/core/package.json +2 −1
  731. packages/kernel/core/src/error-kind.test.ts +25 −0
  732. packages/kernel/core/src/error-kind.ts +37 −0
  733. packages/kernel/core/src/index.ts +1 −0
  734. packages/kernel/core/src/json-schema.ts +27 −8
  735. packages/kernel/core/src/strip-types.test.ts +1 −1
  736. packages/kernel/core/src/types.ts +3 −0
  737. packages/kernel/ir/src/registry.ts +2 −2
  738. packages/kernel/ir/src/serialize.ts +2 −2
  739. packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs +1 −1
  740. packages/kernel/runtime-deno-subprocess/src/deno-worker-process.ts +47 −2
  741. packages/kernel/runtime-deno-subprocess/src/fixtures/close-stdin-worker.mjs +37 −0
  742. packages/kernel/runtime-deno-subprocess/src/index.test.ts +68 −13
  743. packages/kernel/runtime-deno-subprocess/src/index.ts +78 −13
  744. packages/kernel/runtime-dynamic-worker/src/executor.ts +37 −3
  745. packages/kernel/runtime-dynamic-worker/src/invocation.test.ts +86 −2
  746. packages/kernel/runtime-dynamic-worker/src/module-template.ts +26 −6
  747. packages/kernel/runtime-quickjs/CHANGELOG.md +100 −0
  748. packages/kernel/runtime-quickjs/package.json +1 −1
  749. packages/kernel/runtime-quickjs/src/index.test.ts +50 −2
  750. packages/kernel/runtime-quickjs/src/index.ts +94 −21
  751. packages/kernel/runtime-quickjs/src/sealed-bundle.test.ts +113 −0
  752. packages/kernel/runtime-quickjs/src/sealed-bundle.ts +209 −0
  753. packages/kernel/runtime-workerd-subprocess/CHANGELOG.md +99 −0
  754. packages/kernel/runtime-workerd-subprocess/package.json +26 −0
  755. packages/kernel/runtime-workerd-subprocess/src/index.test.ts +251 −0
  756. packages/kernel/runtime-workerd-subprocess/src/index.ts +740 −0
  757. packages/kernel/runtime-workerd-subprocess/tsconfig.json +5 −6
  758. packages/kernel/runtime-workerd-subprocess/vitest.config.ts +7 −0
  759. packages/plugins/desktop-settings/CHANGELOG.md +98 −0
  760. packages/plugins/desktop-settings/package.json +1 −1
  761. packages/plugins/desktop-settings/src/client.tsx +5 −5
  762. packages/plugins/desktop-settings/src/server.ts +1 −1
  763. packages/plugins/encrypted-secrets/CHANGELOG.md +98 −0
  764. packages/plugins/encrypted-secrets/package.json +1 −1
  765. packages/plugins/encrypted-secrets/src/index.test.ts +53 −2
  766. packages/plugins/encrypted-secrets/src/index.ts +20 −13
  767. packages/plugins/encrypted-secrets/src/repartition-migration.test.ts +208 −0
  768. packages/plugins/encrypted-secrets/src/repartition-migration.ts +123 −0
  769. packages/plugins/example/CHANGELOG.md +98 −0
  770. packages/plugins/example/package.json +1 −1
  771. packages/plugins/file-secrets/CHANGELOG.md +100 −0
  772. packages/plugins/file-secrets/package.json +1 −1
  773. packages/plugins/file-secrets/src/data-dir.test.ts +115 −0
  774. packages/plugins/file-secrets/src/index.test.ts +208 −0
  775. packages/plugins/file-secrets/src/index.ts +170 −57
  776. packages/plugins/google/CHANGELOG.md +0 −143
  777. packages/plugins/google/package.json +0 −98
  778. packages/plugins/google/src/api/group.ts +0 −142
  779. packages/plugins/google/src/api/handlers.ts +0 −105
  780. packages/plugins/google/src/api/index.ts +0 −15
  781. packages/plugins/google/src/promise.ts +0 −1
  782. packages/plugins/google/src/react/AddGoogleSource.tsx +0 −178
  783. packages/plugins/google/src/react/GoogleAccountsPanel.tsx +0 −148
  784. packages/plugins/google/src/react/GoogleProductPicker.tsx +0 −363
  785. packages/plugins/google/src/react/atoms.ts +0 −30
  786. packages/plugins/google/src/react/client.ts +0 −13
  787. packages/plugins/google/src/react/index.ts +0 −12
  788. packages/plugins/google/src/react/plugin-client.tsx +0 −8
  789. packages/plugins/google/src/react/source-plugin.ts +0 −18
  790. packages/plugins/google/src/sdk/oauth-batches.test.ts +0 −98
  791. packages/plugins/google/src/sdk/oauth-batches.ts +0 −69
  792. packages/plugins/google/src/sdk/oauth-scopes.test.ts +0 −34
  793. packages/plugins/google/src/sdk/oauth-scopes.ts +0 −66
  794. packages/plugins/google/src/sdk/plugin.test.ts +0 −799
  795. packages/plugins/google/src/sdk/plugin.ts +0 −469
  796. packages/plugins/google/src/sdk/presets.test.ts +0 −29
  797. packages/plugins/google/src/sdk/product-picker-scopes.test.ts +0 −122
  798. packages/plugins/google/tsup.config.ts +0 −14
  799. packages/plugins/google/vitest.config.ts +0 −12
  800. packages/plugins/graphql/CHANGELOG.md +140 −0
  801. packages/plugins/graphql/README.md +2 −2
  802. packages/plugins/graphql/package.json +1 −1
  803. packages/plugins/graphql/src/react/AddGraphqlIntegration.tsx +8 −8
  804. packages/plugins/graphql/src/react/GraphqlAccountsPanel.tsx +3 −3
  805. packages/plugins/graphql/src/react/GraphqlIntegrationFields.tsx +1 −1
  806. packages/plugins/graphql/src/react/atoms.ts +3 −3
  807. packages/plugins/graphql/src/react/index.ts +2 −2
  808. packages/plugins/graphql/src/react/integration-plugin.ts +1 −1
  809. packages/plugins/graphql/src/react/plugin-client.tsx +1 −1
  810. packages/plugins/graphql/src/sdk/errors.ts +14 −0
  811. packages/plugins/graphql/src/sdk/index.ts +6 −1
  812. packages/plugins/graphql/src/sdk/introspect.ts +14 −1
  813. packages/plugins/graphql/src/sdk/invocation-timeout.test.ts +107 −0
  814. packages/plugins/graphql/src/sdk/invoke.ts +67 −33
  815. packages/plugins/graphql/src/sdk/multi-placement-auth.test.ts +50 −0
  816. packages/plugins/graphql/src/sdk/plugin.test.ts +359 −0
  817. packages/plugins/graphql/src/sdk/plugin.ts +334 −41
  818. packages/plugins/graphql/src/sdk/presets.ts +1 −1
  819. packages/plugins/graphql/src/sdk/types.ts +6 −0
  820. packages/plugins/keychain/CHANGELOG.md +98 −0
  821. packages/plugins/keychain/package.json +1 −1
  822. packages/plugins/mcp/CHANGELOG.md +160 −0
  823. packages/plugins/mcp/README.md +4 −4
  824. packages/plugins/mcp/package.json +6 −2
  825. packages/plugins/mcp/scripts/gen-call-tool-result-schema.ts +26 −0
  826. packages/plugins/mcp/src/api/group.ts +4 −0
  827. packages/plugins/mcp/src/api/handlers.ts +2 −0
  828. packages/plugins/mcp/src/react/AddMcpIntegration.tsx +19 −3
  829. packages/plugins/mcp/src/react/EditMcpIntegration.tsx +8 −5
  830. packages/plugins/mcp/src/react/McpAccountsPanel.tsx +3 −3
  831. packages/plugins/mcp/src/react/McpRemoteIntegrationFields.tsx +3 −3
  832. packages/plugins/mcp/src/react/McpSignInButton.tsx +2 −2
  833. packages/plugins/mcp/src/react/index.ts +2 −2
  834. packages/plugins/mcp/src/react/integration-plugin.tsx +5 −5
  835. packages/plugins/mcp/src/react/plugin-client.tsx +2 −2
  836. packages/plugins/mcp/src/sdk/call-tool-result-schema.gen.ts +395 −0
  837. packages/plugins/mcp/src/sdk/call-tool-result-schema.test.ts +16 −0
  838. packages/plugins/mcp/src/sdk/catalog-sync.test.ts +19 −0
  839. packages/plugins/mcp/src/sdk/client-module.ts +39 −0
  840. packages/plugins/mcp/src/sdk/cloudflare-codemode.test.ts +42 −0
  841. packages/plugins/mcp/src/sdk/cloudflare-codemode.ts +15 −0
  842. packages/plugins/mcp/src/sdk/connection-pool-key.test.ts +154 −0
  843. packages/plugins/mcp/src/sdk/connection-pool.test.ts +197 −0
  844. packages/plugins/mcp/src/sdk/connection-pool.ts +147 −0
  845. packages/plugins/mcp/src/sdk/connection-socket-release.test.ts +64 −0
  846. packages/plugins/mcp/src/sdk/connection.test.ts +85 −0
  847. packages/plugins/mcp/src/sdk/connection.ts +274 −37
  848. packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts +37 −0
  849. packages/plugins/mcp/src/sdk/discover.ts +32 −21
  850. packages/plugins/mcp/src/sdk/elicitation.test.ts +56 −81
  851. packages/plugins/mcp/src/sdk/errors.ts +38 −0
  852. packages/plugins/mcp/src/sdk/health-missing-credential.test.ts +111 −0
  853. packages/plugins/mcp/src/sdk/http-status.test.ts +75 −0
  854. packages/plugins/mcp/src/sdk/http-status.ts +68 −11
  855. packages/plugins/mcp/src/sdk/invoke.test.ts +17 −6
  856. packages/plugins/mcp/src/sdk/invoke.ts +85 −27
  857. packages/plugins/mcp/src/sdk/multi-placement-auth.test.ts +6 −2
  858. packages/plugins/mcp/src/sdk/plugin.test.ts +106 −4
  859. packages/plugins/mcp/src/sdk/plugin.ts +195 −28
  860. packages/plugins/mcp/src/sdk/presets.ts +7 −4
  861. packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts +1 −1
  862. packages/plugins/mcp/src/sdk/probe-shape.test.ts +90 −2
  863. packages/plugins/mcp/src/sdk/probe-shape.ts +51 −10
  864. packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts +182 −0
  865. packages/plugins/mcp/src/sdk/slack-connect-file.test.ts +171 −0
  866. packages/plugins/mcp/src/sdk/slack-connect-file.ts +153 −0
  867. packages/plugins/mcp/src/sdk/stdio-connector.ts +4 −3
  868. packages/plugins/mcp/src/sdk/stdio-interrupt-cleanup.test.ts +112 −0
  869. packages/plugins/mcp/src/sdk/stdio-interrupt-test-server.ts +65 −0
  870. packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts +24 −0
  871. packages/plugins/mcp/src/sdk/stdio-negotiation.test.ts +64 −0
  872. packages/plugins/mcp/src/sdk/testing-fixtures.test.ts +5 −3
  873. packages/plugins/mcp/src/sdk/types.ts +38 −1
  874. packages/plugins/mcp/src/testing/server.ts +78 −6
  875. packages/plugins/mcp/tsconfig.json +1 −1
  876. packages/plugins/microsoft/CHANGELOG.md +0 −141
  877. packages/plugins/microsoft/package.json +0 −98
  878. packages/plugins/microsoft/src/api/group.ts +0 −163
  879. packages/plugins/microsoft/src/api/handlers.ts +0 −144
  880. packages/plugins/microsoft/src/api/index.ts +0 −15
  881. packages/plugins/microsoft/src/promise.ts +0 −1
  882. packages/plugins/microsoft/src/react/AddMicrosoftSource.tsx +0 −155
  883. packages/plugins/microsoft/src/react/MicrosoftAccountsPanel.tsx +0 −118
  884. packages/plugins/microsoft/src/react/MicrosoftScopePicker.tsx +0 −286
  885. packages/plugins/microsoft/src/react/atoms.ts +0 −30
  886. packages/plugins/microsoft/src/react/client.ts +0 −13
  887. packages/plugins/microsoft/src/react/index.ts +0 −12
  888. packages/plugins/microsoft/src/react/plugin-client.tsx +0 −8
  889. packages/plugins/microsoft/src/react/source-plugin.ts +0 −18
.changeset/cli-credential-store-permissions.md+11−0
@@ -0,0 +1,11 @@
1---
2"executor": patch
3---
4
5**The CLI's server-connection store is written owner-only, and two of its tests now actually run**
6
7`~/.executor/server-connections.json` holds live credentials for a hosted server — a bearer token, or an OAuth access token together with its long-lived refresh token, rewritten on every silent refresh. It was created with no explicit mode, so the process umask applied and it landed world-readable (0644 by default). Any other account on the machine — or anything that copies a home directory, such as a backup or a container layer — could read a durable credential until the user ran `executor logout`.
8
9It is now created `0600` with a follow-up `chmod`, matching what the local-server manifest already does for the sibling secret it keeps under `server-control/`. Both steps are needed: `mode` applies only on create, and the `chmod` covers rewriting a store that already exists with looser permissions — which is the common path here, since the file is rewritten on every token refresh.
10
11Separately, two tests in `server-profile.test.ts` were written as `it("…", () => Effect.gen(…))`. An `Effect` is not a thenable, so Vitest treated each as passing without ever running its body — a deliberately falsified assertion still passed. They are now `it.effect` and execute for real. No production behaviour was wrong; the tests simply were not checking it.
.changeset/concurrent-data-migration-stamps.md+5−0
@@ -0,0 +1,5 @@
1---
2"executor": patch
3---
4
5Prevent concurrent SQLite data-migration runners from failing when another runner commits the same ledger stamp first.
.changeset/config.json+2−2
@@ -18,7 +18,8 @@ · 17 unmodified lines
18 "@executor-js/plugin-openapi",18 "@executor-js/plugin-openapi",
19 "@executor-js/plugin-example",19 "@executor-js/plugin-example",
20 "@executor-js/plugin-desktop-settings",20 "@executor-js/plugin-desktop-settings",
21 "@executor-js/desktop"21 "@executor-js/desktop",
22 "@executor-js/local"
22 ]23 ]
23 ],24 ],
24 "linked": [],25 "linked": [],
@@ -29,7 +30,6 @@ · 4 unmodified lines
29 "onlyUpdatePeerDependentsWhenOutOfRange": true30 "onlyUpdatePeerDependentsWhenOutOfRange": true
30 },31 },
31 "ignore": [32 "ignore": [
32 "@executor-js/local",
33 "@executor-js/host-mcp",33 "@executor-js/host-mcp",
34 "@executor-js/ir",34 "@executor-js/ir",
35 "@executor-js/runtime-deno-subprocess",35 "@executor-js/runtime-deno-subprocess",
.changeset/connection-edit-sheet-agent-preview.md+9−0
@@ -0,0 +1,9 @@
1---
2"@executor-js/react": patch
3---
4
5**The connection edit sheet now previews what agents actually read**
6
7The "What agents see" preview in the connection edit sheet rendered a `- \`<prefix>\` — <description>`inventory line. That line left the`execute`tool description when the inventory was slimmed to bare integration slugs, so the preview showed text no agent reads. The account label was also marked "Display-only", but`connections.list` returns it to agents alongside the description.
8
9The preview now mirrors the `connections.list` item for the connection (`name`, `identityLabel`, `description`), and the sheet copy says that both fields are agent-visible while the callable name stays as it was at connect time.
.changeset/desktop-settings-owner-only.md+9−0
@@ -0,0 +1,9 @@
1---
2"executor": patch
3---
4
5**The desktop settings store is written owner-only**
6
7`settings.json` holds `serverProfiles`, which carries a remote server's credential — a bearer token, or a basic-auth username and password — for any "Custom server" the user connects to. `conf` (under electron-store) defaults to `configFileMode: 0o666`, so with no explicit mode the file was created `0644`. On Linux, where `~/.config` is not reliably `0700`, that is readable by every other account on the machine. macOS is protected by `~/Library` being `0700` and Windows by ACLs, so this is primarily a Linux-desktop exposure — but owner-only credential files are already this app's standard: `local-auth.ts` writes `auth.json` at `0o600`, and the sidecar manifest is chmodded the same way.
8
9One option is sufficient here, rather than the mode-plus-chmod pair used elsewhere: `atomically` chmods the temp inode only when the requested mode differs from its own default, and the atomic rename then carries the tight mode onto an already-loose file. Verified against the installed `conf` — with no option a fresh file lands `0644`; with `configFileMode: 0o600` a fresh file lands `0600` and an existing `0644` file becomes `0600` on the next write.
.changeset/graph-slice-urls-first-class.md+5−0
@@ -0,0 +1,5 @@
1---
2"@executor-js/plugin-openapi": patch
3---
4
5Make Microsoft Graph slice URLs first-class spec sources instead of a hidden substitution. Catalog tiles now point directly at the slice release assets, the stored specUrl is exactly what gets fetched, and selection narrowing travels visibly in the URL fragment; requesting the upstream monolith URL fetches the monolith, never a silently swapped slice.
.changeset/graph-spec-slices.md+5−0
@@ -0,0 +1,5 @@
1---
2"@executor-js/plugin-openapi": patch
3---
4
5Serve Microsoft Graph preset selections from precomputed slice release assets instead of the 43MB upstream monolith. The monolith fetch almost never survives a 128MB Workers isolate (production traces show one completion in 30 days), so covered selections — every catalog preset, plus any combination within the default bundle — now read a 4–19MB filtered document built offline by the graph-slices workflow, with the monolith path kept only as a fallback and for full-graph/custom-scope selections.
.changeset/host-evaluate-timeout.md+0−5
@@ -1,5 +0,0 @@
1---
2"executor": patch
3---
4
5Back-stop code execution with a host-side timeout so a wedged sandbox delivers a descriptive error instead of hanging silently.
.changeset/lazy-mcp-client-module.md+5−0
@@ -0,0 +1,5 @@
1---
2"@executor-js/plugin-mcp": patch
3---
4
5Load the MCP client SDK lazily on first outbound connection instead of at module evaluation. Runtimes that bundle the plugin (notably Cloudflare Workers) no longer pay the client package's module-eval memory and CPU on startup or on code paths that never dial an MCP server.
.changeset/mcp-abort-releases-response-body.md+7−0
@@ -0,0 +1,7 @@
1---
2"@executor-js/plugin-mcp": patch
3---
4
5**Closing a remote MCP connection now ends its streamable-http SSE request**
6
7On a supplied `httpClientLayer`, the fetch adapter wired the caller's `AbortSignal` only to the pending response promise, never to the response body, so closing a connection left the long-lived `GET` channel in flight forever — one abandoned request per dial. Under Bun each holds one of the 256 concurrent-request slots, so a long-running process eventually exhausts the pool and every connection starts failing with `MCP discovery timed out after 15000ms`. The response stream is now interrupted when the signal aborts.
.changeset/mcp-health-missing-credential.md+11−0
@@ -0,0 +1,11 @@
1---
2"executor": patch
3---
4
5**An MCP health check no longer reports `healthy` when the connection's credential is missing**
6
7Rendering skips an auth placement whose value is unresolved — that is the renderer's documented behaviour, and callers own the missing-value policy. The MCP health check had no such policy, so it dialled unauthenticated, and any server that lists tools without auth answered. `discoverTools` succeeding maps straight to `healthy`, so a connection whose credential was gone reported as healthy.
8
9Health status is the signal telling a user to re-authenticate, which makes `healthy` the one answer it must never give in that state. The check now reports `expired` with the unresolved input names, mirroring the OpenAPI health check, which already did exactly this.
10
11The MCP tool-invocation path already refused for the same reason. `resolveTools` is deliberately left alone — its own comment records that discovery tolerating unresolved credentials is intended, since an open server lists tools unauthenticated.
.changeset/mcp-namespace-search-tools.md+8−0
@@ -0,0 +1,8 @@
1---
2"@executor-js/execution": patch
3"executor": patch
4---
5
6**Opt-in per-integration search tools on the MCP surface**
7
8Connecting with `?search_tools=true` (stdio: `executor mcp --search-tools`) adds one minimally-described `search_<integration>` MCP tool per connected integration, so the integration namespaces reach the model as tool names it can see without calling anything. Each call routes through the same flow as `tools.search({ namespace })` inside `execute`, and the tool list comes from the same inventory the `execute` description shows. Off by default; a clean endpoint URL is unchanged.
.changeset/mcp-pool-key-credential-retention.md+11−0
@@ -0,0 +1,11 @@
1---
2"executor": patch
3---
4
5**The MCP connection pool no longer keeps credentials in its cache key**
6
7A pooled remote MCP session is looked up by a key describing the connection's identity, and that key included the connection's resolved credential values — plus the headers and query params those same secrets had already been rendered into. The key is retained as a `Map` key for the pool's lifetime, so the secret stayed readable in process memory long after the call that needed it had finished, with nothing left to read it.
8
9The key is now the SHA-256 digest of that identity rather than the identity itself. Reuse is unchanged, because equal identities still produce equal keys, and separation is unchanged too: a rotated access token, a different rendered auth header and a credential carried in a query param each still dial a fresh session instead of reusing one authenticated as somebody else. Hashing the whole identity rather than only the fields known to be sensitive means a field added later is covered without anyone having to remember it carries a secret.
10
11Nothing reads the key back — the pool only compares it, and it reaches no log, span or error message — so nothing observable changes.
.changeset/mcp-priming-reconnect.md+0−5
@@ -1,5 +0,0 @@
1---
2"executor": patch
3---
4
5Let stock MCP clients auto-reconnect and recover a tool result when a POST stream drops mid-call.
.changeset/mcp-session-idle-runtime-reclaim.md+15−0
@@ -0,0 +1,15 @@
1---
2"executor": patch
3---
4
5**Idle MCP session runtimes are actually reclaimed**
6
7The MCP session Durable Object has an idle timeout that disposes a session's execution runtime — the execution engine and its executor closure, the built tool catalog, and a live database handle — once the session has gone quiet. That timeout never ran.
8
9The session arms an idle alarm on every request. The agents framework independently recomputes the Durable Object alarm from its own schedule table and keep-alive refcount, and when it finds neither it does not leave the alarm alone: it deletes it. It releases the last keep-alive reference at the end of every ordinary tool call, from a `waitUntil` that runs just after the response goes out — so the idle alarm the session had armed moments earlier was erased, and a session that had just served a request was left with no alarm at all. Its runtime then stayed resident until the platform evicted the whole object.
10
11Durable Objects are colocated many-to-one onto an isolate with a single heap, so runtimes that are never reclaimed accumulate there. When the heap is exhausted the allocation that fails is whichever comes next, anywhere in the isolate — which is why the failure tended to surface from storage rather than from the runtimes that had consumed the memory.
12
13The idle deadline belongs to the session, not to the framework's scheduler, so it is now re-asserted after the framework has arranged whatever it needs — and only while a runtime is actually resident, since once there is nothing left to reclaim the framework's answer is correct.
14
15Disposal also now emits a span carrying a per-isolate resident-runtime gauge, alongside the same gauge on runtime build, so the reclaim can be confirmed in production rather than inferred.
.changeset/mcp-sse-result-replay.md+0−5
@@ -1,5 +0,0 @@
1---
2"executor": patch
3---
4
5Preserve MCP tool results across dropped streamable HTTP SSE connections.
.changeset/oauth-dcr-application-type.md+9−0
@@ -0,0 +1,9 @@
1---
2"executor": patch
3---
4
5**Fix: declare the OAuth application type during dynamic client registration**
6
7Dynamic OAuth registrations now identify HTTPS callbacks as web applications
8and loopback HTTP callbacks as native applications. This lets strict OAuth
9servers validate Executor's redirect URI against the correct client type.
.changeset/oauth-popup-localstorage-residue.md+9−0
@@ -0,0 +1,9 @@
1---
2"executor": patch
3---
4
5**The OAuth popup clears its result out of `localStorage` after handing it over**
6
7The popup writes its result to `localStorage` as the fallback completion channel, because `postMessage` is severed when a provider's consent page sets COOP and `BroadcastChannel` can be partitioned or raced by the auto-close. Nothing removed that entry afterwards, so the payload — which carries the identity label, an email, and on failure the error preview — stayed parked in the user's browser profile.
8
9The entry is now cleared once the handover has had time to land, and on `pagehide` as a backstop — the failure page never auto-closes so the user can read the error, and closing it by hand would otherwise cancel the pending timer and strand the entry. This cannot cost a listener the result: a `storage` event captures `newValue` at dispatch, so an opener that has been notified already holds it.
.changeset/onepassword-service-account-token-lifetime.md+11−0
@@ -0,0 +1,11 @@
1---
2"executor": patch
3---
4
5**The 1Password service-account token is cleared from the op-js global after each call**
6
7`@1password/op-js` keeps the service-account token on a module-level CLI instance (`cli.serviceAccountToken`) and reads it when it spawns `op`. The CLI backend set that global before each call and never cleared it, so one reachable reference to the token stayed live for the rest of the process.
8
9It is now cleared as soon as the call that needed it is done, on success, failure and interruption alike. Authentication is unaffected: every read and write of that global already happens inside the backend's semaphore, so the next operation re-sets the token before it spawns anything.
10
11This is hygiene rather than a boundary change. No unrelated `op` child ever received a stale token — every call routes through the same critical section that sets the correct one immediately before invoking — and the token is separately persisted in plaintext in the plugin's config blob, so an attacker's reach is unchanged. What it removes is a long-lived reachable reference that nothing needed to keep.
.changeset/openapi-streaming-preview.md+5−0
@@ -0,0 +1,5 @@
1---
2"@executor-js/plugin-openapi": patch
3---
4
5Preview OpenAPI spec-format selections (Microsoft Graph) through the streaming structural-split path instead of a whole-document parse, and guard generic whole-document parses by parsed-tree size (line count for block YAML, text size for JSON). Previewing a Graph preset URL previously parsed the 43MB source whole and killed the 128MB Workers isolate mid-request, surfacing as an empty 503; it now streams within budget, and oversized generic specs fail with an actionable error instead of taking down the isolate.
.changeset/slim-search-tools.md+6−0
@@ -0,0 +1,6 @@
1---
2"@executor-js/execution": patch
3"executor": patch
4---
5
6Slim the per-integration `search_<integration>` tool definitions to under half their size: one shared one-line description (the tool name already carries the namespace) and a single bare `query` parameter, dropping the `limit`/`offset` knobs. A session pays for these definitions once per connected integration, so the surface now costs ~2k tokens instead of ~5k at 30 integrations; paging through a namespace belongs in `execute`.
.changeset/storage-error-shaping.md+8−0
@@ -0,0 +1,8 @@
1---
2"@executor-js/sdk": patch
3"@executor-js/api": patch
4---
5
6Build `StorageError.message` from the call-site label plus the driver's error code instead of the driver's raw text. The driver text is drizzle's `Failed query: <sql>\nparams: <bound values>`, so error reporting grouped one storage defect by statement shape and printed bound parameters into issue titles. The full driver error stays on `cause`.
7
8Add `StorageConnectionError`, a `StorageFailure` variant for postgres.js connection faults (`CONNECTION_ENDED`, `CONNECTION_CLOSED`, `CONNECTION_DESTROYED`, `CONNECT_TIMEOUT`, `ECONNREFUSED`, `ECONNRESET`) and workerd's cross-request I/O rejection. It carries the fault `code` and a `retryable` flag so a lost socket can be told apart from a pool-lifetime bug.
.changeset/tools-proxy-enumeration-hint.md+0−5
@@ -1,5 +0,0 @@
1---
2"executor": patch
3---
4
5Throw a guidance error when sandbox code enumerates the `tools` proxy (`Object.keys`, spread, `for...in`) instead of returning an empty list, pointing agents at `tools.search()`.
.changeset/worker-bundler-staging-contract.md+11−0
@@ -0,0 +1,11 @@
1---
2"executor": patch
3---
4
5**The packed Worker toolchain is verified at build time, and an incomplete copy is now reported instead of silently ignored**
6
7`@cloudflare/worker-bundler` cannot live inside the compiled binary: bunfs has no `node_modules`, so a bare specifier is unresolvable there by construction. The build instead copies the package's `dist/` next to the executable and `native-bindings.ts` publishes that path as `EXECUTOR_WORKER_BUNDLER_DIR` for consumers to load from.
8
9That handoff was described in two places that were free to drift, and did. The build writes `dist/index.bundled.js` — the entry consumers actually load, packed so it has no bare imports of its own — while the runtime check only looked for `dist/index.js` and `dist/esbuild.wasm`. Nothing verified the staged copy after the compile, so a partial staging produced a binary that looked fine on the build machine and failed on the user's, at startup. Worse, the runtime check failed open: when a file was missing it silently declined to set the environment variable, leaving a consumer to fall through to the bare specifier and crash.
10
11The required file list is now one shared contract used by both sides, so they cannot disagree. The build asserts the staged copy after compiling each target — every required file present, a size floor on the packed entry, and the `\0asm` magic on the wasm so a truncated copy cannot pass — turning a packaging slip into a failed build rather than a broken install. At runtime, a directory that is present but incomplete is reported on stderr naming the missing files, instead of being swallowed. An absent directory stays quiet, since that is the normal non-packaged path.
.changeset/workos-blip-session-survival.md+0−5
@@ -1,5 +0,0 @@
1---
2"executor": patch
3---
4
5Treat a transient WorkOS outage during the MCP live-membership check as a retryable 503 instead of a Forbidden that destroys the session.
.claude/skills/emulate/SKILL.md+15−12
@@ -32,18 +32,22 @@ const github = await createEmulator({ service: "github", port: 4501 }); · 31 unmodified lines
32`servers`) when a proxy fronts the emulator — the bind stays on `port`.32`servers`) when a proxy fronts the emulator — the bind stays on `port`.
33 33
34**Attach to a running one** (another process, or hosted on Cloudflare with34**Attach to a running one** (another process, or hosted on Cloudflare with
35Durable Object state at `https://<service>.emulators.dev` /35Durable Object state at `https://<service>.<instance>.emulators.dev` —
36`https://<service>.<instance>.emulators.dev` — catalog at36catalog at `GET https://emulators.dev/_emulate/services`). Service hosts
37`GET https://emulators.dev/_emulate/services`):37(`https://<service>.emulators.dev`) are control plane only, with no shared
38default instance behind them: create a private instance first and connect to
39the returned `providerBaseUrl`.
38 40
39```ts41```ts
40import { connectEmulator } from "@executor-js/emulate";42import { connectEmulator } from "@executor-js/emulate";
41const resend = await connectEmulator({ baseUrl: "https://resend.emulators.dev" });43const created = await fetch("https://resend.emulators.dev/_emulate/instances", { method: "POST" });
44const { providerBaseUrl } = await created.json();
45const resend = await connectEmulator({ baseUrl: providerBaseUrl });
42// optional: { service: "resend" } verifies the manifest on connect46// optional: { service: "resend" } verifies the manifest on connect
43```47```
44 48
45Create a private hosted instance with `POST /_emulate/instances` on the49In e2e scenarios use `createEmulatorInstance` from
46service host.50`e2e/src/emulator-instance.ts`, which wraps this.
47 51
48## The typed control-plane client52## The typed control-plane client
49 53
@@ -113,12 +117,11 @@ The emulate repo's own `AGENTS.md` / `README.md` carry the current build, · 63 unmodified lines
113publish, and deploy commands (npm + Cloudflare creds are in 1Password). Read117publish, and deploy commands (npm + Cloudflare creds are in 1Password). Read
114them there rather than memorizing flags here — they move.118them there rather than memorizing flags here — they move.
115 119
116**A hot deploy can redden other people's e2e.** `*.emulators.dev` service120**A hot deploy can redden other people's e2e.** The `emulate-hosts` worker
117hosts are shared infrastructure; a control-plane regression there has failed121behind `*.emulators.dev` is shared infrastructure; a control-plane regression
118unrelated PRs' suites before. When a scenario needs isolation or a behavior122there has failed unrelated PRs' suites before. Scenarios always run on private
119that isn't deployed yet, pin to a published package version or mint a private123per-run instances (`POST /_emulate/instances`); when a scenario needs behavior
120per-run instance (`POST /_emulate/instances`) instead of mutating the shared124that isn't deployed yet, pin to a published package version.
121service host.
122 125
123## Gotchas126## Gotchas
124 127
.claude/skills/prod-telemetry/SKILL.md+18−1
@@ -29,6 +29,20 @@ join the same traces via traceparent). · 28 unmodified lines
29 29
30**Span names worth querying** (and their custom attrs):30**Span names worth querying** (and their custom attrs):
31 31
32- `mcp.execute` / `mcp.execute.resume` — `mcp.execute.mode`
33 (`pausable`/`inline`), `mcp.execute.code_length`, and
34 `mcp.execute.outcome` (`ok`/`fail`/`paused`) with, on failures,
35 `mcp.execute.error_kind` (`type_error` | `reference_error` |
36 `syntax_error` | `range_error` | `tool_error` | `timeout` |
37 `resource_limit` | `serialization_error` | `thrown` | `unknown`).
38 Sandbox script failures ride the MCP success channel, so `status.code`
39 stays OK — filter on these attributes, not span status. Spans from
40 before the attributes shipped carry neither; absence is not success.
41 Also `mcp.execute.result_chars` (compact-JSON size of the returned
42 value, pre-truncation; -1 = unmeasurable), `mcp.execute.log_chars`,
43 `mcp.execute.emitted` — the dump-vs-narrow signal (the model preview
44 truncates at 30k chars, so `result_chars > 30000` means the model tried
45 to pull a truncated blob into context).
32- `executor.tool.execute` — `mcp.tool.name` (full address), and since46- `executor.tool.execute` — `mcp.tool.name` (full address), and since
33 PR #992: `executor.tool.outcome` (`ok`/`fail`),47 PR #992: `executor.tool.outcome` (`ok`/`fail`),
34 `executor.tool.error_code`, `executor.tool.error_status`,48 `executor.tool.error_code`, `executor.tool.error_status`,
@@ -39,7 +53,10 @@ join the same traces via traceparent). · 4 unmodified lines
39 `base_url`, and since PR #992 `http.status_code`.53 `base_url`, and since PR #992 `http.status_code`.
40- `mcp.request` (outer) — `mcp.auth.organization_id`,54- `mcp.request` (outer) — `mcp.auth.organization_id`,
41 `mcp.auth.account_id`, `mcp.tool.name`, CF edge fields (`cf.country`…),55 `mcp.auth.account_id`, `mcp.tool.name`, CF edge fields (`cf.country`…),
42 MCP client fingerprint (`mcp.client.name`…).56 MCP client fingerprint (`mcp.client.name`…), and on managed-cloud
57 `execute`/`execute-action` calls `mcp.execute.code` (the script itself,
58 capped at 10k chars — cloud-only content capture; local/self-host
59 telemetry never records content).
43 60
44**Recipe — error signatures by class (the daily-digest query):**61**Recipe — error signatures by class (the daily-digest query):**
45 62
.github/workflows/ci.yml+144−57
@@ -10,6 +10,18 @@ concurrency: · 9 unmodified lines
10 group: ci-${{ github.ref }}10 group: ci-${{ github.ref }}
11 cancel-in-progress: true11 cancel-in-progress: true
12 12
13# CI is not a user. Without this, executor's own product telemetry reports to the
14# production PostHog project from every e2e shard: the analytics layer
15# (integration_added, execution_completed, artifact_*) and the
16# integrations-registry fetch, which lands as a `hit` event. Each shard is a
17# fresh container, so it mints a fresh anonymous id and arrives on a fresh
18# runner IP — inflating machine and user counts by roughly an order of
19# magnitude. Both opt-outs are the packages' documented CI/test hooks.
20env:
21 DO_NOT_TRACK: "1"
22 EXECUTOR_DISABLE_ANALYTICS: "1"
23 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
24
13jobs:25jobs:
14 changes:26 changes:
15 name: Changed paths27 name: Changed paths
@@ -18,6 +30,7 @@ jobs: · 2 unmodified lines
18 outputs:30 outputs:
19 desktop_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop_smoke == 'true' }}31 desktop_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop_smoke == 'true' }}
20 selfhost_docker_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.selfhost_docker_smoke == 'true' }}32 selfhost_docker_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.selfhost_docker_smoke == 'true' }}
33 cloud_closure: ${{ github.event_name != 'pull_request' || steps.filter.outputs.cloud_closure == 'true' }}
21 steps:34 steps:
22 - uses: actions/checkout@v435 - uses: actions/checkout@v4
23 36
@@ -41,6 +54,14 @@ jobs: · 17 unmodified lines
41 - "packages/kernel/runtime-quickjs/**"54 - "packages/kernel/runtime-quickjs/**"
42 - "packages/plugins/**"55 - "packages/plugins/**"
43 - "packages/react/**"56 - "packages/react/**"
57 cloud_closure:
58 - ".github/workflows/**"
59 - "bun.lock"
60 - "package.json"
61 - "turbo.json"
62 - "apps/cloud/**"
63 - "packages/**"
64
44 selfhost_docker_smoke:65 selfhost_docker_smoke:
45 - ".github/workflows/**"66 - ".github/workflows/**"
46 - ".dockerignore"67 - ".dockerignore"
@@ -136,17 +157,26 @@ jobs: · 89 unmodified lines
136 157
137 - run: bun run typecheck158 - run: bun run typecheck
138 159
160 # The committed routeTree.gen.ts files are generated but checked in, and
161 # they carry @ts-nocheck — so the typecheck above structurally CANNOT see
162 # a route that the contract declares and the tree lacks. That drift has
163 # already shipped a live 404 once. Regenerate and diff instead.
164 - run: bun run check:routes
165
139 test:166 test:
140 name: Test167 name: Test
141 runs-on: blacksmith-4vcpu-ubuntu-2404168 runs-on: blacksmith-16vcpu-ubuntu-2404
142 timeout-minutes: 15169 timeout-minutes: 15
143 # Tuned for Blacksmith's 4 vCPU runners.170 # Run eight independent packages at once and cap each Vitest process at two
171 # workers. This uses all 16 vCPUs without every nested runner seeing the
172 # whole machine and oversubscribing it.
144 env:173 env:
145 TURBO_API: ${{ vars.TURBO_API }}174 TURBO_API: ${{ vars.TURBO_API }}
146 TURBO_TEAM: ${{ vars.TURBO_TEAM }}175 TURBO_TEAM: ${{ vars.TURBO_TEAM }}
147 TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}176 TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
148 TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}177 TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
149 TURBO_TEST_CONCURRENCY: 4178 TURBO_TEST_CONCURRENCY: 8
179 VITEST_MAX_WORKERS: 2
150 steps:180 steps:
151 - uses: actions/checkout@v4181 - uses: actions/checkout@v4
152 182
@@ -178,6 +208,8 @@ jobs: · 25 unmodified lines
178 # no signal. This is just a few file reads plus greps, so ~zero cost.208 # no signal. This is just a few file reads plus greps, so ~zero cost.
179 - run: bun run check:patches209 - run: bun run check:patches
180 210
211 - run: bun run --cwd e2e test:ci-shard
212
181 - run: bun run test213 - run: bun run test
182 214
183 e2e:215 e2e:
@@ -186,14 +218,41 @@ jobs: · 2 unmodified lines
186 fail-fast: false218 fail-fast: false
187 matrix:219 matrix:
188 include:220 include:
189 # Each cloud shard boots its own fresh dev stack. On 4 vCPU runners,221 # The planner assigns every file exactly once using recorded slow-file
190 # four fatter shards keep the longest shard below selfhost while saving222 # durations plus a conservative weight for new tests. The cloud DB's
191 # four runner boots and four warm cache restores.223 # connection teardown and concurrent socket protocol have dedicated
192 - { target: cloud, shard: 1/4, shard-name: 1of4 }224 # regression tests; these shards balance wall clock, not hide retries.
193 - { target: cloud, shard: 2/4, shard-name: 2of4 }225 - { target: cloud, shard-index: 1, shard-name: 1of16 }
194 - { target: cloud, shard: 3/4, shard-name: 3of4 }226 - { target: cloud, shard-index: 2, shard-name: 2of16 }
195 - { target: cloud, shard: 4/4, shard-name: 4of4 }227 - { target: cloud, shard-index: 3, shard-name: 3of16 }
196 - target: selfhost228 - { target: cloud, shard-index: 4, shard-name: 4of16 }
229 - { target: cloud, shard-index: 5, shard-name: 5of16 }
230 - { target: cloud, shard-index: 6, shard-name: 6of16 }
231 - { target: cloud, shard-index: 7, shard-name: 7of16 }
232 - { target: cloud, shard-index: 8, shard-name: 8of16 }
233 - { target: cloud, shard-index: 9, shard-name: 9of16 }
234 - { target: cloud, shard-index: 10, shard-name: 10of16 }
235 - { target: cloud, shard-index: 11, shard-name: 11of16 }
236 - { target: cloud, shard-index: 12, shard-name: 12of16 }
237 - { target: cloud, shard-index: 13, shard-name: 13of16 }
238 - { target: cloud, shard-index: 14, shard-name: 14of16 }
239 - { target: cloud, shard-index: 15, shard-name: 15of16 }
240 - { target: cloud, shard-index: 16, shard-name: 16of16 }
241 - { target: selfhost, shard-index: 1, shard-name: 1of10 }
242 - { target: selfhost, shard-index: 2, shard-name: 2of10 }
243 - { target: selfhost, shard-index: 3, shard-name: 3of10 }
244 - { target: selfhost, shard-index: 4, shard-name: 4of10 }
245 - { target: selfhost, shard-index: 5, shard-name: 5of10 }
246 - { target: selfhost, shard-index: 6, shard-name: 6of10 }
247 - { target: selfhost, shard-index: 7, shard-name: 7of10 }
248 - { target: selfhost, shard-index: 8, shard-name: 8of10 }
249 - { target: selfhost, shard-index: 9, shard-name: 9of10 }
250 - { target: selfhost, shard-index: 10, shard-name: 10of10 }
251 # Local files own their server, browser and data directory. Separate
252 # runners preserve that isolation while removing its 69-second serial
253 # lane from the two-minute critical path.
254 - { target: local, shard-index: 1, shard-name: 1of2 }
255 - { target: local, shard-index: 2, shard-name: 2of2 }
197 runs-on: blacksmith-4vcpu-ubuntu-2404256 runs-on: blacksmith-4vcpu-ubuntu-2404
198 timeout-minutes: 30257 timeout-minutes: 30
199 steps:258 steps:
@@ -229,26 +288,31 @@ jobs: · 29 unmodified lines
229 288
230 # Install from e2e so bunx resolves ITS pinned playwright (the version289 # Install from e2e so bunx resolves ITS pinned playwright (the version
231 # the tests run against) rather than floating to the latest.290 # the tests run against) rather than floating to the latest.
291 # Blacksmith's Ubuntu image carries the official GitHub runner system
292 # dependencies. Restore the pinned browser binaries without apt-updating
293 # every matrix machine.
232 - name: Install Playwright Chromium294 - name: Install Playwright Chromium
233 run: bunx playwright install --with-deps chromium chromium-headless-shell295 run: bunx playwright install chromium chromium-headless-shell
234 working-directory: e2e296 working-directory: e2e
235 297
236 # The globalsetup boots the target's own dev server (ports are claimed298 # Each target either boots its own shared dev server or lets each file own
237 # per checkout, so this is hermetic) and tears it down after the run.299 # its server. Ports and data paths are hermetic in both cases.
238 # --retry=2: browser scenarios can still hit isolated waitFor timeouts300 # Do not retry scenarios: retries hide flakes and multiply slow timeout
239 # (single-test waitFor timeouts, not systemic failures); a retry on the301 # failures. The fixtures and process lifecycle are deterministic enough
240 # same booted stack clears them.302 # that the first result is the result.
241 - name: Run cloud scenarios303 - name: Run cloud shard
242 if: matrix.target == 'cloud'304 if: matrix.target == 'cloud'
243 env:305 env:
244 MCP_SESSION_TIMEOUT_MS: "3000"306 MCP_SESSION_TIMEOUT_MS: "3000"
245 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "6000"307 # Still 18x shorter than production, but long enough for a cold Vite
246 run: bunx vitest run --project cloud --retry=2 ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }}308 # resume route to compile under a fully loaded CI runner.
309 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "30000"
310 run: bun scripts/run-ci-shard.ts cloud ${{ matrix['shard-index'] }}
247 working-directory: e2e311 working-directory: e2e
248 312
249 - name: Run selfhost scenarios313 - name: Run scenarios
250 if: matrix.target == 'selfhost'314 if: matrix.target != 'cloud'
251 run: bunx vitest run --project selfhost --retry=2315 run: bun scripts/run-ci-shard.ts ${{ matrix.target }} ${{ matrix['shard-index'] }}
252 working-directory: e2e316 working-directory: e2e
253 317
254 # Failed runs keep their trace.zip / session.mp4 / step screenshots in318 # Failed runs keep their trace.zip / session.mp4 / step screenshots in
@@ -261,13 +325,23 @@ jobs: · 6 unmodified lines
261 path: e2e/runs/325 path: e2e/runs/
262 retention-days: 7326 retention-days: 7
263 327
264 e2e-local:328 cloud-closure:
265 name: E2E (stdio MCP)329 name: Cloud evaluated closure
266 # Skipped on pull_request: the local scenario boots a real `executor web`330 needs: changes
267 # plus a browser and is currently flaky on PRs. Still runs on push to main.331 if: needs.changes.outputs.cloud_closure == 'true'
268 if: github.event_name != 'pull_request'
269 runs-on: blacksmith-4vcpu-ubuntu-2404332 runs-on: blacksmith-4vcpu-ubuntu-2404
270 timeout-minutes: 20333 timeout-minutes: 20
334 env:
335 TURBO_API: ${{ vars.TURBO_API }}
336 TURBO_TEAM: ${{ vars.TURBO_TEAM }}
337 TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
338 TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
339 # Every cold isolate evaluates this closure before it can answer a
340 # request, and it only ever grows by accident - a barrel export or a new
341 # module-scope import quietly pulls megabytes into the server graph. The
342 # budget is a ratchet just above today's size, not a discovered limit:
343 # when it trips, make the new dependency lazy rather than raising it.
344 START_CLOSURE_BUDGET_MB: 13.5
271 steps:345 steps:
272 - uses: actions/checkout@v4346 - uses: actions/checkout@v4
273 347
@@ -283,41 +357,17 @@ jobs: · 9 unmodified lines
283 restore-keys: |357 restore-keys: |
284 ${{ runner.os }}-bun-1.3.11-358 ${{ runner.os }}-bun-1.3.11-
285 359
286 # The local scenarios boot a real `executor web` (which spawns a Node
287 # sidecar) and some drive a browser, so pin Node 24 and install Chromium.
288 - uses: actions/setup-node@v4360 - uses: actions/setup-node@v4
289 with:361 with:
290 node-version: 24362 node-version: 24
291 363
292 - run: bun install --frozen-lockfile364 - run: bun install --frozen-lockfile
293 365
294 - name: Cache Playwright browsers366 - run: bun run build
295 uses: actions/cache@v4367 working-directory: apps/cloud
296 with:
297 path: ~/.cache/ms-playwright
298 key: ${{ runner.os }}-playwright-1.60.0
299 restore-keys: |
300 ${{ runner.os }}-playwright-
301 368
302 # `chromium` and the new `chromium-headless-shell` ship as separate369 - run: node scripts/start-closure.mjs
303 # downloads; the browser-driven scenarios launch the headless shell.370 working-directory: apps/cloud
304 # Install from e2e so bunx resolves ITS pinned playwright (the version the
305 # tests run against) rather than floating to the latest, which would fetch
306 # a browser build the test runtime does not look for.
307 - name: Install Playwright Chromium
308 run: bunx playwright install --with-deps chromium chromium-headless-shell
309 working-directory: e2e
310
311 # The `local` project is excluded from the default `test` chain (each
312 # scenario boots its own `executor web`). Run just the stdio MCP scenario
313 # here: it is the auto-connect / env-as-secret regression guard, and
314 # running it alone avoids the boot-resource accumulation and the
315 # pre-existing browser flakiness of the rest of the local suite. Expanding
316 # to the full `local` project (bun run test:local) is a follow-up once
317 # those are stabilized.
318 - name: Run the stdio MCP scenario
319 run: bunx vitest run --project local local/stdio-mcp.test.ts
320 working-directory: e2e
321 371
322 desktop-smoke:372 desktop-smoke:
323 name: Desktop smoke build373 name: Desktop smoke build
@@ -369,7 +419,13 @@ jobs: · 45 unmodified lines
369 - name: Run sidecar outside the workspace419 - name: Run sidecar outside the workspace
370 working-directory: apps/desktop420 working-directory: apps/desktop
371 run: |421 run: |
372 docker run --rm -d --name sidecar-smoke -p 45841:45841 -e HOME=/tmp \422 # Containers do not inherit the job environment. Keep the compiled
423 # product smoke from becoming a production analytics/integrations hit.
424 docker run --rm -d --name sidecar-smoke -p 45841:45841 \
425 -e HOME=/tmp \
426 -e DO_NOT_TRACK=1 \
427 -e EXECUTOR_DISABLE_ANALYTICS=1 \
428 -e EXECUTOR_DISABLE_INTEGRATIONS_FETCH=1 \
373 -v "$PWD/resources/executor:/opt/executor:ro" \429 -v "$PWD/resources/executor:/opt/executor:ro" \
374 debian:bookworm-slim /opt/executor/executor daemon run --foreground \430 debian:bookworm-slim /opt/executor/executor daemon run --foreground \
375 --port 45841 --hostname 0.0.0.0 --auth-token=ci-smoke431 --port 45841 --hostname 0.0.0.0 --auth-token=ci-smoke
@@ -414,4 +470,35 @@ jobs: · 38 unmodified lines
414 context: .470 context: .
415 file: apps/host-selfhost/Dockerfile471 file: apps/host-selfhost/Dockerfile
416 push: false472 push: false
473 load: true
417 tags: executor-selfhost:ci474 tags: executor-selfhost:ci
475
476 # Building alone doesn't catch startup crashes (e.g. a runtime-resolved
477 # package missing from the packaged runtime), so boot the container and
478 # require a healthy /api/health before calling the image good.
479 - name: Boot image and check health
480 shell: bash
481 run: |
482 set -euo pipefail
483 docker run -d --name selfhost-smoke -p 4788:4788 \
484 -e BETTER_AUTH_SECRET="$(openssl rand -hex 32)" \
485 -e EXECUTOR_BOOTSTRAP_ADMIN_EMAIL=smoke@example.com \
486 -e EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD="$(openssl rand -hex 16)" \
487 -e EXECUTOR_WEB_BASE_URL=http://localhost:4788 \
488 executor-selfhost:ci
489 for i in $(seq 1 30); do
490 if [ "$(docker inspect -f '{{.State.Running}}' selfhost-smoke)" != "true" ]; then
491 echo "container exited during startup"
492 docker logs selfhost-smoke
493 exit 1
494 fi
495 if curl -fsS http://localhost:4788/api/health >/dev/null 2>&1; then
496 echo "healthy after ${i}s"
497 docker logs selfhost-smoke
498 exit 0
499 fi
500 sleep 1
501 done
502 echo "health check never passed"
503 docker logs selfhost-smoke
504 exit 1
.github/workflows/deploy.yml+29−1
@@ -9,6 +9,16 @@ concurrency: · 8 unmodified lines
9 group: deploy-production9 group: deploy-production
10 cancel-in-progress: false10 cancel-in-progress: false
11 11
12# CI is not a user — see the note in ci.yml. Applied to every workflow rather
13# than the ones that look like they run product code: the first pass guessed,
14# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert
15# where the product is not executed, so the blanket application is the cheap
16# structural answer.
17env:
18 DO_NOT_TRACK: "1"
19 EXECUTOR_DISABLE_ANALYTICS: "1"
20 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
21
12jobs:22jobs:
13 migrate:23 migrate:
14 name: Migrate database24 name: Migrate database
@@ -77,12 +87,30 @@ jobs: · 62 unmodified lines
77 VITE_PUBLIC_SENTRY_DSN: ${{ secrets.VITE_PUBLIC_SENTRY_DSN }}87 VITE_PUBLIC_SENTRY_DSN: ${{ secrets.VITE_PUBLIC_SENTRY_DSN }}
78 88
79 - name: Deploy cloud89 - name: Deploy cloud
80 run: bun run wrangler deploy -c dist/server/wrangler.json90 run: bun run wrangler deploy -c dist/server/wrangler.json --var GIT_COMMIT_SHA:${{ github.sha }}
81 working-directory: apps/cloud91 working-directory: apps/cloud
82 env:92 env:
83 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}93 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
84 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}94 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
85 95
96 # Deploy marker: one event per deploy into the same Axiom dataset the
97 # worker traces land in, so a latency step-change lines up with its
98 # deploy in one query. Skipped (not failed) when the secret is absent,
99 # and never blocks the deploy.
100 - name: Record deploy marker in Axiom
101 env:
102 AXIOM_INGEST_TOKEN: ${{ secrets.AXIOM_INGEST_TOKEN }}
103 run: |
104 if [ -z "$AXIOM_INGEST_TOKEN" ]; then
105 echo "AXIOM_INGEST_TOKEN not configured; skipping deploy marker"
106 exit 0
107 fi
108 curl -sf -X POST "https://api.axiom.co/v1/datasets/executor-cloud/ingest" \
109 -H "Authorization: Bearer $AXIOM_INGEST_TOKEN" \
110 -H "Content-Type: application/json" \
111 -d "[{\"event\":\"deploy\",\"service\":\"executor-cloud\",\"commit_sha\":\"${{ github.sha }}\",\"actor\":\"${{ github.actor }}\",\"run_id\":\"${{ github.run_id }}\"}]" \
112 || echo "deploy marker ingest failed (non-blocking)"
113
86 deploy-marketing:114 deploy-marketing:
87 name: Deploy marketing115 name: Deploy marketing
88 runs-on: blacksmith-4vcpu-ubuntu-2404116 runs-on: blacksmith-4vcpu-ubuntu-2404
.github/workflows/graph-slices.yml+47−0
@@ -0,0 +1,47 @@
1# Refresh the Microsoft Graph slice release assets.
2#
3# The Graph OpenAPI monolith (~43MB) cannot be processed inside a Workers
4# isolate, so the runtime reads per-selection slices published on the
5# `graph-slices` release tag (see packages/plugins/openapi/src/providers/
6# microsoft/slices.ts). This workflow rebuilds the slices from the current
7# upstream spec on a schedule and on demand.
8name: Graph slices
9
10on:
11 schedule:
12 # Weekly; Microsoft's msgraph-metadata automation lands upstream refreshes
13 # on a similar cadence. A failed run leaves the previous assets serving.
14 - cron: "17 6 * * 1"
15 workflow_dispatch: {}
16
17permissions:
18 contents: write
19
20jobs:
21 slices:
22 runs-on: ubuntu-latest
23 timeout-minutes: 20
24 steps:
25 - uses: actions/checkout@v4
26
27 - uses: oven-sh/setup-bun@v2
28 with:
29 bun-version: 1.3.11
30
31 - name: Install dependencies
32 run: bun install --frozen-lockfile
33
34 - name: Generate slices
35 working-directory: packages/plugins/openapi
36 run: bun scripts/generate-graph-slices.ts --out "$RUNNER_TEMP/graph-slices"
37
38 - name: Publish to the graph-slices release
39 env:
40 GH_TOKEN: ${{ github.token }}
41 run: |
42 gh release view graph-slices --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \
43 gh release create graph-slices --repo "$GITHUB_REPOSITORY" \
44 --title "Microsoft Graph slices" --latest=false \
45 --notes "Generated per-preset Microsoft Graph OpenAPI slices. Data release consumed by the openapi plugin's Microsoft adapter; refreshed by the graph-slices workflow."
46 gh release upload graph-slices "$RUNNER_TEMP/graph-slices"/* \
47 --repo "$GITHUB_REPOSITORY" --clobber
.github/workflows/pkg-pr-new.yml+10−0
@@ -10,6 +10,16 @@ concurrency: · 9 unmodified lines
10 group: pkg-pr-new-${{ github.event.pull_request.number }}10 group: pkg-pr-new-${{ github.event.pull_request.number }}
11 cancel-in-progress: true11 cancel-in-progress: true
12 12
13# CI is not a user — see the note in ci.yml. Applied to every workflow rather
14# than the ones that look like they run product code: the first pass guessed,
15# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert
16# where the product is not executed, so the blanket application is the cheap
17# structural answer.
18env:
19 DO_NOT_TRACK: "1"
20 EXECUTOR_DISABLE_ANALYTICS: "1"
21 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
22
13jobs:23jobs:
14 # Per-platform matrix: build the executor binary, tar it, upload to R2.24 # Per-platform matrix: build the executor binary, tar it, upload to R2.
15 # The wrapper npm package is built later by the `publish` job which just25 # The wrapper npm package is built later by the `publish` job which just
.github/workflows/preview-sweep.yml+10−0
@@ -16,6 +16,16 @@ permissions: · 15 unmodified lines
16 contents: read16 contents: read
17 pull-requests: read17 pull-requests: read
18 18
19# CI is not a user — see the note in ci.yml. Applied to every workflow rather
20# than the ones that look like they run product code: the first pass guessed,
21# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert
22# where the product is not executed, so the blanket application is the cheap
23# structural answer.
24env:
25 DO_NOT_TRACK: "1"
26 EXECUTOR_DISABLE_ANALYTICS: "1"
27 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
28
19jobs:29jobs:
20 sweep:30 sweep:
21 name: Destroy previews for closed PRs31 name: Destroy previews for closed PRs
.github/workflows/preview.yml+10−0
@@ -23,6 +23,16 @@ concurrency: · 22 unmodified lines
23 group: preview-${{ github.event.pull_request.number }}23 group: preview-${{ github.event.pull_request.number }}
24 cancel-in-progress: ${{ github.event.action != 'closed' }}24 cancel-in-progress: ${{ github.event.action != 'closed' }}
25 25
26# CI is not a user — see the note in ci.yml. Applied to every workflow rather
27# than the ones that look like they run product code: the first pass guessed,
28# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert
29# where the product is not executed, so the blanket application is the cheap
30# structural answer.
31env:
32 DO_NOT_TRACK: "1"
33 EXECUTOR_DISABLE_ANALYTICS: "1"
34 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
35
26jobs:36jobs:
27 deploy:37 deploy:
28 name: Deploy preview38 name: Deploy preview
.github/workflows/publish-desktop.yml+12−0
@@ -21,6 +21,18 @@ concurrency: · 20 unmodified lines
21 group: publish-desktop-${{ github.ref }}21 group: publish-desktop-${{ github.ref }}
22 cancel-in-progress: false22 cancel-in-progress: false
23 23
24# CI is not a user. Without this, executor's own product telemetry reports to the
25# production PostHog project from every e2e shard: the analytics layer
26# (integration_added, execution_completed, artifact_*) and the
27# integrations-registry fetch, which lands as a `hit` event. Each shard is a
28# fresh container, so it mints a fresh anonymous id and arrives on a fresh
29# runner IP — inflating machine and user counts by roughly an order of
30# magnitude. Both opt-outs are the packages' documented CI/test hooks.
31env:
32 DO_NOT_TRACK: "1"
33 EXECUTOR_DISABLE_ANALYTICS: "1"
34 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
35
24jobs:36jobs:
25 build:37 build:
26 permissions:38 permissions:
.github/workflows/publish-executor-package.yml+13−1
@@ -21,6 +21,16 @@ concurrency: · 20 unmodified lines
21 group: publish-executor-package-${{ github.ref }}21 group: publish-executor-package-${{ github.ref }}
22 cancel-in-progress: false22 cancel-in-progress: false
23 23
24# CI is not a user — see the note in ci.yml. Applied to every workflow rather
25# than the ones that look like they run product code: the first pass guessed,
26# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert
27# where the product is not executed, so the blanket application is the cheap
28# structural answer.
29env:
30 DO_NOT_TRACK: "1"
31 EXECUTOR_DISABLE_ANALYTICS: "1"
32 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
33
24jobs:34jobs:
25 publish:35 publish:
26 runs-on: ubuntu-latest36 runs-on: ubuntu-latest
@@ -62,7 +72,9 @@ jobs: · 35 unmodified lines
62 72
63 - name: Update npm for trusted publishing73 - name: Update npm for trusted publishing
64 run: |74 run: |
65 npm install -g npm@latest75 # npm 12.0.0 fails `publish --provenance` with "Cannot find module
76 # 'sigstore'" (npm/cli#9722); 11.x supports trusted publishing and works.
77 npm install -g npm@11
66 npm --version78 npm --version
67 79
68 - name: Install dependencies80 - name: Install dependencies
.github/workflows/publish-selfhost-docker.yml+142−4
@@ -16,6 +16,18 @@ concurrency: · 15 unmodified lines
16 group: publish-selfhost-docker-${{ inputs.tag }}16 group: publish-selfhost-docker-${{ inputs.tag }}
17 cancel-in-progress: false17 cancel-in-progress: false
18 18
19# CI is not a user. Without this, executor's own product telemetry reports to the
20# production PostHog project from every e2e shard: the analytics layer
21# (integration_added, execution_completed, artifact_*) and the
22# integrations-registry fetch, which lands as a `hit` event. Each shard is a
23# fresh container, so it mints a fresh anonymous id and arrives on a fresh
24# runner IP — inflating machine and user counts by roughly an order of
25# magnitude. Both opt-outs are the packages' documented CI/test hooks.
26env:
27 DO_NOT_TRACK: "1"
28 EXECUTOR_DISABLE_ANALYTICS: "1"
29 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
30
19jobs:31jobs:
20 metadata:32 metadata:
21 runs-on: blacksmith-4vcpu-ubuntu-240433 runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -163,16 +175,124 @@ jobs: · 141 unmodified lines
163 if-no-files-found: error175 if-no-files-found: error
164 retention-days: 1176 retention-days: 1
165 177
178 # Release gate: run the full selfhost-docker e2e project against the exact
179 # by-digest image each build leg pushed, before any user-visible tag moves.
180 # Build-only smoke misses startup crashes and feature-path breaks (a package
181 # resolved at runtime but missing from the packaged runtime shipped broken in
182 # v1.5.30 exactly this way); per-arch matters too — the libsql native-addon
183 # bug was arm64-only.
184 e2e:
185 needs:
186 - metadata
187 - build
188 strategy:
189 fail-fast: false
190 matrix:
191 include:
192 - arch: amd64
193 runner: blacksmith-4vcpu-ubuntu-2404
194 - arch: arm64
195 runner: blacksmith-4vcpu-ubuntu-2404-arm
196 runs-on: ${{ matrix.runner }}
197 timeout-minutes: 40
198 permissions:
199 contents: read
200 packages: read
201
202 steps:
203 - name: Checkout release tag
204 uses: actions/checkout@v4
205 with:
206 ref: ${{ needs.metadata.outputs.release_tag }}
207 persist-credentials: false
208
209 - uses: oven-sh/setup-bun@v2
210 with:
211 bun-version: 1.3.11
212
213 - name: Cache Bun package cache
214 uses: actions/cache@v4
215 with:
216 path: ~/.bun/install/cache
217 key: ${{ runner.os }}-${{ runner.arch }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
218 restore-keys: |
219 ${{ runner.os }}-${{ runner.arch }}-bun-1.3.11-
220
221 # Scenario helpers spawn Node sidecars; pin the same Node 24 runtime the
222 # other workflows use.
223 - uses: actions/setup-node@v4
224 with:
225 node-version: 24
226
227 - run: bun install --frozen-lockfile
228
229 - name: Cache Playwright browsers
230 uses: actions/cache@v4
231 with:
232 path: ~/.cache/ms-playwright
233 key: ${{ runner.os }}-${{ runner.arch }}-playwright-1.60.0
234 restore-keys: |
235 ${{ runner.os }}-${{ runner.arch }}-playwright-
236
237 # Install from e2e so bunx resolves ITS pinned playwright (the version
238 # the tests run against) rather than floating to the latest.
239 - name: Install Playwright Chromium
240 run: bunx playwright install --with-deps chromium chromium-headless-shell
241 working-directory: e2e
242
243 - name: Log in to GHCR
244 uses: docker/login-action@v3
245 with:
246 registry: ghcr.io
247 username: ${{ github.actor }}
248 password: ${{ secrets.GITHUB_TOKEN }}
249
250 - name: Download image digest
251 uses: actions/download-artifact@v4
252 with:
253 name: digests-${{ matrix.arch }}
254 path: ${{ runner.temp }}/digests
255
256 - name: Resolve image reference
257 id: image_ref
258 shell: bash
259 env:
260 IMAGE: ${{ needs.metadata.outputs.image }}
261 run: |
262 set -euo pipefail
263 digest_file="$(find "$RUNNER_TEMP/digests" -maxdepth 1 -type f | head -n 1)"
264 if [ -z "$digest_file" ]; then
265 echo "No image digest was downloaded."
266 exit 1
267 fi
268 ref="${IMAGE}@sha256:$(basename "$digest_file")"
269 echo "ref=$ref" >> "$GITHUB_OUTPUT"
270 docker pull "$ref"
271
272 - name: Run selfhost-docker scenarios against the published digest
273 env:
274 E2E_SELFHOST_DOCKER_IMAGE: ${{ steps.image_ref.outputs.ref }}
275 run: bunx vitest run --project selfhost-docker --retry=2
276 working-directory: e2e
277
278 - name: Upload run artifacts
279 if: failure()
280 uses: actions/upload-artifact@v4
281 with:
282 name: e2e-runs-selfhost-docker-${{ matrix.arch }}
283 path: |
284 e2e/runs/
285 e2e/selfhost-docker.boot.log
286 retention-days: 7
287
166 merge:288 merge:
167 needs:289 needs:
168 - metadata290 - metadata
169 - build291 - build
292 - e2e
170 runs-on: blacksmith-4vcpu-ubuntu-2404293 runs-on: blacksmith-4vcpu-ubuntu-2404
171 permissions:294 permissions:
172 packages: write295 packages: write
173 env:
174 HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }}
175
176 steps:296 steps:
177 - name: Download image digests297 - name: Download image digests
178 uses: actions/download-artifact@v4298 uses: actions/download-artifact@v4
@@ -219,10 +339,28 @@ jobs: · 40 unmodified lines
219 339
220 docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"340 docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"
221 341
342 # Mirrors each release to the pre-org-move namespace
343 # (ghcr.io/rhyssullivan/executor-selfhost), which GHCR cannot redirect.
344 # Deliberately a separate job: the canonical publish above never depends on
345 # it, and a failure here (an expired GHCR_LEGACY_TOKEN, historically) shows
346 # as one red job pointing at exactly what to fix.
347 mirror-legacy:
348 needs:
349 - metadata
350 - merge
351 runs-on: blacksmith-4vcpu-ubuntu-2404
352 env:
353 HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }}
354
355 steps:
222 - name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured)356 - name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured)
223 if: env.HAS_LEGACY_TOKEN != 'true'357 if: env.HAS_LEGACY_TOKEN != 'true'
224 run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror."358 run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror."
225 359
360 - name: Set up Docker Buildx
361 if: env.HAS_LEGACY_TOKEN == 'true'
362 uses: docker/setup-buildx-action@v3
363
226 - name: Log in to legacy GHCR namespace364 - name: Log in to legacy GHCR namespace
227 if: env.HAS_LEGACY_TOKEN == 'true'365 if: env.HAS_LEGACY_TOKEN == 'true'
228 uses: docker/login-action@v3366 uses: docker/login-action@v3
@@ -231,7 +369,7 @@ jobs: · 2 unmodified lines
231 username: rhyssullivan369 username: rhyssullivan
232 password: ${{ secrets.GHCR_LEGACY_TOKEN }}370 password: ${{ secrets.GHCR_LEGACY_TOKEN }}
233 371
234 - name: Mirror self-host image to legacy GHCR namespace372 - name: Mirror release tags to the legacy namespace
235 if: env.HAS_LEGACY_TOKEN == 'true'373 if: env.HAS_LEGACY_TOKEN == 'true'
236 shell: bash374 shell: bash
237 env:375 env:
.github/workflows/release.yml+15−1
@@ -13,6 +13,18 @@ concurrency: · 12 unmodified lines
13 group: release-${{ github.ref }}13 group: release-${{ github.ref }}
14 cancel-in-progress: false14 cancel-in-progress: false
15 15
16# CI is not a user. Without this, executor's own product telemetry reports to the
17# production PostHog project from every e2e shard: the analytics layer
18# (integration_added, execution_completed, artifact_*) and the
19# integrations-registry fetch, which lands as a `hit` event. Each shard is a
20# fresh container, so it mints a fresh anonymous id and arrives on a fresh
21# runner IP — inflating machine and user counts by roughly an order of
22# magnitude. Both opt-outs are the packages' documented CI/test hooks.
23env:
24 DO_NOT_TRACK: "1"
25 EXECUTOR_DISABLE_ANALYTICS: "1"
26 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
27
16jobs:28jobs:
17 release:29 release:
18 runs-on: ubuntu-latest30 runs-on: ubuntu-latest
@@ -42,7 +54,9 @@ jobs: · 23 unmodified lines
42 54
43 - name: Update npm for trusted publishing55 - name: Update npm for trusted publishing
44 run: |56 run: |
45 npm install -g npm@latest57 # npm 12.0.0 fails `publish --provenance` with "Cannot find module
58 # 'sigstore'" (npm/cli#9722); 11.x supports trusted publishing and works.
59 npm install -g npm@11
46 npm --version60 npm --version
47 61
48 - name: Install dependencies62 - name: Install dependencies
.oxfmtrc.json+1−0
@@ -13,6 +13,7 @@ · 12 unmodified lines
13 "*.tsbuildinfo",13 "*.tsbuildinfo",
14 "executor-*.tgz",14 "executor-*.tgz",
15 "**/routeTree.gen.ts",15 "**/routeTree.gen.ts",
16 "**/smoke-harness-bundle.gen.ts",
16 "apps/cloud/src/services/executor-schema.ts"17 "apps/cloud/src/services/executor-schema.ts"
17 ]18 ]
18}19}
.oxlintrc.jsonc+26−0
@@ -64,6 +64,7 @@ · 63 unmodified lines
64 "apps/desktop/src/main.ts",64 "apps/desktop/src/main.ts",
65 "scripts/**/*.{ts,js}",65 "scripts/**/*.{ts,js}",
66 "apps/*/scripts/**/*.{ts,js}",66 "apps/*/scripts/**/*.{ts,js}",
67 "packages/*/*/scripts/**/*.{ts,js}",
67 "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}",68 "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}",
68 ],69 ],
69 "rules": {70 "rules": {
@@ -119,6 +120,31 @@ · 49 unmodified lines
119 "executor/no-try-catch-or-throw": "off",120 "executor/no-try-catch-or-throw": "off",
120 },121 },
121 },122 },
123 {
124 // boundary: the MCP-Apps shell is browser code, not Effect domain code.
125 // It runs inside a sandboxed iframe and talks to the host over
126 // postMessage and the MCP bridge — both of which are throw/Promise APIs
127 // with no Effect runtime present. `new Function` evaluation of generated
128 // JSX, CSS harvesting off `document.styleSheets`, and JSON on the wire
129 // all surface untyped JS errors that must be caught and rendered rather
130 // than modelled. Scoped to the iframe payload plus its build config; the
131 // package's Effect-facing loader (`src/shell-html.ts`) is deliberately
132 // NOT listed and uses inline suppressions instead.
133 "files": [
134 "packages/hosts/mcp-apps-shell/src/shell/**/*.{ts,tsx}",
135 "packages/hosts/mcp-apps-shell/vite.config.shell.ts",
136 ],
137 "rules": {
138 "executor/no-double-cast": "off",
139 "executor/no-error-constructor": "off",
140 "executor/no-instanceof-error": "off",
141 "executor/no-json-parse": "off",
142 "executor/no-promise-catch": "off",
143 "executor/no-promise-reject": "off",
144 "executor/no-try-catch-or-throw": "off",
145 "executor/no-unknown-error-message": "off",
146 },
147 },
122 {148 {
123 "files": ["packages/kernel/core/src/code-recovery.ts"],149 "files": ["packages/kernel/core/src/code-recovery.ts"],
124 "rules": {150 "rules": {
AGENTS.md+43−68
@@ -1,83 +1,58 @@
1# AGENTS.md1# Executor policy
2 2
3Principles: the contracts that stay true while implementations churn. For how to3Stable contracts live here. Current setup and server mechanics are in
4run, boot, share, or navigate things today, see [RUNNING.md](RUNNING.md); for4[RUNNING.md](RUNNING.md); e2e invariants are in
5e2e scenarios, [e2e/AGENTS.md](e2e/AGENTS.md). Run `bun run bootstrap` first in5[e2e/AGENTS.md](e2e/AGENTS.md). Run `bun run bootstrap` in every fresh rift.
6any fresh checkout or worktree.
7 6
8## Task Completion7## Verification and evidence
9 8
10- Tests use Effect Vitest. Run scoped tests with `vitest run ...`. `bun run test`9- Tests use Effect Vitest. Run scoped tests with `vitest run ...` or the
11 is fine (it delegates to Vitest); NEVER run `bun test`.10 package script. Never use `bun test`.
12- Run the narrowest useful verification for a change. For broad or merge-ready11- Use the narrowest meaningful verification while iterating. Merge-ready gates
13 work the full gates are `bun run format:check`, `lint`, `typecheck`, `test`.12 are `bun run format:check`, `lint`, `typecheck`, and `test`.
14- Run `bun run format` before opening a PR, staging only files your branch13- e2e is slow. Avoid running it often during development: optimize for
15 touched (leave unrelated files `format` rewrites out of the PR).14 iteration speed first, then test. Save e2e for when the change is settled,
15 and run the narrowest scenario rather than the full suite.
16- Run `bun run format` before a PR and include only files owned by the branch.
17- User-visible work requires a specific e2e run, a browsable dev instance, and
18 the recording or trace showing what to inspect. Add a scenario when none
19 covers the changed behavior.
16 20
17## Handing Back Work: Evidence, Not Assertions21## Service emulators
18 22
19"Done" is something the user can open, not a claim. When work changes what a user23Tests and demos that need an upstream API, OAuth/OIDC provider, or webhook use
20sees, hand back: an e2e run that proves it (link the specific run with one line24the published `@executor-js/emulate` emulators rather than stubs. They provide
21on what to look at, not a wall of green), the dev server left running so they can25wire-level state, real-shaped credentials, OpenAPI descriptions, and request
22poke at it, and the paths worth trying by hand including ones no scenario covers26ledgers. Create per-run hosted instances through the service's
23yet. If no scenario covers the change, write one, and embed its recording in the27`/_emulate/instances` control route.
24PR. The machinery runs in reverse too: seed an environment into a state
25(reproduce a bug live, stage data) and hand across the link.
26 28
27## Service Emulators29Emulate is a separate project. Make emulator changes there and consume the
30published package here; never re-vendor or create a parallel fake inside
31executor.
28 32
29For any test or demo needing an upstream API, OAuth/OIDC provider, or webhook33## Engineering boundaries
30source, use the `@executor-js/emulate` emulators instead of writing a stub:
31wire-level and stateful, real SDKs run unmodified, each serves a full OpenAPI
32spec, mints real-shaped credentials, and records every call in a request ledger
33to assert against. Hosted at `https://<service>.emulators.dev`. See the `emulate`
34skill (`.claude/skills/emulate/SKILL.md`). They are a standalone project
35(`github.com/UsefulSoftwareCo/emulate`) consumed here as the published package:
36full autonomy to change, publish, and deploy them on their `main`; don't re-vendor.
37
38## Attribution
39
40No AI/Claude/Anthropic/Co-Authored-By attribution in commits, messages, PRs, or
41generated files. PR titles and descriptions go to a public repo: no internal info
42or specific names unless explicitly stated.
43
44## Collaboration Notes
45
46- The user uses speech-to-text; infer likely intent from odd wording, ask only
47 when needed.
48- Code is cheap to write: no time estimates, implementation time isn't a blocker.
49- Never use em-dashes anywhere. Use commas, colons, parentheses, or separate
50 sentences.
51
52## Reference Repos
53
54Repos in `.reference` (Effect, effect-atom, …) are available for patterns. Clone
55a given Git URL into `.reference` and pull latest before using it.
56
57## Engineering Priorities
58 34
59- Prefer correctness and predictable behavior over short-term convenience.35- Prefer correctness and predictable behavior over short-term convenience.
60- Preserve runtime behavior when changing lint, typing, or test structure.36- Preserve runtime behavior during lint, typing, and test-structure changes.
61- Keep package boundaries clear; use public package exports, not cross-package37- Use public package exports; never cross package boundaries with relative
62 relative imports.38 imports.
63- Extract shared logic only when the shared behavior is real; avoid broad generic39- Extract shared logic only for genuinely shared behavior. Avoid generic
64 abstractions for one-off duplication.40 abstractions for one-off duplication.
41- Public PRs, commits, generated files, and documentation contain no private
42 names, internal context, customer-derived data, or AI attribution.
65 43
66## Package Roles44## Package ownership
67 45
68- `packages/core/sdk`: core contracts, plugin wiring, scopes, sources, secrets,46- `packages/core/sdk`: contracts, plugin wiring, scopes, integrations, secrets,
69 policies, fixtures. `@executor-js/sdk/http-auth` carries the shared auth-method47 policies, and fixtures. Shared HTTP auth vocabulary lives in
70 vocabulary the HTTP protocol plugins compose (core never imports it, keeping it48 `@executor-js/sdk/http-auth`; core remains carrier-agnostic.
71 carrier-agnostic).
72- `packages/core/storage-*`: storage adapters and test support.49- `packages/core/storage-*`: storage adapters and test support.
73- `packages/plugins/*`: protocol and provider plugins; their runtime, React, API,50- `packages/plugins/*`: protocol/provider implementations and their runtime,
74 and testing helpers live with the owning plugin.51 React, API, and testing helpers.
75- `packages/react`: shared React UI and atom/client integration.52- `packages/react`: shared React UI and client/atom integration.
76- `packages/hosts/mcp`: MCP host surface.53- `packages/hosts/mcp`: MCP host surface.
77- `packages/kernel/*`: execution runtimes and code execution substrate.54- `packages/kernel/*`: execution runtimes and code-execution substrate.
78- `apps/{local,cloud,cli,desktop}`: product entry points composing the packages.55- `apps/{local,cloud,cli,desktop}`: product composition roots.
79
80## Other
81 56
82Note mistakes in MISTAKES.md, missing context or tools in DESIRES.md, and env57Record mistakes in `MISTAKES.md`, missing capabilities in `DESIRES.md`, and
83learnings in LEARNINGS.md.58environment discoveries in `LEARNINGS.md`.
README.md+8−8
@@ -8,7 +8,7 @@ and per-tool policies, then use that one catalog from any MCP-compatible agent. · 7 unmodified lines
8 8
9[Website](https://executor.sh) · [Documentation](https://executor.sh/docs) · [Discord](https://discord.gg/eF29HBHwM6)9[Website](https://executor.sh) · [Documentation](https://executor.sh/docs) · [Discord](https://discord.gg/eF29HBHwM6)
10 10
11[https://github.com/user-attachments/assets/11225f83-e848-42ba-99b2-a993bcc88dad](https://github.com/user-attachments/assets/11225f83-e848-42ba-99b2-a993bcc88dad)11![Executor demo](https://raw.githubusercontent.com/UsefulSoftwareCo/executor/main/assets/executor-demo.gif)
12 12
13## Why Executor13## Why Executor
14 14
@@ -21,9 +21,9 @@ set its policy once, and every agent shares it over MCP. Your integrations, · 6 unmodified lines
21auth, and policies live in one place instead of being scattered across each21auth, and policies live in one place instead of being scattered across each
22client.22client.
23 23
24- **Any source.** First-party support for MCP servers, OpenAPI, GraphQL, and24- **Any integration.** First-party support for MCP servers, OpenAPI, GraphQL, and
25 Google Discovery. If you can describe it with a JSON schema, it can be an25 Google Discovery. If you can describe it with a JSON schema, it can be an
26 integration. The plugin system is open to any source type.26 integration. The plugin system is open to any integration type.
27- **One catalog, every agent.** Anything MCP-compatible connects to the same27- **One catalog, every agent.** Anything MCP-compatible connects to the same
28 set of tools.28 set of tools.
29- **Governed by policy.** Each tool is allowed, gated behind approval, or29- **Governed by policy.** Each tool is allowed, gated behind approval, or
@@ -100,12 +100,12 @@ appear. · 70 unmodified lines
100 100
101## Add an integration101## Add an integration
102 102
103From the web UI, click **Add Source**, paste an OpenAPI, GraphQL, or MCP URL,103From the web UI, click **Add Integration**, paste an OpenAPI, GraphQL, or MCP URL,
104and Executor detects the type, indexes the tools, and handles auth. Or add one104and Executor detects the type, indexes the tools, and handles auth. Or add one
105from the CLI:105from the CLI:
106 106
107```bash107```bash
108executor call executor openapi addSource '{108executor call executor openapi addIntegration '{
109 "spec": "https://petstore3.swagger.io/api/v3/openapi.json",109 "spec": "https://petstore3.swagger.io/api/v3/openapi.json",
110 "namespace": "petstore",110 "namespace": "petstore",
111 "baseUrl": "https://petstore3.swagger.io/api/v3"111 "baseUrl": "https://petstore3.swagger.io/api/v3"
@@ -113,7 +113,7 @@ executor call executor openapi addSource '{ · 1 unmodified line
113```113```
114 114
115Use `baseUrl` when the OpenAPI document has relative `servers` entries (for115Use `baseUrl` when the OpenAPI document has relative `servers` entries (for
116example `"/api/v3"`). Confirm it is live with `executor tools sources`.116example `"/api/v3"`). Confirm it is live with `executor tools integrations`.
117 117
118## Using tools118## Using tools
119 119
@@ -169,7 +169,7 @@ executor call <path...> --help # browse namespaces/resources/methods · 49 unmodified lines
169executor call <path...> --help --match "<text>" --limit <n> # narrow huge namespaces169executor call <path...> --help --match "<text>" --limit <n> # narrow huge namespaces
170executor resume --execution-id <id> # resume paused execution170executor resume --execution-id <id> # resume paused execution
171executor tools search "<query>" # search tools by intent171executor tools search "<query>" # search tools by intent
172executor tools sources # list configured sources + tool counts172executor tools integrations # list configured integrations + tool counts
173executor tools describe <path> # show tool TypeScript/JSON schema173executor tools describe <path> # show tool TypeScript/JSON schema
174```174```
175 175
@@ -190,7 +190,7 @@ apps/ · 14 unmodified lines
190packages/190packages/
191 core/ contracts, plugin wiring, scopes, policies, SDK, API, CLI core191 core/ contracts, plugin wiring, scopes, policies, SDK, API, CLI core
192 kernel/ execution runtimes (QuickJS, Deno subprocess, dynamic worker)192 kernel/ execution runtimes (QuickJS, Deno subprocess, dynamic worker)
193 plugins/ source and provider plugins (openapi, graphql, mcp, google,193 plugins/ integration and provider plugins (openapi, graphql, mcp, google,
194 microsoft, 1password, keychain, encrypted/file secrets, ...)194 microsoft, 1password, keychain, encrypted/file secrets, ...)
195 hosts/ host adapters (MCP surface, Cloudflare)195 hosts/ host adapters (MCP surface, Cloudflare)
196 react/ shared React UI196 react/ shared React UI
TELEMETRY.md+101−0
@@ -0,0 +1,101 @@
1# Telemetry
2
3Executor's local products (the CLI, the desktop app) and self-hosted
4deployments send a small set of anonymous usage events. This document is the
5complete account of what is sent, what is deliberately not sent, why the
6feature exists, and how to turn it off.
7
8The hosted cloud product has its own browser-side analytics, disclosed
9separately in its terms; this document covers the software that runs on your
10machines.
11
12## Why
13
14Executor is used mostly outside our infrastructure. Without some signal from
15local and self-hosted installs, every product decision about them is a guess:
16we cannot tell whether a feature ships broken, whether anyone uses toolkits,
17whether executions fail at unusual rates after a release, or whether the
18product is growing anywhere except cloud.
19
20The events exist to answer exactly one kind of question: **how are product
21features being used?** They are metadata about the product, not about you.
22Anything that would answer "what is this user doing" — which APIs you call,
23what your tools are named, what your code does — is out of scope by design,
24not by omission.
25
26## What is sent
27
28Each event carries its named properties plus, on every event: a random
29per-install id, the product surface (`cli`, `desktop`, or `selfhost`), the
30release channel, and the app version.
31
32| Event | Properties | Meaning |
33| --------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
34| `execution_completed` | `ok`, `plane` (`mcp`/`api`), `toolkit` (boolean) | A code execution finished, split by whether an agent (MCP) or a human-facing API triggered it, and whether a toolkit-scoped endpoint served it. |
35| `integration_added` | `plugin_key` | An integration was added, by kind (`openapi`, `mcp`, `graphql`, ...). |
36| `integration_removed` | `plugin_key` | An integration was removed, by kind. |
37| `artifact_created` | `via` (`agent`/`ui`) | A generative-UI artifact was saved. |
38| `artifact_viewed` | `via` | An artifact was opened for its content. |
39| `artifact_updated` | `via` | An artifact was overwritten or renamed. |
40| `artifact_deleted` | `via` | An artifact was deleted. |
41
42That table is exhaustive. The typed catalog the code compiles against is
43[`packages/core/analytics/src/events.ts`](packages/core/analytics/src/events.ts);
44an event that is not in that file cannot be sent.
45
46## What is never sent
47
48- No code, tool arguments, tool results, or error messages.
49- No secrets, tokens, or credentials.
50- No names you typed: no integration slugs, connection names, toolkit slugs,
51 tool names, or artifact titles. The **kind** of integration (`openapi`,
52 `mcp`, ...) is a product question; **which** service you connected it to is
53 your business.
54- No identity: no emails, usernames, hostnames, IP-derived location, or org
55 names. Events are marked so the analytics backend builds no person profile.
56
57## The anonymous id
58
59A random UUID is minted the first time the daemon or server starts and stored
60as `analytics-id` in the data directory (`~/.executor` for local installs, the
61configured data dir for self-host). It exists so that ten events from one
62install count as one install, not ten. It is not derived from your machine,
63account, or network, and deleting the file resets it. When telemetry is
64disabled the file is never created.
65
66## Opting out
67
68Set either environment variable to `1`, `true`, or `yes`:
69
70- `DO_NOT_TRACK` — the [cross-tool convention](https://consoledonottrack.com),
71 which Executor also honors for its other outbound calls (crash reporting,
72 the integrations.sh catalog fetch).
73- `EXECUTOR_DISABLE_ANALYTICS` — telemetry only, if you want the catalog
74 fetch and crash reporting to keep working.
75
76Opting out is total: the analytics service becomes a no-op, nothing is
77buffered, nothing is sent, and no id file is written. The CLI's managed
78service (launchd/systemd) forwards both variables into the supervised
79daemon's environment, so an opted-out install stays opted out.
80
81## Delivery mechanics
82
83Events buffer in memory (bounded) and flush in batches on a fixed cadence,
84plus once at shutdown. Delivery is best-effort: a failed flush re-queues and
85retries later, failures are swallowed, and no user-facing operation ever
86waits on — or can be failed by — analytics. The ingest endpoint is PostHog
87(`us.i.posthog.com`); the project key in the source identifies the project
88and grants no read access.
89
90## Where the line is enforced
91
92Structurally, not by convention:
93
94- The event catalog is a closed, typed interface — adding a property means
95 editing [`events.ts`](packages/core/analytics/src/events.ts) in a reviewed
96 change, next to the property rules at the top of that file.
97- Attribution (`plane`, `via`) is bound where each serving surface is
98 composed, so events cannot carry request-controlled labels.
99- Observers hang off neutral seams (an engine wrapper, post-commit hooks); the
100 core SDK carries no analytics vocabulary and hosts that do not opt in — like
101 every test — compose with no analytics at all.
apps/cli/CHANGELOG.md+239−0
@@ -1,5 +1,244 @@
1# executor1# executor
2 2
3## 1.6.0
4
5### Patch Changes
6
7- [#1737](https://github.com/UsefulSoftwareCo/executor/pull/1737) [`9296f36`](https://github.com/UsefulSoftwareCo/executor/commit/9296f36a8adbfdeec700ce33c37987127857b2fd) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Scope the `skills` tool to Executor's own documentation. Its description, argument, index, and unknown-name error now state that it serves a fixed catalog of how-to docs for this server's tools, so an agent on a host without a skill tool of its own no longer reads it as a general reader for the harness's or the user's skills.
8
9- Updated dependencies [[`a2d1417`](https://github.com/UsefulSoftwareCo/executor/commit/a2d141758e478274813c8c24d354e1fd0f66af49)]:
10 - @executor-js/sdk@1.6.0
11 - @executor-js/local@1.6.0
12 - @executor-js/api@1.4.63
13 - @executor-js/runtime-quickjs@1.6.0
14
15## 1.5.42
16
17### Patch Changes
18
19- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]:
20 - @executor-js/sdk@1.5.42
21 - @executor-js/local@1.5.42
22 - @executor-js/api@1.4.62
23 - @executor-js/runtime-quickjs@1.5.42
24
25## 1.5.41
26
27### Patch Changes
28
29- [#1600](https://github.com/UsefulSoftwareCo/executor/pull/1600) [`1b5f931`](https://github.com/UsefulSoftwareCo/executor/commit/1b5f931d90b52fa9eca7b6f53359a117d757c7c1) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Add `integrations.remove` to the core tools so an agent can drop a catalog integration**
30
31 `integrations.list` advertises `canRemove` per integration, but nothing on the agent surface could act on it: removal existed only on the HTTP API and the web console, so an agent that could add an integration could never take one back out. Cleaning up a catalog meant clicking through the UI once per integration.
32
33 The core-tools plugin now contributes `integrations.remove`, taking the `slug` reported by `integrations.list` and cascading to every connection under the integration and the tools those produced. It is approval-gated, being strictly more destructive than `connections.remove`. The `removed` flag is honest rather than always-true: `false` means no catalog row matched, so an already-absent slug and a built-in namespace like `executor` are distinguishable from a real removal, and an integration pinned with `canRemove: false` is refused with `IntegrationRemovalNotAllowedError` instead of silently surviving.
34
35- [#1556](https://github.com/UsefulSoftwareCo/executor/pull/1556) [`f674fb8`](https://github.com/UsefulSoftwareCo/executor/commit/f674fb80eebd597f922edd5ec21b8035ab195a78) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: native MCP elicitation now reaches clients on the local HTTP endpoint instead of timing out**
36
37 The local daemon's Streamable HTTP transport ran with `enableJsonResponse: true`, which buffers a `tools/call` into a single JSON body and leaves no open stream for the server to write on. A server-to-client `elicitation/create` raised during that call was therefore never delivered, and approval-gated tools failed with a `-32001` request timeout even though the session had negotiated `elicitation_mode=native` and the client's `elicitation.form` capability. The transport now uses the spec-default SSE streaming, so the reverse request rides the originating tool call's stream — matching the Cloudflare host's behaviour.
38
39- [#1603](https://github.com/UsefulSoftwareCo/executor/pull/1603) [`624e85f`](https://github.com/UsefulSoftwareCo/executor/commit/624e85f033632a7624c2bddf0944112166b1f481) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: `oauth.clients.remove` reported success for clients it never removed**
40
41 The tool returned `{ removed: true }` unconditionally. `oauth.removeClient` is idempotent by design at the storage layer — `deleteMany` on a missing row is a no-op, which is the right behaviour for a delete — but the tool mapped that silence to success, so a typo'd slug, an already-deleted client, and the wrong owner were all indistinguishable from a real deletion.
42
43 This bites hardest because clients are keyed by BOTH owner and slug, so the same slug can exist separately under `org` and `user`. An agent sweeping a list of slugs under one hardcoded owner would delete only half of them and report every call as a success, leaving org-owned OAuth apps registered after everything they authorized was gone.
44
45 The tool now checks the caller-visible client set first and returns `removed: false` when nothing matched that `(owner, slug)` pair. The service-level `removeClient` is unchanged and stays idempotent.
46
47- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]:
48 - @executor-js/sdk@1.5.41
49 - @executor-js/local@1.5.41
50 - @executor-js/api@1.4.61
51 - @executor-js/runtime-quickjs@1.5.41
52
53## 1.5.40
54
55### Patch Changes
56
57- [#1528](https://github.com/UsefulSoftwareCo/executor/pull/1528) [`676af1a`](https://github.com/UsefulSoftwareCo/executor/commit/676af1a78301d83cdab52b0389b4c67ed07ae872) Thanks [@baggiiiie](https://github.com/baggiiiie)! - Prevent execute agents from attempting unsupported base64 decoding by directing file payloads through ToolFile emission and bodyBase64 forwarding.
58
59- [#1545](https://github.com/UsefulSoftwareCo/executor/pull/1545) [`df62bb3`](https://github.com/UsefulSoftwareCo/executor/commit/df62bb3c8753edf2db32cb45961cf1723114ea2d) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: reconnecting an OAuth connection now refreshes its health status in place — no page reload needed**
60
61 Completing a reconnect previously left the stale "Expired" verdict on the connection row (and the integrations-list summary) until a hard refresh. Re-minting now clears the persisted verdict, and the UI re-probes as soon as the refreshed connection arrives.
62
63- [#1534](https://github.com/UsefulSoftwareCo/executor/pull/1534) [`80e5530`](https://github.com/UsefulSoftwareCo/executor/commit/80e553026278b1ecd7807f1ba99ba13b19d2c336) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Report the real product surface and version in the integrations.sh registry user-agent. The daemon previously sent `local` with a version frozen at 1.4.4; it now reports `cli` or `desktop` (matching analytics surfaces) and `@executor-js/local` is versioned with the release train.
64
65- Updated dependencies [[`8ba64f6`](https://github.com/UsefulSoftwareCo/executor/commit/8ba64f675f6d6ab5302d4f68390c0b055d006f4a), [`80e5530`](https://github.com/UsefulSoftwareCo/executor/commit/80e553026278b1ecd7807f1ba99ba13b19d2c336)]:
66 - @executor-js/sdk@1.5.40
67 - @executor-js/local@1.5.40
68 - @executor-js/api@1.4.60
69 - @executor-js/runtime-quickjs@1.5.40
70
71## 1.5.39
72
73### Patch Changes
74
75- Updated dependencies [[`6c316c7`](https://github.com/UsefulSoftwareCo/executor/commit/6c316c77a9efc98784976236852b58c6156e016e)]:
76 - @executor-js/sdk@1.5.39
77 - @executor-js/local@1.4.4
78 - @executor-js/api@1.4.59
79 - @executor-js/runtime-quickjs@1.5.39
80
81## 1.5.38
82
83### Patch Changes
84
85- [#1417](https://github.com/UsefulSoftwareCo/executor/pull/1417) [`046d67d`](https://github.com/UsefulSoftwareCo/executor/commit/046d67d75c3a8bc4cf0ab9dc4e723bc26ff130a3) Thanks [@morluto](https://github.com/morluto)! - Show policy and OAuth app removal failures in the UI, and keep success-only state unchanged when those writes fail.
86
87- [#1511](https://github.com/UsefulSoftwareCo/executor/pull/1511) [`7eb795d`](https://github.com/UsefulSoftwareCo/executor/commit/7eb795dda19c6177ad3bd590005eca1e326f760c) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: `execute` scripts that both `emit()` output and `return` a value no longer lose the returned value in MCP clients that ignore `structuredContent` — the return value is now appended to the tool-result content after the emitted items**
88
89- [#1418](https://github.com/UsefulSoftwareCo/executor/pull/1418) [`d3610e3`](https://github.com/UsefulSoftwareCo/executor/commit/d3610e386324891ccfde111e1ff519ec9218d30f) Thanks [@morluto](https://github.com/morluto)! - Return an execution error when a Deno subprocess closes stdin instead of emitting an unhandled write failure.
90
91- [#1507](https://github.com/UsefulSoftwareCo/executor/pull/1507) [`541549a`](https://github.com/UsefulSoftwareCo/executor/commit/541549a5dd8806f45b1a01ea6f4fa18ac41f53b1) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: OAuth refresh rejections with non-spec error bodies (e.g. Datadog) now surface as expired connections with a reconnect path, and definitively dead refresh tokens are no longer retried against the authorization server**
92
93- [#1517](https://github.com/UsefulSoftwareCo/executor/pull/1517) [`59a6640`](https://github.com/UsefulSoftwareCo/executor/commit/59a6640e575c740d22e7804cdb67cd76efe8332b) Thanks [@kitze](https://github.com/kitze)! - **Fix: OpenAPI query parameters that use form-style exploded objects now serialize each object field as a query parameter.**
94
95- [#1416](https://github.com/UsefulSoftwareCo/executor/pull/1416) [`f1b617c`](https://github.com/UsefulSoftwareCo/executor/commit/f1b617ce82475d4fe35be7a98c6bf9f468dbbd60) Thanks [@morluto](https://github.com/morluto)! - Prevent provider service migration row loss caused by generated ID conflicts.
96
97- [#1420](https://github.com/UsefulSoftwareCo/executor/pull/1420) [`8c71744`](https://github.com/UsefulSoftwareCo/executor/commit/8c7174452fe05f32815950ed06f38516883a7c8f) Thanks [@morluto](https://github.com/morluto)! - Abort GraphQL tool calls that exceed the configured invocation timeout instead of waiting indefinitely for an upstream response.
98
99- Updated dependencies [[`6a924dd`](https://github.com/UsefulSoftwareCo/executor/commit/6a924dd98de916d6ff8cea2329bf672f149b64f4)]:
100 - @executor-js/sdk@1.5.38
101 - @executor-js/local@1.4.4
102 - @executor-js/api@1.4.58
103 - @executor-js/runtime-quickjs@1.5.38
104
105## 1.5.37
106
107### Patch Changes
108
109- [#1506](https://github.com/UsefulSoftwareCo/executor/pull/1506) [`c05a1cf`](https://github.com/UsefulSoftwareCo/executor/commit/c05a1cfa629b7f28a7d870c584998cb9cbbaf303) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Artifacts are now on by default for MCP connections.** A plain endpoint URL serves the full artifact surface — the artifact tools, the app shell resource, and the artifact skills. Connections that don't want it opt out with `?artifacts=false` (or `--no-artifacts` on the stdio CLI); `?artifacts=true` remains accepted as the explicit default. Previously the surface required a `?artifacts=true` opt-in.
110
111- [#1498](https://github.com/UsefulSoftwareCo/executor/pull/1498) [`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Add anonymous product analytics to the local daemon (CLI + desktop) and self-host: execution counts split by MCP/API plane, toolkit usage, integration add/remove, and artifact usage (created/viewed/updated/deleted, attributed to agent tools vs the console UI), filed under a persisted per-install anonymous id. Opt out with DO_NOT_TRACK or EXECUTOR_DISABLE_ANALYTICS.
112
113- [#1500](https://github.com/UsefulSoftwareCo/executor/pull/1500) [`5eb2ca3`](https://github.com/UsefulSoftwareCo/executor/commit/5eb2ca36f93d2dae6eb8b3506c5de04ca141bb20) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: the artifact migration no longer narrows `definition.name` to varchar(255), which failed on existing long definition names**
114
115- [#1472](https://github.com/UsefulSoftwareCo/executor/pull/1472) [`1178e3b`](https://github.com/UsefulSoftwareCo/executor/commit/1178e3b31cd62cd2c05d6504d1586c2d8f018692) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Add an Artifacts tab. Interactive components an agent generates with `render-ui` are saved and listed in the console, and each one has its own page that renders it live — the page an MCP client without MCP Apps support deep-links to. Artifacts can be renamed and deleted from the console, and agents find them again by title.
116
117- [#1505](https://github.com/UsefulSoftwareCo/executor/pull/1505) [`9bd4a5b`](https://github.com/UsefulSoftwareCo/executor/commit/9bd4a5b6063bd98f5ae8070baf0dd3ee3e110d68) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Artifact tool results now include the web deep link beside the inline widget payload, not just on the no-apps fallback. Clients can lose a rendered widget in ways the server never sees — a reopened transcript that skips the resource re-read shows raw JSON — and the URL in the result is the model's way to point the user back at the artifact.
118
119- [#1483](https://github.com/UsefulSoftwareCo/executor/pull/1483) [`54df2e3`](https://github.com/UsefulSoftwareCo/executor/commit/54df2e3e99509008759269b27484ee6581ce8827) Thanks [@davidwrossiter](https://github.com/davidwrossiter)! - **Fix: GraphQL connections now reject credentials when schema introspection fails and show actionable tool sync diagnostics**
120
121- [#1499](https://github.com/UsefulSoftwareCo/executor/pull/1499) [`010ea98`](https://github.com/UsefulSoftwareCo/executor/commit/010ea98e520643731b07e68e21119f12b8ef1505) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: the add-connection wizard no longer wipes a pasted credential when the key check saves a health check mid-flow**
122
123- [#1503](https://github.com/UsefulSoftwareCo/executor/pull/1503) [`a7c4689`](https://github.com/UsefulSoftwareCo/executor/commit/a7c468944837cfe097f03f69d612bde31903f284) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: MCP responses larger than Durable Object storage's 128 KiB per-value cap hung the client instead of being delivered**
124
125 The DO transport persisted every outbound message for reconnect replay before writing the live SSE frame, and `storage.put` of an oversize value throws — so a large response (the `ui://executor/shell.html` resource is ~5 MB) was neither stored nor sent, and the client waited on keepalives forever. The transport now delivers the live frame first and treats persistence as best-effort: an oversize message skips the event store with a logged warning and arrives without a replay id, which only costs replayability if the connection drops mid-delivery.
126
127- [#1502](https://github.com/UsefulSoftwareCo/executor/pull/1502) [`25270b1`](https://github.com/UsefulSoftwareCo/executor/commit/25270b1f1f091778ba6584e41b041092fc1bdd00) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: MCP clients of the cloud host got a "Shell not built" placeholder as the `ui://executor/shell.html` resource, so every artifact rendered as a widget that never finished loading**
128
129 The deployed Worker has no filesystem, and the shell loader silently fell back to an inert placeholder document when its `fs.readFile` failed. Workers hosts now fetch the built shell through the static-assets binding (the app build emits a stable-named copy alongside the hashed one), the self-host image reads the same emitted asset from its SPA dist, and a host that cannot produce the shell now fails the resource read with an actionable error instead of serving a document that hangs the client. App builds fail if the shell asset was not emitted.
130
131- Updated dependencies [[`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb)]:
132 - @executor-js/sdk@1.5.37
133 - @executor-js/api@1.4.57
134 - @executor-js/local@1.4.4
135 - @executor-js/runtime-quickjs@1.5.37
136
137## 1.5.36
138
139### Patch Changes
140
141- [#1478](https://github.com/UsefulSoftwareCo/executor/pull/1478) [`8ecbfd6`](https://github.com/UsefulSoftwareCo/executor/commit/8ecbfd65f2c1393c75661c792723961877866cc5) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Store minted OAuth tokens in the durable file secret store (`auth.json` under `EXECUTOR_DATA_DIR`) instead of the system keychain. On sandbox/headless hosts the keychain can be an in-memory keyring that a stop/recreate wipes, leaving OAuth connections expired with "Stored refresh token could not be resolved." Existing keychain-backed connections migrate with one clean reconnect.
142
143- [#1462](https://github.com/UsefulSoftwareCo/executor/pull/1462) [`5a70675`](https://github.com/UsefulSoftwareCo/executor/commit/5a706756c66e53c9a929e9a8c30e57166b8d121b) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: org OAuth connections on self-host worked only for whoever ran the consent**
144
145 The encrypted-secrets credential provider (the writable provider on the self-hosted and Cloudflare hosts) filed token rows under the _acting user's_ private partition instead of the credential's own owner. An org-owned OAuth connection whose consent completed in one member's browser session therefore resolved only for that member — every other principal failed with `oauth_connection_missing`, while the UI showed the connection healthy. The provider now partitions by the owner embedded in the item id (`oauth:org:…` → org-shared), matching the WorkOS Vault provider, and a boot-time data migration re-files rows already written wrong. The encrypted value itself was never affected.
146
147- [#1459](https://github.com/UsefulSoftwareCo/executor/pull/1459) [`fc1e589`](https://github.com/UsefulSoftwareCo/executor/commit/fc1e589613a14750c2ca8c34838a71c758544c8d) Thanks [@wan0net](https://github.com/wan0net)! - Preserve `elicitation_mode=native` when creating self-hosted MCP sessions.
148
149- [#1475](https://github.com/UsefulSoftwareCo/executor/pull/1475) [`77b0821`](https://github.com/UsefulSoftwareCo/executor/commit/77b0821ff9bddd6fb419d81a18b9e1af804fdb55) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Refresh OAuth tokens when the upstream rejects them with HTTP 401, not only when the stored expiry says they are due. Connections whose authorization server omits `expires_in` can now recover without a manual reconnect, and the refresh path is traced.
150
151- [#1476](https://github.com/UsefulSoftwareCo/executor/pull/1476) [`167d899`](https://github.com/UsefulSoftwareCo/executor/commit/167d899162794064eeac0a755697c2c943f1b9ac) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Remove the custom apps plugin. Git and local-directory app sources are no
152 longer supported. The packed binary still ships the workerd and worker-bundler
153 sidecars.
154- Updated dependencies []:
155 - @executor-js/sdk@1.5.36
156 - @executor-js/runtime-quickjs@1.5.36
157 - @executor-js/local@1.4.4
158 - @executor-js/api@1.4.56
159
160## 1.5.35
161
162### Patch Changes
163
164- Updated dependencies [[`1b9b1f1`](https://github.com/UsefulSoftwareCo/executor/commit/1b9b1f10313834a625a411169ebf83f6181589df), [`99c808f`](https://github.com/UsefulSoftwareCo/executor/commit/99c808f09d3cf2263945efa4f6592cc4e78c9e08)]:
165 - @executor-js/sdk@1.5.35
166 - @executor-js/runtime-quickjs@1.5.35
167 - @executor-js/local@1.4.4
168 - @executor-js/api@1.4.55
169
170## 1.5.34
171
172### Patch Changes
173
174- Updated dependencies [[`e2712db`](https://github.com/UsefulSoftwareCo/executor/commit/e2712dbff98145c5c340832ffbdcb21113b9dd78), [`7207347`](https://github.com/UsefulSoftwareCo/executor/commit/720734756a70b1b4f1564bdf82dc4118e5de2b76), [`0c4e9b4`](https://github.com/UsefulSoftwareCo/executor/commit/0c4e9b49fecb35ad71c92a464c3ea01131ff9d6f)]:
175 - @executor-js/sdk@1.5.34
176 - @executor-js/local@1.4.4
177 - @executor-js/api@1.4.54
178 - @executor-js/runtime-quickjs@1.5.34
179
180## 1.5.33
181
182### Patch Changes
183
184- [#1404](https://github.com/UsefulSoftwareCo/executor/pull/1404) [`5e0dd15`](https://github.com/UsefulSoftwareCo/executor/commit/5e0dd15291daaedf10f6eb8e03c5afdca8787764) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - The provider service split boot migration now skips an org whose Google or Microsoft integration cannot be migrated (for example a config without a stored specHash) instead of failing the whole migration and blocking server startup. A daemon that does fail during boot now exits with the underlying error message instead of hanging with a generic "Unknown error".
185
186- Updated dependencies []:
187 - @executor-js/local@1.4.4
188 - @executor-js/sdk@1.5.33
189 - @executor-js/runtime-quickjs@1.5.33
190 - @executor-js/api@1.4.53
191
192## 1.5.32
193
194### Patch Changes
195
196- [#1395](https://github.com/UsefulSoftwareCo/executor/pull/1395) [`d90d8be`](https://github.com/UsefulSoftwareCo/executor/commit/d90d8be38fa26d3a6b8a5ac648af815191f537bb) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Policy create now defaults a new rule's position below any more-specific existing rule on the server, so a broad rule written without an explicit position (stale UI, API, agent tool) cannot shadow an existing narrower rule.
197
198- [#1394](https://github.com/UsefulSoftwareCo/executor/pull/1394) [`1ca5111`](https://github.com/UsefulSoftwareCo/executor/commit/1ca511101fad057d129db9941b5c9bce963baf0a) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Ship the platform workerd binary in the self-host Docker runtime; without it custom app tools failed to sync or invoke with "workerd is unavailable on this platform".
199
200- Updated dependencies []:
201 - @executor-js/sdk@1.5.32
202 - @executor-js/runtime-quickjs@1.5.32
203 - @executor-js/local@1.4.4
204 - @executor-js/api@1.4.52
205
206## 1.5.31
207
208### Patch Changes
209
210- [#1390](https://github.com/UsefulSoftwareCo/executor/pull/1390) [`d95e63c`](https://github.com/UsefulSoftwareCo/executor/commit/d95e63cc33ade4ce0996678cfa51a5e4c784a9ea) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Ship @cloudflare/worker-bundler in the self-host Docker runtime so the server starts; it was resolved at runtime since the dynamic Worker bundler change but never copied into the image.
211
212- Updated dependencies []:
213 - @executor-js/local@1.4.4
214 - @executor-js/sdk@1.5.31
215 - @executor-js/runtime-quickjs@1.5.31
216 - @executor-js/api@1.4.51
217
218## 1.5.30
219
220### Patch Changes
221
222- [#1371](https://github.com/UsefulSoftwareCo/executor/pull/1371) [`262fc3e`](https://github.com/UsefulSoftwareCo/executor/commit/262fc3edcad31a53bd8aecacf8fe784b276fb745) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Explain 401s from a hosted server as a sign-in problem with the exact `executor login` command to run, instead of surfacing a raw decode error. `executor login` now defaults to https://executor.sh when no server is specified, and profile plumbing stays out of messages unless you address servers by name.
223
224- [#1375](https://github.com/UsefulSoftwareCo/executor/pull/1375) [`0f81165`](https://github.com/UsefulSoftwareCo/executor/commit/0f81165138c1c987f13ff9839e46539f80b229b0) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Run cloud custom tool bundling in a dynamically loaded Worker so dependency installation and bundling do not share the serving request isolate.
225
226- [#1349](https://github.com/UsefulSoftwareCo/executor/pull/1349) [`a7e3091`](https://github.com/UsefulSoftwareCo/executor/commit/a7e3091a94fbdf032ef134989ceaba4f0b1b3231) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Back-stop code execution with a host-side timeout so a wedged sandbox delivers a descriptive error instead of hanging silently.
227
228- [#1351](https://github.com/UsefulSoftwareCo/executor/pull/1351) [`93b000a`](https://github.com/UsefulSoftwareCo/executor/commit/93b000a4ba24317f5a1f08fd8d7f72457d115f06) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Let stock MCP clients auto-reconnect and recover a tool result when a POST stream drops mid-call.
229
230- [#1345](https://github.com/UsefulSoftwareCo/executor/pull/1345) [`c46730b`](https://github.com/UsefulSoftwareCo/executor/commit/c46730b5d48cc62dae1abdbe32136f3c229d79f6) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Preserve MCP tool results across dropped streamable HTTP SSE connections.
231
232- [#1357](https://github.com/UsefulSoftwareCo/executor/pull/1357) [`4c319ee`](https://github.com/UsefulSoftwareCo/executor/commit/4c319ee2e4a8d062402a80419dd3e1d829908ad8) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Throw a guidance error when sandbox code enumerates the `tools` proxy (`Object.keys`, spread, `for...in`) instead of returning an empty list, pointing agents at `tools.search()`.
233
234- [#1352](https://github.com/UsefulSoftwareCo/executor/pull/1352) [`8bdf315`](https://github.com/UsefulSoftwareCo/executor/commit/8bdf31550f702e6bce6c3460bb9d26fcce925d7b) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Treat a transient WorkOS outage during the MCP live-membership check as a retryable 503 instead of a Forbidden that destroys the session.
235
236- Updated dependencies []:
237 - @executor-js/sdk@1.5.30
238 - @executor-js/runtime-quickjs@1.5.30
239 - @executor-js/local@1.4.4
240 - @executor-js/api@1.4.50
241
3## 1.5.29242## 1.5.29
4 243
5### Patch Changes244### Patch Changes
apps/cli/package.json+4−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "executor",2 "name": "executor",
3 "version": "1.5.29",3 "version": "1.6.0",
4 "private": true,4 "private": true,
5 "bin": {5 "bin": {
6 "executor": "./bin/executor.ts"6 "executor": "./bin/executor.ts"
@@ -37,8 +37,11 @@ · 30 unmodified lines
37 "quickjs-emscripten": "catalog:"37 "quickjs-emscripten": "catalog:"
38 },38 },
39 "devDependencies": {39 "devDependencies": {
40 "@cloudflare/worker-bundler": "0.2.1",
40 "@effect/vitest": "catalog:",41 "@effect/vitest": "catalog:",
42 "@types/node": "catalog:",
41 "bun-types": "catalog:",43 "bun-types": "catalog:",
44 "esbuild": "^0.27.3",
42 "typescript": "catalog:",45 "typescript": "catalog:",
43 "vitest": "catalog:"46 "vitest": "catalog:"
44 }47 }
apps/cli/src/build.ts+163−1
@@ -2,15 +2,17 @@ import { cp, mkdir, rm, writeFile, chmod } from "node:fs/promises"; · 1 unmodified line
2import { existsSync } from "node:fs";2import { existsSync } from "node:fs";
3import { createRequire } from "node:module";3import { createRequire } from "node:module";
4import { resolve, join, dirname } from "node:path";4import { resolve, join, dirname } from "node:path";
5import { fileURLToPath } from "node:url";5import { fileURLToPath, pathToFileURL } from "node:url";
6import { parseArgs } from "node:util";6import { parseArgs } from "node:util";
7import { $ } from "bun";7import { $ } from "bun";
8import { WORKER_BUNDLER_DIRNAME, missingWorkerBundlerFiles } from "./worker-bundler-artifact";
8 9
9const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");10const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
10const cliRoot = resolve(repoRoot, "apps/cli");11const cliRoot = resolve(repoRoot, "apps/cli");
11const webRoot = resolve(repoRoot, "apps/local");12const webRoot = resolve(repoRoot, "apps/local");
12const distDir = resolve(cliRoot, "dist");13const distDir = resolve(cliRoot, "dist");
13const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm";14const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm";
15const WORKERD_VERSION = "1.20260708.1";
14 16
15const resolveQuickJsWasmPath = (): string => {17const resolveQuickJsWasmPath = (): string => {
16 const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json"));18 const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json"));
@@ -23,6 +25,32 @@ const resolveQuickJsWasmPath = (): string => { · 6 unmodified lines
23 return wasmPath;25 return wasmPath;
24};26};
25 27
28// ---------------------------------------------------------------------------
29// MCP-Apps shell (mcp-app.html)
30//
31// The MCP host serves the shell as the `ui://executor/shell.html` resource, and
32// `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime
33// `fs.readFile` is invisible to `bun build --compile`, so without this the
34// packaged binary would fail every artifact resource read. Build it if missing
35// and copy it next to the executable, where the loader finds it via
36// `process.execPath` — the same colocation trick used for `libsql.node` /
37// `emscripten-module.wasm`.
38// ---------------------------------------------------------------------------
39
40const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell");
41const mcpAppsShellHtml = join(mcpAppsShellDir, "dist/mcp-app.html");
42
43const ensureMcpAppsShell = async (): Promise<string> => {
44 if (!existsSync(mcpAppsShellHtml)) {
45 console.log("Building MCP-Apps shell (mcp-app.html)...");
46 await $`bun run --cwd ${mcpAppsShellDir} build:shell`.quiet();
47 }
48 if (!existsSync(mcpAppsShellHtml)) {
49 throw new Error(`MCP-Apps shell missing at ${mcpAppsShellHtml} after build:shell`);
50 }
51 return mcpAppsShellHtml;
52};
53
26const resolveOnePasswordCoreWasmPath = (): string => {54const resolveOnePasswordCoreWasmPath = (): string => {
27 const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json"));55 const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json"));
28 const sdkPkg = req.resolve("@1password/sdk/package.json");56 const sdkPkg = req.resolve("@1password/sdk/package.json");
@@ -33,6 +61,82 @@ const resolveOnePasswordCoreWasmPath = (): string => { · 4 unmodified lines
33 return wasmPath;61 return wasmPath;
34};62};
35 63
64const resolveWorkerBundlerDistPath = (): string => {
65 const req = createRequire(join(repoRoot, "apps/cli/package.json"));
66 const pkgJson = req.resolve("@cloudflare/worker-bundler/package.json");
67 const distPath = join(dirname(pkgJson), "dist");
68 if (!existsSync(join(distPath, "index.js")) || !existsSync(join(distPath, "esbuild.wasm"))) {
69 throw new Error(`@cloudflare/worker-bundler dist files not found at ${distPath}`);
70 }
71 return distPath;
72};
73
74const createPackedWorkerBundlerSource = async (distPath: string): Promise<string> => {
75 const esbuildPath = join(repoRoot, "node_modules/.bun/node_modules/esbuild/lib/main.js");
76 if (!existsSync(esbuildPath)) throw new Error(`esbuild not found at ${esbuildPath}`);
77 const { build } = await import(pathToFileURL(esbuildPath).href);
78 const result = await build({
79 entryPoints: [join(distPath, "index.js")],
80 bundle: true,
81 format: "esm",
82 platform: "browser",
83 external: ["./esbuild.wasm"],
84 logLevel: "silent",
85 write: false,
86 });
87 const source = result.outputFiles[0]?.text;
88 if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler");
89 return source;
90};
91
92/**
93 * Verify the staged worker-bundler beside a freshly compiled binary.
94 *
95 * The compiled binary cannot resolve `@cloudflare/worker-bundler` by name —
96 * bunfs has no node_modules, so a bare specifier throws ResolveMessage at
97 * runtime. The colocated copy IS the delivery mechanism, and until now nothing
98 * checked it after the copy: a partial staging shipped a binary that only fails
99 * on the user's machine, at startup. Assert it here, where a failure costs a
100 * red build instead of a crash loop.
101 *
102 * Checks match the depth of the asset assertions elsewhere in the repo:
103 * presence of every file the runtime contract names, a size floor on the packed
104 * entry, and the `\0asm` magic on the wasm so a truncated or LFS-pointer copy
105 * cannot pass as real.
106 */
107const assertPackedWorkerBundler = async (binDir: string, targetName: string): Promise<void> => {
108 const stagedDir = join(binDir, WORKER_BUNDLER_DIRNAME);
109
110 const missing = missingWorkerBundlerFiles(stagedDir, existsSync);
111 if (missing.length > 0) {
112 throw new Error(
113 `Packed @cloudflare/worker-bundler is incomplete for ${targetName}: missing ${missing.join(", ")} under ${stagedDir}. ` +
114 `The compiled binary resolves this package from disk, not from bunfs, so shipping it partial means a runtime failure on the user's machine.`,
115 );
116 }
117
118 const bundledEntry = join(stagedDir, "dist", "index.bundled.js");
119 const bundledSize = (await Bun.file(bundledEntry).arrayBuffer()).byteLength;
120 if (bundledSize < 100_000) {
121 throw new Error(
122 `Packed worker-bundler entry for ${targetName} is implausibly small (${bundledSize} bytes at ${bundledEntry}). Expected the esbuild-packed bundle.`,
123 );
124 }
125
126 const wasmPath = join(stagedDir, "dist", "esbuild.wasm");
127 const wasmHead = new Uint8Array((await Bun.file(wasmPath).arrayBuffer()).slice(0, 4));
128 if (
129 wasmHead[0] !== 0x00 ||
130 wasmHead[1] !== 0x61 ||
131 wasmHead[2] !== 0x73 ||
132 wasmHead[3] !== 0x6d
133 ) {
134 throw new Error(
135 `Staged esbuild.wasm for ${targetName} is not a WebAssembly module (bad magic at ${wasmPath}).`,
136 );
137 }
138};
139
36// ---------------------------------------------------------------------------140// ---------------------------------------------------------------------------
37// Metadata141// Metadata
38// ---------------------------------------------------------------------------142// ---------------------------------------------------------------------------
@@ -119,6 +223,7 @@ const bunTarget = (t: Target): Bun.Build.CompileTarget => { · 80 unmodified lines
119};223};
120 224
121const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor");225const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor");
226const workerdBinaryName = (t: Target) => (t.os === "win32" ? "workerd.exe" : "workerd");
122 227
123const isCurrentPlatform = (t: Target) =>228const isCurrentPlatform = (t: Target) =>
124 t.os === process.platform && t.arch === process.arch && !t.abi;229 t.os === process.platform && t.arch === process.arch && !t.abi;
@@ -211,6 +316,41 @@ const resolveLibsqlNative = (t: Target): string | null => { · 86 unmodified lines
211 }316 }
212};317};
213 318
319const resolveWorkerdBinary = (t: Target): string | null => {
320 const platformMap: Record<string, string> = {
321 "darwin-arm64": "@cloudflare/workerd-darwin-arm64",
322 "darwin-x64": "@cloudflare/workerd-darwin-64",
323 "linux-arm64": "@cloudflare/workerd-linux-arm64",
324 "linux-x64": "@cloudflare/workerd-linux-64",
325 "win32-x64": "@cloudflare/workerd-windows-64",
326 };
327 const key = [t.os, t.arch, t.abi].filter(Boolean).join("-");
328 const pkg = platformMap[key];
329 if (!pkg) return null;
330 const binary = workerdBinaryName(t);
331 try {
332 const req = createRequire(
333 join(repoRoot, "packages/kernel/runtime-workerd-subprocess/package.json"),
334 );
335 const pkgJson = req.resolve(`${pkg}/package.json`);
336 for (const candidate of [
337 join(dirname(pkgJson), "bin", binary),
338 join(dirname(pkgJson), binary),
339 ]) {
340 if (existsSync(candidate)) return candidate;
341 }
342 } catch {
343 const packageRoot = join(
344 repoRoot,
345 `node_modules/.bun/${pkg.replace("/", "+")}@${WORKERD_VERSION}/node_modules/${pkg}`,
346 );
347 for (const candidate of [join(packageRoot, "bin", binary), join(packageRoot, binary)]) {
348 if (existsSync(candidate)) return candidate;
349 }
350 }
351 return null;
352};
353
214// ---------------------------------------------------------------------------354// ---------------------------------------------------------------------------
215// Build mode355// Build mode
216// ---------------------------------------------------------------------------356// ---------------------------------------------------------------------------
@@ -332,7 +472,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 115 unmodified lines
332 await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`);472 await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`);
333 473
334 const quickJsWasmPath = resolveQuickJsWasmPath();474 const quickJsWasmPath = resolveQuickJsWasmPath();
475 const mcpAppsShellPath = await ensureMcpAppsShell();
335 const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath();476 const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath();
477 const workerBundlerDistPath = resolveWorkerBundlerDistPath();
478 const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath);
336 479
337 try {480 try {
338 for (const target of targets) {481 for (const target of targets) {
@@ -355,6 +498,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 16 unmodified lines
355 // Copy QuickJS WASM next to binary — loaded at runtime by the server498 // Copy QuickJS WASM next to binary — loaded at runtime by the server
356 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));499 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));
357 500
501 // Copy the MCP-Apps shell next to the binary. Platform-independent HTML,
502 // read from execDir at runtime by @executor-js/mcp-apps-shell.
503 await cp(mcpAppsShellPath, join(binDir, "mcp-app.html"));
504
358 // Copy 1Password's sdk-core WASM next to the binary. The patched505 // Copy 1Password's sdk-core WASM next to the binary. The patched
359 // sdk-core loader falls back to this sibling file when bun --compile506 // sdk-core loader falls back to this sibling file when bun --compile
360 // bakes a build-machine node_modules path into __dirname.507 // bakes a build-machine node_modules path into __dirname.
@@ -375,6 +522,21 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 14 unmodified lines
375 await cp(libsqlNative, join(binDir, "libsql.node"));522 await cp(libsqlNative, join(binDir, "libsql.node"));
376 }523 }
377 524
525 const workerdBinary = resolveWorkerdBinary(target);
526 if (workerdBinary && existsSync(workerdBinary)) {
527 await cp(workerdBinary, join(binDir, workerdBinaryName(target)));
528 if (target.os !== "win32") await chmod(join(binDir, workerdBinaryName(target)), 0o755);
529 }
530
531 await cp(workerBundlerDistPath, join(binDir, WORKER_BUNDLER_DIRNAME, "dist"), {
532 recursive: true,
533 });
534 await writeFile(
535 join(binDir, WORKER_BUNDLER_DIRNAME, "dist", "index.bundled.js"),
536 packedWorkerBundlerSource,
537 );
538 await assertPackedWorkerBundler(binDir, name);
539
378 // Smoke test on current platform540 // Smoke test on current platform
379 if (isCurrentPlatform(target)) {541 if (isCurrentPlatform(target)) {
380 const bin = join(binDir, binaryName(target));542 const bin = join(binDir, binaryName(target));
apps/cli/src/device-login.test.ts+118−2
@@ -1,6 +1,44 @@
1import { describe, expect, it } from "@effect/vitest";1import { afterEach, describe, expect, it } from "@effect/vitest";
2 2
3import { browserOpenCommand } from "./device-login";3import {
4 browserOpenCommand,
5 discoverCliLogin,
6 refreshDeviceTokens,
7 requestDeviceCode,
8 type CliLoginDiscovery,
9} from "./device-login";
10
11const originalFetch = globalThis.fetch;
12
13interface FetchCall {
14 readonly url: string;
15 readonly headers: Record<string, string>;
16}
17
18const responseJson = (body: Record<string, unknown>, status = 200): Response =>
19 new Response(JSON.stringify(body), {
20 status,
21 headers: { "content-type": "application/json" },
22 });
23
24const installFetch = (handler: (url: string, init: RequestInit | undefined) => Response) => {
25 globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
26 const url =
27 typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
28 return Promise.resolve(handler(url, init));
29 }) as typeof fetch;
30};
31
32const recordCall = (calls: Array<FetchCall>, url: string, init: RequestInit | undefined): void => {
33 calls.push({
34 url,
35 headers: Object.fromEntries(new Headers(init?.headers).entries()),
36 });
37};
38
39afterEach(() => {
40 globalThis.fetch = originalFetch;
41});
4 42
5describe("browserOpenCommand", () => {43describe("browserOpenCommand", () => {
6 it("opens Windows browser URLs without cmd.exe", () => {44 it("opens Windows browser URLs without cmd.exe", () => {
@@ -32,3 +70,81 @@ describe("browserOpenCommand", () => { · 25 unmodified lines
32 expect(browserOpenCommand("not a url", "win32")).toBeUndefined();70 expect(browserOpenCommand("not a url", "win32")).toBeUndefined();
33 });71 });
34});72});
73
74describe("device login headers", () => {
75 it("sends configured headers when discovering CLI login", async () => {
76 const calls: Array<FetchCall> = [];
77 installFetch((url, init) => {
78 recordCall(calls, url, init);
79 return responseJson({
80 provider: "better-auth",
81 deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code",
82 tokenEndpoint: "https://executor.example/api/auth/device/token",
83 clientId: "executor-cli",
84 requestFormat: "json",
85 });
86 });
87
88 const discovery = await discoverCliLogin("https://executor.example", {
89 headers: { "CF-Access-Client-Id": "client-id" },
90 });
91
92 expect(discovery.clientId).toBe("executor-cli");
93 expect(calls).toHaveLength(1);
94 expect(calls[0]?.url).toBe("https://executor.example/api/auth/cli-login");
95 expect(calls[0]?.headers).toMatchObject({
96 accept: "application/json",
97 "cf-access-client-id": "client-id",
98 });
99 });
100
101 it("sends configured headers only to same-origin device endpoints", async () => {
102 const calls: Array<FetchCall> = [];
103 installFetch((url, init) => {
104 recordCall(calls, url, init);
105 if (url.endsWith("/api/auth/device/code")) {
106 return responseJson({
107 device_code: "device-code",
108 user_code: "USER-CODE",
109 verification_uri: "https://executor.example/device",
110 expires_in: 300,
111 interval: 5,
112 });
113 }
114 return responseJson({
115 access_token: "access-token",
116 refresh_token: "refresh-token-2",
117 expires_in: 600,
118 });
119 });
120 const discovery: CliLoginDiscovery = {
121 provider: "better-auth",
122 deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code",
123 tokenEndpoint: "https://accounts.example/oauth/token",
124 clientId: "executor-cli",
125 requestFormat: "form",
126 };
127 const headers = { "CF-Access-Client-Id": "client-id" };
128
129 await requestDeviceCode(discovery, { serverOrigin: "https://executor.example", headers });
130 await refreshDeviceTokens({
131 tokenEndpoint: discovery.tokenEndpoint,
132 clientId: discovery.clientId,
133 refreshToken: "refresh-token",
134 serverOrigin: "https://executor.example",
135 headers,
136 });
137
138 expect(calls).toHaveLength(2);
139 expect(calls[0]?.headers).toMatchObject({
140 accept: "application/json",
141 "content-type": "application/x-www-form-urlencoded",
142 "cf-access-client-id": "client-id",
143 });
144 expect(calls[1]?.headers).toMatchObject({
145 accept: "application/json",
146 "content-type": "application/x-www-form-urlencoded",
147 });
148 expect(calls[1]?.headers["cf-access-client-id"]).toBeUndefined();
149 });
150});
apps/cli/src/device-login.ts+57−8
@@ -50,6 +50,15 @@ export interface DeviceTokens { · 49 unmodified lines
50 readonly organizationId?: string;50 readonly organizationId?: string;
51}51}
52 52
53export interface DeviceLoginHttpOptions {
54 readonly headers?: Readonly<Record<string, string>>;
55 readonly serverOrigin?: string;
56}
57
58export interface PollForDeviceTokensOptions extends DeviceLoginHttpOptions {
59 readonly now?: () => number;
60}
61
53const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";62const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
54const DEFAULT_INTERVAL_SECONDS = 5;63const DEFAULT_INTERVAL_SECONDS = 5;
55 64
@@ -126,17 +135,43 @@ const definedFields = (fields: Record<string, string | undefined>): Record<strin · 70 unmodified lines
126 string135 string
127 >;136 >;
128 137
138const isSameOrigin = (url: string, origin: string): boolean => {
139 try {
140 return new URL(url).origin === new URL(origin).origin;
141 } catch {
142 return false;
143 }
144};
145
146const headersForUrl = (
147 url: string,
148 baseHeaders: Record<string, string>,
149 options: DeviceLoginHttpOptions = {},
150): Record<string, string> => {
151 const configured =
152 options.headers && (!options.serverOrigin || isSameOrigin(url, options.serverOrigin))
153 ? options.headers
154 : {};
155 return { ...configured, ...baseHeaders };
156};
157
129const post = async (158const post = async (
130 url: string,159 url: string,
131 fields: Record<string, string | undefined>,160 fields: Record<string, string | undefined>,
132 format: "form" | "json",161 format: "form" | "json",
162 options: DeviceLoginHttpOptions = {},
133) =>163) =>
134 fetch(url, {164 fetch(url, {
135 method: "POST",165 method: "POST",
136 headers: {166 headers: headersForUrl(
137 "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded",167 url,
138 accept: "application/json",168 {
139 },169 "content-type":
170 format === "json" ? "application/json" : "application/x-www-form-urlencoded",
171 accept: "application/json",
172 },
173 options,
174 ),
140 body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields),175 body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields),
141 });176 });
142 177
@@ -150,10 +185,16 @@ const readJson = async (response: Response): Promise<Record<string, unknown>> => · 7 unmodified lines
150 }185 }
151};186};
152 187
153export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscovery> => {188export const discoverCliLogin = async (
189 origin: string,
190 options: DeviceLoginHttpOptions = {},
191): Promise<CliLoginDiscovery> => {
154 let response: Response;192 let response: Response;
193 const url = cliLoginUrl(origin);
155 try {194 try {
156 response = await fetch(cliLoginUrl(origin), { headers: { accept: "application/json" } });195 response = await fetch(url, {
196 headers: headersForUrl(url, { accept: "application/json" }, options),
197 });
157 } catch (cause) {198 } catch (cause) {
158 throw new DeviceLoginError(199 throw new DeviceLoginError(
159 `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`,200 `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`,
@@ -182,11 +223,15 @@ export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscover · 22 unmodified lines
182 };223 };
183};224};
184 225
185export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise<DeviceCodeGrant> => {226export const requestDeviceCode = async (
227 discovery: CliLoginDiscovery,
228 options: DeviceLoginHttpOptions = {},
229): Promise<DeviceCodeGrant> => {
186 const response = await post(230 const response = await post(
187 discovery.deviceAuthorizationEndpoint,231 discovery.deviceAuthorizationEndpoint,
188 { client_id: discovery.clientId, scope: discovery.scope },232 { client_id: discovery.clientId, scope: discovery.scope },
189 discovery.requestFormat,233 discovery.requestFormat,
234 options,
190 );235 );
191 const body = await readJson(response);236 const body = await readJson(response);
192 if (!response.ok) {237 if (!response.ok) {
@@ -220,7 +265,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout · 27 unmodified lines
220export const pollForDeviceTokens = async (265export const pollForDeviceTokens = async (
221 discovery: CliLoginDiscovery,266 discovery: CliLoginDiscovery,
222 grant: DeviceCodeGrant,267 grant: DeviceCodeGrant,
223 options: { readonly now?: () => number } = {},268 options: PollForDeviceTokensOptions = {},
224): Promise<DeviceTokens> => {269): Promise<DeviceTokens> => {
225 const now = options.now ?? (() => Date.now());270 const now = options.now ?? (() => Date.now());
226 const deadline = now() + grant.expiresInSeconds * 1000;271 const deadline = now() + grant.expiresInSeconds * 1000;
@@ -240,6 +285,7 @@ export const pollForDeviceTokens = async ( · 13 unmodified lines
240 client_id: discovery.clientId,285 client_id: discovery.clientId,
241 },286 },
242 discovery.requestFormat,287 discovery.requestFormat,
288 options,
243 );289 );
244 const body = await readJson(response);290 const body = await readJson(response);
245 291
@@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: { · 40 unmodified lines
286 readonly tokenEndpoint: string;332 readonly tokenEndpoint: string;
287 readonly clientId: string;333 readonly clientId: string;
288 readonly refreshToken: string;334 readonly refreshToken: string;
335 readonly headers?: Readonly<Record<string, string>>;
336 readonly serverOrigin?: string;
289}): Promise<DeviceTokens> => {337}): Promise<DeviceTokens> => {
290 const response = await post(338 const response = await post(
291 input.tokenEndpoint,339 input.tokenEndpoint,
@@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: { · 3 unmodified lines
295 client_id: input.clientId,343 client_id: input.clientId,
296 },344 },
297 "form",345 "form",
346 input,
298 );347 );
299 const body = await readJson(response);348 const body = await readJson(response);
300 if (!response.ok) {349 if (!response.ok) {
apps/cli/src/main.ts+263−69
@@ -64,7 +64,12 @@ if (sentryDsn) { · 63 unmodified lines
64import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli";64import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli";
65import { BunRuntime, BunServices } from "@effect/platform-bun";65import { BunRuntime, BunServices } from "@effect/platform-bun";
66import { HttpApiClient } from "effect/unstable/httpapi";66import { HttpApiClient } from "effect/unstable/httpapi";
67import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";67import {
68 FetchHttpClient,
69 HttpClient,
70 HttpClientError,
71 HttpClientRequest,
72} from "effect/unstable/http";
68import { FileSystem, Path as PlatformPath } from "effect";73import { FileSystem, Path as PlatformPath } from "effect";
69import type { PlatformError } from "effect/PlatformError";74import type { PlatformError } from "effect/PlatformError";
70import * as Effect from "effect/Effect";75import * as Effect from "effect/Effect";
@@ -79,10 +84,13 @@ import { · 8 unmodified lines
79 getExecutorServerAuthorizationHeader,84 getExecutorServerAuthorizationHeader,
80 normalizeExecutorServerConnection,85 normalizeExecutorServerConnection,
81 normalizeExecutorServerOrigin,86 normalizeExecutorServerOrigin,
87 resolveExecutorServerConfiguredHeaders,
88 resolveExecutorServerRequestHeaders,
82 type ExecutorLocalServerKind,89 type ExecutorLocalServerKind,
83 type ExecutorLocalServerManifest,90 type ExecutorLocalServerManifest,
84 type ExecutorServerConnection,91 type ExecutorServerConnection,
85 type ExecutorServerConnectionInput,92 type ExecutorServerConnectionInput,
93 type ExecutorServerHeaders,
86} from "@executor-js/sdk/shared";94} from "@executor-js/sdk/shared";
87import {95import {
88 decodeAccessTokenClaims,96 decodeAccessTokenClaims,
@@ -131,7 +139,9 @@ import { · 42 unmodified lines
131import {139import {
132 canAutoStartCliServerConnection,140 canAutoStartCliServerConnection,
133 chooseCliServerConnectionWithActiveLocal,141 chooseCliServerConnectionWithActiveLocal,
142 describeUnauthorizedCliServer,
134 parseCliExecutorServerConnection,143 parseCliExecutorServerConnection,
144 profileNameFromConnectionKey,
135 type CliServerConnectionSource,145 type CliServerConnectionSource,
136 withCliServerAuthFallback,146 withCliServerAuthFallback,
137} from "./server-connection";147} from "./server-connection";
@@ -162,7 +172,7 @@ import { · 24 unmodified lines
162 buildDescribeToolCode,172 buildDescribeToolCode,
163 filterToolPathChildren,173 filterToolPathChildren,
164 buildInvokeToolCode,174 buildInvokeToolCode,
165 buildListSourcesCode,175 buildListIntegrationsCode,
166 buildSearchToolsCode,176 buildSearchToolsCode,
167 extractExecutionId,177 extractExecutionId,
168 extractPausedInteraction,178 extractPausedInteraction,
@@ -205,9 +215,11 @@ const waitForShutdownSignal = () => · 36 unmodified lines
205 const shutdown = () => resume(Effect.void);215 const shutdown = () => resume(Effect.void);
206 process.once("SIGINT", shutdown);216 process.once("SIGINT", shutdown);
207 process.once("SIGTERM", shutdown);217 process.once("SIGTERM", shutdown);
218 process.once("SIGHUP", shutdown);
208 return Effect.sync(() => {219 return Effect.sync(() => {
209 process.off("SIGINT", shutdown);220 process.off("SIGINT", shutdown);
210 process.off("SIGTERM", shutdown);221 process.off("SIGTERM", shutdown);
222 process.off("SIGHUP", shutdown);
211 });223 });
212 });224 });
213 225
@@ -743,12 +755,9 @@ const resolveRequestedExecutorServerConnection = ( · 529 unmodified lines
743// login. The refreshed tokens are written back to the originating profile.755// login. The refreshed tokens are written back to the originating profile.
744const OAUTH_REFRESH_SKEW_SECONDS = 60;756const OAUTH_REFRESH_SKEW_SECONDS = 60;
745 757
746const profileNameFromKey = (key: string): string | null =>
747 key.startsWith("profile:") ? key.slice("profile:".length) : null;
748
749const refreshOAuthConnection = (758const refreshOAuthConnection = (
750 connection: ExecutorServerConnection,759 connection: ExecutorServerConnection,
751): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> =>760): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> =>
752 Effect.gen(function* () {761 Effect.gen(function* () {
753 const auth = connection.auth;762 const auth = connection.auth;
754 if (!auth || auth.kind !== "oauth") return connection;763 if (!auth || auth.kind !== "oauth") return connection;
@@ -759,8 +768,19 @@ const refreshOAuthConnection = ( · 4 unmodified lines
759 const { refreshToken, tokenEndpoint, clientId } = auth;768 const { refreshToken, tokenEndpoint, clientId } = auth;
760 if (!refreshToken || !tokenEndpoint || !clientId) return connection;769 if (!refreshToken || !tokenEndpoint || !clientId) return connection;
761 770
771 const headers = yield* resolveExecutorServerConfiguredHeaders(connection, process.env).pipe(
772 Effect.mapError(toError),
773 );
774
762 const refreshed = yield* Effect.tryPromise({775 const refreshed = yield* Effect.tryPromise({
763 try: () => refreshDeviceTokens({ tokenEndpoint, clientId, refreshToken }),776 try: () =>
777 refreshDeviceTokens({
778 tokenEndpoint,
779 clientId,
780 refreshToken,
781 serverOrigin: connection.origin,
782 headers,
783 }),
764 catch: toError,784 catch: toError,
765 // On a failed refresh, keep the existing token and let the eventual 401785 // On a failed refresh, keep the existing token and let the eventual 401
766 // surface, better than blocking the command on a transient hiccup.786 // surface, better than blocking the command on a transient hiccup.
@@ -780,7 +800,7 @@ const refreshOAuthConnection = ( · 13 unmodified lines
780 },800 },
781 });801 });
782 802
783 const profileName = profileNameFromKey(connection.key);803 const profileName = profileNameFromConnectionKey(connection.key);
784 if (profileName) {804 if (profileName) {
785 yield* upsertCliServerConnectionProfile({805 yield* upsertCliServerConnectionProfile({
786 name: profileName,806 name: profileName,
@@ -961,7 +981,7 @@ const executeCode = (input: { · 174 unmodified lines
961}): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> =>981}): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> =>
962 Effect.gen(function* () {982 Effect.gen(function* () {
963 const connection = yield* resolveExecutorServerConnection(input.target);983 const connection = yield* resolveExecutorServerConnection(input.target);
964 const client = yield* makeApiClient(connection);984 const client = yield* makeApiClient(connection, input.target);
965 const response = yield* client.executions.execute({985 const response = yield* client.executions.execute({
966 payload: {986 payload: {
967 code: input.code,987 code: input.code,
@@ -1050,19 +1070,36 @@ const printExecutionOutcome = (input: { · 82 unmodified lines
1050// Typed API client1070// Typed API client
1051// ---------------------------------------------------------------------------1071// ---------------------------------------------------------------------------
1052 1072
1053const makeApiClient = (connection: ExecutorServerConnection) => {1073const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) =>
1054 const authorization = getExecutorServerAuthorizationHeader(connection);1074 Effect.gen(function* () {
1055 return HttpApiClient.make(ExecutorApi, {1075 const headers = yield* resolveExecutorServerRequestHeaders(connection, process.env).pipe(
1056 baseUrl: connection.apiBaseUrl,1076 Effect.mapError(toError),
1057 ...(authorization1077 );
1058 ? {1078 return yield* HttpApiClient.make(ExecutorApi, {
1059 transformClient: HttpClient.mapRequest((request) =>1079 baseUrl: connection.apiBaseUrl,
1060 HttpClientRequest.setHeader(request, "authorization", authorization),1080 ...(Object.keys(headers).length > 0
1061 ),1081 ? {
1062 }1082 transformClient: HttpClient.mapRequest((request) =>
1063 : {}),1083 HttpClientRequest.setHeaders(request, headers),
1084 ),
1085 }
1086 : {}),
1087 // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite
1088 // the transport-level error into the login hint. Without this the client
1089 // fails decoding the unexpected status and prints the opaque
1090 // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors)
1091 // decode before this catch and pass through untouched.
1092 transformResponse: (effect) =>
1093 Effect.catchIf(
1094 effect,
1095 (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401,
1096 () =>
1097 Effect.fail(
1098 new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target })),
1099 ),
1100 ),
1101 });
1064 }).pipe(Effect.provide(FetchHttpClient.layer));1102 }).pipe(Effect.provide(FetchHttpClient.layer));
1065};
1066 1103
1067// ---------------------------------------------------------------------------1104// ---------------------------------------------------------------------------
1068// Foreground session1105// Foreground session
@@ -1321,14 +1358,26 @@ const runBackgroundDaemonStart = (input: { · 252 unmodified lines
1321// Stdio MCP session: a pure stdio <-> HTTP bridge to the owning local daemon.1358// Stdio MCP session: a pure stdio <-> HTTP bridge to the owning local daemon.
1322// ---------------------------------------------------------------------------1359// ---------------------------------------------------------------------------
1323 1360
1324const mcpUrlForActiveLocalServer = (1361const mcpUrlForActiveLocalServer = (input: {
1325 connection: ExecutorServerConnection,1362 readonly connection: ExecutorServerConnection;
1326 elicitationMode: "browser" | "model",1363 readonly elicitationMode: "browser" | "model";
1327): URL => {1364 readonly artifacts: boolean;
1328 const url = new URL("/mcp", connection.origin);1365 readonly searchTools: boolean;
1329 if (elicitationMode === "browser") {1366}): URL => {
1367 const url = new URL("/mcp", input.connection.origin);
1368 if (input.elicitationMode === "browser") {
1330 url.searchParams.set("elicitation_mode", "browser");1369 url.searchParams.set("elicitation_mode", "browser");
1331 }1370 }
1371 // Artifacts are on by default; only the opt-out is spelled out, so the
1372 // default endpoint stays clean.
1373 if (!input.artifacts) {
1374 url.searchParams.set("artifacts", "false");
1375 }
1376 // Per-integration search tools are off by default; only the opt-in is
1377 // spelled out.
1378 if (input.searchTools) {
1379 url.searchParams.set("search_tools", "true");
1380 }
1332 return url;1381 return url;
1333};1382};
1334 1383
@@ -1343,11 +1392,18 @@ const mcpUrlForActiveLocalServer = ( · 8 unmodified lines
1343const runMcpHttpBridge = async (input: {1392const runMcpHttpBridge = async (input: {
1344 readonly manifest: ExecutorLocalServerManifest;1393 readonly manifest: ExecutorLocalServerManifest;
1345 readonly elicitationMode: "browser" | "model";1394 readonly elicitationMode: "browser" | "model";
1395 readonly artifacts: boolean;
1396 readonly searchTools: boolean;
1346}): Promise<void> => {1397}): Promise<void> => {
1347 const stdio = new StdioServerTransport();1398 const stdio = new StdioServerTransport();
1348 const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection);1399 const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection);
1349 const http = new StreamableHTTPClientTransport(1400 const http = new StreamableHTTPClientTransport(
1350 mcpUrlForActiveLocalServer(input.manifest.connection, input.elicitationMode),1401 mcpUrlForActiveLocalServer({
1402 connection: input.manifest.connection,
1403 elicitationMode: input.elicitationMode,
1404 artifacts: input.artifacts,
1405 searchTools: input.searchTools,
1406 }),
1351 authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined,1407 authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined,
1352 );1408 );
1353 1409
@@ -1422,7 +1478,11 @@ const runMcpHttpBridge = async (input: { · 68 unmodified lines
1422 }1478 }
1423};1479};
1424 1480
1425const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) =>1481const runStdioMcpSession = (input: {
1482 readonly elicitationMode: "browser" | "model";
1483 readonly artifacts: boolean;
1484 readonly searchTools: boolean;
1485}) =>
1426 Effect.gen(function* () {1486 Effect.gen(function* () {
1427 // `executor mcp` never owns the local database. If a local server is already1487 // `executor mcp` never owns the local database. If a local server is already
1428 // running, bridge this stdio client to it; otherwise ensure a durable1488 // running, bridge this stdio client to it; otherwise ensure a durable
@@ -1434,7 +1494,12 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 5 unmodified lines
1434 const active = yield* readActiveLocalServerManifest();1494 const active = yield* readActiveLocalServerManifest();
1435 if (active) {1495 if (active) {
1436 yield* Effect.promise(() =>1496 yield* Effect.promise(() =>
1437 runMcpHttpBridge({ manifest: active, elicitationMode: input.elicitationMode }),1497 runMcpHttpBridge({
1498 manifest: active,
1499 elicitationMode: input.elicitationMode,
1500 artifacts: input.artifacts,
1501 searchTools: input.searchTools,
1502 }),
1438 );1503 );
1439 return;1504 return;
1440 }1505 }
@@ -1456,7 +1521,12 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 15 unmodified lines
1456 );1521 );
1457 }1522 }
1458 yield* Effect.promise(() =>1523 yield* Effect.promise(() =>
1459 runMcpHttpBridge({ manifest: elected, elicitationMode: input.elicitationMode }),1524 runMcpHttpBridge({
1525 manifest: elected,
1526 elicitationMode: input.elicitationMode,
1527 artifacts: input.artifacts,
1528 searchTools: input.searchTools,
1529 }),
1460 );1530 );
1461 });1531 });
1462 1532
@@ -1504,14 +1574,45 @@ const parseOptionalJsonObject = ( · 41 unmodified lines
1504 Effect.mapError((error) => new Error(`Invalid --content JSON: ${error.message}`)),1574 Effect.mapError((error) => new Error(`Invalid --content JSON: ${error.message}`)),
1505 );1575 );
1506 1576
1577const ownMessage = (value: unknown): string => {
1578 if (typeof value === "string") return value.trim();
1579 if (typeof value === "object" && value !== null && "message" in value) {
1580 const message = (value as { message: unknown }).message;
1581 if (typeof message === "string") return message.trim();
1582 }
1583 return "";
1584};
1585
1586/**
1587 * Render an unknown failure by walking its `cause` chain. Wrapper errors
1588 * (tagged errors carrying only `{ cause }`) have an empty own message; without
1589 * descending, a boot failure like a data-migration error printed as literally
1590 * "Unknown error" (issue #1403). Nested messages that merely repeat their
1591 * parent are dropped.
1592 */
1507const formatUnknownMessage = (cause: unknown): string => {1593const formatUnknownMessage = (cause: unknown): string => {
1508 if (cause instanceof Error) return cause.message;1594 const messages: string[] = [];
1509 if (typeof cause === "string") return cause;1595 const seen = new Set<unknown>();
1510 if (typeof cause === "object" && cause !== null && "message" in cause) {1596 let current: unknown = cause;
1511 const message = cause.message;1597 while (current !== null && current !== undefined && !seen.has(current)) {
1512 if (typeof message === "string") return message;1598 seen.add(current);
1599 const message = ownMessage(current);
1600 if (message.length > 0 && !messages.some((existing) => existing.includes(message))) {
1601 messages.push(message);
1602 }
1603 current =
1604 typeof current === "object" && "cause" in current
1605 ? (current as { cause: unknown }).cause
1606 : undefined;
1513 }1607 }
1514 return String(cause);1608 if (messages.length === 0) {
1609 return typeof cause === "string"
1610 ? cause
1611 : cause instanceof Error
1612 ? cause.message
1613 : String(cause);
1614 }
1615 return messages.join("\ncaused by: ");
1515};1616};
1516 1617
1517const readCliLogLevel = (argv: ReadonlyArray<string>): string | undefined => {1618const readCliLogLevel = (argv: ReadonlyArray<string>): string | undefined => {
@@ -1802,11 +1903,9 @@ const runCallHelp = ( · 284 unmodified lines
1802 Effect.gen(function* () {1903 Effect.gen(function* () {
1803 if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir);1904 if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir);
1804 1905
1805 const connection = yield* resolveExecutorServerConnection({1906 const target: ServerTarget = { baseUrl: args.baseUrl, serverName: args.serverName };
1806 baseUrl: args.baseUrl,1907 const connection = yield* resolveExecutorServerConnection(target);
1807 serverName: args.serverName,1908 const client = yield* makeApiClient(connection, target);
1808 });
1809 const client = yield* makeApiClient(connection);
1810 const tools = yield* client.tools.list({ query: {} });1909 const tools = yield* client.tools.list({ query: {} });
1811 const toolPaths = tools.map((tool) => tool.address);1910 const toolPaths = tools.map((tool) => tool.address);
1812 1911
@@ -1987,7 +2086,7 @@ const resumeCommand = Command.make( · 174 unmodified lines
1987 2086
1988 const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content));2087 const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content));
1989 2088
1990 const client = yield* makeApiClient(connection);2089 const client = yield* makeApiClient(connection, target);
1991 const result = yield* client.executions.resume({2090 const result = yield* client.executions.resume({
1992 params: { executionId },2091 params: { executionId },
1993 payload: { action, content: contentObj },2092 payload: { action, content: contentObj },
@@ -2052,8 +2151,8 @@ const toolsSearchCommand = Command.make( · 58 unmodified lines
2052 }),2151 }),
2053).pipe(Command.withDescription("Search tools by natural-language query"));2152).pipe(Command.withDescription("Search tools by natural-language query"));
2054 2153
2055const toolsSourcesCommand = Command.make(2154const toolsIntegrationsCommand = Command.make(
2056 "sources",2155 "integrations",
2057 {2156 {
2058 query: Options.string("query").pipe(Options.optional),2157 query: Options.string("query").pipe(Options.optional),
2059 limit: Options.integer("limit").pipe(Options.withDefault(50)),2158 limit: Options.integer("limit").pipe(Options.withDefault(50)),
@@ -2065,7 +2164,7 @@ const toolsSourcesCommand = Command.make( · 5 unmodified lines
2065 Effect.gen(function* () {2164 Effect.gen(function* () {
2066 applyScope(scope);2165 applyScope(scope);
2067 const target = serverTargetFromOptions({ baseUrl, server });2166 const target = serverTargetFromOptions({ baseUrl, server });
2068 const code = buildListSourcesCode({2167 const code = buildListIntegrationsCode({
2069 query: Option.getOrUndefined(query),2168 query: Option.getOrUndefined(query),
2070 limit,2169 limit,
2071 });2170 });
@@ -2077,7 +2176,7 @@ const toolsSourcesCommand = Command.make( · 5 unmodified lines
2077 outcome: result.outcome,2176 outcome: result.outcome,
2078 });2177 });
2079 }),2178 }),
2080).pipe(Command.withDescription("List configured sources and tool counts"));2179).pipe(Command.withDescription("List configured integrations and tool counts"));
2081 2180
2082const toolsDescribeCommand = Command.make(2181const toolsDescribeCommand = Command.make(
2083 "describe",2182 "describe",
@@ -2102,21 +2201,55 @@ const toolsDescribeCommand = Command.make( · 18 unmodified lines
2102).pipe(Command.withDescription("Describe a tool's TypeScript and JSON schema"));2201).pipe(Command.withDescription("Describe a tool's TypeScript and JSON schema"));
2103 2202
2104const toolsCommand = Command.make("tools").pipe(2203const toolsCommand = Command.make("tools").pipe(
2105 Command.withSubcommands([toolsSearchCommand, toolsSourcesCommand, toolsDescribeCommand] as const),2204 Command.withSubcommands([
2106 Command.withDescription("Discover available tools and sources"),2205 toolsSearchCommand,
2206 toolsIntegrationsCommand,
2207 toolsDescribeCommand,
2208 ] as const),
2209 Command.withDescription("Discover available tools and integrations"),
2107);2210);
2108 2211
2212const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
2213const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2214
2215const parseHeaderEnvOption = (
2216 headerEnv: Option.Option<Record<string, string>>,
2217): ExecutorServerHeaders | undefined => {
2218 const values = Option.getOrUndefined(headerEnv);
2219 if (!values) return undefined;
2220 const headers: Record<string, { readonly kind: "env"; readonly name: string }> = {};
2221 for (const [rawHeaderName, rawEnvName] of Object.entries(values)) {
2222 const headerName = rawHeaderName.trim();
2223 const envName = rawEnvName.trim();
2224 if (!HEADER_NAME_PATTERN.test(headerName)) {
2225 throw new Error(
2226 `Invalid --header-env header name "${rawHeaderName}". Use an HTTP header token like CF-Access-Client-Id.`,
2227 );
2228 }
2229 if (!ENV_NAME_PATTERN.test(envName)) {
2230 throw new Error(
2231 `Invalid --header-env env name "${rawEnvName}". Use an environment variable name like EXECUTOR_CF_ACCESS_CLIENT_ID.`,
2232 );
2233 }
2234 headers[headerName] = { kind: "env", name: envName };
2235 }
2236 return Object.keys(headers).length > 0 ? headers : undefined;
2237};
2238
2109const profileConnectionInput = (input: {2239const profileConnectionInput = (input: {
2110 readonly origin: string;2240 readonly origin: string;
2111 readonly displayName: Option.Option<string>;2241 readonly displayName: Option.Option<string>;
2112 readonly kind: Option.Option<"http" | "desktop-sidecar">;2242 readonly kind: Option.Option<"http" | "desktop-sidecar">;
2243 readonly headerEnv: Option.Option<Record<string, string>>;
2113}): ExecutorServerConnectionInput => {2244}): ExecutorServerConnectionInput => {
2114 const selectedKind = Option.getOrUndefined(input.kind);2245 const selectedKind = Option.getOrUndefined(input.kind);
2115 const displayName = Option.getOrUndefined(input.displayName);2246 const displayName = Option.getOrUndefined(input.displayName);
2247 const headers = parseHeaderEnvOption(input.headerEnv);
2116 return {2248 return {
2117 kind: selectedKind ?? "http",2249 kind: selectedKind ?? "http",
2118 origin: input.origin,2250 origin: input.origin,
2119 ...(displayName ? { displayName } : {}),2251 ...(displayName ? { displayName } : {}),
2252 ...(headers ? { headers } : {}),
2120 };2253 };
2121};2254};
2122 2255
@@ -2136,13 +2269,16 @@ const printServerProfiles = () => · 13 unmodified lines
2136 origin: profile.connection.origin,2269 origin: profile.connection.origin,
2137 displayName: profile.connection.displayName,2270 displayName: profile.connection.displayName,
2138 auth: profile.connection.auth ? "stored-auth" : "env-auth",2271 auth: profile.connection.auth ? "stored-auth" : "env-auth",
2272 headers: profile.connection.headers
2273 ? `${Object.keys(profile.connection.headers).length} header-env`
2274 : "no-headers",
2139 }));2275 }));
2140 const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4);2276 const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4);
2141 const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4);2277 const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4);
2142 2278
2143 for (const row of rows) {2279 for (const row of rows) {
2144 console.log(2280 console.log(
2145 `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth}`,2281 `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth} ${row.headers}`,
2146 );2282 );
2147 }2283 }
2148 });2284 });
@@ -2160,17 +2296,23 @@ const serverAddCommand = Command.make( · 11 unmodified lines
2160 Options.optional,2296 Options.optional,
2161 Options.withDescription("Server kind. Defaults to http."),2297 Options.withDescription("Server kind. Defaults to http."),
2162 ),2298 ),
2299 headerEnv: Options.keyValuePair("header-env").pipe(
2300 Options.optional,
2301 Options.withDescription(
2302 "HTTP header mapping header-name=ENV_VAR. Repeat for Cloudflare Access service tokens.",
2303 ),
2304 ),
2163 makeDefault: Options.boolean("default").pipe(2305 makeDefault: Options.boolean("default").pipe(
2164 Options.withDefault(false),2306 Options.withDefault(false),
2165 Options.withDescription("Make this profile the default server."),2307 Options.withDescription("Make this profile the default server."),
2166 ),2308 ),
2167 },2309 },
2168 ({ name, origin, displayName, kind, makeDefault }) =>2310 ({ name, origin, displayName, kind, headerEnv, makeDefault }) =>
2169 Effect.gen(function* () {2311 Effect.gen(function* () {
2170 const profileName = validateCliServerConnectionProfileName(name);2312 const profileName = validateCliServerConnectionProfileName(name);
2171 const store = yield* upsertCliServerConnectionProfile({2313 const store = yield* upsertCliServerConnectionProfile({
2172 name: profileName,2314 name: profileName,
2173 connection: profileConnectionInput({ origin, displayName, kind }),2315 connection: profileConnectionInput({ origin, displayName, kind, headerEnv }),
2174 makeDefault,2316 makeDefault,
2175 });2317 });
2176 const profile = findCliServerConnectionProfile(store, profileName);2318 const profile = findCliServerConnectionProfile(store, profileName);
@@ -2307,9 +2449,13 @@ const chooseLoginProfileName = ( · 130 unmodified lines
2307 }2449 }
2308};2450};
2309 2451
2452/** Where `executor login` lands with no flags and no stored profiles. */
2453const DEFAULT_LOGIN_ORIGIN = "https://executor.sh";
2454
2310// Resolve which server a login/logout targets: an existing profile (--server2455// Resolve which server a login/logout targets: an existing profile (--server
2311// or the default) or a bare origin (--base-url). The profile name is decided2456// or the default), a bare origin (--base-url), or hosted Executor when
2312// later, from the authenticated account.2457// nothing is configured yet. The profile name is decided later, from the
2458// authenticated account.
2313const resolveLoginOrigin = (input: {2459const resolveLoginOrigin = (input: {
2314 readonly baseUrl: Option.Option<string>;2460 readonly baseUrl: Option.Option<string>;
2315 readonly server: Option.Option<string>;2461 readonly server: Option.Option<string>;
@@ -2337,11 +2483,7 @@ const resolveLoginOrigin = (input: { · 21 unmodified lines
2337 const store = yield* readCliServerConnectionStore();2483 const store = yield* readCliServerConnectionStore();
2338 const profile = defaultCliServerConnectionProfile(store);2484 const profile = defaultCliServerConnectionProfile(store);
2339 if (profile) return { origin: profile.connection.origin, profile };2485 if (profile) return { origin: profile.connection.origin, profile };
2340 return yield* Effect.fail(2486 return { origin: DEFAULT_LOGIN_ORIGIN, profile: null };
2341 new Error(
2342 "No server to log in to. Pass --base-url <https://your-host> or --server <profile>.",
2343 ),
2344 );
2345 });2487 });
2346 2488
2347const loginNameOption = Options.string("name").pipe(2489const loginNameOption = Options.string("name").pipe(
@@ -2366,12 +2508,21 @@ const loginCommand = Command.make( · 18 unmodified lines
2366 Effect.gen(function* () {2508 Effect.gen(function* () {
2367 const target = yield* resolveLoginOrigin({ baseUrl, server });2509 const target = yield* resolveLoginOrigin({ baseUrl, server });
2368 const explicitName = Option.getOrUndefined(name);2510 const explicitName = Option.getOrUndefined(name);
2511 // The target may have been picked implicitly (default profile, or the
2512 // hosted fallback): say where the login is going before the device flow.
2513 console.log(`Signing in to ${target.origin}`);
2514 const targetProfile = target.profile;
2515 const headers = targetProfile
2516 ? yield* resolveExecutorServerConfiguredHeaders(targetProfile.connection, process.env).pipe(
2517 Effect.mapError(toError),
2518 )
2519 : {};
2369 const discovery = yield* Effect.tryPromise({2520 const discovery = yield* Effect.tryPromise({
2370 try: () => discoverCliLogin(target.origin),2521 try: () => discoverCliLogin(target.origin, { headers }),
2371 catch: toError,2522 catch: toError,
2372 });2523 });
2373 const grant = yield* Effect.tryPromise({2524 const grant = yield* Effect.tryPromise({
2374 try: () => requestDeviceCode(discovery),2525 try: () => requestDeviceCode(discovery, { serverOrigin: target.origin, headers }),
2375 catch: toError,2526 catch: toError,
2376 });2527 });
2377 const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri;2528 const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri;
@@ -2382,7 +2533,7 @@ const loginCommand = Command.make( · 4 unmodified lines
2382 if (!noBrowser) openBrowser(verifyUrl);2533 if (!noBrowser) openBrowser(verifyUrl);
2383 console.log("Waiting for you to approve in the browser...");2534 console.log("Waiting for you to approve in the browser...");
2384 const tokens = yield* Effect.tryPromise({2535 const tokens = yield* Effect.tryPromise({
2385 try: () => pollForDeviceTokens(discovery, grant),2536 try: () => pollForDeviceTokens(discovery, grant, { serverOrigin: target.origin, headers }),
2386 catch: toError,2537 catch: toError,
2387 });2538 });
2388 2539
@@ -2407,6 +2558,9 @@ const loginCommand = Command.make( · 18 unmodified lines
2407 kind: "http",2558 kind: "http",
2408 origin: target.origin,2559 origin: target.origin,
2409 ...(email ? { displayName: email } : {}),2560 ...(email ? { displayName: email } : {}),
2561 ...(target.profile?.connection.headers
2562 ? { headers: target.profile.connection.headers }
2563 : {}),
2410 auth: {2564 auth: {
2411 kind: "oauth",2565 kind: "oauth",
2412 accessToken: tokens.accessToken,2566 accessToken: tokens.accessToken,
@@ -2420,12 +2574,24 @@ const loginCommand = Command.make( · 7 unmodified lines
2420 });2574 });
2421 2575
2422 console.log("");2576 console.log("");
2423 console.log(`Logged in to ${target.origin} (profile "${profileName}", now the default).`);2577 // Profile bookkeeping stays backstage unless the user works with named
2578 // profiles (--name, or a store that already has several).
2579 const mentionProfile =
2580 explicitName !== undefined || (yield* readCliServerConnectionStore()).profiles.length > 1;
2581 console.log(
2582 mentionProfile
2583 ? `Logged in to ${target.origin} (profile "${profileName}", now the default).`
2584 : `Logged in to ${target.origin}.`,
2585 );
2424 if (email) console.log(`Account: ${email}`);2586 if (email) console.log(`Account: ${email}`);
2425 if (org) console.log(`Organization: ${org}`);2587 if (org) console.log(`Organization: ${org}`);
2426 else if (sub) console.log(`User: ${sub}`);2588 else if (sub) console.log(`User: ${sub}`);
2427 }),2589 }),
2428).pipe(Command.withDescription("Sign in to a hosted Executor server in the browser (device flow)"));2590).pipe(
2591 Command.withDescription(
2592 "Sign in to a hosted Executor server in the browser (device flow). Defaults to https://executor.sh.",
2593 ),
2594);
2429 2595
2430const logoutCommand = Command.make(2596const logoutCommand = Command.make(
2431 "logout",2597 "logout",
@@ -2441,8 +2607,14 @@ const logoutCommand = Command.make( · 9 unmodified lines
2441 console.log(`No stored login for ${target.origin}.`);2607 console.log(`No stored login for ${target.origin}.`);
2442 return;2608 return;
2443 }2609 }
2610 // Profile bookkeeping stays backstage unless the user addressed one.
2611 const mentionProfile = Option.isSome(server) || store.profiles.length > 1;
2444 if (!profile.connection.auth) {2612 if (!profile.connection.auth) {
2445 console.log(`Profile "${profile.name}" has no stored credentials.`);2613 console.log(
2614 mentionProfile
2615 ? `Profile "${profile.name}" has no stored credentials.`
2616 : `Not signed in to ${target.origin}.`,
2617 );
2446 return;2618 return;
2447 }2619 }
2448 yield* upsertCliServerConnectionProfile({2620 yield* upsertCliServerConnectionProfile({
@@ -2451,11 +2623,14 @@ const logoutCommand = Command.make( · 2 unmodified lines
2451 kind: profile.connection.kind,2623 kind: profile.connection.kind,
2452 origin: profile.connection.origin,2624 origin: profile.connection.origin,
2453 displayName: profile.connection.displayName,2625 displayName: profile.connection.displayName,
2626 ...(profile.connection.headers ? { headers: profile.connection.headers } : {}),
2454 },2627 },
2455 makeDefault: store.defaultProfile === profile.name,2628 makeDefault: store.defaultProfile === profile.name,
2456 });2629 });
2457 console.log(2630 console.log(
2458 `Logged out of ${profile.connection.origin} (cleared credentials for "${profile.name}").`,2631 mentionProfile
2632 ? `Logged out of ${profile.connection.origin} (cleared credentials for "${profile.name}").`
2633 : `Logged out of ${profile.connection.origin}.`,
2459 );2634 );
2460 }),2635 }),
2461).pipe(Command.withDescription("Clear stored credentials for a server profile"));2636).pipe(Command.withDescription("Clear stored credentials for a server profile"));
@@ -2709,11 +2884,25 @@ const mcpCommand = Command.make( · 247 unmodified lines
2709 "Choose the stdio approval flow: browser approval or a CLI resume tool exposed to the model.",2884 "Choose the stdio approval flow: browser approval or a CLI resume tool exposed to the model.",
2710 ),2885 ),
2711 ),2886 ),
2887 noArtifacts: Options.boolean("no-artifacts")
2888 .pipe(Options.withDefault(false))
2889 .pipe(
2890 Options.withDescription(
2891 "Withhold the artifact surface from this connection: the artifact tools, the app shell resource, and the artifact skills. Served by default.",
2892 ),
2893 ),
2894 searchTools: Options.boolean("search-tools")
2895 .pipe(Options.withDefault(false))
2896 .pipe(
2897 Options.withDescription(
2898 "Serve one search_<integration> tool per connected integration. Off by default; each routes through the same flow as tools.search inside execute.",
2899 ),
2900 ),
2712 },2901 },
2713 ({ scope, elicitationMode }) =>2902 ({ scope, elicitationMode, noArtifacts, searchTools }) =>
2714 Effect.gen(function* () {2903 Effect.gen(function* () {
2715 applyScope(scope);2904 applyScope(scope);
2716 yield* runStdioMcpSession({ elicitationMode });2905 yield* runStdioMcpSession({ elicitationMode, artifacts: !noArtifacts, searchTools });
2717 }),2906 }),
2718).pipe(Command.withDescription("Start an MCP server over stdio"));2907).pipe(Command.withDescription("Start an MCP server over stdio"));
2719 2908
@@ -3164,7 +3353,12 @@ const program = ( · 444 unmodified lines
3164 } else {3353 } else {
3165 console.error(renderCliError(cause));3354 console.error(renderCliError(cause));
3166 }3355 }
3167 process.exitCode = 1;3356 // Exit hard, not via exitCode: this handler converts the failure into a
3357 // success, so runMain's teardown never force-exits, and background fibers
3358 // (the integrations-refresh fork) keep the event loop alive. A daemon
3359 // that failed boot then lingers with its port unbound, and the desktop
3360 // waits on the ready sentinel forever (issue #1403).
3361 process.exit(1);
3168 }),3362 }),
3169 ),3363 ),
3170);3364);
apps/cli/src/native-bindings.ts+36−5
@@ -1,11 +1,10 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Native-binding bootstrap for the `bun build --compile` binary.2// Native-binding bootstrap for the `bun build --compile` binary.
3//3//
4// `bun --compile` bundles JS into bunfs but does NOT include `.node` native4// `bun --compile` bundles JS into bunfs but does NOT include native binaries,
5// addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside5// wasm sidecars, or runtime-read package files. build.ts copies those artifacts
6// the binary fails. build.ts copies each platform's `.node` next to the6// next to the executable; here we publish their on-disk paths via env vars the
7// executable (`libsql.node`, `keyring.node`); here we publish their on-disk7// loaders read.
8// paths via env vars the loaders read.
9//8//
10// This MUST be the FIRST import in main.ts. ES modules evaluate every import9// This MUST be the FIRST import in main.ts. ES modules evaluate every import
11// before the importer's own body, and libSQL resolves its native addon EAGERLY10// before the importer's own body, and libSQL resolves its native addon EAGERLY
@@ -18,6 +17,8 @@ · 6 unmodified lines
18import { existsSync } from "node:fs";17import { existsSync } from "node:fs";
19import { dirname, join } from "node:path";18import { dirname, join } from "node:path";
20 19
20import { WORKER_BUNDLER_DIRNAME, missingWorkerBundlerFiles } from "./worker-bundler-artifact";
21
21const execDir = dirname(process.execPath);22const execDir = dirname(process.execPath);
22 23
23// libSQL: our `libsql` patch reads EXECUTOR_LIBSQL_NATIVE_PATH and loads the24// libSQL: our `libsql` patch reads EXECUTOR_LIBSQL_NATIVE_PATH and loads the
@@ -43,3 +44,33 @@ if ( · 19 unmodified lines
43) {44) {
44 process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;45 process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;
45}46}
47
48const workerdOnDisk = join(execDir, process.platform === "win32" ? "workerd.exe" : "workerd");
49if (typeof Bun !== "undefined" && !process.env.EXECUTOR_WORKERD_BIN && existsSync(workerdOnDisk)) {
50 process.env.EXECUTOR_WORKERD_BIN = workerdOnDisk;
51}
52
53// worker-bundler: the compiled binary cannot resolve `@cloudflare/worker-bundler`
54// by name (bunfs has no node_modules), so build.ts stages the package's dist
55// beside the executable and we publish its path here. An absent directory is
56// normal off the packaged path (dev, `bun run`), so that stays quiet — but a
57// directory that is PRESENT AND INCOMPLETE is a broken install, and swallowing
58// it is what turns a packaging slip into an unresolvable bare import at
59// startup. Report it on stderr instead of failing open.
60const workerBundlerOnDisk = join(execDir, WORKER_BUNDLER_DIRNAME);
61if (
62 typeof Bun !== "undefined" &&
63 !process.env.EXECUTOR_WORKER_BUNDLER_DIR &&
64 existsSync(workerBundlerOnDisk)
65) {
66 const missing = missingWorkerBundlerFiles(workerBundlerOnDisk, existsSync);
67 if (missing.length === 0) {
68 process.env.EXECUTOR_WORKER_BUNDLER_DIR = workerBundlerOnDisk;
69 } else {
70 process.stderr.write(
71 `executor: the bundled Worker toolchain at ${workerBundlerOnDisk} is incomplete ` +
72 `(missing ${missing.join(", ")}). Features that build Workers will be unavailable; ` +
73 `reinstall or update executor to repair it.\n`,
74 );
75 }
76}
apps/cli/src/server-connection.test.ts+84−0
@@ -1,8 +1,11 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared";
4
3import {5import {
4 canAutoStartCliServerConnection,6 canAutoStartCliServerConnection,
5 chooseCliServerConnectionWithActiveLocal,7 chooseCliServerConnectionWithActiveLocal,
8 describeUnauthorizedCliServer,
6 parseCliExecutorServerConnection,9 parseCliExecutorServerConnection,
7 withCliServerAuthFallback,10 withCliServerAuthFallback,
8} from "./server-connection";11} from "./server-connection";
@@ -135,3 +138,84 @@ describe("CLI server connection", () => { · 126 unmodified lines
135 ).toBe("conflict");138 ).toBe("conflict");
136 });139 });
137});140});
141
142describe("describeUnauthorizedCliServer", () => {
143 it("hints plain `executor login` when the server was picked implicitly", () => {
144 const connection = normalizeExecutorServerConnection({
145 key: "profile:rhys-executor.sh",
146 origin: "https://executor.sh",
147 auth: { kind: "oauth", accessToken: "stale", expiresAt: 1 },
148 });
149
150 const message = describeUnauthorizedCliServer({
151 connection,
152 cliPrefix: "executor",
153 target: {},
154 });
155 expect(message).toContain("You're signed out of https://executor.sh");
156 expect(message).toContain("Run `executor login` to sign in again.");
157 // Profile names are plumbing: never surfaced when the user didn't type one.
158 expect(message).not.toContain("--server");
159 expect(message).not.toContain("profile");
160 });
161
162 it("echoes --server back when the user targeted a named profile", () => {
163 const connection = normalizeExecutorServerConnection({
164 key: "profile:work",
165 origin: "https://executor.example",
166 auth: { kind: "oauth", accessToken: "stale", expiresAt: 1 },
167 });
168
169 const message = describeUnauthorizedCliServer({
170 connection,
171 cliPrefix: "executor",
172 target: { serverName: "work" },
173 });
174 expect(message).toContain("You're signed out of https://executor.example");
175 expect(message).toContain("executor login --server work");
176 });
177
178 it("echoes --base-url back when the user targeted an origin", () => {
179 const connection = normalizeExecutorServerConnection({
180 origin: "https://executor.example",
181 });
182
183 const message = describeUnauthorizedCliServer({
184 connection,
185 cliPrefix: "executor",
186 target: { baseUrl: "https://executor.example" },
187 });
188 expect(message).toContain("no credentials are stored");
189 expect(message).toContain("executor login --base-url https://executor.example");
190 });
191
192 it("mentions the env vars when a profile-less bearer key is rejected", () => {
193 const connection = normalizeExecutorServerConnection({
194 origin: "https://executor.example",
195 auth: { kind: "bearer", token: "key_bad" },
196 });
197
198 const message = describeUnauthorizedCliServer({
199 connection,
200 cliPrefix: "executor",
201 target: {},
202 });
203 expect(message).toContain("rejected the stored bearer credentials");
204 expect(message).toContain("EXECUTOR_API_KEY");
205 });
206
207 it("uses the dev entrypoint prefix verbatim", () => {
208 const connection = normalizeExecutorServerConnection({
209 key: "profile:hosted",
210 origin: "https://executor.example",
211 auth: { kind: "oauth", accessToken: "stale" },
212 });
213
214 const message = describeUnauthorizedCliServer({
215 connection,
216 cliPrefix: "bun run apps/cli/src/main.ts",
217 target: {},
218 });
219 expect(message).toContain("bun run apps/cli/src/main.ts login");
220 });
221});
apps/cli/src/server-connection.ts+49−0
@@ -52,6 +52,55 @@ export const canAutoStartCliServerConnection = (connection: ExecutorServerConnec · 51 unmodified lines
52 return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname);52 return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname);
53};53};
54 54
55export const profileNameFromConnectionKey = (key: string): string | null =>
56 key.startsWith("profile:") ? key.slice("profile:".length) : null;
57
58// A 401 from the server means the stored credential is stale, revoked, or
59// missing: a sign-in problem, not a transport problem. Rendered instead of
60// the raw HTTP client error so the user learns the recovery command, not the
61// failing endpoint.
62//
63// The hint mirrors how the user addressed the server. The common case types
64// no flags (the default profile, normally executor.sh), and login re-targets
65// that same default, so the fix is plain `executor login`: profile names are
66// plumbing and stay invisible. Only an explicit --server / --base-url comes
67// back in the hint.
68export const describeUnauthorizedCliServer = (input: {
69 readonly connection: ExecutorServerConnection;
70 readonly cliPrefix: string;
71 readonly target: { readonly baseUrl?: string; readonly serverName?: string };
72}): string => {
73 const { connection, cliPrefix, target } = input;
74 const loginCommand = target.serverName
75 ? `${cliPrefix} login --server ${target.serverName}`
76 : target.baseUrl
77 ? `${cliPrefix} login --base-url ${connection.origin}`
78 : `${cliPrefix} login`;
79
80 if (!connection.auth) {
81 return [
82 `${connection.origin} requires authentication, and no credentials are stored for it.`,
83 `Run \`${loginCommand}\` to sign in.`,
84 ].join("\n");
85 }
86
87 if (connection.auth.kind === "oauth") {
88 return [
89 `You're signed out of ${connection.origin}.`,
90 `Run \`${loginCommand}\` to sign in again.`,
91 ].join("\n");
92 }
93
94 const envHint =
95 connection.auth.kind === "bearer" && !profileNameFromConnectionKey(connection.key)
96 ? " If the key came from EXECUTOR_API_KEY or EXECUTOR_AUTH_TOKEN, check that value."
97 : "";
98 return [
99 `${connection.origin} rejected the stored ${connection.auth.kind} credentials (401).${envHint}`,
100 `Run \`${loginCommand}\` to sign in again.`,
101 ].join("\n");
102};
103
55export type CliServerConnectionSource =104export type CliServerConnectionSource =
56 | "explicit"105 | "explicit"
57 | "default-profile"106 | "default-profile"
apps/cli/src/server-profile.test.ts+86−5
@@ -1,6 +1,6 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { BunServices } from "@effect/platform-bun";2import { BunServices } from "@effect/platform-bun";
3import { mkdtempSync, rmSync } from "node:fs";3import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
4import { tmpdir } from "node:os";4import { tmpdir } from "node:os";
5import { join } from "node:path";5import { join } from "node:path";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
@@ -25,7 +25,7 @@ afterEach(() => { · 18 unmodified lines
25});25});
26 26
27describe("CLI server connection profiles", () => {27describe("CLI server connection profiles", () => {
28 it("round-trips named server connections and default selection", () =>28 it.effect("round-trips named server connections and default selection", () =>
29 Effect.gen(function* () {29 Effect.gen(function* () {
30 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-"));30 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-"));
31 process.env.EXECUTOR_DATA_DIR = dataDir;31 process.env.EXECUTOR_DATA_DIR = dataDir;
@@ -36,6 +36,9 @@ describe("CLI server connection profiles", () => { · 4 unmodified lines
36 connection: {36 connection: {
37 origin: "https://executor.example/api",37 origin: "https://executor.example/api",
38 auth: { kind: "bearer", token: "key_123" },38 auth: { kind: "bearer", token: "key_123" },
39 headers: {
40 "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
41 },
39 },42 },
40 makeDefault: true,43 makeDefault: true,
41 });44 });
@@ -50,13 +53,17 @@ describe("CLI server connection profiles", () => { · 8 unmodified lines
50 kind: "bearer",53 kind: "bearer",
51 token: "key_123",54 token: "key_123",
52 });55 });
56 expect(store.profiles[0]?.connection.headers).toEqual({
57 "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
58 });
53 expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote");59 expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote");
54 } finally {60 } finally {
55 rmSync(dataDir, { recursive: true, force: true });61 rmSync(dataDir, { recursive: true, force: true });
56 }62 }
57 }).pipe(Effect.provide(BunServices.layer)));63 }).pipe(Effect.provide(BunServices.layer)),
64 );
58 65
59 it("switches and removes the default profile", () =>66 it.effect("switches and removes the default profile", () =>
60 Effect.gen(function* () {67 Effect.gen(function* () {
61 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-"));68 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-"));
62 process.env.EXECUTOR_DATA_DIR = dataDir;69 process.env.EXECUTOR_DATA_DIR = dataDir;
@@ -82,7 +89,8 @@ describe("CLI server connection profiles", () => { · 19 unmodified lines
82 } finally {89 } finally {
83 rmSync(dataDir, { recursive: true, force: true });90 rmSync(dataDir, { recursive: true, force: true });
84 }91 }
85 }).pipe(Effect.provide(BunServices.layer)));92 }).pipe(Effect.provide(BunServices.layer)),
93 );
86 94
87 it("drops malformed profiles when parsing", () => {95 it("drops malformed profiles when parsing", () => {
88 const store = parseCliServerConnectionStore(96 const store = parseCliServerConnectionStore(
@@ -114,6 +122,12 @@ describe("CLI server connection profiles", () => { · 25 unmodified lines
114 key: "desktop-sidecar",122 key: "desktop-sidecar",
115 origin: "http://127.0.0.1:4789",123 origin: "http://127.0.0.1:4789",
116 auth: { kind: "basic", username: "executor", password: "secret" },124 auth: { kind: "basic", username: "executor", password: "secret" },
125 headers: {
126 "CF-Access-Client-Id": {
127 kind: "env",
128 name: "EXECUTOR_CF_ACCESS_CLIENT_ID",
129 },
130 },
117 },131 },
118 },132 },
119 ],133 ],
@@ -127,5 +141,72 @@ describe("CLI server connection profiles", () => { · 7 unmodified lines
127 username: "executor",141 username: "executor",
128 password: "secret",142 password: "secret",
129 });143 });
144 expect(store.profiles[0]?.connection.headers).toEqual({
145 "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
146 });
130 });147 });
148
149 // -------------------------------------------------------------------------
150 // File permissions.
151 //
152 // This store holds live credentials for a hosted server — a bearer token, or
153 // an OAuth access token and its long-lived refresh token. Nothing pinned its
154 // mode before, so it was created with the process umask (0644 by default) and
155 // readable by every other account on the machine.
156 //
157 // Both cases are covered because they need different mechanisms: `mode` on
158 // create, and a `chmod` for the overwrite. The overwrite is the common path
159 // here, since the store is rewritten on every silent token refresh.
160 // -------------------------------------------------------------------------
161
162 it.effect("creates the credential store owner-only", () =>
163 Effect.gen(function* () {
164 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-mode-"));
165 process.env.EXECUTOR_DATA_DIR = dataDir;
166
167 try {
168 yield* upsertCliServerConnectionProfile({
169 name: "remote",
170 connection: {
171 origin: "https://executor.example",
172 auth: { kind: "bearer", token: "key_should_not_be_world_readable" },
173 },
174 makeDefault: true,
175 });
176
177 const mode = statSync(join(dataDir, "server-connections.json")).mode & 0o777;
178 expect(mode).toBe(0o600);
179 } finally {
180 rmSync(dataDir, { recursive: true, force: true });
181 }
182 }).pipe(Effect.provide(BunServices.layer)),
183 );
184
185 it.effect("tightens a pre-existing world-readable credential store on rewrite", () =>
186 Effect.gen(function* () {
187 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-chmod-"));
188 process.env.EXECUTOR_DATA_DIR = dataDir;
189 const storePath = join(dataDir, "server-connections.json");
190
191 try {
192 // A store left behind by an older version, world-readable. `mode` on
193 // write is ignored for an existing file, so only the chmod fixes this.
194 writeFileSync(storePath, JSON.stringify({ version: 1, profiles: [] }));
195 chmodSync(storePath, 0o644);
196
197 yield* upsertCliServerConnectionProfile({
198 name: "remote",
199 connection: {
200 origin: "https://executor.example",
201 auth: { kind: "bearer", token: "key_should_not_be_world_readable" },
202 },
203 makeDefault: true,
204 });
205
206 expect(statSync(storePath).mode & 0o777).toBe(0o600);
207 } finally {
208 rmSync(dataDir, { recursive: true, force: true });
209 }
210 }).pipe(Effect.provide(BunServices.layer)),
211 );
131});212});
apps/cli/src/server-profile.ts+22−4
@@ -62,6 +62,11 @@ const PersistedAuth = Schema.Union([ · 61 unmodified lines
62 }),62 }),
63]);63]);
64 64
65const PersistedHeader = Schema.Struct({
66 kind: Schema.Literal("env"),
67 name: Schema.String,
68});
69
65const PersistedConnection = Schema.Struct({70const PersistedConnection = Schema.Struct({
66 kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])),71 kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])),
67 key: Schema.optional(Schema.String),72 key: Schema.optional(Schema.String),
@@ -69,6 +74,7 @@ const PersistedConnection = Schema.Struct({ · 1 unmodified line
69 apiBaseUrl: Schema.optional(Schema.String),74 apiBaseUrl: Schema.optional(Schema.String),
70 displayName: Schema.optional(Schema.String),75 displayName: Schema.optional(Schema.String),
71 auth: Schema.optional(PersistedAuth),76 auth: Schema.optional(PersistedAuth),
77 headers: Schema.optional(Schema.Record(Schema.String, PersistedHeader)),
72});78});
73 79
74const PersistedProfile = Schema.Struct({80const PersistedProfile = Schema.Struct({
@@ -144,10 +150,22 @@ export const writeCliServerConnectionStore = ( · 69 unmodified lines
144 const path = yield* Path.Path;150 const path = yield* Path.Path;
145 const dataDir = resolveDataDir(path);151 const dataDir = resolveDataDir(path);
146 yield* fs.makeDirectory(dataDir, { recursive: true });152 yield* fs.makeDirectory(dataDir, { recursive: true });
147 yield* fs.writeFileString(153 const storePath = serverConnectionStorePath(path);
148 serverConnectionStorePath(path),154 // This store holds live credentials for a hosted server — a bearer token, or
149 serializeCliServerConnectionStore(store),155 // an OAuth access token AND its long-lived refresh token — so create it
150 );156 // owner-only, exactly as the local-server manifest does for the sibling
157 // secret it keeps under `server-control/`.
158 //
159 // Both steps are needed, and the second matters more here than it does
160 // there. `mode` applies only when the file is CREATED, so it closes the
161 // window where a fresh store is briefly world-readable. The `chmod` covers
162 // overwriting a store that already exists with looser permissions — and this
163 // file is rewritten on every silent token refresh, so overwrite is the
164 // common path, not the rare one.
165 yield* fs.writeFileString(storePath, serializeCliServerConnectionStore(store), {
166 mode: 0o600,
167 });
168 yield* fs.chmod(storePath, 0o600).pipe(Effect.ignore);
151 });169 });
152 170
153export const upsertCliServerConnectionProfile = (input: {171export const upsertCliServerConnectionProfile = (input: {
apps/cli/src/service.test.ts+30−0
@@ -185,6 +185,36 @@ describe("service unit generation", () => { · 184 unmodified lines
185 expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD");185 expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD");
186 });186 });
187 187
188 it("preserves custom TLS trust paths in the supervised environment", () => {
189 const previousNodeExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS;
190 const previousSslCertFile = process.env.SSL_CERT_FILE;
191 const previousSslCertDir = process.env.SSL_CERT_DIR;
192 process.env.NODE_EXTRA_CA_CERTS = "/Users/x/.certs/node-extra.pem";
193 process.env.SSL_CERT_FILE = "/Users/x/.certs/combined.pem";
194 process.env.SSL_CERT_DIR = "/Users/x/.certs";
195
196 const wrapper = generateWindowsDaemonWrapper(
197 {
198 executablePath: "C:\\Program Files\\Executor\\executor.exe",
199 port: 4789,
200 version: "1.5.10",
201 },
202 "C:\\Users\\x\\.executor",
203 "C:\\Users\\x\\.executor\\logs",
204 );
205
206 if (previousNodeExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
207 else process.env.NODE_EXTRA_CA_CERTS = previousNodeExtraCaCerts;
208 if (previousSslCertFile === undefined) delete process.env.SSL_CERT_FILE;
209 else process.env.SSL_CERT_FILE = previousSslCertFile;
210 if (previousSslCertDir === undefined) delete process.env.SSL_CERT_DIR;
211 else process.env.SSL_CERT_DIR = previousSslCertDir;
212
213 expect(wrapper).toContain('set "NODE_EXTRA_CA_CERTS=/Users/x/.certs/node-extra.pem"');
214 expect(wrapper).toContain('set "SSL_CERT_FILE=/Users/x/.certs/combined.pem"');
215 expect(wrapper).toContain('set "SSL_CERT_DIR=/Users/x/.certs"');
216 });
217
188 it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => {218 it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => {
189 // A `"` in PATH would close the `set "PATH=..."` quote early and let a219 // A `"` in PATH would close the `set "PATH=..."` quote early and let a
190 // `& cmd &` fragment run at boot as the user; strip it (illegal in a path220 // `& cmd &` fragment run at boot as the user; strip it (illegal in a path
apps/cli/src/service.ts+11−0
@@ -177,6 +177,17 @@ const serviceEnvironment = ( · 176 unmodified lines
177 "EXECUTOR_SENTRY_RELEASE",177 "EXECUTOR_SENTRY_RELEASE",
178 "EXECUTOR_SENTRY_ENVIRONMENT",178 "EXECUTOR_SENTRY_ENVIRONMENT",
179 "EXECUTOR_RUN_ID",179 "EXECUTOR_RUN_ID",
180 // Analytics opt-out must survive into the supervised unit's minimal env,
181 // or an opted-out install would silently re-enable analytics under launchd.
182 "DO_NOT_TRACK",
183 "EXECUTOR_DISABLE_ANALYTICS",
184 // Preserve the caller's TLS trust configuration. Corporate/intercepting
185 // CAs commonly live outside the OS trust store; dropping these paths in a
186 // minimal launchd/systemd environment makes HTTPS integrations fail only
187 // after service installation while the same CLI call succeeds.
188 "NODE_EXTRA_CA_CERTS",
189 "SSL_CERT_FILE",
190 "SSL_CERT_DIR",
180 ] as const;191 ] as const;
181 const passThrough = Object.fromEntries(192 const passThrough = Object.fromEntries(
182 passThroughKeys.flatMap((key) => {193 passThroughKeys.flatMap((key) => {
apps/cli/src/tooling.ts+2−2
@@ -408,14 +408,14 @@ export const buildSearchToolsCode = (input: { · 407 unmodified lines
408 return `return await tools.search(${JSON.stringify(payload)});`;408 return `return await tools.search(${JSON.stringify(payload)});`;
409};409};
410 410
411export const buildListSourcesCode = (input: { query?: string; limit: number }): string => {411export const buildListIntegrationsCode = (input: { query?: string; limit: number }): string => {
412 const payload: Record<string, unknown> = {412 const payload: Record<string, unknown> = {
413 limit: input.limit,413 limit: input.limit,
414 };414 };
415 if (input.query && input.query.trim().length > 0) {415 if (input.query && input.query.trim().length > 0) {
416 payload.query = input.query;416 payload.query = input.query;
417 }417 }
418 return `return await tools.executor.sources.list(${JSON.stringify(payload)});`;418 return `return await tools.executor.integrations.list(${JSON.stringify(payload)});`;
419};419};
420 420
421export const buildDescribeToolCode = (toolPath: string): string =>421export const buildDescribeToolCode = (toolPath: string): string =>
apps/cli/src/worker-bundler-artifact.test.ts+59−0
@@ -0,0 +1,59 @@
1import { describe, expect, it } from "@effect/vitest";
2import { join } from "node:path";
3
4import {
5 WORKER_BUNDLER_DIRNAME,
6 WORKER_BUNDLER_RUNTIME_FILES,
7 missingWorkerBundlerFiles,
8} from "./worker-bundler-artifact";
9
10const STAGED_DIR = join("/opt/executor/bin", WORKER_BUNDLER_DIRNAME);
11
12/** An existence probe that reports only the given POSIX-relative paths. */
13const staged =
14 (...relatives: readonly string[]) =>
15 (path: string): boolean =>
16 relatives.some((relative) => path === join(STAGED_DIR, ...relative.split("/")));
17
18describe("missingWorkerBundlerFiles", () => {
19 it("reports nothing when the full artifact is staged", () => {
20 expect(missingWorkerBundlerFiles(STAGED_DIR, staged(...WORKER_BUNDLER_RUNTIME_FILES))).toEqual(
21 [],
22 );
23 });
24
25 it("reports every file when the directory is empty", () => {
26 expect(missingWorkerBundlerFiles(STAGED_DIR, () => false)).toEqual([
27 ...WORKER_BUNDLER_RUNTIME_FILES,
28 ]);
29 });
30
31 // The regression this whole seam exists for: the packaged daemon reads the
32 // wasm from disk beside the entry. A copy that brought the JS but dropped the
33 // wasm used to pass the build and fail on the user's machine at startup.
34 it("reports the esbuild wasm when only the JS entrypoints are staged", () => {
35 expect(
36 missingWorkerBundlerFiles(STAGED_DIR, staged("dist/index.js", "dist/index.bundled.js")),
37 ).toEqual(["dist/esbuild.wasm"]);
38 });
39
40 // `index.bundled.js` is the file consumers load — the package's own
41 // `dist/index.js` still has bare imports nothing can resolve inside the
42 // compiled binary. Staging index.js alone is not enough.
43 it("reports the packed entry when only the unbundled package entry is staged", () => {
44 expect(
45 missingWorkerBundlerFiles(STAGED_DIR, staged("dist/index.js", "dist/esbuild.wasm")),
46 ).toEqual(["dist/index.bundled.js"]);
47 });
48
49 it("names files relative to the staged directory so errors are quotable", () => {
50 for (const relative of WORKER_BUNDLER_RUNTIME_FILES) {
51 expect(relative.startsWith("dist/")).toBe(true);
52 }
53 });
54
55 it("requires the packed entry and the wasm, not just the package entry", () => {
56 expect(WORKER_BUNDLER_RUNTIME_FILES).toContain("dist/index.bundled.js");
57 expect(WORKER_BUNDLER_RUNTIME_FILES).toContain("dist/esbuild.wasm");
58 });
59});
apps/cli/src/worker-bundler-artifact.ts+55−0
@@ -0,0 +1,55 @@
1// ---------------------------------------------------------------------------
2// The packed `@cloudflare/worker-bundler` artifact that ships NEXT TO the
3// compiled binary.
4//
5// `bun build --compile` cannot embed a package that is only ever addressed by
6// bare specifier at runtime: bunfs has no `node_modules`, so an
7// `import("@cloudflare/worker-bundler")` evaluated inside the binary resolves
8// against the virtual root and throws
9// ResolveMessage: Cannot find module '@cloudflare/worker-bundler'
10// The build therefore copies the package's `dist/` next to the executable and
11// `native-bindings.ts` publishes its absolute path as
12// EXECUTOR_WORKER_BUNDLER_DIR. Consumers must load from that directory; a bare
13// specifier is unresolvable in the packaged daemon by construction.
14//
15// This module is the single source of truth for which files that contract
16// needs, so the build-time staging check and the runtime lookup cannot drift.
17// They previously did: the build wrote `dist/index.bundled.js` (the file
18// consumers actually load) while the runtime check only looked for
19// `dist/index.js` and `dist/esbuild.wasm`. That drift fails OPEN — an
20// incomplete staged copy passes the build, the runtime silently declines to
21// publish the env var, and the consumer falls through to the bare specifier.
22// ---------------------------------------------------------------------------
23
24import { join } from "node:path";
25
26/** Directory name staged beside the executable, and looked up from execDir. */
27export const WORKER_BUNDLER_DIRNAME = "worker-bundler";
28
29/**
30 * Files the colocated copy must contain to be usable.
31 *
32 * `index.bundled.js` is the entrypoint consumers load (the build packs it with
33 * esbuild so it has no bare-specifier imports of its own); `esbuild.wasm` is
34 * read at runtime beside it; `index.js` is the package's own entry, kept so the
35 * staged directory stays a faithful copy rather than a lookalike.
36 */
37export const WORKER_BUNDLER_RUNTIME_FILES = [
38 "dist/index.js",
39 "dist/index.bundled.js",
40 "dist/esbuild.wasm",
41] as const;
42
43/**
44 * Which required files are absent from a staged worker-bundler directory.
45 *
46 * Takes the existence probe as a parameter so the build script, the runtime
47 * bootstrap, and tests all agree on the requirement without touching a real
48 * filesystem. Returns the POSIX-relative names, which is what error messages
49 * should quote.
50 */
51export const missingWorkerBundlerFiles = (
52 dir: string,
53 exists: (path: string) => boolean,
54): readonly string[] =>
55 WORKER_BUNDLER_RUNTIME_FILES.filter((relative) => !exists(join(dir, ...relative.split("/"))));
apps/cli/tsconfig.json+1−1
@@ -8,7 +8,7 @@ · 7 unmodified lines
8 "skipLibCheck": true,8 "skipLibCheck": true,
9 "noEmit": true,9 "noEmit": true,
10 "declaration": true,10 "declaration": true,
11 "types": ["bun-types"]11 "types": ["bun-types", "node"]
12 },12 },
13 "include": ["src", "bin"]13 "include": ["src", "bin"]
14}14}
apps/cloud/CHANGELOG.md+293−0
@@ -1,5 +1,298 @@
1# @executor-js/cloud1# @executor-js/cloud
2 2
3## 1.4.61
4
5### Patch Changes
6
7- Updated dependencies [[`c11bef2`](https://github.com/UsefulSoftwareCo/executor/commit/c11bef2cd049db7bbf51b15e18761b14acccb534), [`46cea2c`](https://github.com/UsefulSoftwareCo/executor/commit/46cea2cbb1f414ae58ac876819a51b11967909a6), [`a2d1417`](https://github.com/UsefulSoftwareCo/executor/commit/a2d141758e478274813c8c24d354e1fd0f66af49), [`2bdbedf`](https://github.com/UsefulSoftwareCo/executor/commit/2bdbedf257f54d7c209e8c856c618174c10d6bb3), [`0b0b74f`](https://github.com/UsefulSoftwareCo/executor/commit/0b0b74f673b8098c5248159be36c648097f3c87b), [`256e25e`](https://github.com/UsefulSoftwareCo/executor/commit/256e25e7b291b0c023bc7547d092004b66781bba)]:
8 - @executor-js/plugin-mcp@1.6.0
9 - @executor-js/plugin-openapi@1.6.0
10 - @executor-js/sdk@1.6.0
11 - @executor-js/react@1.4.63
12 - @executor-js/runtime-dynamic-worker@1.4.4
13 - @executor-js/api@1.4.63
14 - @executor-js/execution@1.6.0
15 - @executor-js/vite-plugin@0.0.60
16 - @executor-js/cloudflare@0.0.42
17 - @executor-js/host-mcp@1.4.4
18 - @executor-js/mcp-apps-shell@1.4.11
19 - @executor-js/plugin-graphql@1.6.0
20 - @executor-js/plugin-toolkits@1.5.35
21 - @executor-js/plugin-workos-vault@0.0.2
22 - @executor-js/runtime-quickjs@1.6.0
23
24## 1.4.60
25
26### Patch Changes
27
28- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d), [`86c68af`](https://github.com/UsefulSoftwareCo/executor/commit/86c68afef9bf8b7c19ab58f59acfedca0b3c4ca7), [`32206c7`](https://github.com/UsefulSoftwareCo/executor/commit/32206c7f78654f638bfd27c25c71c30c3d6354be), [`9ecc7cb`](https://github.com/UsefulSoftwareCo/executor/commit/9ecc7cb8b30375ffa960e3fefe4d211e0254e691)]:
29 - @executor-js/sdk@1.5.42
30 - @executor-js/cloudflare@0.0.41
31 - @executor-js/plugin-openapi@1.5.42
32 - @executor-js/plugin-mcp@1.5.42
33 - @executor-js/api@1.4.62
34 - @executor-js/execution@1.5.42
35 - @executor-js/vite-plugin@0.0.59
36 - @executor-js/host-mcp@1.4.4
37 - @executor-js/mcp-apps-shell@1.4.10
38 - @executor-js/runtime-dynamic-worker@1.4.4
39 - @executor-js/plugin-graphql@1.5.42
40 - @executor-js/plugin-toolkits@1.5.34
41 - @executor-js/plugin-workos-vault@0.0.2
42 - @executor-js/react@1.4.62
43 - @executor-js/runtime-quickjs@1.5.42
44
45## 1.4.59
46
47### Patch Changes
48
49- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd), [`a9b33d2`](https://github.com/UsefulSoftwareCo/executor/commit/a9b33d25c32fbb4a292b7e8963e22392f862a16f)]:
50 - @executor-js/sdk@1.5.41
51 - @executor-js/plugin-mcp@1.5.41
52 - @executor-js/api@1.4.61
53 - @executor-js/execution@1.5.41
54 - @executor-js/vite-plugin@0.0.58
55 - @executor-js/cloudflare@0.0.40
56 - @executor-js/host-mcp@1.4.4
57 - @executor-js/mcp-apps-shell@1.4.9
58 - @executor-js/runtime-dynamic-worker@1.4.4
59 - @executor-js/plugin-graphql@1.5.41
60 - @executor-js/plugin-openapi@1.5.41
61 - @executor-js/plugin-toolkits@1.5.33
62 - @executor-js/plugin-workos-vault@0.0.2
63 - @executor-js/react@1.4.61
64 - @executor-js/runtime-quickjs@1.5.41
65
66## 1.4.58
67
68### Patch Changes
69
70- Updated dependencies [[`8ba64f6`](https://github.com/UsefulSoftwareCo/executor/commit/8ba64f675f6d6ab5302d4f68390c0b055d006f4a)]:
71 - @executor-js/sdk@1.5.40
72 - @executor-js/api@1.4.60
73 - @executor-js/execution@1.5.40
74 - @executor-js/vite-plugin@0.0.57
75 - @executor-js/cloudflare@0.0.39
76 - @executor-js/host-mcp@1.4.4
77 - @executor-js/mcp-apps-shell@1.4.8
78 - @executor-js/runtime-dynamic-worker@1.4.4
79 - @executor-js/plugin-graphql@1.5.40
80 - @executor-js/plugin-mcp@1.5.40
81 - @executor-js/plugin-openapi@1.5.40
82 - @executor-js/plugin-toolkits@1.5.32
83 - @executor-js/plugin-workos-vault@0.0.2
84 - @executor-js/react@1.4.60
85 - @executor-js/runtime-quickjs@1.5.40
86
87## 1.4.57
88
89### Patch Changes
90
91- Updated dependencies [[`6c316c7`](https://github.com/UsefulSoftwareCo/executor/commit/6c316c77a9efc98784976236852b58c6156e016e)]:
92 - @executor-js/sdk@1.5.39
93 - @executor-js/api@1.4.59
94 - @executor-js/execution@1.5.39
95 - @executor-js/vite-plugin@0.0.56
96 - @executor-js/cloudflare@0.0.38
97 - @executor-js/host-mcp@1.4.4
98 - @executor-js/mcp-apps-shell@1.4.7
99 - @executor-js/runtime-dynamic-worker@1.4.4
100 - @executor-js/plugin-graphql@1.5.39
101 - @executor-js/plugin-mcp@1.5.39
102 - @executor-js/plugin-openapi@1.5.39
103 - @executor-js/plugin-toolkits@1.5.31
104 - @executor-js/plugin-workos-vault@0.0.2
105 - @executor-js/react@1.4.59
106 - @executor-js/runtime-quickjs@1.5.39
107
108## 1.4.56
109
110### Patch Changes
111
112- Updated dependencies [[`df01d91`](https://github.com/UsefulSoftwareCo/executor/commit/df01d9197e7b4fca9bd0adaca0705a80435e188c), [`6a924dd`](https://github.com/UsefulSoftwareCo/executor/commit/6a924dd98de916d6ff8cea2329bf672f149b64f4), [`1de85fc`](https://github.com/UsefulSoftwareCo/executor/commit/1de85fc0201c0c23c0e71e003c49228d406af6c8)]:
113 - @executor-js/plugin-openapi@1.5.38
114 - @executor-js/sdk@1.5.38
115 - @executor-js/react@1.4.58
116 - @executor-js/runtime-dynamic-worker@1.4.4
117 - @executor-js/api@1.4.58
118 - @executor-js/execution@1.5.38
119 - @executor-js/vite-plugin@0.0.55
120 - @executor-js/cloudflare@0.0.37
121 - @executor-js/host-mcp@1.4.4
122 - @executor-js/mcp-apps-shell@1.4.6
123 - @executor-js/plugin-graphql@1.5.38
124 - @executor-js/plugin-mcp@1.5.38
125 - @executor-js/plugin-toolkits@1.5.30
126 - @executor-js/plugin-workos-vault@0.0.2
127 - @executor-js/runtime-quickjs@1.5.38
128
129## 1.4.55
130
131### Patch Changes
132
133- Updated dependencies [[`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb)]:
134 - @executor-js/sdk@1.5.37
135 - @executor-js/api@1.4.57
136 - @executor-js/execution@1.5.37
137 - @executor-js/vite-plugin@0.0.54
138 - @executor-js/cloudflare@0.0.36
139 - @executor-js/host-mcp@1.4.4
140 - @executor-js/mcp-apps-shell@1.4.5
141 - @executor-js/runtime-dynamic-worker@1.4.4
142 - @executor-js/plugin-graphql@1.5.37
143 - @executor-js/plugin-mcp@1.5.37
144 - @executor-js/plugin-openapi@1.5.37
145 - @executor-js/plugin-toolkits@1.5.29
146 - @executor-js/plugin-workos-vault@0.0.2
147 - @executor-js/react@1.4.57
148 - @executor-js/runtime-quickjs@1.5.37
149
150## 1.4.54
151
152### Patch Changes
153
154- Updated dependencies []:
155 - @executor-js/sdk@1.5.36
156 - @executor-js/runtime-quickjs@1.5.36
157 - @executor-js/execution@1.5.36
158 - @executor-js/plugin-graphql@1.5.36
159 - @executor-js/plugin-mcp@1.5.36
160 - @executor-js/plugin-openapi@1.5.36
161 - @executor-js/api@1.4.56
162 - @executor-js/vite-plugin@0.0.53
163 - @executor-js/cloudflare@0.0.35
164 - @executor-js/host-mcp@1.4.4
165 - @executor-js/runtime-dynamic-worker@1.4.4
166 - @executor-js/plugin-toolkits@1.5.28
167 - @executor-js/plugin-workos-vault@0.0.2
168 - @executor-js/react@1.4.56
169
170## 1.4.53
171
172### Patch Changes
173
174- Updated dependencies [[`1b9b1f1`](https://github.com/UsefulSoftwareCo/executor/commit/1b9b1f10313834a625a411169ebf83f6181589df), [`af95edb`](https://github.com/UsefulSoftwareCo/executor/commit/af95edbb0bbde544bb1f4c6e18e9d64a2bcab0f8), [`99c808f`](https://github.com/UsefulSoftwareCo/executor/commit/99c808f09d3cf2263945efa4f6592cc4e78c9e08)]:
175 - @executor-js/sdk@1.5.35
176 - @executor-js/plugin-mcp@1.5.35
177 - @executor-js/runtime-quickjs@1.5.35
178 - @executor-js/api@1.4.55
179 - @executor-js/execution@1.5.35
180 - @executor-js/vite-plugin@0.0.52
181 - @executor-js/cloudflare@0.0.34
182 - @executor-js/host-mcp@1.4.4
183 - @executor-js/runtime-dynamic-worker@1.4.4
184 - @executor-js/plugin-apps@0.1.6
185 - @executor-js/plugin-graphql@1.5.35
186 - @executor-js/plugin-openapi@1.5.35
187 - @executor-js/plugin-toolkits@1.5.27
188 - @executor-js/plugin-workos-vault@0.0.2
189 - @executor-js/react@1.4.55
190
191## 1.4.52
192
193### Patch Changes
194
195- Updated dependencies [[`e2712db`](https://github.com/UsefulSoftwareCo/executor/commit/e2712dbff98145c5c340832ffbdcb21113b9dd78), [`a86cc4e`](https://github.com/UsefulSoftwareCo/executor/commit/a86cc4e6d0252c90834f40ee09837d8a19cab7fe), [`7207347`](https://github.com/UsefulSoftwareCo/executor/commit/720734756a70b1b4f1564bdf82dc4118e5de2b76), [`0c4e9b4`](https://github.com/UsefulSoftwareCo/executor/commit/0c4e9b49fecb35ad71c92a464c3ea01131ff9d6f), [`171de20`](https://github.com/UsefulSoftwareCo/executor/commit/171de204725d10405c693549febc3a1cce2c24d8)]:
196 - @executor-js/sdk@1.5.34
197 - @executor-js/execution@1.5.34
198 - @executor-js/plugin-openapi@1.5.34
199 - @executor-js/api@1.4.54
200 - @executor-js/vite-plugin@0.0.51
201 - @executor-js/cloudflare@0.0.33
202 - @executor-js/host-mcp@1.4.4
203 - @executor-js/runtime-dynamic-worker@1.4.4
204 - @executor-js/plugin-apps@0.1.5
205 - @executor-js/plugin-graphql@1.5.34
206 - @executor-js/plugin-mcp@1.5.34
207 - @executor-js/plugin-toolkits@1.5.26
208 - @executor-js/plugin-workos-vault@0.0.2
209 - @executor-js/react@1.4.54
210 - @executor-js/runtime-quickjs@1.5.34
211
212## 1.4.51
213
214### Patch Changes
215
216- Updated dependencies []:
217 - @executor-js/sdk@1.5.33
218 - @executor-js/runtime-quickjs@1.5.33
219 - @executor-js/execution@1.5.33
220 - @executor-js/plugin-graphql@1.5.33
221 - @executor-js/plugin-mcp@1.5.33
222 - @executor-js/plugin-openapi@1.5.33
223 - @executor-js/api@1.4.53
224 - @executor-js/vite-plugin@0.0.50
225 - @executor-js/cloudflare@0.0.32
226 - @executor-js/host-mcp@1.4.4
227 - @executor-js/runtime-dynamic-worker@1.4.4
228 - @executor-js/plugin-apps@0.1.4
229 - @executor-js/plugin-toolkits@1.5.25
230 - @executor-js/plugin-workos-vault@0.0.2
231 - @executor-js/react@1.4.53
232
233## 1.4.50
234
235### Patch Changes
236
237- Updated dependencies []:
238 - @executor-js/sdk@1.5.32
239 - @executor-js/runtime-quickjs@1.5.32
240 - @executor-js/execution@1.5.32
241 - @executor-js/plugin-graphql@1.5.32
242 - @executor-js/plugin-mcp@1.5.32
243 - @executor-js/plugin-openapi@1.5.32
244 - @executor-js/api@1.4.52
245 - @executor-js/vite-plugin@0.0.49
246 - @executor-js/cloudflare@0.0.31
247 - @executor-js/host-mcp@1.4.4
248 - @executor-js/runtime-dynamic-worker@1.4.4
249 - @executor-js/plugin-apps@0.1.3
250 - @executor-js/plugin-toolkits@1.5.24
251 - @executor-js/plugin-workos-vault@0.0.2
252 - @executor-js/react@1.4.52
253
254## 1.4.49
255
256### Patch Changes
257
258- Updated dependencies [[`9e38928`](https://github.com/UsefulSoftwareCo/executor/commit/9e38928f0fda9032b64b26990270c5d2b6690d13)]:
259 - @executor-js/plugin-openapi@1.5.31
260 - @executor-js/runtime-dynamic-worker@1.4.4
261 - @executor-js/sdk@1.5.31
262 - @executor-js/runtime-quickjs@1.5.31
263 - @executor-js/execution@1.5.31
264 - @executor-js/plugin-graphql@1.5.31
265 - @executor-js/plugin-mcp@1.5.31
266 - @executor-js/api@1.4.51
267 - @executor-js/vite-plugin@0.0.48
268 - @executor-js/cloudflare@0.0.30
269 - @executor-js/host-mcp@1.4.4
270 - @executor-js/plugin-apps@0.1.2
271 - @executor-js/plugin-toolkits@1.5.23
272 - @executor-js/plugin-workos-vault@0.0.2
273 - @executor-js/react@1.4.51
274
275## 1.4.48
276
277### Patch Changes
278
279- Updated dependencies []:
280 - @executor-js/sdk@1.5.30
281 - @executor-js/runtime-quickjs@1.5.30
282 - @executor-js/execution@1.5.30
283 - @executor-js/plugin-graphql@1.5.30
284 - @executor-js/plugin-mcp@1.5.30
285 - @executor-js/plugin-openapi@1.5.30
286 - @executor-js/api@1.4.50
287 - @executor-js/vite-plugin@0.0.47
288 - @executor-js/cloudflare@0.0.29
289 - @executor-js/host-mcp@1.4.4
290 - @executor-js/runtime-dynamic-worker@1.4.4
291 - @executor-js/plugin-apps@0.1.1
292 - @executor-js/plugin-toolkits@1.5.22
293 - @executor-js/plugin-workos-vault@0.0.2
294 - @executor-js/react@1.4.50
295
3## 1.4.47296## 1.4.47
4 297
5### Patch Changes298### Patch Changes
apps/cloud/drizzle/0010_ndjson_output_arrays.sql+23−0
@@ -0,0 +1,23 @@
1-- Extraction used to persist an NDJSON operation's PER-LINE response schema
2-- as the tool's output schema, while the invoke path returns an ARRAY of
3-- parsed lines, so describe previews promised a single object invocations
4-- never returned. The producer now wraps those schemas in an array. Persisted
5-- `tool` rows can't be recognized as NDJSON-derived from their schema alone,
6-- but the stored operation bindings kept the response content type, so the
7-- affected connections are findable: stale-mark `tools_synced_at` and the
8-- next read rebuilds their tool rows through the fixed producer.
9--
10-- Effectively idempotent: re-running re-marks the same connections, and a
11-- rebuild from unchanged bindings writes identical rows.
12UPDATE "connection" c
13SET "tools_synced_at" = NULL
14FROM (
15 SELECT DISTINCT ps."tenant", ps."data"::jsonb ->> 'integration' AS integration
16 FROM "plugin_storage" ps
17 WHERE ps."collection" = 'operation'
18 AND (ps."data"::text LIKE '%application/stream+json%'
19 OR ps."data"::text LIKE '%application/x-ndjson%'
20 OR ps."data"::text LIKE '%application/jsonl%')
21) ndjson
22WHERE c."tenant" = ndjson."tenant"
23 AND c."integration" = ndjson."integration";
apps/cloud/drizzle/0011_tense_caretaker.sql+1−0
@@ -0,0 +1 @@
1ALTER TABLE "oauth_client" ADD COLUMN "origin_redirect_uri" text;
apps/cloud/drizzle/0012_woozy_shiva.sql+10−0
@@ -0,0 +1,10 @@
1CREATE TABLE "subject" (
2 "external_id" varchar(255) NOT NULL,
3 "created_at" timestamp NOT NULL,
4 "last_seen_at" bigint,
5 "status" text,
6 "row_id" varchar(255) PRIMARY KEY NOT NULL,
7 "tenant" varchar(255) NOT NULL
8);
9--> statement-breakpoint
10CREATE UNIQUE INDEX "subject_uidx" ON "subject" USING btree ("tenant","external_id");
apps/cloud/drizzle/0013_curly_rocket_racer.sql+14−0
@@ -0,0 +1,14 @@
1CREATE TABLE "artifact" (
2 "id" varchar(255) NOT NULL,
3 "title" text NOT NULL,
4 "description" text,
5 "code" text NOT NULL,
6 "created_at" timestamp NOT NULL,
7 "updated_at" timestamp NOT NULL,
8 "row_id" varchar(255) PRIMARY KEY NOT NULL,
9 "tenant" varchar(255) NOT NULL,
10 "owner" varchar(255) NOT NULL,
11 "subject" varchar(255) NOT NULL
12);
13--> statement-breakpoint
14CREATE UNIQUE INDEX "artifact_uidx" ON "artifact" USING btree ("tenant","owner","subject","id");
apps/cloud/drizzle/0014_new_karma.sql+1−0
@@ -0,0 +1 @@
1ALTER TABLE "artifact" ADD COLUMN "bindings" json;
apps/cloud/drizzle/0015_equal_the_leader.sql+1−0
@@ -0,0 +1 @@
1ALTER TABLE "artifact" ADD COLUMN "preview" text;
apps/cloud/drizzle/meta/0010_snapshot.json+1290−0
@@ -0,0 +1,1290 @@
1{
2 "id": "c27a8958-ad73-4590-9b9b-d1b2584e0bc6",
3 "prevId": "b07ddbda-be11-4679-9f00-16d1b3b5e879",
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/0011_snapshot.json+1296−0
@@ -0,0 +1,1296 @@
1{
2 "id": "e285be7d-2427-4b1b-8b86-50a80ada25af",
3 "prevId": "c27a8958-ad73-4590-9b9b-d1b2584e0bc6",
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": {
535 "name": "config",
536 "type": "json",
537 "primaryKey": false,
538 "notNull": false
539 },
540 "health_check": {
541 "name": "health_check",
542 "type": "json",
543 "primaryKey": false,
544 "notNull": false
545 },
546 "config_revised_at": {
547 "name": "config_revised_at",
548 "type": "bigint",
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 "origin_redirect_uri": {
686 "name": "origin_redirect_uri",
687 "type": "text",
688 "primaryKey": false,
689 "notNull": false
690 },
691 "created_at": {
692 "name": "created_at",
693 "type": "timestamp",
694 "primaryKey": false,
695 "notNull": true
696 },
697 "row_id": {
698 "name": "row_id",
699 "type": "varchar(255)",
700 "primaryKey": true,
701 "notNull": true
702 },
703 "tenant": {
704 "name": "tenant",
705 "type": "varchar(255)",
706 "primaryKey": false,
707 "notNull": true
708 },
709 "owner": {
710 "name": "owner",
711 "type": "varchar(255)",
712 "primaryKey": false,
713 "notNull": true
714 },
715 "subject": {
716 "name": "subject",
717 "type": "varchar(255)",
718 "primaryKey": false,
719 "notNull": true
720 }
721 },
722 "indexes": {
723 "oauth_client_uidx": {
724 "name": "oauth_client_uidx",
725 "columns": [
726 {
727 "expression": "tenant",
728 "isExpression": false,
729 "asc": true,
730 "nulls": "last"
731 },
732 {
733 "expression": "owner",
734 "isExpression": false,
735 "asc": true,
736 "nulls": "last"
737 },
738 {
739 "expression": "subject",
740 "isExpression": false,
741 "asc": true,
742 "nulls": "last"
743 },
744 {
745 "expression": "slug",
746 "isExpression": false,
747 "asc": true,
748 "nulls": "last"
749 }
750 ],
751 "isUnique": true,
752 "concurrently": false,
753 "method": "btree",
754 "with": {}
755 }
756 },
757 "foreignKeys": {},
758 "compositePrimaryKeys": {},
759 "uniqueConstraints": {},
760 "policies": {},
761 "checkConstraints": {},
762 "isRLSEnabled": false
763 },
764 "public.oauth_session": {
765 "name": "oauth_session",
766 "schema": "",
767 "columns": {
768 "state": {
769 "name": "state",
770 "type": "varchar(255)",
771 "primaryKey": false,
772 "notNull": true
773 },
774 "client_slug": {
775 "name": "client_slug",
776 "type": "text",
777 "primaryKey": false,
778 "notNull": true
779 },
780 "integration": {
781 "name": "integration",
782 "type": "text",
783 "primaryKey": false,
784 "notNull": true
785 },
786 "name": {
787 "name": "name",
788 "type": "text",
789 "primaryKey": false,
790 "notNull": true
791 },
792 "template": {
793 "name": "template",
794 "type": "text",
795 "primaryKey": false,
796 "notNull": true
797 },
798 "redirect_url": {
799 "name": "redirect_url",
800 "type": "text",
801 "primaryKey": false,
802 "notNull": true
803 },
804 "pkce_verifier": {
805 "name": "pkce_verifier",
806 "type": "text",
807 "primaryKey": false,
808 "notNull": false
809 },
810 "identity_label": {
811 "name": "identity_label",
812 "type": "text",
813 "primaryKey": false,
814 "notNull": false
815 },
816 "payload": {
817 "name": "payload",
818 "type": "json",
819 "primaryKey": false,
820 "notNull": true
821 },
822 "expires_at": {
823 "name": "expires_at",
824 "type": "bigint",
825 "primaryKey": false,
826 "notNull": true
827 },
828 "created_at": {
829 "name": "created_at",
830 "type": "timestamp",
831 "primaryKey": false,
832 "notNull": true
833 },
834 "row_id": {
835 "name": "row_id",
836 "type": "varchar(255)",
837 "primaryKey": true,
838 "notNull": true
839 },
840 "tenant": {
841 "name": "tenant",
842 "type": "varchar(255)",
843 "primaryKey": false,
844 "notNull": true
845 },
846 "owner": {
847 "name": "owner",
848 "type": "varchar(255)",
849 "primaryKey": false,
850 "notNull": true
851 },
852 "subject": {
853 "name": "subject",
854 "type": "varchar(255)",
855 "primaryKey": false,
856 "notNull": true
857 }
858 },
859 "indexes": {
860 "oauth_session_uidx": {
861 "name": "oauth_session_uidx",
862 "columns": [
863 {
864 "expression": "tenant",
865 "isExpression": false,
866 "asc": true,
867 "nulls": "last"
868 },
869 {
870 "expression": "state",
871 "isExpression": false,
872 "asc": true,
873 "nulls": "last"
874 }
875 ],
876 "isUnique": true,
877 "concurrently": false,
878 "method": "btree",
879 "with": {}
880 }
881 },
882 "foreignKeys": {},
883 "compositePrimaryKeys": {},
884 "uniqueConstraints": {},
885 "policies": {},
886 "checkConstraints": {},
887 "isRLSEnabled": false
888 },
889 "public.plugin_storage": {
890 "name": "plugin_storage",
891 "schema": "",
892 "columns": {
893 "plugin_id": {
894 "name": "plugin_id",
895 "type": "varchar(255)",
896 "primaryKey": false,
897 "notNull": true
898 },
899 "collection": {
900 "name": "collection",
901 "type": "varchar(255)",
902 "primaryKey": false,
903 "notNull": true
904 },
905 "key": {
906 "name": "key",
907 "type": "varchar(255)",
908 "primaryKey": false,
909 "notNull": true
910 },
911 "data": {
912 "name": "data",
913 "type": "json",
914 "primaryKey": false,
915 "notNull": true
916 },
917 "created_at": {
918 "name": "created_at",
919 "type": "timestamp",
920 "primaryKey": false,
921 "notNull": true
922 },
923 "updated_at": {
924 "name": "updated_at",
925 "type": "timestamp",
926 "primaryKey": false,
927 "notNull": true
928 },
929 "row_id": {
930 "name": "row_id",
931 "type": "varchar(255)",
932 "primaryKey": true,
933 "notNull": true
934 },
935 "tenant": {
936 "name": "tenant",
937 "type": "varchar(255)",
938 "primaryKey": false,
939 "notNull": true
940 },
941 "owner": {
942 "name": "owner",
943 "type": "varchar(255)",
944 "primaryKey": false,
945 "notNull": true
946 },
947 "subject": {
948 "name": "subject",
949 "type": "varchar(255)",
950 "primaryKey": false,
951 "notNull": true
952 }
953 },
954 "indexes": {
955 "plugin_storage_uidx": {
956 "name": "plugin_storage_uidx",
957 "columns": [
958 {
959 "expression": "tenant",
960 "isExpression": false,
961 "asc": true,
962 "nulls": "last"
963 },
964 {
965 "expression": "owner",
966 "isExpression": false,
967 "asc": true,
968 "nulls": "last"
969 },
970 {
971 "expression": "subject",
972 "isExpression": false,
973 "asc": true,
974 "nulls": "last"
975 },
976 {
977 "expression": "plugin_id",
978 "isExpression": false,
979 "asc": true,
980 "nulls": "last"
981 },
982 {
983 "expression": "collection",
984 "isExpression": false,
985 "asc": true,
986 "nulls": "last"
987 },
988 {
989 "expression": "key",
990 "isExpression": false,
991 "asc": true,
992 "nulls": "last"
993 }
994 ],
995 "isUnique": true,
996 "concurrently": false,
997 "method": "btree",
998 "with": {}
999 }
1000 },
1001 "foreignKeys": {},
1002 "compositePrimaryKeys": {},
1003 "uniqueConstraints": {},
1004 "policies": {},
1005 "checkConstraints": {},
1006 "isRLSEnabled": false
1007 },
1008 "public.private_executor_cloud_settings": {
1009 "name": "private_executor_cloud_settings",
1010 "schema": "",
1011 "columns": {
1012 "id": {
1013 "name": "id",
1014 "type": "varchar(255)",
1015 "primaryKey": true,
1016 "notNull": true
1017 },
1018 "version": {
1019 "name": "version",
1020 "type": "varchar(255)",
1021 "primaryKey": false,
1022 "notNull": true,
1023 "default": "'1.0.0'"
1024 }
1025 },
1026 "indexes": {},
1027 "foreignKeys": {},
1028 "compositePrimaryKeys": {},
1029 "uniqueConstraints": {},
1030 "policies": {},
1031 "checkConstraints": {},
1032 "isRLSEnabled": false
1033 },
1034 "public.tool": {
1035 "name": "tool",
1036 "schema": "",
1037 "columns": {
1038 "integration": {
1039 "name": "integration",
1040 "type": "varchar(255)",
1041 "primaryKey": false,
1042 "notNull": true
1043 },
1044 "connection": {
1045 "name": "connection",
1046 "type": "varchar(255)",
1047 "primaryKey": false,
1048 "notNull": true
1049 },
1050 "plugin_id": {
1051 "name": "plugin_id",
1052 "type": "text",
1053 "primaryKey": false,
1054 "notNull": true
1055 },
1056 "name": {
1057 "name": "name",
1058 "type": "varchar(255)",
1059 "primaryKey": false,
1060 "notNull": true
1061 },
1062 "description": {
1063 "name": "description",
1064 "type": "text",
1065 "primaryKey": false,
1066 "notNull": true
1067 },
1068 "input_schema": {
1069 "name": "input_schema",
1070 "type": "json",
1071 "primaryKey": false,
1072 "notNull": false
1073 },
1074 "output_schema": {
1075 "name": "output_schema",
1076 "type": "json",
1077 "primaryKey": false,
1078 "notNull": false
1079 },
1080 "annotations": {
1081 "name": "annotations",
1082 "type": "json",
1083 "primaryKey": false,
1084 "notNull": false
1085 },
1086 "created_at": {
1087 "name": "created_at",
1088 "type": "timestamp",
1089 "primaryKey": false,
1090 "notNull": true
1091 },
1092 "updated_at": {
1093 "name": "updated_at",
1094 "type": "timestamp",
1095 "primaryKey": false,
1096 "notNull": true
1097 },
1098 "row_id": {
1099 "name": "row_id",
1100 "type": "varchar(255)",
1101 "primaryKey": true,
1102 "notNull": true
1103 },
1104 "tenant": {
1105 "name": "tenant",
1106 "type": "varchar(255)",
1107 "primaryKey": false,
1108 "notNull": true
1109 },
1110 "owner": {
1111 "name": "owner",
1112 "type": "varchar(255)",
1113 "primaryKey": false,
1114 "notNull": true
1115 },
1116 "subject": {
1117 "name": "subject",
1118 "type": "varchar(255)",
1119 "primaryKey": false,
1120 "notNull": true
1121 }
1122 },
1123 "indexes": {
1124 "tool_uidx": {
1125 "name": "tool_uidx",
1126 "columns": [
1127 {
1128 "expression": "tenant",
1129 "isExpression": false,
1130 "asc": true,
1131 "nulls": "last"
1132 },
1133 {
1134 "expression": "owner",
1135 "isExpression": false,
1136 "asc": true,
1137 "nulls": "last"
1138 },
1139 {
1140 "expression": "subject",
1141 "isExpression": false,
1142 "asc": true,
1143 "nulls": "last"
1144 },
1145 {
1146 "expression": "integration",
1147 "isExpression": false,
1148 "asc": true,
1149 "nulls": "last"
1150 },
1151 {
1152 "expression": "connection",
1153 "isExpression": false,
1154 "asc": true,
1155 "nulls": "last"
1156 },
1157 {
1158 "expression": "name",
1159 "isExpression": false,
1160 "asc": true,
1161 "nulls": "last"
1162 }
1163 ],
1164 "isUnique": true,
1165 "concurrently": false,
1166 "method": "btree",
1167 "with": {}
1168 }
1169 },
1170 "foreignKeys": {},
1171 "compositePrimaryKeys": {},
1172 "uniqueConstraints": {},
1173 "policies": {},
1174 "checkConstraints": {},
1175 "isRLSEnabled": false
1176 },
1177 "public.tool_policy": {
1178 "name": "tool_policy",
1179 "schema": "",
1180 "columns": {
1181 "id": {
1182 "name": "id",
1183 "type": "varchar(255)",
1184 "primaryKey": false,
1185 "notNull": true
1186 },
1187 "pattern": {
1188 "name": "pattern",
1189 "type": "text",
1190 "primaryKey": false,
1191 "notNull": true
1192 },
1193 "action": {
1194 "name": "action",
1195 "type": "text",
1196 "primaryKey": false,
1197 "notNull": true
1198 },
1199 "position": {
1200 "name": "position",
1201 "type": "text",
1202 "primaryKey": false,
1203 "notNull": true
1204 },
1205 "created_at": {
1206 "name": "created_at",
1207 "type": "timestamp",
1208 "primaryKey": false,
1209 "notNull": true
1210 },
1211 "updated_at": {
1212 "name": "updated_at",
1213 "type": "timestamp",
1214 "primaryKey": false,
1215 "notNull": true
1216 },
1217 "row_id": {
1218 "name": "row_id",
1219 "type": "varchar(255)",
1220 "primaryKey": true,
1221 "notNull": true
1222 },
1223 "tenant": {
1224 "name": "tenant",
1225 "type": "varchar(255)",
1226 "primaryKey": false,
1227 "notNull": true
1228 },
1229 "owner": {
1230 "name": "owner",
1231 "type": "varchar(255)",
1232 "primaryKey": false,
1233 "notNull": true
1234 },
1235 "subject": {
1236 "name": "subject",
1237 "type": "varchar(255)",
1238 "primaryKey": false,
1239 "notNull": true
1240 }
1241 },
1242 "indexes": {
1243 "tool_policy_uidx": {
1244 "name": "tool_policy_uidx",
1245 "columns": [
1246 {
1247 "expression": "tenant",
1248 "isExpression": false,
1249 "asc": true,
1250 "nulls": "last"
1251 },
1252 {
1253 "expression": "owner",
1254 "isExpression": false,
1255 "asc": true,
1256 "nulls": "last"
1257 },
1258 {
1259 "expression": "subject",
1260 "isExpression": false,
1261 "asc": true,
1262 "nulls": "last"
1263 },
1264 {
1265 "expression": "id",
1266 "isExpression": false,
1267 "asc": true,
1268 "nulls": "last"
1269 }
1270 ],
1271 "isUnique": true,
1272 "concurrently": false,
1273 "method": "btree",
1274 "with": {}
1275 }
1276 },
1277 "foreignKeys": {},
1278 "compositePrimaryKeys": {},
1279 "uniqueConstraints": {},
1280 "policies": {},
1281 "checkConstraints": {},
1282 "isRLSEnabled": false
1283 }
1284 },
1285 "enums": {},
1286 "schemas": {},
1287 "sequences": {},
1288 "roles": {},
1289 "policies": {},
1290 "views": {},
1291 "_meta": {
1292 "columns": {},
1293 "schemas": {},
1294 "tables": {}
1295 }
1296}
apps/cloud/drizzle/meta/0012_snapshot.json+1367−0
@@ -0,0 +1,1367 @@
1{
2 "id": "97114994-f0fc-4419-80bf-21c529a6cf0b",
3 "prevId": "e285be7d-2427-4b1b-8b86-50a80ada25af",
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": {
535 "name": "config",
536 "type": "json",
537 "primaryKey": false,
538 "notNull": false
539 },
540 "health_check": {
541 "name": "health_check",
542 "type": "json",
543 "primaryKey": false,
544 "notNull": false
545 },
546 "config_revised_at": {
547 "name": "config_revised_at",
548 "type": "bigint",
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 "origin_redirect_uri": {
686 "name": "origin_redirect_uri",
687 "type": "text",
688 "primaryKey": false,
689 "notNull": false
690 },
691 "created_at": {
692 "name": "created_at",
693 "type": "timestamp",
694 "primaryKey": false,
695 "notNull": true
696 },
697 "row_id": {
698 "name": "row_id",
699 "type": "varchar(255)",
700 "primaryKey": true,
701 "notNull": true
702 },
703 "tenant": {
704 "name": "tenant",
705 "type": "varchar(255)",
706 "primaryKey": false,
707 "notNull": true
708 },
709 "owner": {
710 "name": "owner",
711 "type": "varchar(255)",
712 "primaryKey": false,
713 "notNull": true
714 },
715 "subject": {
716 "name": "subject",
717 "type": "varchar(255)",
718 "primaryKey": false,
719 "notNull": true
720 }
721 },
722 "indexes": {
723 "oauth_client_uidx": {
724 "name": "oauth_client_uidx",
725 "columns": [
726 {
727 "expression": "tenant",
728 "isExpression": false,
729 "asc": true,
730 "nulls": "last"
731 },
732 {
733 "expression": "owner",
734 "isExpression": false,
735 "asc": true,
736 "nulls": "last"
737 },
738 {
739 "expression": "subject",
740 "isExpression": false,
741 "asc": true,
742 "nulls": "last"
743 },
744 {
745 "expression": "slug",
746 "isExpression": false,
747 "asc": true,
748 "nulls": "last"
749 }
750 ],
751 "isUnique": true,
752 "concurrently": false,
753 "method": "btree",
754 "with": {}
755 }
756 },
757 "foreignKeys": {},
758 "compositePrimaryKeys": {},
759 "uniqueConstraints": {},
760 "policies": {},
761 "checkConstraints": {},
762 "isRLSEnabled": false
763 },
764 "public.oauth_session": {
765 "name": "oauth_session",
766 "schema": "",
767 "columns": {
768 "state": {
769 "name": "state",
770 "type": "varchar(255)",
771 "primaryKey": false,
772 "notNull": true
773 },
774 "client_slug": {
775 "name": "client_slug",
776 "type": "text",
777 "primaryKey": false,
778 "notNull": true
779 },
780 "integration": {
781 "name": "integration",
782 "type": "text",
783 "primaryKey": false,
784 "notNull": true
785 },
786 "name": {
787 "name": "name",
788 "type": "text",
789 "primaryKey": false,
790 "notNull": true
791 },
792 "template": {
793 "name": "template",
794 "type": "text",
795 "primaryKey": false,
796 "notNull": true
797 },
798 "redirect_url": {
799 "name": "redirect_url",
800 "type": "text",
801 "primaryKey": false,
802 "notNull": true
803 },
804 "pkce_verifier": {
805 "name": "pkce_verifier",
806 "type": "text",
807 "primaryKey": false,
808 "notNull": false
809 },
810 "identity_label": {
811 "name": "identity_label",
812 "type": "text",
813 "primaryKey": false,
814 "notNull": false
815 },
816 "payload": {
817 "name": "payload",
818 "type": "json",
819 "primaryKey": false,
820 "notNull": true
821 },
822 "expires_at": {
823 "name": "expires_at",
824 "type": "bigint",
825 "primaryKey": false,
826 "notNull": true
827 },
828 "created_at": {
829 "name": "created_at",
830 "type": "timestamp",
831 "primaryKey": false,
832 "notNull": true
833 },
834 "row_id": {
835 "name": "row_id",
836 "type": "varchar(255)",
837 "primaryKey": true,
838 "notNull": true
839 },
840 "tenant": {
841 "name": "tenant",
842 "type": "varchar(255)",
843 "primaryKey": false,
844 "notNull": true
845 },
846 "owner": {
847 "name": "owner",
848 "type": "varchar(255)",
849 "primaryKey": false,
850 "notNull": true
851 },
852 "subject": {
853 "name": "subject",
854 "type": "varchar(255)",
855 "primaryKey": false,
856 "notNull": true
857 }
858 },
859 "indexes": {
860 "oauth_session_uidx": {
861 "name": "oauth_session_uidx",
862 "columns": [
863 {
864 "expression": "tenant",
865 "isExpression": false,
866 "asc": true,
867 "nulls": "last"
868 },
869 {
870 "expression": "state",
871 "isExpression": false,
872 "asc": true,
873 "nulls": "last"
874 }
875 ],
876 "isUnique": true,
877 "concurrently": false,
878 "method": "btree",
879 "with": {}
880 }
881 },
882 "foreignKeys": {},
883 "compositePrimaryKeys": {},
884 "uniqueConstraints": {},
885 "policies": {},
886 "checkConstraints": {},
887 "isRLSEnabled": false
888 },
889 "public.plugin_storage": {
890 "name": "plugin_storage",
891 "schema": "",
892 "columns": {
893 "plugin_id": {
894 "name": "plugin_id",
895 "type": "varchar(255)",
896 "primaryKey": false,
897 "notNull": true
898 },
899 "collection": {
900 "name": "collection",
901 "type": "varchar(255)",
902 "primaryKey": false,
903 "notNull": true
904 },
905 "key": {
906 "name": "key",
907 "type": "varchar(255)",
908 "primaryKey": false,
909 "notNull": true
910 },
911 "data": {
912 "name": "data",
913 "type": "json",
914 "primaryKey": false,
915 "notNull": true
916 },
917 "created_at": {
918 "name": "created_at",
919 "type": "timestamp",
920 "primaryKey": false,
921 "notNull": true
922 },
923 "updated_at": {
924 "name": "updated_at",
925 "type": "timestamp",
926 "primaryKey": false,
927 "notNull": true
928 },
929 "row_id": {
930 "name": "row_id",
931 "type": "varchar(255)",
932 "primaryKey": true,
933 "notNull": true
934 },
935 "tenant": {
936 "name": "tenant",
937 "type": "varchar(255)",
938 "primaryKey": false,
939 "notNull": true
940 },
941 "owner": {
942 "name": "owner",
943 "type": "varchar(255)",
944 "primaryKey": false,
945 "notNull": true
946 },
947 "subject": {
948 "name": "subject",
949 "type": "varchar(255)",
950 "primaryKey": false,
951 "notNull": true
952 }
953 },
954 "indexes": {
955 "plugin_storage_uidx": {
956 "name": "plugin_storage_uidx",
957 "columns": [
958 {
959 "expression": "tenant",
960 "isExpression": false,
961 "asc": true,
962 "nulls": "last"
963 },
964 {
965 "expression": "owner",
966 "isExpression": false,
967 "asc": true,
968 "nulls": "last"
969 },
970 {
971 "expression": "subject",
972 "isExpression": false,
973 "asc": true,
974 "nulls": "last"
975 },
976 {
977 "expression": "plugin_id",
978 "isExpression": false,
979 "asc": true,
980 "nulls": "last"
981 },
982 {
983 "expression": "collection",
984 "isExpression": false,
985 "asc": true,
986 "nulls": "last"
987 },
988 {
989 "expression": "key",
990 "isExpression": false,
991 "asc": true,
992 "nulls": "last"
993 }
994 ],
995 "isUnique": true,
996 "concurrently": false,
997 "method": "btree",
998 "with": {}
999 }
1000 },
1001 "foreignKeys": {},
1002 "compositePrimaryKeys": {},
1003 "uniqueConstraints": {},
1004 "policies": {},
1005 "checkConstraints": {},
1006 "isRLSEnabled": false
1007 },
1008 "public.private_executor_cloud_settings": {
1009 "name": "private_executor_cloud_settings",
1010 "schema": "",
1011 "columns": {
1012 "id": {
1013 "name": "id",
1014 "type": "varchar(255)",
1015 "primaryKey": true,
1016 "notNull": true
1017 },
1018 "version": {
1019 "name": "version",
1020 "type": "varchar(255)",
1021 "primaryKey": false,
1022 "notNull": true,
1023 "default": "'1.0.0'"
1024 }
1025 },
1026 "indexes": {},
1027 "foreignKeys": {},
1028 "compositePrimaryKeys": {},
1029 "uniqueConstraints": {},
1030 "policies": {},
1031 "checkConstraints": {},
1032 "isRLSEnabled": false
1033 },
1034 "public.subject": {
1035 "name": "subject",
1036 "schema": "",
1037 "columns": {
1038 "external_id": {
1039 "name": "external_id",
1040 "type": "varchar(255)",
1041 "primaryKey": false,
1042 "notNull": true
1043 },
1044 "created_at": {
1045 "name": "created_at",
1046 "type": "timestamp",
1047 "primaryKey": false,
1048 "notNull": true
1049 },
1050 "last_seen_at": {
1051 "name": "last_seen_at",
1052 "type": "bigint",
1053 "primaryKey": false,
1054 "notNull": false
1055 },
1056 "status": {
1057 "name": "status",
1058 "type": "text",
1059 "primaryKey": false,
1060 "notNull": false
1061 },
1062 "row_id": {
1063 "name": "row_id",
1064 "type": "varchar(255)",
1065 "primaryKey": true,
1066 "notNull": true
1067 },
1068 "tenant": {
1069 "name": "tenant",
1070 "type": "varchar(255)",
1071 "primaryKey": false,
1072 "notNull": true
1073 }
1074 },
1075 "indexes": {
1076 "subject_uidx": {
1077 "name": "subject_uidx",
1078 "columns": [
1079 {
1080 "expression": "tenant",
1081 "isExpression": false,
1082 "asc": true,
1083 "nulls": "last"
1084 },
1085 {
1086 "expression": "external_id",
1087 "isExpression": false,
1088 "asc": true,
1089 "nulls": "last"
1090 }
1091 ],
1092 "isUnique": true,
1093 "concurrently": false,
1094 "method": "btree",
1095 "with": {}
1096 }
1097 },
1098 "foreignKeys": {},
1099 "compositePrimaryKeys": {},
1100 "uniqueConstraints": {},
1101 "policies": {},
1102 "checkConstraints": {},
1103 "isRLSEnabled": false
1104 },
1105 "public.tool": {
1106 "name": "tool",
1107 "schema": "",
1108 "columns": {
1109 "integration": {
1110 "name": "integration",
1111 "type": "varchar(255)",
1112 "primaryKey": false,
1113 "notNull": true
1114 },
1115 "connection": {
1116 "name": "connection",
1117 "type": "varchar(255)",
1118 "primaryKey": false,
1119 "notNull": true
1120 },
1121 "plugin_id": {
1122 "name": "plugin_id",
1123 "type": "text",
1124 "primaryKey": false,
1125 "notNull": true
1126 },
1127 "name": {
1128 "name": "name",
1129 "type": "varchar(255)",
1130 "primaryKey": false,
1131 "notNull": true
1132 },
1133 "description": {
1134 "name": "description",
1135 "type": "text",
1136 "primaryKey": false,
1137 "notNull": true
1138 },
1139 "input_schema": {
1140 "name": "input_schema",
1141 "type": "json",
1142 "primaryKey": false,
1143 "notNull": false
1144 },
1145 "output_schema": {
1146 "name": "output_schema",
1147 "type": "json",
1148 "primaryKey": false,
1149 "notNull": false
1150 },
1151 "annotations": {
1152 "name": "annotations",
1153 "type": "json",
1154 "primaryKey": false,
1155 "notNull": false
1156 },
1157 "created_at": {
1158 "name": "created_at",
1159 "type": "timestamp",
1160 "primaryKey": false,
1161 "notNull": true
1162 },
1163 "updated_at": {
1164 "name": "updated_at",
1165 "type": "timestamp",
1166 "primaryKey": false,
1167 "notNull": true
1168 },
1169 "row_id": {
1170 "name": "row_id",
1171 "type": "varchar(255)",
1172 "primaryKey": true,
1173 "notNull": true
1174 },
1175 "tenant": {
1176 "name": "tenant",
1177 "type": "varchar(255)",
1178 "primaryKey": false,
1179 "notNull": true
1180 },
1181 "owner": {
1182 "name": "owner",
1183 "type": "varchar(255)",
1184 "primaryKey": false,
1185 "notNull": true
1186 },
1187 "subject": {
1188 "name": "subject",
1189 "type": "varchar(255)",
1190 "primaryKey": false,
1191 "notNull": true
1192 }
1193 },
1194 "indexes": {
1195 "tool_uidx": {
1196 "name": "tool_uidx",
1197 "columns": [
1198 {
1199 "expression": "tenant",
1200 "isExpression": false,
1201 "asc": true,
1202 "nulls": "last"
1203 },
1204 {
1205 "expression": "owner",
1206 "isExpression": false,
1207 "asc": true,
1208 "nulls": "last"
1209 },
1210 {
1211 "expression": "subject",
1212 "isExpression": false,
1213 "asc": true,
1214 "nulls": "last"
1215 },
1216 {
1217 "expression": "integration",
1218 "isExpression": false,
1219 "asc": true,
1220 "nulls": "last"
1221 },
1222 {
1223 "expression": "connection",
1224 "isExpression": false,
1225 "asc": true,
1226 "nulls": "last"
1227 },
1228 {
1229 "expression": "name",
1230 "isExpression": false,
1231 "asc": true,
1232 "nulls": "last"
1233 }
1234 ],
1235 "isUnique": true,
1236 "concurrently": false,
1237 "method": "btree",
1238 "with": {}
1239 }
1240 },
1241 "foreignKeys": {},
1242 "compositePrimaryKeys": {},
1243 "uniqueConstraints": {},
1244 "policies": {},
1245 "checkConstraints": {},
1246 "isRLSEnabled": false
1247 },
1248 "public.tool_policy": {
1249 "name": "tool_policy",
1250 "schema": "",
1251 "columns": {
1252 "id": {
1253 "name": "id",
1254 "type": "varchar(255)",
1255 "primaryKey": false,
1256 "notNull": true
1257 },
1258 "pattern": {
1259 "name": "pattern",
1260 "type": "text",
1261 "primaryKey": false,
1262 "notNull": true
1263 },
1264 "action": {
1265 "name": "action",
1266 "type": "text",
1267 "primaryKey": false,
1268 "notNull": true
1269 },
1270 "position": {
1271 "name": "position",
1272 "type": "text",
1273 "primaryKey": false,
1274 "notNull": true
1275 },
1276 "created_at": {
1277 "name": "created_at",
1278 "type": "timestamp",
1279 "primaryKey": false,
1280 "notNull": true
1281 },
1282 "updated_at": {
1283 "name": "updated_at",
1284 "type": "timestamp",
1285 "primaryKey": false,
1286 "notNull": true
1287 },
1288 "row_id": {
1289 "name": "row_id",
1290 "type": "varchar(255)",
1291 "primaryKey": true,
1292 "notNull": true
1293 },
1294 "tenant": {
1295 "name": "tenant",
1296 "type": "varchar(255)",
1297 "primaryKey": false,
1298 "notNull": true
1299 },
1300 "owner": {
1301 "name": "owner",
1302 "type": "varchar(255)",
1303 "primaryKey": false,
1304 "notNull": true
1305 },
1306 "subject": {
1307 "name": "subject",
1308 "type": "varchar(255)",
1309 "primaryKey": false,
1310 "notNull": true
1311 }
1312 },
1313 "indexes": {
1314 "tool_policy_uidx": {
1315 "name": "tool_policy_uidx",
1316 "columns": [
1317 {
1318 "expression": "tenant",
1319 "isExpression": false,
1320 "asc": true,
1321 "nulls": "last"
1322 },
1323 {
1324 "expression": "owner",
1325 "isExpression": false,
1326 "asc": true,
1327 "nulls": "last"
1328 },
1329 {
1330 "expression": "subject",
1331 "isExpression": false,
1332 "asc": true,
1333 "nulls": "last"
1334 },
1335 {
1336 "expression": "id",
1337 "isExpression": false,
1338 "asc": true,
1339 "nulls": "last"
1340 }
1341 ],
1342 "isUnique": true,
1343 "concurrently": false,
1344 "method": "btree",
1345 "with": {}
1346 }
1347 },
1348 "foreignKeys": {},
1349 "compositePrimaryKeys": {},
1350 "uniqueConstraints": {},
1351 "policies": {},
1352 "checkConstraints": {},
1353 "isRLSEnabled": false
1354 }
1355 },
1356 "enums": {},
1357 "schemas": {},
1358 "sequences": {},
1359 "roles": {},
1360 "policies": {},
1361 "views": {},
1362 "_meta": {
1363 "columns": {},
1364 "schemas": {},
1365 "tables": {}
1366 }
1367}
apps/cloud/drizzle/meta/0013_snapshot.json+1474−0
@@ -0,0 +1,1474 @@
1{
2 "id": "edc90067-822c-443a-8fcd-3bac77bca317",
3 "prevId": "97114994-f0fc-4419-80bf-21c529a6cf0b",
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.artifact": {
144 "name": "artifact",
145 "schema": "",
146 "columns": {
147 "id": {
148 "name": "id",
149 "type": "varchar(255)",
150 "primaryKey": false,
151 "notNull": true
152 },
153 "title": {
154 "name": "title",
155 "type": "text",
156 "primaryKey": false,
157 "notNull": true
158 },
159 "description": {
160 "name": "description",
161 "type": "text",
162 "primaryKey": false,
163 "notNull": false
164 },
165 "code": {
166 "name": "code",
167 "type": "text",
168 "primaryKey": false,
169 "notNull": true
170 },
171 "created_at": {
172 "name": "created_at",
173 "type": "timestamp",
174 "primaryKey": false,
175 "notNull": true
176 },
177 "updated_at": {
178 "name": "updated_at",
179 "type": "timestamp",
180 "primaryKey": false,
181 "notNull": true
182 },
183 "row_id": {
184 "name": "row_id",
185 "type": "varchar(255)",
186 "primaryKey": true,
187 "notNull": true
188 },
189 "tenant": {
190 "name": "tenant",
191 "type": "varchar(255)",
192 "primaryKey": false,
193 "notNull": true
194 },
195 "owner": {
196 "name": "owner",
197 "type": "varchar(255)",
198 "primaryKey": false,
199 "notNull": true
200 },
201 "subject": {
202 "name": "subject",
203 "type": "varchar(255)",
204 "primaryKey": false,
205 "notNull": true
206 }
207 },
208 "indexes": {
209 "artifact_uidx": {
210 "name": "artifact_uidx",
211 "columns": [
212 {
213 "expression": "tenant",
214 "isExpression": false,
215 "asc": true,
216 "nulls": "last"
217 },
218 {
219 "expression": "owner",
220 "isExpression": false,
221 "asc": true,
222 "nulls": "last"
223 },
224 {
225 "expression": "subject",
226 "isExpression": false,
227 "asc": true,
228 "nulls": "last"
229 },
230 {
231 "expression": "id",
232 "isExpression": false,
233 "asc": true,
234 "nulls": "last"
235 }
236 ],
237 "isUnique": true,
238 "concurrently": false,
239 "method": "btree",
240 "with": {}
241 }
242 },
243 "foreignKeys": {},
244 "compositePrimaryKeys": {},
245 "uniqueConstraints": {},
246 "policies": {},
247 "checkConstraints": {},
248 "isRLSEnabled": false
249 },
250 "public.blob": {
251 "name": "blob",
252 "schema": "",
253 "columns": {
254 "namespace": {
255 "name": "namespace",
256 "type": "varchar(255)",
257 "primaryKey": false,
258 "notNull": true
259 },
260 "key": {
261 "name": "key",
262 "type": "varchar(255)",
263 "primaryKey": false,
264 "notNull": true
265 },
266 "value": {
267 "name": "value",
268 "type": "text",
269 "primaryKey": false,
270 "notNull": true
271 },
272 "row_id": {
273 "name": "row_id",
274 "type": "varchar(255)",
275 "primaryKey": true,
276 "notNull": true
277 },
278 "id": {
279 "name": "id",
280 "type": "varchar(255)",
281 "primaryKey": false,
282 "notNull": true
283 }
284 },
285 "indexes": {
286 "blob_id_uidx": {
287 "name": "blob_id_uidx",
288 "columns": [
289 {
290 "expression": "id",
291 "isExpression": false,
292 "asc": true,
293 "nulls": "last"
294 }
295 ],
296 "isUnique": true,
297 "concurrently": false,
298 "method": "btree",
299 "with": {}
300 }
301 },
302 "foreignKeys": {},
303 "compositePrimaryKeys": {},
304 "uniqueConstraints": {},
305 "policies": {},
306 "checkConstraints": {},
307 "isRLSEnabled": false
308 },
309 "public.connection": {
310 "name": "connection",
311 "schema": "",
312 "columns": {
313 "integration": {
314 "name": "integration",
315 "type": "varchar(255)",
316 "primaryKey": false,
317 "notNull": true
318 },
319 "name": {
320 "name": "name",
321 "type": "varchar(255)",
322 "primaryKey": false,
323 "notNull": true
324 },
325 "template": {
326 "name": "template",
327 "type": "text",
328 "primaryKey": false,
329 "notNull": true
330 },
331 "provider": {
332 "name": "provider",
333 "type": "text",
334 "primaryKey": false,
335 "notNull": true
336 },
337 "item_ids": {
338 "name": "item_ids",
339 "type": "json",
340 "primaryKey": false,
341 "notNull": true
342 },
343 "identity_label": {
344 "name": "identity_label",
345 "type": "text",
346 "primaryKey": false,
347 "notNull": false
348 },
349 "description": {
350 "name": "description",
351 "type": "text",
352 "primaryKey": false,
353 "notNull": false
354 },
355 "last_health": {
356 "name": "last_health",
357 "type": "json",
358 "primaryKey": false,
359 "notNull": false
360 },
361 "tools_synced_at": {
362 "name": "tools_synced_at",
363 "type": "bigint",
364 "primaryKey": false,
365 "notNull": false
366 },
367 "oauth_client": {
368 "name": "oauth_client",
369 "type": "text",
370 "primaryKey": false,
371 "notNull": false
372 },
373 "oauth_client_owner": {
374 "name": "oauth_client_owner",
375 "type": "text",
376 "primaryKey": false,
377 "notNull": false
378 },
379 "refresh_item_id": {
380 "name": "refresh_item_id",
381 "type": "text",
382 "primaryKey": false,
383 "notNull": false
384 },
385 "expires_at": {
386 "name": "expires_at",
387 "type": "bigint",
388 "primaryKey": false,
389 "notNull": false
390 },
391 "oauth_scope": {
392 "name": "oauth_scope",
393 "type": "text",
394 "primaryKey": false,
395 "notNull": false
396 },
397 "oauth_token_url": {
398 "name": "oauth_token_url",
399 "type": "text",
400 "primaryKey": false,
401 "notNull": false
402 },
403 "provider_state": {
404 "name": "provider_state",
405 "type": "json",
406 "primaryKey": false,
407 "notNull": false
408 },
409 "created_at": {
410 "name": "created_at",
411 "type": "timestamp",
412 "primaryKey": false,
413 "notNull": true
414 },
415 "updated_at": {
416 "name": "updated_at",
417 "type": "timestamp",
418 "primaryKey": false,
419 "notNull": true
420 },
421 "row_id": {
422 "name": "row_id",
423 "type": "varchar(255)",
424 "primaryKey": true,
425 "notNull": true
426 },
427 "tenant": {
428 "name": "tenant",
429 "type": "varchar(255)",
430 "primaryKey": false,
431 "notNull": true
432 },
433 "owner": {
434 "name": "owner",
435 "type": "varchar(255)",
436 "primaryKey": false,
437 "notNull": true
438 },
439 "subject": {
440 "name": "subject",
441 "type": "varchar(255)",
442 "primaryKey": false,
443 "notNull": true
444 }
445 },
446 "indexes": {
447 "connection_uidx": {
448 "name": "connection_uidx",
449 "columns": [
450 {
451 "expression": "tenant",
452 "isExpression": false,
453 "asc": true,
454 "nulls": "last"
455 },
456 {
457 "expression": "owner",
458 "isExpression": false,
459 "asc": true,
460 "nulls": "last"
461 },
462 {
463 "expression": "subject",
464 "isExpression": false,
465 "asc": true,
466 "nulls": "last"
467 },
468 {
469 "expression": "integration",
470 "isExpression": false,
471 "asc": true,
472 "nulls": "last"
473 },
474 {
475 "expression": "name",
476 "isExpression": false,
477 "asc": true,
478 "nulls": "last"
479 }
480 ],
481 "isUnique": true,
482 "concurrently": false,
483 "method": "btree",
484 "with": {}
485 }
486 },
487 "foreignKeys": {},
488 "compositePrimaryKeys": {},
489 "uniqueConstraints": {},
490 "policies": {},
491 "checkConstraints": {},
492 "isRLSEnabled": false
493 },
494 "public.definition": {
495 "name": "definition",
496 "schema": "",
497 "columns": {
498 "integration": {
499 "name": "integration",
500 "type": "varchar(255)",
501 "primaryKey": false,
502 "notNull": true
503 },
504 "connection": {
505 "name": "connection",
506 "type": "varchar(255)",
507 "primaryKey": false,
508 "notNull": true
509 },
510 "plugin_id": {
511 "name": "plugin_id",
512 "type": "text",
513 "primaryKey": false,
514 "notNull": true
515 },
516 "name": {
517 "name": "name",
518 "type": "text",
519 "primaryKey": false,
520 "notNull": true
521 },
522 "schema": {
523 "name": "schema",
524 "type": "json",
525 "primaryKey": false,
526 "notNull": true
527 },
528 "created_at": {
529 "name": "created_at",
530 "type": "timestamp",
531 "primaryKey": false,
532 "notNull": true
533 },
534 "row_id": {
535 "name": "row_id",
536 "type": "varchar(255)",
537 "primaryKey": true,
538 "notNull": true
539 },
540 "tenant": {
541 "name": "tenant",
542 "type": "varchar(255)",
543 "primaryKey": false,
544 "notNull": true
545 },
546 "owner": {
547 "name": "owner",
548 "type": "varchar(255)",
549 "primaryKey": false,
550 "notNull": true
551 },
552 "subject": {
553 "name": "subject",
554 "type": "varchar(255)",
555 "primaryKey": false,
556 "notNull": true
557 }
558 },
559 "indexes": {
560 "definition_uidx": {
561 "name": "definition_uidx",
562 "columns": [
563 {
564 "expression": "tenant",
565 "isExpression": false,
566 "asc": true,
567 "nulls": "last"
568 },
569 {
570 "expression": "owner",
571 "isExpression": false,
572 "asc": true,
573 "nulls": "last"
574 },
575 {
576 "expression": "subject",
577 "isExpression": false,
578 "asc": true,
579 "nulls": "last"
580 },
581 {
582 "expression": "integration",
583 "isExpression": false,
584 "asc": true,
585 "nulls": "last"
586 },
587 {
588 "expression": "connection",
589 "isExpression": false,
590 "asc": true,
591 "nulls": "last"
592 },
593 {
594 "expression": "name",
595 "isExpression": false,
596 "asc": true,
597 "nulls": "last"
598 }
599 ],
600 "isUnique": true,
601 "concurrently": false,
602 "method": "btree",
603 "with": {}
604 }
605 },
606 "foreignKeys": {},
607 "compositePrimaryKeys": {},
608 "uniqueConstraints": {},
609 "policies": {},
610 "checkConstraints": {},
611 "isRLSEnabled": false
612 },
613 "public.integration": {
614 "name": "integration",
615 "schema": "",
616 "columns": {
617 "slug": {
618 "name": "slug",
619 "type": "varchar(255)",
620 "primaryKey": false,
621 "notNull": true
622 },
623 "plugin_id": {
624 "name": "plugin_id",
625 "type": "text",
626 "primaryKey": false,
627 "notNull": true
628 },
629 "name": {
630 "name": "name",
631 "type": "text",
632 "primaryKey": false,
633 "notNull": false
634 },
635 "description": {
636 "name": "description",
637 "type": "text",
638 "primaryKey": false,
639 "notNull": false
640 },
641 "config": {
642 "name": "config",
643 "type": "json",
644 "primaryKey": false,
645 "notNull": false
646 },
647 "health_check": {
648 "name": "health_check",
649 "type": "json",
650 "primaryKey": false,
651 "notNull": false
652 },
653 "config_revised_at": {
654 "name": "config_revised_at",
655 "type": "bigint",
656 "primaryKey": false,
657 "notNull": false
658 },
659 "can_remove": {
660 "name": "can_remove",
661 "type": "boolean",
662 "primaryKey": false,
663 "notNull": true,
664 "default": true
665 },
666 "can_refresh": {
667 "name": "can_refresh",
668 "type": "boolean",
669 "primaryKey": false,
670 "notNull": true,
671 "default": false
672 },
673 "created_at": {
674 "name": "created_at",
675 "type": "timestamp",
676 "primaryKey": false,
677 "notNull": true
678 },
679 "updated_at": {
680 "name": "updated_at",
681 "type": "timestamp",
682 "primaryKey": false,
683 "notNull": true
684 },
685 "row_id": {
686 "name": "row_id",
687 "type": "varchar(255)",
688 "primaryKey": true,
689 "notNull": true
690 },
691 "tenant": {
692 "name": "tenant",
693 "type": "varchar(255)",
694 "primaryKey": false,
695 "notNull": true
696 }
697 },
698 "indexes": {
699 "integration_uidx": {
700 "name": "integration_uidx",
701 "columns": [
702 {
703 "expression": "tenant",
704 "isExpression": false,
705 "asc": true,
706 "nulls": "last"
707 },
708 {
709 "expression": "slug",
710 "isExpression": false,
711 "asc": true,
712 "nulls": "last"
713 }
714 ],
715 "isUnique": true,
716 "concurrently": false,
717 "method": "btree",
718 "with": {}
719 }
720 },
721 "foreignKeys": {},
722 "compositePrimaryKeys": {},
723 "uniqueConstraints": {},
724 "policies": {},
725 "checkConstraints": {},
726 "isRLSEnabled": false
727 },
728 "public.oauth_client": {
729 "name": "oauth_client",
730 "schema": "",
731 "columns": {
732 "slug": {
733 "name": "slug",
734 "type": "varchar(255)",
735 "primaryKey": false,
736 "notNull": true
737 },
738 "authorization_url": {
739 "name": "authorization_url",
740 "type": "text",
741 "primaryKey": false,
742 "notNull": true
743 },
744 "token_url": {
745 "name": "token_url",
746 "type": "text",
747 "primaryKey": false,
748 "notNull": true
749 },
750 "grant": {
751 "name": "grant",
752 "type": "text",
753 "primaryKey": false,
754 "notNull": true
755 },
756 "client_id": {
757 "name": "client_id",
758 "type": "text",
759 "primaryKey": false,
760 "notNull": true
761 },
762 "client_secret_item_id": {
763 "name": "client_secret_item_id",
764 "type": "text",
765 "primaryKey": false,
766 "notNull": false
767 },
768 "resource": {
769 "name": "resource",
770 "type": "text",
771 "primaryKey": false,
772 "notNull": false
773 },
774 "origin_kind": {
775 "name": "origin_kind",
776 "type": "text",
777 "primaryKey": false,
778 "notNull": false
779 },
780 "origin_integration": {
781 "name": "origin_integration",
782 "type": "text",
783 "primaryKey": false,
784 "notNull": false
785 },
786 "origin_issuer": {
787 "name": "origin_issuer",
788 "type": "text",
789 "primaryKey": false,
790 "notNull": false
791 },
792 "origin_redirect_uri": {
793 "name": "origin_redirect_uri",
794 "type": "text",
795 "primaryKey": false,
796 "notNull": false
797 },
798 "created_at": {
799 "name": "created_at",
800 "type": "timestamp",
801 "primaryKey": false,
802 "notNull": true
803 },
804 "row_id": {
805 "name": "row_id",
806 "type": "varchar(255)",
807 "primaryKey": true,
808 "notNull": true
809 },
810 "tenant": {
811 "name": "tenant",
812 "type": "varchar(255)",
813 "primaryKey": false,
814 "notNull": true
815 },
816 "owner": {
817 "name": "owner",
818 "type": "varchar(255)",
819 "primaryKey": false,
820 "notNull": true
821 },
822 "subject": {
823 "name": "subject",
824 "type": "varchar(255)",
825 "primaryKey": false,
826 "notNull": true
827 }
828 },
829 "indexes": {
830 "oauth_client_uidx": {
831 "name": "oauth_client_uidx",
832 "columns": [
833 {
834 "expression": "tenant",
835 "isExpression": false,
836 "asc": true,
837 "nulls": "last"
838 },
839 {
840 "expression": "owner",
841 "isExpression": false,
842 "asc": true,
843 "nulls": "last"
844 },
845 {
846 "expression": "subject",
847 "isExpression": false,
848 "asc": true,
849 "nulls": "last"
850 },
851 {
852 "expression": "slug",
853 "isExpression": false,
854 "asc": true,
855 "nulls": "last"
856 }
857 ],
858 "isUnique": true,
859 "concurrently": false,
860 "method": "btree",
861 "with": {}
862 }
863 },
864 "foreignKeys": {},
865 "compositePrimaryKeys": {},
866 "uniqueConstraints": {},
867 "policies": {},
868 "checkConstraints": {},
869 "isRLSEnabled": false
870 },
871 "public.oauth_session": {
872 "name": "oauth_session",
873 "schema": "",
874 "columns": {
875 "state": {
876 "name": "state",
877 "type": "varchar(255)",
878 "primaryKey": false,
879 "notNull": true
880 },
881 "client_slug": {
882 "name": "client_slug",
883 "type": "text",
884 "primaryKey": false,
885 "notNull": true
886 },
887 "integration": {
888 "name": "integration",
889 "type": "text",
890 "primaryKey": false,
891 "notNull": true
892 },
893 "name": {
894 "name": "name",
895 "type": "text",
896 "primaryKey": false,
897 "notNull": true
898 },
899 "template": {
900 "name": "template",
901 "type": "text",
902 "primaryKey": false,
903 "notNull": true
904 },
905 "redirect_url": {
906 "name": "redirect_url",
907 "type": "text",
908 "primaryKey": false,
909 "notNull": true
910 },
911 "pkce_verifier": {
912 "name": "pkce_verifier",
913 "type": "text",
914 "primaryKey": false,
915 "notNull": false
916 },
917 "identity_label": {
918 "name": "identity_label",
919 "type": "text",
920 "primaryKey": false,
921 "notNull": false
922 },
923 "payload": {
924 "name": "payload",
925 "type": "json",
926 "primaryKey": false,
927 "notNull": true
928 },
929 "expires_at": {
930 "name": "expires_at",
931 "type": "bigint",
932 "primaryKey": false,
933 "notNull": true
934 },
935 "created_at": {
936 "name": "created_at",
937 "type": "timestamp",
938 "primaryKey": false,
939 "notNull": true
940 },
941 "row_id": {
942 "name": "row_id",
943 "type": "varchar(255)",
944 "primaryKey": true,
945 "notNull": true
946 },
947 "tenant": {
948 "name": "tenant",
949 "type": "varchar(255)",
950 "primaryKey": false,
951 "notNull": true
952 },
953 "owner": {
954 "name": "owner",
955 "type": "varchar(255)",
956 "primaryKey": false,
957 "notNull": true
958 },
959 "subject": {
960 "name": "subject",
961 "type": "varchar(255)",
962 "primaryKey": false,
963 "notNull": true
964 }
965 },
966 "indexes": {
967 "oauth_session_uidx": {
968 "name": "oauth_session_uidx",
969 "columns": [
970 {
971 "expression": "tenant",
972 "isExpression": false,
973 "asc": true,
974 "nulls": "last"
975 },
976 {
977 "expression": "state",
978 "isExpression": false,
979 "asc": true,
980 "nulls": "last"
981 }
982 ],
983 "isUnique": true,
984 "concurrently": false,
985 "method": "btree",
986 "with": {}
987 }
988 },
989 "foreignKeys": {},
990 "compositePrimaryKeys": {},
991 "uniqueConstraints": {},
992 "policies": {},
993 "checkConstraints": {},
994 "isRLSEnabled": false
995 },
996 "public.plugin_storage": {
997 "name": "plugin_storage",
998 "schema": "",
999 "columns": {
1000 "plugin_id": {
1001 "name": "plugin_id",
1002 "type": "varchar(255)",
1003 "primaryKey": false,
1004 "notNull": true
1005 },
1006 "collection": {
1007 "name": "collection",
1008 "type": "varchar(255)",
1009 "primaryKey": false,
1010 "notNull": true
1011 },
1012 "key": {
1013 "name": "key",
1014 "type": "varchar(255)",
1015 "primaryKey": false,
1016 "notNull": true
1017 },
1018 "data": {
1019 "name": "data",
1020 "type": "json",
1021 "primaryKey": false,
1022 "notNull": true
1023 },
1024 "created_at": {
1025 "name": "created_at",
1026 "type": "timestamp",
1027 "primaryKey": false,
1028 "notNull": true
1029 },
1030 "updated_at": {
1031 "name": "updated_at",
1032 "type": "timestamp",
1033 "primaryKey": false,
1034 "notNull": true
1035 },
1036 "row_id": {
1037 "name": "row_id",
1038 "type": "varchar(255)",
1039 "primaryKey": true,
1040 "notNull": true
1041 },
1042 "tenant": {
1043 "name": "tenant",
1044 "type": "varchar(255)",
1045 "primaryKey": false,
1046 "notNull": true
1047 },
1048 "owner": {
1049 "name": "owner",
1050 "type": "varchar(255)",
1051 "primaryKey": false,
1052 "notNull": true
1053 },
1054 "subject": {
1055 "name": "subject",
1056 "type": "varchar(255)",
1057 "primaryKey": false,
1058 "notNull": true
1059 }
1060 },
1061 "indexes": {
1062 "plugin_storage_uidx": {
1063 "name": "plugin_storage_uidx",
1064 "columns": [
1065 {
1066 "expression": "tenant",
1067 "isExpression": false,
1068 "asc": true,
1069 "nulls": "last"
1070 },
1071 {
1072 "expression": "owner",
1073 "isExpression": false,
1074 "asc": true,
1075 "nulls": "last"
1076 },
1077 {
1078 "expression": "subject",
1079 "isExpression": false,
1080 "asc": true,
1081 "nulls": "last"
1082 },
1083 {
1084 "expression": "plugin_id",
1085 "isExpression": false,
1086 "asc": true,
1087 "nulls": "last"
1088 },
1089 {
1090 "expression": "collection",
1091 "isExpression": false,
1092 "asc": true,
1093 "nulls": "last"
1094 },
1095 {
1096 "expression": "key",
1097 "isExpression": false,
1098 "asc": true,
1099 "nulls": "last"
1100 }
1101 ],
1102 "isUnique": true,
1103 "concurrently": false,
1104 "method": "btree",
1105 "with": {}
1106 }
1107 },
1108 "foreignKeys": {},
1109 "compositePrimaryKeys": {},
1110 "uniqueConstraints": {},
1111 "policies": {},
1112 "checkConstraints": {},
1113 "isRLSEnabled": false
1114 },
1115 "public.private_executor_cloud_settings": {
1116 "name": "private_executor_cloud_settings",
1117 "schema": "",
1118 "columns": {
1119 "id": {
1120 "name": "id",
1121 "type": "varchar(255)",
1122 "primaryKey": true,
1123 "notNull": true
1124 },
1125 "version": {
1126 "name": "version",
1127 "type": "varchar(255)",
1128 "primaryKey": false,
1129 "notNull": true,
1130 "default": "'1.0.0'"
1131 }
1132 },
1133 "indexes": {},
1134 "foreignKeys": {},
1135 "compositePrimaryKeys": {},
1136 "uniqueConstraints": {},
1137 "policies": {},
1138 "checkConstraints": {},
1139 "isRLSEnabled": false
1140 },
1141 "public.subject": {
1142 "name": "subject",
1143 "schema": "",
1144 "columns": {
1145 "external_id": {
1146 "name": "external_id",
1147 "type": "varchar(255)",
1148 "primaryKey": false,
1149 "notNull": true
1150 },
1151 "created_at": {
1152 "name": "created_at",
1153 "type": "timestamp",
1154 "primaryKey": false,
1155 "notNull": true
1156 },
1157 "last_seen_at": {
1158 "name": "last_seen_at",
1159 "type": "bigint",
1160 "primaryKey": false,
1161 "notNull": false
1162 },
1163 "status": {
1164 "name": "status",
1165 "type": "text",
1166 "primaryKey": false,
1167 "notNull": false
1168 },
1169 "row_id": {
1170 "name": "row_id",
1171 "type": "varchar(255)",
1172 "primaryKey": true,
1173 "notNull": true
1174 },
1175 "tenant": {
1176 "name": "tenant",
1177 "type": "varchar(255)",
1178 "primaryKey": false,
1179 "notNull": true
1180 }
1181 },
1182 "indexes": {
1183 "subject_uidx": {
1184 "name": "subject_uidx",
1185 "columns": [
1186 {
1187 "expression": "tenant",
1188 "isExpression": false,
1189 "asc": true,
1190 "nulls": "last"
1191 },
1192 {
1193 "expression": "external_id",
1194 "isExpression": false,
1195 "asc": true,
1196 "nulls": "last"
1197 }
1198 ],
1199 "isUnique": true,
1200 "concurrently": false,
1201 "method": "btree",
1202 "with": {}
1203 }
1204 },
1205 "foreignKeys": {},
1206 "compositePrimaryKeys": {},
1207 "uniqueConstraints": {},
1208 "policies": {},
1209 "checkConstraints": {},
1210 "isRLSEnabled": false
1211 },
1212 "public.tool": {
1213 "name": "tool",
1214 "schema": "",
1215 "columns": {
1216 "integration": {
1217 "name": "integration",
1218 "type": "varchar(255)",
1219 "primaryKey": false,
1220 "notNull": true
1221 },
1222 "connection": {
1223 "name": "connection",
1224 "type": "varchar(255)",
1225 "primaryKey": false,
1226 "notNull": true
1227 },
1228 "plugin_id": {
1229 "name": "plugin_id",
1230 "type": "text",
1231 "primaryKey": false,
1232 "notNull": true
1233 },
1234 "name": {
1235 "name": "name",
1236 "type": "varchar(255)",
1237 "primaryKey": false,
1238 "notNull": true
1239 },
1240 "description": {
1241 "name": "description",
1242 "type": "text",
1243 "primaryKey": false,
1244 "notNull": true
1245 },
1246 "input_schema": {
1247 "name": "input_schema",
1248 "type": "json",
1249 "primaryKey": false,
1250 "notNull": false
1251 },
1252 "output_schema": {
1253 "name": "output_schema",
1254 "type": "json",
1255 "primaryKey": false,
1256 "notNull": false
1257 },
1258 "annotations": {
1259 "name": "annotations",
1260 "type": "json",
1261 "primaryKey": false,
1262 "notNull": false
1263 },
1264 "created_at": {
1265 "name": "created_at",
1266 "type": "timestamp",
1267 "primaryKey": false,
1268 "notNull": true
1269 },
1270 "updated_at": {
1271 "name": "updated_at",
1272 "type": "timestamp",
1273 "primaryKey": false,
1274 "notNull": true
1275 },
1276 "row_id": {
1277 "name": "row_id",
1278 "type": "varchar(255)",
1279 "primaryKey": true,
1280 "notNull": true
1281 },
1282 "tenant": {
1283 "name": "tenant",
1284 "type": "varchar(255)",
1285 "primaryKey": false,
1286 "notNull": true
1287 },
1288 "owner": {
1289 "name": "owner",
1290 "type": "varchar(255)",
1291 "primaryKey": false,
1292 "notNull": true
1293 },
1294 "subject": {
1295 "name": "subject",
1296 "type": "varchar(255)",
1297 "primaryKey": false,
1298 "notNull": true
1299 }
1300 },
1301 "indexes": {
1302 "tool_uidx": {
1303 "name": "tool_uidx",
1304 "columns": [
1305 {
1306 "expression": "tenant",
1307 "isExpression": false,
1308 "asc": true,
1309 "nulls": "last"
1310 },
1311 {
1312 "expression": "owner",
1313 "isExpression": false,
1314 "asc": true,
1315 "nulls": "last"
1316 },
1317 {
1318 "expression": "subject",
1319 "isExpression": false,
1320 "asc": true,
1321 "nulls": "last"
1322 },
1323 {
1324 "expression": "integration",
1325 "isExpression": false,
1326 "asc": true,
1327 "nulls": "last"
1328 },
1329 {
1330 "expression": "connection",
1331 "isExpression": false,
1332 "asc": true,
1333 "nulls": "last"
1334 },
1335 {
1336 "expression": "name",
1337 "isExpression": false,
1338 "asc": true,
1339 "nulls": "last"
1340 }
1341 ],
1342 "isUnique": true,
1343 "concurrently": false,
1344 "method": "btree",
1345 "with": {}
1346 }
1347 },
1348 "foreignKeys": {},
1349 "compositePrimaryKeys": {},
1350 "uniqueConstraints": {},
1351 "policies": {},
1352 "checkConstraints": {},
1353 "isRLSEnabled": false
1354 },
1355 "public.tool_policy": {
1356 "name": "tool_policy",
1357 "schema": "",
1358 "columns": {
1359 "id": {
1360 "name": "id",
1361 "type": "varchar(255)",
1362 "primaryKey": false,
1363 "notNull": true
1364 },
1365 "pattern": {
1366 "name": "pattern",
1367 "type": "text",
1368 "primaryKey": false,
1369 "notNull": true
1370 },
1371 "action": {
1372 "name": "action",
1373 "type": "text",
1374 "primaryKey": false,
1375 "notNull": true
1376 },
1377 "position": {
1378 "name": "position",
1379 "type": "text",
1380 "primaryKey": false,
1381 "notNull": true
1382 },
1383 "created_at": {
1384 "name": "created_at",
1385 "type": "timestamp",
1386 "primaryKey": false,
1387 "notNull": true
1388 },
1389 "updated_at": {
1390 "name": "updated_at",
1391 "type": "timestamp",
1392 "primaryKey": false,
1393 "notNull": true
1394 },
1395 "row_id": {
1396 "name": "row_id",
1397 "type": "varchar(255)",
1398 "primaryKey": true,
1399 "notNull": true
1400 },
1401 "tenant": {
1402 "name": "tenant",
1403 "type": "varchar(255)",
1404 "primaryKey": false,
1405 "notNull": true
1406 },
1407 "owner": {
1408 "name": "owner",
1409 "type": "varchar(255)",
1410 "primaryKey": false,
1411 "notNull": true
1412 },
1413 "subject": {
1414 "name": "subject",
1415 "type": "varchar(255)",
1416 "primaryKey": false,
1417 "notNull": true
1418 }
1419 },
1420 "indexes": {
1421 "tool_policy_uidx": {
1422 "name": "tool_policy_uidx",
1423 "columns": [
1424 {
1425 "expression": "tenant",
1426 "isExpression": false,
1427 "asc": true,
1428 "nulls": "last"
1429 },
1430 {
1431 "expression": "owner",
1432 "isExpression": false,
1433 "asc": true,
1434 "nulls": "last"
1435 },
1436 {
1437 "expression": "subject",
1438 "isExpression": false,
1439 "asc": true,
1440 "nulls": "last"
1441 },
1442 {
1443 "expression": "id",
1444 "isExpression": false,
1445 "asc": true,
1446 "nulls": "last"
1447 }
1448 ],
1449 "isUnique": true,
1450 "concurrently": false,
1451 "method": "btree",
1452 "with": {}
1453 }
1454 },
1455 "foreignKeys": {},
1456 "compositePrimaryKeys": {},
1457 "uniqueConstraints": {},
1458 "policies": {},
1459 "checkConstraints": {},
1460 "isRLSEnabled": false
1461 }
1462 },
1463 "enums": {},
1464 "schemas": {},
1465 "sequences": {},
1466 "roles": {},
1467 "policies": {},
1468 "views": {},
1469 "_meta": {
1470 "columns": {},
1471 "schemas": {},
1472 "tables": {}
1473 }
1474}
apps/cloud/drizzle/meta/0014_snapshot.json+1480−0
@@ -0,0 +1,1480 @@
1{
2 "id": "0857c83f-0124-4739-bd88-a0a4a92aab84",
3 "prevId": "edc90067-822c-443a-8fcd-3bac77bca317",
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.artifact": {
144 "name": "artifact",
145 "schema": "",
146 "columns": {
147 "id": {
148 "name": "id",
149 "type": "varchar(255)",
150 "primaryKey": false,
151 "notNull": true
152 },
153 "title": {
154 "name": "title",
155 "type": "text",
156 "primaryKey": false,
157 "notNull": true
158 },
159 "description": {
160 "name": "description",
161 "type": "text",
162 "primaryKey": false,
163 "notNull": false
164 },
165 "code": {
166 "name": "code",
167 "type": "text",
168 "primaryKey": false,
169 "notNull": true
170 },
171 "bindings": {
172 "name": "bindings",
173 "type": "json",
174 "primaryKey": false,
175 "notNull": false
176 },
177 "created_at": {
178 "name": "created_at",
179 "type": "timestamp",
180 "primaryKey": false,
181 "notNull": true
182 },
183 "updated_at": {
184 "name": "updated_at",
185 "type": "timestamp",
186 "primaryKey": false,
187 "notNull": true
188 },
189 "row_id": {
190 "name": "row_id",
191 "type": "varchar(255)",
192 "primaryKey": true,
193 "notNull": true
194 },
195 "tenant": {
196 "name": "tenant",
197 "type": "varchar(255)",
198 "primaryKey": false,
199 "notNull": true
200 },
201 "owner": {
202 "name": "owner",
203 "type": "varchar(255)",
204 "primaryKey": false,
205 "notNull": true
206 },
207 "subject": {
208 "name": "subject",
209 "type": "varchar(255)",
210 "primaryKey": false,
211 "notNull": true
212 }
213 },
214 "indexes": {
215 "artifact_uidx": {
216 "name": "artifact_uidx",
217 "columns": [
218 {
219 "expression": "tenant",
220 "isExpression": false,
221 "asc": true,
222 "nulls": "last"
223 },
224 {
225 "expression": "owner",
226 "isExpression": false,
227 "asc": true,
228 "nulls": "last"
229 },
230 {
231 "expression": "subject",
232 "isExpression": false,
233 "asc": true,
234 "nulls": "last"
235 },
236 {
237 "expression": "id",
238 "isExpression": false,
239 "asc": true,
240 "nulls": "last"
241 }
242 ],
243 "isUnique": true,
244 "concurrently": false,
245 "method": "btree",
246 "with": {}
247 }
248 },
249 "foreignKeys": {},
250 "compositePrimaryKeys": {},
251 "uniqueConstraints": {},
252 "policies": {},
253 "checkConstraints": {},
254 "isRLSEnabled": false
255 },
256 "public.blob": {
257 "name": "blob",
258 "schema": "",
259 "columns": {
260 "namespace": {
261 "name": "namespace",
262 "type": "varchar(255)",
263 "primaryKey": false,
264 "notNull": true
265 },
266 "key": {
267 "name": "key",
268 "type": "varchar(255)",
269 "primaryKey": false,
270 "notNull": true
271 },
272 "value": {
273 "name": "value",
274 "type": "text",
275 "primaryKey": false,
276 "notNull": true
277 },
278 "row_id": {
279 "name": "row_id",
280 "type": "varchar(255)",
281 "primaryKey": true,
282 "notNull": true
283 },
284 "id": {
285 "name": "id",
286 "type": "varchar(255)",
287 "primaryKey": false,
288 "notNull": true
289 }
290 },
291 "indexes": {
292 "blob_id_uidx": {
293 "name": "blob_id_uidx",
294 "columns": [
295 {
296 "expression": "id",
297 "isExpression": false,
298 "asc": true,
299 "nulls": "last"
300 }
301 ],
302 "isUnique": true,
303 "concurrently": false,
304 "method": "btree",
305 "with": {}
306 }
307 },
308 "foreignKeys": {},
309 "compositePrimaryKeys": {},
310 "uniqueConstraints": {},
311 "policies": {},
312 "checkConstraints": {},
313 "isRLSEnabled": false
314 },
315 "public.connection": {
316 "name": "connection",
317 "schema": "",
318 "columns": {
319 "integration": {
320 "name": "integration",
321 "type": "varchar(255)",
322 "primaryKey": false,
323 "notNull": true
324 },
325 "name": {
326 "name": "name",
327 "type": "varchar(255)",
328 "primaryKey": false,
329 "notNull": true
330 },
331 "template": {
332 "name": "template",
333 "type": "text",
334 "primaryKey": false,
335 "notNull": true
336 },
337 "provider": {
338 "name": "provider",
339 "type": "text",
340 "primaryKey": false,
341 "notNull": true
342 },
343 "item_ids": {
344 "name": "item_ids",
345 "type": "json",
346 "primaryKey": false,
347 "notNull": true
348 },
349 "identity_label": {
350 "name": "identity_label",
351 "type": "text",
352 "primaryKey": false,
353 "notNull": false
354 },
355 "description": {
356 "name": "description",
357 "type": "text",
358 "primaryKey": false,
359 "notNull": false
360 },
361 "last_health": {
362 "name": "last_health",
363 "type": "json",
364 "primaryKey": false,
365 "notNull": false
366 },
367 "tools_synced_at": {
368 "name": "tools_synced_at",
369 "type": "bigint",
370 "primaryKey": false,
371 "notNull": false
372 },
373 "oauth_client": {
374 "name": "oauth_client",
375 "type": "text",
376 "primaryKey": false,
377 "notNull": false
378 },
379 "oauth_client_owner": {
380 "name": "oauth_client_owner",
381 "type": "text",
382 "primaryKey": false,
383 "notNull": false
384 },
385 "refresh_item_id": {
386 "name": "refresh_item_id",
387 "type": "text",
388 "primaryKey": false,
389 "notNull": false
390 },
391 "expires_at": {
392 "name": "expires_at",
393 "type": "bigint",
394 "primaryKey": false,
395 "notNull": false
396 },
397 "oauth_scope": {
398 "name": "oauth_scope",
399 "type": "text",
400 "primaryKey": false,
401 "notNull": false
402 },
403 "oauth_token_url": {
404 "name": "oauth_token_url",
405 "type": "text",
406 "primaryKey": false,
407 "notNull": false
408 },
409 "provider_state": {
410 "name": "provider_state",
411 "type": "json",
412 "primaryKey": false,
413 "notNull": false
414 },
415 "created_at": {
416 "name": "created_at",
417 "type": "timestamp",
418 "primaryKey": false,
419 "notNull": true
420 },
421 "updated_at": {
422 "name": "updated_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 "connection_uidx": {
454 "name": "connection_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": "name",
482 "isExpression": false,
483 "asc": true,
484 "nulls": "last"
485 }
486 ],
487 "isUnique": true,
488 "concurrently": false,
489 "method": "btree",
490 "with": {}
491 }
492 },
493 "foreignKeys": {},
494 "compositePrimaryKeys": {},
495 "uniqueConstraints": {},
496 "policies": {},
497 "checkConstraints": {},
498 "isRLSEnabled": false
499 },
500 "public.definition": {
501 "name": "definition",
502 "schema": "",
503 "columns": {
504 "integration": {
505 "name": "integration",
506 "type": "varchar(255)",
507 "primaryKey": false,
508 "notNull": true
509 },
510 "connection": {
511 "name": "connection",
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": true
527 },
528 "schema": {
529 "name": "schema",
530 "type": "json",
531 "primaryKey": false,
532 "notNull": true
533 },
534 "created_at": {
535 "name": "created_at",
536 "type": "timestamp",
537 "primaryKey": false,
538 "notNull": true
539 },
540 "row_id": {
541 "name": "row_id",
542 "type": "varchar(255)",
543 "primaryKey": true,
544 "notNull": true
545 },
546 "tenant": {
547 "name": "tenant",
548 "type": "varchar(255)",
549 "primaryKey": false,
550 "notNull": true
551 },
552 "owner": {
553 "name": "owner",
554 "type": "varchar(255)",
555 "primaryKey": false,
556 "notNull": true
557 },
558 "subject": {
559 "name": "subject",
560 "type": "varchar(255)",
561 "primaryKey": false,
562 "notNull": true
563 }
564 },
565 "indexes": {
566 "definition_uidx": {
567 "name": "definition_uidx",
568 "columns": [
569 {
570 "expression": "tenant",
571 "isExpression": false,
572 "asc": true,
573 "nulls": "last"
574 },
575 {
576 "expression": "owner",
577 "isExpression": false,
578 "asc": true,
579 "nulls": "last"
580 },
581 {
582 "expression": "subject",
583 "isExpression": false,
584 "asc": true,
585 "nulls": "last"
586 },
587 {
588 "expression": "integration",
589 "isExpression": false,
590 "asc": true,
591 "nulls": "last"
592 },
593 {
594 "expression": "connection",
595 "isExpression": false,
596 "asc": true,
597 "nulls": "last"
598 },
599 {
600 "expression": "name",
601 "isExpression": false,
602 "asc": true,
603 "nulls": "last"
604 }
605 ],
606 "isUnique": true,
607 "concurrently": false,
608 "method": "btree",
609 "with": {}
610 }
611 },
612 "foreignKeys": {},
613 "compositePrimaryKeys": {},
614 "uniqueConstraints": {},
615 "policies": {},
616 "checkConstraints": {},
617 "isRLSEnabled": false
618 },
619 "public.integration": {
620 "name": "integration",
621 "schema": "",
622 "columns": {
623 "slug": {
624 "name": "slug",
625 "type": "varchar(255)",
626 "primaryKey": false,
627 "notNull": true
628 },
629 "plugin_id": {
630 "name": "plugin_id",
631 "type": "text",
632 "primaryKey": false,
633 "notNull": true
634 },
635 "name": {
636 "name": "name",
637 "type": "text",
638 "primaryKey": false,
639 "notNull": false
640 },
641 "description": {
642 "name": "description",
643 "type": "text",
644 "primaryKey": false,
645 "notNull": false
646 },
647 "config": {
648 "name": "config",
649 "type": "json",
650 "primaryKey": false,
651 "notNull": false
652 },
653 "health_check": {
654 "name": "health_check",
655 "type": "json",
656 "primaryKey": false,
657 "notNull": false
658 },
659 "config_revised_at": {
660 "name": "config_revised_at",
661 "type": "bigint",
662 "primaryKey": false,
663 "notNull": false
664 },
665 "can_remove": {
666 "name": "can_remove",
667 "type": "boolean",
668 "primaryKey": false,
669 "notNull": true,
670 "default": true
671 },
672 "can_refresh": {
673 "name": "can_refresh",
674 "type": "boolean",
675 "primaryKey": false,
676 "notNull": true,
677 "default": false
678 },
679 "created_at": {
680 "name": "created_at",
681 "type": "timestamp",
682 "primaryKey": false,
683 "notNull": true
684 },
685 "updated_at": {
686 "name": "updated_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 },
704 "indexes": {
705 "integration_uidx": {
706 "name": "integration_uidx",
707 "columns": [
708 {
709 "expression": "tenant",
710 "isExpression": false,
711 "asc": true,
712 "nulls": "last"
713 },
714 {
715 "expression": "slug",
716 "isExpression": false,
717 "asc": true,
718 "nulls": "last"
719 }
720 ],
721 "isUnique": true,
722 "concurrently": false,
723 "method": "btree",
724 "with": {}
725 }
726 },
727 "foreignKeys": {},
728 "compositePrimaryKeys": {},
729 "uniqueConstraints": {},
730 "policies": {},
731 "checkConstraints": {},
732 "isRLSEnabled": false
733 },
734 "public.oauth_client": {
735 "name": "oauth_client",
736 "schema": "",
737 "columns": {
738 "slug": {
739 "name": "slug",
740 "type": "varchar(255)",
741 "primaryKey": false,
742 "notNull": true
743 },
744 "authorization_url": {
745 "name": "authorization_url",
746 "type": "text",
747 "primaryKey": false,
748 "notNull": true
749 },
750 "token_url": {
751 "name": "token_url",
752 "type": "text",
753 "primaryKey": false,
754 "notNull": true
755 },
756 "grant": {
757 "name": "grant",
758 "type": "text",
759 "primaryKey": false,
760 "notNull": true
761 },
762 "client_id": {
763 "name": "client_id",
764 "type": "text",
765 "primaryKey": false,
766 "notNull": true
767 },
768 "client_secret_item_id": {
769 "name": "client_secret_item_id",
770 "type": "text",
771 "primaryKey": false,
772 "notNull": false
773 },
774 "resource": {
775 "name": "resource",
776 "type": "text",
777 "primaryKey": false,
778 "notNull": false
779 },
780 "origin_kind": {
781 "name": "origin_kind",
782 "type": "text",
783 "primaryKey": false,
784 "notNull": false
785 },
786 "origin_integration": {
787 "name": "origin_integration",
788 "type": "text",
789 "primaryKey": false,
790 "notNull": false
791 },
792 "origin_issuer": {
793 "name": "origin_issuer",
794 "type": "text",
795 "primaryKey": false,
796 "notNull": false
797 },
798 "origin_redirect_uri": {
799 "name": "origin_redirect_uri",
800 "type": "text",
801 "primaryKey": false,
802 "notNull": false
803 },
804 "created_at": {
805 "name": "created_at",
806 "type": "timestamp",
807 "primaryKey": false,
808 "notNull": true
809 },
810 "row_id": {
811 "name": "row_id",
812 "type": "varchar(255)",
813 "primaryKey": true,
814 "notNull": true
815 },
816 "tenant": {
817 "name": "tenant",
818 "type": "varchar(255)",
819 "primaryKey": false,
820 "notNull": true
821 },
822 "owner": {
823 "name": "owner",
824 "type": "varchar(255)",
825 "primaryKey": false,
826 "notNull": true
827 },
828 "subject": {
829 "name": "subject",
830 "type": "varchar(255)",
831 "primaryKey": false,
832 "notNull": true
833 }
834 },
835 "indexes": {
836 "oauth_client_uidx": {
837 "name": "oauth_client_uidx",
838 "columns": [
839 {
840 "expression": "tenant",
841 "isExpression": false,
842 "asc": true,
843 "nulls": "last"
844 },
845 {
846 "expression": "owner",
847 "isExpression": false,
848 "asc": true,
849 "nulls": "last"
850 },
851 {
852 "expression": "subject",
853 "isExpression": false,
854 "asc": true,
855 "nulls": "last"
856 },
857 {
858 "expression": "slug",
859 "isExpression": false,
860 "asc": true,
861 "nulls": "last"
862 }
863 ],
864 "isUnique": true,
865 "concurrently": false,
866 "method": "btree",
867 "with": {}
868 }
869 },
870 "foreignKeys": {},
871 "compositePrimaryKeys": {},
872 "uniqueConstraints": {},
873 "policies": {},
874 "checkConstraints": {},
875 "isRLSEnabled": false
876 },
877 "public.oauth_session": {
878 "name": "oauth_session",
879 "schema": "",
880 "columns": {
881 "state": {
882 "name": "state",
883 "type": "varchar(255)",
884 "primaryKey": false,
885 "notNull": true
886 },
887 "client_slug": {
888 "name": "client_slug",
889 "type": "text",
890 "primaryKey": false,
891 "notNull": true
892 },
893 "integration": {
894 "name": "integration",
895 "type": "text",
896 "primaryKey": false,
897 "notNull": true
898 },
899 "name": {
900 "name": "name",
901 "type": "text",
902 "primaryKey": false,
903 "notNull": true
904 },
905 "template": {
906 "name": "template",
907 "type": "text",
908 "primaryKey": false,
909 "notNull": true
910 },
911 "redirect_url": {
912 "name": "redirect_url",
913 "type": "text",
914 "primaryKey": false,
915 "notNull": true
916 },
917 "pkce_verifier": {
918 "name": "pkce_verifier",
919 "type": "text",
920 "primaryKey": false,
921 "notNull": false
922 },
923 "identity_label": {
924 "name": "identity_label",
925 "type": "text",
926 "primaryKey": false,
927 "notNull": false
928 },
929 "payload": {
930 "name": "payload",
931 "type": "json",
932 "primaryKey": false,
933 "notNull": true
934 },
935 "expires_at": {
936 "name": "expires_at",
937 "type": "bigint",
938 "primaryKey": false,
939 "notNull": true
940 },
941 "created_at": {
942 "name": "created_at",
943 "type": "timestamp",
944 "primaryKey": false,
945 "notNull": true
946 },
947 "row_id": {
948 "name": "row_id",
949 "type": "varchar(255)",
950 "primaryKey": true,
951 "notNull": true
952 },
953 "tenant": {
954 "name": "tenant",
955 "type": "varchar(255)",
956 "primaryKey": false,
957 "notNull": true
958 },
959 "owner": {
960 "name": "owner",
961 "type": "varchar(255)",
962 "primaryKey": false,
963 "notNull": true
964 },
965 "subject": {
966 "name": "subject",
967 "type": "varchar(255)",
968 "primaryKey": false,
969 "notNull": true
970 }
971 },
972 "indexes": {
973 "oauth_session_uidx": {
974 "name": "oauth_session_uidx",
975 "columns": [
976 {
977 "expression": "tenant",
978 "isExpression": false,
979 "asc": true,
980 "nulls": "last"
981 },
982 {
983 "expression": "state",
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.plugin_storage": {
1003 "name": "plugin_storage",
1004 "schema": "",
1005 "columns": {
1006 "plugin_id": {
1007 "name": "plugin_id",
1008 "type": "varchar(255)",
1009 "primaryKey": false,
1010 "notNull": true
1011 },
1012 "collection": {
1013 "name": "collection",
1014 "type": "varchar(255)",
1015 "primaryKey": false,
1016 "notNull": true
1017 },
1018 "key": {
1019 "name": "key",
1020 "type": "varchar(255)",
1021 "primaryKey": false,
1022 "notNull": true
1023 },
1024 "data": {
1025 "name": "data",
1026 "type": "json",
1027 "primaryKey": false,
1028 "notNull": true
1029 },
1030 "created_at": {
1031 "name": "created_at",
1032 "type": "timestamp",
1033 "primaryKey": false,
1034 "notNull": true
1035 },
1036 "updated_at": {
1037 "name": "updated_at",
1038 "type": "timestamp",
1039 "primaryKey": false,
1040 "notNull": true
1041 },
1042 "row_id": {
1043 "name": "row_id",
1044 "type": "varchar(255)",
1045 "primaryKey": true,
1046 "notNull": true
1047 },
1048 "tenant": {
1049 "name": "tenant",
1050 "type": "varchar(255)",
1051 "primaryKey": false,
1052 "notNull": true
1053 },
1054 "owner": {
1055 "name": "owner",
1056 "type": "varchar(255)",
1057 "primaryKey": false,
1058 "notNull": true
1059 },
1060 "subject": {
1061 "name": "subject",
1062 "type": "varchar(255)",
1063 "primaryKey": false,
1064 "notNull": true
1065 }
1066 },
1067 "indexes": {
1068 "plugin_storage_uidx": {
1069 "name": "plugin_storage_uidx",
1070 "columns": [
1071 {
1072 "expression": "tenant",
1073 "isExpression": false,
1074 "asc": true,
1075 "nulls": "last"
1076 },
1077 {
1078 "expression": "owner",
1079 "isExpression": false,
1080 "asc": true,
1081 "nulls": "last"
1082 },
1083 {
1084 "expression": "subject",
1085 "isExpression": false,
1086 "asc": true,
1087 "nulls": "last"
1088 },
1089 {
1090 "expression": "plugin_id",
1091 "isExpression": false,
1092 "asc": true,
1093 "nulls": "last"
1094 },
1095 {
1096 "expression": "collection",
1097 "isExpression": false,
1098 "asc": true,
1099 "nulls": "last"
1100 },
1101 {
1102 "expression": "key",
1103 "isExpression": false,
1104 "asc": true,
1105 "nulls": "last"
1106 }
1107 ],
1108 "isUnique": true,
1109 "concurrently": false,
1110 "method": "btree",
1111 "with": {}
1112 }
1113 },
1114 "foreignKeys": {},
1115 "compositePrimaryKeys": {},
1116 "uniqueConstraints": {},
1117 "policies": {},
1118 "checkConstraints": {},
1119 "isRLSEnabled": false
1120 },
1121 "public.private_executor_cloud_settings": {
1122 "name": "private_executor_cloud_settings",
1123 "schema": "",
1124 "columns": {
1125 "id": {
1126 "name": "id",
1127 "type": "varchar(255)",
1128 "primaryKey": true,
1129 "notNull": true
1130 },
1131 "version": {
1132 "name": "version",
1133 "type": "varchar(255)",
1134 "primaryKey": false,
1135 "notNull": true,
1136 "default": "'1.0.0'"
1137 }
1138 },
1139 "indexes": {},
1140 "foreignKeys": {},
1141 "compositePrimaryKeys": {},
1142 "uniqueConstraints": {},
1143 "policies": {},
1144 "checkConstraints": {},
1145 "isRLSEnabled": false
1146 },
1147 "public.subject": {
1148 "name": "subject",
1149 "schema": "",
1150 "columns": {
1151 "external_id": {
1152 "name": "external_id",
1153 "type": "varchar(255)",
1154 "primaryKey": false,
1155 "notNull": true
1156 },
1157 "created_at": {
1158 "name": "created_at",
1159 "type": "timestamp",
1160 "primaryKey": false,
1161 "notNull": true
1162 },
1163 "last_seen_at": {
1164 "name": "last_seen_at",
1165 "type": "bigint",
1166 "primaryKey": false,
1167 "notNull": false
1168 },
1169 "status": {
1170 "name": "status",
1171 "type": "text",
1172 "primaryKey": false,
1173 "notNull": false
1174 },
1175 "row_id": {
1176 "name": "row_id",
1177 "type": "varchar(255)",
1178 "primaryKey": true,
1179 "notNull": true
1180 },
1181 "tenant": {
1182 "name": "tenant",
1183 "type": "varchar(255)",
1184 "primaryKey": false,
1185 "notNull": true
1186 }
1187 },
1188 "indexes": {
1189 "subject_uidx": {
1190 "name": "subject_uidx",
1191 "columns": [
1192 {
1193 "expression": "tenant",
1194 "isExpression": false,
1195 "asc": true,
1196 "nulls": "last"
1197 },
1198 {
1199 "expression": "external_id",
1200 "isExpression": false,
1201 "asc": true,
1202 "nulls": "last"
1203 }
1204 ],
1205 "isUnique": true,
1206 "concurrently": false,
1207 "method": "btree",
1208 "with": {}
1209 }
1210 },
1211 "foreignKeys": {},
1212 "compositePrimaryKeys": {},
1213 "uniqueConstraints": {},
1214 "policies": {},
1215 "checkConstraints": {},
1216 "isRLSEnabled": false
1217 },
1218 "public.tool": {
1219 "name": "tool",
1220 "schema": "",
1221 "columns": {
1222 "integration": {
1223 "name": "integration",
1224 "type": "varchar(255)",
1225 "primaryKey": false,
1226 "notNull": true
1227 },
1228 "connection": {
1229 "name": "connection",
1230 "type": "varchar(255)",
1231 "primaryKey": false,
1232 "notNull": true
1233 },
1234 "plugin_id": {
1235 "name": "plugin_id",
1236 "type": "text",
1237 "primaryKey": false,
1238 "notNull": true
1239 },
1240 "name": {
1241 "name": "name",
1242 "type": "varchar(255)",
1243 "primaryKey": false,
1244 "notNull": true
1245 },
1246 "description": {
1247 "name": "description",
1248 "type": "text",
1249 "primaryKey": false,
1250 "notNull": true
1251 },
1252 "input_schema": {
1253 "name": "input_schema",
1254 "type": "json",
1255 "primaryKey": false,
1256 "notNull": false
1257 },
1258 "output_schema": {
1259 "name": "output_schema",
1260 "type": "json",
1261 "primaryKey": false,
1262 "notNull": false
1263 },
1264 "annotations": {
1265 "name": "annotations",
1266 "type": "json",
1267 "primaryKey": false,
1268 "notNull": false
1269 },
1270 "created_at": {
1271 "name": "created_at",
1272 "type": "timestamp",
1273 "primaryKey": false,
1274 "notNull": true
1275 },
1276 "updated_at": {
1277 "name": "updated_at",
1278 "type": "timestamp",
1279 "primaryKey": false,
1280 "notNull": true
1281 },
1282 "row_id": {
1283 "name": "row_id",
1284 "type": "varchar(255)",
1285 "primaryKey": true,
1286 "notNull": true
1287 },
1288 "tenant": {
1289 "name": "tenant",
1290 "type": "varchar(255)",
1291 "primaryKey": false,
1292 "notNull": true
1293 },
1294 "owner": {
1295 "name": "owner",
1296 "type": "varchar(255)",
1297 "primaryKey": false,
1298 "notNull": true
1299 },
1300 "subject": {
1301 "name": "subject",
1302 "type": "varchar(255)",
1303 "primaryKey": false,
1304 "notNull": true
1305 }
1306 },
1307 "indexes": {
1308 "tool_uidx": {
1309 "name": "tool_uidx",
1310 "columns": [
1311 {
1312 "expression": "tenant",
1313 "isExpression": false,
1314 "asc": true,
1315 "nulls": "last"
1316 },
1317 {
1318 "expression": "owner",
1319 "isExpression": false,
1320 "asc": true,
1321 "nulls": "last"
1322 },
1323 {
1324 "expression": "subject",
1325 "isExpression": false,
1326 "asc": true,
1327 "nulls": "last"
1328 },
1329 {
1330 "expression": "integration",
1331 "isExpression": false,
1332 "asc": true,
1333 "nulls": "last"
1334 },
1335 {
1336 "expression": "connection",
1337 "isExpression": false,
1338 "asc": true,
1339 "nulls": "last"
1340 },
1341 {
1342 "expression": "name",
1343 "isExpression": false,
1344 "asc": true,
1345 "nulls": "last"
1346 }
1347 ],
1348 "isUnique": true,
1349 "concurrently": false,
1350 "method": "btree",
1351 "with": {}
1352 }
1353 },
1354 "foreignKeys": {},
1355 "compositePrimaryKeys": {},
1356 "uniqueConstraints": {},
1357 "policies": {},
1358 "checkConstraints": {},
1359 "isRLSEnabled": false
1360 },
1361 "public.tool_policy": {
1362 "name": "tool_policy",
1363 "schema": "",
1364 "columns": {
1365 "id": {
1366 "name": "id",
1367 "type": "varchar(255)",
1368 "primaryKey": false,
1369 "notNull": true
1370 },
1371 "pattern": {
1372 "name": "pattern",
1373 "type": "text",
1374 "primaryKey": false,
1375 "notNull": true
1376 },
1377 "action": {
1378 "name": "action",
1379 "type": "text",
1380 "primaryKey": false,
1381 "notNull": true
1382 },
1383 "position": {
1384 "name": "position",
1385 "type": "text",
1386 "primaryKey": false,
1387 "notNull": true
1388 },
1389 "created_at": {
1390 "name": "created_at",
1391 "type": "timestamp",
1392 "primaryKey": false,
1393 "notNull": true
1394 },
1395 "updated_at": {
1396 "name": "updated_at",
1397 "type": "timestamp",
1398 "primaryKey": false,
1399 "notNull": true
1400 },
1401 "row_id": {
1402 "name": "row_id",
1403 "type": "varchar(255)",
1404 "primaryKey": true,
1405 "notNull": true
1406 },
1407 "tenant": {
1408 "name": "tenant",
1409 "type": "varchar(255)",
1410 "primaryKey": false,
1411 "notNull": true
1412 },
1413 "owner": {
1414 "name": "owner",
1415 "type": "varchar(255)",
1416 "primaryKey": false,
1417 "notNull": true
1418 },
1419 "subject": {
1420 "name": "subject",
1421 "type": "varchar(255)",
1422 "primaryKey": false,
1423 "notNull": true
1424 }
1425 },
1426 "indexes": {
1427 "tool_policy_uidx": {
1428 "name": "tool_policy_uidx",
1429 "columns": [
1430 {
1431 "expression": "tenant",
1432 "isExpression": false,
1433 "asc": true,
1434 "nulls": "last"
1435 },
1436 {
1437 "expression": "owner",
1438 "isExpression": false,
1439 "asc": true,
1440 "nulls": "last"
1441 },
1442 {
1443 "expression": "subject",
1444 "isExpression": false,
1445 "asc": true,
1446 "nulls": "last"
1447 },
1448 {
1449 "expression": "id",
1450 "isExpression": false,
1451 "asc": true,
1452 "nulls": "last"
1453 }
1454 ],
1455 "isUnique": true,
1456 "concurrently": false,
1457 "method": "btree",
1458 "with": {}
1459 }
1460 },
1461 "foreignKeys": {},
1462 "compositePrimaryKeys": {},
1463 "uniqueConstraints": {},
1464 "policies": {},
1465 "checkConstraints": {},
1466 "isRLSEnabled": false
1467 }
1468 },
1469 "enums": {},
1470 "schemas": {},
1471 "sequences": {},
1472 "roles": {},
1473 "policies": {},
1474 "views": {},
1475 "_meta": {
1476 "columns": {},
1477 "schemas": {},
1478 "tables": {}
1479 }
1480}
apps/cloud/drizzle/meta/0015_snapshot.json+1486−0
@@ -0,0 +1,1486 @@
1{
2 "id": "d666b31a-c3d1-4bd7-9bd6-85f2abc4fb55",
3 "prevId": "0857c83f-0124-4739-bd88-a0a4a92aab84",
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.artifact": {
144 "name": "artifact",
145 "schema": "",
146 "columns": {
147 "id": {
148 "name": "id",
149 "type": "varchar(255)",
150 "primaryKey": false,
151 "notNull": true
152 },
153 "title": {
154 "name": "title",
155 "type": "text",
156 "primaryKey": false,
157 "notNull": true
158 },
159 "description": {
160 "name": "description",
161 "type": "text",
162 "primaryKey": false,
163 "notNull": false
164 },
165 "code": {
166 "name": "code",
167 "type": "text",
168 "primaryKey": false,
169 "notNull": true
170 },
171 "bindings": {
172 "name": "bindings",
173 "type": "json",
174 "primaryKey": false,
175 "notNull": false
176 },
177 "preview": {
178 "name": "preview",
179 "type": "text",
180 "primaryKey": false,
181 "notNull": false
182 },
183 "created_at": {
184 "name": "created_at",
185 "type": "timestamp",
186 "primaryKey": false,
187 "notNull": true
188 },
189 "updated_at": {
190 "name": "updated_at",
191 "type": "timestamp",
192 "primaryKey": false,
193 "notNull": true
194 },
195 "row_id": {
196 "name": "row_id",
197 "type": "varchar(255)",
198 "primaryKey": true,
199 "notNull": true
200 },
201 "tenant": {
202 "name": "tenant",
203 "type": "varchar(255)",
204 "primaryKey": false,
205 "notNull": true
206 },
207 "owner": {
208 "name": "owner",
209 "type": "varchar(255)",
210 "primaryKey": false,
211 "notNull": true
212 },
213 "subject": {
214 "name": "subject",
215 "type": "varchar(255)",
216 "primaryKey": false,
217 "notNull": true
218 }
219 },
220 "indexes": {
221 "artifact_uidx": {
222 "name": "artifact_uidx",
223 "columns": [
224 {
225 "expression": "tenant",
226 "isExpression": false,
227 "asc": true,
228 "nulls": "last"
229 },
230 {
231 "expression": "owner",
232 "isExpression": false,
233 "asc": true,
234 "nulls": "last"
235 },
236 {
237 "expression": "subject",
238 "isExpression": false,
239 "asc": true,
240 "nulls": "last"
241 },
242 {
243 "expression": "id",
244 "isExpression": false,
245 "asc": true,
246 "nulls": "last"
247 }
248 ],
249 "isUnique": true,
250 "concurrently": false,
251 "method": "btree",
252 "with": {}
253 }
254 },
255 "foreignKeys": {},
256 "compositePrimaryKeys": {},
257 "uniqueConstraints": {},
258 "policies": {},
259 "checkConstraints": {},
260 "isRLSEnabled": false
261 },
262 "public.blob": {
263 "name": "blob",
264 "schema": "",
265 "columns": {
266 "namespace": {
267 "name": "namespace",
268 "type": "varchar(255)",
269 "primaryKey": false,
270 "notNull": true
271 },
272 "key": {
273 "name": "key",
274 "type": "varchar(255)",
275 "primaryKey": false,
276 "notNull": true
277 },
278 "value": {
279 "name": "value",
280 "type": "text",
281 "primaryKey": false,
282 "notNull": true
283 },
284 "row_id": {
285 "name": "row_id",
286 "type": "varchar(255)",
287 "primaryKey": true,
288 "notNull": true
289 },
290 "id": {
291 "name": "id",
292 "type": "varchar(255)",
293 "primaryKey": false,
294 "notNull": true
295 }
296 },
297 "indexes": {
298 "blob_id_uidx": {
299 "name": "blob_id_uidx",
300 "columns": [
301 {
302 "expression": "id",
303 "isExpression": false,
304 "asc": true,
305 "nulls": "last"
306 }
307 ],
308 "isUnique": true,
309 "concurrently": false,
310 "method": "btree",
311 "with": {}
312 }
313 },
314 "foreignKeys": {},
315 "compositePrimaryKeys": {},
316 "uniqueConstraints": {},
317 "policies": {},
318 "checkConstraints": {},
319 "isRLSEnabled": false
320 },
321 "public.connection": {
322 "name": "connection",
323 "schema": "",
324 "columns": {
325 "integration": {
326 "name": "integration",
327 "type": "varchar(255)",
328 "primaryKey": false,
329 "notNull": true
330 },
331 "name": {
332 "name": "name",
333 "type": "varchar(255)",
334 "primaryKey": false,
335 "notNull": true
336 },
337 "template": {
338 "name": "template",
339 "type": "text",
340 "primaryKey": false,
341 "notNull": true
342 },
343 "provider": {
344 "name": "provider",
345 "type": "text",
346 "primaryKey": false,
347 "notNull": true
348 },
349 "item_ids": {
350 "name": "item_ids",
351 "type": "json",
352 "primaryKey": false,
353 "notNull": true
354 },
355 "identity_label": {
356 "name": "identity_label",
357 "type": "text",
358 "primaryKey": false,
359 "notNull": false
360 },
361 "description": {
362 "name": "description",
363 "type": "text",
364 "primaryKey": false,
365 "notNull": false
366 },
367 "last_health": {
368 "name": "last_health",
369 "type": "json",
370 "primaryKey": false,
371 "notNull": false
372 },
373 "tools_synced_at": {
374 "name": "tools_synced_at",
375 "type": "bigint",
376 "primaryKey": false,
377 "notNull": false
378 },
379 "oauth_client": {
380 "name": "oauth_client",
381 "type": "text",
382 "primaryKey": false,
383 "notNull": false
384 },
385 "oauth_client_owner": {
386 "name": "oauth_client_owner",
387 "type": "text",
388 "primaryKey": false,
389 "notNull": false
390 },
391 "refresh_item_id": {
392 "name": "refresh_item_id",
393 "type": "text",
394 "primaryKey": false,
395 "notNull": false
396 },
397 "expires_at": {
398 "name": "expires_at",
399 "type": "bigint",
400 "primaryKey": false,
401 "notNull": false
402 },
403 "oauth_scope": {
404 "name": "oauth_scope",
405 "type": "text",
406 "primaryKey": false,
407 "notNull": false
408 },
409 "oauth_token_url": {
410 "name": "oauth_token_url",
411 "type": "text",
412 "primaryKey": false,
413 "notNull": false
414 },
415 "provider_state": {
416 "name": "provider_state",
417 "type": "json",
418 "primaryKey": false,
419 "notNull": false
420 },
421 "created_at": {
422 "name": "created_at",
423 "type": "timestamp",
424 "primaryKey": false,
425 "notNull": true
426 },
427 "updated_at": {
428 "name": "updated_at",
429 "type": "timestamp",
430 "primaryKey": false,
431 "notNull": true
432 },
433 "row_id": {
434 "name": "row_id",
435 "type": "varchar(255)",
436 "primaryKey": true,
437 "notNull": true
438 },
439 "tenant": {
440 "name": "tenant",
441 "type": "varchar(255)",
442 "primaryKey": false,
443 "notNull": true
444 },
445 "owner": {
446 "name": "owner",
447 "type": "varchar(255)",
448 "primaryKey": false,
449 "notNull": true
450 },
451 "subject": {
452 "name": "subject",
453 "type": "varchar(255)",
454 "primaryKey": false,
455 "notNull": true
456 }
457 },
458 "indexes": {
459 "connection_uidx": {
460 "name": "connection_uidx",
461 "columns": [
462 {
463 "expression": "tenant",
464 "isExpression": false,
465 "asc": true,
466 "nulls": "last"
467 },
468 {
469 "expression": "owner",
470 "isExpression": false,
471 "asc": true,
472 "nulls": "last"
473 },
474 {
475 "expression": "subject",
476 "isExpression": false,
477 "asc": true,
478 "nulls": "last"
479 },
480 {
481 "expression": "integration",
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.definition": {
507 "name": "definition",
508 "schema": "",
509 "columns": {
510 "integration": {
511 "name": "integration",
512 "type": "varchar(255)",
513 "primaryKey": false,
514 "notNull": true
515 },
516 "connection": {
517 "name": "connection",
518 "type": "varchar(255)",
519 "primaryKey": false,
520 "notNull": true
521 },
522 "plugin_id": {
523 "name": "plugin_id",
524 "type": "text",
525 "primaryKey": false,
526 "notNull": true
527 },
528 "name": {
529 "name": "name",
530 "type": "text",
531 "primaryKey": false,
532 "notNull": true
533 },
534 "schema": {
535 "name": "schema",
536 "type": "json",
537 "primaryKey": false,
538 "notNull": true
539 },
540 "created_at": {
541 "name": "created_at",
542 "type": "timestamp",
543 "primaryKey": false,
544 "notNull": true
545 },
546 "row_id": {
547 "name": "row_id",
548 "type": "varchar(255)",
549 "primaryKey": true,
550 "notNull": true
551 },
552 "tenant": {
553 "name": "tenant",
554 "type": "varchar(255)",
555 "primaryKey": false,
556 "notNull": true
557 },
558 "owner": {
559 "name": "owner",
560 "type": "varchar(255)",
561 "primaryKey": false,
562 "notNull": true
563 },
564 "subject": {
565 "name": "subject",
566 "type": "varchar(255)",
567 "primaryKey": false,
568 "notNull": true
569 }
570 },
571 "indexes": {
572 "definition_uidx": {
573 "name": "definition_uidx",
574 "columns": [
575 {
576 "expression": "tenant",
577 "isExpression": false,
578 "asc": true,
579 "nulls": "last"
580 },
581 {
582 "expression": "owner",
583 "isExpression": false,
584 "asc": true,
585 "nulls": "last"
586 },
587 {
588 "expression": "subject",
589 "isExpression": false,
590 "asc": true,
591 "nulls": "last"
592 },
593 {
594 "expression": "integration",
595 "isExpression": false,
596 "asc": true,
597 "nulls": "last"
598 },
599 {
600 "expression": "connection",
601 "isExpression": false,
602 "asc": true,
603 "nulls": "last"
604 },
605 {
606 "expression": "name",
607 "isExpression": false,
608 "asc": true,
609 "nulls": "last"
610 }
611 ],
612 "isUnique": true,
613 "concurrently": false,
614 "method": "btree",
615 "with": {}
616 }
617 },
618 "foreignKeys": {},
619 "compositePrimaryKeys": {},
620 "uniqueConstraints": {},
621 "policies": {},
622 "checkConstraints": {},
623 "isRLSEnabled": false
624 },
625 "public.integration": {
626 "name": "integration",
627 "schema": "",
628 "columns": {
629 "slug": {
630 "name": "slug",
631 "type": "varchar(255)",
632 "primaryKey": false,
633 "notNull": true
634 },
635 "plugin_id": {
636 "name": "plugin_id",
637 "type": "text",
638 "primaryKey": false,
639 "notNull": true
640 },
641 "name": {
642 "name": "name",
643 "type": "text",
644 "primaryKey": false,
645 "notNull": false
646 },
647 "description": {
648 "name": "description",
649 "type": "text",
650 "primaryKey": false,
651 "notNull": false
652 },
653 "config": {
654 "name": "config",
655 "type": "json",
656 "primaryKey": false,
657 "notNull": false
658 },
659 "health_check": {
660 "name": "health_check",
661 "type": "json",
662 "primaryKey": false,
663 "notNull": false
664 },
665 "config_revised_at": {
666 "name": "config_revised_at",
667 "type": "bigint",
668 "primaryKey": false,
669 "notNull": false
670 },
671 "can_remove": {
672 "name": "can_remove",
673 "type": "boolean",
674 "primaryKey": false,
675 "notNull": true,
676 "default": true
677 },
678 "can_refresh": {
679 "name": "can_refresh",
680 "type": "boolean",
681 "primaryKey": false,
682 "notNull": true,
683 "default": false
684 },
685 "created_at": {
686 "name": "created_at",
687 "type": "timestamp",
688 "primaryKey": false,
689 "notNull": true
690 },
691 "updated_at": {
692 "name": "updated_at",
693 "type": "timestamp",
694 "primaryKey": false,
695 "notNull": true
696 },
697 "row_id": {
698 "name": "row_id",
699 "type": "varchar(255)",
700 "primaryKey": true,
701 "notNull": true
702 },
703 "tenant": {
704 "name": "tenant",
705 "type": "varchar(255)",
706 "primaryKey": false,
707 "notNull": true
708 }
709 },
710 "indexes": {
711 "integration_uidx": {
712 "name": "integration_uidx",
713 "columns": [
714 {
715 "expression": "tenant",
716 "isExpression": false,
717 "asc": true,
718 "nulls": "last"
719 },
720 {
721 "expression": "slug",
722 "isExpression": false,
723 "asc": true,
724 "nulls": "last"
725 }
726 ],
727 "isUnique": true,
728 "concurrently": false,
729 "method": "btree",
730 "with": {}
731 }
732 },
733 "foreignKeys": {},
734 "compositePrimaryKeys": {},
735 "uniqueConstraints": {},
736 "policies": {},
737 "checkConstraints": {},
738 "isRLSEnabled": false
739 },
740 "public.oauth_client": {
741 "name": "oauth_client",
742 "schema": "",
743 "columns": {
744 "slug": {
745 "name": "slug",
746 "type": "varchar(255)",
747 "primaryKey": false,
748 "notNull": true
749 },
750 "authorization_url": {
751 "name": "authorization_url",
752 "type": "text",
753 "primaryKey": false,
754 "notNull": true
755 },
756 "token_url": {
757 "name": "token_url",
758 "type": "text",
759 "primaryKey": false,
760 "notNull": true
761 },
762 "grant": {
763 "name": "grant",
764 "type": "text",
765 "primaryKey": false,
766 "notNull": true
767 },
768 "client_id": {
769 "name": "client_id",
770 "type": "text",
771 "primaryKey": false,
772 "notNull": true
773 },
774 "client_secret_item_id": {
775 "name": "client_secret_item_id",
776 "type": "text",
777 "primaryKey": false,
778 "notNull": false
779 },
780 "resource": {
781 "name": "resource",
782 "type": "text",
783 "primaryKey": false,
784 "notNull": false
785 },
786 "origin_kind": {
787 "name": "origin_kind",
788 "type": "text",
789 "primaryKey": false,
790 "notNull": false
791 },
792 "origin_integration": {
793 "name": "origin_integration",
794 "type": "text",
795 "primaryKey": false,
796 "notNull": false
797 },
798 "origin_issuer": {
799 "name": "origin_issuer",
800 "type": "text",
801 "primaryKey": false,
802 "notNull": false
803 },
804 "origin_redirect_uri": {
805 "name": "origin_redirect_uri",
806 "type": "text",
807 "primaryKey": false,
808 "notNull": false
809 },
810 "created_at": {
811 "name": "created_at",
812 "type": "timestamp",
813 "primaryKey": false,
814 "notNull": true
815 },
816 "row_id": {
817 "name": "row_id",
818 "type": "varchar(255)",
819 "primaryKey": true,
820 "notNull": true
821 },
822 "tenant": {
823 "name": "tenant",
824 "type": "varchar(255)",
825 "primaryKey": false,
826 "notNull": true
827 },
828 "owner": {
829 "name": "owner",
830 "type": "varchar(255)",
831 "primaryKey": false,
832 "notNull": true
833 },
834 "subject": {
835 "name": "subject",
836 "type": "varchar(255)",
837 "primaryKey": false,
838 "notNull": true
839 }
840 },
841 "indexes": {
842 "oauth_client_uidx": {
843 "name": "oauth_client_uidx",
844 "columns": [
845 {
846 "expression": "tenant",
847 "isExpression": false,
848 "asc": true,
849 "nulls": "last"
850 },
851 {
852 "expression": "owner",
853 "isExpression": false,
854 "asc": true,
855 "nulls": "last"
856 },
857 {
858 "expression": "subject",
859 "isExpression": false,
860 "asc": true,
861 "nulls": "last"
862 },
863 {
864 "expression": "slug",
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.oauth_session": {
884 "name": "oauth_session",
885 "schema": "",
886 "columns": {
887 "state": {
888 "name": "state",
889 "type": "varchar(255)",
890 "primaryKey": false,
891 "notNull": true
892 },
893 "client_slug": {
894 "name": "client_slug",
895 "type": "text",
896 "primaryKey": false,
897 "notNull": true
898 },
899 "integration": {
900 "name": "integration",
901 "type": "text",
902 "primaryKey": false,
903 "notNull": true
904 },
905 "name": {
906 "name": "name",
907 "type": "text",
908 "primaryKey": false,
909 "notNull": true
910 },
911 "template": {
912 "name": "template",
913 "type": "text",
914 "primaryKey": false,
915 "notNull": true
916 },
917 "redirect_url": {
918 "name": "redirect_url",
919 "type": "text",
920 "primaryKey": false,
921 "notNull": true
922 },
923 "pkce_verifier": {
924 "name": "pkce_verifier",
925 "type": "text",
926 "primaryKey": false,
927 "notNull": false
928 },
929 "identity_label": {
930 "name": "identity_label",
931 "type": "text",
932 "primaryKey": false,
933 "notNull": false
934 },
935 "payload": {
936 "name": "payload",
937 "type": "json",
938 "primaryKey": false,
939 "notNull": true
940 },
941 "expires_at": {
942 "name": "expires_at",
943 "type": "bigint",
944 "primaryKey": false,
945 "notNull": true
946 },
947 "created_at": {
948 "name": "created_at",
949 "type": "timestamp",
950 "primaryKey": false,
951 "notNull": true
952 },
953 "row_id": {
954 "name": "row_id",
955 "type": "varchar(255)",
956 "primaryKey": true,
957 "notNull": true
958 },
959 "tenant": {
960 "name": "tenant",
961 "type": "varchar(255)",
962 "primaryKey": false,
963 "notNull": true
964 },
965 "owner": {
966 "name": "owner",
967 "type": "varchar(255)",
968 "primaryKey": false,
969 "notNull": true
970 },
971 "subject": {
972 "name": "subject",
973 "type": "varchar(255)",
974 "primaryKey": false,
975 "notNull": true
976 }
977 },
978 "indexes": {
979 "oauth_session_uidx": {
980 "name": "oauth_session_uidx",
981 "columns": [
982 {
983 "expression": "tenant",
984 "isExpression": false,
985 "asc": true,
986 "nulls": "last"
987 },
988 {
989 "expression": "state",
990 "isExpression": false,
991 "asc": true,
992 "nulls": "last"
993 }
994 ],
995 "isUnique": true,
996 "concurrently": false,
997 "method": "btree",
998 "with": {}
999 }
1000 },
1001 "foreignKeys": {},
1002 "compositePrimaryKeys": {},
1003 "uniqueConstraints": {},
1004 "policies": {},
1005 "checkConstraints": {},
1006 "isRLSEnabled": false
1007 },
1008 "public.plugin_storage": {
1009 "name": "plugin_storage",
1010 "schema": "",
1011 "columns": {
1012 "plugin_id": {
1013 "name": "plugin_id",
1014 "type": "varchar(255)",
1015 "primaryKey": false,
1016 "notNull": true
1017 },
1018 "collection": {
1019 "name": "collection",
1020 "type": "varchar(255)",
1021 "primaryKey": false,
1022 "notNull": true
1023 },
1024 "key": {
1025 "name": "key",
1026 "type": "varchar(255)",
1027 "primaryKey": false,
1028 "notNull": true
1029 },
1030 "data": {
1031 "name": "data",
1032 "type": "json",
1033 "primaryKey": false,
1034 "notNull": true
1035 },
1036 "created_at": {
1037 "name": "created_at",
1038 "type": "timestamp",
1039 "primaryKey": false,
1040 "notNull": true
1041 },
1042 "updated_at": {
1043 "name": "updated_at",
1044 "type": "timestamp",
1045 "primaryKey": false,
1046 "notNull": true
1047 },
1048 "row_id": {
1049 "name": "row_id",
1050 "type": "varchar(255)",
1051 "primaryKey": true,
1052 "notNull": true
1053 },
1054 "tenant": {
1055 "name": "tenant",
1056 "type": "varchar(255)",
1057 "primaryKey": false,
1058 "notNull": true
1059 },
1060 "owner": {
1061 "name": "owner",
1062 "type": "varchar(255)",
1063 "primaryKey": false,
1064 "notNull": true
1065 },
1066 "subject": {
1067 "name": "subject",
1068 "type": "varchar(255)",
1069 "primaryKey": false,
1070 "notNull": true
1071 }
1072 },
1073 "indexes": {
1074 "plugin_storage_uidx": {
1075 "name": "plugin_storage_uidx",
1076 "columns": [
1077 {
1078 "expression": "tenant",
1079 "isExpression": false,
1080 "asc": true,
1081 "nulls": "last"
1082 },
1083 {
1084 "expression": "owner",
1085 "isExpression": false,
1086 "asc": true,
1087 "nulls": "last"
1088 },
1089 {
1090 "expression": "subject",
1091 "isExpression": false,
1092 "asc": true,
1093 "nulls": "last"
1094 },
1095 {
1096 "expression": "plugin_id",
1097 "isExpression": false,
1098 "asc": true,
1099 "nulls": "last"
1100 },
1101 {
1102 "expression": "collection",
1103 "isExpression": false,
1104 "asc": true,
1105 "nulls": "last"
1106 },
1107 {
1108 "expression": "key",
1109 "isExpression": false,
1110 "asc": true,
1111 "nulls": "last"
1112 }
1113 ],
1114 "isUnique": true,
1115 "concurrently": false,
1116 "method": "btree",
1117 "with": {}
1118 }
1119 },
1120 "foreignKeys": {},
1121 "compositePrimaryKeys": {},
1122 "uniqueConstraints": {},
1123 "policies": {},
1124 "checkConstraints": {},
1125 "isRLSEnabled": false
1126 },
1127 "public.private_executor_cloud_settings": {
1128 "name": "private_executor_cloud_settings",
1129 "schema": "",
1130 "columns": {
1131 "id": {
1132 "name": "id",
1133 "type": "varchar(255)",
1134 "primaryKey": true,
1135 "notNull": true
1136 },
1137 "version": {
1138 "name": "version",
1139 "type": "varchar(255)",
1140 "primaryKey": false,
1141 "notNull": true,
1142 "default": "'1.0.0'"
1143 }
1144 },
1145 "indexes": {},
1146 "foreignKeys": {},
1147 "compositePrimaryKeys": {},
1148 "uniqueConstraints": {},
1149 "policies": {},
1150 "checkConstraints": {},
1151 "isRLSEnabled": false
1152 },
1153 "public.subject": {
1154 "name": "subject",
1155 "schema": "",
1156 "columns": {
1157 "external_id": {
1158 "name": "external_id",
1159 "type": "varchar(255)",
1160 "primaryKey": false,
1161 "notNull": true
1162 },
1163 "created_at": {
1164 "name": "created_at",
1165 "type": "timestamp",
1166 "primaryKey": false,
1167 "notNull": true
1168 },
1169 "last_seen_at": {
1170 "name": "last_seen_at",
1171 "type": "bigint",
1172 "primaryKey": false,
1173 "notNull": false
1174 },
1175 "status": {
1176 "name": "status",
1177 "type": "text",
1178 "primaryKey": false,
1179 "notNull": false
1180 },
1181 "row_id": {
1182 "name": "row_id",
1183 "type": "varchar(255)",
1184 "primaryKey": true,
1185 "notNull": true
1186 },
1187 "tenant": {
1188 "name": "tenant",
1189 "type": "varchar(255)",
1190 "primaryKey": false,
1191 "notNull": true
1192 }
1193 },
1194 "indexes": {
1195 "subject_uidx": {
1196 "name": "subject_uidx",
1197 "columns": [
1198 {
1199 "expression": "tenant",
1200 "isExpression": false,
1201 "asc": true,
1202 "nulls": "last"
1203 },
1204 {
1205 "expression": "external_id",
1206 "isExpression": false,
1207 "asc": true,
1208 "nulls": "last"
1209 }
1210 ],
1211 "isUnique": true,
1212 "concurrently": false,
1213 "method": "btree",
1214 "with": {}
1215 }
1216 },
1217 "foreignKeys": {},
1218 "compositePrimaryKeys": {},
1219 "uniqueConstraints": {},
1220 "policies": {},
1221 "checkConstraints": {},
1222 "isRLSEnabled": false
1223 },
1224 "public.tool": {
1225 "name": "tool",
1226 "schema": "",
1227 "columns": {
1228 "integration": {
1229 "name": "integration",
1230 "type": "varchar(255)",
1231 "primaryKey": false,
1232 "notNull": true
1233 },
1234 "connection": {
1235 "name": "connection",
1236 "type": "varchar(255)",
1237 "primaryKey": false,
1238 "notNull": true
1239 },
1240 "plugin_id": {
1241 "name": "plugin_id",
1242 "type": "text",
1243 "primaryKey": false,
1244 "notNull": true
1245 },
1246 "name": {
1247 "name": "name",
1248 "type": "varchar(255)",
1249 "primaryKey": false,
1250 "notNull": true
1251 },
1252 "description": {
1253 "name": "description",
1254 "type": "text",
1255 "primaryKey": false,
1256 "notNull": true
1257 },
1258 "input_schema": {
1259 "name": "input_schema",
1260 "type": "json",
1261 "primaryKey": false,
1262 "notNull": false
1263 },
1264 "output_schema": {
1265 "name": "output_schema",
1266 "type": "json",
1267 "primaryKey": false,
1268 "notNull": false
1269 },
1270 "annotations": {
1271 "name": "annotations",
1272 "type": "json",
1273 "primaryKey": false,
1274 "notNull": false
1275 },
1276 "created_at": {
1277 "name": "created_at",
1278 "type": "timestamp",
1279 "primaryKey": false,
1280 "notNull": true
1281 },
1282 "updated_at": {
1283 "name": "updated_at",
1284 "type": "timestamp",
1285 "primaryKey": false,
1286 "notNull": true
1287 },
1288 "row_id": {
1289 "name": "row_id",
1290 "type": "varchar(255)",
1291 "primaryKey": true,
1292 "notNull": true
1293 },
1294 "tenant": {
1295 "name": "tenant",
1296 "type": "varchar(255)",
1297 "primaryKey": false,
1298 "notNull": true
1299 },
1300 "owner": {
1301 "name": "owner",
1302 "type": "varchar(255)",
1303 "primaryKey": false,
1304 "notNull": true
1305 },
1306 "subject": {
1307 "name": "subject",
1308 "type": "varchar(255)",
1309 "primaryKey": false,
1310 "notNull": true
1311 }
1312 },
1313 "indexes": {
1314 "tool_uidx": {
1315 "name": "tool_uidx",
1316 "columns": [
1317 {
1318 "expression": "tenant",
1319 "isExpression": false,
1320 "asc": true,
1321 "nulls": "last"
1322 },
1323 {
1324 "expression": "owner",
1325 "isExpression": false,
1326 "asc": true,
1327 "nulls": "last"
1328 },
1329 {
1330 "expression": "subject",
1331 "isExpression": false,
1332 "asc": true,
1333 "nulls": "last"
1334 },
1335 {
1336 "expression": "integration",
1337 "isExpression": false,
1338 "asc": true,
1339 "nulls": "last"
1340 },
1341 {
1342 "expression": "connection",
1343 "isExpression": false,
1344 "asc": true,
1345 "nulls": "last"
1346 },
1347 {
1348 "expression": "name",
1349 "isExpression": false,
1350 "asc": true,
1351 "nulls": "last"
1352 }
1353 ],
1354 "isUnique": true,
1355 "concurrently": false,
1356 "method": "btree",
1357 "with": {}
1358 }
1359 },
1360 "foreignKeys": {},
1361 "compositePrimaryKeys": {},
1362 "uniqueConstraints": {},
1363 "policies": {},
1364 "checkConstraints": {},
1365 "isRLSEnabled": false
1366 },
1367 "public.tool_policy": {
1368 "name": "tool_policy",
1369 "schema": "",
1370 "columns": {
1371 "id": {
1372 "name": "id",
1373 "type": "varchar(255)",
1374 "primaryKey": false,
1375 "notNull": true
1376 },
1377 "pattern": {
1378 "name": "pattern",
1379 "type": "text",
1380 "primaryKey": false,
1381 "notNull": true
1382 },
1383 "action": {
1384 "name": "action",
1385 "type": "text",
1386 "primaryKey": false,
1387 "notNull": true
1388 },
1389 "position": {
1390 "name": "position",
1391 "type": "text",
1392 "primaryKey": false,
1393 "notNull": true
1394 },
1395 "created_at": {
1396 "name": "created_at",
1397 "type": "timestamp",
1398 "primaryKey": false,
1399 "notNull": true
1400 },
1401 "updated_at": {
1402 "name": "updated_at",
1403 "type": "timestamp",
1404 "primaryKey": false,
1405 "notNull": true
1406 },
1407 "row_id": {
1408 "name": "row_id",
1409 "type": "varchar(255)",
1410 "primaryKey": true,
1411 "notNull": true
1412 },
1413 "tenant": {
1414 "name": "tenant",
1415 "type": "varchar(255)",
1416 "primaryKey": false,
1417 "notNull": true
1418 },
1419 "owner": {
1420 "name": "owner",
1421 "type": "varchar(255)",
1422 "primaryKey": false,
1423 "notNull": true
1424 },
1425 "subject": {
1426 "name": "subject",
1427 "type": "varchar(255)",
1428 "primaryKey": false,
1429 "notNull": true
1430 }
1431 },
1432 "indexes": {
1433 "tool_policy_uidx": {
1434 "name": "tool_policy_uidx",
1435 "columns": [
1436 {
1437 "expression": "tenant",
1438 "isExpression": false,
1439 "asc": true,
1440 "nulls": "last"
1441 },
1442 {
1443 "expression": "owner",
1444 "isExpression": false,
1445 "asc": true,
1446 "nulls": "last"
1447 },
1448 {
1449 "expression": "subject",
1450 "isExpression": false,
1451 "asc": true,
1452 "nulls": "last"
1453 },
1454 {
1455 "expression": "id",
1456 "isExpression": false,
1457 "asc": true,
1458 "nulls": "last"
1459 }
1460 ],
1461 "isUnique": true,
1462 "concurrently": false,
1463 "method": "btree",
1464 "with": {}
1465 }
1466 },
1467 "foreignKeys": {},
1468 "compositePrimaryKeys": {},
1469 "uniqueConstraints": {},
1470 "policies": {},
1471 "checkConstraints": {},
1472 "isRLSEnabled": false
1473 }
1474 },
1475 "enums": {},
1476 "schemas": {},
1477 "sequences": {},
1478 "roles": {},
1479 "policies": {},
1480 "views": {},
1481 "_meta": {
1482 "columns": {},
1483 "schemas": {},
1484 "tables": {}
1485 }
1486}
apps/cloud/drizzle/meta/_journal.json+42−0
@@ -71,6 +71,48 @@ · 70 unmodified lines
71 "when": 1783025157233,71 "when": 1783025157233,
72 "tag": "0009_true_lake",72 "tag": "0009_true_lake",
73 "breakpoints": true73 "breakpoints": true
74 },
75 {
76 "idx": 10,
77 "version": "7",
78 "when": 1783660669921,
79 "tag": "0010_ndjson_output_arrays",
80 "breakpoints": true
81 },
82 {
83 "idx": 11,
84 "version": "7",
85 "when": 1784581232732,
86 "tag": "0011_tense_caretaker",
87 "breakpoints": true
88 },
89 {
90 "idx": 12,
91 "version": "7",
92 "when": 1785193313601,
93 "tag": "0012_woozy_shiva",
94 "breakpoints": true
95 },
96 {
97 "idx": 13,
98 "version": "7",
99 "when": 1785354087138,
100 "tag": "0013_curly_rocket_racer",
101 "breakpoints": true
102 },
103 {
104 "idx": 14,
105 "version": "7",
106 "when": 1785355312973,
107 "tag": "0014_new_karma",
108 "breakpoints": true
109 },
110 {
111 "idx": 15,
112 "version": "7",
113 "when": 1785355354955,
114 "tag": "0015_equal_the_leader",
115 "breakpoints": true
74 }116 }
75 ]117 ]
76}118}
apps/cloud/executor.config.ts+13−15
@@ -1,7 +1,13 @@
1import { defineExecutorConfig } from "@executor-js/sdk";1import { defineExecutorConfig } from "@executor-js/sdk";
2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3import { googleHttpPlugin } from "@executor-js/plugin-google/api";3import {
4import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";4 googleCatalog,
5 googleDiscoveryAdapter,
6} from "@executor-js/plugin-openapi/providers/google";
7import {
8 microsoftCatalog,
9 microsoftGraphAdapter,
10} from "@executor-js/plugin-openapi/providers/microsoft";
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";11import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";12import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";13import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";
@@ -42,23 +48,15 @@ interface CloudPluginDeps { · 34 unmodified lines
42 * falls back to the credential-driven default. */48 * falls back to the credential-driven default. */
43 readonly workosVaultClient?: WorkOSVaultClient;49 readonly workosVaultClient?: WorkOSVaultClient;
44 readonly activeToolkitSlug?: string;50 readonly activeToolkitSlug?: string;
45 /** Mirrors `HostConfig.allowLocalNetwork` (`ALLOW_LOCAL_NETWORK`): lets
46 * `microsoft.addGraph` point at a loopback emulator instead of the pinned
47 * Microsoft Graph URLs. Off by default; production leaves it unset. */
48 readonly allowLocalNetwork?: boolean;
49}51}
50 52
51export default defineExecutorConfig({53export default defineExecutorConfig({
52 plugins: ({54 plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) =>
53 workosCredentials,
54 workosVaultClient,
55 activeToolkitSlug,
56 allowLocalNetwork,
57 }: CloudPluginDeps = {}) =>
58 [55 [
59 openApiHttpPlugin(),56 openApiHttpPlugin({
60 googleHttpPlugin(),57 presets: [...googleCatalog, ...microsoftCatalog],
61 microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }),58 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
59 }),
62 mcpHttpPlugin({60 mcpHttpPlugin({
63 dangerouslyAllowStdioMCP: false,61 dangerouslyAllowStdioMCP: false,
64 }),62 }),
apps/cloud/package.json+9−4
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/cloud",2 "name": "@executor-js/cloud",
3 "version": "1.4.47",3 "version": "1.4.61",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "scripts": {
@@ -30,7 +30,10 @@ · 23 unmodified lines
30 "typecheck:slow": "tsc --noEmit",30 "typecheck:slow": "tsc --noEmit",
31 "db:backfill-org-slugs:prod": "op run --env-file=.env.production -- bun run scripts/backfill-org-slugs.ts",31 "db:backfill-org-slugs:prod": "op run --env-file=.env.production -- bun run scripts/backfill-org-slugs.ts",
32 "db:backfill-org-slugs:dev": "op run --env-file=.env.op -- bun run scripts/backfill-org-slugs.ts",32 "db:backfill-org-slugs:dev": "op run --env-file=.env.op -- bun run scripts/backfill-org-slugs.ts",
33 "routes:gen": "bun scripts/gen-routes.ts"33 "db:backfill-subjects:prod": "op run --env-file=.env.production -- bun run scripts/backfill-subjects.ts",
34 "db:backfill-subjects:dev": "op run --env-file=.env.op -- bun run scripts/backfill-subjects.ts",
35 "routes:gen": "bun scripts/gen-routes.ts",
36 "vendor-wasm": "bun run scripts/vendor-quickjs-wasm.ts"
34 },37 },
35 "dependencies": {38 "dependencies": {
36 "@cloudflare/vite-plugin": "^1.31.1",39 "@cloudflare/vite-plugin": "^1.31.1",
@@ -41,10 +44,9 @@ · 4 unmodified lines
41 "@executor-js/execution": "workspace:*",44 "@executor-js/execution": "workspace:*",
42 "@executor-js/fumadb": "workspace:*",45 "@executor-js/fumadb": "workspace:*",
43 "@executor-js/host-mcp": "workspace:*",46 "@executor-js/host-mcp": "workspace:*",
44 "@executor-js/plugin-google": "workspace:*",47 "@executor-js/mcp-apps-shell": "workspace:*",
45 "@executor-js/plugin-graphql": "workspace:*",48 "@executor-js/plugin-graphql": "workspace:*",
46 "@executor-js/plugin-mcp": "workspace:*",49 "@executor-js/plugin-mcp": "workspace:*",
47 "@executor-js/plugin-microsoft": "workspace:*",
48 "@executor-js/plugin-openapi": "workspace:*",50 "@executor-js/plugin-openapi": "workspace:*",
49 "@executor-js/plugin-toolkits": "workspace:*",51 "@executor-js/plugin-toolkits": "workspace:*",
50 "@executor-js/plugin-workos-vault": "workspace:*",52 "@executor-js/plugin-workos-vault": "workspace:*",
@@ -53,6 +55,7 @@ · 2 unmodified lines
53 "@executor-js/runtime-quickjs": "workspace:*",55 "@executor-js/runtime-quickjs": "workspace:*",
54 "@executor-js/sdk": "workspace:*",56 "@executor-js/sdk": "workspace:*",
55 "@executor-js/vite-plugin": "workspace:*",57 "@executor-js/vite-plugin": "workspace:*",
58 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
56 "@modelcontextprotocol/sdk": "^1.29.0",59 "@modelcontextprotocol/sdk": "^1.29.0",
57 "@opentelemetry/api": "~1.9.0",60 "@opentelemetry/api": "~1.9.0",
58 "@opentelemetry/exporter-logs-otlp-http": "^0.214.0",61 "@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
@@ -70,9 +73,11 @@ · 11 unmodified lines
70 "autumn-js": "^1.2.8",73 "autumn-js": "^1.2.8",
71 "drizzle-orm": "catalog:",74 "drizzle-orm": "catalog:",
72 "effect": "catalog:",75 "effect": "catalog:",
76 "iron-webcrypto": "2.0.0",
73 "jose": "^5.6.3",77 "jose": "^5.6.3",
74 "postgres": "^3.4.9",78 "postgres": "^3.4.9",
75 "posthog-js": "^1.372.5",79 "posthog-js": "^1.372.5",
80 "quickjs-emscripten-core": "0.31.0",
76 "react": "catalog:",81 "react": "catalog:",
77 "react-dom": "catalog:",82 "react-dom": "catalog:",
78 "sonner": "^2.0.7"83 "sonner": "^2.0.7"
apps/cloud/scripts/backfill-subjects.ts+87−0
@@ -0,0 +1,87 @@
1/* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band migration script over a raw postgres connection */
2// ---------------------------------------------------------------------------
3// One-off data backfill: mint `subject` rows for principals that predate the
4// subject table (added by migration 0012, written only on sightings from then
5// on).
6//
7// bun run db:backfill-subjects:prod # op run --env-file=.env.production
8// bun run db:backfill-subjects:dev # against the local PGlite dev db
9//
10// Source of truth is the `connection` table: before 0012, "which users exist
11// under this tenant" was answerable only through connection rows, so a distinct
12// (tenant, subject) there — excluding the org sentinel '' — is exactly the set
13// of users the admin console under-reports until their next request.
14//
15// created_at ← the tenant+subject's oldest connection, the closest honest
16// answer to "when did this user first appear".
17// last_seen_at ← the tenant+subject's newest connection `updated_at`, the
18// latest activity storage can attest to. Rendering every
19// pre-0012 user as "Never" was literally true of the sighting
20// column but reads as "inactive user" to an operator, which
21// is the wrong answer for a tenant full of active people. The
22// first real sighting overwrites this with request truth.
23//
24// Idempotent — the insert's ON CONFLICT on the (tenant, external_id) unique
25// index leaves rows the runtime has since minted (or a prior run inserted)
26// untouched, and the last_seen_at repair only fills NULLs — so re-running is
27// safe and never rewinds a live row's fields.
28// Pass --dry-run to print the plan without writing.
29// ---------------------------------------------------------------------------
30
31import postgres from "postgres";
32import { createId } from "@executor-js/fumadb/cuid";
33
34const dryRun = process.argv.includes("--dry-run");
35
36const connectionString = process.env.DATABASE_URL;
37if (!connectionString) {
38 console.error("DATABASE_URL is not set");
39 process.exit(1);
40}
41
42const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" });
43
44const rows = await sql<
45 { tenant: string; subject: string; first_connection_at: Date; last_activity_at: Date }[]
46>`
47 SELECT tenant, subject,
48 MIN(created_at) AS first_connection_at,
49 MAX(updated_at) AS last_activity_at
50 FROM connection
51 WHERE subject <> ''
52 GROUP BY tenant, subject
53 ORDER BY tenant, subject
54`;
55console.log(`${rows.length} (tenant, subject) pair(s) with connections`);
56
57let inserted = 0;
58let repaired = 0;
59for (const row of rows) {
60 console.log(
61 `${row.tenant} ${row.subject} first connection ${row.first_connection_at.toISOString()} last activity ${row.last_activity_at.toISOString()}`,
62 );
63 if (dryRun) continue;
64 const lastSeenMs = row.last_activity_at.getTime();
65 const result = await sql`
66 INSERT INTO subject (row_id, tenant, external_id, created_at, last_seen_at, status)
67 VALUES (${createId()}, ${row.tenant}, ${row.subject}, ${row.first_connection_at}, ${lastSeenMs}, NULL)
68 ON CONFLICT (tenant, external_id) DO NOTHING
69 `;
70 inserted += result.count;
71 if (result.count > 0) continue;
72 // The row predates this run but may still carry NULL from a run of the
73 // pre-repair version of this script. Fill it in; a sighted row keeps its
74 // request-truth timestamp.
75 const repair = await sql`
76 UPDATE subject SET last_seen_at = ${lastSeenMs}
77 WHERE tenant = ${row.tenant} AND external_id = ${row.subject} AND last_seen_at IS NULL
78 `;
79 repaired += repair.count;
80}
81
82console.log(
83 dryRun
84 ? `dry run — would insert up to ${rows.length} row(s)`
85 : `inserted ${inserted} row(s), repaired last_seen_at on ${repaired} row(s)`,
86);
87await sql.end();
apps/cloud/scripts/build.mjs+20−1
@@ -9,7 +9,7 @@ · 8 unmodified lines
9 9
10import { spawnSync } from "node:child_process";10import { spawnSync } from "node:child_process";
11import { randomBytes } from "node:crypto";11import { randomBytes } from "node:crypto";
12import { rmSync } from "node:fs";12import { existsSync, readFileSync, rmSync } from "node:fs";
13 13
14if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) {14if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) {
15 process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex");15 process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex");
@@ -32,3 +32,22 @@ for (const step of steps) { · 16 unmodified lines
32 process.exit(result.status ?? 1);32 process.exit(result.status ?? 1);
33 }33 }
34}34}
35
36// The deployed Worker serves the MCP-Apps shell (`ui://executor/shell.html`)
37// by fetching this stable-named asset through the ASSETS binding — it has no
38// filesystem to read the shell from. A build that failed to emit it would
39// deploy a Worker whose artifact resource reads all fail, so make the miss a
40// build failure here instead of a production incident. Same shape as the
41// worker-bundler asset assertion from #1378.
42const shellAsset = new URL(
43 "../dist/client/assets/executor-mcp-apps-shell-stable.html",
44 import.meta.url,
45);
46if (!existsSync(shellAsset)) {
47 console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`);
48 process.exit(1);
49}
50if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) {
51 console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`);
52 process.exit(1);
53}
apps/cloud/scripts/dev-db.ts+145−14
@@ -15,6 +15,7 @@ import { PGlite } from "@electric-sql/pglite"; · 14 unmodified lines
15import { PGLiteSocketServer } from "@electric-sql/pglite-socket";15import { PGLiteSocketServer } from "@electric-sql/pglite-socket";
16import { drizzle } from "drizzle-orm/pglite";16import { drizzle } from "drizzle-orm/pglite";
17import { migrate } from "drizzle-orm/pglite/migrator";17import { migrate } from "drizzle-orm/pglite/migrator";
18import postgres from "postgres";
18 19
19const __dirname = dirname(fileURLToPath(import.meta.url));20const __dirname = dirname(fileURLToPath(import.meta.url));
20// Port + data dir default to the dev values but are env-overridable so a second21// Port + data dir default to the dev values but are env-overridable so a second
@@ -104,25 +105,46 @@ await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); · 83 unmodified lines
104// but prepared statement requires M" -> random 500s on whichever request lost105// but prepared statement requires M" -> random 500s on whichever request lost
105// the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch106// the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch
106// batches each socket data event into one queue entry and holds handler107// batches each socket data event into one queue entry and holds handler
107// affinity while a pipeline is open;108// affinity while a pipeline is open. The patch also fixes the queue's two
108// src/db/dev-db-socket-concurrency.node.test.ts is the regression test.109// self-bricking failure paths — both surfaced in CI as the e2e "cloud signIn:
109const server = new PGLiteSocketServer({110// callback set no session (500)" cascade, where new connections' startup
110 db,111// packets sat unanswered (postgres.js CONNECT_TIMEOUT) until restart:
111 port: PORT,112// 1. Stock 0.1.4 `return`ed out of the drain loop when a query REJECTED at
112 host: "127.0.0.1",113// the JS level, leaving its `processing` flag latched true; nothing was
113 maxConnections: Number(process.env.DEV_DB_MAX_CONNECTIONS ?? 1000),114// ever dequeued again.
114 // Backstop for pipeline affinity: a client that stalls mid-pipeline (Parse115// 2. A client whose socket died WHILE its pipeline-opening entry executed:
115 // sent, no Sync) with its socket still OPEN would hold the queue's handler116// detach() cleared affinity before the entry finished, the queue then
116 // affinity forever and starve every other connection, since affinity only117// took affinity for the already-dead handler, and no timer was left to
117 // releases on detach and detach needs close/error/idle-timeout. In ms; the118// release it. The queue now tracks detached handlers and repairs any
118 // timer resets on every data event, so only a genuinely dead client trips it.119// transaction or pipeline affinity they can no longer release.
119 idleTimeout: Number(process.env.DEV_DB_IDLE_TIMEOUT_MS ?? 30_000),120// src/db/dev-db-socket-concurrency.node.test.ts is the regression test for
120});121// all of the above.
122const makeServer = () =>
123 new PGLiteSocketServer({
124 db,
125 port: PORT,
126 host: "127.0.0.1",
127 maxConnections: Number(process.env.DEV_DB_MAX_CONNECTIONS ?? 1000),
128 // Backstop for pipeline affinity: a client that stalls mid-pipeline (Parse
129 // sent, no Sync) with its socket still OPEN would hold the queue's handler
130 // affinity forever and starve every other connection, since affinity only
131 // releases on detach and detach needs close/error/idle-timeout. In ms; the
132 // timer resets on every data event. The patch scopes the reap to connections
133 // actually HOLDING affinity (open pipeline or transaction): an idle-at-rest
134 // connection is the normal state of a healthy postgres.js pool held by a
135 // long-lived scope (SSE), and reaping those raced live queries into
136 // sporadic `write CONNECTION_ENDED` 500s.
137 idleTimeout: Number(process.env.DEV_DB_IDLE_TIMEOUT_MS ?? 30_000),
138 });
121 139
140let server = makeServer();
122await server.start();141await server.start();
123console.log(`[dev-db] Listening on postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`);142console.log(`[dev-db] Listening on postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`);
124 143
144let stopping = false;
145
125const shutdown = async () => {146const shutdown = async () => {
147 stopping = true;
126 console.log("\n[dev-db] Shutting down");148 console.log("\n[dev-db] Shutting down");
127 await server.stop();149 await server.stop();
128 await db.close();150 await db.close();
@@ -131,3 +153,112 @@ const shutdown = async () => { · 2 unmodified lines
131 153
132process.on("SIGINT", shutdown);154process.on("SIGINT", shutdown);
133process.on("SIGTERM", shutdown);155process.on("SIGTERM", shutdown);
156
157// ---------------------------------------------------------------------------
158// Wedge watchdog
159// ---------------------------------------------------------------------------
160//
161// Twice the socket server has shipped a state machine that could stop
162// answering NEW connections while the process, the port, and PGlite all stayed
163// up (the CI e2e "cloud signIn: callback set no session (500)" cascades: every
164// in-flight query dies once, then every fresh connection's startup packet
165// times out — CONNECT_TIMEOUT — for the rest of the shard). The known paths
166// are patched with regression tests, but each recurrence so far has found a
167// new path, and a wedged front-end turns ONE infra hiccup into a failure of
168// every remaining test in the shard.
169//
170// So: probe the server the way the app does — a fresh TCP connection, real
171// startup handshake, `select 1` — and when several consecutive probes fail,
172// dump the server's internals to the boot log and swap in a fresh socket
173// server on the same PGlite instance (all state is in PGlite; the front-end is
174// stateless, so this drops only already-doomed connections). If a restart
175// doesn't restore service, exit non-zero: the boot supervisor logs the exit
176// loudly and the run fails fast with an attributable cause instead of minutes
177// of anonymous CONNECT_TIMEOUTs. The wedge itself stays visible in the
178// server-logs artifact via the [dev-db][watchdog] lines.
179const WATCHDOG_INTERVAL_MS = Number(process.env.DEV_DB_WATCHDOG_INTERVAL_MS ?? 5_000);
180// 3 consecutive failures ≈ 15s+ of hard unavailability. PGlite serves queries
181// in milliseconds; even a deep queue clears in well under one probe interval,
182// so consecutive startup failures this sustained only happen wedged.
183const WATCHDOG_FAILURES_TO_RESTART = 3;
184const WATCHDOG_MAX_RESTARTS = 3;
185
186const probe = async (): Promise<void> => {
187 const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, {
188 max: 1,
189 idle_timeout: 0,
190 connect_timeout: 5,
191 fetch_types: false,
192 prepare: false,
193 onnotice: () => undefined,
194 });
195 try {
196 // connect_timeout only bounds the handshake; race the query too so a
197 // post-startup wedge cannot hang the watchdog itself.
198 await Promise.race([
199 sql.unsafe("select 1"),
200 sleep(10_000).then(() => {
201 throw new Error("probe query timed out after 10s");
202 }),
203 ]);
204 } finally {
205 await sql.end({ timeout: 5 }).catch(() => {});
206 }
207};
208
209const watchdog = async () => {
210 let consecutiveFailures = 0;
211 let restarts = 0;
212 for (;;) {
213 await sleep(WATCHDOG_INTERVAL_MS);
214 if (stopping) return;
215 try {
216 await probe();
217 consecutiveFailures = 0;
218 } catch (cause) {
219 consecutiveFailures += 1;
220 console.error(
221 `[dev-db][watchdog] probe failed (${consecutiveFailures}/${WATCHDOG_FAILURES_TO_RESTART}): ${String(cause)}`,
222 );
223 if (consecutiveFailures < WATCHDOG_FAILURES_TO_RESTART) continue;
224 console.error(
225 `[dev-db][watchdog] socket server wedged; stats: ${JSON.stringify(server.getStats())}`,
226 );
227 if (restarts >= WATCHDOG_MAX_RESTARTS) {
228 console.error(
229 `[dev-db][watchdog] still wedged after ${restarts} restarts — giving up so the boot supervisor reports it`,
230 );
231 process.exit(1);
232 }
233 restarts += 1;
234 consecutiveFailures = 0;
235 console.error(
236 `[dev-db][watchdog] restarting socket server (${restarts}/${WATCHDOG_MAX_RESTARTS})`,
237 );
238 // stop() itself goes through the query queue (detach rolls back open
239 // transactions), so a wedge deep enough can hang the restart too —
240 // bound it and treat that as fatal rather than hanging the watchdog.
241 const restart = async () => {
242 await server.stop();
243 server = makeServer();
244 await server.start();
245 };
246 try {
247 await Promise.race([
248 restart(),
249 sleep(15_000).then(() => {
250 throw new Error("restart timed out after 15s");
251 }),
252 ]);
253 console.error(`[dev-db][watchdog] socket server restarted`);
254 } catch (restartCause) {
255 console.error(
256 `[dev-db][watchdog] restart failed (${String(restartCause)}) — exiting so the boot supervisor reports it`,
257 );
258 process.exit(1);
259 }
260 }
261 }
262};
263
264void watchdog();
apps/cloud/scripts/start-closure.mjs+124−0
@@ -0,0 +1,124 @@
1// ---------------------------------------------------------------------------
2// Measure the Worker's *evaluated* module closures from the build output.
3// ---------------------------------------------------------------------------
4//
5// Upload size does not predict cold-isolate cost: a Worker can ship megabytes
6// that never load, and the bytes that matter are the ones an isolate must
7// evaluate before it can answer. Rollup already separates static from dynamic
8// edges, so we can compute that split directly instead of reading totals.
9//
10// Two closures matter:
11// startup - statically reachable from the Worker entry. Evaluated on every
12// cold isolate before any request is served.
13// start - the TanStack Start server graph, reached through the lazy
14// `loadEntries` dynamic imports. Evaluated on the first request
15// that enters the app - i.e. a page request.
16// app - the Effect app plane. `/api/*` dispatches at the Worker entry and
17// skips Start entirely, so an API request evaluates this instead of
18// `start`; reported separately because the two planes now diverge.
19//
20// Anything reachable only through a dynamic import is not counted: making a
21// heavy dependency lazy is exactly the outcome this rewards.
22//
23// Scope note: this measures bytes, which is a proxy for cold-start cost, not a
24// proven cause of any particular regression. The Aug 2026 MCP incident was
25// NOT explained by this number - reverting the offending packages moved the
26// evaluated closure by 0.02 MB while restoring production latency, so
27// module-scope execution cost, not size, drove that one. Treat a budget breach
28// as "this will make cold starts worse", not as "this is why the site is slow".
29import { readFileSync, readdirSync, statSync } from "node:fs";
30import { dirname, join, relative, resolve } from "node:path";
31
32const DIST = resolve(process.argv[2] ?? "dist/server");
33const ENTRY = join(DIST, "index.js");
34
35// Rollup emits `from "./x.js"`, bare `import "./x.js"`, `export ... from "./x.js"`
36// (all static) and `import("./x.js")` (dynamic). Matching the emitted output
37// rather than source means we see the graph as the runtime sees it.
38const STATIC_RE = /(?:from|import)\s*["'](\.[^"']+)["']/g;
39const DYNAMIC_RE = /import\(\s*["'](\.[^"']+)["']\s*\)/g;
40
41const listChunks = (dir) =>
42 readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
43 e.isDirectory()
44 ? listChunks(join(dir, e.name))
45 : e.name.endsWith(".js")
46 ? [join(dir, e.name)]
47 : [],
48 );
49
50const graph = new Map();
51for (const file of listChunks(DIST)) {
52 const code = readFileSync(file, "utf8");
53 const dynamic = new Set([...code.matchAll(DYNAMIC_RE)].map((m) => resolve(dirname(file), m[1])));
54 // A specifier inside `import(...)` also matches STATIC_RE's `import` branch,
55 // so subtract the dynamic set rather than trusting the static matches alone.
56 const staticDeps = new Set(
57 [...code.matchAll(STATIC_RE)]
58 .map((m) => resolve(dirname(file), m[1]))
59 .filter((p) => !dynamic.has(p)),
60 );
61 graph.set(file, { size: statSync(file).size, static: staticDeps, dynamic });
62}
63
64/** Bytes evaluated when `roots` are loaded, following static edges only. */
65const closure = (roots) => {
66 const seen = new Set();
67 const queue = [...roots];
68 while (queue.length > 0) {
69 const file = queue.pop();
70 if (seen.has(file) || !graph.has(file)) continue;
71 seen.add(file);
72 queue.push(...graph.get(file).static);
73 }
74 return seen;
75};
76
77const bytes = (files) => [...files].reduce((sum, f) => sum + (graph.get(f)?.size ?? 0), 0);
78const mb = (n) => `${(n / 1024 / 1024).toFixed(2)} MB`;
79const name = (f) => relative(DIST, f);
80
81const startup = closure([ENTRY]);
82// The lazy server-graph entries Start pulls on first request.
83const startRoots = [...graph.get(ENTRY).dynamic].filter((f) =>
84 /(start|router|tanstack)/.test(name(f)),
85);
86const start = closure(startRoots);
87const appRoots = [...graph.get(ENTRY).dynamic].filter((f) => /\/app-[A-Za-z0-9_-]+\.js$/.test(f));
88const app = closure([ENTRY, ...appRoots]);
89// The budget tracks the worst plane: whichever costs a cold isolate more.
90const evaluated = new Set([...startup, ...start]);
91
92const report = (label, files) => {
93 console.log(`\n${label}: ${mb(bytes(files))} (${files.size} chunks)`);
94 const own = [...files].filter((f) => !startup.has(f) || label === "startup");
95 for (const f of own.sort((a, b) => graph.get(b).size - graph.get(a).size).slice(0, 12)) {
96 console.log(` ${(graph.get(f).size / 1024).toFixed(0).padStart(6)} KB ${name(f)}`);
97 }
98};
99
100report("startup", startup);
101report("start", start);
102console.log(`\npage request (startup + start): ${mb(bytes(evaluated))}`);
103console.log(
104 `API request (startup + app): ${mb(bytes(app))}${appRoots.length ? "" : " [no app chunk - /api still routes through Start]"}`,
105);
106const lazyOnly = [...graph.keys()].filter((f) => !evaluated.has(f));
107console.log(
108 `deferred behind dynamic import: ${mb(bytes(lazyOnly))} (${lazyOnly.length} chunks)`,
109);
110
111const budget = Number(process.env.START_CLOSURE_BUDGET_MB ?? 0);
112if (budget > 0) {
113 const actual = bytes(evaluated) / 1024 / 1024;
114 console.log(`\nbudget ${budget} MB — actual ${actual.toFixed(2)} MB`);
115 if (actual > budget) {
116 console.error(
117 `\nFAIL: evaluated closure ${actual.toFixed(2)} MB exceeds the ${budget} MB budget.\n` +
118 `Every cold isolate pays to evaluate this closure before it can answer. Move the\n` +
119 `new weight behind a dynamic import rather than raising the budget; run this\n` +
120 `script with no budget set to see the biggest members and what is already lazy.`,
121 );
122 process.exit(1);
123 }
124}
apps/cloud/scripts/test-globalsetup.ts+20−1
@@ -13,12 +13,26 @@ import { fileURLToPath } from "node:url"; · 12 unmodified lines
13 13
14const __dirname = dirname(fileURLToPath(import.meta.url));14const __dirname = dirname(fileURLToPath(import.meta.url));
15 15
16const PORT = 5434;16const parsePort = (input: string | undefined): number => {
17 if (input === undefined) return 5434;
18 if (!/^\d+$/.test(input)) throw new Error("CLOUD_TEST_DB_PORT must be an integer");
19 const port = Number(input);
20 if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
21 throw new Error("CLOUD_TEST_DB_PORT must be between 1 and 65535");
22 }
23 return port;
24};
25
26const PORT = parsePort(process.env.CLOUD_TEST_DB_PORT);
17const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle");27const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle");
18 28
19let db: PGlite | undefined;29let db: PGlite | undefined;
20let server: PGLiteSocketServer | undefined;30let server: PGLiteSocketServer | undefined;
21 31
32/**
33 * Starts the cloud unit-test database and returns teardown that releases every
34 * resource without allowing PGlite shutdown to erase Vitest's failure status.
35 */
22export default async function setup() {36export default async function setup() {
23 db = await PGlite.create();37 db = await PGlite.create();
24 await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER });38 await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER });
@@ -30,7 +44,12 @@ export default async function setup() { · 5 unmodified lines
30 console.log(`[test-db] PGlite socket server listening on 127.0.0.1:${PORT}`);44 console.log(`[test-db] PGlite socket server listening on 127.0.0.1:${PORT}`);
31 45
32 return async () => {46 return async () => {
47 // PGlite sets an internal 99 sentinel on startup and replaces it with 0 on
48 // close. Preserve Vitest's failure status across that close so
49 // teardown can turn neither a red test green nor a green test red.
50 const testsFailed = process.exitCode === 1;
33 await server?.stop();51 await server?.stop();
34 await db?.close();52 await db?.close();
53 if (testsFailed) process.exitCode = 1;
35 };54 };
36}55}
apps/cloud/scripts/vendor-quickjs-wasm.ts+18−0
@@ -0,0 +1,18 @@
1// Vendors the QuickJS engine WASM into src/ so wrangler's CompiledWasm module
2// rule (rooted at the app dir) can statically compile it at build time. Workers
3// forbid runtime WASM compilation, and the rule's glob won't reach the
4// monorepo-root node_modules, so the bytes must live inside this app.
5//
6// Re-run after bumping @jitl/quickjs-wasmfile-release-sync:
7// bun run scripts/vendor-quickjs-wasm.ts
8import { copyFileSync } from "node:fs";
9import { createRequire } from "node:module";
10import { dirname, join } from "node:path";
11import { fileURLToPath } from "node:url";
12
13const require = createRequire(import.meta.url);
14const source = require.resolve("@jitl/quickjs-wasmfile-release-sync/wasm");
15const dest = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "quickjs-engine.wasm");
16
17copyFileSync(source, dest);
18console.log(`vendored ${source} -> ${dest}`);
apps/cloud/src/account/org-api-key-revoke.node.test.ts+195−0
@@ -0,0 +1,195 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import { AccountProvider } from "@executor-js/api/server";
5import { AccountError, AccountForbidden } from "@executor-js/api";
6
7import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys";
8import { UserStoreService } from "../auth/context";
9import { ORG_SELECTOR_HEADER } from "../auth/organization";
10import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
11import { AutumnService } from "../extensions/billing/service";
12import { AccountCaller, workosAccountProvider } from "./workos-account-service";
13
14// ---------------------------------------------------------------------------
15// `DELETE /account/org-api-keys/:apiKeyId` at the PROVIDER boundary.
16//
17// An org key reads every user and connection in the tenant through `/admin/*`,
18// so it is the most privileged credential the product issues. Two properties
19// have to hold together, and only this seam sees both:
20//
21// 1. Revoke is gated on org ADMIN membership — the same gate as the mint. A
22// plain member calling it gets 403 and no delete goes out.
23// 2. Revoke resolves ownership against the ORG's keys. `revokeApiKey` (the
24// personal path) checks the caller's OWN key list, so it can never revoke
25// an org key; conversely this path must never destroy a member's personal
26// key. The scope-blind `deleteApiKey` would do exactly that if handed a
27// user key id, so a wrong id has to fail cleanly instead.
28//
29// Ownership filtering itself is pinned per-shape in
30// `auth/org-api-key-mint.node.test.ts`; here it is asserted end-to-end through
31// the gate, on the provider the HTTP handler actually calls.
32// ---------------------------------------------------------------------------
33
34const ORG = "org_123";
35const ADMIN = "user_admin";
36const MEMBER = "user_member";
37const ORG_KEY = "key_org_1";
38const USER_KEY = "key_user_1";
39const createdAt = new Date("2026-01-01T00:00:00.000Z");
40const orgHeaders = { [ORG_SELECTOR_HEADER]: ORG };
41
42const session = (accountId: string) => ({
43 accountId,
44 email: `${accountId}@example.test`,
45 name: null,
46 avatarUrl: null,
47 organizationId: ORG,
48 sealedSession: "sealed",
49 refreshedSession: null,
50});
51
52/** Membership roles: only ADMIN carries the `admin` role slug. */
53const stubWorkOS = Layer.succeed(
54 WorkOSClient,
55 new Proxy({} as WorkOSClientService, {
56 get: (_target, prop) => {
57 if (prop === "listUserMemberships") {
58 return (userId: string) =>
59 Effect.succeed({
60 data: [{ userId, organizationId: ORG, status: "active" }],
61 });
62 }
63 if (prop === "getUserOrgMembership") {
64 return (organizationId: string, userId: string) =>
65 Effect.succeed(
66 organizationId === ORG
67 ? {
68 id: `om_${userId}`,
69 userId,
70 organizationId,
71 role: { slug: userId === ADMIN ? "admin" : "member" },
72 }
73 : null,
74 );
75 }
76 return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
77 },
78 }),
79);
80
81const stubUsers = Layer.succeed(UserStoreService)({
82 use: (_op, fn) =>
83 Effect.promise(() =>
84 fn({
85 ensureAccount: async (id: string) => ({ id, createdAt }),
86 getAccount: async (id: string) => ({ id, createdAt }),
87 upsertOrganization: async (org: { id: string; name: string }) => ({
88 ...org,
89 slug: org.id,
90 createdAt,
91 }),
92 getOrganization: async (id: string) => ({
93 id,
94 name: `Org ${id}`,
95 slug: id,
96 createdAt,
97 }),
98 getOrganizationBySlug: async (slug: string) => ({
99 id: slug,
100 name: `Org ${slug}`,
101 slug,
102 createdAt,
103 }),
104 deleteOrganizationCascade: async () => {},
105 }),
106 ),
107});
108
109const stubAutumn = Layer.succeed(AutumnService)({
110 use: () => Effect.die("revoke does not touch billing"),
111 ensureCustomer: () => Effect.die("revoke does not touch billing"),
112 checkExecutionBalance: () => Effect.die("revoke does not touch billing"),
113 trackExecution: () => Effect.void,
114});
115
116/**
117 * The provider under test, over an `ApiKeyService` whose org listing holds a
118 * single org-owned key. Revoked ids land in `revoked` so "no delete happened"
119 * is assertable, not merely inferred from the error.
120 */
121const providerWith = (accountId: string) => {
122 const revoked: string[] = [];
123 const stubApiKeys = Layer.succeed(ApiKeyService)({
124 validate: () => Effect.die("revoke test does not validate keys"),
125 listUserKeys: () => Effect.succeed([]),
126 createUserKey: () => Effect.die("revoke test does not create user keys"),
127 revokeUserKey: () => Effect.die("the ORG path must not call the personal revoke"),
128 listOrgKeys: () => Effect.die("revoke test does not list org keys"),
129 createOrgKey: () => Effect.die("revoke test does not create org keys"),
130 revokeOrgKey: ({ keyId }) =>
131 keyId === ORG_KEY
132 ? Effect.sync(() => {
133 revoked.push(keyId);
134 })
135 : Effect.fail(new OrgApiKeyNotFound({ keyId })),
136 });
137
138 const provider = AccountProvider.asEffect().pipe(
139 Effect.provide(
140 workosAccountProvider.pipe(
141 Layer.provide(
142 Layer.mergeAll(
143 stubWorkOS,
144 stubUsers,
145 stubApiKeys,
146 stubAutumn,
147 Layer.succeed(AccountCaller)({ session: session(accountId) }),
148 ),
149 ),
150 ),
151 ),
152 );
153
154 return { provider, revoked };
155};
156
157describe("revokeOrgApiKey · provider boundary", () => {
158 it.effect("an org admin revokes an org-owned key", () =>
159 Effect.gen(function* () {
160 const { provider, revoked } = providerWith(ADMIN);
161 const account = yield* provider;
162
163 const result = yield* account.revokeOrgApiKey(orgHeaders, ORG_KEY);
164
165 expect(result).toEqual({ success: true });
166 expect(revoked, "the revoke reached the key service").toEqual([ORG_KEY]);
167 }),
168 );
169
170 it.effect("a plain member is forbidden, and nothing is revoked", () =>
171 Effect.gen(function* () {
172 const { provider, revoked } = providerWith(MEMBER);
173 const account = yield* provider;
174
175 const error = yield* Effect.flip(account.revokeOrgApiKey(orgHeaders, ORG_KEY));
176
177 expect(error, "same admin gate as the mint").toBeInstanceOf(AccountForbidden);
178 expect(revoked, "the gate runs BEFORE the key service is touched").toEqual([]);
179 }),
180 );
181
182 it.effect("a user-scoped key id fails cleanly through this path", () =>
183 Effect.gen(function* () {
184 // Not found — not a 500, and emphatically not a silent success that would
185 // report a member's live personal credential as destroyed.
186 const { provider, revoked } = providerWith(ADMIN);
187 const account = yield* provider;
188
189 const error = yield* Effect.flip(account.revokeOrgApiKey(orgHeaders, USER_KEY));
190
191 expect(error).toBeInstanceOf(AccountError);
192 expect(revoked).toEqual([]);
193 }),
194 );
195});
apps/cloud/src/account/workos-account-service.ts+65−8
@@ -91,16 +91,19 @@ export const workosAccountProvider: Layer.Layer< · 90 unmodified lines
91 ? Effect.succeed(caller.session)91 ? Effect.succeed(caller.session)
92 : Effect.fail<AccountUnauthorized>(new AccountUnauthorized());92 : Effect.fail<AccountUnauthorized>(new AccountUnauthorized());
93 93
94 // The org scope for an org-scoped request: the console URL's org (sent in94 // The org scope for an org-scoped request: the console URL's org, sent in
95 // the selector header) when present, else the session's own org. Membership95 // the selector header. FAIL CLOSED when it's missing — the session's own
96 // is re-checked live, so the header is a selector, not a trust boundary —96 // org is a browser-global pinned to whichever org WorkOS last touched, so
97 // falling back to it scopes a multi-org user's request to the WRONG org
98 // (see workos-auth-provider.resolveSessionPrincipal). Membership is
99 // re-checked live, so the header is a selector, not a trust boundary —
97 // and two browser tabs on different orgs each send their own header, so100 // and two browser tabs on different orgs each send their own header, so
98 // they stay independent (see organization.ts). Yields the session +101 // they stay independent (see organization.ts). Yields the session +
99 // resolved org, or AccountNoOrganization.102 // resolved org, or AccountNoOrganization.
100 const requireOrganization = (headers: AccountHeaders) =>103 const requireOrganization = (headers: AccountHeaders) =>
101 Effect.gen(function* () {104 Effect.gen(function* () {
102 const session = yield* requireSession();105 const session = yield* requireSession();
103 const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;106 const selector = headers[ORG_SELECTOR_HEADER];
104 if (!selector) {107 if (!selector) {
105 return yield* new AccountNoOrganization();108 return yield* new AccountNoOrganization();
106 }109 }
@@ -180,9 +183,15 @@ export const workosAccountProvider: Layer.Layer< · 73 unmodified lines
180 me: (headers) =>183 me: (headers) =>
181 Effect.gen(function* () {184 Effect.gen(function* () {
182 const session = yield* requireSession();185 const session = yield* requireSession();
183 // Same selector precedence as requireOrganization: the URL's org186 // The ONE sanctioned fallback to the session org. /account/me is
184 // (header) drives /account/me so the shell reflects the org the tab187 // identity, not tenant data: on a bare URL (no org slug yet, so no
185 // is viewing, not a session-global active org.188 // header) it answers "which org should this browser land in", and
189 // the session's pinned org is the only candidate. Every org-scoped
190 // DATA read fails closed instead (requireOrganization above,
191 // resolveSessionPrincipal) — serving tenant data from this fallback
192 // is exactly the wrong-org connection-list bug (2026-07). With a
193 // header (any slugged URL), the URL's org drives the answer so the
194 // shell reflects the org the tab is viewing.
186 const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;195 const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
187 const org = selector196 const org = selector
188 ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe(197 ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe(
@@ -237,6 +246,52 @@ export const workosAccountProvider: Layer.Layer< · 48 unmodified lines
237 return { success: true };246 return { success: true };
238 }),247 }),
239 248
249 // Org keys read every user in the tenant through the `/admin/*` plane, so
250 // both paths are admin-gated — unlike personal keys, which any member may
251 // mint for themselves.
252 listOrgApiKeys: (headers) =>
253 Effect.gen(function* () {
254 const { session, org } = yield* requireOrganization(headers);
255 yield* requireAdmin(session.accountId, org.id);
256 const keys = yield* apiKeys
257 .listOrgKeys({ organizationId: org.id })
258 .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
259 return { apiKeys: keys };
260 }),
261
262 createOrgApiKey: (headers, name) =>
263 Effect.gen(function* () {
264 const { session, org } = yield* requireOrganization(headers);
265 yield* requireAdmin(session.accountId, org.id);
266 const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH);
267 if (!trimmed) {
268 return yield* new AccountError({ message: "API key name is required" });
269 }
270 return yield* apiKeys
271 .createOrgKey({ organizationId: org.id, name: trimmed })
272 .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
273 }),
274
275 // Same admin gate as the mint. Note this deliberately does NOT reuse
276 // `revokeApiKey`: that path resolves ownership against the caller's
277 // personal keys, so an org key id is never in its owned set. Ownership
278 // here is resolved against the ORG's keys inside `revokeOrgKey`, which is
279 // what keeps a user-scoped (or foreign-org) key id from being destroyed
280 // through the org route — it surfaces as a 404-shaped AccountError, not a
281 // silent success and not a 500.
282 revokeOrgApiKey: (headers, apiKeyId) =>
283 Effect.gen(function* () {
284 const { session, org } = yield* requireOrganization(headers);
285 yield* requireAdmin(session.accountId, org.id);
286 yield* apiKeys.revokeOrgKey({ organizationId: org.id, keyId: apiKeyId }).pipe(
287 Effect.catchTag("ApiKeyManagementError", toAccountError),
288 Effect.catchTag("OrgApiKeyNotFound", () =>
289 Effect.fail(new AccountError({ message: "Organization API key not found" })),
290 ),
291 );
292 return { success: true };
293 }),
294
240 listMembers: (headers) =>295 listMembers: (headers) =>
241 Effect.gen(function* () {296 Effect.gen(function* () {
242 const { session, org } = yield* requireOrganization(headers);297 const { session, org } = yield* requireOrganization(headers);
@@ -331,7 +386,9 @@ export const workosAccountProvider: Layer.Layer< · 88 unmodified lines
331 .updateOrganization(org.id, name)386 .updateOrganization(org.id, name)
332 .pipe(Effect.catchTag("WorkOSError", toAccountError));387 .pipe(Effect.catchTag("WorkOSError", toAccountError));
333 yield* users388 yield* users
334 .use((s) => s.upsertOrganization({ id: updated.id, name: updated.name }))389 .use("upsertOrganization", (s) =>
390 s.upsertOrganization({ id: updated.id, name: updated.name }),
391 )
335 .pipe(Effect.catchTag("UserStoreError", toAccountError));392 .pipe(Effect.catchTag("UserStoreError", toAccountError));
336 return { name: updated.name };393 return { name: updated.name };
337 }),394 }),
apps/cloud/src/admin/admin-users-api.ts+354−0
@@ -0,0 +1,354 @@
1// ---------------------------------------------------------------------------
2// Cloud admin users API — the shared, provider-neutral `AdminUsersHandlers`
3// backed by a WorkOS-authorized platform view, mounted at `/api/admin/users*`.
4//
5// TWO credentials reach this plane, and they are the two an operator actually
6// has:
7// 1. an ORG-SCOPED api key -> `PlatformAuth`. The key IS the authority: WorkOS
8// validated it and reported which org owns it, and there is no member
9// behind it to check membership for. This is the machine credential
10// (a customer's backend calling us).
11// 2. an admin SESSION member -> the console. Requires a live `getUserOrgMembership`
12// whose role slug is `admin` AND whose status is `active`, matching the
13// strictest existing cloud guard (`auth/handlers.ts`'s org-delete check) —
14// a pending admin invite is not an admin.
15// A plain member session, or a USER-scoped api key, is refused: both name one
16// acting member, and this plane deliberately serves the whole tenant.
17//
18// The executor is built by `makePlatformExecutor` — `{ tenant, subject:
19// undefined, platformView: true }` — so the reads are tenant-wide and read-only
20// by storage policy, and no `subject` row is minted for the caller.
21//
22// Cross-tenant isolation is structural, not a check in this file: the tenant is
23// taken from the resolved credential (the key's own org, or the session's
24// authorized org), never from client input, and `reach: "tenant"` still filters
25// every query by that tenant.
26// ---------------------------------------------------------------------------
27
28import { env } from "cloudflare:workers";
29import { HttpRouter } from "effect/unstable/http";
30import { Context, Effect, Layer, Option } from "effect";
31
32import {
33 AdminUsersProvider,
34 DbProvider,
35 HostConfig,
36 PluginsProvider,
37 getAdminUser,
38 listAdminUserConnections,
39 listAdminUsers,
40 listAdminUsersWithConnections,
41 makeAdminUsersApiLayer,
42 makePlatformExecutor,
43 normalizeAdminUserEmail,
44 platformViewOf,
45 requestScopedMiddleware,
46 type AdminEmailResolver,
47 type AdminIdentityDirectory,
48 type AdminUserDirectory,
49 type AdminUserIdentity,
50 type AdminUsersHeaders,
51} from "@executor-js/api/server";
52import {
53 AdminUserNotFound,
54 AdminUsersError,
55 AdminUsersForbidden,
56 AdminUsersUnauthorized,
57} from "@executor-js/api";
58import type { Executor } from "@executor-js/sdk";
59
60import { ApiKeyService } from "../auth/api-keys";
61import { UserStoreService } from "../auth/context";
62import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider";
63import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization";
64import { WorkOSClient } from "../auth/workos";
65import { DbService } from "../db/db";
66import { CloudExecutionSeamsLayer } from "../engine/execution-stack";
67
68/**
69 * Resolve the tenant this request may read, or fail with the neutral 401/403.
70 *
71 * Returns only the organization id: nothing downstream needs to know WHICH of
72 * the two credentials got the caller here, and keeping the acting member out of
73 * the return value means no admin read can accidentally become subject-scoped.
74 */
75const authorizeTenant = (
76 request: Request,
77): Effect.Effect<
78 string,
79 AdminUsersUnauthorized | AdminUsersForbidden,
80 WorkOSClient | ApiKeyService | UserStoreService
81> =>
82 Effect.gen(function* () {
83 // (1) The bearer path. `resolveBearerAuth` (not `resolveApiKeyPrincipal`,
84 // which rejects org keys for the product plane) is what distinguishes an
85 // org key from a user key.
86 const bearer = yield* resolveBearerAuth(request).pipe(
87 // Every rejected-credential and infra failure collapses to one refusal:
88 // this plane must not report whether a key exists, belongs to another
89 // org, or merely lacks privilege.
90 Effect.catchCause(() => Effect.succeed(null)),
91 );
92 if (bearer !== null) {
93 if (isPlatformAuth(bearer)) return bearer.organizationId;
94 // A user-scoped key authenticated fine but names one member; the platform
95 // plane has no honest way to serve it.
96 return yield* new AdminUsersForbidden();
97 }
98
99 // (2) The session path: a live admin membership in the selected org.
100 const workos = yield* WorkOSClient;
101 const session = yield* workos
102 .authenticateRequest(request)
103 .pipe(Effect.catchCause(() => Effect.succeed(null)));
104 if (!session) return yield* new AdminUsersUnauthorized();
105
106 const selector = orgSelectorFromRequest(request) ?? session.organizationId;
107 if (!selector) return yield* new AdminUsersForbidden();
108 // Re-checks live membership, so the org selector header can only ever name
109 // an org the caller already belongs to.
110 const org = yield* authorizeOrganizationSelector(session.userId, selector).pipe(
111 Effect.catchCause(() => Effect.succeed(null)),
112 );
113 if (!org) return yield* new AdminUsersForbidden();
114
115 const membership = yield* workos
116 .getUserOrgMembership(org.id, session.userId)
117 .pipe(Effect.catchCause(() => Effect.succeed(null)));
118 // A pending admin invite is not an active admin — require both.
119 if (!membership || membership.status !== "active" || membership.role?.slug !== "admin") {
120 return yield* new AdminUsersForbidden();
121 }
122 return org.id;
123 });
124
125/**
126 * How many user-detail reads run at once. Matches the account plane's own
127 * member listing (`workos-account-service.ts`), which fans out the same way for
128 * the same reason.
129 */
130const IDENTITY_CONCURRENCY = 5;
131
132/**
133 * Cloud's member directory: `externalId` → email/name.
134 *
135 * THE JOIN KEY is the membership's `userId` — the WorkOS `user_...` that
136 * `workos-auth-provider.ts` binds as `accountId` on every credential path, and
137 * therefore what the subject table records in `external_id`. The membership's
138 * own `id` is an `om_...` row id and joins to nothing.
139 *
140 * WHY THIS IS TWO CALLS AND NOT ONE. The membership list is read once per
141 * request and is the authority on who belongs to the org, but WorkOS's
142 * `listOrganizationMemberships` carries no user detail and offers no
143 * include/expand — email and name only exist on the user resource. The SDK does
144 * expose a batched `listUsers({ organizationId })`, but the pinned
145 * `@executor-js/emulate` WorkOS emulator serves only `GET
146 * /user_management/users/:id`, so taking that path would leave every cloud e2e
147 * user unnamed. So: ONE membership read per request, then user detail fetched
148 * only for the ids ON THIS PAGE — never for the whole org, and never once per
149 * row of some larger list. An id that is not an active/pending member is not
150 * fetched at all and reports absent identity, which is the honest answer for a
151 * member who left while their connections remain.
152 */
153const identityDirectory =
154 (organizationId: string, context: Context.Context<WorkOSClient>): AdminIdentityDirectory =>
155 (externalIds) =>
156 Effect.gen(function* () {
157 const workos = yield* WorkOSClient;
158 const memberships = yield* workos.listOrgMembers(organizationId);
159 const wanted = new Set(externalIds);
160 const memberIds = memberships.data
161 .map((membership) => membership.userId)
162 .filter((userId) => wanted.has(userId));
163
164 const resolved = yield* Effect.all(
165 memberIds.map((userId) =>
166 workos.getUser(userId).pipe(
167 Effect.map(
168 (user) =>
169 [
170 userId,
171 {
172 email: user.email,
173 displayName: [user.firstName, user.lastName].filter(Boolean).join(" ") || null,
174 },
175 ] as const,
176 ),
177 // One unreadable user must not cost the whole page its names.
178 Effect.catchCause(() => Effect.succeed(null)),
179 ),
180 ),
181 { concurrency: IDENTITY_CONCURRENCY },
182 );
183
184 const identities = new Map<string, AdminUserIdentity>();
185 for (const entry of resolved) if (entry) identities.set(entry[0], entry[1]);
186 return identities;
187 }).pipe(Effect.provideContext(context));
188
189/**
190 * Cloud's REVERSE directory lookup: email → the WorkOS `user_...` id.
191 *
192 * Production asks WorkOS for the email AND organization in one request. Both
193 * filters matter: email makes the lookup indexed rather than one `getUser`
194 * request per member, while organization keeps the reverse lookup bound to the
195 * same tenant as the platform view.
196 *
197 * The pinned `@executor-js/emulate` WorkOS emulator has no list-users route.
198 * `WORKOS_API_URL` is the explicit test/dev emulator override, so that path
199 * retains the membership scan until the emulator supports the production
200 * query. The fallback still starts from the tenant's membership list and can
201 * never return a user from another organization.
202 *
203 * CASING: WorkOS preserves whatever casing an email was created with (and the
204 * emulator compares byte-exact), so the directory value is normalized here
205 * before comparison, against an argument the seam already normalized.
206 */
207export const emailResolver =
208 (organizationId: string, context: Context.Context<WorkOSClient>): AdminEmailResolver =>
209 (email) =>
210 Effect.gen(function* () {
211 const workos = yield* WorkOSClient;
212
213 if (!env.WORKOS_API_URL) {
214 const users = yield* workos.listUsers({ email, organizationId });
215 return users.data[0]?.id ?? null;
216 }
217
218 const memberships = yield* workos.listOrgMembers(organizationId);
219 const userIds = memberships.data.map((membership) => membership.userId);
220
221 // Emulator compatibility only. Short-circuit once the normalized email
222 // matches so the fallback makes as few unsupported-detail reads as it can.
223 const match = yield* Effect.findFirst(userIds, (userId) =>
224 workos.getUser(userId).pipe(
225 Effect.map((user) => normalizeAdminUserEmail(user.email ?? "") === email),
226 // One unreadable user must not fail the whole lookup — it simply
227 // cannot be the match.
228 Effect.catchCause(() => Effect.succeed(false)),
229 ),
230 );
231 return Option.getOrNull(match);
232 }).pipe(Effect.provideContext(context));
233
234/** Both directions of cloud's directory, built once per authorized request. */
235const userDirectory = (
236 organizationId: string,
237 context: Context.Context<WorkOSClient>,
238): AdminUserDirectory => ({
239 identities: identityDirectory(organizationId, context),
240 resolveEmail: emailResolver(organizationId, context),
241});
242
243/**
244 * Authorize, then run `body` against the tenant's platform view.
245 *
246 * The executor is built per request and closed after, like every other cloud
247 * execution stack: it holds the per-request postgres socket, which Cloudflare's
248 * I/O isolation forbids sharing across requests.
249 */
250const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = AdminUsersError>(
251 headers: AdminUsersHeaders,
252 body: (executor: Executor, organizationId: string) => Effect.Effect<A, E>,
253): Effect.Effect<
254 A,
255 // `AdminUsersError` unconditionally: opening the platform view can fail that
256 // way regardless of what `body` itself raises.
257 E | AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden,
258 WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
259> =>
260 Effect.gen(function* () {
261 const organizationId = yield* authorizeTenant(
262 new Request("https://admin.invalid", { headers }),
263 );
264 const executor = yield* makePlatformExecutor(organizationId).pipe(
265 Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })),
266 );
267 // The authorized tenant is handed to the body so an identity join reads the
268 // SAME org the reads are scoped to — never one named by client input.
269 return yield* Effect.ensuring(
270 body(executor, organizationId),
271 executor.close().pipe(Effect.ignore),
272 );
273 });
274
275/**
276 * Cloud's `AdminUsersProvider`, built per request so the platform executor
277 * closes over the per-request postgres socket.
278 */
279export const workosAdminUsersProvider: Layer.Layer<
280 AdminUsersProvider,
281 never,
282 WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
283> = Layer.effect(AdminUsersProvider)(
284 Effect.gen(function* () {
285 const context = yield* Effect.context<
286 WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
287 >();
288 return AdminUsersProvider.of({
289 listUsers: (headers, options) =>
290 withPlatformView(headers, (executor, organizationId) =>
291 platformViewOf(executor).pipe(
292 Effect.flatMap((admin) =>
293 listAdminUsers(admin, options, userDirectory(organizationId, context)),
294 ),
295 ),
296 ).pipe(Effect.provideContext(context)),
297 listUsersWithConnections: (headers, options) =>
298 withPlatformView(headers, (executor, organizationId) =>
299 platformViewOf(executor).pipe(
300 Effect.flatMap((admin) =>
301 listAdminUsersWithConnections(admin, options, userDirectory(organizationId, context)),
302 ),
303 ),
304 ).pipe(Effect.provideContext(context)),
305 listUserConnections: (headers, externalId) =>
306 withPlatformView(headers, (executor) =>
307 platformViewOf(executor).pipe(
308 Effect.flatMap((admin) => listAdminUserConnections(admin, externalId)),
309 ),
310 ).pipe(Effect.provideContext(context)),
311 getUser: (headers, identifier) =>
312 withPlatformView(headers, (executor, organizationId) =>
313 platformViewOf(executor).pipe(
314 Effect.flatMap((admin) =>
315 getAdminUser(admin, identifier, userDirectory(organizationId, context)),
316 ),
317 ),
318 ).pipe(Effect.provideContext(context)),
319 });
320 }),
321);
322
323// Builds the provider per request, providing it to the handlers. Long-lived
324// `WorkOSClient | ApiKeyService` come from the surrounding boot context; the
325// per-request `DbService`/`UserStoreService` (and the execution seams built
326// over them) are supplied by the combined `requestScopedMiddleware`.
327const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()(
328 Effect.gen(function* () {
329 const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>();
330 return (httpEffect) =>
331 Effect.gen(function* () {
332 // Built inside the request body so the execution seams close over the
333 // per-request postgres socket.
334 const provider = yield* Effect.provide(
335 AdminUsersProvider.asEffect(),
336 workosAdminUsersProvider.pipe(Layer.provide(CloudExecutionSeamsLayer)),
337 );
338 return yield* Effect.provideService(httpEffect, AdminUsersProvider, provider);
339 }).pipe(Effect.provideContext(longLived));
340 }),
341);
342
343/**
344 * The cloud admin-users route layer, mounted as an app extension under the same
345 * `/api` prefix as the rest of the cloud router.
346 */
347export const makeCloudAdminUsersRoutes = (
348 rsLive: Layer.Layer<DbService | UserStoreService>,
349 options: Parameters<typeof makeAdminUsersApiLayer>[1] = {},
350) =>
351 makeAdminUsersApiLayer(
352 AdminUsersProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer,
353 options,
354 );
apps/cloud/src/admin/admin-users-email.node.test.ts+127−0
@@ -0,0 +1,127 @@
1import { env } from "cloudflare:workers";
2import { expect, it } from "@effect/vitest";
3import { Data, Effect, Layer } from "effect";
4
5import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
6import { emailResolver } from "./admin-users-api";
7
8// Cloud's REVERSE directory lookup: email -> the WorkOS `user_...` id that the
9// subject table records in `external_id`. Production resolves it with one
10// tenant-scoped list-users query. The WorkOS emulator lacks that route, so
11// tests/dev retain the membership-backed scan exercised below.
12
13const ORG = "org_placeholder";
14const OTHER_ORG = "org_other";
15
16class WorkOSUnavailable extends Data.TaggedError("WorkOSUnavailable")<{
17 readonly userId: string;
18}> {}
19
20const DIRECTORY = [
21 // Same email in another tenant must never win either lookup path.
22 { id: "user_foreign", email: "ada@placeholder.test", organizationId: OTHER_ORG },
23 // WorkOS preserves submitted casing, while the resolver seam is normalized.
24 { id: "user_ada", email: "Ada@Placeholder.test", organizationId: ORG },
25 { id: "user_grace", email: "grace@placeholder.test", organizationId: ORG },
26 { id: "user_nameless", email: null, organizationId: ORG },
27] as const;
28
29const stubWorkOS = (calls: string[], unreadableUserIds: ReadonlySet<string>) =>
30 Layer.succeed(
31 WorkOSClient,
32 new Proxy({} as WorkOSClientService, {
33 get: (_target, prop) => {
34 if (prop === "listUsers") {
35 return (params: { email: string; organizationId: string }) => {
36 calls.push(`listUsers:${params.organizationId}:${params.email}`);
37 return Effect.succeed({
38 data: DIRECTORY.filter(
39 (user) =>
40 user.organizationId === params.organizationId &&
41 user.email?.toLowerCase() === params.email,
42 ),
43 });
44 };
45 }
46 if (prop === "listOrgMembers") {
47 return (organizationId: string) => {
48 calls.push(`listOrgMembers:${organizationId}`);
49 return Effect.succeed({
50 data: DIRECTORY.filter((user) => user.organizationId === organizationId).map(
51 (user) => ({ userId: user.id, organizationId }),
52 ),
53 });
54 };
55 }
56 if (prop === "getUser") {
57 return (userId: string) => {
58 calls.push(`getUser:${userId}`);
59 if (unreadableUserIds.has(userId)) {
60 return Effect.fail(new WorkOSUnavailable({ userId }));
61 }
62 const user = DIRECTORY.find((candidate) => candidate.id === userId);
63 if (!user) return Effect.die(`unexpected user ${userId}`);
64 return Effect.succeed(user);
65 };
66 }
67 return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
68 },
69 }),
70 );
71
72const resolve = (
73 email: string,
74 calls: string[],
75 emulator = false,
76 unreadableUserIds: ReadonlySet<string> = new Set(),
77) => {
78 const previousApiUrl = env.WORKOS_API_URL;
79 return Effect.gen(function* () {
80 yield* Effect.sync(() =>
81 Object.assign(env, {
82 WORKOS_API_URL: emulator ? "http://workos-emulator.invalid" : undefined,
83 }),
84 );
85 const context = yield* Effect.context<WorkOSClient>();
86 return yield* emailResolver(ORG, context)(email);
87 }).pipe(
88 Effect.provide(stubWorkOS(calls, unreadableUserIds)),
89 Effect.ensuring(Effect.sync(() => Object.assign(env, { WORKOS_API_URL: previousApiUrl }))),
90 );
91};
92
93it.effect("resolves an email with one tenant-scoped WorkOS query", () =>
94 Effect.gen(function* () {
95 const calls: string[] = [];
96 expect(yield* resolve("ada@placeholder.test", calls)).toBe("user_ada");
97 expect(calls).toEqual([`listUsers:${ORG}:ada@placeholder.test`]);
98 }),
99);
100
101it.effect("returns null from one query when the organization has no matching email", () =>
102 Effect.gen(function* () {
103 const calls: string[] = [];
104 expect(yield* resolve("nobody@placeholder.test", calls)).toBeNull();
105 expect(calls).toEqual([`listUsers:${ORG}:nobody@placeholder.test`]);
106 }),
107);
108
109it.effect("keeps the emulator fallback tenant-scoped and case-insensitive", () =>
110 Effect.gen(function* () {
111 const calls: string[] = [];
112 expect(yield* resolve("ada@placeholder.test", calls, true)).toBe("user_ada");
113 expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada"]);
114 expect(calls).not.toContain("getUser:user_foreign");
115 expect(calls.some((call) => call.startsWith("listUsers:"))).toBe(false);
116 }),
117);
118
119it.effect("lets the emulator fallback continue past one unreadable member", () =>
120 Effect.gen(function* () {
121 const calls: string[] = [];
122 expect(yield* resolve("grace@placeholder.test", calls, true, new Set(["user_ada"]))).toBe(
123 "user_grace",
124 );
125 expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada", "getUser:user_grace"]);
126 }),
127);
apps/cloud/src/analytics/ema-rollout.test.ts+394−0
@@ -0,0 +1,394 @@
1// ---------------------------------------------------------------------------
2// Cloud's PostHog-backed enterprise-managed rollout gate.
3//
4// The gate is exercised through its production seam: the real
5// `makePostHogEnterpriseManagedRollout` with an injected `fetch` and an
6// injected `waitUntil`, exactly as `auth/jwks-cache.node.test.ts` drives the
7// JWKS client. Nothing is module-mocked, so the request these tests read is the
8// request PostHog would receive.
9// ---------------------------------------------------------------------------
10
11import { describe, expect, it } from "@effect/vitest";
12import { Effect } from "effect";
13
14import type { EnterpriseManagedRolloutContext } from "@executor-js/sdk";
15
16import {
17 ENTERPRISE_MANAGED_AUTH_FLAG_KEY,
18 cloudEnterpriseManagedRollout,
19 makePostHogEnterpriseManagedRollout,
20} from "./ema-rollout";
21
22const HOST = "https://us.i.posthog.com";
23const PROJECT_KEY = "phc_test_project_key";
24
25const CONTEXT: EnterpriseManagedRolloutContext = {
26 userId: "user_01ABC",
27 organizationId: "org_01XYZ",
28 integration: "slack" as EnterpriseManagedRolloutContext["integration"],
29};
30
31interface CapturedRequest {
32 readonly url: string;
33 readonly body: Record<string, unknown>;
34 /** The bound the gate put on this call, when it set one. */
35 readonly signal: AbortSignal | null;
36}
37
38interface FetchHarness {
39 readonly fetch: typeof globalThis.fetch;
40 readonly requests: () => readonly CapturedRequest[];
41 /** Resolve every request the gate detached, so an assertion cannot race it. */
42 readonly settle: () => Promise<void>;
43 readonly waitUntil: (work: Promise<unknown>) => void;
44}
45
46/**
47 * Stands in for PostHog. `respond` decides what each call returns; throwing
48 * from it models a transport failure, and an aborted signal models the timeout.
49 */
50const makeFetchHarness = (
51 respond: (request: CapturedRequest) => Response | Promise<Response>,
52): FetchHarness => {
53 const requests: CapturedRequest[] = [];
54 const detached: Promise<unknown>[] = [];
55 return {
56 requests: () => requests,
57 settle: async () => {
58 await Promise.all(detached);
59 },
60 waitUntil: (work) => {
61 detached.push(work);
62 },
63 fetch: async (input, init) => {
64 const raw = typeof init?.body === "string" ? init.body : "{}";
65 const captured: CapturedRequest = {
66 url: String(input),
67 // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture reads back the exact JSON body the gate serialized for PostHog
68 body: JSON.parse(raw) as Record<string, unknown>,
69 signal: init?.signal instanceof AbortSignal ? init.signal : null,
70 };
71 requests.push(captured);
72 return respond(captured);
73 },
74 };
75};
76
77const jsonResponse = (body: unknown, status = 200): Response =>
78 new Response(JSON.stringify(body), {
79 status,
80 headers: { "content-type": "application/json" },
81 });
82
83const flagsBody = (enabled: boolean) => ({
84 flags: {
85 [ENTERPRISE_MANAGED_AUTH_FLAG_KEY]: {
86 key: ENTERPRISE_MANAGED_AUTH_FLAG_KEY,
87 enabled,
88 reason: { code: "condition_match" },
89 },
90 },
91 errorsWhileComputingFlags: false,
92});
93
94const gate = (harness: FetchHarness, timeoutMs?: number) =>
95 makePostHogEnterpriseManagedRollout({
96 projectKey: PROJECT_KEY,
97 host: HOST,
98 fetch: harness.fetch,
99 waitUntil: harness.waitUntil,
100 ...(timeoutMs === undefined ? {} : { timeoutMs }),
101 });
102
103describe("flag evaluation", () => {
104 it.effect("keys the rollout on the user and carries the org as group context", () =>
105 Effect.gen(function* () {
106 const harness = makeFetchHarness(() => jsonResponse(flagsBody(true)));
107
108 const decision = yield* gate(harness).decide(CONTEXT);
109
110 expect(decision).toEqual({ kind: "enabled" });
111 const [request] = harness.requests();
112 expect(request?.url).toBe(`${HOST}/flags?v=2`);
113 expect(
114 request?.body.distinct_id,
115 "the rollout unit is the user, matching posthog.identify(user.id) in the browser",
116 ).toBe(CONTEXT.userId);
117 expect(
118 request?.body.groups,
119 "the org rides along so group targeting stays available without changing the rollout unit",
120 ).toEqual({ organization: CONTEXT.organizationId });
121 expect(request?.body.api_key).toBe(PROJECT_KEY);
122 expect(request?.signal, "the evaluation is always bounded").not.toBeNull();
123 }),
124 );
125
126 it.effect("omits group context when the host named no organization", () =>
127 Effect.gen(function* () {
128 const harness = makeFetchHarness(() => jsonResponse(flagsBody(true)));
129
130 yield* gate(harness).decide({ ...CONTEXT, organizationId: null });
131
132 expect(harness.requests()[0]?.body.groups).toBeUndefined();
133 }),
134 );
135
136 it.effect("withholds when the flag is off for this user", () =>
137 Effect.gen(function* () {
138 const harness = makeFetchHarness(() => jsonResponse(flagsBody(false)));
139
140 expect(yield* gate(harness).decide(CONTEXT)).toEqual({
141 kind: "withheld",
142 reason: "disabled",
143 });
144 }),
145 );
146
147 it.effect("withholds when PostHog returned no verdict for the flag at all", () =>
148 Effect.gen(function* () {
149 const harness = makeFetchHarness(() =>
150 // What a quota-limited project answers.
151 jsonResponse({
152 flags: {},
153 errorsWhileComputingFlags: false,
154 quotaLimited: ["feature_flags"],
155 }),
156 );
157
158 expect(yield* gate(harness).decide(CONTEXT)).toEqual({
159 kind: "withheld",
160 reason: "disabled",
161 });
162 }),
163 );
164});
165
166describe("failing closed", () => {
167 it.effect("withholds on a non-2xx answer", () =>
168 Effect.gen(function* () {
169 const harness = makeFetchHarness(() => new Response("nope", { status: 503 }));
170
171 expect(yield* gate(harness).decide(CONTEXT)).toEqual({
172 kind: "withheld",
173 reason: "evaluation-unavailable",
174 });
175 }),
176 );
177
178 it.effect("withholds on a transport failure", () =>
179 Effect.gen(function* () {
180 const harness = makeFetchHarness(() => {
181 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: models `fetch` rejecting with a TypeError, which is exactly how a Worker sees a dead upstream
182 throw new TypeError("network error");
183 });
184
185 expect(yield* gate(harness).decide(CONTEXT)).toEqual({
186 kind: "withheld",
187 reason: "evaluation-unavailable",
188 });
189 }),
190 );
191
192 it.effect("withholds when the evaluation times out", () =>
193 Effect.gen(function* () {
194 // Never answers. Only the gate's own AbortSignal ends this call, so the
195 // test fails by hanging if the gate ever stops bounding the evaluation.
196 const harness = makeFetchHarness(
197 (request) =>
198 new Promise<Response>((_resolve, reject) => {
199 request.signal?.addEventListener("abort", () =>
200 // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: fetch-compatible fixture mirrors the platform's abort rejection semantics
201 reject(new DOMException("aborted", "AbortError")),
202 );
203 }),
204 );
205
206 expect(
207 yield* gate(harness, 1).decide(CONTEXT),
208 "a slow flag service must degrade the rollout, never hold up a connect",
209 ).toEqual({ kind: "withheld", reason: "evaluation-unavailable" });
210 }),
211 );
212
213 it.effect("withholds on a body that is not a flags response", () =>
214 Effect.gen(function* () {
215 const harness = makeFetchHarness(() => jsonResponse({ flags: "not-an-object" }));
216
217 expect(yield* gate(harness).decide(CONTEXT)).toEqual({
218 kind: "withheld",
219 reason: "evaluation-unavailable",
220 });
221 }),
222 );
223
224 it.effect("withholds when there is no acting user to roll out by", () =>
225 Effect.gen(function* () {
226 const harness = makeFetchHarness(() => jsonResponse(flagsBody(true)));
227
228 expect(yield* gate(harness).decide({ ...CONTEXT, userId: null })).toEqual({
229 kind: "withheld",
230 reason: "evaluation-unavailable",
231 });
232 expect(harness.requests().length, "and asks PostHog nothing").toBe(0);
233 }),
234 );
235
236 it.effect("withholds when the deployment has no PostHog configuration", () =>
237 Effect.gen(function* () {
238 // The cloud test env carries no VITE_PUBLIC_POSTHOG_KEY, so this builds
239 // the misconfigured-deployment gate — which is deliberately NOT the same
240 // as a host that injects no gate at all.
241 expect(yield* cloudEnterpriseManagedRollout().decide(CONTEXT)).toEqual({
242 kind: "withheld",
243 reason: "evaluation-unavailable",
244 });
245 }),
246 );
247});
248
249describe("rollout events", () => {
250 it.effect("captures the connect outcome with the flag decision attached", () =>
251 Effect.gen(function* () {
252 const harness = makeFetchHarness(() => jsonResponse({ status: 1 }));
253 const rollout = gate(harness);
254
255 yield* rollout.record({
256 kind: "connected",
257 context: CONTEXT,
258 decision: { kind: "enabled" },
259 });
260 yield* Effect.promise(() => harness.settle());
261
262 const [request] = harness.requests();
263 expect(request?.url).toBe(`${HOST}/i/v0/e/`);
264 expect(request?.body.event).toBe("ema_connect_connected");
265 expect(request?.body.distinct_id).toBe(CONTEXT.userId);
266 const properties = request?.body.properties as Record<string, unknown>;
267 expect(properties.ema_flag_enabled).toBe(true);
268 expect(properties[`$feature/${ENTERPRISE_MANAGED_AUTH_FLAG_KEY}`]).toBe(true);
269 expect(properties.$groups).toEqual({
270 organization: CONTEXT.organizationId,
271 });
272 expect(properties.integration_slug).toBe("slack");
273 }),
274 );
275
276 it.effect("carries the withheld reason on the attempt that never ran", () =>
277 Effect.gen(function* () {
278 const harness = makeFetchHarness(() => jsonResponse({ status: 1 }));
279
280 yield* gate(harness).record({
281 kind: "attempted",
282 context: CONTEXT,
283 decision: { kind: "withheld", reason: "evaluation-unavailable" },
284 });
285 yield* Effect.promise(() => harness.settle());
286
287 const properties = harness.requests()[0]?.body.properties as Record<string, unknown>;
288 expect(harness.requests()[0]?.body.event).toBe("ema_connect_attempted");
289 expect(properties.ema_flag_enabled).toBe(false);
290 expect(properties.ema_flag_withheld_reason).toBe("evaluation-unavailable");
291 }),
292 );
293
294 it.effect("carries the identity provider's own error code on a blocked connect", () =>
295 Effect.gen(function* () {
296 const harness = makeFetchHarness(() => jsonResponse({ status: 1 }));
297
298 yield* gate(harness).record({
299 kind: "blocked-by-admin",
300 context: CONTEXT,
301 decision: { kind: "enabled" },
302 oauthErrorCode: "unauthorized_client",
303 });
304 yield* Effect.promise(() => harness.settle());
305
306 const [request] = harness.requests();
307 expect(request?.body.event).toBe("ema_connect_blocked_by_admin");
308 const properties = request?.body.properties as Record<string, unknown> | undefined;
309 expect(properties?.oauth_error_code).toBe("unauthorized_client");
310 }),
311 );
312
313 it.effect("sends nothing beyond identity, the integration and the flag decision", () =>
314 Effect.gen(function* () {
315 const harness = makeFetchHarness(() => jsonResponse({ status: 1 }));
316
317 yield* gate(harness).record({
318 kind: "blocked-by-admin",
319 context: CONTEXT,
320 decision: { kind: "enabled" },
321 oauthErrorCode: "access_denied",
322 });
323 yield* Effect.promise(() => harness.settle());
324
325 const properties = harness.requests()[0]?.body.properties as Record<string, unknown>;
326 expect(
327 Object.keys(properties).sort(),
328 "a closed property set is what keeps a token or an assertion from ever being added by accident",
329 ).toEqual(
330 [
331 "$feature/mcp-enterprise-managed-auth",
332 "$groups",
333 "ema_flag_enabled",
334 "integration_slug",
335 "oauth_error_code",
336 ].sort(),
337 );
338 }),
339 );
340
341 it.effect("hands the capture to the platform instead of awaiting it", () =>
342 Effect.gen(function* () {
343 let resolveCapture: (() => void) | undefined;
344 const harness = makeFetchHarness(
345 () =>
346 new Promise<Response>((resolve) => {
347 resolveCapture = () => resolve(jsonResponse({ status: 1 }));
348 }),
349 );
350
351 // Returns while the capture is still in flight: an analytics call can
352 // never be on the critical path of a connect.
353 yield* gate(harness).record({
354 kind: "attempted",
355 context: CONTEXT,
356 decision: { kind: "enabled" },
357 });
358
359 expect(harness.requests().length).toBe(1);
360 resolveCapture?.();
361 yield* Effect.promise(() => harness.settle());
362 }),
363 );
364
365 it.effect("survives a capture that fails outright", () =>
366 Effect.gen(function* () {
367 const harness = makeFetchHarness(() => {
368 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: models the ingest endpoint being unreachable
369 throw new TypeError("network error");
370 });
371
372 yield* gate(harness).record({
373 kind: "connected",
374 context: CONTEXT,
375 decision: { kind: "enabled" },
376 });
377 yield* Effect.promise(() => harness.settle());
378 }),
379 );
380
381 it.effect("records nothing when there is no person to attach the event to", () =>
382 Effect.gen(function* () {
383 const harness = makeFetchHarness(() => jsonResponse({ status: 1 }));
384
385 yield* gate(harness).record({
386 kind: "attempted",
387 context: { ...CONTEXT, userId: null },
388 decision: { kind: "withheld", reason: "evaluation-unavailable" },
389 });
390
391 expect(harness.requests().length).toBe(0);
392 }),
393 );
394});
apps/cloud/src/analytics/ema-rollout.ts+255−0
@@ -0,0 +1,255 @@
1// ---------------------------------------------------------------------------
2// Cloud's rollout gate for MCP Enterprise-Managed Authorization.
3//
4// Implements the vendor-free `EnterpriseManagedRollout` port from
5// `@executor-js/sdk` against PostHog, which is the only flag/analytics service
6// this deployment operates. Two deliberate shapes:
7//
8// 1. NO SDK. This is a hand-written `fetch` against two documented PostHog
9// endpoints, not `posthog-node`. Cloud already ate an unexplained 3-5s
10// page-load regression from adding one dependency to the worker bundle and
11// the mechanism was never identified (see the notes on the MCP SDK v2
12// revert). Until that is understood, a new runtime dependency in this bundle
13// is a cost we are not willing to pay for a boolean, and the whole client we
14// would be importing is two POSTs wide.
15//
16// 2. FAIL CLOSED, ALWAYS. Every way of not getting an answer — timeout,
17// transport failure, non-2xx, a body we cannot parse, no PostHog key
18// configured, no acting user to key the rollout on — withholds the
19// enterprise-managed path and falls back to ordinary interactive OAuth. A
20// PostHog outage therefore degrades the rollout and never fails a connect,
21// and (because the SDK freezes the verdict onto the connection) never
22// touches an enterprise-managed connection that already exists.
23//
24// The rollout UNIT is the user: `distinct_id` is the acting user's id, the same
25// id `posthog.identify(user.id, …)` uses in the browser, so a percentage
26// rollout here means the same population it means everywhere else in the
27// project. The organization rides along as group context so org-level targeting
28// stays available in the PostHog UI without changing that unit.
29// ---------------------------------------------------------------------------
30
31import { env } from "cloudflare:workers";
32import { Effect, Schema } from "effect";
33
34import type {
35 EnterpriseManagedRollout,
36 EnterpriseManagedRolloutContext,
37 EnterpriseManagedRolloutDecision,
38 EnterpriseManagedRolloutEvent,
39} from "@executor-js/sdk";
40
41import { POSTHOG_INGEST_HOST } from "../edge/passthrough";
42
43/** The flag that gates enterprise-managed authorization. */
44export const ENTERPRISE_MANAGED_AUTH_FLAG_KEY = "mcp-enterprise-managed-auth";
45
46/** PostHog group type for organizations — the same one
47 * `posthog.group("organization", …)` establishes in the browser. */
48const ORGANIZATION_GROUP_TYPE = "organization";
49
50/**
51 * How long a flag evaluation may take before the gate gives up and withholds.
52 * Short on purpose: this call sits in front of an interactive connect, and the
53 * safe answer is already known, so waiting is strictly worse than answering.
54 */
55export const DEFAULT_FLAG_EVALUATION_TIMEOUT_MS = 1_000;
56
57/** Wire names of the rollout events, in the product's `object_verb` style. */
58const EVENT_NAMES = {
59 attempted: "ema_connect_attempted",
60 connected: "ema_connect_connected",
61 "blocked-by-admin": "ema_connect_blocked_by_admin",
62} as const satisfies Record<EnterpriseManagedRolloutEvent["kind"], string>;
63
64// ---------------------------------------------------------------------------
65// Boundary parsing
66// ---------------------------------------------------------------------------
67
68/** The slice of PostHog's `POST /flags?v=2` response this gate reads. Unknown
69 * keys are ignored, so PostHog adding fields cannot make the gate fail closed;
70 * a body that is not this shape at all can, which is the intent. */
71const FlagsResponse = Schema.Struct({
72 flags: Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })),
73});
74
75const decodeFlagsResponse = Schema.decodeUnknownEffect(FlagsResponse);
76
77// ---------------------------------------------------------------------------
78// Detached work
79// ---------------------------------------------------------------------------
80
81/**
82 * Hand a request to the platform and stop caring about it.
83 *
84 * `waitUntil` is the correct owner when the caller has an `ExecutionContext`;
85 * the executor composition seam this gate is installed through does not receive
86 * one, so the default owner attaches a terminal rejection handler and lets the
87 * in-flight request ride out the rest of the response. Either way the promise
88 * is owned by something that cannot report back, which is what makes an event
89 * structurally unable to fail or delay a connect.
90 */
91const detach = (
92 work: Promise<unknown>,
93 waitUntil: ((work: Promise<unknown>) => void) | undefined,
94): void => {
95 const settled = work.then(
96 () => undefined,
97 () => undefined,
98 );
99 if (waitUntil) waitUntil(settled);
100};
101
102// ---------------------------------------------------------------------------
103// The gate
104// ---------------------------------------------------------------------------
105
106export interface PostHogRolloutConfig {
107 /** Public project key (`phc_…`). Public by design — it is already shipped to
108 * every browser — so this is a var, not a secret. */
109 readonly projectKey: string;
110 /** PostHog API origin (no trailing slash), e.g. `https://us.i.posthog.com`. */
111 readonly host: string;
112 readonly fetch: typeof globalThis.fetch;
113 readonly timeoutMs?: number;
114 /** Platform post-response hook, when the caller has one. */
115 readonly waitUntil?: (work: Promise<unknown>) => void;
116}
117
118const withheld = (
119 reason: "disabled" | "evaluation-unavailable",
120): EnterpriseManagedRolloutDecision => ({ kind: "withheld", reason });
121
122const ENABLED: EnterpriseManagedRolloutDecision = { kind: "enabled" };
123
124/** Group context for a connect whose organization the host named. */
125const groupsFor = (context: EnterpriseManagedRolloutContext): Record<string, string> | undefined =>
126 context.organizationId === null
127 ? undefined
128 : { [ORGANIZATION_GROUP_TYPE]: context.organizationId };
129
130/**
131 * Build cloud's enterprise-managed rollout gate over an explicit PostHog
132 * configuration. Everything environmental is a parameter so the composition
133 * root parses it once and tests drive the real code path with an injected
134 * `fetch`.
135 */
136export const makePostHogEnterpriseManagedRollout = (
137 config: PostHogRolloutConfig,
138): EnterpriseManagedRollout => {
139 const timeoutMs = config.timeoutMs ?? DEFAULT_FLAG_EVALUATION_TIMEOUT_MS;
140
141 const evaluate = Effect.fn("Cloud.EnterpriseManagedRollout.decide")(function* (
142 context: EnterpriseManagedRolloutContext,
143 ) {
144 // The rollout unit is the user. With no acting user there is no
145 // `distinct_id` to key it on, so there is no answer to be had — withhold
146 // rather than invent a rollout bucket.
147 const distinctId = context.userId;
148 if (distinctId === null) return withheld("evaluation-unavailable");
149
150 const groups = groupsFor(context);
151 const response = yield* Effect.tryPromise(() =>
152 config.fetch(`${config.host}/flags?v=2`, {
153 method: "POST",
154 headers: {
155 "content-type": "application/json",
156 // Declares this a server-side evaluation, which is what selects
157 // server-runtime flags in PostHog's runtime detection.
158 "user-agent": "executor-cloud",
159 },
160 body: JSON.stringify({
161 api_key: config.projectKey,
162 distinct_id: distinctId,
163 ...(groups === undefined ? {} : { groups }),
164 }),
165 signal: AbortSignal.timeout(timeoutMs),
166 }),
167 );
168 if (!response.ok) return withheld("evaluation-unavailable");
169
170 const body = yield* Effect.tryPromise(() => response.json() as Promise<unknown>);
171 const decoded = yield* decodeFlagsResponse(body);
172 const flag = decoded.flags[ENTERPRISE_MANAGED_AUTH_FLAG_KEY];
173 // A flag PostHog did not return is a flag that is not on for this user
174 // (it may not exist yet, or the project may be quota-limited). That is a
175 // real "no", not a failure to answer.
176 if (flag === undefined || !flag.enabled) return withheld("disabled");
177 return ENABLED;
178 });
179
180 return {
181 decide: (context) =>
182 evaluate(context).pipe(
183 // Timeout, transport failure and an unparseable body all land here and
184 // all mean the same thing: no answer, so no enterprise-managed attempt.
185 // `catchCause` rather than `catchAll` because a defect in this adapter
186 // must not become a failed connect either.
187 Effect.catchCause(() => Effect.succeed(withheld("evaluation-unavailable"))),
188 ),
189 record: (event) =>
190 Effect.sync(() => {
191 const distinctId = event.context.userId;
192 // Same reason `decide` withholds: an event with no person to attach to
193 // would only pollute the project with anonymous rows.
194 if (distinctId === null) return;
195 const groups = groupsFor(event.context);
196 const decision = event.decision;
197 detach(
198 config.fetch(`${config.host}/i/v0/e/`, {
199 method: "POST",
200 headers: { "content-type": "application/json" },
201 body: JSON.stringify({
202 api_key: config.projectKey,
203 event: EVENT_NAMES[event.kind],
204 distinct_id: distinctId,
205 properties: {
206 // Product metadata only. There is no field on
207 // `EnterpriseManagedRolloutEvent` that can carry a token, an
208 // identity assertion, a client secret or a scope, and none is
209 // synthesized here.
210 integration_slug: String(event.context.integration),
211 ema_flag_enabled: decision.kind === "enabled",
212 ...(decision.kind === "withheld"
213 ? { ema_flag_withheld_reason: decision.reason }
214 : {}),
215 // PostHog's own convention, so these events can be filtered by
216 // flag value in the UI alongside every other flag.
217 [`$feature/${ENTERPRISE_MANAGED_AUTH_FLAG_KEY}`]: decision.kind === "enabled",
218 ...(event.kind === "blocked-by-admin" && event.oauthErrorCode !== undefined
219 ? { oauth_error_code: event.oauthErrorCode }
220 : {}),
221 ...(groups === undefined ? {} : { $groups: groups }),
222 },
223 }),
224 }),
225 config.waitUntil,
226 );
227 }),
228 };
229};
230
231/** A gate that answers "no" to everything, for a deployment that has no PostHog
232 * configuration to evaluate against. Cloud is SUPPOSED to have one, so a
233 * missing key is a misconfiguration, and failing closed is the same choice the
234 * outage paths make. This is NOT the same as injecting no gate at all, which
235 * is how the hosts with no flag service (local, desktop, CLI, self-host) keep
236 * their existing behavior. */
237export const unavailableEnterpriseManagedRollout: EnterpriseManagedRollout = {
238 decide: () => Effect.succeed(withheld("evaluation-unavailable")),
239 record: () => Effect.void,
240};
241
242/**
243 * The gate as the cloud worker composes it: the public project key already
244 * shipped in `wrangler.jsonc`, and the same PostHog ingest origin the
245 * adblock-dodging passthrough proxies to.
246 */
247export const cloudEnterpriseManagedRollout = (): EnterpriseManagedRollout => {
248 const projectKey = env.VITE_PUBLIC_POSTHOG_KEY;
249 if (!projectKey) return unavailableEnterpriseManagedRollout;
250 return makePostHogEnterpriseManagedRollout({
251 projectKey,
252 host: env.VITE_PUBLIC_POSTHOG_HOST ?? `https://${POSTHOG_INGEST_HOST}`,
253 fetch: (input, init) => globalThis.fetch(input, init),
254 });
255};
apps/cloud/src/api/protected-api-key-auth.node.test.ts+6−1
@@ -13,6 +13,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 12 unmodified lines
13 Effect.succeed(13 Effect.succeed(
14 value === "valid_user_key"14 value === "valid_user_key"
15 ? {15 ? {
16 scope: "user" as const,
16 accountId: "user_123",17 accountId: "user_123",
17 organizationId: "org_123",18 organizationId: "org_123",
18 keyId: "api_key_123",19 keyId: "api_key_123",
@@ -22,6 +23,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 3 unmodified lines
22 listUserKeys: () => Effect.succeed([]),23 listUserKeys: () => Effect.succeed([]),
23 createUserKey: () => Effect.die("protected API auth test does not create API keys"),24 createUserKey: () => Effect.die("protected API auth test does not create API keys"),
24 revokeUserKey: () => Effect.void,25 revokeUserKey: () => Effect.void,
26 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
27 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
28 revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
25});29});
26 30
27const stubWorkOS = Layer.succeed(31const stubWorkOS = Layer.succeed(
@@ -43,7 +47,7 @@ const stubWorkOS = Layer.succeed( · 15 unmodified lines
43);47);
44 48
45const stubUsers = Layer.succeed(UserStoreService)({49const stubUsers = Layer.succeed(UserStoreService)({
46 use: (fn) =>50 use: (_op, fn) =>
47 Effect.promise(() =>51 Effect.promise(() =>
48 fn({52 fn({
49 ensureAccount: async (id: string) => ({ id, createdAt }),53 ensureAccount: async (id: string) => ({ id, createdAt }),
@@ -85,6 +89,7 @@ describe("protected API key auth", () => { · 35 unmodified lines
85 );89 );
86 90
87 expect(identity).toEqual({91 expect(identity).toEqual({
92 kind: "member",
88 accountId: "user_123",93 accountId: "user_123",
89 organizationId: "org_123",94 organizationId: "org_123",
90 organizationName: "Org org_123",95 organizationName: "Org org_123",
apps/cloud/src/api/protected-jwt-auth.node.test.ts+5−1
@@ -40,6 +40,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 39 unmodified lines
40 listUserKeys: () => Effect.succeed([]),40 listUserKeys: () => Effect.succeed([]),
41 createUserKey: () => Effect.die("JWT auth test does not create API keys"),41 createUserKey: () => Effect.die("JWT auth test does not create API keys"),
42 revokeUserKey: () => Effect.void,42 revokeUserKey: () => Effect.void,
43 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
44 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
45 revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
43});46});
44 47
45const stubWorkOS = Layer.succeed(48const stubWorkOS = Layer.succeed(
@@ -61,7 +64,7 @@ const stubWorkOS = Layer.succeed( · 15 unmodified lines
61);64);
62 65
63const stubUsers = Layer.succeed(UserStoreService)({66const stubUsers = Layer.succeed(UserStoreService)({
64 use: (fn) =>67 use: (_op, fn) =>
65 Effect.promise(() =>68 Effect.promise(() =>
66 fn({69 fn({
67 ensureAccount: async (id: string) => ({ id, createdAt }),70 ensureAccount: async (id: string) => ({ id, createdAt }),
@@ -107,6 +110,7 @@ describe("protected JWT (device-login) auth", () => { · 39 unmodified lines
107 const identity = yield* run(request(token), config);110 const identity = yield* run(request(token), config);
108 111
109 expect(identity).toEqual({112 expect(identity).toEqual({
113 kind: "member",
110 accountId: "user_123",114 accountId: "user_123",
111 organizationId: "org_123",115 organizationId: "org_123",
112 organizationName: "Org org_123",116 organizationName: "Org org_123",
apps/cloud/src/api/protected.test.ts+2−0
@@ -20,6 +20,8 @@ const makeBaseEngine = (): ExecutionEngine => · 19 unmodified lines
20 pausedExecutionCount: () => Effect.succeed(0),20 pausedExecutionCount: () => Effect.succeed(0),
21 hasPausedExecutions: () => Effect.succeed(false),21 hasPausedExecutions: () => Effect.succeed(false),
22 getDescription: Effect.succeed("desc"),22 getDescription: Effect.succeed("desc"),
23 // The fake forks nothing, so there is no sandbox fiber to end.
24 shutdown: Effect.void,
23 }) as ExecutionEngine;25 }) as ExecutionEngine;
24 26
25describe("withExecutionUsageTracking", () => {27describe("withExecutionUsageTracking", () => {
apps/cloud/src/app-paths.test.ts+37−1
@@ -1,6 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { isAppOwnedPath } from "./app-paths";3import { isAppOwnedPath, servedByAppPlane } from "./app-paths";
4 4
5// Guards the start.ts dispatch decision: every surface the unified app handler5// Guards the start.ts dispatch decision: every surface the unified app handler
6// serves must be classified app-owned (forwarded to `app.handler`), and Start's6// serves must be classified app-owned (forwarded to `app.handler`), and Start's
@@ -62,3 +62,39 @@ describe("isAppOwnedPath", () => { · 55 unmodified lines
62 });62 });
63 }63 }
64});64});
65
66describe("app-plane dispatch", () => {
67 // These two are the whole risk of dispatching `/api` before Start: both still
68 // return a response if routed early, just the wrong one, so nothing else would
69 // catch a regression here.
70 it("leaves the Sentry tunnel POST to Start's middleware", () => {
71 expect(servedByAppPlane("/api/sentry-tunnel", "POST")).toBe(false);
72 // Only the POST is claimed; anything else under that path is ordinary API.
73 expect(servedByAppPlane("/api/sentry-tunnel", "GET")).toBe(true);
74 });
75
76 it("leaves the OAuth callback to Start, for the signed-out redirect", () => {
77 expect(servedByAppPlane("/api/oauth/callback", "GET")).toBe(false);
78 expect(servedByAppPlane("/api/oauth/callback", "POST")).toBe(false);
79 });
80
81 const appPlane = [
82 "/api/connections",
83 "/api/tools",
84 "/api/integrations",
85 "/api/account/members",
86 "/api/docs",
87 "/api/billing/checkout",
88 ];
89 for (const pathname of appPlane) {
90 it(`serves ${pathname} without entering Start`, () => {
91 expect(servedByAppPlane(pathname, "GET")).toBe(true);
92 });
93 }
94
95 it("never claims a non-API path, however app-owned", () => {
96 expect(servedByAppPlane("/mcp", "POST")).toBe(false);
97 expect(servedByAppPlane("/", "GET")).toBe(false);
98 expect(servedByAppPlane("/.well-known/oauth-authorization-server", "GET")).toBe(false);
99 });
100});
apps/cloud/src/app-paths.ts+28−0
@@ -17,3 +17,31 @@ export const isApiPath = (pathname: string) => pathname === "/api" || pathname.s · 16 unmodified lines
17 17
18export const isAppOwnedPath = (pathname: string) =>18export const isAppOwnedPath = (pathname: string) =>
19 isApiPath(pathname) || classifyMcpPath(pathname) !== null;19 isApiPath(pathname) || classifyMcpPath(pathname) !== null;
20
21// ---------------------------------------------------------------------------
22// Which plane serves an app-owned path: the Effect app directly, or TanStack
23// Start's middleware chain.
24//
25// Everything under `/api` is pure Effect and touches no part of the router,
26// React, or SSR — so `server.ts` dispatches it at the Worker entry and skips
27// Start's lazy `loadEntries` import entirely. Two paths must NOT take that
28// shortcut, because Start's request middleware claims them BEFORE the app
29// handler would ever see them:
30//
31// POST /api/sentry-tunnel - `sentryTunnelMiddleware` forwards the envelope
32// to Sentry; the app has no such route.
33// /api/oauth/callback - `oauthCallbackSignInMiddleware` redirects a
34// signed-out visitor to /login, and start.ts
35// rewrites the org-scoped `state` before handing
36// off. Routing it early would drop both.
37//
38// Getting this wrong is silent: the request still gets a response, just the
39// wrong one, which is why it is classified here and tested rather than being
40// an inline condition at the dispatch site.
41// ---------------------------------------------------------------------------
42
43export const isStartOwnedApiPath = (pathname: string, method: string): boolean =>
44 (pathname === "/api/sentry-tunnel" && method === "POST") || pathname === "/api/oauth/callback";
45
46export const servedByAppPlane = (pathname: string, method: string): boolean =>
47 isApiPath(pathname) && !isStartOwnedApiPath(pathname, method);
apps/cloud/src/app.ts+2−7
@@ -13,7 +13,6 @@ import { ApiKeyService } from "./auth/api-keys"; · 12 unmodified lines
13import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider";13import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider";
14import { DbService } from "./db/db";14import { DbService } from "./db/db";
15import { cloudMcpAuth } from "./mcp";15import { cloudMcpAuth } from "./mcp";
16import { McpSessionDOSqlite } from "./mcp/session-durable-object";
17import { ErrorCaptureLive } from "./observability";16import { ErrorCaptureLive } from "./observability";
18import { AutumnService } from "./extensions/billing/service";17import { AutumnService } from "./extensions/billing/service";
19import {18import {
@@ -31,7 +30,7 @@ import { WorkerTelemetryLive } from "./observability/telemetry"; · 11 unmodified lines
31// The whole scenario in 60 seconds: WorkOS identity (api-key Bearer OR sealed-30// The whole scenario in 60 seconds: WorkOS identity (api-key Bearer OR sealed-
32// session cookie, api-key wins) over a per-request Hyperdrive→Postgres socket,31// session cookie, api-key wins) over a per-request Hyperdrive→Postgres socket,
33// the Cloudflare dynamic-worker code substrate, MCP served by a Durable-Object32// the Cloudflare dynamic-worker code substrate, MCP served by a Durable-Object
34// session store (the DO surfaced via `config.mcpExport`), console+Sentry error33// session store (the DO class is exported straight from server.ts), console+Sentry error
35// capture — and Autumn BILLING entering ONLY as extensions: the engine34// capture — and Autumn BILLING entering ONLY as extensions: the engine
36// metering decorator, the account seat-gate, the `/api/billing/*` proxy route,35// metering decorator, the account seat-gate, the `/api/billing/*` proxy route,
37// and the createOrganization free-limit gate. `diff` against36// and the createOrganization free-limit gate. `diff` against
@@ -66,7 +65,7 @@ const controlPlane = Layer.mergeAll(CoreSharedServices, apiKeyService); · 28 unmodified lines
66// satisfied by `boot`, `DbService` per request via `requestScoped`).65// satisfied by `boot`, `DbService` per request via `requestScoped`).
67const cloudDb: Layer.Layer<DbProvider, never, DbService | AutumnService> = CloudDbProvider;66const cloudDb: Layer.Layer<DbProvider, never, DbService | AutumnService> = CloudDbProvider;
68 67
69const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({68const { appLayer, toWebHandler } = ExecutorApp.make({
70 plugins: cloudPlugins,69 plugins: cloudPlugins,
71 providers: {70 providers: {
72 // Identity: the NEUTRAL `IdentityProvider`. WorkOS api-key Bearer BEATS71 // Identity: the NEUTRAL `IdentityProvider`. WorkOS api-key Bearer BEATS
@@ -112,7 +111,6 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ · 39 unmodified lines
112 failure: cloudIdentityFailureStrategy,111 failure: cloudIdentityFailureStrategy,
113 // The MCP session Durable Object class — a top-level Workers export a Layer112 // The MCP session Durable Object class — a top-level Workers export a Layer
114 // can't return; surfaced so `server.ts` can re-export it.113 // can't return; surfaced so `server.ts` can re-export it.
115 mcpExport: McpSessionDOSqlite,
116 },114 },
117 // The long-lived (boot-scoped) context provideMerge'd under everything: the115 // The long-lived (boot-scoped) context provideMerge'd under everything: the
118 // WorkOS control plane (the raw `WorkOSClient` + `ApiKeyService` the per-request116 // WorkOS control plane (the raw `WorkOSClient` + `ApiKeyService` the per-request
@@ -135,10 +133,7 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ · 16 unmodified lines
135 requestScoped: RequestScopedServicesLive,133 requestScoped: RequestScopedServicesLive,
136});134});
137 135
138export { McpSessionDOSqlite };
139
140export const CloudAppLayer = appLayer;136export const CloudAppLayer = appLayer;
141export const cloudMcpExport = mcpExport;
142 137
143// The unified cloud web handler: serves /api/* (incl. /api/billing/*, /api/docs),138// The unified cloud web handler: serves /api/* (incl. /api/billing/*, /api/docs),
144// /mcp, /.well-known/* — everything the worker dispatches.139// /mcp, /.well-known/* — everything the worker dispatches.
apps/cloud/src/auth/api-keys.node.test.ts+23−8
@@ -42,6 +42,7 @@ describe("ApiKeyService.WorkOS", () => { · 41 unmodified lines
42 });42 });
43 43
44 expect(principal).toEqual({44 expect(principal).toEqual({
45 scope: "user",
45 accountId: "user_123",46 accountId: "user_123",
46 organizationId: "org_123",47 organizationId: "org_123",
47 keyId: "api_key_123",48 keyId: "api_key_123",
@@ -66,15 +67,9 @@ describe("ApiKeyService.WorkOS", () => { · 18 unmodified lines
66 }),67 }),
67 );68 );
68 69
69 it.effect("rejects missing, organization-owned, and org-less keys", () =>70 it.effect("rejects missing and org-less keys", () =>
70 Effect.gen(function* () {71 Effect.gen(function* () {
71 const missing = yield* validate({ apiKey: null });72 const missing = yield* validate({ apiKey: null });
72 const orgOwned = yield* validate({
73 apiKey: {
74 id: "api_key_org",
75 owner: { type: "organization", id: "org_123" },
76 },
77 });
78 const orgLess = yield* validate({73 const orgLess = yield* validate({
79 apiKey: {74 apiKey: {
80 id: "api_key_no_org",75 id: "api_key_no_org",
@@ -83,11 +78,31 @@ describe("ApiKeyService.WorkOS", () => { · 2 unmodified lines
83 });78 });
84 79
85 expect(missing).toBeNull();80 expect(missing).toBeNull();
86 expect(orgOwned).toBeNull();
87 expect(orgLess).toBeNull();81 expect(orgLess).toBeNull();
88 }),82 }),
89 );83 );
90 84
85 it.effect("resolves organization-owned keys to the org scope with no account", () =>
86 Effect.gen(function* () {
87 // An org key USED to fail the decode and come back null — indistinguishable
88 // from an invalid key. It now resolves explicitly: `owner.id` IS the
89 // organization, and there is no member behind it.
90 const principal = yield* validate({
91 apiKey: {
92 id: "api_key_org",
93 owner: { type: "organization", id: "org_123" },
94 },
95 });
96
97 expect(principal).toEqual({
98 scope: "org",
99 accountId: null,
100 organizationId: "org_123",
101 keyId: "api_key_org",
102 });
103 }),
104 );
105
91 it.effect("lists and creates user-owned keys", () =>106 it.effect("lists and creates user-owned keys", () =>
92 Effect.gen(function* () {107 Effect.gen(function* () {
93 const program = Effect.gen(function* () {108 const program = Effect.gen(function* () {
apps/cloud/src/auth/api-keys.ts+214−12
@@ -3,10 +3,24 @@ import { Context, Data, Effect, Layer, Option, Schema } from "effect"; · 2 unmodified lines
3import { ApiKeyManagementError } from "./errors";3import { ApiKeyManagementError } from "./errors";
4import { WorkOSClient } from "./workos";4import { WorkOSClient } from "./workos";
5 5
6/**
7 * Which view a validated key resolves to.
8 * - `"user"` — the product view. The key belongs to a member; the request
9 * binds to that member as the acting subject. Every key today is this.
10 * - `"org"` — the PLATFORM view. The key belongs to the organization itself,
11 * so there is no acting member: the request gets tenant-wide READ-ONLY
12 * reach and no bound subject. Privileged; minted separately (PR 1.5).
13 */
14export type ApiKeyScope = "user" | "org";
15
6/** The owner an api key resolves to — NOT a full {@link Principal} (no email /16/** The owner an api key resolves to — NOT a full {@link Principal} (no email /
7 * name / roles), so it carries an honest, distinct name. */17 * name / roles), so it carries an honest, distinct name. */
8export type ApiKeyOwner = {18export type ApiKeyOwner = {
9 readonly accountId: string;19 readonly scope: ApiKeyScope;
20 /** The acting member for a `"user"` key; `null` for an `"org"` key, which
21 * has no member behind it. Nullable rather than a sentinel so nothing can
22 * accidentally bind an org key to a subject. */
23 readonly accountId: string | null;
10 readonly organizationId: string;24 readonly organizationId: string;
11 readonly keyId: string;25 readonly keyId: string;
12};26};
@@ -28,6 +42,21 @@ export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationErr · 15 unmodified lines
28 readonly cause: unknown;42 readonly cause: unknown;
29}> {}43}> {}
30 44
45/**
46 * The requested key id is not an org-owned key of this organization — it does
47 * not exist, belongs to a member rather than the org, or belongs to a different
48 * org in the same WorkOS workspace. Distinct from `ApiKeyManagementError` (an
49 * upstream failure) so callers can answer "not found" without a 500.
50 */
51export class OrgApiKeyNotFound extends Data.TaggedError("OrgApiKeyNotFound")<{
52 readonly keyId: string;
53}> {}
54
55// WorkOS keys are owned by either a user or an organization. The user shape
56// carries the member id in `id` and the org in `organization_id`; the org shape
57// carries the ORG in `id` and no member at all. Both decode here — the
58// distinction is a branch (`ownerFromApiKey`), not a decode failure, so an
59// org-owned key is no longer indistinguishable from an invalid one.
31const UserApiKeyOwner = Schema.Struct({60const UserApiKeyOwner = Schema.Struct({
32 type: Schema.Literal("user"),61 type: Schema.Literal("user"),
33 id: Schema.String,62 id: Schema.String,
@@ -35,6 +64,13 @@ const UserApiKeyOwner = Schema.Struct({ · 1 unmodified line
35 organization_id: Schema.optional(Schema.String),64 organization_id: Schema.optional(Schema.String),
36});65});
37 66
67const OrgApiKeyOwner = Schema.Struct({
68 type: Schema.Literal("organization"),
69 id: Schema.String,
70});
71
72const WorkOSApiKeyOwner = Schema.Union([UserApiKeyOwner, OrgApiKeyOwner]);
73
38const ApiKey = Schema.Struct({74const ApiKey = Schema.Struct({
39 id: Schema.String,75 id: Schema.String,
40 owner: UserApiKeyOwner,76 owner: UserApiKeyOwner,
@@ -49,8 +85,17 @@ const ApiKey = Schema.Struct({ · 8 unmodified lines
49 last_used_at: Schema.optional(Schema.NullOr(Schema.String)),85 last_used_at: Schema.optional(Schema.NullOr(Schema.String)),
50});86});
51 87
88// Validation accepts BOTH owner shapes. The user list/create paths below stay
89// user-only on purpose (they are the console's personal-key CRUD); the ORG
90// list/create paths are the separate, privileged surface beside them, gated to
91// admins at the account-provider boundary.
92const ValidatedApiKey = Schema.Struct({
93 id: Schema.String,
94 owner: WorkOSApiKeyOwner,
95});
96
52const ValidateApiKeyResponse = Schema.Struct({97const ValidateApiKeyResponse = Schema.Struct({
53 apiKey: Schema.NullOr(ApiKey),98 apiKey: Schema.NullOr(ValidatedApiKey),
54});99});
55 100
56const RawCreatedApiKey = Schema.Struct({101const RawCreatedApiKey = Schema.Struct({
@@ -72,6 +117,45 @@ const ListApiKeysResponse = Schema.Struct({ · 15 unmodified lines
72 data: Schema.Array(ApiKey),117 data: Schema.Array(ApiKey),
73});118});
74 119
120// The ORG-owned mirror of `ApiKey` / `RawCreatedApiKey`. A separate shape
121// rather than a widened one: `summaryFromApiKey` reads the user shape's
122// `organization_id` to prove the key belongs to the caller's org, and an
123// org-owned row carries the org in `owner.id` instead. Merging them would mean
124// one nullable field standing for two different things.
125const OrgApiKey = Schema.Struct({
126 id: Schema.String,
127 owner: OrgApiKeyOwner,
128 name: Schema.optional(Schema.String),
129 obfuscatedValue: Schema.optional(Schema.String),
130 obfuscated_value: Schema.optional(Schema.String),
131 createdAt: Schema.optional(Schema.String),
132 created_at: Schema.optional(Schema.String),
133 updatedAt: Schema.optional(Schema.String),
134 updated_at: Schema.optional(Schema.String),
135 lastUsedAt: Schema.optional(Schema.NullOr(Schema.String)),
136 last_used_at: Schema.optional(Schema.NullOr(Schema.String)),
137});
138
139const RawCreatedOrgApiKey = Schema.Struct({
140 ...OrgApiKey.fields,
141 value: Schema.String,
142});
143
144// `data` is decoded element-by-element, NOT as `Schema.Array(OrgApiKey)`: WorkOS
145// may return user-owned keys alongside org-owned ones, and a whole-array decode
146// fails on the first non-org element — blanking the entire list rather than
147// dropping the one row that doesn't belong. Same per-row tolerance the user-key
148// path gets from `summaryFromApiKey` returning null.
149const ListOrgApiKeysResponse = Schema.Struct({
150 data: Schema.Array(Schema.Unknown),
151});
152
153const CreateOrgApiKeyResponse = Schema.Union([
154 RawCreatedOrgApiKey,
155 Schema.Struct({ apiKey: RawCreatedOrgApiKey }),
156 Schema.Struct({ api_key: RawCreatedOrgApiKey }),
157]);
158
75const CreateApiKeyResponse = Schema.Union([159const CreateApiKeyResponse = Schema.Union([
76 RawCreatedApiKey,160 RawCreatedApiKey,
77 Schema.Struct({ apiKey: RawCreatedApiKey }),161 Schema.Struct({ apiKey: RawCreatedApiKey }),
@@ -81,20 +165,35 @@ const CreateApiKeyResponse = Schema.Union([ · 3 unmodified lines
81const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse);165const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse);
82const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse);166const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse);
83const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse);167const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse);
168const decodeListOrgApiKeysResponse = Schema.decodeUnknownOption(ListOrgApiKeysResponse);
169const decodeOrgApiKey = Schema.decodeUnknownOption(OrgApiKey);
170const decodeCreateOrgApiKeyResponse = Schema.decodeUnknownOption(CreateOrgApiKeyResponse);
171
172const ownerFromApiKey = (apiKey: typeof ValidatedApiKey.Type): ApiKeyOwner | null => {
173 if (apiKey.owner.type === "organization") {
174 // An org-owned key IS the org: `owner.id` is the organization, and there
175 // is no member to act as. This resolves to the platform view.
176 return {
177 scope: "org",
178 accountId: null,
179 organizationId: apiKey.owner.id,
180 keyId: apiKey.id,
181 };
182 }
183 const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id;
184 if (!organizationId) return null;
185 return {
186 scope: "user",
187 accountId: apiKey.owner.id,
188 organizationId,
189 keyId: apiKey.id,
190 };
191};
84 192
85const ownerFromResponse = (value: unknown): ApiKeyOwner | null =>193const ownerFromResponse = (value: unknown): ApiKeyOwner | null =>
86 Option.match(decodeValidateApiKeyResponse(value), {194 Option.match(decodeValidateApiKeyResponse(value), {
87 onNone: () => null,195 onNone: () => null,
88 onSome: ({ apiKey }) => {196 onSome: ({ apiKey }) => (apiKey ? ownerFromApiKey(apiKey) : null),
89 if (!apiKey) return null;
90 const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id;
91 if (!organizationId) return null;
92 return {
93 accountId: apiKey.owner.id,
94 organizationId,
95 keyId: apiKey.id,
96 };
97 },
98 });197 });
99 198
100const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => {199const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => {
@@ -120,6 +219,53 @@ const listFromResponse = (value: unknown): readonly ApiKeySummary[] => · 19 unmodified lines
120 }),219 }),
121 });220 });
122 221
222// The org-owned mirror of `summaryFromApiKey`. `organizationId` is checked by
223// the CALLER against the org it asked for: WorkOS's workspace api key is
224// workspace-wide, so a response naming a different org than the one requested
225// must not be surfaced as that org's key.
226const summaryFromOrgApiKey = (
227 apiKey: typeof OrgApiKey.Type,
228 organizationId: string,
229): ApiKeySummary | null => {
230 if (apiKey.owner.id !== organizationId) return null;
231 return {
232 id: apiKey.id,
233 name: apiKey.name ?? "API key",
234 obfuscatedValue: apiKey.obfuscatedValue ?? apiKey.obfuscated_value ?? "",
235 createdAt: apiKey.createdAt ?? apiKey.created_at ?? "",
236 updatedAt: apiKey.updatedAt ?? apiKey.updated_at ?? "",
237 lastUsedAt: apiKey.lastUsedAt ?? apiKey.last_used_at ?? null,
238 };
239};
240
241const orgListFromResponse = (value: unknown, organizationId: string): readonly ApiKeySummary[] =>
242 Option.match(decodeListOrgApiKeysResponse(value), {
243 onNone: () => [],
244 onSome: ({ data }) =>
245 data.flatMap((entry) =>
246 Option.match(decodeOrgApiKey(entry), {
247 // Not an org-owned key (a user key sharing the listing, or a shape we
248 // don't recognize) — skip the row, keep the rest.
249 onNone: () => [],
250 onSome: (apiKey) => {
251 const summary = summaryFromOrgApiKey(apiKey, organizationId);
252 return summary ? [summary] : [];
253 },
254 }),
255 ),
256 });
257
258const orgCreatedFromResponse = (value: unknown, organizationId: string): CreatedApiKey | null =>
259 Option.match(decodeCreateOrgApiKeyResponse(value), {
260 onNone: () => null,
261 onSome: (response) => {
262 const apiKey =
263 "value" in response ? response : "apiKey" in response ? response.apiKey : response.api_key;
264 const summary = summaryFromOrgApiKey(apiKey, organizationId);
265 return summary ? { ...summary, value: apiKey.value } : null;
266 },
267 });
268
123const createdFromResponse = (value: unknown): CreatedApiKey | null =>269const createdFromResponse = (value: unknown): CreatedApiKey | null =>
124 Option.match(decodeCreateApiKeyResponse(value), {270 Option.match(decodeCreateApiKeyResponse(value), {
125 onNone: () => null,271 onNone: () => null,
@@ -147,6 +293,28 @@ export class ApiKeyService extends Context.Service< · 21 unmodified lines
147 readonly revokeUserKey: (input: {293 readonly revokeUserKey: (input: {
148 readonly keyId: string;294 readonly keyId: string;
149 }) => Effect.Effect<void, ApiKeyManagementError>;295 }) => Effect.Effect<void, ApiKeyManagementError>;
296 /**
297 * The org-owned keys of an organization — the credentials that resolve to
298 * the read-only platform view. Privileged: callers gate on admin membership
299 * before reaching this.
300 */
301 readonly listOrgKeys: (input: {
302 readonly organizationId: string;
303 }) => Effect.Effect<readonly ApiKeySummary[], ApiKeyManagementError>;
304 readonly createOrgKey: (input: {
305 readonly organizationId: string;
306 readonly name: string;
307 }) => Effect.Effect<CreatedApiKey, ApiKeyManagementError>;
308 /**
309 * Revoke an org-owned key. Ownership is resolved against the ORG's own key
310 * listing — never the caller's personal keys — so a member's user key id
311 * (or another org's key id, since the WorkOS workspace key is
312 * workspace-wide) cannot be destroyed through this path.
313 */
314 readonly revokeOrgKey: (input: {
315 readonly organizationId: string;
316 readonly keyId: string;
317 }) => Effect.Effect<void, ApiKeyManagementError | OrgApiKeyNotFound>;
150 }318 }
151>()("@executor-js/cloud/ApiKeyService") {319>()("@executor-js/cloud/ApiKeyService") {
152 static WorkOS = Layer.effect(this)(320 static WorkOS = Layer.effect(this)(
@@ -177,6 +345,40 @@ export class ApiKeyService extends Context.Service< · 24 unmodified lines
177 workos345 workos
178 .deleteApiKey(keyId)346 .deleteApiKey(keyId)
179 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))),347 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))),
348 listOrgKeys: ({ organizationId }) =>
349 workos.listOrgApiKeys(organizationId).pipe(
350 Effect.map((response) => orgListFromResponse(response, organizationId)),
351 Effect.mapError((cause) => new ApiKeyManagementError({ cause })),
352 ),
353 createOrgKey: ({ organizationId, name }) =>
354 workos.createOrgApiKey({ organizationId, name }).pipe(
355 Effect.mapError((cause) => new ApiKeyManagementError({ cause })),
356 Effect.flatMap((response) => {
357 const created = orgCreatedFromResponse(response, organizationId);
358 return created
359 ? Effect.succeed(created)
360 : Effect.fail(new ApiKeyManagementError({ cause: "invalid_create_response" }));
361 }),
362 ),
363 // Ownership is checked against the ORG's decoded key listing before the
364 // delete goes out. `deleteApiKey` is scope-blind — handed any key id it
365 // would happily destroy a member's personal credential, or a key
366 // belonging to a sibling org in the same workspace. The listing already
367 // drops both of those (`orgListFromResponse` filters on owner type AND
368 // organization), so filtering through it is the whole authorization.
369 revokeOrgKey: ({ organizationId, keyId }) =>
370 Effect.gen(function* () {
371 const response = yield* workos
372 .listOrgApiKeys(organizationId)
373 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));
374 const owned = orgListFromResponse(response, organizationId);
375 if (!owned.some((key) => key.id === keyId)) {
376 return yield* new OrgApiKeyNotFound({ keyId });
377 }
378 yield* workos
379 .deleteApiKey(keyId)
380 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));
381 }),
180 };382 };
181 }),383 }),
182 );384 );
apps/cloud/src/auth/api.ts+9−1
@@ -181,6 +181,15 @@ const McpApprovalErrors = [ · 180 unmodified lines
181/** Public auth endpoints — no authentication required */181/** Public auth endpoints — no authentication required */
182export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")182export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")
183 .add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch }))183 .add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch }))
184 // Sign-out is PUBLIC on purpose. The console posts it as a top-level form
185 // navigation (the WorkOS hop is cross-origin, so it can't be a fetch), which
186 // means an error response is rendered as the page — and a browser whose
187 // session has already ended is exactly the browser most likely to click it
188 // (a second tab, a re-submit from history, an expired or revoked session).
189 // Behind SessionAuth all of those got a raw `{"_tag":"Unauthorized"}` screen
190 // instead of being signed out. There is nothing to authorize here anyway:
191 // the request can only end the session whose cookie it presents.
192 .add(HttpApiEndpoint.post("logout", "/auth/logout"))
184 .add(193 .add(
185 HttpApiEndpoint.get("callback", "/auth/callback", {194 HttpApiEndpoint.get("callback", "/auth/callback", {
186 query: AuthCallbackSearch,195 query: AuthCallbackSearch,
@@ -201,7 +210,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 14 unmodified lines
201 error: AuthErrors,210 error: AuthErrors,
202 }),211 }),
203 )212 )
204 .add(HttpApiEndpoint.post("logout", "/auth/logout"))
205 .add(213 .add(
206 HttpApiEndpoint.get("organizations", "/auth/organizations", {214 HttpApiEndpoint.get("organizations", "/auth/organizations", {
207 success: AuthOrganizationsResponse,215 success: AuthOrganizationsResponse,
apps/cloud/src/auth/context.ts+9−4
@@ -1,7 +1,7 @@
1import { Context, Effect, Layer } from "effect";1import { Context, Effect, Layer } from "effect";
2import { makeUserStore } from "../auth/user-store";2import { makeUserStore } from "../auth/user-store";
3import { DbService } from "../db/db";3import { DbService } from "../db/db";
4import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors";4import { tryPromiseService, userStoreErrorFromFailure, withServiceLogging } from "./errors";
5 5
6// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
7// UserStoreService — wraps the Drizzle-backed user store with Effect7// UserStoreService — wraps the Drizzle-backed user store with Effect
@@ -9,11 +9,16 @@ import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors" · 1 unmodified line
9 9
10type RawStore = ReturnType<typeof makeUserStore>;10type RawStore = ReturnType<typeof makeUserStore>;
11 11
12// `op` names the store call so every span reads `user_store.<operation>`
13// instead of one undifferentiated "user_store" bucket, failures log which
14// query actually failed, and — because the same `op` is threaded onto the
15// public error alongside the classified driver reason — an error report is
16// diagnosable without the trace.
12const makeService = (store: RawStore) => ({17const makeService = (store: RawStore) => ({
13 use: <A>(fn: (s: RawStore) => Promise<A>) =>18 use: <A>(op: string, fn: (s: RawStore) => Promise<A>) =>
14 withServiceLogging(19 withServiceLogging(
15 "user_store",20 `user_store.${op}`,
16 () => new UserStoreError(),21 (failure) => userStoreErrorFromFailure(op, failure),
17 tryPromiseService(() => fn(store)),22 tryPromiseService(() => fn(store)),
18 ),23 ),
19});24});
apps/cloud/src/auth/doc-gate.tsrenamed from apps/cloud/src/auth/ssr-gate.ts+71−23
@@ -1,19 +1,23 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// SSR auth gate — the server-side session check for DOCUMENT requests.2// Document auth gate — the server-side session check for DOCUMENT requests.
3//3//
4// The sealed `wos-session` cookie is verified right here in the worker4// The sealed `wos-session` cookie is verified right here in the worker
5// (unseal + JWT check against cached JWKS — no per-request WorkOS round trip5// (unseal + JWT check against cached JWKS — no per-request WorkOS round trip
6// except token refresh), so by the time the SPA is served the server KNOWS6// except token refresh), so by the time the SPA shell is served the server
7// who it's serving:7// KNOWS who it's serving:
8//8//
9// - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists9// - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists
10// - org-less → 302 /create-org (onboarding owns those sessions)10// - org-less → 302 /create-org (onboarding owns those sessions)
11// - signed in → the document is served WITH the verified identity: the11// - signed in → the prerendered shell is served, and the auth-hint cookie is
12// auth-hint travels to the SSR render via request-middleware context (the12// minted when the browser doesn't hold a current one — the client seeds its
13// root loader picks it up), and is minted as a cookie when the browser13// auth state from that cookie right after mount (there is no per-request
14// doesn't hold a current one — so the very first paint is the real app14// render to dehydrate it into). The hint is display-only; /account/me
15// shell, never a skeleton. The hint is display-only; /account/me remains15// remains the authority and the client keeps it fresh from then on.
16// the authority and the client keeps it fresh from then on.16//
17// Beyond redirects, this gate is load-bearing for SESSION LIFETIME: WorkOS
18// refresh tokens are single-use, and a verify that rotates the sealed session
19// must deliver the new cookie to the browser or the next expiry logs the user
20// out. Document navigations are where that rotation lands.
17//21//
18// Scope: GET/HEAD requests that are document navigations (sec-fetch-dest /22// Scope: GET/HEAD requests that are document navigations (sec-fetch-dest /
19// accept), excluding app-owned paths (/api, /mcp — they answer for themselves23// accept), excluding app-owned paths (/api, /mcp — they answer for themselves
@@ -23,6 +27,7 @@ · 3 unmodified lines
23import { createMiddleware } from "@tanstack/react-start";27import { createMiddleware } from "@tanstack/react-start";
24import { Effect, Exit, Layer, ManagedRuntime } from "effect";28import { Effect, Exit, Layer, ManagedRuntime } from "effect";
25 29
30import { isValidOrgSlug } from "@executor-js/api";
26import {31import {
27 AUTH_HINT_COOKIE,32 AUTH_HINT_COOKIE,
28 AUTH_HINT_MAX_AGE_SECONDS,33 AUTH_HINT_MAX_AGE_SECONDS,
@@ -35,8 +40,9 @@ import { isAppOwnedPath } from "../app-paths"; · 6 unmodified lines
35import { makeDbLayer } from "../db/db";40import { makeDbLayer } from "../db/db";
36import { makeUserStoreLayer, UserStoreService } from "./context";41import { makeUserStoreLayer, UserStoreService } from "./context";
37import { parseCookie } from "./cookies";42import { parseCookie } from "./cookies";
43import { LAST_ORG_COOKIE } from "./last-org-cookie";
38import { sealedSessionDisplayName } from "./middleware";44import { sealedSessionDisplayName } from "./middleware";
39import { browserOriginFromRequest } from "./request-origin";45import { authorizeOrganizationSelector } from "./organization";
40import { loginPath, safeReturnTo } from "./return-to";46import { loginPath, safeReturnTo } from "./return-to";
41import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths";47import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths";
42import { WorkOSClient } from "./workos";48import { WorkOSClient } from "./workos";
@@ -150,7 +156,7 @@ const organizationDisplay = async ( · 107 unmodified lines
150): Promise<{ name: string; slug: string }> => {156): Promise<{ name: string; slug: string }> => {
151 const exit = await getRuntime().runPromiseExit(157 const exit = await getRuntime().runPromiseExit(
152 Effect.flatMap(UserStoreService.asEffect(), (users) =>158 Effect.flatMap(UserStoreService.asEffect(), (users) =>
153 users.use((store) => store.getOrganization(organizationId)),159 users.use("getOrganization", (store) => store.getOrganization(organizationId)),
154 ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))),160 ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))),
155 );161 );
156 return Exit.isSuccess(exit)162 return Exit.isSuccess(exit)
@@ -158,6 +164,23 @@ const organizationDisplay = async ( · 1 unmodified line
158 : { name: "", slug: "" };164 : { name: "", slug: "" };
159};165};
160 166
167// Live membership check for the last-org cookie's slug. Same authorize path
168// as any org selector — the cookie is a preference, so a slug the user can't
169// access (stale after removal/deletion, or forged) resolves to null and the
170// bare path falls through to today's canonicalize-onto-session-org behavior.
171// Per-request store layers for the same reason as organizationDisplay.
172const authorizeLastOrgSlug = async (
173 userId: string,
174 slug: string,
175): Promise<{ readonly id: string } | null> => {
176 const exit = await getRuntime().runPromiseExit(
177 authorizeOrganizationSelector(userId, slug).pipe(
178 Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer())),
179 ),
180 );
181 return Exit.isSuccess(exit) ? exit.value : null;
182};
183
161const hintSetCookie = (hint: AuthHint) =>184const hintSetCookie = (hint: AuthHint) =>
162 `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`;185 `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`;
163 186
@@ -226,19 +249,44 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( · 62 unmodified lines
226 return redirect("/create-org", { refreshedSession: session.refreshedSession });249 return redirect("/create-org", { refreshedSession: session.refreshedSession });
227 }250 }
228 251
229 // Serve the document WITH the verified identity: the hint rides to the252 // A BARE console path (no org slug in the URL) canonicalizes onto the org
230 // SSR render through middleware context (the root loader reads it), so253 // this browser last worked in (the last-org cookie), not the org pinned in
231 // the server paints the real authenticated shell — no loading state, no254 // the session at login time — a multi-org user re-entering at `/` lands
232 // skeleton. The request origin rides along too: it's what the connect255 // where they left off. Slugged URLs never enter here (the URL names its
233 // card's MCP URL is built from, and the server knows it (the SPA only256 // own scope), so this costs nothing on the steady state; the not-found
234 // learns `window.location.origin` after mount), so passing it here lets257 // contract is untouched because an unknown-but-valid slug in the URL reads
235 // SSR render the real `https://…/<org>/mcp` instead of the client-side258 // as slugged, not bare. When the cookie matches the session's own org (the
236 // `http://127.0.0.1:4000` default — which would otherwise flash until259 // overwhelmingly common single-org case) the client-side OrgSlugGate
237 // hydration corrected it. Set-cookie writes ride on the rendered response.260 // already canonicalizes onto it, so skip the live membership check and the
261 // redirect entirely.
262 const lastOrgSlug = parseCookie(cookieHeader, LAST_ORG_COOKIE);
263 const firstSegment = pathname.split("/")[1] ?? "";
264 if (
265 session.organizationId &&
266 lastOrgSlug &&
267 isValidOrgSlug(lastOrgSlug) &&
268 !isValidOrgSlug(firstSegment) &&
269 !ONBOARDING_PATHS.has(pathname)
270 ) {
271 const sessionOrgSlug = (await organizationDisplay(session.organizationId)).slug;
272 if (lastOrgSlug !== sessionOrgSlug) {
273 // The cookie is a preference, not an authority: only redirect onto an
274 // org the caller actively belongs to (stale/forged values fall through
275 // to today's session-org canonicalization).
276 const lastOrg = await authorizeLastOrgSlug(session.userId, lastOrgSlug);
277 if (lastOrg) {
278 return redirect(`/${lastOrgSlug}${pathname === "/" ? "" : pathname}${url.search}`, {
279 refreshedSession: session.refreshedSession,
280 });
281 }
282 }
283 }
284
285 // Serve the shell, minting the auth-hint cookie when the browser lacks a
286 // current one so the client's post-mount cookie read seeds the verified
287 // identity. Set-cookie writes ride on the shell response.
238 const { hint, mint } = await resolveAuthHint(session, cookieHeader);288 const { hint, mint } = await resolveAuthHint(session, cookieHeader);
239 const result = await next({289 const result = await next();
240 context: { authHint: hint, origin: browserOriginFromRequest(request) },
241 });
242 if (!mint && !session.refreshedSession) return result;290 if (!mint && !session.refreshedSession) return result;
243 291
244 const response = new Response(result.response.body, result.response);292 const response = new Response(result.response.body, result.response);
apps/cloud/src/auth/errors.ts+95−2
@@ -1,10 +1,103 @@
1import { Data, Effect, Option, Predicate, Schema } from "effect";1import { Data, Effect, Option, Predicate, Schema } from "effect";
2 2
3// How a user-store call failed, classified from the driver cause. Safe to put
4// on the wire and on a Sentry tag: it names a failure MODE, never a query, a
5// value, or a customer.
6export const USER_STORE_FAILURE_REASONS = [
7 "connect_timeout",
8 "connection_closed",
9 "query",
10 "unknown",
11] as const;
12
13export type UserStoreFailureReason = (typeof USER_STORE_FAILURE_REASONS)[number];
14
15/**
16 * The public failure of every cloud user-store call.
17 *
18 * It carries the two fields that make an issue diagnosable from the error
19 * alone: which store call failed, and how. Before those existed the error had
20 * an empty field set, so Sentry showed a titleless, messageless issue and the
21 * only cause detail (the pretty-printed Effect cause in a Sentry `extra`) is
22 * scrubbed server-side — the failing operation and the driver reason existed
23 * only in the trace store. Same shape as `WorkOSError.status`: a small, safe
24 * classification field threaded at the service boundary.
25 */
3export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()(26export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()(
4 "UserStoreError",27 "UserStoreError",
5 {},28 {
29 /** The store call that failed, e.g. `getOrganization`. */
30 operation: Schema.String,
31 /** How it failed, classified from the driver cause chain. */
32 reason: Schema.Literals(USER_STORE_FAILURE_REASONS),
33 },
6 { httpApiStatus: 500 },34 { httpApiStatus: 500 },
7) {}35) {
36 override get message(): string {
37 return `user store ${this.operation} failed: ${this.reason}`;
38 }
39}
40
41/** Reasons a retry can plausibly clear: the query never reached a healthy
42 * server. A `query` failure is deterministic and must not be retried. */
43export const isTransientUserStoreReason = (reason: UserStoreFailureReason): boolean =>
44 reason === "connect_timeout" || reason === "connection_closed";
45
46// postgres.js tags its connection failures with a string `code`
47// (`CONNECT_TIMEOUT`, `CONNECTION_CLOSED`, …) and its query failures with the
48// SQLSTATE. Drizzle re-throws both wrapped in its own "Failed query" error with
49// the driver error in `.cause`, and the service adapter wraps that again, so
50// the classification walks the chain rather than inspecting one level.
51const MAX_CAUSE_DEPTH = 8;
52
53const REASON_BY_DRIVER_CODE: Readonly<Record<string, UserStoreFailureReason>> = {
54 CONNECT_TIMEOUT: "connect_timeout",
55 ETIMEDOUT: "connect_timeout",
56 CONNECTION_CLOSED: "connection_closed",
57 CONNECTION_ENDED: "connection_closed",
58 CONNECTION_DESTROYED: "connection_closed",
59 ECONNREFUSED: "connection_closed",
60 ECONNRESET: "connection_closed",
61};
62
63const stringCodeOf = (value: unknown): string | undefined => {
64 if (typeof value !== "object" || value === null) return undefined;
65 const code = (value as { readonly code?: unknown }).code;
66 return typeof code === "string" ? code : undefined;
67};
68
69const driverCodesOf = (failure: unknown): readonly string[] => {
70 const codes: string[] = [];
71 let current: unknown = isServiceAdapterError(failure) ? failure.cause : failure;
72 for (
73 let depth = 0;
74 depth < MAX_CAUSE_DEPTH && current !== undefined && current !== null;
75 depth++
76 ) {
77 const code = stringCodeOf(current);
78 if (code !== undefined) codes.push(code);
79 current =
80 typeof current === "object" ? (current as { readonly cause?: unknown }).cause : undefined;
81 }
82 return codes;
83};
84
85/** Classify a raw store failure. A recognised connection code wins; any other
86 * driver code (a SQLSTATE) is a deterministic query failure; nothing at all is
87 * `unknown`. */
88export const userStoreReasonFromCause = (failure: unknown): UserStoreFailureReason => {
89 const codes = driverCodesOf(failure);
90 for (const code of codes) {
91 const reason = REASON_BY_DRIVER_CODE[code];
92 if (reason !== undefined) return reason;
93 }
94 return codes.length > 0 ? "query" : "unknown";
95};
96
97/** Build the public `UserStoreError` for a store-adapter failure, naming the
98 * operation the call site already knows and classifying the driver cause. */
99export const userStoreErrorFromFailure = (operation: string, failure: unknown): UserStoreError =>
100 new UserStoreError({ operation, reason: userStoreReasonFromCause(failure) });
8 101
9export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()(102export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()(
10 "WorkOSError",103 "WorkOSError",
apps/cloud/src/auth/handlers.ts+88−25
@@ -22,11 +22,13 @@ import { env } from "cloudflare:workers"; · 21 unmodified lines
22import { WorkOSError } from "./errors";22import { WorkOSError } from "./errors";
23import { WorkOSClient } from "./workos";23import { WorkOSClient } from "./workos";
24import { AutumnService } from "../extensions/billing/service";24import { AutumnService } from "../extensions/billing/service";
25import { captureCauseEffect } from "../observability";
25import {26import {
26 hasPaidOrganizationSubscription,27 hasPaidOrganizationSubscription,
27 isOverFreeOrganizationLimit,28 isOverFreeOrganizationLimit,
28 shouldApplyFreeOrganizationLimit,29 shouldApplyFreeOrganizationLimit,
29} from "../extensions/billing/plans";30} from "../extensions/billing/plans";
31import { LAST_ORG_COOKIE } from "./last-org-cookie";
30import {32import {
31 ORG_SELECTOR_HEADER,33 ORG_SELECTOR_HEADER,
32 authorizeOrganization,34 authorizeOrganization,
@@ -143,8 +145,8 @@ const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, n · 110 unmodified lines
143 HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS);145 HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS);
144 146
145// ---------------------------------------------------------------------------147// ---------------------------------------------------------------------------
146// Single non-protected API surface — public (login/callback) + session148// Single non-protected API surface — public (login/callback/logout) + session
147// (me/logout/organizations/switch-organization). The session group has SessionAuth on it.149// (me/organizations/switch-organization). The session group has SessionAuth on it.
148// ---------------------------------------------------------------------------150// ---------------------------------------------------------------------------
149 151
150export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi);152export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi);
@@ -201,7 +203,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 50 unmodified lines
201 const result = yield* workos.authenticateWithCode(query.code);203 const result = yield* workos.authenticateWithCode(query.code);
202 204
203 // Mirror the account locally205 // Mirror the account locally
204 yield* users.use((s) => s.ensureAccount(result.user.id));206 yield* users.use("ensureAccount", (s) => s.ensureAccount(result.user.id));
205 207
206 let sealedSession = result.sealedSession;208 let sealedSession = result.sealedSession;
207 209
@@ -218,11 +220,25 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 10 unmodified lines
218 : null;220 : null;
219 221
220 // Prefer the org in the URL that sent the user to login. If the URL222 // Prefer the org in the URL that sent the user to login. If the URL
221 // is bare, or not an org route, fall back to WorkOS's org and then to223 // is bare, or not an org route, prefer the org this browser last
222 // the first active membership for org-less sessions. Pending224 // worked in (the last-org cookie — it outlives the session precisely
223 // memberships are skipped because refreshing into one 400s and would225 // so a fresh login lands where the user left off), then WorkOS's
224 // bypass invite consent.226 // org, then the first active membership for org-less sessions.
225 let targetOrganizationId = requestedOrg?.id ?? result.organizationId ?? null;227 // Pending memberships are skipped because refreshing into one 400s
228 // and would bypass invite consent. The cookie is membership-checked
229 // like any selector, so a stale one just falls through.
230 let targetOrganizationId = requestedOrg?.id ?? null;
231 if (!targetOrganizationId && !requestedOrgSelector) {
232 const lastOrgSlug = request.cookies[LAST_ORG_COOKIE];
233 const lastOrg =
234 lastOrgSlug && isValidOrgSlug(lastOrgSlug)
235 ? yield* authorizeOrganizationSelector(result.user.id, lastOrgSlug).pipe(
236 Effect.orElseSucceed(() => null),
237 )
238 : null;
239 targetOrganizationId = lastOrg?.id ?? null;
240 }
241 targetOrganizationId ??= result.organizationId ?? null;
226 if (!targetOrganizationId && !requestedOrgSelector) {242 if (!targetOrganizationId && !requestedOrgSelector) {
227 const memberships = yield* workos.listUserMemberships(result.user.id);243 const memberships = yield* workos.listUserMemberships(result.user.id);
228 const existingActive = memberships.data.find((m) => m.status === "active");244 const existingActive = memberships.data.find((m) => m.status === "active");
@@ -259,6 +275,42 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 30 unmodified lines
259 );275 );
260 }),276 }),
261 )277 )
278 .handleRaw("logout", ({ request }) =>
279 Effect.gen(function* () {
280 const workos = yield* WorkOSClient;
281 // The session this browser presents, NOT one the middleware vouched
282 // for — signing out of a session that has already ended must still
283 // sign the browser out (see the group declaration in ./api.ts).
284 const sealedSession = request.cookies["wos-session"] ?? "";
285
286 // WorkOS's documented sign-out: send the browser through the WorkOS
287 // logout endpoint, which ends the AuthKit session upstream and then
288 // redirects to the registered sign-out URL. Without this hop, the
289 // hosted session survives and the next "Sign in" silently
290 // re-authenticates (issue #1445). Fail-open when the cookie won't
291 // unseal — there is then nothing to end upstream, and local sign-out
292 // must still complete, so fall back to "/".
293 const origin = env.VITE_PUBLIC_SITE_URL ?? "";
294 const logoutUrl = sealedSession
295 ? yield* workos.logoutUrl(sealedSession, origin ? `${origin}/` : undefined)
296 : null;
297
298 const response = HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 });
299
300 // Drop only what this browser actually presented. Both cookies are
301 // SameSite=Lax, so a cross-site form POST carries neither — it gets
302 // the bare redirect and cannot be used to sign anyone out.
303 if (!sealedSession && request.cookies[AUTH_HINT_COOKIE] === undefined) return response;
304
305 // The auth-hint travels with the session: leaving it behind would
306 // make the next page load optimistically paint the app shell for a
307 // signed-out browser.
308 return deleteResponseCookie(
309 deleteResponseCookie(response, "wos-session"),
310 AUTH_HINT_COOKIE,
311 );
312 }),
313 )
262 // CLI device-login discovery. The WorkOS device endpoints live on the314 // CLI device-login discovery. The WorkOS device endpoints live on the
263 // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production,315 // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production,
264 // the SAME base the SDK uses, so e2e points the CLI at the emulator with316 // the SAME base the SDK uses, so e2e points the CLI at the emulator with
@@ -304,17 +356,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 39 unmodified lines
304 };356 };
305 }),357 }),
306 )358 )
307 .handleRaw("logout", () =>
308 Effect.succeed(
309 // The auth-hint travels with the session: leaving it behind would
310 // make the next page load optimistically paint the app shell for a
311 // signed-out browser.
312 deleteResponseCookie(
313 deleteResponseCookie(HttpServerResponse.redirect("/", { status: 302 }), "wos-session"),
314 AUTH_HINT_COOKIE,
315 ),
316 ),
317 )
318 .handle("organizations", () =>359 .handle("organizations", () =>
319 Effect.gen(function* () {360 Effect.gen(function* () {
320 const workos = yield* WorkOSClient;361 const workos = yield* WorkOSClient;
@@ -375,7 +416,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 54 unmodified lines
375 ),416 ),
376 { concurrency: 3 },417 { concurrency: 3 },
377 ).pipe(418 ).pipe(
378 Effect.catchTag("AutumnError", () => Effect.fail(new WorkOSError())),419 // Any Autumn failure here (outage or missing customer) leaves the
420 // paid/free split unknown, and the limit must fail closed.
421 Effect.mapError(() => new WorkOSError()),
379 Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))),422 Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))),
380 );423 );
381 424
@@ -387,10 +430,28 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 5 unmodified lines
387 const org = yield* workos.createOrganization(name);430 const org = yield* workos.createOrganization(name);
388 yield* workos.createMembership(org.id, session.accountId, "admin");431 yield* workos.createMembership(org.id, session.accountId, "admin");
389 // `upsertOrganization` mints the slug at insert — no separate heal step.432 // `upsertOrganization` mints the slug at insert — no separate heal step.
390 const mirrored = yield* users.use((s) =>433 const mirrored = yield* users.use("upsertOrganization", (s) =>
391 s.upsertOrganization({ id: org.id, name: org.name }),434 s.upsertOrganization({ id: org.id, name: org.name }),
392 );435 );
393 436
437 // Provision the org's billing customer while we're the ones creating
438 // the org. Without this the first billing call an org ever makes is a
439 // non-creating one (balance check / usage track), which 404s and keeps
440 // 404ing — unlimited unbilled executions. Non-fatal: a billing blip
441 // must not block signup, and the billing seam heals a customer that
442 // is still missing later.
443 yield* autumn.ensureCustomer(org.id).pipe(
444 Effect.catch((error) =>
445 Effect.gen(function* () {
446 yield* Effect.logWarning(
447 "createOrganization: could not provision the Autumn customer",
448 { organizationId: org.id, error },
449 );
450 yield* captureCauseEffect(error);
451 }),
452 ),
453 );
454
394 // Try to attach the new org to the current session. This can fail455 // Try to attach the new org to the current session. This can fail
395 // (or silently return a session still scoped to the old org) when456 // (or silently return a session still scoped to the old org) when
396 // the caller's current session is stale — most commonly after the457 // the caller's current session is stale — most commonly after the
@@ -441,7 +502,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 44 unmodified lines
441 502
442 // The typed confirmation must match the org's current name — the same503 // The typed confirmation must match the org's current name — the same
443 // label the settings page shows. Trimmed on both sides.504 // label the settings page shows. Trimmed on both sides.
444 const org = yield* users.use((s) => s.getOrganization(organizationId));505 const org = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId));
445 if (!org || payload.confirmName.trim() !== org.name.trim()) {506 if (!org || payload.confirmName.trim() !== org.name.trim()) {
446 return yield* new OrganizationDeletionForbidden();507 return yield* new OrganizationDeletionForbidden();
447 }508 }
@@ -459,7 +520,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 11 unmodified lines
459 // everyone (unreachable) but its secrets/tenant rows linger orphaned —520 // everyone (unreachable) but its secrets/tenant rows linger orphaned —
460 // alert loudly so that window gets swept, then surface the failure.521 // alert loudly so that window gets swept, then surface the failure.
461 yield* users522 yield* users
462 .use((s) => s.deleteOrganizationCascade(organizationId))523 .use("deleteOrganizationCascade", (s) => s.deleteOrganizationCascade(organizationId))
463 .pipe(524 .pipe(
464 Effect.tapError((error) =>525 Effect.tapError((error) =>
465 Effect.logError(526 Effect.logError(
@@ -475,7 +536,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 9 unmodified lines
475 yield* autumn536 yield* autumn
476 .use((client) => client.customers.delete({ customerId: organizationId }))537 .use((client) => client.customers.delete({ customerId: organizationId }))
477 .pipe(538 .pipe(
478 Effect.catchTag("AutumnError", (error) =>539 // Includes the "customer never existed" answer: nothing to cancel
540 // is a fine outcome for a deleted org, and it is still worth a line.
541 Effect.catch((error) =>
479 Effect.logWarning("deleteOrganization: failed to delete Autumn customer", {542 Effect.logWarning("deleteOrganization: failed to delete Autumn customer", {
480 organizationId,543 organizationId,
481 error,544 error,
@@ -560,7 +623,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 78 unmodified lines
560 // Mirror the org locally so domain tables can FK against it; the623 // Mirror the org locally so domain tables can FK against it; the
561 // upsert mints the slug at insert — no separate heal step.624 // upsert mints the slug at insert — no separate heal step.
562 const org = yield* workos.getOrganization(invitation.organizationId);625 const org = yield* workos.getOrganization(invitation.organizationId);
563 const mirrored = yield* users.use((s) =>626 const mirrored = yield* users.use("upsertOrganization", (s) =>
564 s.upsertOrganization({ id: org.id, name: org.name }),627 s.upsertOrganization({ id: org.id, name: org.name }),
565 );628 );
566 629
apps/cloud/src/auth/jwks-cache.node.test.ts+182−1
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
9 type KeyLike,9 type KeyLike,
10} from "jose";10} from "jose";
11 11
12import { createCachedRemoteJWKSet } from "./jwks-cache";12import { createCachedRemoteJWKSet, type JwksStore, type StoredJwks } from "./jwks-cache";
13 13
14const issuer = "https://test-authkit.example.com";14const issuer = "https://test-authkit.example.com";
15const audience = "client_test_fixture";15const audience = "client_test_fixture";
@@ -65,6 +65,40 @@ const makeFetchHarness = (initialKeys: ReadonlyArray<JWK>): FetchHarness => { · 49 unmodified lines
65 };65 };
66};66};
67 67
68interface StoreHarness extends JwksStore {
69 readonly seed: (stored: StoredJwks) => void;
70 readonly reads: () => number;
71 readonly writes: () => number;
72}
73
74/** Stands in for the Workers Cache API: shared across "isolates", in memory. */
75const makeStoreHarness = (): StoreHarness => {
76 const entries = new Map<string, StoredJwks>();
77 let reads = 0;
78 let writes = 0;
79 return {
80 get: async (url) => {
81 reads++;
82 return entries.get(url.toString()) ?? null;
83 },
84 put: async (url, stored) => {
85 writes++;
86 entries.set(url.toString(), stored);
87 },
88 seed: (stored) => {
89 entries.set(jwksUrl.toString(), stored);
90 },
91 reads: () => reads,
92 writes: () => writes,
93 };
94};
95
96/** A fetch that always fails, standing in for a slow/down key server. */
97const failingFetch: typeof globalThis.fetch = async () => {
98 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test double: `fetch` signals an unreachable key server by rejecting
99 throw new Error("JWKS endpoint unreachable");
100};
101
68describe("createCachedRemoteJWKSet", () => {102describe("createCachedRemoteJWKSet", () => {
69 it("FAILING-WITHOUT-CACHE: N verifications hit JWKS endpoint only once within TTL", async () => {103 it("FAILING-WITHOUT-CACHE: N verifications hit JWKS endpoint only once within TTL", async () => {
70 const kp = await generateRotatableKeypair("k1");104 const kp = await generateRotatableKeypair("k1");
@@ -168,4 +202,151 @@ describe("createCachedRemoteJWKSet", () => { · 97 unmodified lines
168 await jwtVerify(t1, jwks, { issuer, audience });202 await jwtVerify(t1, jwks, { issuer, audience });
169 expect(harness.callCount()).toBe(2);203 expect(harness.callCount()).toBe(2);
170 });204 });
205
206 // -------------------------------------------------------------------------
207 // Cross-isolate store — the cold-isolate path that caused the 2026-08-18
208 // 500s: no module-scope entry, so every verify paid an upstream fetch.
209 // -------------------------------------------------------------------------
210
211 it("a cold isolate serves from the store instead of going upstream", async () => {
212 const kp = await generateRotatableKeypair("k1");
213 const store = makeStoreHarness();
214
215 // First isolate: cold everywhere, so it fetches and populates the store.
216 const warm = makeFetchHarness([kp.publicJwk]);
217 const first = createCachedRemoteJWKSet(jwksUrl, { fetch: warm.fetch, store });
218 const t1 = await sign(kp);
219 await jwtVerify(t1, first, { issuer, audience });
220 expect(warm.callCount()).toBe(1);
221 expect(store.writes()).toBe(1);
222
223 // A brand-new isolate (fresh module scope). If it reaches upstream at all
224 // the fetch fails, so verifying proves the store answered.
225 const second = createCachedRemoteJWKSet(jwksUrl, { fetch: failingFetch, store });
226 const { payload } = await jwtVerify(t1, second, { issuer, audience });
227 expect(payload.sub).toBe("user_test");
228 expect(store.reads()).toBeGreaterThan(0);
229 });
230
231 it("keeps serving the last good keys when the key server is down", async () => {
232 const kp = await generateRotatableKeypair("k1");
233 const harness = makeFetchHarness([kp.publicJwk]);
234 let upstreamUp = true;
235 const flaky: typeof globalThis.fetch = (...args) =>
236 upstreamUp ? harness.fetch(...args) : failingFetch(...args);
237
238 // ttl short enough that the next call is past it, stale window generous.
239 const jwks = createCachedRemoteJWKSet(jwksUrl, {
240 fetch: flaky,
241 store: null,
242 ttlMs: 10,
243 staleMaxMs: 60_000,
244 });
245
246 const token = await sign(kp);
247 await jwtVerify(token, jwks, { issuer, audience });
248
249 upstreamUp = false;
250 await new Promise((r) => setTimeout(r, 20));
251
252 // Past the TTL with a dead upstream: the cached keys still verify.
253 const { payload } = await jwtVerify(token, jwks, { issuer, audience });
254 expect(payload.sub).toBe("user_test");
255 });
256
257 it("stops serving stale keys once the stale window closes", async () => {
258 const kp = await generateRotatableKeypair("k1");
259 const harness = makeFetchHarness([kp.publicJwk]);
260 let upstreamUp = true;
261 const flaky: typeof globalThis.fetch = (...args) =>
262 upstreamUp ? harness.fetch(...args) : failingFetch(...args);
263
264 const jwks = createCachedRemoteJWKSet(jwksUrl, {
265 fetch: flaky,
266 store: null,
267 ttlMs: 5,
268 staleMaxMs: 10,
269 });
270
271 const token = await sign(kp);
272 await jwtVerify(token, jwks, { issuer, audience });
273
274 upstreamUp = false;
275 await new Promise((r) => setTimeout(r, 30));
276
277 // Beyond staleMaxMs the keys are no longer trustworthy — fail, don't
278 // silently honour a key set we can no longer confirm.
279 await expect(jwtVerify(token, jwks, { issuer, audience })).rejects.toThrow();
280 });
281
282 it("attributes background revalidation to fetchCount but not blockingFetchCount", async () => {
283 const kp = await generateRotatableKeypair("k1");
284 const harness = makeFetchHarness([kp.publicJwk]);
285 const jwks = createCachedRemoteJWKSet(jwksUrl, {
286 fetch: harness.fetch,
287 store: null,
288 ttlMs: 10,
289 staleMaxMs: 60_000,
290 });
291
292 const token = await sign(kp);
293 await jwtVerify(token, jwks, { issuer, audience });
294 expect(jwks.inspect().blockingFetchCount).toBe(1);
295
296 await new Promise((r) => setTimeout(r, 20));
297
298 // Past the TTL: served from the stale entry, revalidated behind it. The
299 // caller waited on nothing, so latency must not be attributed to it.
300 const before = jwks.inspect();
301 await jwtVerify(token, jwks, { issuer, audience });
302 const after = jwks.inspect();
303
304 expect(after.blockingFetchCount).toBe(before.blockingFetchCount);
305 expect(after.fetchCount).toBeGreaterThan(before.fetchCount);
306 });
307
308 it("records a cold-isolate store read as a store hit, not a fetch", async () => {
309 const kp = await generateRotatableKeypair("k1");
310 const store = makeStoreHarness();
311 const warm = makeFetchHarness([kp.publicJwk]);
312
313 const first = createCachedRemoteJWKSet(jwksUrl, { fetch: warm.fetch, store });
314 const token = await sign(kp);
315 await jwtVerify(token, first, { issuer, audience });
316
317 const second = createCachedRemoteJWKSet(jwksUrl, { fetch: failingFetch, store });
318 await jwtVerify(token, second, { issuer, audience });
319
320 const stats = second.inspect();
321 expect(stats.storeHitCount).toBe(1);
322 expect(stats.blockingFetchCount).toBe(0);
323 });
324
325 it("does not block a request on revalidation once keys are cached", async () => {
326 const kp = await generateRotatableKeypair("k1");
327 const harness = makeFetchHarness([kp.publicJwk]);
328 let hang = false;
329 const slowAfterFirst: typeof globalThis.fetch = async (...args) => {
330 if (hang) await new Promise((r) => setTimeout(r, 5_000));
331 return harness.fetch(...args);
332 };
333
334 const jwks = createCachedRemoteJWKSet(jwksUrl, {
335 fetch: slowAfterFirst,
336 store: null,
337 ttlMs: 10,
338 staleMaxMs: 60_000,
339 });
340
341 const token = await sign(kp);
342 await jwtVerify(token, jwks, { issuer, audience });
343
344 hang = true;
345 await new Promise((r) => setTimeout(r, 20));
346
347 // The revalidation behind this call hangs for 5s; the verify must not.
348 const startedAt = Date.now();
349 await jwtVerify(token, jwks, { issuer, audience });
350 expect(Date.now() - startedAt).toBeLessThan(1_000);
351 });
171});352});
apps/cloud/src/auth/jwks-cache.ts+256−26
@@ -1,5 +1,5 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// In-memory JWKS cache for MCP JWT verification.2// JWKS cache for JWT verification (session + MCP auth).
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4//4//
5// Cloudflare Workers boot many short-lived isolates. `createRemoteJWKSet`'s5// Cloudflare Workers boot many short-lived isolates. `createRemoteJWKSet`'s
@@ -7,14 +7,32 @@ · 1 unmodified line
7// fetches per hour because each new isolate starts cold. Production p99 for7// fetches per hour because each new isolate starts cold. Production p99 for
8// `mcp.auth.jwt_verify` was 1.7s — almost entirely the JWKS fetch.8// `mcp.auth.jwt_verify` was 1.7s — almost entirely the JWKS fetch.
9//9//
10// This module offers a drop-in `createCachedRemoteJWKSet` that:10// Module-scope memory alone only helps while an isolate stays warm. When
11// `workos.session.local_verify` started missing ~92% of the time (2026-08-18),
12// every miss paid a fresh upstream fetch — p50 3s, and the tail crossed the 5s
13// request timeout, turning a slow key-server into `AbortError` → 500 on every
14// authenticated API route and 503 on `/mcp`. A transient key-server blip must
15// never read as an auth failure, and a cold isolate must not have to go
16// upstream at all.
11//17//
12// * Caches the JSON Web Key Set in module-scope memory for a configurable18// So this module layers three caches, fastest first:
13// TTL (default 1 hour).19//
14// * Single-flights concurrent fetches so a stampede of verifies during a20// 1. Module-scope memory — free, but dies with the isolate.
15// cache miss only fires one upstream request.21// 2. A cross-isolate store (the Workers Cache API by default) — colo-local,
16// * Force-refreshes once when verification fails with a cached key, so22// survives isolate recycling, so a cold isolate reads keys in ~1ms
17// genuine key rotation isn't blocked by the TTL.23// instead of paying an upstream round trip.
24// 3. The upstream JWKS endpoint.
25//
26// On top of that it is stale-while-revalidate: once past `ttlMs` a usable key
27// set is served immediately and refreshed in the background, and if a refresh
28// fails we keep serving the last good keys until `staleMaxMs`. Only a fully
29// cold path (no memory, no store) ever blocks on the network.
30//
31// Serving stale keys is safe in a way that serving a stale *token* would not
32// be: key sets rotate on the order of days, tokens are still signature- and
33// expiry-checked against them, and a token whose `kid` is absent forces a real
34// refresh before it is rejected (see `get`). `staleMaxMs` bounds how long a
35// retired key can still be honoured.
18//36//
19// The returned function is a `JWTVerifyGetKey` and slots directly into37// The returned function is a `JWTVerifyGetKey` and slots directly into
20// `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can38// `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can
@@ -32,6 +50,21 @@ import { · 11 unmodified lines
32import { Schema } from "effect";50import { Schema } from "effect";
33import { JWKSNoMatchingKey } from "jose/errors";51import { JWKSNoMatchingKey } from "jose/errors";
34 52
53/**
54 * A cross-isolate key-set store. Defaults to the Workers Cache API; tests and
55 * non-Workers hosts pass their own, or `null` to stay memory-only.
56 */
57export interface JwksStore {
58 readonly get: (url: URL) => Promise<StoredJwks | null>;
59 readonly put: (url: URL, stored: StoredJwks) => Promise<void>;
60}
61
62export interface StoredJwks {
63 readonly jwks: JSONWebKeySet;
64 /** Epoch ms of the upstream fetch these keys came from. */
65 readonly fetchedAt: number;
66}
67
35export interface CachedRemoteJWKSetOptions {68export interface CachedRemoteJWKSetOptions {
36 /**69 /**
37 * How long a successful fetch is considered fresh. Defaults to 1 hour —70 * How long a successful fetch is considered fresh. Defaults to 1 hour —
@@ -39,20 +72,56 @@ export interface CachedRemoteJWKSetOptions { · 1 unmodified line
39 * failure handles unscheduled rotations.72 * failure handles unscheduled rotations.
40 */73 */
41 readonly ttlMs?: number;74 readonly ttlMs?: number;
75 /**
76 * How long past `ttlMs` a key set may still be served while refreshes are
77 * failing. Defaults to 24h — long enough to ride out a key-server outage,
78 * short enough to bound how long a retired key stays honoured.
79 */
80 readonly staleMaxMs?: number;
42 /** Override the fetch implementation for tests. */81 /** Override the fetch implementation for tests. */
43 readonly fetch?: typeof globalThis.fetch;82 readonly fetch?: typeof globalThis.fetch;
44 /** HTTP request timeout. Defaults to 5s, matching jose. */83 /** HTTP request timeout. Defaults to 5s, matching jose. */
45 readonly timeoutMs?: number;84 readonly timeoutMs?: number;
85 /**
86 * Cross-isolate store. Defaults to the Workers Cache API when available,
87 * `null` disables the layer (memory-only).
88 */
89 readonly store?: JwksStore | null;
46}90}
47 91
48export interface CachedRemoteJWKSet extends JWTVerifyGetKey {92export interface CachedRemoteJWKSet extends JWTVerifyGetKey {
49 /** Drop the cached JWKS so the next call refetches. */93 /** Drop the cached JWKS so the next call refetches. */
50 readonly forceRefresh: () => void;94 readonly forceRefresh: () => void;
51 /** Inspect the current cache state (testing/diagnostics). */95 /**
52 readonly inspect: () => { fetchedAt: number | null; hasJwks: boolean };96 * Inspect the current cache state (testing/diagnostics/span annotation).
97 * `fetchCount`/`fetchFailureCount` are lifetime counters for this resolver
98 * instance; callers snapshot them around a verify to tell a cache hit from
99 * a live upstream fetch (the Aug 2026 latency regression was exactly this
100 * cache silently missing on ~92% of verifies, invisible in traces).
101 *
102 * `blockingFetchCount` counts only fetches a verify actually WAITED on.
103 * Under stale-while-revalidate `fetchCount` also moves for background
104 * revalidation the caller never paid for, so latency attribution wants
105 * `blockingFetchCount`. `storeHitCount` counts cold-isolate reads answered
106 * by the cross-isolate store instead of going upstream.
107 */
108 readonly inspect: () => {
109 fetchedAt: number | null;
110 hasJwks: boolean;
111 fetchCount: number;
112 fetchFailureCount: number;
113 blockingFetchCount: number;
114 storeHitCount: number;
115 lastFetchDurationMs: number | null;
116 /** Wall-clock of the last cross-isolate store read (I/O). */
117 lastStoreReadMs: number | null;
118 /** Wall-clock of the last key resolution — WebCrypto `importKey`. */
119 lastResolveMs: number | null;
120 };
53}121}
54 122
55const DEFAULT_TTL_MS = 60 * 60 * 1000;123const DEFAULT_TTL_MS = 60 * 60 * 1000;
124const DEFAULT_STALE_MAX_MS = 24 * 60 * 60 * 1000;
56const DEFAULT_TIMEOUT_MS = 5000;125const DEFAULT_TIMEOUT_MS = 5000;
57 126
58const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown);127const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown);
@@ -73,6 +142,26 @@ interface CacheEntry { · 14 unmodified lines
73 resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise<KeyLike>;142 resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise<KeyLike>;
74}143}
75 144
145const entryFrom = (stored: StoredJwks): CacheEntry => ({
146 jwks: stored.jwks,
147 fetchedAt: stored.fetchedAt,
148 resolver: createLocalJWKSet(stored.jwks),
149});
150
151/**
152 * Cache upkeep (store writes, background revalidation) is best effort: it must
153 * never fail or delay the verify that happened to trigger it.
154 */
155const ignoreFailure = async (work: Promise<unknown>): Promise<void> => {
156 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort cache upkeep must not surface as a verify failure
157 try {
158 await work;
159 } catch {
160 // Deliberately swallowed — the caller already has usable keys, or will
161 // take the upstream path on its next miss.
162 }
163};
164
76const fetchJwksOnce = async (165const fetchJwksOnce = async (
77 url: URL,166 url: URL,
78 fetchImpl: typeof globalThis.fetch,167 fetchImpl: typeof globalThis.fetch,
@@ -107,17 +196,74 @@ const fetchJwksOnce = async ( · 28 unmodified lines
107 }196 }
108};197};
109 198
199// ---------------------------------------------------------------------------
200// Workers Cache API store
201// ---------------------------------------------------------------------------
202//
203// Keyed by the JWKS URL itself. We store our own Response rather than the
204// upstream one so the body is already-validated JSON and the cache headers are
205// ours: `max-age` covers the stale window, because an entry past `ttlMs` is
206// still useful to us as a stale fallback.
207
208const STORE_HEADER_FETCHED_AT = "x-jwks-fetched-at";
209
210/** `caches.default` is a Workers extension the standard lib type omits. */
211type WorkersCacheStorage = CacheStorage & { readonly default?: Cache };
212
213const workersCacheStore = (staleMaxMs: number): JwksStore | null => {
214 if (typeof caches === "undefined") return null;
215 const open = (): Cache | null => (caches as WorkersCacheStorage).default ?? null;
216
217 return {
218 get: async (url) => {
219 const cache = open();
220 if (!cache) return null;
221 const hit = await cache.match(url.toString());
222 if (!hit) return null;
223 const fetchedAtHeader = hit.headers.get(STORE_HEADER_FETCHED_AT);
224 const fetchedAt = fetchedAtHeader === null ? Number.NaN : Number(fetchedAtHeader);
225 if (!Number.isFinite(fetchedAt)) return null;
226 const body = await hit.json();
227 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a corrupt cache entry must degrade to a miss, never fail the verify
228 try {
229 await decodeJsonWebKeySetPayload(body);
230 } catch {
231 return null;
232 }
233 return { jwks: body as JSONWebKeySet, fetchedAt };
234 },
235 put: async (url, stored) => {
236 const cache = open();
237 if (!cache) return;
238 const maxAgeSeconds = Math.max(1, Math.floor(staleMaxMs / 1000));
239 await cache.put(
240 url.toString(),
241 new Response(JSON.stringify(stored.jwks), {
242 status: 200,
243 headers: {
244 "content-type": "application/json",
245 "cache-control": `max-age=${maxAgeSeconds}`,
246 [STORE_HEADER_FETCHED_AT]: String(stored.fetchedAt),
247 },
248 }),
249 );
250 },
251 };
252};
253
110/**254/**
111 * Creates a cached, single-flight, force-refreshable JWKS resolver compatible255 * Creates a cached, single-flight, force-refreshable JWKS resolver compatible
112 * with `jose.jwtVerify`. Drop-in replacement for `createRemoteJWKSet` for the256 * with `jose.jwtVerify`. Drop-in replacement for `createRemoteJWKSet` for the
113 * MCP auth path — see module header for why we don't just use jose's built-in.257 * auth paths — see module header for why we don't just use jose's built-in.
114 */258 */
115export const createCachedRemoteJWKSet = (259export const createCachedRemoteJWKSet = (
116 url: URL,260 url: URL,
117 options: CachedRemoteJWKSetOptions = {},261 options: CachedRemoteJWKSetOptions = {},
118): CachedRemoteJWKSet => {262): CachedRemoteJWKSet => {
119 const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;263 const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
264 const staleMaxMs = Math.max(options.staleMaxMs ?? DEFAULT_STALE_MAX_MS, ttlMs);
120 const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;265 const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
266 const store = options.store === undefined ? workersCacheStore(staleMaxMs) : options.store;
121 // Capture the fetch impl lazily so consumers can swap globalThis.fetch267 // Capture the fetch impl lazily so consumers can swap globalThis.fetch
122 // (tests do this) without us snapshotting a stale reference.268 // (tests do this) without us snapshotting a stale reference.
123 const fetchImpl = (): typeof globalThis.fetch =>269 const fetchImpl = (): typeof globalThis.fetch =>
@@ -125,39 +271,116 @@ export const createCachedRemoteJWKSet = ( · 1 unmodified line
125 271
126 let entry: CacheEntry | null = null;272 let entry: CacheEntry | null = null;
127 let inflight: Promise<CacheEntry> | null = null;273 let inflight: Promise<CacheEntry> | null = null;
274 let fetchCount = 0;
275 let fetchFailureCount = 0;
276 let blockingFetchCount = 0;
277 let storeHitCount = 0;
278 let lastFetchDurationMs: number | null = null;
279 let lastStoreReadMs: number | null = null;
280 let lastResolveMs: number | null = null;
281
282 const isFresh = (candidate: CacheEntry): boolean => Date.now() - candidate.fetchedAt < ttlMs;
283 const isUsable = (candidate: CacheEntry): boolean =>
284 Date.now() - candidate.fetchedAt < staleMaxMs;
128 285
129 const refresh = (): Promise<CacheEntry> => {286 const refresh = (): Promise<CacheEntry> => {
130 if (inflight) return inflight;287 if (inflight) return inflight;
288 const startedAt = Date.now();
289 fetchCount += 1;
131 inflight = (async () => {290 inflight = (async () => {
132 const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs);291 const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs);
133 const next: CacheEntry = {292 const next = entryFrom({ jwks, fetchedAt: Date.now() });
134 jwks,
135 fetchedAt: Date.now(),
136 resolver: createLocalJWKSet(jwks),
137 };
138 entry = next;293 entry = next;
294 if (store) {
295 await ignoreFailure(store.put(url, { jwks: next.jwks, fetchedAt: next.fetchedAt }));
296 }
139 return next;297 return next;
140 })().finally(() => {298 })()
141 inflight = null;299 .then(
142 });300 (next) => next,
301 (error: unknown) => {
302 fetchFailureCount += 1;
303 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: counting a fetch failure must preserve the original rejection for jose
304 throw error;
305 },
306 )
307 .finally(() => {
308 lastFetchDurationMs = Date.now() - startedAt;
309 inflight = null;
310 });
143 return inflight;311 return inflight;
144 };312 };
145 313
146 const ensureFresh = async (forceRefresh: boolean): Promise<CacheEntry> => {314 /** Fire-and-forget revalidation behind a stale hit. */
147 if (forceRefresh) return refresh();315 const refreshInBackground = (): void => {
148 if (entry && Date.now() - entry.fetchedAt < ttlMs) return entry;316 void ignoreFailure(refresh());
317 };
318
319 const loadFromStore = async (): Promise<CacheEntry | null> => {
320 if (!store) return null;
321 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the L2 store is an optimization; any failure degrades to an upstream fetch
322 try {
323 const storeReadStartedAt = Date.now();
324 const stored = await store.get(url);
325 lastStoreReadMs = Date.now() - storeReadStartedAt;
326 if (!stored) return null;
327 const candidate = entryFrom(stored);
328 if (!isUsable(candidate)) return null;
329 entry = candidate;
330 storeHitCount += 1;
331 return candidate;
332 } catch {
333 return null;
334 }
335 };
336
337 /** A refresh the caller waits on — the only kind that costs it latency. */
338 const refreshBlocking = (): Promise<CacheEntry> => {
339 blockingFetchCount += 1;
149 return refresh();340 return refresh();
150 };341 };
151 342
343 const ensureFresh = async (forceRefresh: boolean): Promise<CacheEntry> => {
344 if (forceRefresh) return refreshBlocking();
345 if (entry && isFresh(entry)) return entry;
346
347 // Memory is stale but still usable: serve it now, revalidate behind it.
348 if (entry && isUsable(entry)) {
349 refreshInBackground();
350 return entry;
351 }
352
353 // Cold isolate — the L2 store saves us the upstream round trip.
354 const stored = await loadFromStore();
355 if (stored) {
356 if (!isFresh(stored)) refreshInBackground();
357 return stored;
358 }
359
360 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a failed refresh must fall back to stale keys rather than fail the verify
361 try {
362 return await refreshBlocking();
363 } catch (error) {
364 // Upstream is slow or down. Last good keys beat failing every request.
365 if (entry && isUsable(entry)) return entry;
366 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: nothing usable is cached, so the upstream failure is the real answer
367 throw error;
368 }
369 };
370
152 const get: JWTVerifyGetKey = async (protectedHeader, token) => {371 const get: JWTVerifyGetKey = async (protectedHeader, token) => {
153 const current = await ensureFresh(false);372 const current = await ensureFresh(false);
154 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures373 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures
155 try {374 try {
156 return await current.resolver(protectedHeader, token);375 const resolveStartedAt = Date.now();
376 const key = await current.resolver(protectedHeader, token);
377 lastResolveMs = Date.now() - resolveStartedAt;
378 return key;
157 } catch (error) {379 } catch (error) {
158 // Likely cause: keys rotated upstream after our TTL window started.380 // Likely cause: keys rotated upstream after our TTL window started, or
159 // Refetch once and try again. Anything still failing bubbles up so381 // we answered from a stale entry. Refetch once and try again. Anything
160 // jose can classify it (we do not silently swallow real failures).382 // still failing bubbles up so jose can classify it (we do not silently
383 // swallow real failures).
161 if (!isJwksNoMatchingKey(error)) {384 if (!isJwksNoMatchingKey(error)) {
162 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey requires preserving upstream resolver rejection385 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey requires preserving upstream resolver rejection
163 throw error;386 throw error;
@@ -177,6 +400,13 @@ export const createCachedRemoteJWKSet = ( · 13 unmodified lines
177 value: () => ({400 value: () => ({
178 fetchedAt: entry?.fetchedAt ?? null,401 fetchedAt: entry?.fetchedAt ?? null,
179 hasJwks: entry !== null,402 hasJwks: entry !== null,
403 fetchCount,
404 fetchFailureCount,
405 blockingFetchCount,
406 storeHitCount,
407 lastFetchDurationMs,
408 lastStoreReadMs,
409 lastResolveMs,
180 }),410 }),
181 });411 });
182 return result;412 return result;
apps/cloud/src/auth/last-org-cookie.ts+32−0
@@ -0,0 +1,32 @@
1// ---------------------------------------------------------------------------
2// Last-visited-org cookie — the browser's "which org was I working in" memory.
3//
4// Org switching is stateless by design (the URL slug scopes every request; see
5// organization.ts), which leaves nothing that remembers the last org across
6// entries: the sealed session still carries the org pinned at LOGIN time, so a
7// bare URL (`executor.sh/`) would always canonicalize onto that first org. This
8// cookie fills the gap: the client records the slug of the org it's verifiably
9// viewing, and the two bare-entry deciders honor it —
10//
11// - the document auth gate redirects bare document paths onto it (doc-gate.ts)
12// - the login callback prefers it when picking the org for a fresh session
13// with a bare returnTo (handlers.ts)
14//
15// It is a PREFERENCE, never an authority: both readers re-check live membership
16// through the same authorize path as any org selector, so a stale or forged
17// value at worst falls back to today's behavior. Not HttpOnly — the client is
18// the writer. Deliberately NOT cleared on logout: surviving the session is what
19// lets the next login land on the last-used org.
20// ---------------------------------------------------------------------------
21
22export const LAST_ORG_COOKIE = "executor-last-org";
23
24/** Outlives the 7d session on purpose — it spans logins. */
25const LAST_ORG_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
26
27/** Browser-side write. Slugs are `[a-z0-9-]` by grammar, so no encoding. */
28export const writeLastOrgCookie = (slug: string): void => {
29 document.cookie = `${LAST_ORG_COOKIE}=${slug}; Path=/; Max-Age=${LAST_ORG_MAX_AGE_SECONDS}; SameSite=Lax${
30 window.location.protocol === "https:" ? "; Secure" : ""
31 }`;
32};
apps/cloud/src/auth/org-api-key-auth.node.test.ts+159−0
@@ -0,0 +1,159 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import { ApiKeyService } from "./api-keys";
5import { UserStoreService } from "./context";
6import { WorkOSClient, type WorkOSClientService } from "./workos";
7import { isPlatformAuth, resolveApiKeyPrincipal, resolveBearerAuth } from "./workos-auth-provider";
8
9// Groundwork for the PRIVILEGED, org-level API key: it resolves to the platform
10// view (tenant-wide, read-only, NO acting member) rather than to a member
11// `Principal`. The product surfaces must reject it outright rather than
12// inventing a subject for it — that is the property these tests pin.
13
14const createdAt = new Date("2026-01-01T00:00:00.000Z");
15
16const stubApiKeys = Layer.succeed(ApiKeyService)({
17 validate: (value: string) => {
18 if (value === "valid_org_key") {
19 return Effect.succeed({
20 scope: "org" as const,
21 accountId: null,
22 organizationId: "org_123",
23 keyId: "api_key_org",
24 });
25 }
26 if (value === "valid_user_key") {
27 return Effect.succeed({
28 scope: "user" as const,
29 accountId: "user_123",
30 organizationId: "org_123",
31 keyId: "api_key_123",
32 });
33 }
34 return Effect.succeed(null);
35 },
36 listUserKeys: () => Effect.succeed([]),
37 createUserKey: () => Effect.die("org api key test does not create API keys"),
38 revokeUserKey: () => Effect.void,
39 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
40 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
41 revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
42});
43
44const stubWorkOS = Layer.succeed(
45 WorkOSClient,
46 new Proxy({} as WorkOSClientService, {
47 get: (_target, prop) => {
48 if (prop === "listUserMemberships") {
49 return (userId: string) =>
50 Effect.succeed({
51 data:
52 userId === "user_123"
53 ? [{ userId, organizationId: "org_123", status: "active" }]
54 : [],
55 });
56 }
57 // An org key must NOT trigger a membership check — there is no user to
58 // check. Any such call dies here, which is the assertion.
59 return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
60 },
61 }),
62);
63
64const stubUsers = Layer.succeed(UserStoreService)({
65 use: (_op, fn) =>
66 Effect.promise(() =>
67 fn({
68 ensureAccount: async (id: string) => ({ id, createdAt }),
69 getAccount: async (id: string) => ({ id, createdAt }),
70 upsertOrganization: async (org: { id: string; name: string }) => ({
71 ...org,
72 slug: `org-slug-${org.id}`,
73 createdAt,
74 }),
75 getOrganization: async (id: string) => ({
76 id,
77 name: `Org ${id}`,
78 slug: `org-slug-${id}`,
79 createdAt,
80 }),
81 getOrganizationBySlug: async (slug: string) => ({
82 id: "org_by_slug",
83 name: `Org ${slug}`,
84 slug,
85 createdAt,
86 }),
87 deleteOrganizationCascade: async () => {},
88 }),
89 ),
90});
91
92const layers = Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers);
93
94const bearer = (token: string) =>
95 new Request("https://executor.test/api/tools", {
96 headers: { authorization: `Bearer ${token}` },
97 });
98
99describe("org-level API keys", () => {
100 it.effect("resolve to the platform view with no acting member", () =>
101 Effect.gen(function* () {
102 const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers));
103
104 expect(isPlatformAuth(auth)).toBe(true);
105 expect(auth).toEqual({
106 kind: "platform",
107 organizationId: "org_123",
108 organizationName: "Org org_123",
109 organizationSlug: "org-slug-org_123",
110 keyId: "api_key_org",
111 });
112 // No `accountId` anywhere in the shape: nothing downstream can bind a
113 // subject from it by accident.
114 expect(auth).not.toHaveProperty("accountId");
115 }),
116 );
117
118 it.effect("user keys still resolve to a bound member principal", () =>
119 Effect.gen(function* () {
120 const auth = yield* resolveBearerAuth(bearer("valid_user_key")).pipe(Effect.provide(layers));
121
122 expect(isPlatformAuth(auth)).toBe(false);
123 expect(auth).toMatchObject({ accountId: "user_123", organizationId: "org_123" });
124 }),
125 );
126
127 it.effect("resolve on the product surface as a platform principal, never a subject", () =>
128 Effect.gen(function* () {
129 // THE security property of the api-key half, updated for platform reads:
130 // a product request carrying an org key resolves to the NEUTRAL platform
131 // shape — which the shared middleware routes to the subject-less,
132 // GET-only platform executor — and must never carry an accountId a
133 // handler could bind as an acting member.
134 const principal = yield* resolveApiKeyPrincipal(bearer("valid_org_key")).pipe(
135 Effect.provide(layers),
136 );
137
138 expect(principal).toEqual({
139 kind: "platform",
140 organizationId: "org_123",
141 organizationName: "Org org_123",
142 organizationSlug: "org-slug-org_123",
143 keyId: "api_key_org",
144 });
145 expect(principal).not.toHaveProperty("accountId");
146 }),
147 );
148
149 it.effect("do not trigger a user membership check", () =>
150 Effect.gen(function* () {
151 // `authorizeOrganization` checks a USER's live membership; there is no
152 // user here. The WorkOS stub dies on any call other than the user path,
153 // so a clean resolution proves the org branch never took it.
154 const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers));
155
156 expect(isPlatformAuth(auth)).toBe(true);
157 }),
158 );
159});
apps/cloud/src/auth/org-api-key-mint.node.test.ts+316−0
@@ -0,0 +1,316 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import { ApiKeyService, OrgApiKeyNotFound } from "./api-keys";
5import { ApiKeyManagementError } from "./errors";
6import { WorkOSClient, type WorkOSClientService } from "./workos";
7
8// ---------------------------------------------------------------------------
9// Minting, listing, and revoking ORGANIZATION-owned api keys — the privileged
10// credential that resolves to the read-only platform view (`/admin/*`).
11//
12// The decode boundary is what these pin. WorkOS returns an org-owned key with
13// the ORG in `owner.id` and no member at all, which is a different shape from
14// the user-owned keys the console's personal-key CRUD handles. The workspace
15// api key is also workspace-WIDE, so a response naming a different org than the
16// one asked for must not be surfaced as that org's key.
17//
18// NOTE (emulator gap): the WorkOS emulator's `serializeApiKey` hardcodes
19// `owner.type: "user"` and exposes no org-key mint route, so this path cannot
20// be exercised over the wire in e2e today. These stubs encode the REAL WorkOS
21// API shape (`organizations.createOrganizationApiKey` /
22// `listOrganizationApiKeys`, per the installed SDK's types).
23// ---------------------------------------------------------------------------
24
25const ORG = "org_123";
26
27const orgKeyResponse = (organizationId: string, id: string, name: string) => ({
28 object: "api_key",
29 id,
30 owner: { type: "organization", id: organizationId },
31 name,
32 obfuscatedValue: "sk_org…a1b2",
33 lastUsedAt: null,
34 permissions: [],
35 createdAt: "2026-01-01T00:00:00.000Z",
36 updatedAt: "2026-01-01T00:00:00.000Z",
37});
38
39const stubWorkOS = (overrides: Record<string, unknown> = {}) =>
40 Layer.succeed(
41 WorkOSClient,
42 new Proxy({} as WorkOSClientService, {
43 get: (_target, prop) => {
44 const override = overrides[String(prop)];
45 if (override) return override;
46 return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
47 },
48 }),
49 );
50
51const serviceWith = (overrides: Record<string, unknown>) =>
52 ApiKeyService.asEffect().pipe(
53 Effect.provide(ApiKeyService.WorkOS.pipe(Layer.provide(stubWorkOS(overrides)))),
54 );
55
56describe("organization API keys", () => {
57 it.effect("mints an org-owned key and returns the one-time secret", () =>
58 Effect.gen(function* () {
59 const apiKeys = yield* serviceWith({
60 createOrgApiKey: (params: { organizationId: string; name: string }) =>
61 Effect.succeed({
62 ...orgKeyResponse(params.organizationId, "key_org_1", params.name),
63 value: "sk_org_plaintext",
64 }),
65 });
66
67 const created = yield* apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" });
68
69 expect(created.id).toBe("key_org_1");
70 expect(created.name).toBe("acme-backend");
71 expect(created.value, "create returns the one-time plaintext secret").toBe(
72 "sk_org_plaintext",
73 );
74 expect(created.obfuscatedValue, "the display value is masked, not the secret").not.toBe(
75 created.value,
76 );
77 }),
78 );
79
80 it.effect("lists the organization's own keys, masked", () =>
81 Effect.gen(function* () {
82 const apiKeys = yield* serviceWith({
83 listOrgApiKeys: (organizationId: string) =>
84 Effect.succeed({
85 object: "list",
86 data: [
87 orgKeyResponse(organizationId, "key_org_1", "acme-backend"),
88 orgKeyResponse(organizationId, "key_org_2", "ci"),
89 ],
90 listMetadata: { before: null, after: null },
91 }),
92 });
93
94 const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG });
95
96 expect(keys.map((key) => key.name)).toEqual(["acme-backend", "ci"]);
97 expect(JSON.stringify(keys), "the list never carries a plaintext secret").not.toContain(
98 "sk_org_plaintext",
99 );
100 }),
101 );
102
103 it.effect("drops a key owned by a DIFFERENT organization", () =>
104 Effect.gen(function* () {
105 // The WorkOS workspace api key is workspace-wide, so a response naming
106 // another org must never be surfaced as this org's key.
107 const apiKeys = yield* serviceWith({
108 listOrgApiKeys: () =>
109 Effect.succeed({
110 object: "list",
111 data: [
112 orgKeyResponse(ORG, "key_mine", "mine"),
113 orgKeyResponse("org_other", "key_theirs", "theirs"),
114 ],
115 listMetadata: { before: null, after: null },
116 }),
117 });
118
119 const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG });
120
121 expect(
122 keys.map((key) => key.id),
123 "only this org's key survives",
124 ).toEqual(["key_mine"]);
125 }),
126 );
127
128 it.effect("refuses a create response that names another organization", () =>
129 Effect.gen(function* () {
130 const apiKeys = yield* serviceWith({
131 createOrgApiKey: () =>
132 Effect.succeed({
133 ...orgKeyResponse("org_other", "key_theirs", "theirs"),
134 value: "sk_org_plaintext",
135 }),
136 });
137
138 const error = yield* Effect.flip(
139 apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" }),
140 );
141
142 expect(error).toBeInstanceOf(ApiKeyManagementError);
143 }),
144 );
145
146 it.effect("ignores a user-owned key in an org-key listing", () =>
147 Effect.gen(function* () {
148 // The org and user shapes are decoded separately on purpose; a user key
149 // appearing here would mean the caller could mistake a member's personal
150 // credential for a workspace one.
151 const apiKeys = yield* serviceWith({
152 listOrgApiKeys: () =>
153 Effect.succeed({
154 object: "list",
155 data: [
156 orgKeyResponse(ORG, "key_mine", "mine"),
157 {
158 object: "api_key",
159 id: "key_user",
160 owner: { type: "user", id: "user_1", organization_id: ORG },
161 name: "personal",
162 obfuscatedValue: "sk_user…c3d4",
163 lastUsedAt: null,
164 createdAt: "2026-01-01T00:00:00.000Z",
165 updatedAt: "2026-01-01T00:00:00.000Z",
166 },
167 ],
168 listMetadata: { before: null, after: null },
169 }),
170 });
171
172 const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG });
173
174 expect(
175 keys.map((key) => key.id),
176 "a user-owned key is not an org key",
177 ).toEqual(["key_mine"]);
178 }),
179 );
180});
181
182// ---------------------------------------------------------------------------
183// Revoking an org key. The mint is only half a credential lifecycle: an org key
184// reads every user and connection in the tenant, so it has to be destroyable.
185//
186// Revoke cannot go through `revokeUserKey`/`revokeApiKey` — that path resolves
187// ownership against the CALLER's personal keys, so an org key id is never in
188// its owned set. It also cannot just call `deleteApiKey` directly: that route is
189// scope-blind and would happily destroy a member's personal key, or a key
190// belonging to a sibling org in the same WorkOS workspace. So ownership is
191// resolved against the ORG's decoded listing first, and that filter IS the
192// authorization. These pin that filter.
193// ---------------------------------------------------------------------------
194
195describe("revoking an organization API key", () => {
196 const listing = (...keys: ReadonlyArray<unknown>) => ({
197 object: "list",
198 data: keys,
199 listMetadata: { before: null, after: null },
200 });
201
202 it.effect("revokes a key the organization owns", () =>
203 Effect.gen(function* () {
204 const deleted: string[] = [];
205 const apiKeys = yield* serviceWith({
206 listOrgApiKeys: (organizationId: string) =>
207 Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))),
208 deleteApiKey: (id: string) =>
209 Effect.sync(() => {
210 deleted.push(id);
211 }),
212 });
213
214 yield* apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" });
215
216 expect(deleted, "the delete reaches WorkOS exactly once, for that key").toEqual([
217 "key_org_1",
218 ]);
219 }),
220 );
221
222 it.effect("refuses a USER-owned key id, without deleting anything", () =>
223 Effect.gen(function* () {
224 // The regression this guards: a member's personal credential must not be
225 // destroyable through the org route just because an admin can call it.
226 const deleted: string[] = [];
227 const apiKeys = yield* serviceWith({
228 listOrgApiKeys: () =>
229 Effect.succeed(
230 listing(orgKeyResponse(ORG, "key_org_1", "acme-backend"), {
231 object: "api_key",
232 id: "key_user",
233 owner: { type: "user", id: "user_1", organization_id: ORG },
234 name: "personal",
235 obfuscatedValue: "sk_user…c3d4",
236 lastUsedAt: null,
237 createdAt: "2026-01-01T00:00:00.000Z",
238 updatedAt: "2026-01-01T00:00:00.000Z",
239 }),
240 ),
241 deleteApiKey: (id: string) =>
242 Effect.sync(() => {
243 deleted.push(id);
244 }),
245 });
246
247 const error = yield* Effect.flip(
248 apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_user" }),
249 );
250
251 expect(error).toBeInstanceOf(OrgApiKeyNotFound);
252 expect(deleted, "no delete is issued for a key the org does not own").toEqual([]);
253 }),
254 );
255
256 it.effect("refuses a key belonging to ANOTHER organization", () =>
257 Effect.gen(function* () {
258 // The WorkOS workspace key is workspace-wide, so an admin of one org must
259 // not be able to revoke a sibling org's credential by id.
260 const deleted: string[] = [];
261 const apiKeys = yield* serviceWith({
262 listOrgApiKeys: () =>
263 Effect.succeed(
264 listing(
265 orgKeyResponse(ORG, "key_mine", "mine"),
266 orgKeyResponse("org_other", "key_theirs", "theirs"),
267 ),
268 ),
269 deleteApiKey: (id: string) =>
270 Effect.sync(() => {
271 deleted.push(id);
272 }),
273 });
274
275 const error = yield* Effect.flip(
276 apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_theirs" }),
277 );
278
279 expect(error).toBeInstanceOf(OrgApiKeyNotFound);
280 expect(deleted).toEqual([]);
281 }),
282 );
283
284 it.effect("refuses an unknown key id", () =>
285 Effect.gen(function* () {
286 const apiKeys = yield* serviceWith({
287 listOrgApiKeys: () => Effect.succeed(listing()),
288 deleteApiKey: () => Effect.die("nothing should be deleted"),
289 });
290
291 const error = yield* Effect.flip(
292 apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_nope" }),
293 );
294
295 expect(error).toBeInstanceOf(OrgApiKeyNotFound);
296 }),
297 );
298
299 it.effect("surfaces an upstream delete failure as a management error", () =>
300 Effect.gen(function* () {
301 // A failed delete must not read as a successful revoke — the console
302 // would tell an admin a live credential is gone.
303 const apiKeys = yield* serviceWith({
304 listOrgApiKeys: (organizationId: string) =>
305 Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))),
306 deleteApiKey: () => Effect.fail("workos_unavailable"),
307 });
308
309 const error = yield* Effect.flip(
310 apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }),
311 );
312
313 expect(error).toBeInstanceOf(ApiKeyManagementError);
314 }),
315 );
316});
apps/cloud/src/auth/org-selector-auth.node.test.ts+17−7
@@ -7,9 +7,11 @@ import { resolveSessionPrincipal } from "./workos-auth-provider"; · 6 unmodified lines
7import { WorkOSClient, type WorkOSClientService } from "./workos";7import { WorkOSClient, type WorkOSClientService } from "./workos";
8 8
9// The org a console request resolves to is the URL's org (sent in the9// The org a console request resolves to is the URL's org (sent in the
10// `x-executor-organization` selector header), not the session's stored org —10// `x-executor-organization` selector header) — NEVER the session's stored org.
11// with the session org as the fallback for non-console callers, and live11// The sealed cookie's org is a browser-global pinned to whichever org WorkOS
12// membership re-checked either way. This is what makes two browser tabs on12// last touched, so a fallback to it silently scopes a multi-org user's request
13// to the wrong org; a header-less request fails closed instead. Live
14// membership is re-checked either way. This is what makes two browser tabs on
13// different orgs independent.15// different orgs independent.
14 16
15const createdAt = new Date("2026-01-01T00:00:00.000Z");17const createdAt = new Date("2026-01-01T00:00:00.000Z");
@@ -27,6 +29,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 11 unmodified lines
27 listUserKeys: () => Effect.succeed([]),29 listUserKeys: () => Effect.succeed([]),
28 createUserKey: () => Effect.die("not used"),30 createUserKey: () => Effect.die("not used"),
29 revokeUserKey: () => Effect.void,31 revokeUserKey: () => Effect.void,
32 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
33 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
34 revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
30});35});
31 36
32const stubWorkOS = Layer.succeed(37const stubWorkOS = Layer.succeed(
@@ -59,7 +64,7 @@ const stubWorkOS = Layer.succeed( · 26 unmodified lines
59);64);
60 65
61const stubUsers = Layer.succeed(UserStoreService)({66const stubUsers = Layer.succeed(UserStoreService)({
62 use: (fn) =>67 use: (_op, fn) =>
63 Effect.promise(() =>68 Effect.promise(() =>
64 fn({69 fn({
65 ensureAccount: async (id: string) => ({ id, createdAt }),70 ensureAccount: async (id: string) => ({ id, createdAt }),
@@ -95,10 +100,15 @@ const run = (headers: Record<string, string>) => · 29 unmodified lines
95 );100 );
96 101
97describe("resolveSessionPrincipal · URL org selector", () => {102describe("resolveSessionPrincipal · URL org selector", () => {
98 it.effect("falls back to the session org when no selector header is sent", () =>103 it.effect("fails closed when no selector header is sent", () =>
99 Effect.gen(function* () {104 Effect.gen(function* () {
100 const principal = yield* run({ cookie: "wos-session=x" });105 // No fallback to the session org: the cookie's org is browser-global
101 expect(principal.organizationId, "scopes to the session org").toBe(SESSION_ORG);106 // and can name a different org than the tab's URL for a multi-org user.
107 const error = yield* Effect.flip(run({ cookie: "wos-session=x" }));
108 expect(error, "rejects instead of scoping to the session org").toMatchObject({
109 _tag: "NoOrganization",
110 code: "no_organization",
111 });
102 }),112 }),
103 );113 );
104 114
apps/cloud/src/auth/organization.ts+5−3
@@ -38,12 +38,14 @@ import { WorkOSClient } from "./workos"; · 37 unmodified lines
38export const resolveOrganization = (organizationId: string) =>38export const resolveOrganization = (organizationId: string) =>
39 Effect.gen(function* () {39 Effect.gen(function* () {
40 const users = yield* UserStoreService;40 const users = yield* UserStoreService;
41 const existing = yield* users.use((s) => s.getOrganization(organizationId));41 const existing = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId));
42 if (existing) return existing;42 if (existing) return existing;
43 43
44 const workos = yield* WorkOSClient;44 const workos = yield* WorkOSClient;
45 const fresh = yield* workos.getOrganization(organizationId);45 const fresh = yield* workos.getOrganization(organizationId);
46 return yield* users.use((s) => s.upsertOrganization({ id: fresh.id, name: fresh.name }));46 return yield* users.use("upsertOrganization", (s) =>
47 s.upsertOrganization({ id: fresh.id, name: fresh.name }),
48 );
47 });49 });
48 50
49// ---------------------------------------------------------------------------51// ---------------------------------------------------------------------------
@@ -125,7 +127,7 @@ export const authorizeOrganizationSelector = (userId: string, selector: string) · 75 unmodified lines
125 return yield* authorizeOrganization(userId, selector);127 return yield* authorizeOrganization(userId, selector);
126 }128 }
127 const users = yield* UserStoreService;129 const users = yield* UserStoreService;
128 const org = yield* users.use((s) => s.getOrganizationBySlug(selector));130 const org = yield* users.use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(selector));
129 if (!org) return null;131 if (!org) return null;
130 return yield* authorizeOrganization(userId, org.id);132 return yield* authorizeOrganization(userId, org.id);
131 });133 });
apps/cloud/src/auth/user-store-error.node.test.ts+128−0
@@ -0,0 +1,128 @@
1// `UserStoreError` is the public failure of every cloud user-store call, and it
2// used to carry NOTHING: no operation, no reason, no message. Sentry grouped
3// every store failure — a connect timeout, a missing table, a constraint
4// violation — into one titleless issue, and the only cause detail (the
5// pretty-printed Effect cause stuffed into a Sentry `extra`) is scrubbed
6// server-side, so the issue was undiagnosable from Sentry alone.
7//
8// This pins the two safe classification fields it must carry instead:
9// `operation` (already in hand at the call site) and `reason` (classified from
10// the driver cause the way `statusFromWorkOSCause` classifies WorkOS causes).
11import { createServer, type Server, type Socket } from "node:net";
12
13import { describe, expect, it } from "@effect/vitest";
14import { Effect, Layer, Result } from "effect";
15import postgres from "postgres";
16
17import { UserStoreService } from "./context";
18import { ServiceAdapterError, userStoreReasonFromCause, type UserStoreError } from "./errors";
19import { DbService } from "../db/db";
20
21// A socket that completes the TCP handshake and then says nothing — exactly
22// what a wedged Hyperdrive/Postgres endpoint looks like to postgres.js, and the
23// only way to obtain the driver's REAL connect-timeout error object rather than
24// a hand-written imitation of it.
25const blackHolePort = async (): Promise<{
26 readonly port: number;
27 readonly close: () => Promise<void>;
28}> => {
29 const sockets = new Set<Socket>();
30 const server: Server = createServer((socket) => {
31 sockets.add(socket);
32 socket.on("close", () => sockets.delete(socket));
33 });
34 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
35 const address = server.address();
36 // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: the fixture cannot run without a bound port
37 if (address === null || typeof address === "string") throw new Error("no port");
38 return {
39 port: address.port,
40 close: () =>
41 new Promise<void>((resolve) => {
42 // The timed-out client leaves its half-open socket attached; without
43 // dropping it first, `close` waits for a peer that will never speak.
44 for (const socket of sockets) socket.destroy();
45 server.close(() => resolve());
46 }),
47 };
48};
49
50const realConnectTimeoutError = async (): Promise<unknown> => {
51 const hole = await blackHolePort();
52 const sql = postgres(`postgresql://postgres:postgres@127.0.0.1:${hole.port}/postgres`, {
53 max: 1,
54 connect_timeout: 1,
55 fetch_types: false,
56 onnotice: () => undefined,
57 });
58 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: capturing the driver's own thrown error IS the fixture
59 try {
60 await sql`select 1`;
61 // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: the fixture is unusable if the socket answered
62 throw new Error("expected the connect to time out");
63 } catch (error) {
64 return error;
65 } finally {
66 // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort teardown of a connection that never opened
67 await sql.end({ timeout: 0 }).catch(() => undefined);
68 await hole.close();
69 }
70};
71
72// Drizzle re-throws driver failures wrapped in its own error with the failing
73// SQL in the message and the driver error in `.cause` — the shape production
74// actually reports (`Failed query: select … -> write CONNECT_TIMEOUT …`).
75const wrappedLikeDrizzle = (cause: unknown): Error =>
76 // oxlint-disable-next-line executor/no-error-constructor -- boundary: reproducing the driver wrapper shape the store really fails with
77 Object.assign(new Error('Failed query: select "id" from "organizations" where "id" = $1'), {
78 cause,
79 });
80
81const stubDb = Layer.succeed(DbService)({ db: {} as never });
82
83const failingStoreCall = (
84 operation: string,
85 failure: unknown,
86): Effect.Effect<Result.Result<never, UserStoreError>> =>
87 Effect.gen(function* () {
88 const users = yield* UserStoreService;
89 // oxlint-disable-next-line executor/no-promise-reject -- boundary: the store adapter lifts a REJECTING promise; rejecting is the fixture
90 return yield* users.use(operation, () => Promise.reject(failure));
91 }).pipe(
92 Effect.provide(UserStoreService.Live.pipe(Layer.provide(stubDb))),
93 Effect.result,
94 ) as Effect.Effect<Result.Result<never, UserStoreError>>;
95
96describe("UserStoreError classification", () => {
97 it("classifies the driver cause chain", async () => {
98 const driverError = await realConnectTimeoutError();
99
100 expect(userStoreReasonFromCause(wrappedLikeDrizzle(driverError))).toBe("connect_timeout");
101 expect(
102 userStoreReasonFromCause(new ServiceAdapterError({ cause: wrappedLikeDrizzle(driverError) })),
103 ).toBe("connect_timeout");
104 expect(
105 userStoreReasonFromCause(
106 // oxlint-disable-next-line executor/no-error-constructor -- boundary: a SQLSTATE failure shape
107 Object.assign(new Error('relation "organizations" does not exist'), { code: "42P01" }),
108 ),
109 ).toBe("query");
110 // oxlint-disable-next-line executor/no-error-constructor -- boundary: a failure with nothing to classify
111 expect(userStoreReasonFromCause(new Error("something else"))).toBe("unknown");
112 }, 20_000);
113
114 it("carries operation, reason and a stable message on the public error", async () => {
115 const driverError = await realConnectTimeoutError();
116
117 const result = await Effect.runPromise(
118 failingStoreCall("getOrganization", wrappedLikeDrizzle(driverError)),
119 );
120
121 expect(Result.isFailure(result)).toBe(true);
122 if (!Result.isFailure(result)) return;
123 expect(result.failure.operation).toBe("getOrganization");
124 expect(result.failure.reason).toBe("connect_timeout");
125 expect(result.failure.message).toContain("getOrganization");
126 expect(result.failure.message).toContain("connect_timeout");
127 }, 20_000);
128});
apps/cloud/src/auth/workos-auth-provider.ts+132−19
@@ -16,6 +16,7 @@ · 15 unmodified lines
16// - invalid api key -> Unauthorized 401 invalid_api_key16// - invalid api key -> Unauthorized 401 invalid_api_key
17// - api-key org not authorized -> NoOrganization 403 no_organization17// - api-key org not authorized -> NoOrganization 403 no_organization
18// - no/invalid session -> NoOrganization 403 no_organization18// - no/invalid session -> NoOrganization 403 no_organization
19// - session without org header -> NoOrganization 403 no_organization (fail closed)
19// - session org not authorized -> NoOrganization 403 no_organization20// - session org not authorized -> NoOrganization 403 no_organization
20// - no auth header -> falls through to the sealed-session path21// - no auth header -> falls through to the sealed-session path
21// The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are22// The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are
@@ -37,7 +38,13 @@ import { · 15 unmodified lines
37 Unauthorized,38 Unauthorized,
38 Unavailable,39 Unavailable,
39} from "@executor-js/api/server";40} from "@executor-js/api/server";
40import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server";41import type {
42 FailureRenderingStrategy,
43 IdentityFailure,
44 PlatformPrincipal,
45 Principal,
46 ResolvedPrincipal,
47} from "@executor-js/api/server";
41 48
42import { ApiKeyService } from "./api-keys";49import { ApiKeyService } from "./api-keys";
43import { workosApiJwtBearerConfig } from "./api-jwt-bearer";50import { workosApiJwtBearerConfig } from "./api-jwt-bearer";
@@ -46,6 +53,7 @@ import { · 2 unmodified lines
46 authorizeOrganization,53 authorizeOrganization,
47 authorizeOrganizationSelector,54 authorizeOrganizationSelector,
48 orgSelectorFromRequest,55 orgSelectorFromRequest,
56 resolveOrganization,
49} from "./organization";57} from "./organization";
50import { UserStoreService } from "./context";58import { UserStoreService } from "./context";
51import { sealedSessionDisplayName } from "./middleware";59import { sealedSessionDisplayName } from "./middleware";
@@ -86,6 +94,10 @@ const NO_ORGANIZATION_IN_SESSION = { · 34 unmodified lines
86 code: "no_organization",94 code: "no_organization",
87 message: "No organization in session",95 message: "No organization in session",
88};96};
97const NO_ORGANIZATION_SELECTOR = {
98 code: "no_organization",
99 message: "No organization selector in request",
100};
89const INVALID_ACCESS_TOKEN = {101const INVALID_ACCESS_TOKEN = {
90 code: "invalid_access_token",102 code: "invalid_access_token",
91 message: "Invalid or expired access token",103 message: "Invalid or expired access token",
@@ -98,7 +110,6 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { · 6 unmodified lines
98 code: "no_organization",110 code: "no_organization",
99 message: "No organization in access token",111 message: "No organization in access token",
100};112};
101
102// A bearer value with three dot-separated segments is a JWT (a WorkOS access113// A bearer value with three dot-separated segments is a JWT (a WorkOS access
103// token from the CLI device-login); anything else is treated as an API key.114// token from the CLI device-login); anything else is treated as an API key.
104// Same discriminator the MCP plane uses (`mcp/auth.ts`).115// Same discriminator the MCP plane uses (`mcp/auth.ts`).
@@ -134,6 +145,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 29 unmodified lines
134 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN);145 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN);
135 146
136 return {147 return {
148 kind: "member",
137 accountId: verified.accountId,149 accountId: verified.accountId,
138 organizationId: org.id,150 organizationId: org.id,
139 organizationName: org.name,151 organizationName: org.name,
@@ -146,13 +158,49 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 6 unmodified lines
146 });158 });
147 159
148/**160/**
149 * Resolve a `Bearer` credential into a `Principal`: a WorkOS access-token JWT161 * What an ORG-level API key resolves to: the platform view. Deliberately NOT a
150 * (when `jwt` config is supplied and the value looks like a JWT) or a WorkOS162 * `Principal` — a `Principal` names an acting member (`accountId: string`), and
151 * API key. Returns `null` when there is no `Authorization` header, so the163 * an org key has none. Keeping it a separate shape means no product code path
152 * caller falls through to the sealed-session path. (Kept the historical name,164 * can accidentally treat it as a member, and the executor built from it binds
153 * the re-export and resolver tests reference it.)165 * `subject: null` + the read-only tenant reach rather than inventing a subject.
166 *
167 * The `/admin/*` mount turns this into an executor with `{ tenant:
168 * organizationId, subject: undefined, platformView: true }`.
169 */
170export interface PlatformAuth {
171 readonly kind: "platform";
172 readonly organizationId: string;
173 readonly organizationName: string;
174 readonly organizationSlug?: string;
175 readonly keyId: string;
176}
177
178/** A resolved bearer credential: an acting member, or the org-level platform
179 * view. `null` means no `Authorization` header at all. */
180export type BearerAuth = Principal | PlatformAuth | null;
181
182export const isPlatformAuth = (value: BearerAuth): value is PlatformAuth =>
183 value !== null && "kind" in value && value.kind === "platform";
184
185/**
186 * Resolve a `Bearer` credential into either a member `Principal` or the
187 * org-level {@link PlatformAuth}. Returns `null` when there is no
188 * `Authorization` header, so the caller falls through to the sealed-session
189 * path.
190 *
191 * The org branch does NOT call `authorizeOrganization`: that checks a USER's
192 * live membership, and there is no user here. The key itself is the authority —
193 * WorkOS validated it and reported which org owns it — so the org row is merely
194 * resolved (mirrored on first read) for its name and slug.
154 */195 */
155export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | null = null) =>196export const resolveBearerAuth = (
197 request: Request,
198 jwt: JwtBearerConfig | null = null,
199): Effect.Effect<
200 BearerAuth,
201 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
202 WorkOSClient | ApiKeyService | UserStoreService
203> =>
156 Effect.gen(function* () {204 Effect.gen(function* () {
157 const authHeader = request.headers.get("authorization");205 const authHeader = request.headers.get("authorization");
158 if (!authHeader) return null;206 if (!authHeader) return null;
@@ -167,7 +215,7 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 8 unmodified lines
167 if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt);215 if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt);
168 216
169 const apiKeys = yield* ApiKeyService;217 const apiKeys = yield* ApiKeyService;
170 const principal = yield* apiKeys218 const owner = yield* apiKeys
171 .validate(value)219 .validate(value)
172 .pipe(220 .pipe(
173 Effect.catchTag("ApiKeyValidationError", () =>221 Effect.catchTag("ApiKeyValidationError", () =>
@@ -175,13 +223,29 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 1 unmodified line
175 ),223 ),
176 );224 );
177 225
178 if (!principal) return yield* new Unauthorized(INVALID_API_KEY);226 if (!owner) return yield* new Unauthorized(INVALID_API_KEY);
179 227
180 const org = yield* authorizeOrganization(principal.accountId, principal.organizationId);228 if (owner.scope === "org") {
229 const org = yield* resolveOrganization(owner.organizationId);
230 return {
231 kind: "platform",
232 organizationId: org.id,
233 organizationName: org.name,
234 ...(org.slug === undefined || org.slug === null ? {} : { organizationSlug: org.slug }),
235 keyId: owner.keyId,
236 } satisfies PlatformAuth;
237 }
238
239 // A `"user"` key always carries an accountId (see `ownerFromApiKey`); the
240 // guard keeps the narrowing honest rather than asserting.
241 if (owner.accountId == null) return yield* new Unauthorized(INVALID_API_KEY);
242
243 const org = yield* authorizeOrganization(owner.accountId, owner.organizationId);
181 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY);244 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY);
182 245
183 return {246 return {
184 accountId: principal.accountId,247 kind: "member",
248 accountId: owner.accountId,
185 organizationId: org.id,249 organizationId: org.id,
186 organizationName: org.name,250 organizationName: org.name,
187 organizationSlug: org.slug,251 organizationSlug: org.slug,
@@ -192,6 +256,40 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 4 unmodified lines
192 } satisfies Principal;256 } satisfies Principal;
193 });257 });
194 258
259/**
260 * The PRODUCT-plane bearer resolver. An org-level key resolves to the neutral
261 * seam's {@link PlatformPrincipal} — NOT a member `Principal` — and the shared
262 * middleware routes it to the subject-less, read-only platform executor
263 * (refusing non-GET up front). Previously the product plane rejected org keys
264 * outright; serving tenant-level READS to them is deliberate: the catalog,
265 * tools, policies, and org-owned connection listings are tenant-shared answers
266 * a machine credential can honestly receive, while everything subject-bound
267 * stays structurally out of reach (a platform executor binds no subject, so no
268 * member's personal rows resolve). (Kept the historical name; the re-export and
269 * resolver tests reference it.)
270 */
271export const resolveApiKeyPrincipal = (
272 request: Request,
273 jwt: JwtBearerConfig | null = null,
274): Effect.Effect<
275 ResolvedPrincipal | null,
276 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
277 WorkOSClient | ApiKeyService | UserStoreService
278> =>
279 Effect.gen(function* () {
280 const auth = yield* resolveBearerAuth(request, jwt);
281 if (isPlatformAuth(auth)) {
282 return {
283 kind: "platform",
284 organizationId: auth.organizationId,
285 organizationName: auth.organizationName,
286 ...(auth.organizationSlug === undefined ? {} : { organizationSlug: auth.organizationSlug }),
287 keyId: auth.keyId,
288 } satisfies PlatformPrincipal;
289 }
290 return auth;
291 });
292
195export const resolveSessionPrincipal = (request: Request) =>293export const resolveSessionPrincipal = (request: Request) =>
196 Effect.gen(function* () {294 Effect.gen(function* () {
197 const workos = yield* WorkOSClient;295 const workos = yield* WorkOSClient;
@@ -199,17 +297,27 @@ export const resolveSessionPrincipal = (request: Request) => · 1 unmodified line
199 if (!session) {297 if (!session) {
200 return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);298 return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
201 }299 }
202 // The console URL's org is the scope authority (sent as a header); the300 // The console URL's org is the scope authority, sent as a header on every
203 // session's own org is the fallback for non-console callers. Membership is301 // request. FAIL CLOSED when it's missing: the sealed cookie's org is
204 // re-checked live either way — the header is a selector, not a trust302 // browser-global and pinned to whichever org WorkOS last touched, so
205 // boundary (see organization.ts).303 // falling back to it silently serves ANOTHER org's data to a multi-org
206 const selector = orgSelectorFromRequest(request) ?? session.organizationId;304 // user (the wrong-tenant connection-list bug, 2026-07). A header-less
305 // session call gets a clear 403 instead. Membership is re-checked live —
306 // the header is a selector, not a trust boundary (see organization.ts).
307 // A bare-URL first paint (no org in the path yet) may 403 here; that's
308 // the safe outcome — OrgSlugGate immediately canonicalizes the URL onto
309 // an org slug, the org-keyed atom registry remounts, and everything
310 // refetches with the header. `/account/me` — which deliberately DOES fall
311 // back to the session org to pick that landing org — lives on the account
312 // plane, not here.
313 const selector = orgSelectorFromRequest(request);
207 if (!selector) {314 if (!selector) {
208 return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);315 return yield* new NoOrganization(NO_ORGANIZATION_SELECTOR);
209 }316 }
210 const org = yield* authorizeOrganizationSelector(session.userId, selector);317 const org = yield* authorizeOrganizationSelector(session.userId, selector);
211 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);318 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
212 return {319 return {
320 kind: "member",
213 accountId: session.userId,321 accountId: session.userId,
214 organizationId: org.id,322 organizationId: org.id,
215 organizationName: org.name,323 organizationName: org.name,
@@ -236,7 +344,7 @@ export const resolveProtectedPrincipal = ( · 20 unmodified lines
236 request: Request,344 request: Request,
237 jwt: JwtBearerConfig | null = null,345 jwt: JwtBearerConfig | null = null,
238): Effect.Effect<346): Effect.Effect<
239 Principal,347 ResolvedPrincipal,
240 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,348 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
241 WorkOSClient | ApiKeyService | UserStoreService349 WorkOSClient | ApiKeyService | UserStoreService
242> =>350> =>
@@ -317,6 +425,11 @@ export const cloudIdentityFailureStrategy: FailureRenderingStrategy<IdentityFail · 74 unmodified lines
317 "service_unavailable",425 "service_unavailable",
318 "Service temporarily unavailable",426 "Service temporarily unavailable",
319 ),427 ),
428 ReadOnlyCredential: renderIdentityFailure(
429 403,
430 "read_only_credential",
431 "Organization API keys are read-only",
432 ),
320 }),433 }),
321 ),434 ),
322};435};
apps/cloud/src/auth/workos.node.test.ts+282−1
@@ -1,6 +1,287 @@
1import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2import type { AddressInfo } from "node:net";
3
1import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
5import { env } from "cloudflare:workers";
6import { Effect, Schema } from "effect";
7import { defaults as ironDefaults, seal as sealIron } from "iron-webcrypto";
8import {
9 SignJWT,
10 exportJWK,
11 generateKeyPair,
12 type JSONWebKeySet,
13 type JWK,
14 type KeyLike,
15} from "jose";
16
17import { WorkOSClient, collectRawWorkOSList, collectWorkOSList } from "./workos";
18
19const COOKIE_PASSWORD = "test_cookie_password_at_least_32_chars!";
20const CLIENT_ID = "client_test";
21const API_KEY = "sk_test";
22const USER = {
23 id: "user_test",
24 email: "test@example.com",
25 firstName: "Test",
26 lastName: "User",
27 profilePictureUrl: "https://example.com/avatar.png",
28} as const;
29
30interface Keypair {
31 readonly kid: string;
32 readonly publicJwk: JWK;
33 readonly privateKey: KeyLike;
34}
35
36interface RecordedRequest {
37 readonly method: string;
38 readonly path: string;
39 readonly body: unknown;
40}
41
42interface WorkOSStub {
43 readonly baseUrl: string;
44 readonly requests: () => readonly RecordedRequest[];
45 readonly setKeys: (keys: ReadonlyArray<JWK>) => void;
46}
47
48const generateKeypair = async (kid: string): Promise<Keypair> => {
49 const { publicKey, privateKey } = await generateKeyPair("RS256");
50 const jwk = await exportJWK(publicKey);
51 return { kid, publicJwk: { ...jwk, kid, alg: "RS256" }, privateKey };
52};
53
54const signAccessToken = (
55 keypair: Keypair,
56 claims: {
57 readonly subject?: string;
58 readonly organizationId?: string;
59 readonly sessionId?: string;
60 readonly expiresIn?: string | number;
61 } = {},
62) => {
63 const jwt = new SignJWT({
64 org_id: claims.organizationId ?? "org_test",
65 sid: claims.sessionId ?? "session_test",
66 })
67 .setProtectedHeader({ alg: "RS256", kid: keypair.kid })
68 .setSubject(claims.subject ?? USER.id)
69 .setIssuedAt();
70
71 return (
72 typeof claims.expiresIn === "number"
73 ? jwt.setExpirationTime(claims.expiresIn)
74 : jwt.setExpirationTime(claims.expiresIn ?? "5m")
75 ).sign(keypair.privateKey);
76};
77
78const sealSession = async (accessToken: string, refreshToken = "refresh_test"): Promise<string> =>
79 `${await sealIron(
80 {
81 accessToken,
82 refreshToken,
83 user: USER,
84 },
85 { id: "1", secret: COOKIE_PASSWORD },
86 { ...ironDefaults, ttl: 0, encode: JSON.stringify },
87 )}~2`;
88
89const workosUserResponse = {
90 object: "user",
91 id: USER.id,
92 email: USER.email,
93 email_verified: true,
94 profile_picture_url: USER.profilePictureUrl,
95 first_name: USER.firstName,
96 last_name: USER.lastName,
97 last_sign_in_at: null,
98 locale: null,
99 created_at: "2026-07-09T00:00:00.000Z",
100 updated_at: "2026-07-09T00:00:00.000Z",
101 external_id: null,
102 metadata: {},
103} as const;
104
105const decodeJsonBody = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
106
107const readJsonBody = async (request: IncomingMessage): Promise<unknown> => {
108 const chunks: Buffer[] = [];
109 for await (const chunk of request) {
110 chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
111 }
112 const body = Buffer.concat(chunks).toString("utf8");
113 return body ? decodeJsonBody(body) : null;
114};
115
116const writeJson = (response: ServerResponse, status: number, body: unknown): void => {
117 response.writeHead(status, { "content-type": "application/json" });
118 response.end(JSON.stringify(body));
119};
120
121const withWorkOSStub = async <A>(
122 keypair: Keypair,
123 use: (stub: WorkOSStub) => Promise<A>,
124): Promise<A> => {
125 let keys: ReadonlyArray<JWK> = [keypair.publicJwk];
126 const requests: RecordedRequest[] = [];
127 const server = createServer((request, response) => {
128 // oxlint-disable-next-line executor/no-promise-catch -- boundary: Node's request callback cannot await, so the fixture converts async failures into a stable HTTP 500
129 void (async () => {
130 const path = request.url ?? "/";
131 const body = request.method === "POST" ? await readJsonBody(request) : null;
132 requests.push({ method: request.method ?? "GET", path, body });
133
134 if (request.method === "GET" && path === `/sso/jwks/${CLIENT_ID}`) {
135 writeJson(response, 200, { keys: keys.map((key) => ({ ...key })) } satisfies JSONWebKeySet);
136 return;
137 }
138
139 if (request.method === "POST" && path === "/user_management/authenticate") {
140 const refreshed = await signAccessToken(keypair, {
141 sessionId: "session_refreshed",
142 });
143 writeJson(response, 200, {
144 user: workosUserResponse,
145 organization_id: "org_test",
146 access_token: refreshed,
147 refresh_token: "refresh_next",
148 authentication_method: "Password",
149 });
150 return;
151 }
152
153 writeJson(response, 404, { error: "unexpected request" });
154 })().catch((_error: unknown) => {
155 writeJson(response, 500, {
156 error: "fixture server failed",
157 });
158 });
159 });
160
161 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
162 const address = server.address() as AddressInfo;
163 const stub: WorkOSStub = {
164 baseUrl: `http://127.0.0.1:${address.port}`,
165 requests: () => requests,
166 setKeys: (next) => {
167 keys = next;
168 },
169 };
170
171 return use(stub).finally(() => {
172 server.closeAllConnections();
173 server.close();
174 });
175};
2 176
3import { collectRawWorkOSList, collectWorkOSList } from "./workos";177const runAuthenticate = (sessionData: string, baseUrl: string) => {
178 Object.assign(env, {
179 WORKOS_API_KEY: API_KEY,
180 WORKOS_CLIENT_ID: CLIENT_ID,
181 WORKOS_COOKIE_PASSWORD: COOKIE_PASSWORD,
182 WORKOS_API_URL: baseUrl,
183 });
184
185 return Effect.runPromise(
186 Effect.gen(function* () {
187 const workos = yield* WorkOSClient;
188 return yield* workos.authenticateSealedSession(sessionData);
189 }).pipe(Effect.provide(WorkOSClient.Default)),
190 );
191};
192
193describe("authenticateSealedSession", () => {
194 it("validates a sealed session locally with the cached JWKS", async () => {
195 const keypair = await generateKeypair("k_valid");
196 await withWorkOSStub(keypair, async (stub) => {
197 const token = await signAccessToken(keypair, {
198 organizationId: "org_test",
199 sessionId: "session_valid",
200 });
201 const session = await sealSession(token);
202
203 const result = await runAuthenticate(session, stub.baseUrl);
204
205 expect(result).toEqual({
206 userId: USER.id,
207 email: USER.email,
208 firstName: USER.firstName,
209 lastName: USER.lastName,
210 avatarUrl: USER.profilePictureUrl,
211 organizationId: "org_test",
212 sessionId: "session_valid",
213 refreshedSession: undefined,
214 });
215 expect(stub.requests()).toEqual([
216 { method: "GET", path: `/sso/jwks/${CLIENT_ID}`, body: null },
217 ]);
218 });
219 });
220
221 it("reuses the module cached JWKS for a second sealed session", async () => {
222 const keypair = await generateKeypair("k_cached");
223 await withWorkOSStub(keypair, async (stub) => {
224 const first = await sealSession(
225 await signAccessToken(keypair, {
226 sessionId: "session_first",
227 }),
228 );
229 const second = await sealSession(
230 await signAccessToken(keypair, {
231 sessionId: "session_second",
232 }),
233 );
234
235 await runAuthenticate(first, stub.baseUrl);
236 const afterFirst = stub.requests().length;
237 const result = await runAuthenticate(second, stub.baseUrl);
238
239 expect(afterFirst).toBe(1);
240 expect(result?.sessionId).toBe("session_second");
241 expect(stub.requests()).toEqual([
242 { method: "GET", path: `/sso/jwks/${CLIENT_ID}`, body: null },
243 ]);
244 });
245 });
246
247 it("falls through to SDK refresh when the access token is expired", async () => {
248 const keypair = await generateKeypair("k_expired");
249 await withWorkOSStub(keypair, async (stub) => {
250 const expiredAt = Math.floor(Date.now() / 1000) - 60;
251 const expired = await signAccessToken(keypair, {
252 expiresIn: expiredAt,
253 sessionId: "session_expired",
254 });
255 const session = await sealSession(expired, "refresh_expired");
256
257 const result = await runAuthenticate(session, stub.baseUrl);
258
259 expect(result?.sessionId).toBe("session_refreshed");
260 expect(result?.refreshedSession).toEqual(expect.any(String));
261 expect(stub.requests().map((request) => [request.method, request.path])).toEqual([
262 ["GET", `/sso/jwks/${CLIENT_ID}`],
263 ["POST", "/user_management/authenticate"],
264 ]);
265 expect(stub.requests()[1]?.body).toMatchObject({
266 grant_type: "refresh_token",
267 client_id: CLIENT_ID,
268 client_secret: API_KEY,
269 refresh_token: "refresh_expired",
270 organization_id: "org_test",
271 });
272 });
273 });
274
275 it("returns null for garbage session data", async () => {
276 const keypair = await generateKeypair("k_garbage");
277 await withWorkOSStub(keypair, async (stub) => {
278 const result = await runAuthenticate("not-a-sealed-session", stub.baseUrl);
279
280 expect(result).toBeNull();
281 expect(stub.requests()).toEqual([]);
282 });
283 });
284});
4 285
5describe("collectWorkOSList", () => {286describe("collectWorkOSList", () => {
6 it("collects memberships beyond the first WorkOS page", async () => {287 it("collects memberships beyond the first WorkOS page", async () => {
apps/cloud/src/auth/workos.ts+394−44
@@ -3,13 +3,23 @@ · 2 unmodified lines
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4 4
5import { env } from "cloudflare:workers";5import { env } from "cloudflare:workers";
6import { Context, Data, Effect, Layer, Option, Schema } from "effect";6import { Context, Data, Effect, Layer, Option, Predicate, Schema } from "effect";
7import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";7import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";
8import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto";
9import { decodeJwt, jwtVerify } from "jose";
10import { JWKSInvalid, JWKSNoMatchingKey, JWKSTimeout } from "jose/errors";
8import { parseCookie } from "./cookies";11import { parseCookie } from "./cookies";
9import { tryPromiseService, withServiceLogging, workosErrorFromFailure } from "./errors";12import { createCachedRemoteJWKSet, type CachedRemoteJWKSet } from "./jwks-cache";
13import {
14 ServiceAdapterError,
15 tryPromiseService,
16 withServiceLogging,
17 workosErrorFromFailure,
18} from "./errors";
10 19
11const COOKIE_NAME = "wos-session";20const COOKIE_NAME = "wos-session";
12const INVALID_COOKIE_PASSWORD_MESSAGE = "WORKOS_COOKIE_PASSWORD must be at least 32 characters";21const INVALID_COOKIE_PASSWORD_MESSAGE = "WORKOS_COOKIE_PASSWORD must be at least 32 characters";
22const WORKOS_SEAL_VERSION_DELIMITER = "~";
13 23
14type RawWorkOS = WorkOS & {24type RawWorkOS = WorkOS & {
15 readonly get: (25 readonly get: (
@@ -59,6 +69,239 @@ const RawWorkOSListResponse = Schema.Struct({ · 43 unmodified lines
59 69
60const decodeRawWorkOSListResponse = Schema.decodeUnknownOption(RawWorkOSListResponse);70const decodeRawWorkOSListResponse = Schema.decodeUnknownOption(RawWorkOSListResponse);
61 71
72const SealedSessionUser = Schema.Struct({
73 id: Schema.String,
74 email: Schema.String,
75 firstName: Schema.NullOr(Schema.String),
76 lastName: Schema.NullOr(Schema.String),
77 profilePictureUrl: Schema.NullOr(Schema.String),
78});
79
80const SealedSessionPayload = Schema.Struct({
81 accessToken: Schema.String,
82 refreshToken: Schema.String,
83 user: SealedSessionUser,
84 authenticationMethod: Schema.optional(Schema.Unknown),
85 impersonator: Schema.optional(Schema.Unknown),
86});
87
88const decodeSealedSessionPayload = Schema.decodeUnknownOption(SealedSessionPayload);
89
90const JwtClaims = Schema.Struct({
91 sid: Schema.String,
92 org_id: Schema.optional(Schema.String),
93});
94
95const decodeJwtClaims = Schema.decodeUnknownOption(JwtClaims);
96
97const LegacySealedSessionPayload = Schema.Struct({
98 persistent: Schema.Unknown,
99});
100
101const decodeLegacySealedSessionPayload = Schema.decodeUnknownOption(LegacySealedSessionPayload);
102
103type SealedSessionPayload = typeof SealedSessionPayload.Type;
104
105type LocalSessionVerification =
106 | {
107 readonly _tag: "Valid";
108 readonly session: SealedSessionPayload;
109 readonly organizationId: string | undefined;
110 readonly sessionId: string;
111 }
112 | { readonly _tag: "Refresh" }
113 | { readonly _tag: "InvalidCookie" };
114
115class LocalSessionCookieError extends Data.TaggedError("LocalSessionCookieError")<{
116 readonly cause: unknown;
117}> {}
118
119const isLocalSessionValid = Predicate.isTagged("Valid") as (
120 value: LocalSessionVerification,
121) => value is Extract<LocalSessionVerification, { readonly _tag: "Valid" }>;
122
123const isLocalSessionInvalidCookie = Predicate.isTagged("InvalidCookie") as (
124 value: LocalSessionVerification,
125) => value is Extract<LocalSessionVerification, { readonly _tag: "InvalidCookie" }>;
126
127const ErrorWithCode = Schema.Struct({ code: Schema.String });
128const isErrorWithCode = Schema.is(ErrorWithCode);
129
130const isJwtVerificationFailure = (cause: unknown): boolean =>
131 isErrorWithCode(cause) &&
132 (cause.code.startsWith("ERR_JWT_") ||
133 cause.code.startsWith("ERR_JWS_") ||
134 cause.code === JWKSNoMatchingKey.code);
135
136const isJwksSystemFailure = (cause: unknown): boolean =>
137 isErrorWithCode(cause) && (cause.code === JWKSTimeout.code || cause.code === JWKSInvalid.code);
138
139const parseWorkOSSeal = (
140 seal: string,
141): { readonly sealWithoutVersion: string; readonly tokenVersion: number | null } => {
142 const [sealWithoutVersion = "", tokenVersionAsString] = seal.split(WORKOS_SEAL_VERSION_DELIMITER);
143 return {
144 sealWithoutVersion,
145 tokenVersion:
146 tokenVersionAsString === undefined ? null : Number.parseInt(tokenVersionAsString, 10),
147 };
148};
149
150const unsealWorkOSSession = async (
151 sessionData: string,
152 cookiePassword: string,
153): Promise<unknown> => {
154 const { sealWithoutVersion, tokenVersion } = parseWorkOSSeal(sessionData);
155 const data =
156 (await unsealIron(sealWithoutVersion, { 1: cookiePassword }, { ...ironDefaults, ttl: 0 })) ??
157 {};
158 // Mirrors the SDK's unsealData version handling: current (v2) seals hold the
159 // payload directly, OTHER versioned seals nest it under `persistent`, and an
160 // unversioned seal is the payload itself.
161 if (tokenVersion === 2 || tokenVersion === null) return data;
162 return Option.match(decodeLegacySealedSessionPayload(data), {
163 onNone: () => data,
164 onSome: (legacy) => legacy.persistent,
165 });
166};
167
168const getWorkOSSessionJwks = (() => {
169 const resolvers = new Map<string, CachedRemoteJWKSet>();
170 return (url: URL): CachedRemoteJWKSet => {
171 const key = url.toString();
172 const existing = resolvers.get(key);
173 if (existing) return existing;
174 const created = createCachedRemoteJWKSet(url);
175 resolvers.set(key, created);
176 return created;
177 };
178})();
179
180const verifyJwtOnce = (accessToken: string, jwks: CachedRemoteJWKSet) =>
181 Effect.tryPromise({
182 try: () => jwtVerify(accessToken, jwks),
183 catch: (cause) => new ServiceAdapterError({ cause }),
184 });
185
186const verifyJwtWithRefreshRetry = (
187 accessToken: string,
188 jwks: CachedRemoteJWKSet,
189): Effect.Effect<boolean, ServiceAdapterError> =>
190 verifyJwtOnce(accessToken, jwks).pipe(
191 Effect.as(true),
192 Effect.catchTag("ServiceAdapterError", (error) => {
193 if (isErrorWithCode(error.cause) && error.cause.code === JWKSNoMatchingKey.code) {
194 return Effect.sync(() => jwks.forceRefresh()).pipe(
195 Effect.flatMap(() => verifyJwtOnce(accessToken, jwks)),
196 Effect.as(true),
197 Effect.catchTag("ServiceAdapterError", (retryError) =>
198 isJwtVerificationFailure(retryError.cause)
199 ? Effect.succeed(false)
200 : Effect.fail(retryError),
201 ),
202 );
203 }
204 if (isJwtVerificationFailure(error.cause)) return Effect.succeed(false);
205 if (isJwksSystemFailure(error.cause)) return Effect.fail(error);
206 return Effect.fail(error);
207 }),
208 );
209
210const verifySealedSessionLocally = (
211 sessionData: string,
212 cookiePassword: string,
213 jwks: CachedRemoteJWKSet,
214): Effect.Effect<LocalSessionVerification, ServiceAdapterError> =>
215 Effect.gen(function* () {
216 // Phase timings, not just child spans. `local_verify` is a leaf in
217 // production traces, so a ~3.3s verify has nothing under it to blame —
218 // and it stayed 3.3s after the JWKS fetch was eliminated entirely
219 // (jwks.fetch_count == 0), so the cost is one of the phases below. Under
220 // workerd `Date.now()` only advances at I/O boundaries, which is exactly
221 // what makes a raw span duration misleading here: recording each phase
222 // explicitly says which await the wall-clock actually crossed.
223 const verifyStartedAt = Date.now();
224
225 const unsealStartedAt = Date.now();
226 const unsealed = yield* Effect.tryPromise({
227 try: () => unsealWorkOSSession(sessionData, cookiePassword),
228 catch: (cause) => new LocalSessionCookieError({ cause }),
229 }).pipe(
230 Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null as unknown | null)),
231 Effect.withSpan("workos.session.unseal"),
232 );
233 const unsealMs = Date.now() - unsealStartedAt;
234 yield* Effect.annotateCurrentSpan({ "verify.unseal_ms": unsealMs });
235 if (!unsealed) return { _tag: "InvalidCookie" };
236
237 const decodeStartedAt = Date.now();
238 const session = Option.match(decodeSealedSessionPayload(unsealed), {
239 onNone: (): SealedSessionPayload | null => null,
240 onSome: (payload) => payload,
241 });
242 yield* Effect.annotateCurrentSpan({ "verify.decode_ms": Date.now() - decodeStartedAt });
243 if (!session) return { _tag: "InvalidCookie" };
244
245 // Snapshot the JWKS cache around the verify so the `local_verify` span
246 // says whether THIS verify was a warm-cache signature check or paid for a
247 // live upstream JWKS fetch. The Aug 2026 latency regression was the cache
248 // silently missing on most verifies, and no span attribute distinguished
249 // the two paths.
250 const jwksBefore = jwks.inspect();
251 // Entry-state annotation goes on BEFORE the verify so a failing verify
252 // (the case worth debugging) still records whether the cache was warm.
253 yield* Effect.annotateCurrentSpan({
254 "jwks.cache_populated_at_start": jwksBefore.hasJwks,
255 ...(jwksBefore.fetchedAt === null
256 ? {}
257 : { "jwks.cache_age_ms": Date.now() - jwksBefore.fetchedAt }),
258 });
259 const jwtStartedAt = Date.now();
260 const verified = yield* verifyJwtWithRefreshRetry(session.accessToken, jwks).pipe(
261 Effect.withSpan("workos.session.jwt_verify"),
262 Effect.onExit(() => {
263 const jwksAfter = jwks.inspect();
264 const finishedAt = Date.now();
265 return Effect.annotateCurrentSpan({
266 "verify.jwt_ms": finishedAt - jwtStartedAt,
267 "verify.total_ms": finishedAt - verifyStartedAt,
268 // Blocking, not total: under stale-while-revalidate a background
269 // refresh moves `fetchCount` without costing this verify anything.
270 // Attribute latency to what the caller actually waited on.
271 "jwks.fetched_during_verify":
272 jwksAfter.blockingFetchCount > jwksBefore.blockingFetchCount,
273 "jwks.served_from_store": jwksAfter.storeHitCount > jwksBefore.storeHitCount,
274 "jwks.fetch_count": jwksAfter.fetchCount,
275 "jwks.blocking_fetch_count": jwksAfter.blockingFetchCount,
276 "jwks.fetch_failure_count": jwksAfter.fetchFailureCount,
277 ...(jwksAfter.lastFetchDurationMs === null
278 ? {}
279 : { "jwks.last_fetch_ms": jwksAfter.lastFetchDurationMs }),
280 // Splits the ~3.4s that sits inside jwt_verify with zero upstream
281 // fetches: the cross-isolate store read (I/O) vs WebCrypto key
282 // import vs the signature check itself.
283 ...(jwksAfter.lastStoreReadMs === null
284 ? {}
285 : { "jwks.store_read_ms": jwksAfter.lastStoreReadMs }),
286 ...(jwksAfter.lastResolveMs === null
287 ? {}
288 : { "jwks.key_resolve_ms": jwksAfter.lastResolveMs }),
289 });
290 }),
291 );
292 if (!verified) return { _tag: "Refresh" };
293
294 const claims = Option.getOrNull(decodeJwtClaims(decodeJwt(session.accessToken)));
295 if (!claims) return { _tag: "Refresh" };
296
297 return {
298 _tag: "Valid",
299 session,
300 organizationId: claims.org_id,
301 sessionId: claims.sid,
302 };
303 });
304
62const completedListMetadata = {305const completedListMetadata = {
63 before: null,306 before: null,
64 after: null,307 after: null,
@@ -150,14 +393,18 @@ const make = Effect.gen(function* () { · 85 unmodified lines
150 clientId,393 clientId,
151 ...workosApiUrlOptions(env.WORKOS_API_URL),394 ...workosApiUrlOptions(env.WORKOS_API_URL),
152 });395 });
396 const sessionJwks = getWorkOSSessionJwks(new URL(workos.userManagement.getJwksUrl(clientId)));
153 397
154 // The public `WorkOSError` carries the upstream HTTP status when the SDK398 // The public `WorkOSError` carries the upstream HTTP status when the SDK
155 // exception had one (all its typed exceptions do), so consumers can tell a399 // exception had one (all its typed exceptions do), so consumers can tell a
156 // definitive WorkOS denial (401/403/404 — fail closed) from a transient400 // definitive WorkOS denial (401/403/404 — fail closed) from a transient
157 // failure (429/5xx/network — retryable).401 // failure (429/5xx/network — retryable).
158 const use = <A>(fn: (wos: WorkOS) => Promise<A>) =>402 // `op` names the SDK call (mirroring its `namespace.method` path) so every
403 // span reads `workos.<operation>` instead of one undifferentiated "workos"
404 // bucket, and failures log which call actually failed.
405 const use = <A>(op: string, fn: (wos: WorkOS) => Promise<A>) =>
159 withServiceLogging(406 withServiceLogging(
160 "workos",407 `workos.${op}`,
161 workosErrorFromFailure,408 workosErrorFromFailure,
162 tryPromiseService(() => fn(workos)),409 tryPromiseService(() => fn(workos)),
163 );410 );
@@ -171,25 +418,29 @@ const make = Effect.gen(function* () { · 7 unmodified lines
171 cookiePassword,418 cookiePassword,
172 });419 });
173 420
174 const result = yield* use(() => session.authenticate());421 const local = yield* withServiceLogging(
422 "workos.session.local_verify",
423 workosErrorFromFailure,
424 verifySealedSessionLocally(sessionData, cookiePassword, sessionJwks),
425 );
175 426
176 if (result.authenticated) {427 if (isLocalSessionValid(local)) {
177 return {428 return {
178 userId: result.user.id,429 userId: local.session.user.id,
179 email: result.user.email,430 email: local.session.user.email,
180 firstName: result.user.firstName,431 firstName: local.session.user.firstName,
181 lastName: result.user.lastName,432 lastName: local.session.user.lastName,
182 avatarUrl: result.user.profilePictureUrl,433 avatarUrl: local.session.user.profilePictureUrl,
183 organizationId: result.organizationId,434 organizationId: local.organizationId,
184 sessionId: result.sessionId,435 sessionId: local.sessionId,
185 refreshedSession: undefined as string | undefined,436 refreshedSession: undefined as string | undefined,
186 };437 };
187 }438 }
188 439
189 if (result.reason === "no_session_cookie_provided") return null;440 if (isLocalSessionInvalidCookie(local)) return null;
190 441
191 // Try refreshing442 // Try refreshing
192 const refreshed = yield* use(() => session.refresh()).pipe(443 const refreshed = yield* use("session.refresh", () => session.refresh()).pipe(
193 Effect.orElseSucceed(() => ({ authenticated: false as const })),444 Effect.orElseSucceed(() => ({ authenticated: false as const })),
194 );445 );
195 446
@@ -218,7 +469,7 @@ const make = Effect.gen(function* () { · 22 unmodified lines
218 }),469 }),
219 470
220 authenticateWithCode: (code: string) =>471 authenticateWithCode: (code: string) =>
221 use((wos) =>472 use("userManagement.authenticateWithCode", (wos) =>
222 wos.userManagement.authenticateWithCode({473 wos.userManagement.authenticateWithCode({
223 code,474 code,
224 clientId,475 clientId,
@@ -228,11 +479,13 @@ const make = Effect.gen(function* () { · 3 unmodified lines
228 479
229 /** Create a new organization in WorkOS. */480 /** Create a new organization in WorkOS. */
230 createOrganization: (name: string) =>481 createOrganization: (name: string) =>
231 use((wos) => wos.organizations.createOrganization({ name })),482 use("organizations.createOrganization", (wos) =>
483 wos.organizations.createOrganization({ name }),
484 ),
232 485
233 /** Add a user to an organization. */486 /** Add a user to an organization. */
234 createMembership: (organizationId: string, userId: string, roleSlug?: string) =>487 createMembership: (organizationId: string, userId: string, roleSlug?: string) =>
235 use((wos) =>488 use("userManagement.createOrganizationMembership", (wos) =>
236 wos.userManagement.createOrganizationMembership({489 wos.userManagement.createOrganizationMembership({
237 organizationId,490 organizationId,
238 userId,491 userId,
@@ -242,7 +495,7 @@ const make = Effect.gen(function* () { · 3 unmodified lines
242 495
243 /** List organization memberships for a user. */496 /** List organization memberships for a user. */
244 listUserMemberships: (userId: string) =>497 listUserMemberships: (userId: string) =>
245 use(async (wos) =>498 use("userManagement.listOrganizationMemberships", async (wos) =>
246 collectWorkOSList(499 collectWorkOSList(
247 await wos.userManagement.listOrganizationMemberships({500 await wos.userManagement.listOrganizationMemberships({
248 userId,501 userId,
@@ -261,13 +514,53 @@ const make = Effect.gen(function* () { · 12 unmodified lines
261 sessionData,514 sessionData,
262 cookiePassword,515 cookiePassword,
263 });516 });
264 const refreshed = yield* use(() =>517 const refreshed = yield* use("session.refresh", () =>
265 session.refresh(organizationId ? { organizationId } : undefined),518 session.refresh(organizationId ? { organizationId } : undefined),
266 );519 );
267 if (!refreshed.authenticated || !("sealedSession" in refreshed)) return null;520 if (!refreshed.authenticated || !("sealedSession" in refreshed)) return null;
268 return refreshed.sealedSession ?? null;521 return refreshed.sealedSession ?? null;
269 }),522 }),
270 523
524 /**
525 * The WorkOS logout URL for a sealed session, or null when the cookie
526 * doesn't unseal or carries no session id (fail-open: the caller clears
527 * local cookies and redirects home instead). The access token is decoded
528 * WITHOUT signature verification, deliberately — WorkOS's documented
529 * logout flow reads `sid` from a possibly-expired token, and logout must
530 * still work for a stale session. (The SDK's `session.getLogoutUrl()` is
531 * deliberately NOT used: it re-authenticates first and throws on an
532 * expired token.) Pure URL construction, no network call; hitting the
533 * URL is the browser's navigation, which is what ends the AuthKit
534 * session upstream.
535 */
536 logoutUrl: (sessionData: string, returnTo?: string) =>
537 Effect.gen(function* () {
538 const unsealed = yield* Effect.tryPromise({
539 try: () => unsealWorkOSSession(sessionData, cookiePassword),
540 catch: (cause) => new LocalSessionCookieError({ cause }),
541 }).pipe(
542 Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null as unknown | null)),
543 );
544 if (!unsealed) return null;
545
546 const session = Option.getOrNull(decodeSealedSessionPayload(unsealed));
547 if (!session) return null;
548
549 const claims = yield* Effect.try({
550 try: () => decodeJwt(session.accessToken),
551 catch: (cause) => new LocalSessionCookieError({ cause }),
552 }).pipe(
553 Effect.map((jwt) => Option.getOrNull(decodeJwtClaims(jwt))),
554 Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null)),
555 );
556 if (!claims) return null;
557
558 return workos.userManagement.getLogoutUrl({
559 sessionId: claims.sid,
560 ...(returnTo ? { returnTo } : {}),
561 });
562 }),
563
271 /**564 /**
272 * Authenticate a sealed session string. Returns the user info plus565 * Authenticate a sealed session string. Returns the user info plus
273 * any refreshed session that needs to be set on the response.566 * any refreshed session that needs to be set on the response.
@@ -290,10 +583,13 @@ const make = Effect.gen(function* () { · 16 unmodified lines
290 * auth/api-keys.ts.583 * auth/api-keys.ts.
291 */584 */
292 validateApiKey: (value: string) =>585 validateApiKey: (value: string) =>
293 use((wos) => wos.apiKeys.validateApiKey({ value }) as Promise<unknown>),586 use(
587 "apiKeys.validateApiKey",
588 (wos) => wos.apiKeys.validateApiKey({ value }) as Promise<unknown>,
589 ),
294 590
295 listUserApiKeys: (userId: string, organizationId: string) =>591 listUserApiKeys: (userId: string, organizationId: string) =>
296 use(async (wos) => {592 use("userManagement.listUserApiKeys", async (wos) => {
297 const raw = wos as RawWorkOS;593 const raw = wos as RawWorkOS;
298 return collectRawWorkOSList(async (after) => {594 return collectRawWorkOSList(async (after) => {
299 const response = await raw.get(`/user_management/users/${userId}/api_keys`, {595 const response = await raw.get(`/user_management/users/${userId}/api_keys`, {
@@ -308,7 +604,7 @@ const make = Effect.gen(function* () { · 8 unmodified lines
308 }),604 }),
309 605
310 createUserApiKey: (params: { userId: string; organizationId: string; name: string }) =>606 createUserApiKey: (params: { userId: string; organizationId: string; name: string }) =>
311 use(async (wos) => {607 use("userManagement.createUserApiKey", async (wos) => {
312 const raw = wos as RawWorkOS;608 const raw = wos as RawWorkOS;
313 const response = await raw.post(`/user_management/users/${params.userId}/api_keys`, {609 const response = await raw.post(`/user_management/users/${params.userId}/api_keys`, {
314 name: params.name,610 name: params.name,
@@ -317,11 +613,40 @@ const make = Effect.gen(function* () { · 2 unmodified lines
317 return response.data;613 return response.data;
318 }),614 }),
319 615
320 deleteApiKey: (id: string) => use((wos) => wos.apiKeys.deleteApiKey(id)),616 /**
617 * List the ORGANIZATION-owned api keys of an org. Unlike the user-key
618 * paths above, org keys are first-class in the installed SDK
619 * (`organizations.listOrganizationApiKeys`), so no raw escape hatch is
620 * needed. Returned unknown and decoded in `auth/api-keys.ts` like every
621 * other key response.
622 */
623 listOrgApiKeys: (organizationId: string) =>
624 use("organizations.listOrganizationApiKeys", async (wos) =>
625 collectWorkOSList(await wos.organizations.listOrganizationApiKeys({ organizationId })),
626 ),
627
628 /**
629 * Mint an ORGANIZATION-owned api key: the credential that resolves to the
630 * read-only platform view (`PlatformAuth`) instead of an acting member.
631 * Privileged — the caller must be an admin of the org, which is enforced at
632 * the account-provider boundary, not here.
633 */
634 createOrgApiKey: (params: { organizationId: string; name: string }) =>
635 use(
636 "organizations.createOrganizationApiKey",
637 (wos) =>
638 wos.organizations.createOrganizationApiKey({
639 organizationId: params.organizationId,
640 name: params.name,
641 }) as Promise<unknown>,
642 ),
643
644 deleteApiKey: (id: string) =>
645 use("apiKeys.deleteApiKey", (wos) => wos.apiKeys.deleteApiKey(id)),
321 646
322 /** List organization memberships with user details. */647 /** List organization memberships with user details. */
323 listOrgMembers: (organizationId: string) =>648 listOrgMembers: (organizationId: string) =>
324 use(async (wos) =>649 use("userManagement.listOrganizationMemberships", async (wos) =>
325 collectWorkOSList(650 collectWorkOSList(
326 await wos.userManagement.listOrganizationMemberships({651 await wos.userManagement.listOrganizationMemberships({
327 organizationId,652 organizationId,
@@ -332,7 +657,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines
332 657
333 /** Get a user's membership in an organization. */658 /** Get a user's membership in an organization. */
334 getUserOrgMembership: (organizationId: string, userId: string) =>659 getUserOrgMembership: (organizationId: string, userId: string) =>
335 use(async (wos) => {660 use("userManagement.listOrganizationMemberships", async (wos) => {
336 const response = await wos.userManagement.listOrganizationMemberships({661 const response = await wos.userManagement.listOrganizationMemberships({
337 organizationId,662 organizationId,
338 userId,663 userId,
@@ -342,11 +667,23 @@ const make = Effect.gen(function* () { · 3 unmodified lines
342 }),667 }),
343 668
344 /** Get a user by ID. */669 /** Get a user by ID. */
345 getUser: (userId: string) => use((wos) => wos.userManagement.getUser(userId)),670 getUser: (userId: string) =>
671 use("userManagement.getUser", (wos) => wos.userManagement.getUser(userId)),
672
673 /** List users matching an email within one organization. */
674 listUsers: (params: { email: string; organizationId: string }) =>
675 use("userManagement.listUsers", async (wos) =>
676 collectWorkOSList(
677 await wos.userManagement.listUsers({
678 email: params.email,
679 organizationId: params.organizationId,
680 }),
681 ),
682 ),
346 683
347 /** Send an organization invitation. */684 /** Send an organization invitation. */
348 sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) =>685 sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) =>
349 use((wos) =>686 use("userManagement.sendInvitation", (wos) =>
350 wos.userManagement.sendInvitation({687 wos.userManagement.sendInvitation({
351 email: params.email,688 email: params.email,
352 organizationId: params.organizationId,689 organizationId: params.organizationId,
@@ -360,7 +697,7 @@ const make = Effect.gen(function* () { · 7 unmodified lines
360 * API level, so we filter after.697 * API level, so we filter after.
361 */698 */
362 listPendingInvitations: (organizationId: string) =>699 listPendingInvitations: (organizationId: string) =>
363 use(async (wos) =>700 use("userManagement.listInvitations", async (wos) =>
364 collectWorkOSList(701 collectWorkOSList(
365 await wos.userManagement.listInvitations({702 await wos.userManagement.listInvitations({
366 organizationId,703 organizationId,
@@ -375,7 +712,7 @@ const make = Effect.gen(function* () { · 8 unmodified lines
375 712
376 /** List invitations for an email address (across all orgs). */713 /** List invitations for an email address (across all orgs). */
377 listInvitationsByEmail: (email: string) =>714 listInvitationsByEmail: (email: string) =>
378 use(async (wos) =>715 use("userManagement.listInvitations", async (wos) =>
379 collectWorkOSList(716 collectWorkOSList(
380 await wos.userManagement.listInvitations({717 await wos.userManagement.listInvitations({
381 email,718 email,
@@ -385,19 +722,25 @@ const make = Effect.gen(function* () { · 3 unmodified lines
385 722
386 /** Accept an invitation; returns the (now accepted) invitation. */723 /** Accept an invitation; returns the (now accepted) invitation. */
387 acceptInvitation: (invitationId: string) =>724 acceptInvitation: (invitationId: string) =>
388 use((wos) => wos.userManagement.acceptInvitation(invitationId)),725 use("userManagement.acceptInvitation", (wos) =>
726 wos.userManagement.acceptInvitation(invitationId),
727 ),
389 728
390 /** Remove an organization membership. */729 /** Remove an organization membership. */
391 deleteOrgMembership: (membershipId: string) =>730 deleteOrgMembership: (membershipId: string) =>
392 use((wos) => wos.userManagement.deleteOrganizationMembership(membershipId)),731 use("userManagement.deleteOrganizationMembership", (wos) =>
732 wos.userManagement.deleteOrganizationMembership(membershipId),
733 ),
393 734
394 /** Get the role for a membership. */735 /** Get the role for a membership. */
395 getOrgMembership: (membershipId: string) =>736 getOrgMembership: (membershipId: string) =>
396 use((wos) => wos.userManagement.getOrganizationMembership(membershipId)),737 use("userManagement.getOrganizationMembership", (wos) =>
738 wos.userManagement.getOrganizationMembership(membershipId),
739 ),
397 740
398 /** Update a membership's role. */741 /** Update a membership's role. */
399 updateOrgMembershipRole: (membershipId: string, roleSlug: string) =>742 updateOrgMembershipRole: (membershipId: string, roleSlug: string) =>
400 use((wos) =>743 use("userManagement.updateOrganizationMembership", (wos) =>
401 wos.userManagement.updateOrganizationMembership(membershipId, {744 wos.userManagement.updateOrganizationMembership(membershipId, {
402 roleSlug,745 roleSlug,
403 }),746 }),
@@ -405,15 +748,19 @@ const make = Effect.gen(function* () { · 1 unmodified line
405 748
406 /** List available roles for an organization. */749 /** List available roles for an organization. */
407 listOrgRoles: (organizationId: string) =>750 listOrgRoles: (organizationId: string) =>
408 use((wos) => wos.organizations.listOrganizationRoles({ organizationId })),751 use("organizations.listOrganizationRoles", (wos) =>
752 wos.organizations.listOrganizationRoles({ organizationId }),
753 ),
409 754
410 /** Get an organization (includes domains). */755 /** Get an organization (includes domains). */
411 getOrganization: (organizationId: string) =>756 getOrganization: (organizationId: string) =>
412 use((wos) => wos.organizations.getOrganization(organizationId)),757 use("organizations.getOrganization", (wos) =>
758 wos.organizations.getOrganization(organizationId),
759 ),
413 760
414 /** Update an organization. */761 /** Update an organization. */
415 updateOrganization: (organizationId: string, name: string) =>762 updateOrganization: (organizationId: string, name: string) =>
416 use((wos) =>763 use("organizations.updateOrganization", (wos) =>
417 wos.organizations.updateOrganization({764 wos.organizations.updateOrganization({
418 organization: organizationId,765 organization: organizationId,
419 name,766 name,
@@ -425,11 +772,13 @@ const make = Effect.gen(function* () { · 5 unmodified lines
425 * invitations, and domains go with it, so every member loses access.772 * invitations, and domains go with it, so every member loses access.
426 */773 */
427 deleteOrganization: (organizationId: string) =>774 deleteOrganization: (organizationId: string) =>
428 use((wos) => wos.organizations.deleteOrganization(organizationId)),775 use("organizations.deleteOrganization", (wos) =>
776 wos.organizations.deleteOrganization(organizationId),
777 ),
429 778
430 /** Generate an Admin Portal link for domain verification. */779 /** Generate an Admin Portal link for domain verification. */
431 generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) =>780 generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) =>
432 use((wos) =>781 use("portal.generateLink", (wos) =>
433 wos.portal.generateLink({782 wos.portal.generateLink({
434 organization: organizationId,783 organization: organizationId,
435 intent: GeneratePortalLinkIntent.DomainVerification,784 intent: GeneratePortalLinkIntent.DomainVerification,
@@ -439,11 +788,11 @@ const make = Effect.gen(function* () { · 3 unmodified lines
439 788
440 /** Get a domain by ID. */789 /** Get a domain by ID. */
441 getOrganizationDomain: (domainId: string) =>790 getOrganizationDomain: (domainId: string) =>
442 use((wos) => wos.organizationDomains.get(domainId)),791 use("organizationDomains.get", (wos) => wos.organizationDomains.get(domainId)),
443 792
444 /** Delete a domain claim. */793 /** Delete a domain claim. */
445 deleteOrganizationDomain: (domainId: string) =>794 deleteOrganizationDomain: (domainId: string) =>
446 use((wos) => wos.organizationDomains.delete(domainId)),795 use("organizationDomains.delete", (wos) => wos.organizationDomains.delete(domainId)),
447 };796 };
448});797});
449 798
@@ -452,9 +801,10 @@ export type WorkOSClientService = Effect.Success<typeof make>; · 2 unmodified lines
452export class WorkOSClient extends Context.Service<WorkOSClient, WorkOSClientService>()(801export class WorkOSClient extends Context.Service<WorkOSClient, WorkOSClientService>()(
453 "@executor-js/cloud/WorkOSClient",802 "@executor-js/cloud/WorkOSClient",
454) {803) {
455 static Default = Layer.effect(this)(make).pipe(804 // Deliberately unspanned: client construction is synchronous and ran per
456 Layer.withSpan("WorkOSClient", { attributes: { module: "WorkOSClient" } }),805 // layer build, which produced one of the highest-volume zero-duration span
457 );806 // names in the whole trace corpus while telling us nothing.
807 static Default = Layer.effect(this)(make);
458}808}
459 809
460// The boot-scoped WorkOS client root — the one neutral service the stateless810// The boot-scoped WorkOS client root — the one neutral service the stateless
apps/cloud/src/db/db.schema.test.ts+2−2
@@ -2,7 +2,7 @@ · 1 unmodified line
2// merge of cloud + executor schemas. If ANY call site builds its own2// merge of cloud + executor schemas. If ANY call site builds its own
3// `{ schema }` without spreading both, `db._.fullSchema` comes back3// `{ schema }` without spreading both, `db._.fullSchema` comes back
4// missing tables and the drizzle adapter throws4// missing tables and the drizzle adapter throws
5// `unknown model "source"` at the first request that touches scoped data.5// `unknown model "integration"` at the first request that touches scoped data.
6//6//
7// We hit this in prod (MCP endpoint) when `mcp-session.ts` built7// We hit this in prod (MCP endpoint) when `mcp-session.ts` built
8// `combinedSchema = { ...cloudSchema }` and forgot to spread the executor8// `combinedSchema = { ...cloudSchema }` and forgot to spread the executor
@@ -56,7 +56,7 @@ describe("combinedSchema", () => { · 47 unmodified lines
56 }56 }
57 });57 });
58 58
59 // Executor-schema drives the scope-sharded tables (source/tool/etc). If59 // Executor-schema drives the scope-sharded tables (integration/tool/etc). If
60 // any of these go missing the drizzle adapter's `getTable` lookup throws.60 // any of these go missing the drizzle adapter's `getTable` lookup throws.
61 it("includes scope-sharded executor tables", () => {61 it("includes scope-sharded executor tables", () => {
62 for (const key of Object.keys(executorSchema)) {62 for (const key of Object.keys(executorSchema)) {
apps/cloud/src/db/db.ts+1−1
@@ -24,7 +24,7 @@ import * as executorSchema from "./executor-schema"; · 23 unmodified lines
24 24
25// Exported so every drizzle() call in the cloud app shares one schema25// Exported so every drizzle() call in the cloud app shares one schema
26// object. Historically `mcp-session.ts` built its own and forgot to spread26// object. Historically `mcp-session.ts` built its own and forgot to spread
27// `executorSchema`, producing runtime "unknown model source" errors that27// `executorSchema`, producing runtime "unknown model integration" errors that
28// only surfaced in prod. See apps/cloud/src/db/db.schema.test.ts.28// only surfaced in prod. See apps/cloud/src/db/db.schema.test.ts.
29export const combinedSchema = { ...cloudSchema, ...executorSchema };29export const combinedSchema = { ...cloudSchema, ...executorSchema };
30 30
apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts+272−8
@@ -21,6 +21,8 @@ · 20 unmodified lines
21// with DIFFERENT parameter counts (the exact drizzle/postgres-js shape) through21// with DIFFERENT parameter counts (the exact drizzle/postgres-js shape) through
22// one PGLiteSocketServer and asserts zero protocol corruption.22// one PGLiteSocketServer and asserts zero protocol corruption.
23 23
24import { setTimeout as sleep } from "node:timers/promises";
25import { connect, type Socket } from "node:net";
24import { describe, expect, it } from "@effect/vitest";26import { describe, expect, it } from "@effect/vitest";
25import { PGlite } from "@electric-sql/pglite";27import { PGlite } from "@electric-sql/pglite";
26import { PGLiteSocketServer } from "@electric-sql/pglite-socket";28import { PGLiteSocketServer } from "@electric-sql/pglite-socket";
@@ -30,6 +32,50 @@ const PORT = 45998; · 3 unmodified lines
30const CLIENTS = 6;32const CLIENTS = 6;
31const QUERIES_PER_CLIENT = 40;33const QUERIES_PER_CLIENT = 40;
32 34
35const makeClient = (port: number, connectTimeout = 5) =>
36 postgres(`postgres://postgres:postgres@127.0.0.1:${port}/postgres`, {
37 max: 1,
38 idle_timeout: 0,
39 connect_timeout: connectTimeout,
40 fetch_types: false,
41 prepare: true,
42 onnotice: () => undefined,
43 });
44
45// Hand-rolled wire client: connect and complete the trust-auth startup, so a
46// test can then speak raw protocol frames (e.g. a lone Parse) that postgres.js
47// would never emit on its own. Resolves after ReadyForQuery so the next write
48// is its own data event — and its own queue entry — on the server.
49const openWireClient = async (port: number): Promise<Socket> => {
50 const socket: Socket = connect(port, "127.0.0.1");
51 await new Promise<void>((res, rej) => {
52 socket.once("connect", res);
53 socket.once("error", rej);
54 });
55 const startupBody = Buffer.concat([
56 Buffer.from([0, 3, 0, 0]),
57 Buffer.from("user\0postgres\0database\0postgres\0\0"),
58 ]);
59 const startup = Buffer.concat([Buffer.alloc(4), startupBody]);
60 startup.writeInt32BE(startup.length, 0);
61 socket.write(startup);
62 await new Promise<void>((res) => {
63 socket.on("data", (chunk: Buffer) => {
64 if (chunk.includes(0x5a)) res(); // 'Z' = ReadyForQuery
65 });
66 });
67 return socket;
68};
69
70// A Parse frame for an unnamed statement: opens an extended-protocol pipeline
71// that only a later Sync (or the server's recovery) closes.
72const parseFrame = (query: string): Buffer => {
73 const body = Buffer.concat([Buffer.from(`\0${query}\0`), Buffer.from([0, 0])]);
74 const frame = Buffer.concat([Buffer.from("P"), Buffer.alloc(4), body]);
75 frame.writeInt32BE(4 + body.length, 1);
76 return frame;
77};
78
33describe("dev-db PGlite socket under concurrent connections", () => {79describe("dev-db PGlite socket under concurrent connections", () => {
34 it(80 it(
35 "serves interleaved multi-connection pipelines without protocol corruption",81 "serves interleaved multi-connection pipelines without protocol corruption",
@@ -48,14 +94,7 @@ describe("dev-db PGlite socket under concurrent connections", () => { · 12 unmodified lines
48 const errors: string[] = [];94 const errors: string[] = [];
49 95
50 const worker = async (id: number) => {96 const worker = async (id: number) => {
51 const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, {97 const sql = makeClient(PORT, 10);
52 max: 1,
53 idle_timeout: 0,
54 connect_timeout: 10,
55 fetch_types: false,
56 prepare: true,
57 onnotice: () => undefined,
58 });
59 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: postgres.js is promise-native and the socket must be closed on every path98 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: postgres.js is promise-native and the socket must be closed on every path
60 try {99 try {
61 for (let q = 0; q < QUERIES_PER_CLIENT; q++) {100 for (let q = 0; q < QUERIES_PER_CLIENT; q++) {
@@ -91,4 +130,229 @@ describe("dev-db PGlite socket under concurrent connections", () => { · 29 unmodified lines
91 expect(ok).toBe(CLIENTS * QUERIES_PER_CLIENT);130 expect(ok).toBe(CLIENTS * QUERIES_PER_CLIENT);
92 },131 },
93 );132 );
133
134 // Regression for the CI e2e "cloud signIn: callback set no session (500)"
135 // cascade: QueryQueueManager.processQueue used to `return` out of its drain
136 // loop when a query REJECTED (as opposed to returning a wire-level
137 // ErrorResponse), leaving `processing` latched true. From then on every
138 // enqueue — including brand-new connections' startup packets — sat in the
139 // queue forever: in-flight requests hung, postgres.js reconnects died with
140 // CONNECT_TIMEOUT, and the whole dev stack was bricked until restart. The
141 // patch rejects the one entry, drops pipeline affinity, and keeps draining.
142 it(
143 "a rejected query fails one client, not the whole socket server",
144 { timeout: 30_000 },
145 async () => {
146 const port = 45997;
147 const db = await PGlite.create();
148 const server = new PGLiteSocketServer({ db, port, host: "127.0.0.1", maxConnections: 100 });
149 await server.start();
150
151 const first = makeClient(port);
152 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path
153 try {
154 expect((await first.unsafe(`select 1 as one`))[0]).toEqual({ one: 1 });
155
156 // Force the NEXT protocol exchange to reject at the JS level, the shape
157 // PGlite produces when the shared session is broken mid-run.
158 const real = db.execProtocolRawStream.bind(db);
159 let arm = true;
160 (db as { execProtocolRawStream: typeof real }).execProtocolRawStream = (...args) => {
161 if (arm) {
162 arm = false;
163 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: simulating a PGlite internal failure requires a raw throw
164 throw new Error("synthetic PGlite failure");
165 }
166 return real(...args);
167 };
168
169 await expect(first.unsafe(`select 2 as two`)).rejects.toThrow();
170
171 // The poisoned entry must take down only its own connection: a fresh
172 // client (new socket, full startup handshake) still gets served.
173 const second = makeClient(port);
174 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path
175 try {
176 expect((await second.unsafe(`select 3 as three`))[0]).toEqual({ three: 3 });
177 } finally {
178 // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion
179 await second.end({ timeout: 5 }).catch(() => {});
180 }
181 } finally {
182 // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion
183 await first.end({ timeout: 5 }).catch(() => {});
184 await server.stop();
185 await db.close();
186 }
187 },
188 );
189
190 // Regression for the sporadic `write CONNECTION_ENDED` 500s: the server's
191 // idleTimeout backstop used to kill ANY connection with no traffic for the
192 // window, which is the resting state of every healthy postgres.js pool
193 // connection (idle_timeout: 0) held by a long-lived scope. The backstop now
194 // only fires on a connection that is actually blocking the shared session —
195 // an open pipeline or an open transaction.
196 it("an idle-at-rest connection outlives the idle backstop", { timeout: 30_000 }, async () => {
197 const port = 45996;
198 const db = await PGlite.create();
199 const server = new PGLiteSocketServer({
200 db,
201 port,
202 host: "127.0.0.1",
203 maxConnections: 100,
204 idleTimeout: 250,
205 });
206 await server.start();
207
208 const sql = makeClient(port);
209 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path
210 try {
211 expect((await sql.unsafe(`select 1 as one`))[0]).toEqual({ one: 1 });
212 await sleep(900);
213 expect((await sql.unsafe(`select 2 as two`))[0]).toEqual({ two: 2 });
214 } finally {
215 // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion
216 await sql.end({ timeout: 5 }).catch(() => {});
217 await server.stop();
218 await db.close();
219 }
220 });
221
222 // The backstop's actual job still works: a client that opens a pipeline
223 // (Parse sent, never Sync) and goes silent holds queue affinity, which
224 // starves every other connection. The idle timer must reap exactly that
225 // client and hand the queue back.
226 it(
227 "a client stalled mid-pipeline is reaped and the queue recovers",
228 { timeout: 30_000 },
229 async () => {
230 const port = 45995;
231 const db = await PGlite.create();
232 const server = new PGLiteSocketServer({
233 db,
234 port,
235 host: "127.0.0.1",
236 maxConnections: 100,
237 idleTimeout: 250,
238 });
239 await server.start();
240
241 // Hand-rolled wire client: complete the trust-auth startup, then send a
242 // lone Parse. Its last frame type ('P') marks the pipeline open, so the
243 // handler takes affinity and every other connection queues behind it.
244 const staller = await openWireClient(port);
245 staller.write(parseFrame("select 1"));
246
247 const bystander = makeClient(port, 10);
248 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path
249 try {
250 // Connects and queries only once the staller is reaped (~250ms).
251 expect((await bystander.unsafe(`select 4 as four`))[0]).toEqual({ four: 4 });
252 } finally {
253 // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion
254 await bystander.end({ timeout: 5 }).catch(() => {});
255 staller.destroy();
256 await server.stop();
257 await db.close();
258 }
259 },
260 );
261
262 // Regression for the reap SLOT LEAK: detach(true) removes the socket's
263 // listeners before destroying it, so a server-initiated teardown (the idle
264 // backstop) never fired the server's 'close' bookkeeping — the reaped
265 // handler stayed in the server's handlers set forever, burning one
266 // maxConnections slot per reap. Enough reaps over a long run and the server
267 // answers every NEW connection with "Too many connections" while the
268 // process, the port, and PGlite are all healthy — postgres.js surfaces that
269 // as the same CONNECT_TIMEOUT cascade as the queue wedges. The server now
270 // drops the handler when it dispatches its terminal error.
271 it("reaped handlers release their connection slots", { timeout: 30_000 }, async () => {
272 const port = 45993;
273 const db = await PGlite.create();
274 const server = new PGLiteSocketServer({
275 db,
276 port,
277 host: "127.0.0.1",
278 maxConnections: 2,
279 idleTimeout: 250,
280 });
281 await server.start();
282
283 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path
284 try {
285 // Burn through more reaps than there are slots: each staller opens a
286 // pipeline and goes silent, so the idle backstop reaps it (the server
287 // destroys the socket — its 'close' marks that reap complete).
288 for (let i = 0; i < 3; i++) {
289 const staller = await openWireClient(port);
290 staller.write(parseFrame(`select ${i + 1}`));
291 await new Promise<void>((res) => staller.once("close", res));
292 }
293
294 expect(
295 server.getStats().activeConnections,
296 "reaped handlers stay counted against maxConnections",
297 ).toBe(0);
298
299 const sql = makeClient(port);
300 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path
301 try {
302 expect((await sql.unsafe(`select 6 as six`))[0]).toEqual({ six: 6 });
303 } finally {
304 // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion
305 await sql.end({ timeout: 5 }).catch(() => {});
306 }
307 } finally {
308 await server.stop();
309 await db.close();
310 }
311 });
312
313 // Regression for the second wedge mode behind the same CI cascade: a client
314 // whose socket dies WHILE its pipeline-opening entry is executing. detach()
315 // clears pipeline affinity before the entry finishes, so the queue then
316 // assigned affinity to the already-dead handler — and nothing ever cleared
317 // it: the dead handler has no timers left, and every other connection
318 // (including fresh startups) queued behind the ghost forever. The queue now
319 // tracks detached handlers and repairs affinity they can no longer release.
320 it(
321 "a client that dies mid-execution does not leave the queue pinned to its ghost",
322 { timeout: 30_000 },
323 async () => {
324 const port = 45994;
325 const db = await PGlite.create();
326
327 // Hold the marker query in flight long enough that the disconnect below
328 // reliably lands while the entry is EXECUTING (after detach's cleanup,
329 // before the queue takes affinity for it).
330 const real = db.execProtocolRawStream.bind(db);
331 (db as { execProtocolRawStream: typeof real }).execProtocolRawStream = async (...args) => {
332 if (Buffer.from(args[0]).includes("ghost_marker")) await sleep(300);
333 return real(...args);
334 };
335
336 const server = new PGLiteSocketServer({ db, port, host: "127.0.0.1", maxConnections: 100 });
337 await server.start();
338
339 const ghost = await openWireClient(port);
340 ghost.write(parseFrame("select 'ghost_marker'"));
341 // Give the data event time to reach the queue and start executing, then
342 // die without a trace mid-flight.
343 await sleep(100);
344 ghost.destroy();
345
346 const bystander = makeClient(port);
347 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path
348 try {
349 expect((await bystander.unsafe(`select 5 as five`))[0]).toEqual({ five: 5 });
350 } finally {
351 // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion
352 await bystander.end({ timeout: 5 }).catch(() => {});
353 await server.stop();
354 await db.close();
355 }
356 },
357 );
94});358});
apps/cloud/src/db/executor-schema.ts+40−1
@@ -33,6 +33,22 @@ export const integration = pgTable( · 32 unmodified lines
33 (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)],33 (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)],
34);34);
35 35
36export const subject = pgTable(
37 "subject",
38 {
39 external_id: varchar("external_id", { length: 255 }).notNull(),
40 created_at: timestamp("created_at").notNull(),
41 last_seen_at: bigint("last_seen_at", { mode: "bigint" }),
42 status: text("status"),
43 row_id: varchar("row_id", { length: 255 })
44 .primaryKey()
45 .notNull()
46 .$defaultFn(() => createId()),
47 tenant: varchar("tenant", { length: 255 }).notNull(),
48 },
49 (table) => [uniqueIndex("subject_uidx").on(table.tenant, table.external_id)],
50);
51
36export const connection = pgTable(52export const connection = pgTable(
37 "connection",53 "connection",
38 {54 {
@@ -86,6 +102,7 @@ export const oauth_client = pgTable( · 47 unmodified lines
86 origin_kind: text("origin_kind"),102 origin_kind: text("origin_kind"),
87 origin_integration: text("origin_integration"),103 origin_integration: text("origin_integration"),
88 origin_issuer: text("origin_issuer"),104 origin_issuer: text("origin_issuer"),
105 origin_redirect_uri: text("origin_redirect_uri"),
89 created_at: timestamp("created_at").notNull(),106 created_at: timestamp("created_at").notNull(),
90 row_id: varchar("row_id", { length: 255 })107 row_id: varchar("row_id", { length: 255 })
91 .primaryKey()108 .primaryKey()
@@ -164,7 +181,7 @@ export const definition = pgTable( · 72 unmodified lines
164 integration: varchar("integration", { length: 255 }).notNull(),181 integration: varchar("integration", { length: 255 }).notNull(),
165 connection: varchar("connection", { length: 255 }).notNull(),182 connection: varchar("connection", { length: 255 }).notNull(),
166 plugin_id: text("plugin_id").notNull(),183 plugin_id: text("plugin_id").notNull(),
167 name: varchar("name", { length: 255 }).notNull(),184 name: text("name").notNull(),
168 schema: json("schema").notNull(),185 schema: json("schema").notNull(),
169 created_at: timestamp("created_at").notNull(),186 created_at: timestamp("created_at").notNull(),
170 row_id: varchar("row_id", { length: 255 })187 row_id: varchar("row_id", { length: 255 })
@@ -209,6 +226,28 @@ export const tool_policy = pgTable( · 38 unmodified lines
209 ],226 ],
210);227);
211 228
229export const artifact = pgTable(
230 "artifact",
231 {
232 id: varchar("id", { length: 255 }).notNull(),
233 title: text("title").notNull(),
234 description: text("description"),
235 code: text("code").notNull(),
236 bindings: json("bindings"),
237 preview: text("preview"),
238 created_at: timestamp("created_at").notNull(),
239 updated_at: timestamp("updated_at").notNull(),
240 row_id: varchar("row_id", { length: 255 })
241 .primaryKey()
242 .notNull()
243 .$defaultFn(() => createId()),
244 tenant: varchar("tenant", { length: 255 }).notNull(),
245 owner: varchar("owner", { length: 255 }).notNull(),
246 subject: varchar("subject", { length: 255 }).notNull(),
247 },
248 (table) => [uniqueIndex("artifact_uidx").on(table.tenant, table.owner, table.subject, table.id)],
249);
250
212export const plugin_storage = pgTable(251export const plugin_storage = pgTable(
213 "plugin_storage",252 "plugin_storage",
214 {253 {
apps/cloud/src/db/org-deletion.test.ts+113−1
@@ -11,16 +11,24 @@ · 10 unmodified lines
11// - a second org's data is completely untouched11// - a second org's data is completely untouched
12// - the blob prefix match escapes LIKE wildcards (a `_` in the org id must12// - the blob prefix match escapes LIKE wildcards (a `_` in the org id must
13// not widen the match to a look-alike namespace)13// not widen the match to a look-alike namespace)
14//
15// Plus a SCHEMA-DRIFT guard (see "purge coverage" below): the purge list is
16// hand-written, so a table added to the schema without being added to it would
17// leave that org's rows behind forever, silently. The guard derives the set of
18// org-owned tables from the schema itself rather than restating it.
14 19
15import { describe, it, expect } from "@effect/vitest";20import { describe, it, expect } from "@effect/vitest";
16import { Effect } from "effect";21import { Effect } from "effect";
17import { eq, inArray } from "drizzle-orm";22import { eq, getTableColumns, getTableName, inArray } from "drizzle-orm";
18 23
19import { DbService } from "./db";24import { DbService } from "./db";
20import type { DrizzleDb } from "./db";25import type { DrizzleDb } from "./db";
21import { makeUserStore } from "../auth/user-store";26import { makeUserStore } from "../auth/user-store";
27import * as cloudSchema from "./schema";
28import * as executorSchema from "./executor-schema";
22import { memberships, accounts } from "./schema";29import { memberships, accounts } from "./schema";
23import {30import {
31 artifact,
24 blob,32 blob,
25 connection,33 connection,
26 definition,34 definition,
@@ -28,6 +36,7 @@ import { · 1 unmodified line
28 oauth_client,36 oauth_client,
29 oauth_session,37 oauth_session,
30 plugin_storage,38 plugin_storage,
39 subject,
31 tool,40 tool,
32 tool_policy,41 tool_policy,
33} from "./executor-schema";42} from "./executor-schema";
@@ -131,6 +140,24 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { · 97 unmodified lines
131 subject: "s",140 subject: "s",
132 });141 });
133 142
143 await db.insert(subject).values({
144 external_id: `acct-${tag}`,
145 created_at: now,
146 last_seen_at: BigInt(now.getTime()),
147 tenant,
148 });
149
150 await db.insert(artifact).values({
151 id: `art-${tag}`,
152 title: "Dashboard",
153 code: "export default function App() { return null; }",
154 created_at: now,
155 updated_at: now,
156 tenant,
157 owner: "o",
158 subject: "s",
159 });
160
134 const orgNs = `o:${tenant}/plugin`;161 const orgNs = `o:${tenant}/plugin`;
135 const userNs = `u:${tenant}:subject/plugin`;162 const userNs = `u:${tenant}:subject/plugin`;
136 await db.insert(blob).values({163 await db.insert(blob).values({
@@ -156,8 +183,26 @@ const TENANT_TABLES = [ · 19 unmodified lines
156 definition,183 definition,
157 tool_policy,184 tool_policy,
158 plugin_storage,185 plugin_storage,
186 subject,
187 artifact,
159] as const;188] as const;
160 189
190// Tables that are NOT purged by org id, each with the reason it is exempt. Any
191// schema table not carrying a `tenant` column must be listed here — that is
192// what makes the drift guard below total rather than a spot check.
193const NOT_ORG_OWNED: Record<string, string> = {
194 // Purged, but by NAMESPACE PREFIX (`o:<org>/…` / `u:<org>:…`) rather than a
195 // tenant column, so it cannot be checked by the column rule.
196 blob: "org-scoped by namespace prefix, purged via the LIKE match",
197 // Instance-wide, not owned by any org.
198 private_executor_cloud_settings: "singleton instance settings, not org-scoped",
199 // Identity mirror. `organizations` is deleted directly and `memberships`
200 // cascades from its FK; `accounts` deliberately outlives the org.
201 organizations: "the identity row itself, deleted directly",
202 memberships: "cascades from the organizations FK",
203 accounts: "shared across orgs — deliberately survives",
204};
205
161const countTenantRows = async (db: DrizzleDb, tenant: string): Promise<number> => {206const countTenantRows = async (db: DrizzleDb, tenant: string): Promise<number> => {
162 let total = 0;207 let total = 0;
163 for (const table of TENANT_TABLES) {208 for (const table of TENANT_TABLES) {
@@ -249,3 +294,70 @@ describe("purgeOrganizationData", () => { · 85 unmodified lines
249 );294 );
250 }, 20_000);295 }, 20_000);
251});296});
297
298// ---------------------------------------------------------------------------
299// Purge coverage vs the schema
300// ---------------------------------------------------------------------------
301//
302// The purge list in `org-deletion.ts` is hand-written, one `tx.delete(...)` per
303// table. Adding a tenant table to the schema and forgetting that line leaves
304// every deleted org's rows in it forever — no error, no failing test, and the
305// data is exactly what a deletion was supposed to remove.
306//
307// So the expected set is DERIVED: a `tenant` column is what makes a table
308// org-owned, and every table without one must be named in `NOT_ORG_OWNED` with
309// its reason. A new table lands in one of the two buckets automatically, and
310// both buckets fail loudly until it is handled.
311describe("purge coverage", () => {
312 // Every drizzle table the cloud DB is built from (the same two modules
313 // `combinedSchema` spreads), by table name.
314 const allTables = Object.values({ ...cloudSchema, ...executorSchema });
315
316 const orgOwnedTables = allTables.filter((table) => "tenant" in getTableColumns(table));
317 const orgOwnedNames = orgOwnedTables.map(getTableName).sort();
318 const seededNames = TENANT_TABLES.map(getTableName).sort();
319
320 it("classifies every schema table as org-owned or explicitly exempt", () => {
321 const unclassified = allTables
322 .map(getTableName)
323 .filter((name) => !orgOwnedNames.includes(name) && !(name in NOT_ORG_OWNED));
324 expect(
325 unclassified,
326 "a new table must either carry `tenant` (and be purged) or be listed in NOT_ORG_OWNED with a reason",
327 ).toEqual([]);
328 });
329
330 it("purges every org-owned table in the schema", () => {
331 // `TENANT_TABLES` is what this suite seeds and then asserts is empty after
332 // the purge, so keeping it equal to the schema-derived set is what makes
333 // the cascade test above cover the WHOLE schema rather than a snapshot of
334 // it. A tenant table added tomorrow fails here first — the fix is to seed
335 // it, which then fails the cascade test until `purgeOrganizationData`
336 // deletes it too.
337 expect(seededNames, "every org-owned table is seeded and checked by the cascade test").toEqual(
338 orgOwnedNames,
339 );
340 });
341
342 it("seeds a row in every org-owned table, so the cascade assertions are not vacuous", async () => {
343 // Guards the other half: a table listed in TENANT_TABLES but never seeded
344 // would count zero rows before AND after the purge, passing while proving
345 // nothing. Assert the seed actually populates each one.
346 const tenant = `org_seed_${crypto.randomUUID().slice(0, 8)}`;
347 const empty = await program(
348 Effect.gen(function* () {
349 const { db } = yield* DbService;
350 return yield* Effect.promise(async () => {
351 await seedTenant(db, tenant, `seed${crypto.randomUUID().slice(0, 6)}`);
352 const missing: Array<string> = [];
353 for (const table of TENANT_TABLES) {
354 const rows = await db.select().from(table).where(eq(table.tenant, tenant));
355 if (rows.length === 0) missing.push(getTableName(table));
356 }
357 return missing;
358 });
359 }),
360 );
361 expect(empty, "seedTenant must insert a row into every table the cascade checks").toEqual([]);
362 }, 20_000);
363});
apps/cloud/src/db/org-deletion.ts+4−0
@@ -16,6 +16,7 @@ import { eq, or, sql } from "drizzle-orm"; · 15 unmodified lines
16import type { DrizzleDb } from "./db";16import type { DrizzleDb } from "./db";
17import { organizations } from "./schema";17import { organizations } from "./schema";
18import {18import {
19 artifact,
19 blob,20 blob,
20 connection,21 connection,
21 definition,22 definition,
@@ -23,6 +24,7 @@ import { · 1 unmodified line
23 oauth_client,24 oauth_client,
24 oauth_session,25 oauth_session,
25 plugin_storage,26 plugin_storage,
27 subject,
26 tool,28 tool,
27 tool_policy,29 tool_policy,
28} from "./executor-schema";30} from "./executor-schema";
@@ -48,6 +50,8 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr · 19 unmodified lines
48 await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId));50 await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId));
49 await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId));51 await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId));
50 await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId));52 await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId));
53 await tx.delete(subject).where(eq(subject.tenant, organizationId));
54 await tx.delete(artifact).where(eq(artifact.tenant, organizationId));
51 55
52 // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by56 // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by
53 // owner: `o:<org>/<plugin>` (org scope) and `u:<org>:<subject>/<plugin>`57 // owner: `o:<org>/<plugin>` (org scope) and `u:<org>:<subject>/<plugin>`
apps/cloud/src/edge/docs.ts+9−29
@@ -6,43 +6,23 @@ · 5 unmodified lines
6// base path, so the pathname is forwarded UNCHANGED — only the host/proto swap6// base path, so the pathname is forwarded UNCHANGED — only the host/proto swap
7// to the upstream origin (unlike the PostHog proxy, which strips its prefix).7// to the upstream origin (unlike the PostHog proxy, which strips its prefix).
8//8//
9// Like the PostHog/Sentry tunnels (and unlike the marketing proxy, which needs9// The matching, upstream construction, and client span live in `./passthrough`,
10// the prod-only `env.MARKETING` service binding), this is a plain external10// which server.ts dispatches BEFORE Start loads: forwarding a docs page must
11// `fetch`, so it runs on every host — `/docs` previews against live Mintlify in11// not pay for the whole server graph. This middleware stays registered so hosts
12// local dev too. `/docs` is distinct from the app-owned `/api/docs` (Swagger),12// that reach Start by another entry (local dev) keep identical behavior; in the
13// so this never shadows an Effect-served route.13// deployed Worker it is unreachable. `/docs` is distinct from the app-owned
14// `/api/docs` (Swagger), so this never shadows an Effect-served route.
14// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
15 16
16import { createMiddleware } from "@tanstack/react-start";17import { createMiddleware } from "@tanstack/react-start";
17 18
18const DOCS_UPSTREAM_HOST = "executor.mintlify.dev";19import { docsProxyResponse, isDocsPath } from "./passthrough";
19 20
20export const isDocsPath = (pathname: string) =>21export { buildDocsUpstream, isDocsPath } from "./passthrough";
21 pathname === "/docs" || pathname.startsWith("/docs/");
22
23// Build the upstream request for an already-classified `/docs` path. Caller
24// guarantees `isDocsPath(pathname)` — we only swap the origin and fix up the
25// forwarding headers, preserving method, body, path, and query.
26export const buildDocsUpstream = (request: Request): Request => {
27 const url = new URL(request.url);
28 const forwardedHost = url.host;
29
30 url.hostname = DOCS_UPSTREAM_HOST;
31 url.protocol = "https:";
32 url.port = "";
33
34 const upstream = new Request(url, request);
35 // Mintlify keys canonical links off the public host; tell it the real one.
36 upstream.headers.set("X-Forwarded-Host", forwardedHost);
37 upstream.headers.set("X-Forwarded-Proto", "https");
38 // Never leak the executor.sh session cookie to the docs origin.
39 upstream.headers.delete("cookie");
40 return upstream;
41};
42 22
43export const docsProxyMiddleware = createMiddleware({ type: "request" }).server(23export const docsProxyMiddleware = createMiddleware({ type: "request" }).server(
44 ({ pathname, request, next }) => {24 ({ pathname, request, next }) => {
45 if (!isDocsPath(pathname)) return next();25 if (!isDocsPath(pathname)) return next();
46 return fetch(buildDocsUpstream(request));26 return docsProxyResponse(request, pathname);
47 },27 },
48);28);
apps/cloud/src/edge/index.ts+4−5
@@ -1,11 +1,10 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Edge concerns — the analytics/marketing/docs request middlewares that run at2// Edge concerns — request middleware that runs before the app's own mcp + api
3// the worker edge BEFORE the app's own mcp + api dispatch. None of these touch3// dispatch. These proxy or tunnel to external services without touching the
4// the Effect app layer; they proxy or tunnel to external services (the4// Effect app layer. Marketing is dispatched even earlier, from server.ts, so a
5// marketing worker, Sentry, PostHog, Mintlify docs).5// public page never loads the TanStack Start graph.
6// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
7 7
8export { marketingMiddleware } from "./marketing";
9export { sentryTunnelMiddleware } from "./sentry-tunnel";8export { sentryTunnelMiddleware } from "./sentry-tunnel";
10export { posthogProxyMiddleware } from "./posthog";9export { posthogProxyMiddleware } from "./posthog";
11export { docsProxyMiddleware } from "./docs";10export { docsProxyMiddleware } from "./docs";
apps/cloud/src/edge/marketing.test.ts+55−1
@@ -1,6 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { isMarketingPath } from "./marketing";3import { isMarketingPath, marketingProxyRequest } from "./marketing";
4 4
5// On executor.sh the marketing middleware proxies an allow-list of paths to the5// On executor.sh the marketing middleware proxies an allow-list of paths to the
6// `executor-marketing` worker; everything else falls through to the auth-gated6// `executor-marketing` worker; everything else falls through to the auth-gated
@@ -12,6 +12,9 @@ describe("isMarketingPath", () => { · 5 unmodified lines
12 "/home",12 "/home",
13 "/privacy",13 "/privacy",
14 "/terms",14 "/terms",
15 "/about-executor",
16 "/google-oauth",
17 "/google-workspace",
15 "/blog",18 "/blog",
16 "/blog/",19 "/blog/",
17 "/blog/some-post",20 "/blog/some-post",
@@ -37,3 +40,54 @@ describe("isMarketingPath", () => { · 19 unmodified lines
37 });40 });
38 }41 }
39});42});
43
44describe("marketingProxyRequest", () => {
45 it("routes a signed-out homepage request", () => {
46 const request = new Request("https://executor.sh/?source=test");
47
48 const proxied = marketingProxyRequest(request);
49
50 expect(proxied?.url).toBe("https://executor.sh/?source=test");
51 });
52
53 it("leaves the signed-in homepage with the cloud application", () => {
54 const request = new Request("https://executor.sh/", {
55 headers: { cookie: "other=value; wos-session=sealed" },
56 });
57
58 expect(marketingProxyRequest(request)).toBeNull();
59 });
60
61 it("routes public content even when a session cookie is present", () => {
62 const request = new Request("https://executor.sh/blog/post", {
63 headers: { cookie: "wos-session=sealed" },
64 });
65
66 expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/blog/post");
67 });
68
69 it("rewrites the public home alias to the marketing root", () => {
70 const request = new Request("https://executor.sh/home?source=test");
71
72 expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/?source=test");
73 });
74
75 it("preserves the request method, headers, and body", async () => {
76 const request = new Request("https://executor.sh/_astro/_ph/capture", {
77 method: "POST",
78 headers: { "content-type": "application/json", "x-request-id": "request-1" },
79 body: JSON.stringify({ event: "test" }),
80 });
81
82 const proxied = marketingProxyRequest(request);
83
84 expect(proxied?.method).toBe("POST");
85 expect(proxied?.headers.get("x-request-id")).toBe("request-1");
86 await expect(proxied?.json()).resolves.toEqual({ event: "test" });
87 });
88
89 it("does not proxy non-production hosts or app-owned paths", () => {
90 expect(marketingProxyRequest(new Request("http://executor-cloud.localhost/"))).toBeNull();
91 expect(marketingProxyRequest(new Request("https://executor.sh/login"))).toBeNull();
92 });
93});
apps/cloud/src/edge/marketing.ts+25−34
@@ -3,14 +3,10 @@ · 2 unmodified lines
3//3//
4// On the production domain (`executor.sh`), marketing paths and the4// On the production domain (`executor.sh`), marketing paths and the
5// unauthenticated landing page are served by the separate `executor-marketing`5// unauthenticated landing page are served by the separate `executor-marketing`
6// worker (bound as `env.MARKETING`). In local dev that worker isn't running, so6// worker. This module deliberately has no TanStack Start or cloud application
7// unauthenticated visits fall through to the cloud app's routes (the sign-in7// imports: the Worker entry calls it before loading the Start server graph.
8// page).
9// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
10 9
11import { env } from "cloudflare:workers";
12import { createMiddleware } from "@tanstack/react-start";
13
14import { parseCookie } from "../auth/cookies";10import { parseCookie } from "../auth/cookies";
15 11
16const MARKETING_PATHS = [12const MARKETING_PATHS = [
@@ -18,6 +14,9 @@ const MARKETING_PATHS = [ · 1 unmodified line
18 "/setup",14 "/setup",
19 "/privacy",15 "/privacy",
20 "/terms",16 "/terms",
17 "/about-executor",
18 "/google-oauth",
19 "/google-workspace",
21 "/blog",20 "/blog",
22 "/changelog",21 "/changelog",
23 "/changelog.json",22 "/changelog.json",
@@ -29,33 +28,25 @@ const MARKETING_PATHS = [ · 5 unmodified lines
29 "/pattern-graph-paper.svg",28 "/pattern-graph-paper.svg",
30];29];
31 30
32export const isMarketingPath = (pathname: string) =>31const SESSION_COOKIE = "wos-session";
33 MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
34
35const getMarketingWorker = () => env.MARKETING as { fetch: typeof fetch } | undefined;
36
37export const marketingMiddleware = createMiddleware({ type: "request" }).server(
38 async ({ pathname, request, next }) => {
39 // Only proxy to the marketing worker on the production domain. In local
40 // dev we don't run `executor-marketing`, so unauthenticated visits fall
41 // through to the cloud app's routes (which show the sign-in page).
42 const host = new URL(request.url).hostname;
43 if (host !== "executor.sh") return next();
44 32
45 const shouldProxyToMarketing =33/** Whether an exact pathname belongs to the public marketing worker. */
46 isMarketingPath(pathname) ||34export const isMarketingPath = (pathname: string): boolean =>
47 (pathname === "/" && !parseCookie(request.headers.get("cookie"), "wos-session"));35 MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
48
49 if (!shouldProxyToMarketing) return next();
50
51 const marketing = getMarketingWorker();
52 if (!marketing) return next();
53 36
54 const url = new URL(request.url);37/**
55 // Rewrite /home to / so marketing worker serves its homepage38 * Project a production request onto the marketing service-binding request.
56 if (pathname === "/home") {39 * Returns `null` when the cloud application owns the request instead.
57 url.pathname = "/";40 */
58 }41export const marketingProxyRequest = (request: Request): Request | null => {
59 return marketing.fetch(new Request(url, request));42 const url = new URL(request.url);
60 },43 if (url.hostname !== "executor.sh") return null;
61);44
45 const shouldProxy =
46 isMarketingPath(url.pathname) ||
47 (url.pathname === "/" && !parseCookie(request.headers.get("cookie"), SESSION_COOKIE));
48 if (!shouldProxy) return null;
49
50 if (url.pathname === "/home") url.pathname = "/";
51 return new Request(url, request);
52};
apps/cloud/src/edge/passthrough.test.ts+85−0
@@ -0,0 +1,85 @@
1import { readFileSync } from "node:fs";
2import { describe, expect, it } from "@effect/vitest";
3
4import {
5 buildDocsUpstream,
6 buildPosthogUpstream,
7 isDocsPath,
8 isPosthogPath,
9 passthroughResponse,
10 POSTHOG_PROXY_PATH,
11} from "./passthrough";
12
13describe("passthrough matching", () => {
14 it("claims /docs and everything under it, but not /api/docs", () => {
15 expect(isDocsPath("/docs")).toBe(true);
16 expect(isDocsPath("/docs/concepts/policies")).toBe(true);
17 // The app-owned Swagger route must keep reaching the Effect app.
18 expect(isDocsPath("/api/docs")).toBe(false);
19 expect(isDocsPath("/docsearch")).toBe(false);
20 });
21
22 it("claims the PostHog proxy path and its subtree only", () => {
23 expect(isPosthogPath(POSTHOG_PROXY_PATH)).toBe(true);
24 expect(isPosthogPath(`${POSTHOG_PROXY_PATH}/i/v0/e/`)).toBe(true);
25 expect(isPosthogPath(`${POSTHOG_PROXY_PATH}extra`)).toBe(false);
26 expect(isPosthogPath("/api/connections")).toBe(false);
27 });
28
29 it("returns null for app paths so they fall through to normal dispatch", () => {
30 expect(passthroughResponse(new Request("https://executor.sh/"), "/")).toBeNull();
31 expect(
32 passthroughResponse(new Request("https://executor.sh/api/connections"), "/api/connections"),
33 ).toBeNull();
34 expect(passthroughResponse(new Request("https://executor.sh/mcp"), "/mcp")).toBeNull();
35 });
36});
37
38describe("upstream construction", () => {
39 it("forwards the docs path unchanged and strips the session cookie", () => {
40 const upstream = buildDocsUpstream(
41 new Request("https://executor.sh/docs/concepts/policies?x=1", {
42 headers: { cookie: "wos-session=secret" },
43 }),
44 );
45 const url = new URL(upstream.url);
46 expect(url.hostname).toBe("executor.mintlify.dev");
47 expect(url.pathname).toBe("/docs/concepts/policies");
48 expect(url.search).toBe("?x=1");
49 expect(upstream.headers.get("X-Forwarded-Host")).toBe("executor.sh");
50 expect(upstream.headers.get("cookie")).toBeNull();
51 });
52
53 it("strips the proxy prefix for PostHog and splits ingest from assets", () => {
54 const ingest = buildPosthogUpstream(
55 new Request(`https://executor.sh${POSTHOG_PROXY_PATH}/i/v0/e/`),
56 `${POSTHOG_PROXY_PATH}/i/v0/e/`,
57 );
58 expect(new URL(ingest.url).hostname).toBe("us.i.posthog.com");
59 expect(new URL(ingest.url).pathname).toBe("/i/v0/e/");
60
61 const assets = buildPosthogUpstream(
62 new Request(`https://executor.sh${POSTHOG_PROXY_PATH}/static/array.js`),
63 `${POSTHOG_PROXY_PATH}/static/array.js`,
64 );
65 expect(new URL(assets.url).hostname).toBe("us-assets.i.posthog.com");
66 expect(new URL(assets.url).pathname).toBe("/static/array.js");
67 });
68});
69
70describe("Start-graph independence", () => {
71 // The entire point of this module is that server.ts can answer a proxy
72 // request WITHOUT importing TanStack Start. An import of the app or of
73 // `@tanstack/react-start` here silently reintroduces the ~3.1s cold-isolate
74 // `loadEntries` cost this module exists to avoid, and nothing else would
75 // catch it — the behavior stays correct, only slow.
76 it("imports neither TanStack Start nor an app module", () => {
77 const source = readFileSync(new URL("./passthrough.ts", import.meta.url), "utf8");
78 const imports = [...source.matchAll(/^\s*import[^"']*["']([^"']+)["']/gm)].map((m) => m[1]);
79 expect(imports.length).toBeGreaterThan(0);
80 for (const specifier of imports) {
81 expect(specifier).not.toMatch(/@tanstack/);
82 expect(specifier).not.toMatch(/^\.\.\//);
83 }
84 });
85});
apps/cloud/src/edge/passthrough.ts+138−0
@@ -0,0 +1,138 @@
1// ---------------------------------------------------------------------------
2// Pure passthrough proxies — dispatched from the Worker entry, before Start.
3// ---------------------------------------------------------------------------
4//
5// `/docs` and the PostHog proxy forward to an external origin and never touch
6// the router, React, or the Effect app. They lived in Start's request
7// middleware, which meant each one still paid Start's lazy `loadEntries`
8// import of the whole server graph before it could forward a request.
9//
10// Measured on production (2026-08-18), splitting the two costs apart on a cold
11// isolate: the graph import is p50 **3.1s** while the request's own work is
12// p50 **33ms**. And essentially every request is cold — `worker.dispatch` ran
13// 1,666 requests across 1,608 isolates (1.04 req/isolate), because `/mcp`
14// dispatches before `fetchHandler` and so never warms the graph. A `/docs`
15// page took 3-6s through the Worker against 0.098s straight from the upstream.
16//
17// So these move to the Worker entry, exactly as marketing did: classify and
18// forward before anything imports Start. This module therefore must NOT import
19// `@tanstack/react-start` or any app module — that import is the cost it
20// exists to avoid.
21//
22// The middleware wrappers in `./docs` and `./posthog` stay registered. In the
23// deployed Worker they become unreachable (server.ts answers first), but they
24// keep the behavior identical on any host that reaches Start by another entry
25// (local dev), and they source their matching from here so the two can't drift.
26// ---------------------------------------------------------------------------
27
28import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
29
30const DOCS_UPSTREAM_HOST = "executor.mintlify.dev";
31/** PostHog's US ingest origin. Exported because the server-side feature-flag
32 * gate (`../analytics/ema-rollout`) calls the same origin directly, and the
33 * two must not be able to drift onto different PostHog regions. */
34export const POSTHOG_INGEST_HOST = "us.i.posthog.com";
35const POSTHOG_ASSETS_HOST = "us-assets.i.posthog.com";
36
37export const POSTHOG_PROXY_PATH = `/api/${(
38 import.meta.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a"
39).replace(/^\/+|\/+$/g, "")}`;
40
41// The proxy fetch gets its own client span: `/docs` requests otherwise render
42// as a single opaque server span, and during the Aug 2026 regression there
43// was no way to tell upstream (Mintlify/Vercel) latency from worker-side
44// dispatch cost. The noop tracer applies when no provider is installed
45// (local dev without AXIOM_TOKEN), so this is free there.
46const tracer = trace.getTracer("executor-cloud-docs-proxy");
47
48export const isDocsPath = (pathname: string): boolean =>
49 pathname === "/docs" || pathname.startsWith("/docs/");
50
51export const isPosthogPath = (pathname: string): boolean =>
52 pathname === POSTHOG_PROXY_PATH || pathname.startsWith(`${POSTHOG_PROXY_PATH}/`);
53
54/**
55 * Build the upstream request for an already-classified `/docs` path. Caller
56 * guarantees `isDocsPath(pathname)` — we only swap the origin and fix up the
57 * forwarding headers, preserving method, body, path, and query.
58 */
59export const buildDocsUpstream = (request: Request): Request => {
60 const url = new URL(request.url);
61 const forwardedHost = url.host;
62
63 url.hostname = DOCS_UPSTREAM_HOST;
64 url.protocol = "https:";
65 url.port = "";
66
67 const upstream = new Request(url, request);
68 // Mintlify keys canonical links off the public host; tell it the real one.
69 upstream.headers.set("X-Forwarded-Host", forwardedHost);
70 upstream.headers.set("X-Forwarded-Proto", "https");
71 // Never leak the executor.sh session cookie to the docs origin.
72 upstream.headers.delete("cookie");
73 return upstream;
74};
75
76/** Build the upstream request for an already-classified PostHog proxy path. */
77export const buildPosthogUpstream = (request: Request, pathname: string): Request => {
78 const url = new URL(request.url);
79 url.hostname = pathname.startsWith(`${POSTHOG_PROXY_PATH}/static/`)
80 ? POSTHOG_ASSETS_HOST
81 : POSTHOG_INGEST_HOST;
82 url.protocol = "https:";
83 url.port = "";
84 url.pathname = pathname.slice(POSTHOG_PROXY_PATH.length) || "/";
85
86 const upstream = new Request(url, request);
87 upstream.headers.delete("cookie");
88 return upstream;
89};
90
91export const docsProxyResponse = (request: Request, pathname: string): Promise<Response> =>
92 tracer.startActiveSpan(
93 `http.client ${request.method}`,
94 {
95 kind: SpanKind.CLIENT,
96 attributes: {
97 "server.address": DOCS_UPSTREAM_HOST,
98 "url.path": pathname,
99 "http.request.method": request.method,
100 },
101 },
102 async (span) => {
103 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe upstream response/error for span status, then pass both through unchanged
104 try {
105 const response = await fetch(buildDocsUpstream(request));
106 span.setAttribute("http.response.status_code", response.status);
107 if (response.status >= 500) {
108 span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${response.status}` });
109 }
110 return response;
111 } catch (err) {
112 // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- adapter boundary: fetch rejects untyped; normalized only for the OTel span record, the original error is rethrown below
113 const cause = err instanceof Error ? err : String(err);
114 span.recordException(cause);
115 // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: same normalization as the recordException line above
116 const message = typeof cause === "string" ? cause : cause.message;
117 span.setStatus({ code: SpanStatusCode.ERROR, message });
118 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve the original rejection for the platform handler
119 throw err;
120 } finally {
121 span.end();
122 }
123 },
124 );
125
126/**
127 * Answer a pure passthrough request without loading the Start server graph.
128 * Returns `null` when the request belongs to the app, so the caller falls
129 * through to normal dispatch.
130 */
131export const passthroughResponse = (
132 request: Request,
133 pathname: string,
134): Promise<Response> | null => {
135 if (isDocsPath(pathname)) return docsProxyResponse(request, pathname);
136 if (isPosthogPath(pathname)) return fetch(buildPosthogUpstream(request, pathname));
137 return null;
138};
apps/cloud/src/edge/posthog.ts+8−21
@@ -3,33 +3,20 @@ · 2 unmodified lines
3// first-party path and we forward to PostHog's ingest + asset hosts. Keeps3// first-party path and we forward to PostHog's ingest + asset hosts. Keeps
4// events flowing past adblockers that match *.posthog.com. See4// events flowing past adblockers that match *.posthog.com. See
5// https://posthog.com/docs/advanced/proxy/cloudflare5// https://posthog.com/docs/advanced/proxy/cloudflare
6//
7// The matching and forwarding live in `./passthrough`, which server.ts
8// dispatches BEFORE Start loads (a proxy must not pay for the server graph).
9// This middleware stays registered so hosts that reach Start by another entry
10// keep identical behavior; in the deployed Worker it is unreachable.
6// ---------------------------------------------------------------------------11// ---------------------------------------------------------------------------
7 12
8import { createMiddleware } from "@tanstack/react-start";13import { createMiddleware } from "@tanstack/react-start";
9 14
10const POSTHOG_INGEST_HOST = "us.i.posthog.com";15import { buildPosthogUpstream, isPosthogPath } from "./passthrough";
11const POSTHOG_ASSETS_HOST = "us-assets.i.posthog.com";
12const POSTHOG_PROXY_PATH = `/api/${(import.meta.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a").replace(
13 /^\/+|\/+$/g,
14 "",
15)}`;
16 16
17export const posthogProxyMiddleware = createMiddleware({ type: "request" }).server(17export const posthogProxyMiddleware = createMiddleware({ type: "request" }).server(
18 ({ pathname, request, next }) => {18 ({ pathname, request, next }) => {
19 if (pathname !== POSTHOG_PROXY_PATH && !pathname.startsWith(`${POSTHOG_PROXY_PATH}/`)) {19 if (!isPosthogPath(pathname)) return next();
20 return next();20 return fetch(buildPosthogUpstream(request, pathname));
21 }
22
23 const url = new URL(request.url);
24 url.hostname = pathname.startsWith(`${POSTHOG_PROXY_PATH}/static/`)
25 ? POSTHOG_ASSETS_HOST
26 : POSTHOG_INGEST_HOST;
27 url.protocol = "https:";
28 url.port = "";
29 url.pathname = pathname.slice(POSTHOG_PROXY_PATH.length) || "/";
30
31 const upstream = new Request(url, request);
32 upstream.headers.delete("cookie");
33 return fetch(upstream);
34 },21 },
35);22);
apps/cloud/src/engine/execution-gate.ts+7−0
@@ -100,10 +100,17 @@ export const withPreExecutionGate = <E extends Cause.YieldableError>( · 99 unmodified lines
100 ),100 ),
101 // resume is never gated: paused executions must be able to complete.101 // resume is never gated: paused executions must be able to complete.
102 resume: (executionId, response) => engine.resume(executionId, response),102 resume: (executionId, response) => engine.resume(executionId, response),
103 // Optional member, so it must be forwarded explicitly — a decorator that
104 // rebuilds the object literal drops it, and the host then reads every settled
105 // execution as "never existed".
106 isExecutionSettled: engine.isExecutionSettled,
103 getPausedExecution: (executionId) => engine.getPausedExecution(executionId),107 getPausedExecution: (executionId) => engine.getPausedExecution(executionId),
104 pausedExecutionCount: () => engine.pausedExecutionCount(),108 pausedExecutionCount: () => engine.pausedExecutionCount(),
105 hasPausedExecutions: () => engine.hasPausedExecutions(),109 hasPausedExecutions: () => engine.hasPausedExecutions(),
106 getDescription: engine.getDescription,110 getDescription: engine.getDescription,
111 // Forwarded, not re-implemented: the wrapped engine owns the sandbox fibers,
112 // so the host's request-scope teardown has to reach through this decorator.
113 shutdown: engine.shutdown,
107});114});
108 115
109// ---------------------------------------------------------------------------116// ---------------------------------------------------------------------------
apps/cloud/src/engine/execution-rate-limit.node.test.ts+435−0
@@ -0,0 +1,435 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { env } from "cloudflare:workers";
3import { Data, Effect } from "effect";
4import type * as Tracer from "effect/Tracer";
5
6import type { ExecutionEngine } from "@executor-js/execution";
7
8import {
9 ExecutionRateLimiterDO,
10 makeCloudExecutionRateLimiter,
11 makeExecutionRateLimiter,
12 RateLimitCounterError,
13} from "./execution-rate-limit";
14import { RATE_LIMIT_BLOCKED_MESSAGE } from "./execution-limit-messages";
15
16const ORG = "org_test";
17
18/** Stands in for whatever the real lookups fail with (Autumn down, DO down). */
19class UpstreamDownError extends Data.TaggedError("UpstreamDownError")<{
20 readonly which: string;
21}> {}
22
23// A stand-in engine: `execute` resolves to a marker, so a test tells an allowed
24// execution (marker) from a blocked one (the gate's error result) by which of
25// the two came back. A blocked decision never reaches the engine at all.
26const engineStub: ExecutionEngine = {
27 execute: () => Effect.succeed({ result: "ran" }),
28 executeWithPause: () => Effect.succeed({ status: "completed", result: { result: "ran" } }),
29 resume: () => Effect.succeed(null),
30 getPausedExecution: () => Effect.succeed(null),
31 pausedExecutionCount: () => Effect.succeed(0),
32 hasPausedExecutions: () => Effect.succeed(false),
33 getDescription: Effect.succeed("stub"),
34 // The stub forks nothing, so there is no sandbox fiber to end.
35 shutdown: Effect.void,
36};
37
38/** Counter that hands out a caller-controlled sequence of counts. */
39const countingIncrement = (counts: ReadonlyArray<number>) => {
40 let calls = 0;
41 return () => Effect.succeed(counts[Math.min(calls++, counts.length - 1)] ?? 0);
42};
43
44const runExecute = (limiter: ReturnType<typeof makeExecutionRateLimiter>) =>
45 Effect.runPromise(
46 limiter.decorate(ORG, engineStub).execute("code", {
47 // Never invoked: the stub ignores it, and a blocked execution never runs.
48 onElicitation: () => Effect.die("elicitation is not exercised here"),
49 }),
50 );
51
52describe("execution rate limiter — paid exemption", () => {
53 it("allows executions under the cap without consulting the exemption", async () => {
54 let exemptionCalls = 0;
55 const limiter = makeExecutionRateLimiter(countingIncrement([1]), {
56 limit: 10,
57 isExempt: () => {
58 exemptionCalls += 1;
59 return Effect.succeed(false);
60 },
61 });
62
63 expect(await runExecute(limiter)).toMatchObject({ result: "ran" });
64 // The whole point of resolving lazily: the common path costs no lookup.
65 expect(exemptionCalls).toBe(0);
66 });
67
68 it("blocks a non-exempt org over the cap", async () => {
69 const limiter = makeExecutionRateLimiter(countingIncrement([11]), {
70 limit: 10,
71 isExempt: () => Effect.succeed(false),
72 });
73
74 expect(await runExecute(limiter)).toMatchObject({
75 result: null,
76 error: RATE_LIMIT_BLOCKED_MESSAGE,
77 });
78 });
79
80 it("allows an exempt org over the cap", async () => {
81 const limiter = makeExecutionRateLimiter(countingIncrement([11]), {
82 limit: 10,
83 isExempt: () => Effect.succeed(true),
84 });
85
86 expect(await runExecute(limiter)).toMatchObject({ result: "ran" });
87 });
88
89 it("caches the exemption so a paid org past the cap looks it up once", async () => {
90 let exemptionCalls = 0;
91 const limiter = makeExecutionRateLimiter(countingIncrement([11, 12, 13]), {
92 limit: 10,
93 exemptionTtlMs: 60_000,
94 now: () => 1_000,
95 isExempt: () => {
96 exemptionCalls += 1;
97 return Effect.succeed(true);
98 },
99 });
100
101 for (let i = 0; i < 3; i += 1)
102 expect(await runExecute(limiter)).toMatchObject({ result: "ran" });
103 expect(exemptionCalls).toBe(1);
104 });
105
106 it("re-resolves once the cached exemption expires", async () => {
107 let exemptionCalls = 0;
108 let clock = 1_000;
109 const limiter = makeExecutionRateLimiter(countingIncrement([11, 12]), {
110 limit: 10,
111 exemptionTtlMs: 1_000,
112 now: () => clock,
113 isExempt: () => {
114 exemptionCalls += 1;
115 return Effect.succeed(true);
116 },
117 });
118
119 await runExecute(limiter);
120 clock += 5_000;
121 await runExecute(limiter);
122 expect(exemptionCalls).toBe(2);
123 });
124
125 it("blocks when the exemption cannot be resolved and nothing is cached", async () => {
126 // Deliberately NOT fail-open: an unresolvable exemption during an Autumn
127 // outage must not switch the backstop off, which is the one scenario it
128 // exists to cover.
129 const limiter = makeExecutionRateLimiter(countingIncrement([11]), {
130 limit: 10,
131 isExempt: () => Effect.fail(new UpstreamDownError({ which: "autumn" })),
132 });
133
134 expect(await runExecute(limiter)).toMatchObject({
135 result: null,
136 error: RATE_LIMIT_BLOCKED_MESSAGE,
137 });
138 });
139
140 it("honours a stale exemption when a later lookup fails", async () => {
141 let clock = 1_000;
142 let shouldFail = false;
143 const limiter = makeExecutionRateLimiter(countingIncrement([11, 12]), {
144 limit: 10,
145 exemptionTtlMs: 1_000,
146 now: () => clock,
147 isExempt: () =>
148 shouldFail ? Effect.fail(new UpstreamDownError({ which: "autumn" })) : Effect.succeed(true),
149 });
150
151 expect(await runExecute(limiter)).toMatchObject({ result: "ran" });
152
153 // Cache expires and Autumn is now unreachable: the known-paid org keeps
154 // running rather than getting blocked mid-workload by a blip.
155 clock += 5_000;
156 shouldFail = true;
157 expect(await runExecute(limiter)).toMatchObject({ result: "ran" });
158 });
159
160 it("fails open when the counter itself is unreachable", async () => {
161 const limiter = makeExecutionRateLimiter(
162 (organizationId) =>
163 Effect.fail(
164 new RateLimitCounterError({
165 organizationId,
166 code: "unknown",
167 reason: "counter DO unreachable",
168 cause: null,
169 }),
170 ),
171 {
172 limit: 10,
173 isExempt: () => Effect.succeed(false),
174 },
175 );
176
177 expect(await runExecute(limiter)).toMatchObject({ result: "ran" });
178 });
179
180 it("applies the cap when no exemption predicate is wired", async () => {
181 const limiter = makeExecutionRateLimiter(countingIncrement([11]), { limit: 10 });
182
183 expect(await runExecute(limiter)).toMatchObject({
184 result: null,
185 error: RATE_LIMIT_BLOCKED_MESSAGE,
186 });
187 });
188});
189
190// ---------------------------------------------------------------------------
191// Counter observability — the production DO wiring.
192//
193// The counter increment used to be a bare one-argument `Effect.tryPromise`
194// with no span, so a Durable Object fault reached error reporting as
195// `UnknownError: An error occurred in Effect.tryPromise` with no application
196// frames, and the 2s check budget was invisible in traces. These tests pin
197// the named spans, the typed/classified failure, and the timeout override —
198// all read through the REAL `makeCloudExecutionRateLimiter` wiring against a
199// fake `EXECUTION_RATE_LIMITER` binding.
200// ---------------------------------------------------------------------------
201
202type RecordedSpan = {
203 readonly name: string;
204 readonly attributes: Map<string, unknown>;
205};
206
207/** A tracer that keeps every span it is asked to open, with its attributes. */
208const recordingTracer = (recorded: Array<RecordedSpan>): Tracer.Tracer => {
209 let nextId = 1;
210 return {
211 span: (options) => {
212 let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };
213 const attributes = new Map<string, unknown>();
214 recorded.push({ name: options.name, attributes });
215 const id = String(nextId++).padStart(16, "0");
216 return {
217 _tag: "Span",
218 name: options.name,
219 spanId: id,
220 traceId: "00000000000000000000000000000001",
221 parent: options.parent,
222 annotations: options.annotations,
223 get status() {
224 return status;
225 },
226 attributes,
227 links: options.links,
228 sampled: options.sampled,
229 kind: options.kind,
230 end: (endTime, exit) => {
231 status = { _tag: "Ended", startTime: options.startTime, endTime, exit };
232 },
233 attribute: (key, value) => {
234 attributes.set(key, value);
235 },
236 event: () => undefined,
237 addLinks: () => undefined,
238 };
239 },
240 };
241};
242
243const spanNamed = (recorded: ReadonlyArray<RecordedSpan>, name: string): RecordedSpan => {
244 const span = recorded.find((candidate) => candidate.name === name);
245 expect(
246 span,
247 `a span named ${name} is recorded (got: ${recorded.map((s) => s.name).join(", ") || "none"})`,
248 ).toBeDefined();
249 return span ?? { name, attributes: new Map() };
250};
251
252/** A counter-DO namespace whose single RPC method behaves as the test says. */
253const namespaceReturning = (increment: () => Promise<number>) => ({
254 idFromName: (name: string) => ({ toString: () => name }),
255 get: () => ({ increment }),
256});
257
258/** The three worker vars the production limiter reads at construction. */
259type CounterEnv = {
260 EXECUTION_RATE_LIMITER?: unknown;
261 EXECUTION_RATE_LIMIT_PER_HOUR?: string;
262 EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS?: string;
263};
264
265const cloudEnv: CounterEnv = env;
266const savedEnv: CounterEnv = { ...cloudEnv };
267
268// Restore rather than leak: the limiter is built from the worker env, and a
269// stale binding or budget would silently retune a later test.
270afterEach(() => {
271 delete cloudEnv.EXECUTION_RATE_LIMITER;
272 delete cloudEnv.EXECUTION_RATE_LIMIT_PER_HOUR;
273 delete cloudEnv.EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS;
274 Object.assign(cloudEnv, savedEnv);
275});
276
277// The exact platform fault behind the production issue: Cloudflare resets the
278// object and the RPC rejects with a plain Error. The reference id is synthetic.
279const DO_STORAGE_RESET =
280 "Internal error in Durable Object storage caused object to be reset; reference = 0000000000000000";
281
282// oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: the Durable Object RPC is a promise that rejects with a platform Error; the test double has to fail the same way for the classification to mean anything
283const rejectStorageReset = (): Promise<number> => Promise.reject(new Error(DO_STORAGE_RESET));
284
285/** Build the production limiter against a fake binding and worker env. */
286const cloudLimiter = (options: {
287 readonly namespace: ReturnType<typeof namespaceReturning>;
288 readonly limit: string;
289 readonly timeoutMs?: string;
290}): ReturnType<typeof makeExecutionRateLimiter> => {
291 cloudEnv.EXECUTION_RATE_LIMITER = options.namespace;
292 cloudEnv.EXECUTION_RATE_LIMIT_PER_HOUR = options.limit;
293 if (options.timeoutMs !== undefined)
294 cloudEnv.EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS = options.timeoutMs;
295 return makeCloudExecutionRateLimiter(() => Effect.succeed(false));
296};
297
298const runExecuteTraced = (
299 limiter: ReturnType<typeof makeExecutionRateLimiter>,
300 recorded: Array<RecordedSpan>,
301) =>
302 Effect.runPromise(
303 limiter
304 .decorate(ORG, engineStub)
305 .execute("code", { onElicitation: () => Effect.die("elicitation is not exercised here") })
306 .pipe(Effect.withTracer(recordingTracer(recorded))),
307 );
308
309describe("execution rate limiter — counter observability", () => {
310 it("reports a counter-DO fault as a typed, classified error on a named span", async () => {
311 const recorded: Array<RecordedSpan> = [];
312 const limiter = cloudLimiter({
313 namespace: namespaceReturning(rejectStorageReset),
314 limit: "10",
315 });
316 const result = await runExecuteTraced(limiter, recorded);
317
318 // Fail-open semantics are unchanged: a broken counter never blocks.
319 expect(result).toMatchObject({ result: "ran" });
320
321 const increment = spanNamed(recorded, "rate_limit.increment");
322 expect(increment.attributes.get("rate_limit.counter.error_tag")).toBe("RateLimitCounterError");
323 expect(increment.attributes.get("rate_limit.counter.error_code")).toBe("storage_reset");
324
325 const check = spanNamed(recorded, "rate_limit.check");
326 expect(check.attributes.get("rate_limit.check.failed_open")).toBe(true);
327 expect(check.attributes.get("rate_limit.check.timed_out")).toBe(false);
328 expect(check.attributes.get("rate_limit.check.error_tag")).toBe("RateLimitCounterError");
329 });
330
331 it("honours the EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS override", async () => {
332 const recorded: Array<RecordedSpan> = [];
333 const limiter = cloudLimiter({
334 // Answers well past the tiny budget, with a count far over the cap. If
335 // the override were ignored the default 2s budget would let that count
336 // through and BLOCK the execution; honouring it times the check out and
337 // fails open instead, so the two outcomes are distinguishable without
338 // measuring wall time.
339 namespace: namespaceReturning(
340 () => new Promise((resolve) => setTimeout(() => resolve(999), 200)),
341 ),
342 limit: "10",
343 timeoutMs: "5",
344 });
345 const result = await runExecuteTraced(limiter, recorded);
346
347 expect(result).toMatchObject({ result: "ran" });
348
349 const check = spanNamed(recorded, "rate_limit.check");
350 expect(check.attributes.get("rate_limit.check.timed_out")).toBe(true);
351 expect(check.attributes.get("rate_limit.check.error_tag")).toBe("RateLimitCheckTimeoutError");
352 });
353
354 // The healthy check's span (count / limit / blocked / failed_open) is NOT
355 // asserted here: the cloud e2e scenario "the rate-limit counter check is
356 // visible in the exported spans" pins it on the real workerd + Durable
357 // Object topology, against the spans the worker actually exports. The two
358 // cases above stay because neither is reachable from the e2e harness — it
359 // has no fault seam for a Durable Object RPC, and the check budget is a
360 // process-wide worker var that the shared cloud boot cannot vary per
361 // scenario without disabling the backstop for the whole run.
362});
363
364// ---------------------------------------------------------------------------
365// Counter Durable Object
366// ---------------------------------------------------------------------------
367
368/** Minimal DO storage that counts the calls the increment path makes. */
369const fakeStorage = () => {
370 const values = new Map<string, unknown>();
371 const calls = { put: 0, setAlarm: 0, deleteAll: 0 };
372 return {
373 calls,
374 storage: {
375 get: (key: string) => Promise.resolve(values.get(key)),
376 put: (key: string, value: unknown) => {
377 calls.put += 1;
378 values.set(key, value);
379 return Promise.resolve();
380 },
381 setAlarm: () => {
382 calls.setAlarm += 1;
383 return Promise.resolve();
384 },
385 deleteAll: () => {
386 calls.deleteAll += 1;
387 values.clear();
388 return Promise.resolve();
389 },
390 },
391 };
392};
393
394const makeCounter = (storage: ReturnType<typeof fakeStorage>["storage"]) =>
395 // oxlint-disable-next-line executor/no-double-cast -- test double: only the four storage methods the counter uses are implemented
396 new ExecutionRateLimiterDO({ storage } as unknown as DurableObjectState, {} as Env);
397
398describe("execution rate-limit counter DO", () => {
399 it("writes the purge alarm once per window instead of on every increment", async () => {
400 // The alarm only has to outlive the window; rewriting it on every call put
401 // a second durable write on the hot path with the input gate closed.
402 const fake = fakeStorage();
403 const counter = makeCounter(fake.storage);
404
405 expect(await counter.increment(7)).toBe(1);
406 expect(await counter.increment(7)).toBe(2);
407 expect(await counter.increment(7)).toBe(3);
408
409 expect(fake.calls.put, "every increment still persists the count").toBe(3);
410 expect(fake.calls.setAlarm, "the purge alarm is written once for the window").toBe(1);
411 });
412
413 it("moves the purge alarm when the window rolls", async () => {
414 const fake = fakeStorage();
415 const counter = makeCounter(fake.storage);
416
417 await counter.increment(7);
418 await counter.increment(7);
419 expect(await counter.increment(8), "a new window restarts the count").toBe(1);
420
421 expect(fake.calls.setAlarm, "one alarm write per window, not per increment").toBe(2);
422 });
423
424 it("re-arms the purge alarm after it has fired", async () => {
425 const fake = fakeStorage();
426 const counter = makeCounter(fake.storage);
427
428 await counter.increment(7);
429 await counter.alarm();
430 expect(fake.calls.deleteAll, "the alarm purges the counter's storage").toBe(1);
431
432 expect(await counter.increment(7), "the purge reset the window's count").toBe(1);
433 expect(fake.calls.setAlarm, "a purged counter schedules a fresh purge").toBe(2);
434 });
435});
apps/cloud/src/engine/execution-rate-limit.ts+335−37
@@ -1,5 +1,5 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Per-org execution rate limit — an abuse backstop independent of billing.2// Per-org execution rate limit — a free-tier abuse backstop.
3//3//
4// The balance gate (execution-gate.ts) depends on Autumn and fails open, so a4// 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.5// billing outage plus runaway automation could still run unbounded executions.
@@ -8,13 +8,26 @@ · 2 unmodified lines
8// each MCP session lives in its own DO instance, so an in-memory counter8// 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).9// would be per-session and trivially bypassed by opening more sessions).
10//10//
11// Like the balance gate it FAILS OPEN: an unreachable counter DO, a missing11// Paid organizations are EXEMPT. The cap was sized for free-tier abuse but
12// applied to everyone, and on 2026-08-18 it blocked a paying customer
13// mid-workload — their agent gave up on Executor and routed around it. Paid
14// usage is what the balance gate and metered overage are for; this backstop
15// has no business capping it.
16//
17// The exemption is resolved ONLY once the counter reports an org over the cap,
18// so the common path (under the cap) costs the counter increment and nothing
19// else. `isExempt` is an opaque predicate: this module still names no billing
20// concept, and the Autumn coupling lives in `execution-stack-metered.ts`,
21// which already owns that dependency.
22//
23// FAIL OPEN applies to the COUNTER: an unreachable counter DO, a missing
12// binding, or a slow call allows the execution (warn + Sentry). The backstop24// binding, or a slow call allows the execution (warn + Sentry). The backstop
13// must never take executions down with it.25// must never take executions down with it. An unresolved EXEMPTION is the one
26// thing that does not fail open — see `resolveExemption`.
14// ---------------------------------------------------------------------------27// ---------------------------------------------------------------------------
15 28
16import { DurableObject, env } from "cloudflare:workers";29import { DurableObject, env } from "cloudflare:workers";
17import { Data, Effect } from "effect";30import { Data, Effect, Predicate } from "effect";
18import type * as Cause from "effect/Cause";31import type * as Cause from "effect/Cause";
19 32
20import type { ExecutionEngine } from "@executor-js/execution";33import type { ExecutionEngine } from "@executor-js/execution";
@@ -25,14 +38,26 @@ import { RATE_LIMIT_BLOCKED_MESSAGE } from "./execution-limit-messages"; · 4 unmodified lines
25 38
26// Fixed window: all executions in the same clock hour share one counter.39// Fixed window: all executions in the same clock hour share one counter.
27export const RATE_LIMIT_WINDOW_MS = 3_600_000;40export const RATE_LIMIT_WINDOW_MS = 3_600_000;
28// Calibration: the heaviest legitimate org runs ~1.1k executions per MONTH,41// The cap for organizations WITHOUT a paid subscription.
29// so 1000 per HOUR is far above any human-driven usage and only trips on42//
30// runaway automation (the incident this backstops: ~18k in 30 days would43// The original calibration ("the heaviest legitimate org runs ~1.1k executions
31// still pass, which is fine — that class of overrun is the balance gate's44// per MONTH, so 1000 per HOUR is far above any human-driven usage") went stale
32// job; this catches tight loops).45// inside six weeks: by 2026-08-18 a paying org was sustaining ~5.3k executions
46// per DAY and crossed this cap in a single hour. Sizing a shared number
47// against the largest customer is a losing game, so the number no longer tries
48// to describe them — paid orgs are exempt below, and this now has only
49// free-tier abuse to cover, which is what it was picked for.
33export const EXECUTIONS_PER_ORG_PER_HOUR = 1000;50export const EXECUTIONS_PER_ORG_PER_HOUR = 1000;
34// Counter DO slower than this => fail open rather than stall executions.51// Counter DO slower than this => fail open rather than stall executions.
35const RATE_LIMIT_CHECK_TIMEOUT_MS = 2_000;52const RATE_LIMIT_CHECK_TIMEOUT_MS = 2_000;
53// Exemption lookup slower than this => treat as unresolved.
54const EXEMPTION_CHECK_TIMEOUT_MS = 2_000;
55// An org over the cap is checked at most once per TTL rather than once per
56// execution, so a paid org running far past it doesn't hammer the lookup.
57const EXEMPTION_CACHE_TTL_MS = 60_000;
58// Sweep guard, mirroring the balance gate's cache: one long-lived isolate can
59// serve many orgs.
60const EXEMPTION_CACHE_MAX_ENTRIES = 10_000;
36// The DO purges its storage this long after the last increment, so idle orgs61// 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.62// cost nothing. Two windows: long enough that an active window never purges.
38const COUNTER_PURGE_AFTER_MS = 2 * RATE_LIMIT_WINDOW_MS;63const COUNTER_PURGE_AFTER_MS = 2 * RATE_LIMIT_WINDOW_MS;
@@ -51,6 +76,80 @@ class RateLimitCheckTimeoutError extends Data.TaggedError("RateLimitCheckTimeout · 12 unmodified lines
51 readonly timeoutMs: number;76 readonly timeoutMs: number;
52}> {}77}> {}
53 78
79/**
80 * Why a counter DO call failed, as a small closed vocabulary.
81 *
82 * The counter's failures are overwhelmingly transient Cloudflare platform
83 * faults, and they used to arrive at error reporting as one untyped
84 * `UnknownError: An error occurred in Effect.tryPromise` with no application
85 * frames — a group that says nothing and would eventually swallow a real
86 * misconfiguration too. The code is what makes a storage reset (retryable,
87 * expected) distinguishable from an overload or an outright unknown fault.
88 */
89export type RateLimitCounterErrorCode =
90 | "storage_reset"
91 | "overloaded"
92 | "exceeded_memory"
93 | "network"
94 | "unknown";
95
96/** A counter DO call that failed, carrying the classification and the org. */
97export class RateLimitCounterError extends Data.TaggedError("RateLimitCounterError")<{
98 readonly organizationId: string;
99 readonly code: RateLimitCounterErrorCode;
100 readonly reason: string;
101 readonly cause: unknown;
102}> {}
103
104// Cloudflare surfaces these as plain `Error`s with documented message text;
105// there is no structured code to read, so the message is the only signal.
106// (A shared `classifyDurableObjectError` would be the right home for this once
107// one exists.)
108const counterErrorCode = (reason: string): RateLimitCounterErrorCode => {
109 if (/caused object to be reset/i.test(reason)) return "storage_reset";
110 if (/overloaded/i.test(reason)) return "overloaded";
111 if (/exceeded (its )?memory|out of memory/i.test(reason)) return "exceeded_memory";
112 if (/network connection lost|connection.*(lost|reset)/i.test(reason)) return "network";
113 return "unknown";
114};
115
116/**
117 * The fail-open landing: record the outcome on the check span, warn, and allow
118 * the execution. Only failures that are NOT deliberate degradation reach the
119 * error reporter — see the call sites in `decide`.
120 */
121const failOpen = (
122 error: unknown,
123 outcome: { readonly errorTag: string; readonly timedOut: boolean },
124): Effect.Effect<GateDecision> =>
125 Effect.gen(function* () {
126 yield* Effect.annotateCurrentSpan({
127 "rate_limit.blocked": false,
128 "rate_limit.check.failed_open": true,
129 "rate_limit.check.timed_out": outcome.timedOut,
130 "rate_limit.check.error_tag": outcome.errorTag,
131 });
132 yield* Effect.sync(() => {
133 console.warn("[rate-limit] execution rate limit check failed open:", error);
134 });
135 if (!outcome.timedOut) yield* captureCauseEffect(error);
136 return { blocked: false } as const satisfies GateDecision;
137 });
138
139/** Internal sentinel for an exemption lookup that exceeded its time budget. */
140class ExemptionCheckTimeoutError extends Data.TaggedError("ExemptionCheckTimeoutError")<{
141 readonly timeoutMs: number;
142}> {}
143
144/**
145 * Whether an organization is exempt from the cap. Production passes a paid-
146 * subscription check; keeping it an opaque predicate is what lets this module
147 * stay free of any billing import.
148 */
149export type ExecutionRateLimitExemption = (
150 organizationId: string,
151) => Effect.Effect<boolean, unknown>;
152
54// ---------------------------------------------------------------------------153// ---------------------------------------------------------------------------
55// Counter Durable Object — one instance per organization (idFromName(orgId)).154// Counter Durable Object — one instance per organization (idFromName(orgId)).
56// Stores a single { windowId, count } record: an increment in a new window155// Stores a single { windowId, count } record: an increment in a new window
@@ -67,6 +166,12 @@ type WindowRecord = { · 10 unmodified lines
67 166
68export class ExecutionRateLimiterDO extends DurableObject {167export class ExecutionRateLimiterDO extends DurableObject {
69 private readonly counterStorage: DurableObjectState["storage"];168 private readonly counterStorage: DurableObjectState["storage"];
169 /**
170 * The window this instance has already armed the purge alarm for. In-memory
171 * on purpose: it costs no storage read, and a fresh instance (eviction, cold
172 * start) simply re-arms on its first increment.
173 */
174 private purgeArmedForWindow: number | null = null;
70 175
71 constructor(ctx: DurableObjectState, doEnv: Env) {176 constructor(ctx: DurableObjectState, doEnv: Env) {
72 super(ctx, doEnv);177 super(ctx, doEnv);
@@ -80,11 +185,21 @@ export class ExecutionRateLimiterDO extends DurableObject { · 7 unmodified lines
80 const stored = await this.counterStorage.get<WindowRecord>(WINDOW_RECORD_KEY);185 const stored = await this.counterStorage.get<WindowRecord>(WINDOW_RECORD_KEY);
81 const count = stored && stored.windowId === windowId ? stored.count + 1 : 1;186 const count = stored && stored.windowId === windowId ? stored.count + 1 : 1;
82 await this.counterStorage.put(WINDOW_RECORD_KEY, { windowId, count });187 await this.counterStorage.put(WINDOW_RECORD_KEY, { windowId, count });
83 await this.counterStorage.setAlarm(Date.now() + COUNTER_PURGE_AFTER_MS);188 // The alarm only has to outlive the window, and it is set two windows out,
189 // so once per window is enough — rewriting it on every increment put a
190 // second durable write and an alarm-manager update on the hot path of
191 // every execution, with the input gate closed across all three. `count`
192 // back at 1 means the window rolled (or a purge already ran), so the
193 // deadline moves with it.
194 if (count === 1 || this.purgeArmedForWindow !== windowId) {
195 await this.counterStorage.setAlarm(Date.now() + COUNTER_PURGE_AFTER_MS);
196 this.purgeArmedForWindow = windowId;
197 }
84 return count;198 return count;
85 }199 }
86 200
87 async alarm(): Promise<void> {201 async alarm(): Promise<void> {
202 this.purgeArmedForWindow = null;
88 await this.counterStorage.deleteAll();203 await this.counterStorage.deleteAll();
89 }204 }
90}205}
@@ -93,11 +208,17 @@ export class ExecutionRateLimiterDO extends DurableObject { · 2 unmodified lines
93// Client208// Client
94// ---------------------------------------------------------------------------209// ---------------------------------------------------------------------------
95 210
96/** Count one execution for (organizationId, windowId); returns the new count. */211/**
212 * Count one execution for (organizationId, windowId); returns the new count.
213 *
214 * The failure channel is typed rather than `unknown` so the fail-open path can
215 * tell a counter fault from a blown budget by tag, and so error reporting
216 * groups by cause instead of by one opaque `UnknownError`.
217 */
97export type RateLimitIncrement = (218export type RateLimitIncrement = (
98 organizationId: string,219 organizationId: string,
99 windowId: number,220 windowId: number,
100) => Effect.Effect<number, unknown>;221) => Effect.Effect<number, RateLimitCounterError>;
101 222
102export type ExecutionRateLimiter = {223export type ExecutionRateLimiter = {
103 readonly decorate: <E extends Cause.YieldableError>(224 readonly decorate: <E extends Cause.YieldableError>(
@@ -119,12 +240,76 @@ export const makeExecutionRateLimiter = ( · 15 unmodified lines
119 readonly windowMs?: number;240 readonly windowMs?: number;
120 readonly timeoutMs?: number;241 readonly timeoutMs?: number;
121 readonly now?: () => number;242 readonly now?: () => number;
243 readonly isExempt?: ExecutionRateLimitExemption;
244 readonly exemptionTtlMs?: number;
122 },245 },
123): ExecutionRateLimiter => {246): ExecutionRateLimiter => {
124 const limit = options?.limit ?? EXECUTIONS_PER_ORG_PER_HOUR;247 const limit = options?.limit ?? EXECUTIONS_PER_ORG_PER_HOUR;
125 const windowMs = options?.windowMs ?? RATE_LIMIT_WINDOW_MS;248 const windowMs = options?.windowMs ?? RATE_LIMIT_WINDOW_MS;
126 const timeoutMs = options?.timeoutMs ?? RATE_LIMIT_CHECK_TIMEOUT_MS;249 const timeoutMs = options?.timeoutMs ?? RATE_LIMIT_CHECK_TIMEOUT_MS;
127 const now = options?.now ?? Date.now;250 const now = options?.now ?? Date.now;
251 const isExempt = options?.isExempt;
252 const exemptionTtlMs = options?.exemptionTtlMs ?? EXEMPTION_CACHE_TTL_MS;
253
254 const exemptionCache = new Map<
255 string,
256 { readonly exempt: boolean; readonly expiresAtMs: number }
257 >([]);
258
259 const writeExemptionCache = (organizationId: string, exempt: boolean, nowMs: number): void => {
260 if (exemptionCache.size >= EXEMPTION_CACHE_MAX_ENTRIES) {
261 for (const [key, entry] of exemptionCache) {
262 if (entry.expiresAtMs <= nowMs) exemptionCache.delete(key);
263 }
264 // Still saturated after dropping expired entries: reset rather than grow.
265 if (exemptionCache.size >= EXEMPTION_CACHE_MAX_ENTRIES) exemptionCache.clear();
266 }
267 exemptionCache.set(organizationId, { exempt, expiresAtMs: nowMs + exemptionTtlMs });
268 };
269
270 /**
271 * Resolved only for orgs already over the cap, so the lookup never touches
272 * the common path.
273 *
274 * This is the one place that does NOT fail open. The balance gate already
275 * allows executions when Autumn is unreachable; if the exemption did too,
276 * an Autumn outage would switch this backstop off entirely — precisely the
277 * "billing outage plus runaway automation" case it exists to cover. A stale
278 * positive is honoured ahead of that fallback, so a blip can't flip a
279 * known-paid org into a block mid-workload.
280 */
281 const resolveExemption = (organizationId: string): Effect.Effect<boolean> =>
282 Effect.suspend(() => {
283 if (!isExempt) return Effect.succeed(false);
284 const nowMs = now();
285 const cached = exemptionCache.get(organizationId);
286 if (cached && cached.expiresAtMs > nowMs) return Effect.succeed(cached.exempt);
287 return isExempt(organizationId).pipe(
288 Effect.timeoutOrElse({
289 duration: `${EXEMPTION_CHECK_TIMEOUT_MS} millis`,
290 orElse: () =>
291 Effect.fail(new ExemptionCheckTimeoutError({ timeoutMs: EXEMPTION_CHECK_TIMEOUT_MS })),
292 }),
293 Effect.map((exempt) => {
294 writeExemptionCache(organizationId, exempt, nowMs);
295 return exempt;
296 }),
297 Effect.catch((error: unknown) =>
298 Effect.gen(function* () {
299 yield* Effect.sync(() => {
300 console.warn(
301 `[rate-limit] exemption lookup failed for ${organizationId}; treating as ${
302 cached ? "last known" : "not exempt"
303 }:`,
304 error,
305 );
306 });
307 yield* captureCauseEffect(error);
308 return cached?.exempt ?? false;
309 }),
310 ),
311 );
312 });
128 313
129 const decide = (organizationId: string): Effect.Effect<GateDecision> =>314 const decide = (organizationId: string): Effect.Effect<GateDecision> =>
130 Effect.suspend(() => {315 Effect.suspend(() => {
@@ -134,29 +319,86 @@ export const makeExecutionRateLimiter = ( · 3 unmodified lines
134 duration: `${timeoutMs} millis`,319 duration: `${timeoutMs} millis`,
135 orElse: () => Effect.fail(new RateLimitCheckTimeoutError({ timeoutMs })),320 orElse: () => Effect.fail(new RateLimitCheckTimeoutError({ timeoutMs })),
136 }),321 }),
137 Effect.map(322 Effect.flatMap((count): Effect.Effect<GateDecision> => {
138 (count): GateDecision =>323 // Under the cap: no exemption lookup, no extra I/O.
139 count > limit324 if (count <= limit)
140 ? {325 return Effect.as(
141 blocked: true,326 Effect.annotateCurrentSpan({
142 error: new ExecutionRateLimitExceededError({327 "rate_limit.count": count,
143 organizationId,328 "rate_limit.blocked": false,
144 message: RATE_LIMIT_BLOCKED_MESSAGE,329 "rate_limit.check.failed_open": false,
145 }),330 }),
146 }331 { blocked: false },
147 : { blocked: false },332 );
148 ),333 return Effect.gen(function* () {
334 yield* Effect.annotateCurrentSpan({
335 "rate_limit.count": count,
336 "rate_limit.check.failed_open": false,
337 });
338 if (yield* resolveExemption(organizationId)) {
339 yield* Effect.annotateCurrentSpan({
340 "rate_limit.blocked": false,
341 "rate_limit.exempt": true,
342 });
343 return { blocked: false } as const satisfies GateDecision;
344 }
345 // The only record that the backstop fired. A blocked execution is
346 // never usage-tracked (the gate short-circuits before the tracker)
347 // and deliberately not sent to Sentry — a backstop stopping
348 // runaway automation is expected, not exceptional — so without
349 // this line a blocked org is invisible outside a bug report, which
350 // is how the 2026-08-18 block went unnoticed until a customer
351 // sent a screenshot.
352 yield* Effect.sync(() => {
353 console.warn(
354 `[rate-limit] blocked execution for ${organizationId}: ${count} > ${limit} in window ${windowId}`,
355 );
356 });
357 yield* Effect.annotateCurrentSpan({
358 "rate_limit.blocked": true,
359 "rate_limit.exempt": false,
360 });
361 return {
362 blocked: true,
363 error: new ExecutionRateLimitExceededError({
364 organizationId,
365 message: RATE_LIMIT_BLOCKED_MESSAGE,
366 }),
367 } as const satisfies GateDecision;
368 });
369 }),
149 // FAIL OPEN: the backstop must never block executions because its370 // FAIL OPEN: the backstop must never block executions because its
150 // counter is unreachable or slow.371 // counter is unreachable or slow.
372 //
373 // A check that blew its own budget is DELIBERATE degradation, not an
374 // exception — the timeout exists precisely so a slow counter can't
375 // stall a user-facing execution. It is measured on the span
376 // (`rate_limit.check.timed_out`), where a step change in the rate is
377 // alertable, rather than paged per occurrence, which is what buried
378 // real counter failures under one opaque group. Everything else (RPC
379 // faults, a missing binding) still reports.
380 Effect.catchTag("RateLimitCheckTimeoutError", (error) =>
381 failOpen(error, { errorTag: "RateLimitCheckTimeoutError", timedOut: true }),
382 ),
383 // A catch-all rather than a second `catchTag`: fail-open is a hard
384 // requirement and must not depend on the failure being one the types
385 // predicted.
151 Effect.catch((error: unknown) =>386 Effect.catch((error: unknown) =>
152 Effect.gen(function* () {387 failOpen(error, {
153 yield* Effect.sync(() => {388 errorTag: Predicate.isTagged(error, "RateLimitCounterError")
154 console.warn("[rate-limit] execution rate limit check failed open:", error);389 ? "RateLimitCounterError"
155 });390 : "unknown",
156 yield* captureCauseEffect(error);391 timedOut: false,
157 return { blocked: false } as const satisfies GateDecision;
158 }),392 }),
159 ),393 ),
394 Effect.withSpan("rate_limit.check", {
395 attributes: {
396 "rate_limit.organization_id": organizationId,
397 "rate_limit.window_id": windowId,
398 "rate_limit.limit": limit,
399 "rate_limit.check.timeout_ms": timeoutMs,
400 },
401 }),
160 );402 );
161 });403 });
162 404
@@ -185,8 +427,13 @@ type RateLimiterNamespace = { · 22 unmodified lines
185 * Production rate limiter backed by the `EXECUTION_RATE_LIMITER` counter DO.427 * Production rate limiter backed by the `EXECUTION_RATE_LIMITER` counter DO.
186 * When the binding is absent (unit-test workers, older local setups) the428 * When the binding is absent (unit-test workers, older local setups) the
187 * limiter is disabled: every check passes, logged once at construction.429 * limiter is disabled: every check passes, logged once at construction.
430 *
431 * `isExempt` decides which orgs the cap skips; production passes a paid-
432 * subscription check from `execution-stack-metered.ts`.
188 */433 */
189export const makeCloudExecutionRateLimiter = (): ExecutionRateLimiter => {434export const makeCloudExecutionRateLimiter = (
435 isExempt: ExecutionRateLimitExemption,
436): ExecutionRateLimiter => {
190 const limit = resolveRateLimit();437 const limit = resolveRateLimit();
191 const namespace = (env as { EXECUTION_RATE_LIMITER?: RateLimiterNamespace })438 const namespace = (env as { EXECUTION_RATE_LIMITER?: RateLimiterNamespace })
192 .EXECUTION_RATE_LIMITER;439 .EXECUTION_RATE_LIMITER;
@@ -196,17 +443,53 @@ export const makeCloudExecutionRateLimiter = (): ExecutionRateLimiter => { · 3 unmodified lines
196 );443 );
197 return makeExecutionRateLimiter(() => Effect.succeed(0));444 return makeExecutionRateLimiter(() => Effect.succeed(0));
198 }445 }
199 return makeExecutionRateLimiter(446 return makeExecutionRateLimiter(counterIncrement(namespace), {
200 (organizationId, windowId) =>447 limit,
201 Effect.tryPromise(() => {448 timeoutMs: resolveCheckTimeoutMs(),
449 isExempt,
450 });
451};
452
453/**
454 * The counter DO RPC, as a traced and typed increment.
455 *
456 * The span is the whole point: this call is a blocking, cold-startable hop on
457 * the execute hot path, and until it had one nothing about its duration was
458 * measurable — the only evidence it was slow was the fail-open warning 2s
459 * later. The typed error replaces Effect's generic `UnknownError`, which
460 * reported no org, no window, and no hint that a Durable Object was involved.
461 */
462const counterIncrement =
463 (namespace: RateLimiterNamespace): RateLimitIncrement =>
464 (organizationId, windowId) =>
465 Effect.tryPromise({
466 try: () => {
202 const stub = namespace.get(467 const stub = namespace.get(
203 namespace.idFromName(organizationId),468 namespace.idFromName(organizationId),
204 ) as ExecutionRateLimiterStub;469 ) as ExecutionRateLimiterStub;
205 return stub.increment(windowId);470 return stub.increment(windowId);
471 },
472 catch: (cause) => {
473 // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: the Durable Object RPC rejects with a plain platform Error whose message text is the only classification signal Cloudflare gives
474 const reason = cause instanceof Error ? cause.message : String(cause);
475 return new RateLimitCounterError({
476 organizationId,
477 code: counterErrorCode(reason),
478 reason,
479 cause,
480 });
481 },
482 }).pipe(
483 Effect.tapError((error) =>
484 Effect.annotateCurrentSpan({
485 "rate_limit.counter.error_tag": "RateLimitCounterError",
486 "rate_limit.counter.error_code": error.code,
487 }),
488 ),
489 Effect.withSpan("rate_limit.increment", {
490 attributes: { "rate_limit.window_id": windowId },
206 }),491 }),
207 { limit },492 );
208 );
209};
210 493
211/**494/**
212 * The per-org hourly cap: the `EXECUTION_RATE_LIMIT_PER_HOUR` env override495 * The per-org hourly cap: the `EXECUTION_RATE_LIMIT_PER_HOUR` env override
@@ -220,3 +503,18 @@ const resolveRateLimit = (): number => { · 7 unmodified lines
220 const parsed = Number.parseInt(raw, 10);503 const parsed = Number.parseInt(raw, 10);
221 return Number.isInteger(parsed) && parsed > 0 ? parsed : EXECUTIONS_PER_ORG_PER_HOUR;504 return Number.isInteger(parsed) && parsed > 0 ? parsed : EXECUTIONS_PER_ORG_PER_HOUR;
222};505};
506
507/**
508 * The counter's time budget: the `EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS` env
509 * override or `RATE_LIMIT_CHECK_TIMEOUT_MS` when it's unset or unparseable.
510 * Same precedent and same purpose as `EXECUTION_RATE_LIMIT_PER_HOUR`: the
511 * production 2s budget can't be blown on demand, so tests set a tiny one to
512 * drive the fail-open path deterministically. Production leaves it unset.
513 */
514const resolveCheckTimeoutMs = (): number => {
515 const raw = (env as { EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS?: string })
516 .EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS;
517 if (raw === undefined) return RATE_LIMIT_CHECK_TIMEOUT_MS;
518 const parsed = Number.parseInt(raw, 10);
519 return Number.isInteger(parsed) && parsed > 0 ? parsed : RATE_LIMIT_CHECK_TIMEOUT_MS;
520};
apps/cloud/src/engine/execution-stack-metered.ts+15−2
@@ -26,6 +26,7 @@ import { · 25 unmodified lines
26} from "@executor-js/api/server";26} from "@executor-js/api/server";
27 27
28import { AutumnService } from "../extensions/billing/service";28import { AutumnService } from "../extensions/billing/service";
29import { hasPaidOrganizationSubscription } from "../extensions/billing/plans";
29import type { DbService } from "../db/db";30import type { DbService } from "../db/db";
30import { CloudExecutionSeamsLayer } from "../engine/execution-stack";31import { CloudExecutionSeamsLayer } from "../engine/execution-stack";
31import { makeExecutionLimitGate } from "./execution-gate";32import { makeExecutionLimitGate } from "./execution-gate";
@@ -34,7 +35,8 @@ import { withExecutionUsageTracking } from "./execution-usage"; · 2 unmodified lines
34 35
35// Usage-metering decorator bound to the billing service, plus the two36// Usage-metering decorator bound to the billing service, plus the two
36// pre-execution guards this layer owns, ordered cheapest first:37// pre-execution guards this layer owns, ordered cheapest first:
37// 1. rate-limit backstop (counter DO, independent of billing)38// 1. rate-limit backstop (counter DO; free-tier abuse only — paid orgs are
39// exempt via the subscription lookup below)
38// 2. execution balance gate (Autumn check, cached 60s, fails open)40// 2. execution balance gate (Autumn check, cached 60s, fails open)
39// 3. usage tracking — fire-and-forget (`Effect.runFork`) so the billing41// 3. usage tracking — fire-and-forget (`Effect.runFork`) so the billing
40// call can't stall a user-facing execution.42// call can't stall a user-facing execution.
@@ -48,7 +50,18 @@ export const CloudMeteringEngineDecorator: Layer.Layer<EngineDecorator, never, A · 7 unmodified lines
48 const balanceGate = makeExecutionLimitGate((organizationId) =>50 const balanceGate = makeExecutionLimitGate((organizationId) =>
49 autumn.checkExecutionBalance(organizationId),51 autumn.checkExecutionBalance(organizationId),
50 );52 );
51 const rateLimiter = makeCloudExecutionRateLimiter();53 // The limiter's paid-org exemption. This is the billing coupling the
54 // limiter module deliberately avoids owning, and it reads the same
55 // `PAID_AUTUMN_PLAN_IDS` config as the org-creation and seat gates so
56 // "paid" means one thing across the app. The limiter calls this only
57 // for orgs already over the cap and caches the answer, so the extra
58 // Autumn round trip stays off the hot path.
59 const rateLimiter = makeCloudExecutionRateLimiter((organizationId) =>
60 Effect.map(
61 autumn.use((client) => client.customers.getOrCreate({ customerId: organizationId })),
62 (customer) => hasPaidOrganizationSubscription(customer.subscriptions),
63 ),
64 );
52 return {65 return {
53 decorate: (engine, identity: EngineStackIdentity) =>66 decorate: (engine, identity: EngineStackIdentity) =>
54 rateLimiter.decorate(67 rateLimiter.decorate(
apps/cloud/src/engine/execution-stack.ts+116−2
@@ -42,9 +42,17 @@ import { · 41 unmodified lines
42 PluginsProvider,42 PluginsProvider,
43 collectTables,43 collectTables,
44} from "@executor-js/api/server";44} from "@executor-js/api/server";
45import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google";
46import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
45import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";47import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";
48import {
49 IntegrationSlug,
50 type AnyPlugin,
51 type FirstPartyOAuthClientConfig,
52} from "@executor-js/sdk";
46 53
47import executorConfig from "../../executor.config";54import executorConfig from "../../executor.config";
55import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout";
48import { DbService } from "../db/db";56import { DbService } from "../db/db";
49import { cloudDbProviderLayer } from "../db/fuma";57import { cloudDbProviderLayer } from "../db/fuma";
50 58
@@ -54,11 +62,20 @@ export { makeExecutionStack } from "@executor-js/api/server"; · 3 unmodified lines
54// DbProvider rebuilds the fuma client over the same schema.62// DbProvider rebuilds the fuma client over the same schema.
55export const CloudDbProvider = cloudDbProviderLayer(collectTables());63export const CloudDbProvider = cloudDbProviderLayer(collectTables());
56 64
65const cloudPluginFactory = executorConfig.plugins as (deps: {
66 readonly workosCredentials: {
67 readonly apiKey: string;
68 readonly clientId: string;
69 readonly apiUrl?: string;
70 };
71 readonly activeToolkitSlug?: string;
72}) => readonly AnyPlugin[];
73
57// Fresh plugin instances per request, carrying the Worker env's WorkOS Vault74// Fresh plugin instances per request, carrying the Worker env's WorkOS Vault
58// credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`.75// credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`.
59export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({76export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({
60 plugins: (context) =>77 plugins: (context) =>
61 executorConfig.plugins({78 cloudPluginFactory({
62 workosCredentials: {79 workosCredentials: {
63 apiKey: env.WORKOS_API_KEY,80 apiKey: env.WORKOS_API_KEY,
64 clientId: env.WORKOS_CLIENT_ID,81 clientId: env.WORKOS_CLIENT_ID,
@@ -66,7 +83,6 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 1 unmodified line
66 },83 },
67 activeToolkitSlug:84 activeToolkitSlug:
68 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,85 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
69 allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
70 }),86 }),
71});87});
72 88
@@ -79,6 +95,98 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 6 unmodified lines
79 */95 */
80export const CLOUD_MOUNT_PREFIX = "/api" as const;96export const CLOUD_MOUNT_PREFIX = "/api" as const;
81 97
98// Consumer Google launch boundary. Keep this list aligned with the scopes
99// submitted for the Executor-owned production app: ordinary Workspace services
100// plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script,
101// BigQuery, and Cloud Resource Manager have materially different audiences or
102// provider requirements and remain BYO OAuth. The same scope source builds each
103// catalog auth template, preventing picker/start drift.
104const GOOGLE_FIRST_PARTY_PRESET_IDS = [
105 "google-calendar",
106 "google-meet",
107 "google-gmail",
108 "google-sheets",
109 "google-drive",
110 "google-docs",
111 "google-slides",
112 "google-forms",
113 "google-tasks",
114 "google-people",
115 "google-photos-library",
116 "google-photos-picker",
117 "google-search-console",
118] as const;
119
120const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [
121 ...new Set([
122 ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset),
123 // Connections created before the full-Gmail review retain this declared
124 // scope on reconnect. New Gmail presets request `mail.google.com`.
125 "https://www.googleapis.com/auth/gmail.modify",
126 ]),
127];
128
129// Executor-owned provider apps, enabled per provider by setting BOTH env vars
130// (id + secret). Each provider-side registration must list
131// `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug
132// travels inside OAuth `state`, so the single static callback serves every org.
133//
134// The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` /
135// `_TOKEN_URL` overrides exist so tests and dev instances can point the app at
136// an emulated provider (`@executor-js/emulate`) and run the complete flow.
137// Production leaves them unset.
138export const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] => [
139 ...(env.FIRST_PARTY_GITHUB_CLIENT_ID && env.FIRST_PARTY_GITHUB_CLIENT_SECRET
140 ? [
141 {
142 name: "github",
143 authorizationUrl:
144 env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize",
145 tokenUrl:
146 env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token",
147 clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID,
148 clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET,
149 integrations: [IntegrationSlug.make("github_rest")],
150 // GitHub App user access tokens do not use classic OAuth scopes;
151 // their capabilities come from the app's registered permissions.
152 authorizationScopes: [],
153 },
154 ]
155 : []),
156 ...(env.FIRST_PARTY_GOOGLE_CLIENT_ID && env.FIRST_PARTY_GOOGLE_CLIENT_SECRET
157 ? [
158 {
159 name: "google",
160 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
161 tokenUrl: "https://oauth2.googleapis.com/token",
162 clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID,
163 clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET,
164 allowedScopes: GOOGLE_FIRST_PARTY_ALLOWED_SCOPES,
165 // Withdrawn from the connect picker: no new connection is offered the
166 // Executor-owned Google app. The entry stays declared on purpose —
167 // every connection already minted against it keeps refreshing and
168 // reconnecting through it. Deleting this block, or unsetting the env
169 // vars, would strand those connections instead.
170 unlisted: true,
171 },
172 ]
173 : []),
174 ...(env.FIRST_PARTY_SLACK_CLIENT_ID && env.FIRST_PARTY_SLACK_CLIENT_SECRET
175 ? [
176 {
177 name: "slack",
178 authorizationUrl: "https://slack.com/oauth/v2_user/authorize",
179 tokenUrl: "https://slack.com/api/oauth.v2.user.access",
180 resource: "https://mcp.slack.com",
181 clientId: env.FIRST_PARTY_SLACK_CLIENT_ID,
182 clientSecret: env.FIRST_PARTY_SLACK_CLIENT_SECRET,
183 integrations: [IntegrationSlug.make("slack")],
184 allowedScopes: slackMcpUserScopes,
185 },
186 ]
187 : []),
188];
189
82export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({190export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({
83 // SSRF / private-network egress guard. Config-driven, NOT a test flag:191 // SSRF / private-network egress guard. Config-driven, NOT a test flag:
84 // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);192 // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);
@@ -90,6 +198,12 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, ( · 5 unmodified lines
90 // WorkOS Vault is cloud's credential storage implementation detail, not a198 // WorkOS Vault is cloud's credential storage implementation detail, not a
91 // user-selectable provider surface.199 // user-selectable provider surface.
92 exposeCredentialProviders: false,200 exposeCredentialProviders: false,
201 firstPartyOAuthClients: cloudFirstPartyOAuthClients(),
202 // Enterprise-managed authorization ships behind a PostHog flag. Cloud is the
203 // one host with a flag service, so cloud is the one host that installs a
204 // gate; everywhere else the seam stays empty and the profile is attempted as
205 // before. Gating happens at connect only — see the SDK contract.
206 enterpriseManagedRollout: cloudEnterpriseManagedRollout(),
93}));207}));
94 208
95export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync(209export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync(
apps/cloud/src/engine/execution-usage.ts+8−2
@@ -12,14 +12,20 @@ export const withExecutionUsageTracking = <E extends Cause.YieldableError>( · 11 unmodified lines
12 engine12 engine
13 .execute(code, options)13 .execute(code, options)
14 .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))),14 .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))),
15 executeWithPause: (code) =>15 // `options` MUST be forwarded: it carries `autoApprove`, and dropping it
16 // silently turned every auto-approved execution back into a paused one.
17 executeWithPause: (code, options) =>
16 engine18 engine
17 .executeWithPause(code)19 .executeWithPause(code, options)
18 .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))),20 .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))),
19 // resume doesn't count as usage21 // resume doesn't count as usage
20 resume: (executionId, response) => engine.resume(executionId, response),22 resume: (executionId, response) => engine.resume(executionId, response),
23 isExecutionSettled: engine.isExecutionSettled,
21 getPausedExecution: (executionId) => engine.getPausedExecution(executionId),24 getPausedExecution: (executionId) => engine.getPausedExecution(executionId),
22 pausedExecutionCount: () => engine.pausedExecutionCount(),25 pausedExecutionCount: () => engine.pausedExecutionCount(),
23 hasPausedExecutions: () => engine.hasPausedExecutions(),26 hasPausedExecutions: () => engine.hasPausedExecutions(),
24 getDescription: engine.getDescription,27 getDescription: engine.getDescription,
28 // Forwarded, not re-implemented: the wrapped engine owns the sandbox fibers,
29 // so the host's request-scope teardown has to reach through this decorator.
30 shutdown: engine.shutdown,
25});31});
apps/cloud/src/engine/first-party-oauth-clients.test.ts+87−0
@@ -0,0 +1,87 @@
1import { env } from "cloudflare:workers";
2import { beforeAll, describe, expect, it } from "@effect/vitest";
3
4import { cloudFirstPartyOAuthClients } from "./execution-stack";
5
6// The reviewed consumer scope boundary of the Executor-owned Google app.
7//
8// These assertions used to live in `e2e/scenarios/first-party-oauth.test.ts`,
9// read off `listClients`. The app is now `unlisted`, so it has no read surface
10// to introspect — the bundle is only observable on the config it is built from,
11// which is here. The e2e still owns the BEHAVIOUR the boundary produces (which
12// scopes an `oauth.start` requests, and that admin scopes are refused).
13const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`;
14
15describe("cloud first-party oauth clients", () => {
16 beforeAll(() => {
17 env.FIRST_PARTY_GOOGLE_CLIENT_ID = "test-google-client";
18 env.FIRST_PARTY_GOOGLE_CLIENT_SECRET = "test-google-secret";
19 });
20
21 const google = () => cloudFirstPartyOAuthClients().find((client) => client.name === "google");
22
23 it("declares the Google app but withholds it from every listing", () => {
24 const client = google();
25 expect(client, "the env-declared first-party Google app is configured").toBeDefined();
26 // The entry MUST stay declared: `loadClient` resolves it by slug for every
27 // existing connection's refresh and reconnect. `unlisted` is what stops it
28 // being offered for new connections.
29 expect(client?.unlisted).toBe(true);
30 });
31
32 it("covers the reviewed consumer bundle", () => {
33 const allowed = google()?.allowedScopes;
34 expect(allowed).toBeDefined();
35 for (const scope of [
36 "calendar",
37 "meetings.space.readonly",
38 "spreadsheets",
39 "drive.file",
40 "drive",
41 "documents",
42 "presentations",
43 "forms.body",
44 "forms.responses.readonly",
45 "tasks",
46 "contacts",
47 "contacts.other.readonly",
48 "directory.readonly",
49 "user.addresses.read",
50 "user.birthday.read",
51 "user.emails.read",
52 "user.gender.read",
53 "user.organization.read",
54 "user.phonenumbers.read",
55 "photoslibrary.appendonly",
56 "photoslibrary.edit.appcreateddata",
57 "photospicker.mediaitems.readonly",
58 "webmasters",
59 "gmail.settings.basic",
60 ]) {
61 expect(allowed).toContain(GOOGLE_SCOPE(scope));
62 }
63 // `gmail.modify` stays in the host-enforced allowlist on purpose: a
64 // connection created before the full-Gmail review still declares it, and
65 // `resolveFirstPartyScopes` filters discovered scopes through this list, so

22 more lines aren’t shown here.

apps/cloud/src/env-augment.d.ts+35−0

This diff is large: its 56 lines aren’t shown here.

apps/cloud/src/extensions/billing/route.node.test.ts+9−14

This diff is large: its 51 lines aren’t shown here.

apps/cloud/src/extensions/billing/route.ts+5−2

This diff is large: its 18 lines aren’t shown here.

apps/cloud/src/extensions/billing/service.ts+95−10

This diff is large: its 149 lines aren’t shown here.

apps/cloud/src/extensions/docs.ts+7−1

This diff is large: its 20 lines aren’t shown here.

apps/cloud/src/extensions/routes.ts+18−1

This diff is large: its 45 lines aren’t shown here.

apps/cloud/src/mcp-session.e2e.node.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/cloud/src/mcp/agent-handler.ts+130−11

This diff is large: its 213 lines aren’t shown here.

apps/cloud/src/mcp/auth-provider.test.ts+4−2

This diff is large: its 14 lines aren’t shown here.

apps/cloud/src/mcp/auth-provider.ts+23−13

This diff is large: its 66 lines aren’t shown here.

apps/cloud/src/mcp/auth.ts+32−4

This diff is large: its 75 lines aren’t shown here.

apps/cloud/src/mcp/session-durable-object.ts+95−53

This diff is large: its 237 lines aren’t shown here.

apps/cloud/src/mcp/session-meta.node.test.ts+167−0

This diff is large: its 168 lines aren’t shown here.

apps/cloud/src/mcp/session-meta.ts+209−0

This diff is large: its 210 lines aren’t shown here.

apps/cloud/src/mcp/telemetry.test.ts+160−0

This diff is large: its 161 lines aren’t shown here.

apps/cloud/src/mcp/telemetry.ts+51−3

This diff is large: its 95 lines aren’t shown here.

apps/cloud/src/mcp/traceparent.ts+29−0

This diff is large: its 30 lines aren’t shown here.

apps/cloud/src/observability/header-redaction.ts+19−0

This diff is large: its 20 lines aren’t shown here.

apps/cloud/src/observability/index.ts+195−2

This diff is large: its 253 lines aren’t shown here.

apps/cloud/src/observability/observability.test.ts+309−0

This diff is large: its 329 lines aren’t shown here.

apps/cloud/src/observability/telemetry.ts+52−11

This diff is large: its 91 lines aren’t shown here.

apps/cloud/src/org/auth-middleware.ts+5−1

This diff is large: its 13 lines aren’t shown here.

apps/cloud/src/org/handlers.test.ts+1−0

This diff is large: its 8 lines aren’t shown here.

apps/cloud/src/org/handlers.ts+5−0

This diff is large: its 12 lines aren’t shown here.

apps/cloud/src/quickjs-engine.wasm+0−0

Binary file not shown.

apps/cloud/src/quickjs.ts+35−0

This diff is large: its 36 lines aren’t shown here.

apps/cloud/src/routeTree.gen.ts+109−0

This diff is large: its 310 lines aren’t shown here.

apps/cloud/src/routes/__root.tsx+58−57

This diff is large: its 212 lines aren’t shown here.

apps/cloud/src/routes/app/api-keys.tsx+9−3

This diff is large: its 18 lines aren’t shown here.

apps/cloud/src/routes/app/billing_.plans.tsx+28−9

This diff is large: its 70 lines aren’t shown here.

apps/cloud/src/routes/bare/login.tsx+1−1

This diff is large: its 9 lines aren’t shown here.

apps/cloud/src/server.ts+289−71

This diff is large: its 450 lines aren’t shown here.

apps/cloud/src/start.ts+25−18

This diff is large: its 82 lines aren’t shown here.

apps/cloud/src/test-globalsetup-exit.node.test.ts+41−0

This diff is large: its 42 lines aren’t shown here.

apps/cloud/src/wasm.d.ts+6−0

This diff is large: its 7 lines aren’t shown here.

apps/cloud/src/web/auth.tsx+11−0

This diff is large: its 25 lines aren’t shown here.

apps/cloud/src/web/pages/login.tsx+3−1

This diff is large: its 11 lines aren’t shown here.

apps/cloud/src/web/shell.tsx+20−5

This diff is large: its 53 lines aren’t shown here.

apps/cloud/test-fixtures/test-globalsetup-exit/fixture.test.ts+7−0

This diff is large: its 8 lines aren’t shown here.

apps/cloud/test-fixtures/test-globalsetup-exit/vitest.config.ts+12−0

This diff is large: its 13 lines aren’t shown here.

apps/cloud/tsconfig.json+1−0

This diff is large: its 8 lines aren’t shown here.

apps/cloud/vite.config.ts+19−1

This diff is large: its 48 lines aren’t shown here.

apps/cloud/wrangler.jsonc+30−0

This diff is large: its 60 lines aren’t shown here.

apps/desktop/CHANGELOG.md+28−0

This diff is large: its 34 lines aren’t shown here.

apps/desktop/build/entitlements.mac.plist+0−21

This diff is large: its 22 lines aren’t shown here.

apps/desktop/build/icon.png+0−0

Binary file not shown.

apps/desktop/package.json+1−3

This diff is large: its 20 lines aren’t shown here.

apps/desktop/src/main/app-quit.ts+23−0

This diff is large: its 24 lines aren’t shown here.

apps/desktop/src/main/crash-fingerprint.test.ts+143−0

This diff is large: its 144 lines aren’t shown here.

apps/desktop/src/main/crash-fingerprint.ts+80−0

This diff is large: its 81 lines aren’t shown here.

apps/desktop/src/main/crash-screen.test.ts+26−0

This diff is large: its 27 lines aren’t shown here.

apps/desktop/src/main/diagnostics.ts+9−12

This diff is large: its 35 lines aren’t shown here.

apps/desktop/src/main/index.ts+51−6

This diff is large: its 100 lines aren’t shown here.

apps/desktop/src/main/navigation-errors.test.ts+40−0

This diff is large: its 41 lines aren’t shown here.

apps/desktop/src/main/navigation-errors.ts+21−0

This diff is large: its 22 lines aren’t shown here.

apps/desktop/src/main/settings-permissions.test.ts+92−0

This diff is large: its 93 lines aren’t shown here.

apps/desktop/src/main/settings.ts+10−0

This diff is large: its 17 lines aren’t shown here.

apps/desktop/src/main/sidecar-exit-wiring.test.ts+163−0

This diff is large: its 164 lines aren’t shown here.

apps/desktop/src/main/sidecar-exit.test.ts+70−0

This diff is large: its 71 lines aren’t shown here.

apps/desktop/src/main/sidecar-exit.ts+50−0

This diff is large: its 51 lines aren’t shown here.

apps/desktop/src/main/sidecar.ts+31−8

This diff is large: its 65 lines aren’t shown here.

apps/desktop/src/sidecar/native-bindings.ts+18−5

This diff is large: its 34 lines aren’t shown here.

apps/docs/concepts/integrations.mdx+1−1

This diff is large: its 9 lines aren’t shown here.

apps/docs/docs.json+1−1

This diff is large: its 9 lines aren’t shown here.

apps/docs/hosted/docker.mdx+3−0

This diff is large: its 10 lines aren’t shown here.

apps/docs/hosted/tracing.mdx+115−0

This diff is large: its 116 lines aren’t shown here.

apps/docs/local/cli.mdx+3−3

This diff is large: its 24 lines aren’t shown here.

apps/host-cloudflare/README.md+13−9

This diff is large: its 42 lines aren’t shown here.

apps/host-cloudflare/executor.config.ts+12−5

This diff is large: its 30 lines aren’t shown here.

apps/host-cloudflare/package.json+6−4

This diff is large: its 37 lines aren’t shown here.

apps/host-cloudflare/scripts/assert-shell-asset.mjs+17−0

This diff is large: its 18 lines aren’t shown here.

apps/host-cloudflare/scripts/deploy.sh+11−7

This diff is large: its 38 lines aren’t shown here.

apps/host-cloudflare/src/account/account-provider.ts+5−0

This diff is large: its 12 lines aren’t shown here.

apps/host-cloudflare/src/auth/cloudflare-access.ts+6−1

This diff is large: its 33 lines aren’t shown here.

apps/host-cloudflare/src/config.test.ts+74−0

This diff is large: its 75 lines aren’t shown here.

apps/host-cloudflare/src/config.ts+48−5

This diff is large: its 101 lines aren’t shown here.

apps/host-cloudflare/src/db/d1.test.ts+207−0

This diff is large: its 208 lines aren’t shown here.

apps/host-cloudflare/src/db/d1.ts+84−5

This diff is large: its 110 lines aren’t shown here.

apps/host-cloudflare/src/db/data-migrations.test.ts+60−5

This diff is large: its 103 lines aren’t shown here.

apps/host-cloudflare/src/db/data-migrations.ts+101−7

This diff is large: its 156 lines aren’t shown here.

apps/host-cloudflare/src/execution.ts+10−5

This diff is large: its 37 lines aren’t shown here.

apps/host-cloudflare/src/mcp/agent-handler.ts+4−0

This diff is large: its 19 lines aren’t shown here.

apps/host-cloudflare/src/mcp/session-durable-object.ts+44−3

This diff is large: its 99 lines aren’t shown here.

apps/host-cloudflare/src/plugins.ts+12−5

This diff is large: its 29 lines aren’t shown here.

apps/host-cloudflare/src/worker.e2e.node.test.ts+127−13

This diff is large: its 188 lines aren’t shown here.

apps/host-cloudflare/src/worker.ts+19−1

This diff is large: its 34 lines aren’t shown here.

apps/host-cloudflare/test-stubs/cloudflare-workers.ts+7−0

This diff is large: its 8 lines aren’t shown here.

apps/host-cloudflare/tsconfig.json+4−0

This diff is large: its 11 lines aren’t shown here.

apps/host-cloudflare/tsr.routes.ts+5−1

This diff is large: its 12 lines aren’t shown here.

apps/host-cloudflare/vite.config.ts+8−1

This diff is large: its 27 lines aren’t shown here.

apps/host-cloudflare/vitest.config.ts+7−0

This diff is large: its 14 lines aren’t shown here.

apps/host-cloudflare/web/routeTree.gen.ts+86−0

This diff is large: its 229 lines aren’t shown here.

apps/host-cloudflare/web/routes/__root.tsx+15−1

This diff is large: its 37 lines aren’t shown here.

apps/host-cloudflare/wrangler.jsonc+22−7

This diff is large: its 61 lines aren’t shown here.

apps/host-selfhost/.env.example+6−0

This diff is large: its 10 lines aren’t shown here.

apps/host-selfhost/CHANGELOG.md+290−0

This diff is large: its 296 lines aren’t shown here.

apps/host-selfhost/Dockerfile+1−1

This diff is large: its 7 lines aren’t shown here.

apps/host-selfhost/executor.config.ts+22−14

This diff is large: its 58 lines aren’t shown here.

apps/host-selfhost/package.json+9−5

This diff is large: its 53 lines aren’t shown here.

apps/host-selfhost/scripts/assert-shell-asset.mjs+18−0

This diff is large: its 19 lines aren’t shown here.

apps/host-selfhost/scripts/package-runtime.ts+55−1

This diff is large: its 90 lines aren’t shown here.

apps/host-selfhost/src/account/better-auth-account-provider.ts+27−0

This diff is large: its 34 lines aren’t shown here.

apps/host-selfhost/src/admin/admin-escalation.node.test.ts+184−0

This diff is large: its 185 lines aren’t shown here.

apps/host-selfhost/src/admin/admin-users-api.ts+225−0

This diff is large: its 226 lines aren’t shown here.

apps/host-selfhost/src/admin/admin-users.node.test.ts+234−0

This diff is large: its 235 lines aren’t shown here.

apps/host-selfhost/src/admin/handlers.ts+14−14

This diff is large: its 50 lines aren’t shown here.

apps/host-selfhost/src/admin/require-admin.ts+126−0

This diff is large: its 127 lines aren’t shown here.

apps/host-selfhost/src/analytics.ts+83−0

This diff is large: its 84 lines aren’t shown here.

apps/host-selfhost/src/app.ts+28−4

This diff is large: its 70 lines aren’t shown here.

apps/host-selfhost/src/auth/better-auth.ts+20−1

This diff is large: its 28 lines aren’t shown here.

apps/host-selfhost/src/auth/identity.ts+1−0

This diff is large: its 8 lines aren’t shown here.

apps/host-selfhost/src/auth/return-to.test.ts+45−1

This diff is large: its 59 lines aren’t shown here.

apps/host-selfhost/src/auth/return-to.ts+29−0

This diff is large: its 33 lines aren’t shown here.

apps/host-selfhost/src/config.ts+25−0

This diff is large: its 42 lines aren’t shown here.

apps/host-selfhost/src/db/data-migrations.ts+11−1

This diff is large: its 29 lines aren’t shown here.

apps/host-selfhost/src/execution.ts+20−9

This diff is large: its 79 lines aren’t shown here.

apps/host-selfhost/src/executor-config.test.ts+59−0

This diff is large: its 60 lines aren’t shown here.

apps/host-selfhost/src/integrations-mcp.test.tsrenamed from apps/host-selfhost/src/sources-mcp.test.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/host-selfhost/src/integrations.test.tsrenamed from apps/host-selfhost/src/sources.test.ts+0−0

File renamed without changes.

apps/host-selfhost/src/mcp/auth.ts+7−2

This diff is large: its 28 lines aren’t shown here.

apps/host-selfhost/src/mcp/mcp.test.ts+1−0

This diff is large: its 8 lines aren’t shown here.

apps/host-selfhost/src/mcp/org-path.test.ts+18−1

This diff is large: its 29 lines aren’t shown here.

apps/host-selfhost/src/mcp/org-path.ts+12−0

This diff is large: its 19 lines aren’t shown here.

apps/host-selfhost/src/mcp/session-store.ts+11−0

This diff is large: its 32 lines aren’t shown here.

apps/host-selfhost/src/platform-credential.test.ts+210−0

This diff is large: its 211 lines aren’t shown here.

apps/host-selfhost/src/serve.ts+37−2

This diff is large: its 75 lines aren’t shown here.

apps/host-selfhost/src/subject-sightings.test.ts+82−0

This diff is large: its 83 lines aren’t shown here.

apps/host-selfhost/src/telemetry.test.ts+142−0

This diff is large: its 143 lines aren’t shown here.

apps/host-selfhost/src/telemetry.ts+122−0

This diff is large: its 123 lines aren’t shown here.

apps/host-selfhost/src/testing/test-app.ts+43−2

This diff is large: its 111 lines aren’t shown here.

apps/host-selfhost/tsconfig.json+4−0

This diff is large: its 11 lines aren’t shown here.

apps/host-selfhost/vite.config.ts+7−0

This diff is large: its 21 lines aren’t shown here.

apps/host-selfhost/web/login.tsx+5−10

This diff is large: its 29 lines aren’t shown here.

apps/host-selfhost/web/routeTree.gen.ts+109−0

This diff is large: its 275 lines aren’t shown here.

apps/host-selfhost/web/routes/__root.tsx+25−2

This diff is large: its 74 lines aren’t shown here.

apps/local/CHANGELOG.md+110−0

This diff is large: its 112 lines aren’t shown here.

apps/local/drizzle/0004_cultured_shadowcat.sql+1−0

This diff is large: its 2 lines aren’t shown here.

apps/local/drizzle/meta/0004_snapshot.json+927−0

This diff is large: its 928 lines aren’t shown here.

apps/local/drizzle/meta/_journal.json+7−0

This diff is large: its 14 lines aren’t shown here.

apps/local/executor.config.ts+18−6

This diff is large: its 41 lines aren’t shown here.

apps/local/package.json+5−4

This diff is large: its 43 lines aren’t shown here.

apps/local/src/analytics.ts+68−0

This diff is large: its 69 lines aren’t shown here.

apps/local/src/app.ts+23−6

This diff is large: its 58 lines aren’t shown here.

apps/local/src/auth-tool-failures.test.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/local/src/db/data-migrations.ts+7−1

This diff is large: its 28 lines aren’t shown here.

apps/local/src/db/db-file-permissions.test.ts+99−0

This diff is large: its 100 lines aren’t shown here.

apps/local/src/db/executor-schema.ts+1−0

This diff is large: its 8 lines aren’t shown here.

apps/local/src/db/libsql.ts+38−0

This diff is large: its 57 lines aren’t shown here.

apps/local/src/db/sqlite-fumadb.ts+6−0

This diff is large: its 13 lines aren’t shown here.

apps/local/src/executor.ts+48−18

This diff is large: its 117 lines aren’t shown here.

apps/local/src/identity.ts+1−0

This diff is large: its 8 lines aren’t shown here.

apps/local/src/installation.ts+5−4

This diff is large: its 17 lines aren’t shown here.

apps/local/src/main.ts+72−12

This diff is large: its 105 lines aren’t shown here.

apps/local/src/mcp-browser-resume.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/local/src/mcp.ts+13−1

This diff is large: its 37 lines aren’t shown here.

apps/local/src/serve.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/local/src/serve.ts+42−11

This diff is large: its 113 lines aren’t shown here.

apps/marketing/src/assets/yc-backed-by.svg+1−15

This diff is large: its 25 lines aren’t shown here.

apps/marketing/src/components/self-host-contact-modal.tsx+0−126

This diff is large: its 127 lines aren’t shown here.

apps/marketing/src/layouts/Layout.astro+11−4

This diff is large: its 29 lines aren’t shown here.

apps/marketing/src/pages/about-executor.astro+158−0

This diff is large: its 159 lines aren’t shown here.

apps/marketing/src/pages/google-oauth.astro+554−0

This diff is large: its 555 lines aren’t shown here.

apps/marketing/src/pages/google-workspace.astro+218−0

This diff is large: its 219 lines aren’t shown here.

apps/marketing/src/pages/index.astro+8−4

This diff is large: its 44 lines aren’t shown here.

apps/marketing/src/pages/privacy.astro+35−2

This diff is large: its 58 lines aren’t shown here.

assets/executor-demo.gif+0−0

Binary file not shown.

autumn.config.ts+2−2

This diff is large: its 18 lines aren’t shown here.

bun.lock+363−215

This diff is large: its 1477 lines aren’t shown here.

design.md+6−6

This diff is large: its 30 lines aren’t shown here.

e2e/AGENTS.md+42−280

This diff is large: its 323 lines aren’t shown here.

e2e/CHANGELOG.md+168−0

This diff is large: its 174 lines aren’t shown here.

e2e/RUNNING.md+283−0

This diff is large: its 284 lines aren’t shown here.

e2e/cli/service-lifecycle.test.ts+2−2

This diff is large: its 18 lines aren’t shown here.

e2e/cloud/admin-users-console.test.ts+390−0

This diff is large: its 391 lines aren’t shown here.

e2e/cloud/admin-users.test.ts+322−0

This diff is large: its 323 lines aren’t shown here.

e2e/cloud/auth-hint.test.ts+21−3

This diff is large: its 31 lines aren’t shown here.

e2e/cloud/auth-routing-flow.test.ts+3−2

This diff is large: its 26 lines aren’t shown here.

e2e/cloud/auth-session.test.ts+21−3

This diff is large: its 40 lines aren’t shown here.

e2e/cloud/billing-customer-provisioning.test.ts+281−0

This diff is large: its 282 lines aren’t shown here.

e2e/cloud/billing-trial-checkout-stale.test.ts+3−2

This diff is large: its 23 lines aren’t shown here.

e2e/cloud/cli-device-login.test.ts+3−3

This diff is large: its 24 lines aren’t shown here.

e2e/cloud/connect-card-origin.test.ts+55−0

This diff is large: its 56 lines aren’t shown here.

e2e/cloud/connect-card-ssr-origin.test.ts+0−79

This diff is large: its 80 lines aren’t shown here.

e2e/cloud/connect-link-multi-org.test.ts+232−0

This diff is large: its 233 lines aren’t shown here.

e2e/cloud/connect-panel.test.ts+4−3

This diff is large: its 35 lines aren’t shown here.

e2e/cloud/connection-modal-oauth-abandon.test.ts+3−2

This diff is large: its 26 lines aren’t shown here.

e2e/cloud/connection-owner-isolation.test.ts+37−15

This diff is large: its 110 lines aren’t shown here.

e2e/cloud/credential-write-durability.test.ts+570−0

This diff is large: its 571 lines aren’t shown here.

e2e/cloud/frontend-error-reporting.test.ts+132−0

This diff is large: its 133 lines aren’t shown here.

e2e/cloud/integrations-api.test.tsrenamed from e2e/cloud/sources-api.test.ts+0−0

File renamed without changes.

e2e/cloud/integrations-refresh.test.tsrenamed from e2e/cloud/sources-refresh.test.ts+0−0

File renamed without changes.

e2e/cloud/logout-stale-session.test.ts+166−0

This diff is large: its 167 lines aren’t shown here.

e2e/cloud/mcp-browser-approval-org-scope.test.ts+4−3

This diff is large: its 33 lines aren’t shown here.

e2e/cloud/mcp-browser-resume-page.test.ts+64−57

This diff is large: its 133 lines aren’t shown here.

e2e/cloud/mcp-destroyed-session-envelope.test.ts+365−0

This diff is large: its 366 lines aren’t shown here.

e2e/cloud/mcp-execution-limits.test.ts+179−2

This diff is large: its 201 lines aren’t shown here.

e2e/cloud/mcp-oversize-response.test.ts+136−0

This diff is large: its 137 lines aren’t shown here.

e2e/cloud/mcp-session-cold-init.test.ts+191−0

This diff is large: its 192 lines aren’t shown here.

e2e/cloud/mcp-session-idle-runtime-disposal.test.ts+252−0

This diff is large: its 253 lines aren’t shown here.

e2e/cloud/mcp-telemetry-contract.test.ts+106−0

This diff is large: its 107 lines aren’t shown here.

e2e/cloud/member-invite-seat-limit.test.ts+3−2

This diff is large: its 26 lines aren’t shown here.

e2e/cloud/oauth-callback-org-scope.test.ts+5−6

This diff is large: its 44 lines aren’t shown here.

e2e/cloud/oauth-callback-unauthenticated.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

e2e/cloud/onboarding-mcp-url.test.ts+2−1

This diff is large: its 17 lines aren’t shown here.

e2e/cloud/org-api-keys-console.test.ts+142−0

This diff is large: its 143 lines aren’t shown here.

e2e/cloud/org-delete.test.ts+4−3

This diff is large: its 33 lines aren’t shown here.

e2e/cloud/org-last-visited.test.ts+96−0

This diff is large: its 97 lines aren’t shown here.

e2e/cloud/org-limit.test.ts+3−2

This diff is large: its 26 lines aren’t shown here.

e2e/cloud/org-multitab-cookie.test.ts+4−3

This diff is large: its 35 lines aren’t shown here.

e2e/cloud/org-slug-foreign.test.ts+3−2

This diff is large: its 21 lines aren’t shown here.

e2e/cloud/org-switcher.test.ts+4−3

This diff is large: its 35 lines aren’t shown here.

e2e/cloud/repro-listener-supersede.test.ts+130−0

This diff is large: its 131 lines aren’t shown here.

e2e/cloud/spec-update-convergence.test.ts+20−6

This diff is large: its 63 lines aren’t shown here.

e2e/cloud/storage-error-report-shape.test.ts+280−0

This diff is large: its 281 lines aren’t shown here.

e2e/cloud/support/session.ts+205−0

This diff is large: its 206 lines aren’t shown here.

e2e/cloud/surface-reachability.test.ts+21−0

This diff is large: its 28 lines aren’t shown here.

e2e/cloud/toolkit-opencode-real.test.ts+3−3

This diff is large: its 18 lines aren’t shown here.

e2e/cloud/toolkit-policy-perf.test.ts+8−8

This diff is large: its 52 lines aren’t shown here.

e2e/cloud/unauthenticated-skeleton.test.ts+14−5

This diff is large: its 47 lines aren’t shown here.

e2e/cloudflare/mcp-native-elicitation.test.ts+108−0

This diff is large: its 109 lines aren’t shown here.

e2e/local/auth.test.ts+6−4

This diff is large: its 24 lines aren’t shown here.

e2e/local/boot-process.test.ts+101−0

This diff is large: its 102 lines aren’t shown here.

e2e/local/cli-call-logged-out.test.ts+160−0

This diff is large: its 161 lines aren’t shown here.

e2e/local/cli-mcp-daemon-attach-stress.test.ts+52−62

This diff is large: its 188 lines aren’t shown here.

e2e/local/cli-telemetry-optout.test.ts+27−0

This diff is large: its 28 lines aren’t shown here.

e2e/local/daemon-process.ts+55−0

This diff is large: its 56 lines aren’t shown here.

e2e/local/local-server.ts+25−5

This diff is large: its 62 lines aren’t shown here.

e2e/local/mcp-native-elicitation.test.ts+128−0

This diff is large: its 129 lines aren’t shown here.

e2e/local/oauth-token-durability.test.ts+252−0

This diff is large: its 253 lines aren’t shown here.

e2e/local/server-profile-header-env.test.ts+109−0

This diff is large: its 110 lines aren’t shown here.

e2e/local/stdio-mcp.test.ts+30−0

This diff is large: its 37 lines aren’t shown here.

e2e/local/update-notice.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

e2e/local/vite-dev-routing.test.ts+3−2

This diff is large: its 14 lines aren’t shown here.

e2e/mcp-apps/.gitignore+5−0

This diff is large: its 6 lines aren’t shown here.

e2e/mcp-apps/README.md+67−0

This diff is large: its 68 lines aren’t shown here.

e2e/mcp-apps/package.json+17−0

This diff is large: its 18 lines aren’t shown here.

e2e/mcp-apps/playwright.config.ts+31−0

This diff is large: its 32 lines aren’t shown here.

e2e/mcp-apps/scripts/check-sunpeak.mjs+45−0

This diff is large: its 46 lines aren’t shown here.

e2e/mcp-apps/tests/create-artifact.spec.ts+87−0

This diff is large: its 88 lines aren’t shown here.

e2e/package.json+3−4

This diff is large: its 30 lines aren’t shown here.

e2e/recordings/custom-tools-console.mp4+0−0

Binary file not shown.

e2e/scenarios/api-tools.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

e2e/scenarios/artifact-approval.test.ts+228−0

This diff is large: its 229 lines aren’t shown here.

e2e/scenarios/artifact-loading-surface.test.ts+331−0

This diff is large: its 332 lines aren’t shown here.

e2e/scenarios/artifact-preview-gallery.test.ts+282−0

This diff is large: its 283 lines aren’t shown here.

e2e/scenarios/artifacts.test.ts+854−0

This diff is large: its 855 lines aren’t shown here.

e2e/scenarios/auth-methods-ui.test.ts+2−3

This diff is large: its 19 lines aren’t shown here.

e2e/scenarios/auth-methods.test.ts+4−4

This diff is large: its 36 lines aren’t shown here.

e2e/scenarios/browser-approval.test.ts+2−1

This diff is large: its 17 lines aren’t shown here.

e2e/scenarios/connect-deep-link.test.ts+169−0

This diff is large: its 170 lines aren’t shown here.

e2e/scenarios/connect-handoff-session.test.ts+26−23

This diff is large: its 110 lines aren’t shown here.

e2e/scenarios/connect-handoff.test.ts+37−24

This diff is large: its 141 lines aren’t shown here.

e2e/scenarios/connection-health-verdict.test.ts+531−0

This diff is large: its 532 lines aren’t shown here.

e2e/scenarios/connection-remove-confirm.test.ts+122−0

This diff is large: its 123 lines aren’t shown here.

e2e/scenarios/docs-link.test.ts+2−1

This diff is large: its 17 lines aren’t shown here.

e2e/scenarios/first-party-oauth.test.ts+382−0

This diff is large: its 383 lines aren’t shown here.

e2e/scenarios/google-health-checks.test.ts+425−0

This diff is large: its 426 lines aren’t shown here.

e2e/scenarios/google-photos-preset-ui.test.ts+22−21

This diff is large: its 63 lines aren’t shown here.

e2e/scenarios/graphql-introspection-health.test.ts+146−0

This diff is large: its 147 lines aren’t shown here.

e2e/scenarios/health-checks-ui.test.ts+12−11

This diff is large: its 107 lines aren’t shown here.

e2e/scenarios/integrations-catalog-search.test.ts+77−0

This diff is large: its 78 lines aren’t shown here.

e2e/scenarios/mcp-catalog-sync-ui.test.ts+3−4

This diff is large: its 28 lines aren’t shown here.

e2e/scenarios/mcp-cloudflare-codemode-warning.test.ts+41−0

This diff is large: its 42 lines aren’t shown here.

e2e/scenarios/mcp-session-state.test.ts+157−0

This diff is large: its 158 lines aren’t shown here.

e2e/scenarios/microsoft-emulator.test.ts+25−22

This diff is large: its 96 lines aren’t shown here.

e2e/scenarios/microsoft-graph-default.test.ts+65−59

This diff is large: its 199 lines aren’t shown here.

e2e/scenarios/microsoft-graph-full.test.ts+56−48

This diff is large: its 165 lines aren’t shown here.

e2e/scenarios/namespace-enumeration.test.ts+167−0

This diff is large: its 168 lines aren’t shown here.

e2e/scenarios/namespace-search-tools.test.ts+146−0

This diff is large: its 147 lines aren’t shown here.

e2e/scenarios/oauth-callback-url.test.ts+2−3

This diff is large: its 19 lines aren’t shown here.

e2e/scenarios/oauth-client-handoff.test.ts+31−28

This diff is large: its 126 lines aren’t shown here.

e2e/scenarios/oauth-refresh-on-401.test.ts+326−0

This diff is large: its 327 lines aren’t shown here.

e2e/scenarios/oauth-refresh-rejected-non-json.test.ts+503−0

This diff is large: its 504 lines aren’t shown here.

e2e/scenarios/oauth-refresh-rejected.test.ts+291−0

This diff is large: its 292 lines aren’t shown here.

e2e/scenarios/oauth-scope-insufficient.test.ts+336−0

This diff is large: its 337 lines aren’t shown here.

e2e/scenarios/oauth-second-connection.test.ts+233−0

This diff is large: its 234 lines aren’t shown here.

e2e/scenarios/openapi-add-integration-action-bar.test.ts+6−5

This diff is large: its 43 lines aren’t shown here.

e2e/scenarios/openapi-integration.test.tsrenamed from e2e/scenarios/openapi-source.test.ts+0−0

File renamed without changes.

e2e/scenarios/openapi-multi-key-auth-ui.test.ts+2−1

This diff is large: its 17 lines aren’t shown here.

e2e/scenarios/openapi-ndjson-output.test.ts+250−0

This diff is large: its 251 lines aren’t shown here.

e2e/scenarios/openapi-server-selection-ui.test.ts+4−3

This diff is large: its 26 lines aren’t shown here.

e2e/scenarios/openapi-unknown-args.test.ts+162−0

This diff is large: its 163 lines aren’t shown here.

e2e/scenarios/org-slug-routing.test.ts+5−4

This diff is large: its 36 lines aren’t shown here.

e2e/scenarios/policies-ui.test.ts+61−5

This diff is large: its 103 lines aren’t shown here.

e2e/scenarios/provider-plugins-ui.test.ts+37−42

This diff is large: its 109 lines aren’t shown here.

e2e/scenarios/resume-after-sandbox-deadline.test.ts+165−0

This diff is large: its 166 lines aren’t shown here.

e2e/scenarios/run-panel-auto-approve.test.ts+8−21

This diff is large: its 38 lines aren’t shown here.

e2e/scenarios/shape-memory.test.ts+178−0

This diff is large: its 179 lines aren’t shown here.

e2e/scenarios/support/large-catalog.ts+29−29

This diff is large: its 137 lines aren’t shown here.

e2e/scenarios/tool-descriptions.test.ts+2−2

This diff is large: its 18 lines aren’t shown here.

e2e/scenarios/toolkits-mcp.test.ts+67−0

This diff is large: its 74 lines aren’t shown here.

e2e/scripts/ci-shard-durations.ts+94−0

This diff is large: its 95 lines aren’t shown here.

e2e/scripts/ci-shard.test.ts+126−0

This diff is large: its 127 lines aren’t shown here.

e2e/scripts/ci-shard.ts+231−0

This diff is large: its 232 lines aren’t shown here.

e2e/scripts/ci-shard.vitest.config.ts+9−0

This diff is large: its 10 lines aren’t shown here.

e2e/scripts/cli.ts+5−0

This diff is large: its 12 lines aren’t shown here.

e2e/scripts/record-connect-card.ts+2−1

This diff is large: its 17 lines aren’t shown here.

e2e/scripts/run-ci-shard.ts+37−0

This diff is large: its 38 lines aren’t shown here.

e2e/selfhost/admin-users-console.test.ts+331−0

This diff is large: its 332 lines aren’t shown here.

e2e/selfhost/admin-users-pager.test.ts+167−0

This diff is large: its 168 lines aren’t shown here.

e2e/selfhost/api-keys-feedback.test.ts+4−3

This diff is large: its 35 lines aren’t shown here.

e2e/selfhost/auth-methods-ui.test.ts+5−12

This diff is large: its 52 lines aren’t shown here.

e2e/selfhost/cli-device-login.test.ts+3−3

This diff is large: its 27 lines aren’t shown here.

e2e/selfhost/connect-modal-credential-ux.test.ts+46−2

This diff is large: its 82 lines aren’t shown here.

e2e/selfhost/detected-auth-immutable-ui.test.ts+3−4

This diff is large: its 28 lines aren’t shown here.

e2e/selfhost/mcp-auth-required-add.test.ts+2−3

This diff is large: its 19 lines aren’t shown here.

e2e/selfhost/mcp-browser-approval-ownership.test.ts+4−45

This diff is large: its 77 lines aren’t shown here.

e2e/selfhost/mcp-connect-card-url.test.ts+2−1

This diff is large: its 17 lines aren’t shown here.

e2e/selfhost/mcp-enterprise-managed-auth.test.ts+443−0

This diff is large: its 444 lines aren’t shown here.

e2e/selfhost/mcp-oauth-cimd-connect.test.ts+109−0

This diff is large: its 110 lines aren’t shown here.

e2e/selfhost/mcp-oauth-consent.test.ts+3−2

This diff is large: its 26 lines aren’t shown here.

e2e/selfhost/mcp-oauth-reconnect-health.test.ts+222−19

This diff is large: its 306 lines aren’t shown here.

e2e/selfhost/mcp-oauth-slow-connect.test.ts+133−0

This diff is large: its 134 lines aren’t shown here.

e2e/selfhost/mcp-opencode-consent.test.ts+2−1

This diff is large: its 17 lines aren’t shown here.

e2e/selfhost/oauth-app-modal.test.ts+29−1

This diff is large: its 51 lines aren’t shown here.

e2e/selfhost/oauth-callback-unauthenticated.test.ts+2−1

This diff is large: its 17 lines aren’t shown here.

e2e/selfhost/oauth-org-connection-cross-principal.test.ts+275−0

This diff is large: its 276 lines aren’t shown here.

e2e/selfhost/posthog-mcp-oauth.test.ts+45−21

This diff is large: its 115 lines aren’t shown here.

e2e/selfhost/toolkits-ui.test.ts+6−1

This diff is large: its 16 lines aren’t shown here.

e2e/setup/boot.ts+122−13

This diff is large: its 188 lines aren’t shown here.

e2e/setup/cloud.boot.ts+41−4

This diff is large: its 81 lines aren’t shown here.

e2e/setup/cloud.globalsetup.ts+16−3

This diff is large: its 41 lines aren’t shown here.

e2e/setup/cloudflare.boot.ts+7−2

This diff is large: its 23 lines aren’t shown here.

e2e/setup/desktop-linux.globalsetup.ts+1−0

This diff is large: its 8 lines aren’t shown here.

e2e/setup/desktop-macos.globalsetup.ts+1−0

This diff is large: its 8 lines aren’t shown here.

e2e/setup/execution-limits.ts+15−0

This diff is large: its 19 lines aren’t shown here.

e2e/setup/mcp-session-timeouts.ts+3−1

This diff is large: its 9 lines aren’t shown here.

e2e/setup/motel.ts+3−3

This diff is large: its 27 lines aren’t shown here.

e2e/setup/sandbox-timeout.ts+28−0

This diff is large: its 29 lines aren’t shown here.

e2e/setup/selfhost.boot.ts+10−2

This diff is large: its 40 lines aren’t shown here.

e2e/setup/selfhost.globalsetup.ts+8−2

This diff is large: its 34 lines aren’t shown here.

e2e/src/emulator-instance.ts+71−0

This diff is large: its 72 lines aren’t shown here.

e2e/src/ports.ts+31−7

This diff is large: its 78 lines aren’t shown here.

e2e/src/surfaces/autumn.ts+80−6

This diff is large: its 136 lines aren’t shown here.

e2e/src/surfaces/browser.ts+123−1

This diff is large: its 138 lines aren’t shown here.

e2e/src/surfaces/cli.ts+10−1

This diff is large: its 25 lines aren’t shown here.

e2e/src/surfaces/mcp.ts+24−7

This diff is large: its 45 lines aren’t shown here.

e2e/src/update-card-render.ts+2−1

This diff is large: its 17 lines aren’t shown here.

e2e/targets/cloud.ts+9−1

This diff is large: its 28 lines aren’t shown here.

e2e/targets/selfhost-docker.ts+7−6

This diff is large: its 32 lines aren’t shown here.

e2e/targets/selfhost.ts+50−1

This diff is large: its 72 lines aren’t shown here.

examples/all-plugins/CHANGELOG.md+196−0

This diff is large: its 202 lines aren’t shown here.

examples/all-plugins/package.json+1−2

This diff is large: its 16 lines aren’t shown here.

examples/all-plugins/src/main.ts+13−4

This diff is large: its 45 lines aren’t shown here.

examples/docs-sdk-quickstart/CHANGELOG.md+112−0

This diff is large: its 118 lines aren’t shown here.

examples/docs-sdk-quickstart/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

knip.config.ts+0−3

This diff is large: its 10 lines aren’t shown here.

package.json+5−2

This diff is large: its 34 lines aren’t shown here.

packages/app/package.json+6−1

This diff is large: its 33 lines aren’t shown here.

packages/app/src/crash-reporting.test.ts+115−0

This diff is large: its 116 lines aren’t shown here.

packages/app/src/crash-reporting.ts+35−3

This diff is large: its 60 lines aren’t shown here.

packages/app/src/entry-client.tsx+5−0

This diff is large: its 15 lines aren’t shown here.

packages/app/src/preload-error.test.ts+132−0

This diff is large: its 133 lines aren’t shown here.

packages/app/src/preload-error.ts+90−0

This diff is large: its 91 lines aren’t shown here.

packages/app/src/routeTree.gen.ts+81−0

This diff is large: its 224 lines aren’t shown here.

packages/app/src/routes/__root.tsx+15−4

This diff is large: its 37 lines aren’t shown here.

packages/app/src/server-disconnected.test.ts+184−0

This diff is large: its 185 lines aren’t shown here.

packages/app/src/server-disconnected.ts+113−0

This diff is large: its 114 lines aren’t shown here.

packages/app/src/web/shell.tsx+22−7

This diff is large: its 83 lines aren’t shown here.

packages/app/tsr.routes.ts+6−4

This diff is large: its 20 lines aren’t shown here.

packages/app/vite.ts+8−1

This diff is large: its 30 lines aren’t shown here.

packages/app/vitest.config.ts+8−0

This diff is large: its 9 lines aren’t shown here.

packages/core/analytics/CHANGELOG.md+52−0

This diff is large: its 53 lines aren’t shown here.

packages/core/analytics/package.json+51−0

This diff is large: its 52 lines aren’t shown here.

packages/core/analytics/src/analytics.test.ts+233−0

This diff is large: its 234 lines aren’t shown here.

packages/core/analytics/src/analytics.ts+240−0

This diff is large: its 241 lines aren’t shown here.

packages/core/analytics/src/engine.test.ts+159−0

This diff is large: its 160 lines aren’t shown here.

packages/core/analytics/src/engine.ts+63−0

This diff is large: its 64 lines aren’t shown here.

packages/core/analytics/src/events.ts+71−0

This diff is large: its 72 lines aren’t shown here.

packages/core/analytics/src/index.ts+12−0

This diff is large: its 13 lines aren’t shown here.

packages/core/analytics/tsconfig.json+24−0

This diff is large: its 25 lines aren’t shown here.

packages/core/analytics/tsup.config.ts+12−0

This diff is large: its 13 lines aren’t shown here.

packages/core/analytics/vitest.config.ts+7−0

This diff is large: its 8 lines aren’t shown here.

packages/core/api/CHANGELOG.md+128−0

This diff is large: its 134 lines aren’t shown here.

packages/core/api/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/core/api/src/account/api.ts+37−0

This diff is large: its 51 lines aren’t shown here.

packages/core/api/src/account/handlers.ts+18−0

This diff is large: its 25 lines aren’t shown here.

packages/core/api/src/account/org-slug.test.ts+28−1

This diff is large: its 36 lines aren’t shown here.

packages/core/api/src/account/org-slug.ts+7−2

This diff is large: its 27 lines aren’t shown here.

packages/core/api/src/account/service.ts+12−0

This diff is large: its 33 lines aren’t shown here.

packages/core/api/src/admin/admin-users.test.ts+1194−0

This diff is large: its 1195 lines aren’t shown here.

packages/core/api/src/admin/api.ts+362−0

This diff is large: its 363 lines aren’t shown here.

packages/core/api/src/admin/handlers.ts+69−0

This diff is large: its 70 lines aren’t shown here.

packages/core/api/src/admin/reads.ts+387−0

This diff is large: its 388 lines aren’t shown here.

packages/core/api/src/admin/service.ts+81−0

This diff is large: its 82 lines aren’t shown here.

packages/core/api/src/api.ts+2−0

This diff is large: its 16 lines aren’t shown here.

packages/core/api/src/artifacts/api.ts+165−0

This diff is large: its 166 lines aren’t shown here.

packages/core/api/src/client.ts+13−0

This diff is large: its 17 lines aren’t shown here.

packages/core/api/src/connections/api.ts+1−0

This diff is large: its 8 lines aren’t shown here.

packages/core/api/src/executions/api.ts+51−2

This diff is large: its 80 lines aren’t shown here.

packages/core/api/src/handlers/artifacts.ts+119−0

This diff is large: its 120 lines aren’t shown here.

packages/core/api/src/handlers/connections.ts+1−0

This diff is large: its 8 lines aren’t shown here.

packages/core/api/src/handlers/executions.ts+194−3

This diff is large: its 234 lines aren’t shown here.

packages/core/api/src/handlers/index.ts+3−0

This diff is large: its 22 lines aren’t shown here.

packages/core/api/src/handlers/integrations.ts+1−0

This diff is large: its 8 lines aren’t shown here.

packages/core/api/src/handlers/oauth.ts+6−0

This diff is large: its 21 lines aren’t shown here.

packages/core/api/src/index.ts+17−0

This diff is large: its 38 lines aren’t shown here.

packages/core/api/src/integrations/api.ts+8−0

This diff is large: its 29 lines aren’t shown here.

packages/core/api/src/oauth-popup.test.ts+114−3

This diff is large: its 131 lines aren’t shown here.

packages/core/api/src/oauth-popup.ts+15−4

This diff is large: its 33 lines aren’t shown here.

packages/core/api/src/oauth/api.ts+22−2

This diff is large: its 67 lines aren’t shown here.

packages/core/api/src/observability.test.ts+25−1

This diff is large: its 40 lines aren’t shown here.

packages/core/api/src/observability.ts+13−2

This diff is large: its 36 lines aren’t shown here.

packages/core/api/src/plugin-routes.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/core/api/src/server.ts+35−1

This diff is large: its 86 lines aren’t shown here.

packages/core/api/src/server/execution-stack-middleware.ts+148−18

This diff is large: its 240 lines aren’t shown here.

packages/core/api/src/server/execution-stack.ts+58−9

This diff is large: its 89 lines aren’t shown here.

packages/core/api/src/server/executor-app.ts+7−2

This diff is large: its 23 lines aren’t shown here.

packages/core/api/src/server/fixed-execution-middleware.ts+28−5

This diff is large: its 56 lines aren’t shown here.

packages/core/api/src/server/host-foundation.ts+42−0

This diff is large: its 56 lines aren’t shown here.

packages/core/api/src/server/identity.ts+71−4

This diff is large: its 121 lines aren’t shown here.

packages/core/api/src/server/mcp-build.ts+57−6

This diff is large: its 91 lines aren’t shown here.

packages/core/api/src/server/scoped-executor.ts+114−5

This diff is large: its 167 lines aren’t shown here.

packages/core/api/src/services.ts+18−1

This diff is large: its 30 lines aren’t shown here.

packages/core/cli/CHANGELOG.md+98−0

This diff is large: its 104 lines aren’t shown here.

packages/core/cli/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/core/config/CHANGELOG.md+98−0

This diff is large: its 104 lines aren’t shown here.

packages/core/config/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/core/config/src/config.test.ts+37−35

This diff is large: its 222 lines aren’t shown here.

packages/core/config/src/index.ts+7−7

This diff is large: its 28 lines aren’t shown here.

packages/core/config/src/schema.ts+16−16

This diff is large: its 85 lines aren’t shown here.

packages/core/config/src/sink.ts+10−10

This diff is large: its 51 lines aren’t shown here.

packages/core/config/src/write.ts+24−24

This diff is large: its 114 lines aren’t shown here.

packages/core/execution/CHANGELOG.md+114−0

This diff is large: its 120 lines aren’t shown here.

packages/core/execution/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/core/execution/src/description.test.ts+50−1

This diff is large: its 62 lines aren’t shown here.

packages/core/execution/src/description.ts+22−0

This diff is large: its 29 lines aren’t shown here.

packages/core/execution/src/engine.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/core/execution/src/engine.ts+119−12

This diff is large: its 262 lines aren’t shown here.

packages/core/execution/src/index.ts+16−3

This diff is large: its 31 lines aren’t shown here.

packages/core/execution/src/provided-globals.ts+274−0

This diff is large: its 275 lines aren’t shown here.

packages/core/execution/src/skills.test.ts+27−1

This diff is large: its 39 lines aren’t shown here.

packages/core/execution/src/skills.ts+579−11

This diff is large: its 617 lines aren’t shown here.

packages/core/execution/src/tool-invoker.leak.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/core/execution/src/tool-invoker.test.ts+434−54

This diff is large: its 611 lines aren’t shown here.

packages/core/execution/src/tool-invoker.ts+108−34

This diff is large: its 276 lines aren’t shown here.

packages/core/integrations-registry/src/registry.test.ts+10−2

This diff is large: its 33 lines aren’t shown here.

packages/core/integrations-registry/src/registry.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/core/sdk/CHANGELOG.md+87−0

This diff is large: its 93 lines aren’t shown here.

packages/core/sdk/README.md+3−3

This diff is large: its 26 lines aren’t shown here.

packages/core/sdk/package.json+9−2

This diff is large: its 31 lines aren’t shown here.

packages/core/sdk/src/artifact-preview.test.ts+304−0

This diff is large: its 305 lines aren’t shown here.

packages/core/sdk/src/artifact-preview.ts+574−0

This diff is large: its 575 lines aren’t shown here.

packages/core/sdk/src/artifact-table.test.ts+132−0

This diff is large: its 133 lines aren’t shown here.

packages/core/sdk/src/artifact.ts+211−0

This diff is large: its 212 lines aren’t shown here.

packages/core/sdk/src/artifacts.test.ts+353−0

This diff is large: its 354 lines aren’t shown here.

packages/core/sdk/src/auth-tool-failure.ts+55−17

This diff is large: its 111 lines aren’t shown here.

packages/core/sdk/src/blob.ts+1−1

This diff is large: its 8 lines aren’t shown here.

packages/core/sdk/src/client.test.ts+2−2

This diff is large: its 16 lines aren’t shown here.

packages/core/sdk/src/client.ts+34−5

This diff is large: its 90 lines aren’t shown here.

packages/core/sdk/src/connection-name-identifier.ts+4−0

This diff is large: its 11 lines aren’t shown here.

packages/core/sdk/src/connection.ts+7−4

This diff is large: its 26 lines aren’t shown here.

packages/core/sdk/src/connections.test.ts+212−2

This diff is large: its 268 lines aren’t shown here.

packages/core/sdk/src/core-schema.ts+129−4

This diff is large: its 203 lines aren’t shown here.

packages/core/sdk/src/core-tools.ts+78−17

This diff is large: its 210 lines aren’t shown here.

packages/core/sdk/src/errors.ts+47−4

This diff is large: its 89 lines aren’t shown here.

packages/core/sdk/src/executor.test.ts+259−1

This diff is large: its 306 lines aren’t shown here.

packages/core/sdk/src/executor.ts+1724−194

This diff is large: its 2569 lines aren’t shown here.

packages/core/sdk/src/fuma-runtime.test.ts+265−0

This diff is large: its 266 lines aren’t shown here.

packages/core/sdk/src/fuma-runtime.ts+167−12

This diff is large: its 226 lines aren’t shown here.

packages/core/sdk/src/host-internal.ts+15−0

This diff is large: its 33 lines aren’t shown here.

packages/core/sdk/src/hosted-http-client.test.ts+68−0

This diff is large: its 79 lines aren’t shown here.

packages/core/sdk/src/hosted-http-client.ts+71−2

This diff is large: its 88 lines aren’t shown here.

packages/core/sdk/src/ids.ts+4−0

This diff is large: its 11 lines aren’t shown here.

packages/core/sdk/src/index.ts+91−2

This diff is large: its 217 lines aren’t shown here.

packages/core/sdk/src/insufficient-scope.test.ts+377−0

This diff is large: its 378 lines aren’t shown here.

packages/core/sdk/src/insufficient-scope.ts+274−0

This diff is large: its 275 lines aren’t shown here.

packages/core/sdk/src/integration.ts+26−0

This diff is large: its 59 lines aren’t shown here.

packages/core/sdk/src/migration-spec.test.ts+2−2

This diff is large: its 16 lines aren’t shown here.

packages/core/sdk/src/migration-spec.ts+2−2

This diff is large: its 18 lines aren’t shown here.

packages/core/sdk/src/oauth-client.ts+199−3

This diff is large: its 255 lines aren’t shown here.

packages/core/sdk/src/oauth-discovery.test.ts+26−0

This diff is large: its 33 lines aren’t shown here.

packages/core/sdk/src/oauth-discovery.ts+58−9

This diff is large: its 95 lines aren’t shown here.

packages/core/sdk/src/oauth-ema-lifecycle.test.ts+336−0

This diff is large: its 337 lines aren’t shown here.

packages/core/sdk/src/oauth-ema-rollout.test.ts+467−0

This diff is large: its 468 lines aren’t shown here.

packages/core/sdk/src/oauth-ema.test.ts+502−0

This diff is large: its 503 lines aren’t shown here.

packages/core/sdk/src/oauth-ema.ts+476−0

This diff is large: its 477 lines aren’t shown here.

packages/core/sdk/src/oauth-first-party.test.ts+471−0

This diff is large: its 472 lines aren’t shown here.

packages/core/sdk/src/oauth-flow.test.ts+985−8

This diff is large: its 1026 lines aren’t shown here.

packages/core/sdk/src/oauth-helpers.test.ts+340−0

This diff is large: its 383 lines aren’t shown here.

packages/core/sdk/src/oauth-helpers.ts+731−49

This diff is large: its 902 lines aren’t shown here.

packages/core/sdk/src/oauth-register-dynamic.test.ts+211−0

This diff is large: its 232 lines aren’t shown here.

packages/core/sdk/src/oauth-remove-client.test.ts+66−1

This diff is large: its 79 lines aren’t shown here.

packages/core/sdk/src/oauth-scope-union.test.ts+93−2

This diff is large: its 130 lines aren’t shown here.

packages/core/sdk/src/oauth-service.ts+773−68

This diff is large: its 1207 lines aren’t shown here.

packages/core/sdk/src/owner-policy-reach.test.ts+318−0

This diff is large: its 319 lines aren’t shown here.

packages/core/sdk/src/owner-policy.ts+78−1

This diff is large: its 123 lines aren’t shown here.

packages/core/sdk/src/pending-approval.test.ts+142−0

This diff is large: its 143 lines aren’t shown here.

packages/core/sdk/src/pending-approval.ts+110−0

This diff is large: its 111 lines aren’t shown here.

packages/core/sdk/src/platform-view.test.ts+901−0

This diff is large: its 902 lines aren’t shown here.

packages/core/sdk/src/plugin.ts+114−11

This diff is large: its 258 lines aren’t shown here.

packages/core/sdk/src/policies.test.ts+43−2

This diff is large: its 66 lines aren’t shown here.

packages/core/sdk/src/policies.ts+41−0

This diff is large: its 55 lines aren’t shown here.

packages/core/sdk/src/promise-executor.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/core/sdk/src/promise.test.ts+2−2

This diff is large: its 18 lines aren’t shown here.

packages/core/sdk/src/provider-call-timeout.test.ts+212−0

This diff is large: its 213 lines aren’t shown here.

packages/core/sdk/src/provider-item-owner.ts+47−0

This diff is large: its 48 lines aren’t shown here.

packages/core/sdk/src/sentry-grouping.test.ts+269−0

This diff is large: its 270 lines aren’t shown here.

packages/core/sdk/src/sentry-grouping.ts+169−0

This diff is large: its 170 lines aren’t shown here.

packages/core/sdk/src/server-connection.test.ts+54−0

This diff is large: its 80 lines aren’t shown here.

packages/core/sdk/src/server-connection.ts+69−1

This diff is large: its 137 lines aren’t shown here.

packages/core/sdk/src/shape-inference.test.ts+136−0

This diff is large: its 137 lines aren’t shown here.

packages/core/sdk/src/shape-inference.ts+219−0

This diff is large: its 220 lines aren’t shown here.

packages/core/sdk/src/shape-memory.ts+90−0

This diff is large: its 91 lines aren’t shown here.

packages/core/sdk/src/shared.ts+34−0

This diff is large: its 87 lines aren’t shown here.

packages/core/sdk/src/sqlite-config-blob-migration.test.ts+2−2

This diff is large: its 18 lines aren’t shown here.

packages/core/sdk/src/sqlite-data-migrations.test.ts+61−0

This diff is large: its 73 lines aren’t shown here.

packages/core/sdk/src/sqlite-data-migrations.ts+12−6

This diff is large: its 33 lines aren’t shown here.

packages/core/sdk/src/subject-registry.test.ts+418−0

This diff is large: its 419 lines aren’t shown here.

packages/core/sdk/src/subject-registry.ts+0−0

Binary file not shown.

packages/core/sdk/src/subject-table.test.ts+103−0

This diff is large: its 104 lines aren’t shown here.

packages/core/sdk/src/test-config.ts+6−0

This diff is large: its 22 lines aren’t shown here.

packages/core/sdk/src/testing/id-jag-test-support.ts+251−0

This diff is large: its 252 lines aren’t shown here.

packages/core/sdk/src/testing/oauth-test-server.ts+340−2

This diff is large: its 467 lines aren’t shown here.

packages/core/sdk/src/tool-not-found-empty-catalog.test.ts+113−0

This diff is large: its 114 lines aren’t shown here.

packages/core/sdk/src/types.ts+4−0

This diff is large: its 11 lines aren’t shown here.

packages/core/sdk/tsup.config.ts+1−0

This diff is large: its 8 lines aren’t shown here.

packages/core/vite-plugin/CHANGELOG.md+98−0

This diff is large: its 104 lines aren’t shown here.

packages/core/vite-plugin/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/hosts/cloudflare/CHANGELOG.md+144−0

This diff is large: its 150 lines aren’t shown here.

packages/hosts/cloudflare/package.json+6−2

This diff is large: its 28 lines aren’t shown here.

packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts+582−4

This diff is large: its 637 lines aren’t shown here.

packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+439−37

This diff is large: its 709 lines aren’t shown here.

packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts+2−0

This diff is large: its 9 lines aren’t shown here.

packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts+41−12

This diff is large: its 83 lines aren’t shown here.

packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts+11−4

This diff is large: its 40 lines aren’t shown here.

packages/hosts/cloudflare/src/mcp/do-headers.test.ts+49−0

This diff is large: its 50 lines aren’t shown here.

packages/hosts/cloudflare/src/mcp/do-headers.ts+41−6

This diff is large: its 62 lines aren’t shown here.

packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts+275−0

This diff is large: its 276 lines aren’t shown here.

packages/hosts/cloudflare/src/mcp/durable-object-errors.ts+237−0

This diff is large: its 238 lines aren’t shown here.

packages/hosts/cloudflare/src/mcp/session-runtime-residency.ts+96−0

This diff is large: its 97 lines aren’t shown here.

packages/hosts/mcp-apps-shell/.gitignore+1−0

This diff is large: its 2 lines aren’t shown here.

packages/hosts/mcp-apps-shell/CHANGELOG.md+57−0

This diff is large: its 58 lines aren’t shown here.

packages/hosts/mcp-apps-shell/package.json+102−0

This diff is large: its 103 lines aren’t shown here.

packages/hosts/mcp-apps-shell/scripts/gen-provided-globals.ts+23−0

This diff is large: its 24 lines aren’t shown here.

packages/hosts/mcp-apps-shell/scripts/gen-smoke-harness.ts+45−0

This diff is large: its 46 lines aren’t shown here.

packages/hosts/mcp-apps-shell/scripts/subset-fonts.ts+96−0

This diff is large: its 97 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/index.ts+13−0

This diff is large: its 14 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell-asset-path.ts+35−0

This diff is large: its 36 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell-dev-html.d.ts+14−0

This diff is large: its 15 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell-html.ts+67−0

This diff is large: its 68 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts+98−0

This diff is large: its 99 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx+432−0

This diff is large: its 433 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/artifact-states.tsx+217−0

This diff is large: its 218 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/component-runtime.test.ts+53−0

This diff is large: its 54 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/component-runtime.ts+190−0

This diff is large: its 191 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/components.ts+283−0

This diff is large: its 284 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/display-mode.ts+104−0

This diff is large: its 105 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/fonts/README.md+29−0

This diff is large: its 30 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/fonts/geist-mono.woff2+0−0

Binary file not shown.

packages/hosts/mcp-apps-shell/src/shell/fonts/geist-sans.woff2+0−0

Binary file not shown.

packages/hosts/mcp-apps-shell/src/shell/globals.css+53−0

This diff is large: its 54 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/hooks.ts+158−0

This diff is large: its 159 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/inner-renderer-source.d.ts+14−0

This diff is large: its 15 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx+432−0

This diff is large: its 433 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts+2649−0

This diff is large: its 2650 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/mcp-app.html+17−0

This diff is large: its 18 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx+101−0

This diff is large: its 102 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/preview-capture.ts+36−0

This diff is large: its 37 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts+40−0

This diff is large: its 41 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/proxy.ts+181−0

This diff is large: its 182 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/render-signal.ts+60−0

This diff is large: its 61 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx+1098−0

This diff is large: its 1099 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/shell-html-url.d.ts+13−0

This diff is large: its 14 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/smoke-harness-bundle.gen.ts+17−0

This diff is large: its 18 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/smoke-harness-shims.ts+274−0

This diff is large: its 275 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/smoke-harness.tsx+168−0

This diff is large: its 169 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/smoke-render-result.ts+36−0

This diff is large: its 37 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/smoke-render.test.tsx+388−0

This diff is large: its 389 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/smoke-render.ts+189−0

This diff is large: its 190 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/theme-tokens.pin.test.ts+196−0

This diff is large: its 197 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/theme.css+317−0

This diff is large: its 318 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/tool-call-grammar.pin.test.ts+110−0

This diff is large: its 111 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/shell/tools-proxy.ts+261−0

This diff is large: its 262 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/smoke-render-entry.ts+43−0

This diff is large: its 44 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/vite.test.ts+28−0

This diff is large: its 29 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/vite.ts+411−0

This diff is large: its 412 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/worker.test.ts+85−0

This diff is large: its 86 lines aren’t shown here.

packages/hosts/mcp-apps-shell/src/worker.ts+75−0

This diff is large: its 76 lines aren’t shown here.

packages/hosts/mcp-apps-shell/tsconfig.json+16−0

This diff is large: its 17 lines aren’t shown here.

packages/hosts/mcp-apps-shell/vite.config.shell.ts+46−0

This diff is large: its 47 lines aren’t shown here.

packages/hosts/mcp-apps-shell/vitest.config.ts+12−0

This diff is large: its 13 lines aren’t shown here.

packages/hosts/mcp/package.json+14−1

This diff is large: its 29 lines aren’t shown here.

packages/hosts/mcp/src/artifact-action.ts+87−0

This diff is large: its 88 lines aren’t shown here.

packages/hosts/mcp/src/artifact-bindings.test.ts+398−0

This diff is large: its 399 lines aren’t shown here.

packages/hosts/mcp/src/artifact-bindings.ts+419−0

This diff is large: its 420 lines aren’t shown here.

packages/hosts/mcp/src/artifacts-tools.test.ts+2557−0

This diff is large: its 2558 lines aren’t shown here.

packages/hosts/mcp/src/browser-approval.ts+28−0

This diff is large: its 35 lines aren’t shown here.

packages/hosts/mcp/src/create-artifact.ts+489−0

This diff is large: its 490 lines aren’t shown here.

packages/hosts/mcp/src/envelope.test.ts+7−0

This diff is large: its 14 lines aren’t shown here.

packages/hosts/mcp/src/envelope.ts+53−19

This diff is large: its 160 lines aren’t shown here.

packages/hosts/mcp/src/in-memory-session-store.test.ts+54−0

This diff is large: its 55 lines aren’t shown here.

packages/hosts/mcp/src/in-memory-session-store.ts+17−1

This diff is large: its 41 lines aren’t shown here.

packages/hosts/mcp/src/namespace-search-tools.test.ts+222−0

This diff is large: its 223 lines aren’t shown here.

packages/hosts/mcp/src/tool-call-code.ts+119−0

This diff is large: its 120 lines aren’t shown here.

packages/hosts/mcp/src/tool-server.test.ts+228−13

This diff is large: its 327 lines aren’t shown here.

packages/hosts/mcp/src/tool-server.ts+1364−54

This diff is large: its 1657 lines aren’t shown here.

packages/kernel/core/CHANGELOG.md+28−0

This diff is large: its 34 lines aren’t shown here.

packages/kernel/core/package.json+2−1

This diff is large: its 16 lines aren’t shown here.

packages/kernel/core/src/error-kind.test.ts+25−0

This diff is large: its 26 lines aren’t shown here.

packages/kernel/core/src/error-kind.ts+37−0

This diff is large: its 38 lines aren’t shown here.

packages/kernel/core/src/index.ts+1−0

This diff is large: its 6 lines aren’t shown here.

packages/kernel/core/src/json-schema.ts+27−8

This diff is large: its 50 lines aren’t shown here.

packages/kernel/core/src/strip-types.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/kernel/core/src/types.ts+3−0

This diff is large: its 17 lines aren’t shown here.

packages/kernel/ir/src/registry.ts+2−2

This diff is large: its 18 lines aren’t shown here.

packages/kernel/ir/src/serialize.ts+2−2

This diff is large: its 18 lines aren’t shown here.

packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs+1−1

This diff is large: its 9 lines aren’t shown here.

packages/kernel/runtime-deno-subprocess/src/deno-worker-process.ts+47−2

This diff is large: its 99 lines aren’t shown here.

packages/kernel/runtime-deno-subprocess/src/fixtures/close-stdin-worker.mjs+37−0

This diff is large: its 38 lines aren’t shown here.

packages/kernel/runtime-deno-subprocess/src/index.test.ts+68−13

This diff is large: its 109 lines aren’t shown here.

packages/kernel/runtime-deno-subprocess/src/index.ts+78−13

This diff is large: its 156 lines aren’t shown here.

packages/kernel/runtime-dynamic-worker/src/executor.ts+37−3

This diff is large: its 81 lines aren’t shown here.

packages/kernel/runtime-dynamic-worker/src/invocation.test.ts+86−2

This diff is large: its 145 lines aren’t shown here.

packages/kernel/runtime-dynamic-worker/src/module-template.ts+26−6

This diff is large: its 68 lines aren’t shown here.

packages/kernel/runtime-quickjs/CHANGELOG.md+100−0

This diff is large: its 106 lines aren’t shown here.

packages/kernel/runtime-quickjs/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/kernel/runtime-quickjs/src/index.test.ts+50−2

This diff is large: its 67 lines aren’t shown here.

packages/kernel/runtime-quickjs/src/index.ts+94−21

This diff is large: its 263 lines aren’t shown here.

packages/kernel/runtime-quickjs/src/sealed-bundle.test.ts+113−0

This diff is large: its 114 lines aren’t shown here.

packages/kernel/runtime-quickjs/src/sealed-bundle.ts+209−0

This diff is large: its 210 lines aren’t shown here.

packages/kernel/runtime-workerd-subprocess/CHANGELOG.md+99−0

This diff is large: its 100 lines aren’t shown here.

packages/kernel/runtime-workerd-subprocess/package.json+26−0

This diff is large: its 27 lines aren’t shown here.

packages/kernel/runtime-workerd-subprocess/src/index.test.ts+251−0

This diff is large: its 252 lines aren’t shown here.

packages/kernel/runtime-workerd-subprocess/src/index.ts+740−0

This diff is large: its 741 lines aren’t shown here.

packages/kernel/runtime-workerd-subprocess/tsconfig.jsonrenamed from packages/plugins/google/tsconfig.json+5−6

This diff is large: its 26 lines aren’t shown here.

packages/kernel/runtime-workerd-subprocess/vitest.config.ts+7−0

This diff is large: its 8 lines aren’t shown here.

packages/plugins/desktop-settings/CHANGELOG.md+98−0

This diff is large: its 104 lines aren’t shown here.

packages/plugins/desktop-settings/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/plugins/desktop-settings/src/client.tsx+5−5

This diff is large: its 31 lines aren’t shown here.

packages/plugins/desktop-settings/src/server.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/plugins/encrypted-secrets/CHANGELOG.md+98−0

This diff is large: its 104 lines aren’t shown here.

packages/plugins/encrypted-secrets/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/plugins/encrypted-secrets/src/index.test.ts+53−2

This diff is large: its 73 lines aren’t shown here.

packages/plugins/encrypted-secrets/src/index.ts+20−13

This diff is large: its 78 lines aren’t shown here.

packages/plugins/encrypted-secrets/src/repartition-migration.test.ts+208−0

This diff is large: its 209 lines aren’t shown here.

packages/plugins/encrypted-secrets/src/repartition-migration.ts+123−0

This diff is large: its 124 lines aren’t shown here.

packages/plugins/example/CHANGELOG.md+98−0

This diff is large: its 104 lines aren’t shown here.

packages/plugins/example/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/plugins/file-secrets/CHANGELOG.md+100−0

This diff is large: its 106 lines aren’t shown here.

packages/plugins/file-secrets/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/plugins/file-secrets/src/data-dir.test.ts+115−0

This diff is large: its 116 lines aren’t shown here.

packages/plugins/file-secrets/src/index.test.ts+208−0

This diff is large: its 209 lines aren’t shown here.

packages/plugins/file-secrets/src/index.ts+170−57

This diff is large: its 324 lines aren’t shown here.

packages/plugins/google/CHANGELOG.md+0−143

This diff is large: its 144 lines aren’t shown here.

packages/plugins/google/package.json+0−98

This diff is large: its 99 lines aren’t shown here.

packages/plugins/google/src/api/group.ts+0−142

This diff is large: its 143 lines aren’t shown here.

packages/plugins/google/src/api/handlers.ts+0−105

This diff is large: its 106 lines aren’t shown here.

packages/plugins/google/src/api/index.ts+0−15

This diff is large: its 16 lines aren’t shown here.

packages/plugins/google/src/promise.ts+0−1

This diff is large: its 2 lines aren’t shown here.

packages/plugins/google/src/react/AddGoogleSource.tsx+0−178

This diff is large: its 179 lines aren’t shown here.

packages/plugins/google/src/react/GoogleAccountsPanel.tsx+0−148

This diff is large: its 149 lines aren’t shown here.

packages/plugins/google/src/react/GoogleProductPicker.tsx+0−363

This diff is large: its 364 lines aren’t shown here.

packages/plugins/google/src/react/atoms.ts+0−30

This diff is large: its 31 lines aren’t shown here.

packages/plugins/google/src/react/client.ts+0−13

This diff is large: its 14 lines aren’t shown here.

packages/plugins/google/src/react/index.ts+0−12

This diff is large: its 13 lines aren’t shown here.

packages/plugins/google/src/react/plugin-client.tsx+0−8

This diff is large: its 9 lines aren’t shown here.

packages/plugins/google/src/react/source-plugin.ts+0−18

This diff is large: its 19 lines aren’t shown here.

packages/plugins/google/src/sdk/oauth-batches.test.ts+0−98

This diff is large: its 99 lines aren’t shown here.

packages/plugins/google/src/sdk/oauth-batches.ts+0−69

This diff is large: its 70 lines aren’t shown here.

packages/plugins/google/src/sdk/oauth-scopes.test.ts+0−34

This diff is large: its 35 lines aren’t shown here.

packages/plugins/google/src/sdk/oauth-scopes.ts+0−66

This diff is large: its 67 lines aren’t shown here.

packages/plugins/google/src/sdk/plugin.test.ts+0−799

This diff is large: its 800 lines aren’t shown here.

packages/plugins/google/src/sdk/plugin.ts+0−469

This diff is large: its 470 lines aren’t shown here.

packages/plugins/google/src/sdk/presets.test.ts+0−29

This diff is large: its 30 lines aren’t shown here.

packages/plugins/google/src/sdk/product-picker-scopes.test.ts+0−122

This diff is large: its 123 lines aren’t shown here.

packages/plugins/google/tsup.config.ts+0−14

This diff is large: its 15 lines aren’t shown here.

packages/plugins/google/vitest.config.ts+0−12

This diff is large: its 13 lines aren’t shown here.

packages/plugins/graphql/CHANGELOG.md+140−0

This diff is large: its 146 lines aren’t shown here.

packages/plugins/graphql/README.md+2−2

This diff is large: its 18 lines aren’t shown here.

packages/plugins/graphql/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/plugins/graphql/src/react/AddGraphqlIntegration.tsxrenamed from packages/plugins/graphql/src/react/AddGraphqlSource.tsx+8−8

This diff is large: its 65 lines aren’t shown here.

packages/plugins/graphql/src/react/GraphqlAccountsPanel.tsx+3−3

This diff is large: its 16 lines aren’t shown here.

packages/plugins/graphql/src/react/GraphqlIntegrationFields.tsxrenamed from packages/plugins/graphql/src/react/GraphqlSourceFields.tsx+1−1

This diff is large: its 9 lines aren’t shown here.

packages/plugins/graphql/src/react/atoms.ts+3−3

This diff is large: its 26 lines aren’t shown here.

packages/plugins/graphql/src/react/index.ts+2−2

This diff is large: its 6 lines aren’t shown here.

packages/plugins/graphql/src/react/integration-plugin.tsrenamed from packages/plugins/graphql/src/react/source-plugin.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/plugins/graphql/src/react/plugin-client.tsx+1−1

This diff is large: its 8 lines aren’t shown here.

packages/plugins/graphql/src/sdk/errors.ts+14−0

This diff is large: its 28 lines aren’t shown here.

packages/plugins/graphql/src/sdk/index.ts+6−1

This diff is large: its 13 lines aren’t shown here.

packages/plugins/graphql/src/sdk/introspect.ts+14−1

This diff is large: its 58 lines aren’t shown here.

packages/plugins/graphql/src/sdk/invocation-timeout.test.ts+107−0

This diff is large: its 108 lines aren’t shown here.

packages/plugins/graphql/src/sdk/invoke.ts+67−33

This diff is large: its 132 lines aren’t shown here.

packages/plugins/graphql/src/sdk/multi-placement-auth.test.ts+50−0

This diff is large: its 55 lines aren’t shown here.

packages/plugins/graphql/src/sdk/plugin.test.ts+359−0

This diff is large: its 380 lines aren’t shown here.

packages/plugins/graphql/src/sdk/plugin.ts+334−41

This diff is large: its 499 lines aren’t shown here.

packages/plugins/graphql/src/sdk/presets.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/plugins/graphql/src/sdk/types.ts+6−0

This diff is large: its 12 lines aren’t shown here.

packages/plugins/keychain/CHANGELOG.md+98−0

This diff is large: its 104 lines aren’t shown here.

packages/plugins/keychain/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/plugins/mcp/CHANGELOG.md+160−0

This diff is large: its 166 lines aren’t shown here.

packages/plugins/mcp/README.md+4−4

This diff is large: its 35 lines aren’t shown here.

packages/plugins/mcp/package.json+6−2

This diff is large: its 34 lines aren’t shown here.

packages/plugins/mcp/scripts/gen-call-tool-result-schema.ts+26−0

This diff is large: its 27 lines aren’t shown here.

packages/plugins/mcp/src/api/group.ts+4−0

This diff is large: its 11 lines aren’t shown here.

packages/plugins/mcp/src/api/handlers.ts+2−0

This diff is large: its 16 lines aren’t shown here.

packages/plugins/mcp/src/react/AddMcpIntegration.tsxrenamed from packages/plugins/mcp/src/react/AddMcpSource.tsx+19−3

This diff is large: its 58 lines aren’t shown here.

packages/plugins/mcp/src/react/EditMcpIntegration.tsxrenamed from packages/plugins/mcp/src/react/EditMcpSource.tsx+8−5

This diff is large: its 29 lines aren’t shown here.

packages/plugins/mcp/src/react/McpAccountsPanel.tsx+3−3

This diff is large: its 16 lines aren’t shown here.

packages/plugins/mcp/src/react/McpRemoteIntegrationFields.tsxrenamed from packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx+3−3

This diff is large: its 27 lines aren’t shown here.

packages/plugins/mcp/src/react/McpSignInButton.tsx+2−2

This diff is large: its 11 lines aren’t shown here.

packages/plugins/mcp/src/react/index.ts+2−2

This diff is large: its 8 lines aren’t shown here.

packages/plugins/mcp/src/react/integration-plugin.tsxrenamed from packages/plugins/mcp/src/react/source-plugin.tsx+5−5

This diff is large: its 33 lines aren’t shown here.

packages/plugins/mcp/src/react/plugin-client.tsx+2−2

This diff is large: its 15 lines aren’t shown here.

packages/plugins/mcp/src/sdk/call-tool-result-schema.gen.ts+395−0

This diff is large: its 396 lines aren’t shown here.

packages/plugins/mcp/src/sdk/call-tool-result-schema.test.ts+16−0

This diff is large: its 17 lines aren’t shown here.

packages/plugins/mcp/src/sdk/catalog-sync.test.ts+19−0

This diff is large: its 26 lines aren’t shown here.

packages/plugins/mcp/src/sdk/client-module.ts+39−0

This diff is large: its 40 lines aren’t shown here.

packages/plugins/mcp/src/sdk/cloudflare-codemode.test.ts+42−0

This diff is large: its 43 lines aren’t shown here.

packages/plugins/mcp/src/sdk/cloudflare-codemode.ts+15−0

This diff is large: its 16 lines aren’t shown here.

packages/plugins/mcp/src/sdk/connection-pool-key.test.ts+154−0

This diff is large: its 155 lines aren’t shown here.

packages/plugins/mcp/src/sdk/connection-pool.test.ts+197−0

This diff is large: its 198 lines aren’t shown here.

packages/plugins/mcp/src/sdk/connection-pool.ts+147−0

This diff is large: its 148 lines aren’t shown here.

packages/plugins/mcp/src/sdk/connection-socket-release.test.ts+64−0

This diff is large: its 65 lines aren’t shown here.

packages/plugins/mcp/src/sdk/connection.test.ts+85−0

This diff is large: its 86 lines aren’t shown here.

packages/plugins/mcp/src/sdk/connection.ts+274−37

This diff is large: its 411 lines aren’t shown here.

packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts+37−0

This diff is large: its 44 lines aren’t shown here.

packages/plugins/mcp/src/sdk/discover.ts+32−21

This diff is large: its 67 lines aren’t shown here.

packages/plugins/mcp/src/sdk/elicitation.test.ts+56−81

This diff is large: its 202 lines aren’t shown here.

packages/plugins/mcp/src/sdk/errors.ts+38−0

This diff is large: its 71 lines aren’t shown here.

packages/plugins/mcp/src/sdk/health-missing-credential.test.ts+111−0

This diff is large: its 112 lines aren’t shown here.

packages/plugins/mcp/src/sdk/http-status.test.ts+75−0

This diff is large: its 76 lines aren’t shown here.

packages/plugins/mcp/src/sdk/http-status.ts+68−11

This diff is large: its 102 lines aren’t shown here.

packages/plugins/mcp/src/sdk/invoke.test.ts+17−6

This diff is large: its 47 lines aren’t shown here.

packages/plugins/mcp/src/sdk/invoke.ts+85−27

This diff is large: its 204 lines aren’t shown here.

packages/plugins/mcp/src/sdk/multi-placement-auth.test.ts+6−2

This diff is large: its 22 lines aren’t shown here.

packages/plugins/mcp/src/sdk/plugin.test.ts+106−4

This diff is large: its 143 lines aren’t shown here.

packages/plugins/mcp/src/sdk/plugin.ts+195−28

This diff is large: its 433 lines aren’t shown here.

packages/plugins/mcp/src/sdk/presets.ts+7−4

This diff is large: its 31 lines aren’t shown here.

packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/plugins/mcp/src/sdk/probe-shape.test.ts+90−2

This diff is large: its 104 lines aren’t shown here.

packages/plugins/mcp/src/sdk/probe-shape.ts+51−10

This diff is large: its 108 lines aren’t shown here.

packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts+182−0

This diff is large: its 183 lines aren’t shown here.

packages/plugins/mcp/src/sdk/slack-connect-file.test.ts+171−0

This diff is large: its 172 lines aren’t shown here.

packages/plugins/mcp/src/sdk/slack-connect-file.ts+153−0

This diff is large: its 154 lines aren’t shown here.

packages/plugins/mcp/src/sdk/stdio-connector.ts+4−3

This diff is large: its 21 lines aren’t shown here.

packages/plugins/mcp/src/sdk/stdio-interrupt-cleanup.test.ts+112−0

This diff is large: its 113 lines aren’t shown here.

packages/plugins/mcp/src/sdk/stdio-interrupt-test-server.ts+65−0

This diff is large: its 66 lines aren’t shown here.

packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts+24−0

This diff is large: its 25 lines aren’t shown here.

packages/plugins/mcp/src/sdk/stdio-negotiation.test.ts+64−0

This diff is large: its 65 lines aren’t shown here.

packages/plugins/mcp/src/sdk/testing-fixtures.test.ts+5−3

This diff is large: its 21 lines aren’t shown here.

packages/plugins/mcp/src/sdk/types.ts+38−1

This diff is large: its 75 lines aren’t shown here.

packages/plugins/mcp/src/testing/server.ts+78−6

This diff is large: its 172 lines aren’t shown here.

packages/plugins/mcp/tsconfig.json+1−1

This diff is large: its 9 lines aren’t shown here.

packages/plugins/microsoft/CHANGELOG.md+0−141

This diff is large: its 142 lines aren’t shown here.

packages/plugins/microsoft/package.json+0−98

This diff is large: its 99 lines aren’t shown here.

packages/plugins/microsoft/src/api/group.ts+0−163

This diff is large: its 164 lines aren’t shown here.

packages/plugins/microsoft/src/api/handlers.ts+0−144

This diff is large: its 145 lines aren’t shown here.

packages/plugins/microsoft/src/api/index.ts+0−15

This diff is large: its 16 lines aren’t shown here.

packages/plugins/microsoft/src/promise.ts+0−1

This diff is large: its 2 lines aren’t shown here.

packages/plugins/microsoft/src/react/AddMicrosoftSource.tsx+0−155

This diff is large: its 156 lines aren’t shown here.

packages/plugins/microsoft/src/react/MicrosoftAccountsPanel.tsx+0−118

This diff is large: its 119 lines aren’t shown here.

packages/plugins/microsoft/src/react/MicrosoftScopePicker.tsx+0−286

This diff is large: its 287 lines aren’t shown here.

packages/plugins/microsoft/src/react/atoms.ts+0−30

This diff is large: its 31 lines aren’t shown here.

packages/plugins/microsoft/src/react/client.ts+0−13

This diff is large: its 14 lines aren’t shown here.

packages/plugins/microsoft/src/react/index.ts+0−12

This diff is large: its 13 lines aren’t shown here.

packages/plugins/microsoft/src/react/plugin-client.tsx+0−8

This diff is large: its 9 lines aren’t shown here.

packages/plugins/microsoft/src/react/source-plugin.ts+0−18

This diff is large: its 19 lines aren’t shown here.

This diff is larger than a page shows: the files after these aren’t listed. Clone the repository to see all of it.