History

Merge origin/main into connection-create-conflict

Keep the connections.create conflict (409 ConnectionAlreadyExistsError) and drop the branch's OAuth-side guard: main now resolves a taken name for fresh OAuth connects with `newConnection`, which suffixes to the next free name, so the `reconnect` discriminator and the oauth.start/complete rejections are redundant and would break reconnect.

Rhys Sullivan committed 2 months ago

Commit 641aa732 parents 62d6c90 ac2dec61139 files · +127827 −11894Browse files

Unified · Split

786 files
  1. .changeset/concurrent-stale-tools-sync.md +11 −0
  2. .changeset/config.json +2 −2
  3. .changeset/connection-create-conflict.md +7 −1
  4. .changeset/defer-irreversible-cleanup.md +15 −0
  5. .changeset/expired-authorization-session-sweep.md +11 −0
  6. .changeset/graphql-introspection-credential-log.md +24 −0
  7. .changeset/health-response-sample-scrub.md +16 −0
  8. .changeset/mcp-oauth-declared-scopes.md +11 −0
  9. .changeset/mcp-pool-idle-sweep.md +13 −0
  10. .changeset/mcp-pre-initialize-method-not-found.md +9 −0
  11. .changeset/mcp-session-idle-eviction.md +7 −0
  12. .changeset/mcp-stdio-env-isolation.md +11 −0
  13. .changeset/oauth-refresh-store-writability-gate.md +13 −0
  14. .changeset/oauth-token-endpoint-error-leak.md +35 −0
  15. .changeset/openapi-multipart-file-fields.md +12 −0
  16. .changeset/persist-mcp-install-options.md +5 −0
  17. .changeset/scope-mcp-stream-replay.md +5 −0
  18. .changeset/seat-based-team-pricing.md +22 −0
  19. .claude/skills/emulate/SKILL.md +15 −12
  20. .claude/skills/prod-telemetry/SKILL.md +18 −1
  21. .github/workflows/ci.yml +151 −57
  22. .github/workflows/deploy.yml +29 −1
  23. .github/workflows/graph-slices.yml +47 −0
  24. .github/workflows/pkg-pr-new.yml +10 −0
  25. .github/workflows/preview-sweep.yml +10 −0
  26. .github/workflows/preview.yml +10 −0
  27. .github/workflows/publish-desktop.yml +39 −2
  28. .github/workflows/publish-executor-package.yml +13 −1
  29. .github/workflows/publish-selfhost-docker.yml +142 −4
  30. .github/workflows/release.yml +15 −1
  31. .oxfmtrc.json +1 −0
  32. .oxlintrc.jsonc +26 −0
  33. AGENTS.md +43 −68
  34. README.md +8 −8
  35. TELEMETRY.md +101 −0
  36. apps/cli/CHANGELOG.md +347 −0
  37. apps/cli/package.json +4 −1
  38. apps/cli/src/build.ts +163 −1
  39. apps/cli/src/device-login.test.ts +118 −2
  40. apps/cli/src/device-login.ts +57 −8
  41. apps/cli/src/main.ts +263 −69
  42. apps/cli/src/native-bindings.ts +36 −5
  43. apps/cli/src/server-connection.test.ts +84 −0
  44. apps/cli/src/server-connection.ts +49 −0
  45. apps/cli/src/server-profile.test.ts +86 −5
  46. apps/cli/src/server-profile.ts +22 −4
  47. apps/cli/src/service.test.ts +30 −0
  48. apps/cli/src/service.ts +11 −0
  49. apps/cli/src/tooling.ts +2 −2
  50. apps/cli/src/worker-bundler-artifact.test.ts +59 −0
  51. apps/cli/src/worker-bundler-artifact.ts +55 −0
  52. apps/cli/tsconfig.json +1 −1
  53. apps/cloud/CHANGELOG.md +335 −0
  54. apps/cloud/drizzle/0010_ndjson_output_arrays.sql +23 −0
  55. apps/cloud/drizzle/0011_tense_caretaker.sql +1 −0
  56. apps/cloud/drizzle/0012_woozy_shiva.sql +10 −0
  57. apps/cloud/drizzle/0013_curly_rocket_racer.sql +14 −0
  58. apps/cloud/drizzle/0014_new_karma.sql +1 −0
  59. apps/cloud/drizzle/0015_equal_the_leader.sql +1 −0
  60. apps/cloud/drizzle/meta/0010_snapshot.json +1290 −0
  61. apps/cloud/drizzle/meta/0011_snapshot.json +1296 −0
  62. apps/cloud/drizzle/meta/0012_snapshot.json +1367 −0
  63. apps/cloud/drizzle/meta/0013_snapshot.json +1474 −0
  64. apps/cloud/drizzle/meta/0014_snapshot.json +1480 −0
  65. apps/cloud/drizzle/meta/0015_snapshot.json +1486 −0
  66. apps/cloud/drizzle/meta/_journal.json +42 −0
  67. apps/cloud/executor.config.ts +13 −15
  68. apps/cloud/package.json +9 −4
  69. apps/cloud/scripts/backfill-subjects.ts +87 −0
  70. apps/cloud/scripts/build.mjs +20 −1
  71. apps/cloud/scripts/dev-db.ts +145 −14
  72. apps/cloud/scripts/start-closure.mjs +124 −0
  73. apps/cloud/scripts/test-globalsetup.ts +20 −1
  74. apps/cloud/scripts/vendor-quickjs-wasm.ts +18 −0
  75. apps/cloud/src/account/account-api.ts +4 −1
  76. apps/cloud/src/account/org-api-key-revoke.node.test.ts +196 −0
  77. apps/cloud/src/account/workos-account-service.ts +68 −9
  78. apps/cloud/src/admin/admin-users-api.ts +357 −0
  79. apps/cloud/src/admin/admin-users-email.node.test.ts +127 −0
  80. apps/cloud/src/analytics/ema-rollout.test.ts +394 −0
  81. apps/cloud/src/analytics/ema-rollout.ts +255 −0
  82. apps/cloud/src/api.request-scope.node.test.ts +309 −3
  83. apps/cloud/src/api/protected-api-key-auth.node.test.ts +7 −1
  84. apps/cloud/src/api/protected-jwt-auth.node.test.ts +18 −4
  85. apps/cloud/src/api/protected.test.ts +2 −0
  86. apps/cloud/src/app-paths.test.ts +37 −1
  87. apps/cloud/src/app-paths.ts +28 −0
  88. apps/cloud/src/app.ts +2 −7
  89. apps/cloud/src/auth/api-keys.node.test.ts +23 −8
  90. apps/cloud/src/auth/api-keys.ts +214 −12
  91. apps/cloud/src/auth/api.ts +40 −2
  92. apps/cloud/src/auth/context.ts +9 −4
  93. apps/cloud/src/auth/doc-gate.ts +71 −23
  94. apps/cloud/src/auth/errors.test.ts +68 −0
  95. apps/cloud/src/auth/errors.ts +161 −5
  96. apps/cloud/src/auth/handlers.ts +186 −25
  97. apps/cloud/src/auth/jwks-cache.node.test.ts +182 −1
  98. apps/cloud/src/auth/jwks-cache.ts +256 −26
  99. apps/cloud/src/auth/last-org-cookie.ts +32 −0
  100. apps/cloud/src/auth/org-api-key-auth.node.test.ts +159 −0
  101. apps/cloud/src/auth/org-api-key-mint.node.test.ts +316 −0
  102. apps/cloud/src/auth/org-selector-auth.node.test.ts +33 −10
  103. apps/cloud/src/auth/organization.ts +18 −6
  104. apps/cloud/src/auth/user-store-error.node.test.ts +128 −0
  105. apps/cloud/src/auth/user-store.ts +6 −0
  106. apps/cloud/src/auth/workos-auth-provider.ts +132 −19
  107. apps/cloud/src/auth/workos.node.test.ts +282 −1
  108. apps/cloud/src/auth/workos.ts +420 −46
  109. apps/cloud/src/db/db.schema.test.ts +2 −2
  110. apps/cloud/src/db/db.ts +1 −1
  111. apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts +272 −8
  112. apps/cloud/src/db/executor-schema.ts +40 −1
  113. apps/cloud/src/db/org-deletion.test.ts +363 −0
  114. apps/cloud/src/db/org-deletion.ts +72 −0
  115. apps/cloud/src/edge/docs.ts +9 −29
  116. apps/cloud/src/edge/index.ts +4 −5
  117. apps/cloud/src/edge/marketing.test.ts +55 −1
  118. apps/cloud/src/edge/marketing.ts +25 −34
  119. apps/cloud/src/edge/passthrough.test.ts +85 −0
  120. apps/cloud/src/edge/passthrough.ts +138 −0
  121. apps/cloud/src/edge/posthog.ts +8 −21
  122. apps/cloud/src/engine/execution-gate.ts +7 −0
  123. apps/cloud/src/engine/execution-rate-limit.node.test.ts +435 −0
  124. apps/cloud/src/engine/execution-rate-limit.ts +335 −37
  125. apps/cloud/src/engine/execution-stack-metered.ts +15 −2
  126. apps/cloud/src/engine/execution-stack.ts +116 −2
  127. apps/cloud/src/engine/execution-usage.ts +8 −2
  128. apps/cloud/src/engine/first-party-oauth-clients.test.ts +87 −0
  129. apps/cloud/src/env-augment.d.ts +35 −0
  130. apps/cloud/src/extensions/billing/member-seats.ts +53 −0
  131. apps/cloud/src/extensions/billing/route.node.test.ts +16 −15
  132. apps/cloud/src/extensions/billing/route.ts +5 −2
  133. apps/cloud/src/extensions/billing/service.ts +147 −10
  134. apps/cloud/src/extensions/docs.ts +7 −1
  135. apps/cloud/src/extensions/routes.ts +18 −1
  136. apps/cloud/src/mcp-session.e2e.node.test.ts +1 −1
  137. apps/cloud/src/mcp/agent-handler.ts +165 −14
  138. apps/cloud/src/mcp/auth-provider.test.ts +175 −0
  139. apps/cloud/src/mcp/auth-provider.ts +99 −31
  140. apps/cloud/src/mcp/auth.ts +32 −4
  141. apps/cloud/src/mcp/session-durable-object.ts +95 −53
  142. apps/cloud/src/mcp/session-meta.node.test.ts +167 −0
  143. apps/cloud/src/mcp/session-meta.ts +209 −0
  144. apps/cloud/src/mcp/telemetry.test.ts +160 −0
  145. apps/cloud/src/mcp/telemetry.ts +51 −3
  146. apps/cloud/src/mcp/traceparent.ts +29 −0
  147. apps/cloud/src/observability/header-redaction.ts +19 −0
  148. apps/cloud/src/observability/index.ts +195 −2
  149. apps/cloud/src/observability/observability.test.ts +309 −0
  150. apps/cloud/src/observability/telemetry.ts +52 −11
  151. apps/cloud/src/org/auth-middleware.ts +5 −1
  152. apps/cloud/src/org/handlers.test.ts +1 −0
  153. apps/cloud/src/org/handlers.ts +5 −0
  154. apps/cloud/src/quickjs-engine.wasm +0 −0
  155. apps/cloud/src/quickjs.ts +35 −0
  156. apps/cloud/src/routeTree.gen.ts +109 −0
  157. apps/cloud/src/routes/__root.tsx +58 −57
  158. apps/cloud/src/routes/app/api-keys.tsx +9 −3
  159. apps/cloud/src/routes/app/billing.tsx +1 −1
  160. apps/cloud/src/routes/app/billing_.plans.tsx +47 −30
  161. apps/cloud/src/routes/app/org.tsx +147 −1
  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 +17 −1
  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 +32 −0
  176. apps/desktop/electron-builder.config.ts +21 −0
  177. apps/desktop/package.json +1 −3
  178. apps/desktop/scripts/check-build-resources.ts +97 −0
  179. apps/desktop/src/build-resources.test.ts +12 −0
  180. apps/desktop/src/main/app-quit.ts +23 −0
  181. apps/desktop/src/main/crash-fingerprint.test.ts +143 −0
  182. apps/desktop/src/main/crash-fingerprint.ts +80 −0
  183. apps/desktop/src/main/crash-screen.test.ts +26 −0
  184. apps/desktop/src/main/diagnostics.ts +9 −12
  185. apps/desktop/src/main/index.ts +51 −6
  186. apps/desktop/src/main/navigation-errors.test.ts +40 −0
  187. apps/desktop/src/main/navigation-errors.ts +21 −0
  188. apps/desktop/src/main/settings-permissions.test.ts +92 −0
  189. apps/desktop/src/main/settings.ts +10 −0
  190. apps/desktop/src/main/sidecar-exit-wiring.test.ts +163 −0
  191. apps/desktop/src/main/sidecar-exit.test.ts +70 −0
  192. apps/desktop/src/main/sidecar-exit.ts +50 −0
  193. apps/desktop/src/main/sidecar.ts +31 −8
  194. apps/desktop/src/sidecar/native-bindings.ts +18 −5
  195. apps/docs/concepts/integrations.mdx +1 −1
  196. apps/docs/docs.json +1 −1
  197. apps/docs/hosted/docker.mdx +3 −0
  198. apps/docs/hosted/tracing.mdx +115 −0
  199. apps/docs/local/cli.mdx +3 −3
  200. apps/host-cloudflare/README.md +13 −9
  201. apps/host-cloudflare/executor.config.ts +12 −5
  202. apps/host-cloudflare/package.json +6 −4
  203. apps/host-cloudflare/scripts/assert-shell-asset.mjs +17 −0
  204. apps/host-cloudflare/scripts/deploy.sh +11 −7
  205. apps/host-cloudflare/src/account/account-provider.ts +5 −0
  206. apps/host-cloudflare/src/auth/cloudflare-access.ts +6 −1
  207. apps/host-cloudflare/src/config.test.ts +74 −0
  208. apps/host-cloudflare/src/config.ts +48 −5
  209. apps/host-cloudflare/src/db/d1.test.ts +207 −0
  210. apps/host-cloudflare/src/db/d1.ts +84 −5
  211. apps/host-cloudflare/src/db/data-migrations.test.ts +60 −5
  212. apps/host-cloudflare/src/db/data-migrations.ts +101 −7
  213. apps/host-cloudflare/src/execution.ts +10 −5
  214. apps/host-cloudflare/src/mcp/agent-handler.ts +9 −0
  215. apps/host-cloudflare/src/mcp/session-durable-object.ts +44 −3
  216. apps/host-cloudflare/src/plugins.ts +12 −5
  217. apps/host-cloudflare/src/worker.e2e.node.test.ts +153 −19
  218. apps/host-cloudflare/src/worker.ts +19 −1
  219. apps/host-cloudflare/test-stubs/cloudflare-workers.ts +7 −0
  220. apps/host-cloudflare/tsconfig.json +4 −0
  221. apps/host-cloudflare/tsr.routes.ts +5 −1
  222. apps/host-cloudflare/vite.config.ts +8 −1
  223. apps/host-cloudflare/vitest.config.ts +7 −0
  224. apps/host-cloudflare/web/routeTree.gen.ts +86 −0
  225. apps/host-cloudflare/web/routes/__root.tsx +15 −1
  226. apps/host-cloudflare/wrangler.jsonc +22 −7
  227. apps/host-selfhost/.env.example +6 −0
  228. apps/host-selfhost/CHANGELOG.md +332 −0
  229. apps/host-selfhost/Dockerfile +1 −1
  230. apps/host-selfhost/executor.config.ts +22 −14
  231. apps/host-selfhost/package.json +9 −5
  232. apps/host-selfhost/scripts/assert-shell-asset.mjs +18 −0
  233. apps/host-selfhost/scripts/package-runtime.ts +55 −1
  234. apps/host-selfhost/src/account/better-auth-account-provider.ts +27 −0
  235. apps/host-selfhost/src/admin/admin-escalation.node.test.ts +184 −0
  236. apps/host-selfhost/src/admin/admin-users-api.ts +225 −0
  237. apps/host-selfhost/src/admin/admin-users.node.test.ts +234 −0
  238. apps/host-selfhost/src/admin/handlers.ts +14 −14
  239. apps/host-selfhost/src/admin/require-admin.ts +126 −0
  240. apps/host-selfhost/src/analytics.ts +83 −0
  241. apps/host-selfhost/src/app.ts +29 −7
  242. apps/host-selfhost/src/auth/better-auth.ts +20 −1
  243. apps/host-selfhost/src/auth/identity.ts +1 −0
  244. apps/host-selfhost/src/auth/return-to.test.ts +45 −1
  245. apps/host-selfhost/src/auth/return-to.ts +29 −0
  246. apps/host-selfhost/src/config.ts +94 −0
  247. apps/host-selfhost/src/db/data-migrations.ts +11 −1
  248. apps/host-selfhost/src/execution.ts +21 −9
  249. apps/host-selfhost/src/executor-config.test.ts +114 −0
  250. apps/host-selfhost/src/integrations-mcp.test.ts +2 −2
  251. apps/host-selfhost/src/integrations.test.ts +0 −0
  252. apps/host-selfhost/src/mcp/auth.ts +7 −2
  253. apps/host-selfhost/src/mcp/index.ts +14 −2
  254. apps/host-selfhost/src/mcp/mcp.test.ts +1 −0
  255. apps/host-selfhost/src/mcp/org-path.test.ts +18 −1
  256. apps/host-selfhost/src/mcp/org-path.ts +12 −0
  257. apps/host-selfhost/src/mcp/session-store.ts +16 −1
  258. apps/host-selfhost/src/platform-credential.test.ts +210 −0
  259. apps/host-selfhost/src/serve.ts +52 −3
  260. apps/host-selfhost/src/subject-sightings.test.ts +82 −0
  261. apps/host-selfhost/src/telemetry.test.ts +142 −0
  262. apps/host-selfhost/src/telemetry.ts +122 −0
  263. apps/host-selfhost/src/testing/test-app.ts +43 −2
  264. apps/host-selfhost/tsconfig.json +4 −0
  265. apps/host-selfhost/vite.config.ts +7 −0
  266. apps/host-selfhost/web/login.tsx +5 −10
  267. apps/host-selfhost/web/routeTree.gen.ts +109 −0
  268. apps/host-selfhost/web/routes/__root.tsx +25 −2
  269. apps/local/CHANGELOG.md +164 −0
  270. apps/local/drizzle/0004_cultured_shadowcat.sql +1 −0
  271. apps/local/drizzle/meta/0004_snapshot.json +927 −0
  272. apps/local/drizzle/meta/_journal.json +7 −0
  273. apps/local/executor.config.ts +18 −6
  274. apps/local/package.json +5 −4
  275. apps/local/src/analytics.ts +68 −0
  276. apps/local/src/app.ts +23 −6
  277. apps/local/src/auth-tool-failures.test.ts +2 −2
  278. apps/local/src/db/data-migrations.ts +7 −1
  279. apps/local/src/db/db-file-permissions.test.ts +99 −0
  280. apps/local/src/db/executor-schema.ts +1 −0
  281. apps/local/src/db/libsql.ts +38 −0
  282. apps/local/src/db/sqlite-fumadb.ts +6 −0
  283. apps/local/src/executor.test.ts +52 −1
  284. apps/local/src/executor.ts +48 −18
  285. apps/local/src/identity.ts +1 −0
  286. apps/local/src/installation.ts +5 −4
  287. apps/local/src/main.ts +72 −12
  288. apps/local/src/mcp-browser-resume.test.ts +1 −1
  289. apps/local/src/mcp-pre-initialize.test.ts +110 −0
  290. apps/local/src/mcp.ts +21 −1
  291. apps/local/src/serve.test.ts +1 −1
  292. apps/local/src/serve.ts +42 −11
  293. apps/marketing/src/assets/yc-backed-by.svg +1 −15
  294. apps/marketing/src/components/self-host-contact-modal.tsx +0 −126
  295. apps/marketing/src/layouts/Layout.astro +11 −4
  296. apps/marketing/src/pages/about-executor.astro +158 −0
  297. apps/marketing/src/pages/google-oauth.astro +554 −0
  298. apps/marketing/src/pages/google-workspace.astro +218 −0
  299. apps/marketing/src/pages/index.astro +14 −14
  300. apps/marketing/src/pages/privacy.astro +35 −2
  301. assets/executor-demo.gif +0 −0
  302. autumn.config.ts +25 −11
  303. bun.lock +363 −215
  304. design.md +6 −6
  305. e2e/AGENTS.md +42 −280
  306. e2e/CHANGELOG.md +192 −0
  307. e2e/RUNNING.md +283 −0
  308. e2e/cli/service-lifecycle.test.ts +2 −2
  309. e2e/cloud/admin-users-console.test.ts +390 −0
  310. e2e/cloud/admin-users.test.ts +322 −0
  311. e2e/cloud/auth-hint.test.ts +21 −3
  312. e2e/cloud/auth-routing-flow.test.ts +3 −2
  313. e2e/cloud/auth-session.test.ts +21 −3
  314. e2e/cloud/billing-customer-provisioning.test.ts +281 −0
  315. e2e/cloud/billing-trial-checkout-stale.test.ts +3 −2
  316. e2e/cloud/cli-device-login.test.ts +3 −3
  317. e2e/cloud/connect-card-origin.test.ts +55 −0
  318. e2e/cloud/connect-card-ssr-origin.test.ts +0 −79
  319. e2e/cloud/connect-link-multi-org.test.ts +232 −0
  320. e2e/cloud/connect-panel.test.ts +49 −5
  321. e2e/cloud/connection-modal-oauth-abandon.test.ts +3 −2
  322. e2e/cloud/connection-owner-isolation.test.ts +37 −15
  323. e2e/cloud/credential-write-durability.test.ts +660 −0
  324. e2e/cloud/frontend-error-reporting.test.ts +132 −0
  325. e2e/cloud/integrations-api.test.ts +0 −0
  326. e2e/cloud/integrations-refresh.test.ts +0 −0
  327. e2e/cloud/logout-stale-session.test.ts +166 −0
  328. e2e/cloud/mcp-browser-approval-org-scope.test.ts +4 −3
  329. e2e/cloud/mcp-browser-resume-page.test.ts +64 −57
  330. e2e/cloud/mcp-destroyed-session-envelope.test.ts +365 −0
  331. e2e/cloud/mcp-execution-limits.test.ts +179 −2
  332. e2e/cloud/mcp-oversize-response.test.ts +136 −0
  333. e2e/cloud/mcp-priming-reconnect.test.ts +218 −0
  334. e2e/cloud/mcp-session-cold-init.test.ts +191 −0
  335. e2e/cloud/mcp-session-idle-runtime-disposal.test.ts +252 −0
  336. e2e/cloud/mcp-sse-replay.test.ts +387 −0
  337. e2e/cloud/mcp-telemetry-contract.test.ts +106 −0
  338. e2e/cloud/mcp-workos-blip-session-survival.test.ts +256 −0
  339. e2e/cloud/member-invite-seat-limit.test.ts +3 −2
  340. e2e/cloud/member-seat-billing-sync.test.ts +107 −0
  341. e2e/cloud/oauth-callback-org-scope.test.ts +5 −6
  342. e2e/cloud/oauth-callback-unauthenticated.test.ts +1 −1
  343. e2e/cloud/onboarding-mcp-url.test.ts +2 −1
  344. e2e/cloud/org-api-keys-console.test.ts +142 −0
  345. e2e/cloud/org-delete.test.ts +79 −0
  346. e2e/cloud/org-last-visited.test.ts +96 −0
  347. e2e/cloud/org-limit.test.ts +3 −2
  348. e2e/cloud/org-multitab-cookie.test.ts +4 −3
  349. e2e/cloud/org-slug-foreign.test.ts +3 −2
  350. e2e/cloud/org-switcher.test.ts +4 −3
  351. e2e/cloud/repro-listener-supersede.test.ts +130 −0
  352. e2e/cloud/spec-update-convergence.test.ts +20 −6
  353. e2e/cloud/storage-error-report-shape.test.ts +280 −0
  354. e2e/cloud/support/session.ts +205 −0
  355. e2e/cloud/surface-reachability.test.ts +21 −0
  356. e2e/cloud/toolkit-opencode-real.test.ts +3 −3
  357. e2e/cloud/toolkit-policy-perf.test.ts +8 −8
  358. e2e/cloud/unauthenticated-skeleton.test.ts +14 −5
  359. e2e/cloudflare/mcp-native-elicitation.test.ts +108 −0
  360. e2e/local/auth.test.ts +6 −4
  361. e2e/local/boot-process.test.ts +101 −0
  362. e2e/local/cli-call-logged-out.test.ts +160 −0
  363. e2e/local/cli-mcp-daemon-attach-stress.test.ts +52 −62
  364. e2e/local/cli-telemetry-optout.test.ts +27 −0
  365. e2e/local/daemon-process.ts +55 −0
  366. e2e/local/fixtures/stdio-mcp-server.mjs +25 −0
  367. e2e/local/local-server.ts +25 −5
  368. e2e/local/mcp-native-elicitation.test.ts +128 −0
  369. e2e/local/oauth-token-durability.test.ts +252 −0
  370. e2e/local/server-profile-header-env.test.ts +109 −0
  371. e2e/local/stdio-mcp.test.ts +174 −97
  372. e2e/local/update-notice.test.ts +1 −1
  373. e2e/local/vite-dev-routing.test.ts +3 −2
  374. e2e/mcp-apps/.gitignore +5 −0
  375. e2e/mcp-apps/README.md +67 −0
  376. e2e/mcp-apps/package.json +17 −0
  377. e2e/mcp-apps/playwright.config.ts +31 −0
  378. e2e/mcp-apps/scripts/check-sunpeak.mjs +45 −0
  379. e2e/mcp-apps/tests/create-artifact.spec.ts +87 −0
  380. e2e/package.json +3 −4
  381. e2e/recordings/custom-tools-console.mp4 +0 −0
  382. e2e/recordings/slack-mcp-oauth-app-setup.mp4 +0 −0
  383. e2e/scenarios/api-tools.test.ts +1 −1
  384. e2e/scenarios/artifact-approval.test.ts +228 −0
  385. e2e/scenarios/artifact-loading-surface.test.ts +331 −0
  386. e2e/scenarios/artifact-preview-gallery.test.ts +282 −0
  387. e2e/scenarios/artifacts.test.ts +854 −0
  388. e2e/scenarios/auth-methods-ui.test.ts +2 −3
  389. e2e/scenarios/auth-methods.test.ts +4 −4
  390. e2e/scenarios/browser-approval.test.ts +2 −1
  391. e2e/scenarios/connect-deep-link.test.ts +169 −0
  392. e2e/scenarios/connect-handoff-session.test.ts +26 −23
  393. e2e/scenarios/connect-handoff.test.ts +37 −24
  394. e2e/scenarios/connection-health-verdict.test.ts +531 −0
  395. e2e/scenarios/connection-remove-confirm.test.ts +122 −0
  396. e2e/scenarios/docs-link.test.ts +2 −1
  397. e2e/scenarios/first-party-oauth.test.ts +382 −0
  398. e2e/scenarios/google-health-checks.test.ts +425 −0
  399. e2e/scenarios/google-photos-preset-ui.test.ts +22 −21
  400. e2e/scenarios/graphql-introspection-health.test.ts +146 −0
  401. e2e/scenarios/health-checks-ui.test.ts +12 −11
  402. e2e/scenarios/integrations-catalog-search.test.ts +77 −0
  403. e2e/scenarios/mcp-catalog-sync-ui.test.ts +3 −4
  404. e2e/scenarios/mcp-cloudflare-codemode-warning.test.ts +41 −0
  405. e2e/scenarios/mcp-session-state.test.ts +157 −0
  406. e2e/scenarios/microsoft-emulator.test.ts +25 −22
  407. e2e/scenarios/microsoft-graph-default.test.ts +65 −59
  408. e2e/scenarios/microsoft-graph-full.test.ts +56 −48
  409. e2e/scenarios/namespace-enumeration.test.ts +167 −0
  410. e2e/scenarios/namespace-search-tools.test.ts +146 −0
  411. e2e/scenarios/oauth-callback-url.test.ts +2 −3
  412. e2e/scenarios/oauth-client-handoff.test.ts +31 −28
  413. e2e/scenarios/oauth-refresh-on-401.test.ts +326 −0
  414. e2e/scenarios/oauth-refresh-rejected-non-json.test.ts +503 −0
  415. e2e/scenarios/oauth-refresh-rejected.test.ts +291 −0
  416. e2e/scenarios/oauth-scope-insufficient.test.ts +336 −0
  417. e2e/scenarios/oauth-second-connection.test.ts +233 −0
  418. e2e/scenarios/openapi-add-integration-action-bar.test.ts +6 −5
  419. e2e/scenarios/openapi-integration.test.ts +0 −0
  420. e2e/scenarios/openapi-multi-key-auth-ui.test.ts +2 −1
  421. e2e/scenarios/openapi-ndjson-output.test.ts +250 −0
  422. e2e/scenarios/openapi-server-selection-ui.test.ts +4 −3
  423. e2e/scenarios/openapi-unknown-args.test.ts +162 −0
  424. e2e/scenarios/org-slug-routing.test.ts +5 −4
  425. e2e/scenarios/policies-ui.test.ts +61 −5
  426. e2e/scenarios/provider-plugins-ui.test.ts +37 −42
  427. e2e/scenarios/resume-after-sandbox-deadline.test.ts +165 −0
  428. e2e/scenarios/run-panel-auto-approve.test.ts +8 −21
  429. e2e/scenarios/shape-memory.test.ts +178 −0
  430. e2e/scenarios/support/large-catalog.ts +29 −29
  431. e2e/scenarios/tool-descriptions.test.ts +2 −2
  432. e2e/scenarios/toolkits-mcp.test.ts +67 −0
  433. e2e/scripts/ci-shard-durations.ts +94 −0
  434. e2e/scripts/ci-shard.test.ts +126 −0
  435. e2e/scripts/ci-shard.ts +231 −0
  436. e2e/scripts/ci-shard.vitest.config.ts +9 −0
  437. e2e/scripts/cli.ts +5 −0
  438. e2e/scripts/record-connect-card.ts +2 −1
  439. e2e/scripts/run-ci-shard.ts +37 −0
  440. e2e/selfhost/admin-users-console.test.ts +331 −0
  441. e2e/selfhost/admin-users-pager.test.ts +167 −0
  442. e2e/selfhost/api-keys-feedback.test.ts +4 −3
  443. e2e/selfhost/auth-methods-ui.test.ts +203 −10
  444. e2e/selfhost/cli-device-login.test.ts +3 −3
  445. e2e/selfhost/connect-modal-credential-ux.test.ts +46 −2
  446. e2e/selfhost/detected-auth-immutable-ui.test.ts +3 −4
  447. e2e/selfhost/mcp-auth-required-add.test.ts +2 −3
  448. e2e/selfhost/mcp-browser-approval-ownership.test.ts +4 −45
  449. e2e/selfhost/mcp-connect-card-url.test.ts +2 −1
  450. e2e/selfhost/mcp-enterprise-managed-auth.test.ts +443 −0
  451. e2e/selfhost/mcp-oauth-cimd-connect.test.ts +109 −0
  452. e2e/selfhost/mcp-oauth-consent.test.ts +3 −2
  453. e2e/selfhost/mcp-oauth-reconnect-health.test.ts +223 −34
  454. e2e/selfhost/mcp-oauth-slow-connect.test.ts +133 −0
  455. e2e/selfhost/mcp-opencode-consent.test.ts +2 −1
  456. e2e/selfhost/mcp-session-idle-eviction.test.ts +172 −0
  457. e2e/selfhost/oauth-app-modal.test.ts +29 −1
  458. e2e/selfhost/oauth-callback-unauthenticated.test.ts +2 −1
  459. e2e/selfhost/oauth-org-connection-cross-principal.test.ts +275 −0
  460. e2e/selfhost/posthog-mcp-oauth.test.ts +45 −21
  461. e2e/selfhost/toolkits-ui.test.ts +6 −1
  462. e2e/setup/boot.ts +122 −13
  463. e2e/setup/cloud.boot.ts +41 −4
  464. e2e/setup/cloud.globalsetup.ts +16 −3
  465. e2e/setup/cloudflare.boot.ts +7 −2
  466. e2e/setup/desktop-linux.globalsetup.ts +1 −0
  467. e2e/setup/desktop-macos.globalsetup.ts +1 −0
  468. e2e/setup/execution-limits.ts +15 −0
  469. e2e/setup/mcp-session-timeouts.ts +3 −1
  470. e2e/setup/motel.ts +3 −3
  471. e2e/setup/sandbox-timeout.ts +28 −0
  472. e2e/setup/selfhost.boot.ts +17 −2
  473. e2e/setup/selfhost.globalsetup.ts +8 −2
  474. e2e/src/emulator-instance.ts +71 −0
  475. e2e/src/ports.ts +31 −7
  476. e2e/src/surfaces/autumn.ts +136 −6
  477. e2e/src/surfaces/browser.ts +123 −1
  478. e2e/src/surfaces/cli.ts +10 −1
  479. e2e/src/surfaces/mcp.ts +24 −7
  480. e2e/src/update-card-render.ts +2 −1
  481. e2e/targets/cloud.ts +9 −1
  482. e2e/targets/selfhost-docker.ts +7 −6
  483. e2e/targets/selfhost.ts +50 −1
  484. examples/all-plugins/CHANGELOG.md +224 −0
  485. examples/all-plugins/package.json +1 −2
  486. examples/all-plugins/src/main.ts +13 −4
  487. examples/docs-sdk-quickstart/CHANGELOG.md +128 −0
  488. examples/docs-sdk-quickstart/package.json +1 −1
  489. knip.config.ts +0 −3
  490. package.json +6 −2
  491. packages/app/package.json +6 −1
  492. packages/app/src/crash-reporting.test.ts +115 −0
  493. packages/app/src/crash-reporting.ts +35 −3
  494. packages/app/src/entry-client.tsx +5 −0
  495. packages/app/src/preload-error.test.ts +132 −0
  496. packages/app/src/preload-error.ts +90 −0
  497. packages/app/src/routeTree.gen.ts +81 −0
  498. packages/app/src/routes/__root.tsx +15 −4
  499. packages/app/src/server-disconnected.test.ts +184 −0
  500. packages/app/src/server-disconnected.ts +113 −0
  501. packages/app/src/web/shell.tsx +22 −7
  502. packages/app/tsr.routes.ts +6 −4
  503. packages/app/vite.ts +8 −1
  504. packages/app/vitest.config.ts +8 −0
  505. packages/core/analytics/CHANGELOG.md +66 −0
  506. packages/core/analytics/package.json +51 −0
  507. packages/core/analytics/src/analytics.test.ts +233 −0
  508. packages/core/analytics/src/analytics.ts +240 −0
  509. packages/core/analytics/src/engine.test.ts +159 −0
  510. packages/core/analytics/src/engine.ts +63 −0
  511. packages/core/analytics/src/events.ts +71 −0
  512. packages/core/analytics/src/index.ts +12 −0
  513. packages/core/analytics/tsconfig.json +24 −0
  514. packages/core/analytics/tsup.config.ts +12 −0
  515. packages/core/analytics/vitest.config.ts +7 −0
  516. packages/core/api/CHANGELOG.md +150 −0
  517. packages/core/api/package.json +1 −1
  518. packages/core/api/src/account/api.ts +37 −0
  519. packages/core/api/src/account/handlers.ts +18 −0
  520. packages/core/api/src/account/org-slug.test.ts +28 −1
  521. packages/core/api/src/account/org-slug.ts +7 −2
  522. packages/core/api/src/account/service.ts +12 −0
  523. packages/core/api/src/admin/admin-users.test.ts +1194 −0
  524. packages/core/api/src/admin/api.ts +362 −0
  525. packages/core/api/src/admin/handlers.ts +69 −0
  526. packages/core/api/src/admin/reads.ts +387 −0
  527. packages/core/api/src/admin/service.ts +81 −0
  528. packages/core/api/src/api.ts +2 −0
  529. packages/core/api/src/artifacts/api.ts +165 −0
  530. packages/core/api/src/client.ts +13 −0
  531. packages/core/api/src/connections/api.ts +1 −0
  532. packages/core/api/src/executions/api.ts +51 −2
  533. packages/core/api/src/handlers/artifacts.ts +119 −0
  534. packages/core/api/src/handlers/connections.ts +1 −0
  535. packages/core/api/src/handlers/executions.ts +194 −3
  536. packages/core/api/src/handlers/index.ts +3 −0
  537. packages/core/api/src/handlers/integrations.ts +1 −0
  538. packages/core/api/src/handlers/oauth.ts +6 −1
  539. packages/core/api/src/index.ts +17 −0
  540. packages/core/api/src/integrations/api.ts +8 −0
  541. packages/core/api/src/oauth-popup.test.ts +114 −3
  542. packages/core/api/src/oauth-popup.ts +15 −4
  543. packages/core/api/src/oauth/api.ts +22 −6
  544. packages/core/api/src/observability.test.ts +25 −1
  545. packages/core/api/src/observability.ts +13 −2
  546. packages/core/api/src/plugin-routes.ts +1 −1
  547. packages/core/api/src/server.ts +35 −1
  548. packages/core/api/src/server/execution-stack-middleware.ts +160 −19
  549. packages/core/api/src/server/execution-stack.ts +58 −9
  550. packages/core/api/src/server/executor-app.ts +7 −2
  551. packages/core/api/src/server/fixed-execution-middleware.ts +28 −5
  552. packages/core/api/src/server/host-foundation.ts +42 −0
  553. packages/core/api/src/server/identity.ts +71 −4
  554. packages/core/api/src/server/mcp-build.ts +57 −6
  555. packages/core/api/src/server/scoped-executor.ts +123 −5
  556. packages/core/api/src/services.ts +18 −1
  557. packages/core/cli/CHANGELOG.md +112 −0
  558. packages/core/cli/package.json +1 −1
  559. packages/core/config/CHANGELOG.md +112 −0
  560. packages/core/config/package.json +1 −1
  561. packages/core/config/src/config.test.ts +37 −35
  562. packages/core/config/src/index.ts +7 −7
  563. packages/core/config/src/schema.ts +16 −16
  564. packages/core/config/src/sink.ts +10 −10
  565. packages/core/config/src/write.ts +24 −24
  566. packages/core/execution/CHANGELOG.md +136 −0
  567. packages/core/execution/package.json +1 −1
  568. packages/core/execution/src/description.test.ts +50 −1
  569. packages/core/execution/src/description.ts +22 −0
  570. packages/core/execution/src/engine.test.ts +1 −1
  571. packages/core/execution/src/engine.ts +119 −12
  572. packages/core/execution/src/index.ts +16 −3
  573. packages/core/execution/src/provided-globals.ts +274 −0
  574. packages/core/execution/src/skills.test.ts +27 −1
  575. packages/core/execution/src/skills.ts +580 −12
  576. packages/core/execution/src/tool-invoker.leak.test.ts +1 −1
  577. packages/core/execution/src/tool-invoker.test.ts +576 −56
  578. packages/core/execution/src/tool-invoker.ts +121 −34
  579. packages/core/integrations-registry/src/registry.test.ts +10 −2
  580. packages/core/integrations-registry/src/registry.ts +1 −1
  581. packages/core/sdk/CHANGELOG.md +97 −0
  582. packages/core/sdk/README.md +3 −3
  583. packages/core/sdk/package.json +9 −2
  584. packages/core/sdk/src/artifact-preview.test.ts +304 −0
  585. packages/core/sdk/src/artifact-preview.ts +574 −0
  586. packages/core/sdk/src/artifact-table.test.ts +132 −0
  587. packages/core/sdk/src/artifact.ts +211 −0
  588. packages/core/sdk/src/artifacts.test.ts +353 −0
  589. packages/core/sdk/src/auth-tool-failure.ts +55 −17
  590. packages/core/sdk/src/blob.ts +1 −1
  591. packages/core/sdk/src/client.test.ts +2 −2
  592. packages/core/sdk/src/client.ts +34 −5
  593. packages/core/sdk/src/connection-name-identifier.ts +4 −0
  594. packages/core/sdk/src/connection.ts +7 −4
  595. packages/core/sdk/src/connections.test.ts +708 −2
  596. packages/core/sdk/src/core-schema.ts +129 −4
  597. packages/core/sdk/src/core-tools.ts +79 −20
  598. packages/core/sdk/src/errors.ts +65 −4
  599. packages/core/sdk/src/executor.test.ts +259 −1
  600. packages/core/sdk/src/executor.ts +1934 −227
  601. packages/core/sdk/src/fuma-runtime.test.ts +265 −0
  602. packages/core/sdk/src/fuma-runtime.ts +167 −12
  603. packages/core/sdk/src/health-check.ts +98 −3
  604. packages/core/sdk/src/health-response-sample-redaction.test.ts +183 −0
  605. packages/core/sdk/src/host-internal.ts +15 −0
  606. packages/core/sdk/src/hosted-http-client.test.ts +68 −0
  607. packages/core/sdk/src/hosted-http-client.ts +71 −2
  608. packages/core/sdk/src/ids.ts +4 −0
  609. packages/core/sdk/src/index.ts +96 −2
  610. packages/core/sdk/src/insufficient-scope.test.ts +377 −0
  611. packages/core/sdk/src/insufficient-scope.ts +274 −0
  612. packages/core/sdk/src/integration.ts +26 −0
  613. packages/core/sdk/src/migration-spec.test.ts +2 −2
  614. packages/core/sdk/src/migration-spec.ts +2 −2
  615. packages/core/sdk/src/oauth-client.ts +251 −23
  616. packages/core/sdk/src/oauth-discovery.test.ts +26 −0
  617. packages/core/sdk/src/oauth-discovery.ts +58 −9
  618. packages/core/sdk/src/oauth-ema-lifecycle.test.ts +336 −0
  619. packages/core/sdk/src/oauth-ema-rollout.test.ts +467 −0
  620. packages/core/sdk/src/oauth-ema.test.ts +502 −0
  621. packages/core/sdk/src/oauth-ema.ts +476 −0
  622. packages/core/sdk/src/oauth-first-party.test.ts +471 −0
  623. packages/core/sdk/src/oauth-flow.test.ts +1175 −203
  624. packages/core/sdk/src/oauth-helpers.test.ts +628 −1
  625. packages/core/sdk/src/oauth-helpers.ts +872 −53
  626. packages/core/sdk/src/oauth-register-dynamic.test.ts +211 −0
  627. packages/core/sdk/src/oauth-remove-client.test.ts +337 −2
  628. packages/core/sdk/src/oauth-scope-union.test.ts +93 −2
  629. packages/core/sdk/src/oauth-service.ts +883 −131
  630. packages/core/sdk/src/oauth-session-cleanup.test.ts +406 −0
  631. packages/core/sdk/src/owner-policy-reach.test.ts +318 −0
  632. packages/core/sdk/src/owner-policy.ts +78 −1
  633. packages/core/sdk/src/pending-approval.test.ts +142 −0
  634. packages/core/sdk/src/pending-approval.ts +110 −0
  635. packages/core/sdk/src/platform-view.test.ts +901 −0
  636. packages/core/sdk/src/plugin-after-commit.test.ts +116 −0
  637. packages/core/sdk/src/plugin.ts +151 −12
  638. packages/core/sdk/src/policies.test.ts +43 −2
  639. packages/core/sdk/src/policies.ts +41 −0
  640. packages/core/sdk/src/promise-executor.ts +1 −1
  641. packages/core/sdk/src/promise.test.ts +2 −2
  642. packages/core/sdk/src/provider-call-timeout.test.ts +212 −0
  643. packages/core/sdk/src/provider-item-owner.ts +52 −0
  644. packages/core/sdk/src/sentry-grouping.test.ts +269 −0
  645. packages/core/sdk/src/sentry-grouping.ts +169 −0
  646. packages/core/sdk/src/server-connection.test.ts +54 −0
  647. packages/core/sdk/src/server-connection.ts +69 −1
  648. packages/core/sdk/src/shape-inference.test.ts +136 −0
  649. packages/core/sdk/src/shape-inference.ts +219 −0
  650. packages/core/sdk/src/shape-memory.ts +90 −0
  651. packages/core/sdk/src/shared.ts +36 −0
  652. packages/core/sdk/src/sqlite-config-blob-migration.test.ts +2 −2
  653. packages/core/sdk/src/sqlite-data-migrations.test.ts +61 −0
  654. packages/core/sdk/src/sqlite-data-migrations.ts +12 −6
  655. packages/core/sdk/src/subject-registry.test.ts +418 −0
  656. packages/core/sdk/src/subject-registry.ts +0 −0
  657. packages/core/sdk/src/subject-table.test.ts +103 −0
  658. packages/core/sdk/src/test-config.ts +6 −0
  659. packages/core/sdk/src/testing/id-jag-test-support.ts +251 −0
  660. packages/core/sdk/src/testing/oauth-test-server.ts +340 −2
  661. packages/core/sdk/src/tool-not-found-empty-catalog.test.ts +113 −0
  662. packages/core/sdk/src/types.ts +4 −0
  663. packages/core/sdk/tsup.config.ts +1 −0
  664. packages/core/vite-plugin/CHANGELOG.md +112 −0
  665. packages/core/vite-plugin/package.json +1 −1
  666. packages/hosts/cloudflare/CHANGELOG.md +164 −0
  667. packages/hosts/cloudflare/package.json +6 −2
  668. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +583 −5
  669. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +541 −60
  670. packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts +2 −0
  671. packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts +176 −0
  672. packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts +219 −0
  673. packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts +205 −2
  674. packages/hosts/cloudflare/src/mcp/do-headers.test.ts +49 −0
  675. packages/hosts/cloudflare/src/mcp/do-headers.ts +41 −6
  676. packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts +275 −0
  677. packages/hosts/cloudflare/src/mcp/durable-object-errors.ts +237 −0
  678. packages/hosts/cloudflare/src/mcp/session-alarm-policy.test.ts +78 −0
  679. packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts +65 −4
  680. packages/hosts/cloudflare/src/mcp/session-runtime-residency.ts +96 −0
  681. packages/hosts/cloudflare/src/mcp/session-stub.ts +1 −1
  682. packages/hosts/mcp-apps-shell/.gitignore +1 −0
  683. packages/hosts/mcp-apps-shell/CHANGELOG.md +73 −0
  684. packages/hosts/mcp-apps-shell/package.json +102 −0
  685. packages/hosts/mcp-apps-shell/scripts/gen-provided-globals.ts +23 −0
  686. packages/hosts/mcp-apps-shell/scripts/gen-smoke-harness.ts +45 −0
  687. packages/hosts/mcp-apps-shell/scripts/subset-fonts.ts +96 −0
  688. packages/hosts/mcp-apps-shell/src/index.ts +13 −0
  689. packages/hosts/mcp-apps-shell/src/shell-asset-path.ts +35 −0
  690. packages/hosts/mcp-apps-shell/src/shell-dev-html.d.ts +14 −0
  691. packages/hosts/mcp-apps-shell/src/shell-html.ts +67 −0
  692. packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts +98 −0
  693. packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx +432 −0
  694. packages/hosts/mcp-apps-shell/src/shell/artifact-states.tsx +217 −0
  695. packages/hosts/mcp-apps-shell/src/shell/component-runtime.test.ts +53 −0
  696. packages/hosts/mcp-apps-shell/src/shell/component-runtime.ts +190 −0
  697. packages/hosts/mcp-apps-shell/src/shell/components.ts +283 −0
  698. packages/hosts/mcp-apps-shell/src/shell/display-mode.ts +104 −0
  699. packages/hosts/mcp-apps-shell/src/shell/fonts/README.md +29 −0
  700. packages/hosts/mcp-apps-shell/src/shell/fonts/geist-mono.woff2 +0 −0
  701. packages/hosts/mcp-apps-shell/src/shell/fonts/geist-sans.woff2 +0 −0
  702. packages/hosts/mcp-apps-shell/src/shell/globals.css +53 −0
  703. packages/hosts/mcp-apps-shell/src/shell/hooks.ts +158 −0
  704. packages/hosts/mcp-apps-shell/src/shell/inner-renderer-source.d.ts +14 −0
  705. packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx +432 −0
  706. packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts +2649 −0
  707. packages/hosts/mcp-apps-shell/src/shell/mcp-app.html +17 −0
  708. packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx +101 −0
  709. packages/hosts/mcp-apps-shell/src/shell/preview-capture.ts +36 −0
  710. packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts +40 −0
  711. packages/hosts/mcp-apps-shell/src/shell/proxy.ts +181 −0
  712. packages/hosts/mcp-apps-shell/src/shell/render-signal.ts +60 −0
  713. packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx +1098 −0
  714. packages/hosts/mcp-apps-shell/src/shell/shell-html-url.d.ts +13 −0
  715. packages/hosts/mcp-apps-shell/src/shell/smoke-harness-bundle.gen.ts +17 −0
  716. packages/hosts/mcp-apps-shell/src/shell/smoke-harness-shims.ts +274 −0
  717. packages/hosts/mcp-apps-shell/src/shell/smoke-harness.tsx +168 −0
  718. packages/hosts/mcp-apps-shell/src/shell/smoke-render-result.ts +36 −0
  719. packages/hosts/mcp-apps-shell/src/shell/smoke-render.test.tsx +388 −0
  720. packages/hosts/mcp-apps-shell/src/shell/smoke-render.ts +189 −0
  721. packages/hosts/mcp-apps-shell/src/shell/theme-tokens.pin.test.ts +196 −0
  722. packages/hosts/mcp-apps-shell/src/shell/theme.css +317 −0
  723. packages/hosts/mcp-apps-shell/src/shell/tool-call-grammar.pin.test.ts +110 −0
  724. packages/hosts/mcp-apps-shell/src/shell/tools-proxy.ts +261 −0
  725. packages/hosts/mcp-apps-shell/src/smoke-render-entry.ts +43 −0
  726. packages/hosts/mcp-apps-shell/src/vite.test.ts +28 −0
  727. packages/hosts/mcp-apps-shell/src/vite.ts +411 −0
  728. packages/hosts/mcp-apps-shell/src/worker.test.ts +85 −0
  729. packages/hosts/mcp-apps-shell/src/worker.ts +75 −0
  730. packages/hosts/mcp-apps-shell/tsconfig.json +16 −0
  731. packages/hosts/mcp-apps-shell/vite.config.shell.ts +46 −0
  732. packages/hosts/mcp-apps-shell/vitest.config.ts +12 −0
  733. packages/hosts/mcp/package.json +14 −1
  734. packages/hosts/mcp/src/artifact-action.ts +87 −0
  735. packages/hosts/mcp/src/artifact-bindings.test.ts +398 −0
  736. packages/hosts/mcp/src/artifact-bindings.ts +419 −0
  737. packages/hosts/mcp/src/artifacts-tools.test.ts +2557 −0
  738. packages/hosts/mcp/src/browser-approval.ts +28 −0
  739. packages/hosts/mcp/src/create-artifact.ts +489 −0
  740. packages/hosts/mcp/src/envelope.test.ts +147 −0
  741. packages/hosts/mcp/src/envelope.ts +191 −33
  742. packages/hosts/mcp/src/in-memory-session-store.test.ts +430 −0
  743. packages/hosts/mcp/src/in-memory-session-store.ts +227 −15
  744. packages/hosts/mcp/src/index.ts +7 −1
  745. packages/hosts/mcp/src/namespace-search-tools.test.ts +222 −0
  746. packages/hosts/mcp/src/stdio-integration.test.ts +181 −21
  747. packages/hosts/mcp/src/tool-call-code.ts +119 −0
  748. packages/hosts/mcp/src/tool-server.test.ts +228 −13
  749. packages/hosts/mcp/src/tool-server.ts +1364 −54
  750. packages/kernel/core/CHANGELOG.md +32 −0
  751. packages/kernel/core/package.json +2 −1
  752. packages/kernel/core/src/effect-errors.ts +23 −0
  753. packages/kernel/core/src/error-kind.test.ts +25 −0
  754. packages/kernel/core/src/error-kind.ts +37 −0
  755. packages/kernel/core/src/index.ts +1 −0
  756. packages/kernel/core/src/json-schema.ts +27 −8
  757. packages/kernel/core/src/strip-types.test.ts +1 −1
  758. packages/kernel/core/src/types.ts +11 −0
  759. packages/kernel/ir/src/registry.ts +2 −2
  760. packages/kernel/ir/src/serialize.ts +2 −2
  761. packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs +10 −0
  762. packages/kernel/runtime-deno-subprocess/src/deno-worker-process.ts +47 −2
  763. packages/kernel/runtime-deno-subprocess/src/fixtures/close-stdin-worker.mjs +37 −0
  764. packages/kernel/runtime-deno-subprocess/src/index.test.ts +117 −11
  765. packages/kernel/runtime-deno-subprocess/src/index.ts +78 −13
  766. packages/kernel/runtime-dynamic-worker/src/executor.ts +215 −8
  767. packages/kernel/runtime-dynamic-worker/src/host-timeout.test.ts +195 −0
  768. packages/kernel/runtime-dynamic-worker/src/index.ts +3 −0
  769. packages/kernel/runtime-dynamic-worker/src/invocation.test.ts +136 −0
  770. packages/kernel/runtime-dynamic-worker/src/module-template.ts +35 −5
  771. packages/kernel/runtime-dynamic-worker/src/wedge-repro.test.ts +58 −0
  772. packages/kernel/runtime-quickjs/CHANGELOG.md +114 −0
  773. packages/kernel/runtime-quickjs/package.json +1 −1
  774. packages/kernel/runtime-quickjs/src/index.test.ts +94 −0
  775. packages/kernel/runtime-quickjs/src/index.ts +103 −20
  776. packages/kernel/runtime-quickjs/src/sealed-bundle.test.ts +113 −0
  777. packages/kernel/runtime-quickjs/src/sealed-bundle.ts +209 −0
  778. packages/kernel/runtime-workerd-subprocess/CHANGELOG.md +113 −0
  779. packages/kernel/runtime-workerd-subprocess/package.json +26 −0
  780. packages/kernel/runtime-workerd-subprocess/src/index.test.ts +251 −0
  781. packages/kernel/runtime-workerd-subprocess/src/index.ts +740 −0
  782. packages/kernel/runtime-workerd-subprocess/tsconfig.json +5 −6
  783. packages/kernel/runtime-workerd-subprocess/vitest.config.ts +7 −0
  784. packages/plugins/desktop-settings/CHANGELOG.md +112 −0
  785. packages/plugins/desktop-settings/package.json +1 −1
  786. packages/plugins/desktop-settings/src/client.tsx +5 −5
.changeset/concurrent-stale-tools-sync.md+11−0
@@ -0,0 +1,11 @@
1+---
2+"executor": patch
3+---
4+
5+**Stale tool catalogs refresh together instead of one after another, and self-host can set the freshness window**
6+
7+A tools read rebuilds every connection whose catalog has gone stale. Those rebuilds each dial their own upstream, but ran strictly one after another, so a host with several stale remote catalogs paid the sum of every server's latency on the read that tripped the TTL. The upstream listings now run concurrently, bounded so a large stale set cannot open an unbounded number of listings from one read.
8+
9+Only the listings overlap. Each rebuild's catalog write stays single-file, because a self-host database is one connection issuing raw `BEGIN`/`COMMIT` and a second transaction opened while one is live fails outright. A rebuild that fails now also logs a warning naming the connection and the reason, instead of disappearing: the read still succeeds on the stale-but-working catalog and the other connections still finish, but a permanently broken connection no longer re-fails silently on every read.
10+
11+Self-host also exposes the freshness window as `EXECUTOR_TOOLS_SYNC_TTL_MS`. Leave it unset for the 15-minute default, or set `off` (equivalently `null` or `false`, in any case) to disable time-based re-sync and leave stale-marking and config revision as the only refresh triggers. The value forwards to the SDK verbatim, so `0` keeps its SDK meaning: every catalog is expired on every read. A malformed, negative, or too-large-to-represent value is refused at boot rather than silently falling back to the default.
.changeset/config.json+2−2
@@ -18,7 +18,8 @@ · 17 unmodified lines
1818 "@executor-js/plugin-openapi",
1919 "@executor-js/plugin-example",
2020 "@executor-js/plugin-desktop-settings",
21− "@executor-js/desktop"
21+ "@executor-js/desktop",
22+ "@executor-js/local"
2223 ]
2324 ],
2425 "linked": [],
@@ -29,7 +30,6 @@ · 4 unmodified lines
2930 "onlyUpdatePeerDependentsWhenOutOfRange": true
3031 },
3132 "ignore": [
32− "@executor-js/local",
3333 "@executor-js/host-mcp",
3434 "@executor-js/ir",
3535 "@executor-js/runtime-deno-subprocess",
.changeset/connection-create-conflict.md+7−1
@@ -2,4 +2,10 @@ · 1 unmodified line
22 "@executor-js/sdk": patch
33 ---
44
5−Creating a connection over an existing one is now rejected instead of silently overwriting it. `connections.create` fails with `ConnectionAlreadyExistsError` (HTTP 409) when the (owner, integration, name) is already taken, and `oauth.start` / `oauth.complete` reject a fresh OAuth connect that targets an existing connection name. Reconnect flows pass `reconnect: true` and keep re-minting the same connection for re-consent and token refresh.
5+**Creating a connection over an existing one is rejected instead of silently overwriting it**
6+
7+`connections.create` used to upsert: a create with the same (owner, integration, name) replaced the saved connection and, for a pasted value, overwrote the stored secret itself. It now fails with the new `ConnectionAlreadyExistsError` and leaves the existing connection untouched. Remove the connection first, or pick a different name.
8+
9+This adds one error to the wire contract: the `POST /connections` endpoint can answer **HTTP 409** with tag `ConnectionAlreadyExistsError`, and the `connections.create` core tool resolves the same case as `{ ok: false, error: { code: "connection_already_exists" } }`. The change is additive — no existing status, field, or success shape moves.
10+
11+OAuth is unaffected. Fresh OAuth connects already resolve a taken name to the next free suffix through `newConnection`, and reconnect still re-mints the same connection on purpose.
.changeset/defer-irreversible-cleanup.md+15−0
@@ -0,0 +1,15 @@
1+---
2+"executor": minor
3+---
4+
5+**Irreversible cleanup now waits for the transaction to commit, and plugins can do the same**
6+
7+`oauth.removeClient` deleted the client row and then deleted the client secret from the credential provider. The provider does not enlist in the caller's transaction and does not roll back with it, so an abort restored the client row while its secret stayed destroyed — a client that looks configured and can never authenticate again. The deletion now waits until the removal is durable and is discarded if the removal rolls back. With no transaction active it runs immediately, exactly as before.
8+
9+Deferring the deletion is not enough on its own. The secret is stored under a key derived from the app's `(owner, slug)` identity alone, so the key outlives the row it belonged to: whoever holds that identity when the deletion finally runs owns the key. A slug registered again before the removal committed would lose the new app's secret to the old app's queued deletion — the same unauthenticatable client, reached the other way round. The deferred deletion now re-checks that the app is still gone and stands down when it is not. A removal that matched no row also no longer queues a deletion at all: it removed nothing, so it has no claim on the key, which may well hold another subject's live secret.
10+
11+The same trap was reachable by plugins and they had no way out of it. `removeConnection` and `removeIntegration` run inside core's removal transaction — deliberately, so a plugin's own rows die atomically with the connection — which makes them exactly the wrong place to revoke a token at the provider's API, delete a remote object, or notify a third party. Nothing in the hooks' documentation said so, and `PluginCtx` exposed `transaction` but nothing to defer past it.
12+
13+`PluginCtx` gains `afterCommit`. It runs the effect once the outermost transaction commits, discards it if that transaction rolls back, and runs it immediately when no transaction is active. The lifecycle hooks now document that they run inside core's transaction and that outside-world work belongs in `afterCommit`.
14+
15+Sequencing work after your own `transaction(...)` call is not equivalent, and the documentation says so explicitly: `transaction` nests by pass-through, so inside an active transaction the inner call simply runs its effect and "afterwards" is still before any commit.
.changeset/expired-authorization-session-sweep.md+11−0
@@ -0,0 +1,11 @@
1+---
2+"executor": patch
3+---
4+
5+**Abandoned authorization sessions no longer keep their PKCE verifier forever**
6+
7+An OAuth authorization session stores its PKCE verifier so the callback can redeem the code. `complete` discarded an expired session lazily, but an _abandoned_ flow is never completed, so that check never ran for it and nothing else swept the table — the verifier sat there in plaintext indefinitely.
8+
9+Starting a new authorization now sweeps sessions that have already expired. Doing it on `start` bounds the table by how often authorization is begun rather than by how often it is abandoned, and needs no scheduler in any host. A session whose completion cannot be retried is dropped rather than left behind.
10+
11+The sweep only ever reaches rows the caller can already see, so one member's authorization never touches another member's sessions. It is best-effort: a sweep that fails logs a warning and lets the authorization continue.
.changeset/graphql-introspection-credential-log.md+24−0
@@ -0,0 +1,24 @@
1+---
2+"executor": patch
3+---
4+
5+**GraphQL introspection no longer logs a credential carried in the endpoint URL**
6+
7+`query` is a supported credential carrier, so a GraphQL endpoint can be reached with `?token=<secret>`. Introspection built its request from a URL **string**, and `HttpClientRequest.setUrl` keeps a string verbatim as `request.url`. Every `HttpClientError` renders `${method} ${request.url}` into its `message` getter, and introspection logs the raw failure cause — so on any transport failure or non-JSON response, the connection's secret was written to the process log.
8+
9+The request is now built from a URL **object**, which moves the query into `request.urlParams` and clears it from `request.url`. The secret is therefore absent from the error message, and from anything else that renders the request URL. The credential still reaches the upstream: the client recombines url and urlParams when it executes the request. The endpoint's own query string is handled the same way, not just the separately-supplied query parameters, since a configured endpoint can carry a credential too.
10+
11+**The query string is now normalized on the wire.** Recombination appends each pair through `URLSearchParams`, so the query is re-serialized in form-urlencoded form instead of passed through byte-for-byte:
12+
13+- a space written as `%20` is sent as `+`
14+- `~` and `!'()` are percent-encoded
15+- a valueless `?flag` is sent as `flag=`
16+
17+Key order, repeated keys, and already-encoded reserved characters are unchanged, and every parameter still decodes to the same value. This is not avoidable while the fix holds: raw query bytes only survive inside `request.url`, which is the one field every error message renders, so byte-transparency and keeping the credential out of the log cannot both hold. An upstream that signs its raw query string is the case to watch. The exact resulting URLs are pinned by test.
18+
19+Two endpoints are now rejected up front with an `invalid-endpoint` failure rather than dialed:
20+
21+- an endpoint that is not a valid URL, which cannot be split this way and would otherwise be sent without the query parameters it was asked to include
22+- an endpoint carrying userinfo (`https://user:pass@host/…`), which `URL` keeps in the origin, so it would stay in `request.url` and leak into error messages exactly the way a query-carried secret used to
23+
24+Neither rejection echoes any part of the endpoint. A health check on such an integration now reports the invalid configuration and points the operator at the endpoint URL, instead of blaming the credential that was never sent.
.changeset/health-response-sample-scrub.md+16−0
@@ -0,0 +1,16 @@
1+---
2+"executor": patch
3+---
4+
5+**Credentials are kept out of the health-check result that gets persisted**
6+
7+A health check stores a sample of the probed operation's response body, plus the extracted identity, in `connection.last_health` — so whatever those carry is written to the database. The operation is user-chosen from the plugin's catalog, which means it can just as easily be a key-listing endpoint as a `/me`, and those return secrets that no scrub of the connection's own credential value can recognise, because they are different secrets entirely.
8+
9+Two passes now cover both kinds of secret:
10+
11+- **By key name.** Leaves whose key names a credential (`token`, `api_key`, `secret`, `authorization`, `session`, …) have their value replaced with `[redacted]`. The row itself is kept, so the live preview still shows the response shape and the identity picker still works. Keys that merely contain a matching substring, such as `author`, are left alone. camelCase spellings are recognised too: `accessToken`, `refreshToken`, `clientSecret`, `privateKey` and `sessionId` have no separator before the credential word, so a matcher that only looks for one reads them as innocent.
12+- **By value.** The OpenAPI health check removes the connection's own credential value from each sampled value, covering the other direction: a body that echoes back the key it was authenticated with under an innocent-looking name. This runs before the sample's 120-char truncation, not after — truncating first leaves a prefix of a long credential that an exact-value scrub can no longer match, and that prefix is what would be persisted.
13+
14+The key check reads a dotted path two ways. It uses the nearest NAMED segment, because array elements are named by index: `{"tokens": ["sk-live-…"]}` produces the path `tokens.0`, and testing the literal `"0"` matches nothing. It also uses an enclosing array container, because a key listing returns `{"api_keys": [{"value": "sk-live-…"}]}`, whose path is `api_keys.0.value` — the nearest named segment there is the innocent `value`, and only the array's own key says what the collection holds. A collection whose key names nothing, such as `names.0`, is still shown in full.
15+
16+The extracted `identity` goes through both passes as well. It is read straight off the raw body, so it previously bypassed them even though it is persisted the same way, and `identityField` is user-chosen from whatever the picker listed.
.changeset/mcp-oauth-declared-scopes.md+11−0
@@ -0,0 +1,11 @@
1+---
2+"executor": patch
3+---
4+
5+**Fix: allow MCP integrations to declare OAuth scopes when resource metadata omits them**
6+
7+MCP OAuth methods can now carry an optional non-empty scope list. Declared scopes
8+take precedence over protected-resource scope discovery, so servers with fixed
9+scopes can connect even when their dynamically registered OAuth client has no
10+resource identifier. Existing integrations without declared scopes keep
11+discovering them from the server at connect time.
.changeset/mcp-pool-idle-sweep.md+13−0
@@ -0,0 +1,13 @@
1+---
2+"executor": patch
3+---
4+
5+**Idle MCP connections age out on the pool's next acquire, even when their identity is never dialled again**
6+
7+The pool's five-minute idle window was only consulted against the entry being requested, so an identity that was never asked for a second time was never examined a second time. Its session stayed open and authenticated for as long as the pool lived, holding the bearer token or API key it was dialled with. The advertised bound applied only to connections that happened to be reused.
8+
9+`acquire` now sweeps every entry past the window, closing each one, rather than just the entry matching the key. This stays lazy in the sense the pool intends — activity drives it, there is no timer and no background fiber — and the map holds at most one entry per identity, so the scan is trivial.
10+
11+Because the sweep is paid for by whichever invocation acquires next, it cannot be allowed to stall that caller. The expired entries leave the pool synchronously, before any close is awaited, and the closes then run concurrently with each one bounded by a two-second timeout — so a server that accepts a close and goes quiet is abandoned rather than waited on, and cannot hold up an unrelated request or the connections queued behind it.
12+
13+Reuse is unchanged: an entry still inside the window is left alone, and a second call for the same identity still gets the parked session rather than a fresh dial.
.changeset/mcp-pre-initialize-method-not-found.md+9−0
@@ -0,0 +1,9 @@
1+---
2+"@executor-js/local": patch
3+---
4+
5+**An unsupported method probed before `initialize` no longer kills the MCP connection**
6+
7+Only `initialize` can open a session, so the streamable-HTTP transport answered every other pre-session method with HTTP 400 + `-32000 Server not initialized`. A 400 is a transport-level failure, so clients dropped the connection instead of treating it as one request failing — a client that opens with an optional probe (MCP 2026-07-28 clients lead with `server/discover`) was disconnected before it could fall back to `initialize`. Over `executor mcp`, which bridges this endpoint to stdio, that closed the client's pipe outright.
8+
9+Pre-session dispatch now answers any method other than `initialize` with `-32601 Method not found` on a normal 200, which is a per-request error, so the connection survives and the handshake proceeds. This replaces only that one answer: a POST with a bad `Accept` or `Content-Type` still gets the transport's 406 or 415, and a message that is not a valid JSON-RPC request still gets its parse error.
.changeset/mcp-session-idle-eviction.md+7−0
@@ -0,0 +1,7 @@
1+---
2+"@executor-js/host-mcp": patch
3+---
4+
5+Evict idle MCP sessions instead of holding them for the lifetime of the process. The in-process session store only released a session when the client sent `DELETE /mcp`, which the MCP client SDK's `transport.close()` never sends and a crashed client cannot send, so every `initialize` permanently retained an `McpServer`, its tool registry, and an `ExecutionEngine`. Sessions are now stamped on create and on each request, and a timer disposes anything idle past `sessionIdleTtlMs` (30 minutes by default). An open server-to-client stream does not defer eviction, matching how cloud's session alarm destroys a session once it passes its running-lease ceiling; an evicted id answers 404 `-32001`, which is the client's cue to re-initialize.
6+
7+A request in flight holds its session: idleness counts from when a call ends, not from when it started, so a tool call slower than the idle window is never cut off mid-flight. Disposal also shuts the session's execution engine down rather than only dropping the reference, which is what ends its detached sandbox fibers, and a handle that fails to close is now logged with its session id instead of being discarded silently.
.changeset/mcp-stdio-env-isolation.md+11−0
@@ -0,0 +1,11 @@
1+---
2+"@executor-js/plugin-mcp": patch
3+---
4+
5+**Stdio MCP servers no longer inherit executor's full environment**
6+
7+A stdio MCP server that declared any `env` at all was spawned with every environment variable this process holds. The MCP SDK already guards against that: it spawns with `{ ...getDefaultEnvironment(), ...serverParams.env }`, where `getDefaultEnvironment()` is a sudo-style safe-list of `HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM` and `USER`. Passing `{ ...process.env, ...config.env }` did not add to that safe-list, it overwrote it. In practice, adding one third-party `npx` server went from "this server can see the API key I gave it" to "this server also holds `EXECUTOR_SECRET_KEY`, the key that decrypts every other stored credential, plus `EXECUTOR_AUTH_TOKEN` and `DATABASE_URL`". The leak sat on the `config.env` branch — the branch a credential-bearing integration takes.
8+
9+A stdio server now receives the SDK's safe-list, the variables declared on the source config, and one short allowlist of infrastructure variables read from the host: `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` (both spellings), `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE` and `SSL_CERT_DIR`. Those carry no credential, no source config declares them, and a server behind a corporate proxy or an intercepting CA cannot reach anything without them — the same reasoning and the same list `service install` already uses when it bakes a supervised unit's minimal environment. The declared `env` wins on a key collision. On Windows that collision is resolved case-insensitively, because the OS treats `Path` and `PATH` as one variable while a JavaScript spread does not: a declared `http_proxy` now replaces an inherited `HTTP_PROXY` instead of travelling beside it, which would have left the child reading whichever spelling Windows resolved first.
10+
11+If a stdio server relied on some other variable arriving from the host, set it explicitly on the source's `env`. That is now the only way anything beyond the lists above reaches a server, and it is the mechanism that already existed for it.
.changeset/oauth-refresh-store-writability-gate.md+13−0
@@ -0,0 +1,13 @@
1+---
2+"executor": patch
3+---
4+
5+**A credential-store outage no longer costs an OAuth connection its grant**
6+
7+Refreshing an OAuth token spends the stored refresh token: the authorization server rotates it, so the copy we sent stops working the moment the grant succeeds and the rotated one is the only thing that can mint again. Persisting the rotated token first bounds what a partial write can lose, but it cannot help when the store is refusing writes outright — the grant has already run, there is nowhere to put the successor, and every later refresh replays a token the server has revoked. The connection then reports `invalid_grant` and demands a re-auth over what was only a storage blip.
8+
9+The refresh is now gated on a store that is proven writable. Before the grant runs, it writes a fixed value to an item of its own that holds no credential and sits in the same partition as the connection's tokens. A store that cannot take that write fails the resolve while the stored refresh token is still valid, so the connection recovers on its own once the store does.
10+
11+The probe deliberately does not test the store by rewriting the refresh token with the value it just read. That is a read-then-write with no compare-and-set, and two instances refreshing one connection would lose the newer token to it: one reads the stored token, the other spends that same token and stores its rotated replacement, and the first then writes the spent one back over the replacement. The connection would die exactly the way the gate is meant to prevent.
12+
13+The probe also removes a write rather than adding one in the common case. Authorization servers that do not rotate hand back the same refresh token, and that value is no longer re-persisted when it has not changed — a rotated token never matches, so the write that matters still happens.
.changeset/oauth-token-endpoint-error-leak.md+35−0
@@ -0,0 +1,35 @@
1+---
2+"executor": patch
3+---
4+
5+**Keep token material out of OAuth token-endpoint error messages**
6+
7+A token-endpoint failure renders a preview of the upstream body into its
8+message, and that message is persisted onto connection health, returned to the
9+caller, and carried into telemetry. On a malformed HTTP 200 the body being
10+previewed is a _successful_ token response, so an access token and a refresh
11+token could be rendered into it.
12+
13+The preview is now built from an allowlist of fields that are safe to show
14+(`error`, `errors`, `error_description`, `error_uri`, plus `code`, `message`,
15+and `detail` nested inside them) instead of a denylist of fields to hide. A
16+field nobody anticipated is omitted by default rather than printed by default.
17+Keys stay visible and only non-allowlisted string values are replaced, so an
18+operator can still read the shape of what the server sent. `code` is readable
19+only when nested, because at the top level of a token response it is the RFC
20+6749 authorization code.
21+
22+Form-encoded bodies take the same allowlist, the walk over a body is
23+depth-bounded, and the failure summary records the token endpoint's hostname
24+rather than its full URL, which can carry identifiers in its path.
25+
26+On that same malformed-200 path the failure no longer keeps the underlying
27+rejection as its `cause`. That rejection carries the parsed token response, so
28+keeping it put the raw tokens back into anything that renders the whole failure
29+rather than only its message. Everything the path needs from the body — the
30+status, the error code, the redacted preview — is read before the failure is
31+built. A transport failure still keeps its cause, which is what tells a DNS miss
32+apart from a refused connection.
33+
34+No public API changes. The dead-grant classification added for HTTP 200 refresh
35+refusals is unaffected: it reads the HTTP status, not the rendered preview.
.changeset/openapi-multipart-file-fields.md+12−0
@@ -0,0 +1,12 @@
1+---
2+"@executor-js/plugin-openapi": patch
3+---
4+
5+Multipart file fields in an OpenAPI spec now accept and send real files. A `multipart/form-data` property typed as a binary or byte string is rewritten into the SDK's tool-file schema when the tool is extracted, so an agent supplies a file the same way it does everywhere else. On invocation those values are decoded back into `File`/`Blob` parts — as bare properties and inside arrays, with a per-property `encoding.contentType` applied to each file part — instead of being JSON-stringified into the form body, which is what upstreams were previously rejecting. A file whose base64 payload does not decode now fails the invocation and names the field, rather than sending the file envelope as JSON.
6+
7+The rewrite advertises only the shapes the request encoder can deliver. Two are deliberately left alone:
8+
9+- A binary field nested inside an object property. Only top-level multipart properties and direct items of a top-level array property become form parts.
10+- A multipart body schema, or one of its properties, behind a `$ref`. Component schemas are carried through unresolved by design — the streaming compile path never materializes `components.schemas` — so a `$ref`'d file field keeps its declared binary string type.
11+
12+The rewrite reads the request schema's own `properties` map rather than walking every object key, so a `default`, `example`, or vendor extension that happens to look like a binary string schema is untouched. Descriptions, titles, and nullability on the replaced field are carried onto the file schema.
.changeset/persist-mcp-install-options.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"executor": patch
3+---
4+
5+Persist the Connect-an-agent card's transport, artifact, integration-search, and approval preferences in the browser so its generated MCP install command remains stable across page reloads.
.changeset/scope-mcp-stream-replay.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"executor": patch
3+---
4+
5+Keep Last-Event-ID recovery scoped to its originating MCP stream.
.changeset/seat-based-team-pricing.md+22−0
@@ -0,0 +1,22 @@
1+---
2+"@executor-js/cloud": patch
3+---
4+
5+**Team pricing is per member with unlimited executions**
6+
7+The Team plan moves from $150 per organization with a 250,000-execution
8+allowance to $15 per member per month with unlimited executions. The
9+`members` feature is unarchived in `autumn.config.ts` and billed in arrears
10+on the seat count the app reports; Free keeps its 3-member, 100,000-execution
11+shape and Enterprise stays custom with seat usage tracked for visibility.
12+
13+Seat counts reconcile from a full WorkOS recount (active members only —
14+pending invites hold a seat for the plan gate but are not billed) after
15+member removal, invitation acceptance, organization creation, and on every
16+login callback, which also picks up joins the app never sees a mutation for
17+(SSO JIT provisioning, join by domain, dashboard edits). Plans that predate
18+seat pricing have no members balance and are skipped, so existing
19+subscriptions keep billing exactly as before on their current plan version.
20+
21+The plans page, billing page, and marketing pricing cards now show the
22+per-member price.
.claude/skills/emulate/SKILL.md+15−12
@@ -32,18 +32,22 @@ const github = await createEmulator({ service: "github", port: 4501 }); · 31 unmodified lines
3232 `servers`) when a proxy fronts the emulator — the bind stays on `port`.
3333
3434 **Attach to a running one** (another process, or hosted on Cloudflare with
35−Durable Object state at `https://<service>.emulators.dev` /
36−`https://<service>.<instance>.emulators.dev` — catalog at
37−`GET https://emulators.dev/_emulate/services`):
35+Durable Object state at `https://<service>.<instance>.emulators.dev` —
36+catalog at `GET https://emulators.dev/_emulate/services`). Service hosts
37+(`https://<service>.emulators.dev`) are control plane only, with no shared
38+default instance behind them: create a private instance first and connect to
39+the returned `providerBaseUrl`.
3840
3941 ```ts
4042 import { connectEmulator } from "@executor-js/emulate";
41−const resend = await connectEmulator({ baseUrl: "https://resend.emulators.dev" });
43+const created = await fetch("https://resend.emulators.dev/_emulate/instances", { method: "POST" });
44+const { providerBaseUrl } = await created.json();
45+const resend = await connectEmulator({ baseUrl: providerBaseUrl });
4246 // optional: { service: "resend" } verifies the manifest on connect
4347 ```
4448
45−Create a private hosted instance with `POST /_emulate/instances` on the
46−service host.
49+In e2e scenarios use `createEmulatorInstance` from
50+`e2e/src/emulator-instance.ts`, which wraps this.
4751
4852 ## The typed control-plane client
4953
@@ -113,12 +117,11 @@ The emulate repo's own `AGENTS.md` / `README.md` carry the current build, · 63 unmodified lines
113117 publish, and deploy commands (npm + Cloudflare creds are in 1Password). Read
114118 them there rather than memorizing flags here — they move.
115119
116−**A hot deploy can redden other people's e2e.** `*.emulators.dev` service
117−hosts are shared infrastructure; a control-plane regression there has failed
118−unrelated PRs' suites before. When a scenario needs isolation or a behavior
119−that isn't deployed yet, pin to a published package version or mint a private
120−per-run instance (`POST /_emulate/instances`) instead of mutating the shared
121−service host.
120+**A hot deploy can redden other people's e2e.** The `emulate-hosts` worker
121+behind `*.emulators.dev` is shared infrastructure; a control-plane regression
122+there has failed unrelated PRs' suites before. Scenarios always run on private
123+per-run instances (`POST /_emulate/instances`); when a scenario needs behavior
124+that isn't deployed yet, pin to a published package version.
122125
123126 ## Gotchas
124127
.claude/skills/prod-telemetry/SKILL.md+18−1
@@ -29,6 +29,20 @@ join the same traces via traceparent). · 28 unmodified lines
2929
3030 **Span names worth querying** (and their custom attrs):
3131
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).
3246 - `executor.tool.execute` — `mcp.tool.name` (full address), and since
3347 PR #992: `executor.tool.outcome` (`ok`/`fail`),
3448 `executor.tool.error_code`, `executor.tool.error_status`,
@@ -39,7 +53,10 @@ join the same traces via traceparent). · 4 unmodified lines
3953 `base_url`, and since PR #992 `http.status_code`.
4054 - `mcp.request` (outer) — `mcp.auth.organization_id`,
4155 `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).
4360
4461 **Recipe — error signatures by class (the daily-digest query):**
4562
.github/workflows/ci.yml+151−57
@@ -10,6 +10,18 @@ concurrency: · 9 unmodified lines
1010 group: ci-${{ github.ref }}
1111 cancel-in-progress: true
1212
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.
20+env:
21+ DO_NOT_TRACK: "1"
22+ EXECUTOR_DISABLE_ANALYTICS: "1"
23+ EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
24+
1325 jobs:
1426 changes:
1527 name: Changed paths
@@ -18,6 +30,7 @@ jobs: · 2 unmodified lines
1830 outputs:
1931 desktop_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop_smoke == 'true' }}
2032 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' }}
2134 steps:
2235 - uses: actions/checkout@v4
2336
@@ -41,6 +54,14 @@ jobs: · 17 unmodified lines
4154 - "packages/kernel/runtime-quickjs/**"
4255 - "packages/plugins/**"
4356 - "packages/react/**"
57+ cloud_closure:
58+ - ".github/workflows/**"
59+ - "bun.lock"
60+ - "package.json"
61+ - "turbo.json"
62+ - "apps/cloud/**"
63+ - "packages/**"
64+
4465 selfhost_docker_smoke:
4566 - ".github/workflows/**"
4667 - ".dockerignore"
@@ -136,17 +157,26 @@ jobs: · 89 unmodified lines
136157
137158 - run: bun run typecheck
138159
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+
139166 test:
140167 name: Test
141− runs-on: blacksmith-4vcpu-ubuntu-2404
168+ runs-on: blacksmith-16vcpu-ubuntu-2404
142169 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.
144173 env:
145174 TURBO_API: ${{ vars.TURBO_API }}
146175 TURBO_TEAM: ${{ vars.TURBO_TEAM }}
147176 TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
148177 TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
149− TURBO_TEST_CONCURRENCY: 4
178+ TURBO_TEST_CONCURRENCY: 8
179+ VITEST_MAX_WORKERS: 2
150180 steps:
151181 - uses: actions/checkout@v4
152182
@@ -171,6 +201,15 @@ jobs: · 18 unmodified lines
171201
172202 - run: bun install --frozen-lockfile
173203
204+ # Fail loudly if a load-bearing patched dependency (e.g. the agents MCP
205+ # transport hang fix) resolved to a stale, unpatched dist. Without this,
206+ # a bun cache edge case can leave the patch unapplied while everything
207+ # still builds and tests green, and prod ships the pre-fix transport with
208+ # no signal. This is just a few file reads plus greps, so ~zero cost.
209+ - run: bun run check:patches
210+
211+ - run: bun run --cwd e2e test:ci-shard
212+
174213 - run: bun run test
175214
176215 e2e:
@@ -179,14 +218,41 @@ jobs: · 2 unmodified lines
179218 fail-fast: false
180219 matrix:
181220 include:
182− # Each cloud shard boots its own fresh dev stack. On 4 vCPU runners,
183− # four fatter shards keep the longest shard below selfhost while saving
184− # four runner boots and four warm cache restores.
185− - { target: cloud, shard: 1/4, shard-name: 1of4 }
186− - { target: cloud, shard: 2/4, shard-name: 2of4 }
187− - { target: cloud, shard: 3/4, shard-name: 3of4 }
188− - { target: cloud, shard: 4/4, shard-name: 4of4 }
189− - target: selfhost
221+ # The planner assigns every file exactly once using recorded slow-file
222+ # durations plus a conservative weight for new tests. The cloud DB's
223+ # connection teardown and concurrent socket protocol have dedicated
224+ # regression tests; these shards balance wall clock, not hide retries.
225+ - { target: cloud, shard-index: 1, shard-name: 1of16 }
226+ - { target: cloud, shard-index: 2, shard-name: 2of16 }
227+ - { target: cloud, shard-index: 3, shard-name: 3of16 }
228+ - { 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 }
190256 runs-on: blacksmith-4vcpu-ubuntu-2404
191257 timeout-minutes: 30
192258 steps:
@@ -222,26 +288,31 @@ jobs: · 29 unmodified lines
222288
223289 # Install from e2e so bunx resolves ITS pinned playwright (the version
224290 # 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.
225294 - name: Install Playwright Chromium
226− run: bunx playwright install --with-deps chromium chromium-headless-shell
295+ run: bunx playwright install chromium chromium-headless-shell
227296 working-directory: e2e
228297
229− # The globalsetup boots the target's own dev server (ports are claimed
230− # per checkout, so this is hermetic) and tears it down after the run.
231− # --retry=2: browser scenarios can still hit isolated waitFor timeouts
232− # (single-test waitFor timeouts, not systemic failures); a retry on the
233− # same booted stack clears them.
234− - name: Run cloud scenarios
298+ # Each target either boots its own shared dev server or lets each file own
299+ # its server. Ports and data paths are hermetic in both cases.
300+ # Do not retry scenarios: retries hide flakes and multiply slow timeout
301+ # failures. The fixtures and process lifecycle are deterministic enough
302+ # that the first result is the result.
303+ - name: Run cloud shard
235304 if: matrix.target == 'cloud'
236305 env:
237306 MCP_SESSION_TIMEOUT_MS: "3000"
238− MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "6000"
239− run: bunx vitest run --project cloud --retry=2 ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }}
307+ # Still 18x shorter than production, but long enough for a cold Vite
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'] }}
240311 working-directory: e2e
241312
242− - name: Run selfhost scenarios
243− if: matrix.target == 'selfhost'
244− run: bunx vitest run --project selfhost --retry=2
313+ - name: Run scenarios
314+ if: matrix.target != 'cloud'
315+ run: bun scripts/run-ci-shard.ts ${{ matrix.target }} ${{ matrix['shard-index'] }}
245316 working-directory: e2e
246317
247318 # Failed runs keep their trace.zip / session.mp4 / step screenshots in
@@ -254,13 +325,23 @@ jobs: · 6 unmodified lines
254325 path: e2e/runs/
255326 retention-days: 7
256327
257− e2e-local:
258− name: E2E (stdio MCP)
259− # Skipped on pull_request: the local scenario boots a real `executor web`
260− # plus a browser and is currently flaky on PRs. Still runs on push to main.
261− if: github.event_name != 'pull_request'
328+ cloud-closure:
329+ name: Cloud evaluated closure
330+ needs: changes
331+ if: needs.changes.outputs.cloud_closure == 'true'
262332 runs-on: blacksmith-4vcpu-ubuntu-2404
263333 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
264345 steps:
265346 - uses: actions/checkout@v4
266347
@@ -276,41 +357,17 @@ jobs: · 9 unmodified lines
276357 restore-keys: |
277358 ${{ runner.os }}-bun-1.3.11-
278359
279− # The local scenarios boot a real `executor web` (which spawns a Node
280− # sidecar) and some drive a browser, so pin Node 24 and install Chromium.
281360 - uses: actions/setup-node@v4
282361 with:
283362 node-version: 24
284363
285364 - run: bun install --frozen-lockfile
286365
287− - name: Cache Playwright browsers
288− uses: actions/cache@v4
289− with:
290− path: ~/.cache/ms-playwright
291− key: ${{ runner.os }}-playwright-1.60.0
292− restore-keys: |
293− ${{ runner.os }}-playwright-
366+ - run: bun run build
367+ working-directory: apps/cloud
294368
295− # `chromium` and the new `chromium-headless-shell` ship as separate
296− # downloads; the browser-driven scenarios launch the headless shell.
297− # Install from e2e so bunx resolves ITS pinned playwright (the version the
298− # tests run against) rather than floating to the latest, which would fetch
299− # a browser build the test runtime does not look for.
300− - name: Install Playwright Chromium
301− run: bunx playwright install --with-deps chromium chromium-headless-shell
302− working-directory: e2e
303−
304− # The `local` project is excluded from the default `test` chain (each
305− # scenario boots its own `executor web`). Run just the stdio MCP scenario
306− # here: it is the auto-connect / env-as-secret regression guard, and
307− # running it alone avoids the boot-resource accumulation and the
308− # pre-existing browser flakiness of the rest of the local suite. Expanding
309− # to the full `local` project (bun run test:local) is a follow-up once
310− # those are stabilized.
311− - name: Run the stdio MCP scenario
312− run: bunx vitest run --project local local/stdio-mcp.test.ts
313− working-directory: e2e
369+ - run: node scripts/start-closure.mjs
370+ working-directory: apps/cloud
314371
315372 desktop-smoke:
316373 name: Desktop smoke build
@@ -362,7 +419,13 @@ jobs: · 45 unmodified lines
362419 - name: Run sidecar outside the workspace
363420 working-directory: apps/desktop
364421 run: |
365− 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 \
366429 -v "$PWD/resources/executor:/opt/executor:ro" \
367430 debian:bookworm-slim /opt/executor/executor daemon run --foreground \
368431 --port 45841 --hostname 0.0.0.0 --auth-token=ci-smoke
@@ -407,4 +470,35 @@ jobs: · 38 unmodified lines
407470 context: .
408471 file: apps/host-selfhost/Dockerfile
409472 push: false
473+ load: true
410474 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
99 group: deploy-production
1010 cancel-in-progress: false
1111
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.
17+env:
18+ DO_NOT_TRACK: "1"
19+ EXECUTOR_DISABLE_ANALYTICS: "1"
20+ EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
21+
1222 jobs:
1323 migrate:
1424 name: Migrate database
@@ -77,12 +87,30 @@ jobs: · 62 unmodified lines
7787 VITE_PUBLIC_SENTRY_DSN: ${{ secrets.VITE_PUBLIC_SENTRY_DSN }}
7888
7989 - name: Deploy cloud
80− run: bun run wrangler deploy -c dist/server/wrangler.json
90+ run: bun run wrangler deploy -c dist/server/wrangler.json --var GIT_COMMIT_SHA:${{ github.sha }}
8191 working-directory: apps/cloud
8292 env:
8393 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
8494 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
8595
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+
86114 deploy-marketing:
87115 name: Deploy marketing
88116 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.
8+name: Graph slices
9+
10+on:
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+
17+permissions:
18+ contents: write
19+
20+jobs:
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
1010 group: pkg-pr-new-${{ github.event.pull_request.number }}
1111 cancel-in-progress: true
1212
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.
18+env:
19+ DO_NOT_TRACK: "1"
20+ EXECUTOR_DISABLE_ANALYTICS: "1"
21+ EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
22+
1323 jobs:
1424 # Per-platform matrix: build the executor binary, tar it, upload to R2.
1525 # 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
1616 contents: read
1717 pull-requests: read
1818
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.
24+env:
25+ DO_NOT_TRACK: "1"
26+ EXECUTOR_DISABLE_ANALYTICS: "1"
27+ EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
28+
1929 jobs:
2030 sweep:
2131 name: Destroy previews for closed PRs
.github/workflows/preview.yml+10−0
@@ -23,6 +23,16 @@ concurrency: · 22 unmodified lines
2323 group: preview-${{ github.event.pull_request.number }}
2424 cancel-in-progress: ${{ github.event.action != 'closed' }}
2525
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.
31+env:
32+ DO_NOT_TRACK: "1"
33+ EXECUTOR_DISABLE_ANALYTICS: "1"
34+ EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
35+
2636 jobs:
2737 deploy:
2838 name: Deploy preview
.github/workflows/publish-desktop.yml+39−2
@@ -21,6 +21,18 @@ concurrency: · 20 unmodified lines
2121 group: publish-desktop-${{ github.ref }}
2222 cancel-in-progress: false
2323
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.
31+env:
32+ DO_NOT_TRACK: "1"
33+ EXECUTOR_DISABLE_ANALYTICS: "1"
34+ EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
35+
2436 jobs:
2537 build:
2638 permissions:
@@ -94,6 +106,17 @@ jobs: · 67 unmodified lines
94106 - name: Install dependencies
95107 run: bun install --frozen-lockfile
96108
109+ # apps/desktop/build/ holds committed static inputs (icon, entitlements)
110+ # that no module imports, so a sweep can delete them without breaking a
111+ # build or a test. v1.6.1 was tagged without them: both mac legs ran ~8
112+ # minutes, then died inside codesign with "build/entitlements.mac.plist:
113+ # cannot read entitlement data" against an arbitrary locale.pak, while
114+ # the missing icon only ever produced a "default Electron icon is used"
115+ # notice. Check the tag's own tree here so the failure names the cause.
116+ - name: Check desktop build resources
117+ run: bun ./scripts/check-build-resources.ts
118+ working-directory: apps/desktop
119+
97120 - name: Build web app
98121 run: bun run --filter @executor-js/local build
99122
@@ -143,8 +166,22 @@ jobs: · 43 unmodified lines
143166 # → notarytool authentication for the notarization upload
144167 # When the secrets aren't set (forks, local), electron-builder
145168 # silently produces an unsigned build.
146− CSC_LINK: ${{ secrets.CSC_LINK }}
147− CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
169+ #
170+ # CSC_LINK / CSC_KEY_PASSWORD MUST stay scoped to the mac legs.
171+ # Despite the name used here, CSC_LINK is electron-builder's
172+ # cross-platform certificate variable: WinPackager falls back to it
173+ # whenever WIN_CSC_LINK is unset, via
174+ # platformPackager.getCscLink("WIN_CSC_LINK"). Handing the Apple
175+ # Developer ID .p12 to the windows leg made electron-builder read its
176+ # subject and write `publisherName: Developer ID Application: ...`
177+ # into app-update.yml, so every Windows client rejected the update it
178+ # had just downloaded with "New version is not signed by the
179+ # application owner". Empty string is the documented "no certificate"
180+ # value — WindowsSignToolManager treats `cscLink === ""` exactly like
181+ # unset. The APPLE_* vars below need no such guard: they are only
182+ # read by notarytool on darwin and are inert elsewhere.
183+ CSC_LINK: ${{ matrix.platform == 'mac' && secrets.CSC_LINK || '' }}
184+ CSC_KEY_PASSWORD: ${{ matrix.platform == 'mac' && secrets.CSC_KEY_PASSWORD || '' }}
148185 APPLE_API_KEY: ${{ env.APPLE_API_KEY_PATH }}
149186 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
150187 APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
.github/workflows/publish-executor-package.yml+13−1
@@ -21,6 +21,16 @@ concurrency: · 20 unmodified lines
2121 group: publish-executor-package-${{ github.ref }}
2222 cancel-in-progress: false
2323
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.
29+env:
30+ DO_NOT_TRACK: "1"
31+ EXECUTOR_DISABLE_ANALYTICS: "1"
32+ EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
33+
2434 jobs:
2535 publish:
2636 runs-on: ubuntu-latest
@@ -62,7 +72,9 @@ jobs: · 35 unmodified lines
6272
6373 - name: Update npm for trusted publishing
6474 run: |
65− npm install -g npm@latest
75+ # 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
6678 npm --version
6779
6880 - name: Install dependencies
.github/workflows/publish-selfhost-docker.yml+142−4
@@ -16,6 +16,18 @@ concurrency: · 15 unmodified lines
1616 group: publish-selfhost-docker-${{ inputs.tag }}
1717 cancel-in-progress: false
1818
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.
26+env:
27+ DO_NOT_TRACK: "1"
28+ EXECUTOR_DISABLE_ANALYTICS: "1"
29+ EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
30+
1931 jobs:
2032 metadata:
2133 runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -163,16 +175,124 @@ jobs: · 141 unmodified lines
163175 if-no-files-found: error
164176 retention-days: 1
165177
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+
166288 merge:
167289 needs:
168290 - metadata
169291 - build
292+ - e2e
170293 runs-on: blacksmith-4vcpu-ubuntu-2404
171294 permissions:
172295 packages: write
173− env:
174− HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }}
175−
176296 steps:
177297 - name: Download image digests
178298 uses: actions/download-artifact@v4
@@ -219,10 +339,28 @@ jobs: · 40 unmodified lines
219339
220340 docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"
221341
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:
222356 - name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured)
223357 if: env.HAS_LEGACY_TOKEN != 'true'
224358 run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror."
225359
360+ - name: Set up Docker Buildx
361+ if: env.HAS_LEGACY_TOKEN == 'true'
362+ uses: docker/setup-buildx-action@v3
363+
226364 - name: Log in to legacy GHCR namespace
227365 if: env.HAS_LEGACY_TOKEN == 'true'
228366 uses: docker/login-action@v3
@@ -231,7 +369,7 @@ jobs: · 2 unmodified lines
231369 username: rhyssullivan
232370 password: ${{ secrets.GHCR_LEGACY_TOKEN }}
233371
234− - name: Mirror self-host image to legacy GHCR namespace
372+ - name: Mirror release tags to the legacy namespace
235373 if: env.HAS_LEGACY_TOKEN == 'true'
236374 shell: bash
237375 env:
.github/workflows/release.yml+15−1
@@ -13,6 +13,18 @@ concurrency: · 12 unmodified lines
1313 group: release-${{ github.ref }}
1414 cancel-in-progress: false
1515
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.
23+env:
24+ DO_NOT_TRACK: "1"
25+ EXECUTOR_DISABLE_ANALYTICS: "1"
26+ EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
27+
1628 jobs:
1729 release:
1830 runs-on: ubuntu-latest
@@ -42,7 +54,9 @@ jobs: · 23 unmodified lines
4254
4355 - name: Update npm for trusted publishing
4456 run: |
45− npm install -g npm@latest
57+ # 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
4660 npm --version
4761
4862 - name: Install dependencies
.oxfmtrc.json+1−0
@@ -13,6 +13,7 @@ · 12 unmodified lines
1313 "*.tsbuildinfo",
1414 "executor-*.tgz",
1515 "**/routeTree.gen.ts",
16+ "**/smoke-harness-bundle.gen.ts",
1617 "apps/cloud/src/services/executor-schema.ts"
1718 ]
1819 }
.oxlintrc.jsonc+26−0
@@ -64,6 +64,7 @@ · 63 unmodified lines
6464 "apps/desktop/src/main.ts",
6565 "scripts/**/*.{ts,js}",
6666 "apps/*/scripts/**/*.{ts,js}",
67+ "packages/*/*/scripts/**/*.{ts,js}",
6768 "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}",
6869 ],
6970 "rules": {
@@ -119,6 +120,31 @@ · 49 unmodified lines
119120 "executor/no-try-catch-or-throw": "off",
120121 },
121122 },
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+ },
122148 {
123149 "files": ["packages/kernel/core/src/code-recovery.ts"],
124150 "rules": {
AGENTS.md+43−68
@@ -1,83 +1,58 @@
1−# AGENTS.md
1+# Executor policy
22
3−Principles: the contracts that stay true while implementations churn. For how to
4−run, boot, share, or navigate things today, see [RUNNING.md](RUNNING.md); for
5−e2e scenarios, [e2e/AGENTS.md](e2e/AGENTS.md). Run `bun run bootstrap` first in
6−any fresh checkout or worktree.
3+Stable contracts live here. Current setup and server mechanics are in
4+[RUNNING.md](RUNNING.md); e2e invariants are in
5+[e2e/AGENTS.md](e2e/AGENTS.md). Run `bun run bootstrap` in every fresh rift.
76
8−## Task Completion
7+## Verification and evidence
98
10−- Tests use Effect Vitest. Run scoped tests with `vitest run ...`. `bun run test`
11− is fine (it delegates to Vitest); NEVER run `bun test`.
12−- Run the narrowest useful verification for a change. For broad or merge-ready
13− work the full gates are `bun run format:check`, `lint`, `typecheck`, `test`.
14−- Run `bun run format` before opening a PR, staging only files your branch
15− touched (leave unrelated files `format` rewrites out of the PR).
9+- Tests use Effect Vitest. Run scoped tests with `vitest run ...` or the
10+ package script. Never use `bun test`.
11+- Use the narrowest meaningful verification while iterating. Merge-ready gates
12+ are `bun run format:check`, `lint`, `typecheck`, and `test`.
13+- e2e is slow. Avoid running it often during development: optimize for
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.
1620
17−## Handing Back Work: Evidence, Not Assertions
21+## Service emulators
1822
19−"Done" is something the user can open, not a claim. When work changes what a user
20−sees, hand back: an e2e run that proves it (link the specific run with one line
21−on what to look at, not a wall of green), the dev server left running so they can
22−poke at it, and the paths worth trying by hand including ones no scenario covers
23−yet. If no scenario covers the change, write one, and embed its recording in the
24−PR. The machinery runs in reverse too: seed an environment into a state
25−(reproduce a bug live, stage data) and hand across the link.
23+Tests and demos that need an upstream API, OAuth/OIDC provider, or webhook use
24+the published `@executor-js/emulate` emulators rather than stubs. They provide
25+wire-level state, real-shaped credentials, OpenAPI descriptions, and request
26+ledgers. Create per-run hosted instances through the service's
27+`/_emulate/instances` control route.
2628
27−## Service Emulators
29+Emulate is a separate project. Make emulator changes there and consume the
30+published package here; never re-vendor or create a parallel fake inside
31+executor.
2832
29−For any test or demo needing an upstream API, OAuth/OIDC provider, or webhook
30−source, use the `@executor-js/emulate` emulators instead of writing a stub:
31−wire-level and stateful, real SDKs run unmodified, each serves a full OpenAPI
32−spec, mints real-shaped credentials, and records every call in a request ledger
33−to assert against. Hosted at `https://<service>.emulators.dev`. See the `emulate`
34−skill (`.claude/skills/emulate/SKILL.md`). They are a standalone project
35−(`github.com/UsefulSoftwareCo/emulate`) consumed here as the published package:
36−full autonomy to change, publish, and deploy them on their `main`; don't re-vendor.
37−
38−## Attribution
39−
40−No AI/Claude/Anthropic/Co-Authored-By attribution in commits, messages, PRs, or
41−generated files. PR titles and descriptions go to a public repo: no internal info
42−or 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−
54−Repos in `.reference` (Effect, effect-atom, …) are available for patterns. Clone
55−a given Git URL into `.reference` and pull latest before using it.
56−
57−## Engineering Priorities
33+## Engineering boundaries
5834
5935 - Prefer correctness and predictable behavior over short-term convenience.
60−- Preserve runtime behavior when changing lint, typing, or test structure.
61−- Keep package boundaries clear; use public package exports, not cross-package
62− relative imports.
63−- Extract shared logic only when the shared behavior is real; avoid broad generic
36+- Preserve runtime behavior during lint, typing, and test-structure changes.
37+- Use public package exports; never cross package boundaries with relative
38+ imports.
39+- Extract shared logic only for genuinely shared behavior. Avoid generic
6440 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.
6543
66−## Package Roles
44+## Package ownership
6745
68−- `packages/core/sdk`: core contracts, plugin wiring, scopes, sources, secrets,
69− policies, fixtures. `@executor-js/sdk/http-auth` carries the shared auth-method
70− vocabulary the HTTP protocol plugins compose (core never imports it, keeping it
71− carrier-agnostic).
46+- `packages/core/sdk`: contracts, plugin wiring, scopes, integrations, secrets,
47+ policies, and fixtures. Shared HTTP auth vocabulary lives in
48+ `@executor-js/sdk/http-auth`; core remains carrier-agnostic.
7249 - `packages/core/storage-*`: storage adapters and test support.
73−- `packages/plugins/*`: protocol and provider plugins; their runtime, React, API,
74− and testing helpers live with the owning plugin.
75−- `packages/react`: shared React UI and atom/client integration.
50+- `packages/plugins/*`: protocol/provider implementations and their runtime,
51+ React, API, and testing helpers.
52+- `packages/react`: shared React UI and client/atom integration.
7653 - `packages/hosts/mcp`: MCP host surface.
77−- `packages/kernel/*`: execution runtimes and code execution substrate.
78−- `apps/{local,cloud,cli,desktop}`: product entry points composing the packages.
79−
80−## Other
54+- `packages/kernel/*`: execution runtimes and code-execution substrate.
55+- `apps/{local,cloud,cli,desktop}`: product composition roots.
8156
82−Note mistakes in MISTAKES.md, missing context or tools in DESIRES.md, and env
83−learnings in LEARNINGS.md.
57+Record mistakes in `MISTAKES.md`, missing capabilities in `DESIRES.md`, and
58+environment 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
88
99 [Website](https://executor.sh) · [Documentation](https://executor.sh/docs) · [Discord](https://discord.gg/eF29HBHwM6)
1010
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)
1212
1313 ## Why Executor
1414
@@ -21,9 +21,9 @@ set its policy once, and every agent shares it over MCP. Your integrations, · 6 unmodified lines
2121 auth, and policies live in one place instead of being scattered across each
2222 client.
2323
24−- **Any source.** First-party support for MCP servers, OpenAPI, GraphQL, and
24+- **Any integration.** First-party support for MCP servers, OpenAPI, GraphQL, and
2525 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.
2727 - **One catalog, every agent.** Anything MCP-compatible connects to the same
2828 set of tools.
2929 - **Governed by policy.** Each tool is allowed, gated behind approval, or
@@ -100,12 +100,12 @@ appear. · 70 unmodified lines
100100
101101 ## Add an integration
102102
103−From the web UI, click **Add Source**, paste an OpenAPI, GraphQL, or MCP URL,
103+From the web UI, click **Add Integration**, paste an OpenAPI, GraphQL, or MCP URL,
104104 and Executor detects the type, indexes the tools, and handles auth. Or add one
105105 from the CLI:
106106
107107 ```bash
108−executor call executor openapi addSource '{
108+executor call executor openapi addIntegration '{
109109 "spec": "https://petstore3.swagger.io/api/v3/openapi.json",
110110 "namespace": "petstore",
111111 "baseUrl": "https://petstore3.swagger.io/api/v3"
@@ -113,7 +113,7 @@ executor call executor openapi addSource '{ · 1 unmodified line
113113 ```
114114
115115 Use `baseUrl` when the OpenAPI document has relative `servers` entries (for
116−example `"/api/v3"`). Confirm it is live with `executor tools sources`.
116+example `"/api/v3"`). Confirm it is live with `executor tools integrations`.
117117
118118 ## Using tools
119119
@@ -169,7 +169,7 @@ executor call <path...> --help # browse namespaces/resources/methods · 49 unmodified lines
169169 executor call <path...> --help --match "<text>" --limit <n> # narrow huge namespaces
170170 executor resume --execution-id <id> # resume paused execution
171171 executor tools search "<query>" # search tools by intent
172−executor tools sources # list configured sources + tool counts
172+executor tools integrations # list configured integrations + tool counts
173173 executor tools describe <path> # show tool TypeScript/JSON schema
174174 ```
175175
@@ -190,7 +190,7 @@ apps/ · 14 unmodified lines
190190 packages/
191191 core/ contracts, plugin wiring, scopes, policies, SDK, API, CLI core
192192 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,
194194 microsoft, 1password, keychain, encrypted/file secrets, ...)
195195 hosts/ host adapters (MCP surface, Cloudflare)
196196 react/ shared React UI
TELEMETRY.md+101−0
@@ -0,0 +1,101 @@
1+# Telemetry
2+
3+Executor's local products (the CLI, the desktop app) and self-hosted
4+deployments send a small set of anonymous usage events. This document is the
5+complete account of what is sent, what is deliberately not sent, why the
6+feature exists, and how to turn it off.
7+
8+The hosted cloud product has its own browser-side analytics, disclosed
9+separately in its terms; this document covers the software that runs on your
10+machines.
11+
12+## Why
13+
14+Executor is used mostly outside our infrastructure. Without some signal from
15+local and self-hosted installs, every product decision about them is a guess:
16+we cannot tell whether a feature ships broken, whether anyone uses toolkits,
17+whether executions fail at unusual rates after a release, or whether the
18+product is growing anywhere except cloud.
19+
20+The events exist to answer exactly one kind of question: **how are product
21+features being used?** They are metadata about the product, not about you.
22+Anything that would answer "what is this user doing" — which APIs you call,
23+what your tools are named, what your code does — is out of scope by design,
24+not by omission.
25+
26+## What is sent
27+
28+Each event carries its named properties plus, on every event: a random
29+per-install id, the product surface (`cli`, `desktop`, or `selfhost`), the
30+release 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+
42+That table is exhaustive. The typed catalog the code compiles against is
43+[`packages/core/analytics/src/events.ts`](packages/core/analytics/src/events.ts);
44+an 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+
59+A random UUID is minted the first time the daemon or server starts and stored
60+as `analytics-id` in the data directory (`~/.executor` for local installs, the
61+configured data dir for self-host). It exists so that ten events from one
62+install count as one install, not ten. It is not derived from your machine,
63+account, or network, and deleting the file resets it. When telemetry is
64+disabled the file is never created.
65+
66+## Opting out
67+
68+Set 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+
76+Opting out is total: the analytics service becomes a no-op, nothing is
77+buffered, nothing is sent, and no id file is written. The CLI's managed
78+service (launchd/systemd) forwards both variables into the supervised
79+daemon's environment, so an opted-out install stays opted out.
80+
81+## Delivery mechanics
82+
83+Events buffer in memory (bounded) and flush in batches on a fixed cadence,
84+plus once at shutdown. Delivery is best-effort: a failed flush re-queues and
85+retries later, failures are swallowed, and no user-facing operation ever
86+waits 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
88+and grants no read access.
89+
90+## Where the line is enforced
91+
92+Structurally, 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+347−0
@@ -1,5 +1,352 @@
11 # executor
22
3+## 1.6.2
4+
5+### Patch Changes
6+
7+- [#1802](https://github.com/UsefulSoftwareCo/executor/pull/1802) [`2afb6e8`](https://github.com/UsefulSoftwareCo/executor/commit/2afb6e86ddf081afb8a246d76291a7ae668e05f4) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Concurrent API requests no longer share one database provider build**
8+
9+ Every HTTP request gets its own database connection, opened when the request fiber's scope opens and closed when it closes. The middleware that builds a request's execution stack, however, captures the boot fiber's context once at layer-construction time and re-applies it to every request. A captured context carries Effect's current memoization map, and re-applying it replaced the fresh per-request map with the boot one — which every in-flight request in the isolate shares.
10+
11+ The per-request stack build then memoized itself there. Sequential requests still rebuilt, because the memo entry is released once the request that built it finishes, so the problem was confined to requests that overlap: the second request reused the first one's stack build, and with it the first one's database connection. A request could therefore issue queries on a connection it did not own, and lose that connection mid-flight when the owner finished and closed it — typically surfacing as a failed read after a slow outbound call, on a request that had already read successfully.
12+
13+ The stack is now built with a request-local memoization scope, so overlapping requests each build their own stack over their own connection. The captured context still carries the long-lived services it exists to carry. The two other per-request provider builds that ran under a captured context — the account provider and the admin-users provider — are built the same way, for the same reason.
14+
15+- [#1807](https://github.com/UsefulSoftwareCo/executor/pull/1807) [`78311a1`](https://github.com/UsefulSoftwareCo/executor/commit/78311a1ce506705a2420239fd96823203a9a1374) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Desktop: restore the macOS signing entitlements and app icon that were accidentally removed from the build inputs, and fail fast at PR time and before publishing when any configured build resource is missing. The v1.6.1 desktop build could not be signed; this release supersedes it.
16+
17+- Updated dependencies []:
18+ - @executor-js/sdk@1.6.2
19+ - @executor-js/runtime-quickjs@1.6.2
20+ - @executor-js/local@1.6.2
21+ - @executor-js/api@1.4.65
22+
23+## 1.6.1
24+
25+### Patch Changes
26+
27+- [#1576](https://github.com/UsefulSoftwareCo/executor/pull/1576) [`6535a74`](https://github.com/UsefulSoftwareCo/executor/commit/6535a74de4860e376e5a2e273cb2d5a662f98c5c) Thanks [@GeiserX](https://github.com/GeiserX)! - **The CLI's server-connection store is written owner-only, and two of its tests now actually run**
28+
29+ `~/.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`.
30+
31+ It 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.
32+
33+ Separately, 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.
34+
35+- [#1454](https://github.com/UsefulSoftwareCo/executor/pull/1454) [`e8ea62c`](https://github.com/UsefulSoftwareCo/executor/commit/e8ea62c8ba295931cf969cd310278b6a50771bdc) Thanks [@jadch](https://github.com/jadch)! - Prevent concurrent SQLite data-migration runners from failing when another runner commits the same ledger stamp first.
36+
37+- [#1579](https://github.com/UsefulSoftwareCo/executor/pull/1579) [`31e17c7`](https://github.com/UsefulSoftwareCo/executor/commit/31e17c725f0987f86896519f0626f9c94d39ccb7) Thanks [@GeiserX](https://github.com/GeiserX)! - **The desktop settings store is written owner-only**
38+
39+ `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.
40+
41+ One 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.
42+
43+- [#1582](https://github.com/UsefulSoftwareCo/executor/pull/1582) [`e8e97c4`](https://github.com/UsefulSoftwareCo/executor/commit/e8e97c4c008fce702ee07ec0ffe2a8d3e6946a21) Thanks [@GeiserX](https://github.com/GeiserX)! - **An MCP health check no longer reports `healthy` when the connection's credential is missing**
44+
45+ Rendering 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.
46+
47+ Health 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.
48+
49+ The 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.
50+
51+- [#1741](https://github.com/UsefulSoftwareCo/executor/pull/1741) [`62748e8`](https://github.com/UsefulSoftwareCo/executor/commit/62748e86122b747226c76c2e112c5c4d2b4f7095) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Opt-in per-integration search tools on the MCP surface**
52+
53+ Connecting 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.
54+
55+- [#1573](https://github.com/UsefulSoftwareCo/executor/pull/1573) [`45ba141`](https://github.com/UsefulSoftwareCo/executor/commit/45ba1419b4e33ad10b0856c5fbc40fabc8f6efe1) Thanks [@GeiserX](https://github.com/GeiserX)! - **The MCP connection pool no longer keeps credentials in its cache key**
56+
57+ A 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.
58+
59+ The 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.
60+
61+ Nothing reads the key back — the pool only compares it, and it reaches no log, span or error message — so nothing observable changes.
62+
63+- [#1800](https://github.com/UsefulSoftwareCo/executor/pull/1800) [`eac13e7`](https://github.com/UsefulSoftwareCo/executor/commit/eac13e7032c027d866c06728f1d5b634fd9ef7dd) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Idle MCP session runtimes are actually reclaimed**
64+
65+ The 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.
66+
67+ The 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.
68+
69+ Durable 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.
70+
71+ The 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.
72+
73+ Disposal 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.
74+
75+- [#1609](https://github.com/UsefulSoftwareCo/executor/pull/1609) [`662ebe2`](https://github.com/UsefulSoftwareCo/executor/commit/662ebe28d5109156a1aa3b27eaa80ba9daacaf11) Thanks [@timkley](https://github.com/timkley)! - **Fix: declare the OAuth application type during dynamic client registration**
76+
77+ Dynamic OAuth registrations now identify HTTPS callbacks as web applications
78+ and loopback HTTP callbacks as native applications. This lets strict OAuth
79+ servers validate Executor's redirect URI against the correct client type.
80+
81+- [#1570](https://github.com/UsefulSoftwareCo/executor/pull/1570) [`e66a3d8`](https://github.com/UsefulSoftwareCo/executor/commit/e66a3d893c948fcccc7fc028870d051cb2d31860) Thanks [@GeiserX](https://github.com/GeiserX)! - **The OAuth popup clears its result out of `localStorage` after handing it over**
82+
83+ The 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.
84+
85+ The 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.
86+
87+- [#1574](https://github.com/UsefulSoftwareCo/executor/pull/1574) [`c8bb857`](https://github.com/UsefulSoftwareCo/executor/commit/c8bb8578c3afdc9731274d9e9abc68d7ce32d9ab) Thanks [@GeiserX](https://github.com/GeiserX)! - **The 1Password service-account token is cleared from the op-js global after each call**
88+
89+ `@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.
90+
91+ It 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.
92+
93+ This 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.
94+
95+- [#1749](https://github.com/UsefulSoftwareCo/executor/pull/1749) [`d4afe0c`](https://github.com/UsefulSoftwareCo/executor/commit/d4afe0c79f146dd169a00988a2d5d0469297be19) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Slim 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`.
96+
97+- [#1798](https://github.com/UsefulSoftwareCo/executor/pull/1798) [`69b0e64`](https://github.com/UsefulSoftwareCo/executor/commit/69b0e6413737599f11848b3877048f789ca84ac5) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **The packed Worker toolchain is verified at build time, and an incomplete copy is now reported instead of silently ignored**
98+
99+ `@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.
100+
101+ That 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.
102+
103+ The 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.
104+
105+- Updated dependencies [[`55180cb`](https://github.com/UsefulSoftwareCo/executor/commit/55180cb1487f9a3a28ddc0ee0bedfab8464c1f72)]:
106+ - @executor-js/sdk@1.6.1
107+ - @executor-js/api@1.4.64
108+ - @executor-js/local@1.6.1
109+ - @executor-js/runtime-quickjs@1.6.1
110+
111+## 1.6.0
112+
113+### Patch Changes
114+
115+- [#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.
116+
117+- Updated dependencies [[`a2d1417`](https://github.com/UsefulSoftwareCo/executor/commit/a2d141758e478274813c8c24d354e1fd0f66af49)]:
118+ - @executor-js/sdk@1.6.0
119+ - @executor-js/local@1.6.0
120+ - @executor-js/api@1.4.63
121+ - @executor-js/runtime-quickjs@1.6.0
122+
123+## 1.5.42
124+
125+### Patch Changes
126+
127+- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]:
128+ - @executor-js/sdk@1.5.42
129+ - @executor-js/local@1.5.42
130+ - @executor-js/api@1.4.62
131+ - @executor-js/runtime-quickjs@1.5.42
132+
133+## 1.5.41
134+
135+### Patch Changes
136+
137+- [#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**
138+
139+ `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.
140+
141+ 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.
142+
143+- [#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**
144+
145+ 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.
146+
147+- [#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**
148+
149+ 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.
150+
151+ 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.
152+
153+ 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.
154+
155+- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]:
156+ - @executor-js/sdk@1.5.41
157+ - @executor-js/local@1.5.41
158+ - @executor-js/api@1.4.61
159+ - @executor-js/runtime-quickjs@1.5.41
160+
161+## 1.5.40
162+
163+### Patch Changes
164+
165+- [#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.
166+
167+- [#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**
168+
169+ 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.
170+
171+- [#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.
172+
173+- Updated dependencies [[`8ba64f6`](https://github.com/UsefulSoftwareCo/executor/commit/8ba64f675f6d6ab5302d4f68390c0b055d006f4a), [`80e5530`](https://github.com/UsefulSoftwareCo/executor/commit/80e553026278b1ecd7807f1ba99ba13b19d2c336)]:
174+ - @executor-js/sdk@1.5.40
175+ - @executor-js/local@1.5.40
176+ - @executor-js/api@1.4.60
177+ - @executor-js/runtime-quickjs@1.5.40
178+
179+## 1.5.39
180+
181+### Patch Changes
182+
183+- Updated dependencies [[`6c316c7`](https://github.com/UsefulSoftwareCo/executor/commit/6c316c77a9efc98784976236852b58c6156e016e)]:
184+ - @executor-js/sdk@1.5.39
185+ - @executor-js/local@1.4.4
186+ - @executor-js/api@1.4.59
187+ - @executor-js/runtime-quickjs@1.5.39
188+
189+## 1.5.38
190+
191+### Patch Changes
192+
193+- [#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.
194+
195+- [#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**
196+
197+- [#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.
198+
199+- [#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**
200+
201+- [#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.**
202+
203+- [#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.
204+
205+- [#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.
206+
207+- Updated dependencies [[`6a924dd`](https://github.com/UsefulSoftwareCo/executor/commit/6a924dd98de916d6ff8cea2329bf672f149b64f4)]:
208+ - @executor-js/sdk@1.5.38
209+ - @executor-js/local@1.4.4
210+ - @executor-js/api@1.4.58
211+ - @executor-js/runtime-quickjs@1.5.38
212+
213+## 1.5.37
214+
215+### Patch Changes
216+
217+- [#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.
218+
219+- [#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.
220+
221+- [#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**
222+
223+- [#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.
224+
225+- [#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.
226+
227+- [#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**
228+
229+- [#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**
230+
231+- [#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**
232+
233+ 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.
234+
235+- [#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**
236+
237+ 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.
238+
239+- Updated dependencies [[`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb)]:
240+ - @executor-js/sdk@1.5.37
241+ - @executor-js/api@1.4.57
242+ - @executor-js/local@1.4.4
243+ - @executor-js/runtime-quickjs@1.5.37
244+
245+## 1.5.36
246+
247+### Patch Changes
248+
249+- [#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.
250+
251+- [#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**
252+
253+ 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.
254+
255+- [#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.
256+
257+- [#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.
258+
259+- [#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
260+ longer supported. The packed binary still ships the workerd and worker-bundler
261+ sidecars.
262+- Updated dependencies []:
263+ - @executor-js/sdk@1.5.36
264+ - @executor-js/runtime-quickjs@1.5.36
265+ - @executor-js/local@1.4.4
266+ - @executor-js/api@1.4.56
267+
268+## 1.5.35
269+
270+### Patch Changes
271+
272+- Updated dependencies [[`1b9b1f1`](https://github.com/UsefulSoftwareCo/executor/commit/1b9b1f10313834a625a411169ebf83f6181589df), [`99c808f`](https://github.com/UsefulSoftwareCo/executor/commit/99c808f09d3cf2263945efa4f6592cc4e78c9e08)]:
273+ - @executor-js/sdk@1.5.35
274+ - @executor-js/runtime-quickjs@1.5.35
275+ - @executor-js/local@1.4.4
276+ - @executor-js/api@1.4.55
277+
278+## 1.5.34
279+
280+### Patch Changes
281+
282+- 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)]:
283+ - @executor-js/sdk@1.5.34
284+ - @executor-js/local@1.4.4
285+ - @executor-js/api@1.4.54
286+ - @executor-js/runtime-quickjs@1.5.34
287+
288+## 1.5.33
289+
290+### Patch Changes
291+
292+- [#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".
293+
294+- Updated dependencies []:
295+ - @executor-js/local@1.4.4
296+ - @executor-js/sdk@1.5.33
297+ - @executor-js/runtime-quickjs@1.5.33
298+ - @executor-js/api@1.4.53
299+
300+## 1.5.32
301+
302+### Patch Changes
303+
304+- [#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.
305+
306+- [#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".
307+
308+- Updated dependencies []:
309+ - @executor-js/sdk@1.5.32
310+ - @executor-js/runtime-quickjs@1.5.32
311+ - @executor-js/local@1.4.4
312+ - @executor-js/api@1.4.52
313+
314+## 1.5.31
315+
316+### Patch Changes
317+
318+- [#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.
319+
320+- Updated dependencies []:
321+ - @executor-js/local@1.4.4
322+ - @executor-js/sdk@1.5.31
323+ - @executor-js/runtime-quickjs@1.5.31
324+ - @executor-js/api@1.4.51
325+
326+## 1.5.30
327+
328+### Patch Changes
329+
330+- [#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.
331+
332+- [#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.
333+
334+- [#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.
335+
336+- [#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.
337+
338+- [#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.
339+
340+- [#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()`.
341+
342+- [#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.
343+
344+- Updated dependencies []:
345+ - @executor-js/sdk@1.5.30
346+ - @executor-js/runtime-quickjs@1.5.30
347+ - @executor-js/local@1.4.4
348+ - @executor-js/api@1.4.50
349+
3350 ## 1.5.29
4351
5352 ### Patch Changes
apps/cli/package.json+4−1
@@ -1,6 +1,6 @@
11 {
22 "name": "executor",
3− "version": "1.5.29",
3+ "version": "1.6.2",
44 "private": true,
55 "bin": {
66 "executor": "./bin/executor.ts"
@@ -36,8 +36,11 @@ · 29 unmodified lines
3636 "quickjs-emscripten": "catalog:"
3737 },
3838 "devDependencies": {
39+ "@cloudflare/worker-bundler": "0.2.1",
3940 "@effect/vitest": "catalog:",
41+ "@types/node": "catalog:",
4042 "bun-types": "catalog:",
43+ "esbuild": "^0.27.3",
4144 "typescript": "catalog:",
4245 "vitest": "catalog:"
4346 }
apps/cli/src/build.ts+163−1
@@ -2,15 +2,17 @@ import { cp, mkdir, rm, writeFile, chmod } from "node:fs/promises"; · 1 unmodified line
22 import { existsSync } from "node:fs";
33 import { createRequire } from "node:module";
44 import { resolve, join, dirname } from "node:path";
5−import { fileURLToPath } from "node:url";
5+import { fileURLToPath, pathToFileURL } from "node:url";
66 import { parseArgs } from "node:util";
77 import { $ } from "bun";
8+import { WORKER_BUNDLER_DIRNAME, missingWorkerBundlerFiles } from "./worker-bundler-artifact";
89
910 const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
1011 const cliRoot = resolve(repoRoot, "apps/cli");
1112 const webRoot = resolve(repoRoot, "apps/local");
1213 const distDir = resolve(cliRoot, "dist");
1314 const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm";
15+const WORKERD_VERSION = "1.20260708.1";
1416
1517 const resolveQuickJsWasmPath = (): string => {
1618 const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json"));
@@ -23,6 +25,32 @@ const resolveQuickJsWasmPath = (): string => { · 6 unmodified lines
2325 return wasmPath;
2426 };
2527
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+
40+const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell");
41+const mcpAppsShellHtml = join(mcpAppsShellDir, "dist/mcp-app.html");
42+
43+const 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+
2654 const resolveOnePasswordCoreWasmPath = (): string => {
2755 const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json"));
2856 const sdkPkg = req.resolve("@1password/sdk/package.json");
@@ -33,6 +61,82 @@ const resolveOnePasswordCoreWasmPath = (): string => { · 4 unmodified lines
3361 return wasmPath;
3462 };
3563
64+const 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+
74+const 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+ */
107+const 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+
36140 // ---------------------------------------------------------------------------
37141 // Metadata
38142 // ---------------------------------------------------------------------------
@@ -119,6 +223,7 @@ const bunTarget = (t: Target): Bun.Build.CompileTarget => { · 80 unmodified lines
119223 };
120224
121225 const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor");
226+const workerdBinaryName = (t: Target) => (t.os === "win32" ? "workerd.exe" : "workerd");
122227
123228 const isCurrentPlatform = (t: Target) =>
124229 t.os === process.platform && t.arch === process.arch && !t.abi;
@@ -211,6 +316,41 @@ const resolveLibsqlNative = (t: Target): string | null => { · 86 unmodified lines
211316 }
212317 };
213318
319+const 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+
214354 // ---------------------------------------------------------------------------
215355 // Build mode
216356 // ---------------------------------------------------------------------------
@@ -332,7 +472,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 115 unmodified lines
332472 await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`);
333473
334474 const quickJsWasmPath = resolveQuickJsWasmPath();
475+ const mcpAppsShellPath = await ensureMcpAppsShell();
335476 const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath();
477+ const workerBundlerDistPath = resolveWorkerBundlerDistPath();
478+ const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath);
336479
337480 try {
338481 for (const target of targets) {
@@ -355,6 +498,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 16 unmodified lines
355498 // Copy QuickJS WASM next to binary — loaded at runtime by the server
356499 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));
357500
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+
358505 // Copy 1Password's sdk-core WASM next to the binary. The patched
359506 // sdk-core loader falls back to this sibling file when bun --compile
360507 // bakes a build-machine node_modules path into __dirname.
@@ -375,6 +522,21 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 14 unmodified lines
375522 await cp(libsqlNative, join(binDir, "libsql.node"));
376523 }
377524
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+
378540 // Smoke test on current platform
379541 if (isCurrentPlatform(target)) {
380542 const bin = join(binDir, binaryName(target));
apps/cli/src/device-login.test.ts+118−2
@@ -1,6 +1,44 @@
1−import { describe, expect, it } from "@effect/vitest";
1+import { afterEach, describe, expect, it } from "@effect/vitest";
22
3−import { browserOpenCommand } from "./device-login";
3+import {
4+ browserOpenCommand,
5+ discoverCliLogin,
6+ refreshDeviceTokens,
7+ requestDeviceCode,
8+ type CliLoginDiscovery,
9+} from "./device-login";
10+
11+const originalFetch = globalThis.fetch;
12+
13+interface FetchCall {
14+ readonly url: string;
15+ readonly headers: Record<string, string>;
16+}
17+
18+const responseJson = (body: Record<string, unknown>, status = 200): Response =>
19+ new Response(JSON.stringify(body), {
20+ status,
21+ headers: { "content-type": "application/json" },
22+ });
23+
24+const 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+
32+const 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+
39+afterEach(() => {
40+ globalThis.fetch = originalFetch;
41+});
442
543 describe("browserOpenCommand", () => {
644 it("opens Windows browser URLs without cmd.exe", () => {
@@ -32,3 +70,81 @@ describe("browserOpenCommand", () => { · 25 unmodified lines
3270 expect(browserOpenCommand("not a url", "win32")).toBeUndefined();
3371 });
3472 });
73+
74+describe("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
5050 readonly organizationId?: string;
5151 }
5252
53+export interface DeviceLoginHttpOptions {
54+ readonly headers?: Readonly<Record<string, string>>;
55+ readonly serverOrigin?: string;
56+}
57+
58+export interface PollForDeviceTokensOptions extends DeviceLoginHttpOptions {
59+ readonly now?: () => number;
60+}
61+
5362 const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
5463 const DEFAULT_INTERVAL_SECONDS = 5;
5564
@@ -126,17 +135,43 @@ const definedFields = (fields: Record<string, string | undefined>): Record<strin · 70 unmodified lines
126135 string
127136 >;
128137
138+const 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+
146+const 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+
129158 const post = async (
130159 url: string,
131160 fields: Record<string, string | undefined>,
132161 format: "form" | "json",
162+ options: DeviceLoginHttpOptions = {},
133163 ) =>
134164 fetch(url, {
135165 method: "POST",
136− headers: {
137− "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded",
138− accept: "application/json",
139− },
166+ headers: headersForUrl(
167+ url,
168+ {
169+ "content-type":
170+ format === "json" ? "application/json" : "application/x-www-form-urlencoded",
171+ accept: "application/json",
172+ },
173+ options,
174+ ),
140175 body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields),
141176 });
142177
@@ -150,10 +185,16 @@ const readJson = async (response: Response): Promise<Record<string, unknown>> => · 7 unmodified lines
150185 }
151186 };
152187
153−export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscovery> => {
188+export const discoverCliLogin = async (
189+ origin: string,
190+ options: DeviceLoginHttpOptions = {},
191+): Promise<CliLoginDiscovery> => {
154192 let response: Response;
193+ const url = cliLoginUrl(origin);
155194 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+ });
157198 } catch (cause) {
158199 throw new DeviceLoginError(
159200 `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
182223 };
183224 };
184225
185−export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise<DeviceCodeGrant> => {
226+export const requestDeviceCode = async (
227+ discovery: CliLoginDiscovery,
228+ options: DeviceLoginHttpOptions = {},
229+): Promise<DeviceCodeGrant> => {
186230 const response = await post(
187231 discovery.deviceAuthorizationEndpoint,
188232 { client_id: discovery.clientId, scope: discovery.scope },
189233 discovery.requestFormat,
234+ options,
190235 );
191236 const body = await readJson(response);
192237 if (!response.ok) {
@@ -220,7 +265,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout · 27 unmodified lines
220265 export const pollForDeviceTokens = async (
221266 discovery: CliLoginDiscovery,
222267 grant: DeviceCodeGrant,
223− options: { readonly now?: () => number } = {},
268+ options: PollForDeviceTokensOptions = {},
224269 ): Promise<DeviceTokens> => {
225270 const now = options.now ?? (() => Date.now());
226271 const deadline = now() + grant.expiresInSeconds * 1000;
@@ -240,6 +285,7 @@ export const pollForDeviceTokens = async ( · 13 unmodified lines
240285 client_id: discovery.clientId,
241286 },
242287 discovery.requestFormat,
288+ options,
243289 );
244290 const body = await readJson(response);
245291
@@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: { · 40 unmodified lines
286332 readonly tokenEndpoint: string;
287333 readonly clientId: string;
288334 readonly refreshToken: string;
335+ readonly headers?: Readonly<Record<string, string>>;
336+ readonly serverOrigin?: string;
289337 }): Promise<DeviceTokens> => {
290338 const response = await post(
291339 input.tokenEndpoint,
@@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: { · 3 unmodified lines
295343 client_id: input.clientId,
296344 },
297345 "form",
346+ input,
298347 );
299348 const body = await readJson(response);
300349 if (!response.ok) {
apps/cli/src/main.ts+263−69
@@ -64,7 +64,12 @@ if (sentryDsn) { · 63 unmodified lines
6464 import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli";
6565 import { BunRuntime, BunServices } from "@effect/platform-bun";
6666 import { HttpApiClient } from "effect/unstable/httpapi";
67−import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
67+import {
68+ FetchHttpClient,
69+ HttpClient,
70+ HttpClientError,
71+ HttpClientRequest,
72+} from "effect/unstable/http";
6873 import { FileSystem, Path as PlatformPath } from "effect";
6974 import type { PlatformError } from "effect/PlatformError";
7075 import * as Effect from "effect/Effect";
@@ -79,10 +84,13 @@ import { · 8 unmodified lines
7984 getExecutorServerAuthorizationHeader,
8085 normalizeExecutorServerConnection,
8186 normalizeExecutorServerOrigin,
87+ resolveExecutorServerConfiguredHeaders,
88+ resolveExecutorServerRequestHeaders,
8289 type ExecutorLocalServerKind,
8390 type ExecutorLocalServerManifest,
8491 type ExecutorServerConnection,
8592 type ExecutorServerConnectionInput,
93+ type ExecutorServerHeaders,
8694 } from "@executor-js/sdk/shared";
8795 import {
8896 decodeAccessTokenClaims,
@@ -131,7 +139,9 @@ import { · 42 unmodified lines
131139 import {
132140 canAutoStartCliServerConnection,
133141 chooseCliServerConnectionWithActiveLocal,
142+ describeUnauthorizedCliServer,
134143 parseCliExecutorServerConnection,
144+ profileNameFromConnectionKey,
135145 type CliServerConnectionSource,
136146 withCliServerAuthFallback,
137147 } from "./server-connection";
@@ -162,7 +172,7 @@ import { · 24 unmodified lines
162172 buildDescribeToolCode,
163173 filterToolPathChildren,
164174 buildInvokeToolCode,
165− buildListSourcesCode,
175+ buildListIntegrationsCode,
166176 buildSearchToolsCode,
167177 extractExecutionId,
168178 extractPausedInteraction,
@@ -205,9 +215,11 @@ const waitForShutdownSignal = () => · 36 unmodified lines
205215 const shutdown = () => resume(Effect.void);
206216 process.once("SIGINT", shutdown);
207217 process.once("SIGTERM", shutdown);
218+ process.once("SIGHUP", shutdown);
208219 return Effect.sync(() => {
209220 process.off("SIGINT", shutdown);
210221 process.off("SIGTERM", shutdown);
222+ process.off("SIGHUP", shutdown);
211223 });
212224 });
213225
@@ -743,12 +755,9 @@ const resolveRequestedExecutorServerConnection = ( · 529 unmodified lines
743755 // login. The refreshed tokens are written back to the originating profile.
744756 const OAUTH_REFRESH_SKEW_SECONDS = 60;
745757
746−const profileNameFromKey = (key: string): string | null =>
747− key.startsWith("profile:") ? key.slice("profile:".length) : null;
748−
749758 const refreshOAuthConnection = (
750759 connection: ExecutorServerConnection,
751−): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> =>
760+): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> =>
752761 Effect.gen(function* () {
753762 const auth = connection.auth;
754763 if (!auth || auth.kind !== "oauth") return connection;
@@ -759,8 +768,19 @@ const refreshOAuthConnection = ( · 4 unmodified lines
759768 const { refreshToken, tokenEndpoint, clientId } = auth;
760769 if (!refreshToken || !tokenEndpoint || !clientId) return connection;
761770
771+ const headers = yield* resolveExecutorServerConfiguredHeaders(connection, process.env).pipe(
772+ Effect.mapError(toError),
773+ );
774+
762775 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+ }),
764784 catch: toError,
765785 // On a failed refresh, keep the existing token and let the eventual 401
766786 // surface, better than blocking the command on a transient hiccup.
@@ -780,7 +800,7 @@ const refreshOAuthConnection = ( · 13 unmodified lines
780800 },
781801 });
782802
783− const profileName = profileNameFromKey(connection.key);
803+ const profileName = profileNameFromConnectionKey(connection.key);
784804 if (profileName) {
785805 yield* upsertCliServerConnectionProfile({
786806 name: profileName,
@@ -961,7 +981,7 @@ const executeCode = (input: { · 174 unmodified lines
961981 }): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> =>
962982 Effect.gen(function* () {
963983 const connection = yield* resolveExecutorServerConnection(input.target);
964− const client = yield* makeApiClient(connection);
984+ const client = yield* makeApiClient(connection, input.target);
965985 const response = yield* client.executions.execute({
966986 payload: {
967987 code: input.code,
@@ -1050,19 +1070,36 @@ const printExecutionOutcome = (input: { · 82 unmodified lines
10501070 // Typed API client
10511071 // ---------------------------------------------------------------------------
10521072
1053−const makeApiClient = (connection: ExecutorServerConnection) => {
1054− const authorization = getExecutorServerAuthorizationHeader(connection);
1055− return HttpApiClient.make(ExecutorApi, {
1056− baseUrl: connection.apiBaseUrl,
1057− ...(authorization
1058− ? {
1059− transformClient: HttpClient.mapRequest((request) =>
1060− HttpClientRequest.setHeader(request, "authorization", authorization),
1061− ),
1062− }
1063− : {}),
1073+const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) =>
1074+ Effect.gen(function* () {
1075+ const headers = yield* resolveExecutorServerRequestHeaders(connection, process.env).pipe(
1076+ Effect.mapError(toError),
1077+ );
1078+ return yield* HttpApiClient.make(ExecutorApi, {
1079+ baseUrl: connection.apiBaseUrl,
1080+ ...(Object.keys(headers).length > 0
1081+ ? {
1082+ transformClient: HttpClient.mapRequest((request) =>
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+ });
10641102 }).pipe(Effect.provide(FetchHttpClient.layer));
1065−};
10661103
10671104 // ---------------------------------------------------------------------------
10681105 // Foreground session
@@ -1321,14 +1358,26 @@ const runBackgroundDaemonStart = (input: { · 252 unmodified lines
13211358 // Stdio MCP session: a pure stdio <-> HTTP bridge to the owning local daemon.
13221359 // ---------------------------------------------------------------------------
13231360
1324−const mcpUrlForActiveLocalServer = (
1325− connection: ExecutorServerConnection,
1326− elicitationMode: "browser" | "model",
1327−): URL => {
1328− const url = new URL("/mcp", connection.origin);
1329− if (elicitationMode === "browser") {
1361+const mcpUrlForActiveLocalServer = (input: {
1362+ readonly connection: ExecutorServerConnection;
1363+ readonly elicitationMode: "browser" | "model";
1364+ readonly artifacts: boolean;
1365+ readonly searchTools: boolean;
1366+}): URL => {
1367+ const url = new URL("/mcp", input.connection.origin);
1368+ if (input.elicitationMode === "browser") {
13301369 url.searchParams.set("elicitation_mode", "browser");
13311370 }
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+ }
13321381 return url;
13331382 };
13341383
@@ -1343,11 +1392,18 @@ const mcpUrlForActiveLocalServer = ( · 8 unmodified lines
13431392 const runMcpHttpBridge = async (input: {
13441393 readonly manifest: ExecutorLocalServerManifest;
13451394 readonly elicitationMode: "browser" | "model";
1395+ readonly artifacts: boolean;
1396+ readonly searchTools: boolean;
13461397 }): Promise<void> => {
13471398 const stdio = new StdioServerTransport();
13481399 const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection);
13491400 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+ }),
13511407 authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined,
13521408 );
13531409
@@ -1422,7 +1478,11 @@ const runMcpHttpBridge = async (input: { · 68 unmodified lines
14221478 }
14231479 };
14241480
1425−const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) =>
1481+const runStdioMcpSession = (input: {
1482+ readonly elicitationMode: "browser" | "model";
1483+ readonly artifacts: boolean;
1484+ readonly searchTools: boolean;
1485+}) =>
14261486 Effect.gen(function* () {
14271487 // `executor mcp` never owns the local database. If a local server is already
14281488 // 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
14341494 const active = yield* readActiveLocalServerManifest();
14351495 if (active) {
14361496 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+ }),
14381503 );
14391504 return;
14401505 }
@@ -1456,7 +1521,12 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 15 unmodified lines
14561521 );
14571522 }
14581523 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+ }),
14601530 );
14611531 });
14621532
@@ -1504,14 +1574,45 @@ const parseOptionalJsonObject = ( · 41 unmodified lines
15041574 Effect.mapError((error) => new Error(`Invalid --content JSON: ${error.message}`)),
15051575 );
15061576
1577+const 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+ */
15071593 const formatUnknownMessage = (cause: unknown): string => {
1508− if (cause instanceof Error) return cause.message;
1509− if (typeof cause === "string") return cause;
1510− if (typeof cause === "object" && cause !== null && "message" in cause) {
1511− const message = cause.message;
1512− if (typeof message === "string") return message;
1594+ const messages: string[] = [];
1595+ const seen = new Set<unknown>();
1596+ let current: unknown = cause;
1597+ while (current !== null && current !== undefined && !seen.has(current)) {
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;
15131607 }
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: ");
15151616 };
15161617
15171618 const readCliLogLevel = (argv: ReadonlyArray<string>): string | undefined => {
@@ -1802,11 +1903,9 @@ const runCallHelp = ( · 284 unmodified lines
18021903 Effect.gen(function* () {
18031904 if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir);
18041905
1805− const connection = yield* resolveExecutorServerConnection({
1806− baseUrl: args.baseUrl,
1807− serverName: args.serverName,
1808− });
1809− const client = yield* makeApiClient(connection);
1906+ const target: ServerTarget = { baseUrl: args.baseUrl, serverName: args.serverName };
1907+ const connection = yield* resolveExecutorServerConnection(target);
1908+ const client = yield* makeApiClient(connection, target);
18101909 const tools = yield* client.tools.list({ query: {} });
18111910 const toolPaths = tools.map((tool) => tool.address);
18121911
@@ -1987,7 +2086,7 @@ const resumeCommand = Command.make( · 174 unmodified lines
19872086
19882087 const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content));
19892088
1990− const client = yield* makeApiClient(connection);
2089+ const client = yield* makeApiClient(connection, target);
19912090 const result = yield* client.executions.resume({
19922091 params: { executionId },
19932092 payload: { action, content: contentObj },
@@ -2052,8 +2151,8 @@ const toolsSearchCommand = Command.make( · 58 unmodified lines
20522151 }),
20532152 ).pipe(Command.withDescription("Search tools by natural-language query"));
20542153
2055−const toolsSourcesCommand = Command.make(
2056− "sources",
2154+const toolsIntegrationsCommand = Command.make(
2155+ "integrations",
20572156 {
20582157 query: Options.string("query").pipe(Options.optional),
20592158 limit: Options.integer("limit").pipe(Options.withDefault(50)),
@@ -2065,7 +2164,7 @@ const toolsSourcesCommand = Command.make( · 5 unmodified lines
20652164 Effect.gen(function* () {
20662165 applyScope(scope);
20672166 const target = serverTargetFromOptions({ baseUrl, server });
2068− const code = buildListSourcesCode({
2167+ const code = buildListIntegrationsCode({
20692168 query: Option.getOrUndefined(query),
20702169 limit,
20712170 });
@@ -2077,7 +2176,7 @@ const toolsSourcesCommand = Command.make( · 5 unmodified lines
20772176 outcome: result.outcome,
20782177 });
20792178 }),
2080−).pipe(Command.withDescription("List configured sources and tool counts"));
2179+).pipe(Command.withDescription("List configured integrations and tool counts"));
20812180
20822181 const toolsDescribeCommand = Command.make(
20832182 "describe",
@@ -2102,21 +2201,55 @@ const toolsDescribeCommand = Command.make( · 18 unmodified lines
21022201 ).pipe(Command.withDescription("Describe a tool's TypeScript and JSON schema"));
21032202
21042203 const toolsCommand = Command.make("tools").pipe(
2105− Command.withSubcommands([toolsSearchCommand, toolsSourcesCommand, toolsDescribeCommand] as const),
2106− Command.withDescription("Discover available tools and sources"),
2204+ Command.withSubcommands([
2205+ toolsSearchCommand,
2206+ toolsIntegrationsCommand,
2207+ toolsDescribeCommand,
2208+ ] as const),
2209+ Command.withDescription("Discover available tools and integrations"),
21072210 );
21082211
2212+const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
2213+const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2214+
2215+const 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+
21092239 const profileConnectionInput = (input: {
21102240 readonly origin: string;
21112241 readonly displayName: Option.Option<string>;
21122242 readonly kind: Option.Option<"http" | "desktop-sidecar">;
2243+ readonly headerEnv: Option.Option<Record<string, string>>;
21132244 }): ExecutorServerConnectionInput => {
21142245 const selectedKind = Option.getOrUndefined(input.kind);
21152246 const displayName = Option.getOrUndefined(input.displayName);
2247+ const headers = parseHeaderEnvOption(input.headerEnv);
21162248 return {
21172249 kind: selectedKind ?? "http",
21182250 origin: input.origin,
21192251 ...(displayName ? { displayName } : {}),
2252+ ...(headers ? { headers } : {}),
21202253 };
21212254 };
21222255
@@ -2136,13 +2269,16 @@ const printServerProfiles = () => · 13 unmodified lines
21362269 origin: profile.connection.origin,
21372270 displayName: profile.connection.displayName,
21382271 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",
21392275 }));
21402276 const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4);
21412277 const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4);
21422278
21432279 for (const row of rows) {
21442280 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}`,
21462282 );
21472283 }
21482284 });
@@ -2160,17 +2296,23 @@ const serverAddCommand = Command.make( · 11 unmodified lines
21602296 Options.optional,
21612297 Options.withDescription("Server kind. Defaults to http."),
21622298 ),
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+ ),
21632305 makeDefault: Options.boolean("default").pipe(
21642306 Options.withDefault(false),
21652307 Options.withDescription("Make this profile the default server."),
21662308 ),
21672309 },
2168− ({ name, origin, displayName, kind, makeDefault }) =>
2310+ ({ name, origin, displayName, kind, headerEnv, makeDefault }) =>
21692311 Effect.gen(function* () {
21702312 const profileName = validateCliServerConnectionProfileName(name);
21712313 const store = yield* upsertCliServerConnectionProfile({
21722314 name: profileName,
2173− connection: profileConnectionInput({ origin, displayName, kind }),
2315+ connection: profileConnectionInput({ origin, displayName, kind, headerEnv }),
21742316 makeDefault,
21752317 });
21762318 const profile = findCliServerConnectionProfile(store, profileName);
@@ -2307,9 +2449,13 @@ const chooseLoginProfileName = ( · 130 unmodified lines
23072449 }
23082450 };
23092451
2452+/** Where `executor login` lands with no flags and no stored profiles. */
2453+const DEFAULT_LOGIN_ORIGIN = "https://executor.sh";
2454+
23102455 // 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 decided
2312−// later, from the authenticated account.
2456+// or the default), a bare origin (--base-url), or hosted Executor when
2457+// nothing is configured yet. The profile name is decided later, from the
2458+// authenticated account.
23132459 const resolveLoginOrigin = (input: {
23142460 readonly baseUrl: Option.Option<string>;
23152461 readonly server: Option.Option<string>;
@@ -2337,11 +2483,7 @@ const resolveLoginOrigin = (input: { · 21 unmodified lines
23372483 const store = yield* readCliServerConnectionStore();
23382484 const profile = defaultCliServerConnectionProfile(store);
23392485 if (profile) return { origin: profile.connection.origin, profile };
2340− return yield* Effect.fail(
2341− new Error(
2342− "No server to log in to. Pass --base-url <https://your-host> or --server <profile>.",
2343− ),
2344− );
2486+ return { origin: DEFAULT_LOGIN_ORIGIN, profile: null };
23452487 });
23462488
23472489 const loginNameOption = Options.string("name").pipe(
@@ -2366,12 +2508,21 @@ const loginCommand = Command.make( · 18 unmodified lines
23662508 Effect.gen(function* () {
23672509 const target = yield* resolveLoginOrigin({ baseUrl, server });
23682510 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+ : {};
23692520 const discovery = yield* Effect.tryPromise({
2370− try: () => discoverCliLogin(target.origin),
2521+ try: () => discoverCliLogin(target.origin, { headers }),
23712522 catch: toError,
23722523 });
23732524 const grant = yield* Effect.tryPromise({
2374− try: () => requestDeviceCode(discovery),
2525+ try: () => requestDeviceCode(discovery, { serverOrigin: target.origin, headers }),
23752526 catch: toError,
23762527 });
23772528 const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri;
@@ -2382,7 +2533,7 @@ const loginCommand = Command.make( · 4 unmodified lines
23822533 if (!noBrowser) openBrowser(verifyUrl);
23832534 console.log("Waiting for you to approve in the browser...");
23842535 const tokens = yield* Effect.tryPromise({
2385− try: () => pollForDeviceTokens(discovery, grant),
2536+ try: () => pollForDeviceTokens(discovery, grant, { serverOrigin: target.origin, headers }),
23862537 catch: toError,
23872538 });
23882539
@@ -2407,6 +2558,9 @@ const loginCommand = Command.make( · 18 unmodified lines
24072558 kind: "http",
24082559 origin: target.origin,
24092560 ...(email ? { displayName: email } : {}),
2561+ ...(target.profile?.connection.headers
2562+ ? { headers: target.profile.connection.headers }
2563+ : {}),
24102564 auth: {
24112565 kind: "oauth",
24122566 accessToken: tokens.accessToken,
@@ -2420,12 +2574,24 @@ const loginCommand = Command.make( · 7 unmodified lines
24202574 });
24212575
24222576 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+ );
24242586 if (email) console.log(`Account: ${email}`);
24252587 if (org) console.log(`Organization: ${org}`);
24262588 else if (sub) console.log(`User: ${sub}`);
24272589 }),
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+);
24292595
24302596 const logoutCommand = Command.make(
24312597 "logout",
@@ -2441,8 +2607,14 @@ const logoutCommand = Command.make( · 9 unmodified lines
24412607 console.log(`No stored login for ${target.origin}.`);
24422608 return;
24432609 }
2610+ // Profile bookkeeping stays backstage unless the user addressed one.
2611+ const mentionProfile = Option.isSome(server) || store.profiles.length > 1;
24442612 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+ );
24462618 return;
24472619 }
24482620 yield* upsertCliServerConnectionProfile({
@@ -2451,11 +2623,14 @@ const logoutCommand = Command.make( · 2 unmodified lines
24512623 kind: profile.connection.kind,
24522624 origin: profile.connection.origin,
24532625 displayName: profile.connection.displayName,
2626+ ...(profile.connection.headers ? { headers: profile.connection.headers } : {}),
24542627 },
24552628 makeDefault: store.defaultProfile === profile.name,
24562629 });
24572630 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}.`,
24592634 );
24602635 }),
24612636 ).pipe(Command.withDescription("Clear stored credentials for a server profile"));
@@ -2709,11 +2884,25 @@ const mcpCommand = Command.make( · 247 unmodified lines
27092884 "Choose the stdio approval flow: browser approval or a CLI resume tool exposed to the model.",
27102885 ),
27112886 ),
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+ ),
27122901 },
2713− ({ scope, elicitationMode }) =>
2902+ ({ scope, elicitationMode, noArtifacts, searchTools }) =>
27142903 Effect.gen(function* () {
27152904 applyScope(scope);
2716− yield* runStdioMcpSession({ elicitationMode });
2905+ yield* runStdioMcpSession({ elicitationMode, artifacts: !noArtifacts, searchTools });
27172906 }),
27182907 ).pipe(Command.withDescription("Start an MCP server over stdio"));
27192908
@@ -3164,7 +3353,12 @@ const program = ( · 444 unmodified lines
31643353 } else {
31653354 console.error(renderCliError(cause));
31663355 }
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);
31683362 }),
31693363 ),
31703364 );
apps/cli/src/native-bindings.ts+36−5
@@ -1,11 +1,10 @@
11 // ---------------------------------------------------------------------------
22 // Native-binding bootstrap for the `bun build --compile` binary.
33 //
4−// `bun --compile` bundles JS into bunfs but does NOT include `.node` native
5−// addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside
6−// the binary fails. build.ts copies each platform's `.node` next to the
7−// executable (`libsql.node`, `keyring.node`); here we publish their on-disk
8−// paths via env vars the loaders read.
4+// `bun --compile` bundles JS into bunfs but does NOT include native binaries,
5+// wasm sidecars, or runtime-read package files. build.ts copies those artifacts
6+// next to the executable; here we publish their on-disk paths via env vars the
7+// loaders read.
98 //
109 // This MUST be the FIRST import in main.ts. ES modules evaluate every import
1110 // before the importer's own body, and libSQL resolves its native addon EAGERLY
@@ -18,6 +17,8 @@ · 6 unmodified lines
1817 import { existsSync } from "node:fs";
1918 import { dirname, join } from "node:path";
2019
20+import { WORKER_BUNDLER_DIRNAME, missingWorkerBundlerFiles } from "./worker-bundler-artifact";
21+
2122 const execDir = dirname(process.execPath);
2223
2324 // libSQL: our `libsql` patch reads EXECUTOR_LIBSQL_NATIVE_PATH and loads the
@@ -43,3 +44,33 @@ if ( · 19 unmodified lines
4344 ) {
4445 process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;
4546 }
47+
48+const workerdOnDisk = join(execDir, process.platform === "win32" ? "workerd.exe" : "workerd");
49+if (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.
60+const workerBundlerOnDisk = join(execDir, WORKER_BUNDLER_DIRNAME);
61+if (
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 @@
11 import { describe, expect, it } from "@effect/vitest";
22
3+import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared";
4+
35 import {
46 canAutoStartCliServerConnection,
57 chooseCliServerConnectionWithActiveLocal,
8+ describeUnauthorizedCliServer,
69 parseCliExecutorServerConnection,
710 withCliServerAuthFallback,
811 } from "./server-connection";
@@ -135,3 +138,84 @@ describe("CLI server connection", () => { · 126 unmodified lines
135138 ).toBe("conflict");
136139 });
137140 });
141+
142+describe("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
5252 return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname);
5353 };
5454
55+export 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.
68+export 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+
55104 export type CliServerConnectionSource =
56105 | "explicit"
57106 | "default-profile"
apps/cli/src/server-profile.test.ts+86−5
@@ -1,6 +1,6 @@
11 import { afterEach, describe, expect, it } from "@effect/vitest";
22 import { BunServices } from "@effect/platform-bun";
3−import { mkdtempSync, rmSync } from "node:fs";
3+import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import * as Effect from "effect/Effect";
@@ -25,7 +25,7 @@ afterEach(() => { · 18 unmodified lines
2525 });
2626
2727 describe("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", () =>
2929 Effect.gen(function* () {
3030 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-"));
3131 process.env.EXECUTOR_DATA_DIR = dataDir;
@@ -36,6 +36,9 @@ describe("CLI server connection profiles", () => { · 4 unmodified lines
3636 connection: {
3737 origin: "https://executor.example/api",
3838 auth: { kind: "bearer", token: "key_123" },
39+ headers: {
40+ "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
41+ },
3942 },
4043 makeDefault: true,
4144 });
@@ -50,13 +53,17 @@ describe("CLI server connection profiles", () => { · 8 unmodified lines
5053 kind: "bearer",
5154 token: "key_123",
5255 });
56+ expect(store.profiles[0]?.connection.headers).toEqual({
57+ "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
58+ });
5359 expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote");
5460 } finally {
5561 rmSync(dataDir, { recursive: true, force: true });
5662 }
57− }).pipe(Effect.provide(BunServices.layer)));
63+ }).pipe(Effect.provide(BunServices.layer)),
64+ );
5865
59− it("switches and removes the default profile", () =>
66+ it.effect("switches and removes the default profile", () =>
6067 Effect.gen(function* () {
6168 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-"));
6269 process.env.EXECUTOR_DATA_DIR = dataDir;
@@ -82,7 +89,8 @@ describe("CLI server connection profiles", () => { · 19 unmodified lines
8289 } finally {
8390 rmSync(dataDir, { recursive: true, force: true });
8491 }
85− }).pipe(Effect.provide(BunServices.layer)));
92+ }).pipe(Effect.provide(BunServices.layer)),
93+ );
8694
8795 it("drops malformed profiles when parsing", () => {
8896 const store = parseCliServerConnectionStore(
@@ -114,6 +122,12 @@ describe("CLI server connection profiles", () => { · 25 unmodified lines
114122 key: "desktop-sidecar",
115123 origin: "http://127.0.0.1:4789",
116124 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+ },
117131 },
118132 },
119133 ],
@@ -127,5 +141,72 @@ describe("CLI server connection profiles", () => { · 7 unmodified lines
127141 username: "executor",
128142 password: "secret",
129143 });
144+ expect(store.profiles[0]?.connection.headers).toEqual({
145+ "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
146+ });
130147 });
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+ );
131212 });
apps/cli/src/server-profile.ts+22−4
@@ -62,6 +62,11 @@ const PersistedAuth = Schema.Union([ · 61 unmodified lines
6262 }),
6363 ]);
6464
65+const PersistedHeader = Schema.Struct({
66+ kind: Schema.Literal("env"),
67+ name: Schema.String,
68+});
69+
6570 const PersistedConnection = Schema.Struct({
6671 kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])),
6772 key: Schema.optional(Schema.String),
@@ -69,6 +74,7 @@ const PersistedConnection = Schema.Struct({ · 1 unmodified line
6974 apiBaseUrl: Schema.optional(Schema.String),
7075 displayName: Schema.optional(Schema.String),
7176 auth: Schema.optional(PersistedAuth),
77+ headers: Schema.optional(Schema.Record(Schema.String, PersistedHeader)),
7278 });
7379
7480 const PersistedProfile = Schema.Struct({
@@ -144,10 +150,22 @@ export const writeCliServerConnectionStore = ( · 69 unmodified lines
144150 const path = yield* Path.Path;
145151 const dataDir = resolveDataDir(path);
146152 yield* fs.makeDirectory(dataDir, { recursive: true });
147− yield* fs.writeFileString(
148− serverConnectionStorePath(path),
149− serializeCliServerConnectionStore(store),
150− );
153+ const storePath = serverConnectionStorePath(path);
154+ // This store holds live credentials for a hosted server — a bearer token, or
155+ // an OAuth access token AND its long-lived refresh token — so create it
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);
151169 });
152170
153171 export const upsertCliServerConnectionProfile = (input: {
apps/cli/src/service.test.ts+30−0
@@ -185,6 +185,36 @@ describe("service unit generation", () => { · 184 unmodified lines
185185 expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD");
186186 });
187187
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+
188218 it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => {
189219 // A `"` in PATH would close the `set "PATH=..."` quote early and let a
190220 // `& 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
177177 "EXECUTOR_SENTRY_RELEASE",
178178 "EXECUTOR_SENTRY_ENVIRONMENT",
179179 "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",
180191 ] as const;
181192 const passThrough = Object.fromEntries(
182193 passThroughKeys.flatMap((key) => {
apps/cli/src/tooling.ts+2−2
@@ -408,14 +408,14 @@ export const buildSearchToolsCode = (input: { · 407 unmodified lines
408408 return `return await tools.search(${JSON.stringify(payload)});`;
409409 };
410410
411−export const buildListSourcesCode = (input: { query?: string; limit: number }): string => {
411+export const buildListIntegrationsCode = (input: { query?: string; limit: number }): string => {
412412 const payload: Record<string, unknown> = {
413413 limit: input.limit,
414414 };
415415 if (input.query && input.query.trim().length > 0) {
416416 payload.query = input.query;
417417 }
418− return `return await tools.executor.sources.list(${JSON.stringify(payload)});`;
418+ return `return await tools.executor.integrations.list(${JSON.stringify(payload)});`;
419419 };
420420
421421 export const buildDescribeToolCode = (toolPath: string): string =>
apps/cli/src/worker-bundler-artifact.test.ts+59−0
@@ -0,0 +1,59 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { join } from "node:path";
3+
4+import {
5+ WORKER_BUNDLER_DIRNAME,
6+ WORKER_BUNDLER_RUNTIME_FILES,
7+ missingWorkerBundlerFiles,
8+} from "./worker-bundler-artifact";
9+
10+const STAGED_DIR = join("/opt/executor/bin", WORKER_BUNDLER_DIRNAME);
11+
12+/** An existence probe that reports only the given POSIX-relative paths. */
13+const staged =
14+ (...relatives: readonly string[]) =>
15+ (path: string): boolean =>
16+ relatives.some((relative) => path === join(STAGED_DIR, ...relative.split("/")));
17+
18+describe("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+
24+import { join } from "node:path";
25+
26+/** Directory name staged beside the executable, and looked up from execDir. */
27+export 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+ */
37+export 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+ */
51+export 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
88 "skipLibCheck": true,
99 "noEmit": true,
1010 "declaration": true,
11− "types": ["bun-types"]
11+ "types": ["bun-types", "node"]
1212 },
1313 "include": ["src", "bin"]
1414 }
apps/cloud/CHANGELOG.md+335−0
@@ -1,5 +1,340 @@
11 # @executor-js/cloud
22
3+## 1.4.63
4+
5+### Patch Changes
6+
7+- Updated dependencies []:
8+ - @executor-js/sdk@1.6.2
9+ - @executor-js/runtime-quickjs@1.6.2
10+ - @executor-js/execution@1.6.2
11+ - @executor-js/plugin-graphql@1.6.2
12+ - @executor-js/plugin-mcp@1.6.2
13+ - @executor-js/plugin-openapi@1.6.2
14+ - @executor-js/api@1.4.65
15+ - @executor-js/vite-plugin@0.0.62
16+ - @executor-js/cloudflare@0.0.44
17+ - @executor-js/host-mcp@1.4.4
18+ - @executor-js/mcp-apps-shell@1.4.13
19+ - @executor-js/runtime-dynamic-worker@1.4.4
20+ - @executor-js/plugin-toolkits@1.5.37
21+ - @executor-js/plugin-workos-vault@0.0.2
22+ - @executor-js/react@1.4.65
23+
24+## 1.4.62
25+
26+### Patch Changes
27+
28+- Updated dependencies [[`9dff4e8`](https://github.com/UsefulSoftwareCo/executor/commit/9dff4e8e6598e7d3108634a71269245ba9b480bb), [`7c12aee`](https://github.com/UsefulSoftwareCo/executor/commit/7c12aeea390225291ce4c97865b392237ee7934d), [`ddbf0fe`](https://github.com/UsefulSoftwareCo/executor/commit/ddbf0feba38c8502d78fa20c3081391b8ba3d112), [`91062c2`](https://github.com/UsefulSoftwareCo/executor/commit/91062c2b1d7b8edbc8470ca5eaa544045652afaa), [`9c35f26`](https://github.com/UsefulSoftwareCo/executor/commit/9c35f269dd5de3548111fe5c83cf1e877f23c80d), [`62748e8`](https://github.com/UsefulSoftwareCo/executor/commit/62748e86122b747226c76c2e112c5c4d2b4f7095), [`0007474`](https://github.com/UsefulSoftwareCo/executor/commit/0007474602d8da3642648f216bfdb0f09eb0914f), [`d4afe0c`](https://github.com/UsefulSoftwareCo/executor/commit/d4afe0c79f146dd169a00988a2d5d0469297be19), [`55180cb`](https://github.com/UsefulSoftwareCo/executor/commit/55180cb1487f9a3a28ddc0ee0bedfab8464c1f72)]:
29+ - @executor-js/react@1.4.64
30+ - @executor-js/plugin-openapi@1.6.1
31+ - @executor-js/plugin-mcp@1.6.1
32+ - @executor-js/execution@1.6.1
33+ - @executor-js/sdk@1.6.1
34+ - @executor-js/api@1.4.64
35+ - @executor-js/mcp-apps-shell@1.4.12
36+ - @executor-js/plugin-graphql@1.6.1
37+ - @executor-js/plugin-toolkits@1.5.36
38+ - @executor-js/plugin-workos-vault@0.0.2
39+ - @executor-js/runtime-dynamic-worker@1.4.4
40+ - @executor-js/cloudflare@0.0.43
41+ - @executor-js/host-mcp@1.4.4
42+ - @executor-js/vite-plugin@0.0.61
43+ - @executor-js/runtime-quickjs@1.6.1
44+
45+## 1.4.61
46+
47+### Patch Changes
48+
49+- 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)]:
50+ - @executor-js/plugin-mcp@1.6.0
51+ - @executor-js/plugin-openapi@1.6.0
52+ - @executor-js/sdk@1.6.0
53+ - @executor-js/react@1.4.63
54+ - @executor-js/runtime-dynamic-worker@1.4.4
55+ - @executor-js/api@1.4.63
56+ - @executor-js/execution@1.6.0
57+ - @executor-js/vite-plugin@0.0.60
58+ - @executor-js/cloudflare@0.0.42
59+ - @executor-js/host-mcp@1.4.4
60+ - @executor-js/mcp-apps-shell@1.4.11
61+ - @executor-js/plugin-graphql@1.6.0
62+ - @executor-js/plugin-toolkits@1.5.35
63+ - @executor-js/plugin-workos-vault@0.0.2
64+ - @executor-js/runtime-quickjs@1.6.0
65+
66+## 1.4.60
67+
68+### Patch Changes
69+
70+- 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)]:
71+ - @executor-js/sdk@1.5.42
72+ - @executor-js/cloudflare@0.0.41
73+ - @executor-js/plugin-openapi@1.5.42
74+ - @executor-js/plugin-mcp@1.5.42
75+ - @executor-js/api@1.4.62
76+ - @executor-js/execution@1.5.42
77+ - @executor-js/vite-plugin@0.0.59
78+ - @executor-js/host-mcp@1.4.4
79+ - @executor-js/mcp-apps-shell@1.4.10
80+ - @executor-js/runtime-dynamic-worker@1.4.4
81+ - @executor-js/plugin-graphql@1.5.42
82+ - @executor-js/plugin-toolkits@1.5.34
83+ - @executor-js/plugin-workos-vault@0.0.2
84+ - @executor-js/react@1.4.62
85+ - @executor-js/runtime-quickjs@1.5.42
86+
87+## 1.4.59
88+
89+### Patch Changes
90+
91+- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd), [`a9b33d2`](https://github.com/UsefulSoftwareCo/executor/commit/a9b33d25c32fbb4a292b7e8963e22392f862a16f)]:
92+ - @executor-js/sdk@1.5.41
93+ - @executor-js/plugin-mcp@1.5.41
94+ - @executor-js/api@1.4.61
95+ - @executor-js/execution@1.5.41
96+ - @executor-js/vite-plugin@0.0.58
97+ - @executor-js/cloudflare@0.0.40
98+ - @executor-js/host-mcp@1.4.4
99+ - @executor-js/mcp-apps-shell@1.4.9
100+ - @executor-js/runtime-dynamic-worker@1.4.4
101+ - @executor-js/plugin-graphql@1.5.41
102+ - @executor-js/plugin-openapi@1.5.41
103+ - @executor-js/plugin-toolkits@1.5.33
104+ - @executor-js/plugin-workos-vault@0.0.2
105+ - @executor-js/react@1.4.61
106+ - @executor-js/runtime-quickjs@1.5.41
107+
108+## 1.4.58
109+
110+### Patch Changes
111+
112+- Updated dependencies [[`8ba64f6`](https://github.com/UsefulSoftwareCo/executor/commit/8ba64f675f6d6ab5302d4f68390c0b055d006f4a)]:
113+ - @executor-js/sdk@1.5.40
114+ - @executor-js/api@1.4.60
115+ - @executor-js/execution@1.5.40
116+ - @executor-js/vite-plugin@0.0.57
117+ - @executor-js/cloudflare@0.0.39
118+ - @executor-js/host-mcp@1.4.4
119+ - @executor-js/mcp-apps-shell@1.4.8
120+ - @executor-js/runtime-dynamic-worker@1.4.4
121+ - @executor-js/plugin-graphql@1.5.40
122+ - @executor-js/plugin-mcp@1.5.40
123+ - @executor-js/plugin-openapi@1.5.40
124+ - @executor-js/plugin-toolkits@1.5.32
125+ - @executor-js/plugin-workos-vault@0.0.2
126+ - @executor-js/react@1.4.60
127+ - @executor-js/runtime-quickjs@1.5.40
128+
129+## 1.4.57
130+
131+### Patch Changes
132+
133+- Updated dependencies [[`6c316c7`](https://github.com/UsefulSoftwareCo/executor/commit/6c316c77a9efc98784976236852b58c6156e016e)]:
134+ - @executor-js/sdk@1.5.39
135+ - @executor-js/api@1.4.59
136+ - @executor-js/execution@1.5.39
137+ - @executor-js/vite-plugin@0.0.56
138+ - @executor-js/cloudflare@0.0.38
139+ - @executor-js/host-mcp@1.4.4
140+ - @executor-js/mcp-apps-shell@1.4.7
141+ - @executor-js/runtime-dynamic-worker@1.4.4
142+ - @executor-js/plugin-graphql@1.5.39
143+ - @executor-js/plugin-mcp@1.5.39
144+ - @executor-js/plugin-openapi@1.5.39
145+ - @executor-js/plugin-toolkits@1.5.31
146+ - @executor-js/plugin-workos-vault@0.0.2
147+ - @executor-js/react@1.4.59
148+ - @executor-js/runtime-quickjs@1.5.39
149+
150+## 1.4.56
151+
152+### Patch Changes
153+
154+- 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)]:
155+ - @executor-js/plugin-openapi@1.5.38
156+ - @executor-js/sdk@1.5.38
157+ - @executor-js/react@1.4.58
158+ - @executor-js/runtime-dynamic-worker@1.4.4
159+ - @executor-js/api@1.4.58
160+ - @executor-js/execution@1.5.38
161+ - @executor-js/vite-plugin@0.0.55
162+ - @executor-js/cloudflare@0.0.37
163+ - @executor-js/host-mcp@1.4.4
164+ - @executor-js/mcp-apps-shell@1.4.6
165+ - @executor-js/plugin-graphql@1.5.38
166+ - @executor-js/plugin-mcp@1.5.38
167+ - @executor-js/plugin-toolkits@1.5.30
168+ - @executor-js/plugin-workos-vault@0.0.2
169+ - @executor-js/runtime-quickjs@1.5.38
170+
171+## 1.4.55
172+
173+### Patch Changes
174+
175+- Updated dependencies [[`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb)]:
176+ - @executor-js/sdk@1.5.37
177+ - @executor-js/api@1.4.57
178+ - @executor-js/execution@1.5.37
179+ - @executor-js/vite-plugin@0.0.54
180+ - @executor-js/cloudflare@0.0.36
181+ - @executor-js/host-mcp@1.4.4
182+ - @executor-js/mcp-apps-shell@1.4.5
183+ - @executor-js/runtime-dynamic-worker@1.4.4
184+ - @executor-js/plugin-graphql@1.5.37
185+ - @executor-js/plugin-mcp@1.5.37
186+ - @executor-js/plugin-openapi@1.5.37
187+ - @executor-js/plugin-toolkits@1.5.29
188+ - @executor-js/plugin-workos-vault@0.0.2
189+ - @executor-js/react@1.4.57
190+ - @executor-js/runtime-quickjs@1.5.37
191+
192+## 1.4.54
193+
194+### Patch Changes
195+
196+- Updated dependencies []:
197+ - @executor-js/sdk@1.5.36
198+ - @executor-js/runtime-quickjs@1.5.36
199+ - @executor-js/execution@1.5.36
200+ - @executor-js/plugin-graphql@1.5.36
201+ - @executor-js/plugin-mcp@1.5.36
202+ - @executor-js/plugin-openapi@1.5.36
203+ - @executor-js/api@1.4.56
204+ - @executor-js/vite-plugin@0.0.53
205+ - @executor-js/cloudflare@0.0.35
206+ - @executor-js/host-mcp@1.4.4
207+ - @executor-js/runtime-dynamic-worker@1.4.4
208+ - @executor-js/plugin-toolkits@1.5.28
209+ - @executor-js/plugin-workos-vault@0.0.2
210+ - @executor-js/react@1.4.56
211+
212+## 1.4.53
213+
214+### Patch Changes
215+
216+- 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)]:
217+ - @executor-js/sdk@1.5.35
218+ - @executor-js/plugin-mcp@1.5.35
219+ - @executor-js/runtime-quickjs@1.5.35
220+ - @executor-js/api@1.4.55
221+ - @executor-js/execution@1.5.35
222+ - @executor-js/vite-plugin@0.0.52
223+ - @executor-js/cloudflare@0.0.34
224+ - @executor-js/host-mcp@1.4.4
225+ - @executor-js/runtime-dynamic-worker@1.4.4
226+ - @executor-js/plugin-apps@0.1.6
227+ - @executor-js/plugin-graphql@1.5.35
228+ - @executor-js/plugin-openapi@1.5.35
229+ - @executor-js/plugin-toolkits@1.5.27
230+ - @executor-js/plugin-workos-vault@0.0.2
231+ - @executor-js/react@1.4.55
232+
233+## 1.4.52
234+
235+### Patch Changes
236+
237+- 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)]:
238+ - @executor-js/sdk@1.5.34
239+ - @executor-js/execution@1.5.34
240+ - @executor-js/plugin-openapi@1.5.34
241+ - @executor-js/api@1.4.54
242+ - @executor-js/vite-plugin@0.0.51
243+ - @executor-js/cloudflare@0.0.33
244+ - @executor-js/host-mcp@1.4.4
245+ - @executor-js/runtime-dynamic-worker@1.4.4
246+ - @executor-js/plugin-apps@0.1.5
247+ - @executor-js/plugin-graphql@1.5.34
248+ - @executor-js/plugin-mcp@1.5.34
249+ - @executor-js/plugin-toolkits@1.5.26
250+ - @executor-js/plugin-workos-vault@0.0.2
251+ - @executor-js/react@1.4.54
252+ - @executor-js/runtime-quickjs@1.5.34
253+
254+## 1.4.51
255+
256+### Patch Changes
257+
258+- Updated dependencies []:
259+ - @executor-js/sdk@1.5.33
260+ - @executor-js/runtime-quickjs@1.5.33
261+ - @executor-js/execution@1.5.33
262+ - @executor-js/plugin-graphql@1.5.33
263+ - @executor-js/plugin-mcp@1.5.33
264+ - @executor-js/plugin-openapi@1.5.33
265+ - @executor-js/api@1.4.53
266+ - @executor-js/vite-plugin@0.0.50
267+ - @executor-js/cloudflare@0.0.32
268+ - @executor-js/host-mcp@1.4.4
269+ - @executor-js/runtime-dynamic-worker@1.4.4
270+ - @executor-js/plugin-apps@0.1.4
271+ - @executor-js/plugin-toolkits@1.5.25
272+ - @executor-js/plugin-workos-vault@0.0.2
273+ - @executor-js/react@1.4.53
274+
275+## 1.4.50
276+
277+### Patch Changes
278+
279+- Updated dependencies []:
280+ - @executor-js/sdk@1.5.32
281+ - @executor-js/runtime-quickjs@1.5.32
282+ - @executor-js/execution@1.5.32
283+ - @executor-js/plugin-graphql@1.5.32
284+ - @executor-js/plugin-mcp@1.5.32
285+ - @executor-js/plugin-openapi@1.5.32
286+ - @executor-js/api@1.4.52
287+ - @executor-js/vite-plugin@0.0.49
288+ - @executor-js/cloudflare@0.0.31
289+ - @executor-js/host-mcp@1.4.4
290+ - @executor-js/runtime-dynamic-worker@1.4.4
291+ - @executor-js/plugin-apps@0.1.3
292+ - @executor-js/plugin-toolkits@1.5.24
293+ - @executor-js/plugin-workos-vault@0.0.2
294+ - @executor-js/react@1.4.52
295+
296+## 1.4.49
297+
298+### Patch Changes
299+
300+- Updated dependencies [[`9e38928`](https://github.com/UsefulSoftwareCo/executor/commit/9e38928f0fda9032b64b26990270c5d2b6690d13)]:
301+ - @executor-js/plugin-openapi@1.5.31
302+ - @executor-js/runtime-dynamic-worker@1.4.4
303+ - @executor-js/sdk@1.5.31
304+ - @executor-js/runtime-quickjs@1.5.31
305+ - @executor-js/execution@1.5.31
306+ - @executor-js/plugin-graphql@1.5.31
307+ - @executor-js/plugin-mcp@1.5.31
308+ - @executor-js/api@1.4.51
309+ - @executor-js/vite-plugin@0.0.48
310+ - @executor-js/cloudflare@0.0.30
311+ - @executor-js/host-mcp@1.4.4
312+ - @executor-js/plugin-apps@0.1.2
313+ - @executor-js/plugin-toolkits@1.5.23
314+ - @executor-js/plugin-workos-vault@0.0.2
315+ - @executor-js/react@1.4.51
316+
317+## 1.4.48
318+
319+### Patch Changes
320+
321+- Updated dependencies []:
322+ - @executor-js/sdk@1.5.30
323+ - @executor-js/runtime-quickjs@1.5.30
324+ - @executor-js/execution@1.5.30
325+ - @executor-js/plugin-graphql@1.5.30
326+ - @executor-js/plugin-mcp@1.5.30
327+ - @executor-js/plugin-openapi@1.5.30
328+ - @executor-js/api@1.4.50
329+ - @executor-js/vite-plugin@0.0.47
330+ - @executor-js/cloudflare@0.0.29
331+ - @executor-js/host-mcp@1.4.4
332+ - @executor-js/runtime-dynamic-worker@1.4.4
333+ - @executor-js/plugin-apps@0.1.1
334+ - @executor-js/plugin-toolkits@1.5.22
335+ - @executor-js/plugin-workos-vault@0.0.2
336+ - @executor-js/react@1.4.50
337+
3338 ## 1.4.47
4339
5340 ### 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.
12+UPDATE "connection" c
13+SET "tools_synced_at" = NULL
14+FROM (
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
22+WHERE c."tenant" = ndjson."tenant"
23+ AND c."integration" = ndjson."integration";
apps/cloud/drizzle/0011_tense_caretaker.sql+1−0
@@ -0,0 +1 @@
1+ALTER TABLE "oauth_client" ADD COLUMN "origin_redirect_uri" text;
apps/cloud/drizzle/0012_woozy_shiva.sql+10−0
@@ -0,0 +1,10 @@
1+CREATE 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
10+CREATE 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 @@
1+CREATE 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
14+CREATE UNIQUE INDEX "artifact_uidx" ON "artifact" USING btree ("tenant","owner","subject","id");
apps/cloud/drizzle/0014_new_karma.sql+1−0
@@ -0,0 +1 @@
1+ALTER TABLE "artifact" ADD COLUMN "bindings" json;
apps/cloud/drizzle/0015_equal_the_leader.sql+1−0
@@ -0,0 +1 @@
1+ALTER 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
7171 "when": 1783025157233,
7272 "tag": "0009_true_lake",
7373 "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
74116 }
75117 ]
76118 }
apps/cloud/executor.config.ts+13−15
@@ -1,7 +1,13 @@
11 import { defineExecutorConfig } from "@executor-js/sdk";
22 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3−import { googleHttpPlugin } from "@executor-js/plugin-google/api";
4−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
3+import {
4+ googleCatalog,
5+ googleDiscoveryAdapter,
6+} from "@executor-js/plugin-openapi/providers/google";
7+import {
8+ microsoftCatalog,
9+ microsoftGraphAdapter,
10+} from "@executor-js/plugin-openapi/providers/microsoft";
511 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
612 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
713 import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";
@@ -42,23 +48,15 @@ interface CloudPluginDeps { · 34 unmodified lines
4248 * falls back to the credential-driven default. */
4349 readonly workosVaultClient?: WorkOSVaultClient;
4450 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;
4951 }
5052
5153 export default defineExecutorConfig({
52− plugins: ({
53− workosCredentials,
54− workosVaultClient,
55− activeToolkitSlug,
56− allowLocalNetwork,
57− }: CloudPluginDeps = {}) =>
54+ plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) =>
5855 [
59− openApiHttpPlugin(),
60− googleHttpPlugin(),
61− microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }),
56+ openApiHttpPlugin({
57+ presets: [...googleCatalog, ...microsoftCatalog],
58+ specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
59+ }),
6260 mcpHttpPlugin({
6361 dangerouslyAllowStdioMCP: false,
6462 }),
apps/cloud/package.json+9−4
@@ -1,6 +1,6 @@
11 {
22 "name": "@executor-js/cloud",
3− "version": "1.4.47",
3+ "version": "1.4.63",
44 "private": true,
55 "type": "module",
66 "scripts": {
@@ -30,7 +30,10 @@ · 23 unmodified lines
3030 "typecheck:slow": "tsc --noEmit",
3131 "db:backfill-org-slugs:prod": "op run --env-file=.env.production -- bun run scripts/backfill-org-slugs.ts",
3232 "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"
3437 },
3538 "dependencies": {
3639 "@cloudflare/vite-plugin": "^1.31.1",
@@ -41,10 +44,9 @@ · 4 unmodified lines
4144 "@executor-js/execution": "workspace:*",
4245 "@executor-js/fumadb": "workspace:*",
4346 "@executor-js/host-mcp": "workspace:*",
44− "@executor-js/plugin-google": "workspace:*",
47+ "@executor-js/mcp-apps-shell": "workspace:*",
4548 "@executor-js/plugin-graphql": "workspace:*",
4649 "@executor-js/plugin-mcp": "workspace:*",
47− "@executor-js/plugin-microsoft": "workspace:*",
4850 "@executor-js/plugin-openapi": "workspace:*",
4951 "@executor-js/plugin-toolkits": "workspace:*",
5052 "@executor-js/plugin-workos-vault": "workspace:*",
@@ -53,6 +55,7 @@ · 2 unmodified lines
5355 "@executor-js/runtime-quickjs": "workspace:*",
5456 "@executor-js/sdk": "workspace:*",
5557 "@executor-js/vite-plugin": "workspace:*",
58+ "@jitl/quickjs-wasmfile-release-sync": "catalog:",
5659 "@modelcontextprotocol/sdk": "^1.29.0",
5760 "@opentelemetry/api": "~1.9.0",
5861 "@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
@@ -70,9 +73,11 @@ · 11 unmodified lines
7073 "autumn-js": "^1.2.8",
7174 "drizzle-orm": "catalog:",
7275 "effect": "catalog:",
76+ "iron-webcrypto": "2.0.0",
7377 "jose": "^5.6.3",
7478 "postgres": "^3.4.9",
7579 "posthog-js": "^1.372.5",
80+ "quickjs-emscripten-core": "0.31.0",
7681 "react": "catalog:",
7782 "react-dom": "catalog:",
7883 "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+
31+import postgres from "postgres";
32+import { createId } from "@executor-js/fumadb/cuid";
33+
34+const dryRun = process.argv.includes("--dry-run");
35+
36+const connectionString = process.env.DATABASE_URL;
37+if (!connectionString) {
38+ console.error("DATABASE_URL is not set");
39+ process.exit(1);
40+}
41+
42+const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" });
43+
44+const 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+`;
55+console.log(`${rows.length} (tenant, subject) pair(s) with connections`);
56+
57+let inserted = 0;
58+let repaired = 0;
59+for (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+
82+console.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+);
87+await sql.end();
apps/cloud/scripts/build.mjs+20−1
@@ -9,7 +9,7 @@ · 8 unmodified lines
99
1010 import { spawnSync } from "node:child_process";
1111 import { randomBytes } from "node:crypto";
12−import { rmSync } from "node:fs";
12+import { existsSync, readFileSync, rmSync } from "node:fs";
1313
1414 if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) {
1515 process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex");
@@ -32,3 +32,22 @@ for (const step of steps) { · 16 unmodified lines
3232 process.exit(result.status ?? 1);
3333 }
3434 }
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.
42+const shellAsset = new URL(
43+ "../dist/client/assets/executor-mcp-apps-shell-stable.html",
44+ import.meta.url,
45+);
46+if (!existsSync(shellAsset)) {
47+ console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`);
48+ process.exit(1);
49+}
50+if (!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
1515 import { PGLiteSocketServer } from "@electric-sql/pglite-socket";
1616 import { drizzle } from "drizzle-orm/pglite";
1717 import { migrate } from "drizzle-orm/pglite/migrator";
18+import postgres from "postgres";
1819
1920 const __dirname = dirname(fileURLToPath(import.meta.url));
2021 // 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
104105 // but prepared statement requires M" -> random 500s on whichever request lost
105106 // the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch
106107 // batches each socket data event into one queue entry and holds handler
107−// affinity while a pipeline is open;
108−// src/db/dev-db-socket-concurrency.node.test.ts is the regression test.
109−const server = new PGLiteSocketServer({
110− db,
111− port: PORT,
112− host: "127.0.0.1",
113− maxConnections: Number(process.env.DEV_DB_MAX_CONNECTIONS ?? 1000),
114− // Backstop for pipeline affinity: a client that stalls mid-pipeline (Parse
115− // sent, no Sync) with its socket still OPEN would hold the queue's handler
116− // affinity forever and starve every other connection, since affinity only
117− // releases on detach and detach needs close/error/idle-timeout. In ms; the
118− // timer resets on every data event, so only a genuinely dead client trips it.
119− idleTimeout: Number(process.env.DEV_DB_IDLE_TIMEOUT_MS ?? 30_000),
120−});
108+// affinity while a pipeline is open. The patch also fixes the queue's two
109+// self-bricking failure paths — both surfaced in CI as the e2e "cloud signIn:
110+// callback set no session (500)" cascade, where new connections' startup
111+// packets sat unanswered (postgres.js CONNECT_TIMEOUT) until restart:
112+// 1. Stock 0.1.4 `return`ed out of the drain loop when a query REJECTED at
113+// the JS level, leaving its `processing` flag latched true; nothing was
114+// ever dequeued again.
115+// 2. A client whose socket died WHILE its pipeline-opening entry executed:
116+// detach() cleared affinity before the entry finished, the queue then
117+// took affinity for the already-dead handler, and no timer was left to
118+// release it. The queue now tracks detached handlers and repairs any
119+// transaction or pipeline affinity they can no longer release.
120+// src/db/dev-db-socket-concurrency.node.test.ts is the regression test for
121+// all of the above.
122+const 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+ });
121139
140+let server = makeServer();
122141 await server.start();
123142 console.log(`[dev-db] Listening on postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`);
124143
144+let stopping = false;
145+
125146 const shutdown = async () => {
147+ stopping = true;
126148 console.log("\n[dev-db] Shutting down");
127149 await server.stop();
128150 await db.close();
@@ -131,3 +153,112 @@ const shutdown = async () => { · 2 unmodified lines
131153
132154 process.on("SIGINT", shutdown);
133155 process.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.
179+const 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.
183+const WATCHDOG_FAILURES_TO_RESTART = 3;
184+const WATCHDOG_MAX_RESTARTS = 3;
185+
186+const 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+
209+const 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+
264+void 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".
29+import { readFileSync, readdirSync, statSync } from "node:fs";
30+import { dirname, join, relative, resolve } from "node:path";
31+
32+const DIST = resolve(process.argv[2] ?? "dist/server");
33+const 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.
38+const STATIC_RE = /(?:from|import)\s*["'](\.[^"']+)["']/g;
39+const DYNAMIC_RE = /import\(\s*["'](\.[^"']+)["']\s*\)/g;
40+
41+const 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+
50+const graph = new Map();
51+for (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. */
65+const 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+
77+const bytes = (files) => [...files].reduce((sum, f) => sum + (graph.get(f)?.size ?? 0), 0);
78+const mb = (n) => `${(n / 1024 / 1024).toFixed(2)} MB`;
79+const name = (f) => relative(DIST, f);
80+
81+const startup = closure([ENTRY]);
82+// The lazy server-graph entries Start pulls on first request.
83+const startRoots = [...graph.get(ENTRY).dynamic].filter((f) =>
84+ /(start|router|tanstack)/.test(name(f)),
85+);
86+const start = closure(startRoots);
87+const appRoots = [...graph.get(ENTRY).dynamic].filter((f) => /\/app-[A-Za-z0-9_-]+\.js$/.test(f));
88+const app = closure([ENTRY, ...appRoots]);
89+// The budget tracks the worst plane: whichever costs a cold isolate more.
90+const evaluated = new Set([...startup, ...start]);
91+
92+const 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+
100+report("startup", startup);
101+report("start", start);
102+console.log(`\npage request (startup + start): ${mb(bytes(evaluated))}`);
103+console.log(
104+ `API request (startup + app): ${mb(bytes(app))}${appRoots.length ? "" : " [no app chunk - /api still routes through Start]"}`,
105+);
106+const lazyOnly = [...graph.keys()].filter((f) => !evaluated.has(f));
107+console.log(
108+ `deferred behind dynamic import: ${mb(bytes(lazyOnly))} (${lazyOnly.length} chunks)`,
109+);
110+
111+const budget = Number(process.env.START_CLOSURE_BUDGET_MB ?? 0);
112+if (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
1313
1414 const __dirname = dirname(fileURLToPath(import.meta.url));
1515
16−const PORT = 5434;
16+const 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+
26+const PORT = parsePort(process.env.CLOUD_TEST_DB_PORT);
1727 const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle");
1828
1929 let db: PGlite | undefined;
2030 let server: PGLiteSocketServer | undefined;
2131
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+ */
2236 export default async function setup() {
2337 db = await PGlite.create();
2438 await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER });
@@ -30,7 +44,12 @@ export default async function setup() { · 5 unmodified lines
3044 console.log(`[test-db] PGlite socket server listening on 127.0.0.1:${PORT}`);
3145
3246 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;
3351 await server?.stop();
3452 await db?.close();
53+ if (testsFailed) process.exitCode = 1;
3554 };
3655 }
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
8+import { copyFileSync } from "node:fs";
9+import { createRequire } from "node:module";
10+import { dirname, join } from "node:path";
11+import { fileURLToPath } from "node:url";
12+
13+const require = createRequire(import.meta.url);
14+const source = require.resolve("@jitl/quickjs-wasmfile-release-sync/wasm");
15+const dest = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "quickjs-engine.wasm");
16+
17+copyFileSync(source, dest);
18+console.log(`vendored ${source} -> ${dest}`);
apps/cloud/src/account/account-api.ts+4−1
@@ -72,13 +72,16 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi · 71 unmodified lines
7272
7373 // Built inside the request body so the WorkOS account service closes
7474 // over the per-request `UserStoreService` (postgres socket) supplied by
75− // the combined request-scoped layer.
75+ // the combined request-scoped layer. `local` keeps that promise: the
76+ // `longLived` context re-applied below carries the boot `CurrentMemoMap`,
77+ // so a shared build would hand overlapping requests one another's socket.
7678 const accountProvider = yield* Effect.provide(
7779 AccountProvider.asEffect(),
7880 workosAccountProvider.pipe(
7981 Layer.provide(ApiKeyService.WorkOS),
8082 Layer.provide(Layer.succeed(AccountCaller)({ session })),
8183 ),
84+ { local: true },
8285 );
8386 return yield* Effect.provideService(httpEffect, AccountProvider, accountProvider);
8487 }).pipe(Effect.provideContext(longLived));
apps/cloud/src/account/org-api-key-revoke.node.test.ts+196−0
@@ -0,0 +1,196 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+
4+import { AccountProvider } from "@executor-js/api/server";
5+import { AccountError, AccountForbidden } from "@executor-js/api";
6+
7+import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys";
8+import { UserStoreService } from "../auth/context";
9+import { ORG_SELECTOR_HEADER } from "../auth/organization";
10+import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
11+import { AutumnService } from "../extensions/billing/service";
12+import { 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+
34+const ORG = "org_123";
35+const ADMIN = "user_admin";
36+const MEMBER = "user_member";
37+const ORG_KEY = "key_org_1";
38+const USER_KEY = "key_user_1";
39+const createdAt = new Date("2026-01-01T00:00:00.000Z");
40+const orgHeaders = { [ORG_SELECTOR_HEADER]: ORG };
41+
42+const 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. */
53+const 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+
81+const 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+
109+const 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+ setMemberSeats: () => Effect.void,
115+});
116+
117+/**
118+ * The provider under test, over an `ApiKeyService` whose org listing holds a
119+ * single org-owned key. Revoked ids land in `revoked` so "no delete happened"
120+ * is assertable, not merely inferred from the error.
121+ */
122+const providerWith = (accountId: string) => {
123+ const revoked: string[] = [];
124+ const stubApiKeys = Layer.succeed(ApiKeyService)({
125+ validate: () => Effect.die("revoke test does not validate keys"),
126+ listUserKeys: () => Effect.succeed([]),
127+ createUserKey: () => Effect.die("revoke test does not create user keys"),
128+ revokeUserKey: () => Effect.die("the ORG path must not call the personal revoke"),
129+ listOrgKeys: () => Effect.die("revoke test does not list org keys"),
130+ createOrgKey: () => Effect.die("revoke test does not create org keys"),
131+ revokeOrgKey: ({ keyId }) =>
132+ keyId === ORG_KEY
133+ ? Effect.sync(() => {
134+ revoked.push(keyId);
135+ })
136+ : Effect.fail(new OrgApiKeyNotFound({ keyId })),
137+ });
138+
139+ const provider = AccountProvider.asEffect().pipe(
140+ Effect.provide(
141+ workosAccountProvider.pipe(
142+ Layer.provide(
143+ Layer.mergeAll(
144+ stubWorkOS,
145+ stubUsers,
146+ stubApiKeys,
147+ stubAutumn,
148+ Layer.succeed(AccountCaller)({ session: session(accountId) }),
149+ ),
150+ ),
151+ ),
152+ ),
153+ );
154+
155+ return { provider, revoked };
156+};
157+
158+describe("revokeOrgApiKey · provider boundary", () => {
159+ it.effect("an org admin revokes an org-owned key", () =>
160+ Effect.gen(function* () {
161+ const { provider, revoked } = providerWith(ADMIN);
162+ const account = yield* provider;
163+
164+ const result = yield* account.revokeOrgApiKey(orgHeaders, ORG_KEY);
165+
166+ expect(result).toEqual({ success: true });
167+ expect(revoked, "the revoke reached the key service").toEqual([ORG_KEY]);
168+ }),
169+ );
170+
171+ it.effect("a plain member is forbidden, and nothing is revoked", () =>
172+ Effect.gen(function* () {
173+ const { provider, revoked } = providerWith(MEMBER);
174+ const account = yield* provider;
175+
176+ const error = yield* Effect.flip(account.revokeOrgApiKey(orgHeaders, ORG_KEY));
177+
178+ expect(error, "same admin gate as the mint").toBeInstanceOf(AccountForbidden);
179+ expect(revoked, "the gate runs BEFORE the key service is touched").toEqual([]);
180+ }),
181+ );
182+
183+ it.effect("a user-scoped key id fails cleanly through this path", () =>
184+ Effect.gen(function* () {
185+ // Not found — not a 500, and emphatically not a silent success that would
186+ // report a member's live personal credential as destroyed.
187+ const { provider, revoked } = providerWith(ADMIN);
188+ const account = yield* provider;
189+
190+ const error = yield* Effect.flip(account.revokeOrgApiKey(orgHeaders, USER_KEY));
191+
192+ expect(error).toBeInstanceOf(AccountError);
193+ expect(revoked).toEqual([]);
194+ }),
195+ );
196+});
apps/cloud/src/account/workos-account-service.ts+68−9
@@ -14,6 +14,7 @@ import type { Session } from "../auth/middleware"; · 13 unmodified lines
1414 import { WorkOSClient } from "../auth/workos";
1515 import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";
1616 import { AutumnService } from "../extensions/billing/service";
17+import { forkReportMemberSeats } from "../extensions/billing/member-seats";
1718 import {
1819 countSeatsUsed,
1920 getMemberLimitForPlan,
@@ -82,7 +83,7 @@ export const workosAccountProvider: Layer.Layer< · 62 unmodified lines
8283 // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) —
8384 // can be erased to `R = never`, as the neutral AccountProvider shape
8485 // requires. Provided per method below.
85− const ctx = yield* Effect.context<WorkOSClient | UserStoreService>();
86+ const ctx = yield* Effect.context<WorkOSClient | UserStoreService | AutumnService>();
8687
8788 // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly
8889 // as the old inline `requireSession` did.
@@ -91,16 +92,19 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines
9192 ? Effect.succeed(caller.session)
9293 : Effect.fail<AccountUnauthorized>(new AccountUnauthorized());
9394
94− // 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. Membership
96− // is re-checked live, so the header is a selector, not a trust boundary —
95+ // The org scope for an org-scoped request: the console URL's org, sent in
96+ // the selector header. FAIL CLOSED when it's missing — the session's own
97+ // org is a browser-global pinned to whichever org WorkOS last touched, so
98+ // falling back to it scopes a multi-org user's request to the WRONG org
99+ // (see workos-auth-provider.resolveSessionPrincipal). Membership is
100+ // re-checked live, so the header is a selector, not a trust boundary —
97101 // and two browser tabs on different orgs each send their own header, so
98102 // they stay independent (see organization.ts). Yields the session +
99103 // resolved org, or AccountNoOrganization.
100104 const requireOrganization = (headers: AccountHeaders) =>
101105 Effect.gen(function* () {
102106 const session = yield* requireSession();
103− const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
107+ const selector = headers[ORG_SELECTOR_HEADER];
104108 if (!selector) {
105109 return yield* new AccountNoOrganization();
106110 }
@@ -180,9 +184,15 @@ export const workosAccountProvider: Layer.Layer< · 73 unmodified lines
180184 me: (headers) =>
181185 Effect.gen(function* () {
182186 const session = yield* requireSession();
183− // Same selector precedence as requireOrganization: the URL's org
184− // (header) drives /account/me so the shell reflects the org the tab
185− // is viewing, not a session-global active org.
187+ // The ONE sanctioned fallback to the session org. /account/me is
188+ // identity, not tenant data: on a bare URL (no org slug yet, so no
189+ // header) it answers "which org should this browser land in", and
190+ // the session's pinned org is the only candidate. Every org-scoped
191+ // DATA read fails closed instead (requireOrganization above,
192+ // resolveSessionPrincipal) — serving tenant data from this fallback
193+ // is exactly the wrong-org connection-list bug (2026-07). With a
194+ // header (any slugged URL), the URL's org drives the answer so the
195+ // shell reflects the org the tab is viewing.
186196 const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
187197 const org = selector
188198 ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe(
@@ -237,6 +247,52 @@ export const workosAccountProvider: Layer.Layer< · 48 unmodified lines
237247 return { success: true };
238248 }),
239249
250+ // Org keys read every user in the tenant through the `/admin/*` plane, so
251+ // both paths are admin-gated — unlike personal keys, which any member may
252+ // mint for themselves.
253+ listOrgApiKeys: (headers) =>
254+ Effect.gen(function* () {
255+ const { session, org } = yield* requireOrganization(headers);
256+ yield* requireAdmin(session.accountId, org.id);
257+ const keys = yield* apiKeys
258+ .listOrgKeys({ organizationId: org.id })
259+ .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
260+ return { apiKeys: keys };
261+ }),
262+
263+ createOrgApiKey: (headers, name) =>
264+ Effect.gen(function* () {
265+ const { session, org } = yield* requireOrganization(headers);
266+ yield* requireAdmin(session.accountId, org.id);
267+ const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH);
268+ if (!trimmed) {
269+ return yield* new AccountError({ message: "API key name is required" });
270+ }
271+ return yield* apiKeys
272+ .createOrgKey({ organizationId: org.id, name: trimmed })
273+ .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
274+ }),
275+
276+ // Same admin gate as the mint. Note this deliberately does NOT reuse
277+ // `revokeApiKey`: that path resolves ownership against the caller's
278+ // personal keys, so an org key id is never in its owned set. Ownership
279+ // here is resolved against the ORG's keys inside `revokeOrgKey`, which is
280+ // what keeps a user-scoped (or foreign-org) key id from being destroyed
281+ // through the org route — it surfaces as a 404-shaped AccountError, not a
282+ // silent success and not a 500.
283+ revokeOrgApiKey: (headers, apiKeyId) =>
284+ Effect.gen(function* () {
285+ const { session, org } = yield* requireOrganization(headers);
286+ yield* requireAdmin(session.accountId, org.id);
287+ yield* apiKeys.revokeOrgKey({ organizationId: org.id, keyId: apiKeyId }).pipe(
288+ Effect.catchTag("ApiKeyManagementError", toAccountError),
289+ Effect.catchTag("OrgApiKeyNotFound", () =>
290+ Effect.fail(new AccountError({ message: "Organization API key not found" })),
291+ ),
292+ );
293+ return { success: true };
294+ }),
295+
240296 listMembers: (headers) =>
241297 Effect.gen(function* () {
242298 const { session, org } = yield* requireOrganization(headers);
@@ -309,6 +365,7 @@ export const workosAccountProvider: Layer.Layer< · 66 unmodified lines
309365 yield* workos
310366 .deleteOrgMembership(membershipId)
311367 .pipe(Effect.catchTag("WorkOSError", toAccountError));
368+ yield* forkReportMemberSeats(org.id).pipe(Effect.provideContext(ctx));
312369 return { success: true };
313370 }),
314371
@@ -331,7 +388,9 @@ export const workosAccountProvider: Layer.Layer< · 16 unmodified lines
331388 .updateOrganization(org.id, name)
332389 .pipe(Effect.catchTag("WorkOSError", toAccountError));
333390 yield* users
334− .use((s) => s.upsertOrganization({ id: updated.id, name: updated.name }))
391+ .use("upsertOrganization", (s) =>
392+ s.upsertOrganization({ id: updated.id, name: updated.name }),
393+ )
335394 .pipe(Effect.catchTag("UserStoreError", toAccountError));
336395 return { name: updated.name };
337396 }),
apps/cloud/src/admin/admin-users-api.ts+357−0
@@ -0,0 +1,357 @@
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+
28+import { env } from "cloudflare:workers";
29+import { HttpRouter } from "effect/unstable/http";
30+import { Context, Effect, Layer, Option } from "effect";
31+
32+import {
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";
52+import {
53+ AdminUserNotFound,
54+ AdminUsersError,
55+ AdminUsersForbidden,
56+ AdminUsersUnauthorized,
57+} from "@executor-js/api";
58+import type { Executor } from "@executor-js/sdk";
59+
60+import { ApiKeyService } from "../auth/api-keys";
61+import { UserStoreService } from "../auth/context";
62+import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider";
63+import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization";
64+import { WorkOSClient } from "../auth/workos";
65+import { DbService } from "../db/db";
66+import { 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+ */
75+const 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+ */
130+const 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+ */
153+const 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+ */
207+export 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. */
235+const 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+ */
250+const 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+ */
279+export 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`.
327+const 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. `local` keeps that promise: the
334+ // `longLived` context re-applied below carries the boot `CurrentMemoMap`,
335+ // so a shared build would hand overlapping requests one another's socket.
336+ const provider = yield* Effect.provide(
337+ AdminUsersProvider.asEffect(),
338+ workosAdminUsersProvider.pipe(Layer.provide(CloudExecutionSeamsLayer)),
339+ { local: true },
340+ );
341+ return yield* Effect.provideService(httpEffect, AdminUsersProvider, provider);
342+ }).pipe(Effect.provideContext(longLived));
343+ }),
344+);
345+
346+/**
347+ * The cloud admin-users route layer, mounted as an app extension under the same
348+ * `/api` prefix as the rest of the cloud router.
349+ */
350+export const makeCloudAdminUsersRoutes = (
351+ rsLive: Layer.Layer<DbService | UserStoreService>,
352+ options: Parameters<typeof makeAdminUsersApiLayer>[1] = {},
353+) =>
354+ makeAdminUsersApiLayer(
355+ AdminUsersProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer,
356+ options,
357+ );
apps/cloud/src/admin/admin-users-email.node.test.ts+127−0
@@ -0,0 +1,127 @@
1+import { env } from "cloudflare:workers";
2+import { expect, it } from "@effect/vitest";
3+import { Data, Effect, Layer } from "effect";
4+
5+import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
6+import { 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+
13+const ORG = "org_placeholder";
14+const OTHER_ORG = "org_other";
15+
16+class WorkOSUnavailable extends Data.TaggedError("WorkOSUnavailable")<{
17+ readonly userId: string;
18+}> {}
19+
20+const 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+
29+const 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+
72+const 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+
93+it.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+
101+it.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+
109+it.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+
119+it.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+
11+import { describe, expect, it } from "@effect/vitest";
12+import { Effect } from "effect";
13+
14+import type { EnterpriseManagedRolloutContext } from "@executor-js/sdk";
15+
16+import {
17+ ENTERPRISE_MANAGED_AUTH_FLAG_KEY,
18+ cloudEnterpriseManagedRollout,
19+ makePostHogEnterpriseManagedRollout,
20+} from "./ema-rollout";
21+
22+const HOST = "https://us.i.posthog.com";
23+const PROJECT_KEY = "phc_test_project_key";
24+
25+const CONTEXT: EnterpriseManagedRolloutContext = {
26+ userId: "user_01ABC",
27+ organizationId: "org_01XYZ",
28+ integration: "slack" as EnterpriseManagedRolloutContext["integration"],
29+};
30+
31+interface 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+
38+interface 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+ */
50+const 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+
77+const jsonResponse = (body: unknown, status = 200): Response =>
78+ new Response(JSON.stringify(body), {
79+ status,
80+ headers: { "content-type": "application/json" },
81+ });
82+
83+const 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+
94+const 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+
103+describe("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+
166+describe("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+
249+describe("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+
31+import { env } from "cloudflare:workers";
32+import { Effect, Schema } from "effect";
33+
34+import type {
35+ EnterpriseManagedRollout,
36+ EnterpriseManagedRolloutContext,
37+ EnterpriseManagedRolloutDecision,
38+ EnterpriseManagedRolloutEvent,
39+} from "@executor-js/sdk";
40+
41+import { POSTHOG_INGEST_HOST } from "../edge/passthrough";
42+
43+/** The flag that gates enterprise-managed authorization. */
44+export 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. */
48+const 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+ */
55+export const DEFAULT_FLAG_EVALUATION_TIMEOUT_MS = 1_000;
56+
57+/** Wire names of the rollout events, in the product's `object_verb` style. */
58+const 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. */
71+const FlagsResponse = Schema.Struct({
72+ flags: Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })),
73+});
74+
75+const 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+ */
91+const 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+
106+export 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+
118+const withheld = (
119+ reason: "disabled" | "evaluation-unavailable",
120+): EnterpriseManagedRolloutDecision => ({ kind: "withheld", reason });
121+
122+const ENABLED: EnterpriseManagedRolloutDecision = { kind: "enabled" };
123+
124+/** Group context for a connect whose organization the host named. */
125+const 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+ */
136+export 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. */
237+export 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+ */
247+export 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.request-scope.node.test.ts+309−3
@@ -21,11 +21,32 @@ · 20 unmodified lines
2121 // for `acquireRelease`.
2222 // ---------------------------------------------------------------------------
2323
24−import { describe, it, expect } from "@effect/vitest";
24+import { describe, it, expect, beforeEach } from "@effect/vitest";
2525 import { Context, Effect, Layer } from "effect";
26−import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http";
26+import {
27+ HttpRouter,
28+ HttpServer,
29+ HttpServerRequest,
30+ HttpServerResponse,
31+} from "effect/unstable/http";
2732
28−import { requestScopedMiddleware } from "@executor-js/api/server";
33+import {
34+ CodeExecutorProvider,
35+ DbProvider,
36+ EngineDecoratorNoop,
37+ ExecutorService,
38+ HostConfig,
39+ PluginsProvider,
40+ makeExecutionStackMiddleware,
41+ requestScopedMiddleware,
42+ textFailureStrategy,
43+ type CodeExecutor,
44+ type IdentityFailure,
45+ type Principal,
46+} from "@executor-js/api/server";
47+import { collectTables } from "@executor-js/sdk";
48+import { resetSubjectTouchCache } from "@executor-js/sdk/host-internal";
49+import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing";
2950
3051 import { RequestScopedServicesLive } from "./api/layers";
3152 import { makeApiLive } from "./api/router";
@@ -195,3 +216,288 @@ describe("makeApiLive (prod handler factory) request scoping", () => { · 163 unmodified lines
195216 expect(counts.releases).toBe(2);
196217 });
197218 });
219+
220+// ---------------------------------------------------------------------------
221+// `ExecutionStackMiddleware` request scoping.
222+//
223+// `requestScopedMiddleware` gives every request a fresh `MemoMap`, so the
224+// per-request DB handle is genuinely per request (the suites above). But
225+// `makeExecutionStackMiddleware` captures the BOOT fiber's context once, at
226+// layer-construction time, and re-applies it to every request with
227+// `Effect.provideContext`. That captured context carries Effect's
228+// `CurrentMemoMap`, and `provideContext`'s merge lets the BOOT map overwrite
229+// the fresh per-request one. The per-request `Effect.provide(stackLayer)` then
230+// memoizes its build in the boot map, which every in-flight request fiber in
231+// the isolate shares.
232+//
233+// Sequential requests still rebuild (the memo entry is refcounted by observer
234+// and drops to zero when the request scope closes), so only OVERLAPPING
235+// requests are affected: the second request reuses the first request's stack
236+// build, and therefore the first request's database handle. On Cloudflare
237+// Workers that is a cross-request I/O violation; and when the owning request
238+// finishes, its scope finalizer closes the connection out from under the
239+// borrower, whose next query fails.
240+//
241+// These tests stand the real middleware up over per-request in-memory
242+// databases and pin all three symptoms.
243+// ---------------------------------------------------------------------------
244+
245+interface TestDbHandle {
246+ readonly id: number;
247+ readonly db: SqliteTestFumaDb;
248+ closed: boolean;
249+}
250+
251+class TestDb extends Context.Service<TestDb, TestDbHandle>()("test/TestDb") {}
252+
253+interface StackProbeState {
254+ /**
255+ * Databases created up front, one per request the case will make. Handing a
256+ * ready database to the request-scoped layer keeps its acquire synchronous,
257+ * so the first request to arrive is reliably the one that owns handle 1 —
258+ * which is what makes the borrow direction deterministic below.
259+ */
260+ readonly pool: readonly SqliteTestFumaDb[];
261+ /** Handles acquired by `requestScopedMiddleware` — one per request. */
262+ readonly handles: TestDbHandle[];
263+ /** How many times the stack layer's `DbProvider` was actually built. */
264+ builds: number;
265+ /** The handle id each stack build read. One entry per build. */
266+ readonly builtWithHandleId: number[];
267+ /**
268+ * What each request could see about connection ownership at the moment it
269+ * performed its read-after-await, keyed by that request's await duration.
270+ */
271+ readonly lateReadOwnership: Map<
272+ number,
273+ {
274+ readonly ownHandleId: number;
275+ readonly ownClosed: boolean;
276+ readonly otherClosed: readonly boolean[];
277+ }
278+ >;
279+}
280+
281+const noopCodeExecutor: CodeExecutor = {
282+ execute: () => Effect.succeed({ result: undefined, logs: [] }),
283+};
284+
285+const stackProbePrincipal: Principal = {
286+ kind: "member",
287+ accountId: "user_request_scope",
288+ organizationId: "org_request_scope",
289+ organizationName: "Request Scope Test Org",
290+ email: "request-scope@test.local",
291+ name: "Request Scope",
292+ avatarUrl: null,
293+ roles: ["admin"],
294+};
295+
296+/**
297+ * The per-request DB handle, in the slot cloud's postgres.js socket occupies:
298+ * acquired when the request fiber's scope opens, closed when it closes.
299+ */
300+const makeTestDbLive = (state: StackProbeState) =>
301+ Layer.effect(TestDb)(
302+ Effect.acquireRelease(
303+ Effect.sync(() => {
304+ const index = state.handles.length;
305+ const handle: TestDbHandle = {
306+ id: index + 1,
307+ db: state.pool[index] as SqliteTestFumaDb,
308+ closed: false,
309+ };
310+ state.handles.push(handle);
311+ return handle;
312+ }),
313+ (handle) =>
314+ // The request's own scope ends its connection, exactly as the host
315+ // closes the postgres socket when the request finishes.
316+ Effect.promise(async () => {
317+ handle.closed = true;
318+ await handle.db.close();
319+ }),
320+ ),
321+ );
322+
323+/**
324+ * The host's `stackLayer` seam, reduced to the parts that matter: a
325+ * `DbProvider` derived from the REQUEST-scoped handle (exactly cloud's
326+ * arrangement) plus inert stand-ins for the rest. The 5ms sleep widens the
327+ * window in which two request fibers overlap on the layer build, which is what
328+ * an isolate serving concurrent requests does on its own.
329+ */
330+const makeTestStackLayer = (state: StackProbeState) =>
331+ Layer.mergeAll(
332+ Layer.effect(DbProvider)(
333+ Effect.gen(function* () {
334+ const handle = yield* TestDb;
335+ yield* Effect.sleep("5 millis");
336+ state.builds += 1;
337+ state.builtWithHandleId.push(handle.id);
338+ return handle.db;
339+ }),
340+ ),
341+ Layer.succeed(PluginsProvider)({ plugins: () => [] }),
342+ Layer.succeed(HostConfig)({ allowLocalNetwork: false, oauthCallbackPath: "/oauth/callback" }),
343+ Layer.succeed(CodeExecutorProvider)(noopCodeExecutor),
344+ EngineDecoratorNoop,
345+ );
346+
347+/**
348+ * `GET /probe?delay=N`: one cheap read, a pause standing in for a slow
349+ * outbound call, then a second read. That is the production shape — an early
350+ * query succeeds, the request awaits something slow, and by the time it queries
351+ * again the connection it borrowed has been closed by its real owner.
352+ */
353+const makeStackProbeRoutes = (state: StackProbeState) =>
354+ HttpRouter.add(
355+ "GET",
356+ "/probe",
357+ Effect.gen(function* () {
358+ const request = yield* HttpServerRequest.HttpServerRequest;
359+ const delayMs = Number(
360+ new URL(request.url, "http://test.local").searchParams.get("delay") ?? "0",
361+ );
362+ const executor = yield* ExecutorService;
363+ const read = () =>
364+ executor.connections.list().pipe(
365+ Effect.as("ok" as const),
366+ Effect.catchCause(() => Effect.succeed("failed" as const)),
367+ );
368+
369+ // The handle this request acquired for itself, whatever order the
370+ // request fibers happened to acquire in.
371+ const own = yield* TestDb;
372+
373+ const early = yield* read();
374+ yield* Effect.sleep(`${delayMs} millis`);
375+ state.lateReadOwnership.set(delayMs, {
376+ ownHandleId: own.id,
377+ ownClosed: own.closed,
378+ otherClosed: state.handles.filter((handle) => handle.id !== own.id).map((h) => h.closed),
379+ });
380+ const late = yield* read();
381+ return HttpServerResponse.jsonUnsafe({ early, late });
382+ }),
383+ );
384+
385+const makeStackProbeHandler = (state: StackProbeState) => {
386+ const ExecutionStackMiddleware = makeExecutionStackMiddleware<
387+ readonly [],
388+ IdentityFailure,
389+ never,
390+ TestDb,
391+ never,
392+ never
393+ >({
394+ plugins: [],
395+ authenticate: () => Effect.succeed(stackProbePrincipal),
396+ strategy: textFailureStrategy,
397+ stackLayer: makeTestStackLayer(state),
398+ });
399+
400+ const App = makeStackProbeRoutes(state).pipe(
401+ // Exactly cloud's composition: the stack middleware with the per-request
402+ // DB layer combined in, so `DbProvider` is built over a handle owned by the
403+ // request fiber's scope.
404+ Layer.provide(
405+ ExecutionStackMiddleware.combine(requestScopedMiddleware(makeTestDbLive(state))).layer,
406+ ),
407+ Layer.provideMerge(HttpServer.layerServices),
408+ );
409+ return HttpRouter.toWebHandler(App, { disableLogger: true }).handler;
410+};
411+
412+const makeStackProbeState = async (requestCount: number): Promise<StackProbeState> => {
413+ const pool: SqliteTestFumaDb[] = [];
414+ for (let index = 0; index < requestCount; index += 1) {
415+ pool.push(await createSqliteTestFumaDb({ tables: collectTables() }));
416+ }
417+ return {
418+ pool,
419+ handles: [],
420+ builds: 0,
421+ builtWithHandleId: [],
422+ lateReadOwnership: new Map(),
423+ };
424+};
425+
426+describe("ExecutionStackMiddleware request scoping", () => {
427+ beforeEach(() => {
428+ // `makeExecutionStack` touches the subject once per build, behind a
429+ // process-local throttle. Reset it so every case does the same work.
430+ resetSubjectTouchCache();
431+ });
432+
433+ it("builds the execution stack once per concurrent request", async () => {
434+ const state = await makeStackProbeState(3);
435+ const handler = makeStackProbeHandler(state);
436+
437+ const responses = await Promise.all([
438+ handler(new Request("http://test.local/probe"), Context.empty()),
439+ handler(new Request("http://test.local/probe"), Context.empty()),
440+ handler(new Request("http://test.local/probe"), Context.empty()),
441+ ]);
442+
443+ expect(responses.map((response) => response.status)).toEqual([200, 200, 200]);
444+ // Three requests, three request-scoped handles, three stack builds. A lower
445+ // build count means some request served its work on a stack built for a
446+ // different request.
447+ expect(state.handles.length).toBe(3);
448+ expect(state.builds).toBe(3);
449+ });
450+
451+ it("never builds a request's stack over another request's database handle", async () => {
452+ const state = await makeStackProbeState(3);
453+ const handler = makeStackProbeHandler(state);
454+
455+ await Promise.all([
456+ handler(new Request("http://test.local/probe"), Context.empty()),
457+ handler(new Request("http://test.local/probe"), Context.empty()),
458+ handler(new Request("http://test.local/probe"), Context.empty()),
459+ ]);
460+
461+ // Every acquired handle must be the one its own request's stack was built
462+ // over: three builds reading three distinct handles, covering all three.
463+ const acquired = state.handles.map((handle) => handle.id);
464+ expect(acquired).toEqual([1, 2, 3]);
465+ expect([...state.builtWithHandleId].sort()).toEqual(acquired);
466+ expect(new Set(state.builtWithHandleId).size).toBe(state.handles.length);
467+ });
468+
469+ it("keeps a slow request's connection usable after a faster overlapping request ends", async () => {
470+ const state = await makeStackProbeState(2);
471+ const handler = makeStackProbeHandler(state);
472+
473+ // The fast request is served first, so it acquires handle 1. It finishes
474+ // while the slow request is still awaiting, which is the window in which a
475+ // borrowed connection gets pulled out from under its borrower.
476+ const [fast, slow] = await Promise.all([
477+ handler(new Request("http://test.local/probe?delay=0"), Context.empty()),
478+ handler(new Request("http://test.local/probe?delay=80"), Context.empty()),
479+ ]);
480+
481+ // Read, slow await, read again — both reads succeed for both requests.
482+ expect(await fast.json()).toEqual({ early: "ok", late: "ok" });
483+ expect(await slow.json()).toEqual({ early: "ok", late: "ok" });
484+
485+ // Ownership at the read-after-await is the real assertion. The fast
486+ // request has ended and released its connection by then, so the slow
487+ // request is reading in exactly the window that breaks in production —
488+ // and its OWN connection is still open, because that is the one its stack
489+ // was built over.
490+ //
491+ // Sharing one build inverts this: the slow request's own connection is
492+ // released while it is still running, and it keeps reading on the fast
493+ // request's instead.
494+ const slowView = state.lateReadOwnership.get(80);
495+ expect(slowView?.ownClosed).toBe(false);
496+ expect(slowView?.otherClosed).toEqual([true]);
497+
498+ // Each request built its stack over its own connection.
499+ expect(state.builds).toBe(2);
500+ expect([...state.builtWithHandleId].sort()).toEqual([1, 2]);
501+ expect(state.lateReadOwnership.get(0)?.ownHandleId).not.toBe(slowView?.ownHandleId);
502+ });
503+});
apps/cloud/src/api/protected-api-key-auth.node.test.ts+7−1
@@ -13,6 +13,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 12 unmodified lines
1313 Effect.succeed(
1414 value === "valid_user_key"
1515 ? {
16+ scope: "user" as const,
1617 accountId: "user_123",
1718 organizationId: "org_123",
1819 keyId: "api_key_123",
@@ -22,6 +23,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 3 unmodified lines
2223 listUserKeys: () => Effect.succeed([]),
2324 createUserKey: () => Effect.die("protected API auth test does not create API keys"),
2425 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"),
2529 });
2630
2731 const stubWorkOS = Layer.succeed(
@@ -43,7 +47,7 @@ const stubWorkOS = Layer.succeed( · 15 unmodified lines
4347 );
4448
4549 const stubUsers = Layer.succeed(UserStoreService)({
46− use: (fn) =>
50+ use: (_op, fn) =>
4751 Effect.promise(() =>
4852 fn({
4953 ensureAccount: async (id: string) => ({ id, createdAt }),
@@ -65,6 +69,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 15 unmodified lines
6569 slug,
6670 createdAt,
6771 }),
72+ deleteOrganizationCascade: async () => {},
6873 }),
6974 ),
7075 });
@@ -84,6 +89,7 @@ describe("protected API key auth", () => { · 13 unmodified lines
8489 );
8590
8691 expect(identity).toEqual({
92+ kind: "member",
8793 accountId: "user_123",
8894 organizationId: "org_123",
8995 organizationName: "Org org_123",
apps/cloud/src/api/protected-jwt-auth.node.test.ts+18−4
@@ -40,6 +40,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 39 unmodified lines
4040 listUserKeys: () => Effect.succeed([]),
4141 createUserKey: () => Effect.die("JWT auth test does not create API keys"),
4242 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"),
4346 });
4447
4548 const stubWorkOS = Layer.succeed(
@@ -61,7 +64,7 @@ const stubWorkOS = Layer.succeed( · 15 unmodified lines
6164 );
6265
6366 const stubUsers = Layer.succeed(UserStoreService)({
64− use: (fn) =>
67+ use: (_op, fn) =>
6568 Effect.promise(() =>
6669 fn({
6770 ensureAccount: async (id: string) => ({ id, createdAt }),
@@ -83,6 +86,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 15 unmodified lines
8386 slug,
8487 createdAt,
8588 }),
89+ deleteOrganizationCascade: async () => {},
8690 }),
8791 ),
8892 });
@@ -106,6 +110,7 @@ describe("protected JWT (device-login) auth", () => { · 17 unmodified lines
106110 const identity = yield* run(request(token), config);
107111
108112 expect(identity).toEqual({
113+ kind: "member",
109114 accountId: "user_123",
110115 organizationId: "org_123",
111116 organizationName: "Org org_123",
@@ -143,7 +148,10 @@ describe("protected JWT (device-login) auth", () => { · 31 unmodified lines
143148
144149 const error = yield* Effect.flip(run(request(token), config));
145150
146− expect(error).toMatchObject({ _tag: "Unauthorized", code: "invalid_access_token" });
151+ expect(error).toMatchObject({
152+ _tag: "Unauthorized",
153+ code: "invalid_access_token",
154+ });
147155 }),
148156 );
149157
@@ -154,7 +162,10 @@ describe("protected JWT (device-login) auth", () => { · 4 unmodified lines
154162
155163 const error = yield* Effect.flip(run(request(token), config));
156164
157− expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" });
165+ expect(error).toMatchObject({
166+ _tag: "NoOrganization",
167+ code: "no_organization",
168+ });
158169 }),
159170 );
160171
@@ -166,7 +177,10 @@ describe("protected JWT (device-login) auth", () => { · 5 unmodified lines
166177
167178 const error = yield* Effect.flip(run(request(token), config));
168179
169− expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" });
180+ expect(error).toMatchObject({
181+ _tag: "NoOrganization",
182+ code: "no_organization",
183+ });
170184 }),
171185 );
172186 });
apps/cloud/src/api/protected.test.ts+2−0
@@ -20,6 +20,8 @@ const makeBaseEngine = (): ExecutionEngine => · 19 unmodified lines
2020 pausedExecutionCount: () => Effect.succeed(0),
2121 hasPausedExecutions: () => Effect.succeed(false),
2222 getDescription: Effect.succeed("desc"),
23+ // The fake forks nothing, so there is no sandbox fiber to end.
24+ shutdown: Effect.void,
2325 }) as ExecutionEngine;
2426
2527 describe("withExecutionUsageTracking", () => {
apps/cloud/src/app-paths.test.ts+37−1
@@ -1,6 +1,6 @@
11 import { describe, expect, it } from "@effect/vitest";
22
3−import { isAppOwnedPath } from "./app-paths";
3+import { isAppOwnedPath, servedByAppPlane } from "./app-paths";
44
55 // Guards the start.ts dispatch decision: every surface the unified app handler
66 // serves must be classified app-owned (forwarded to `app.handler`), and Start's
@@ -62,3 +62,39 @@ describe("isAppOwnedPath", () => { · 55 unmodified lines
6262 });
6363 }
6464 });
65+
66+describe("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
1717
1818 export const isAppOwnedPath = (pathname: string) =>
1919 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+
43+export const isStartOwnedApiPath = (pathname: string, method: string): boolean =>
44+ (pathname === "/api/sentry-tunnel" && method === "POST") || pathname === "/api/oauth/callback";
45+
46+export 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
1313 import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider";
1414 import { DbService } from "./db/db";
1515 import { cloudMcpAuth } from "./mcp";
16−import { McpSessionDOSqlite } from "./mcp/session-durable-object";
1716 import { ErrorCaptureLive } from "./observability";
1817 import { AutumnService } from "./extensions/billing/service";
1918 import {
@@ -31,7 +30,7 @@ import { WorkerTelemetryLive } from "./observability/telemetry"; · 11 unmodified lines
3130 // The whole scenario in 60 seconds: WorkOS identity (api-key Bearer OR sealed-
3231 // session cookie, api-key wins) over a per-request Hyperdrive→Postgres socket,
3332 // the Cloudflare dynamic-worker code substrate, MCP served by a Durable-Object
34−// session store (the DO surfaced via `config.mcpExport`), console+Sentry error
33+// session store (the DO class is exported straight from server.ts), console+Sentry error
3534 // capture — and Autumn BILLING entering ONLY as extensions: the engine
3635 // metering decorator, the account seat-gate, the `/api/billing/*` proxy route,
3736 // and the createOrganization free-limit gate. `diff` against
@@ -66,7 +65,7 @@ const controlPlane = Layer.mergeAll(CoreSharedServices, apiKeyService); · 28 unmodified lines
6665 // satisfied by `boot`, `DbService` per request via `requestScoped`).
6766 const cloudDb: Layer.Layer<DbProvider, never, DbService | AutumnService> = CloudDbProvider;
6867
69−const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({
68+const { appLayer, toWebHandler } = ExecutorApp.make({
7069 plugins: cloudPlugins,
7170 providers: {
7271 // Identity: the NEUTRAL `IdentityProvider`. WorkOS api-key Bearer BEATS
@@ -112,7 +111,6 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ · 39 unmodified lines
112111 failure: cloudIdentityFailureStrategy,
113112 // The MCP session Durable Object class — a top-level Workers export a Layer
114113 // can't return; surfaced so `server.ts` can re-export it.
115− mcpExport: McpSessionDOSqlite,
116114 },
117115 // The long-lived (boot-scoped) context provideMerge'd under everything: the
118116 // WorkOS control plane (the raw `WorkOSClient` + `ApiKeyService` the per-request
@@ -135,10 +133,7 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ · 16 unmodified lines
135133 requestScoped: RequestScopedServicesLive,
136134 });
137135
138−export { McpSessionDOSqlite };
139−
140136 export const CloudAppLayer = appLayer;
141−export const cloudMcpExport = mcpExport;
142137
143138 // The unified cloud web handler: serves /api/* (incl. /api/billing/*, /api/docs),
144139 // /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
4242 });
4343
4444 expect(principal).toEqual({
45+ scope: "user",
4546 accountId: "user_123",
4647 organizationId: "org_123",
4748 keyId: "api_key_123",
@@ -66,15 +67,9 @@ describe("ApiKeyService.WorkOS", () => { · 18 unmodified lines
6667 }),
6768 );
6869
69− it.effect("rejects missing, organization-owned, and org-less keys", () =>
70+ it.effect("rejects missing and org-less keys", () =>
7071 Effect.gen(function* () {
7172 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− });
7873 const orgLess = yield* validate({
7974 apiKey: {
8075 id: "api_key_no_org",
@@ -83,11 +78,31 @@ describe("ApiKeyService.WorkOS", () => { · 2 unmodified lines
8378 });
8479
8580 expect(missing).toBeNull();
86− expect(orgOwned).toBeNull();
8781 expect(orgLess).toBeNull();
8882 }),
8983 );
9084
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+
91106 it.effect("lists and creates user-owned keys", () =>
92107 Effect.gen(function* () {
93108 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
33 import { ApiKeyManagementError } from "./errors";
44 import { WorkOSClient } from "./workos";
55
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+ */
14+export type ApiKeyScope = "user" | "org";
15+
616 /** The owner an api key resolves to — NOT a full {@link Principal} (no email /
717 * name / roles), so it carries an honest, distinct name. */
818 export 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;
1024 readonly organizationId: string;
1125 readonly keyId: string;
1226 };
@@ -28,6 +42,21 @@ export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationErr · 15 unmodified lines
2842 readonly cause: unknown;
2943 }> {}
3044
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+ */
51+export 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.
3160 const UserApiKeyOwner = Schema.Struct({
3261 type: Schema.Literal("user"),
3362 id: Schema.String,
@@ -35,6 +64,13 @@ const UserApiKeyOwner = Schema.Struct({ · 1 unmodified line
3564 organization_id: Schema.optional(Schema.String),
3665 });
3766
67+const OrgApiKeyOwner = Schema.Struct({
68+ type: Schema.Literal("organization"),
69+ id: Schema.String,
70+});
71+
72+const WorkOSApiKeyOwner = Schema.Union([UserApiKeyOwner, OrgApiKeyOwner]);
73+
3874 const ApiKey = Schema.Struct({
3975 id: Schema.String,
4076 owner: UserApiKeyOwner,
@@ -49,8 +85,17 @@ const ApiKey = Schema.Struct({ · 8 unmodified lines
4985 last_used_at: Schema.optional(Schema.NullOr(Schema.String)),
5086 });
5187
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.
92+const ValidatedApiKey = Schema.Struct({
93+ id: Schema.String,
94+ owner: WorkOSApiKeyOwner,
95+});
96+
5297 const ValidateApiKeyResponse = Schema.Struct({
53− apiKey: Schema.NullOr(ApiKey),
98+ apiKey: Schema.NullOr(ValidatedApiKey),
5499 });
55100
56101 const RawCreatedApiKey = Schema.Struct({
@@ -72,6 +117,45 @@ const ListApiKeysResponse = Schema.Struct({ · 15 unmodified lines
72117 data: Schema.Array(ApiKey),
73118 });
74119
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.
125+const 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+
139+const 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.
149+const ListOrgApiKeysResponse = Schema.Struct({
150+ data: Schema.Array(Schema.Unknown),
151+});
152+
153+const CreateOrgApiKeyResponse = Schema.Union([
154+ RawCreatedOrgApiKey,
155+ Schema.Struct({ apiKey: RawCreatedOrgApiKey }),
156+ Schema.Struct({ api_key: RawCreatedOrgApiKey }),
157+]);
158+
75159 const CreateApiKeyResponse = Schema.Union([
76160 RawCreatedApiKey,
77161 Schema.Struct({ apiKey: RawCreatedApiKey }),
@@ -81,20 +165,35 @@ const CreateApiKeyResponse = Schema.Union([ · 3 unmodified lines
81165 const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse);
82166 const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse);
83167 const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse);
168+const decodeListOrgApiKeysResponse = Schema.decodeUnknownOption(ListOrgApiKeysResponse);
169+const decodeOrgApiKey = Schema.decodeUnknownOption(OrgApiKey);
170+const decodeCreateOrgApiKeyResponse = Schema.decodeUnknownOption(CreateOrgApiKeyResponse);
171+
172+const 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+};
84192
85193 const ownerFromResponse = (value: unknown): ApiKeyOwner | null =>
86194 Option.match(decodeValidateApiKeyResponse(value), {
87195 onNone: () => null,
88− onSome: ({ apiKey }) => {
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− },
196+ onSome: ({ apiKey }) => (apiKey ? ownerFromApiKey(apiKey) : null),
98197 });
99198
100199 const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => {
@@ -120,6 +219,53 @@ const listFromResponse = (value: unknown): readonly ApiKeySummary[] => · 19 unmodified lines
120219 }),
121220 });
122221
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.
226+const 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+
241+const 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+
258+const 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+
123269 const createdFromResponse = (value: unknown): CreatedApiKey | null =>
124270 Option.match(decodeCreateApiKeyResponse(value), {
125271 onNone: () => null,
@@ -147,6 +293,28 @@ export class ApiKeyService extends Context.Service< · 21 unmodified lines
147293 readonly revokeUserKey: (input: {
148294 readonly keyId: string;
149295 }) => 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>;
150318 }
151319 >()("@executor-js/cloud/ApiKeyService") {
152320 static WorkOS = Layer.effect(this)(
@@ -177,6 +345,40 @@ export class ApiKeyService extends Context.Service< · 24 unmodified lines
177345 workos
178346 .deleteApiKey(keyId)
179347 .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+ }),
180382 };
181383 }),
182384 );
apps/cloud/src/auth/api.ts+40−2
@@ -44,6 +44,16 @@ const CreateOrganizationResponse = Schema.Struct({ · 43 unmodified lines
4444 slug: Schema.String,
4545 });
4646
47+// Deleting an org requires re-typing its name (the label the UI shows) as a
48+// deliberate, non-automatable confirmation. Verified server-side too.
49+const DeleteOrganizationBody = Schema.Struct({
50+ confirmName: Schema.String,
51+});
52+
53+const DeleteOrganizationResponse = Schema.Struct({
54+ success: Schema.Boolean,
55+});
56+
4757 // CLI device-login discovery (`executor login`). Tells the CLI where to run
4858 // the OAuth 2.0 Device Authorization Grant (RFC 8628) and which public client
4959 // to use. The CLI hits these provider endpoints directly, gets a WorkOS access
@@ -146,6 +156,15 @@ export class McpSessionForbiddenError extends Schema.TaggedErrorClass<McpSession · 96 unmodified lines
146156 { httpApiStatus: 403 },
147157 ) {}
148158
159+// Refused org deletion: the caller is not an admin of the org, or the typed
160+// confirmation did not match. Deliberately one error for both so it never
161+// reveals which check failed.
162+export class OrganizationDeletionForbidden extends Schema.TaggedErrorClass<OrganizationDeletionForbidden>()(
163+ "OrganizationDeletionForbidden",
164+ {},
165+ { httpApiStatus: 403 },
166+) {}
167+
149168 export const AUTH_PATHS = {
150169 login: "/api/auth/login",
151170 logout: "/api/auth/logout",
@@ -162,13 +181,26 @@ const McpApprovalErrors = [ · 10 unmodified lines
162181 /** Public auth endpoints — no authentication required */
163182 export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")
164183 .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"))
165193 .add(
166194 HttpApiEndpoint.get("callback", "/auth/callback", {
167195 query: AuthCallbackSearch,
168196 error: AuthErrors,
169197 }),
170198 )
171− .add(HttpApiEndpoint.get("cliLogin", "/auth/cli-login", { success: CliLoginResponse })) {}
199+ .add(
200+ HttpApiEndpoint.get("cliLogin", "/auth/cli-login", {
201+ success: CliLoginResponse,
202+ }),
203+ ) {}
172204
173205 /** Session auth endpoints — require a logged-in user, may not have an org */
174206 export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
@@ -178,7 +210,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 3 unmodified lines
178210 error: AuthErrors,
179211 }),
180212 )
181− .add(HttpApiEndpoint.post("logout", "/auth/logout"))
182213 .add(
183214 HttpApiEndpoint.get("organizations", "/auth/organizations", {
184215 success: AuthOrganizationsResponse,
@@ -192,6 +223,13 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 7 unmodified lines
192223 error: AuthErrors,
193224 }),
194225 )
226+ .add(
227+ HttpApiEndpoint.post("deleteOrganization", "/auth/delete-organization", {
228+ payload: DeleteOrganizationBody,
229+ success: DeleteOrganizationResponse,
230+ error: [...AuthErrors, NoOrganization, OrganizationDeletionForbidden],
231+ }),
232+ )
195233 .add(
196234 HttpApiEndpoint.get("pendingInvitations", "/auth/pending-invitations", {
197235 success: PendingInvitationsResponse,
apps/cloud/src/auth/context.ts+9−4
@@ -1,7 +1,7 @@
11 import { Context, Effect, Layer } from "effect";
22 import { makeUserStore } from "../auth/user-store";
33 import { DbService } from "../db/db";
4−import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors";
4+import { tryPromiseService, userStoreErrorFromFailure, withServiceLogging } from "./errors";
55
66 // ---------------------------------------------------------------------------
77 // UserStoreService — wraps the Drizzle-backed user store with Effect
@@ -9,11 +9,16 @@ import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors" · 1 unmodified line
99
1010 type RawStore = ReturnType<typeof makeUserStore>;
1111
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.
1217 const makeService = (store: RawStore) => ({
13− use: <A>(fn: (s: RawStore) => Promise<A>) =>
18+ use: <A>(op: string, fn: (s: RawStore) => Promise<A>) =>
1419 withServiceLogging(
15− "user_store",
16− () => new UserStoreError(),
20+ `user_store.${op}`,
21+ (failure) => userStoreErrorFromFailure(op, failure),
1722 tryPromiseService(() => fn(store)),
1823 ),
1924 });
apps/cloud/src/auth/doc-gate.tsrenamed from apps/cloud/src/auth/ssr-gate.ts+71−23
@@ -1,19 +1,23 @@
11 // ---------------------------------------------------------------------------
2−// SSR auth gate — the server-side session check for DOCUMENT requests.
2+// Document auth gate — the server-side session check for DOCUMENT requests.
33 //
44 // The sealed `wos-session` cookie is verified right here in the worker
55 // (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 KNOWS
7−// who it's serving:
6+// except token refresh), so by the time the SPA shell is served the server
7+// KNOWS who it's serving:
88 //
99 // - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists
1010 // - org-less → 302 /create-org (onboarding owns those sessions)
11−// - signed in → the document is served WITH the verified identity: the
12−// auth-hint travels to the SSR render via request-middleware context (the
13−// root loader picks it up), and is minted as a cookie when the browser
14−// doesn't hold a current one — so the very first paint is the real app
15−// shell, never a skeleton. The hint is display-only; /account/me remains
16−// the authority and the client keeps it fresh from then on.
11+// - signed in → the prerendered shell is served, and the auth-hint cookie is
12+// minted when the browser doesn't hold a current one — the client seeds its
13+// auth state from that cookie right after mount (there is no per-request
14+// render to dehydrate it into). The hint is display-only; /account/me
15+// remains 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.
1721 //
1822 // Scope: GET/HEAD requests that are document navigations (sec-fetch-dest /
1923 // accept), excluding app-owned paths (/api, /mcp — they answer for themselves
@@ -23,6 +27,7 @@ · 3 unmodified lines
2327 import { createMiddleware } from "@tanstack/react-start";
2428 import { Effect, Exit, Layer, ManagedRuntime } from "effect";
2529
30+import { isValidOrgSlug } from "@executor-js/api";
2631 import {
2732 AUTH_HINT_COOKIE,
2833 AUTH_HINT_MAX_AGE_SECONDS,
@@ -35,8 +40,9 @@ import { isAppOwnedPath } from "../app-paths"; · 6 unmodified lines
3540 import { makeDbLayer } from "../db/db";
3641 import { makeUserStoreLayer, UserStoreService } from "./context";
3742 import { parseCookie } from "./cookies";
43+import { LAST_ORG_COOKIE } from "./last-org-cookie";
3844 import { sealedSessionDisplayName } from "./middleware";
39−import { browserOriginFromRequest } from "./request-origin";
45+import { authorizeOrganizationSelector } from "./organization";
4046 import { loginPath, safeReturnTo } from "./return-to";
4147 import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths";
4248 import { WorkOSClient } from "./workos";
@@ -150,7 +156,7 @@ const organizationDisplay = async ( · 107 unmodified lines
150156 ): Promise<{ name: string; slug: string }> => {
151157 const exit = await getRuntime().runPromiseExit(
152158 Effect.flatMap(UserStoreService.asEffect(), (users) =>
153− users.use((store) => store.getOrganization(organizationId)),
159+ users.use("getOrganization", (store) => store.getOrganization(organizationId)),
154160 ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))),
155161 );
156162 return Exit.isSuccess(exit)
@@ -158,6 +164,23 @@ const organizationDisplay = async ( · 1 unmodified line
158164 : { name: "", slug: "" };
159165 };
160166
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.
172+const 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+
161184 const hintSetCookie = (hint: AuthHint) =>
162185 `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`;
163186
@@ -226,19 +249,44 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( · 62 unmodified lines
226249 return redirect("/create-org", { refreshedSession: session.refreshedSession });
227250 }
228251
229− // Serve the document WITH the verified identity: the hint rides to the
230− // SSR render through middleware context (the root loader reads it), so
231− // the server paints the real authenticated shell — no loading state, no
232− // skeleton. The request origin rides along too: it's what the connect
233− // card's MCP URL is built from, and the server knows it (the SPA only
234− // learns `window.location.origin` after mount), so passing it here lets
235− // SSR render the real `https://…/<org>/mcp` instead of the client-side
236− // `http://127.0.0.1:4000` default — which would otherwise flash until
237− // hydration corrected it. Set-cookie writes ride on the rendered response.
252+ // A BARE console path (no org slug in the URL) canonicalizes onto the org
253+ // this browser last worked in (the last-org cookie), not the org pinned in
254+ // the session at login time — a multi-org user re-entering at `/` lands
255+ // where they left off. Slugged URLs never enter here (the URL names its
256+ // own scope), so this costs nothing on the steady state; the not-found
257+ // contract is untouched because an unknown-but-valid slug in the URL reads
258+ // as slugged, not bare. When the cookie matches the session's own org (the
259+ // overwhelmingly common single-org case) the client-side OrgSlugGate
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.
238288 const { hint, mint } = await resolveAuthHint(session, cookieHeader);
239− const result = await next({
240− context: { authHint: hint, origin: browserOriginFromRequest(request) },
241− });
289+ const result = await next();
242290 if (!mint && !session.refreshedSession) return result;
243291
244292 const response = new Response(result.response.body, result.response);
apps/cloud/src/auth/errors.test.ts+68−0
@@ -0,0 +1,68 @@
1+// The WorkOS failure-classification boundary: the service adapter must thread
2+// the SDK exception's HTTP status onto the public `WorkOSError`, and the
3+// definitive-denial predicate must split 401/403/404 (WorkOS answered "no" —
4+// fail closed) from 429/5xx/no-status (transient — retryable). This split is
5+// what keeps a WorkOS blip from condemning MCP sessions AND a revoked key from
6+// being mistaken for a blip.
7+import { describe, expect, it } from "@effect/vitest";
8+import {
9+ GenericServerException,
10+ NotFoundException,
11+ RateLimitExceededException,
12+ UnauthorizedException,
13+} from "@workos-inc/node/worker";
14+
15+import {
16+ ServiceAdapterError,
17+ isDefinitiveWorkOSDenial,
18+ isDefinitiveWorkOSDenialStatus,
19+ workosErrorFromFailure,
20+} from "./errors";
21+
22+describe("workosErrorFromFailure", () => {
23+ it("reads the status off the real SDK exceptions through the adapter wrapper", () => {
24+ const cases: ReadonlyArray<readonly [unknown, number]> = [
25+ [new UnauthorizedException("req_1"), 401],
26+ [
27+ new NotFoundException({ code: "not_found", message: "gone", path: "/x", requestID: "r" }),
28+ 404,
29+ ],
30+ [new RateLimitExceededException("slow down", "req_2", 1), 429],
31+ [new GenericServerException(503, "upstream", {}, "req_3"), 503],
32+ ];
33+ for (const [sdkError, status] of cases) {
34+ const wrapped = workosErrorFromFailure(new ServiceAdapterError({ cause: sdkError }));
35+ expect(wrapped.status, `status for ${String(sdkError)}`).toBe(status);
36+ }
37+ });
38+
39+ it("leaves status unset for non-HTTP failures (network error, timeout)", () => {
40+ // The shape fetch/undici raise on a network failure: an error value with a
41+ // message and no HTTP status (statusless by construction, unlike the SDK's
42+ // typed HTTP exceptions).
43+ const networkFailure: unknown = { name: "TypeError", message: "fetch failed" };
44+ const wrapped = workosErrorFromFailure(new ServiceAdapterError({ cause: networkFailure }));
45+ expect(wrapped.status).toBeUndefined();
46+ });
47+});
48+
49+describe("isDefinitiveWorkOSDenial", () => {
50+ it("treats 401/403/404 as definitive and everything else as transient", () => {
51+ expect(isDefinitiveWorkOSDenialStatus(401)).toBe(true);
52+ expect(isDefinitiveWorkOSDenialStatus(403)).toBe(true);
53+ expect(isDefinitiveWorkOSDenialStatus(404)).toBe(true);
54+ expect(isDefinitiveWorkOSDenialStatus(429)).toBe(false);
55+ expect(isDefinitiveWorkOSDenialStatus(500)).toBe(false);
56+ expect(isDefinitiveWorkOSDenialStatus(503)).toBe(false);
57+ expect(isDefinitiveWorkOSDenialStatus(undefined)).toBe(false);
58+ });
59+
60+ it("only claims WorkOSError instances, never arbitrary errors with a status", () => {
61+ expect(isDefinitiveWorkOSDenial({ status: 403 })).toBe(false);
62+ expect(
63+ isDefinitiveWorkOSDenial(
64+ workosErrorFromFailure(new ServiceAdapterError({ cause: new UnauthorizedException("r") })),
65+ ),
66+ ).toBe(true);
67+ });
68+});
apps/cloud/src/auth/errors.ts+161−5
@@ -1,17 +1,169 @@
1−import { Data, Effect, Schema } from "effect";
1+import { Data, Effect, Option, Predicate, Schema } from "effect";
22
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.
6+export const USER_STORE_FAILURE_REASONS = [
7+ "connect_timeout",
8+ "connection_closed",
9+ "query",
10+ "unknown",
11+] as const;
12+
13+export 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+ */
326 export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()(
427 "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+ },
634 { 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. */
43+export 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.
51+const MAX_CAUSE_DEPTH = 8;
52+
53+const 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+
63+const 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+
69+const 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`. */
88+export 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. */
99+export const userStoreErrorFromFailure = (operation: string, failure: unknown): UserStoreError =>
100+ new UserStoreError({ operation, reason: userStoreReasonFromCause(failure) });
8101
9102 export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()(
10103 "WorkOSError",
11− {},
104+ {
105+ /**
106+ * The upstream HTTP status WorkOS answered with, when the failure WAS an
107+ * HTTP answer (the SDK sets `.status` on all its typed exceptions).
108+ * Absent for network errors / timeouts. Consumers that must distinguish
109+ * "WorkOS said no" (definitive 4xx) from "WorkOS was unreachable"
110+ * (transient) branch on this — see `isDefinitiveWorkOSDenialStatus`.
111+ */
112+ status: Schema.optional(Schema.Number),
113+ },
12114 { httpApiStatus: 500 },
13115 ) {}
14116
117+// Statuses that are a DEFINITIVE denial from WorkOS — a deterministic "this
118+// request cannot be authorized" answer (invalid/revoked API key, forbidden,
119+// resource gone), not a blip. Retrying does not help, and auth decisions built
120+// on them must fail CLOSED. Everything else (429, 5xx, or no status at all —
121+// network error/timeout) is transient and must stay retryable.
122+const DEFINITIVE_DENIAL_STATUSES: ReadonlySet<number> = new Set([401, 403, 404]);
123+
124+export const isDefinitiveWorkOSDenialStatus = (status: number | undefined): boolean =>
125+ status !== undefined && DEFINITIVE_DENIAL_STATUSES.has(status);
126+
127+// Tag-based guards (same pattern as `isOAuth2Error` in core/sdk/oauth-helpers):
128+// the untyped failure channels these run against carry values that crossed
129+// layer boundaries, so the discriminant is the `_tag`, not the prototype chain.
130+const isWorkOSError = Predicate.isTagged("WorkOSError") as (error: unknown) => error is WorkOSError;
131+
132+/** A `WorkOSError` that is a definitive denial (see above), typed on `unknown`
133+ * so auth code holding an untyped failure channel can branch safely. */
134+export const isDefinitiveWorkOSDenial = (error: unknown): boolean =>
135+ isWorkOSError(error) && isDefinitiveWorkOSDenialStatus(error.status);
136+
137+// Every typed exception the WorkOS node SDK throws for an HTTP answer carries a
138+// numeric `.status` (UnauthorizedException 401, NotFoundException 404,
139+// RateLimitExceededException 429, GenericServerException/OauthException with the
140+// live status, ...). Network errors and timeouts throw non-SDK errors with no
141+// status. Same extraction pattern as the workos-vault plugin's
142+// `statusFromWorkOSCause` (packages/plugins/workos-vault/src/sdk/client.ts).
143+const CauseWithStatusSchema = Schema.Struct({ status: Schema.Number });
144+const decodeCauseWithStatusOption = Schema.decodeUnknownOption(CauseWithStatusSchema);
145+
146+export const statusFromWorkOSCause = (cause: unknown): number | undefined =>
147+ Option.match(decodeCauseWithStatusOption(cause), {
148+ onNone: () => undefined,
149+ onSome: (decoded) => decoded.status,
150+ });
151+
152+/**
153+ * Build the public `WorkOSError` for a WorkOS service-adapter failure,
154+ * threading the upstream HTTP status through when the underlying SDK exception
155+ * carried one. The failure is normally the `ServiceAdapterError` wrapper from
156+ * `tryPromiseService` (SDK exception in `.cause`); a bare cause also works.
157+ */
158+const isServiceAdapterError = Predicate.isTagged("ServiceAdapterError") as (
159+ failure: unknown,
160+) => failure is ServiceAdapterError;
161+
162+export const workosErrorFromFailure = (failure: unknown): WorkOSError =>
163+ new WorkOSError({
164+ status: statusFromWorkOSCause(isServiceAdapterError(failure) ? failure.cause : failure),
165+ });
166+
15167 export class ApiKeyManagementError extends Schema.TaggedErrorClass<ApiKeyManagementError>()(
16168 "ApiKeyManagementError",
17169 { cause: Schema.Unknown },
@@ -47,7 +199,11 @@ export const tryPromiseService = <A>(fn: () => Promise<A>): Effect.Effect<A, Ser · 29 unmodified lines
47199 */
48200 export const withServiceLogging = <A, E, R>(
49201 name: string,
50− publicError: () => E,
202+ // Receives the raw failure (for service adapters: the `ServiceAdapterError`
203+ // whose `.cause` is the SDK exception) so the public error can carry safe
204+ // classification fields (e.g. `WorkOSError.status`). Zero-arg callers that
205+ // ignore the failure keep working unchanged.
206+ publicError: (failure: unknown) => E,
51207 effect: Effect.Effect<A, unknown, R>,
52208 ): Effect.Effect<A, E, R> =>
53209 effect.pipe(
apps/cloud/src/auth/handlers.ts+186−25
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 CloudAuthPublicApi,
1010 McpExecutionNotFoundError,
1111 McpSessionForbiddenError,
12+ OrganizationDeletionForbidden,
1213 } from "./api";
1314 import { NoOrganization } from "@executor-js/api/server";
1415 // Pure constants/codec module (no React) — safe in the backend graph.
@@ -21,11 +22,14 @@ import { env } from "cloudflare:workers"; · 6 unmodified lines
2122 import { WorkOSError } from "./errors";
2223 import { WorkOSClient } from "./workos";
2324 import { AutumnService } from "../extensions/billing/service";
25+import { forkReportMemberSeats } from "../extensions/billing/member-seats";
26+import { captureCauseEffect } from "../observability";
2427 import {
2528 hasPaidOrganizationSubscription,
2629 isOverFreeOrganizationLimit,
2730 shouldApplyFreeOrganizationLimit,
2831 } from "../extensions/billing/plans";
32+import { LAST_ORG_COOKIE } from "./last-org-cookie";
2933 import {
3034 ORG_SELECTOR_HEADER,
3135 authorizeOrganization,
@@ -142,8 +146,8 @@ const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, n · 110 unmodified lines
142146 HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS);
143147
144148 // ---------------------------------------------------------------------------
145−// Single non-protected API surface — public (login/callback) + session
146−// (me/logout/organizations/switch-organization). The session group has SessionAuth on it.
149+// Single non-protected API surface — public (login/callback/logout) + session
150+// (me/organizations/switch-organization). The session group has SessionAuth on it.
147151 // ---------------------------------------------------------------------------
148152
149153 export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi);
@@ -200,7 +204,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 50 unmodified lines
200204 const result = yield* workos.authenticateWithCode(query.code);
201205
202206 // Mirror the account locally
203− yield* users.use((s) => s.ensureAccount(result.user.id));
207+ yield* users.use("ensureAccount", (s) => s.ensureAccount(result.user.id));
204208
205209 let sealedSession = result.sealedSession;
206210
@@ -217,17 +221,40 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 10 unmodified lines
217221 : null;
218222
219223 // Prefer the org in the URL that sent the user to login. If the URL
220− // is bare, or not an org route, fall back to WorkOS's org and then to
221− // the first active membership for org-less sessions. Pending
222− // memberships are skipped because refreshing into one 400s and would
223− // bypass invite consent.
224− let targetOrganizationId = requestedOrg?.id ?? result.organizationId ?? null;
224+ // is bare, or not an org route, prefer the org this browser last
225+ // worked in (the last-org cookie — it outlives the session precisely
226+ // so a fresh login lands where the user left off), then WorkOS's
227+ // org, then the first active membership for org-less sessions.
228+ // Pending memberships are skipped because refreshing into one 400s
229+ // and would bypass invite consent. The cookie is membership-checked
230+ // like any selector, so a stale one just falls through.
231+ let targetOrganizationId = requestedOrg?.id ?? null;
232+ if (!targetOrganizationId && !requestedOrgSelector) {
233+ const lastOrgSlug = request.cookies[LAST_ORG_COOKIE];
234+ const lastOrg =
235+ lastOrgSlug && isValidOrgSlug(lastOrgSlug)
236+ ? yield* authorizeOrganizationSelector(result.user.id, lastOrgSlug).pipe(
237+ Effect.orElseSucceed(() => null),
238+ )
239+ : null;
240+ targetOrganizationId = lastOrg?.id ?? null;
241+ }
242+ targetOrganizationId ??= result.organizationId ?? null;
225243 if (!targetOrganizationId && !requestedOrgSelector) {
226244 const memberships = yield* workos.listUserMemberships(result.user.id);
227245 const existingActive = memberships.data.find((m) => m.status === "active");
228246 targetOrganizationId = existingActive?.organizationId ?? null;
229247 }
230248
249+ // Seat changes the app never sees a mutation for (invitation
250+ // acceptance in AuthKit, SSO JIT provisioning, join by domain,
251+ // WorkOS dashboard edits) all end in a sign-in, so every login
252+ // reconciles the landed org's billed seat count. Forked: billing
253+ // must not delay the login.
254+ if (targetOrganizationId) {
255+ yield* forkReportMemberSeats(targetOrganizationId);
256+ }
257+
231258 if (
232259 targetOrganizationId &&
233260 targetOrganizationId !== result.organizationId &&
@@ -242,7 +269,9 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 8 unmodified lines
242269 }
243270
244271 if (!sealedSession) {
245− return HttpServerResponse.text("Failed to create session", { status: 500 });
272+ return HttpServerResponse.text("Failed to create session", {
273+ status: 500,
274+ });
246275 }
247276
248277 return deleteResponseCookie(
@@ -256,6 +285,42 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 7 unmodified lines
256285 );
257286 }),
258287 )
288+ .handleRaw("logout", ({ request }) =>
289+ Effect.gen(function* () {
290+ const workos = yield* WorkOSClient;
291+ // The session this browser presents, NOT one the middleware vouched
292+ // for — signing out of a session that has already ended must still
293+ // sign the browser out (see the group declaration in ./api.ts).
294+ const sealedSession = request.cookies["wos-session"] ?? "";
295+
296+ // WorkOS's documented sign-out: send the browser through the WorkOS
297+ // logout endpoint, which ends the AuthKit session upstream and then
298+ // redirects to the registered sign-out URL. Without this hop, the
299+ // hosted session survives and the next "Sign in" silently
300+ // re-authenticates (issue #1445). Fail-open when the cookie won't
301+ // unseal — there is then nothing to end upstream, and local sign-out
302+ // must still complete, so fall back to "/".
303+ const origin = env.VITE_PUBLIC_SITE_URL ?? "";
304+ const logoutUrl = sealedSession
305+ ? yield* workos.logoutUrl(sealedSession, origin ? `${origin}/` : undefined)
306+ : null;
307+
308+ const response = HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 });
309+
310+ // Drop only what this browser actually presented. Both cookies are
311+ // SameSite=Lax, so a cross-site form POST carries neither — it gets
312+ // the bare redirect and cannot be used to sign anyone out.
313+ if (!sealedSession && request.cookies[AUTH_HINT_COOKIE] === undefined) return response;
314+
315+ // The auth-hint travels with the session: leaving it behind would
316+ // make the next page load optimistically paint the app shell for a
317+ // signed-out browser.
318+ return deleteResponseCookie(
319+ deleteResponseCookie(response, "wos-session"),
320+ AUTH_HINT_COOKIE,
321+ );
322+ }),
323+ )
259324 // CLI device-login discovery. The WorkOS device endpoints live on the
260325 // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production,
261326 // the SAME base the SDK uses, so e2e points the CLI at the emulator with
@@ -301,17 +366,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 39 unmodified lines
301366 };
302367 }),
303368 )
304− .handleRaw("logout", () =>
305− Effect.succeed(
306− // The auth-hint travels with the session: leaving it behind would
307− // make the next page load optimistically paint the app shell for a
308− // signed-out browser.
309− deleteResponseCookie(
310− deleteResponseCookie(HttpServerResponse.redirect("/", { status: 302 }), "wos-session"),
311− AUTH_HINT_COOKIE,
312− ),
313− ),
314− )
315369 .handle("organizations", () =>
316370 Effect.gen(function* () {
317371 const workos = yield* WorkOSClient;
@@ -323,7 +377,11 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 5 unmodified lines
323377 const organizations = yield* Effect.all(
324378 memberships.data.map((m) =>
325379 resolveOrganization(m.organizationId).pipe(
326− Effect.map((org) => ({ id: org.id, name: org.name, slug: org.slug })),
380+ Effect.map((org) => ({
381+ id: org.id,
382+ name: org.name,
383+ slug: org.slug,
384+ })),
327385 Effect.orElseSucceed(() => null),
328386 ),
329387 ),
@@ -354,7 +412,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 24 unmodified lines
354412 activeMemberships.map((membership) =>
355413 autumn
356414 .use((client) =>
357− client.customers.getOrCreate({ customerId: membership.organizationId }),
415+ client.customers.getOrCreate({
416+ customerId: membership.organizationId,
417+ }),
358418 )
359419 .pipe(
360420 Effect.map((customer) =>
@@ -366,7 +426,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 5 unmodified lines
366426 ),
367427 { concurrency: 3 },
368428 ).pipe(
369− Effect.catchTag("AutumnError", () => Effect.fail(new WorkOSError())),
429+ // Any Autumn failure here (outage or missing customer) leaves the
430+ // paid/free split unknown, and the limit must fail closed.
431+ Effect.mapError(() => new WorkOSError()),
370432 Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))),
371433 );
372434
@@ -378,10 +440,30 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 5 unmodified lines
378440 const org = yield* workos.createOrganization(name);
379441 yield* workos.createMembership(org.id, session.accountId, "admin");
380442 // `upsertOrganization` mints the slug at insert — no separate heal step.
381− const mirrored = yield* users.use((s) =>
443+ const mirrored = yield* users.use("upsertOrganization", (s) =>
382444 s.upsertOrganization({ id: org.id, name: org.name }),
383445 );
384446
447+ // Provision the org's billing customer while we're the ones creating
448+ // the org. Without this the first billing call an org ever makes is a
449+ // non-creating one (balance check / usage track), which 404s and keeps
450+ // 404ing — unlimited unbilled executions. Non-fatal: a billing blip
451+ // must not block signup, and the billing seam heals a customer that
452+ // is still missing later.
453+ yield* autumn.ensureCustomer(org.id).pipe(
454+ Effect.catch((error) =>
455+ Effect.gen(function* () {
456+ yield* Effect.logWarning(
457+ "createOrganization: could not provision the Autumn customer",
458+ { organizationId: org.id, error },
459+ );
460+ yield* captureCauseEffect(error);
461+ }),
462+ ),
463+ );
464+ // Seed the new org's billed seat count (the creator's seat).
465+ yield* forkReportMemberSeats(org.id);
466+
385467 // Try to attach the new org to the current session. This can fail
386468 // (or silently return a session still scoped to the old org) when
387469 // the caller's current session is stale — most commonly after the
@@ -410,6 +492,80 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 22 unmodified lines
410492 return { id: org.id, name: org.name, slug: mirrored.slug };
411493 }),
412494 )
495+ .handle("deleteOrganization", ({ payload }) =>
496+ Effect.gen(function* () {
497+ const workos = yield* WorkOSClient;
498+ const users = yield* UserStoreService;
499+ const autumn = yield* AutumnService;
500+
501+ // Target the caller's currently-selected org (honors the org-selector
502+ // header, same as the other org-scoped auth handlers). NoOrganization
503+ // when the session has no org to act on.
504+ const session = yield* requireSelectedOrganization;
505+ const organizationId = session.organizationId;
506+
507+ // Admin-only. Live WorkOS check so a member removed/demoted moments
508+ // ago can't delete the workspace. A pending admin invite is not an
509+ // active admin, so require active status too.
510+ const membership = yield* workos.getUserOrgMembership(organizationId, session.accountId);
511+ if (!membership || membership.status !== "active" || membership.role?.slug !== "admin") {
512+ return yield* new OrganizationDeletionForbidden();
513+ }
514+
515+ // The typed confirmation must match the org's current name — the same
516+ // label the settings page shows. Trimmed on both sides.
517+ const org = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId));
518+ if (!org || payload.confirmName.trim() !== org.name.trim()) {
519+ return yield* new OrganizationDeletionForbidden();
520+ }
521+
522+ // WorkOS FIRST. Once the org is gone there, membership authorization
523+ // fails for every member, so the workspace is truly deleted even if a
524+ // later local step lags (leftover local rows become unreachable, not
525+ // user-visible). The reverse order risks the org resurrecting as an
526+ // empty workspace when a later request re-mirrors it with a new slug.
527+ yield* workos.deleteOrganization(organizationId);
528+
529+ // Purge all local tenant data, secrets, and the identity mirror
530+ // (cascades local memberships) in one transaction. If this fails
531+ // after the WorkOS delete already succeeded, the org is gone for
532+ // everyone (unreachable) but its secrets/tenant rows linger orphaned —
533+ // alert loudly so that window gets swept, then surface the failure.
534+ yield* users
535+ .use("deleteOrganizationCascade", (s) => s.deleteOrganizationCascade(organizationId))
536+ .pipe(
537+ Effect.tapError((error) =>
538+ Effect.logError(
539+ "deleteOrganization: WorkOS org deleted but local purge failed, tenant data and secrets orphaned",
540+ { organizationId, error },
541+ ),
542+ ),
543+ );
544+
545+ // Cancel billing. Best-effort: the org is already deleted, so a
546+ // lingering Autumn customer is a billing loose end (log loudly) rather
547+ // than a correctness failure that should 500 the caller.
548+ yield* autumn
549+ .use((client) => client.customers.delete({ customerId: organizationId }))
550+ .pipe(
551+ // Includes the "customer never existed" answer: nothing to cancel
552+ // is a fine outcome for a deleted org, and it is still worth a line.
553+ Effect.catch((error) =>
554+ Effect.logWarning("deleteOrganization: failed to delete Autumn customer", {
555+ organizationId,
556+ error,
557+ }),
558+ ),
559+ );
560+
561+ // The caller's session is pinned to the now-deleted org — clear it so
562+ // the browser bounces to login and rehydrates to another membership
563+ // (or the create-org screen when they have none left).
564+ (yield* SessionCookies).set("wos-session", "", DELETE_COOKIE_OPTIONS);
565+
566+ return { success: true };
567+ }),
568+ )
413569 .handle("pendingInvitations", () =>
414570 Effect.gen(function* () {
415571 const workos = yield* WorkOSClient;
@@ -479,10 +635,15 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 63 unmodified lines
479635 // Mirror the org locally so domain tables can FK against it; the
480636 // upsert mints the slug at insert — no separate heal step.
481637 const org = yield* workos.getOrganization(invitation.organizationId);
482− const mirrored = yield* users.use((s) =>
638+ const mirrored = yield* users.use("upsertOrganization", (s) =>
483639 s.upsertOrganization({ id: org.id, name: org.name }),
484640 );
485641
642+ // The membership is active in WorkOS from this point even if
643+ // attaching the session below fails, so reconcile the org's billed
644+ // seat count now.
645+ yield* forkReportMemberSeats(org.id);
646+
486647 // Attach the just-accepted org to the current session. Same shape
487648 // as createOrganization: refresh + verify; if we can't pin the
488649 // session in-place, clear the cookie and let the user bounce
apps/cloud/src/auth/jwks-cache.node.test.ts+182−1
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
99 type KeyLike,
1010 } from "jose";
1111
12−import { createCachedRemoteJWKSet } from "./jwks-cache";
12+import { createCachedRemoteJWKSet, type JwksStore, type StoredJwks } from "./jwks-cache";
1313
1414 const issuer = "https://test-authkit.example.com";
1515 const audience = "client_test_fixture";
@@ -65,6 +65,40 @@ const makeFetchHarness = (initialKeys: ReadonlyArray<JWK>): FetchHarness => { · 49 unmodified lines
6565 };
6666 };
6767
68+interface 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. */
75+const 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. */
97+const 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+
68102 describe("createCachedRemoteJWKSet", () => {
69103 it("FAILING-WITHOUT-CACHE: N verifications hit JWKS endpoint only once within TTL", async () => {
70104 const kp = await generateRotatableKeypair("k1");
@@ -168,4 +202,151 @@ describe("createCachedRemoteJWKSet", () => { · 97 unmodified lines
168202 await jwtVerify(t1, jwks, { issuer, audience });
169203 expect(harness.callCount()).toBe(2);
170204 });
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+ });
171352 });
apps/cloud/src/auth/jwks-cache.ts+256−26
@@ -1,5 +1,5 @@
11 // ---------------------------------------------------------------------------
2−// In-memory JWKS cache for MCP JWT verification.
2+// JWKS cache for JWT verification (session + MCP auth).
33 // ---------------------------------------------------------------------------
44 //
55 // Cloudflare Workers boot many short-lived isolates. `createRemoteJWKSet`'s
@@ -7,14 +7,32 @@ · 1 unmodified line
77 // fetches per hour because each new isolate starts cold. Production p99 for
88 // `mcp.auth.jwt_verify` was 1.7s — almost entirely the JWKS fetch.
99 //
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.
1117 //
12−// * Caches the JSON Web Key Set in module-scope memory for a configurable
13−// TTL (default 1 hour).
14−// * Single-flights concurrent fetches so a stampede of verifies during a
15−// cache miss only fires one upstream request.
16−// * Force-refreshes once when verification fails with a cached key, so
17−// genuine key rotation isn't blocked by the TTL.
18+// So this module layers three caches, fastest first:
19+//
20+// 1. Module-scope memory — free, but dies with the isolate.
21+// 2. A cross-isolate store (the Workers Cache API by default) — colo-local,
22+// survives isolate recycling, so a cold isolate reads keys in ~1ms
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.
1836 //
1937 // The returned function is a `JWTVerifyGetKey` and slots directly into
2038 // `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can
@@ -32,6 +50,21 @@ import { · 11 unmodified lines
3250 import { Schema } from "effect";
3351 import { JWKSNoMatchingKey } from "jose/errors";
3452
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+ */
57+export interface JwksStore {
58+ readonly get: (url: URL) => Promise<StoredJwks | null>;
59+ readonly put: (url: URL, stored: StoredJwks) => Promise<void>;
60+}
61+
62+export interface StoredJwks {
63+ readonly jwks: JSONWebKeySet;
64+ /** Epoch ms of the upstream fetch these keys came from. */
65+ readonly fetchedAt: number;
66+}
67+
3568 export interface CachedRemoteJWKSetOptions {
3669 /**
3770 * How long a successful fetch is considered fresh. Defaults to 1 hour —
@@ -39,20 +72,56 @@ export interface CachedRemoteJWKSetOptions { · 1 unmodified line
3972 * failure handles unscheduled rotations.
4073 */
4174 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;
4281 /** Override the fetch implementation for tests. */
4382 readonly fetch?: typeof globalThis.fetch;
4483 /** HTTP request timeout. Defaults to 5s, matching jose. */
4584 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;
4690 }
4791
4892 export interface CachedRemoteJWKSet extends JWTVerifyGetKey {
4993 /** Drop the cached JWKS so the next call refetches. */
5094 readonly forceRefresh: () => void;
51− /** Inspect the current cache state (testing/diagnostics). */
52− readonly inspect: () => { fetchedAt: number | null; hasJwks: boolean };
95+ /**
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+ };
53121 }
54122
55123 const DEFAULT_TTL_MS = 60 * 60 * 1000;
124+const DEFAULT_STALE_MAX_MS = 24 * 60 * 60 * 1000;
56125 const DEFAULT_TIMEOUT_MS = 5000;
57126
58127 const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown);
@@ -73,6 +142,26 @@ interface CacheEntry { · 14 unmodified lines
73142 resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise<KeyLike>;
74143 }
75144
145+const 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+ */
155+const 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+
76165 const fetchJwksOnce = async (
77166 url: URL,
78167 fetchImpl: typeof globalThis.fetch,
@@ -107,17 +196,74 @@ const fetchJwksOnce = async ( · 28 unmodified lines
107196 }
108197 };
109198
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+
208+const STORE_HEADER_FETCHED_AT = "x-jwks-fetched-at";
209+
210+/** `caches.default` is a Workers extension the standard lib type omits. */
211+type WorkersCacheStorage = CacheStorage & { readonly default?: Cache };
212+
213+const 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+
110254 /**
111255 * Creates a cached, single-flight, force-refreshable JWKS resolver compatible
112256 * 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.
114258 */
115259 export const createCachedRemoteJWKSet = (
116260 url: URL,
117261 options: CachedRemoteJWKSetOptions = {},
118262 ): CachedRemoteJWKSet => {
119263 const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
264+ const staleMaxMs = Math.max(options.staleMaxMs ?? DEFAULT_STALE_MAX_MS, ttlMs);
120265 const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
266+ const store = options.store === undefined ? workersCacheStore(staleMaxMs) : options.store;
121267 // Capture the fetch impl lazily so consumers can swap globalThis.fetch
122268 // (tests do this) without us snapshotting a stale reference.
123269 const fetchImpl = (): typeof globalThis.fetch =>
@@ -125,39 +271,116 @@ export const createCachedRemoteJWKSet = ( · 1 unmodified line
125271
126272 let entry: CacheEntry | null = null;
127273 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;
128285
129286 const refresh = (): Promise<CacheEntry> => {
130287 if (inflight) return inflight;
288+ const startedAt = Date.now();
289+ fetchCount += 1;
131290 inflight = (async () => {
132291 const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs);
133− const next: CacheEntry = {
134− jwks,
135− fetchedAt: Date.now(),
136− resolver: createLocalJWKSet(jwks),
137− };
292+ const next = entryFrom({ jwks, fetchedAt: Date.now() });
138293 entry = next;
294+ if (store) {
295+ await ignoreFailure(store.put(url, { jwks: next.jwks, fetchedAt: next.fetchedAt }));
296+ }
139297 return next;
140− })().finally(() => {
141− inflight = null;
142− });
298+ })()
299+ .then(
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+ });
143311 return inflight;
144312 };
145313
146− const ensureFresh = async (forceRefresh: boolean): Promise<CacheEntry> => {
147− if (forceRefresh) return refresh();
148− if (entry && Date.now() - entry.fetchedAt < ttlMs) return entry;
314+ /** Fire-and-forget revalidation behind a stale hit. */
315+ const refreshInBackground = (): void => {
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;
149340 return refresh();
150341 };
151342
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+
152371 const get: JWTVerifyGetKey = async (protectedHeader, token) => {
153372 const current = await ensureFresh(false);
154373 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures
155374 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;
157379 } catch (error) {
158− // Likely cause: keys rotated upstream after our TTL window started.
159− // Refetch once and try again. Anything still failing bubbles up so
160− // jose can classify it (we do not silently swallow real failures).
380+ // Likely cause: keys rotated upstream after our TTL window started, or
381+ // we answered from a stale entry. Refetch once and try again. Anything
382+ // still failing bubbles up so jose can classify it (we do not silently
383+ // swallow real failures).
161384 if (!isJwksNoMatchingKey(error)) {
162385 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey requires preserving upstream resolver rejection
163386 throw error;
@@ -177,6 +400,13 @@ export const createCachedRemoteJWKSet = ( · 13 unmodified lines
177400 value: () => ({
178401 fetchedAt: entry?.fetchedAt ?? null,
179402 hasJwks: entry !== null,
403+ fetchCount,
404+ fetchFailureCount,
405+ blockingFetchCount,
406+ storeHitCount,
407+ lastFetchDurationMs,
408+ lastStoreReadMs,
409+ lastResolveMs,
180410 }),
181411 });
182412 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+
22+export const LAST_ORG_COOKIE = "executor-last-org";
23+
24+/** Outlives the 7d session on purpose — it spans logins. */
25+const LAST_ORG_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
26+
27+/** Browser-side write. Slugs are `[a-z0-9-]` by grammar, so no encoding. */
28+export 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 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+
4+import { ApiKeyService } from "./api-keys";
5+import { UserStoreService } from "./context";
6+import { WorkOSClient, type WorkOSClientService } from "./workos";
7+import { 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+
14+const createdAt = new Date("2026-01-01T00:00:00.000Z");
15+
16+const 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+
44+const 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+
64+const 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+
92+const layers = Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers);
93+
94+const bearer = (token: string) =>
95+ new Request("https://executor.test/api/tools", {
96+ headers: { authorization: `Bearer ${token}` },
97+ });
98+
99+describe("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 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+
4+import { ApiKeyService, OrgApiKeyNotFound } from "./api-keys";
5+import { ApiKeyManagementError } from "./errors";
6+import { 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+
25+const ORG = "org_123";
26+
27+const 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+
39+const 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+
51+const serviceWith = (overrides: Record<string, unknown>) =>
52+ ApiKeyService.asEffect().pipe(
53+ Effect.provide(ApiKeyService.WorkOS.pipe(Layer.provide(stubWorkOS(overrides)))),
54+ );
55+
56+describe("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+
195+describe("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+33−10
@@ -7,9 +7,11 @@ import { resolveSessionPrincipal } from "./workos-auth-provider"; · 6 unmodified lines
77 import { WorkOSClient, type WorkOSClientService } from "./workos";
88
99 // 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 —
11−// with the session org as the fallback for non-console callers, and live
12−// membership re-checked either way. This is what makes two browser tabs on
10+// `x-executor-organization` selector header) — NEVER the session's stored org.
11+// The sealed cookie's org is a browser-global pinned to whichever org WorkOS
12+// 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
1315 // different orgs independent.
1416
1517 const createdAt = new Date("2026-01-01T00:00:00.000Z");
@@ -27,6 +29,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 11 unmodified lines
2729 listUserKeys: () => Effect.succeed([]),
2830 createUserKey: () => Effect.die("not used"),
2931 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"),
3035 });
3136
3237 const stubWorkOS = Layer.succeed(
@@ -35,7 +40,11 @@ const stubWorkOS = Layer.succeed( · 2 unmodified lines
3540 get: (_t, prop) => {
3641 if (prop === "authenticateRequest") {
3742 return () =>
38− Effect.succeed({ userId: MEMBER, email: "u@e2e.test", organizationId: SESSION_ORG });
43+ Effect.succeed({
44+ userId: MEMBER,
45+ email: "u@e2e.test",
46+ organizationId: SESSION_ORG,
47+ });
3948 }
4049 if (prop === "listUserMemberships") {
4150 return (userId: string) =>
@@ -55,7 +64,7 @@ const stubWorkOS = Layer.succeed( · 13 unmodified lines
5564 );
5665
5766 const stubUsers = Layer.succeed(UserStoreService)({
58− use: (fn) =>
67+ use: (_op, fn) =>
5968 Effect.promise(() =>
6069 fn({
6170 ensureAccount: async (id: string) => ({ id, createdAt }),
@@ -66,7 +75,12 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 4 unmodified lines
6675 slug: org.id,
6776 createdAt,
6877 }),
69− getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: id, createdAt }),
78+ getOrganization: async (id: string) => ({
79+ id,
80+ name: `Org ${id}`,
81+ slug: id,
82+ createdAt,
83+ }),
7084 // The URL slug maps to URL_ORG (the member's other org); any other slug
7185 // maps to an org the caller is NOT a member of, so membership rejects it.
7286 getOrganizationBySlug: async (slug: string) => ({
@@ -75,6 +89,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 2 unmodified lines
7589 slug,
7690 createdAt,
7791 }),
92+ deleteOrganizationCascade: async () => {},
7893 }),
7994 ),
8095 });
@@ -85,10 +100,15 @@ const run = (headers: Record<string, string>) => · 4 unmodified lines
85100 );
86101
87102 describe("resolveSessionPrincipal · URL org selector", () => {
88− 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", () =>
89104 Effect.gen(function* () {
90− const principal = yield* run({ cookie: "wos-session=x" });
91− expect(principal.organizationId, "scopes to the session org").toBe(SESSION_ORG);
105+ // No fallback to the session org: the cookie's org is browser-global
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+ });
92112 }),
93113 );
94114
@@ -117,7 +137,10 @@ describe("resolveSessionPrincipal · URL org selector", () => { · 22 unmodified lines
117137 // The slug resolves to a real org id, but membership is re-checked — a
118138 // slug is a selector, not a trust boundary, so a non-member is rejected.
119139 const error = yield* Effect.flip(
120− run({ cookie: "wos-session=x", "x-executor-organization": "outsider-slug" }),
140+ run({
141+ cookie: "wos-session=x",
142+ "x-executor-organization": "outsider-slug",
143+ }),
121144 );
122145 expect(error).toMatchObject({ _tag: "NoOrganization" });
123146 }),
apps/cloud/src/auth/organization.ts+18−6
@@ -38,12 +38,14 @@ import { WorkOSClient } from "./workos"; · 37 unmodified lines
3838 export const resolveOrganization = (organizationId: string) =>
3939 Effect.gen(function* () {
4040 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));
4242 if (existing) return existing;
4343
4444 const workos = yield* WorkOSClient;
4545 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+ );
4749 });
4850
4951 // ---------------------------------------------------------------------------
@@ -57,9 +59,19 @@ export const resolveOrganization = (organizationId: string) => · 7 unmodified lines
5759 // until their access token naturally expires (~10 min).
5860 //
5961 // To close that gap we verify membership live on every protected request.
60−// `listUserMemberships` is one WorkOS call per request. If this becomes a
61−// hot path we can layer a short per-(user, org) TTL cache underneath, or
62−// swap it for a local memberships table fed by the WorkOS Events API.
62+// `listUserMemberships` is one WorkOS call per request.
63+//
64+// Caching decision (2026-07): we deliberately do NOT add a positive TTL cache
65+// here. A positive cache is exactly what would re-open the revocation gap this
66+// live check exists to close — a revoked member would keep access for the cache
67+// TTL. Negative caching is worse still (a transient WorkOS blip would get
68+// pinned as "no access"), so it is out too. The rate-limit amplification a
69+// shared-API-key org can cause under a WorkOS slowdown is mitigated instead by
70+// the classification fix at the MCP call site (a blip now yields a retryable
71+// 503, so it no longer condemns sessions or triggers reconnect storms). If per-
72+// request WorkOS load later proves to be the bottleneck, the right structural
73+// fix is a local memberships table fed by the WorkOS Events API (authoritative,
74+// no staleness window), not a TTL cache over this call — tracked as follow-up.
6375 //
6476 // Returns the resolved organization (via resolveOrganization) if the user
6577 // currently holds an *active* membership in it, otherwise null. Callers
@@ -115,7 +127,7 @@ export const authorizeOrganizationSelector = (userId: string, selector: string) · 49 unmodified lines
115127 return yield* authorizeOrganization(userId, selector);
116128 }
117129 const users = yield* UserStoreService;
118− const org = yield* users.use((s) => s.getOrganizationBySlug(selector));
130+ const org = yield* users.use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(selector));
119131 if (!org) return null;
120132 return yield* authorizeOrganization(userId, org.id);
121133 });
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).
11+import { createServer, type Server, type Socket } from "node:net";
12+
13+import { describe, expect, it } from "@effect/vitest";
14+import { Effect, Layer, Result } from "effect";
15+import postgres from "postgres";
16+
17+import { UserStoreService } from "./context";
18+import { ServiceAdapterError, userStoreReasonFromCause, type UserStoreError } from "./errors";
19+import { 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.
25+const 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+
50+const 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 …`).
75+const 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+
81+const stubDb = Layer.succeed(DbService)({ db: {} as never });
82+
83+const 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+
96+describe("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/user-store.ts+6−0
@@ -13,6 +13,7 @@ import { generateOrgSlug } from "@executor-js/api"; · 12 unmodified lines
1313
1414 import { accounts, organizations } from "../db/schema";
1515 import type { DrizzleDb } from "../db/db";
16+import { purgeOrganizationData } from "../db/org-deletion";
1617
1718 export type Account = typeof accounts.$inferSelect;
1819 export type Organization = typeof organizations.$inferSelect;
@@ -96,5 +97,10 @@ export const makeUserStore = (db: DrizzleDb) => { · 77 unmodified lines
9697 const rows = await db.select().from(organizations).where(eq(organizations.slug, slug));
9798 return rows[0] ?? null;
9899 },
100+
101+ // Permanently delete an org and everything it owns (tenant data, secrets,
102+ // identity mirror + cascaded memberships) in a single transaction. Callers
103+ // sequence the external WorkOS/Autumn deletions around this.
104+ deleteOrganizationCascade: (id: string) => purgeOrganizationData(db, id),
99105 };
100106 };
apps/cloud/src/auth/workos-auth-provider.ts+132−19
@@ -16,6 +16,7 @@ · 15 unmodified lines
1616 // - invalid api key -> Unauthorized 401 invalid_api_key
1717 // - api-key org not authorized -> NoOrganization 403 no_organization
1818 // - no/invalid session -> NoOrganization 403 no_organization
19+// - session without org header -> NoOrganization 403 no_organization (fail closed)
1920 // - session org not authorized -> NoOrganization 403 no_organization
2021 // - no auth header -> falls through to the sealed-session path
2122 // The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are
@@ -37,7 +38,13 @@ import { · 15 unmodified lines
3738 Unauthorized,
3839 Unavailable,
3940 } from "@executor-js/api/server";
40−import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server";
41+import type {
42+ FailureRenderingStrategy,
43+ IdentityFailure,
44+ PlatformPrincipal,
45+ Principal,
46+ ResolvedPrincipal,
47+} from "@executor-js/api/server";
4148
4249 import { ApiKeyService } from "./api-keys";
4350 import { workosApiJwtBearerConfig } from "./api-jwt-bearer";
@@ -46,6 +53,7 @@ import { · 2 unmodified lines
4653 authorizeOrganization,
4754 authorizeOrganizationSelector,
4855 orgSelectorFromRequest,
56+ resolveOrganization,
4957 } from "./organization";
5058 import { UserStoreService } from "./context";
5159 import { sealedSessionDisplayName } from "./middleware";
@@ -86,6 +94,10 @@ const NO_ORGANIZATION_IN_SESSION = { · 34 unmodified lines
8694 code: "no_organization",
8795 message: "No organization in session",
8896 };
97+const NO_ORGANIZATION_SELECTOR = {
98+ code: "no_organization",
99+ message: "No organization selector in request",
100+};
89101 const INVALID_ACCESS_TOKEN = {
90102 code: "invalid_access_token",
91103 message: "Invalid or expired access token",
@@ -98,7 +110,6 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { · 6 unmodified lines
98110 code: "no_organization",
99111 message: "No organization in access token",
100112 };
101−
102113 // A bearer value with three dot-separated segments is a JWT (a WorkOS access
103114 // token from the CLI device-login); anything else is treated as an API key.
104115 // Same discriminator the MCP plane uses (`mcp/auth.ts`).
@@ -134,6 +145,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 29 unmodified lines
134145 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN);
135146
136147 return {
148+ kind: "member",
137149 accountId: verified.accountId,
138150 organizationId: org.id,
139151 organizationName: org.name,
@@ -146,13 +158,49 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 6 unmodified lines
146158 });
147159
148160 /**
149− * Resolve a `Bearer` credential into a `Principal`: a WorkOS access-token JWT
150− * (when `jwt` config is supplied and the value looks like a JWT) or a WorkOS
151− * API key. Returns `null` when there is no `Authorization` header, so the
152− * caller falls through to the sealed-session path. (Kept the historical name,
153− * the re-export and resolver tests reference it.)
161+ * What an ORG-level API key resolves to: the platform view. Deliberately NOT a
162+ * `Principal` — a `Principal` names an acting member (`accountId: string`), and
163+ * an org key has none. Keeping it a separate shape means no product code path
164+ * can accidentally treat it as a member, and the executor built from it binds
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+ */
170+export 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. */
180+export type BearerAuth = Principal | PlatformAuth | null;
181+
182+export 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.
154195 */
155−export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | null = null) =>
196+export 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+> =>
156204 Effect.gen(function* () {
157205 const authHeader = request.headers.get("authorization");
158206 if (!authHeader) return null;
@@ -167,7 +215,7 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 8 unmodified lines
167215 if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt);
168216
169217 const apiKeys = yield* ApiKeyService;
170− const principal = yield* apiKeys
218+ const owner = yield* apiKeys
171219 .validate(value)
172220 .pipe(
173221 Effect.catchTag("ApiKeyValidationError", () =>
@@ -175,13 +223,29 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 1 unmodified line
175223 ),
176224 );
177225
178− if (!principal) return yield* new Unauthorized(INVALID_API_KEY);
226+ if (!owner) return yield* new Unauthorized(INVALID_API_KEY);
179227
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);
181244 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY);
182245
183246 return {
184− accountId: principal.accountId,
247+ kind: "member",
248+ accountId: owner.accountId,
185249 organizationId: org.id,
186250 organizationName: org.name,
187251 organizationSlug: org.slug,
@@ -192,6 +256,40 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 4 unmodified lines
192256 } satisfies Principal;
193257 });
194258
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+ */
271+export 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+
195293 export const resolveSessionPrincipal = (request: Request) =>
196294 Effect.gen(function* () {
197295 const workos = yield* WorkOSClient;
@@ -199,17 +297,27 @@ export const resolveSessionPrincipal = (request: Request) => · 1 unmodified line
199297 if (!session) {
200298 return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
201299 }
202− // The console URL's org is the scope authority (sent as a header); the
203− // session's own org is the fallback for non-console callers. Membership is
204− // re-checked live either way — the header is a selector, not a trust
205− // boundary (see organization.ts).
206− const selector = orgSelectorFromRequest(request) ?? session.organizationId;
300+ // The console URL's org is the scope authority, sent as a header on every
301+ // request. FAIL CLOSED when it's missing: the sealed cookie's org is
302+ // browser-global and pinned to whichever org WorkOS last touched, so
303+ // falling back to it silently serves ANOTHER org's data to a multi-org
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);
207314 if (!selector) {
208− return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
315+ return yield* new NoOrganization(NO_ORGANIZATION_SELECTOR);
209316 }
210317 const org = yield* authorizeOrganizationSelector(session.userId, selector);
211318 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
212319 return {
320+ kind: "member",
213321 accountId: session.userId,
214322 organizationId: org.id,
215323 organizationName: org.name,
@@ -236,7 +344,7 @@ export const resolveProtectedPrincipal = ( · 20 unmodified lines
236344 request: Request,
237345 jwt: JwtBearerConfig | null = null,
238346 ): Effect.Effect<
239− Principal,
347+ ResolvedPrincipal,
240348 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
241349 WorkOSClient | ApiKeyService | UserStoreService
242350 > =>
@@ -317,6 +425,11 @@ export const cloudIdentityFailureStrategy: FailureRenderingStrategy<IdentityFail · 74 unmodified lines
317425 "service_unavailable",
318426 "Service temporarily unavailable",
319427 ),
428+ ReadOnlyCredential: renderIdentityFailure(
429+ 403,
430+ "read_only_credential",
431+ "Organization API keys are read-only",
432+ ),
320433 }),
321434 ),
322435 };
apps/cloud/src/auth/workos.node.test.ts+282−1
@@ -1,6 +1,287 @@
1+import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2+import type { AddressInfo } from "node:net";
3+
14 import { describe, expect, it } from "@effect/vitest";
5+import { env } from "cloudflare:workers";
6+import { Effect, Schema } from "effect";
7+import { defaults as ironDefaults, seal as sealIron } from "iron-webcrypto";
8+import {
9+ SignJWT,
10+ exportJWK,
11+ generateKeyPair,
12+ type JSONWebKeySet,
13+ type JWK,
14+ type KeyLike,
15+} from "jose";
16+
17+import { WorkOSClient, collectRawWorkOSList, collectWorkOSList } from "./workos";
18+
19+const COOKIE_PASSWORD = "test_cookie_password_at_least_32_chars!";
20+const CLIENT_ID = "client_test";
21+const API_KEY = "sk_test";
22+const 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+
30+interface Keypair {
31+ readonly kid: string;
32+ readonly publicJwk: JWK;
33+ readonly privateKey: KeyLike;
34+}
35+
36+interface RecordedRequest {
37+ readonly method: string;
38+ readonly path: string;
39+ readonly body: unknown;
40+}
41+
42+interface WorkOSStub {
43+ readonly baseUrl: string;
44+ readonly requests: () => readonly RecordedRequest[];
45+ readonly setKeys: (keys: ReadonlyArray<JWK>) => void;
46+}
47+
48+const 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+
54+const 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+
78+const 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+
89+const 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+
105+const decodeJsonBody = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
106+
107+const 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+
116+const writeJson = (response: ServerResponse, status: number, body: unknown): void => {
117+ response.writeHead(status, { "content-type": "application/json" });
118+ response.end(JSON.stringify(body));
119+};
120+
121+const 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+};
2176
3−import { collectRawWorkOSList, collectWorkOSList } from "./workos";
177+const 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+
193+describe("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+});
4285
5286 describe("collectWorkOSList", () => {
6287 it("collects memberships beyond the first WorkOS page", async () => {
apps/cloud/src/auth/workos.ts+420−46
@@ -3,13 +3,23 @@ · 2 unmodified lines
33 // ---------------------------------------------------------------------------
44
55 import { env } from "cloudflare:workers";
6−import { Context, Data, Effect, Layer, Option, Schema } from "effect";
6+import { Context, Data, Effect, Layer, Option, Predicate, Schema } from "effect";
77 import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";
8+import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto";
9+import { decodeJwt, jwtVerify } from "jose";
10+import { JWKSInvalid, JWKSNoMatchingKey, JWKSTimeout } from "jose/errors";
811 import { parseCookie } from "./cookies";
9−import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors";
12+import { createCachedRemoteJWKSet, type CachedRemoteJWKSet } from "./jwks-cache";
13+import {
14+ ServiceAdapterError,
15+ tryPromiseService,
16+ withServiceLogging,
17+ workosErrorFromFailure,
18+} from "./errors";
1019
1120 const COOKIE_NAME = "wos-session";
1221 const INVALID_COOKIE_PASSWORD_MESSAGE = "WORKOS_COOKIE_PASSWORD must be at least 32 characters";
22+const WORKOS_SEAL_VERSION_DELIMITER = "~";
1323
1424 type RawWorkOS = WorkOS & {
1525 readonly get: (
@@ -59,6 +69,239 @@ const RawWorkOSListResponse = Schema.Struct({ · 43 unmodified lines
5969
6070 const decodeRawWorkOSListResponse = Schema.decodeUnknownOption(RawWorkOSListResponse);
6171
72+const 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+
80+const 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+
88+const decodeSealedSessionPayload = Schema.decodeUnknownOption(SealedSessionPayload);
89+
90+const JwtClaims = Schema.Struct({
91+ sid: Schema.String,
92+ org_id: Schema.optional(Schema.String),
93+});
94+
95+const decodeJwtClaims = Schema.decodeUnknownOption(JwtClaims);
96+
97+const LegacySealedSessionPayload = Schema.Struct({
98+ persistent: Schema.Unknown,
99+});
100+
101+const decodeLegacySealedSessionPayload = Schema.decodeUnknownOption(LegacySealedSessionPayload);
102+
103+type SealedSessionPayload = typeof SealedSessionPayload.Type;
104+
105+type 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+
115+class LocalSessionCookieError extends Data.TaggedError("LocalSessionCookieError")<{
116+ readonly cause: unknown;
117+}> {}
118+
119+const isLocalSessionValid = Predicate.isTagged("Valid") as (
120+ value: LocalSessionVerification,
121+) => value is Extract<LocalSessionVerification, { readonly _tag: "Valid" }>;
122+
123+const isLocalSessionInvalidCookie = Predicate.isTagged("InvalidCookie") as (
124+ value: LocalSessionVerification,
125+) => value is Extract<LocalSessionVerification, { readonly _tag: "InvalidCookie" }>;
126+
127+const ErrorWithCode = Schema.Struct({ code: Schema.String });
128+const isErrorWithCode = Schema.is(ErrorWithCode);
129+
130+const 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+
136+const isJwksSystemFailure = (cause: unknown): boolean =>
137+ isErrorWithCode(cause) && (cause.code === JWKSTimeout.code || cause.code === JWKSInvalid.code);
138+
139+const 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+
150+const 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+
168+const 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+
180+const verifyJwtOnce = (accessToken: string, jwks: CachedRemoteJWKSet) =>
181+ Effect.tryPromise({
182+ try: () => jwtVerify(accessToken, jwks),
183+ catch: (cause) => new ServiceAdapterError({ cause }),
184+ });
185+
186+const 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+
210+const 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+
62305 const completedListMetadata = {
63306 before: null,
64307 after: null,
@@ -145,12 +388,24 @@ const make = Effect.gen(function* () { · 80 unmodified lines
145388 });
146389 }
147390
148− const workos = new WorkOS({ apiKey, clientId, ...workosApiUrlOptions(env.WORKOS_API_URL) });
149−
150− const use = <A>(fn: (wos: WorkOS) => Promise<A>) =>
391+ const workos = new WorkOS({
392+ apiKey,
393+ clientId,
394+ ...workosApiUrlOptions(env.WORKOS_API_URL),
395+ });
396+ const sessionJwks = getWorkOSSessionJwks(new URL(workos.userManagement.getJwksUrl(clientId)));
397+
398+ // The public `WorkOSError` carries the upstream HTTP status when the SDK
399+ // exception had one (all its typed exceptions do), so consumers can tell a
400+ // definitive WorkOS denial (401/403/404 — fail closed) from a transient
401+ // failure (429/5xx/network — retryable).
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>) =>
151406 withServiceLogging(
152− "workos",
153− () => new WorkOSError(),
407+ `workos.${op}`,
408+ workosErrorFromFailure,
154409 tryPromiseService(() => fn(workos)),
155410 );
156411
@@ -163,25 +418,29 @@ const make = Effect.gen(function* () { · 6 unmodified lines
163418 cookiePassword,
164419 });
165420
166− const result = yield* use(() => session.authenticate());
421+ const local = yield* withServiceLogging(
422+ "workos.session.local_verify",
423+ workosErrorFromFailure,
424+ verifySealedSessionLocally(sessionData, cookiePassword, sessionJwks),
425+ );
167426
168− if (result.authenticated) {
427+ if (isLocalSessionValid(local)) {
169428 return {
170− userId: result.user.id,
171− email: result.user.email,
172− firstName: result.user.firstName,
173− lastName: result.user.lastName,
174− avatarUrl: result.user.profilePictureUrl,
175− organizationId: result.organizationId,
176− sessionId: result.sessionId,
429+ userId: local.session.user.id,
430+ email: local.session.user.email,
431+ firstName: local.session.user.firstName,
432+ lastName: local.session.user.lastName,
433+ avatarUrl: local.session.user.profilePictureUrl,
434+ organizationId: local.organizationId,
435+ sessionId: local.sessionId,
177436 refreshedSession: undefined as string | undefined,
178437 };
179438 }
180439
181− if (result.reason === "no_session_cookie_provided") return null;
440+ if (isLocalSessionInvalidCookie(local)) return null;
182441
183442 // Try refreshing
184− const refreshed = yield* use(() => session.refresh()).pipe(
443+ const refreshed = yield* use("session.refresh", () => session.refresh()).pipe(
185444 Effect.orElseSucceed(() => ({ authenticated: false as const })),
186445 );
187446
@@ -210,7 +469,7 @@ const make = Effect.gen(function* () { · 22 unmodified lines
210469 }),
211470
212471 authenticateWithCode: (code: string) =>
213− use((wos) =>
472+ use("userManagement.authenticateWithCode", (wos) =>
214473 wos.userManagement.authenticateWithCode({
215474 code,
216475 clientId,
@@ -220,11 +479,13 @@ const make = Effect.gen(function* () { · 3 unmodified lines
220479
221480 /** Create a new organization in WorkOS. */
222481 createOrganization: (name: string) =>
223− use((wos) => wos.organizations.createOrganization({ name })),
482+ use("organizations.createOrganization", (wos) =>
483+ wos.organizations.createOrganization({ name }),
484+ ),
224485
225486 /** Add a user to an organization. */
226487 createMembership: (organizationId: string, userId: string, roleSlug?: string) =>
227− use((wos) =>
488+ use("userManagement.createOrganizationMembership", (wos) =>
228489 wos.userManagement.createOrganizationMembership({
229490 organizationId,
230491 userId,
@@ -234,7 +495,7 @@ const make = Effect.gen(function* () { · 3 unmodified lines
234495
235496 /** List organization memberships for a user. */
236497 listUserMemberships: (userId: string) =>
237− use(async (wos) =>
498+ use("userManagement.listOrganizationMemberships", async (wos) =>
238499 collectWorkOSList(
239500 await wos.userManagement.listOrganizationMemberships({
240501 userId,
@@ -253,13 +514,53 @@ const make = Effect.gen(function* () { · 12 unmodified lines
253514 sessionData,
254515 cookiePassword,
255516 });
256− const refreshed = yield* use(() =>
517+ const refreshed = yield* use("session.refresh", () =>
257518 session.refresh(organizationId ? { organizationId } : undefined),
258519 );
259520 if (!refreshed.authenticated || !("sealedSession" in refreshed)) return null;
260521 return refreshed.sealedSession ?? null;
261522 }),
262523
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+
263564 /**
264565 * Authenticate a sealed session string. Returns the user info plus
265566 * any refreshed session that needs to be set on the response.
@@ -282,10 +583,13 @@ const make = Effect.gen(function* () { · 16 unmodified lines
282583 * auth/api-keys.ts.
283584 */
284585 validateApiKey: (value: string) =>
285− use((wos) => wos.apiKeys.validateApiKey({ value }) as Promise<unknown>),
586+ use(
587+ "apiKeys.validateApiKey",
588+ (wos) => wos.apiKeys.validateApiKey({ value }) as Promise<unknown>,
589+ ),
286590
287591 listUserApiKeys: (userId: string, organizationId: string) =>
288− use(async (wos) => {
592+ use("userManagement.listUserApiKeys", async (wos) => {
289593 const raw = wos as RawWorkOS;
290594 return collectRawWorkOSList(async (after) => {
291595 const response = await raw.get(`/user_management/users/${userId}/api_keys`, {
@@ -300,7 +604,7 @@ const make = Effect.gen(function* () { · 8 unmodified lines
300604 }),
301605
302606 createUserApiKey: (params: { userId: string; organizationId: string; name: string }) =>
303− use(async (wos) => {
607+ use("userManagement.createUserApiKey", async (wos) => {
304608 const raw = wos as RawWorkOS;
305609 const response = await raw.post(`/user_management/users/${params.userId}/api_keys`, {
306610 name: params.name,
@@ -309,11 +613,40 @@ const make = Effect.gen(function* () { · 2 unmodified lines
309613 return response.data;
310614 }),
311615
312− 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)),
313646
314647 /** List organization memberships with user details. */
315648 listOrgMembers: (organizationId: string) =>
316− use(async (wos) =>
649+ use("userManagement.listOrganizationMemberships", async (wos) =>
317650 collectWorkOSList(
318651 await wos.userManagement.listOrganizationMemberships({
319652 organizationId,
@@ -324,7 +657,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines
324657
325658 /** Get a user's membership in an organization. */
326659 getUserOrgMembership: (organizationId: string, userId: string) =>
327− use(async (wos) => {
660+ use("userManagement.listOrganizationMemberships", async (wos) => {
328661 const response = await wos.userManagement.listOrganizationMemberships({
329662 organizationId,
330663 userId,
@@ -334,11 +667,23 @@ const make = Effect.gen(function* () { · 3 unmodified lines
334667 }),
335668
336669 /** Get a user by ID. */
337− 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+ ),
338683
339684 /** Send an organization invitation. */
340685 sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) =>
341− use((wos) =>
686+ use("userManagement.sendInvitation", (wos) =>
342687 wos.userManagement.sendInvitation({
343688 email: params.email,
344689 organizationId: params.organizationId,
@@ -352,7 +697,7 @@ const make = Effect.gen(function* () { · 7 unmodified lines
352697 * API level, so we filter after.
353698 */
354699 listPendingInvitations: (organizationId: string) =>
355− use(async (wos) =>
700+ use("userManagement.listInvitations", async (wos) =>
356701 collectWorkOSList(
357702 await wos.userManagement.listInvitations({
358703 organizationId,
@@ -367,7 +712,7 @@ const make = Effect.gen(function* () { · 8 unmodified lines
367712
368713 /** List invitations for an email address (across all orgs). */
369714 listInvitationsByEmail: (email: string) =>
370− use(async (wos) =>
715+ use("userManagement.listInvitations", async (wos) =>
371716 collectWorkOSList(
372717 await wos.userManagement.listInvitations({
373718 email,
@@ -377,35 +722,63 @@ const make = Effect.gen(function* () { · 3 unmodified lines
377722
378723 /** Accept an invitation; returns the (now accepted) invitation. */
379724 acceptInvitation: (invitationId: string) =>
380− use((wos) => wos.userManagement.acceptInvitation(invitationId)),
725+ use("userManagement.acceptInvitation", (wos) =>
726+ wos.userManagement.acceptInvitation(invitationId),
727+ ),
381728
382729 /** Remove an organization membership. */
383730 deleteOrgMembership: (membershipId: string) =>
384− use((wos) => wos.userManagement.deleteOrganizationMembership(membershipId)),
731+ use("userManagement.deleteOrganizationMembership", (wos) =>
732+ wos.userManagement.deleteOrganizationMembership(membershipId),
733+ ),
385734
386735 /** Get the role for a membership. */
387736 getOrgMembership: (membershipId: string) =>
388− use((wos) => wos.userManagement.getOrganizationMembership(membershipId)),
737+ use("userManagement.getOrganizationMembership", (wos) =>
738+ wos.userManagement.getOrganizationMembership(membershipId),
739+ ),
389740
390741 /** Update a membership's role. */
391742 updateOrgMembershipRole: (membershipId: string, roleSlug: string) =>
392− use((wos) => wos.userManagement.updateOrganizationMembership(membershipId, { roleSlug })),
743+ use("userManagement.updateOrganizationMembership", (wos) =>
744+ wos.userManagement.updateOrganizationMembership(membershipId, {
745+ roleSlug,
746+ }),
747+ ),
393748
394749 /** List available roles for an organization. */
395750 listOrgRoles: (organizationId: string) =>
396− use((wos) => wos.organizations.listOrganizationRoles({ organizationId })),
751+ use("organizations.listOrganizationRoles", (wos) =>
752+ wos.organizations.listOrganizationRoles({ organizationId }),
753+ ),
397754
398755 /** Get an organization (includes domains). */
399756 getOrganization: (organizationId: string) =>
400− use((wos) => wos.organizations.getOrganization(organizationId)),
757+ use("organizations.getOrganization", (wos) =>
758+ wos.organizations.getOrganization(organizationId),
759+ ),
401760
402761 /** Update an organization. */
403762 updateOrganization: (organizationId: string, name: string) =>
404− use((wos) => wos.organizations.updateOrganization({ organization: organizationId, name })),
763+ use("organizations.updateOrganization", (wos) =>
764+ wos.organizations.updateOrganization({
765+ organization: organizationId,
766+ name,
767+ }),
768+ ),
769+
770+ /**
771+ * Delete an organization. Cascades in WorkOS: the org's memberships,
772+ * invitations, and domains go with it, so every member loses access.
773+ */
774+ deleteOrganization: (organizationId: string) =>
775+ use("organizations.deleteOrganization", (wos) =>
776+ wos.organizations.deleteOrganization(organizationId),
777+ ),
405778
406779 /** Generate an Admin Portal link for domain verification. */
407780 generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) =>
408− use((wos) =>
781+ use("portal.generateLink", (wos) =>
409782 wos.portal.generateLink({
410783 organization: organizationId,
411784 intent: GeneratePortalLinkIntent.DomainVerification,
@@ -415,11 +788,11 @@ const make = Effect.gen(function* () { · 3 unmodified lines
415788
416789 /** Get a domain by ID. */
417790 getOrganizationDomain: (domainId: string) =>
418− use((wos) => wos.organizationDomains.get(domainId)),
791+ use("organizationDomains.get", (wos) => wos.organizationDomains.get(domainId)),
419792
420793 /** Delete a domain claim. */
421794 deleteOrganizationDomain: (domainId: string) =>
422− use((wos) => wos.organizationDomains.delete(domainId)),
795+ use("organizationDomains.delete", (wos) => wos.organizationDomains.delete(domainId)),
423796 };
424797 });
425798
@@ -428,9 +801,10 @@ export type WorkOSClientService = Effect.Success<typeof make>; · 2 unmodified lines
428801 export class WorkOSClient extends Context.Service<WorkOSClient, WorkOSClientService>()(
429802 "@executor-js/cloud/WorkOSClient",
430803 ) {
431− static Default = Layer.effect(this)(make).pipe(
432− Layer.withSpan("WorkOSClient", { attributes: { module: "WorkOSClient" } }),
433− );
804+ // Deliberately unspanned: client construction is synchronous and ran per
805+ // layer build, which produced one of the highest-volume zero-duration span
806+ // names in the whole trace corpus while telling us nothing.
807+ static Default = Layer.effect(this)(make);
434808 }
435809
436810 // 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
22 // merge of cloud + executor schemas. If ANY call site builds its own
33 // `{ schema }` without spreading both, `db._.fullSchema` comes back
44 // 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.
66 //
77 // We hit this in prod (MCP endpoint) when `mcp-session.ts` built
88 // `combinedSchema = { ...cloudSchema }` and forgot to spread the executor
@@ -56,7 +56,7 @@ describe("combinedSchema", () => { · 47 unmodified lines
5656 }
5757 });
5858
59− // Executor-schema drives the scope-sharded tables (source/tool/etc). If
59+ // Executor-schema drives the scope-sharded tables (integration/tool/etc). If
6060 // any of these go missing the drizzle adapter's `getTable` lookup throws.
6161 it("includes scope-sharded executor tables", () => {
6262 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
2424
2525 // Exported so every drizzle() call in the cloud app shares one schema
2626 // object. Historically `mcp-session.ts` built its own and forgot to spread
27−// `executorSchema`, producing runtime "unknown model source" errors that
27+// `executorSchema`, producing runtime "unknown model integration" errors that
2828 // only surfaced in prod. See apps/cloud/src/db/db.schema.test.ts.
2929 export const combinedSchema = { ...cloudSchema, ...executorSchema };
3030
apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts+272−8
@@ -21,6 +21,8 @@ · 20 unmodified lines
2121 // with DIFFERENT parameter counts (the exact drizzle/postgres-js shape) through
2222 // one PGLiteSocketServer and asserts zero protocol corruption.
2323
24+import { setTimeout as sleep } from "node:timers/promises";
25+import { connect, type Socket } from "node:net";
2426 import { describe, expect, it } from "@effect/vitest";
2527 import { PGlite } from "@electric-sql/pglite";
2628 import { PGLiteSocketServer } from "@electric-sql/pglite-socket";
@@ -30,6 +32,50 @@ const PORT = 45998; · 3 unmodified lines
3032 const CLIENTS = 6;
3133 const QUERIES_PER_CLIENT = 40;
3234
35+const 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.
49+const 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.
72+const 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+
3379 describe("dev-db PGlite socket under concurrent connections", () => {
3480 it(
3581 "serves interleaved multi-connection pipelines without protocol corruption",
@@ -48,14 +94,7 @@ describe("dev-db PGlite socket under concurrent connections", () => { · 12 unmodified lines
4894 const errors: string[] = [];
4995
5096 const worker = async (id: number) => {
51− const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, {
52− max: 1,
53− idle_timeout: 0,
54− connect_timeout: 10,
55− fetch_types: false,
56− prepare: true,
57− onnotice: () => undefined,
58− });
97+ const sql = makeClient(PORT, 10);
5998 // 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
6099 try {
61100 for (let q = 0; q < QUERIES_PER_CLIENT; q++) {
@@ -91,4 +130,229 @@ describe("dev-db PGlite socket under concurrent connections", () => { · 29 unmodified lines
91130 expect(ok).toBe(CLIENTS * QUERIES_PER_CLIENT);
92131 },
93132 );
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+ );
94358 });
apps/cloud/src/db/executor-schema.ts+40−1
@@ -33,6 +33,22 @@ export const integration = pgTable( · 32 unmodified lines
3333 (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)],
3434 );
3535
36+export 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+
3652 export const connection = pgTable(
3753 "connection",
3854 {
@@ -86,6 +102,7 @@ export const oauth_client = pgTable( · 47 unmodified lines
86102 origin_kind: text("origin_kind"),
87103 origin_integration: text("origin_integration"),
88104 origin_issuer: text("origin_issuer"),
105+ origin_redirect_uri: text("origin_redirect_uri"),
89106 created_at: timestamp("created_at").notNull(),
90107 row_id: varchar("row_id", { length: 255 })
91108 .primaryKey()
@@ -164,7 +181,7 @@ export const definition = pgTable( · 72 unmodified lines
164181 integration: varchar("integration", { length: 255 }).notNull(),
165182 connection: varchar("connection", { length: 255 }).notNull(),
166183 plugin_id: text("plugin_id").notNull(),
167− name: varchar("name", { length: 255 }).notNull(),
184+ name: text("name").notNull(),
168185 schema: json("schema").notNull(),
169186 created_at: timestamp("created_at").notNull(),
170187 row_id: varchar("row_id", { length: 255 })
@@ -209,6 +226,28 @@ export const tool_policy = pgTable( · 38 unmodified lines
209226 ],
210227 );
211228
229+export 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+
212251 export const plugin_storage = pgTable(
213252 "plugin_storage",
214253 {
apps/cloud/src/db/org-deletion.test.ts+363−0
@@ -0,0 +1,363 @@
1+// ---------------------------------------------------------------------------
2+// purgeOrganizationData — org deletion cascade
3+// ---------------------------------------------------------------------------
4+//
5+// Runs inside the Cloudflare Workers runtime against a real PGlite Postgres
6+// (scripts/test-globalsetup.ts), the same path api.ts uses per request. Seeds
7+// two orgs across every tenant table + blob namespace, purges one, and asserts:
8+// - every executor tenant table row for the target org is gone
9+// - org- and user-scoped secret blobs for the target org are gone
10+// - the identity row is gone and its memberships cascade with it
11+// - a second org's data is completely untouched
12+// - the blob prefix match escapes LIKE wildcards (a `_` in the org id must
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.
19+
20+import { describe, it, expect } from "@effect/vitest";
21+import { Effect } from "effect";
22+import { eq, getTableColumns, getTableName, inArray } from "drizzle-orm";
23+
24+import { DbService } from "./db";
25+import type { DrizzleDb } from "./db";
26+import { makeUserStore } from "../auth/user-store";
27+import * as cloudSchema from "./schema";
28+import * as executorSchema from "./executor-schema";
29+import { memberships, accounts } from "./schema";
30+import {
31+ artifact,
32+ blob,
33+ connection,
34+ definition,
35+ integration,
36+ oauth_client,
37+ oauth_session,
38+ plugin_storage,
39+ subject,
40+ tool,
41+ tool_policy,
42+} from "./executor-schema";
43+
44+const program = <A, E>(body: Effect.Effect<A, E, DbService>) =>
45+ Effect.runPromise(
46+ body.pipe(Effect.provide(DbService.Live), Effect.scoped) as Effect.Effect<A, E, never>,
47+ );
48+
49+// Insert one row into every tenant table, plus an org- and a user-scoped blob,
50+// all owned by `tenant`. `tag` keeps unique indexes from colliding across orgs.
51+const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => {
52+ const now = new Date();
53+ await db.insert(integration).values({
54+ slug: `int-${tag}`,
55+ plugin_id: "p",
56+ created_at: now,
57+ updated_at: now,
58+ tenant,
59+ });
60+ await db.insert(connection).values({
61+ integration: "int",
62+ name: `conn-${tag}`,
63+ template: "t",
64+ provider: "pr",
65+ item_ids: [],
66+ created_at: now,
67+ updated_at: now,
68+ tenant,
69+ owner: "o",
70+ subject: "s",
71+ });
72+ await db.insert(oauth_client).values({
73+ slug: `oc-${tag}`,
74+ authorization_url: "u",
75+ token_url: "u",
76+ grant: "g",
77+ client_id: "c",
78+ created_at: now,
79+ tenant,
80+ owner: "o",
81+ subject: "s",
82+ });
83+ await db.insert(oauth_session).values({
84+ state: `state-${tag}`,
85+ client_slug: "cs",
86+ integration: "int",
87+ name: "n",
88+ template: "t",
89+ redirect_url: "r",
90+ payload: {},
91+ expires_at: 0n,
92+ created_at: now,
93+ tenant,
94+ owner: "o",
95+ subject: "s",
96+ });
97+ await db.insert(tool).values({
98+ integration: "int",
99+ connection: "conn",
100+ plugin_id: "p",
101+ name: `tool-${tag}`,
102+ description: "d",
103+ created_at: now,
104+ updated_at: now,
105+ tenant,
106+ owner: "o",
107+ subject: "s",
108+ });
109+ await db.insert(definition).values({
110+ integration: "int",
111+ connection: "conn",
112+ plugin_id: "p",
113+ name: `def-${tag}`,
114+ schema: {},
115+ created_at: now,
116+ tenant,
117+ owner: "o",
118+ subject: "s",
119+ });
120+ await db.insert(tool_policy).values({
121+ id: `tp-${tag}`,
122+ pattern: "*",
123+ action: "allow",
124+ position: "1",
125+ created_at: now,
126+ updated_at: now,
127+ tenant,
128+ owner: "o",
129+ subject: "s",
130+ });
131+ await db.insert(plugin_storage).values({
132+ plugin_id: "p",
133+ collection: "col",
134+ key: `k-${tag}`,
135+ data: {},
136+ created_at: now,
137+ updated_at: now,
138+ tenant,
139+ owner: "o",
140+ subject: "s",
141+ });
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+
161+ const orgNs = `o:${tenant}/plugin`;
162+ const userNs = `u:${tenant}:subject/plugin`;
163+ await db.insert(blob).values({
164+ namespace: orgNs,
165+ key: "k",
166+ value: "v",
167+ id: JSON.stringify([orgNs, "k"]),
168+ });
169+ await db.insert(blob).values({
170+ namespace: userNs,
171+ key: "k",
172+ value: "v",
173+ id: JSON.stringify([userNs, "k"]),
174+ });
175+};
176+
177+const TENANT_TABLES = [
178+ integration,
179+ connection,
180+ oauth_client,
181+ oauth_session,
182+ tool,
183+ definition,
184+ tool_policy,
185+ plugin_storage,
186+ subject,
187+ artifact,
188+] as const;
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.
193+const 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+
206+const countTenantRows = async (db: DrizzleDb, tenant: string): Promise<number> => {
207+ let total = 0;
208+ for (const table of TENANT_TABLES) {
209+ const rows = await db.select().from(table).where(eq(table.tenant, tenant));
210+ total += rows.length;
211+ }
212+ // Count by the exact namespaces `seedTenant` inserts. Matching on a `%tenant%`
213+ // LIKE here would reintroduce the very wildcard hazard under test.
214+ const blobs = await db
215+ .select()
216+ .from(blob)
217+ .where(inArray(blob.namespace, [`o:${tenant}/plugin`, `u:${tenant}:subject/plugin`]));
218+ return total + blobs.length;
219+};
220+
221+describe("purgeOrganizationData", () => {
222+ it("removes all of one org's data and leaves other orgs untouched", async () => {
223+ // Underscore in the id is deliberate: an unescaped `_` in a LIKE pattern is
224+ // a single-char wildcard, so the escaping has to hold for this to be safe.
225+ const orgA = `org_del_${crypto.randomUUID().slice(0, 8)}`;
226+ const orgB = `org_keep_${crypto.randomUUID().slice(0, 8)}`;
227+ const accountId = `user_${crypto.randomUUID().slice(0, 8)}`;
228+
229+ // A look-alike blob that only an UNescaped `_` wildcard would match:
230+ // `o:<orgA>/…` with the underscore replaced by another char.
231+ const trapNs = `o:${orgA.replace("_", "X")}/plugin`;
232+
233+ await program(
234+ Effect.gen(function* () {
235+ const { db } = yield* DbService;
236+ yield* Effect.promise(async () => {
237+ const store = makeUserStore(db);
238+ await store.upsertOrganization({ id: orgA, name: "Delete Me" });
239+ await store.upsertOrganization({ id: orgB, name: "Keep Me" });
240+ await store.ensureAccount(accountId);
241+ await db.insert(memberships).values({ accountId, organizationId: orgA });
242+ await db.insert(memberships).values({ accountId, organizationId: orgB });
243+ await seedTenant(db, orgA, "a");
244+ await seedTenant(db, orgB, "b");
245+ await db.insert(blob).values({
246+ namespace: trapNs,
247+ key: "k",
248+ value: "v",
249+ id: JSON.stringify([trapNs, "k"]),
250+ });
251+ });
252+ }),
253+ );
254+
255+ await program(
256+ Effect.gen(function* () {
257+ const { db } = yield* DbService;
258+ yield* Effect.promise(() => makeUserStore(db).deleteOrganizationCascade(orgA));
259+ }),
260+ );
261+
262+ await program(
263+ Effect.gen(function* () {
264+ const { db } = yield* DbService;
265+ yield* Effect.promise(async () => {
266+ const store = makeUserStore(db);
267+
268+ // Target org: every tenant row + blob gone, identity gone, membership
269+ // cascaded, but the shared account survives (it may join other orgs).
270+ expect(await countTenantRows(db, orgA)).toBe(0);
271+ expect(await store.getOrganization(orgA)).toBeNull();
272+ const orgAMemberships = await db
273+ .select()
274+ .from(memberships)
275+ .where(eq(memberships.organizationId, orgA));
276+ expect(orgAMemberships).toHaveLength(0);
277+ const account = await db.select().from(accounts).where(eq(accounts.id, accountId));
278+ expect(account, "the account outlives the org").toHaveLength(1);
279+
280+ // The look-alike blob must survive: escaping keeps `_` literal.
281+ const trap = await db.select().from(blob).where(eq(blob.namespace, trapNs));
282+ expect(trap, "escaped LIKE must not match a look-alike namespace").toHaveLength(1);
283+
284+ // Second org: fully intact.
285+ expect(await countTenantRows(db, orgB)).toBeGreaterThan(0);
286+ expect(await store.getOrganization(orgB)).not.toBeNull();
287+ const orgBMemberships = await db
288+ .select()
289+ .from(memberships)
290+ .where(eq(memberships.organizationId, orgB));
291+ expect(orgBMemberships).toHaveLength(1);
292+ });
293+ }),
294+ );
295+ }, 20_000);
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.
311+describe("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+72−0
@@ -0,0 +1,72 @@
1+// ---------------------------------------------------------------------------
2+// Organization data purge — removes every local trace of an org in one txn
3+// ---------------------------------------------------------------------------
4+//
5+// Identity (accounts/organizations/memberships) and executor tenant data
6+// (integrations, connections, tools, secrets, ...) share ONE Postgres database
7+// (`combinedSchema` in `db.ts`), so deleting an org is a single transaction
8+// here rather than a fan-out across stores. Tenant rows carry the org id in a
9+// `tenant` column; secrets/tokens live in `blob`, namespaced by owner.
10+//
11+// External side effects (the WorkOS org, the Autumn customer) are NOT touched
12+// here — the caller (auth handler) sequences those around this purge.
13+
14+import { eq, or, sql } from "drizzle-orm";
15+
16+import type { DrizzleDb } from "./db";
17+import { organizations } from "./schema";
18+import {
19+ artifact,
20+ blob,
21+ connection,
22+ definition,
23+ integration,
24+ oauth_client,
25+ oauth_session,
26+ plugin_storage,
27+ subject,
28+ tool,
29+ tool_policy,
30+} from "./executor-schema";
31+
32+// Escape LIKE wildcards (`\`, `%`, `_`) in the org id before using it as a
33+// prefix. WorkOS org ids contain underscores, and a bare `_` in a LIKE pattern
34+// matches any character, which would widen the match to unrelated tenants.
35+const escapeLike = (value: string): string => value.replace(/[\\%_]/g, "\\$&");
36+
37+/**
38+ * Delete all rows owned by `organizationId`: every executor tenant table, the
39+ * org's secret blobs (org- and user-scoped), and the identity mirror row (which
40+ * cascades to local `memberships`). Idempotent — a second run deletes nothing.
41+ */
42+export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Promise<void> =>
43+ db.transaction(async (tx) => {
44+ // Executor tenant tables — every row is scoped by `tenant = organizationId`.
45+ await tx.delete(tool).where(eq(tool.tenant, organizationId));
46+ await tx.delete(definition).where(eq(definition.tenant, organizationId));
47+ await tx.delete(connection).where(eq(connection.tenant, organizationId));
48+ await tx.delete(integration).where(eq(integration.tenant, organizationId));
49+ await tx.delete(oauth_client).where(eq(oauth_client.tenant, organizationId));
50+ await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId));
51+ await tx.delete(tool_policy).where(eq(tool_policy.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));
55+
56+ // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by
57+ // owner: `o:<org>/<plugin>` (org scope) and `u:<org>:<subject>/<plugin>`
58+ // (per-user scope). Match both prefixes for this org.
59+ const esc = escapeLike(organizationId);
60+ await tx
61+ .delete(blob)
62+ .where(
63+ or(
64+ sql`${blob.namespace} LIKE ${`o:${esc}/%`} ESCAPE '\\'`,
65+ sql`${blob.namespace} LIKE ${`u:${esc}:%`} ESCAPE '\\'`,
66+ ),
67+ );
68+
69+ // Identity mirror — FK `ON DELETE CASCADE` removes local memberships too.
70+ // `accounts` are intentionally left: a user may belong to other orgs.
71+ await tx.delete(organizations).where(eq(organizations.id, organizationId));
72+ });
apps/cloud/src/edge/docs.ts+9−29
@@ -6,43 +6,23 @@ · 5 unmodified lines
66 // base path, so the pathname is forwarded UNCHANGED — only the host/proto swap
77 // to the upstream origin (unlike the PostHog proxy, which strips its prefix).
88 //
9−// Like the PostHog/Sentry tunnels (and unlike the marketing proxy, which needs
10−// the prod-only `env.MARKETING` service binding), this is a plain external
11−// `fetch`, so it runs on every host — `/docs` previews against live Mintlify in
12−// local dev too. `/docs` is distinct from the app-owned `/api/docs` (Swagger),
13−// so this never shadows an Effect-served route.
9+// The matching, upstream construction, and client span live in `./passthrough`,
10+// which server.ts dispatches BEFORE Start loads: forwarding a docs page must
11+// not pay for the whole server graph. This middleware stays registered so hosts
12+// that reach Start by another entry (local dev) keep identical behavior; in the
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.
1415 // ---------------------------------------------------------------------------
1516
1617 import { createMiddleware } from "@tanstack/react-start";
1718
18−const DOCS_UPSTREAM_HOST = "executor.mintlify.dev";
19+import { docsProxyResponse, isDocsPath } from "./passthrough";
1920
20−export const isDocsPath = (pathname: string) =>
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.
26−export 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−};
21+export { buildDocsUpstream, isDocsPath } from "./passthrough";
4222
4323 export const docsProxyMiddleware = createMiddleware({ type: "request" }).server(
4424 ({ pathname, request, next }) => {
4525 if (!isDocsPath(pathname)) return next();
46− return fetch(buildDocsUpstream(request));
26+ return docsProxyResponse(request, pathname);
4727 },
4828 );
apps/cloud/src/edge/index.ts+4−5
@@ -1,11 +1,10 @@
11 // ---------------------------------------------------------------------------
2−// Edge concerns — the analytics/marketing/docs request middlewares that run at
3−// the worker edge BEFORE the app's own mcp + api dispatch. None of these touch
4−// the Effect app layer; they proxy or tunnel to external services (the
5−// marketing worker, Sentry, PostHog, Mintlify docs).
2+// Edge concerns — request middleware that runs before the app's own mcp + api
3+// dispatch. These proxy or tunnel to external services without touching the
4+// Effect app layer. Marketing is dispatched even earlier, from server.ts, so a
5+// public page never loads the TanStack Start graph.
66 // ---------------------------------------------------------------------------
77
8−export { marketingMiddleware } from "./marketing";
98 export { sentryTunnelMiddleware } from "./sentry-tunnel";
109 export { posthogProxyMiddleware } from "./posthog";
1110 export { docsProxyMiddleware } from "./docs";
apps/cloud/src/edge/marketing.test.ts+55−1
@@ -1,6 +1,6 @@
11 import { describe, expect, it } from "@effect/vitest";
22
3−import { isMarketingPath } from "./marketing";
3+import { isMarketingPath, marketingProxyRequest } from "./marketing";
44
55 // On executor.sh the marketing middleware proxies an allow-list of paths to the
66 // `executor-marketing` worker; everything else falls through to the auth-gated
@@ -12,6 +12,9 @@ describe("isMarketingPath", () => { · 5 unmodified lines
1212 "/home",
1313 "/privacy",
1414 "/terms",
15+ "/about-executor",
16+ "/google-oauth",
17+ "/google-workspace",
1518 "/blog",
1619 "/blog/",
1720 "/blog/some-post",
@@ -37,3 +40,54 @@ describe("isMarketingPath", () => { · 19 unmodified lines
3740 });
3841 }
3942 });
43+
44+describe("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
33 //
44 // On the production domain (`executor.sh`), marketing paths and the
55 // unauthenticated landing page are served by the separate `executor-marketing`
6−// worker (bound as `env.MARKETING`). In local dev that worker isn't running, so
7−// unauthenticated visits fall through to the cloud app's routes (the sign-in
8−// page).
6+// worker. This module deliberately has no TanStack Start or cloud application
7+// imports: the Worker entry calls it before loading the Start server graph.
98 // ---------------------------------------------------------------------------
109
11−import { env } from "cloudflare:workers";
12−import { createMiddleware } from "@tanstack/react-start";
13−
1410 import { parseCookie } from "../auth/cookies";
1511
1612 const MARKETING_PATHS = [
@@ -18,6 +14,9 @@ const MARKETING_PATHS = [ · 1 unmodified line
1814 "/setup",
1915 "/privacy",
2016 "/terms",
17+ "/about-executor",
18+ "/google-oauth",
19+ "/google-workspace",
2120 "/blog",
2221 "/llms.txt",
2322 "/api/detect",
@@ -27,33 +26,25 @@ const MARKETING_PATHS = [ · 3 unmodified lines
2726 "/pattern-graph-paper.svg",
2827 ];
2928
30−export const isMarketingPath = (pathname: string) =>
31− MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
32−
33−const getMarketingWorker = () => env.MARKETING as { fetch: typeof fetch } | undefined;
34−
35−export const marketingMiddleware = createMiddleware({ type: "request" }).server(
36− async ({ pathname, request, next }) => {
37− // Only proxy to the marketing worker on the production domain. In local
38− // dev we don't run `executor-marketing`, so unauthenticated visits fall
39− // through to the cloud app's routes (which show the sign-in page).
40− const host = new URL(request.url).hostname;
41− if (host !== "executor.sh") return next();
29+const SESSION_COOKIE = "wos-session";
4230
43− const shouldProxyToMarketing =
44− isMarketingPath(pathname) ||
45− (pathname === "/" && !parseCookie(request.headers.get("cookie"), "wos-session"));
46−
47− if (!shouldProxyToMarketing) return next();
48−
49− const marketing = getMarketingWorker();
50− if (!marketing) return next();
31+/** Whether an exact pathname belongs to the public marketing worker. */
32+export const isMarketingPath = (pathname: string): boolean =>
33+ MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
5134
52− const url = new URL(request.url);
53− // Rewrite /home to / so marketing worker serves its homepage
54− if (pathname === "/home") {
55− url.pathname = "/";
56− }
57− return marketing.fetch(new Request(url, request));
58− },
59−);
35+/**
36+ * Project a production request onto the marketing service-binding request.
37+ * Returns `null` when the cloud application owns the request instead.
38+ */
39+export const marketingProxyRequest = (request: Request): Request | null => {
40+ const url = new URL(request.url);
41+ if (url.hostname !== "executor.sh") return null;
42+
43+ const shouldProxy =
44+ isMarketingPath(url.pathname) ||
45+ (url.pathname === "/" && !parseCookie(request.headers.get("cookie"), SESSION_COOKIE));
46+ if (!shouldProxy) return null;
47+
48+ if (url.pathname === "/home") url.pathname = "/";
49+ return new Request(url, request);
50+};
apps/cloud/src/edge/passthrough.test.ts+85−0
@@ -0,0 +1,85 @@
1+import { readFileSync } from "node:fs";
2+import { describe, expect, it } from "@effect/vitest";
3+
4+import {
5+ buildDocsUpstream,
6+ buildPosthogUpstream,
7+ isDocsPath,
8+ isPosthogPath,
9+ passthroughResponse,
10+ POSTHOG_PROXY_PATH,
11+} from "./passthrough";
12+
13+describe("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+
38+describe("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+
70+describe("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+
28+import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
29+
30+const 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. */
34+export const POSTHOG_INGEST_HOST = "us.i.posthog.com";
35+const POSTHOG_ASSETS_HOST = "us-assets.i.posthog.com";
36+
37+export 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.
46+const tracer = trace.getTracer("executor-cloud-docs-proxy");
47+
48+export const isDocsPath = (pathname: string): boolean =>
49+ pathname === "/docs" || pathname.startsWith("/docs/");
50+
51+export const isPosthogPath = (pathname: string): boolean =>
52+ pathname === POSTHOG_PROXY_PATH || pathname.startsWith(`${POSTHOG_PROXY_PATH}/`);
53+
54+/**

84 more lines aren’t shown here.

apps/cloud/src/edge/posthog.ts+8−21

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

apps/cloud/src/engine/execution-gate.ts+7−0

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

apps/cloud/src/engine/execution-rate-limit.node.test.ts+435−0

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

apps/cloud/src/engine/execution-rate-limit.ts+335−37

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

apps/cloud/src/engine/execution-stack-metered.ts+15−2

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

apps/cloud/src/engine/execution-stack.ts+116−2

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

apps/cloud/src/engine/execution-usage.ts+8−2

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

apps/cloud/src/engine/first-party-oauth-clients.test.ts+87−0

This diff is large: its 88 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/member-seats.ts+53−0

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

apps/cloud/src/extensions/billing/route.node.test.ts+16−15

This diff is large: its 69 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+147−10

This diff is large: its 213 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+165−14

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

apps/cloud/src/mcp/auth-provider.test.ts+175−0

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

apps/cloud/src/mcp/auth-provider.ts+99−31

This diff is large: its 194 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.tsx+1−1

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

apps/cloud/src/routes/app/billing_.plans.tsx+47−30

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

apps/cloud/src/routes/app/org.tsx+147−1

This diff is large: its 182 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+17−1

This diff is large: its 41 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+32−0

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

apps/desktop/electron-builder.config.ts+21−0

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

apps/desktop/package.json+1−3

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

apps/desktop/scripts/check-build-resources.ts+97−0

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

apps/desktop/src/build-resources.test.ts+12−0

This diff is large: its 13 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+9−0

This diff is large: its 31 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+153−19

This diff is large: its 227 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+332−0

This diff is large: its 338 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+29−7

This diff is large: its 81 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+94−0

This diff is large: its 115 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+21−9

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

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

This diff is large: its 115 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/index.ts+14−2

This diff is large: its 35 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+16−1

This diff is large: its 43 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+52−3

This diff is large: its 98 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+164−0

This diff is large: its 166 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.test.ts+52−1

This diff is large: its 65 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-pre-initialize.test.ts+110−0

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

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

This diff is large: its 56 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+14−14

This diff is large: its 81 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+25−11

This diff is large: its 85 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+192−0

This diff is large: its 198 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+49−5

This diff is large: its 84 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+660−0

This diff is large: its 661 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-priming-reconnect.test.ts+218−0

This diff is large: its 219 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-sse-replay.test.ts+387−0

This diff is large: its 388 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/mcp-workos-blip-session-survival.test.ts+256−0

This diff is large: its 257 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/member-seat-billing-sync.test.ts+107−0

This diff is large: its 108 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+79−0

This diff is large: its 80 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/fixtures/stdio-mcp-server.mjs+25−0

This diff is large: its 39 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+174−97

This diff is large: its 293 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/recordings/slack-mcp-oauth-app-setup.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+203−10

This diff is large: its 260 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+223−34

This diff is large: its 342 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/mcp-session-idle-eviction.test.ts+172−0

This diff is large: its 173 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+17−2

This diff is large: its 47 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+136−6

This diff is large: its 202 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+224−0

This diff is large: its 230 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+128−0

This diff is large: its 134 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+6−2

This diff is large: its 35 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+66−0

This diff is large: its 67 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+150−0

This diff is large: its 156 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−1

This diff is large: its 22 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−6

This diff is large: its 71 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+160−19

This diff is large: its 266 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+123−5

This diff is large: its 176 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+112−0

This diff is large: its 118 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+112−0

This diff is large: its 118 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+136−0

This diff is large: its 142 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+580−12

This diff is large: its 618 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+576−56

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

packages/core/execution/src/tool-invoker.ts+121−34

This diff is large: its 297 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+97−0

This diff is large: its 103 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+708−2

This diff is large: its 745 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+79−20

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

packages/core/sdk/src/errors.ts+65−4

This diff is large: its 104 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+1934−227

This diff is large: its 2888 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/health-check.ts+98−3

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

packages/core/sdk/src/health-response-sample-redaction.test.ts+183−0

This diff is large: its 184 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+96−2

This diff is large: its 236 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+251−23

This diff is large: its 340 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+1175−203

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

packages/core/sdk/src/oauth-helpers.test.ts+628−1

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

packages/core/sdk/src/oauth-helpers.ts+872−53

This diff is large: its 1057 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+337−2

This diff is large: its 353 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+883−131

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

packages/core/sdk/src/oauth-session-cleanup.test.ts+406−0

This diff is large: its 407 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-after-commit.test.ts+116−0

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

packages/core/sdk/src/plugin.ts+151−12

This diff is large: its 299 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+52−0

This diff is large: its 53 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+36−0

This diff is large: its 91 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+112−0

This diff is large: its 118 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+164−0

This diff is large: its 170 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+583−5

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

packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+541−60

This diff is large: its 866 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+176−0

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

packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts+219−0

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

packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts+205−2

This diff is large: its 266 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-alarm-policy.test.ts+78−0

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

packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts+65−4

This diff is large: its 105 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/cloudflare/src/mcp/session-stub.ts+1−1

This diff is large: its 9 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+73−0

This diff is large: its 74 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+147−0

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

packages/hosts/mcp/src/envelope.ts+191−33

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

packages/hosts/mcp/src/in-memory-session-store.test.ts+430−0

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

packages/hosts/mcp/src/in-memory-session-store.ts+227−15

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

packages/hosts/mcp/src/index.ts+7−1

This diff is large: its 12 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/stdio-integration.test.ts+181−21

This diff is large: its 233 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+32−0

This diff is large: its 38 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/effect-errors.ts+23−0

This diff is large: its 27 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+11−0

This diff is large: its 32 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+10−0

This diff is large: its 24 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+117−11

This diff is large: its 153 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+215−8

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

packages/kernel/runtime-dynamic-worker/src/host-timeout.test.ts+195−0

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

packages/kernel/runtime-dynamic-worker/src/index.ts+3−0

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

packages/kernel/runtime-dynamic-worker/src/invocation.test.ts+136−0

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

packages/kernel/runtime-dynamic-worker/src/module-template.ts+35−5

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

packages/kernel/runtime-dynamic-worker/src/wedge-repro.test.ts+58−0

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

packages/kernel/runtime-quickjs/CHANGELOG.md+114−0

This diff is large: its 120 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+94−0

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

packages/kernel/runtime-quickjs/src/index.ts+103−20

This diff is large: its 278 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+113−0

This diff is large: its 114 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+112−0

This diff is large: its 118 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.

This diff is larger than a page shows: the files after these aren’t listed. Clone the repository to see all of it.