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
7A 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
9Only 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
11Self-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
18 "@executor-js/plugin-openapi",18 "@executor-js/plugin-openapi",
19 "@executor-js/plugin-example",19 "@executor-js/plugin-example",
20 "@executor-js/plugin-desktop-settings",20 "@executor-js/plugin-desktop-settings",
21 "@executor-js/desktop"21 "@executor-js/desktop",
22 "@executor-js/local"
22 ]23 ]
23 ],24 ],
24 "linked": [],25 "linked": [],
@@ -29,7 +30,6 @@ · 4 unmodified lines
29 "onlyUpdatePeerDependentsWhenOutOfRange": true30 "onlyUpdatePeerDependentsWhenOutOfRange": true
30 },31 },
31 "ignore": [32 "ignore": [
32 "@executor-js/local",
33 "@executor-js/host-mcp",33 "@executor-js/host-mcp",
34 "@executor-js/ir",34 "@executor-js/ir",
35 "@executor-js/runtime-deno-subprocess",35 "@executor-js/runtime-deno-subprocess",
.changeset/connection-create-conflict.md+7−1
@@ -2,4 +2,10 @@ · 1 unmodified line
2"@executor-js/sdk": patch2"@executor-js/sdk": patch
3---3---
4 4
5Creating 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
9This 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
11OAuth 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
9Deferring 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
11The 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
15Sequencing 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
7An 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
9Starting 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
11The 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
9The 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
17Key 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
19Two 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
24Neither 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
7A 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
9Two 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
14The 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
16The 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
7MCP OAuth methods can now carry an optional non-empty scope list. Declared scopes
8take precedence over protected-resource scope discovery, so servers with fixed
9scopes can connect even when their dynamically registered OAuth client has no
10resource identifier. Existing integrations without declared scopes keep
11discovering 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
7The 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
11Because 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
13Reuse 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
7Only `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
9Pre-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
5Evict 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
7A 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
7A 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
9A 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
11If 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
7Refreshing 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
9The 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
11The 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
13The 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
7A token-endpoint failure renders a preview of the upstream body into its
8message, and that message is persisted onto connection health, returned to the
9caller, and carried into telemetry. On a malformed HTTP 200 the body being
10previewed is a _successful_ token response, so an access token and a refresh
11token could be rendered into it.
12
13The preview is now built from an allowlist of fields that are safe to show
14(`error`, `errors`, `error_description`, `error_uri`, plus `code`, `message`,
15and `detail` nested inside them) instead of a denylist of fields to hide. A
16field nobody anticipated is omitted by default rather than printed by default.
17Keys stay visible and only non-allowlisted string values are replaced, so an
18operator can still read the shape of what the server sent. `code` is readable
19only when nested, because at the top level of a token response it is the RFC
206749 authorization code.
21
22Form-encoded bodies take the same allowlist, the walk over a body is
23depth-bounded, and the failure summary records the token endpoint's hostname
24rather than its full URL, which can carry identifiers in its path.
25
26On that same malformed-200 path the failure no longer keeps the underlying
27rejection as its `cause`. That rejection carries the parsed token response, so
28keeping it put the raw tokens back into anything that renders the whole failure
29rather than only its message. Everything the path needs from the body — the
30status, the error code, the redacted preview — is read before the failure is
31built. A transport failure still keeps its cause, which is what tells a DNS miss
32apart from a refused connection.
33
34No public API changes. The dead-grant classification added for HTTP 200 refresh
35refusals 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
5Multipart 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
7The 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
12The 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
5Persist 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
5Keep 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
7The Team plan moves from $150 per organization with a 250,000-execution
8allowance to $15 per member per month with unlimited executions. The
9`members` feature is unarchived in `autumn.config.ts` and billed in arrears
10on the seat count the app reports; Free keeps its 3-member, 100,000-execution
11shape and Enterprise stays custom with seat usage tracked for visibility.
12
13Seat counts reconcile from a full WorkOS recount (active members only —
14pending invites hold a seat for the plan gate but are not billed) after
15member removal, invitation acceptance, organization creation, and on every
16login 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
18seat pricing have no members balance and are skipped, so existing
19subscriptions keep billing exactly as before on their current plan version.
20
21The plans page, billing page, and marketing pricing cards now show the
22per-member price.
.claude/skills/emulate/SKILL.md+15−12
@@ -32,18 +32,22 @@ const github = await createEmulator({ service: "github", port: 4501 }); · 31 unmodified lines
32`servers`) when a proxy fronts the emulator — the bind stays on `port`.32`servers`) when a proxy fronts the emulator — the bind stays on `port`.
33 33
34**Attach to a running one** (another process, or hosted on Cloudflare with34**Attach to a running one** (another process, or hosted on Cloudflare with
35Durable Object state at `https://<service>.emulators.dev` /35Durable Object state at `https://<service>.<instance>.emulators.dev` —
36`https://<service>.<instance>.emulators.dev` — catalog at36catalog at `GET https://emulators.dev/_emulate/services`). Service hosts
37`GET https://emulators.dev/_emulate/services`):37(`https://<service>.emulators.dev`) are control plane only, with no shared
38default instance behind them: create a private instance first and connect to
39the returned `providerBaseUrl`.
38 40
39```ts41```ts
40import { connectEmulator } from "@executor-js/emulate";42import { connectEmulator } from "@executor-js/emulate";
41const resend = await connectEmulator({ baseUrl: "https://resend.emulators.dev" });43const created = await fetch("https://resend.emulators.dev/_emulate/instances", { method: "POST" });
44const { providerBaseUrl } = await created.json();
45const resend = await connectEmulator({ baseUrl: providerBaseUrl });
42// optional: { service: "resend" } verifies the manifest on connect46// optional: { service: "resend" } verifies the manifest on connect
43```47```
44 48
45Create a private hosted instance with `POST /_emulate/instances` on the49In e2e scenarios use `createEmulatorInstance` from
46service host.50`e2e/src/emulator-instance.ts`, which wraps this.
47 51
48## The typed control-plane client52## The typed control-plane client
49 53
@@ -113,12 +117,11 @@ The emulate repo's own `AGENTS.md` / `README.md` carry the current build, · 63 unmodified lines
113publish, and deploy commands (npm + Cloudflare creds are in 1Password). Read117publish, and deploy commands (npm + Cloudflare creds are in 1Password). Read
114them there rather than memorizing flags here — they move.118them there rather than memorizing flags here — they move.
115 119
116**A hot deploy can redden other people's e2e.** `*.emulators.dev` service120**A hot deploy can redden other people's e2e.** The `emulate-hosts` worker
117hosts are shared infrastructure; a control-plane regression there has failed121behind `*.emulators.dev` is shared infrastructure; a control-plane regression
118unrelated PRs' suites before. When a scenario needs isolation or a behavior122there has failed unrelated PRs' suites before. Scenarios always run on private
119that isn't deployed yet, pin to a published package version or mint a private123per-run instances (`POST /_emulate/instances`); when a scenario needs behavior
120per-run instance (`POST /_emulate/instances`) instead of mutating the shared124that isn't deployed yet, pin to a published package version.
121service host.
122 125
123## Gotchas126## Gotchas
124 127
.claude/skills/prod-telemetry/SKILL.md+18−1
@@ -29,6 +29,20 @@ join the same traces via traceparent). · 28 unmodified lines
29 29
30**Span names worth querying** (and their custom attrs):30**Span names worth querying** (and their custom attrs):
31 31
32- `mcp.execute` / `mcp.execute.resume` — `mcp.execute.mode`
33 (`pausable`/`inline`), `mcp.execute.code_length`, and
34 `mcp.execute.outcome` (`ok`/`fail`/`paused`) with, on failures,
35 `mcp.execute.error_kind` (`type_error` | `reference_error` |
36 `syntax_error` | `range_error` | `tool_error` | `timeout` |
37 `resource_limit` | `serialization_error` | `thrown` | `unknown`).
38 Sandbox script failures ride the MCP success channel, so `status.code`
39 stays OK — filter on these attributes, not span status. Spans from
40 before the attributes shipped carry neither; absence is not success.
41 Also `mcp.execute.result_chars` (compact-JSON size of the returned
42 value, pre-truncation; -1 = unmeasurable), `mcp.execute.log_chars`,
43 `mcp.execute.emitted` — the dump-vs-narrow signal (the model preview
44 truncates at 30k chars, so `result_chars > 30000` means the model tried
45 to pull a truncated blob into context).
32- `executor.tool.execute` — `mcp.tool.name` (full address), and since46- `executor.tool.execute` — `mcp.tool.name` (full address), and since
33 PR #992: `executor.tool.outcome` (`ok`/`fail`),47 PR #992: `executor.tool.outcome` (`ok`/`fail`),
34 `executor.tool.error_code`, `executor.tool.error_status`,48 `executor.tool.error_code`, `executor.tool.error_status`,
@@ -39,7 +53,10 @@ join the same traces via traceparent). · 4 unmodified lines
39 `base_url`, and since PR #992 `http.status_code`.53 `base_url`, and since PR #992 `http.status_code`.
40- `mcp.request` (outer) — `mcp.auth.organization_id`,54- `mcp.request` (outer) — `mcp.auth.organization_id`,
41 `mcp.auth.account_id`, `mcp.tool.name`, CF edge fields (`cf.country`…),55 `mcp.auth.account_id`, `mcp.tool.name`, CF edge fields (`cf.country`…),
42 MCP client fingerprint (`mcp.client.name`…).56 MCP client fingerprint (`mcp.client.name`…), and on managed-cloud
57 `execute`/`execute-action` calls `mcp.execute.code` (the script itself,
58 capped at 10k chars — cloud-only content capture; local/self-host
59 telemetry never records content).
43 60
44**Recipe — error signatures by class (the daily-digest query):**61**Recipe — error signatures by class (the daily-digest query):**
45 62
.github/workflows/ci.yml+151−57
@@ -10,6 +10,18 @@ concurrency: · 9 unmodified lines
10 group: ci-${{ github.ref }}10 group: ci-${{ github.ref }}
11 cancel-in-progress: true11 cancel-in-progress: true
12 12
13# CI is not a user. Without this, executor's own product telemetry reports to the
14# production PostHog project from every e2e shard: the analytics layer
15# (integration_added, execution_completed, artifact_*) and the
16# integrations-registry fetch, which lands as a `hit` event. Each shard is a
17# fresh container, so it mints a fresh anonymous id and arrives on a fresh
18# runner IP — inflating machine and user counts by roughly an order of
19# magnitude. Both opt-outs are the packages' documented CI/test hooks.
20env:
21 DO_NOT_TRACK: "1"
22 EXECUTOR_DISABLE_ANALYTICS: "1"
23 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
24
13jobs:25jobs:
14 changes:26 changes:
15 name: Changed paths27 name: Changed paths
@@ -18,6 +30,7 @@ jobs: · 2 unmodified lines
18 outputs:30 outputs:
19 desktop_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop_smoke == 'true' }}31 desktop_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop_smoke == 'true' }}
20 selfhost_docker_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.selfhost_docker_smoke == 'true' }}32 selfhost_docker_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.selfhost_docker_smoke == 'true' }}
33 cloud_closure: ${{ github.event_name != 'pull_request' || steps.filter.outputs.cloud_closure == 'true' }}
21 steps:34 steps:
22 - uses: actions/checkout@v435 - uses: actions/checkout@v4
23 36
@@ -41,6 +54,14 @@ jobs: · 17 unmodified lines
41 - "packages/kernel/runtime-quickjs/**"54 - "packages/kernel/runtime-quickjs/**"
42 - "packages/plugins/**"55 - "packages/plugins/**"
43 - "packages/react/**"56 - "packages/react/**"
57 cloud_closure:
58 - ".github/workflows/**"
59 - "bun.lock"
60 - "package.json"
61 - "turbo.json"
62 - "apps/cloud/**"
63 - "packages/**"
64
44 selfhost_docker_smoke:65 selfhost_docker_smoke:
45 - ".github/workflows/**"66 - ".github/workflows/**"
46 - ".dockerignore"67 - ".dockerignore"
@@ -136,17 +157,26 @@ jobs: · 89 unmodified lines
136 157
137 - run: bun run typecheck158 - run: bun run typecheck
138 159
160 # The committed routeTree.gen.ts files are generated but checked in, and
161 # they carry @ts-nocheck — so the typecheck above structurally CANNOT see
162 # a route that the contract declares and the tree lacks. That drift has
163 # already shipped a live 404 once. Regenerate and diff instead.
164 - run: bun run check:routes
165
139 test:166 test:
140 name: Test167 name: Test
141 runs-on: blacksmith-4vcpu-ubuntu-2404168 runs-on: blacksmith-16vcpu-ubuntu-2404
142 timeout-minutes: 15169 timeout-minutes: 15
143 # Tuned for Blacksmith's 4 vCPU runners.170 # Run eight independent packages at once and cap each Vitest process at two
171 # workers. This uses all 16 vCPUs without every nested runner seeing the
172 # whole machine and oversubscribing it.
144 env:173 env:
145 TURBO_API: ${{ vars.TURBO_API }}174 TURBO_API: ${{ vars.TURBO_API }}
146 TURBO_TEAM: ${{ vars.TURBO_TEAM }}175 TURBO_TEAM: ${{ vars.TURBO_TEAM }}
147 TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}176 TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
148 TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}177 TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
149 TURBO_TEST_CONCURRENCY: 4178 TURBO_TEST_CONCURRENCY: 8
179 VITEST_MAX_WORKERS: 2
150 steps:180 steps:
151 - uses: actions/checkout@v4181 - uses: actions/checkout@v4
152 182
@@ -171,6 +201,15 @@ jobs: · 18 unmodified lines
171 201
172 - run: bun install --frozen-lockfile202 - run: bun install --frozen-lockfile
173 203
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
174 - run: bun run test213 - run: bun run test
175 214
176 e2e:215 e2e:
@@ -179,14 +218,41 @@ jobs: · 2 unmodified lines
179 fail-fast: false218 fail-fast: false
180 matrix:219 matrix:
181 include:220 include:
182 # Each cloud shard boots its own fresh dev stack. On 4 vCPU runners,221 # The planner assigns every file exactly once using recorded slow-file
183 # four fatter shards keep the longest shard below selfhost while saving222 # durations plus a conservative weight for new tests. The cloud DB's
184 # four runner boots and four warm cache restores.223 # connection teardown and concurrent socket protocol have dedicated
185 - { target: cloud, shard: 1/4, shard-name: 1of4 }224 # regression tests; these shards balance wall clock, not hide retries.
186 - { target: cloud, shard: 2/4, shard-name: 2of4 }225 - { target: cloud, shard-index: 1, shard-name: 1of16 }
187 - { target: cloud, shard: 3/4, shard-name: 3of4 }226 - { target: cloud, shard-index: 2, shard-name: 2of16 }
188 - { target: cloud, shard: 4/4, shard-name: 4of4 }227 - { target: cloud, shard-index: 3, shard-name: 3of16 }
189 - target: selfhost228 - { target: cloud, shard-index: 4, shard-name: 4of16 }
229 - { target: cloud, shard-index: 5, shard-name: 5of16 }
230 - { target: cloud, shard-index: 6, shard-name: 6of16 }
231 - { target: cloud, shard-index: 7, shard-name: 7of16 }
232 - { target: cloud, shard-index: 8, shard-name: 8of16 }
233 - { target: cloud, shard-index: 9, shard-name: 9of16 }
234 - { target: cloud, shard-index: 10, shard-name: 10of16 }
235 - { target: cloud, shard-index: 11, shard-name: 11of16 }
236 - { target: cloud, shard-index: 12, shard-name: 12of16 }
237 - { target: cloud, shard-index: 13, shard-name: 13of16 }
238 - { target: cloud, shard-index: 14, shard-name: 14of16 }
239 - { target: cloud, shard-index: 15, shard-name: 15of16 }
240 - { target: cloud, shard-index: 16, shard-name: 16of16 }
241 - { target: selfhost, shard-index: 1, shard-name: 1of10 }
242 - { target: selfhost, shard-index: 2, shard-name: 2of10 }
243 - { target: selfhost, shard-index: 3, shard-name: 3of10 }
244 - { target: selfhost, shard-index: 4, shard-name: 4of10 }
245 - { target: selfhost, shard-index: 5, shard-name: 5of10 }
246 - { target: selfhost, shard-index: 6, shard-name: 6of10 }
247 - { target: selfhost, shard-index: 7, shard-name: 7of10 }
248 - { target: selfhost, shard-index: 8, shard-name: 8of10 }
249 - { target: selfhost, shard-index: 9, shard-name: 9of10 }
250 - { target: selfhost, shard-index: 10, shard-name: 10of10 }
251 # Local files own their server, browser and data directory. Separate
252 # runners preserve that isolation while removing its 69-second serial
253 # lane from the two-minute critical path.
254 - { target: local, shard-index: 1, shard-name: 1of2 }
255 - { target: local, shard-index: 2, shard-name: 2of2 }
190 runs-on: blacksmith-4vcpu-ubuntu-2404256 runs-on: blacksmith-4vcpu-ubuntu-2404
191 timeout-minutes: 30257 timeout-minutes: 30
192 steps:258 steps:
@@ -222,26 +288,31 @@ jobs: · 29 unmodified lines
222 288
223 # Install from e2e so bunx resolves ITS pinned playwright (the version289 # Install from e2e so bunx resolves ITS pinned playwright (the version
224 # the tests run against) rather than floating to the latest.290 # the tests run against) rather than floating to the latest.
291 # Blacksmith's Ubuntu image carries the official GitHub runner system
292 # dependencies. Restore the pinned browser binaries without apt-updating
293 # every matrix machine.
225 - name: Install Playwright Chromium294 - name: Install Playwright Chromium
226 run: bunx playwright install --with-deps chromium chromium-headless-shell295 run: bunx playwright install chromium chromium-headless-shell
227 working-directory: e2e296 working-directory: e2e
228 297
229 # The globalsetup boots the target's own dev server (ports are claimed298 # Each target either boots its own shared dev server or lets each file own
230 # per checkout, so this is hermetic) and tears it down after the run.299 # its server. Ports and data paths are hermetic in both cases.
231 # --retry=2: browser scenarios can still hit isolated waitFor timeouts300 # Do not retry scenarios: retries hide flakes and multiply slow timeout
232 # (single-test waitFor timeouts, not systemic failures); a retry on the301 # failures. The fixtures and process lifecycle are deterministic enough
233 # same booted stack clears them.302 # that the first result is the result.
234 - name: Run cloud scenarios303 - name: Run cloud shard
235 if: matrix.target == 'cloud'304 if: matrix.target == 'cloud'
236 env:305 env:
237 MCP_SESSION_TIMEOUT_MS: "3000"306 MCP_SESSION_TIMEOUT_MS: "3000"
238 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "6000"307 # Still 18x shorter than production, but long enough for a cold Vite
239 run: bunx vitest run --project cloud --retry=2 ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }}308 # resume route to compile under a fully loaded CI runner.
309 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "30000"
310 run: bun scripts/run-ci-shard.ts cloud ${{ matrix['shard-index'] }}
240 working-directory: e2e311 working-directory: e2e
241 312
242 - name: Run selfhost scenarios313 - name: Run scenarios
243 if: matrix.target == 'selfhost'314 if: matrix.target != 'cloud'
244 run: bunx vitest run --project selfhost --retry=2315 run: bun scripts/run-ci-shard.ts ${{ matrix.target }} ${{ matrix['shard-index'] }}
245 working-directory: e2e316 working-directory: e2e
246 317
247 # Failed runs keep their trace.zip / session.mp4 / step screenshots in318 # Failed runs keep their trace.zip / session.mp4 / step screenshots in
@@ -254,13 +325,23 @@ jobs: · 6 unmodified lines
254 path: e2e/runs/325 path: e2e/runs/
255 retention-days: 7326 retention-days: 7
256 327
257 e2e-local:328 cloud-closure:
258 name: E2E (stdio MCP)329 name: Cloud evaluated closure
259 # Skipped on pull_request: the local scenario boots a real `executor web`330 needs: changes
260 # plus a browser and is currently flaky on PRs. Still runs on push to main.331 if: needs.changes.outputs.cloud_closure == 'true'
261 if: github.event_name != 'pull_request'
262 runs-on: blacksmith-4vcpu-ubuntu-2404332 runs-on: blacksmith-4vcpu-ubuntu-2404
263 timeout-minutes: 20333 timeout-minutes: 20
334 env:
335 TURBO_API: ${{ vars.TURBO_API }}
336 TURBO_TEAM: ${{ vars.TURBO_TEAM }}
337 TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
338 TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
339 # Every cold isolate evaluates this closure before it can answer a
340 # request, and it only ever grows by accident - a barrel export or a new
341 # module-scope import quietly pulls megabytes into the server graph. The
342 # budget is a ratchet just above today's size, not a discovered limit:
343 # when it trips, make the new dependency lazy rather than raising it.
344 START_CLOSURE_BUDGET_MB: 13.5
264 steps:345 steps:
265 - uses: actions/checkout@v4346 - uses: actions/checkout@v4
266 347
@@ -276,41 +357,17 @@ jobs: · 9 unmodified lines
276 restore-keys: |357 restore-keys: |
277 ${{ runner.os }}-bun-1.3.11-358 ${{ runner.os }}-bun-1.3.11-
278 359
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.
281 - uses: actions/setup-node@v4360 - uses: actions/setup-node@v4
282 with:361 with:
283 node-version: 24362 node-version: 24
284 363
285 - run: bun install --frozen-lockfile364 - run: bun install --frozen-lockfile
286 365
287 - name: Cache Playwright browsers366 - run: bun run build
288 uses: actions/cache@v4367 working-directory: apps/cloud
289 with:
290 path: ~/.cache/ms-playwright
291 key: ${{ runner.os }}-playwright-1.60.0
292 restore-keys: |
293 ${{ runner.os }}-playwright-
294 368
295 # `chromium` and the new `chromium-headless-shell` ship as separate369 - run: node scripts/start-closure.mjs
296 # downloads; the browser-driven scenarios launch the headless shell.370 working-directory: apps/cloud
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
314 371
315 desktop-smoke:372 desktop-smoke:
316 name: Desktop smoke build373 name: Desktop smoke build
@@ -362,7 +419,13 @@ jobs: · 45 unmodified lines
362 - name: Run sidecar outside the workspace419 - name: Run sidecar outside the workspace
363 working-directory: apps/desktop420 working-directory: apps/desktop
364 run: |421 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 \
366 -v "$PWD/resources/executor:/opt/executor:ro" \429 -v "$PWD/resources/executor:/opt/executor:ro" \
367 debian:bookworm-slim /opt/executor/executor daemon run --foreground \430 debian:bookworm-slim /opt/executor/executor daemon run --foreground \
368 --port 45841 --hostname 0.0.0.0 --auth-token=ci-smoke431 --port 45841 --hostname 0.0.0.0 --auth-token=ci-smoke
@@ -407,4 +470,35 @@ jobs: · 38 unmodified lines
407 context: .470 context: .
408 file: apps/host-selfhost/Dockerfile471 file: apps/host-selfhost/Dockerfile
409 push: false472 push: false
473 load: true
410 tags: executor-selfhost:ci474 tags: executor-selfhost:ci
475
476 # Building alone doesn't catch startup crashes (e.g. a runtime-resolved
477 # package missing from the packaged runtime), so boot the container and
478 # require a healthy /api/health before calling the image good.
479 - name: Boot image and check health
480 shell: bash
481 run: |
482 set -euo pipefail
483 docker run -d --name selfhost-smoke -p 4788:4788 \
484 -e BETTER_AUTH_SECRET="$(openssl rand -hex 32)" \
485 -e EXECUTOR_BOOTSTRAP_ADMIN_EMAIL=smoke@example.com \
486 -e EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD="$(openssl rand -hex 16)" \
487 -e EXECUTOR_WEB_BASE_URL=http://localhost:4788 \
488 executor-selfhost:ci
489 for i in $(seq 1 30); do
490 if [ "$(docker inspect -f '{{.State.Running}}' selfhost-smoke)" != "true" ]; then
491 echo "container exited during startup"
492 docker logs selfhost-smoke
493 exit 1
494 fi
495 if curl -fsS http://localhost:4788/api/health >/dev/null 2>&1; then
496 echo "healthy after ${i}s"
497 docker logs selfhost-smoke
498 exit 0
499 fi
500 sleep 1
501 done
502 echo "health check never passed"
503 docker logs selfhost-smoke
504 exit 1
.github/workflows/deploy.yml+29−1
@@ -9,6 +9,16 @@ concurrency: · 8 unmodified lines
9 group: deploy-production9 group: deploy-production
10 cancel-in-progress: false10 cancel-in-progress: false
11 11
12# CI is not a user — see the note in ci.yml. Applied to every workflow rather
13# than the ones that look like they run product code: the first pass guessed,
14# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert
15# where the product is not executed, so the blanket application is the cheap
16# structural answer.
17env:
18 DO_NOT_TRACK: "1"
19 EXECUTOR_DISABLE_ANALYTICS: "1"
20 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
21
12jobs:22jobs:
13 migrate:23 migrate:
14 name: Migrate database24 name: Migrate database
@@ -77,12 +87,30 @@ jobs: · 62 unmodified lines
77 VITE_PUBLIC_SENTRY_DSN: ${{ secrets.VITE_PUBLIC_SENTRY_DSN }}87 VITE_PUBLIC_SENTRY_DSN: ${{ secrets.VITE_PUBLIC_SENTRY_DSN }}
78 88
79 - name: Deploy cloud89 - name: Deploy cloud
80 run: bun run wrangler deploy -c dist/server/wrangler.json90 run: bun run wrangler deploy -c dist/server/wrangler.json --var GIT_COMMIT_SHA:${{ github.sha }}
81 working-directory: apps/cloud91 working-directory: apps/cloud
82 env:92 env:
83 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}93 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
84 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}94 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
85 95
96 # Deploy marker: one event per deploy into the same Axiom dataset the
97 # worker traces land in, so a latency step-change lines up with its
98 # deploy in one query. Skipped (not failed) when the secret is absent,
99 # and never blocks the deploy.
100 - name: Record deploy marker in Axiom
101 env:
102 AXIOM_INGEST_TOKEN: ${{ secrets.AXIOM_INGEST_TOKEN }}
103 run: |
104 if [ -z "$AXIOM_INGEST_TOKEN" ]; then
105 echo "AXIOM_INGEST_TOKEN not configured; skipping deploy marker"
106 exit 0
107 fi
108 curl -sf -X POST "https://api.axiom.co/v1/datasets/executor-cloud/ingest" \
109 -H "Authorization: Bearer $AXIOM_INGEST_TOKEN" \
110 -H "Content-Type: application/json" \
111 -d "[{\"event\":\"deploy\",\"service\":\"executor-cloud\",\"commit_sha\":\"${{ github.sha }}\",\"actor\":\"${{ github.actor }}\",\"run_id\":\"${{ github.run_id }}\"}]" \
112 || echo "deploy marker ingest failed (non-blocking)"
113
86 deploy-marketing:114 deploy-marketing:
87 name: Deploy marketing115 name: Deploy marketing
88 runs-on: blacksmith-4vcpu-ubuntu-2404116 runs-on: blacksmith-4vcpu-ubuntu-2404
.github/workflows/graph-slices.yml+47−0
@@ -0,0 +1,47 @@
1# Refresh the Microsoft Graph slice release assets.
2#
3# The Graph OpenAPI monolith (~43MB) cannot be processed inside a Workers
4# isolate, so the runtime reads per-selection slices published on the
5# `graph-slices` release tag (see packages/plugins/openapi/src/providers/
6# microsoft/slices.ts). This workflow rebuilds the slices from the current
7# upstream spec on a schedule and on demand.
8name: Graph slices
9
10on:
11 schedule:
12 # Weekly; Microsoft's msgraph-metadata automation lands upstream refreshes
13 # on a similar cadence. A failed run leaves the previous assets serving.
14 - cron: "17 6 * * 1"
15 workflow_dispatch: {}
16
17permissions:
18 contents: write
19
20jobs:
21 slices:
22 runs-on: ubuntu-latest
23 timeout-minutes: 20
24 steps:
25 - uses: actions/checkout@v4
26
27 - uses: oven-sh/setup-bun@v2
28 with:
29 bun-version: 1.3.11
30
31 - name: Install dependencies
32 run: bun install --frozen-lockfile
33
34 - name: Generate slices
35 working-directory: packages/plugins/openapi
36 run: bun scripts/generate-graph-slices.ts --out "$RUNNER_TEMP/graph-slices"
37
38 - name: Publish to the graph-slices release
39 env:
40 GH_TOKEN: ${{ github.token }}
41 run: |
42 gh release view graph-slices --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \
43 gh release create graph-slices --repo "$GITHUB_REPOSITORY" \
44 --title "Microsoft Graph slices" --latest=false \
45 --notes "Generated per-preset Microsoft Graph OpenAPI slices. Data release consumed by the openapi plugin's Microsoft adapter; refreshed by the graph-slices workflow."
46 gh release upload graph-slices "$RUNNER_TEMP/graph-slices"/* \
47 --repo "$GITHUB_REPOSITORY" --clobber
.github/workflows/pkg-pr-new.yml+10−0
@@ -10,6 +10,16 @@ concurrency: · 9 unmodified lines
10 group: pkg-pr-new-${{ github.event.pull_request.number }}10 group: pkg-pr-new-${{ github.event.pull_request.number }}
11 cancel-in-progress: true11 cancel-in-progress: true
12 12
13# CI is not a user — see the note in ci.yml. Applied to every workflow rather
14# than the ones that look like they run product code: the first pass guessed,
15# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert
16# where the product is not executed, so the blanket application is the cheap
17# structural answer.
18env:
19 DO_NOT_TRACK: "1"
20 EXECUTOR_DISABLE_ANALYTICS: "1"
21 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
22
13jobs:23jobs:
14 # Per-platform matrix: build the executor binary, tar it, upload to R2.24 # Per-platform matrix: build the executor binary, tar it, upload to R2.
15 # The wrapper npm package is built later by the `publish` job which just25 # The wrapper npm package is built later by the `publish` job which just
.github/workflows/preview-sweep.yml+10−0
@@ -16,6 +16,16 @@ permissions: · 15 unmodified lines
16 contents: read16 contents: read
17 pull-requests: read17 pull-requests: read
18 18
19# CI is not a user — see the note in ci.yml. Applied to every workflow rather
20# than the ones that look like they run product code: the first pass guessed,
21# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert
22# where the product is not executed, so the blanket application is the cheap
23# structural answer.
24env:
25 DO_NOT_TRACK: "1"
26 EXECUTOR_DISABLE_ANALYTICS: "1"
27 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
28
19jobs:29jobs:
20 sweep:30 sweep:
21 name: Destroy previews for closed PRs31 name: Destroy previews for closed PRs
.github/workflows/preview.yml+10−0
@@ -23,6 +23,16 @@ concurrency: · 22 unmodified lines
23 group: preview-${{ github.event.pull_request.number }}23 group: preview-${{ github.event.pull_request.number }}
24 cancel-in-progress: ${{ github.event.action != 'closed' }}24 cancel-in-progress: ${{ github.event.action != 'closed' }}
25 25
26# CI is not a user — see the note in ci.yml. Applied to every workflow rather
27# than the ones that look like they run product code: the first pass guessed,
28# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert
29# where the product is not executed, so the blanket application is the cheap
30# structural answer.
31env:
32 DO_NOT_TRACK: "1"
33 EXECUTOR_DISABLE_ANALYTICS: "1"
34 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
35
26jobs:36jobs:
27 deploy:37 deploy:
28 name: Deploy preview38 name: Deploy preview
.github/workflows/publish-desktop.yml+39−2
@@ -21,6 +21,18 @@ concurrency: · 20 unmodified lines
21 group: publish-desktop-${{ github.ref }}21 group: publish-desktop-${{ github.ref }}
22 cancel-in-progress: false22 cancel-in-progress: false
23 23
24# CI is not a user. Without this, executor's own product telemetry reports to the
25# production PostHog project from every e2e shard: the analytics layer
26# (integration_added, execution_completed, artifact_*) and the
27# integrations-registry fetch, which lands as a `hit` event. Each shard is a
28# fresh container, so it mints a fresh anonymous id and arrives on a fresh
29# runner IP — inflating machine and user counts by roughly an order of
30# magnitude. Both opt-outs are the packages' documented CI/test hooks.
31env:
32 DO_NOT_TRACK: "1"
33 EXECUTOR_DISABLE_ANALYTICS: "1"
34 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
35
24jobs:36jobs:
25 build:37 build:
26 permissions:38 permissions:
@@ -94,6 +106,17 @@ jobs: · 67 unmodified lines
94 - name: Install dependencies106 - name: Install dependencies
95 run: bun install --frozen-lockfile107 run: bun install --frozen-lockfile
96 108
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
97 - name: Build web app120 - name: Build web app
98 run: bun run --filter @executor-js/local build121 run: bun run --filter @executor-js/local build
99 122
@@ -143,8 +166,22 @@ jobs: · 43 unmodified lines
143 # → notarytool authentication for the notarization upload166 # → notarytool authentication for the notarization upload
144 # When the secrets aren't set (forks, local), electron-builder167 # When the secrets aren't set (forks, local), electron-builder
145 # silently produces an unsigned build.168 # silently produces an unsigned build.
146 CSC_LINK: ${{ secrets.CSC_LINK }}169 #
147 CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}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 || '' }}
148 APPLE_API_KEY: ${{ env.APPLE_API_KEY_PATH }}185 APPLE_API_KEY: ${{ env.APPLE_API_KEY_PATH }}
149 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}186 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
150 APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}187 APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
.github/workflows/publish-executor-package.yml+13−1
@@ -21,6 +21,16 @@ concurrency: · 20 unmodified lines
21 group: publish-executor-package-${{ github.ref }}21 group: publish-executor-package-${{ github.ref }}
22 cancel-in-progress: false22 cancel-in-progress: false
23 23
24# CI is not a user — see the note in ci.yml. Applied to every workflow rather
25# than the ones that look like they run product code: the first pass guessed,
26# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert
27# where the product is not executed, so the blanket application is the cheap
28# structural answer.
29env:
30 DO_NOT_TRACK: "1"
31 EXECUTOR_DISABLE_ANALYTICS: "1"
32 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
33
24jobs:34jobs:
25 publish:35 publish:
26 runs-on: ubuntu-latest36 runs-on: ubuntu-latest
@@ -62,7 +72,9 @@ jobs: · 35 unmodified lines
62 72
63 - name: Update npm for trusted publishing73 - name: Update npm for trusted publishing
64 run: |74 run: |
65 npm install -g npm@latest75 # npm 12.0.0 fails `publish --provenance` with "Cannot find module
76 # 'sigstore'" (npm/cli#9722); 11.x supports trusted publishing and works.
77 npm install -g npm@11
66 npm --version78 npm --version
67 79
68 - name: Install dependencies80 - name: Install dependencies
.github/workflows/publish-selfhost-docker.yml+142−4
@@ -16,6 +16,18 @@ concurrency: · 15 unmodified lines
16 group: publish-selfhost-docker-${{ inputs.tag }}16 group: publish-selfhost-docker-${{ inputs.tag }}
17 cancel-in-progress: false17 cancel-in-progress: false
18 18
19# CI is not a user. Without this, executor's own product telemetry reports to the
20# production PostHog project from every e2e shard: the analytics layer
21# (integration_added, execution_completed, artifact_*) and the
22# integrations-registry fetch, which lands as a `hit` event. Each shard is a
23# fresh container, so it mints a fresh anonymous id and arrives on a fresh
24# runner IP — inflating machine and user counts by roughly an order of
25# magnitude. Both opt-outs are the packages' documented CI/test hooks.
26env:
27 DO_NOT_TRACK: "1"
28 EXECUTOR_DISABLE_ANALYTICS: "1"
29 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
30
19jobs:31jobs:
20 metadata:32 metadata:
21 runs-on: blacksmith-4vcpu-ubuntu-240433 runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -163,16 +175,124 @@ jobs: · 141 unmodified lines
163 if-no-files-found: error175 if-no-files-found: error
164 retention-days: 1176 retention-days: 1
165 177
178 # Release gate: run the full selfhost-docker e2e project against the exact
179 # by-digest image each build leg pushed, before any user-visible tag moves.
180 # Build-only smoke misses startup crashes and feature-path breaks (a package
181 # resolved at runtime but missing from the packaged runtime shipped broken in
182 # v1.5.30 exactly this way); per-arch matters too — the libsql native-addon
183 # bug was arm64-only.
184 e2e:
185 needs:
186 - metadata
187 - build
188 strategy:
189 fail-fast: false
190 matrix:
191 include:
192 - arch: amd64
193 runner: blacksmith-4vcpu-ubuntu-2404
194 - arch: arm64
195 runner: blacksmith-4vcpu-ubuntu-2404-arm
196 runs-on: ${{ matrix.runner }}
197 timeout-minutes: 40
198 permissions:
199 contents: read
200 packages: read
201
202 steps:
203 - name: Checkout release tag
204 uses: actions/checkout@v4
205 with:
206 ref: ${{ needs.metadata.outputs.release_tag }}
207 persist-credentials: false
208
209 - uses: oven-sh/setup-bun@v2
210 with:
211 bun-version: 1.3.11
212
213 - name: Cache Bun package cache
214 uses: actions/cache@v4
215 with:
216 path: ~/.bun/install/cache
217 key: ${{ runner.os }}-${{ runner.arch }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
218 restore-keys: |
219 ${{ runner.os }}-${{ runner.arch }}-bun-1.3.11-
220
221 # Scenario helpers spawn Node sidecars; pin the same Node 24 runtime the
222 # other workflows use.
223 - uses: actions/setup-node@v4
224 with:
225 node-version: 24
226
227 - run: bun install --frozen-lockfile
228
229 - name: Cache Playwright browsers
230 uses: actions/cache@v4
231 with:
232 path: ~/.cache/ms-playwright
233 key: ${{ runner.os }}-${{ runner.arch }}-playwright-1.60.0
234 restore-keys: |
235 ${{ runner.os }}-${{ runner.arch }}-playwright-
236
237 # Install from e2e so bunx resolves ITS pinned playwright (the version
238 # the tests run against) rather than floating to the latest.
239 - name: Install Playwright Chromium
240 run: bunx playwright install --with-deps chromium chromium-headless-shell
241 working-directory: e2e
242
243 - name: Log in to GHCR
244 uses: docker/login-action@v3
245 with:
246 registry: ghcr.io
247 username: ${{ github.actor }}
248 password: ${{ secrets.GITHUB_TOKEN }}
249
250 - name: Download image digest
251 uses: actions/download-artifact@v4
252 with:
253 name: digests-${{ matrix.arch }}
254 path: ${{ runner.temp }}/digests
255
256 - name: Resolve image reference
257 id: image_ref
258 shell: bash
259 env:
260 IMAGE: ${{ needs.metadata.outputs.image }}
261 run: |
262 set -euo pipefail
263 digest_file="$(find "$RUNNER_TEMP/digests" -maxdepth 1 -type f | head -n 1)"
264 if [ -z "$digest_file" ]; then
265 echo "No image digest was downloaded."
266 exit 1
267 fi
268 ref="${IMAGE}@sha256:$(basename "$digest_file")"
269 echo "ref=$ref" >> "$GITHUB_OUTPUT"
270 docker pull "$ref"
271
272 - name: Run selfhost-docker scenarios against the published digest
273 env:
274 E2E_SELFHOST_DOCKER_IMAGE: ${{ steps.image_ref.outputs.ref }}
275 run: bunx vitest run --project selfhost-docker --retry=2
276 working-directory: e2e
277
278 - name: Upload run artifacts
279 if: failure()
280 uses: actions/upload-artifact@v4
281 with:
282 name: e2e-runs-selfhost-docker-${{ matrix.arch }}
283 path: |
284 e2e/runs/
285 e2e/selfhost-docker.boot.log
286 retention-days: 7
287
166 merge:288 merge:
167 needs:289 needs:
168 - metadata290 - metadata
169 - build291 - build
292 - e2e
170 runs-on: blacksmith-4vcpu-ubuntu-2404293 runs-on: blacksmith-4vcpu-ubuntu-2404
171 permissions:294 permissions:
172 packages: write295 packages: write
173 env:
174 HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }}
175
176 steps:296 steps:
177 - name: Download image digests297 - name: Download image digests
178 uses: actions/download-artifact@v4298 uses: actions/download-artifact@v4
@@ -219,10 +339,28 @@ jobs: · 40 unmodified lines
219 339
220 docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"340 docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"
221 341
342 # Mirrors each release to the pre-org-move namespace
343 # (ghcr.io/rhyssullivan/executor-selfhost), which GHCR cannot redirect.
344 # Deliberately a separate job: the canonical publish above never depends on
345 # it, and a failure here (an expired GHCR_LEGACY_TOKEN, historically) shows
346 # as one red job pointing at exactly what to fix.
347 mirror-legacy:
348 needs:
349 - metadata
350 - merge
351 runs-on: blacksmith-4vcpu-ubuntu-2404
352 env:
353 HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }}
354
355 steps:
222 - name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured)356 - name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured)
223 if: env.HAS_LEGACY_TOKEN != 'true'357 if: env.HAS_LEGACY_TOKEN != 'true'
224 run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror."358 run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror."
225 359
360 - name: Set up Docker Buildx
361 if: env.HAS_LEGACY_TOKEN == 'true'
362 uses: docker/setup-buildx-action@v3
363
226 - name: Log in to legacy GHCR namespace364 - name: Log in to legacy GHCR namespace
227 if: env.HAS_LEGACY_TOKEN == 'true'365 if: env.HAS_LEGACY_TOKEN == 'true'
228 uses: docker/login-action@v3366 uses: docker/login-action@v3
@@ -231,7 +369,7 @@ jobs: · 2 unmodified lines
231 username: rhyssullivan369 username: rhyssullivan
232 password: ${{ secrets.GHCR_LEGACY_TOKEN }}370 password: ${{ secrets.GHCR_LEGACY_TOKEN }}
233 371
234 - name: Mirror self-host image to legacy GHCR namespace372 - name: Mirror release tags to the legacy namespace
235 if: env.HAS_LEGACY_TOKEN == 'true'373 if: env.HAS_LEGACY_TOKEN == 'true'
236 shell: bash374 shell: bash
237 env:375 env:
.github/workflows/release.yml+15−1
@@ -13,6 +13,18 @@ concurrency: · 12 unmodified lines
13 group: release-${{ github.ref }}13 group: release-${{ github.ref }}
14 cancel-in-progress: false14 cancel-in-progress: false
15 15
16# CI is not a user. Without this, executor's own product telemetry reports to the
17# production PostHog project from every e2e shard: the analytics layer
18# (integration_added, execution_completed, artifact_*) and the
19# integrations-registry fetch, which lands as a `hit` event. Each shard is a
20# fresh container, so it mints a fresh anonymous id and arrives on a fresh
21# runner IP — inflating machine and user counts by roughly an order of
22# magnitude. Both opt-outs are the packages' documented CI/test hooks.
23env:
24 DO_NOT_TRACK: "1"
25 EXECUTOR_DISABLE_ANALYTICS: "1"
26 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1"
27
16jobs:28jobs:
17 release:29 release:
18 runs-on: ubuntu-latest30 runs-on: ubuntu-latest
@@ -42,7 +54,9 @@ jobs: · 23 unmodified lines
42 54
43 - name: Update npm for trusted publishing55 - name: Update npm for trusted publishing
44 run: |56 run: |
45 npm install -g npm@latest57 # npm 12.0.0 fails `publish --provenance` with "Cannot find module
58 # 'sigstore'" (npm/cli#9722); 11.x supports trusted publishing and works.
59 npm install -g npm@11
46 npm --version60 npm --version
47 61
48 - name: Install dependencies62 - name: Install dependencies
.oxfmtrc.json+1−0
@@ -13,6 +13,7 @@ · 12 unmodified lines
13 "*.tsbuildinfo",13 "*.tsbuildinfo",
14 "executor-*.tgz",14 "executor-*.tgz",
15 "**/routeTree.gen.ts",15 "**/routeTree.gen.ts",
16 "**/smoke-harness-bundle.gen.ts",
16 "apps/cloud/src/services/executor-schema.ts"17 "apps/cloud/src/services/executor-schema.ts"
17 ]18 ]
18}19}
.oxlintrc.jsonc+26−0
@@ -64,6 +64,7 @@ · 63 unmodified lines
64 "apps/desktop/src/main.ts",64 "apps/desktop/src/main.ts",
65 "scripts/**/*.{ts,js}",65 "scripts/**/*.{ts,js}",
66 "apps/*/scripts/**/*.{ts,js}",66 "apps/*/scripts/**/*.{ts,js}",
67 "packages/*/*/scripts/**/*.{ts,js}",
67 "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}",68 "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}",
68 ],69 ],
69 "rules": {70 "rules": {
@@ -119,6 +120,31 @@ · 49 unmodified lines
119 "executor/no-try-catch-or-throw": "off",120 "executor/no-try-catch-or-throw": "off",
120 },121 },
121 },122 },
123 {
124 // boundary: the MCP-Apps shell is browser code, not Effect domain code.
125 // It runs inside a sandboxed iframe and talks to the host over
126 // postMessage and the MCP bridge — both of which are throw/Promise APIs
127 // with no Effect runtime present. `new Function` evaluation of generated
128 // JSX, CSS harvesting off `document.styleSheets`, and JSON on the wire
129 // all surface untyped JS errors that must be caught and rendered rather
130 // than modelled. Scoped to the iframe payload plus its build config; the
131 // package's Effect-facing loader (`src/shell-html.ts`) is deliberately
132 // NOT listed and uses inline suppressions instead.
133 "files": [
134 "packages/hosts/mcp-apps-shell/src/shell/**/*.{ts,tsx}",
135 "packages/hosts/mcp-apps-shell/vite.config.shell.ts",
136 ],
137 "rules": {
138 "executor/no-double-cast": "off",
139 "executor/no-error-constructor": "off",
140 "executor/no-instanceof-error": "off",
141 "executor/no-json-parse": "off",
142 "executor/no-promise-catch": "off",
143 "executor/no-promise-reject": "off",
144 "executor/no-try-catch-or-throw": "off",
145 "executor/no-unknown-error-message": "off",
146 },
147 },
122 {148 {
123 "files": ["packages/kernel/core/src/code-recovery.ts"],149 "files": ["packages/kernel/core/src/code-recovery.ts"],
124 "rules": {150 "rules": {
AGENTS.md+43−68
@@ -1,83 +1,58 @@
1# AGENTS.md1# Executor policy
2 2
3Principles: the contracts that stay true while implementations churn. For how to3Stable contracts live here. Current setup and server mechanics are in
4run, boot, share, or navigate things today, see [RUNNING.md](RUNNING.md); for4[RUNNING.md](RUNNING.md); e2e invariants are in
5e2e scenarios, [e2e/AGENTS.md](e2e/AGENTS.md). Run `bun run bootstrap` first in5[e2e/AGENTS.md](e2e/AGENTS.md). Run `bun run bootstrap` in every fresh rift.
6any fresh checkout or worktree.
7 6
8## Task Completion7## Verification and evidence
9 8
10- Tests use Effect Vitest. Run scoped tests with `vitest run ...`. `bun run test`9- Tests use Effect Vitest. Run scoped tests with `vitest run ...` or the
11 is fine (it delegates to Vitest); NEVER run `bun test`.10 package script. Never use `bun test`.
12- Run the narrowest useful verification for a change. For broad or merge-ready11- Use the narrowest meaningful verification while iterating. Merge-ready gates
13 work the full gates are `bun run format:check`, `lint`, `typecheck`, `test`.12 are `bun run format:check`, `lint`, `typecheck`, and `test`.
14- Run `bun run format` before opening a PR, staging only files your branch13- e2e is slow. Avoid running it often during development: optimize for
15 touched (leave unrelated files `format` rewrites out of the PR).14 iteration speed first, then test. Save e2e for when the change is settled,
15 and run the narrowest scenario rather than the full suite.
16- Run `bun run format` before a PR and include only files owned by the branch.
17- User-visible work requires a specific e2e run, a browsable dev instance, and
18 the recording or trace showing what to inspect. Add a scenario when none
19 covers the changed behavior.
16 20
17## Handing Back Work: Evidence, Not Assertions21## Service emulators
18 22
19"Done" is something the user can open, not a claim. When work changes what a user23Tests and demos that need an upstream API, OAuth/OIDC provider, or webhook use
20sees, hand back: an e2e run that proves it (link the specific run with one line24the published `@executor-js/emulate` emulators rather than stubs. They provide
21on what to look at, not a wall of green), the dev server left running so they can25wire-level state, real-shaped credentials, OpenAPI descriptions, and request
22poke at it, and the paths worth trying by hand including ones no scenario covers26ledgers. Create per-run hosted instances through the service's
23yet. If no scenario covers the change, write one, and embed its recording in the27`/_emulate/instances` control route.
24PR. The machinery runs in reverse too: seed an environment into a state
25(reproduce a bug live, stage data) and hand across the link.
26 28
27## Service Emulators29Emulate is a separate project. Make emulator changes there and consume the
30published package here; never re-vendor or create a parallel fake inside
31executor.
28 32
29For any test or demo needing an upstream API, OAuth/OIDC provider, or webhook33## Engineering boundaries
30source, use the `@executor-js/emulate` emulators instead of writing a stub:
31wire-level and stateful, real SDKs run unmodified, each serves a full OpenAPI
32spec, mints real-shaped credentials, and records every call in a request ledger
33to assert against. Hosted at `https://<service>.emulators.dev`. See the `emulate`
34skill (`.claude/skills/emulate/SKILL.md`). They are a standalone project
35(`github.com/UsefulSoftwareCo/emulate`) consumed here as the published package:
36full autonomy to change, publish, and deploy them on their `main`; don't re-vendor.
37
38## Attribution
39
40No AI/Claude/Anthropic/Co-Authored-By attribution in commits, messages, PRs, or
41generated files. PR titles and descriptions go to a public repo: no internal info
42or specific names unless explicitly stated.
43
44## Collaboration Notes
45
46- The user uses speech-to-text; infer likely intent from odd wording, ask only
47 when needed.
48- Code is cheap to write: no time estimates, implementation time isn't a blocker.
49- Never use em-dashes anywhere. Use commas, colons, parentheses, or separate
50 sentences.
51
52## Reference Repos
53
54Repos in `.reference` (Effect, effect-atom, …) are available for patterns. Clone
55a given Git URL into `.reference` and pull latest before using it.
56
57## Engineering Priorities
58 34
59- Prefer correctness and predictable behavior over short-term convenience.35- Prefer correctness and predictable behavior over short-term convenience.
60- Preserve runtime behavior when changing lint, typing, or test structure.36- Preserve runtime behavior during lint, typing, and test-structure changes.
61- Keep package boundaries clear; use public package exports, not cross-package37- Use public package exports; never cross package boundaries with relative
62 relative imports.38 imports.
63- Extract shared logic only when the shared behavior is real; avoid broad generic39- Extract shared logic only for genuinely shared behavior. Avoid generic
64 abstractions for one-off duplication.40 abstractions for one-off duplication.
41- Public PRs, commits, generated files, and documentation contain no private
42 names, internal context, customer-derived data, or AI attribution.
65 43
66## Package Roles44## Package ownership
67 45
68- `packages/core/sdk`: core contracts, plugin wiring, scopes, sources, secrets,46- `packages/core/sdk`: contracts, plugin wiring, scopes, integrations, secrets,
69 policies, fixtures. `@executor-js/sdk/http-auth` carries the shared auth-method47 policies, and fixtures. Shared HTTP auth vocabulary lives in
70 vocabulary the HTTP protocol plugins compose (core never imports it, keeping it48 `@executor-js/sdk/http-auth`; core remains carrier-agnostic.
71 carrier-agnostic).
72- `packages/core/storage-*`: storage adapters and test support.49- `packages/core/storage-*`: storage adapters and test support.
73- `packages/plugins/*`: protocol and provider plugins; their runtime, React, API,50- `packages/plugins/*`: protocol/provider implementations and their runtime,
74 and testing helpers live with the owning plugin.51 React, API, and testing helpers.
75- `packages/react`: shared React UI and atom/client integration.52- `packages/react`: shared React UI and client/atom integration.
76- `packages/hosts/mcp`: MCP host surface.53- `packages/hosts/mcp`: MCP host surface.
77- `packages/kernel/*`: execution runtimes and code execution substrate.54- `packages/kernel/*`: execution runtimes and code-execution substrate.
78- `apps/{local,cloud,cli,desktop}`: product entry points composing the packages.55- `apps/{local,cloud,cli,desktop}`: product composition roots.
79
80## Other
81 56
82Note mistakes in MISTAKES.md, missing context or tools in DESIRES.md, and env57Record mistakes in `MISTAKES.md`, missing capabilities in `DESIRES.md`, and
83learnings in LEARNINGS.md.58environment discoveries in `LEARNINGS.md`.
README.md+8−8
@@ -8,7 +8,7 @@ and per-tool policies, then use that one catalog from any MCP-compatible agent. · 7 unmodified lines
8 8
9[Website](https://executor.sh) · [Documentation](https://executor.sh/docs) · [Discord](https://discord.gg/eF29HBHwM6)9[Website](https://executor.sh) · [Documentation](https://executor.sh/docs) · [Discord](https://discord.gg/eF29HBHwM6)
10 10
11[https://github.com/user-attachments/assets/11225f83-e848-42ba-99b2-a993bcc88dad](https://github.com/user-attachments/assets/11225f83-e848-42ba-99b2-a993bcc88dad)11![Executor demo](https://raw.githubusercontent.com/UsefulSoftwareCo/executor/main/assets/executor-demo.gif)
12 12
13## Why Executor13## Why Executor
14 14
@@ -21,9 +21,9 @@ set its policy once, and every agent shares it over MCP. Your integrations, · 6 unmodified lines
21auth, and policies live in one place instead of being scattered across each21auth, and policies live in one place instead of being scattered across each
22client.22client.
23 23
24- **Any source.** First-party support for MCP servers, OpenAPI, GraphQL, and24- **Any integration.** First-party support for MCP servers, OpenAPI, GraphQL, and
25 Google Discovery. If you can describe it with a JSON schema, it can be an25 Google Discovery. If you can describe it with a JSON schema, it can be an
26 integration. The plugin system is open to any source type.26 integration. The plugin system is open to any integration type.
27- **One catalog, every agent.** Anything MCP-compatible connects to the same27- **One catalog, every agent.** Anything MCP-compatible connects to the same
28 set of tools.28 set of tools.
29- **Governed by policy.** Each tool is allowed, gated behind approval, or29- **Governed by policy.** Each tool is allowed, gated behind approval, or
@@ -100,12 +100,12 @@ appear. · 70 unmodified lines
100 100
101## Add an integration101## Add an integration
102 102
103From the web UI, click **Add Source**, paste an OpenAPI, GraphQL, or MCP URL,103From the web UI, click **Add Integration**, paste an OpenAPI, GraphQL, or MCP URL,
104and Executor detects the type, indexes the tools, and handles auth. Or add one104and Executor detects the type, indexes the tools, and handles auth. Or add one
105from the CLI:105from the CLI:
106 106
107```bash107```bash
108executor call executor openapi addSource '{108executor call executor openapi addIntegration '{
109 "spec": "https://petstore3.swagger.io/api/v3/openapi.json",109 "spec": "https://petstore3.swagger.io/api/v3/openapi.json",
110 "namespace": "petstore",110 "namespace": "petstore",
111 "baseUrl": "https://petstore3.swagger.io/api/v3"111 "baseUrl": "https://petstore3.swagger.io/api/v3"
@@ -113,7 +113,7 @@ executor call executor openapi addSource '{ · 1 unmodified line
113```113```
114 114
115Use `baseUrl` when the OpenAPI document has relative `servers` entries (for115Use `baseUrl` when the OpenAPI document has relative `servers` entries (for
116example `"/api/v3"`). Confirm it is live with `executor tools sources`.116example `"/api/v3"`). Confirm it is live with `executor tools integrations`.
117 117
118## Using tools118## Using tools
119 119
@@ -169,7 +169,7 @@ executor call <path...> --help # browse namespaces/resources/methods · 49 unmodified lines
169executor call <path...> --help --match "<text>" --limit <n> # narrow huge namespaces169executor call <path...> --help --match "<text>" --limit <n> # narrow huge namespaces
170executor resume --execution-id <id> # resume paused execution170executor resume --execution-id <id> # resume paused execution
171executor tools search "<query>" # search tools by intent171executor tools search "<query>" # search tools by intent
172executor tools sources # list configured sources + tool counts172executor tools integrations # list configured integrations + tool counts
173executor tools describe <path> # show tool TypeScript/JSON schema173executor tools describe <path> # show tool TypeScript/JSON schema
174```174```
175 175
@@ -190,7 +190,7 @@ apps/ · 14 unmodified lines
190packages/190packages/
191 core/ contracts, plugin wiring, scopes, policies, SDK, API, CLI core191 core/ contracts, plugin wiring, scopes, policies, SDK, API, CLI core
192 kernel/ execution runtimes (QuickJS, Deno subprocess, dynamic worker)192 kernel/ execution runtimes (QuickJS, Deno subprocess, dynamic worker)
193 plugins/ source and provider plugins (openapi, graphql, mcp, google,193 plugins/ integration and provider plugins (openapi, graphql, mcp, google,
194 microsoft, 1password, keychain, encrypted/file secrets, ...)194 microsoft, 1password, keychain, encrypted/file secrets, ...)
195 hosts/ host adapters (MCP surface, Cloudflare)195 hosts/ host adapters (MCP surface, Cloudflare)
196 react/ shared React UI196 react/ shared React UI
TELEMETRY.md+101−0
@@ -0,0 +1,101 @@
1# Telemetry
2
3Executor's local products (the CLI, the desktop app) and self-hosted
4deployments send a small set of anonymous usage events. This document is the
5complete account of what is sent, what is deliberately not sent, why the
6feature exists, and how to turn it off.
7
8The hosted cloud product has its own browser-side analytics, disclosed
9separately in its terms; this document covers the software that runs on your
10machines.
11
12## Why
13
14Executor is used mostly outside our infrastructure. Without some signal from
15local and self-hosted installs, every product decision about them is a guess:
16we cannot tell whether a feature ships broken, whether anyone uses toolkits,
17whether executions fail at unusual rates after a release, or whether the
18product is growing anywhere except cloud.
19
20The events exist to answer exactly one kind of question: **how are product
21features being used?** They are metadata about the product, not about you.
22Anything that would answer "what is this user doing" — which APIs you call,
23what your tools are named, what your code does — is out of scope by design,
24not by omission.
25
26## What is sent
27
28Each event carries its named properties plus, on every event: a random
29per-install id, the product surface (`cli`, `desktop`, or `selfhost`), the
30release channel, and the app version.
31
32| Event | Properties | Meaning |
33| --------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
34| `execution_completed` | `ok`, `plane` (`mcp`/`api`), `toolkit` (boolean) | A code execution finished, split by whether an agent (MCP) or a human-facing API triggered it, and whether a toolkit-scoped endpoint served it. |
35| `integration_added` | `plugin_key` | An integration was added, by kind (`openapi`, `mcp`, `graphql`, ...). |
36| `integration_removed` | `plugin_key` | An integration was removed, by kind. |
37| `artifact_created` | `via` (`agent`/`ui`) | A generative-UI artifact was saved. |
38| `artifact_viewed` | `via` | An artifact was opened for its content. |
39| `artifact_updated` | `via` | An artifact was overwritten or renamed. |
40| `artifact_deleted` | `via` | An artifact was deleted. |
41
42That table is exhaustive. The typed catalog the code compiles against is
43[`packages/core/analytics/src/events.ts`](packages/core/analytics/src/events.ts);
44an event that is not in that file cannot be sent.
45
46## What is never sent
47
48- No code, tool arguments, tool results, or error messages.
49- No secrets, tokens, or credentials.
50- No names you typed: no integration slugs, connection names, toolkit slugs,
51 tool names, or artifact titles. The **kind** of integration (`openapi`,
52 `mcp`, ...) is a product question; **which** service you connected it to is
53 your business.
54- No identity: no emails, usernames, hostnames, IP-derived location, or org
55 names. Events are marked so the analytics backend builds no person profile.
56
57## The anonymous id
58
59A random UUID is minted the first time the daemon or server starts and stored
60as `analytics-id` in the data directory (`~/.executor` for local installs, the
61configured data dir for self-host). It exists so that ten events from one
62install count as one install, not ten. It is not derived from your machine,
63account, or network, and deleting the file resets it. When telemetry is
64disabled the file is never created.
65
66## Opting out
67
68Set either environment variable to `1`, `true`, or `yes`:
69
70- `DO_NOT_TRACK` — the [cross-tool convention](https://consoledonottrack.com),
71 which Executor also honors for its other outbound calls (crash reporting,
72 the integrations.sh catalog fetch).
73- `EXECUTOR_DISABLE_ANALYTICS` — telemetry only, if you want the catalog
74 fetch and crash reporting to keep working.
75
76Opting out is total: the analytics service becomes a no-op, nothing is
77buffered, nothing is sent, and no id file is written. The CLI's managed
78service (launchd/systemd) forwards both variables into the supervised
79daemon's environment, so an opted-out install stays opted out.
80
81## Delivery mechanics
82
83Events buffer in memory (bounded) and flush in batches on a fixed cadence,
84plus once at shutdown. Delivery is best-effort: a failed flush re-queues and
85retries later, failures are swallowed, and no user-facing operation ever
86waits on — or can be failed by — analytics. The ingest endpoint is PostHog
87(`us.i.posthog.com`); the project key in the source identifies the project
88and grants no read access.
89
90## Where the line is enforced
91
92Structurally, not by convention:
93
94- The event catalog is a closed, typed interface — adding a property means
95 editing [`events.ts`](packages/core/analytics/src/events.ts) in a reviewed
96 change, next to the property rules at the top of that file.
97- Attribution (`plane`, `via`) is bound where each serving surface is
98 composed, so events cannot carry request-controlled labels.
99- Observers hang off neutral seams (an engine wrapper, post-commit hooks); the
100 core SDK carries no analytics vocabulary and hosts that do not opt in — like
101 every test — compose with no analytics at all.
apps/cli/CHANGELOG.md+347−0
@@ -1,5 +1,352 @@
1# executor1# executor
2 2
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
3## 1.5.29350## 1.5.29
4 351
5### Patch Changes352### Patch Changes
apps/cli/package.json+4−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "executor",2 "name": "executor",
3 "version": "1.5.29",3 "version": "1.6.2",
4 "private": true,4 "private": true,
5 "bin": {5 "bin": {
6 "executor": "./bin/executor.ts"6 "executor": "./bin/executor.ts"
@@ -36,8 +36,11 @@ · 29 unmodified lines
36 "quickjs-emscripten": "catalog:"36 "quickjs-emscripten": "catalog:"
37 },37 },
38 "devDependencies": {38 "devDependencies": {
39 "@cloudflare/worker-bundler": "0.2.1",
39 "@effect/vitest": "catalog:",40 "@effect/vitest": "catalog:",
41 "@types/node": "catalog:",
40 "bun-types": "catalog:",42 "bun-types": "catalog:",
43 "esbuild": "^0.27.3",
41 "typescript": "catalog:",44 "typescript": "catalog:",
42 "vitest": "catalog:"45 "vitest": "catalog:"
43 }46 }
apps/cli/src/build.ts+163−1
@@ -2,15 +2,17 @@ import { cp, mkdir, rm, writeFile, chmod } from "node:fs/promises"; · 1 unmodified line
2import { existsSync } from "node:fs";2import { existsSync } from "node:fs";
3import { createRequire } from "node:module";3import { createRequire } from "node:module";
4import { resolve, join, dirname } from "node:path";4import { resolve, join, dirname } from "node:path";
5import { fileURLToPath } from "node:url";5import { fileURLToPath, pathToFileURL } from "node:url";
6import { parseArgs } from "node:util";6import { parseArgs } from "node:util";
7import { $ } from "bun";7import { $ } from "bun";
8import { WORKER_BUNDLER_DIRNAME, missingWorkerBundlerFiles } from "./worker-bundler-artifact";
8 9
9const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");10const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
10const cliRoot = resolve(repoRoot, "apps/cli");11const cliRoot = resolve(repoRoot, "apps/cli");
11const webRoot = resolve(repoRoot, "apps/local");12const webRoot = resolve(repoRoot, "apps/local");
12const distDir = resolve(cliRoot, "dist");13const distDir = resolve(cliRoot, "dist");
13const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm";14const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm";
15const WORKERD_VERSION = "1.20260708.1";
14 16
15const resolveQuickJsWasmPath = (): string => {17const resolveQuickJsWasmPath = (): string => {
16 const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json"));18 const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json"));
@@ -23,6 +25,32 @@ const resolveQuickJsWasmPath = (): string => { · 6 unmodified lines
23 return wasmPath;25 return wasmPath;
24};26};
25 27
28// ---------------------------------------------------------------------------
29// MCP-Apps shell (mcp-app.html)
30//
31// The MCP host serves the shell as the `ui://executor/shell.html` resource, and
32// `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime
33// `fs.readFile` is invisible to `bun build --compile`, so without this the
34// packaged binary would fail every artifact resource read. Build it if missing
35// and copy it next to the executable, where the loader finds it via
36// `process.execPath` — the same colocation trick used for `libsql.node` /
37// `emscripten-module.wasm`.
38// ---------------------------------------------------------------------------
39
40const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell");
41const mcpAppsShellHtml = join(mcpAppsShellDir, "dist/mcp-app.html");
42
43const ensureMcpAppsShell = async (): Promise<string> => {
44 if (!existsSync(mcpAppsShellHtml)) {
45 console.log("Building MCP-Apps shell (mcp-app.html)...");
46 await $`bun run --cwd ${mcpAppsShellDir} build:shell`.quiet();
47 }
48 if (!existsSync(mcpAppsShellHtml)) {
49 throw new Error(`MCP-Apps shell missing at ${mcpAppsShellHtml} after build:shell`);
50 }
51 return mcpAppsShellHtml;
52};
53
26const resolveOnePasswordCoreWasmPath = (): string => {54const resolveOnePasswordCoreWasmPath = (): string => {
27 const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json"));55 const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json"));
28 const sdkPkg = req.resolve("@1password/sdk/package.json");56 const sdkPkg = req.resolve("@1password/sdk/package.json");
@@ -33,6 +61,82 @@ const resolveOnePasswordCoreWasmPath = (): string => { · 4 unmodified lines
33 return wasmPath;61 return wasmPath;
34};62};
35 63
64const resolveWorkerBundlerDistPath = (): string => {
65 const req = createRequire(join(repoRoot, "apps/cli/package.json"));
66 const pkgJson = req.resolve("@cloudflare/worker-bundler/package.json");
67 const distPath = join(dirname(pkgJson), "dist");
68 if (!existsSync(join(distPath, "index.js")) || !existsSync(join(distPath, "esbuild.wasm"))) {
69 throw new Error(`@cloudflare/worker-bundler dist files not found at ${distPath}`);
70 }
71 return distPath;
72};
73
74const createPackedWorkerBundlerSource = async (distPath: string): Promise<string> => {
75 const esbuildPath = join(repoRoot, "node_modules/.bun/node_modules/esbuild/lib/main.js");
76 if (!existsSync(esbuildPath)) throw new Error(`esbuild not found at ${esbuildPath}`);
77 const { build } = await import(pathToFileURL(esbuildPath).href);
78 const result = await build({
79 entryPoints: [join(distPath, "index.js")],
80 bundle: true,
81 format: "esm",
82 platform: "browser",
83 external: ["./esbuild.wasm"],
84 logLevel: "silent",
85 write: false,
86 });
87 const source = result.outputFiles[0]?.text;
88 if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler");
89 return source;
90};
91
92/**
93 * Verify the staged worker-bundler beside a freshly compiled binary.
94 *
95 * The compiled binary cannot resolve `@cloudflare/worker-bundler` by name —
96 * bunfs has no node_modules, so a bare specifier throws ResolveMessage at
97 * runtime. The colocated copy IS the delivery mechanism, and until now nothing
98 * checked it after the copy: a partial staging shipped a binary that only fails
99 * on the user's machine, at startup. Assert it here, where a failure costs a
100 * red build instead of a crash loop.
101 *
102 * Checks match the depth of the asset assertions elsewhere in the repo:
103 * presence of every file the runtime contract names, a size floor on the packed
104 * entry, and the `\0asm` magic on the wasm so a truncated or LFS-pointer copy
105 * cannot pass as real.
106 */
107const assertPackedWorkerBundler = async (binDir: string, targetName: string): Promise<void> => {
108 const stagedDir = join(binDir, WORKER_BUNDLER_DIRNAME);
109
110 const missing = missingWorkerBundlerFiles(stagedDir, existsSync);
111 if (missing.length > 0) {
112 throw new Error(
113 `Packed @cloudflare/worker-bundler is incomplete for ${targetName}: missing ${missing.join(", ")} under ${stagedDir}. ` +
114 `The compiled binary resolves this package from disk, not from bunfs, so shipping it partial means a runtime failure on the user's machine.`,
115 );
116 }
117
118 const bundledEntry = join(stagedDir, "dist", "index.bundled.js");
119 const bundledSize = (await Bun.file(bundledEntry).arrayBuffer()).byteLength;
120 if (bundledSize < 100_000) {
121 throw new Error(
122 `Packed worker-bundler entry for ${targetName} is implausibly small (${bundledSize} bytes at ${bundledEntry}). Expected the esbuild-packed bundle.`,
123 );
124 }
125
126 const wasmPath = join(stagedDir, "dist", "esbuild.wasm");
127 const wasmHead = new Uint8Array((await Bun.file(wasmPath).arrayBuffer()).slice(0, 4));
128 if (
129 wasmHead[0] !== 0x00 ||
130 wasmHead[1] !== 0x61 ||
131 wasmHead[2] !== 0x73 ||
132 wasmHead[3] !== 0x6d
133 ) {
134 throw new Error(
135 `Staged esbuild.wasm for ${targetName} is not a WebAssembly module (bad magic at ${wasmPath}).`,
136 );
137 }
138};
139
36// ---------------------------------------------------------------------------140// ---------------------------------------------------------------------------
37// Metadata141// Metadata
38// ---------------------------------------------------------------------------142// ---------------------------------------------------------------------------
@@ -119,6 +223,7 @@ const bunTarget = (t: Target): Bun.Build.CompileTarget => { · 80 unmodified lines
119};223};
120 224
121const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor");225const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor");
226const workerdBinaryName = (t: Target) => (t.os === "win32" ? "workerd.exe" : "workerd");
122 227
123const isCurrentPlatform = (t: Target) =>228const isCurrentPlatform = (t: Target) =>
124 t.os === process.platform && t.arch === process.arch && !t.abi;229 t.os === process.platform && t.arch === process.arch && !t.abi;
@@ -211,6 +316,41 @@ const resolveLibsqlNative = (t: Target): string | null => { · 86 unmodified lines
211 }316 }
212};317};
213 318
319const resolveWorkerdBinary = (t: Target): string | null => {
320 const platformMap: Record<string, string> = {
321 "darwin-arm64": "@cloudflare/workerd-darwin-arm64",
322 "darwin-x64": "@cloudflare/workerd-darwin-64",
323 "linux-arm64": "@cloudflare/workerd-linux-arm64",
324 "linux-x64": "@cloudflare/workerd-linux-64",
325 "win32-x64": "@cloudflare/workerd-windows-64",
326 };
327 const key = [t.os, t.arch, t.abi].filter(Boolean).join("-");
328 const pkg = platformMap[key];
329 if (!pkg) return null;
330 const binary = workerdBinaryName(t);
331 try {
332 const req = createRequire(
333 join(repoRoot, "packages/kernel/runtime-workerd-subprocess/package.json"),
334 );
335 const pkgJson = req.resolve(`${pkg}/package.json`);
336 for (const candidate of [
337 join(dirname(pkgJson), "bin", binary),
338 join(dirname(pkgJson), binary),
339 ]) {
340 if (existsSync(candidate)) return candidate;
341 }
342 } catch {
343 const packageRoot = join(
344 repoRoot,
345 `node_modules/.bun/${pkg.replace("/", "+")}@${WORKERD_VERSION}/node_modules/${pkg}`,
346 );
347 for (const candidate of [join(packageRoot, "bin", binary), join(packageRoot, binary)]) {
348 if (existsSync(candidate)) return candidate;
349 }
350 }
351 return null;
352};
353
214// ---------------------------------------------------------------------------354// ---------------------------------------------------------------------------
215// Build mode355// Build mode
216// ---------------------------------------------------------------------------356// ---------------------------------------------------------------------------
@@ -332,7 +472,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 115 unmodified lines
332 await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`);472 await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`);
333 473
334 const quickJsWasmPath = resolveQuickJsWasmPath();474 const quickJsWasmPath = resolveQuickJsWasmPath();
475 const mcpAppsShellPath = await ensureMcpAppsShell();
335 const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath();476 const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath();
477 const workerBundlerDistPath = resolveWorkerBundlerDistPath();
478 const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath);
336 479
337 try {480 try {
338 for (const target of targets) {481 for (const target of targets) {
@@ -355,6 +498,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 16 unmodified lines
355 // Copy QuickJS WASM next to binary — loaded at runtime by the server498 // Copy QuickJS WASM next to binary — loaded at runtime by the server
356 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));499 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));
357 500
501 // Copy the MCP-Apps shell next to the binary. Platform-independent HTML,
502 // read from execDir at runtime by @executor-js/mcp-apps-shell.
503 await cp(mcpAppsShellPath, join(binDir, "mcp-app.html"));
504
358 // Copy 1Password's sdk-core WASM next to the binary. The patched505 // Copy 1Password's sdk-core WASM next to the binary. The patched
359 // sdk-core loader falls back to this sibling file when bun --compile506 // sdk-core loader falls back to this sibling file when bun --compile
360 // bakes a build-machine node_modules path into __dirname.507 // bakes a build-machine node_modules path into __dirname.
@@ -375,6 +522,21 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 14 unmodified lines
375 await cp(libsqlNative, join(binDir, "libsql.node"));522 await cp(libsqlNative, join(binDir, "libsql.node"));
376 }523 }
377 524
525 const workerdBinary = resolveWorkerdBinary(target);
526 if (workerdBinary && existsSync(workerdBinary)) {
527 await cp(workerdBinary, join(binDir, workerdBinaryName(target)));
528 if (target.os !== "win32") await chmod(join(binDir, workerdBinaryName(target)), 0o755);
529 }
530
531 await cp(workerBundlerDistPath, join(binDir, WORKER_BUNDLER_DIRNAME, "dist"), {
532 recursive: true,
533 });
534 await writeFile(
535 join(binDir, WORKER_BUNDLER_DIRNAME, "dist", "index.bundled.js"),
536 packedWorkerBundlerSource,
537 );
538 await assertPackedWorkerBundler(binDir, name);
539
378 // Smoke test on current platform540 // Smoke test on current platform
379 if (isCurrentPlatform(target)) {541 if (isCurrentPlatform(target)) {
380 const bin = join(binDir, binaryName(target));542 const bin = join(binDir, binaryName(target));
apps/cli/src/device-login.test.ts+118−2
@@ -1,6 +1,44 @@
1import { describe, expect, it } from "@effect/vitest";1import { afterEach, describe, expect, it } from "@effect/vitest";
2 2
3import { browserOpenCommand } from "./device-login";3import {
4 browserOpenCommand,
5 discoverCliLogin,
6 refreshDeviceTokens,
7 requestDeviceCode,
8 type CliLoginDiscovery,
9} from "./device-login";
10
11const originalFetch = globalThis.fetch;
12
13interface FetchCall {
14 readonly url: string;
15 readonly headers: Record<string, string>;
16}
17
18const responseJson = (body: Record<string, unknown>, status = 200): Response =>
19 new Response(JSON.stringify(body), {
20 status,
21 headers: { "content-type": "application/json" },
22 });
23
24const installFetch = (handler: (url: string, init: RequestInit | undefined) => Response) => {
25 globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
26 const url =
27 typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
28 return Promise.resolve(handler(url, init));
29 }) as typeof fetch;
30};
31
32const recordCall = (calls: Array<FetchCall>, url: string, init: RequestInit | undefined): void => {
33 calls.push({
34 url,
35 headers: Object.fromEntries(new Headers(init?.headers).entries()),
36 });
37};
38
39afterEach(() => {
40 globalThis.fetch = originalFetch;
41});
4 42
5describe("browserOpenCommand", () => {43describe("browserOpenCommand", () => {
6 it("opens Windows browser URLs without cmd.exe", () => {44 it("opens Windows browser URLs without cmd.exe", () => {
@@ -32,3 +70,81 @@ describe("browserOpenCommand", () => { · 25 unmodified lines
32 expect(browserOpenCommand("not a url", "win32")).toBeUndefined();70 expect(browserOpenCommand("not a url", "win32")).toBeUndefined();
33 });71 });
34});72});
73
74describe("device login headers", () => {
75 it("sends configured headers when discovering CLI login", async () => {
76 const calls: Array<FetchCall> = [];
77 installFetch((url, init) => {
78 recordCall(calls, url, init);
79 return responseJson({
80 provider: "better-auth",
81 deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code",
82 tokenEndpoint: "https://executor.example/api/auth/device/token",
83 clientId: "executor-cli",
84 requestFormat: "json",
85 });
86 });
87
88 const discovery = await discoverCliLogin("https://executor.example", {
89 headers: { "CF-Access-Client-Id": "client-id" },
90 });
91
92 expect(discovery.clientId).toBe("executor-cli");
93 expect(calls).toHaveLength(1);
94 expect(calls[0]?.url).toBe("https://executor.example/api/auth/cli-login");
95 expect(calls[0]?.headers).toMatchObject({
96 accept: "application/json",
97 "cf-access-client-id": "client-id",
98 });
99 });
100
101 it("sends configured headers only to same-origin device endpoints", async () => {
102 const calls: Array<FetchCall> = [];
103 installFetch((url, init) => {
104 recordCall(calls, url, init);
105 if (url.endsWith("/api/auth/device/code")) {
106 return responseJson({
107 device_code: "device-code",
108 user_code: "USER-CODE",
109 verification_uri: "https://executor.example/device",
110 expires_in: 300,
111 interval: 5,
112 });
113 }
114 return responseJson({
115 access_token: "access-token",
116 refresh_token: "refresh-token-2",
117 expires_in: 600,
118 });
119 });
120 const discovery: CliLoginDiscovery = {
121 provider: "better-auth",
122 deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code",
123 tokenEndpoint: "https://accounts.example/oauth/token",
124 clientId: "executor-cli",
125 requestFormat: "form",
126 };
127 const headers = { "CF-Access-Client-Id": "client-id" };
128
129 await requestDeviceCode(discovery, { serverOrigin: "https://executor.example", headers });
130 await refreshDeviceTokens({
131 tokenEndpoint: discovery.tokenEndpoint,
132 clientId: discovery.clientId,
133 refreshToken: "refresh-token",
134 serverOrigin: "https://executor.example",
135 headers,
136 });
137
138 expect(calls).toHaveLength(2);
139 expect(calls[0]?.headers).toMatchObject({
140 accept: "application/json",
141 "content-type": "application/x-www-form-urlencoded",
142 "cf-access-client-id": "client-id",
143 });
144 expect(calls[1]?.headers).toMatchObject({
145 accept: "application/json",
146 "content-type": "application/x-www-form-urlencoded",
147 });
148 expect(calls[1]?.headers["cf-access-client-id"]).toBeUndefined();
149 });
150});
apps/cli/src/device-login.ts+57−8
@@ -50,6 +50,15 @@ export interface DeviceTokens { · 49 unmodified lines
50 readonly organizationId?: string;50 readonly organizationId?: string;
51}51}
52 52
53export interface DeviceLoginHttpOptions {
54 readonly headers?: Readonly<Record<string, string>>;
55 readonly serverOrigin?: string;
56}
57
58export interface PollForDeviceTokensOptions extends DeviceLoginHttpOptions {
59 readonly now?: () => number;
60}
61
53const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";62const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
54const DEFAULT_INTERVAL_SECONDS = 5;63const DEFAULT_INTERVAL_SECONDS = 5;
55 64
@@ -126,17 +135,43 @@ const definedFields = (fields: Record<string, string | undefined>): Record<strin · 70 unmodified lines
126 string135 string
127 >;136 >;
128 137
138const isSameOrigin = (url: string, origin: string): boolean => {
139 try {
140 return new URL(url).origin === new URL(origin).origin;
141 } catch {
142 return false;
143 }
144};
145
146const headersForUrl = (
147 url: string,
148 baseHeaders: Record<string, string>,
149 options: DeviceLoginHttpOptions = {},
150): Record<string, string> => {
151 const configured =
152 options.headers && (!options.serverOrigin || isSameOrigin(url, options.serverOrigin))
153 ? options.headers
154 : {};
155 return { ...configured, ...baseHeaders };
156};
157
129const post = async (158const post = async (
130 url: string,159 url: string,
131 fields: Record<string, string | undefined>,160 fields: Record<string, string | undefined>,
132 format: "form" | "json",161 format: "form" | "json",
162 options: DeviceLoginHttpOptions = {},
133) =>163) =>
134 fetch(url, {164 fetch(url, {
135 method: "POST",165 method: "POST",
136 headers: {166 headers: headersForUrl(
137 "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded",167 url,
138 accept: "application/json",168 {
139 },169 "content-type":
170 format === "json" ? "application/json" : "application/x-www-form-urlencoded",
171 accept: "application/json",
172 },
173 options,
174 ),
140 body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields),175 body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields),
141 });176 });
142 177
@@ -150,10 +185,16 @@ const readJson = async (response: Response): Promise<Record<string, unknown>> => · 7 unmodified lines
150 }185 }
151};186};
152 187
153export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscovery> => {188export const discoverCliLogin = async (
189 origin: string,
190 options: DeviceLoginHttpOptions = {},
191): Promise<CliLoginDiscovery> => {
154 let response: Response;192 let response: Response;
193 const url = cliLoginUrl(origin);
155 try {194 try {
156 response = await fetch(cliLoginUrl(origin), { headers: { accept: "application/json" } });195 response = await fetch(url, {
196 headers: headersForUrl(url, { accept: "application/json" }, options),
197 });
157 } catch (cause) {198 } catch (cause) {
158 throw new DeviceLoginError(199 throw new DeviceLoginError(
159 `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`,200 `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`,
@@ -182,11 +223,15 @@ export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscover · 22 unmodified lines
182 };223 };
183};224};
184 225
185export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise<DeviceCodeGrant> => {226export const requestDeviceCode = async (
227 discovery: CliLoginDiscovery,
228 options: DeviceLoginHttpOptions = {},
229): Promise<DeviceCodeGrant> => {
186 const response = await post(230 const response = await post(
187 discovery.deviceAuthorizationEndpoint,231 discovery.deviceAuthorizationEndpoint,
188 { client_id: discovery.clientId, scope: discovery.scope },232 { client_id: discovery.clientId, scope: discovery.scope },
189 discovery.requestFormat,233 discovery.requestFormat,
234 options,
190 );235 );
191 const body = await readJson(response);236 const body = await readJson(response);
192 if (!response.ok) {237 if (!response.ok) {
@@ -220,7 +265,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout · 27 unmodified lines
220export const pollForDeviceTokens = async (265export const pollForDeviceTokens = async (
221 discovery: CliLoginDiscovery,266 discovery: CliLoginDiscovery,
222 grant: DeviceCodeGrant,267 grant: DeviceCodeGrant,
223 options: { readonly now?: () => number } = {},268 options: PollForDeviceTokensOptions = {},
224): Promise<DeviceTokens> => {269): Promise<DeviceTokens> => {
225 const now = options.now ?? (() => Date.now());270 const now = options.now ?? (() => Date.now());
226 const deadline = now() + grant.expiresInSeconds * 1000;271 const deadline = now() + grant.expiresInSeconds * 1000;
@@ -240,6 +285,7 @@ export const pollForDeviceTokens = async ( · 13 unmodified lines
240 client_id: discovery.clientId,285 client_id: discovery.clientId,
241 },286 },
242 discovery.requestFormat,287 discovery.requestFormat,
288 options,
243 );289 );
244 const body = await readJson(response);290 const body = await readJson(response);
245 291
@@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: { · 40 unmodified lines
286 readonly tokenEndpoint: string;332 readonly tokenEndpoint: string;
287 readonly clientId: string;333 readonly clientId: string;
288 readonly refreshToken: string;334 readonly refreshToken: string;
335 readonly headers?: Readonly<Record<string, string>>;
336 readonly serverOrigin?: string;
289}): Promise<DeviceTokens> => {337}): Promise<DeviceTokens> => {
290 const response = await post(338 const response = await post(
291 input.tokenEndpoint,339 input.tokenEndpoint,
@@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: { · 3 unmodified lines
295 client_id: input.clientId,343 client_id: input.clientId,
296 },344 },
297 "form",345 "form",
346 input,
298 );347 );
299 const body = await readJson(response);348 const body = await readJson(response);
300 if (!response.ok) {349 if (!response.ok) {
apps/cli/src/main.ts+263−69
@@ -64,7 +64,12 @@ if (sentryDsn) { · 63 unmodified lines
64import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli";64import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli";
65import { BunRuntime, BunServices } from "@effect/platform-bun";65import { BunRuntime, BunServices } from "@effect/platform-bun";
66import { HttpApiClient } from "effect/unstable/httpapi";66import { HttpApiClient } from "effect/unstable/httpapi";
67import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";67import {
68 FetchHttpClient,
69 HttpClient,
70 HttpClientError,
71 HttpClientRequest,
72} from "effect/unstable/http";
68import { FileSystem, Path as PlatformPath } from "effect";73import { FileSystem, Path as PlatformPath } from "effect";
69import type { PlatformError } from "effect/PlatformError";74import type { PlatformError } from "effect/PlatformError";
70import * as Effect from "effect/Effect";75import * as Effect from "effect/Effect";
@@ -79,10 +84,13 @@ import { · 8 unmodified lines
79 getExecutorServerAuthorizationHeader,84 getExecutorServerAuthorizationHeader,
80 normalizeExecutorServerConnection,85 normalizeExecutorServerConnection,
81 normalizeExecutorServerOrigin,86 normalizeExecutorServerOrigin,
87 resolveExecutorServerConfiguredHeaders,
88 resolveExecutorServerRequestHeaders,
82 type ExecutorLocalServerKind,89 type ExecutorLocalServerKind,
83 type ExecutorLocalServerManifest,90 type ExecutorLocalServerManifest,
84 type ExecutorServerConnection,91 type ExecutorServerConnection,
85 type ExecutorServerConnectionInput,92 type ExecutorServerConnectionInput,
93 type ExecutorServerHeaders,
86} from "@executor-js/sdk/shared";94} from "@executor-js/sdk/shared";
87import {95import {
88 decodeAccessTokenClaims,96 decodeAccessTokenClaims,
@@ -131,7 +139,9 @@ import { · 42 unmodified lines
131import {139import {
132 canAutoStartCliServerConnection,140 canAutoStartCliServerConnection,
133 chooseCliServerConnectionWithActiveLocal,141 chooseCliServerConnectionWithActiveLocal,
142 describeUnauthorizedCliServer,
134 parseCliExecutorServerConnection,143 parseCliExecutorServerConnection,
144 profileNameFromConnectionKey,
135 type CliServerConnectionSource,145 type CliServerConnectionSource,
136 withCliServerAuthFallback,146 withCliServerAuthFallback,
137} from "./server-connection";147} from "./server-connection";
@@ -162,7 +172,7 @@ import { · 24 unmodified lines
162 buildDescribeToolCode,172 buildDescribeToolCode,
163 filterToolPathChildren,173 filterToolPathChildren,
164 buildInvokeToolCode,174 buildInvokeToolCode,
165 buildListSourcesCode,175 buildListIntegrationsCode,
166 buildSearchToolsCode,176 buildSearchToolsCode,
167 extractExecutionId,177 extractExecutionId,
168 extractPausedInteraction,178 extractPausedInteraction,
@@ -205,9 +215,11 @@ const waitForShutdownSignal = () => · 36 unmodified lines
205 const shutdown = () => resume(Effect.void);215 const shutdown = () => resume(Effect.void);
206 process.once("SIGINT", shutdown);216 process.once("SIGINT", shutdown);
207 process.once("SIGTERM", shutdown);217 process.once("SIGTERM", shutdown);
218 process.once("SIGHUP", shutdown);
208 return Effect.sync(() => {219 return Effect.sync(() => {
209 process.off("SIGINT", shutdown);220 process.off("SIGINT", shutdown);
210 process.off("SIGTERM", shutdown);221 process.off("SIGTERM", shutdown);
222 process.off("SIGHUP", shutdown);
211 });223 });
212 });224 });
213 225
@@ -743,12 +755,9 @@ const resolveRequestedExecutorServerConnection = ( · 529 unmodified lines
743// login. The refreshed tokens are written back to the originating profile.755// login. The refreshed tokens are written back to the originating profile.
744const OAUTH_REFRESH_SKEW_SECONDS = 60;756const OAUTH_REFRESH_SKEW_SECONDS = 60;
745 757
746const profileNameFromKey = (key: string): string | null =>
747 key.startsWith("profile:") ? key.slice("profile:".length) : null;
748
749const refreshOAuthConnection = (758const refreshOAuthConnection = (
750 connection: ExecutorServerConnection,759 connection: ExecutorServerConnection,
751): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> =>760): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> =>
752 Effect.gen(function* () {761 Effect.gen(function* () {
753 const auth = connection.auth;762 const auth = connection.auth;
754 if (!auth || auth.kind !== "oauth") return connection;763 if (!auth || auth.kind !== "oauth") return connection;
@@ -759,8 +768,19 @@ const refreshOAuthConnection = ( · 4 unmodified lines
759 const { refreshToken, tokenEndpoint, clientId } = auth;768 const { refreshToken, tokenEndpoint, clientId } = auth;
760 if (!refreshToken || !tokenEndpoint || !clientId) return connection;769 if (!refreshToken || !tokenEndpoint || !clientId) return connection;
761 770
771 const headers = yield* resolveExecutorServerConfiguredHeaders(connection, process.env).pipe(
772 Effect.mapError(toError),
773 );
774
762 const refreshed = yield* Effect.tryPromise({775 const refreshed = yield* Effect.tryPromise({
763 try: () => refreshDeviceTokens({ tokenEndpoint, clientId, refreshToken }),776 try: () =>
777 refreshDeviceTokens({
778 tokenEndpoint,
779 clientId,
780 refreshToken,
781 serverOrigin: connection.origin,
782 headers,
783 }),
764 catch: toError,784 catch: toError,
765 // On a failed refresh, keep the existing token and let the eventual 401785 // On a failed refresh, keep the existing token and let the eventual 401
766 // surface, better than blocking the command on a transient hiccup.786 // surface, better than blocking the command on a transient hiccup.
@@ -780,7 +800,7 @@ const refreshOAuthConnection = ( · 13 unmodified lines
780 },800 },
781 });801 });
782 802
783 const profileName = profileNameFromKey(connection.key);803 const profileName = profileNameFromConnectionKey(connection.key);
784 if (profileName) {804 if (profileName) {
785 yield* upsertCliServerConnectionProfile({805 yield* upsertCliServerConnectionProfile({
786 name: profileName,806 name: profileName,
@@ -961,7 +981,7 @@ const executeCode = (input: { · 174 unmodified lines
961}): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> =>981}): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> =>
962 Effect.gen(function* () {982 Effect.gen(function* () {
963 const connection = yield* resolveExecutorServerConnection(input.target);983 const connection = yield* resolveExecutorServerConnection(input.target);
964 const client = yield* makeApiClient(connection);984 const client = yield* makeApiClient(connection, input.target);
965 const response = yield* client.executions.execute({985 const response = yield* client.executions.execute({
966 payload: {986 payload: {
967 code: input.code,987 code: input.code,
@@ -1050,19 +1070,36 @@ const printExecutionOutcome = (input: { · 82 unmodified lines
1050// Typed API client1070// Typed API client
1051// ---------------------------------------------------------------------------1071// ---------------------------------------------------------------------------
1052 1072
1053const makeApiClient = (connection: ExecutorServerConnection) => {1073const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) =>
1054 const authorization = getExecutorServerAuthorizationHeader(connection);1074 Effect.gen(function* () {
1055 return HttpApiClient.make(ExecutorApi, {1075 const headers = yield* resolveExecutorServerRequestHeaders(connection, process.env).pipe(
1056 baseUrl: connection.apiBaseUrl,1076 Effect.mapError(toError),
1057 ...(authorization1077 );
1058 ? {1078 return yield* HttpApiClient.make(ExecutorApi, {
1059 transformClient: HttpClient.mapRequest((request) =>1079 baseUrl: connection.apiBaseUrl,
1060 HttpClientRequest.setHeader(request, "authorization", authorization),1080 ...(Object.keys(headers).length > 0
1061 ),1081 ? {
1062 }1082 transformClient: HttpClient.mapRequest((request) =>
1063 : {}),1083 HttpClientRequest.setHeaders(request, headers),
1084 ),
1085 }
1086 : {}),
1087 // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite
1088 // the transport-level error into the login hint. Without this the client
1089 // fails decoding the unexpected status and prints the opaque
1090 // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors)
1091 // decode before this catch and pass through untouched.
1092 transformResponse: (effect) =>
1093 Effect.catchIf(
1094 effect,
1095 (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401,
1096 () =>
1097 Effect.fail(
1098 new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target })),
1099 ),
1100 ),
1101 });
1064 }).pipe(Effect.provide(FetchHttpClient.layer));1102 }).pipe(Effect.provide(FetchHttpClient.layer));
1065};
1066 1103
1067// ---------------------------------------------------------------------------1104// ---------------------------------------------------------------------------
1068// Foreground session1105// Foreground session
@@ -1321,14 +1358,26 @@ const runBackgroundDaemonStart = (input: { · 252 unmodified lines
1321// Stdio MCP session: a pure stdio <-> HTTP bridge to the owning local daemon.1358// Stdio MCP session: a pure stdio <-> HTTP bridge to the owning local daemon.
1322// ---------------------------------------------------------------------------1359// ---------------------------------------------------------------------------
1323 1360
1324const mcpUrlForActiveLocalServer = (1361const mcpUrlForActiveLocalServer = (input: {
1325 connection: ExecutorServerConnection,1362 readonly connection: ExecutorServerConnection;
1326 elicitationMode: "browser" | "model",1363 readonly elicitationMode: "browser" | "model";
1327): URL => {1364 readonly artifacts: boolean;
1328 const url = new URL("/mcp", connection.origin);1365 readonly searchTools: boolean;
1329 if (elicitationMode === "browser") {1366}): URL => {
1367 const url = new URL("/mcp", input.connection.origin);
1368 if (input.elicitationMode === "browser") {
1330 url.searchParams.set("elicitation_mode", "browser");1369 url.searchParams.set("elicitation_mode", "browser");
1331 }1370 }
1371 // Artifacts are on by default; only the opt-out is spelled out, so the
1372 // default endpoint stays clean.
1373 if (!input.artifacts) {
1374 url.searchParams.set("artifacts", "false");
1375 }
1376 // Per-integration search tools are off by default; only the opt-in is
1377 // spelled out.
1378 if (input.searchTools) {
1379 url.searchParams.set("search_tools", "true");
1380 }
1332 return url;1381 return url;
1333};1382};
1334 1383
@@ -1343,11 +1392,18 @@ const mcpUrlForActiveLocalServer = ( · 8 unmodified lines
1343const runMcpHttpBridge = async (input: {1392const runMcpHttpBridge = async (input: {
1344 readonly manifest: ExecutorLocalServerManifest;1393 readonly manifest: ExecutorLocalServerManifest;
1345 readonly elicitationMode: "browser" | "model";1394 readonly elicitationMode: "browser" | "model";
1395 readonly artifacts: boolean;
1396 readonly searchTools: boolean;
1346}): Promise<void> => {1397}): Promise<void> => {
1347 const stdio = new StdioServerTransport();1398 const stdio = new StdioServerTransport();
1348 const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection);1399 const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection);
1349 const http = new StreamableHTTPClientTransport(1400 const http = new StreamableHTTPClientTransport(
1350 mcpUrlForActiveLocalServer(input.manifest.connection, input.elicitationMode),1401 mcpUrlForActiveLocalServer({
1402 connection: input.manifest.connection,
1403 elicitationMode: input.elicitationMode,
1404 artifacts: input.artifacts,
1405 searchTools: input.searchTools,
1406 }),
1351 authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined,1407 authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined,
1352 );1408 );
1353 1409
@@ -1422,7 +1478,11 @@ const runMcpHttpBridge = async (input: { · 68 unmodified lines
1422 }1478 }
1423};1479};
1424 1480
1425const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) =>1481const runStdioMcpSession = (input: {
1482 readonly elicitationMode: "browser" | "model";
1483 readonly artifacts: boolean;
1484 readonly searchTools: boolean;
1485}) =>
1426 Effect.gen(function* () {1486 Effect.gen(function* () {
1427 // `executor mcp` never owns the local database. If a local server is already1487 // `executor mcp` never owns the local database. If a local server is already
1428 // running, bridge this stdio client to it; otherwise ensure a durable1488 // running, bridge this stdio client to it; otherwise ensure a durable
@@ -1434,7 +1494,12 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 5 unmodified lines
1434 const active = yield* readActiveLocalServerManifest();1494 const active = yield* readActiveLocalServerManifest();
1435 if (active) {1495 if (active) {
1436 yield* Effect.promise(() =>1496 yield* Effect.promise(() =>
1437 runMcpHttpBridge({ manifest: active, elicitationMode: input.elicitationMode }),1497 runMcpHttpBridge({
1498 manifest: active,
1499 elicitationMode: input.elicitationMode,
1500 artifacts: input.artifacts,
1501 searchTools: input.searchTools,
1502 }),
1438 );1503 );
1439 return;1504 return;
1440 }1505 }
@@ -1456,7 +1521,12 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 15 unmodified lines
1456 );1521 );
1457 }1522 }
1458 yield* Effect.promise(() =>1523 yield* Effect.promise(() =>
1459 runMcpHttpBridge({ manifest: elected, elicitationMode: input.elicitationMode }),1524 runMcpHttpBridge({
1525 manifest: elected,
1526 elicitationMode: input.elicitationMode,
1527 artifacts: input.artifacts,
1528 searchTools: input.searchTools,
1529 }),
1460 );1530 );
1461 });1531 });
1462 1532
@@ -1504,14 +1574,45 @@ const parseOptionalJsonObject = ( · 41 unmodified lines
1504 Effect.mapError((error) => new Error(`Invalid --content JSON: ${error.message}`)),1574 Effect.mapError((error) => new Error(`Invalid --content JSON: ${error.message}`)),
1505 );1575 );
1506 1576
1577const ownMessage = (value: unknown): string => {
1578 if (typeof value === "string") return value.trim();
1579 if (typeof value === "object" && value !== null && "message" in value) {
1580 const message = (value as { message: unknown }).message;
1581 if (typeof message === "string") return message.trim();
1582 }
1583 return "";
1584};
1585
1586/**
1587 * Render an unknown failure by walking its `cause` chain. Wrapper errors
1588 * (tagged errors carrying only `{ cause }`) have an empty own message; without
1589 * descending, a boot failure like a data-migration error printed as literally
1590 * "Unknown error" (issue #1403). Nested messages that merely repeat their
1591 * parent are dropped.
1592 */
1507const formatUnknownMessage = (cause: unknown): string => {1593const formatUnknownMessage = (cause: unknown): string => {
1508 if (cause instanceof Error) return cause.message;1594 const messages: string[] = [];
1509 if (typeof cause === "string") return cause;1595 const seen = new Set<unknown>();
1510 if (typeof cause === "object" && cause !== null && "message" in cause) {1596 let current: unknown = cause;
1511 const message = cause.message;1597 while (current !== null && current !== undefined && !seen.has(current)) {
1512 if (typeof message === "string") return message;1598 seen.add(current);
1599 const message = ownMessage(current);
1600 if (message.length > 0 && !messages.some((existing) => existing.includes(message))) {
1601 messages.push(message);
1602 }
1603 current =
1604 typeof current === "object" && "cause" in current
1605 ? (current as { cause: unknown }).cause
1606 : undefined;
1513 }1607 }
1514 return String(cause);1608 if (messages.length === 0) {
1609 return typeof cause === "string"
1610 ? cause
1611 : cause instanceof Error
1612 ? cause.message
1613 : String(cause);
1614 }
1615 return messages.join("\ncaused by: ");
1515};1616};
1516 1617
1517const readCliLogLevel = (argv: ReadonlyArray<string>): string | undefined => {1618const readCliLogLevel = (argv: ReadonlyArray<string>): string | undefined => {
@@ -1802,11 +1903,9 @@ const runCallHelp = ( · 284 unmodified lines
1802 Effect.gen(function* () {1903 Effect.gen(function* () {
1803 if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir);1904 if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir);
1804 1905
1805 const connection = yield* resolveExecutorServerConnection({1906 const target: ServerTarget = { baseUrl: args.baseUrl, serverName: args.serverName };
1806 baseUrl: args.baseUrl,1907 const connection = yield* resolveExecutorServerConnection(target);
1807 serverName: args.serverName,1908 const client = yield* makeApiClient(connection, target);
1808 });
1809 const client = yield* makeApiClient(connection);
1810 const tools = yield* client.tools.list({ query: {} });1909 const tools = yield* client.tools.list({ query: {} });
1811 const toolPaths = tools.map((tool) => tool.address);1910 const toolPaths = tools.map((tool) => tool.address);
1812 1911
@@ -1987,7 +2086,7 @@ const resumeCommand = Command.make( · 174 unmodified lines
1987 2086
1988 const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content));2087 const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content));
1989 2088
1990 const client = yield* makeApiClient(connection);2089 const client = yield* makeApiClient(connection, target);
1991 const result = yield* client.executions.resume({2090 const result = yield* client.executions.resume({
1992 params: { executionId },2091 params: { executionId },
1993 payload: { action, content: contentObj },2092 payload: { action, content: contentObj },
@@ -2052,8 +2151,8 @@ const toolsSearchCommand = Command.make( · 58 unmodified lines
2052 }),2151 }),
2053).pipe(Command.withDescription("Search tools by natural-language query"));2152).pipe(Command.withDescription("Search tools by natural-language query"));
2054 2153
2055const toolsSourcesCommand = Command.make(2154const toolsIntegrationsCommand = Command.make(
2056 "sources",2155 "integrations",
2057 {2156 {
2058 query: Options.string("query").pipe(Options.optional),2157 query: Options.string("query").pipe(Options.optional),
2059 limit: Options.integer("limit").pipe(Options.withDefault(50)),2158 limit: Options.integer("limit").pipe(Options.withDefault(50)),
@@ -2065,7 +2164,7 @@ const toolsSourcesCommand = Command.make( · 5 unmodified lines
2065 Effect.gen(function* () {2164 Effect.gen(function* () {
2066 applyScope(scope);2165 applyScope(scope);
2067 const target = serverTargetFromOptions({ baseUrl, server });2166 const target = serverTargetFromOptions({ baseUrl, server });
2068 const code = buildListSourcesCode({2167 const code = buildListIntegrationsCode({
2069 query: Option.getOrUndefined(query),2168 query: Option.getOrUndefined(query),
2070 limit,2169 limit,
2071 });2170 });
@@ -2077,7 +2176,7 @@ const toolsSourcesCommand = Command.make( · 5 unmodified lines
2077 outcome: result.outcome,2176 outcome: result.outcome,
2078 });2177 });
2079 }),2178 }),
2080).pipe(Command.withDescription("List configured sources and tool counts"));2179).pipe(Command.withDescription("List configured integrations and tool counts"));
2081 2180
2082const toolsDescribeCommand = Command.make(2181const toolsDescribeCommand = Command.make(
2083 "describe",2182 "describe",
@@ -2102,21 +2201,55 @@ const toolsDescribeCommand = Command.make( · 18 unmodified lines
2102).pipe(Command.withDescription("Describe a tool's TypeScript and JSON schema"));2201).pipe(Command.withDescription("Describe a tool's TypeScript and JSON schema"));
2103 2202
2104const toolsCommand = Command.make("tools").pipe(2203const toolsCommand = Command.make("tools").pipe(
2105 Command.withSubcommands([toolsSearchCommand, toolsSourcesCommand, toolsDescribeCommand] as const),2204 Command.withSubcommands([
2106 Command.withDescription("Discover available tools and sources"),2205 toolsSearchCommand,
2206 toolsIntegrationsCommand,
2207 toolsDescribeCommand,
2208 ] as const),
2209 Command.withDescription("Discover available tools and integrations"),
2107);2210);
2108 2211
2212const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
2213const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2214
2215const parseHeaderEnvOption = (
2216 headerEnv: Option.Option<Record<string, string>>,
2217): ExecutorServerHeaders | undefined => {
2218 const values = Option.getOrUndefined(headerEnv);
2219 if (!values) return undefined;
2220 const headers: Record<string, { readonly kind: "env"; readonly name: string }> = {};
2221 for (const [rawHeaderName, rawEnvName] of Object.entries(values)) {
2222 const headerName = rawHeaderName.trim();
2223 const envName = rawEnvName.trim();
2224 if (!HEADER_NAME_PATTERN.test(headerName)) {
2225 throw new Error(
2226 `Invalid --header-env header name "${rawHeaderName}". Use an HTTP header token like CF-Access-Client-Id.`,
2227 );
2228 }
2229 if (!ENV_NAME_PATTERN.test(envName)) {
2230 throw new Error(
2231 `Invalid --header-env env name "${rawEnvName}". Use an environment variable name like EXECUTOR_CF_ACCESS_CLIENT_ID.`,
2232 );
2233 }
2234 headers[headerName] = { kind: "env", name: envName };
2235 }
2236 return Object.keys(headers).length > 0 ? headers : undefined;
2237};
2238
2109const profileConnectionInput = (input: {2239const profileConnectionInput = (input: {
2110 readonly origin: string;2240 readonly origin: string;
2111 readonly displayName: Option.Option<string>;2241 readonly displayName: Option.Option<string>;
2112 readonly kind: Option.Option<"http" | "desktop-sidecar">;2242 readonly kind: Option.Option<"http" | "desktop-sidecar">;
2243 readonly headerEnv: Option.Option<Record<string, string>>;
2113}): ExecutorServerConnectionInput => {2244}): ExecutorServerConnectionInput => {
2114 const selectedKind = Option.getOrUndefined(input.kind);2245 const selectedKind = Option.getOrUndefined(input.kind);
2115 const displayName = Option.getOrUndefined(input.displayName);2246 const displayName = Option.getOrUndefined(input.displayName);
2247 const headers = parseHeaderEnvOption(input.headerEnv);
2116 return {2248 return {
2117 kind: selectedKind ?? "http",2249 kind: selectedKind ?? "http",
2118 origin: input.origin,2250 origin: input.origin,
2119 ...(displayName ? { displayName } : {}),2251 ...(displayName ? { displayName } : {}),
2252 ...(headers ? { headers } : {}),
2120 };2253 };
2121};2254};
2122 2255
@@ -2136,13 +2269,16 @@ const printServerProfiles = () => · 13 unmodified lines
2136 origin: profile.connection.origin,2269 origin: profile.connection.origin,
2137 displayName: profile.connection.displayName,2270 displayName: profile.connection.displayName,
2138 auth: profile.connection.auth ? "stored-auth" : "env-auth",2271 auth: profile.connection.auth ? "stored-auth" : "env-auth",
2272 headers: profile.connection.headers
2273 ? `${Object.keys(profile.connection.headers).length} header-env`
2274 : "no-headers",
2139 }));2275 }));
2140 const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4);2276 const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4);
2141 const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4);2277 const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4);
2142 2278
2143 for (const row of rows) {2279 for (const row of rows) {
2144 console.log(2280 console.log(
2145 `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth}`,2281 `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth} ${row.headers}`,
2146 );2282 );
2147 }2283 }
2148 });2284 });
@@ -2160,17 +2296,23 @@ const serverAddCommand = Command.make( · 11 unmodified lines
2160 Options.optional,2296 Options.optional,
2161 Options.withDescription("Server kind. Defaults to http."),2297 Options.withDescription("Server kind. Defaults to http."),
2162 ),2298 ),
2299 headerEnv: Options.keyValuePair("header-env").pipe(
2300 Options.optional,
2301 Options.withDescription(
2302 "HTTP header mapping header-name=ENV_VAR. Repeat for Cloudflare Access service tokens.",
2303 ),
2304 ),
2163 makeDefault: Options.boolean("default").pipe(2305 makeDefault: Options.boolean("default").pipe(
2164 Options.withDefault(false),2306 Options.withDefault(false),
2165 Options.withDescription("Make this profile the default server."),2307 Options.withDescription("Make this profile the default server."),
2166 ),2308 ),
2167 },2309 },
2168 ({ name, origin, displayName, kind, makeDefault }) =>2310 ({ name, origin, displayName, kind, headerEnv, makeDefault }) =>
2169 Effect.gen(function* () {2311 Effect.gen(function* () {
2170 const profileName = validateCliServerConnectionProfileName(name);2312 const profileName = validateCliServerConnectionProfileName(name);
2171 const store = yield* upsertCliServerConnectionProfile({2313 const store = yield* upsertCliServerConnectionProfile({
2172 name: profileName,2314 name: profileName,
2173 connection: profileConnectionInput({ origin, displayName, kind }),2315 connection: profileConnectionInput({ origin, displayName, kind, headerEnv }),
2174 makeDefault,2316 makeDefault,
2175 });2317 });
2176 const profile = findCliServerConnectionProfile(store, profileName);2318 const profile = findCliServerConnectionProfile(store, profileName);
@@ -2307,9 +2449,13 @@ const chooseLoginProfileName = ( · 130 unmodified lines
2307 }2449 }
2308};2450};
2309 2451
2452/** Where `executor login` lands with no flags and no stored profiles. */
2453const DEFAULT_LOGIN_ORIGIN = "https://executor.sh";
2454
2310// Resolve which server a login/logout targets: an existing profile (--server2455// Resolve which server a login/logout targets: an existing profile (--server
2311// or the default) or a bare origin (--base-url). The profile name is decided2456// or the default), a bare origin (--base-url), or hosted Executor when
2312// later, from the authenticated account.2457// nothing is configured yet. The profile name is decided later, from the
2458// authenticated account.
2313const resolveLoginOrigin = (input: {2459const resolveLoginOrigin = (input: {
2314 readonly baseUrl: Option.Option<string>;2460 readonly baseUrl: Option.Option<string>;
2315 readonly server: Option.Option<string>;2461 readonly server: Option.Option<string>;
@@ -2337,11 +2483,7 @@ const resolveLoginOrigin = (input: { · 21 unmodified lines
2337 const store = yield* readCliServerConnectionStore();2483 const store = yield* readCliServerConnectionStore();
2338 const profile = defaultCliServerConnectionProfile(store);2484 const profile = defaultCliServerConnectionProfile(store);
2339 if (profile) return { origin: profile.connection.origin, profile };2485 if (profile) return { origin: profile.connection.origin, profile };
2340 return yield* Effect.fail(2486 return { origin: DEFAULT_LOGIN_ORIGIN, profile: null };
2341 new Error(
2342 "No server to log in to. Pass --base-url <https://your-host> or --server <profile>.",
2343 ),
2344 );
2345 });2487 });
2346 2488
2347const loginNameOption = Options.string("name").pipe(2489const loginNameOption = Options.string("name").pipe(
@@ -2366,12 +2508,21 @@ const loginCommand = Command.make( · 18 unmodified lines
2366 Effect.gen(function* () {2508 Effect.gen(function* () {
2367 const target = yield* resolveLoginOrigin({ baseUrl, server });2509 const target = yield* resolveLoginOrigin({ baseUrl, server });
2368 const explicitName = Option.getOrUndefined(name);2510 const explicitName = Option.getOrUndefined(name);
2511 // The target may have been picked implicitly (default profile, or the
2512 // hosted fallback): say where the login is going before the device flow.
2513 console.log(`Signing in to ${target.origin}`);
2514 const targetProfile = target.profile;
2515 const headers = targetProfile
2516 ? yield* resolveExecutorServerConfiguredHeaders(targetProfile.connection, process.env).pipe(
2517 Effect.mapError(toError),
2518 )
2519 : {};
2369 const discovery = yield* Effect.tryPromise({2520 const discovery = yield* Effect.tryPromise({
2370 try: () => discoverCliLogin(target.origin),2521 try: () => discoverCliLogin(target.origin, { headers }),
2371 catch: toError,2522 catch: toError,
2372 });2523 });
2373 const grant = yield* Effect.tryPromise({2524 const grant = yield* Effect.tryPromise({
2374 try: () => requestDeviceCode(discovery),2525 try: () => requestDeviceCode(discovery, { serverOrigin: target.origin, headers }),
2375 catch: toError,2526 catch: toError,
2376 });2527 });
2377 const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri;2528 const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri;
@@ -2382,7 +2533,7 @@ const loginCommand = Command.make( · 4 unmodified lines
2382 if (!noBrowser) openBrowser(verifyUrl);2533 if (!noBrowser) openBrowser(verifyUrl);
2383 console.log("Waiting for you to approve in the browser...");2534 console.log("Waiting for you to approve in the browser...");
2384 const tokens = yield* Effect.tryPromise({2535 const tokens = yield* Effect.tryPromise({
2385 try: () => pollForDeviceTokens(discovery, grant),2536 try: () => pollForDeviceTokens(discovery, grant, { serverOrigin: target.origin, headers }),
2386 catch: toError,2537 catch: toError,
2387 });2538 });
2388 2539
@@ -2407,6 +2558,9 @@ const loginCommand = Command.make( · 18 unmodified lines
2407 kind: "http",2558 kind: "http",
2408 origin: target.origin,2559 origin: target.origin,
2409 ...(email ? { displayName: email } : {}),2560 ...(email ? { displayName: email } : {}),
2561 ...(target.profile?.connection.headers
2562 ? { headers: target.profile.connection.headers }
2563 : {}),
2410 auth: {2564 auth: {
2411 kind: "oauth",2565 kind: "oauth",
2412 accessToken: tokens.accessToken,2566 accessToken: tokens.accessToken,
@@ -2420,12 +2574,24 @@ const loginCommand = Command.make( · 7 unmodified lines
2420 });2574 });
2421 2575
2422 console.log("");2576 console.log("");
2423 console.log(`Logged in to ${target.origin} (profile "${profileName}", now the default).`);2577 // Profile bookkeeping stays backstage unless the user works with named
2578 // profiles (--name, or a store that already has several).
2579 const mentionProfile =
2580 explicitName !== undefined || (yield* readCliServerConnectionStore()).profiles.length > 1;
2581 console.log(
2582 mentionProfile
2583 ? `Logged in to ${target.origin} (profile "${profileName}", now the default).`
2584 : `Logged in to ${target.origin}.`,
2585 );
2424 if (email) console.log(`Account: ${email}`);2586 if (email) console.log(`Account: ${email}`);
2425 if (org) console.log(`Organization: ${org}`);2587 if (org) console.log(`Organization: ${org}`);
2426 else if (sub) console.log(`User: ${sub}`);2588 else if (sub) console.log(`User: ${sub}`);
2427 }),2589 }),
2428).pipe(Command.withDescription("Sign in to a hosted Executor server in the browser (device flow)"));2590).pipe(
2591 Command.withDescription(
2592 "Sign in to a hosted Executor server in the browser (device flow). Defaults to https://executor.sh.",
2593 ),
2594);
2429 2595
2430const logoutCommand = Command.make(2596const logoutCommand = Command.make(
2431 "logout",2597 "logout",
@@ -2441,8 +2607,14 @@ const logoutCommand = Command.make( · 9 unmodified lines
2441 console.log(`No stored login for ${target.origin}.`);2607 console.log(`No stored login for ${target.origin}.`);
2442 return;2608 return;
2443 }2609 }
2610 // Profile bookkeeping stays backstage unless the user addressed one.
2611 const mentionProfile = Option.isSome(server) || store.profiles.length > 1;
2444 if (!profile.connection.auth) {2612 if (!profile.connection.auth) {
2445 console.log(`Profile "${profile.name}" has no stored credentials.`);2613 console.log(
2614 mentionProfile
2615 ? `Profile "${profile.name}" has no stored credentials.`
2616 : `Not signed in to ${target.origin}.`,
2617 );
2446 return;2618 return;
2447 }2619 }
2448 yield* upsertCliServerConnectionProfile({2620 yield* upsertCliServerConnectionProfile({
@@ -2451,11 +2623,14 @@ const logoutCommand = Command.make( · 2 unmodified lines
2451 kind: profile.connection.kind,2623 kind: profile.connection.kind,
2452 origin: profile.connection.origin,2624 origin: profile.connection.origin,
2453 displayName: profile.connection.displayName,2625 displayName: profile.connection.displayName,
2626 ...(profile.connection.headers ? { headers: profile.connection.headers } : {}),
2454 },2627 },
2455 makeDefault: store.defaultProfile === profile.name,2628 makeDefault: store.defaultProfile === profile.name,
2456 });2629 });
2457 console.log(2630 console.log(
2458 `Logged out of ${profile.connection.origin} (cleared credentials for "${profile.name}").`,2631 mentionProfile
2632 ? `Logged out of ${profile.connection.origin} (cleared credentials for "${profile.name}").`
2633 : `Logged out of ${profile.connection.origin}.`,
2459 );2634 );
2460 }),2635 }),
2461).pipe(Command.withDescription("Clear stored credentials for a server profile"));2636).pipe(Command.withDescription("Clear stored credentials for a server profile"));
@@ -2709,11 +2884,25 @@ const mcpCommand = Command.make( · 247 unmodified lines
2709 "Choose the stdio approval flow: browser approval or a CLI resume tool exposed to the model.",2884 "Choose the stdio approval flow: browser approval or a CLI resume tool exposed to the model.",
2710 ),2885 ),
2711 ),2886 ),
2887 noArtifacts: Options.boolean("no-artifacts")
2888 .pipe(Options.withDefault(false))
2889 .pipe(
2890 Options.withDescription(
2891 "Withhold the artifact surface from this connection: the artifact tools, the app shell resource, and the artifact skills. Served by default.",
2892 ),
2893 ),
2894 searchTools: Options.boolean("search-tools")
2895 .pipe(Options.withDefault(false))
2896 .pipe(
2897 Options.withDescription(
2898 "Serve one search_<integration> tool per connected integration. Off by default; each routes through the same flow as tools.search inside execute.",
2899 ),
2900 ),
2712 },2901 },
2713 ({ scope, elicitationMode }) =>2902 ({ scope, elicitationMode, noArtifacts, searchTools }) =>
2714 Effect.gen(function* () {2903 Effect.gen(function* () {
2715 applyScope(scope);2904 applyScope(scope);
2716 yield* runStdioMcpSession({ elicitationMode });2905 yield* runStdioMcpSession({ elicitationMode, artifacts: !noArtifacts, searchTools });
2717 }),2906 }),
2718).pipe(Command.withDescription("Start an MCP server over stdio"));2907).pipe(Command.withDescription("Start an MCP server over stdio"));
2719 2908
@@ -3164,7 +3353,12 @@ const program = ( · 444 unmodified lines
3164 } else {3353 } else {
3165 console.error(renderCliError(cause));3354 console.error(renderCliError(cause));
3166 }3355 }
3167 process.exitCode = 1;3356 // Exit hard, not via exitCode: this handler converts the failure into a
3357 // success, so runMain's teardown never force-exits, and background fibers
3358 // (the integrations-refresh fork) keep the event loop alive. A daemon
3359 // that failed boot then lingers with its port unbound, and the desktop
3360 // waits on the ready sentinel forever (issue #1403).
3361 process.exit(1);
3168 }),3362 }),
3169 ),3363 ),
3170);3364);
apps/cli/src/native-bindings.ts+36−5
@@ -1,11 +1,10 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Native-binding bootstrap for the `bun build --compile` binary.2// Native-binding bootstrap for the `bun build --compile` binary.
3//3//
4// `bun --compile` bundles JS into bunfs but does NOT include `.node` native4// `bun --compile` bundles JS into bunfs but does NOT include native binaries,
5// addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside5// wasm sidecars, or runtime-read package files. build.ts copies those artifacts
6// the binary fails. build.ts copies each platform's `.node` next to the6// next to the executable; here we publish their on-disk paths via env vars the
7// executable (`libsql.node`, `keyring.node`); here we publish their on-disk7// loaders read.
8// paths via env vars the loaders read.
9//8//
10// This MUST be the FIRST import in main.ts. ES modules evaluate every import9// This MUST be the FIRST import in main.ts. ES modules evaluate every import
11// before the importer's own body, and libSQL resolves its native addon EAGERLY10// before the importer's own body, and libSQL resolves its native addon EAGERLY
@@ -18,6 +17,8 @@ · 6 unmodified lines
18import { existsSync } from "node:fs";17import { existsSync } from "node:fs";
19import { dirname, join } from "node:path";18import { dirname, join } from "node:path";
20 19
20import { WORKER_BUNDLER_DIRNAME, missingWorkerBundlerFiles } from "./worker-bundler-artifact";
21
21const execDir = dirname(process.execPath);22const execDir = dirname(process.execPath);
22 23
23// libSQL: our `libsql` patch reads EXECUTOR_LIBSQL_NATIVE_PATH and loads the24// libSQL: our `libsql` patch reads EXECUTOR_LIBSQL_NATIVE_PATH and loads the
@@ -43,3 +44,33 @@ if ( · 19 unmodified lines
43) {44) {
44 process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;45 process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;
45}46}
47
48const workerdOnDisk = join(execDir, process.platform === "win32" ? "workerd.exe" : "workerd");
49if (typeof Bun !== "undefined" && !process.env.EXECUTOR_WORKERD_BIN && existsSync(workerdOnDisk)) {
50 process.env.EXECUTOR_WORKERD_BIN = workerdOnDisk;
51}
52
53// worker-bundler: the compiled binary cannot resolve `@cloudflare/worker-bundler`
54// by name (bunfs has no node_modules), so build.ts stages the package's dist
55// beside the executable and we publish its path here. An absent directory is
56// normal off the packaged path (dev, `bun run`), so that stays quiet — but a
57// directory that is PRESENT AND INCOMPLETE is a broken install, and swallowing
58// it is what turns a packaging slip into an unresolvable bare import at
59// startup. Report it on stderr instead of failing open.
60const workerBundlerOnDisk = join(execDir, WORKER_BUNDLER_DIRNAME);
61if (
62 typeof Bun !== "undefined" &&
63 !process.env.EXECUTOR_WORKER_BUNDLER_DIR &&
64 existsSync(workerBundlerOnDisk)
65) {
66 const missing = missingWorkerBundlerFiles(workerBundlerOnDisk, existsSync);
67 if (missing.length === 0) {
68 process.env.EXECUTOR_WORKER_BUNDLER_DIR = workerBundlerOnDisk;
69 } else {
70 process.stderr.write(
71 `executor: the bundled Worker toolchain at ${workerBundlerOnDisk} is incomplete ` +
72 `(missing ${missing.join(", ")}). Features that build Workers will be unavailable; ` +
73 `reinstall or update executor to repair it.\n`,
74 );
75 }
76}
apps/cli/src/server-connection.test.ts+84−0
@@ -1,8 +1,11 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared";
4
3import {5import {
4 canAutoStartCliServerConnection,6 canAutoStartCliServerConnection,
5 chooseCliServerConnectionWithActiveLocal,7 chooseCliServerConnectionWithActiveLocal,
8 describeUnauthorizedCliServer,
6 parseCliExecutorServerConnection,9 parseCliExecutorServerConnection,
7 withCliServerAuthFallback,10 withCliServerAuthFallback,
8} from "./server-connection";11} from "./server-connection";
@@ -135,3 +138,84 @@ describe("CLI server connection", () => { · 126 unmodified lines
135 ).toBe("conflict");138 ).toBe("conflict");
136 });139 });
137});140});
141
142describe("describeUnauthorizedCliServer", () => {
143 it("hints plain `executor login` when the server was picked implicitly", () => {
144 const connection = normalizeExecutorServerConnection({
145 key: "profile:rhys-executor.sh",
146 origin: "https://executor.sh",
147 auth: { kind: "oauth", accessToken: "stale", expiresAt: 1 },
148 });
149
150 const message = describeUnauthorizedCliServer({
151 connection,
152 cliPrefix: "executor",
153 target: {},
154 });
155 expect(message).toContain("You're signed out of https://executor.sh");
156 expect(message).toContain("Run `executor login` to sign in again.");
157 // Profile names are plumbing: never surfaced when the user didn't type one.
158 expect(message).not.toContain("--server");
159 expect(message).not.toContain("profile");
160 });
161
162 it("echoes --server back when the user targeted a named profile", () => {
163 const connection = normalizeExecutorServerConnection({
164 key: "profile:work",
165 origin: "https://executor.example",
166 auth: { kind: "oauth", accessToken: "stale", expiresAt: 1 },
167 });
168
169 const message = describeUnauthorizedCliServer({
170 connection,
171 cliPrefix: "executor",
172 target: { serverName: "work" },
173 });
174 expect(message).toContain("You're signed out of https://executor.example");
175 expect(message).toContain("executor login --server work");
176 });
177
178 it("echoes --base-url back when the user targeted an origin", () => {
179 const connection = normalizeExecutorServerConnection({
180 origin: "https://executor.example",
181 });
182
183 const message = describeUnauthorizedCliServer({
184 connection,
185 cliPrefix: "executor",
186 target: { baseUrl: "https://executor.example" },
187 });
188 expect(message).toContain("no credentials are stored");
189 expect(message).toContain("executor login --base-url https://executor.example");
190 });
191
192 it("mentions the env vars when a profile-less bearer key is rejected", () => {
193 const connection = normalizeExecutorServerConnection({
194 origin: "https://executor.example",
195 auth: { kind: "bearer", token: "key_bad" },
196 });
197
198 const message = describeUnauthorizedCliServer({
199 connection,
200 cliPrefix: "executor",
201 target: {},
202 });
203 expect(message).toContain("rejected the stored bearer credentials");
204 expect(message).toContain("EXECUTOR_API_KEY");
205 });
206
207 it("uses the dev entrypoint prefix verbatim", () => {
208 const connection = normalizeExecutorServerConnection({
209 key: "profile:hosted",
210 origin: "https://executor.example",
211 auth: { kind: "oauth", accessToken: "stale" },
212 });
213
214 const message = describeUnauthorizedCliServer({
215 connection,
216 cliPrefix: "bun run apps/cli/src/main.ts",
217 target: {},
218 });
219 expect(message).toContain("bun run apps/cli/src/main.ts login");
220 });
221});
apps/cli/src/server-connection.ts+49−0
@@ -52,6 +52,55 @@ export const canAutoStartCliServerConnection = (connection: ExecutorServerConnec · 51 unmodified lines
52 return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname);52 return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname);
53};53};
54 54
55export const profileNameFromConnectionKey = (key: string): string | null =>
56 key.startsWith("profile:") ? key.slice("profile:".length) : null;
57
58// A 401 from the server means the stored credential is stale, revoked, or
59// missing: a sign-in problem, not a transport problem. Rendered instead of
60// the raw HTTP client error so the user learns the recovery command, not the
61// failing endpoint.
62//
63// The hint mirrors how the user addressed the server. The common case types
64// no flags (the default profile, normally executor.sh), and login re-targets
65// that same default, so the fix is plain `executor login`: profile names are
66// plumbing and stay invisible. Only an explicit --server / --base-url comes
67// back in the hint.
68export const describeUnauthorizedCliServer = (input: {
69 readonly connection: ExecutorServerConnection;
70 readonly cliPrefix: string;
71 readonly target: { readonly baseUrl?: string; readonly serverName?: string };
72}): string => {
73 const { connection, cliPrefix, target } = input;
74 const loginCommand = target.serverName
75 ? `${cliPrefix} login --server ${target.serverName}`
76 : target.baseUrl
77 ? `${cliPrefix} login --base-url ${connection.origin}`
78 : `${cliPrefix} login`;
79
80 if (!connection.auth) {
81 return [
82 `${connection.origin} requires authentication, and no credentials are stored for it.`,
83 `Run \`${loginCommand}\` to sign in.`,
84 ].join("\n");
85 }
86
87 if (connection.auth.kind === "oauth") {
88 return [
89 `You're signed out of ${connection.origin}.`,
90 `Run \`${loginCommand}\` to sign in again.`,
91 ].join("\n");
92 }
93
94 const envHint =
95 connection.auth.kind === "bearer" && !profileNameFromConnectionKey(connection.key)
96 ? " If the key came from EXECUTOR_API_KEY or EXECUTOR_AUTH_TOKEN, check that value."
97 : "";
98 return [
99 `${connection.origin} rejected the stored ${connection.auth.kind} credentials (401).${envHint}`,
100 `Run \`${loginCommand}\` to sign in again.`,
101 ].join("\n");
102};
103
55export type CliServerConnectionSource =104export type CliServerConnectionSource =
56 | "explicit"105 | "explicit"
57 | "default-profile"106 | "default-profile"
apps/cli/src/server-profile.test.ts+86−5
@@ -1,6 +1,6 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { BunServices } from "@effect/platform-bun";2import { BunServices } from "@effect/platform-bun";
3import { mkdtempSync, rmSync } from "node:fs";3import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
4import { tmpdir } from "node:os";4import { tmpdir } from "node:os";
5import { join } from "node:path";5import { join } from "node:path";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
@@ -25,7 +25,7 @@ afterEach(() => { · 18 unmodified lines
25});25});
26 26
27describe("CLI server connection profiles", () => {27describe("CLI server connection profiles", () => {
28 it("round-trips named server connections and default selection", () =>28 it.effect("round-trips named server connections and default selection", () =>
29 Effect.gen(function* () {29 Effect.gen(function* () {
30 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-"));30 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-"));
31 process.env.EXECUTOR_DATA_DIR = dataDir;31 process.env.EXECUTOR_DATA_DIR = dataDir;
@@ -36,6 +36,9 @@ describe("CLI server connection profiles", () => { · 4 unmodified lines
36 connection: {36 connection: {
37 origin: "https://executor.example/api",37 origin: "https://executor.example/api",
38 auth: { kind: "bearer", token: "key_123" },38 auth: { kind: "bearer", token: "key_123" },
39 headers: {
40 "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
41 },
39 },42 },
40 makeDefault: true,43 makeDefault: true,
41 });44 });
@@ -50,13 +53,17 @@ describe("CLI server connection profiles", () => { · 8 unmodified lines
50 kind: "bearer",53 kind: "bearer",
51 token: "key_123",54 token: "key_123",
52 });55 });
56 expect(store.profiles[0]?.connection.headers).toEqual({
57 "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
58 });
53 expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote");59 expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote");
54 } finally {60 } finally {
55 rmSync(dataDir, { recursive: true, force: true });61 rmSync(dataDir, { recursive: true, force: true });
56 }62 }
57 }).pipe(Effect.provide(BunServices.layer)));63 }).pipe(Effect.provide(BunServices.layer)),
64 );
58 65
59 it("switches and removes the default profile", () =>66 it.effect("switches and removes the default profile", () =>
60 Effect.gen(function* () {67 Effect.gen(function* () {
61 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-"));68 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-"));
62 process.env.EXECUTOR_DATA_DIR = dataDir;69 process.env.EXECUTOR_DATA_DIR = dataDir;
@@ -82,7 +89,8 @@ describe("CLI server connection profiles", () => { · 19 unmodified lines
82 } finally {89 } finally {
83 rmSync(dataDir, { recursive: true, force: true });90 rmSync(dataDir, { recursive: true, force: true });
84 }91 }
85 }).pipe(Effect.provide(BunServices.layer)));92 }).pipe(Effect.provide(BunServices.layer)),
93 );
86 94
87 it("drops malformed profiles when parsing", () => {95 it("drops malformed profiles when parsing", () => {
88 const store = parseCliServerConnectionStore(96 const store = parseCliServerConnectionStore(
@@ -114,6 +122,12 @@ describe("CLI server connection profiles", () => { · 25 unmodified lines
114 key: "desktop-sidecar",122 key: "desktop-sidecar",
115 origin: "http://127.0.0.1:4789",123 origin: "http://127.0.0.1:4789",
116 auth: { kind: "basic", username: "executor", password: "secret" },124 auth: { kind: "basic", username: "executor", password: "secret" },
125 headers: {
126 "CF-Access-Client-Id": {
127 kind: "env",
128 name: "EXECUTOR_CF_ACCESS_CLIENT_ID",
129 },
130 },
117 },131 },
118 },132 },
119 ],133 ],
@@ -127,5 +141,72 @@ describe("CLI server connection profiles", () => { · 7 unmodified lines
127 username: "executor",141 username: "executor",
128 password: "secret",142 password: "secret",
129 });143 });
144 expect(store.profiles[0]?.connection.headers).toEqual({
145 "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
146 });
130 });147 });
148
149 // -------------------------------------------------------------------------
150 // File permissions.
151 //
152 // This store holds live credentials for a hosted server — a bearer token, or
153 // an OAuth access token and its long-lived refresh token. Nothing pinned its
154 // mode before, so it was created with the process umask (0644 by default) and
155 // readable by every other account on the machine.
156 //
157 // Both cases are covered because they need different mechanisms: `mode` on
158 // create, and a `chmod` for the overwrite. The overwrite is the common path
159 // here, since the store is rewritten on every silent token refresh.
160 // -------------------------------------------------------------------------
161
162 it.effect("creates the credential store owner-only", () =>
163 Effect.gen(function* () {
164 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-mode-"));
165 process.env.EXECUTOR_DATA_DIR = dataDir;
166
167 try {
168 yield* upsertCliServerConnectionProfile({
169 name: "remote",
170 connection: {
171 origin: "https://executor.example",
172 auth: { kind: "bearer", token: "key_should_not_be_world_readable" },
173 },
174 makeDefault: true,
175 });
176
177 const mode = statSync(join(dataDir, "server-connections.json")).mode & 0o777;
178 expect(mode).toBe(0o600);
179 } finally {
180 rmSync(dataDir, { recursive: true, force: true });
181 }
182 }).pipe(Effect.provide(BunServices.layer)),
183 );
184
185 it.effect("tightens a pre-existing world-readable credential store on rewrite", () =>
186 Effect.gen(function* () {
187 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-chmod-"));
188 process.env.EXECUTOR_DATA_DIR = dataDir;
189 const storePath = join(dataDir, "server-connections.json");
190
191 try {
192 // A store left behind by an older version, world-readable. `mode` on
193 // write is ignored for an existing file, so only the chmod fixes this.
194 writeFileSync(storePath, JSON.stringify({ version: 1, profiles: [] }));
195 chmodSync(storePath, 0o644);
196
197 yield* upsertCliServerConnectionProfile({
198 name: "remote",
199 connection: {
200 origin: "https://executor.example",
201 auth: { kind: "bearer", token: "key_should_not_be_world_readable" },
202 },
203 makeDefault: true,
204 });
205
206 expect(statSync(storePath).mode & 0o777).toBe(0o600);
207 } finally {
208 rmSync(dataDir, { recursive: true, force: true });
209 }
210 }).pipe(Effect.provide(BunServices.layer)),
211 );
131});212});
apps/cli/src/server-profile.ts+22−4
@@ -62,6 +62,11 @@ const PersistedAuth = Schema.Union([ · 61 unmodified lines
62 }),62 }),
63]);63]);
64 64
65const PersistedHeader = Schema.Struct({
66 kind: Schema.Literal("env"),
67 name: Schema.String,
68});
69
65const PersistedConnection = Schema.Struct({70const PersistedConnection = Schema.Struct({
66 kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])),71 kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])),
67 key: Schema.optional(Schema.String),72 key: Schema.optional(Schema.String),
@@ -69,6 +74,7 @@ const PersistedConnection = Schema.Struct({ · 1 unmodified line
69 apiBaseUrl: Schema.optional(Schema.String),74 apiBaseUrl: Schema.optional(Schema.String),
70 displayName: Schema.optional(Schema.String),75 displayName: Schema.optional(Schema.String),
71 auth: Schema.optional(PersistedAuth),76 auth: Schema.optional(PersistedAuth),
77 headers: Schema.optional(Schema.Record(Schema.String, PersistedHeader)),
72});78});
73 79
74const PersistedProfile = Schema.Struct({80const PersistedProfile = Schema.Struct({
@@ -144,10 +150,22 @@ export const writeCliServerConnectionStore = ( · 69 unmodified lines
144 const path = yield* Path.Path;150 const path = yield* Path.Path;
145 const dataDir = resolveDataDir(path);151 const dataDir = resolveDataDir(path);
146 yield* fs.makeDirectory(dataDir, { recursive: true });152 yield* fs.makeDirectory(dataDir, { recursive: true });
147 yield* fs.writeFileString(153 const storePath = serverConnectionStorePath(path);
148 serverConnectionStorePath(path),154 // This store holds live credentials for a hosted server — a bearer token, or
149 serializeCliServerConnectionStore(store),155 // an OAuth access token AND its long-lived refresh token — so create it
150 );156 // owner-only, exactly as the local-server manifest does for the sibling
157 // secret it keeps under `server-control/`.
158 //
159 // Both steps are needed, and the second matters more here than it does
160 // there. `mode` applies only when the file is CREATED, so it closes the
161 // window where a fresh store is briefly world-readable. The `chmod` covers
162 // overwriting a store that already exists with looser permissions — and this
163 // file is rewritten on every silent token refresh, so overwrite is the
164 // common path, not the rare one.
165 yield* fs.writeFileString(storePath, serializeCliServerConnectionStore(store), {
166 mode: 0o600,
167 });
168 yield* fs.chmod(storePath, 0o600).pipe(Effect.ignore);
151 });169 });
152 170
153export const upsertCliServerConnectionProfile = (input: {171export const upsertCliServerConnectionProfile = (input: {
apps/cli/src/service.test.ts+30−0
@@ -185,6 +185,36 @@ describe("service unit generation", () => { · 184 unmodified lines
185 expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD");185 expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD");
186 });186 });
187 187
188 it("preserves custom TLS trust paths in the supervised environment", () => {
189 const previousNodeExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS;
190 const previousSslCertFile = process.env.SSL_CERT_FILE;
191 const previousSslCertDir = process.env.SSL_CERT_DIR;
192 process.env.NODE_EXTRA_CA_CERTS = "/Users/x/.certs/node-extra.pem";
193 process.env.SSL_CERT_FILE = "/Users/x/.certs/combined.pem";
194 process.env.SSL_CERT_DIR = "/Users/x/.certs";
195
196 const wrapper = generateWindowsDaemonWrapper(
197 {
198 executablePath: "C:\\Program Files\\Executor\\executor.exe",
199 port: 4789,
200 version: "1.5.10",
201 },
202 "C:\\Users\\x\\.executor",
203 "C:\\Users\\x\\.executor\\logs",
204 );
205
206 if (previousNodeExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
207 else process.env.NODE_EXTRA_CA_CERTS = previousNodeExtraCaCerts;
208 if (previousSslCertFile === undefined) delete process.env.SSL_CERT_FILE;
209 else process.env.SSL_CERT_FILE = previousSslCertFile;
210 if (previousSslCertDir === undefined) delete process.env.SSL_CERT_DIR;
211 else process.env.SSL_CERT_DIR = previousSslCertDir;
212
213 expect(wrapper).toContain('set "NODE_EXTRA_CA_CERTS=/Users/x/.certs/node-extra.pem"');
214 expect(wrapper).toContain('set "SSL_CERT_FILE=/Users/x/.certs/combined.pem"');
215 expect(wrapper).toContain('set "SSL_CERT_DIR=/Users/x/.certs"');
216 });
217
188 it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => {218 it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => {
189 // A `"` in PATH would close the `set "PATH=..."` quote early and let a219 // A `"` in PATH would close the `set "PATH=..."` quote early and let a
190 // `& cmd &` fragment run at boot as the user; strip it (illegal in a path220 // `& cmd &` fragment run at boot as the user; strip it (illegal in a path
apps/cli/src/service.ts+11−0
@@ -177,6 +177,17 @@ const serviceEnvironment = ( · 176 unmodified lines
177 "EXECUTOR_SENTRY_RELEASE",177 "EXECUTOR_SENTRY_RELEASE",
178 "EXECUTOR_SENTRY_ENVIRONMENT",178 "EXECUTOR_SENTRY_ENVIRONMENT",
179 "EXECUTOR_RUN_ID",179 "EXECUTOR_RUN_ID",
180 // Analytics opt-out must survive into the supervised unit's minimal env,
181 // or an opted-out install would silently re-enable analytics under launchd.
182 "DO_NOT_TRACK",
183 "EXECUTOR_DISABLE_ANALYTICS",
184 // Preserve the caller's TLS trust configuration. Corporate/intercepting
185 // CAs commonly live outside the OS trust store; dropping these paths in a
186 // minimal launchd/systemd environment makes HTTPS integrations fail only
187 // after service installation while the same CLI call succeeds.
188 "NODE_EXTRA_CA_CERTS",
189 "SSL_CERT_FILE",
190 "SSL_CERT_DIR",
180 ] as const;191 ] as const;
181 const passThrough = Object.fromEntries(192 const passThrough = Object.fromEntries(
182 passThroughKeys.flatMap((key) => {193 passThroughKeys.flatMap((key) => {
apps/cli/src/tooling.ts+2−2
@@ -408,14 +408,14 @@ export const buildSearchToolsCode = (input: { · 407 unmodified lines
408 return `return await tools.search(${JSON.stringify(payload)});`;408 return `return await tools.search(${JSON.stringify(payload)});`;
409};409};
410 410
411export const buildListSourcesCode = (input: { query?: string; limit: number }): string => {411export const buildListIntegrationsCode = (input: { query?: string; limit: number }): string => {
412 const payload: Record<string, unknown> = {412 const payload: Record<string, unknown> = {
413 limit: input.limit,413 limit: input.limit,
414 };414 };
415 if (input.query && input.query.trim().length > 0) {415 if (input.query && input.query.trim().length > 0) {
416 payload.query = input.query;416 payload.query = input.query;
417 }417 }
418 return `return await tools.executor.sources.list(${JSON.stringify(payload)});`;418 return `return await tools.executor.integrations.list(${JSON.stringify(payload)});`;
419};419};
420 420
421export const buildDescribeToolCode = (toolPath: string): string =>421export const buildDescribeToolCode = (toolPath: string): string =>
apps/cli/src/worker-bundler-artifact.test.ts+59−0
@@ -0,0 +1,59 @@
1import { describe, expect, it } from "@effect/vitest";
2import { join } from "node:path";
3
4import {
5 WORKER_BUNDLER_DIRNAME,
6 WORKER_BUNDLER_RUNTIME_FILES,
7 missingWorkerBundlerFiles,
8} from "./worker-bundler-artifact";
9
10const STAGED_DIR = join("/opt/executor/bin", WORKER_BUNDLER_DIRNAME);
11
12/** An existence probe that reports only the given POSIX-relative paths. */
13const staged =
14 (...relatives: readonly string[]) =>
15 (path: string): boolean =>
16 relatives.some((relative) => path === join(STAGED_DIR, ...relative.split("/")));
17
18describe("missingWorkerBundlerFiles", () => {
19 it("reports nothing when the full artifact is staged", () => {
20 expect(missingWorkerBundlerFiles(STAGED_DIR, staged(...WORKER_BUNDLER_RUNTIME_FILES))).toEqual(
21 [],
22 );
23 });
24
25 it("reports every file when the directory is empty", () => {
26 expect(missingWorkerBundlerFiles(STAGED_DIR, () => false)).toEqual([
27 ...WORKER_BUNDLER_RUNTIME_FILES,
28 ]);
29 });
30
31 // The regression this whole seam exists for: the packaged daemon reads the
32 // wasm from disk beside the entry. A copy that brought the JS but dropped the
33 // wasm used to pass the build and fail on the user's machine at startup.
34 it("reports the esbuild wasm when only the JS entrypoints are staged", () => {
35 expect(
36 missingWorkerBundlerFiles(STAGED_DIR, staged("dist/index.js", "dist/index.bundled.js")),
37 ).toEqual(["dist/esbuild.wasm"]);
38 });
39
40 // `index.bundled.js` is the file consumers load — the package's own
41 // `dist/index.js` still has bare imports nothing can resolve inside the
42 // compiled binary. Staging index.js alone is not enough.
43 it("reports the packed entry when only the unbundled package entry is staged", () => {
44 expect(
45 missingWorkerBundlerFiles(STAGED_DIR, staged("dist/index.js", "dist/esbuild.wasm")),
46 ).toEqual(["dist/index.bundled.js"]);
47 });
48
49 it("names files relative to the staged directory so errors are quotable", () => {
50 for (const relative of WORKER_BUNDLER_RUNTIME_FILES) {
51 expect(relative.startsWith("dist/")).toBe(true);
52 }
53 });
54
55 it("requires the packed entry and the wasm, not just the package entry", () => {
56 expect(WORKER_BUNDLER_RUNTIME_FILES).toContain("dist/index.bundled.js");
57 expect(WORKER_BUNDLER_RUNTIME_FILES).toContain("dist/esbuild.wasm");
58 });
59});
apps/cli/src/worker-bundler-artifact.ts+55−0
@@ -0,0 +1,55 @@
1// ---------------------------------------------------------------------------
2// The packed `@cloudflare/worker-bundler` artifact that ships NEXT TO the
3// compiled binary.
4//
5// `bun build --compile` cannot embed a package that is only ever addressed by
6// bare specifier at runtime: bunfs has no `node_modules`, so an
7// `import("@cloudflare/worker-bundler")` evaluated inside the binary resolves
8// against the virtual root and throws
9// ResolveMessage: Cannot find module '@cloudflare/worker-bundler'
10// The build therefore copies the package's `dist/` next to the executable and
11// `native-bindings.ts` publishes its absolute path as
12// EXECUTOR_WORKER_BUNDLER_DIR. Consumers must load from that directory; a bare
13// specifier is unresolvable in the packaged daemon by construction.
14//
15// This module is the single source of truth for which files that contract
16// needs, so the build-time staging check and the runtime lookup cannot drift.
17// They previously did: the build wrote `dist/index.bundled.js` (the file
18// consumers actually load) while the runtime check only looked for
19// `dist/index.js` and `dist/esbuild.wasm`. That drift fails OPEN — an
20// incomplete staged copy passes the build, the runtime silently declines to
21// publish the env var, and the consumer falls through to the bare specifier.
22// ---------------------------------------------------------------------------
23
24import { join } from "node:path";
25
26/** Directory name staged beside the executable, and looked up from execDir. */
27export const WORKER_BUNDLER_DIRNAME = "worker-bundler";
28
29/**
30 * Files the colocated copy must contain to be usable.
31 *
32 * `index.bundled.js` is the entrypoint consumers load (the build packs it with
33 * esbuild so it has no bare-specifier imports of its own); `esbuild.wasm` is
34 * read at runtime beside it; `index.js` is the package's own entry, kept so the
35 * staged directory stays a faithful copy rather than a lookalike.
36 */
37export const WORKER_BUNDLER_RUNTIME_FILES = [
38 "dist/index.js",
39 "dist/index.bundled.js",
40 "dist/esbuild.wasm",
41] as const;
42
43/**
44 * Which required files are absent from a staged worker-bundler directory.
45 *
46 * Takes the existence probe as a parameter so the build script, the runtime
47 * bootstrap, and tests all agree on the requirement without touching a real
48 * filesystem. Returns the POSIX-relative names, which is what error messages
49 * should quote.
50 */
51export const missingWorkerBundlerFiles = (
52 dir: string,
53 exists: (path: string) => boolean,
54): readonly string[] =>
55 WORKER_BUNDLER_RUNTIME_FILES.filter((relative) => !exists(join(dir, ...relative.split("/"))));
apps/cli/tsconfig.json+1−1
@@ -8,7 +8,7 @@ · 7 unmodified lines
8 "skipLibCheck": true,8 "skipLibCheck": true,
9 "noEmit": true,9 "noEmit": true,
10 "declaration": true,10 "declaration": true,
11 "types": ["bun-types"]11 "types": ["bun-types", "node"]
12 },12 },
13 "include": ["src", "bin"]13 "include": ["src", "bin"]
14}14}
apps/cloud/CHANGELOG.md+335−0
@@ -1,5 +1,340 @@
1# @executor-js/cloud1# @executor-js/cloud
2 2
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
3## 1.4.47338## 1.4.47
4 339
5### Patch Changes340### Patch Changes
apps/cloud/drizzle/0010_ndjson_output_arrays.sql+23−0
@@ -0,0 +1,23 @@
1-- Extraction used to persist an NDJSON operation's PER-LINE response schema
2-- as the tool's output schema, while the invoke path returns an ARRAY of
3-- parsed lines, so describe previews promised a single object invocations
4-- never returned. The producer now wraps those schemas in an array. Persisted
5-- `tool` rows can't be recognized as NDJSON-derived from their schema alone,
6-- but the stored operation bindings kept the response content type, so the
7-- affected connections are findable: stale-mark `tools_synced_at` and the
8-- next read rebuilds their tool rows through the fixed producer.
9--
10-- Effectively idempotent: re-running re-marks the same connections, and a
11-- rebuild from unchanged bindings writes identical rows.
12UPDATE "connection" c
13SET "tools_synced_at" = NULL
14FROM (
15 SELECT DISTINCT ps."tenant", ps."data"::jsonb ->> 'integration' AS integration
16 FROM "plugin_storage" ps
17 WHERE ps."collection" = 'operation'
18 AND (ps."data"::text LIKE '%application/stream+json%'
19 OR ps."data"::text LIKE '%application/x-ndjson%'
20 OR ps."data"::text LIKE '%application/jsonl%')
21) ndjson
22WHERE c."tenant" = ndjson."tenant"
23 AND c."integration" = ndjson."integration";
apps/cloud/drizzle/0011_tense_caretaker.sql+1−0
@@ -0,0 +1 @@
1ALTER TABLE "oauth_client" ADD COLUMN "origin_redirect_uri" text;
apps/cloud/drizzle/0012_woozy_shiva.sql+10−0
@@ -0,0 +1,10 @@
1CREATE TABLE "subject" (
2 "external_id" varchar(255) NOT NULL,
3 "created_at" timestamp NOT NULL,
4 "last_seen_at" bigint,
5 "status" text,
6 "row_id" varchar(255) PRIMARY KEY NOT NULL,
7 "tenant" varchar(255) NOT NULL
8);
9--> statement-breakpoint
10CREATE UNIQUE INDEX "subject_uidx" ON "subject" USING btree ("tenant","external_id");
apps/cloud/drizzle/0013_curly_rocket_racer.sql+14−0
@@ -0,0 +1,14 @@
1CREATE TABLE "artifact" (
2 "id" varchar(255) NOT NULL,
3 "title" text NOT NULL,
4 "description" text,
5 "code" text NOT NULL,
6 "created_at" timestamp NOT NULL,
7 "updated_at" timestamp NOT NULL,
8 "row_id" varchar(255) PRIMARY KEY NOT NULL,
9 "tenant" varchar(255) NOT NULL,
10 "owner" varchar(255) NOT NULL,
11 "subject" varchar(255) NOT NULL
12);
13--> statement-breakpoint
14CREATE UNIQUE INDEX "artifact_uidx" ON "artifact" USING btree ("tenant","owner","subject","id");
apps/cloud/drizzle/0014_new_karma.sql+1−0
@@ -0,0 +1 @@
1ALTER TABLE "artifact" ADD COLUMN "bindings" json;
apps/cloud/drizzle/0015_equal_the_leader.sql+1−0
@@ -0,0 +1 @@
1ALTER TABLE "artifact" ADD COLUMN "preview" text;
apps/cloud/drizzle/meta/0010_snapshot.json+1290−0
@@ -0,0 +1,1290 @@
1{
2 "id": "c27a8958-ad73-4590-9b9b-d1b2584e0bc6",
3 "prevId": "b07ddbda-be11-4679-9f00-16d1b3b5e879",
4 "version": "7",
5 "dialect": "postgresql",
6 "tables": {
7 "public.accounts": {
8 "name": "accounts",
9 "schema": "",
10 "columns": {
11 "id": {
12 "name": "id",
13 "type": "text",
14 "primaryKey": true,
15 "notNull": true
16 },
17 "created_at": {
18 "name": "created_at",
19 "type": "timestamp with time zone",
20 "primaryKey": false,
21 "notNull": true,
22 "default": "now()"
23 }
24 },
25 "indexes": {},
26 "foreignKeys": {},
27 "compositePrimaryKeys": {},
28 "uniqueConstraints": {},
29 "policies": {},
30 "checkConstraints": {},
31 "isRLSEnabled": false
32 },
33 "public.memberships": {
34 "name": "memberships",
35 "schema": "",
36 "columns": {
37 "account_id": {
38 "name": "account_id",
39 "type": "text",
40 "primaryKey": false,
41 "notNull": true
42 },
43 "organization_id": {
44 "name": "organization_id",
45 "type": "text",
46 "primaryKey": false,
47 "notNull": true
48 },
49 "created_at": {
50 "name": "created_at",
51 "type": "timestamp with time zone",
52 "primaryKey": false,
53 "notNull": true,
54 "default": "now()"
55 }
56 },
57 "indexes": {},
58 "foreignKeys": {
59 "memberships_account_id_accounts_id_fk": {
60 "name": "memberships_account_id_accounts_id_fk",
61 "tableFrom": "memberships",
62 "tableTo": "accounts",
63 "columnsFrom": ["account_id"],
64 "columnsTo": ["id"],
65 "onDelete": "cascade",
66 "onUpdate": "no action"
67 },
68 "memberships_organization_id_organizations_id_fk": {
69 "name": "memberships_organization_id_organizations_id_fk",
70 "tableFrom": "memberships",
71 "tableTo": "organizations",
72 "columnsFrom": ["organization_id"],
73 "columnsTo": ["id"],
74 "onDelete": "cascade",
75 "onUpdate": "no action"
76 }
77 },
78 "compositePrimaryKeys": {
79 "memberships_account_id_organization_id_pk": {
80 "name": "memberships_account_id_organization_id_pk",
81 "columns": ["account_id", "organization_id"]
82 }
83 },
84 "uniqueConstraints": {},
85 "policies": {},
86 "checkConstraints": {},
87 "isRLSEnabled": false
88 },
89 "public.organizations": {
90 "name": "organizations",
91 "schema": "",
92 "columns": {
93 "id": {
94 "name": "id",
95 "type": "text",
96 "primaryKey": true,
97 "notNull": true
98 },
99 "name": {
100 "name": "name",
101 "type": "text",
102 "primaryKey": false,
103 "notNull": true
104 },
105 "slug": {
106 "name": "slug",
107 "type": "text",
108 "primaryKey": false,
109 "notNull": true
110 },
111 "created_at": {
112 "name": "created_at",
113 "type": "timestamp with time zone",
114 "primaryKey": false,
115 "notNull": true,
116 "default": "now()"
117 }
118 },
119 "indexes": {
120 "organizations_slug_unique": {
121 "name": "organizations_slug_unique",
122 "columns": [
123 {
124 "expression": "slug",
125 "isExpression": false,
126 "asc": true,
127 "nulls": "last"
128 }
129 ],
130 "isUnique": true,
131 "concurrently": false,
132 "method": "btree",
133 "with": {}
134 }
135 },
136 "foreignKeys": {},
137 "compositePrimaryKeys": {},
138 "uniqueConstraints": {},
139 "policies": {},
140 "checkConstraints": {},
141 "isRLSEnabled": false
142 },
143 "public.blob": {
144 "name": "blob",
145 "schema": "",
146 "columns": {
147 "namespace": {
148 "name": "namespace",
149 "type": "varchar(255)",
150 "primaryKey": false,
151 "notNull": true
152 },
153 "key": {
154 "name": "key",
155 "type": "varchar(255)",
156 "primaryKey": false,
157 "notNull": true
158 },
159 "value": {
160 "name": "value",
161 "type": "text",
162 "primaryKey": false,
163 "notNull": true
164 },
165 "row_id": {
166 "name": "row_id",
167 "type": "varchar(255)",
168 "primaryKey": true,
169 "notNull": true
170 },
171 "id": {
172 "name": "id",
173 "type": "varchar(255)",
174 "primaryKey": false,
175 "notNull": true
176 }
177 },
178 "indexes": {
179 "blob_id_uidx": {
180 "name": "blob_id_uidx",
181 "columns": [
182 {
183 "expression": "id",
184 "isExpression": false,
185 "asc": true,
186 "nulls": "last"
187 }
188 ],
189 "isUnique": true,
190 "concurrently": false,
191 "method": "btree",
192 "with": {}
193 }
194 },
195 "foreignKeys": {},
196 "compositePrimaryKeys": {},
197 "uniqueConstraints": {},
198 "policies": {},
199 "checkConstraints": {},
200 "isRLSEnabled": false
201 },
202 "public.connection": {
203 "name": "connection",
204 "schema": "",
205 "columns": {
206 "integration": {
207 "name": "integration",
208 "type": "varchar(255)",
209 "primaryKey": false,
210 "notNull": true
211 },
212 "name": {
213 "name": "name",
214 "type": "varchar(255)",
215 "primaryKey": false,
216 "notNull": true
217 },
218 "template": {
219 "name": "template",
220 "type": "text",
221 "primaryKey": false,
222 "notNull": true
223 },
224 "provider": {
225 "name": "provider",
226 "type": "text",
227 "primaryKey": false,
228 "notNull": true
229 },
230 "item_ids": {
231 "name": "item_ids",
232 "type": "json",
233 "primaryKey": false,
234 "notNull": true
235 },
236 "identity_label": {
237 "name": "identity_label",
238 "type": "text",
239 "primaryKey": false,
240 "notNull": false
241 },
242 "description": {
243 "name": "description",
244 "type": "text",
245 "primaryKey": false,
246 "notNull": false
247 },
248 "last_health": {
249 "name": "last_health",
250 "type": "json",
251 "primaryKey": false,
252 "notNull": false
253 },
254 "tools_synced_at": {
255 "name": "tools_synced_at",
256 "type": "bigint",
257 "primaryKey": false,
258 "notNull": false
259 },
260 "oauth_client": {
261 "name": "oauth_client",
262 "type": "text",
263 "primaryKey": false,
264 "notNull": false
265 },
266 "oauth_client_owner": {
267 "name": "oauth_client_owner",
268 "type": "text",
269 "primaryKey": false,
270 "notNull": false
271 },
272 "refresh_item_id": {
273 "name": "refresh_item_id",
274 "type": "text",
275 "primaryKey": false,
276 "notNull": false
277 },
278 "expires_at": {
279 "name": "expires_at",
280 "type": "bigint",
281 "primaryKey": false,
282 "notNull": false
283 },
284 "oauth_scope": {
285 "name": "oauth_scope",
286 "type": "text",
287 "primaryKey": false,
288 "notNull": false
289 },
290 "oauth_token_url": {
291 "name": "oauth_token_url",
292 "type": "text",
293 "primaryKey": false,
294 "notNull": false
295 },
296 "provider_state": {
297 "name": "provider_state",
298 "type": "json",
299 "primaryKey": false,
300 "notNull": false
301 },
302 "created_at": {
303 "name": "created_at",
304 "type": "timestamp",
305 "primaryKey": false,
306 "notNull": true
307 },
308 "updated_at": {
309 "name": "updated_at",
310 "type": "timestamp",
311 "primaryKey": false,
312 "notNull": true
313 },
314 "row_id": {
315 "name": "row_id",
316 "type": "varchar(255)",
317 "primaryKey": true,
318 "notNull": true
319 },
320 "tenant": {
321 "name": "tenant",
322 "type": "varchar(255)",
323 "primaryKey": false,
324 "notNull": true
325 },
326 "owner": {
327 "name": "owner",
328 "type": "varchar(255)",
329 "primaryKey": false,
330 "notNull": true
331 },
332 "subject": {
333 "name": "subject",
334 "type": "varchar(255)",
335 "primaryKey": false,
336 "notNull": true
337 }
338 },
339 "indexes": {
340 "connection_uidx": {
341 "name": "connection_uidx",
342 "columns": [
343 {
344 "expression": "tenant",
345 "isExpression": false,
346 "asc": true,
347 "nulls": "last"
348 },
349 {
350 "expression": "owner",
351 "isExpression": false,
352 "asc": true,
353 "nulls": "last"
354 },
355 {
356 "expression": "subject",
357 "isExpression": false,
358 "asc": true,
359 "nulls": "last"
360 },
361 {
362 "expression": "integration",
363 "isExpression": false,
364 "asc": true,
365 "nulls": "last"
366 },
367 {
368 "expression": "name",
369 "isExpression": false,
370 "asc": true,
371 "nulls": "last"
372 }
373 ],
374 "isUnique": true,
375 "concurrently": false,
376 "method": "btree",
377 "with": {}
378 }
379 },
380 "foreignKeys": {},
381 "compositePrimaryKeys": {},
382 "uniqueConstraints": {},
383 "policies": {},
384 "checkConstraints": {},
385 "isRLSEnabled": false
386 },
387 "public.definition": {
388 "name": "definition",
389 "schema": "",
390 "columns": {
391 "integration": {
392 "name": "integration",
393 "type": "varchar(255)",
394 "primaryKey": false,
395 "notNull": true
396 },
397 "connection": {
398 "name": "connection",
399 "type": "varchar(255)",
400 "primaryKey": false,
401 "notNull": true
402 },
403 "plugin_id": {
404 "name": "plugin_id",
405 "type": "text",
406 "primaryKey": false,
407 "notNull": true
408 },
409 "name": {
410 "name": "name",
411 "type": "text",
412 "primaryKey": false,
413 "notNull": true
414 },
415 "schema": {
416 "name": "schema",
417 "type": "json",
418 "primaryKey": false,
419 "notNull": true
420 },
421 "created_at": {
422 "name": "created_at",
423 "type": "timestamp",
424 "primaryKey": false,
425 "notNull": true
426 },
427 "row_id": {
428 "name": "row_id",
429 "type": "varchar(255)",
430 "primaryKey": true,
431 "notNull": true
432 },
433 "tenant": {
434 "name": "tenant",
435 "type": "varchar(255)",
436 "primaryKey": false,
437 "notNull": true
438 },
439 "owner": {
440 "name": "owner",
441 "type": "varchar(255)",
442 "primaryKey": false,
443 "notNull": true
444 },
445 "subject": {
446 "name": "subject",
447 "type": "varchar(255)",
448 "primaryKey": false,
449 "notNull": true
450 }
451 },
452 "indexes": {
453 "definition_uidx": {
454 "name": "definition_uidx",
455 "columns": [
456 {
457 "expression": "tenant",
458 "isExpression": false,
459 "asc": true,
460 "nulls": "last"
461 },
462 {
463 "expression": "owner",
464 "isExpression": false,
465 "asc": true,
466 "nulls": "last"
467 },
468 {
469 "expression": "subject",
470 "isExpression": false,
471 "asc": true,
472 "nulls": "last"
473 },
474 {
475 "expression": "integration",
476 "isExpression": false,
477 "asc": true,
478 "nulls": "last"
479 },
480 {
481 "expression": "connection",
482 "isExpression": false,
483 "asc": true,
484 "nulls": "last"
485 },
486 {
487 "expression": "name",
488 "isExpression": false,
489 "asc": true,
490 "nulls": "last"
491 }
492 ],
493 "isUnique": true,
494 "concurrently": false,
495 "method": "btree",
496 "with": {}
497 }
498 },
499 "foreignKeys": {},
500 "compositePrimaryKeys": {},
501 "uniqueConstraints": {},
502 "policies": {},
503 "checkConstraints": {},
504 "isRLSEnabled": false
505 },
506 "public.integration": {
507 "name": "integration",
508 "schema": "",
509 "columns": {
510 "slug": {
511 "name": "slug",
512 "type": "varchar(255)",
513 "primaryKey": false,
514 "notNull": true
515 },
516 "plugin_id": {
517 "name": "plugin_id",
518 "type": "text",
519 "primaryKey": false,
520 "notNull": true
521 },
522 "name": {
523 "name": "name",
524 "type": "text",
525 "primaryKey": false,
526 "notNull": false
527 },
528 "description": {
529 "name": "description",
530 "type": "text",
531 "primaryKey": false,
532 "notNull": false
533 },
534 "config_revised_at": {
535 "name": "config_revised_at",
536 "type": "bigint",
537 "primaryKey": false,
538 "notNull": false
539 },
540 "config": {
541 "name": "config",
542 "type": "json",
543 "primaryKey": false,
544 "notNull": false
545 },
546 "health_check": {
547 "name": "health_check",
548 "type": "json",
549 "primaryKey": false,
550 "notNull": false
551 },
552 "can_remove": {
553 "name": "can_remove",
554 "type": "boolean",
555 "primaryKey": false,
556 "notNull": true,
557 "default": true
558 },
559 "can_refresh": {
560 "name": "can_refresh",
561 "type": "boolean",
562 "primaryKey": false,
563 "notNull": true,
564 "default": false
565 },
566 "created_at": {
567 "name": "created_at",
568 "type": "timestamp",
569 "primaryKey": false,
570 "notNull": true
571 },
572 "updated_at": {
573 "name": "updated_at",
574 "type": "timestamp",
575 "primaryKey": false,
576 "notNull": true
577 },
578 "row_id": {
579 "name": "row_id",
580 "type": "varchar(255)",
581 "primaryKey": true,
582 "notNull": true
583 },
584 "tenant": {
585 "name": "tenant",
586 "type": "varchar(255)",
587 "primaryKey": false,
588 "notNull": true
589 }
590 },
591 "indexes": {
592 "integration_uidx": {
593 "name": "integration_uidx",
594 "columns": [
595 {
596 "expression": "tenant",
597 "isExpression": false,
598 "asc": true,
599 "nulls": "last"
600 },
601 {
602 "expression": "slug",
603 "isExpression": false,
604 "asc": true,
605 "nulls": "last"
606 }
607 ],
608 "isUnique": true,
609 "concurrently": false,
610 "method": "btree",
611 "with": {}
612 }
613 },
614 "foreignKeys": {},
615 "compositePrimaryKeys": {},
616 "uniqueConstraints": {},
617 "policies": {},
618 "checkConstraints": {},
619 "isRLSEnabled": false
620 },
621 "public.oauth_client": {
622 "name": "oauth_client",
623 "schema": "",
624 "columns": {
625 "slug": {
626 "name": "slug",
627 "type": "varchar(255)",
628 "primaryKey": false,
629 "notNull": true
630 },
631 "authorization_url": {
632 "name": "authorization_url",
633 "type": "text",
634 "primaryKey": false,
635 "notNull": true
636 },
637 "token_url": {
638 "name": "token_url",
639 "type": "text",
640 "primaryKey": false,
641 "notNull": true
642 },
643 "grant": {
644 "name": "grant",
645 "type": "text",
646 "primaryKey": false,
647 "notNull": true
648 },
649 "client_id": {
650 "name": "client_id",
651 "type": "text",
652 "primaryKey": false,
653 "notNull": true
654 },
655 "client_secret_item_id": {
656 "name": "client_secret_item_id",
657 "type": "text",
658 "primaryKey": false,
659 "notNull": false
660 },
661 "resource": {
662 "name": "resource",
663 "type": "text",
664 "primaryKey": false,
665 "notNull": false
666 },
667 "origin_kind": {
668 "name": "origin_kind",
669 "type": "text",
670 "primaryKey": false,
671 "notNull": false
672 },
673 "origin_integration": {
674 "name": "origin_integration",
675 "type": "text",
676 "primaryKey": false,
677 "notNull": false
678 },
679 "origin_issuer": {
680 "name": "origin_issuer",
681 "type": "text",
682 "primaryKey": false,
683 "notNull": false
684 },
685 "created_at": {
686 "name": "created_at",
687 "type": "timestamp",
688 "primaryKey": false,
689 "notNull": true
690 },
691 "row_id": {
692 "name": "row_id",
693 "type": "varchar(255)",
694 "primaryKey": true,
695 "notNull": true
696 },
697 "tenant": {
698 "name": "tenant",
699 "type": "varchar(255)",
700 "primaryKey": false,
701 "notNull": true
702 },
703 "owner": {
704 "name": "owner",
705 "type": "varchar(255)",
706 "primaryKey": false,
707 "notNull": true
708 },
709 "subject": {
710 "name": "subject",
711 "type": "varchar(255)",
712 "primaryKey": false,
713 "notNull": true
714 }
715 },
716 "indexes": {
717 "oauth_client_uidx": {
718 "name": "oauth_client_uidx",
719 "columns": [
720 {
721 "expression": "tenant",
722 "isExpression": false,
723 "asc": true,
724 "nulls": "last"
725 },
726 {
727 "expression": "owner",
728 "isExpression": false,
729 "asc": true,
730 "nulls": "last"
731 },
732 {
733 "expression": "subject",
734 "isExpression": false,
735 "asc": true,
736 "nulls": "last"
737 },
738 {
739 "expression": "slug",
740 "isExpression": false,
741 "asc": true,
742 "nulls": "last"
743 }
744 ],
745 "isUnique": true,
746 "concurrently": false,
747 "method": "btree",
748 "with": {}
749 }
750 },
751 "foreignKeys": {},
752 "compositePrimaryKeys": {},
753 "uniqueConstraints": {},
754 "policies": {},
755 "checkConstraints": {},
756 "isRLSEnabled": false
757 },
758 "public.oauth_session": {
759 "name": "oauth_session",
760 "schema": "",
761 "columns": {
762 "state": {
763 "name": "state",
764 "type": "varchar(255)",
765 "primaryKey": false,
766 "notNull": true
767 },
768 "client_slug": {
769 "name": "client_slug",
770 "type": "text",
771 "primaryKey": false,
772 "notNull": true
773 },
774 "integration": {
775 "name": "integration",
776 "type": "text",
777 "primaryKey": false,
778 "notNull": true
779 },
780 "name": {
781 "name": "name",
782 "type": "text",
783 "primaryKey": false,
784 "notNull": true
785 },
786 "template": {
787 "name": "template",
788 "type": "text",
789 "primaryKey": false,
790 "notNull": true
791 },
792 "redirect_url": {
793 "name": "redirect_url",
794 "type": "text",
795 "primaryKey": false,
796 "notNull": true
797 },
798 "pkce_verifier": {
799 "name": "pkce_verifier",
800 "type": "text",
801 "primaryKey": false,
802 "notNull": false
803 },
804 "identity_label": {
805 "name": "identity_label",
806 "type": "text",
807 "primaryKey": false,
808 "notNull": false
809 },
810 "payload": {
811 "name": "payload",
812 "type": "json",
813 "primaryKey": false,
814 "notNull": true
815 },
816 "expires_at": {
817 "name": "expires_at",
818 "type": "bigint",
819 "primaryKey": false,
820 "notNull": true
821 },
822 "created_at": {
823 "name": "created_at",
824 "type": "timestamp",
825 "primaryKey": false,
826 "notNull": true
827 },
828 "row_id": {
829 "name": "row_id",
830 "type": "varchar(255)",
831 "primaryKey": true,
832 "notNull": true
833 },
834 "tenant": {
835 "name": "tenant",
836 "type": "varchar(255)",
837 "primaryKey": false,
838 "notNull": true
839 },
840 "owner": {
841 "name": "owner",
842 "type": "varchar(255)",
843 "primaryKey": false,
844 "notNull": true
845 },
846 "subject": {
847 "name": "subject",
848 "type": "varchar(255)",
849 "primaryKey": false,
850 "notNull": true
851 }
852 },
853 "indexes": {
854 "oauth_session_uidx": {
855 "name": "oauth_session_uidx",
856 "columns": [
857 {
858 "expression": "tenant",
859 "isExpression": false,
860 "asc": true,
861 "nulls": "last"
862 },
863 {
864 "expression": "state",
865 "isExpression": false,
866 "asc": true,
867 "nulls": "last"
868 }
869 ],
870 "isUnique": true,
871 "concurrently": false,
872 "method": "btree",
873 "with": {}
874 }
875 },
876 "foreignKeys": {},
877 "compositePrimaryKeys": {},
878 "uniqueConstraints": {},
879 "policies": {},
880 "checkConstraints": {},
881 "isRLSEnabled": false
882 },
883 "public.plugin_storage": {
884 "name": "plugin_storage",
885 "schema": "",
886 "columns": {
887 "plugin_id": {
888 "name": "plugin_id",
889 "type": "varchar(255)",
890 "primaryKey": false,
891 "notNull": true
892 },
893 "collection": {
894 "name": "collection",
895 "type": "varchar(255)",
896 "primaryKey": false,
897 "notNull": true
898 },
899 "key": {
900 "name": "key",
901 "type": "varchar(255)",
902 "primaryKey": false,
903 "notNull": true
904 },
905 "data": {
906 "name": "data",
907 "type": "json",
908 "primaryKey": false,
909 "notNull": true
910 },
911 "created_at": {
912 "name": "created_at",
913 "type": "timestamp",
914 "primaryKey": false,
915 "notNull": true
916 },
917 "updated_at": {
918 "name": "updated_at",
919 "type": "timestamp",
920 "primaryKey": false,
921 "notNull": true
922 },
923 "row_id": {
924 "name": "row_id",
925 "type": "varchar(255)",
926 "primaryKey": true,
927 "notNull": true
928 },
929 "tenant": {
930 "name": "tenant",
931 "type": "varchar(255)",
932 "primaryKey": false,
933 "notNull": true
934 },
935 "owner": {
936 "name": "owner",
937 "type": "varchar(255)",
938 "primaryKey": false,
939 "notNull": true
940 },
941 "subject": {
942 "name": "subject",
943 "type": "varchar(255)",
944 "primaryKey": false,
945 "notNull": true
946 }
947 },
948 "indexes": {
949 "plugin_storage_uidx": {
950 "name": "plugin_storage_uidx",
951 "columns": [
952 {
953 "expression": "tenant",
954 "isExpression": false,
955 "asc": true,
956 "nulls": "last"
957 },
958 {
959 "expression": "owner",
960 "isExpression": false,
961 "asc": true,
962 "nulls": "last"
963 },
964 {
965 "expression": "subject",
966 "isExpression": false,
967 "asc": true,
968 "nulls": "last"
969 },
970 {
971 "expression": "plugin_id",
972 "isExpression": false,
973 "asc": true,
974 "nulls": "last"
975 },
976 {
977 "expression": "collection",
978 "isExpression": false,
979 "asc": true,
980 "nulls": "last"
981 },
982 {
983 "expression": "key",
984 "isExpression": false,
985 "asc": true,
986 "nulls": "last"
987 }
988 ],
989 "isUnique": true,
990 "concurrently": false,
991 "method": "btree",
992 "with": {}
993 }
994 },
995 "foreignKeys": {},
996 "compositePrimaryKeys": {},
997 "uniqueConstraints": {},
998 "policies": {},
999 "checkConstraints": {},
1000 "isRLSEnabled": false
1001 },
1002 "public.private_executor_cloud_settings": {
1003 "name": "private_executor_cloud_settings",
1004 "schema": "",
1005 "columns": {
1006 "id": {
1007 "name": "id",
1008 "type": "varchar(255)",
1009 "primaryKey": true,
1010 "notNull": true
1011 },
1012 "version": {
1013 "name": "version",
1014 "type": "varchar(255)",
1015 "primaryKey": false,
1016 "notNull": true,
1017 "default": "'1.0.0'"
1018 }
1019 },
1020 "indexes": {},
1021 "foreignKeys": {},
1022 "compositePrimaryKeys": {},
1023 "uniqueConstraints": {},
1024 "policies": {},
1025 "checkConstraints": {},
1026 "isRLSEnabled": false
1027 },
1028 "public.tool": {
1029 "name": "tool",
1030 "schema": "",
1031 "columns": {
1032 "integration": {
1033 "name": "integration",
1034 "type": "varchar(255)",
1035 "primaryKey": false,
1036 "notNull": true
1037 },
1038 "connection": {
1039 "name": "connection",
1040 "type": "varchar(255)",
1041 "primaryKey": false,
1042 "notNull": true
1043 },
1044 "plugin_id": {
1045 "name": "plugin_id",
1046 "type": "text",
1047 "primaryKey": false,
1048 "notNull": true
1049 },
1050 "name": {
1051 "name": "name",
1052 "type": "varchar(255)",
1053 "primaryKey": false,
1054 "notNull": true
1055 },
1056 "description": {
1057 "name": "description",
1058 "type": "text",
1059 "primaryKey": false,
1060 "notNull": true
1061 },
1062 "input_schema": {
1063 "name": "input_schema",
1064 "type": "json",
1065 "primaryKey": false,
1066 "notNull": false
1067 },
1068 "output_schema": {
1069 "name": "output_schema",
1070 "type": "json",
1071 "primaryKey": false,
1072 "notNull": false
1073 },
1074 "annotations": {
1075 "name": "annotations",
1076 "type": "json",
1077 "primaryKey": false,
1078 "notNull": false
1079 },
1080 "created_at": {
1081 "name": "created_at",
1082 "type": "timestamp",
1083 "primaryKey": false,
1084 "notNull": true
1085 },
1086 "updated_at": {
1087 "name": "updated_at",
1088 "type": "timestamp",
1089 "primaryKey": false,
1090 "notNull": true
1091 },
1092 "row_id": {
1093 "name": "row_id",
1094 "type": "varchar(255)",
1095 "primaryKey": true,
1096 "notNull": true
1097 },
1098 "tenant": {
1099 "name": "tenant",
1100 "type": "varchar(255)",
1101 "primaryKey": false,
1102 "notNull": true
1103 },
1104 "owner": {
1105 "name": "owner",
1106 "type": "varchar(255)",
1107 "primaryKey": false,
1108 "notNull": true
1109 },
1110 "subject": {
1111 "name": "subject",
1112 "type": "varchar(255)",
1113 "primaryKey": false,
1114 "notNull": true
1115 }
1116 },
1117 "indexes": {
1118 "tool_uidx": {
1119 "name": "tool_uidx",
1120 "columns": [
1121 {
1122 "expression": "tenant",
1123 "isExpression": false,
1124 "asc": true,
1125 "nulls": "last"
1126 },
1127 {
1128 "expression": "owner",
1129 "isExpression": false,
1130 "asc": true,
1131 "nulls": "last"
1132 },
1133 {
1134 "expression": "subject",
1135 "isExpression": false,
1136 "asc": true,
1137 "nulls": "last"
1138 },
1139 {
1140 "expression": "integration",
1141 "isExpression": false,
1142 "asc": true,
1143 "nulls": "last"
1144 },
1145 {
1146 "expression": "connection",
1147 "isExpression": false,
1148 "asc": true,
1149 "nulls": "last"
1150 },
1151 {
1152 "expression": "name",
1153 "isExpression": false,
1154 "asc": true,
1155 "nulls": "last"
1156 }
1157 ],
1158 "isUnique": true,
1159 "concurrently": false,
1160 "method": "btree",
1161 "with": {}
1162 }
1163 },
1164 "foreignKeys": {},
1165 "compositePrimaryKeys": {},
1166 "uniqueConstraints": {},
1167 "policies": {},
1168 "checkConstraints": {},
1169 "isRLSEnabled": false
1170 },
1171 "public.tool_policy": {
1172 "name": "tool_policy",
1173 "schema": "",
1174 "columns": {
1175 "id": {
1176 "name": "id",
1177 "type": "varchar(255)",
1178 "primaryKey": false,
1179 "notNull": true
1180 },
1181 "pattern": {
1182 "name": "pattern",
1183 "type": "text",
1184 "primaryKey": false,
1185 "notNull": true
1186 },
1187 "action": {
1188 "name": "action",
1189 "type": "text",
1190 "primaryKey": false,
1191 "notNull": true
1192 },
1193 "position": {
1194 "name": "position",
1195 "type": "text",
1196 "primaryKey": false,
1197 "notNull": true
1198 },
1199 "created_at": {
1200 "name": "created_at",
1201 "type": "timestamp",
1202 "primaryKey": false,
1203 "notNull": true
1204 },
1205 "updated_at": {
1206 "name": "updated_at",
1207 "type": "timestamp",
1208 "primaryKey": false,
1209 "notNull": true
1210 },
1211 "row_id": {
1212 "name": "row_id",
1213 "type": "varchar(255)",
1214 "primaryKey": true,
1215 "notNull": true
1216 },
1217 "tenant": {
1218 "name": "tenant",
1219 "type": "varchar(255)",
1220 "primaryKey": false,
1221 "notNull": true
1222 },
1223 "owner": {
1224 "name": "owner",
1225 "type": "varchar(255)",
1226 "primaryKey": false,
1227 "notNull": true
1228 },
1229 "subject": {
1230 "name": "subject",
1231 "type": "varchar(255)",
1232 "primaryKey": false,
1233 "notNull": true
1234 }
1235 },
1236 "indexes": {
1237 "tool_policy_uidx": {
1238 "name": "tool_policy_uidx",
1239 "columns": [
1240 {
1241 "expression": "tenant",
1242 "isExpression": false,
1243 "asc": true,
1244 "nulls": "last"
1245 },
1246 {
1247 "expression": "owner",
1248 "isExpression": false,
1249 "asc": true,
1250 "nulls": "last"
1251 },
1252 {
1253 "expression": "subject",
1254 "isExpression": false,
1255 "asc": true,
1256 "nulls": "last"
1257 },
1258 {
1259 "expression": "id",
1260 "isExpression": false,
1261 "asc": true,
1262 "nulls": "last"
1263 }
1264 ],
1265 "isUnique": true,
1266 "concurrently": false,
1267 "method": "btree",
1268 "with": {}
1269 }
1270 },
1271 "foreignKeys": {},
1272 "compositePrimaryKeys": {},
1273 "uniqueConstraints": {},
1274 "policies": {},
1275 "checkConstraints": {},
1276 "isRLSEnabled": false
1277 }
1278 },
1279 "enums": {},
1280 "schemas": {},
1281 "sequences": {},
1282 "roles": {},
1283 "policies": {},
1284 "views": {},
1285 "_meta": {
1286 "columns": {},
1287 "schemas": {},
1288 "tables": {}
1289 }
1290}
apps/cloud/drizzle/meta/0011_snapshot.json+1296−0
@@ -0,0 +1,1296 @@
1{
2 "id": "e285be7d-2427-4b1b-8b86-50a80ada25af",
3 "prevId": "c27a8958-ad73-4590-9b9b-d1b2584e0bc6",
4 "version": "7",
5 "dialect": "postgresql",
6 "tables": {
7 "public.accounts": {
8 "name": "accounts",
9 "schema": "",
10 "columns": {
11 "id": {
12 "name": "id",
13 "type": "text",
14 "primaryKey": true,
15 "notNull": true
16 },
17 "created_at": {
18 "name": "created_at",
19 "type": "timestamp with time zone",
20 "primaryKey": false,
21 "notNull": true,
22 "default": "now()"
23 }
24 },
25 "indexes": {},
26 "foreignKeys": {},
27 "compositePrimaryKeys": {},
28 "uniqueConstraints": {},
29 "policies": {},
30 "checkConstraints": {},
31 "isRLSEnabled": false
32 },
33 "public.memberships": {
34 "name": "memberships",
35 "schema": "",
36 "columns": {
37 "account_id": {
38 "name": "account_id",
39 "type": "text",
40 "primaryKey": false,
41 "notNull": true
42 },
43 "organization_id": {
44 "name": "organization_id",
45 "type": "text",
46 "primaryKey": false,
47 "notNull": true
48 },
49 "created_at": {
50 "name": "created_at",
51 "type": "timestamp with time zone",
52 "primaryKey": false,
53 "notNull": true,
54 "default": "now()"
55 }
56 },
57 "indexes": {},
58 "foreignKeys": {
59 "memberships_account_id_accounts_id_fk": {
60 "name": "memberships_account_id_accounts_id_fk",
61 "tableFrom": "memberships",
62 "tableTo": "accounts",
63 "columnsFrom": ["account_id"],
64 "columnsTo": ["id"],
65 "onDelete": "cascade",
66 "onUpdate": "no action"
67 },
68 "memberships_organization_id_organizations_id_fk": {
69 "name": "memberships_organization_id_organizations_id_fk",
70 "tableFrom": "memberships",
71 "tableTo": "organizations",
72 "columnsFrom": ["organization_id"],
73 "columnsTo": ["id"],
74 "onDelete": "cascade",
75 "onUpdate": "no action"
76 }
77 },
78 "compositePrimaryKeys": {
79 "memberships_account_id_organization_id_pk": {
80 "name": "memberships_account_id_organization_id_pk",
81 "columns": ["account_id", "organization_id"]
82 }
83 },
84 "uniqueConstraints": {},
85 "policies": {},
86 "checkConstraints": {},
87 "isRLSEnabled": false
88 },
89 "public.organizations": {
90 "name": "organizations",
91 "schema": "",
92 "columns": {
93 "id": {
94 "name": "id",
95 "type": "text",
96 "primaryKey": true,
97 "notNull": true
98 },
99 "name": {
100 "name": "name",
101 "type": "text",
102 "primaryKey": false,
103 "notNull": true
104 },
105 "slug": {
106 "name": "slug",
107 "type": "text",
108 "primaryKey": false,
109 "notNull": true
110 },
111 "created_at": {
112 "name": "created_at",
113 "type": "timestamp with time zone",
114 "primaryKey": false,
115 "notNull": true,
116 "default": "now()"
117 }
118 },
119 "indexes": {
120 "organizations_slug_unique": {
121 "name": "organizations_slug_unique",
122 "columns": [
123 {
124 "expression": "slug",
125 "isExpression": false,
126 "asc": true,
127 "nulls": "last"
128 }
129 ],
130 "isUnique": true,
131 "concurrently": false,
132 "method": "btree",
133 "with": {}
134 }
135 },
136 "foreignKeys": {},
137 "compositePrimaryKeys": {},
138 "uniqueConstraints": {},
139 "policies": {},
140 "checkConstraints": {},
141 "isRLSEnabled": false
142 },
143 "public.blob": {
144 "name": "blob",
145 "schema": "",
146 "columns": {
147 "namespace": {
148 "name": "namespace",
149 "type": "varchar(255)",
150 "primaryKey": false,
151 "notNull": true
152 },
153 "key": {
154 "name": "key",
155 "type": "varchar(255)",
156 "primaryKey": false,
157 "notNull": true
158 },
159 "value": {
160 "name": "value",
161 "type": "text",
162 "primaryKey": false,
163 "notNull": true
164 },
165 "row_id": {
166 "name": "row_id",
167 "type": "varchar(255)",
168 "primaryKey": true,
169 "notNull": true
170 },
171 "id": {
172 "name": "id",
173 "type": "varchar(255)",
174 "primaryKey": false,
175 "notNull": true
176 }
177 },
178 "indexes": {
179 "blob_id_uidx": {
180 "name": "blob_id_uidx",
181 "columns": [
182 {
183 "expression": "id",
184 "isExpression": false,
185 "asc": true,
186 "nulls": "last"
187 }
188 ],
189 "isUnique": true,
190 "concurrently": false,
191 "method": "btree",
192 "with": {}
193 }
194 },
195 "foreignKeys": {},
196 "compositePrimaryKeys": {},
197 "uniqueConstraints": {},
198 "policies": {},
199 "checkConstraints": {},
200 "isRLSEnabled": false
201 },
202 "public.connection": {
203 "name": "connection",
204 "schema": "",
205 "columns": {
206 "integration": {
207 "name": "integration",
208 "type": "varchar(255)",
209 "primaryKey": false,
210 "notNull": true
211 },
212 "name": {
213 "name": "name",
214 "type": "varchar(255)",
215 "primaryKey": false,
216 "notNull": true
217 },
218 "template": {
219 "name": "template",
220 "type": "text",
221 "primaryKey": false,
222 "notNull": true
223 },
224 "provider": {
225 "name": "provider",
226 "type": "text",
227 "primaryKey": false,
228 "notNull": true
229 },
230 "item_ids": {
231 "name": "item_ids",
232 "type": "json",
233 "primaryKey": false,
234 "notNull": true
235 },
236 "identity_label": {
237 "name": "identity_label",
238 "type": "text",
239 "primaryKey": false,
240 "notNull": false
241 },
242 "description": {
243 "name": "description",
244 "type": "text",
245 "primaryKey": false,
246 "notNull": false
247 },
248 "last_health": {
249 "name": "last_health",
250 "type": "json",
251 "primaryKey": false,
252 "notNull": false
253 },
254 "tools_synced_at": {
255 "name": "tools_synced_at",
256 "type": "bigint",
257 "primaryKey": false,
258 "notNull": false
259 },
260 "oauth_client": {
261 "name": "oauth_client",
262 "type": "text",
263 "primaryKey": false,
264 "notNull": false
265 },
266 "oauth_client_owner": {
267 "name": "oauth_client_owner",
268 "type": "text",
269 "primaryKey": false,
270 "notNull": false
271 },
272 "refresh_item_id": {
273 "name": "refresh_item_id",
274 "type": "text",
275 "primaryKey": false,
276 "notNull": false
277 },
278 "expires_at": {
279 "name": "expires_at",
280 "type": "bigint",
281 "primaryKey": false,
282 "notNull": false
283 },
284 "oauth_scope": {
285 "name": "oauth_scope",
286 "type": "text",
287 "primaryKey": false,
288 "notNull": false
289 },
290 "oauth_token_url": {
291 "name": "oauth_token_url",
292 "type": "text",
293 "primaryKey": false,
294 "notNull": false
295 },
296 "provider_state": {
297 "name": "provider_state",
298 "type": "json",
299 "primaryKey": false,
300 "notNull": false
301 },
302 "created_at": {
303 "name": "created_at",
304 "type": "timestamp",
305 "primaryKey": false,
306 "notNull": true
307 },
308 "updated_at": {
309 "name": "updated_at",
310 "type": "timestamp",
311 "primaryKey": false,
312 "notNull": true
313 },
314 "row_id": {
315 "name": "row_id",
316 "type": "varchar(255)",
317 "primaryKey": true,
318 "notNull": true
319 },
320 "tenant": {
321 "name": "tenant",
322 "type": "varchar(255)",
323 "primaryKey": false,
324 "notNull": true
325 },
326 "owner": {
327 "name": "owner",
328 "type": "varchar(255)",
329 "primaryKey": false,
330 "notNull": true
331 },
332 "subject": {
333 "name": "subject",
334 "type": "varchar(255)",
335 "primaryKey": false,
336 "notNull": true
337 }
338 },
339 "indexes": {
340 "connection_uidx": {
341 "name": "connection_uidx",
342 "columns": [
343 {
344 "expression": "tenant",
345 "isExpression": false,
346 "asc": true,
347 "nulls": "last"
348 },
349 {
350 "expression": "owner",
351 "isExpression": false,
352 "asc": true,
353 "nulls": "last"
354 },
355 {
356 "expression": "subject",
357 "isExpression": false,
358 "asc": true,
359 "nulls": "last"
360 },
361 {
362 "expression": "integration",
363 "isExpression": false,
364 "asc": true,
365 "nulls": "last"
366 },
367 {
368 "expression": "name",
369 "isExpression": false,
370 "asc": true,
371 "nulls": "last"
372 }
373 ],
374 "isUnique": true,
375 "concurrently": false,
376 "method": "btree",
377 "with": {}
378 }
379 },
380 "foreignKeys": {},
381 "compositePrimaryKeys": {},
382 "uniqueConstraints": {},
383 "policies": {},
384 "checkConstraints": {},
385 "isRLSEnabled": false
386 },
387 "public.definition": {
388 "name": "definition",
389 "schema": "",
390 "columns": {
391 "integration": {
392 "name": "integration",
393 "type": "varchar(255)",
394 "primaryKey": false,
395 "notNull": true
396 },
397 "connection": {
398 "name": "connection",
399 "type": "varchar(255)",
400 "primaryKey": false,
401 "notNull": true
402 },
403 "plugin_id": {
404 "name": "plugin_id",
405 "type": "text",
406 "primaryKey": false,
407 "notNull": true
408 },
409 "name": {
410 "name": "name",
411 "type": "text",
412 "primaryKey": false,
413 "notNull": true
414 },
415 "schema": {
416 "name": "schema",
417 "type": "json",
418 "primaryKey": false,
419 "notNull": true
420 },
421 "created_at": {
422 "name": "created_at",
423 "type": "timestamp",
424 "primaryKey": false,
425 "notNull": true
426 },
427 "row_id": {
428 "name": "row_id",
429 "type": "varchar(255)",
430 "primaryKey": true,
431 "notNull": true
432 },
433 "tenant": {
434 "name": "tenant",
435 "type": "varchar(255)",
436 "primaryKey": false,
437 "notNull": true
438 },
439 "owner": {
440 "name": "owner",
441 "type": "varchar(255)",
442 "primaryKey": false,
443 "notNull": true
444 },
445 "subject": {
446 "name": "subject",
447 "type": "varchar(255)",
448 "primaryKey": false,
449 "notNull": true
450 }
451 },
452 "indexes": {
453 "definition_uidx": {
454 "name": "definition_uidx",
455 "columns": [
456 {
457 "expression": "tenant",
458 "isExpression": false,
459 "asc": true,
460 "nulls": "last"
461 },
462 {
463 "expression": "owner",
464 "isExpression": false,
465 "asc": true,
466 "nulls": "last"
467 },
468 {
469 "expression": "subject",
470 "isExpression": false,
471 "asc": true,
472 "nulls": "last"
473 },
474 {
475 "expression": "integration",
476 "isExpression": false,
477 "asc": true,
478 "nulls": "last"
479 },
480 {
481 "expression": "connection",
482 "isExpression": false,
483 "asc": true,
484 "nulls": "last"
485 },
486 {
487 "expression": "name",
488 "isExpression": false,
489 "asc": true,
490 "nulls": "last"
491 }
492 ],
493 "isUnique": true,
494 "concurrently": false,
495 "method": "btree",
496 "with": {}
497 }
498 },
499 "foreignKeys": {},
500 "compositePrimaryKeys": {},
501 "uniqueConstraints": {},
502 "policies": {},
503 "checkConstraints": {},
504 "isRLSEnabled": false
505 },
506 "public.integration": {
507 "name": "integration",
508 "schema": "",
509 "columns": {
510 "slug": {
511 "name": "slug",
512 "type": "varchar(255)",
513 "primaryKey": false,
514 "notNull": true
515 },
516 "plugin_id": {
517 "name": "plugin_id",
518 "type": "text",
519 "primaryKey": false,
520 "notNull": true
521 },
522 "name": {
523 "name": "name",
524 "type": "text",
525 "primaryKey": false,
526 "notNull": false
527 },
528 "description": {
529 "name": "description",
530 "type": "text",
531 "primaryKey": false,
532 "notNull": false
533 },
534 "config": {
535 "name": "config",
536 "type": "json",
537 "primaryKey": false,
538 "notNull": false
539 },
540 "health_check": {
541 "name": "health_check",
542 "type": "json",
543 "primaryKey": false,
544 "notNull": false
545 },
546 "config_revised_at": {
547 "name": "config_revised_at",
548 "type": "bigint",
549 "primaryKey": false,
550 "notNull": false
551 },
552 "can_remove": {
553 "name": "can_remove",
554 "type": "boolean",
555 "primaryKey": false,
556 "notNull": true,
557 "default": true
558 },
559 "can_refresh": {
560 "name": "can_refresh",
561 "type": "boolean",
562 "primaryKey": false,
563 "notNull": true,
564 "default": false
565 },
566 "created_at": {
567 "name": "created_at",
568 "type": "timestamp",
569 "primaryKey": false,
570 "notNull": true
571 },
572 "updated_at": {
573 "name": "updated_at",
574 "type": "timestamp",
575 "primaryKey": false,
576 "notNull": true
577 },
578 "row_id": {
579 "name": "row_id",
580 "type": "varchar(255)",
581 "primaryKey": true,
582 "notNull": true
583 },
584 "tenant": {
585 "name": "tenant",
586 "type": "varchar(255)",
587 "primaryKey": false,
588 "notNull": true
589 }
590 },
591 "indexes": {
592 "integration_uidx": {
593 "name": "integration_uidx",
594 "columns": [
595 {
596 "expression": "tenant",
597 "isExpression": false,
598 "asc": true,
599 "nulls": "last"
600 },
601 {
602 "expression": "slug",
603 "isExpression": false,
604 "asc": true,
605 "nulls": "last"
606 }
607 ],
608 "isUnique": true,
609 "concurrently": false,
610 "method": "btree",
611 "with": {}
612 }
613 },
614 "foreignKeys": {},
615 "compositePrimaryKeys": {},
616 "uniqueConstraints": {},
617 "policies": {},
618 "checkConstraints": {},
619 "isRLSEnabled": false
620 },
621 "public.oauth_client": {
622 "name": "oauth_client",
623 "schema": "",
624 "columns": {
625 "slug": {
626 "name": "slug",
627 "type": "varchar(255)",
628 "primaryKey": false,
629 "notNull": true
630 },
631 "authorization_url": {
632 "name": "authorization_url",
633 "type": "text",
634 "primaryKey": false,
635 "notNull": true
636 },
637 "token_url": {
638 "name": "token_url",
639 "type": "text",
640 "primaryKey": false,
641 "notNull": true
642 },
643 "grant": {
644 "name": "grant",
645 "type": "text",
646 "primaryKey": false,
647 "notNull": true
648 },
649 "client_id": {
650 "name": "client_id",
651 "type": "text",
652 "primaryKey": false,
653 "notNull": true
654 },
655 "client_secret_item_id": {
656 "name": "client_secret_item_id",
657 "type": "text",
658 "primaryKey": false,
659 "notNull": false
660 },
661 "resource": {
662 "name": "resource",
663 "type": "text",
664 "primaryKey": false,
665 "notNull": false
666 },
667 "origin_kind": {
668 "name": "origin_kind",
669 "type": "text",
670 "primaryKey": false,
671 "notNull": false
672 },
673 "origin_integration": {
674 "name": "origin_integration",
675 "type": "text",
676 "primaryKey": false,
677 "notNull": false
678 },
679 "origin_issuer": {
680 "name": "origin_issuer",
681 "type": "text",
682 "primaryKey": false,
683 "notNull": false
684 },
685 "origin_redirect_uri": {
686 "name": "origin_redirect_uri",
687 "type": "text",
688 "primaryKey": false,
689 "notNull": false
690 },
691 "created_at": {
692 "name": "created_at",
693 "type": "timestamp",
694 "primaryKey": false,
695 "notNull": true
696 },
697 "row_id": {
698 "name": "row_id",
699 "type": "varchar(255)",
700 "primaryKey": true,
701 "notNull": true
702 },
703 "tenant": {
704 "name": "tenant",
705 "type": "varchar(255)",
706 "primaryKey": false,
707 "notNull": true
708 },
709 "owner": {
710 "name": "owner",
711 "type": "varchar(255)",
712 "primaryKey": false,
713 "notNull": true
714 },
715 "subject": {
716 "name": "subject",
717 "type": "varchar(255)",
718 "primaryKey": false,
719 "notNull": true
720 }
721 },
722 "indexes": {
723 "oauth_client_uidx": {
724 "name": "oauth_client_uidx",
725 "columns": [
726 {
727 "expression": "tenant",
728 "isExpression": false,
729 "asc": true,
730 "nulls": "last"
731 },
732 {
733 "expression": "owner",
734 "isExpression": false,
735 "asc": true,
736 "nulls": "last"
737 },
738 {
739 "expression": "subject",
740 "isExpression": false,
741 "asc": true,
742 "nulls": "last"
743 },
744 {
745 "expression": "slug",
746 "isExpression": false,
747 "asc": true,
748 "nulls": "last"
749 }
750 ],
751 "isUnique": true,
752 "concurrently": false,
753 "method": "btree",
754 "with": {}
755 }
756 },
757 "foreignKeys": {},
758 "compositePrimaryKeys": {},
759 "uniqueConstraints": {},
760 "policies": {},
761 "checkConstraints": {},
762 "isRLSEnabled": false
763 },
764 "public.oauth_session": {
765 "name": "oauth_session",
766 "schema": "",
767 "columns": {
768 "state": {
769 "name": "state",
770 "type": "varchar(255)",
771 "primaryKey": false,
772 "notNull": true
773 },
774 "client_slug": {
775 "name": "client_slug",
776 "type": "text",
777 "primaryKey": false,
778 "notNull": true
779 },
780 "integration": {
781 "name": "integration",
782 "type": "text",
783 "primaryKey": false,
784 "notNull": true
785 },
786 "name": {
787 "name": "name",
788 "type": "text",
789 "primaryKey": false,
790 "notNull": true
791 },
792 "template": {
793 "name": "template",
794 "type": "text",
795 "primaryKey": false,
796 "notNull": true
797 },
798 "redirect_url": {
799 "name": "redirect_url",
800 "type": "text",
801 "primaryKey": false,
802 "notNull": true
803 },
804 "pkce_verifier": {
805 "name": "pkce_verifier",
806 "type": "text",
807 "primaryKey": false,
808 "notNull": false
809 },
810 "identity_label": {
811 "name": "identity_label",
812 "type": "text",
813 "primaryKey": false,
814 "notNull": false
815 },
816 "payload": {
817 "name": "payload",
818 "type": "json",
819 "primaryKey": false,
820 "notNull": true
821 },
822 "expires_at": {
823 "name": "expires_at",
824 "type": "bigint",
825 "primaryKey": false,
826 "notNull": true
827 },
828 "created_at": {
829 "name": "created_at",
830 "type": "timestamp",
831 "primaryKey": false,
832 "notNull": true
833 },
834 "row_id": {
835 "name": "row_id",
836 "type": "varchar(255)",
837 "primaryKey": true,
838 "notNull": true
839 },
840 "tenant": {
841 "name": "tenant",
842 "type": "varchar(255)",
843 "primaryKey": false,
844 "notNull": true
845 },
846 "owner": {
847 "name": "owner",
848 "type": "varchar(255)",
849 "primaryKey": false,
850 "notNull": true
851 },
852 "subject": {
853 "name": "subject",
854 "type": "varchar(255)",
855 "primaryKey": false,
856 "notNull": true
857 }
858 },
859 "indexes": {
860 "oauth_session_uidx": {
861 "name": "oauth_session_uidx",
862 "columns": [
863 {
864 "expression": "tenant",
865 "isExpression": false,
866 "asc": true,
867 "nulls": "last"
868 },
869 {
870 "expression": "state",
871 "isExpression": false,
872 "asc": true,
873 "nulls": "last"
874 }
875 ],
876 "isUnique": true,
877 "concurrently": false,
878 "method": "btree",
879 "with": {}
880 }
881 },
882 "foreignKeys": {},
883 "compositePrimaryKeys": {},
884 "uniqueConstraints": {},
885 "policies": {},
886 "checkConstraints": {},
887 "isRLSEnabled": false
888 },
889 "public.plugin_storage": {
890 "name": "plugin_storage",
891 "schema": "",
892 "columns": {
893 "plugin_id": {
894 "name": "plugin_id",
895 "type": "varchar(255)",
896 "primaryKey": false,
897 "notNull": true
898 },
899 "collection": {
900 "name": "collection",
901 "type": "varchar(255)",
902 "primaryKey": false,
903 "notNull": true
904 },
905 "key": {
906 "name": "key",
907 "type": "varchar(255)",
908 "primaryKey": false,
909 "notNull": true
910 },
911 "data": {
912 "name": "data",
913 "type": "json",
914 "primaryKey": false,
915 "notNull": true
916 },
917 "created_at": {
918 "name": "created_at",
919 "type": "timestamp",
920 "primaryKey": false,
921 "notNull": true
922 },
923 "updated_at": {
924 "name": "updated_at",
925 "type": "timestamp",
926 "primaryKey": false,
927 "notNull": true
928 },
929 "row_id": {
930 "name": "row_id",
931 "type": "varchar(255)",
932 "primaryKey": true,
933 "notNull": true
934 },
935 "tenant": {
936 "name": "tenant",
937 "type": "varchar(255)",
938 "primaryKey": false,
939 "notNull": true
940 },
941 "owner": {
942 "name": "owner",
943 "type": "varchar(255)",
944 "primaryKey": false,
945 "notNull": true
946 },
947 "subject": {
948 "name": "subject",
949 "type": "varchar(255)",
950 "primaryKey": false,
951 "notNull": true
952 }
953 },
954 "indexes": {
955 "plugin_storage_uidx": {
956 "name": "plugin_storage_uidx",
957 "columns": [
958 {
959 "expression": "tenant",
960 "isExpression": false,
961 "asc": true,
962 "nulls": "last"
963 },
964 {
965 "expression": "owner",
966 "isExpression": false,
967 "asc": true,
968 "nulls": "last"
969 },
970 {
971 "expression": "subject",
972 "isExpression": false,
973 "asc": true,
974 "nulls": "last"
975 },
976 {
977 "expression": "plugin_id",
978 "isExpression": false,
979 "asc": true,
980 "nulls": "last"
981 },
982 {
983 "expression": "collection",
984 "isExpression": false,
985 "asc": true,
986 "nulls": "last"
987 },
988 {
989 "expression": "key",
990 "isExpression": false,
991 "asc": true,
992 "nulls": "last"
993 }
994 ],
995 "isUnique": true,
996 "concurrently": false,
997 "method": "btree",
998 "with": {}
999 }
1000 },
1001 "foreignKeys": {},
1002 "compositePrimaryKeys": {},
1003 "uniqueConstraints": {},
1004 "policies": {},
1005 "checkConstraints": {},
1006 "isRLSEnabled": false
1007 },
1008 "public.private_executor_cloud_settings": {
1009 "name": "private_executor_cloud_settings",
1010 "schema": "",
1011 "columns": {
1012 "id": {
1013 "name": "id",
1014 "type": "varchar(255)",
1015 "primaryKey": true,
1016 "notNull": true
1017 },
1018 "version": {
1019 "name": "version",
1020 "type": "varchar(255)",
1021 "primaryKey": false,
1022 "notNull": true,
1023 "default": "'1.0.0'"
1024 }
1025 },
1026 "indexes": {},
1027 "foreignKeys": {},
1028 "compositePrimaryKeys": {},
1029 "uniqueConstraints": {},
1030 "policies": {},
1031 "checkConstraints": {},
1032 "isRLSEnabled": false
1033 },
1034 "public.tool": {
1035 "name": "tool",
1036 "schema": "",
1037 "columns": {
1038 "integration": {
1039 "name": "integration",
1040 "type": "varchar(255)",
1041 "primaryKey": false,
1042 "notNull": true
1043 },
1044 "connection": {
1045 "name": "connection",
1046 "type": "varchar(255)",
1047 "primaryKey": false,
1048 "notNull": true
1049 },
1050 "plugin_id": {
1051 "name": "plugin_id",
1052 "type": "text",
1053 "primaryKey": false,
1054 "notNull": true
1055 },
1056 "name": {
1057 "name": "name",
1058 "type": "varchar(255)",
1059 "primaryKey": false,
1060 "notNull": true
1061 },
1062 "description": {
1063 "name": "description",
1064 "type": "text",
1065 "primaryKey": false,
1066 "notNull": true
1067 },
1068 "input_schema": {
1069 "name": "input_schema",
1070 "type": "json",
1071 "primaryKey": false,
1072 "notNull": false
1073 },
1074 "output_schema": {
1075 "name": "output_schema",
1076 "type": "json",
1077 "primaryKey": false,
1078 "notNull": false
1079 },
1080 "annotations": {
1081 "name": "annotations",
1082 "type": "json",
1083 "primaryKey": false,
1084 "notNull": false
1085 },
1086 "created_at": {
1087 "name": "created_at",
1088 "type": "timestamp",
1089 "primaryKey": false,
1090 "notNull": true
1091 },
1092 "updated_at": {
1093 "name": "updated_at",
1094 "type": "timestamp",
1095 "primaryKey": false,
1096 "notNull": true
1097 },
1098 "row_id": {
1099 "name": "row_id",
1100 "type": "varchar(255)",
1101 "primaryKey": true,
1102 "notNull": true
1103 },
1104 "tenant": {
1105 "name": "tenant",
1106 "type": "varchar(255)",
1107 "primaryKey": false,
1108 "notNull": true
1109 },
1110 "owner": {
1111 "name": "owner",
1112 "type": "varchar(255)",
1113 "primaryKey": false,
1114 "notNull": true
1115 },
1116 "subject": {
1117 "name": "subject",
1118 "type": "varchar(255)",
1119 "primaryKey": false,
1120 "notNull": true
1121 }
1122 },
1123 "indexes": {
1124 "tool_uidx": {
1125 "name": "tool_uidx",
1126 "columns": [
1127 {
1128 "expression": "tenant",
1129 "isExpression": false,
1130 "asc": true,
1131 "nulls": "last"
1132 },
1133 {
1134 "expression": "owner",
1135 "isExpression": false,
1136 "asc": true,
1137 "nulls": "last"
1138 },
1139 {
1140 "expression": "subject",
1141 "isExpression": false,
1142 "asc": true,
1143 "nulls": "last"
1144 },
1145 {
1146 "expression": "integration",
1147 "isExpression": false,
1148 "asc": true,
1149 "nulls": "last"
1150 },
1151 {
1152 "expression": "connection",
1153 "isExpression": false,
1154 "asc": true,
1155 "nulls": "last"
1156 },
1157 {
1158 "expression": "name",
1159 "isExpression": false,
1160 "asc": true,
1161 "nulls": "last"
1162 }
1163 ],
1164 "isUnique": true,
1165 "concurrently": false,
1166 "method": "btree",
1167 "with": {}
1168 }
1169 },
1170 "foreignKeys": {},
1171 "compositePrimaryKeys": {},
1172 "uniqueConstraints": {},
1173 "policies": {},
1174 "checkConstraints": {},
1175 "isRLSEnabled": false
1176 },
1177 "public.tool_policy": {
1178 "name": "tool_policy",
1179 "schema": "",
1180 "columns": {
1181 "id": {
1182 "name": "id",
1183 "type": "varchar(255)",
1184 "primaryKey": false,
1185 "notNull": true
1186 },
1187 "pattern": {
1188 "name": "pattern",
1189 "type": "text",
1190 "primaryKey": false,
1191 "notNull": true
1192 },
1193 "action": {
1194 "name": "action",
1195 "type": "text",
1196 "primaryKey": false,
1197 "notNull": true
1198 },
1199 "position": {
1200 "name": "position",
1201 "type": "text",
1202 "primaryKey": false,
1203 "notNull": true
1204 },
1205 "created_at": {
1206 "name": "created_at",
1207 "type": "timestamp",
1208 "primaryKey": false,
1209 "notNull": true
1210 },
1211 "updated_at": {
1212 "name": "updated_at",
1213 "type": "timestamp",
1214 "primaryKey": false,
1215 "notNull": true
1216 },
1217 "row_id": {
1218 "name": "row_id",
1219 "type": "varchar(255)",
1220 "primaryKey": true,
1221 "notNull": true
1222 },
1223 "tenant": {
1224 "name": "tenant",
1225 "type": "varchar(255)",
1226 "primaryKey": false,
1227 "notNull": true
1228 },
1229 "owner": {
1230 "name": "owner",
1231 "type": "varchar(255)",
1232 "primaryKey": false,
1233 "notNull": true
1234 },
1235 "subject": {
1236 "name": "subject",
1237 "type": "varchar(255)",
1238 "primaryKey": false,
1239 "notNull": true
1240 }
1241 },
1242 "indexes": {
1243 "tool_policy_uidx": {
1244 "name": "tool_policy_uidx",
1245 "columns": [
1246 {
1247 "expression": "tenant",
1248 "isExpression": false,
1249 "asc": true,
1250 "nulls": "last"
1251 },
1252 {
1253 "expression": "owner",
1254 "isExpression": false,
1255 "asc": true,
1256 "nulls": "last"
1257 },
1258 {
1259 "expression": "subject",
1260 "isExpression": false,
1261 "asc": true,
1262 "nulls": "last"
1263 },
1264 {
1265 "expression": "id",
1266 "isExpression": false,
1267 "asc": true,
1268 "nulls": "last"
1269 }
1270 ],
1271 "isUnique": true,
1272 "concurrently": false,
1273 "method": "btree",
1274 "with": {}
1275 }
1276 },
1277 "foreignKeys": {},
1278 "compositePrimaryKeys": {},
1279 "uniqueConstraints": {},
1280 "policies": {},
1281 "checkConstraints": {},
1282 "isRLSEnabled": false
1283 }
1284 },
1285 "enums": {},
1286 "schemas": {},
1287 "sequences": {},
1288 "roles": {},
1289 "policies": {},
1290 "views": {},
1291 "_meta": {
1292 "columns": {},
1293 "schemas": {},
1294 "tables": {}
1295 }
1296}
apps/cloud/drizzle/meta/0012_snapshot.json+1367−0
@@ -0,0 +1,1367 @@
1{
2 "id": "97114994-f0fc-4419-80bf-21c529a6cf0b",
3 "prevId": "e285be7d-2427-4b1b-8b86-50a80ada25af",
4 "version": "7",
5 "dialect": "postgresql",
6 "tables": {
7 "public.accounts": {
8 "name": "accounts",
9 "schema": "",
10 "columns": {
11 "id": {
12 "name": "id",
13 "type": "text",
14 "primaryKey": true,
15 "notNull": true
16 },
17 "created_at": {
18 "name": "created_at",
19 "type": "timestamp with time zone",
20 "primaryKey": false,
21 "notNull": true,
22 "default": "now()"
23 }
24 },
25 "indexes": {},
26 "foreignKeys": {},
27 "compositePrimaryKeys": {},
28 "uniqueConstraints": {},
29 "policies": {},
30 "checkConstraints": {},
31 "isRLSEnabled": false
32 },
33 "public.memberships": {
34 "name": "memberships",
35 "schema": "",
36 "columns": {
37 "account_id": {
38 "name": "account_id",
39 "type": "text",
40 "primaryKey": false,
41 "notNull": true
42 },
43 "organization_id": {
44 "name": "organization_id",
45 "type": "text",
46 "primaryKey": false,
47 "notNull": true
48 },
49 "created_at": {
50 "name": "created_at",
51 "type": "timestamp with time zone",
52 "primaryKey": false,
53 "notNull": true,
54 "default": "now()"
55 }
56 },
57 "indexes": {},
58 "foreignKeys": {
59 "memberships_account_id_accounts_id_fk": {
60 "name": "memberships_account_id_accounts_id_fk",
61 "tableFrom": "memberships",
62 "tableTo": "accounts",
63 "columnsFrom": ["account_id"],
64 "columnsTo": ["id"],
65 "onDelete": "cascade",
66 "onUpdate": "no action"
67 },
68 "memberships_organization_id_organizations_id_fk": {
69 "name": "memberships_organization_id_organizations_id_fk",
70 "tableFrom": "memberships",
71 "tableTo": "organizations",
72 "columnsFrom": ["organization_id"],
73 "columnsTo": ["id"],
74 "onDelete": "cascade",
75 "onUpdate": "no action"
76 }
77 },
78 "compositePrimaryKeys": {
79 "memberships_account_id_organization_id_pk": {
80 "name": "memberships_account_id_organization_id_pk",
81 "columns": ["account_id", "organization_id"]
82 }
83 },
84 "uniqueConstraints": {},
85 "policies": {},
86 "checkConstraints": {},
87 "isRLSEnabled": false
88 },
89 "public.organizations": {
90 "name": "organizations",
91 "schema": "",
92 "columns": {
93 "id": {
94 "name": "id",
95 "type": "text",
96 "primaryKey": true,
97 "notNull": true
98 },
99 "name": {
100 "name": "name",
101 "type": "text",
102 "primaryKey": false,
103 "notNull": true
104 },
105 "slug": {
106 "name": "slug",
107 "type": "text",
108 "primaryKey": false,
109 "notNull": true
110 },
111 "created_at": {
112 "name": "created_at",
113 "type": "timestamp with time zone",
114 "primaryKey": false,
115 "notNull": true,
116 "default": "now()"
117 }
118 },
119 "indexes": {
120 "organizations_slug_unique": {
121 "name": "organizations_slug_unique",
122 "columns": [
123 {
124 "expression": "slug",
125 "isExpression": false,
126 "asc": true,
127 "nulls": "last"
128 }
129 ],
130 "isUnique": true,
131 "concurrently": false,
132 "method": "btree",
133 "with": {}
134 }
135 },
136 "foreignKeys": {},
137 "compositePrimaryKeys": {},
138 "uniqueConstraints": {},
139 "policies": {},
140 "checkConstraints": {},
141 "isRLSEnabled": false
142 },
143 "public.blob": {
144 "name": "blob",
145 "schema": "",
146 "columns": {
147 "namespace": {
148 "name": "namespace",
149 "type": "varchar(255)",
150 "primaryKey": false,
151 "notNull": true
152 },
153 "key": {
154 "name": "key",
155 "type": "varchar(255)",
156 "primaryKey": false,
157 "notNull": true
158 },
159 "value": {
160 "name": "value",
161 "type": "text",
162 "primaryKey": false,
163 "notNull": true
164 },
165 "row_id": {
166 "name": "row_id",
167 "type": "varchar(255)",
168 "primaryKey": true,
169 "notNull": true
170 },
171 "id": {
172 "name": "id",
173 "type": "varchar(255)",
174 "primaryKey": false,
175 "notNull": true
176 }
177 },
178 "indexes": {
179 "blob_id_uidx": {
180 "name": "blob_id_uidx",
181 "columns": [
182 {
183 "expression": "id",
184 "isExpression": false,
185 "asc": true,
186 "nulls": "last"
187 }
188 ],
189 "isUnique": true,
190 "concurrently": false,
191 "method": "btree",
192 "with": {}
193 }
194 },
195 "foreignKeys": {},
196 "compositePrimaryKeys": {},
197 "uniqueConstraints": {},
198 "policies": {},
199 "checkConstraints": {},
200 "isRLSEnabled": false
201 },
202 "public.connection": {
203 "name": "connection",
204 "schema": "",
205 "columns": {
206 "integration": {
207 "name": "integration",
208 "type": "varchar(255)",
209 "primaryKey": false,
210 "notNull": true
211 },
212 "name": {
213 "name": "name",
214 "type": "varchar(255)",
215 "primaryKey": false,
216 "notNull": true
217 },
218 "template": {
219 "name": "template",
220 "type": "text",
221 "primaryKey": false,
222 "notNull": true
223 },
224 "provider": {
225 "name": "provider",
226 "type": "text",
227 "primaryKey": false,
228 "notNull": true
229 },
230 "item_ids": {
231 "name": "item_ids",
232 "type": "json",
233 "primaryKey": false,
234 "notNull": true
235 },
236 "identity_label": {
237 "name": "identity_label",
238 "type": "text",
239 "primaryKey": false,
240 "notNull": false
241 },
242 "description": {
243 "name": "description",
244 "type": "text",
245 "primaryKey": false,
246 "notNull": false
247 },
248 "last_health": {
249 "name": "last_health",
250 "type": "json",
251 "primaryKey": false,
252 "notNull": false
253 },
254 "tools_synced_at": {
255 "name": "tools_synced_at",
256 "type": "bigint",
257 "primaryKey": false,
258 "notNull": false
259 },
260 "oauth_client": {
261 "name": "oauth_client",
262 "type": "text",
263 "primaryKey": false,
264 "notNull": false
265 },
266 "oauth_client_owner": {
267 "name": "oauth_client_owner",
268 "type": "text",
269 "primaryKey": false,
270 "notNull": false
271 },
272 "refresh_item_id": {
273 "name": "refresh_item_id",
274 "type": "text",
275 "primaryKey": false,
276 "notNull": false
277 },
278 "expires_at": {
279 "name": "expires_at",
280 "type": "bigint",
281 "primaryKey": false,
282 "notNull": false
283 },
284 "oauth_scope": {
285 "name": "oauth_scope",
286 "type": "text",
287 "primaryKey": false,
288 "notNull": false
289 },
290 "oauth_token_url": {
291 "name": "oauth_token_url",
292 "type": "text",
293 "primaryKey": false,
294 "notNull": false
295 },
296 "provider_state": {
297 "name": "provider_state",
298 "type": "json",
299 "primaryKey": false,
300 "notNull": false
301 },
302 "created_at": {
303 "name": "created_at",
304 "type": "timestamp",
305 "primaryKey": false,
306 "notNull": true
307 },
308 "updated_at": {
309 "name": "updated_at",
310 "type": "timestamp",
311 "primaryKey": false,
312 "notNull": true
313 },
314 "row_id": {
315 "name": "row_id",
316 "type": "varchar(255)",
317 "primaryKey": true,
318 "notNull": true
319 },
320 "tenant": {
321 "name": "tenant",
322 "type": "varchar(255)",
323 "primaryKey": false,
324 "notNull": true
325 },
326 "owner": {
327 "name": "owner",
328 "type": "varchar(255)",
329 "primaryKey": false,
330 "notNull": true
331 },
332 "subject": {
333 "name": "subject",
334 "type": "varchar(255)",
335 "primaryKey": false,
336 "notNull": true
337 }
338 },
339 "indexes": {
340 "connection_uidx": {
341 "name": "connection_uidx",
342 "columns": [
343 {
344 "expression": "tenant",
345 "isExpression": false,
346 "asc": true,
347 "nulls": "last"
348 },
349 {
350 "expression": "owner",
351 "isExpression": false,
352 "asc": true,
353 "nulls": "last"
354 },
355 {
356 "expression": "subject",
357 "isExpression": false,
358 "asc": true,
359 "nulls": "last"
360 },
361 {
362 "expression": "integration",
363 "isExpression": false,
364 "asc": true,
365 "nulls": "last"
366 },
367 {
368 "expression": "name",
369 "isExpression": false,
370 "asc": true,
371 "nulls": "last"
372 }
373 ],
374 "isUnique": true,
375 "concurrently": false,
376 "method": "btree",
377 "with": {}
378 }
379 },
380 "foreignKeys": {},
381 "compositePrimaryKeys": {},
382 "uniqueConstraints": {},
383 "policies": {},
384 "checkConstraints": {},
385 "isRLSEnabled": false
386 },
387 "public.definition": {
388 "name": "definition",
389 "schema": "",
390 "columns": {
391 "integration": {
392 "name": "integration",
393 "type": "varchar(255)",
394 "primaryKey": false,
395 "notNull": true
396 },
397 "connection": {
398 "name": "connection",
399 "type": "varchar(255)",
400 "primaryKey": false,
401 "notNull": true
402 },
403 "plugin_id": {
404 "name": "plugin_id",
405 "type": "text",
406 "primaryKey": false,
407 "notNull": true
408 },
409 "name": {
410 "name": "name",
411 "type": "text",
412 "primaryKey": false,
413 "notNull": true
414 },
415 "schema": {
416 "name": "schema",
417 "type": "json",
418 "primaryKey": false,
419 "notNull": true
420 },
421 "created_at": {
422 "name": "created_at",
423 "type": "timestamp",
424 "primaryKey": false,
425 "notNull": true
426 },
427 "row_id": {
428 "name": "row_id",
429 "type": "varchar(255)",
430 "primaryKey": true,
431 "notNull": true
432 },
433 "tenant": {
434 "name": "tenant",
435 "type": "varchar(255)",
436 "primaryKey": false,
437 "notNull": true
438 },
439 "owner": {
440 "name": "owner",
441 "type": "varchar(255)",
442 "primaryKey": false,
443 "notNull": true
444 },
445 "subject": {
446 "name": "subject",
447 "type": "varchar(255)",
448 "primaryKey": false,
449 "notNull": true
450 }
451 },
452 "indexes": {
453 "definition_uidx": {
454 "name": "definition_uidx",
455 "columns": [
456 {
457 "expression": "tenant",
458 "isExpression": false,
459 "asc": true,
460 "nulls": "last"
461 },
462 {
463 "expression": "owner",
464 "isExpression": false,
465 "asc": true,
466 "nulls": "last"
467 },
468 {
469 "expression": "subject",
470 "isExpression": false,
471 "asc": true,
472 "nulls": "last"
473 },
474 {
475 "expression": "integration",
476 "isExpression": false,
477 "asc": true,
478 "nulls": "last"
479 },
480 {
481 "expression": "connection",
482 "isExpression": false,
483 "asc": true,
484 "nulls": "last"
485 },
486 {
487 "expression": "name",
488 "isExpression": false,
489 "asc": true,
490 "nulls": "last"
491 }
492 ],
493 "isUnique": true,
494 "concurrently": false,
495 "method": "btree",
496 "with": {}
497 }
498 },
499 "foreignKeys": {},
500 "compositePrimaryKeys": {},
501 "uniqueConstraints": {},
502 "policies": {},
503 "checkConstraints": {},
504 "isRLSEnabled": false
505 },
506 "public.integration": {
507 "name": "integration",
508 "schema": "",
509 "columns": {
510 "slug": {
511 "name": "slug",
512 "type": "varchar(255)",
513 "primaryKey": false,
514 "notNull": true
515 },
516 "plugin_id": {
517 "name": "plugin_id",
518 "type": "text",
519 "primaryKey": false,
520 "notNull": true
521 },
522 "name": {
523 "name": "name",
524 "type": "text",
525 "primaryKey": false,
526 "notNull": false
527 },
528 "description": {
529 "name": "description",
530 "type": "text",
531 "primaryKey": false,
532 "notNull": false
533 },
534 "config": {
535 "name": "config",
536 "type": "json",
537 "primaryKey": false,
538 "notNull": false
539 },
540 "health_check": {
541 "name": "health_check",
542 "type": "json",
543 "primaryKey": false,
544 "notNull": false
545 },
546 "config_revised_at": {
547 "name": "config_revised_at",
548 "type": "bigint",
549 "primaryKey": false,
550 "notNull": false
551 },
552 "can_remove": {
553 "name": "can_remove",
554 "type": "boolean",
555 "primaryKey": false,
556 "notNull": true,
557 "default": true
558 },
559 "can_refresh": {
560 "name": "can_refresh",
561 "type": "boolean",
562 "primaryKey": false,
563 "notNull": true,
564 "default": false
565 },
566 "created_at": {
567 "name": "created_at",
568 "type": "timestamp",
569 "primaryKey": false,
570 "notNull": true
571 },
572 "updated_at": {
573 "name": "updated_at",
574 "type": "timestamp",
575 "primaryKey": false,
576 "notNull": true
577 },
578 "row_id": {
579 "name": "row_id",
580 "type": "varchar(255)",
581 "primaryKey": true,
582 "notNull": true
583 },
584 "tenant": {
585 "name": "tenant",
586 "type": "varchar(255)",
587 "primaryKey": false,
588 "notNull": true
589 }
590 },
591 "indexes": {
592 "integration_uidx": {
593 "name": "integration_uidx",
594 "columns": [
595 {
596 "expression": "tenant",
597 "isExpression": false,
598 "asc": true,
599 "nulls": "last"
600 },
601 {
602 "expression": "slug",
603 "isExpression": false,
604 "asc": true,
605 "nulls": "last"
606 }
607 ],
608 "isUnique": true,
609 "concurrently": false,
610 "method": "btree",
611 "with": {}
612 }
613 },
614 "foreignKeys": {},
615 "compositePrimaryKeys": {},
616 "uniqueConstraints": {},
617 "policies": {},
618 "checkConstraints": {},
619 "isRLSEnabled": false
620 },
621 "public.oauth_client": {
622 "name": "oauth_client",
623 "schema": "",
624 "columns": {
625 "slug": {
626 "name": "slug",
627 "type": "varchar(255)",
628 "primaryKey": false,
629 "notNull": true
630 },
631 "authorization_url": {
632 "name": "authorization_url",
633 "type": "text",
634 "primaryKey": false,
635 "notNull": true
636 },
637 "token_url": {
638 "name": "token_url",
639 "type": "text",
640 "primaryKey": false,
641 "notNull": true
642 },
643 "grant": {
644 "name": "grant",
645 "type": "text",
646 "primaryKey": false,
647 "notNull": true
648 },
649 "client_id": {
650 "name": "client_id",
651 "type": "text",
652 "primaryKey": false,
653 "notNull": true
654 },
655 "client_secret_item_id": {
656 "name": "client_secret_item_id",
657 "type": "text",
658 "primaryKey": false,
659 "notNull": false
660 },
661 "resource": {
662 "name": "resource",
663 "type": "text",
664 "primaryKey": false,
665 "notNull": false
666 },
667 "origin_kind": {
668 "name": "origin_kind",
669 "type": "text",
670 "primaryKey": false,
671 "notNull": false
672 },
673 "origin_integration": {
674 "name": "origin_integration",
675 "type": "text",
676 "primaryKey": false,
677 "notNull": false
678 },
679 "origin_issuer": {
680 "name": "origin_issuer",
681 "type": "text",
682 "primaryKey": false,
683 "notNull": false
684 },
685 "origin_redirect_uri": {
686 "name": "origin_redirect_uri",
687 "type": "text",
688 "primaryKey": false,
689 "notNull": false
690 },
691 "created_at": {
692 "name": "created_at",
693 "type": "timestamp",
694 "primaryKey": false,
695 "notNull": true
696 },
697 "row_id": {
698 "name": "row_id",
699 "type": "varchar(255)",
700 "primaryKey": true,
701 "notNull": true
702 },
703 "tenant": {
704 "name": "tenant",
705 "type": "varchar(255)",
706 "primaryKey": false,
707 "notNull": true
708 },
709 "owner": {
710 "name": "owner",
711 "type": "varchar(255)",
712 "primaryKey": false,
713 "notNull": true
714 },
715 "subject": {
716 "name": "subject",
717 "type": "varchar(255)",
718 "primaryKey": false,
719 "notNull": true
720 }
721 },
722 "indexes": {
723 "oauth_client_uidx": {
724 "name": "oauth_client_uidx",
725 "columns": [
726 {
727 "expression": "tenant",
728 "isExpression": false,
729 "asc": true,
730 "nulls": "last"
731 },
732 {
733 "expression": "owner",
734 "isExpression": false,
735 "asc": true,
736 "nulls": "last"
737 },
738 {
739 "expression": "subject",
740 "isExpression": false,
741 "asc": true,
742 "nulls": "last"
743 },
744 {
745 "expression": "slug",
746 "isExpression": false,
747 "asc": true,
748 "nulls": "last"
749 }
750 ],
751 "isUnique": true,
752 "concurrently": false,
753 "method": "btree",
754 "with": {}
755 }
756 },
757 "foreignKeys": {},
758 "compositePrimaryKeys": {},
759 "uniqueConstraints": {},
760 "policies": {},
761 "checkConstraints": {},
762 "isRLSEnabled": false
763 },
764 "public.oauth_session": {
765 "name": "oauth_session",
766 "schema": "",
767 "columns": {
768 "state": {
769 "name": "state",
770 "type": "varchar(255)",
771 "primaryKey": false,
772 "notNull": true
773 },
774 "client_slug": {
775 "name": "client_slug",
776 "type": "text",
777 "primaryKey": false,
778 "notNull": true
779 },
780 "integration": {
781 "name": "integration",
782 "type": "text",
783 "primaryKey": false,
784 "notNull": true
785 },
786 "name": {
787 "name": "name",
788 "type": "text",
789 "primaryKey": false,
790 "notNull": true
791 },
792 "template": {
793 "name": "template",
794 "type": "text",
795 "primaryKey": false,
796 "notNull": true
797 },
798 "redirect_url": {
799 "name": "redirect_url",
800 "type": "text",
801 "primaryKey": false,
802 "notNull": true
803 },
804 "pkce_verifier": {
805 "name": "pkce_verifier",
806 "type": "text",
807 "primaryKey": false,
808 "notNull": false
809 },
810 "identity_label": {
811 "name": "identity_label",
812 "type": "text",
813 "primaryKey": false,
814 "notNull": false
815 },
816 "payload": {
817 "name": "payload",
818 "type": "json",
819 "primaryKey": false,
820 "notNull": true
821 },
822 "expires_at": {
823 "name": "expires_at",
824 "type": "bigint",
825 "primaryKey": false,
826 "notNull": true
827 },
828 "created_at": {
829 "name": "created_at",
830 "type": "timestamp",
831 "primaryKey": false,
832 "notNull": true
833 },
834 "row_id": {
835 "name": "row_id",
836 "type": "varchar(255)",
837 "primaryKey": true,
838 "notNull": true
839 },
840 "tenant": {
841 "name": "tenant",
842 "type": "varchar(255)",
843 "primaryKey": false,
844 "notNull": true
845 },
846 "owner": {
847 "name": "owner",
848 "type": "varchar(255)",
849 "primaryKey": false,
850 "notNull": true
851 },
852 "subject": {
853 "name": "subject",
854 "type": "varchar(255)",
855 "primaryKey": false,
856 "notNull": true
857 }
858 },
859 "indexes": {
860 "oauth_session_uidx": {
861 "name": "oauth_session_uidx",
862 "columns": [
863 {
864 "expression": "tenant",
865 "isExpression": false,
866 "asc": true,
867 "nulls": "last"
868 },
869 {
870 "expression": "state",
871 "isExpression": false,
872 "asc": true,
873 "nulls": "last"
874 }
875 ],
876 "isUnique": true,
877 "concurrently": false,
878 "method": "btree",
879 "with": {}
880 }
881 },
882 "foreignKeys": {},
883 "compositePrimaryKeys": {},
884 "uniqueConstraints": {},
885 "policies": {},
886 "checkConstraints": {},
887 "isRLSEnabled": false
888 },
889 "public.plugin_storage": {
890 "name": "plugin_storage",
891 "schema": "",
892 "columns": {
893 "plugin_id": {
894 "name": "plugin_id",
895 "type": "varchar(255)",
896 "primaryKey": false,
897 "notNull": true
898 },
899 "collection": {
900 "name": "collection",
901 "type": "varchar(255)",
902 "primaryKey": false,
903 "notNull": true
904 },
905 "key": {
906 "name": "key",
907 "type": "varchar(255)",
908 "primaryKey": false,
909 "notNull": true
910 },
911 "data": {
912 "name": "data",
913 "type": "json",
914 "primaryKey": false,
915 "notNull": true
916 },
917 "created_at": {
918 "name": "created_at",
919 "type": "timestamp",
920 "primaryKey": false,
921 "notNull": true
922 },
923 "updated_at": {
924 "name": "updated_at",
925 "type": "timestamp",
926 "primaryKey": false,
927 "notNull": true
928 },
929 "row_id": {
930 "name": "row_id",
931 "type": "varchar(255)",
932 "primaryKey": true,
933 "notNull": true
934 },
935 "tenant": {
936 "name": "tenant",
937 "type": "varchar(255)",
938 "primaryKey": false,
939 "notNull": true
940 },
941 "owner": {
942 "name": "owner",
943 "type": "varchar(255)",
944 "primaryKey": false,
945 "notNull": true
946 },
947 "subject": {
948 "name": "subject",
949 "type": "varchar(255)",
950 "primaryKey": false,
951 "notNull": true
952 }
953 },
954 "indexes": {
955 "plugin_storage_uidx": {
956 "name": "plugin_storage_uidx",
957 "columns": [
958 {
959 "expression": "tenant",
960 "isExpression": false,
961 "asc": true,
962 "nulls": "last"
963 },
964 {
965 "expression": "owner",
966 "isExpression": false,
967 "asc": true,
968 "nulls": "last"
969 },
970 {
971 "expression": "subject",
972 "isExpression": false,
973 "asc": true,
974 "nulls": "last"
975 },
976 {
977 "expression": "plugin_id",
978 "isExpression": false,
979 "asc": true,
980 "nulls": "last"
981 },
982 {
983 "expression": "collection",
984 "isExpression": false,
985 "asc": true,
986 "nulls": "last"
987 },
988 {
989 "expression": "key",
990 "isExpression": false,
991 "asc": true,
992 "nulls": "last"
993 }
994 ],
995 "isUnique": true,
996 "concurrently": false,
997 "method": "btree",
998 "with": {}
999 }
1000 },
1001 "foreignKeys": {},
1002 "compositePrimaryKeys": {},
1003 "uniqueConstraints": {},
1004 "policies": {},
1005 "checkConstraints": {},
1006 "isRLSEnabled": false
1007 },
1008 "public.private_executor_cloud_settings": {
1009 "name": "private_executor_cloud_settings",
1010 "schema": "",
1011 "columns": {
1012 "id": {
1013 "name": "id",
1014 "type": "varchar(255)",
1015 "primaryKey": true,
1016 "notNull": true
1017 },
1018 "version": {
1019 "name": "version",
1020 "type": "varchar(255)",
1021 "primaryKey": false,
1022 "notNull": true,
1023 "default": "'1.0.0'"
1024 }
1025 },
1026 "indexes": {},
1027 "foreignKeys": {},
1028 "compositePrimaryKeys": {},
1029 "uniqueConstraints": {},
1030 "policies": {},
1031 "checkConstraints": {},
1032 "isRLSEnabled": false
1033 },
1034 "public.subject": {
1035 "name": "subject",
1036 "schema": "",
1037 "columns": {
1038 "external_id": {
1039 "name": "external_id",
1040 "type": "varchar(255)",
1041 "primaryKey": false,
1042 "notNull": true
1043 },
1044 "created_at": {
1045 "name": "created_at",
1046 "type": "timestamp",
1047 "primaryKey": false,
1048 "notNull": true
1049 },
1050 "last_seen_at": {
1051 "name": "last_seen_at",
1052 "type": "bigint",
1053 "primaryKey": false,
1054 "notNull": false
1055 },
1056 "status": {
1057 "name": "status",
1058 "type": "text",
1059 "primaryKey": false,
1060 "notNull": false
1061 },
1062 "row_id": {
1063 "name": "row_id",
1064 "type": "varchar(255)",
1065 "primaryKey": true,
1066 "notNull": true
1067 },
1068 "tenant": {
1069 "name": "tenant",
1070 "type": "varchar(255)",
1071 "primaryKey": false,
1072 "notNull": true
1073 }
1074 },
1075 "indexes": {
1076 "subject_uidx": {
1077 "name": "subject_uidx",
1078 "columns": [
1079 {
1080 "expression": "tenant",
1081 "isExpression": false,
1082 "asc": true,
1083 "nulls": "last"
1084 },
1085 {
1086 "expression": "external_id",
1087 "isExpression": false,
1088 "asc": true,
1089 "nulls": "last"
1090 }
1091 ],
1092 "isUnique": true,
1093 "concurrently": false,
1094 "method": "btree",
1095 "with": {}
1096 }
1097 },
1098 "foreignKeys": {},
1099 "compositePrimaryKeys": {},
1100 "uniqueConstraints": {},
1101 "policies": {},
1102 "checkConstraints": {},
1103 "isRLSEnabled": false
1104 },
1105 "public.tool": {
1106 "name": "tool",
1107 "schema": "",
1108 "columns": {
1109 "integration": {
1110 "name": "integration",
1111 "type": "varchar(255)",
1112 "primaryKey": false,
1113 "notNull": true
1114 },
1115 "connection": {
1116 "name": "connection",
1117 "type": "varchar(255)",
1118 "primaryKey": false,
1119 "notNull": true
1120 },
1121 "plugin_id": {
1122 "name": "plugin_id",
1123 "type": "text",
1124 "primaryKey": false,
1125 "notNull": true
1126 },
1127 "name": {
1128 "name": "name",
1129 "type": "varchar(255)",
1130 "primaryKey": false,
1131 "notNull": true
1132 },
1133 "description": {
1134 "name": "description",
1135 "type": "text",
1136 "primaryKey": false,
1137 "notNull": true
1138 },
1139 "input_schema": {
1140 "name": "input_schema",
1141 "type": "json",
1142 "primaryKey": false,
1143 "notNull": false
1144 },
1145 "output_schema": {
1146 "name": "output_schema",
1147 "type": "json",
1148 "primaryKey": false,
1149 "notNull": false
1150 },
1151 "annotations": {
1152 "name": "annotations",
1153 "type": "json",
1154 "primaryKey": false,
1155 "notNull": false
1156 },
1157 "created_at": {
1158 "name": "created_at",
1159 "type": "timestamp",
1160 "primaryKey": false,
1161 "notNull": true
1162 },
1163 "updated_at": {
1164 "name": "updated_at",
1165 "type": "timestamp",
1166 "primaryKey": false,
1167 "notNull": true
1168 },
1169 "row_id": {
1170 "name": "row_id",
1171 "type": "varchar(255)",
1172 "primaryKey": true,
1173 "notNull": true
1174 },
1175 "tenant": {
1176 "name": "tenant",
1177 "type": "varchar(255)",
1178 "primaryKey": false,
1179 "notNull": true
1180 },
1181 "owner": {
1182 "name": "owner",
1183 "type": "varchar(255)",
1184 "primaryKey": false,
1185 "notNull": true
1186 },
1187 "subject": {
1188 "name": "subject",
1189 "type": "varchar(255)",
1190 "primaryKey": false,
1191 "notNull": true
1192 }
1193 },
1194 "indexes": {
1195 "tool_uidx": {
1196 "name": "tool_uidx",
1197 "columns": [
1198 {
1199 "expression": "tenant",
1200 "isExpression": false,
1201 "asc": true,
1202 "nulls": "last"
1203 },
1204 {
1205 "expression": "owner",
1206 "isExpression": false,
1207 "asc": true,
1208 "nulls": "last"
1209 },
1210 {
1211 "expression": "subject",
1212 "isExpression": false,
1213 "asc": true,
1214 "nulls": "last"
1215 },
1216 {
1217 "expression": "integration",
1218 "isExpression": false,
1219 "asc": true,
1220 "nulls": "last"
1221 },
1222 {
1223 "expression": "connection",
1224 "isExpression": false,
1225 "asc": true,
1226 "nulls": "last"
1227 },
1228 {
1229 "expression": "name",
1230 "isExpression": false,
1231 "asc": true,
1232 "nulls": "last"
1233 }
1234 ],
1235 "isUnique": true,
1236 "concurrently": false,
1237 "method": "btree",
1238 "with": {}
1239 }
1240 },
1241 "foreignKeys": {},
1242 "compositePrimaryKeys": {},
1243 "uniqueConstraints": {},
1244 "policies": {},
1245 "checkConstraints": {},
1246 "isRLSEnabled": false
1247 },
1248 "public.tool_policy": {
1249 "name": "tool_policy",
1250 "schema": "",
1251 "columns": {
1252 "id": {
1253 "name": "id",
1254 "type": "varchar(255)",
1255 "primaryKey": false,
1256 "notNull": true
1257 },
1258 "pattern": {
1259 "name": "pattern",
1260 "type": "text",
1261 "primaryKey": false,
1262 "notNull": true
1263 },
1264 "action": {
1265 "name": "action",
1266 "type": "text",
1267 "primaryKey": false,
1268 "notNull": true
1269 },
1270 "position": {
1271 "name": "position",
1272 "type": "text",
1273 "primaryKey": false,
1274 "notNull": true
1275 },
1276 "created_at": {
1277 "name": "created_at",
1278 "type": "timestamp",
1279 "primaryKey": false,
1280 "notNull": true
1281 },
1282 "updated_at": {
1283 "name": "updated_at",
1284 "type": "timestamp",
1285 "primaryKey": false,
1286 "notNull": true
1287 },
1288 "row_id": {
1289 "name": "row_id",
1290 "type": "varchar(255)",
1291 "primaryKey": true,
1292 "notNull": true
1293 },
1294 "tenant": {
1295 "name": "tenant",
1296 "type": "varchar(255)",
1297 "primaryKey": false,
1298 "notNull": true
1299 },
1300 "owner": {
1301 "name": "owner",
1302 "type": "varchar(255)",
1303 "primaryKey": false,
1304 "notNull": true
1305 },
1306 "subject": {
1307 "name": "subject",
1308 "type": "varchar(255)",
1309 "primaryKey": false,
1310 "notNull": true
1311 }
1312 },
1313 "indexes": {
1314 "tool_policy_uidx": {
1315 "name": "tool_policy_uidx",
1316 "columns": [
1317 {
1318 "expression": "tenant",
1319 "isExpression": false,
1320 "asc": true,
1321 "nulls": "last"
1322 },
1323 {
1324 "expression": "owner",
1325 "isExpression": false,
1326 "asc": true,
1327 "nulls": "last"
1328 },
1329 {
1330 "expression": "subject",
1331 "isExpression": false,
1332 "asc": true,
1333 "nulls": "last"
1334 },
1335 {
1336 "expression": "id",
1337 "isExpression": false,
1338 "asc": true,
1339 "nulls": "last"
1340 }
1341 ],
1342 "isUnique": true,
1343 "concurrently": false,
1344 "method": "btree",
1345 "with": {}
1346 }
1347 },
1348 "foreignKeys": {},
1349 "compositePrimaryKeys": {},
1350 "uniqueConstraints": {},
1351 "policies": {},
1352 "checkConstraints": {},
1353 "isRLSEnabled": false
1354 }
1355 },
1356 "enums": {},
1357 "schemas": {},
1358 "sequences": {},
1359 "roles": {},
1360 "policies": {},
1361 "views": {},
1362 "_meta": {
1363 "columns": {},
1364 "schemas": {},
1365 "tables": {}
1366 }
1367}
apps/cloud/drizzle/meta/0013_snapshot.json+1474−0
@@ -0,0 +1,1474 @@
1{
2 "id": "edc90067-822c-443a-8fcd-3bac77bca317",
3 "prevId": "97114994-f0fc-4419-80bf-21c529a6cf0b",
4 "version": "7",
5 "dialect": "postgresql",
6 "tables": {
7 "public.accounts": {
8 "name": "accounts",
9 "schema": "",
10 "columns": {
11 "id": {
12 "name": "id",
13 "type": "text",
14 "primaryKey": true,
15 "notNull": true
16 },
17 "created_at": {
18 "name": "created_at",
19 "type": "timestamp with time zone",
20 "primaryKey": false,
21 "notNull": true,
22 "default": "now()"
23 }
24 },
25 "indexes": {},
26 "foreignKeys": {},
27 "compositePrimaryKeys": {},
28 "uniqueConstraints": {},
29 "policies": {},
30 "checkConstraints": {},
31 "isRLSEnabled": false
32 },
33 "public.memberships": {
34 "name": "memberships",
35 "schema": "",
36 "columns": {
37 "account_id": {
38 "name": "account_id",
39 "type": "text",
40 "primaryKey": false,
41 "notNull": true
42 },
43 "organization_id": {
44 "name": "organization_id",
45 "type": "text",
46 "primaryKey": false,
47 "notNull": true
48 },
49 "created_at": {
50 "name": "created_at",
51 "type": "timestamp with time zone",
52 "primaryKey": false,
53 "notNull": true,
54 "default": "now()"
55 }
56 },
57 "indexes": {},
58 "foreignKeys": {
59 "memberships_account_id_accounts_id_fk": {
60 "name": "memberships_account_id_accounts_id_fk",
61 "tableFrom": "memberships",
62 "tableTo": "accounts",
63 "columnsFrom": ["account_id"],
64 "columnsTo": ["id"],
65 "onDelete": "cascade",
66 "onUpdate": "no action"
67 },
68 "memberships_organization_id_organizations_id_fk": {
69 "name": "memberships_organization_id_organizations_id_fk",
70 "tableFrom": "memberships",
71 "tableTo": "organizations",
72 "columnsFrom": ["organization_id"],
73 "columnsTo": ["id"],
74 "onDelete": "cascade",
75 "onUpdate": "no action"
76 }
77 },
78 "compositePrimaryKeys": {
79 "memberships_account_id_organization_id_pk": {
80 "name": "memberships_account_id_organization_id_pk",
81 "columns": ["account_id", "organization_id"]
82 }
83 },
84 "uniqueConstraints": {},
85 "policies": {},
86 "checkConstraints": {},
87 "isRLSEnabled": false
88 },
89 "public.organizations": {
90 "name": "organizations",
91 "schema": "",
92 "columns": {
93 "id": {
94 "name": "id",
95 "type": "text",
96 "primaryKey": true,
97 "notNull": true
98 },
99 "name": {
100 "name": "name",
101 "type": "text",
102 "primaryKey": false,
103 "notNull": true
104 },
105 "slug": {
106 "name": "slug",
107 "type": "text",
108 "primaryKey": false,
109 "notNull": true
110 },
111 "created_at": {
112 "name": "created_at",
113 "type": "timestamp with time zone",
114 "primaryKey": false,
115 "notNull": true,
116 "default": "now()"
117 }
118 },
119 "indexes": {
120 "organizations_slug_unique": {
121 "name": "organizations_slug_unique",
122 "columns": [
123 {
124 "expression": "slug",
125 "isExpression": false,
126 "asc": true,
127 "nulls": "last"
128 }
129 ],
130 "isUnique": true,
131 "concurrently": false,
132 "method": "btree",
133 "with": {}
134 }
135 },
136 "foreignKeys": {},
137 "compositePrimaryKeys": {},
138 "uniqueConstraints": {},
139 "policies": {},
140 "checkConstraints": {},
141 "isRLSEnabled": false
142 },
143 "public.artifact": {
144 "name": "artifact",
145 "schema": "",
146 "columns": {
147 "id": {
148 "name": "id",
149 "type": "varchar(255)",
150 "primaryKey": false,
151 "notNull": true
152 },
153 "title": {
154 "name": "title",
155 "type": "text",
156 "primaryKey": false,
157 "notNull": true
158 },
159 "description": {
160 "name": "description",
161 "type": "text",
162 "primaryKey": false,
163 "notNull": false
164 },
165 "code": {
166 "name": "code",
167 "type": "text",
168 "primaryKey": false,
169 "notNull": true
170 },
171 "created_at": {
172 "name": "created_at",
173 "type": "timestamp",
174 "primaryKey": false,
175 "notNull": true
176 },
177 "updated_at": {
178 "name": "updated_at",
179 "type": "timestamp",
180 "primaryKey": false,
181 "notNull": true
182 },
183 "row_id": {
184 "name": "row_id",
185 "type": "varchar(255)",
186 "primaryKey": true,
187 "notNull": true
188 },
189 "tenant": {
190 "name": "tenant",
191 "type": "varchar(255)",
192 "primaryKey": false,
193 "notNull": true
194 },
195 "owner": {
196 "name": "owner",
197 "type": "varchar(255)",
198 "primaryKey": false,
199 "notNull": true
200 },
201 "subject": {
202 "name": "subject",
203 "type": "varchar(255)",
204 "primaryKey": false,
205 "notNull": true
206 }
207 },
208 "indexes": {
209 "artifact_uidx": {
210 "name": "artifact_uidx",
211 "columns": [
212 {
213 "expression": "tenant",
214 "isExpression": false,
215 "asc": true,
216 "nulls": "last"
217 },
218 {
219 "expression": "owner",
220 "isExpression": false,
221 "asc": true,
222 "nulls": "last"
223 },
224 {
225 "expression": "subject",
226 "isExpression": false,
227 "asc": true,
228 "nulls": "last"
229 },
230 {
231 "expression": "id",
232 "isExpression": false,
233 "asc": true,
234 "nulls": "last"
235 }
236 ],
237 "isUnique": true,
238 "concurrently": false,
239 "method": "btree",
240 "with": {}
241 }
242 },
243 "foreignKeys": {},
244 "compositePrimaryKeys": {},
245 "uniqueConstraints": {},
246 "policies": {},
247 "checkConstraints": {},
248 "isRLSEnabled": false
249 },
250 "public.blob": {
251 "name": "blob",
252 "schema": "",
253 "columns": {
254 "namespace": {
255 "name": "namespace",
256 "type": "varchar(255)",
257 "primaryKey": false,
258 "notNull": true
259 },
260 "key": {
261 "name": "key",
262 "type": "varchar(255)",
263 "primaryKey": false,
264 "notNull": true
265 },
266 "value": {
267 "name": "value",
268 "type": "text",
269 "primaryKey": false,
270 "notNull": true
271 },
272 "row_id": {
273 "name": "row_id",
274 "type": "varchar(255)",
275 "primaryKey": true,
276 "notNull": true
277 },
278 "id": {
279 "name": "id",
280 "type": "varchar(255)",
281 "primaryKey": false,
282 "notNull": true
283 }
284 },
285 "indexes": {
286 "blob_id_uidx": {
287 "name": "blob_id_uidx",
288 "columns": [
289 {
290 "expression": "id",
291 "isExpression": false,
292 "asc": true,
293 "nulls": "last"
294 }
295 ],
296 "isUnique": true,
297 "concurrently": false,
298 "method": "btree",
299 "with": {}
300 }
301 },
302 "foreignKeys": {},
303 "compositePrimaryKeys": {},
304 "uniqueConstraints": {},
305 "policies": {},
306 "checkConstraints": {},
307 "isRLSEnabled": false
308 },
309 "public.connection": {
310 "name": "connection",
311 "schema": "",
312 "columns": {
313 "integration": {
314 "name": "integration",
315 "type": "varchar(255)",
316 "primaryKey": false,
317 "notNull": true
318 },
319 "name": {
320 "name": "name",
321 "type": "varchar(255)",
322 "primaryKey": false,
323 "notNull": true
324 },
325 "template": {
326 "name": "template",
327 "type": "text",
328 "primaryKey": false,
329 "notNull": true
330 },
331 "provider": {
332 "name": "provider",
333 "type": "text",
334 "primaryKey": false,
335 "notNull": true
336 },
337 "item_ids": {
338 "name": "item_ids",
339 "type": "json",
340 "primaryKey": false,
341 "notNull": true
342 },
343 "identity_label": {
344 "name": "identity_label",
345 "type": "text",
346 "primaryKey": false,
347 "notNull": false
348 },
349 "description": {
350 "name": "description",
351 "type": "text",
352 "primaryKey": false,
353 "notNull": false
354 },
355 "last_health": {
356 "name": "last_health",
357 "type": "json",
358 "primaryKey": false,
359 "notNull": false
360 },
361 "tools_synced_at": {
362 "name": "tools_synced_at",
363 "type": "bigint",
364 "primaryKey": false,
365 "notNull": false
366 },
367 "oauth_client": {
368 "name": "oauth_client",
369 "type": "text",
370 "primaryKey": false,
371 "notNull": false
372 },
373 "oauth_client_owner": {
374 "name": "oauth_client_owner",
375 "type": "text",
376 "primaryKey": false,
377 "notNull": false
378 },
379 "refresh_item_id": {
380 "name": "refresh_item_id",
381 "type": "text",
382 "primaryKey": false,
383 "notNull": false
384 },
385 "expires_at": {
386 "name": "expires_at",
387 "type": "bigint",
388 "primaryKey": false,
389 "notNull": false
390 },
391 "oauth_scope": {
392 "name": "oauth_scope",
393 "type": "text",
394 "primaryKey": false,
395 "notNull": false
396 },
397 "oauth_token_url": {
398 "name": "oauth_token_url",
399 "type": "text",
400 "primaryKey": false,
401 "notNull": false
402 },
403 "provider_state": {
404 "name": "provider_state",
405 "type": "json",
406 "primaryKey": false,
407 "notNull": false
408 },
409 "created_at": {
410 "name": "created_at",
411 "type": "timestamp",
412 "primaryKey": false,
413 "notNull": true
414 },
415 "updated_at": {
416 "name": "updated_at",
417 "type": "timestamp",
418 "primaryKey": false,
419 "notNull": true
420 },
421 "row_id": {
422 "name": "row_id",
423 "type": "varchar(255)",
424 "primaryKey": true,
425 "notNull": true
426 },
427 "tenant": {
428 "name": "tenant",
429 "type": "varchar(255)",
430 "primaryKey": false,
431 "notNull": true
432 },
433 "owner": {
434 "name": "owner",
435 "type": "varchar(255)",
436 "primaryKey": false,
437 "notNull": true
438 },
439 "subject": {
440 "name": "subject",
441 "type": "varchar(255)",
442 "primaryKey": false,
443 "notNull": true
444 }
445 },
446 "indexes": {
447 "connection_uidx": {
448 "name": "connection_uidx",
449 "columns": [
450 {
451 "expression": "tenant",
452 "isExpression": false,
453 "asc": true,
454 "nulls": "last"
455 },
456 {
457 "expression": "owner",
458 "isExpression": false,
459 "asc": true,
460 "nulls": "last"
461 },
462 {
463 "expression": "subject",
464 "isExpression": false,
465 "asc": true,
466 "nulls": "last"
467 },
468 {
469 "expression": "integration",
470 "isExpression": false,
471 "asc": true,
472 "nulls": "last"
473 },
474 {
475 "expression": "name",
476 "isExpression": false,
477 "asc": true,
478 "nulls": "last"
479 }
480 ],
481 "isUnique": true,
482 "concurrently": false,
483 "method": "btree",
484 "with": {}
485 }
486 },
487 "foreignKeys": {},
488 "compositePrimaryKeys": {},
489 "uniqueConstraints": {},
490 "policies": {},
491 "checkConstraints": {},
492 "isRLSEnabled": false
493 },
494 "public.definition": {
495 "name": "definition",
496 "schema": "",
497 "columns": {
498 "integration": {
499 "name": "integration",
500 "type": "varchar(255)",
501 "primaryKey": false,
502 "notNull": true
503 },
504 "connection": {
505 "name": "connection",
506 "type": "varchar(255)",
507 "primaryKey": false,
508 "notNull": true
509 },
510 "plugin_id": {
511 "name": "plugin_id",
512 "type": "text",
513 "primaryKey": false,
514 "notNull": true
515 },
516 "name": {
517 "name": "name",
518 "type": "text",
519 "primaryKey": false,
520 "notNull": true
521 },
522 "schema": {
523 "name": "schema",
524 "type": "json",
525 "primaryKey": false,
526 "notNull": true
527 },
528 "created_at": {
529 "name": "created_at",
530 "type": "timestamp",
531 "primaryKey": false,
532 "notNull": true
533 },
534 "row_id": {
535 "name": "row_id",
536 "type": "varchar(255)",
537 "primaryKey": true,
538 "notNull": true
539 },
540 "tenant": {
541 "name": "tenant",
542 "type": "varchar(255)",
543 "primaryKey": false,
544 "notNull": true
545 },
546 "owner": {
547 "name": "owner",
548 "type": "varchar(255)",
549 "primaryKey": false,
550 "notNull": true
551 },
552 "subject": {
553 "name": "subject",
554 "type": "varchar(255)",
555 "primaryKey": false,
556 "notNull": true
557 }
558 },
559 "indexes": {
560 "definition_uidx": {
561 "name": "definition_uidx",
562 "columns": [
563 {
564 "expression": "tenant",
565 "isExpression": false,
566 "asc": true,
567 "nulls": "last"
568 },
569 {
570 "expression": "owner",
571 "isExpression": false,
572 "asc": true,
573 "nulls": "last"
574 },
575 {
576 "expression": "subject",
577 "isExpression": false,
578 "asc": true,
579 "nulls": "last"
580 },
581 {
582 "expression": "integration",
583 "isExpression": false,
584 "asc": true,
585 "nulls": "last"
586 },
587 {
588 "expression": "connection",
589 "isExpression": false,
590 "asc": true,
591 "nulls": "last"
592 },
593 {
594 "expression": "name",
595 "isExpression": false,
596 "asc": true,
597 "nulls": "last"
598 }
599 ],
600 "isUnique": true,
601 "concurrently": false,
602 "method": "btree",
603 "with": {}
604 }
605 },
606 "foreignKeys": {},
607 "compositePrimaryKeys": {},
608 "uniqueConstraints": {},
609 "policies": {},
610 "checkConstraints": {},
611 "isRLSEnabled": false
612 },
613 "public.integration": {
614 "name": "integration",
615 "schema": "",
616 "columns": {
617 "slug": {
618 "name": "slug",
619 "type": "varchar(255)",
620 "primaryKey": false,
621 "notNull": true
622 },
623 "plugin_id": {
624 "name": "plugin_id",
625 "type": "text",
626 "primaryKey": false,
627 "notNull": true
628 },
629 "name": {
630 "name": "name",
631 "type": "text",
632 "primaryKey": false,
633 "notNull": false
634 },
635 "description": {
636 "name": "description",
637 "type": "text",
638 "primaryKey": false,
639 "notNull": false
640 },
641 "config": {
642 "name": "config",
643 "type": "json",
644 "primaryKey": false,
645 "notNull": false
646 },
647 "health_check": {
648 "name": "health_check",
649 "type": "json",
650 "primaryKey": false,
651 "notNull": false
652 },
653 "config_revised_at": {
654 "name": "config_revised_at",
655 "type": "bigint",
656 "primaryKey": false,
657 "notNull": false
658 },
659 "can_remove": {
660 "name": "can_remove",
661 "type": "boolean",
662 "primaryKey": false,
663 "notNull": true,
664 "default": true
665 },
666 "can_refresh": {
667 "name": "can_refresh",
668 "type": "boolean",
669 "primaryKey": false,
670 "notNull": true,
671 "default": false
672 },
673 "created_at": {
674 "name": "created_at",
675 "type": "timestamp",
676 "primaryKey": false,
677 "notNull": true
678 },
679 "updated_at": {
680 "name": "updated_at",
681 "type": "timestamp",
682 "primaryKey": false,
683 "notNull": true
684 },
685 "row_id": {
686 "name": "row_id",
687 "type": "varchar(255)",
688 "primaryKey": true,
689 "notNull": true
690 },
691 "tenant": {
692 "name": "tenant",
693 "type": "varchar(255)",
694 "primaryKey": false,
695 "notNull": true
696 }
697 },
698 "indexes": {
699 "integration_uidx": {
700 "name": "integration_uidx",
701 "columns": [
702 {
703 "expression": "tenant",
704 "isExpression": false,
705 "asc": true,
706 "nulls": "last"
707 },
708 {
709 "expression": "slug",
710 "isExpression": false,
711 "asc": true,
712 "nulls": "last"
713 }
714 ],
715 "isUnique": true,
716 "concurrently": false,
717 "method": "btree",
718 "with": {}
719 }
720 },
721 "foreignKeys": {},
722 "compositePrimaryKeys": {},
723 "uniqueConstraints": {},
724 "policies": {},
725 "checkConstraints": {},
726 "isRLSEnabled": false
727 },
728 "public.oauth_client": {
729 "name": "oauth_client",
730 "schema": "",
731 "columns": {
732 "slug": {
733 "name": "slug",
734 "type": "varchar(255)",
735 "primaryKey": false,
736 "notNull": true
737 },
738 "authorization_url": {
739 "name": "authorization_url",
740 "type": "text",
741 "primaryKey": false,
742 "notNull": true
743 },
744 "token_url": {
745 "name": "token_url",
746 "type": "text",
747 "primaryKey": false,
748 "notNull": true
749 },
750 "grant": {
751 "name": "grant",
752 "type": "text",
753 "primaryKey": false,
754 "notNull": true
755 },
756 "client_id": {
757 "name": "client_id",
758 "type": "text",
759 "primaryKey": false,
760 "notNull": true
761 },
762 "client_secret_item_id": {
763 "name": "client_secret_item_id",
764 "type": "text",
765 "primaryKey": false,
766 "notNull": false
767 },
768 "resource": {
769 "name": "resource",
770 "type": "text",
771 "primaryKey": false,
772 "notNull": false
773 },
774 "origin_kind": {
775 "name": "origin_kind",
776 "type": "text",
777 "primaryKey": false,
778 "notNull": false
779 },
780 "origin_integration": {
781 "name": "origin_integration",
782 "type": "text",
783 "primaryKey": false,
784 "notNull": false
785 },
786 "origin_issuer": {
787 "name": "origin_issuer",
788 "type": "text",
789 "primaryKey": false,
790 "notNull": false
791 },
792 "origin_redirect_uri": {
793 "name": "origin_redirect_uri",
794 "type": "text",
795 "primaryKey": false,
796 "notNull": false
797 },
798 "created_at": {
799 "name": "created_at",
800 "type": "timestamp",
801 "primaryKey": false,
802 "notNull": true
803 },
804 "row_id": {
805 "name": "row_id",
806 "type": "varchar(255)",
807 "primaryKey": true,
808 "notNull": true
809 },
810 "tenant": {
811 "name": "tenant",
812 "type": "varchar(255)",
813 "primaryKey": false,
814 "notNull": true
815 },
816 "owner": {
817 "name": "owner",
818 "type": "varchar(255)",
819 "primaryKey": false,
820 "notNull": true
821 },
822 "subject": {
823 "name": "subject",
824 "type": "varchar(255)",
825 "primaryKey": false,
826 "notNull": true
827 }
828 },
829 "indexes": {
830 "oauth_client_uidx": {
831 "name": "oauth_client_uidx",
832 "columns": [
833 {
834 "expression": "tenant",
835 "isExpression": false,
836 "asc": true,
837 "nulls": "last"
838 },
839 {
840 "expression": "owner",
841 "isExpression": false,
842 "asc": true,
843 "nulls": "last"
844 },
845 {
846 "expression": "subject",
847 "isExpression": false,
848 "asc": true,
849 "nulls": "last"
850 },
851 {
852 "expression": "slug",
853 "isExpression": false,
854 "asc": true,
855 "nulls": "last"
856 }
857 ],
858 "isUnique": true,
859 "concurrently": false,
860 "method": "btree",
861 "with": {}
862 }
863 },
864 "foreignKeys": {},
865 "compositePrimaryKeys": {},
866 "uniqueConstraints": {},
867 "policies": {},
868 "checkConstraints": {},
869 "isRLSEnabled": false
870 },
871 "public.oauth_session": {
872 "name": "oauth_session",
873 "schema": "",
874 "columns": {
875 "state": {
876 "name": "state",
877 "type": "varchar(255)",
878 "primaryKey": false,
879 "notNull": true
880 },
881 "client_slug": {
882 "name": "client_slug",
883 "type": "text",
884 "primaryKey": false,
885 "notNull": true
886 },
887 "integration": {
888 "name": "integration",
889 "type": "text",
890 "primaryKey": false,
891 "notNull": true
892 },
893 "name": {
894 "name": "name",
895 "type": "text",
896 "primaryKey": false,
897 "notNull": true
898 },
899 "template": {
900 "name": "template",
901 "type": "text",
902 "primaryKey": false,
903 "notNull": true
904 },
905 "redirect_url": {
906 "name": "redirect_url",
907 "type": "text",
908 "primaryKey": false,
909 "notNull": true
910 },
911 "pkce_verifier": {
912 "name": "pkce_verifier",
913 "type": "text",
914 "primaryKey": false,
915 "notNull": false
916 },
917 "identity_label": {
918 "name": "identity_label",
919 "type": "text",
920 "primaryKey": false,
921 "notNull": false
922 },
923 "payload": {
924 "name": "payload",
925 "type": "json",
926 "primaryKey": false,
927 "notNull": true
928 },
929 "expires_at": {
930 "name": "expires_at",
931 "type": "bigint",
932 "primaryKey": false,
933 "notNull": true
934 },
935 "created_at": {
936 "name": "created_at",
937 "type": "timestamp",
938 "primaryKey": false,
939 "notNull": true
940 },
941 "row_id": {
942 "name": "row_id",
943 "type": "varchar(255)",
944 "primaryKey": true,
945 "notNull": true
946 },
947 "tenant": {
948 "name": "tenant",
949 "type": "varchar(255)",
950 "primaryKey": false,
951 "notNull": true
952 },
953 "owner": {
954 "name": "owner",
955 "type": "varchar(255)",
956 "primaryKey": false,
957 "notNull": true
958 },
959 "subject": {
960 "name": "subject",
961 "type": "varchar(255)",
962 "primaryKey": false,
963 "notNull": true
964 }
965 },
966 "indexes": {
967 "oauth_session_uidx": {
968 "name": "oauth_session_uidx",
969 "columns": [
970 {
971 "expression": "tenant",
972 "isExpression": false,
973 "asc": true,
974 "nulls": "last"
975 },
976 {
977 "expression": "state",
978 "isExpression": false,
979 "asc": true,
980 "nulls": "last"
981 }
982 ],
983 "isUnique": true,
984 "concurrently": false,
985 "method": "btree",
986 "with": {}
987 }
988 },
989 "foreignKeys": {},
990 "compositePrimaryKeys": {},
991 "uniqueConstraints": {},
992 "policies": {},
993 "checkConstraints": {},
994 "isRLSEnabled": false
995 },
996 "public.plugin_storage": {
997 "name": "plugin_storage",
998 "schema": "",
999 "columns": {
1000 "plugin_id": {
1001 "name": "plugin_id",
1002 "type": "varchar(255)",
1003 "primaryKey": false,
1004 "notNull": true
1005 },
1006 "collection": {
1007 "name": "collection",
1008 "type": "varchar(255)",
1009 "primaryKey": false,
1010 "notNull": true
1011 },
1012 "key": {
1013 "name": "key",
1014 "type": "varchar(255)",
1015 "primaryKey": false,
1016 "notNull": true
1017 },
1018 "data": {
1019 "name": "data",
1020 "type": "json",
1021 "primaryKey": false,
1022 "notNull": true
1023 },
1024 "created_at": {
1025 "name": "created_at",
1026 "type": "timestamp",
1027 "primaryKey": false,
1028 "notNull": true
1029 },
1030 "updated_at": {
1031 "name": "updated_at",
1032 "type": "timestamp",
1033 "primaryKey": false,
1034 "notNull": true
1035 },
1036 "row_id": {
1037 "name": "row_id",
1038 "type": "varchar(255)",
1039 "primaryKey": true,
1040 "notNull": true
1041 },
1042 "tenant": {
1043 "name": "tenant",
1044 "type": "varchar(255)",
1045 "primaryKey": false,
1046 "notNull": true
1047 },
1048 "owner": {
1049 "name": "owner",
1050 "type": "varchar(255)",
1051 "primaryKey": false,
1052 "notNull": true
1053 },
1054 "subject": {
1055 "name": "subject",
1056 "type": "varchar(255)",
1057 "primaryKey": false,
1058 "notNull": true
1059 }
1060 },
1061 "indexes": {
1062 "plugin_storage_uidx": {
1063 "name": "plugin_storage_uidx",
1064 "columns": [
1065 {
1066 "expression": "tenant",
1067 "isExpression": false,
1068 "asc": true,
1069 "nulls": "last"
1070 },
1071 {
1072 "expression": "owner",
1073 "isExpression": false,
1074 "asc": true,
1075 "nulls": "last"
1076 },
1077 {
1078 "expression": "subject",
1079 "isExpression": false,
1080 "asc": true,
1081 "nulls": "last"
1082 },
1083 {
1084 "expression": "plugin_id",
1085 "isExpression": false,
1086 "asc": true,
1087 "nulls": "last"
1088 },
1089 {
1090 "expression": "collection",
1091 "isExpression": false,
1092 "asc": true,
1093 "nulls": "last"
1094 },
1095 {
1096 "expression": "key",
1097 "isExpression": false,
1098 "asc": true,
1099 "nulls": "last"
1100 }
1101 ],
1102 "isUnique": true,
1103 "concurrently": false,
1104 "method": "btree",
1105 "with": {}
1106 }
1107 },
1108 "foreignKeys": {},
1109 "compositePrimaryKeys": {},
1110 "uniqueConstraints": {},
1111 "policies": {},
1112 "checkConstraints": {},
1113 "isRLSEnabled": false
1114 },
1115 "public.private_executor_cloud_settings": {
1116 "name": "private_executor_cloud_settings",
1117 "schema": "",
1118 "columns": {
1119 "id": {
1120 "name": "id",
1121 "type": "varchar(255)",
1122 "primaryKey": true,
1123 "notNull": true
1124 },
1125 "version": {
1126 "name": "version",
1127 "type": "varchar(255)",
1128 "primaryKey": false,
1129 "notNull": true,
1130 "default": "'1.0.0'"
1131 }
1132 },
1133 "indexes": {},
1134 "foreignKeys": {},
1135 "compositePrimaryKeys": {},
1136 "uniqueConstraints": {},
1137 "policies": {},
1138 "checkConstraints": {},
1139 "isRLSEnabled": false
1140 },
1141 "public.subject": {
1142 "name": "subject",
1143 "schema": "",
1144 "columns": {
1145 "external_id": {
1146 "name": "external_id",
1147 "type": "varchar(255)",
1148 "primaryKey": false,
1149 "notNull": true
1150 },
1151 "created_at": {
1152 "name": "created_at",
1153 "type": "timestamp",
1154 "primaryKey": false,
1155 "notNull": true
1156 },
1157 "last_seen_at": {
1158 "name": "last_seen_at",
1159 "type": "bigint",
1160 "primaryKey": false,
1161 "notNull": false
1162 },
1163 "status": {
1164 "name": "status",
1165 "type": "text",
1166 "primaryKey": false,
1167 "notNull": false
1168 },
1169 "row_id": {
1170 "name": "row_id",
1171 "type": "varchar(255)",
1172 "primaryKey": true,
1173 "notNull": true
1174 },
1175 "tenant": {
1176 "name": "tenant",
1177 "type": "varchar(255)",
1178 "primaryKey": false,
1179 "notNull": true
1180 }
1181 },
1182 "indexes": {
1183 "subject_uidx": {
1184 "name": "subject_uidx",
1185 "columns": [
1186 {
1187 "expression": "tenant",
1188 "isExpression": false,
1189 "asc": true,
1190 "nulls": "last"
1191 },
1192 {
1193 "expression": "external_id",
1194 "isExpression": false,
1195 "asc": true,
1196 "nulls": "last"
1197 }
1198 ],
1199 "isUnique": true,
1200 "concurrently": false,
1201 "method": "btree",
1202 "with": {}
1203 }
1204 },
1205 "foreignKeys": {},
1206 "compositePrimaryKeys": {},
1207 "uniqueConstraints": {},
1208 "policies": {},
1209 "checkConstraints": {},
1210 "isRLSEnabled": false
1211 },
1212 "public.tool": {
1213 "name": "tool",
1214 "schema": "",
1215 "columns": {
1216 "integration": {
1217 "name": "integration",
1218 "type": "varchar(255)",
1219 "primaryKey": false,
1220 "notNull": true
1221 },
1222 "connection": {
1223 "name": "connection",
1224 "type": "varchar(255)",
1225 "primaryKey": false,
1226 "notNull": true
1227 },
1228 "plugin_id": {
1229 "name": "plugin_id",
1230 "type": "text",
1231 "primaryKey": false,
1232 "notNull": true
1233 },
1234 "name": {
1235 "name": "name",
1236 "type": "varchar(255)",
1237 "primaryKey": false,
1238 "notNull": true
1239 },
1240 "description": {
1241 "name": "description",
1242 "type": "text",
1243 "primaryKey": false,
1244 "notNull": true
1245 },
1246 "input_schema": {
1247 "name": "input_schema",
1248 "type": "json",
1249 "primaryKey": false,
1250 "notNull": false
1251 },
1252 "output_schema": {
1253 "name": "output_schema",
1254 "type": "json",
1255 "primaryKey": false,
1256 "notNull": false
1257 },
1258 "annotations": {
1259 "name": "annotations",
1260 "type": "json",
1261 "primaryKey": false,
1262 "notNull": false
1263 },
1264 "created_at": {
1265 "name": "created_at",
1266 "type": "timestamp",
1267 "primaryKey": false,
1268 "notNull": true
1269 },
1270 "updated_at": {
1271 "name": "updated_at",
1272 "type": "timestamp",
1273 "primaryKey": false,
1274 "notNull": true
1275 },
1276 "row_id": {
1277 "name": "row_id",
1278 "type": "varchar(255)",
1279 "primaryKey": true,
1280 "notNull": true
1281 },
1282 "tenant": {
1283 "name": "tenant",
1284 "type": "varchar(255)",
1285 "primaryKey": false,
1286 "notNull": true
1287 },
1288 "owner": {
1289 "name": "owner",
1290 "type": "varchar(255)",
1291 "primaryKey": false,
1292 "notNull": true
1293 },
1294 "subject": {
1295 "name": "subject",
1296 "type": "varchar(255)",
1297 "primaryKey": false,
1298 "notNull": true
1299 }
1300 },
1301 "indexes": {
1302 "tool_uidx": {
1303 "name": "tool_uidx",
1304 "columns": [
1305 {
1306 "expression": "tenant",
1307 "isExpression": false,
1308 "asc": true,
1309 "nulls": "last"
1310 },
1311 {
1312 "expression": "owner",
1313 "isExpression": false,
1314 "asc": true,
1315 "nulls": "last"
1316 },
1317 {
1318 "expression": "subject",
1319 "isExpression": false,
1320 "asc": true,
1321 "nulls": "last"
1322 },
1323 {
1324 "expression": "integration",
1325 "isExpression": false,
1326 "asc": true,
1327 "nulls": "last"
1328 },
1329 {
1330 "expression": "connection",
1331 "isExpression": false,
1332 "asc": true,
1333 "nulls": "last"
1334 },
1335 {
1336 "expression": "name",
1337 "isExpression": false,
1338 "asc": true,
1339 "nulls": "last"
1340 }
1341 ],
1342 "isUnique": true,
1343 "concurrently": false,
1344 "method": "btree",
1345 "with": {}
1346 }
1347 },
1348 "foreignKeys": {},
1349 "compositePrimaryKeys": {},
1350 "uniqueConstraints": {},
1351 "policies": {},
1352 "checkConstraints": {},
1353 "isRLSEnabled": false
1354 },
1355 "public.tool_policy": {
1356 "name": "tool_policy",
1357 "schema": "",
1358 "columns": {
1359 "id": {
1360 "name": "id",
1361 "type": "varchar(255)",
1362 "primaryKey": false,
1363 "notNull": true
1364 },
1365 "pattern": {
1366 "name": "pattern",
1367 "type": "text",
1368 "primaryKey": false,
1369 "notNull": true
1370 },
1371 "action": {
1372 "name": "action",
1373 "type": "text",
1374 "primaryKey": false,
1375 "notNull": true
1376 },
1377 "position": {
1378 "name": "position",
1379 "type": "text",
1380 "primaryKey": false,
1381 "notNull": true
1382 },
1383 "created_at": {
1384 "name": "created_at",
1385 "type": "timestamp",
1386 "primaryKey": false,
1387 "notNull": true
1388 },
1389 "updated_at": {
1390 "name": "updated_at",
1391 "type": "timestamp",
1392 "primaryKey": false,
1393 "notNull": true
1394 },
1395 "row_id": {
1396 "name": "row_id",
1397 "type": "varchar(255)",
1398 "primaryKey": true,
1399 "notNull": true
1400 },
1401 "tenant": {
1402 "name": "tenant",
1403 "type": "varchar(255)",
1404 "primaryKey": false,
1405 "notNull": true
1406 },
1407 "owner": {
1408 "name": "owner",
1409 "type": "varchar(255)",
1410 "primaryKey": false,
1411 "notNull": true
1412 },
1413 "subject": {
1414 "name": "subject",
1415 "type": "varchar(255)",
1416 "primaryKey": false,
1417 "notNull": true
1418 }
1419 },
1420 "indexes": {
1421 "tool_policy_uidx": {
1422 "name": "tool_policy_uidx",
1423 "columns": [
1424 {
1425 "expression": "tenant",
1426 "isExpression": false,
1427 "asc": true,
1428 "nulls": "last"
1429 },
1430 {
1431 "expression": "owner",
1432 "isExpression": false,
1433 "asc": true,
1434 "nulls": "last"
1435 },
1436 {
1437 "expression": "subject",
1438 "isExpression": false,
1439 "asc": true,
1440 "nulls": "last"
1441 },
1442 {
1443 "expression": "id",
1444 "isExpression": false,
1445 "asc": true,
1446 "nulls": "last"
1447 }
1448 ],
1449 "isUnique": true,
1450 "concurrently": false,
1451 "method": "btree",
1452 "with": {}
1453 }
1454 },
1455 "foreignKeys": {},
1456 "compositePrimaryKeys": {},
1457 "uniqueConstraints": {},
1458 "policies": {},
1459 "checkConstraints": {},
1460 "isRLSEnabled": false
1461 }
1462 },
1463 "enums": {},
1464 "schemas": {},
1465 "sequences": {},
1466 "roles": {},
1467 "policies": {},
1468 "views": {},
1469 "_meta": {
1470 "columns": {},
1471 "schemas": {},
1472 "tables": {}
1473 }
1474}
apps/cloud/drizzle/meta/0014_snapshot.json+1480−0
@@ -0,0 +1,1480 @@
1{
2 "id": "0857c83f-0124-4739-bd88-a0a4a92aab84",
3 "prevId": "edc90067-822c-443a-8fcd-3bac77bca317",
4 "version": "7",
5 "dialect": "postgresql",
6 "tables": {
7 "public.accounts": {
8 "name": "accounts",
9 "schema": "",
10 "columns": {
11 "id": {
12 "name": "id",
13 "type": "text",
14 "primaryKey": true,
15 "notNull": true
16 },
17 "created_at": {
18 "name": "created_at",
19 "type": "timestamp with time zone",
20 "primaryKey": false,
21 "notNull": true,
22 "default": "now()"
23 }
24 },
25 "indexes": {},
26 "foreignKeys": {},
27 "compositePrimaryKeys": {},
28 "uniqueConstraints": {},
29 "policies": {},
30 "checkConstraints": {},
31 "isRLSEnabled": false
32 },
33 "public.memberships": {
34 "name": "memberships",
35 "schema": "",
36 "columns": {
37 "account_id": {
38 "name": "account_id",
39 "type": "text",
40 "primaryKey": false,
41 "notNull": true
42 },
43 "organization_id": {
44 "name": "organization_id",
45 "type": "text",
46 "primaryKey": false,
47 "notNull": true
48 },
49 "created_at": {
50 "name": "created_at",
51 "type": "timestamp with time zone",
52 "primaryKey": false,
53 "notNull": true,
54 "default": "now()"
55 }
56 },
57 "indexes": {},
58 "foreignKeys": {
59 "memberships_account_id_accounts_id_fk": {
60 "name": "memberships_account_id_accounts_id_fk",
61 "tableFrom": "memberships",
62 "tableTo": "accounts",
63 "columnsFrom": ["account_id"],
64 "columnsTo": ["id"],
65 "onDelete": "cascade",
66 "onUpdate": "no action"
67 },
68 "memberships_organization_id_organizations_id_fk": {
69 "name": "memberships_organization_id_organizations_id_fk",
70 "tableFrom": "memberships",
71 "tableTo": "organizations",
72 "columnsFrom": ["organization_id"],
73 "columnsTo": ["id"],
74 "onDelete": "cascade",
75 "onUpdate": "no action"
76 }
77 },
78 "compositePrimaryKeys": {
79 "memberships_account_id_organization_id_pk": {
80 "name": "memberships_account_id_organization_id_pk",
81 "columns": ["account_id", "organization_id"]
82 }
83 },
84 "uniqueConstraints": {},
85 "policies": {},
86 "checkConstraints": {},
87 "isRLSEnabled": false
88 },
89 "public.organizations": {
90 "name": "organizations",
91 "schema": "",
92 "columns": {
93 "id": {
94 "name": "id",
95 "type": "text",
96 "primaryKey": true,
97 "notNull": true
98 },
99 "name": {
100 "name": "name",
101 "type": "text",
102 "primaryKey": false,
103 "notNull": true
104 },
105 "slug": {
106 "name": "slug",
107 "type": "text",
108 "primaryKey": false,
109 "notNull": true
110 },
111 "created_at": {
112 "name": "created_at",
113 "type": "timestamp with time zone",
114 "primaryKey": false,
115 "notNull": true,
116 "default": "now()"
117 }
118 },
119 "indexes": {
120 "organizations_slug_unique": {
121 "name": "organizations_slug_unique",
122 "columns": [
123 {
124 "expression": "slug",
125 "isExpression": false,
126 "asc": true,
127 "nulls": "last"
128 }
129 ],
130 "isUnique": true,
131 "concurrently": false,
132 "method": "btree",
133 "with": {}
134 }
135 },
136 "foreignKeys": {},
137 "compositePrimaryKeys": {},
138 "uniqueConstraints": {},
139 "policies": {},
140 "checkConstraints": {},
141 "isRLSEnabled": false
142 },
143 "public.artifact": {
144 "name": "artifact",
145 "schema": "",
146 "columns": {
147 "id": {
148 "name": "id",
149 "type": "varchar(255)",
150 "primaryKey": false,
151 "notNull": true
152 },
153 "title": {
154 "name": "title",
155 "type": "text",
156 "primaryKey": false,
157 "notNull": true
158 },
159 "description": {
160 "name": "description",
161 "type": "text",
162 "primaryKey": false,
163 "notNull": false
164 },
165 "code": {
166 "name": "code",
167 "type": "text",
168 "primaryKey": false,
169 "notNull": true
170 },
171 "bindings": {
172 "name": "bindings",
173 "type": "json",
174 "primaryKey": false,
175 "notNull": false
176 },
177 "created_at": {
178 "name": "created_at",
179 "type": "timestamp",
180 "primaryKey": false,
181 "notNull": true
182 },
183 "updated_at": {
184 "name": "updated_at",
185 "type": "timestamp",
186 "primaryKey": false,
187 "notNull": true
188 },
189 "row_id": {
190 "name": "row_id",
191 "type": "varchar(255)",
192 "primaryKey": true,
193 "notNull": true
194 },
195 "tenant": {
196 "name": "tenant",
197 "type": "varchar(255)",
198 "primaryKey": false,
199 "notNull": true
200 },
201 "owner": {
202 "name": "owner",
203 "type": "varchar(255)",
204 "primaryKey": false,
205 "notNull": true
206 },
207 "subject": {
208 "name": "subject",
209 "type": "varchar(255)",
210 "primaryKey": false,
211 "notNull": true
212 }
213 },
214 "indexes": {
215 "artifact_uidx": {
216 "name": "artifact_uidx",
217 "columns": [
218 {
219 "expression": "tenant",
220 "isExpression": false,
221 "asc": true,
222 "nulls": "last"
223 },
224 {
225 "expression": "owner",
226 "isExpression": false,
227 "asc": true,
228 "nulls": "last"
229 },
230 {
231 "expression": "subject",
232 "isExpression": false,
233 "asc": true,
234 "nulls": "last"
235 },
236 {
237 "expression": "id",
238 "isExpression": false,
239 "asc": true,
240 "nulls": "last"
241 }
242 ],
243 "isUnique": true,
244 "concurrently": false,
245 "method": "btree",
246 "with": {}
247 }
248 },
249 "foreignKeys": {},
250 "compositePrimaryKeys": {},
251 "uniqueConstraints": {},
252 "policies": {},
253 "checkConstraints": {},
254 "isRLSEnabled": false
255 },
256 "public.blob": {
257 "name": "blob",
258 "schema": "",
259 "columns": {
260 "namespace": {
261 "name": "namespace",
262 "type": "varchar(255)",
263 "primaryKey": false,
264 "notNull": true
265 },
266 "key": {
267 "name": "key",
268 "type": "varchar(255)",
269 "primaryKey": false,
270 "notNull": true
271 },
272 "value": {
273 "name": "value",
274 "type": "text",
275 "primaryKey": false,
276 "notNull": true
277 },
278 "row_id": {
279 "name": "row_id",
280 "type": "varchar(255)",
281 "primaryKey": true,
282 "notNull": true
283 },
284 "id": {
285 "name": "id",
286 "type": "varchar(255)",
287 "primaryKey": false,
288 "notNull": true
289 }
290 },
291 "indexes": {
292 "blob_id_uidx": {
293 "name": "blob_id_uidx",
294 "columns": [
295 {
296 "expression": "id",
297 "isExpression": false,
298 "asc": true,
299 "nulls": "last"
300 }
301 ],
302 "isUnique": true,
303 "concurrently": false,
304 "method": "btree",
305 "with": {}
306 }
307 },
308 "foreignKeys": {},
309 "compositePrimaryKeys": {},
310 "uniqueConstraints": {},
311 "policies": {},
312 "checkConstraints": {},
313 "isRLSEnabled": false
314 },
315 "public.connection": {
316 "name": "connection",
317 "schema": "",
318 "columns": {
319 "integration": {
320 "name": "integration",
321 "type": "varchar(255)",
322 "primaryKey": false,
323 "notNull": true
324 },
325 "name": {
326 "name": "name",
327 "type": "varchar(255)",
328 "primaryKey": false,
329 "notNull": true
330 },
331 "template": {
332 "name": "template",
333 "type": "text",
334 "primaryKey": false,
335 "notNull": true
336 },
337 "provider": {
338 "name": "provider",
339 "type": "text",
340 "primaryKey": false,
341 "notNull": true
342 },
343 "item_ids": {
344 "name": "item_ids",
345 "type": "json",
346 "primaryKey": false,
347 "notNull": true
348 },
349 "identity_label": {
350 "name": "identity_label",
351 "type": "text",
352 "primaryKey": false,
353 "notNull": false
354 },
355 "description": {
356 "name": "description",
357 "type": "text",
358 "primaryKey": false,
359 "notNull": false
360 },
361 "last_health": {
362 "name": "last_health",
363 "type": "json",
364 "primaryKey": false,
365 "notNull": false
366 },
367 "tools_synced_at": {
368 "name": "tools_synced_at",
369 "type": "bigint",
370 "primaryKey": false,
371 "notNull": false
372 },
373 "oauth_client": {
374 "name": "oauth_client",
375 "type": "text",
376 "primaryKey": false,
377 "notNull": false
378 },
379 "oauth_client_owner": {
380 "name": "oauth_client_owner",
381 "type": "text",
382 "primaryKey": false,
383 "notNull": false
384 },
385 "refresh_item_id": {
386 "name": "refresh_item_id",
387 "type": "text",
388 "primaryKey": false,
389 "notNull": false
390 },
391 "expires_at": {
392 "name": "expires_at",
393 "type": "bigint",
394 "primaryKey": false,
395 "notNull": false
396 },
397 "oauth_scope": {
398 "name": "oauth_scope",
399 "type": "text",
400 "primaryKey": false,
401 "notNull": false
402 },
403 "oauth_token_url": {
404 "name": "oauth_token_url",
405 "type": "text",
406 "primaryKey": false,
407 "notNull": false
408 },
409 "provider_state": {
410 "name": "provider_state",
411 "type": "json",
412 "primaryKey": false,
413 "notNull": false
414 },
415 "created_at": {
416 "name": "created_at",
417 "type": "timestamp",
418 "primaryKey": false,
419 "notNull": true
420 },
421 "updated_at": {
422 "name": "updated_at",
423 "type": "timestamp",
424 "primaryKey": false,
425 "notNull": true
426 },
427 "row_id": {
428 "name": "row_id",
429 "type": "varchar(255)",
430 "primaryKey": true,
431 "notNull": true
432 },
433 "tenant": {
434 "name": "tenant",
435 "type": "varchar(255)",
436 "primaryKey": false,
437 "notNull": true
438 },
439 "owner": {
440 "name": "owner",
441 "type": "varchar(255)",
442 "primaryKey": false,
443 "notNull": true
444 },
445 "subject": {
446 "name": "subject",
447 "type": "varchar(255)",
448 "primaryKey": false,
449 "notNull": true
450 }
451 },
452 "indexes": {
453 "connection_uidx": {
454 "name": "connection_uidx",
455 "columns": [
456 {
457 "expression": "tenant",
458 "isExpression": false,
459 "asc": true,
460 "nulls": "last"
461 },
462 {
463 "expression": "owner",
464 "isExpression": false,
465 "asc": true,
466 "nulls": "last"
467 },
468 {
469 "expression": "subject",
470 "isExpression": false,
471 "asc": true,
472 "nulls": "last"
473 },
474 {
475 "expression": "integration",
476 "isExpression": false,
477 "asc": true,
478 "nulls": "last"
479 },
480 {
481 "expression": "name",
482 "isExpression": false,
483 "asc": true,
484 "nulls": "last"
485 }
486 ],
487 "isUnique": true,
488 "concurrently": false,
489 "method": "btree",
490 "with": {}
491 }
492 },
493 "foreignKeys": {},
494 "compositePrimaryKeys": {},
495 "uniqueConstraints": {},
496 "policies": {},
497 "checkConstraints": {},
498 "isRLSEnabled": false
499 },
500 "public.definition": {
501 "name": "definition",
502 "schema": "",
503 "columns": {
504 "integration": {
505 "name": "integration",
506 "type": "varchar(255)",
507 "primaryKey": false,
508 "notNull": true
509 },
510 "connection": {
511 "name": "connection",
512 "type": "varchar(255)",
513 "primaryKey": false,
514 "notNull": true
515 },
516 "plugin_id": {
517 "name": "plugin_id",
518 "type": "text",
519 "primaryKey": false,
520 "notNull": true
521 },
522 "name": {
523 "name": "name",
524 "type": "text",
525 "primaryKey": false,
526 "notNull": true
527 },
528 "schema": {
529 "name": "schema",
530 "type": "json",
531 "primaryKey": false,
532 "notNull": true
533 },
534 "created_at": {
535 "name": "created_at",
536 "type": "timestamp",
537 "primaryKey": false,
538 "notNull": true
539 },
540 "row_id": {
541 "name": "row_id",
542 "type": "varchar(255)",
543 "primaryKey": true,
544 "notNull": true
545 },
546 "tenant": {
547 "name": "tenant",
548 "type": "varchar(255)",
549 "primaryKey": false,
550 "notNull": true
551 },
552 "owner": {
553 "name": "owner",
554 "type": "varchar(255)",
555 "primaryKey": false,
556 "notNull": true
557 },
558 "subject": {
559 "name": "subject",
560 "type": "varchar(255)",
561 "primaryKey": false,
562 "notNull": true
563 }
564 },
565 "indexes": {
566 "definition_uidx": {
567 "name": "definition_uidx",
568 "columns": [
569 {
570 "expression": "tenant",
571 "isExpression": false,
572 "asc": true,
573 "nulls": "last"
574 },
575 {
576 "expression": "owner",
577 "isExpression": false,
578 "asc": true,
579 "nulls": "last"
580 },
581 {
582 "expression": "subject",
583 "isExpression": false,
584 "asc": true,
585 "nulls": "last"
586 },
587 {
588 "expression": "integration",
589 "isExpression": false,
590 "asc": true,
591 "nulls": "last"
592 },
593 {
594 "expression": "connection",
595 "isExpression": false,
596 "asc": true,
597 "nulls": "last"
598 },
599 {
600 "expression": "name",
601 "isExpression": false,
602 "asc": true,
603 "nulls": "last"
604 }
605 ],
606 "isUnique": true,
607 "concurrently": false,
608 "method": "btree",
609 "with": {}
610 }
611 },
612 "foreignKeys": {},
613 "compositePrimaryKeys": {},
614 "uniqueConstraints": {},
615 "policies": {},
616 "checkConstraints": {},
617 "isRLSEnabled": false
618 },
619 "public.integration": {
620 "name": "integration",
621 "schema": "",
622 "columns": {
623 "slug": {
624 "name": "slug",
625 "type": "varchar(255)",
626 "primaryKey": false,
627 "notNull": true
628 },
629 "plugin_id": {
630 "name": "plugin_id",
631 "type": "text",
632 "primaryKey": false,
633 "notNull": true
634 },
635 "name": {
636 "name": "name",
637 "type": "text",
638 "primaryKey": false,
639 "notNull": false
640 },
641 "description": {
642 "name": "description",
643 "type": "text",
644 "primaryKey": false,
645 "notNull": false
646 },
647 "config": {
648 "name": "config",
649 "type": "json",
650 "primaryKey": false,
651 "notNull": false
652 },
653 "health_check": {
654 "name": "health_check",
655 "type": "json",
656 "primaryKey": false,
657 "notNull": false
658 },
659 "config_revised_at": {
660 "name": "config_revised_at",
661 "type": "bigint",
662 "primaryKey": false,
663 "notNull": false
664 },
665 "can_remove": {
666 "name": "can_remove",
667 "type": "boolean",
668 "primaryKey": false,
669 "notNull": true,
670 "default": true
671 },
672 "can_refresh": {
673 "name": "can_refresh",
674 "type": "boolean",
675 "primaryKey": false,
676 "notNull": true,
677 "default": false
678 },
679 "created_at": {
680 "name": "created_at",
681 "type": "timestamp",
682 "primaryKey": false,
683 "notNull": true
684 },
685 "updated_at": {
686 "name": "updated_at",
687 "type": "timestamp",
688 "primaryKey": false,
689 "notNull": true
690 },
691 "row_id": {
692 "name": "row_id",
693 "type": "varchar(255)",
694 "primaryKey": true,
695 "notNull": true
696 },
697 "tenant": {
698 "name": "tenant",
699 "type": "varchar(255)",
700 "primaryKey": false,
701 "notNull": true
702 }
703 },
704 "indexes": {
705 "integration_uidx": {
706 "name": "integration_uidx",
707 "columns": [
708 {
709 "expression": "tenant",
710 "isExpression": false,
711 "asc": true,
712 "nulls": "last"
713 },
714 {
715 "expression": "slug",
716 "isExpression": false,
717 "asc": true,
718 "nulls": "last"
719 }
720 ],
721 "isUnique": true,
722 "concurrently": false,
723 "method": "btree",
724 "with": {}
725 }
726 },
727 "foreignKeys": {},
728 "compositePrimaryKeys": {},
729 "uniqueConstraints": {},
730 "policies": {},
731 "checkConstraints": {},
732 "isRLSEnabled": false
733 },
734 "public.oauth_client": {
735 "name": "oauth_client",
736 "schema": "",
737 "columns": {
738 "slug": {
739 "name": "slug",
740 "type": "varchar(255)",
741 "primaryKey": false,
742 "notNull": true
743 },
744 "authorization_url": {
745 "name": "authorization_url",
746 "type": "text",
747 "primaryKey": false,
748 "notNull": true
749 },
750 "token_url": {
751 "name": "token_url",
752 "type": "text",
753 "primaryKey": false,
754 "notNull": true
755 },
756 "grant": {
757 "name": "grant",
758 "type": "text",
759 "primaryKey": false,
760 "notNull": true
761 },
762 "client_id": {
763 "name": "client_id",
764 "type": "text",
765 "primaryKey": false,
766 "notNull": true
767 },
768 "client_secret_item_id": {
769 "name": "client_secret_item_id",
770 "type": "text",
771 "primaryKey": false,
772 "notNull": false
773 },
774 "resource": {
775 "name": "resource",
776 "type": "text",
777 "primaryKey": false,
778 "notNull": false
779 },
780 "origin_kind": {
781 "name": "origin_kind",
782 "type": "text",
783 "primaryKey": false,
784 "notNull": false
785 },
786 "origin_integration": {
787 "name": "origin_integration",
788 "type": "text",
789 "primaryKey": false,
790 "notNull": false
791 },
792 "origin_issuer": {
793 "name": "origin_issuer",
794 "type": "text",
795 "primaryKey": false,
796 "notNull": false
797 },
798 "origin_redirect_uri": {
799 "name": "origin_redirect_uri",
800 "type": "text",
801 "primaryKey": false,
802 "notNull": false
803 },
804 "created_at": {
805 "name": "created_at",
806 "type": "timestamp",
807 "primaryKey": false,
808 "notNull": true
809 },
810 "row_id": {
811 "name": "row_id",
812 "type": "varchar(255)",
813 "primaryKey": true,
814 "notNull": true
815 },
816 "tenant": {
817 "name": "tenant",
818 "type": "varchar(255)",
819 "primaryKey": false,
820 "notNull": true
821 },
822 "owner": {
823 "name": "owner",
824 "type": "varchar(255)",
825 "primaryKey": false,
826 "notNull": true
827 },
828 "subject": {
829 "name": "subject",
830 "type": "varchar(255)",
831 "primaryKey": false,
832 "notNull": true
833 }
834 },
835 "indexes": {
836 "oauth_client_uidx": {
837 "name": "oauth_client_uidx",
838 "columns": [
839 {
840 "expression": "tenant",
841 "isExpression": false,
842 "asc": true,
843 "nulls": "last"
844 },
845 {
846 "expression": "owner",
847 "isExpression": false,
848 "asc": true,
849 "nulls": "last"
850 },
851 {
852 "expression": "subject",
853 "isExpression": false,
854 "asc": true,
855 "nulls": "last"
856 },
857 {
858 "expression": "slug",
859 "isExpression": false,
860 "asc": true,
861 "nulls": "last"
862 }
863 ],
864 "isUnique": true,
865 "concurrently": false,
866 "method": "btree",
867 "with": {}
868 }
869 },
870 "foreignKeys": {},
871 "compositePrimaryKeys": {},
872 "uniqueConstraints": {},
873 "policies": {},
874 "checkConstraints": {},
875 "isRLSEnabled": false
876 },
877 "public.oauth_session": {
878 "name": "oauth_session",
879 "schema": "",
880 "columns": {
881 "state": {
882 "name": "state",
883 "type": "varchar(255)",
884 "primaryKey": false,
885 "notNull": true
886 },
887 "client_slug": {
888 "name": "client_slug",
889 "type": "text",
890 "primaryKey": false,
891 "notNull": true
892 },
893 "integration": {
894 "name": "integration",
895 "type": "text",
896 "primaryKey": false,
897 "notNull": true
898 },
899 "name": {
900 "name": "name",
901 "type": "text",
902 "primaryKey": false,
903 "notNull": true
904 },
905 "template": {
906 "name": "template",
907 "type": "text",
908 "primaryKey": false,
909 "notNull": true
910 },
911 "redirect_url": {
912 "name": "redirect_url",
913 "type": "text",
914 "primaryKey": false,
915 "notNull": true
916 },
917 "pkce_verifier": {
918 "name": "pkce_verifier",
919 "type": "text",
920 "primaryKey": false,
921 "notNull": false
922 },
923 "identity_label": {
924 "name": "identity_label",
925 "type": "text",
926 "primaryKey": false,
927 "notNull": false
928 },
929 "payload": {
930 "name": "payload",
931 "type": "json",
932 "primaryKey": false,
933 "notNull": true
934 },
935 "expires_at": {
936 "name": "expires_at",
937 "type": "bigint",
938 "primaryKey": false,
939 "notNull": true
940 },
941 "created_at": {
942 "name": "created_at",
943 "type": "timestamp",
944 "primaryKey": false,
945 "notNull": true
946 },
947 "row_id": {
948 "name": "row_id",
949 "type": "varchar(255)",
950 "primaryKey": true,
951 "notNull": true
952 },
953 "tenant": {
954 "name": "tenant",
955 "type": "varchar(255)",
956 "primaryKey": false,
957 "notNull": true
958 },
959 "owner": {
960 "name": "owner",
961 "type": "varchar(255)",
962 "primaryKey": false,
963 "notNull": true
964 },
965 "subject": {
966 "name": "subject",
967 "type": "varchar(255)",
968 "primaryKey": false,
969 "notNull": true
970 }
971 },
972 "indexes": {
973 "oauth_session_uidx": {
974 "name": "oauth_session_uidx",
975 "columns": [
976 {
977 "expression": "tenant",
978 "isExpression": false,
979 "asc": true,
980 "nulls": "last"
981 },
982 {
983 "expression": "state",
984 "isExpression": false,
985 "asc": true,
986 "nulls": "last"
987 }
988 ],
989 "isUnique": true,
990 "concurrently": false,
991 "method": "btree",
992 "with": {}
993 }
994 },
995 "foreignKeys": {},
996 "compositePrimaryKeys": {},
997 "uniqueConstraints": {},
998 "policies": {},
999 "checkConstraints": {},
1000 "isRLSEnabled": false
1001 },
1002 "public.plugin_storage": {
1003 "name": "plugin_storage",
1004 "schema": "",
1005 "columns": {
1006 "plugin_id": {
1007 "name": "plugin_id",
1008 "type": "varchar(255)",
1009 "primaryKey": false,
1010 "notNull": true
1011 },
1012 "collection": {
1013 "name": "collection",
1014 "type": "varchar(255)",
1015 "primaryKey": false,
1016 "notNull": true
1017 },
1018 "key": {
1019 "name": "key",
1020 "type": "varchar(255)",
1021 "primaryKey": false,
1022 "notNull": true
1023 },
1024 "data": {
1025 "name": "data",
1026 "type": "json",
1027 "primaryKey": false,
1028 "notNull": true
1029 },
1030 "created_at": {
1031 "name": "created_at",
1032 "type": "timestamp",
1033 "primaryKey": false,
1034 "notNull": true
1035 },
1036 "updated_at": {
1037 "name": "updated_at",
1038 "type": "timestamp",
1039 "primaryKey": false,
1040 "notNull": true
1041 },
1042 "row_id": {
1043 "name": "row_id",
1044 "type": "varchar(255)",
1045 "primaryKey": true,
1046 "notNull": true
1047 },
1048 "tenant": {
1049 "name": "tenant",
1050 "type": "varchar(255)",
1051 "primaryKey": false,
1052 "notNull": true
1053 },
1054 "owner": {
1055 "name": "owner",
1056 "type": "varchar(255)",
1057 "primaryKey": false,
1058 "notNull": true
1059 },
1060 "subject": {
1061 "name": "subject",
1062 "type": "varchar(255)",
1063 "primaryKey": false,
1064 "notNull": true
1065 }
1066 },
1067 "indexes": {
1068 "plugin_storage_uidx": {
1069 "name": "plugin_storage_uidx",
1070 "columns": [
1071 {
1072 "expression": "tenant",
1073 "isExpression": false,
1074 "asc": true,
1075 "nulls": "last"
1076 },
1077 {
1078 "expression": "owner",
1079 "isExpression": false,
1080 "asc": true,
1081 "nulls": "last"
1082 },
1083 {
1084 "expression": "subject",
1085 "isExpression": false,
1086 "asc": true,
1087 "nulls": "last"
1088 },
1089 {
1090 "expression": "plugin_id",
1091 "isExpression": false,
1092 "asc": true,
1093 "nulls": "last"
1094 },
1095 {
1096 "expression": "collection",
1097 "isExpression": false,
1098 "asc": true,
1099 "nulls": "last"
1100 },
1101 {
1102 "expression": "key",
1103 "isExpression": false,
1104 "asc": true,
1105 "nulls": "last"
1106 }
1107 ],
1108 "isUnique": true,
1109 "concurrently": false,
1110 "method": "btree",
1111 "with": {}
1112 }
1113 },
1114 "foreignKeys": {},
1115 "compositePrimaryKeys": {},
1116 "uniqueConstraints": {},
1117 "policies": {},
1118 "checkConstraints": {},
1119 "isRLSEnabled": false
1120 },
1121 "public.private_executor_cloud_settings": {
1122 "name": "private_executor_cloud_settings",
1123 "schema": "",
1124 "columns": {
1125 "id": {
1126 "name": "id",
1127 "type": "varchar(255)",
1128 "primaryKey": true,
1129 "notNull": true
1130 },
1131 "version": {
1132 "name": "version",
1133 "type": "varchar(255)",
1134 "primaryKey": false,
1135 "notNull": true,
1136 "default": "'1.0.0'"
1137 }
1138 },
1139 "indexes": {},
1140 "foreignKeys": {},
1141 "compositePrimaryKeys": {},
1142 "uniqueConstraints": {},
1143 "policies": {},
1144 "checkConstraints": {},
1145 "isRLSEnabled": false
1146 },
1147 "public.subject": {
1148 "name": "subject",
1149 "schema": "",
1150 "columns": {
1151 "external_id": {
1152 "name": "external_id",
1153 "type": "varchar(255)",
1154 "primaryKey": false,
1155 "notNull": true
1156 },
1157 "created_at": {
1158 "name": "created_at",
1159 "type": "timestamp",
1160 "primaryKey": false,
1161 "notNull": true
1162 },
1163 "last_seen_at": {
1164 "name": "last_seen_at",
1165 "type": "bigint",
1166 "primaryKey": false,
1167 "notNull": false
1168 },
1169 "status": {
1170 "name": "status",
1171 "type": "text",
1172 "primaryKey": false,
1173 "notNull": false
1174 },
1175 "row_id": {
1176 "name": "row_id",
1177 "type": "varchar(255)",
1178 "primaryKey": true,
1179 "notNull": true
1180 },
1181 "tenant": {
1182 "name": "tenant",
1183 "type": "varchar(255)",
1184 "primaryKey": false,
1185 "notNull": true
1186 }
1187 },
1188 "indexes": {
1189 "subject_uidx": {
1190 "name": "subject_uidx",
1191 "columns": [
1192 {
1193 "expression": "tenant",
1194 "isExpression": false,
1195 "asc": true,
1196 "nulls": "last"
1197 },
1198 {
1199 "expression": "external_id",
1200 "isExpression": false,
1201 "asc": true,
1202 "nulls": "last"
1203 }
1204 ],
1205 "isUnique": true,
1206 "concurrently": false,
1207 "method": "btree",
1208 "with": {}
1209 }
1210 },
1211 "foreignKeys": {},
1212 "compositePrimaryKeys": {},
1213 "uniqueConstraints": {},
1214 "policies": {},
1215 "checkConstraints": {},
1216 "isRLSEnabled": false
1217 },
1218 "public.tool": {
1219 "name": "tool",
1220 "schema": "",
1221 "columns": {
1222 "integration": {
1223 "name": "integration",
1224 "type": "varchar(255)",
1225 "primaryKey": false,
1226 "notNull": true
1227 },
1228 "connection": {
1229 "name": "connection",
1230 "type": "varchar(255)",
1231 "primaryKey": false,
1232 "notNull": true
1233 },
1234 "plugin_id": {
1235 "name": "plugin_id",
1236 "type": "text",
1237 "primaryKey": false,
1238 "notNull": true
1239 },
1240 "name": {
1241 "name": "name",
1242 "type": "varchar(255)",
1243 "primaryKey": false,
1244 "notNull": true
1245 },
1246 "description": {
1247 "name": "description",
1248 "type": "text",
1249 "primaryKey": false,
1250 "notNull": true
1251 },
1252 "input_schema": {
1253 "name": "input_schema",
1254 "type": "json",
1255 "primaryKey": false,
1256 "notNull": false
1257 },
1258 "output_schema": {
1259 "name": "output_schema",
1260 "type": "json",
1261 "primaryKey": false,
1262 "notNull": false
1263 },
1264 "annotations": {
1265 "name": "annotations",
1266 "type": "json",
1267 "primaryKey": false,
1268 "notNull": false
1269 },
1270 "created_at": {
1271 "name": "created_at",
1272 "type": "timestamp",
1273 "primaryKey": false,
1274 "notNull": true
1275 },
1276 "updated_at": {
1277 "name": "updated_at",
1278 "type": "timestamp",
1279 "primaryKey": false,
1280 "notNull": true
1281 },
1282 "row_id": {
1283 "name": "row_id",
1284 "type": "varchar(255)",
1285 "primaryKey": true,
1286 "notNull": true
1287 },
1288 "tenant": {
1289 "name": "tenant",
1290 "type": "varchar(255)",
1291 "primaryKey": false,
1292 "notNull": true
1293 },
1294 "owner": {
1295 "name": "owner",
1296 "type": "varchar(255)",
1297 "primaryKey": false,
1298 "notNull": true
1299 },
1300 "subject": {
1301 "name": "subject",
1302 "type": "varchar(255)",
1303 "primaryKey": false,
1304 "notNull": true
1305 }
1306 },
1307 "indexes": {
1308 "tool_uidx": {
1309 "name": "tool_uidx",
1310 "columns": [
1311 {
1312 "expression": "tenant",
1313 "isExpression": false,
1314 "asc": true,
1315 "nulls": "last"
1316 },
1317 {
1318 "expression": "owner",
1319 "isExpression": false,
1320 "asc": true,
1321 "nulls": "last"
1322 },
1323 {
1324 "expression": "subject",
1325 "isExpression": false,
1326 "asc": true,
1327 "nulls": "last"
1328 },
1329 {
1330 "expression": "integration",
1331 "isExpression": false,
1332 "asc": true,
1333 "nulls": "last"
1334 },
1335 {
1336 "expression": "connection",
1337 "isExpression": false,
1338 "asc": true,
1339 "nulls": "last"
1340 },
1341 {
1342 "expression": "name",
1343 "isExpression": false,
1344 "asc": true,
1345 "nulls": "last"
1346 }
1347 ],
1348 "isUnique": true,
1349 "concurrently": false,
1350 "method": "btree",
1351 "with": {}
1352 }
1353 },
1354 "foreignKeys": {},
1355 "compositePrimaryKeys": {},
1356 "uniqueConstraints": {},
1357 "policies": {},
1358 "checkConstraints": {},
1359 "isRLSEnabled": false
1360 },
1361 "public.tool_policy": {
1362 "name": "tool_policy",
1363 "schema": "",
1364 "columns": {
1365 "id": {
1366 "name": "id",
1367 "type": "varchar(255)",
1368 "primaryKey": false,
1369 "notNull": true
1370 },
1371 "pattern": {
1372 "name": "pattern",
1373 "type": "text",
1374 "primaryKey": false,
1375 "notNull": true
1376 },
1377 "action": {
1378 "name": "action",
1379 "type": "text",
1380 "primaryKey": false,
1381 "notNull": true
1382 },
1383 "position": {
1384 "name": "position",
1385 "type": "text",
1386 "primaryKey": false,
1387 "notNull": true
1388 },
1389 "created_at": {
1390 "name": "created_at",
1391 "type": "timestamp",
1392 "primaryKey": false,
1393 "notNull": true
1394 },
1395 "updated_at": {
1396 "name": "updated_at",
1397 "type": "timestamp",
1398 "primaryKey": false,
1399 "notNull": true
1400 },
1401 "row_id": {
1402 "name": "row_id",
1403 "type": "varchar(255)",
1404 "primaryKey": true,
1405 "notNull": true
1406 },
1407 "tenant": {
1408 "name": "tenant",
1409 "type": "varchar(255)",
1410 "primaryKey": false,
1411 "notNull": true
1412 },
1413 "owner": {
1414 "name": "owner",
1415 "type": "varchar(255)",
1416 "primaryKey": false,
1417 "notNull": true
1418 },
1419 "subject": {
1420 "name": "subject",
1421 "type": "varchar(255)",
1422 "primaryKey": false,
1423 "notNull": true
1424 }
1425 },
1426 "indexes": {
1427 "tool_policy_uidx": {
1428 "name": "tool_policy_uidx",
1429 "columns": [
1430 {
1431 "expression": "tenant",
1432 "isExpression": false,
1433 "asc": true,
1434 "nulls": "last"
1435 },
1436 {
1437 "expression": "owner",
1438 "isExpression": false,
1439 "asc": true,
1440 "nulls": "last"
1441 },
1442 {
1443 "expression": "subject",
1444 "isExpression": false,
1445 "asc": true,
1446 "nulls": "last"
1447 },
1448 {
1449 "expression": "id",
1450 "isExpression": false,
1451 "asc": true,
1452 "nulls": "last"
1453 }
1454 ],
1455 "isUnique": true,
1456 "concurrently": false,
1457 "method": "btree",
1458 "with": {}
1459 }
1460 },
1461 "foreignKeys": {},
1462 "compositePrimaryKeys": {},
1463 "uniqueConstraints": {},
1464 "policies": {},
1465 "checkConstraints": {},
1466 "isRLSEnabled": false
1467 }
1468 },
1469 "enums": {},
1470 "schemas": {},
1471 "sequences": {},
1472 "roles": {},
1473 "policies": {},
1474 "views": {},
1475 "_meta": {
1476 "columns": {},
1477 "schemas": {},
1478 "tables": {}
1479 }
1480}
apps/cloud/drizzle/meta/0015_snapshot.json+1486−0
@@ -0,0 +1,1486 @@
1{
2 "id": "d666b31a-c3d1-4bd7-9bd6-85f2abc4fb55",
3 "prevId": "0857c83f-0124-4739-bd88-a0a4a92aab84",
4 "version": "7",
5 "dialect": "postgresql",
6 "tables": {
7 "public.accounts": {
8 "name": "accounts",
9 "schema": "",
10 "columns": {
11 "id": {
12 "name": "id",
13 "type": "text",
14 "primaryKey": true,
15 "notNull": true
16 },
17 "created_at": {
18 "name": "created_at",
19 "type": "timestamp with time zone",
20 "primaryKey": false,
21 "notNull": true,
22 "default": "now()"
23 }
24 },
25 "indexes": {},
26 "foreignKeys": {},
27 "compositePrimaryKeys": {},
28 "uniqueConstraints": {},
29 "policies": {},
30 "checkConstraints": {},
31 "isRLSEnabled": false
32 },
33 "public.memberships": {
34 "name": "memberships",
35 "schema": "",
36 "columns": {
37 "account_id": {
38 "name": "account_id",
39 "type": "text",
40 "primaryKey": false,
41 "notNull": true
42 },
43 "organization_id": {
44 "name": "organization_id",
45 "type": "text",
46 "primaryKey": false,
47 "notNull": true
48 },
49 "created_at": {
50 "name": "created_at",
51 "type": "timestamp with time zone",
52 "primaryKey": false,
53 "notNull": true,
54 "default": "now()"
55 }
56 },
57 "indexes": {},
58 "foreignKeys": {
59 "memberships_account_id_accounts_id_fk": {
60 "name": "memberships_account_id_accounts_id_fk",
61 "tableFrom": "memberships",
62 "tableTo": "accounts",
63 "columnsFrom": ["account_id"],
64 "columnsTo": ["id"],
65 "onDelete": "cascade",
66 "onUpdate": "no action"
67 },
68 "memberships_organization_id_organizations_id_fk": {
69 "name": "memberships_organization_id_organizations_id_fk",
70 "tableFrom": "memberships",
71 "tableTo": "organizations",
72 "columnsFrom": ["organization_id"],
73 "columnsTo": ["id"],
74 "onDelete": "cascade",
75 "onUpdate": "no action"
76 }
77 },
78 "compositePrimaryKeys": {
79 "memberships_account_id_organization_id_pk": {
80 "name": "memberships_account_id_organization_id_pk",
81 "columns": ["account_id", "organization_id"]
82 }
83 },
84 "uniqueConstraints": {},
85 "policies": {},
86 "checkConstraints": {},
87 "isRLSEnabled": false
88 },
89 "public.organizations": {
90 "name": "organizations",
91 "schema": "",
92 "columns": {
93 "id": {
94 "name": "id",
95 "type": "text",
96 "primaryKey": true,
97 "notNull": true
98 },
99 "name": {
100 "name": "name",
101 "type": "text",
102 "primaryKey": false,
103 "notNull": true
104 },
105 "slug": {
106 "name": "slug",
107 "type": "text",
108 "primaryKey": false,
109 "notNull": true
110 },
111 "created_at": {
112 "name": "created_at",
113 "type": "timestamp with time zone",
114 "primaryKey": false,
115 "notNull": true,
116 "default": "now()"
117 }
118 },
119 "indexes": {
120 "organizations_slug_unique": {
121 "name": "organizations_slug_unique",
122 "columns": [
123 {
124 "expression": "slug",
125 "isExpression": false,
126 "asc": true,
127 "nulls": "last"
128 }
129 ],
130 "isUnique": true,
131 "concurrently": false,
132 "method": "btree",
133 "with": {}
134 }
135 },
136 "foreignKeys": {},
137 "compositePrimaryKeys": {},
138 "uniqueConstraints": {},
139 "policies": {},
140 "checkConstraints": {},
141 "isRLSEnabled": false
142 },
143 "public.artifact": {
144 "name": "artifact",
145 "schema": "",
146 "columns": {
147 "id": {
148 "name": "id",
149 "type": "varchar(255)",
150 "primaryKey": false,
151 "notNull": true
152 },
153 "title": {
154 "name": "title",
155 "type": "text",
156 "primaryKey": false,
157 "notNull": true
158 },
159 "description": {
160 "name": "description",
161 "type": "text",
162 "primaryKey": false,
163 "notNull": false
164 },
165 "code": {
166 "name": "code",
167 "type": "text",
168 "primaryKey": false,
169 "notNull": true
170 },
171 "bindings": {
172 "name": "bindings",
173 "type": "json",
174 "primaryKey": false,
175 "notNull": false
176 },
177 "preview": {
178 "name": "preview",
179 "type": "text",
180 "primaryKey": false,
181 "notNull": false
182 },
183 "created_at": {
184 "name": "created_at",
185 "type": "timestamp",
186 "primaryKey": false,
187 "notNull": true
188 },
189 "updated_at": {
190 "name": "updated_at",
191 "type": "timestamp",
192 "primaryKey": false,
193 "notNull": true
194 },
195 "row_id": {
196 "name": "row_id",
197 "type": "varchar(255)",
198 "primaryKey": true,
199 "notNull": true
200 },
201 "tenant": {
202 "name": "tenant",
203 "type": "varchar(255)",
204 "primaryKey": false,
205 "notNull": true
206 },
207 "owner": {
208 "name": "owner",
209 "type": "varchar(255)",
210 "primaryKey": false,
211 "notNull": true
212 },
213 "subject": {
214 "name": "subject",
215 "type": "varchar(255)",
216 "primaryKey": false,
217 "notNull": true
218 }
219 },
220 "indexes": {
221 "artifact_uidx": {
222 "name": "artifact_uidx",
223 "columns": [
224 {
225 "expression": "tenant",
226 "isExpression": false,
227 "asc": true,
228 "nulls": "last"
229 },
230 {
231 "expression": "owner",
232 "isExpression": false,
233 "asc": true,
234 "nulls": "last"
235 },
236 {
237 "expression": "subject",
238 "isExpression": false,
239 "asc": true,
240 "nulls": "last"
241 },
242 {
243 "expression": "id",
244 "isExpression": false,
245 "asc": true,
246 "nulls": "last"
247 }
248 ],
249 "isUnique": true,
250 "concurrently": false,
251 "method": "btree",
252 "with": {}
253 }
254 },
255 "foreignKeys": {},
256 "compositePrimaryKeys": {},
257 "uniqueConstraints": {},
258 "policies": {},
259 "checkConstraints": {},
260 "isRLSEnabled": false
261 },
262 "public.blob": {
263 "name": "blob",
264 "schema": "",
265 "columns": {
266 "namespace": {
267 "name": "namespace",
268 "type": "varchar(255)",
269 "primaryKey": false,
270 "notNull": true
271 },
272 "key": {
273 "name": "key",
274 "type": "varchar(255)",
275 "primaryKey": false,
276 "notNull": true
277 },
278 "value": {
279 "name": "value",
280 "type": "text",
281 "primaryKey": false,
282 "notNull": true
283 },
284 "row_id": {
285 "name": "row_id",
286 "type": "varchar(255)",
287 "primaryKey": true,
288 "notNull": true
289 },
290 "id": {
291 "name": "id",
292 "type": "varchar(255)",
293 "primaryKey": false,
294 "notNull": true
295 }
296 },
297 "indexes": {
298 "blob_id_uidx": {
299 "name": "blob_id_uidx",
300 "columns": [
301 {
302 "expression": "id",
303 "isExpression": false,
304 "asc": true,
305 "nulls": "last"
306 }
307 ],
308 "isUnique": true,
309 "concurrently": false,
310 "method": "btree",
311 "with": {}
312 }
313 },
314 "foreignKeys": {},
315 "compositePrimaryKeys": {},
316 "uniqueConstraints": {},
317 "policies": {},
318 "checkConstraints": {},
319 "isRLSEnabled": false
320 },
321 "public.connection": {
322 "name": "connection",
323 "schema": "",
324 "columns": {
325 "integration": {
326 "name": "integration",
327 "type": "varchar(255)",
328 "primaryKey": false,
329 "notNull": true
330 },
331 "name": {
332 "name": "name",
333 "type": "varchar(255)",
334 "primaryKey": false,
335 "notNull": true
336 },
337 "template": {
338 "name": "template",
339 "type": "text",
340 "primaryKey": false,
341 "notNull": true
342 },
343 "provider": {
344 "name": "provider",
345 "type": "text",
346 "primaryKey": false,
347 "notNull": true
348 },
349 "item_ids": {
350 "name": "item_ids",
351 "type": "json",
352 "primaryKey": false,
353 "notNull": true
354 },
355 "identity_label": {
356 "name": "identity_label",
357 "type": "text",
358 "primaryKey": false,
359 "notNull": false
360 },
361 "description": {
362 "name": "description",
363 "type": "text",
364 "primaryKey": false,
365 "notNull": false
366 },
367 "last_health": {
368 "name": "last_health",
369 "type": "json",
370 "primaryKey": false,
371 "notNull": false
372 },
373 "tools_synced_at": {
374 "name": "tools_synced_at",
375 "type": "bigint",
376 "primaryKey": false,
377 "notNull": false
378 },
379 "oauth_client": {
380 "name": "oauth_client",
381 "type": "text",
382 "primaryKey": false,
383 "notNull": false
384 },
385 "oauth_client_owner": {
386 "name": "oauth_client_owner",
387 "type": "text",
388 "primaryKey": false,
389 "notNull": false
390 },
391 "refresh_item_id": {
392 "name": "refresh_item_id",
393 "type": "text",
394 "primaryKey": false,
395 "notNull": false
396 },
397 "expires_at": {
398 "name": "expires_at",
399 "type": "bigint",
400 "primaryKey": false,
401 "notNull": false
402 },
403 "oauth_scope": {
404 "name": "oauth_scope",
405 "type": "text",
406 "primaryKey": false,
407 "notNull": false
408 },
409 "oauth_token_url": {
410 "name": "oauth_token_url",
411 "type": "text",
412 "primaryKey": false,
413 "notNull": false
414 },
415 "provider_state": {
416 "name": "provider_state",
417 "type": "json",
418 "primaryKey": false,
419 "notNull": false
420 },
421 "created_at": {
422 "name": "created_at",
423 "type": "timestamp",
424 "primaryKey": false,
425 "notNull": true
426 },
427 "updated_at": {
428 "name": "updated_at",
429 "type": "timestamp",
430 "primaryKey": false,
431 "notNull": true
432 },
433 "row_id": {
434 "name": "row_id",
435 "type": "varchar(255)",
436 "primaryKey": true,
437 "notNull": true
438 },
439 "tenant": {
440 "name": "tenant",
441 "type": "varchar(255)",
442 "primaryKey": false,
443 "notNull": true
444 },
445 "owner": {
446 "name": "owner",
447 "type": "varchar(255)",
448 "primaryKey": false,
449 "notNull": true
450 },
451 "subject": {
452 "name": "subject",
453 "type": "varchar(255)",
454 "primaryKey": false,
455 "notNull": true
456 }
457 },
458 "indexes": {
459 "connection_uidx": {
460 "name": "connection_uidx",
461 "columns": [
462 {
463 "expression": "tenant",
464 "isExpression": false,
465 "asc": true,
466 "nulls": "last"
467 },
468 {
469 "expression": "owner",
470 "isExpression": false,
471 "asc": true,
472 "nulls": "last"
473 },
474 {
475 "expression": "subject",
476 "isExpression": false,
477 "asc": true,
478 "nulls": "last"
479 },
480 {
481 "expression": "integration",
482 "isExpression": false,
483 "asc": true,
484 "nulls": "last"
485 },
486 {
487 "expression": "name",
488 "isExpression": false,
489 "asc": true,
490 "nulls": "last"
491 }
492 ],
493 "isUnique": true,
494 "concurrently": false,
495 "method": "btree",
496 "with": {}
497 }
498 },
499 "foreignKeys": {},
500 "compositePrimaryKeys": {},
501 "uniqueConstraints": {},
502 "policies": {},
503 "checkConstraints": {},
504 "isRLSEnabled": false
505 },
506 "public.definition": {
507 "name": "definition",
508 "schema": "",
509 "columns": {
510 "integration": {
511 "name": "integration",
512 "type": "varchar(255)",
513 "primaryKey": false,
514 "notNull": true
515 },
516 "connection": {
517 "name": "connection",
518 "type": "varchar(255)",
519 "primaryKey": false,
520 "notNull": true
521 },
522 "plugin_id": {
523 "name": "plugin_id",
524 "type": "text",
525 "primaryKey": false,
526 "notNull": true
527 },
528 "name": {
529 "name": "name",
530 "type": "text",
531 "primaryKey": false,
532 "notNull": true
533 },
534 "schema": {
535 "name": "schema",
536 "type": "json",
537 "primaryKey": false,
538 "notNull": true
539 },
540 "created_at": {
541 "name": "created_at",
542 "type": "timestamp",
543 "primaryKey": false,
544 "notNull": true
545 },
546 "row_id": {
547 "name": "row_id",
548 "type": "varchar(255)",
549 "primaryKey": true,
550 "notNull": true
551 },
552 "tenant": {
553 "name": "tenant",
554 "type": "varchar(255)",
555 "primaryKey": false,
556 "notNull": true
557 },
558 "owner": {
559 "name": "owner",
560 "type": "varchar(255)",
561 "primaryKey": false,
562 "notNull": true
563 },
564 "subject": {
565 "name": "subject",
566 "type": "varchar(255)",
567 "primaryKey": false,
568 "notNull": true
569 }
570 },
571 "indexes": {
572 "definition_uidx": {
573 "name": "definition_uidx",
574 "columns": [
575 {
576 "expression": "tenant",
577 "isExpression": false,
578 "asc": true,
579 "nulls": "last"
580 },
581 {
582 "expression": "owner",
583 "isExpression": false,
584 "asc": true,
585 "nulls": "last"
586 },
587 {
588 "expression": "subject",
589 "isExpression": false,
590 "asc": true,
591 "nulls": "last"
592 },
593 {
594 "expression": "integration",
595 "isExpression": false,
596 "asc": true,
597 "nulls": "last"
598 },
599 {
600 "expression": "connection",
601 "isExpression": false,
602 "asc": true,
603 "nulls": "last"
604 },
605 {
606 "expression": "name",
607 "isExpression": false,
608 "asc": true,
609 "nulls": "last"
610 }
611 ],
612 "isUnique": true,
613 "concurrently": false,
614 "method": "btree",
615 "with": {}
616 }
617 },
618 "foreignKeys": {},
619 "compositePrimaryKeys": {},
620 "uniqueConstraints": {},
621 "policies": {},
622 "checkConstraints": {},
623 "isRLSEnabled": false
624 },
625 "public.integration": {
626 "name": "integration",
627 "schema": "",
628 "columns": {
629 "slug": {
630 "name": "slug",
631 "type": "varchar(255)",
632 "primaryKey": false,
633 "notNull": true
634 },
635 "plugin_id": {
636 "name": "plugin_id",
637 "type": "text",
638 "primaryKey": false,
639 "notNull": true
640 },
641 "name": {
642 "name": "name",
643 "type": "text",
644 "primaryKey": false,
645 "notNull": false
646 },
647 "description": {
648 "name": "description",
649 "type": "text",
650 "primaryKey": false,
651 "notNull": false
652 },
653 "config": {
654 "name": "config",
655 "type": "json",
656 "primaryKey": false,
657 "notNull": false
658 },
659 "health_check": {
660 "name": "health_check",
661 "type": "json",
662 "primaryKey": false,
663 "notNull": false
664 },
665 "config_revised_at": {
666 "name": "config_revised_at",
667 "type": "bigint",
668 "primaryKey": false,
669 "notNull": false
670 },
671 "can_remove": {
672 "name": "can_remove",
673 "type": "boolean",
674 "primaryKey": false,
675 "notNull": true,
676 "default": true
677 },
678 "can_refresh": {
679 "name": "can_refresh",
680 "type": "boolean",
681 "primaryKey": false,
682 "notNull": true,
683 "default": false
684 },
685 "created_at": {
686 "name": "created_at",
687 "type": "timestamp",
688 "primaryKey": false,
689 "notNull": true
690 },
691 "updated_at": {
692 "name": "updated_at",
693 "type": "timestamp",
694 "primaryKey": false,
695 "notNull": true
696 },
697 "row_id": {
698 "name": "row_id",
699 "type": "varchar(255)",
700 "primaryKey": true,
701 "notNull": true
702 },
703 "tenant": {
704 "name": "tenant",
705 "type": "varchar(255)",
706 "primaryKey": false,
707 "notNull": true
708 }
709 },
710 "indexes": {
711 "integration_uidx": {
712 "name": "integration_uidx",
713 "columns": [
714 {
715 "expression": "tenant",
716 "isExpression": false,
717 "asc": true,
718 "nulls": "last"
719 },
720 {
721 "expression": "slug",
722 "isExpression": false,
723 "asc": true,
724 "nulls": "last"
725 }
726 ],
727 "isUnique": true,
728 "concurrently": false,
729 "method": "btree",
730 "with": {}
731 }
732 },
733 "foreignKeys": {},
734 "compositePrimaryKeys": {},
735 "uniqueConstraints": {},
736 "policies": {},
737 "checkConstraints": {},
738 "isRLSEnabled": false
739 },
740 "public.oauth_client": {
741 "name": "oauth_client",
742 "schema": "",
743 "columns": {
744 "slug": {
745 "name": "slug",
746 "type": "varchar(255)",
747 "primaryKey": false,
748 "notNull": true
749 },
750 "authorization_url": {
751 "name": "authorization_url",
752 "type": "text",
753 "primaryKey": false,
754 "notNull": true
755 },
756 "token_url": {
757 "name": "token_url",
758 "type": "text",
759 "primaryKey": false,
760 "notNull": true
761 },
762 "grant": {
763 "name": "grant",
764 "type": "text",
765 "primaryKey": false,
766 "notNull": true
767 },
768 "client_id": {
769 "name": "client_id",
770 "type": "text",
771 "primaryKey": false,
772 "notNull": true
773 },
774 "client_secret_item_id": {
775 "name": "client_secret_item_id",
776 "type": "text",
777 "primaryKey": false,
778 "notNull": false
779 },
780 "resource": {
781 "name": "resource",
782 "type": "text",
783 "primaryKey": false,
784 "notNull": false
785 },
786 "origin_kind": {
787 "name": "origin_kind",
788 "type": "text",
789 "primaryKey": false,
790 "notNull": false
791 },
792 "origin_integration": {
793 "name": "origin_integration",
794 "type": "text",
795 "primaryKey": false,
796 "notNull": false
797 },
798 "origin_issuer": {
799 "name": "origin_issuer",
800 "type": "text",
801 "primaryKey": false,
802 "notNull": false
803 },
804 "origin_redirect_uri": {
805 "name": "origin_redirect_uri",
806 "type": "text",
807 "primaryKey": false,
808 "notNull": false
809 },
810 "created_at": {
811 "name": "created_at",
812 "type": "timestamp",
813 "primaryKey": false,
814 "notNull": true
815 },
816 "row_id": {
817 "name": "row_id",
818 "type": "varchar(255)",
819 "primaryKey": true,
820 "notNull": true
821 },
822 "tenant": {
823 "name": "tenant",
824 "type": "varchar(255)",
825 "primaryKey": false,
826 "notNull": true
827 },
828 "owner": {
829 "name": "owner",
830 "type": "varchar(255)",
831 "primaryKey": false,
832 "notNull": true
833 },
834 "subject": {
835 "name": "subject",
836 "type": "varchar(255)",
837 "primaryKey": false,
838 "notNull": true
839 }
840 },
841 "indexes": {
842 "oauth_client_uidx": {
843 "name": "oauth_client_uidx",
844 "columns": [
845 {
846 "expression": "tenant",
847 "isExpression": false,
848 "asc": true,
849 "nulls": "last"
850 },
851 {
852 "expression": "owner",
853 "isExpression": false,
854 "asc": true,
855 "nulls": "last"
856 },
857 {
858 "expression": "subject",
859 "isExpression": false,
860 "asc": true,
861 "nulls": "last"
862 },
863 {
864 "expression": "slug",
865 "isExpression": false,
866 "asc": true,
867 "nulls": "last"
868 }
869 ],
870 "isUnique": true,
871 "concurrently": false,
872 "method": "btree",
873 "with": {}
874 }
875 },
876 "foreignKeys": {},
877 "compositePrimaryKeys": {},
878 "uniqueConstraints": {},
879 "policies": {},
880 "checkConstraints": {},
881 "isRLSEnabled": false
882 },
883 "public.oauth_session": {
884 "name": "oauth_session",
885 "schema": "",
886 "columns": {
887 "state": {
888 "name": "state",
889 "type": "varchar(255)",
890 "primaryKey": false,
891 "notNull": true
892 },
893 "client_slug": {
894 "name": "client_slug",
895 "type": "text",
896 "primaryKey": false,
897 "notNull": true
898 },
899 "integration": {
900 "name": "integration",
901 "type": "text",
902 "primaryKey": false,
903 "notNull": true
904 },
905 "name": {
906 "name": "name",
907 "type": "text",
908 "primaryKey": false,
909 "notNull": true
910 },
911 "template": {
912 "name": "template",
913 "type": "text",
914 "primaryKey": false,
915 "notNull": true
916 },
917 "redirect_url": {
918 "name": "redirect_url",
919 "type": "text",
920 "primaryKey": false,
921 "notNull": true
922 },
923 "pkce_verifier": {
924 "name": "pkce_verifier",
925 "type": "text",
926 "primaryKey": false,
927 "notNull": false
928 },
929 "identity_label": {
930 "name": "identity_label",
931 "type": "text",
932 "primaryKey": false,
933 "notNull": false
934 },
935 "payload": {
936 "name": "payload",
937 "type": "json",
938 "primaryKey": false,
939 "notNull": true
940 },
941 "expires_at": {
942 "name": "expires_at",
943 "type": "bigint",
944 "primaryKey": false,
945 "notNull": true
946 },
947 "created_at": {
948 "name": "created_at",
949 "type": "timestamp",
950 "primaryKey": false,
951 "notNull": true
952 },
953 "row_id": {
954 "name": "row_id",
955 "type": "varchar(255)",
956 "primaryKey": true,
957 "notNull": true
958 },
959 "tenant": {
960 "name": "tenant",
961 "type": "varchar(255)",
962 "primaryKey": false,
963 "notNull": true
964 },
965 "owner": {
966 "name": "owner",
967 "type": "varchar(255)",
968 "primaryKey": false,
969 "notNull": true
970 },
971 "subject": {
972 "name": "subject",
973 "type": "varchar(255)",
974 "primaryKey": false,
975 "notNull": true
976 }
977 },
978 "indexes": {
979 "oauth_session_uidx": {
980 "name": "oauth_session_uidx",
981 "columns": [
982 {
983 "expression": "tenant",
984 "isExpression": false,
985 "asc": true,
986 "nulls": "last"
987 },
988 {
989 "expression": "state",
990 "isExpression": false,
991 "asc": true,
992 "nulls": "last"
993 }
994 ],
995 "isUnique": true,
996 "concurrently": false,
997 "method": "btree",
998 "with": {}
999 }
1000 },
1001 "foreignKeys": {},
1002 "compositePrimaryKeys": {},
1003 "uniqueConstraints": {},
1004 "policies": {},
1005 "checkConstraints": {},
1006 "isRLSEnabled": false
1007 },
1008 "public.plugin_storage": {
1009 "name": "plugin_storage",
1010 "schema": "",
1011 "columns": {
1012 "plugin_id": {
1013 "name": "plugin_id",
1014 "type": "varchar(255)",
1015 "primaryKey": false,
1016 "notNull": true
1017 },
1018 "collection": {
1019 "name": "collection",
1020 "type": "varchar(255)",
1021 "primaryKey": false,
1022 "notNull": true
1023 },
1024 "key": {
1025 "name": "key",
1026 "type": "varchar(255)",
1027 "primaryKey": false,
1028 "notNull": true
1029 },
1030 "data": {
1031 "name": "data",
1032 "type": "json",
1033 "primaryKey": false,
1034 "notNull": true
1035 },
1036 "created_at": {
1037 "name": "created_at",
1038 "type": "timestamp",
1039 "primaryKey": false,
1040 "notNull": true
1041 },
1042 "updated_at": {
1043 "name": "updated_at",
1044 "type": "timestamp",
1045 "primaryKey": false,
1046 "notNull": true
1047 },
1048 "row_id": {
1049 "name": "row_id",
1050 "type": "varchar(255)",
1051 "primaryKey": true,
1052 "notNull": true
1053 },
1054 "tenant": {
1055 "name": "tenant",
1056 "type": "varchar(255)",
1057 "primaryKey": false,
1058 "notNull": true
1059 },
1060 "owner": {
1061 "name": "owner",
1062 "type": "varchar(255)",
1063 "primaryKey": false,
1064 "notNull": true
1065 },
1066 "subject": {
1067 "name": "subject",
1068 "type": "varchar(255)",
1069 "primaryKey": false,
1070 "notNull": true
1071 }
1072 },
1073 "indexes": {
1074 "plugin_storage_uidx": {
1075 "name": "plugin_storage_uidx",
1076 "columns": [
1077 {
1078 "expression": "tenant",
1079 "isExpression": false,
1080 "asc": true,
1081 "nulls": "last"
1082 },
1083 {
1084 "expression": "owner",
1085 "isExpression": false,
1086 "asc": true,
1087 "nulls": "last"
1088 },
1089 {
1090 "expression": "subject",
1091 "isExpression": false,
1092 "asc": true,
1093 "nulls": "last"
1094 },
1095 {
1096 "expression": "plugin_id",
1097 "isExpression": false,
1098 "asc": true,
1099 "nulls": "last"
1100 },
1101 {
1102 "expression": "collection",
1103 "isExpression": false,
1104 "asc": true,
1105 "nulls": "last"
1106 },
1107 {
1108 "expression": "key",
1109 "isExpression": false,
1110 "asc": true,
1111 "nulls": "last"
1112 }
1113 ],
1114 "isUnique": true,
1115 "concurrently": false,
1116 "method": "btree",
1117 "with": {}
1118 }
1119 },
1120 "foreignKeys": {},
1121 "compositePrimaryKeys": {},
1122 "uniqueConstraints": {},
1123 "policies": {},
1124 "checkConstraints": {},
1125 "isRLSEnabled": false
1126 },
1127 "public.private_executor_cloud_settings": {
1128 "name": "private_executor_cloud_settings",
1129 "schema": "",
1130 "columns": {
1131 "id": {
1132 "name": "id",
1133 "type": "varchar(255)",
1134 "primaryKey": true,
1135 "notNull": true
1136 },
1137 "version": {
1138 "name": "version",
1139 "type": "varchar(255)",
1140 "primaryKey": false,
1141 "notNull": true,
1142 "default": "'1.0.0'"
1143 }
1144 },
1145 "indexes": {},
1146 "foreignKeys": {},
1147 "compositePrimaryKeys": {},
1148 "uniqueConstraints": {},
1149 "policies": {},
1150 "checkConstraints": {},
1151 "isRLSEnabled": false
1152 },
1153 "public.subject": {
1154 "name": "subject",
1155 "schema": "",
1156 "columns": {
1157 "external_id": {
1158 "name": "external_id",
1159 "type": "varchar(255)",
1160 "primaryKey": false,
1161 "notNull": true
1162 },
1163 "created_at": {
1164 "name": "created_at",
1165 "type": "timestamp",
1166 "primaryKey": false,
1167 "notNull": true
1168 },
1169 "last_seen_at": {
1170 "name": "last_seen_at",
1171 "type": "bigint",
1172 "primaryKey": false,
1173 "notNull": false
1174 },
1175 "status": {
1176 "name": "status",
1177 "type": "text",
1178 "primaryKey": false,
1179 "notNull": false
1180 },
1181 "row_id": {
1182 "name": "row_id",
1183 "type": "varchar(255)",
1184 "primaryKey": true,
1185 "notNull": true
1186 },
1187 "tenant": {
1188 "name": "tenant",
1189 "type": "varchar(255)",
1190 "primaryKey": false,
1191 "notNull": true
1192 }
1193 },
1194 "indexes": {
1195 "subject_uidx": {
1196 "name": "subject_uidx",
1197 "columns": [
1198 {
1199 "expression": "tenant",
1200 "isExpression": false,
1201 "asc": true,
1202 "nulls": "last"
1203 },
1204 {
1205 "expression": "external_id",
1206 "isExpression": false,
1207 "asc": true,
1208 "nulls": "last"
1209 }
1210 ],
1211 "isUnique": true,
1212 "concurrently": false,
1213 "method": "btree",
1214 "with": {}
1215 }
1216 },
1217 "foreignKeys": {},
1218 "compositePrimaryKeys": {},
1219 "uniqueConstraints": {},
1220 "policies": {},
1221 "checkConstraints": {},
1222 "isRLSEnabled": false
1223 },
1224 "public.tool": {
1225 "name": "tool",
1226 "schema": "",
1227 "columns": {
1228 "integration": {
1229 "name": "integration",
1230 "type": "varchar(255)",
1231 "primaryKey": false,
1232 "notNull": true
1233 },
1234 "connection": {
1235 "name": "connection",
1236 "type": "varchar(255)",
1237 "primaryKey": false,
1238 "notNull": true
1239 },
1240 "plugin_id": {
1241 "name": "plugin_id",
1242 "type": "text",
1243 "primaryKey": false,
1244 "notNull": true
1245 },
1246 "name": {
1247 "name": "name",
1248 "type": "varchar(255)",
1249 "primaryKey": false,
1250 "notNull": true
1251 },
1252 "description": {
1253 "name": "description",
1254 "type": "text",
1255 "primaryKey": false,
1256 "notNull": true
1257 },
1258 "input_schema": {
1259 "name": "input_schema",
1260 "type": "json",
1261 "primaryKey": false,
1262 "notNull": false
1263 },
1264 "output_schema": {
1265 "name": "output_schema",
1266 "type": "json",
1267 "primaryKey": false,
1268 "notNull": false
1269 },
1270 "annotations": {
1271 "name": "annotations",
1272 "type": "json",
1273 "primaryKey": false,
1274 "notNull": false
1275 },
1276 "created_at": {
1277 "name": "created_at",
1278 "type": "timestamp",
1279 "primaryKey": false,
1280 "notNull": true
1281 },
1282 "updated_at": {
1283 "name": "updated_at",
1284 "type": "timestamp",
1285 "primaryKey": false,
1286 "notNull": true
1287 },
1288 "row_id": {
1289 "name": "row_id",
1290 "type": "varchar(255)",
1291 "primaryKey": true,
1292 "notNull": true
1293 },
1294 "tenant": {
1295 "name": "tenant",
1296 "type": "varchar(255)",
1297 "primaryKey": false,
1298 "notNull": true
1299 },
1300 "owner": {
1301 "name": "owner",
1302 "type": "varchar(255)",
1303 "primaryKey": false,
1304 "notNull": true
1305 },
1306 "subject": {
1307 "name": "subject",
1308 "type": "varchar(255)",
1309 "primaryKey": false,
1310 "notNull": true
1311 }
1312 },
1313 "indexes": {
1314 "tool_uidx": {
1315 "name": "tool_uidx",
1316 "columns": [
1317 {
1318 "expression": "tenant",
1319 "isExpression": false,
1320 "asc": true,
1321 "nulls": "last"
1322 },
1323 {
1324 "expression": "owner",
1325 "isExpression": false,
1326 "asc": true,
1327 "nulls": "last"
1328 },
1329 {
1330 "expression": "subject",
1331 "isExpression": false,
1332 "asc": true,
1333 "nulls": "last"
1334 },
1335 {
1336 "expression": "integration",
1337 "isExpression": false,
1338 "asc": true,
1339 "nulls": "last"
1340 },
1341 {
1342 "expression": "connection",
1343 "isExpression": false,
1344 "asc": true,
1345 "nulls": "last"
1346 },
1347 {
1348 "expression": "name",
1349 "isExpression": false,
1350 "asc": true,
1351 "nulls": "last"
1352 }
1353 ],
1354 "isUnique": true,
1355 "concurrently": false,
1356 "method": "btree",
1357 "with": {}
1358 }
1359 },
1360 "foreignKeys": {},
1361 "compositePrimaryKeys": {},
1362 "uniqueConstraints": {},
1363 "policies": {},
1364 "checkConstraints": {},
1365 "isRLSEnabled": false
1366 },
1367 "public.tool_policy": {
1368 "name": "tool_policy",
1369 "schema": "",
1370 "columns": {
1371 "id": {
1372 "name": "id",
1373 "type": "varchar(255)",
1374 "primaryKey": false,
1375 "notNull": true
1376 },
1377 "pattern": {
1378 "name": "pattern",
1379 "type": "text",
1380 "primaryKey": false,
1381 "notNull": true
1382 },
1383 "action": {
1384 "name": "action",
1385 "type": "text",
1386 "primaryKey": false,
1387 "notNull": true
1388 },
1389 "position": {
1390 "name": "position",
1391 "type": "text",
1392 "primaryKey": false,
1393 "notNull": true
1394 },
1395 "created_at": {
1396 "name": "created_at",
1397 "type": "timestamp",
1398 "primaryKey": false,
1399 "notNull": true
1400 },
1401 "updated_at": {
1402 "name": "updated_at",
1403 "type": "timestamp",
1404 "primaryKey": false,
1405 "notNull": true
1406 },
1407 "row_id": {
1408 "name": "row_id",
1409 "type": "varchar(255)",
1410 "primaryKey": true,
1411 "notNull": true
1412 },
1413 "tenant": {
1414 "name": "tenant",
1415 "type": "varchar(255)",
1416 "primaryKey": false,
1417 "notNull": true
1418 },
1419 "owner": {
1420 "name": "owner",
1421 "type": "varchar(255)",
1422 "primaryKey": false,
1423 "notNull": true
1424 },
1425 "subject": {
1426 "name": "subject",
1427 "type": "varchar(255)",
1428 "primaryKey": false,
1429 "notNull": true
1430 }
1431 },
1432 "indexes": {
1433 "tool_policy_uidx": {
1434 "name": "tool_policy_uidx",
1435 "columns": [
1436 {
1437 "expression": "tenant",
1438 "isExpression": false,
1439 "asc": true,
1440 "nulls": "last"
1441 },
1442 {
1443 "expression": "owner",
1444 "isExpression": false,
1445 "asc": true,
1446 "nulls": "last"
1447 },
1448 {
1449 "expression": "subject",
1450 "isExpression": false,
1451 "asc": true,
1452 "nulls": "last"
1453 },
1454 {
1455 "expression": "id",
1456 "isExpression": false,
1457 "asc": true,
1458 "nulls": "last"
1459 }
1460 ],
1461 "isUnique": true,
1462 "concurrently": false,
1463 "method": "btree",
1464 "with": {}
1465 }
1466 },
1467 "foreignKeys": {},
1468 "compositePrimaryKeys": {},
1469 "uniqueConstraints": {},
1470 "policies": {},
1471 "checkConstraints": {},
1472 "isRLSEnabled": false
1473 }
1474 },
1475 "enums": {},
1476 "schemas": {},
1477 "sequences": {},
1478 "roles": {},
1479 "policies": {},
1480 "views": {},
1481 "_meta": {
1482 "columns": {},
1483 "schemas": {},
1484 "tables": {}
1485 }
1486}
apps/cloud/drizzle/meta/_journal.json+42−0
@@ -71,6 +71,48 @@ · 70 unmodified lines
71 "when": 1783025157233,71 "when": 1783025157233,
72 "tag": "0009_true_lake",72 "tag": "0009_true_lake",
73 "breakpoints": true73 "breakpoints": true
74 },
75 {
76 "idx": 10,
77 "version": "7",
78 "when": 1783660669921,
79 "tag": "0010_ndjson_output_arrays",
80 "breakpoints": true
81 },
82 {
83 "idx": 11,
84 "version": "7",
85 "when": 1784581232732,
86 "tag": "0011_tense_caretaker",
87 "breakpoints": true
88 },
89 {
90 "idx": 12,
91 "version": "7",
92 "when": 1785193313601,
93 "tag": "0012_woozy_shiva",
94 "breakpoints": true
95 },
96 {
97 "idx": 13,
98 "version": "7",
99 "when": 1785354087138,
100 "tag": "0013_curly_rocket_racer",
101 "breakpoints": true
102 },
103 {
104 "idx": 14,
105 "version": "7",
106 "when": 1785355312973,
107 "tag": "0014_new_karma",
108 "breakpoints": true
109 },
110 {
111 "idx": 15,
112 "version": "7",
113 "when": 1785355354955,
114 "tag": "0015_equal_the_leader",
115 "breakpoints": true
74 }116 }
75 ]117 ]
76}118}
apps/cloud/executor.config.ts+13−15
@@ -1,7 +1,13 @@
1import { defineExecutorConfig } from "@executor-js/sdk";1import { defineExecutorConfig } from "@executor-js/sdk";
2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3import { googleHttpPlugin } from "@executor-js/plugin-google/api";3import {
4import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";4 googleCatalog,
5 googleDiscoveryAdapter,
6} from "@executor-js/plugin-openapi/providers/google";
7import {
8 microsoftCatalog,
9 microsoftGraphAdapter,
10} from "@executor-js/plugin-openapi/providers/microsoft";
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";11import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";12import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";13import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";
@@ -42,23 +48,15 @@ interface CloudPluginDeps { · 34 unmodified lines
42 * falls back to the credential-driven default. */48 * falls back to the credential-driven default. */
43 readonly workosVaultClient?: WorkOSVaultClient;49 readonly workosVaultClient?: WorkOSVaultClient;
44 readonly activeToolkitSlug?: string;50 readonly activeToolkitSlug?: string;
45 /** Mirrors `HostConfig.allowLocalNetwork` (`ALLOW_LOCAL_NETWORK`): lets
46 * `microsoft.addGraph` point at a loopback emulator instead of the pinned
47 * Microsoft Graph URLs. Off by default; production leaves it unset. */
48 readonly allowLocalNetwork?: boolean;
49}51}
50 52
51export default defineExecutorConfig({53export default defineExecutorConfig({
52 plugins: ({54 plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) =>
53 workosCredentials,
54 workosVaultClient,
55 activeToolkitSlug,
56 allowLocalNetwork,
57 }: CloudPluginDeps = {}) =>
58 [55 [
59 openApiHttpPlugin(),56 openApiHttpPlugin({
60 googleHttpPlugin(),57 presets: [...googleCatalog, ...microsoftCatalog],
61 microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }),58 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
59 }),
62 mcpHttpPlugin({60 mcpHttpPlugin({
63 dangerouslyAllowStdioMCP: false,61 dangerouslyAllowStdioMCP: false,
64 }),62 }),
apps/cloud/package.json+9−4
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/cloud",2 "name": "@executor-js/cloud",
3 "version": "1.4.47",3 "version": "1.4.63",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "scripts": {
@@ -30,7 +30,10 @@ · 23 unmodified lines
30 "typecheck:slow": "tsc --noEmit",30 "typecheck:slow": "tsc --noEmit",
31 "db:backfill-org-slugs:prod": "op run --env-file=.env.production -- bun run scripts/backfill-org-slugs.ts",31 "db:backfill-org-slugs:prod": "op run --env-file=.env.production -- bun run scripts/backfill-org-slugs.ts",
32 "db:backfill-org-slugs:dev": "op run --env-file=.env.op -- bun run scripts/backfill-org-slugs.ts",32 "db:backfill-org-slugs:dev": "op run --env-file=.env.op -- bun run scripts/backfill-org-slugs.ts",
33 "routes:gen": "bun scripts/gen-routes.ts"33 "db:backfill-subjects:prod": "op run --env-file=.env.production -- bun run scripts/backfill-subjects.ts",
34 "db:backfill-subjects:dev": "op run --env-file=.env.op -- bun run scripts/backfill-subjects.ts",
35 "routes:gen": "bun scripts/gen-routes.ts",
36 "vendor-wasm": "bun run scripts/vendor-quickjs-wasm.ts"
34 },37 },
35 "dependencies": {38 "dependencies": {
36 "@cloudflare/vite-plugin": "^1.31.1",39 "@cloudflare/vite-plugin": "^1.31.1",
@@ -41,10 +44,9 @@ · 4 unmodified lines
41 "@executor-js/execution": "workspace:*",44 "@executor-js/execution": "workspace:*",
42 "@executor-js/fumadb": "workspace:*",45 "@executor-js/fumadb": "workspace:*",
43 "@executor-js/host-mcp": "workspace:*",46 "@executor-js/host-mcp": "workspace:*",
44 "@executor-js/plugin-google": "workspace:*",47 "@executor-js/mcp-apps-shell": "workspace:*",
45 "@executor-js/plugin-graphql": "workspace:*",48 "@executor-js/plugin-graphql": "workspace:*",
46 "@executor-js/plugin-mcp": "workspace:*",49 "@executor-js/plugin-mcp": "workspace:*",
47 "@executor-js/plugin-microsoft": "workspace:*",
48 "@executor-js/plugin-openapi": "workspace:*",50 "@executor-js/plugin-openapi": "workspace:*",
49 "@executor-js/plugin-toolkits": "workspace:*",51 "@executor-js/plugin-toolkits": "workspace:*",
50 "@executor-js/plugin-workos-vault": "workspace:*",52 "@executor-js/plugin-workos-vault": "workspace:*",
@@ -53,6 +55,7 @@ · 2 unmodified lines
53 "@executor-js/runtime-quickjs": "workspace:*",55 "@executor-js/runtime-quickjs": "workspace:*",
54 "@executor-js/sdk": "workspace:*",56 "@executor-js/sdk": "workspace:*",
55 "@executor-js/vite-plugin": "workspace:*",57 "@executor-js/vite-plugin": "workspace:*",
58 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
56 "@modelcontextprotocol/sdk": "^1.29.0",59 "@modelcontextprotocol/sdk": "^1.29.0",
57 "@opentelemetry/api": "~1.9.0",60 "@opentelemetry/api": "~1.9.0",
58 "@opentelemetry/exporter-logs-otlp-http": "^0.214.0",61 "@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
@@ -70,9 +73,11 @@ · 11 unmodified lines
70 "autumn-js": "^1.2.8",73 "autumn-js": "^1.2.8",
71 "drizzle-orm": "catalog:",74 "drizzle-orm": "catalog:",
72 "effect": "catalog:",75 "effect": "catalog:",
76 "iron-webcrypto": "2.0.0",
73 "jose": "^5.6.3",77 "jose": "^5.6.3",
74 "postgres": "^3.4.9",78 "postgres": "^3.4.9",
75 "posthog-js": "^1.372.5",79 "posthog-js": "^1.372.5",
80 "quickjs-emscripten-core": "0.31.0",
76 "react": "catalog:",81 "react": "catalog:",
77 "react-dom": "catalog:",82 "react-dom": "catalog:",
78 "sonner": "^2.0.7"83 "sonner": "^2.0.7"
apps/cloud/scripts/backfill-subjects.ts+87−0
@@ -0,0 +1,87 @@
1/* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band migration script over a raw postgres connection */
2// ---------------------------------------------------------------------------
3// One-off data backfill: mint `subject` rows for principals that predate the
4// subject table (added by migration 0012, written only on sightings from then
5// on).
6//
7// bun run db:backfill-subjects:prod # op run --env-file=.env.production
8// bun run db:backfill-subjects:dev # against the local PGlite dev db
9//
10// Source of truth is the `connection` table: before 0012, "which users exist
11// under this tenant" was answerable only through connection rows, so a distinct
12// (tenant, subject) there — excluding the org sentinel '' — is exactly the set
13// of users the admin console under-reports until their next request.
14//
15// created_at ← the tenant+subject's oldest connection, the closest honest
16// answer to "when did this user first appear".
17// last_seen_at ← the tenant+subject's newest connection `updated_at`, the
18// latest activity storage can attest to. Rendering every
19// pre-0012 user as "Never" was literally true of the sighting
20// column but reads as "inactive user" to an operator, which
21// is the wrong answer for a tenant full of active people. The
22// first real sighting overwrites this with request truth.
23//
24// Idempotent — the insert's ON CONFLICT on the (tenant, external_id) unique
25// index leaves rows the runtime has since minted (or a prior run inserted)
26// untouched, and the last_seen_at repair only fills NULLs — so re-running is
27// safe and never rewinds a live row's fields.
28// Pass --dry-run to print the plan without writing.
29// ---------------------------------------------------------------------------
30
31import postgres from "postgres";
32import { createId } from "@executor-js/fumadb/cuid";
33
34const dryRun = process.argv.includes("--dry-run");
35
36const connectionString = process.env.DATABASE_URL;
37if (!connectionString) {
38 console.error("DATABASE_URL is not set");
39 process.exit(1);
40}
41
42const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" });
43
44const rows = await sql<
45 { tenant: string; subject: string; first_connection_at: Date; last_activity_at: Date }[]
46>`
47 SELECT tenant, subject,
48 MIN(created_at) AS first_connection_at,
49 MAX(updated_at) AS last_activity_at
50 FROM connection
51 WHERE subject <> ''
52 GROUP BY tenant, subject
53 ORDER BY tenant, subject
54`;
55console.log(`${rows.length} (tenant, subject) pair(s) with connections`);
56
57let inserted = 0;
58let repaired = 0;
59for (const row of rows) {
60 console.log(
61 `${row.tenant} ${row.subject} first connection ${row.first_connection_at.toISOString()} last activity ${row.last_activity_at.toISOString()}`,
62 );
63 if (dryRun) continue;
64 const lastSeenMs = row.last_activity_at.getTime();
65 const result = await sql`
66 INSERT INTO subject (row_id, tenant, external_id, created_at, last_seen_at, status)
67 VALUES (${createId()}, ${row.tenant}, ${row.subject}, ${row.first_connection_at}, ${lastSeenMs}, NULL)
68 ON CONFLICT (tenant, external_id) DO NOTHING
69 `;
70 inserted += result.count;
71 if (result.count > 0) continue;
72 // The row predates this run but may still carry NULL from a run of the
73 // pre-repair version of this script. Fill it in; a sighted row keeps its
74 // request-truth timestamp.
75 const repair = await sql`
76 UPDATE subject SET last_seen_at = ${lastSeenMs}
77 WHERE tenant = ${row.tenant} AND external_id = ${row.subject} AND last_seen_at IS NULL
78 `;
79 repaired += repair.count;
80}
81
82console.log(
83 dryRun
84 ? `dry run — would insert up to ${rows.length} row(s)`
85 : `inserted ${inserted} row(s), repaired last_seen_at on ${repaired} row(s)`,
86);
87await sql.end();
apps/cloud/scripts/build.mjs+20−1
@@ -9,7 +9,7 @@ · 8 unmodified lines
9 9
10import { spawnSync } from "node:child_process";10import { spawnSync } from "node:child_process";
11import { randomBytes } from "node:crypto";11import { randomBytes } from "node:crypto";
12import { rmSync } from "node:fs";12import { existsSync, readFileSync, rmSync } from "node:fs";
13 13
14if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) {14if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) {
15 process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex");15 process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex");
@@ -32,3 +32,22 @@ for (const step of steps) { · 16 unmodified lines
32 process.exit(result.status ?? 1);32 process.exit(result.status ?? 1);
33 }33 }
34}34}
35
36// The deployed Worker serves the MCP-Apps shell (`ui://executor/shell.html`)
37// by fetching this stable-named asset through the ASSETS binding — it has no
38// filesystem to read the shell from. A build that failed to emit it would
39// deploy a Worker whose artifact resource reads all fail, so make the miss a
40// build failure here instead of a production incident. Same shape as the
41// worker-bundler asset assertion from #1378.
42const shellAsset = new URL(
43 "../dist/client/assets/executor-mcp-apps-shell-stable.html",
44 import.meta.url,
45);
46if (!existsSync(shellAsset)) {
47 console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`);
48 process.exit(1);
49}
50if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) {
51 console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`);
52 process.exit(1);
53}
apps/cloud/scripts/dev-db.ts+145−14
@@ -15,6 +15,7 @@ import { PGlite } from "@electric-sql/pglite"; · 14 unmodified lines
15import { PGLiteSocketServer } from "@electric-sql/pglite-socket";15import { PGLiteSocketServer } from "@electric-sql/pglite-socket";
16import { drizzle } from "drizzle-orm/pglite";16import { drizzle } from "drizzle-orm/pglite";
17import { migrate } from "drizzle-orm/pglite/migrator";17import { migrate } from "drizzle-orm/pglite/migrator";
18import postgres from "postgres";
18 19
19const __dirname = dirname(fileURLToPath(import.meta.url));20const __dirname = dirname(fileURLToPath(import.meta.url));
20// Port + data dir default to the dev values but are env-overridable so a second21// Port + data dir default to the dev values but are env-overridable so a second
@@ -104,25 +105,46 @@ await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); · 83 unmodified lines
104// but prepared statement requires M" -> random 500s on whichever request lost105// but prepared statement requires M" -> random 500s on whichever request lost
105// the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch106// the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch
106// batches each socket data event into one queue entry and holds handler107// batches each socket data event into one queue entry and holds handler
107// affinity while a pipeline is open;108// affinity while a pipeline is open. The patch also fixes the queue's two
108// src/db/dev-db-socket-concurrency.node.test.ts is the regression test.109// self-bricking failure paths — both surfaced in CI as the e2e "cloud signIn:
109const server = new PGLiteSocketServer({110// callback set no session (500)" cascade, where new connections' startup
110 db,111// packets sat unanswered (postgres.js CONNECT_TIMEOUT) until restart:
111 port: PORT,112// 1. Stock 0.1.4 `return`ed out of the drain loop when a query REJECTED at
112 host: "127.0.0.1",113// the JS level, leaving its `processing` flag latched true; nothing was
113 maxConnections: Number(process.env.DEV_DB_MAX_CONNECTIONS ?? 1000),114// ever dequeued again.
114 // Backstop for pipeline affinity: a client that stalls mid-pipeline (Parse115// 2. A client whose socket died WHILE its pipeline-opening entry executed:
115 // sent, no Sync) with its socket still OPEN would hold the queue's handler116// detach() cleared affinity before the entry finished, the queue then
116 // affinity forever and starve every other connection, since affinity only117// took affinity for the already-dead handler, and no timer was left to
117 // releases on detach and detach needs close/error/idle-timeout. In ms; the118// release it. The queue now tracks detached handlers and repairs any
118 // timer resets on every data event, so only a genuinely dead client trips it.119// transaction or pipeline affinity they can no longer release.
119 idleTimeout: Number(process.env.DEV_DB_IDLE_TIMEOUT_MS ?? 30_000),120// src/db/dev-db-socket-concurrency.node.test.ts is the regression test for
120});121// all of the above.
122const makeServer = () =>
123 new PGLiteSocketServer({
124 db,
125 port: PORT,
126 host: "127.0.0.1",
127 maxConnections: Number(process.env.DEV_DB_MAX_CONNECTIONS ?? 1000),
128 // Backstop for pipeline affinity: a client that stalls mid-pipeline (Parse
129 // sent, no Sync) with its socket still OPEN would hold the queue's handler
130 // affinity forever and starve every other connection, since affinity only
131 // releases on detach and detach needs close/error/idle-timeout. In ms; the
132 // timer resets on every data event. The patch scopes the reap to connections
133 // actually HOLDING affinity (open pipeline or transaction): an idle-at-rest
134 // connection is the normal state of a healthy postgres.js pool held by a
135 // long-lived scope (SSE), and reaping those raced live queries into
136 // sporadic `write CONNECTION_ENDED` 500s.
137 idleTimeout: Number(process.env.DEV_DB_IDLE_TIMEOUT_MS ?? 30_000),
138 });
121 139
140let server = makeServer();
122await server.start();141await server.start();
123console.log(`[dev-db] Listening on postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`);142console.log(`[dev-db] Listening on postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`);
124 143
144let stopping = false;
145
125const shutdown = async () => {146const shutdown = async () => {
147 stopping = true;
126 console.log("\n[dev-db] Shutting down");148 console.log("\n[dev-db] Shutting down");
127 await server.stop();149 await server.stop();
128 await db.close();150 await db.close();
@@ -131,3 +153,112 @@ const shutdown = async () => { · 2 unmodified lines
131 153
132process.on("SIGINT", shutdown);154process.on("SIGINT", shutdown);
133process.on("SIGTERM", shutdown);155process.on("SIGTERM", shutdown);
156
157// ---------------------------------------------------------------------------
158// Wedge watchdog
159// ---------------------------------------------------------------------------
160//
161// Twice the socket server has shipped a state machine that could stop
162// answering NEW connections while the process, the port, and PGlite all stayed
163// up (the CI e2e "cloud signIn: callback set no session (500)" cascades: every
164// in-flight query dies once, then every fresh connection's startup packet
165// times out — CONNECT_TIMEOUT — for the rest of the shard). The known paths
166// are patched with regression tests, but each recurrence so far has found a
167// new path, and a wedged front-end turns ONE infra hiccup into a failure of
168// every remaining test in the shard.
169//
170// So: probe the server the way the app does — a fresh TCP connection, real
171// startup handshake, `select 1` — and when several consecutive probes fail,
172// dump the server's internals to the boot log and swap in a fresh socket
173// server on the same PGlite instance (all state is in PGlite; the front-end is
174// stateless, so this drops only already-doomed connections). If a restart
175// doesn't restore service, exit non-zero: the boot supervisor logs the exit
176// loudly and the run fails fast with an attributable cause instead of minutes
177// of anonymous CONNECT_TIMEOUTs. The wedge itself stays visible in the
178// server-logs artifact via the [dev-db][watchdog] lines.
179const WATCHDOG_INTERVAL_MS = Number(process.env.DEV_DB_WATCHDOG_INTERVAL_MS ?? 5_000);
180// 3 consecutive failures ≈ 15s+ of hard unavailability. PGlite serves queries
181// in milliseconds; even a deep queue clears in well under one probe interval,
182// so consecutive startup failures this sustained only happen wedged.
183const WATCHDOG_FAILURES_TO_RESTART = 3;
184const WATCHDOG_MAX_RESTARTS = 3;
185
186const probe = async (): Promise<void> => {
187 const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, {
188 max: 1,
189 idle_timeout: 0,
190 connect_timeout: 5,
191 fetch_types: false,
192 prepare: false,
193 onnotice: () => undefined,
194 });
195 try {
196 // connect_timeout only bounds the handshake; race the query too so a
197 // post-startup wedge cannot hang the watchdog itself.
198 await Promise.race([
199 sql.unsafe("select 1"),
200 sleep(10_000).then(() => {
201 throw new Error("probe query timed out after 10s");
202 }),
203 ]);
204 } finally {
205 await sql.end({ timeout: 5 }).catch(() => {});
206 }
207};
208
209const watchdog = async () => {
210 let consecutiveFailures = 0;
211 let restarts = 0;
212 for (;;) {
213 await sleep(WATCHDOG_INTERVAL_MS);
214 if (stopping) return;
215 try {
216 await probe();
217 consecutiveFailures = 0;
218 } catch (cause) {
219 consecutiveFailures += 1;
220 console.error(
221 `[dev-db][watchdog] probe failed (${consecutiveFailures}/${WATCHDOG_FAILURES_TO_RESTART}): ${String(cause)}`,
222 );
223 if (consecutiveFailures < WATCHDOG_FAILURES_TO_RESTART) continue;
224 console.error(
225 `[dev-db][watchdog] socket server wedged; stats: ${JSON.stringify(server.getStats())}`,
226 );
227 if (restarts >= WATCHDOG_MAX_RESTARTS) {
228 console.error(
229 `[dev-db][watchdog] still wedged after ${restarts} restarts — giving up so the boot supervisor reports it`,
230 );
231 process.exit(1);
232 }
233 restarts += 1;
234 consecutiveFailures = 0;
235 console.error(
236 `[dev-db][watchdog] restarting socket server (${restarts}/${WATCHDOG_MAX_RESTARTS})`,
237 );
238 // stop() itself goes through the query queue (detach rolls back open
239 // transactions), so a wedge deep enough can hang the restart too —
240 // bound it and treat that as fatal rather than hanging the watchdog.
241 const restart = async () => {
242 await server.stop();
243 server = makeServer();
244 await server.start();
245 };
246 try {
247 await Promise.race([
248 restart(),
249 sleep(15_000).then(() => {
250 throw new Error("restart timed out after 15s");
251 }),
252 ]);
253 console.error(`[dev-db][watchdog] socket server restarted`);
254 } catch (restartCause) {
255 console.error(
256 `[dev-db][watchdog] restart failed (${String(restartCause)}) — exiting so the boot supervisor reports it`,
257 );
258 process.exit(1);
259 }
260 }
261 }
262};
263
264void watchdog();
apps/cloud/scripts/start-closure.mjs+124−0
@@ -0,0 +1,124 @@
1// ---------------------------------------------------------------------------
2// Measure the Worker's *evaluated* module closures from the build output.
3// ---------------------------------------------------------------------------
4//
5// Upload size does not predict cold-isolate cost: a Worker can ship megabytes
6// that never load, and the bytes that matter are the ones an isolate must
7// evaluate before it can answer. Rollup already separates static from dynamic
8// edges, so we can compute that split directly instead of reading totals.
9//
10// Two closures matter:
11// startup - statically reachable from the Worker entry. Evaluated on every
12// cold isolate before any request is served.
13// start - the TanStack Start server graph, reached through the lazy
14// `loadEntries` dynamic imports. Evaluated on the first request
15// that enters the app - i.e. a page request.
16// app - the Effect app plane. `/api/*` dispatches at the Worker entry and
17// skips Start entirely, so an API request evaluates this instead of
18// `start`; reported separately because the two planes now diverge.
19//
20// Anything reachable only through a dynamic import is not counted: making a
21// heavy dependency lazy is exactly the outcome this rewards.
22//
23// Scope note: this measures bytes, which is a proxy for cold-start cost, not a
24// proven cause of any particular regression. The Aug 2026 MCP incident was
25// NOT explained by this number - reverting the offending packages moved the
26// evaluated closure by 0.02 MB while restoring production latency, so
27// module-scope execution cost, not size, drove that one. Treat a budget breach
28// as "this will make cold starts worse", not as "this is why the site is slow".
29import { readFileSync, readdirSync, statSync } from "node:fs";
30import { dirname, join, relative, resolve } from "node:path";
31
32const DIST = resolve(process.argv[2] ?? "dist/server");
33const ENTRY = join(DIST, "index.js");
34
35// Rollup emits `from "./x.js"`, bare `import "./x.js"`, `export ... from "./x.js"`
36// (all static) and `import("./x.js")` (dynamic). Matching the emitted output
37// rather than source means we see the graph as the runtime sees it.
38const STATIC_RE = /(?:from|import)\s*["'](\.[^"']+)["']/g;
39const DYNAMIC_RE = /import\(\s*["'](\.[^"']+)["']\s*\)/g;
40
41const listChunks = (dir) =>
42 readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
43 e.isDirectory()
44 ? listChunks(join(dir, e.name))
45 : e.name.endsWith(".js")
46 ? [join(dir, e.name)]
47 : [],
48 );
49
50const graph = new Map();
51for (const file of listChunks(DIST)) {
52 const code = readFileSync(file, "utf8");
53 const dynamic = new Set([...code.matchAll(DYNAMIC_RE)].map((m) => resolve(dirname(file), m[1])));
54 // A specifier inside `import(...)` also matches STATIC_RE's `import` branch,
55 // so subtract the dynamic set rather than trusting the static matches alone.
56 const staticDeps = new Set(
57 [...code.matchAll(STATIC_RE)]
58 .map((m) => resolve(dirname(file), m[1]))
59 .filter((p) => !dynamic.has(p)),
60 );
61 graph.set(file, { size: statSync(file).size, static: staticDeps, dynamic });
62}
63
64/** Bytes evaluated when `roots` are loaded, following static edges only. */
65const closure = (roots) => {
66 const seen = new Set();
67 const queue = [...roots];
68 while (queue.length > 0) {
69 const file = queue.pop();
70 if (seen.has(file) || !graph.has(file)) continue;
71 seen.add(file);
72 queue.push(...graph.get(file).static);
73 }
74 return seen;
75};
76
77const bytes = (files) => [...files].reduce((sum, f) => sum + (graph.get(f)?.size ?? 0), 0);
78const mb = (n) => `${(n / 1024 / 1024).toFixed(2)} MB`;
79const name = (f) => relative(DIST, f);
80
81const startup = closure([ENTRY]);
82// The lazy server-graph entries Start pulls on first request.
83const startRoots = [...graph.get(ENTRY).dynamic].filter((f) =>
84 /(start|router|tanstack)/.test(name(f)),
85);
86const start = closure(startRoots);
87const appRoots = [...graph.get(ENTRY).dynamic].filter((f) => /\/app-[A-Za-z0-9_-]+\.js$/.test(f));
88const app = closure([ENTRY, ...appRoots]);
89// The budget tracks the worst plane: whichever costs a cold isolate more.
90const evaluated = new Set([...startup, ...start]);
91
92const report = (label, files) => {
93 console.log(`\n${label}: ${mb(bytes(files))} (${files.size} chunks)`);
94 const own = [...files].filter((f) => !startup.has(f) || label === "startup");
95 for (const f of own.sort((a, b) => graph.get(b).size - graph.get(a).size).slice(0, 12)) {
96 console.log(` ${(graph.get(f).size / 1024).toFixed(0).padStart(6)} KB ${name(f)}`);
97 }
98};
99
100report("startup", startup);
101report("start", start);
102console.log(`\npage request (startup + start): ${mb(bytes(evaluated))}`);
103console.log(
104 `API request (startup + app): ${mb(bytes(app))}${appRoots.length ? "" : " [no app chunk - /api still routes through Start]"}`,
105);
106const lazyOnly = [...graph.keys()].filter((f) => !evaluated.has(f));
107console.log(
108 `deferred behind dynamic import: ${mb(bytes(lazyOnly))} (${lazyOnly.length} chunks)`,
109);
110
111const budget = Number(process.env.START_CLOSURE_BUDGET_MB ?? 0);
112if (budget > 0) {
113 const actual = bytes(evaluated) / 1024 / 1024;
114 console.log(`\nbudget ${budget} MB — actual ${actual.toFixed(2)} MB`);
115 if (actual > budget) {
116 console.error(
117 `\nFAIL: evaluated closure ${actual.toFixed(2)} MB exceeds the ${budget} MB budget.\n` +
118 `Every cold isolate pays to evaluate this closure before it can answer. Move the\n` +
119 `new weight behind a dynamic import rather than raising the budget; run this\n` +
120 `script with no budget set to see the biggest members and what is already lazy.`,
121 );
122 process.exit(1);
123 }
124}
apps/cloud/scripts/test-globalsetup.ts+20−1
@@ -13,12 +13,26 @@ import { fileURLToPath } from "node:url"; · 12 unmodified lines
13 13
14const __dirname = dirname(fileURLToPath(import.meta.url));14const __dirname = dirname(fileURLToPath(import.meta.url));
15 15
16const PORT = 5434;16const parsePort = (input: string | undefined): number => {
17 if (input === undefined) return 5434;
18 if (!/^\d+$/.test(input)) throw new Error("CLOUD_TEST_DB_PORT must be an integer");
19 const port = Number(input);
20 if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
21 throw new Error("CLOUD_TEST_DB_PORT must be between 1 and 65535");
22 }
23 return port;
24};
25
26const PORT = parsePort(process.env.CLOUD_TEST_DB_PORT);
17const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle");27const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle");
18 28
19let db: PGlite | undefined;29let db: PGlite | undefined;
20let server: PGLiteSocketServer | undefined;30let server: PGLiteSocketServer | undefined;
21 31
32/**
33 * Starts the cloud unit-test database and returns teardown that releases every
34 * resource without allowing PGlite shutdown to erase Vitest's failure status.
35 */
22export default async function setup() {36export default async function setup() {
23 db = await PGlite.create();37 db = await PGlite.create();
24 await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER });38 await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER });
@@ -30,7 +44,12 @@ export default async function setup() { · 5 unmodified lines
30 console.log(`[test-db] PGlite socket server listening on 127.0.0.1:${PORT}`);44 console.log(`[test-db] PGlite socket server listening on 127.0.0.1:${PORT}`);
31 45
32 return async () => {46 return async () => {
47 // PGlite sets an internal 99 sentinel on startup and replaces it with 0 on
48 // close. Preserve Vitest's failure status across that close so
49 // teardown can turn neither a red test green nor a green test red.
50 const testsFailed = process.exitCode === 1;
33 await server?.stop();51 await server?.stop();
34 await db?.close();52 await db?.close();
53 if (testsFailed) process.exitCode = 1;
35 };54 };
36}55}
apps/cloud/scripts/vendor-quickjs-wasm.ts+18−0
@@ -0,0 +1,18 @@
1// Vendors the QuickJS engine WASM into src/ so wrangler's CompiledWasm module
2// rule (rooted at the app dir) can statically compile it at build time. Workers
3// forbid runtime WASM compilation, and the rule's glob won't reach the
4// monorepo-root node_modules, so the bytes must live inside this app.
5//
6// Re-run after bumping @jitl/quickjs-wasmfile-release-sync:
7// bun run scripts/vendor-quickjs-wasm.ts
8import { copyFileSync } from "node:fs";
9import { createRequire } from "node:module";
10import { dirname, join } from "node:path";
11import { fileURLToPath } from "node:url";
12
13const require = createRequire(import.meta.url);
14const source = require.resolve("@jitl/quickjs-wasmfile-release-sync/wasm");
15const dest = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "quickjs-engine.wasm");
16
17copyFileSync(source, dest);
18console.log(`vendored ${source} -> ${dest}`);
apps/cloud/src/account/account-api.ts+4−1
@@ -72,13 +72,16 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi · 71 unmodified lines
72 72
73 // Built inside the request body so the WorkOS account service closes73 // Built inside the request body so the WorkOS account service closes
74 // over the per-request `UserStoreService` (postgres socket) supplied by74 // 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.
76 const accountProvider = yield* Effect.provide(78 const accountProvider = yield* Effect.provide(
77 AccountProvider.asEffect(),79 AccountProvider.asEffect(),
78 workosAccountProvider.pipe(80 workosAccountProvider.pipe(
79 Layer.provide(ApiKeyService.WorkOS),81 Layer.provide(ApiKeyService.WorkOS),
80 Layer.provide(Layer.succeed(AccountCaller)({ session })),82 Layer.provide(Layer.succeed(AccountCaller)({ session })),
81 ),83 ),
84 { local: true },
82 );85 );
83 return yield* Effect.provideService(httpEffect, AccountProvider, accountProvider);86 return yield* Effect.provideService(httpEffect, AccountProvider, accountProvider);
84 }).pipe(Effect.provideContext(longLived));87 }).pipe(Effect.provideContext(longLived));
apps/cloud/src/account/org-api-key-revoke.node.test.ts+196−0
@@ -0,0 +1,196 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import { AccountProvider } from "@executor-js/api/server";
5import { AccountError, AccountForbidden } from "@executor-js/api";
6
7import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys";
8import { UserStoreService } from "../auth/context";
9import { ORG_SELECTOR_HEADER } from "../auth/organization";
10import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
11import { AutumnService } from "../extensions/billing/service";
12import { AccountCaller, workosAccountProvider } from "./workos-account-service";
13
14// ---------------------------------------------------------------------------
15// `DELETE /account/org-api-keys/:apiKeyId` at the PROVIDER boundary.
16//
17// An org key reads every user and connection in the tenant through `/admin/*`,
18// so it is the most privileged credential the product issues. Two properties
19// have to hold together, and only this seam sees both:
20//
21// 1. Revoke is gated on org ADMIN membership — the same gate as the mint. A
22// plain member calling it gets 403 and no delete goes out.
23// 2. Revoke resolves ownership against the ORG's keys. `revokeApiKey` (the
24// personal path) checks the caller's OWN key list, so it can never revoke
25// an org key; conversely this path must never destroy a member's personal
26// key. The scope-blind `deleteApiKey` would do exactly that if handed a
27// user key id, so a wrong id has to fail cleanly instead.
28//
29// Ownership filtering itself is pinned per-shape in
30// `auth/org-api-key-mint.node.test.ts`; here it is asserted end-to-end through
31// the gate, on the provider the HTTP handler actually calls.
32// ---------------------------------------------------------------------------
33
34const ORG = "org_123";
35const ADMIN = "user_admin";
36const MEMBER = "user_member";
37const ORG_KEY = "key_org_1";
38const USER_KEY = "key_user_1";
39const createdAt = new Date("2026-01-01T00:00:00.000Z");
40const orgHeaders = { [ORG_SELECTOR_HEADER]: ORG };
41
42const session = (accountId: string) => ({
43 accountId,
44 email: `${accountId}@example.test`,
45 name: null,
46 avatarUrl: null,
47 organizationId: ORG,
48 sealedSession: "sealed",
49 refreshedSession: null,
50});
51
52/** Membership roles: only ADMIN carries the `admin` role slug. */
53const stubWorkOS = Layer.succeed(
54 WorkOSClient,
55 new Proxy({} as WorkOSClientService, {
56 get: (_target, prop) => {
57 if (prop === "listUserMemberships") {
58 return (userId: string) =>
59 Effect.succeed({
60 data: [{ userId, organizationId: ORG, status: "active" }],
61 });
62 }
63 if (prop === "getUserOrgMembership") {
64 return (organizationId: string, userId: string) =>
65 Effect.succeed(
66 organizationId === ORG
67 ? {
68 id: `om_${userId}`,
69 userId,
70 organizationId,
71 role: { slug: userId === ADMIN ? "admin" : "member" },
72 }
73 : null,
74 );
75 }
76 return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
77 },
78 }),
79);
80
81const stubUsers = Layer.succeed(UserStoreService)({
82 use: (_op, fn) =>
83 Effect.promise(() =>
84 fn({
85 ensureAccount: async (id: string) => ({ id, createdAt }),
86 getAccount: async (id: string) => ({ id, createdAt }),
87 upsertOrganization: async (org: { id: string; name: string }) => ({
88 ...org,
89 slug: org.id,
90 createdAt,
91 }),
92 getOrganization: async (id: string) => ({
93 id,
94 name: `Org ${id}`,
95 slug: id,
96 createdAt,
97 }),
98 getOrganizationBySlug: async (slug: string) => ({
99 id: slug,
100 name: `Org ${slug}`,
101 slug,
102 createdAt,
103 }),
104 deleteOrganizationCascade: async () => {},
105 }),
106 ),
107});
108
109const stubAutumn = Layer.succeed(AutumnService)({
110 use: () => Effect.die("revoke does not touch billing"),
111 ensureCustomer: () => Effect.die("revoke does not touch billing"),
112 checkExecutionBalance: () => Effect.die("revoke does not touch billing"),
113 trackExecution: () => Effect.void,
114 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 */
122const 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
158describe("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
14import { WorkOSClient } from "../auth/workos";14import { WorkOSClient } from "../auth/workos";
15import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";15import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";
16import { AutumnService } from "../extensions/billing/service";16import { AutumnService } from "../extensions/billing/service";
17import { forkReportMemberSeats } from "../extensions/billing/member-seats";
17import {18import {
18 countSeatsUsed,19 countSeatsUsed,
19 getMemberLimitForPlan,20 getMemberLimitForPlan,
@@ -82,7 +83,7 @@ export const workosAccountProvider: Layer.Layer< · 62 unmodified lines
82 // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) —83 // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) —
83 // can be erased to `R = never`, as the neutral AccountProvider shape84 // can be erased to `R = never`, as the neutral AccountProvider shape
84 // requires. Provided per method below.85 // requires. Provided per method below.
85 const ctx = yield* Effect.context<WorkOSClient | UserStoreService>();86 const ctx = yield* Effect.context<WorkOSClient | UserStoreService | AutumnService>();
86 87
87 // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly88 // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly
88 // as the old inline `requireSession` did.89 // as the old inline `requireSession` did.
@@ -91,16 +92,19 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines
91 ? Effect.succeed(caller.session)92 ? Effect.succeed(caller.session)
92 : Effect.fail<AccountUnauthorized>(new AccountUnauthorized());93 : Effect.fail<AccountUnauthorized>(new AccountUnauthorized());
93 94
94 // The org scope for an org-scoped request: the console URL's org (sent in95 // 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. Membership96 // the selector header. FAIL CLOSED when it's missing — the session's own
96 // is re-checked live, so the header is a selector, not a trust boundary —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 —
97 // and two browser tabs on different orgs each send their own header, so101 // and two browser tabs on different orgs each send their own header, so
98 // they stay independent (see organization.ts). Yields the session +102 // they stay independent (see organization.ts). Yields the session +
99 // resolved org, or AccountNoOrganization.103 // resolved org, or AccountNoOrganization.
100 const requireOrganization = (headers: AccountHeaders) =>104 const requireOrganization = (headers: AccountHeaders) =>
101 Effect.gen(function* () {105 Effect.gen(function* () {
102 const session = yield* requireSession();106 const session = yield* requireSession();
103 const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;107 const selector = headers[ORG_SELECTOR_HEADER];
104 if (!selector) {108 if (!selector) {
105 return yield* new AccountNoOrganization();109 return yield* new AccountNoOrganization();
106 }110 }
@@ -180,9 +184,15 @@ export const workosAccountProvider: Layer.Layer< · 73 unmodified lines
180 me: (headers) =>184 me: (headers) =>
181 Effect.gen(function* () {185 Effect.gen(function* () {
182 const session = yield* requireSession();186 const session = yield* requireSession();
183 // Same selector precedence as requireOrganization: the URL's org187 // The ONE sanctioned fallback to the session org. /account/me is
184 // (header) drives /account/me so the shell reflects the org the tab188 // identity, not tenant data: on a bare URL (no org slug yet, so no
185 // is viewing, not a session-global active org.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.
186 const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;196 const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
187 const org = selector197 const org = selector
188 ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe(198 ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe(
@@ -237,6 +247,52 @@ export const workosAccountProvider: Layer.Layer< · 48 unmodified lines
237 return { success: true };247 return { success: true };
238 }),248 }),
239 249
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
240 listMembers: (headers) =>296 listMembers: (headers) =>
241 Effect.gen(function* () {297 Effect.gen(function* () {
242 const { session, org } = yield* requireOrganization(headers);298 const { session, org } = yield* requireOrganization(headers);
@@ -309,6 +365,7 @@ export const workosAccountProvider: Layer.Layer< · 66 unmodified lines
309 yield* workos365 yield* workos
310 .deleteOrgMembership(membershipId)366 .deleteOrgMembership(membershipId)
311 .pipe(Effect.catchTag("WorkOSError", toAccountError));367 .pipe(Effect.catchTag("WorkOSError", toAccountError));
368 yield* forkReportMemberSeats(org.id).pipe(Effect.provideContext(ctx));
312 return { success: true };369 return { success: true };
313 }),370 }),
314 371
@@ -331,7 +388,9 @@ export const workosAccountProvider: Layer.Layer< · 16 unmodified lines
331 .updateOrganization(org.id, name)388 .updateOrganization(org.id, name)
332 .pipe(Effect.catchTag("WorkOSError", toAccountError));389 .pipe(Effect.catchTag("WorkOSError", toAccountError));
333 yield* users390 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 )
335 .pipe(Effect.catchTag("UserStoreError", toAccountError));394 .pipe(Effect.catchTag("UserStoreError", toAccountError));
336 return { name: updated.name };395 return { name: updated.name };
337 }),396 }),
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
28import { env } from "cloudflare:workers";
29import { HttpRouter } from "effect/unstable/http";
30import { Context, Effect, Layer, Option } from "effect";
31
32import {
33 AdminUsersProvider,
34 DbProvider,
35 HostConfig,
36 PluginsProvider,
37 getAdminUser,
38 listAdminUserConnections,
39 listAdminUsers,
40 listAdminUsersWithConnections,
41 makeAdminUsersApiLayer,
42 makePlatformExecutor,
43 normalizeAdminUserEmail,
44 platformViewOf,
45 requestScopedMiddleware,
46 type AdminEmailResolver,
47 type AdminIdentityDirectory,
48 type AdminUserDirectory,
49 type AdminUserIdentity,
50 type AdminUsersHeaders,
51} from "@executor-js/api/server";
52import {
53 AdminUserNotFound,
54 AdminUsersError,
55 AdminUsersForbidden,
56 AdminUsersUnauthorized,
57} from "@executor-js/api";
58import type { Executor } from "@executor-js/sdk";
59
60import { ApiKeyService } from "../auth/api-keys";
61import { UserStoreService } from "../auth/context";
62import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider";
63import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization";
64import { WorkOSClient } from "../auth/workos";
65import { DbService } from "../db/db";
66import { CloudExecutionSeamsLayer } from "../engine/execution-stack";
67
68/**
69 * Resolve the tenant this request may read, or fail with the neutral 401/403.
70 *
71 * Returns only the organization id: nothing downstream needs to know WHICH of
72 * the two credentials got the caller here, and keeping the acting member out of
73 * the return value means no admin read can accidentally become subject-scoped.
74 */
75const authorizeTenant = (
76 request: Request,
77): Effect.Effect<
78 string,
79 AdminUsersUnauthorized | AdminUsersForbidden,
80 WorkOSClient | ApiKeyService | UserStoreService
81> =>
82 Effect.gen(function* () {
83 // (1) The bearer path. `resolveBearerAuth` (not `resolveApiKeyPrincipal`,
84 // which rejects org keys for the product plane) is what distinguishes an
85 // org key from a user key.
86 const bearer = yield* resolveBearerAuth(request).pipe(
87 // Every rejected-credential and infra failure collapses to one refusal:
88 // this plane must not report whether a key exists, belongs to another
89 // org, or merely lacks privilege.
90 Effect.catchCause(() => Effect.succeed(null)),
91 );
92 if (bearer !== null) {
93 if (isPlatformAuth(bearer)) return bearer.organizationId;
94 // A user-scoped key authenticated fine but names one member; the platform
95 // plane has no honest way to serve it.
96 return yield* new AdminUsersForbidden();
97 }
98
99 // (2) The session path: a live admin membership in the selected org.
100 const workos = yield* WorkOSClient;
101 const session = yield* workos
102 .authenticateRequest(request)
103 .pipe(Effect.catchCause(() => Effect.succeed(null)));
104 if (!session) return yield* new AdminUsersUnauthorized();
105
106 const selector = orgSelectorFromRequest(request) ?? session.organizationId;
107 if (!selector) return yield* new AdminUsersForbidden();
108 // Re-checks live membership, so the org selector header can only ever name
109 // an org the caller already belongs to.
110 const org = yield* authorizeOrganizationSelector(session.userId, selector).pipe(
111 Effect.catchCause(() => Effect.succeed(null)),
112 );
113 if (!org) return yield* new AdminUsersForbidden();
114
115 const membership = yield* workos
116 .getUserOrgMembership(org.id, session.userId)
117 .pipe(Effect.catchCause(() => Effect.succeed(null)));
118 // A pending admin invite is not an active admin — require both.
119 if (!membership || membership.status !== "active" || membership.role?.slug !== "admin") {
120 return yield* new AdminUsersForbidden();
121 }
122 return org.id;
123 });
124
125/**
126 * How many user-detail reads run at once. Matches the account plane's own
127 * member listing (`workos-account-service.ts`), which fans out the same way for
128 * the same reason.
129 */
130const IDENTITY_CONCURRENCY = 5;
131
132/**
133 * Cloud's member directory: `externalId` → email/name.
134 *
135 * THE JOIN KEY is the membership's `userId` — the WorkOS `user_...` that
136 * `workos-auth-provider.ts` binds as `accountId` on every credential path, and
137 * therefore what the subject table records in `external_id`. The membership's
138 * own `id` is an `om_...` row id and joins to nothing.
139 *
140 * WHY THIS IS TWO CALLS AND NOT ONE. The membership list is read once per
141 * request and is the authority on who belongs to the org, but WorkOS's
142 * `listOrganizationMemberships` carries no user detail and offers no
143 * include/expand — email and name only exist on the user resource. The SDK does
144 * expose a batched `listUsers({ organizationId })`, but the pinned
145 * `@executor-js/emulate` WorkOS emulator serves only `GET
146 * /user_management/users/:id`, so taking that path would leave every cloud e2e
147 * user unnamed. So: ONE membership read per request, then user detail fetched
148 * only for the ids ON THIS PAGE — never for the whole org, and never once per
149 * row of some larger list. An id that is not an active/pending member is not
150 * fetched at all and reports absent identity, which is the honest answer for a
151 * member who left while their connections remain.
152 */
153const identityDirectory =
154 (organizationId: string, context: Context.Context<WorkOSClient>): AdminIdentityDirectory =>
155 (externalIds) =>
156 Effect.gen(function* () {
157 const workos = yield* WorkOSClient;
158 const memberships = yield* workos.listOrgMembers(organizationId);
159 const wanted = new Set(externalIds);
160 const memberIds = memberships.data
161 .map((membership) => membership.userId)
162 .filter((userId) => wanted.has(userId));
163
164 const resolved = yield* Effect.all(
165 memberIds.map((userId) =>
166 workos.getUser(userId).pipe(
167 Effect.map(
168 (user) =>
169 [
170 userId,
171 {
172 email: user.email,
173 displayName: [user.firstName, user.lastName].filter(Boolean).join(" ") || null,
174 },
175 ] as const,
176 ),
177 // One unreadable user must not cost the whole page its names.
178 Effect.catchCause(() => Effect.succeed(null)),
179 ),
180 ),
181 { concurrency: IDENTITY_CONCURRENCY },
182 );
183
184 const identities = new Map<string, AdminUserIdentity>();
185 for (const entry of resolved) if (entry) identities.set(entry[0], entry[1]);
186 return identities;
187 }).pipe(Effect.provideContext(context));
188
189/**
190 * Cloud's REVERSE directory lookup: email → the WorkOS `user_...` id.
191 *
192 * Production asks WorkOS for the email AND organization in one request. Both
193 * filters matter: email makes the lookup indexed rather than one `getUser`
194 * request per member, while organization keeps the reverse lookup bound to the
195 * same tenant as the platform view.
196 *
197 * The pinned `@executor-js/emulate` WorkOS emulator has no list-users route.
198 * `WORKOS_API_URL` is the explicit test/dev emulator override, so that path
199 * retains the membership scan until the emulator supports the production
200 * query. The fallback still starts from the tenant's membership list and can
201 * never return a user from another organization.
202 *
203 * CASING: WorkOS preserves whatever casing an email was created with (and the
204 * emulator compares byte-exact), so the directory value is normalized here
205 * before comparison, against an argument the seam already normalized.
206 */
207export const emailResolver =
208 (organizationId: string, context: Context.Context<WorkOSClient>): AdminEmailResolver =>
209 (email) =>
210 Effect.gen(function* () {
211 const workos = yield* WorkOSClient;
212
213 if (!env.WORKOS_API_URL) {
214 const users = yield* workos.listUsers({ email, organizationId });
215 return users.data[0]?.id ?? null;
216 }
217
218 const memberships = yield* workos.listOrgMembers(organizationId);
219 const userIds = memberships.data.map((membership) => membership.userId);
220
221 // Emulator compatibility only. Short-circuit once the normalized email
222 // matches so the fallback makes as few unsupported-detail reads as it can.
223 const match = yield* Effect.findFirst(userIds, (userId) =>
224 workos.getUser(userId).pipe(
225 Effect.map((user) => normalizeAdminUserEmail(user.email ?? "") === email),
226 // One unreadable user must not fail the whole lookup — it simply
227 // cannot be the match.
228 Effect.catchCause(() => Effect.succeed(false)),
229 ),
230 );
231 return Option.getOrNull(match);
232 }).pipe(Effect.provideContext(context));
233
234/** Both directions of cloud's directory, built once per authorized request. */
235const userDirectory = (
236 organizationId: string,
237 context: Context.Context<WorkOSClient>,
238): AdminUserDirectory => ({
239 identities: identityDirectory(organizationId, context),
240 resolveEmail: emailResolver(organizationId, context),
241});
242
243/**
244 * Authorize, then run `body` against the tenant's platform view.
245 *
246 * The executor is built per request and closed after, like every other cloud
247 * execution stack: it holds the per-request postgres socket, which Cloudflare's
248 * I/O isolation forbids sharing across requests.
249 */
250const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = AdminUsersError>(
251 headers: AdminUsersHeaders,
252 body: (executor: Executor, organizationId: string) => Effect.Effect<A, E>,
253): Effect.Effect<
254 A,
255 // `AdminUsersError` unconditionally: opening the platform view can fail that
256 // way regardless of what `body` itself raises.
257 E | AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden,
258 WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
259> =>
260 Effect.gen(function* () {
261 const organizationId = yield* authorizeTenant(
262 new Request("https://admin.invalid", { headers }),
263 );
264 const executor = yield* makePlatformExecutor(organizationId).pipe(
265 Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })),
266 );
267 // The authorized tenant is handed to the body so an identity join reads the
268 // SAME org the reads are scoped to — never one named by client input.
269 return yield* Effect.ensuring(
270 body(executor, organizationId),
271 executor.close().pipe(Effect.ignore),
272 );
273 });
274
275/**
276 * Cloud's `AdminUsersProvider`, built per request so the platform executor
277 * closes over the per-request postgres socket.
278 */
279export const workosAdminUsersProvider: Layer.Layer<
280 AdminUsersProvider,
281 never,
282 WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
283> = Layer.effect(AdminUsersProvider)(
284 Effect.gen(function* () {
285 const context = yield* Effect.context<
286 WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
287 >();
288 return AdminUsersProvider.of({
289 listUsers: (headers, options) =>
290 withPlatformView(headers, (executor, organizationId) =>
291 platformViewOf(executor).pipe(
292 Effect.flatMap((admin) =>
293 listAdminUsers(admin, options, userDirectory(organizationId, context)),
294 ),
295 ),
296 ).pipe(Effect.provideContext(context)),
297 listUsersWithConnections: (headers, options) =>
298 withPlatformView(headers, (executor, organizationId) =>
299 platformViewOf(executor).pipe(
300 Effect.flatMap((admin) =>
301 listAdminUsersWithConnections(admin, options, userDirectory(organizationId, context)),
302 ),
303 ),
304 ).pipe(Effect.provideContext(context)),
305 listUserConnections: (headers, externalId) =>
306 withPlatformView(headers, (executor) =>
307 platformViewOf(executor).pipe(
308 Effect.flatMap((admin) => listAdminUserConnections(admin, externalId)),
309 ),
310 ).pipe(Effect.provideContext(context)),
311 getUser: (headers, identifier) =>
312 withPlatformView(headers, (executor, organizationId) =>
313 platformViewOf(executor).pipe(
314 Effect.flatMap((admin) =>
315 getAdminUser(admin, identifier, userDirectory(organizationId, context)),
316 ),
317 ),
318 ).pipe(Effect.provideContext(context)),
319 });
320 }),
321);
322
323// Builds the provider per request, providing it to the handlers. Long-lived
324// `WorkOSClient | ApiKeyService` come from the surrounding boot context; the
325// per-request `DbService`/`UserStoreService` (and the execution seams built
326// over them) are supplied by the combined `requestScopedMiddleware`.
327const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()(
328 Effect.gen(function* () {
329 const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>();
330 return (httpEffect) =>
331 Effect.gen(function* () {
332 // Built inside the request body so the execution seams close over the
333 // per-request postgres socket. `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 */
350export 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 @@
1import { env } from "cloudflare:workers";
2import { expect, it } from "@effect/vitest";
3import { Data, Effect, Layer } from "effect";
4
5import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
6import { emailResolver } from "./admin-users-api";
7
8// Cloud's REVERSE directory lookup: email -> the WorkOS `user_...` id that the
9// subject table records in `external_id`. Production resolves it with one
10// tenant-scoped list-users query. The WorkOS emulator lacks that route, so
11// tests/dev retain the membership-backed scan exercised below.
12
13const ORG = "org_placeholder";
14const OTHER_ORG = "org_other";
15
16class WorkOSUnavailable extends Data.TaggedError("WorkOSUnavailable")<{
17 readonly userId: string;
18}> {}
19
20const DIRECTORY = [
21 // Same email in another tenant must never win either lookup path.
22 { id: "user_foreign", email: "ada@placeholder.test", organizationId: OTHER_ORG },
23 // WorkOS preserves submitted casing, while the resolver seam is normalized.
24 { id: "user_ada", email: "Ada@Placeholder.test", organizationId: ORG },
25 { id: "user_grace", email: "grace@placeholder.test", organizationId: ORG },
26 { id: "user_nameless", email: null, organizationId: ORG },
27] as const;
28
29const stubWorkOS = (calls: string[], unreadableUserIds: ReadonlySet<string>) =>
30 Layer.succeed(
31 WorkOSClient,
32 new Proxy({} as WorkOSClientService, {
33 get: (_target, prop) => {
34 if (prop === "listUsers") {
35 return (params: { email: string; organizationId: string }) => {
36 calls.push(`listUsers:${params.organizationId}:${params.email}`);
37 return Effect.succeed({
38 data: DIRECTORY.filter(
39 (user) =>
40 user.organizationId === params.organizationId &&
41 user.email?.toLowerCase() === params.email,
42 ),
43 });
44 };
45 }
46 if (prop === "listOrgMembers") {
47 return (organizationId: string) => {
48 calls.push(`listOrgMembers:${organizationId}`);
49 return Effect.succeed({
50 data: DIRECTORY.filter((user) => user.organizationId === organizationId).map(
51 (user) => ({ userId: user.id, organizationId }),
52 ),
53 });
54 };
55 }
56 if (prop === "getUser") {
57 return (userId: string) => {
58 calls.push(`getUser:${userId}`);
59 if (unreadableUserIds.has(userId)) {
60 return Effect.fail(new WorkOSUnavailable({ userId }));
61 }
62 const user = DIRECTORY.find((candidate) => candidate.id === userId);
63 if (!user) return Effect.die(`unexpected user ${userId}`);
64 return Effect.succeed(user);
65 };
66 }
67 return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
68 },
69 }),
70 );
71
72const resolve = (
73 email: string,
74 calls: string[],
75 emulator = false,
76 unreadableUserIds: ReadonlySet<string> = new Set(),
77) => {
78 const previousApiUrl = env.WORKOS_API_URL;
79 return Effect.gen(function* () {
80 yield* Effect.sync(() =>
81 Object.assign(env, {
82 WORKOS_API_URL: emulator ? "http://workos-emulator.invalid" : undefined,
83 }),
84 );
85 const context = yield* Effect.context<WorkOSClient>();
86 return yield* emailResolver(ORG, context)(email);
87 }).pipe(
88 Effect.provide(stubWorkOS(calls, unreadableUserIds)),
89 Effect.ensuring(Effect.sync(() => Object.assign(env, { WORKOS_API_URL: previousApiUrl }))),
90 );
91};
92
93it.effect("resolves an email with one tenant-scoped WorkOS query", () =>
94 Effect.gen(function* () {
95 const calls: string[] = [];
96 expect(yield* resolve("ada@placeholder.test", calls)).toBe("user_ada");
97 expect(calls).toEqual([`listUsers:${ORG}:ada@placeholder.test`]);
98 }),
99);
100
101it.effect("returns null from one query when the organization has no matching email", () =>
102 Effect.gen(function* () {
103 const calls: string[] = [];
104 expect(yield* resolve("nobody@placeholder.test", calls)).toBeNull();
105 expect(calls).toEqual([`listUsers:${ORG}:nobody@placeholder.test`]);
106 }),
107);
108
109it.effect("keeps the emulator fallback tenant-scoped and case-insensitive", () =>
110 Effect.gen(function* () {
111 const calls: string[] = [];
112 expect(yield* resolve("ada@placeholder.test", calls, true)).toBe("user_ada");
113 expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada"]);
114 expect(calls).not.toContain("getUser:user_foreign");
115 expect(calls.some((call) => call.startsWith("listUsers:"))).toBe(false);
116 }),
117);
118
119it.effect("lets the emulator fallback continue past one unreadable member", () =>
120 Effect.gen(function* () {
121 const calls: string[] = [];
122 expect(yield* resolve("grace@placeholder.test", calls, true, new Set(["user_ada"]))).toBe(
123 "user_grace",
124 );
125 expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada", "getUser:user_grace"]);
126 }),
127);
apps/cloud/src/analytics/ema-rollout.test.ts+394−0
@@ -0,0 +1,394 @@
1// ---------------------------------------------------------------------------
2// Cloud's PostHog-backed enterprise-managed rollout gate.
3//
4// The gate is exercised through its production seam: the real
5// `makePostHogEnterpriseManagedRollout` with an injected `fetch` and an
6// injected `waitUntil`, exactly as `auth/jwks-cache.node.test.ts` drives the
7// JWKS client. Nothing is module-mocked, so the request these tests read is the
8// request PostHog would receive.
9// ---------------------------------------------------------------------------
10
11import { describe, expect, it } from "@effect/vitest";
12import { Effect } from "effect";
13
14import type { EnterpriseManagedRolloutContext } from "@executor-js/sdk";
15
16import {
17 ENTERPRISE_MANAGED_AUTH_FLAG_KEY,
18 cloudEnterpriseManagedRollout,
19 makePostHogEnterpriseManagedRollout,
20} from "./ema-rollout";
21
22const HOST = "https://us.i.posthog.com";
23const PROJECT_KEY = "phc_test_project_key";
24
25const CONTEXT: EnterpriseManagedRolloutContext = {
26 userId: "user_01ABC",
27 organizationId: "org_01XYZ",
28 integration: "slack" as EnterpriseManagedRolloutContext["integration"],
29};
30
31interface CapturedRequest {
32 readonly url: string;
33 readonly body: Record<string, unknown>;
34 /** The bound the gate put on this call, when it set one. */
35 readonly signal: AbortSignal | null;
36}
37
38interface FetchHarness {
39 readonly fetch: typeof globalThis.fetch;
40 readonly requests: () => readonly CapturedRequest[];
41 /** Resolve every request the gate detached, so an assertion cannot race it. */
42 readonly settle: () => Promise<void>;
43 readonly waitUntil: (work: Promise<unknown>) => void;
44}
45
46/**
47 * Stands in for PostHog. `respond` decides what each call returns; throwing
48 * from it models a transport failure, and an aborted signal models the timeout.
49 */
50const makeFetchHarness = (
51 respond: (request: CapturedRequest) => Response | Promise<Response>,
52): FetchHarness => {
53 const requests: CapturedRequest[] = [];
54 const detached: Promise<unknown>[] = [];
55 return {
56 requests: () => requests,
57 settle: async () => {
58 await Promise.all(detached);
59 },
60 waitUntil: (work) => {
61 detached.push(work);
62 },
63 fetch: async (input, init) => {
64 const raw = typeof init?.body === "string" ? init.body : "{}";
65 const captured: CapturedRequest = {
66 url: String(input),
67 // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture reads back the exact JSON body the gate serialized for PostHog
68 body: JSON.parse(raw) as Record<string, unknown>,
69 signal: init?.signal instanceof AbortSignal ? init.signal : null,
70 };
71 requests.push(captured);
72 return respond(captured);
73 },
74 };
75};
76
77const jsonResponse = (body: unknown, status = 200): Response =>
78 new Response(JSON.stringify(body), {
79 status,
80 headers: { "content-type": "application/json" },
81 });
82
83const flagsBody = (enabled: boolean) => ({
84 flags: {
85 [ENTERPRISE_MANAGED_AUTH_FLAG_KEY]: {
86 key: ENTERPRISE_MANAGED_AUTH_FLAG_KEY,
87 enabled,
88 reason: { code: "condition_match" },
89 },
90 },
91 errorsWhileComputingFlags: false,
92});
93
94const gate = (harness: FetchHarness, timeoutMs?: number) =>
95 makePostHogEnterpriseManagedRollout({
96 projectKey: PROJECT_KEY,
97 host: HOST,
98 fetch: harness.fetch,
99 waitUntil: harness.waitUntil,
100 ...(timeoutMs === undefined ? {} : { timeoutMs }),
101 });
102
103describe("flag evaluation", () => {
104 it.effect("keys the rollout on the user and carries the org as group context", () =>
105 Effect.gen(function* () {
106 const harness = makeFetchHarness(() => jsonResponse(flagsBody(true)));
107
108 const decision = yield* gate(harness).decide(CONTEXT);
109
110 expect(decision).toEqual({ kind: "enabled" });
111 const [request] = harness.requests();
112 expect(request?.url).toBe(`${HOST}/flags?v=2`);
113 expect(
114 request?.body.distinct_id,
115 "the rollout unit is the user, matching posthog.identify(user.id) in the browser",
116 ).toBe(CONTEXT.userId);
117 expect(
118 request?.body.groups,
119 "the org rides along so group targeting stays available without changing the rollout unit",
120 ).toEqual({ organization: CONTEXT.organizationId });
121 expect(request?.body.api_key).toBe(PROJECT_KEY);
122 expect(request?.signal, "the evaluation is always bounded").not.toBeNull();
123 }),
124 );
125
126 it.effect("omits group context when the host named no organization", () =>
127 Effect.gen(function* () {
128 const harness = makeFetchHarness(() => jsonResponse(flagsBody(true)));
129
130 yield* gate(harness).decide({ ...CONTEXT, organizationId: null });
131
132 expect(harness.requests()[0]?.body.groups).toBeUndefined();
133 }),
134 );
135
136 it.effect("withholds when the flag is off for this user", () =>
137 Effect.gen(function* () {
138 const harness = makeFetchHarness(() => jsonResponse(flagsBody(false)));
139
140 expect(yield* gate(harness).decide(CONTEXT)).toEqual({
141 kind: "withheld",
142 reason: "disabled",
143 });
144 }),
145 );
146
147 it.effect("withholds when PostHog returned no verdict for the flag at all", () =>
148 Effect.gen(function* () {
149 const harness = makeFetchHarness(() =>
150 // What a quota-limited project answers.
151 jsonResponse({
152 flags: {},
153 errorsWhileComputingFlags: false,
154 quotaLimited: ["feature_flags"],
155 }),
156 );
157
158 expect(yield* gate(harness).decide(CONTEXT)).toEqual({
159 kind: "withheld",
160 reason: "disabled",
161 });
162 }),
163 );
164});
165
166describe("failing closed", () => {
167 it.effect("withholds on a non-2xx answer", () =>
168 Effect.gen(function* () {
169 const harness = makeFetchHarness(() => new Response("nope", { status: 503 }));
170
171 expect(yield* gate(harness).decide(CONTEXT)).toEqual({
172 kind: "withheld",
173 reason: "evaluation-unavailable",
174 });
175 }),
176 );
177
178 it.effect("withholds on a transport failure", () =>
179 Effect.gen(function* () {
180 const harness = makeFetchHarness(() => {
181 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: models `fetch` rejecting with a TypeError, which is exactly how a Worker sees a dead upstream
182 throw new TypeError("network error");
183 });
184
185 expect(yield* gate(harness).decide(CONTEXT)).toEqual({
186 kind: "withheld",
187 reason: "evaluation-unavailable",
188 });
189 }),
190 );
191
192 it.effect("withholds when the evaluation times out", () =>
193 Effect.gen(function* () {
194 // Never answers. Only the gate's own AbortSignal ends this call, so the
195 // test fails by hanging if the gate ever stops bounding the evaluation.
196 const harness = makeFetchHarness(
197 (request) =>
198 new Promise<Response>((_resolve, reject) => {
199 request.signal?.addEventListener("abort", () =>
200 // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: fetch-compatible fixture mirrors the platform's abort rejection semantics
201 reject(new DOMException("aborted", "AbortError")),
202 );
203 }),
204 );
205
206 expect(
207 yield* gate(harness, 1).decide(CONTEXT),
208 "a slow flag service must degrade the rollout, never hold up a connect",
209 ).toEqual({ kind: "withheld", reason: "evaluation-unavailable" });
210 }),
211 );
212
213 it.effect("withholds on a body that is not a flags response", () =>
214 Effect.gen(function* () {
215 const harness = makeFetchHarness(() => jsonResponse({ flags: "not-an-object" }));
216
217 expect(yield* gate(harness).decide(CONTEXT)).toEqual({
218 kind: "withheld",
219 reason: "evaluation-unavailable",
220 });
221 }),
222 );
223
224 it.effect("withholds when there is no acting user to roll out by", () =>
225 Effect.gen(function* () {
226 const harness = makeFetchHarness(() => jsonResponse(flagsBody(true)));
227
228 expect(yield* gate(harness).decide({ ...CONTEXT, userId: null })).toEqual({
229 kind: "withheld",
230 reason: "evaluation-unavailable",
231 });
232 expect(harness.requests().length, "and asks PostHog nothing").toBe(0);
233 }),
234 );
235
236 it.effect("withholds when the deployment has no PostHog configuration", () =>
237 Effect.gen(function* () {
238 // The cloud test env carries no VITE_PUBLIC_POSTHOG_KEY, so this builds
239 // the misconfigured-deployment gate — which is deliberately NOT the same
240 // as a host that injects no gate at all.
241 expect(yield* cloudEnterpriseManagedRollout().decide(CONTEXT)).toEqual({
242 kind: "withheld",
243 reason: "evaluation-unavailable",
244 });
245 }),
246 );
247});
248
249describe("rollout events", () => {
250 it.effect("captures the connect outcome with the flag decision attached", () =>
251 Effect.gen(function* () {
252 const harness = makeFetchHarness(() => jsonResponse({ status: 1 }));
253 const rollout = gate(harness);
254
255 yield* rollout.record({
256 kind: "connected",
257 context: CONTEXT,
258 decision: { kind: "enabled" },
259 });
260 yield* Effect.promise(() => harness.settle());
261
262 const [request] = harness.requests();
263 expect(request?.url).toBe(`${HOST}/i/v0/e/`);
264 expect(request?.body.event).toBe("ema_connect_connected");
265 expect(request?.body.distinct_id).toBe(CONTEXT.userId);
266 const properties = request?.body.properties as Record<string, unknown>;
267 expect(properties.ema_flag_enabled).toBe(true);
268 expect(properties[`$feature/${ENTERPRISE_MANAGED_AUTH_FLAG_KEY}`]).toBe(true);
269 expect(properties.$groups).toEqual({
270 organization: CONTEXT.organizationId,
271 });
272 expect(properties.integration_slug).toBe("slack");
273 }),
274 );
275
276 it.effect("carries the withheld reason on the attempt that never ran", () =>
277 Effect.gen(function* () {
278 const harness = makeFetchHarness(() => jsonResponse({ status: 1 }));
279
280 yield* gate(harness).record({
281 kind: "attempted",
282 context: CONTEXT,
283 decision: { kind: "withheld", reason: "evaluation-unavailable" },
284 });
285 yield* Effect.promise(() => harness.settle());
286
287 const properties = harness.requests()[0]?.body.properties as Record<string, unknown>;
288 expect(harness.requests()[0]?.body.event).toBe("ema_connect_attempted");
289 expect(properties.ema_flag_enabled).toBe(false);
290 expect(properties.ema_flag_withheld_reason).toBe("evaluation-unavailable");
291 }),
292 );
293
294 it.effect("carries the identity provider's own error code on a blocked connect", () =>
295 Effect.gen(function* () {
296 const harness = makeFetchHarness(() => jsonResponse({ status: 1 }));
297
298 yield* gate(harness).record({
299 kind: "blocked-by-admin",
300 context: CONTEXT,
301 decision: { kind: "enabled" },
302 oauthErrorCode: "unauthorized_client",
303 });
304 yield* Effect.promise(() => harness.settle());
305
306 const [request] = harness.requests();
307 expect(request?.body.event).toBe("ema_connect_blocked_by_admin");
308 const properties = request?.body.properties as Record<string, unknown> | undefined;
309 expect(properties?.oauth_error_code).toBe("unauthorized_client");
310 }),
311 );
312
313 it.effect("sends nothing beyond identity, the integration and the flag decision", () =>
314 Effect.gen(function* () {
315 const harness = makeFetchHarness(() => jsonResponse({ status: 1 }));
316
317 yield* gate(harness).record({
318 kind: "blocked-by-admin",
319 context: CONTEXT,
320 decision: { kind: "enabled" },
321 oauthErrorCode: "access_denied",
322 });
323 yield* Effect.promise(() => harness.settle());
324
325 const properties = harness.requests()[0]?.body.properties as Record<string, unknown>;
326 expect(
327 Object.keys(properties).sort(),
328 "a closed property set is what keeps a token or an assertion from ever being added by accident",
329 ).toEqual(
330 [
331 "$feature/mcp-enterprise-managed-auth",
332 "$groups",
333 "ema_flag_enabled",
334 "integration_slug",
335 "oauth_error_code",
336 ].sort(),
337 );
338 }),
339 );
340
341 it.effect("hands the capture to the platform instead of awaiting it", () =>
342 Effect.gen(function* () {
343 let resolveCapture: (() => void) | undefined;
344 const harness = makeFetchHarness(
345 () =>
346 new Promise<Response>((resolve) => {
347 resolveCapture = () => resolve(jsonResponse({ status: 1 }));
348 }),
349 );
350
351 // Returns while the capture is still in flight: an analytics call can
352 // never be on the critical path of a connect.
353 yield* gate(harness).record({
354 kind: "attempted",
355 context: CONTEXT,
356 decision: { kind: "enabled" },
357 });
358
359 expect(harness.requests().length).toBe(1);
360 resolveCapture?.();
361 yield* Effect.promise(() => harness.settle());
362 }),
363 );
364
365 it.effect("survives a capture that fails outright", () =>
366 Effect.gen(function* () {
367 const harness = makeFetchHarness(() => {
368 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: models the ingest endpoint being unreachable
369 throw new TypeError("network error");
370 });
371
372 yield* gate(harness).record({
373 kind: "connected",
374 context: CONTEXT,
375 decision: { kind: "enabled" },
376 });
377 yield* Effect.promise(() => harness.settle());
378 }),
379 );
380
381 it.effect("records nothing when there is no person to attach the event to", () =>
382 Effect.gen(function* () {
383 const harness = makeFetchHarness(() => jsonResponse({ status: 1 }));
384
385 yield* gate(harness).record({
386 kind: "attempted",
387 context: { ...CONTEXT, userId: null },
388 decision: { kind: "withheld", reason: "evaluation-unavailable" },
389 });
390
391 expect(harness.requests().length).toBe(0);
392 }),
393 );
394});
apps/cloud/src/analytics/ema-rollout.ts+255−0
@@ -0,0 +1,255 @@
1// ---------------------------------------------------------------------------
2// Cloud's rollout gate for MCP Enterprise-Managed Authorization.
3//
4// Implements the vendor-free `EnterpriseManagedRollout` port from
5// `@executor-js/sdk` against PostHog, which is the only flag/analytics service
6// this deployment operates. Two deliberate shapes:
7//
8// 1. NO SDK. This is a hand-written `fetch` against two documented PostHog
9// endpoints, not `posthog-node`. Cloud already ate an unexplained 3-5s
10// page-load regression from adding one dependency to the worker bundle and
11// the mechanism was never identified (see the notes on the MCP SDK v2
12// revert). Until that is understood, a new runtime dependency in this bundle
13// is a cost we are not willing to pay for a boolean, and the whole client we
14// would be importing is two POSTs wide.
15//
16// 2. FAIL CLOSED, ALWAYS. Every way of not getting an answer — timeout,
17// transport failure, non-2xx, a body we cannot parse, no PostHog key
18// configured, no acting user to key the rollout on — withholds the
19// enterprise-managed path and falls back to ordinary interactive OAuth. A
20// PostHog outage therefore degrades the rollout and never fails a connect,
21// and (because the SDK freezes the verdict onto the connection) never
22// touches an enterprise-managed connection that already exists.
23//
24// The rollout UNIT is the user: `distinct_id` is the acting user's id, the same
25// id `posthog.identify(user.id, …)` uses in the browser, so a percentage
26// rollout here means the same population it means everywhere else in the
27// project. The organization rides along as group context so org-level targeting
28// stays available in the PostHog UI without changing that unit.
29// ---------------------------------------------------------------------------
30
31import { env } from "cloudflare:workers";
32import { Effect, Schema } from "effect";
33
34import type {
35 EnterpriseManagedRollout,
36 EnterpriseManagedRolloutContext,
37 EnterpriseManagedRolloutDecision,
38 EnterpriseManagedRolloutEvent,
39} from "@executor-js/sdk";
40
41import { POSTHOG_INGEST_HOST } from "../edge/passthrough";
42
43/** The flag that gates enterprise-managed authorization. */
44export const ENTERPRISE_MANAGED_AUTH_FLAG_KEY = "mcp-enterprise-managed-auth";
45
46/** PostHog group type for organizations — the same one
47 * `posthog.group("organization", …)` establishes in the browser. */
48const ORGANIZATION_GROUP_TYPE = "organization";
49
50/**
51 * How long a flag evaluation may take before the gate gives up and withholds.
52 * Short on purpose: this call sits in front of an interactive connect, and the
53 * safe answer is already known, so waiting is strictly worse than answering.
54 */
55export const DEFAULT_FLAG_EVALUATION_TIMEOUT_MS = 1_000;
56
57/** Wire names of the rollout events, in the product's `object_verb` style. */
58const EVENT_NAMES = {
59 attempted: "ema_connect_attempted",
60 connected: "ema_connect_connected",
61 "blocked-by-admin": "ema_connect_blocked_by_admin",
62} as const satisfies Record<EnterpriseManagedRolloutEvent["kind"], string>;
63
64// ---------------------------------------------------------------------------
65// Boundary parsing
66// ---------------------------------------------------------------------------
67
68/** The slice of PostHog's `POST /flags?v=2` response this gate reads. Unknown
69 * keys are ignored, so PostHog adding fields cannot make the gate fail closed;
70 * a body that is not this shape at all can, which is the intent. */
71const FlagsResponse = Schema.Struct({
72 flags: Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })),
73});
74
75const decodeFlagsResponse = Schema.decodeUnknownEffect(FlagsResponse);
76
77// ---------------------------------------------------------------------------
78// Detached work
79// ---------------------------------------------------------------------------
80
81/**
82 * Hand a request to the platform and stop caring about it.
83 *
84 * `waitUntil` is the correct owner when the caller has an `ExecutionContext`;
85 * the executor composition seam this gate is installed through does not receive
86 * one, so the default owner attaches a terminal rejection handler and lets the
87 * in-flight request ride out the rest of the response. Either way the promise
88 * is owned by something that cannot report back, which is what makes an event
89 * structurally unable to fail or delay a connect.
90 */
91const detach = (
92 work: Promise<unknown>,
93 waitUntil: ((work: Promise<unknown>) => void) | undefined,
94): void => {
95 const settled = work.then(
96 () => undefined,
97 () => undefined,
98 );
99 if (waitUntil) waitUntil(settled);
100};
101
102// ---------------------------------------------------------------------------
103// The gate
104// ---------------------------------------------------------------------------
105
106export interface PostHogRolloutConfig {
107 /** Public project key (`phc_…`). Public by design — it is already shipped to
108 * every browser — so this is a var, not a secret. */
109 readonly projectKey: string;
110 /** PostHog API origin (no trailing slash), e.g. `https://us.i.posthog.com`. */
111 readonly host: string;
112 readonly fetch: typeof globalThis.fetch;
113 readonly timeoutMs?: number;
114 /** Platform post-response hook, when the caller has one. */
115 readonly waitUntil?: (work: Promise<unknown>) => void;
116}
117
118const withheld = (
119 reason: "disabled" | "evaluation-unavailable",
120): EnterpriseManagedRolloutDecision => ({ kind: "withheld", reason });
121
122const ENABLED: EnterpriseManagedRolloutDecision = { kind: "enabled" };
123
124/** Group context for a connect whose organization the host named. */
125const groupsFor = (context: EnterpriseManagedRolloutContext): Record<string, string> | undefined =>
126 context.organizationId === null
127 ? undefined
128 : { [ORGANIZATION_GROUP_TYPE]: context.organizationId };
129
130/**
131 * Build cloud's enterprise-managed rollout gate over an explicit PostHog
132 * configuration. Everything environmental is a parameter so the composition
133 * root parses it once and tests drive the real code path with an injected
134 * `fetch`.
135 */
136export const makePostHogEnterpriseManagedRollout = (
137 config: PostHogRolloutConfig,
138): EnterpriseManagedRollout => {
139 const timeoutMs = config.timeoutMs ?? DEFAULT_FLAG_EVALUATION_TIMEOUT_MS;
140
141 const evaluate = Effect.fn("Cloud.EnterpriseManagedRollout.decide")(function* (
142 context: EnterpriseManagedRolloutContext,
143 ) {
144 // The rollout unit is the user. With no acting user there is no
145 // `distinct_id` to key it on, so there is no answer to be had — withhold
146 // rather than invent a rollout bucket.
147 const distinctId = context.userId;
148 if (distinctId === null) return withheld("evaluation-unavailable");
149
150 const groups = groupsFor(context);
151 const response = yield* Effect.tryPromise(() =>
152 config.fetch(`${config.host}/flags?v=2`, {
153 method: "POST",
154 headers: {
155 "content-type": "application/json",
156 // Declares this a server-side evaluation, which is what selects
157 // server-runtime flags in PostHog's runtime detection.
158 "user-agent": "executor-cloud",
159 },
160 body: JSON.stringify({
161 api_key: config.projectKey,
162 distinct_id: distinctId,
163 ...(groups === undefined ? {} : { groups }),
164 }),
165 signal: AbortSignal.timeout(timeoutMs),
166 }),
167 );
168 if (!response.ok) return withheld("evaluation-unavailable");
169
170 const body = yield* Effect.tryPromise(() => response.json() as Promise<unknown>);
171 const decoded = yield* decodeFlagsResponse(body);
172 const flag = decoded.flags[ENTERPRISE_MANAGED_AUTH_FLAG_KEY];
173 // A flag PostHog did not return is a flag that is not on for this user
174 // (it may not exist yet, or the project may be quota-limited). That is a
175 // real "no", not a failure to answer.
176 if (flag === undefined || !flag.enabled) return withheld("disabled");
177 return ENABLED;
178 });
179
180 return {
181 decide: (context) =>
182 evaluate(context).pipe(
183 // Timeout, transport failure and an unparseable body all land here and
184 // all mean the same thing: no answer, so no enterprise-managed attempt.
185 // `catchCause` rather than `catchAll` because a defect in this adapter
186 // must not become a failed connect either.
187 Effect.catchCause(() => Effect.succeed(withheld("evaluation-unavailable"))),
188 ),
189 record: (event) =>
190 Effect.sync(() => {
191 const distinctId = event.context.userId;
192 // Same reason `decide` withholds: an event with no person to attach to
193 // would only pollute the project with anonymous rows.
194 if (distinctId === null) return;
195 const groups = groupsFor(event.context);
196 const decision = event.decision;
197 detach(
198 config.fetch(`${config.host}/i/v0/e/`, {
199 method: "POST",
200 headers: { "content-type": "application/json" },
201 body: JSON.stringify({
202 api_key: config.projectKey,
203 event: EVENT_NAMES[event.kind],
204 distinct_id: distinctId,
205 properties: {
206 // Product metadata only. There is no field on
207 // `EnterpriseManagedRolloutEvent` that can carry a token, an
208 // identity assertion, a client secret or a scope, and none is
209 // synthesized here.
210 integration_slug: String(event.context.integration),
211 ema_flag_enabled: decision.kind === "enabled",
212 ...(decision.kind === "withheld"
213 ? { ema_flag_withheld_reason: decision.reason }
214 : {}),
215 // PostHog's own convention, so these events can be filtered by
216 // flag value in the UI alongside every other flag.
217 [`$feature/${ENTERPRISE_MANAGED_AUTH_FLAG_KEY}`]: decision.kind === "enabled",
218 ...(event.kind === "blocked-by-admin" && event.oauthErrorCode !== undefined
219 ? { oauth_error_code: event.oauthErrorCode }
220 : {}),
221 ...(groups === undefined ? {} : { $groups: groups }),
222 },
223 }),
224 }),
225 config.waitUntil,
226 );
227 }),
228 };
229};
230
231/** A gate that answers "no" to everything, for a deployment that has no PostHog
232 * configuration to evaluate against. Cloud is SUPPOSED to have one, so a
233 * missing key is a misconfiguration, and failing closed is the same choice the
234 * outage paths make. This is NOT the same as injecting no gate at all, which
235 * is how the hosts with no flag service (local, desktop, CLI, self-host) keep
236 * their existing behavior. */
237export const unavailableEnterpriseManagedRollout: EnterpriseManagedRollout = {
238 decide: () => Effect.succeed(withheld("evaluation-unavailable")),
239 record: () => Effect.void,
240};
241
242/**
243 * The gate as the cloud worker composes it: the public project key already
244 * shipped in `wrangler.jsonc`, and the same PostHog ingest origin the
245 * adblock-dodging passthrough proxies to.
246 */
247export const cloudEnterpriseManagedRollout = (): EnterpriseManagedRollout => {
248 const projectKey = env.VITE_PUBLIC_POSTHOG_KEY;
249 if (!projectKey) return unavailableEnterpriseManagedRollout;
250 return makePostHogEnterpriseManagedRollout({
251 projectKey,
252 host: env.VITE_PUBLIC_POSTHOG_HOST ?? `https://${POSTHOG_INGEST_HOST}`,
253 fetch: (input, init) => globalThis.fetch(input, init),
254 });
255};
apps/cloud/src/api.request-scope.node.test.ts+309−3
@@ -21,11 +21,32 @@ · 20 unmodified lines
21// for `acquireRelease`.21// for `acquireRelease`.
22// ---------------------------------------------------------------------------22// ---------------------------------------------------------------------------
23 23
24import { describe, it, expect } from "@effect/vitest";24import { describe, it, expect, beforeEach } from "@effect/vitest";
25import { Context, Effect, Layer } from "effect";25import { Context, Effect, Layer } from "effect";
26import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http";26import {
27 HttpRouter,
28 HttpServer,
29 HttpServerRequest,
30 HttpServerResponse,
31} from "effect/unstable/http";
27 32
28import { requestScopedMiddleware } from "@executor-js/api/server";33import {
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";
47import { collectTables } from "@executor-js/sdk";
48import { resetSubjectTouchCache } from "@executor-js/sdk/host-internal";
49import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing";
29 50
30import { RequestScopedServicesLive } from "./api/layers";51import { RequestScopedServicesLive } from "./api/layers";
31import { makeApiLive } from "./api/router";52import { makeApiLive } from "./api/router";
@@ -195,3 +216,288 @@ describe("makeApiLive (prod handler factory) request scoping", () => { · 163 unmodified lines
195 expect(counts.releases).toBe(2);216 expect(counts.releases).toBe(2);
196 });217 });
197});218});
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
245interface TestDbHandle {
246 readonly id: number;
247 readonly db: SqliteTestFumaDb;
248 closed: boolean;
249}
250
251class TestDb extends Context.Service<TestDb, TestDbHandle>()("test/TestDb") {}
252
253interface 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
281const noopCodeExecutor: CodeExecutor = {
282 execute: () => Effect.succeed({ result: undefined, logs: [] }),
283};
284
285const 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 */
300const 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 */
330const 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 */
353const 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
385const 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
412const 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
426describe("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
13 Effect.succeed(13 Effect.succeed(
14 value === "valid_user_key"14 value === "valid_user_key"
15 ? {15 ? {
16 scope: "user" as const,
16 accountId: "user_123",17 accountId: "user_123",
17 organizationId: "org_123",18 organizationId: "org_123",
18 keyId: "api_key_123",19 keyId: "api_key_123",
@@ -22,6 +23,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 3 unmodified lines
22 listUserKeys: () => Effect.succeed([]),23 listUserKeys: () => Effect.succeed([]),
23 createUserKey: () => Effect.die("protected API auth test does not create API keys"),24 createUserKey: () => Effect.die("protected API auth test does not create API keys"),
24 revokeUserKey: () => Effect.void,25 revokeUserKey: () => Effect.void,
26 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
27 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
28 revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
25});29});
26 30
27const stubWorkOS = Layer.succeed(31const stubWorkOS = Layer.succeed(
@@ -43,7 +47,7 @@ const stubWorkOS = Layer.succeed( · 15 unmodified lines
43);47);
44 48
45const stubUsers = Layer.succeed(UserStoreService)({49const stubUsers = Layer.succeed(UserStoreService)({
46 use: (fn) =>50 use: (_op, fn) =>
47 Effect.promise(() =>51 Effect.promise(() =>
48 fn({52 fn({
49 ensureAccount: async (id: string) => ({ id, createdAt }),53 ensureAccount: async (id: string) => ({ id, createdAt }),
@@ -65,6 +69,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 15 unmodified lines
65 slug,69 slug,
66 createdAt,70 createdAt,
67 }),71 }),
72 deleteOrganizationCascade: async () => {},
68 }),73 }),
69 ),74 ),
70});75});
@@ -84,6 +89,7 @@ describe("protected API key auth", () => { · 13 unmodified lines
84 );89 );
85 90
86 expect(identity).toEqual({91 expect(identity).toEqual({
92 kind: "member",
87 accountId: "user_123",93 accountId: "user_123",
88 organizationId: "org_123",94 organizationId: "org_123",
89 organizationName: "Org org_123",95 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
40 listUserKeys: () => Effect.succeed([]),40 listUserKeys: () => Effect.succeed([]),
41 createUserKey: () => Effect.die("JWT auth test does not create API keys"),41 createUserKey: () => Effect.die("JWT auth test does not create API keys"),
42 revokeUserKey: () => Effect.void,42 revokeUserKey: () => Effect.void,
43 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
44 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
45 revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
43});46});
44 47
45const stubWorkOS = Layer.succeed(48const stubWorkOS = Layer.succeed(
@@ -61,7 +64,7 @@ const stubWorkOS = Layer.succeed( · 15 unmodified lines
61);64);
62 65
63const stubUsers = Layer.succeed(UserStoreService)({66const stubUsers = Layer.succeed(UserStoreService)({
64 use: (fn) =>67 use: (_op, fn) =>
65 Effect.promise(() =>68 Effect.promise(() =>
66 fn({69 fn({
67 ensureAccount: async (id: string) => ({ id, createdAt }),70 ensureAccount: async (id: string) => ({ id, createdAt }),
@@ -83,6 +86,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 15 unmodified lines
83 slug,86 slug,
84 createdAt,87 createdAt,
85 }),88 }),
89 deleteOrganizationCascade: async () => {},
86 }),90 }),
87 ),91 ),
88});92});
@@ -106,6 +110,7 @@ describe("protected JWT (device-login) auth", () => { · 17 unmodified lines
106 const identity = yield* run(request(token), config);110 const identity = yield* run(request(token), config);
107 111
108 expect(identity).toEqual({112 expect(identity).toEqual({
113 kind: "member",
109 accountId: "user_123",114 accountId: "user_123",
110 organizationId: "org_123",115 organizationId: "org_123",
111 organizationName: "Org org_123",116 organizationName: "Org org_123",
@@ -143,7 +148,10 @@ describe("protected JWT (device-login) auth", () => { · 31 unmodified lines
143 148
144 const error = yield* Effect.flip(run(request(token), config));149 const error = yield* Effect.flip(run(request(token), config));
145 150
146 expect(error).toMatchObject({ _tag: "Unauthorized", code: "invalid_access_token" });151 expect(error).toMatchObject({
152 _tag: "Unauthorized",
153 code: "invalid_access_token",
154 });
147 }),155 }),
148 );156 );
149 157
@@ -154,7 +162,10 @@ describe("protected JWT (device-login) auth", () => { · 4 unmodified lines
154 162
155 const error = yield* Effect.flip(run(request(token), config));163 const error = yield* Effect.flip(run(request(token), config));
156 164
157 expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" });165 expect(error).toMatchObject({
166 _tag: "NoOrganization",
167 code: "no_organization",
168 });
158 }),169 }),
159 );170 );
160 171
@@ -166,7 +177,10 @@ describe("protected JWT (device-login) auth", () => { · 5 unmodified lines
166 177
167 const error = yield* Effect.flip(run(request(token), config));178 const error = yield* Effect.flip(run(request(token), config));
168 179
169 expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" });180 expect(error).toMatchObject({
181 _tag: "NoOrganization",
182 code: "no_organization",
183 });
170 }),184 }),
171 );185 );
172});186});
apps/cloud/src/api/protected.test.ts+2−0
@@ -20,6 +20,8 @@ const makeBaseEngine = (): ExecutionEngine => · 19 unmodified lines
20 pausedExecutionCount: () => Effect.succeed(0),20 pausedExecutionCount: () => Effect.succeed(0),
21 hasPausedExecutions: () => Effect.succeed(false),21 hasPausedExecutions: () => Effect.succeed(false),
22 getDescription: Effect.succeed("desc"),22 getDescription: Effect.succeed("desc"),
23 // The fake forks nothing, so there is no sandbox fiber to end.
24 shutdown: Effect.void,
23 }) as ExecutionEngine;25 }) as ExecutionEngine;
24 26
25describe("withExecutionUsageTracking", () => {27describe("withExecutionUsageTracking", () => {
apps/cloud/src/app-paths.test.ts+37−1
@@ -1,6 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { isAppOwnedPath } from "./app-paths";3import { isAppOwnedPath, servedByAppPlane } from "./app-paths";
4 4
5// Guards the start.ts dispatch decision: every surface the unified app handler5// Guards the start.ts dispatch decision: every surface the unified app handler
6// serves must be classified app-owned (forwarded to `app.handler`), and Start's6// serves must be classified app-owned (forwarded to `app.handler`), and Start's
@@ -62,3 +62,39 @@ describe("isAppOwnedPath", () => { · 55 unmodified lines
62 });62 });
63 }63 }
64});64});
65
66describe("app-plane dispatch", () => {
67 // These two are the whole risk of dispatching `/api` before Start: both still
68 // return a response if routed early, just the wrong one, so nothing else would
69 // catch a regression here.
70 it("leaves the Sentry tunnel POST to Start's middleware", () => {
71 expect(servedByAppPlane("/api/sentry-tunnel", "POST")).toBe(false);
72 // Only the POST is claimed; anything else under that path is ordinary API.
73 expect(servedByAppPlane("/api/sentry-tunnel", "GET")).toBe(true);
74 });
75
76 it("leaves the OAuth callback to Start, for the signed-out redirect", () => {
77 expect(servedByAppPlane("/api/oauth/callback", "GET")).toBe(false);
78 expect(servedByAppPlane("/api/oauth/callback", "POST")).toBe(false);
79 });
80
81 const appPlane = [
82 "/api/connections",
83 "/api/tools",
84 "/api/integrations",
85 "/api/account/members",
86 "/api/docs",
87 "/api/billing/checkout",
88 ];
89 for (const pathname of appPlane) {
90 it(`serves ${pathname} without entering Start`, () => {
91 expect(servedByAppPlane(pathname, "GET")).toBe(true);
92 });
93 }
94
95 it("never claims a non-API path, however app-owned", () => {
96 expect(servedByAppPlane("/mcp", "POST")).toBe(false);
97 expect(servedByAppPlane("/", "GET")).toBe(false);
98 expect(servedByAppPlane("/.well-known/oauth-authorization-server", "GET")).toBe(false);
99 });
100});
apps/cloud/src/app-paths.ts+28−0
@@ -17,3 +17,31 @@ export const isApiPath = (pathname: string) => pathname === "/api" || pathname.s · 16 unmodified lines
17 17
18export const isAppOwnedPath = (pathname: string) =>18export const isAppOwnedPath = (pathname: string) =>
19 isApiPath(pathname) || classifyMcpPath(pathname) !== null;19 isApiPath(pathname) || classifyMcpPath(pathname) !== null;
20
21// ---------------------------------------------------------------------------
22// Which plane serves an app-owned path: the Effect app directly, or TanStack
23// Start's middleware chain.
24//
25// Everything under `/api` is pure Effect and touches no part of the router,
26// React, or SSR — so `server.ts` dispatches it at the Worker entry and skips
27// Start's lazy `loadEntries` import entirely. Two paths must NOT take that
28// shortcut, because Start's request middleware claims them BEFORE the app
29// handler would ever see them:
30//
31// POST /api/sentry-tunnel - `sentryTunnelMiddleware` forwards the envelope
32// to Sentry; the app has no such route.
33// /api/oauth/callback - `oauthCallbackSignInMiddleware` redirects a
34// signed-out visitor to /login, and start.ts
35// rewrites the org-scoped `state` before handing
36// off. Routing it early would drop both.
37//
38// Getting this wrong is silent: the request still gets a response, just the
39// wrong one, which is why it is classified here and tested rather than being
40// an inline condition at the dispatch site.
41// ---------------------------------------------------------------------------
42
43export const isStartOwnedApiPath = (pathname: string, method: string): boolean =>
44 (pathname === "/api/sentry-tunnel" && method === "POST") || pathname === "/api/oauth/callback";
45
46export const servedByAppPlane = (pathname: string, method: string): boolean =>
47 isApiPath(pathname) && !isStartOwnedApiPath(pathname, method);
apps/cloud/src/app.ts+2−7
@@ -13,7 +13,6 @@ import { ApiKeyService } from "./auth/api-keys"; · 12 unmodified lines
13import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider";13import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider";
14import { DbService } from "./db/db";14import { DbService } from "./db/db";
15import { cloudMcpAuth } from "./mcp";15import { cloudMcpAuth } from "./mcp";
16import { McpSessionDOSqlite } from "./mcp/session-durable-object";
17import { ErrorCaptureLive } from "./observability";16import { ErrorCaptureLive } from "./observability";
18import { AutumnService } from "./extensions/billing/service";17import { AutumnService } from "./extensions/billing/service";
19import {18import {
@@ -31,7 +30,7 @@ import { WorkerTelemetryLive } from "./observability/telemetry"; · 11 unmodified lines
31// The whole scenario in 60 seconds: WorkOS identity (api-key Bearer OR sealed-30// The whole scenario in 60 seconds: WorkOS identity (api-key Bearer OR sealed-
32// session cookie, api-key wins) over a per-request Hyperdrive→Postgres socket,31// session cookie, api-key wins) over a per-request Hyperdrive→Postgres socket,
33// the Cloudflare dynamic-worker code substrate, MCP served by a Durable-Object32// the Cloudflare dynamic-worker code substrate, MCP served by a Durable-Object
34// session store (the DO surfaced via `config.mcpExport`), console+Sentry error33// session store (the DO class is exported straight from server.ts), console+Sentry error
35// capture — and Autumn BILLING entering ONLY as extensions: the engine34// capture — and Autumn BILLING entering ONLY as extensions: the engine
36// metering decorator, the account seat-gate, the `/api/billing/*` proxy route,35// metering decorator, the account seat-gate, the `/api/billing/*` proxy route,
37// and the createOrganization free-limit gate. `diff` against36// and the createOrganization free-limit gate. `diff` against
@@ -66,7 +65,7 @@ const controlPlane = Layer.mergeAll(CoreSharedServices, apiKeyService); · 28 unmodified lines
66// satisfied by `boot`, `DbService` per request via `requestScoped`).65// satisfied by `boot`, `DbService` per request via `requestScoped`).
67const cloudDb: Layer.Layer<DbProvider, never, DbService | AutumnService> = CloudDbProvider;66const cloudDb: Layer.Layer<DbProvider, never, DbService | AutumnService> = CloudDbProvider;
68 67
69const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({68const { appLayer, toWebHandler } = ExecutorApp.make({
70 plugins: cloudPlugins,69 plugins: cloudPlugins,
71 providers: {70 providers: {
72 // Identity: the NEUTRAL `IdentityProvider`. WorkOS api-key Bearer BEATS71 // Identity: the NEUTRAL `IdentityProvider`. WorkOS api-key Bearer BEATS
@@ -112,7 +111,6 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ · 39 unmodified lines
112 failure: cloudIdentityFailureStrategy,111 failure: cloudIdentityFailureStrategy,
113 // The MCP session Durable Object class — a top-level Workers export a Layer112 // The MCP session Durable Object class — a top-level Workers export a Layer
114 // can't return; surfaced so `server.ts` can re-export it.113 // can't return; surfaced so `server.ts` can re-export it.
115 mcpExport: McpSessionDOSqlite,
116 },114 },
117 // The long-lived (boot-scoped) context provideMerge'd under everything: the115 // The long-lived (boot-scoped) context provideMerge'd under everything: the
118 // WorkOS control plane (the raw `WorkOSClient` + `ApiKeyService` the per-request116 // WorkOS control plane (the raw `WorkOSClient` + `ApiKeyService` the per-request
@@ -135,10 +133,7 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ · 16 unmodified lines
135 requestScoped: RequestScopedServicesLive,133 requestScoped: RequestScopedServicesLive,
136});134});
137 135
138export { McpSessionDOSqlite };
139
140export const CloudAppLayer = appLayer;136export const CloudAppLayer = appLayer;
141export const cloudMcpExport = mcpExport;
142 137
143// The unified cloud web handler: serves /api/* (incl. /api/billing/*, /api/docs),138// The unified cloud web handler: serves /api/* (incl. /api/billing/*, /api/docs),
144// /mcp, /.well-known/* — everything the worker dispatches.139// /mcp, /.well-known/* — everything the worker dispatches.
apps/cloud/src/auth/api-keys.node.test.ts+23−8
@@ -42,6 +42,7 @@ describe("ApiKeyService.WorkOS", () => { · 41 unmodified lines
42 });42 });
43 43
44 expect(principal).toEqual({44 expect(principal).toEqual({
45 scope: "user",
45 accountId: "user_123",46 accountId: "user_123",
46 organizationId: "org_123",47 organizationId: "org_123",
47 keyId: "api_key_123",48 keyId: "api_key_123",
@@ -66,15 +67,9 @@ describe("ApiKeyService.WorkOS", () => { · 18 unmodified lines
66 }),67 }),
67 );68 );
68 69
69 it.effect("rejects missing, organization-owned, and org-less keys", () =>70 it.effect("rejects missing and org-less keys", () =>
70 Effect.gen(function* () {71 Effect.gen(function* () {
71 const missing = yield* validate({ apiKey: null });72 const missing = yield* validate({ apiKey: null });
72 const orgOwned = yield* validate({
73 apiKey: {
74 id: "api_key_org",
75 owner: { type: "organization", id: "org_123" },
76 },
77 });
78 const orgLess = yield* validate({73 const orgLess = yield* validate({
79 apiKey: {74 apiKey: {
80 id: "api_key_no_org",75 id: "api_key_no_org",
@@ -83,11 +78,31 @@ describe("ApiKeyService.WorkOS", () => { · 2 unmodified lines
83 });78 });
84 79
85 expect(missing).toBeNull();80 expect(missing).toBeNull();
86 expect(orgOwned).toBeNull();
87 expect(orgLess).toBeNull();81 expect(orgLess).toBeNull();
88 }),82 }),
89 );83 );
90 84
85 it.effect("resolves organization-owned keys to the org scope with no account", () =>
86 Effect.gen(function* () {
87 // An org key USED to fail the decode and come back null — indistinguishable
88 // from an invalid key. It now resolves explicitly: `owner.id` IS the
89 // organization, and there is no member behind it.
90 const principal = yield* validate({
91 apiKey: {
92 id: "api_key_org",
93 owner: { type: "organization", id: "org_123" },
94 },
95 });
96
97 expect(principal).toEqual({
98 scope: "org",
99 accountId: null,
100 organizationId: "org_123",
101 keyId: "api_key_org",
102 });
103 }),
104 );
105
91 it.effect("lists and creates user-owned keys", () =>106 it.effect("lists and creates user-owned keys", () =>
92 Effect.gen(function* () {107 Effect.gen(function* () {
93 const program = Effect.gen(function* () {108 const program = Effect.gen(function* () {
apps/cloud/src/auth/api-keys.ts+214−12
@@ -3,10 +3,24 @@ import { Context, Data, Effect, Layer, Option, Schema } from "effect"; · 2 unmodified lines
3import { ApiKeyManagementError } from "./errors";3import { ApiKeyManagementError } from "./errors";
4import { WorkOSClient } from "./workos";4import { WorkOSClient } from "./workos";
5 5
6/**
7 * Which view a validated key resolves to.
8 * - `"user"` — the product view. The key belongs to a member; the request
9 * binds to that member as the acting subject. Every key today is this.
10 * - `"org"` — the PLATFORM view. The key belongs to the organization itself,
11 * so there is no acting member: the request gets tenant-wide READ-ONLY
12 * reach and no bound subject. Privileged; minted separately (PR 1.5).
13 */
14export type ApiKeyScope = "user" | "org";
15
6/** The owner an api key resolves to — NOT a full {@link Principal} (no email /16/** The owner an api key resolves to — NOT a full {@link Principal} (no email /
7 * name / roles), so it carries an honest, distinct name. */17 * name / roles), so it carries an honest, distinct name. */
8export type ApiKeyOwner = {18export type ApiKeyOwner = {
9 readonly accountId: string;19 readonly scope: ApiKeyScope;
20 /** The acting member for a `"user"` key; `null` for an `"org"` key, which
21 * has no member behind it. Nullable rather than a sentinel so nothing can
22 * accidentally bind an org key to a subject. */
23 readonly accountId: string | null;
10 readonly organizationId: string;24 readonly organizationId: string;
11 readonly keyId: string;25 readonly keyId: string;
12};26};
@@ -28,6 +42,21 @@ export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationErr · 15 unmodified lines
28 readonly cause: unknown;42 readonly cause: unknown;
29}> {}43}> {}
30 44
45/**
46 * The requested key id is not an org-owned key of this organization — it does
47 * not exist, belongs to a member rather than the org, or belongs to a different
48 * org in the same WorkOS workspace. Distinct from `ApiKeyManagementError` (an
49 * upstream failure) so callers can answer "not found" without a 500.
50 */
51export class OrgApiKeyNotFound extends Data.TaggedError("OrgApiKeyNotFound")<{
52 readonly keyId: string;
53}> {}
54
55// WorkOS keys are owned by either a user or an organization. The user shape
56// carries the member id in `id` and the org in `organization_id`; the org shape
57// carries the ORG in `id` and no member at all. Both decode here — the
58// distinction is a branch (`ownerFromApiKey`), not a decode failure, so an
59// org-owned key is no longer indistinguishable from an invalid one.
31const UserApiKeyOwner = Schema.Struct({60const UserApiKeyOwner = Schema.Struct({
32 type: Schema.Literal("user"),61 type: Schema.Literal("user"),
33 id: Schema.String,62 id: Schema.String,
@@ -35,6 +64,13 @@ const UserApiKeyOwner = Schema.Struct({ · 1 unmodified line
35 organization_id: Schema.optional(Schema.String),64 organization_id: Schema.optional(Schema.String),
36});65});
37 66
67const OrgApiKeyOwner = Schema.Struct({
68 type: Schema.Literal("organization"),
69 id: Schema.String,
70});
71
72const WorkOSApiKeyOwner = Schema.Union([UserApiKeyOwner, OrgApiKeyOwner]);
73
38const ApiKey = Schema.Struct({74const ApiKey = Schema.Struct({
39 id: Schema.String,75 id: Schema.String,
40 owner: UserApiKeyOwner,76 owner: UserApiKeyOwner,
@@ -49,8 +85,17 @@ const ApiKey = Schema.Struct({ · 8 unmodified lines
49 last_used_at: Schema.optional(Schema.NullOr(Schema.String)),85 last_used_at: Schema.optional(Schema.NullOr(Schema.String)),
50});86});
51 87
88// Validation accepts BOTH owner shapes. The user list/create paths below stay
89// user-only on purpose (they are the console's personal-key CRUD); the ORG
90// list/create paths are the separate, privileged surface beside them, gated to
91// admins at the account-provider boundary.
92const ValidatedApiKey = Schema.Struct({
93 id: Schema.String,
94 owner: WorkOSApiKeyOwner,
95});
96
52const ValidateApiKeyResponse = Schema.Struct({97const ValidateApiKeyResponse = Schema.Struct({
53 apiKey: Schema.NullOr(ApiKey),98 apiKey: Schema.NullOr(ValidatedApiKey),
54});99});
55 100
56const RawCreatedApiKey = Schema.Struct({101const RawCreatedApiKey = Schema.Struct({
@@ -72,6 +117,45 @@ const ListApiKeysResponse = Schema.Struct({ · 15 unmodified lines
72 data: Schema.Array(ApiKey),117 data: Schema.Array(ApiKey),
73});118});
74 119
120// The ORG-owned mirror of `ApiKey` / `RawCreatedApiKey`. A separate shape
121// rather than a widened one: `summaryFromApiKey` reads the user shape's
122// `organization_id` to prove the key belongs to the caller's org, and an
123// org-owned row carries the org in `owner.id` instead. Merging them would mean
124// one nullable field standing for two different things.
125const OrgApiKey = Schema.Struct({
126 id: Schema.String,
127 owner: OrgApiKeyOwner,
128 name: Schema.optional(Schema.String),
129 obfuscatedValue: Schema.optional(Schema.String),
130 obfuscated_value: Schema.optional(Schema.String),
131 createdAt: Schema.optional(Schema.String),
132 created_at: Schema.optional(Schema.String),
133 updatedAt: Schema.optional(Schema.String),
134 updated_at: Schema.optional(Schema.String),
135 lastUsedAt: Schema.optional(Schema.NullOr(Schema.String)),
136 last_used_at: Schema.optional(Schema.NullOr(Schema.String)),
137});
138
139const RawCreatedOrgApiKey = Schema.Struct({
140 ...OrgApiKey.fields,
141 value: Schema.String,
142});
143
144// `data` is decoded element-by-element, NOT as `Schema.Array(OrgApiKey)`: WorkOS
145// may return user-owned keys alongside org-owned ones, and a whole-array decode
146// fails on the first non-org element — blanking the entire list rather than
147// dropping the one row that doesn't belong. Same per-row tolerance the user-key
148// path gets from `summaryFromApiKey` returning null.
149const ListOrgApiKeysResponse = Schema.Struct({
150 data: Schema.Array(Schema.Unknown),
151});
152
153const CreateOrgApiKeyResponse = Schema.Union([
154 RawCreatedOrgApiKey,
155 Schema.Struct({ apiKey: RawCreatedOrgApiKey }),
156 Schema.Struct({ api_key: RawCreatedOrgApiKey }),
157]);
158
75const CreateApiKeyResponse = Schema.Union([159const CreateApiKeyResponse = Schema.Union([
76 RawCreatedApiKey,160 RawCreatedApiKey,
77 Schema.Struct({ apiKey: RawCreatedApiKey }),161 Schema.Struct({ apiKey: RawCreatedApiKey }),
@@ -81,20 +165,35 @@ const CreateApiKeyResponse = Schema.Union([ · 3 unmodified lines
81const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse);165const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse);
82const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse);166const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse);
83const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse);167const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse);
168const decodeListOrgApiKeysResponse = Schema.decodeUnknownOption(ListOrgApiKeysResponse);
169const decodeOrgApiKey = Schema.decodeUnknownOption(OrgApiKey);
170const decodeCreateOrgApiKeyResponse = Schema.decodeUnknownOption(CreateOrgApiKeyResponse);
171
172const ownerFromApiKey = (apiKey: typeof ValidatedApiKey.Type): ApiKeyOwner | null => {
173 if (apiKey.owner.type === "organization") {
174 // An org-owned key IS the org: `owner.id` is the organization, and there
175 // is no member to act as. This resolves to the platform view.
176 return {
177 scope: "org",
178 accountId: null,
179 organizationId: apiKey.owner.id,
180 keyId: apiKey.id,
181 };
182 }
183 const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id;
184 if (!organizationId) return null;
185 return {
186 scope: "user",
187 accountId: apiKey.owner.id,
188 organizationId,
189 keyId: apiKey.id,
190 };
191};
84 192
85const ownerFromResponse = (value: unknown): ApiKeyOwner | null =>193const ownerFromResponse = (value: unknown): ApiKeyOwner | null =>
86 Option.match(decodeValidateApiKeyResponse(value), {194 Option.match(decodeValidateApiKeyResponse(value), {
87 onNone: () => null,195 onNone: () => null,
88 onSome: ({ apiKey }) => {196 onSome: ({ apiKey }) => (apiKey ? ownerFromApiKey(apiKey) : null),
89 if (!apiKey) return null;
90 const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id;
91 if (!organizationId) return null;
92 return {
93 accountId: apiKey.owner.id,
94 organizationId,
95 keyId: apiKey.id,
96 };
97 },
98 });197 });
99 198
100const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => {199const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => {
@@ -120,6 +219,53 @@ const listFromResponse = (value: unknown): readonly ApiKeySummary[] => · 19 unmodified lines
120 }),219 }),
121 });220 });
122 221
222// The org-owned mirror of `summaryFromApiKey`. `organizationId` is checked by
223// the CALLER against the org it asked for: WorkOS's workspace api key is
224// workspace-wide, so a response naming a different org than the one requested
225// must not be surfaced as that org's key.
226const summaryFromOrgApiKey = (
227 apiKey: typeof OrgApiKey.Type,
228 organizationId: string,
229): ApiKeySummary | null => {
230 if (apiKey.owner.id !== organizationId) return null;
231 return {
232 id: apiKey.id,
233 name: apiKey.name ?? "API key",
234 obfuscatedValue: apiKey.obfuscatedValue ?? apiKey.obfuscated_value ?? "",
235 createdAt: apiKey.createdAt ?? apiKey.created_at ?? "",
236 updatedAt: apiKey.updatedAt ?? apiKey.updated_at ?? "",
237 lastUsedAt: apiKey.lastUsedAt ?? apiKey.last_used_at ?? null,
238 };
239};
240
241const orgListFromResponse = (value: unknown, organizationId: string): readonly ApiKeySummary[] =>
242 Option.match(decodeListOrgApiKeysResponse(value), {
243 onNone: () => [],
244 onSome: ({ data }) =>
245 data.flatMap((entry) =>
246 Option.match(decodeOrgApiKey(entry), {
247 // Not an org-owned key (a user key sharing the listing, or a shape we
248 // don't recognize) — skip the row, keep the rest.
249 onNone: () => [],
250 onSome: (apiKey) => {
251 const summary = summaryFromOrgApiKey(apiKey, organizationId);
252 return summary ? [summary] : [];
253 },
254 }),
255 ),
256 });
257
258const orgCreatedFromResponse = (value: unknown, organizationId: string): CreatedApiKey | null =>
259 Option.match(decodeCreateOrgApiKeyResponse(value), {
260 onNone: () => null,
261 onSome: (response) => {
262 const apiKey =
263 "value" in response ? response : "apiKey" in response ? response.apiKey : response.api_key;
264 const summary = summaryFromOrgApiKey(apiKey, organizationId);
265 return summary ? { ...summary, value: apiKey.value } : null;
266 },
267 });
268
123const createdFromResponse = (value: unknown): CreatedApiKey | null =>269const createdFromResponse = (value: unknown): CreatedApiKey | null =>
124 Option.match(decodeCreateApiKeyResponse(value), {270 Option.match(decodeCreateApiKeyResponse(value), {
125 onNone: () => null,271 onNone: () => null,
@@ -147,6 +293,28 @@ export class ApiKeyService extends Context.Service< · 21 unmodified lines
147 readonly revokeUserKey: (input: {293 readonly revokeUserKey: (input: {
148 readonly keyId: string;294 readonly keyId: string;
149 }) => Effect.Effect<void, ApiKeyManagementError>;295 }) => Effect.Effect<void, ApiKeyManagementError>;
296 /**
297 * The org-owned keys of an organization — the credentials that resolve to
298 * the read-only platform view. Privileged: callers gate on admin membership
299 * before reaching this.
300 */
301 readonly listOrgKeys: (input: {
302 readonly organizationId: string;
303 }) => Effect.Effect<readonly ApiKeySummary[], ApiKeyManagementError>;
304 readonly createOrgKey: (input: {
305 readonly organizationId: string;
306 readonly name: string;
307 }) => Effect.Effect<CreatedApiKey, ApiKeyManagementError>;
308 /**
309 * Revoke an org-owned key. Ownership is resolved against the ORG's own key
310 * listing — never the caller's personal keys — so a member's user key id
311 * (or another org's key id, since the WorkOS workspace key is
312 * workspace-wide) cannot be destroyed through this path.
313 */
314 readonly revokeOrgKey: (input: {
315 readonly organizationId: string;
316 readonly keyId: string;
317 }) => Effect.Effect<void, ApiKeyManagementError | OrgApiKeyNotFound>;
150 }318 }
151>()("@executor-js/cloud/ApiKeyService") {319>()("@executor-js/cloud/ApiKeyService") {
152 static WorkOS = Layer.effect(this)(320 static WorkOS = Layer.effect(this)(
@@ -177,6 +345,40 @@ export class ApiKeyService extends Context.Service< · 24 unmodified lines
177 workos345 workos
178 .deleteApiKey(keyId)346 .deleteApiKey(keyId)
179 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))),347 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))),
348 listOrgKeys: ({ organizationId }) =>
349 workos.listOrgApiKeys(organizationId).pipe(
350 Effect.map((response) => orgListFromResponse(response, organizationId)),
351 Effect.mapError((cause) => new ApiKeyManagementError({ cause })),
352 ),
353 createOrgKey: ({ organizationId, name }) =>
354 workos.createOrgApiKey({ organizationId, name }).pipe(
355 Effect.mapError((cause) => new ApiKeyManagementError({ cause })),
356 Effect.flatMap((response) => {
357 const created = orgCreatedFromResponse(response, organizationId);
358 return created
359 ? Effect.succeed(created)
360 : Effect.fail(new ApiKeyManagementError({ cause: "invalid_create_response" }));
361 }),
362 ),
363 // Ownership is checked against the ORG's decoded key listing before the
364 // delete goes out. `deleteApiKey` is scope-blind — handed any key id it
365 // would happily destroy a member's personal credential, or a key
366 // belonging to a sibling org in the same workspace. The listing already
367 // drops both of those (`orgListFromResponse` filters on owner type AND
368 // organization), so filtering through it is the whole authorization.
369 revokeOrgKey: ({ organizationId, keyId }) =>
370 Effect.gen(function* () {
371 const response = yield* workos
372 .listOrgApiKeys(organizationId)
373 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));
374 const owned = orgListFromResponse(response, organizationId);
375 if (!owned.some((key) => key.id === keyId)) {
376 return yield* new OrgApiKeyNotFound({ keyId });
377 }
378 yield* workos
379 .deleteApiKey(keyId)
380 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));
381 }),
180 };382 };
181 }),383 }),
182 );384 );
apps/cloud/src/auth/api.ts+40−2
@@ -44,6 +44,16 @@ const CreateOrganizationResponse = Schema.Struct({ · 43 unmodified lines
44 slug: Schema.String,44 slug: Schema.String,
45});45});
46 46
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.
49const DeleteOrganizationBody = Schema.Struct({
50 confirmName: Schema.String,
51});
52
53const DeleteOrganizationResponse = Schema.Struct({
54 success: Schema.Boolean,
55});
56
47// CLI device-login discovery (`executor login`). Tells the CLI where to run57// CLI device-login discovery (`executor login`). Tells the CLI where to run
48// the OAuth 2.0 Device Authorization Grant (RFC 8628) and which public client58// the OAuth 2.0 Device Authorization Grant (RFC 8628) and which public client
49// to use. The CLI hits these provider endpoints directly, gets a WorkOS access59// 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
146 { httpApiStatus: 403 },156 { httpApiStatus: 403 },
147) {}157) {}
148 158
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.
162export class OrganizationDeletionForbidden extends Schema.TaggedErrorClass<OrganizationDeletionForbidden>()(
163 "OrganizationDeletionForbidden",
164 {},
165 { httpApiStatus: 403 },
166) {}
167
149export const AUTH_PATHS = {168export const AUTH_PATHS = {
150 login: "/api/auth/login",169 login: "/api/auth/login",
151 logout: "/api/auth/logout",170 logout: "/api/auth/logout",
@@ -162,13 +181,26 @@ const McpApprovalErrors = [ · 10 unmodified lines
162/** Public auth endpoints — no authentication required */181/** Public auth endpoints — no authentication required */
163export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")182export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")
164 .add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch }))183 .add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch }))
184 // Sign-out is PUBLIC on purpose. The console posts it as a top-level form
185 // navigation (the WorkOS hop is cross-origin, so it can't be a fetch), which
186 // means an error response is rendered as the page — and a browser whose
187 // session has already ended is exactly the browser most likely to click it
188 // (a second tab, a re-submit from history, an expired or revoked session).
189 // Behind SessionAuth all of those got a raw `{"_tag":"Unauthorized"}` screen
190 // instead of being signed out. There is nothing to authorize here anyway:
191 // the request can only end the session whose cookie it presents.
192 .add(HttpApiEndpoint.post("logout", "/auth/logout"))
165 .add(193 .add(
166 HttpApiEndpoint.get("callback", "/auth/callback", {194 HttpApiEndpoint.get("callback", "/auth/callback", {
167 query: AuthCallbackSearch,195 query: AuthCallbackSearch,
168 error: AuthErrors,196 error: AuthErrors,
169 }),197 }),
170 )198 )
171 .add(HttpApiEndpoint.get("cliLogin", "/auth/cli-login", { success: CliLoginResponse })) {}199 .add(
200 HttpApiEndpoint.get("cliLogin", "/auth/cli-login", {
201 success: CliLoginResponse,
202 }),
203 ) {}
172 204
173/** Session auth endpoints — require a logged-in user, may not have an org */205/** Session auth endpoints — require a logged-in user, may not have an org */
174export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")206export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
@@ -178,7 +210,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 3 unmodified lines
178 error: AuthErrors,210 error: AuthErrors,
179 }),211 }),
180 )212 )
181 .add(HttpApiEndpoint.post("logout", "/auth/logout"))
182 .add(213 .add(
183 HttpApiEndpoint.get("organizations", "/auth/organizations", {214 HttpApiEndpoint.get("organizations", "/auth/organizations", {
184 success: AuthOrganizationsResponse,215 success: AuthOrganizationsResponse,
@@ -192,6 +223,13 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 7 unmodified lines
192 error: AuthErrors,223 error: AuthErrors,
193 }),224 }),
194 )225 )
226 .add(
227 HttpApiEndpoint.post("deleteOrganization", "/auth/delete-organization", {
228 payload: DeleteOrganizationBody,
229 success: DeleteOrganizationResponse,
230 error: [...AuthErrors, NoOrganization, OrganizationDeletionForbidden],
231 }),
232 )
195 .add(233 .add(
196 HttpApiEndpoint.get("pendingInvitations", "/auth/pending-invitations", {234 HttpApiEndpoint.get("pendingInvitations", "/auth/pending-invitations", {
197 success: PendingInvitationsResponse,235 success: PendingInvitationsResponse,
apps/cloud/src/auth/context.ts+9−4
@@ -1,7 +1,7 @@
1import { Context, Effect, Layer } from "effect";1import { Context, Effect, Layer } from "effect";
2import { makeUserStore } from "../auth/user-store";2import { makeUserStore } from "../auth/user-store";
3import { DbService } from "../db/db";3import { DbService } from "../db/db";
4import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors";4import { tryPromiseService, userStoreErrorFromFailure, withServiceLogging } from "./errors";
5 5
6// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
7// UserStoreService — wraps the Drizzle-backed user store with Effect7// UserStoreService — wraps the Drizzle-backed user store with Effect
@@ -9,11 +9,16 @@ import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors" · 1 unmodified line
9 9
10type RawStore = ReturnType<typeof makeUserStore>;10type RawStore = ReturnType<typeof makeUserStore>;
11 11
12// `op` names the store call so every span reads `user_store.<operation>`
13// instead of one undifferentiated "user_store" bucket, failures log which
14// query actually failed, and — because the same `op` is threaded onto the
15// public error alongside the classified driver reason — an error report is
16// diagnosable without the trace.
12const makeService = (store: RawStore) => ({17const makeService = (store: RawStore) => ({
13 use: <A>(fn: (s: RawStore) => Promise<A>) =>18 use: <A>(op: string, fn: (s: RawStore) => Promise<A>) =>
14 withServiceLogging(19 withServiceLogging(
15 "user_store",20 `user_store.${op}`,
16 () => new UserStoreError(),21 (failure) => userStoreErrorFromFailure(op, failure),
17 tryPromiseService(() => fn(store)),22 tryPromiseService(() => fn(store)),
18 ),23 ),
19});24});
apps/cloud/src/auth/doc-gate.tsrenamed from apps/cloud/src/auth/ssr-gate.ts+71−23
@@ -1,19 +1,23 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// SSR auth gate — the server-side session check for DOCUMENT requests.2// Document auth gate — the server-side session check for DOCUMENT requests.
3//3//
4// The sealed `wos-session` cookie is verified right here in the worker4// The sealed `wos-session` cookie is verified right here in the worker
5// (unseal + JWT check against cached JWKS — no per-request WorkOS round trip5// (unseal + JWT check against cached JWKS — no per-request WorkOS round trip
6// except token refresh), so by the time the SPA is served the server KNOWS6// except token refresh), so by the time the SPA shell is served the server
7// who it's serving:7// KNOWS who it's serving:
8//8//
9// - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists9// - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists
10// - org-less → 302 /create-org (onboarding owns those sessions)10// - org-less → 302 /create-org (onboarding owns those sessions)
11// - signed in → the document is served WITH the verified identity: the11// - signed in → the prerendered shell is served, and the auth-hint cookie is
12// auth-hint travels to the SSR render via request-middleware context (the12// minted when the browser doesn't hold a current one — the client seeds its
13// root loader picks it up), and is minted as a cookie when the browser13// auth state from that cookie right after mount (there is no per-request
14// doesn't hold a current one — so the very first paint is the real app14// render to dehydrate it into). The hint is display-only; /account/me
15// shell, never a skeleton. The hint is display-only; /account/me remains15// remains the authority and the client keeps it fresh from then on.
16// the authority and the client keeps it fresh from then on.16//
17// Beyond redirects, this gate is load-bearing for SESSION LIFETIME: WorkOS
18// refresh tokens are single-use, and a verify that rotates the sealed session
19// must deliver the new cookie to the browser or the next expiry logs the user
20// out. Document navigations are where that rotation lands.
17//21//
18// Scope: GET/HEAD requests that are document navigations (sec-fetch-dest /22// Scope: GET/HEAD requests that are document navigations (sec-fetch-dest /
19// accept), excluding app-owned paths (/api, /mcp — they answer for themselves23// accept), excluding app-owned paths (/api, /mcp — they answer for themselves
@@ -23,6 +27,7 @@ · 3 unmodified lines
23import { createMiddleware } from "@tanstack/react-start";27import { createMiddleware } from "@tanstack/react-start";
24import { Effect, Exit, Layer, ManagedRuntime } from "effect";28import { Effect, Exit, Layer, ManagedRuntime } from "effect";
25 29
30import { isValidOrgSlug } from "@executor-js/api";
26import {31import {
27 AUTH_HINT_COOKIE,32 AUTH_HINT_COOKIE,
28 AUTH_HINT_MAX_AGE_SECONDS,33 AUTH_HINT_MAX_AGE_SECONDS,
@@ -35,8 +40,9 @@ import { isAppOwnedPath } from "../app-paths"; · 6 unmodified lines
35import { makeDbLayer } from "../db/db";40import { makeDbLayer } from "../db/db";
36import { makeUserStoreLayer, UserStoreService } from "./context";41import { makeUserStoreLayer, UserStoreService } from "./context";
37import { parseCookie } from "./cookies";42import { parseCookie } from "./cookies";
43import { LAST_ORG_COOKIE } from "./last-org-cookie";
38import { sealedSessionDisplayName } from "./middleware";44import { sealedSessionDisplayName } from "./middleware";
39import { browserOriginFromRequest } from "./request-origin";45import { authorizeOrganizationSelector } from "./organization";
40import { loginPath, safeReturnTo } from "./return-to";46import { loginPath, safeReturnTo } from "./return-to";
41import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths";47import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths";
42import { WorkOSClient } from "./workos";48import { WorkOSClient } from "./workos";
@@ -150,7 +156,7 @@ const organizationDisplay = async ( · 107 unmodified lines
150): Promise<{ name: string; slug: string }> => {156): Promise<{ name: string; slug: string }> => {
151 const exit = await getRuntime().runPromiseExit(157 const exit = await getRuntime().runPromiseExit(
152 Effect.flatMap(UserStoreService.asEffect(), (users) =>158 Effect.flatMap(UserStoreService.asEffect(), (users) =>
153 users.use((store) => store.getOrganization(organizationId)),159 users.use("getOrganization", (store) => store.getOrganization(organizationId)),
154 ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))),160 ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))),
155 );161 );
156 return Exit.isSuccess(exit)162 return Exit.isSuccess(exit)
@@ -158,6 +164,23 @@ const organizationDisplay = async ( · 1 unmodified line
158 : { name: "", slug: "" };164 : { name: "", slug: "" };
159};165};
160 166
167// Live membership check for the last-org cookie's slug. Same authorize path
168// as any org selector — the cookie is a preference, so a slug the user can't
169// access (stale after removal/deletion, or forged) resolves to null and the
170// bare path falls through to today's canonicalize-onto-session-org behavior.
171// Per-request store layers for the same reason as organizationDisplay.
172const authorizeLastOrgSlug = async (
173 userId: string,
174 slug: string,
175): Promise<{ readonly id: string } | null> => {
176 const exit = await getRuntime().runPromiseExit(
177 authorizeOrganizationSelector(userId, slug).pipe(
178 Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer())),
179 ),
180 );
181 return Exit.isSuccess(exit) ? exit.value : null;
182};
183
161const hintSetCookie = (hint: AuthHint) =>184const hintSetCookie = (hint: AuthHint) =>
162 `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`;185 `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`;
163 186
@@ -226,19 +249,44 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( · 62 unmodified lines
226 return redirect("/create-org", { refreshedSession: session.refreshedSession });249 return redirect("/create-org", { refreshedSession: session.refreshedSession });
227 }250 }
228 251
229 // Serve the document WITH the verified identity: the hint rides to the252 // A BARE console path (no org slug in the URL) canonicalizes onto the org
230 // SSR render through middleware context (the root loader reads it), so253 // this browser last worked in (the last-org cookie), not the org pinned in
231 // the server paints the real authenticated shell — no loading state, no254 // the session at login time — a multi-org user re-entering at `/` lands
232 // skeleton. The request origin rides along too: it's what the connect255 // where they left off. Slugged URLs never enter here (the URL names its
233 // card's MCP URL is built from, and the server knows it (the SPA only256 // own scope), so this costs nothing on the steady state; the not-found
234 // learns `window.location.origin` after mount), so passing it here lets257 // contract is untouched because an unknown-but-valid slug in the URL reads
235 // SSR render the real `https://…/<org>/mcp` instead of the client-side258 // as slugged, not bare. When the cookie matches the session's own org (the
236 // `http://127.0.0.1:4000` default — which would otherwise flash until259 // overwhelmingly common single-org case) the client-side OrgSlugGate
237 // hydration corrected it. Set-cookie writes ride on the rendered response.260 // already canonicalizes onto it, so skip the live membership check and the
261 // redirect entirely.
262 const lastOrgSlug = parseCookie(cookieHeader, LAST_ORG_COOKIE);
263 const firstSegment = pathname.split("/")[1] ?? "";
264 if (
265 session.organizationId &&
266 lastOrgSlug &&
267 isValidOrgSlug(lastOrgSlug) &&
268 !isValidOrgSlug(firstSegment) &&
269 !ONBOARDING_PATHS.has(pathname)
270 ) {
271 const sessionOrgSlug = (await organizationDisplay(session.organizationId)).slug;
272 if (lastOrgSlug !== sessionOrgSlug) {
273 // The cookie is a preference, not an authority: only redirect onto an
274 // org the caller actively belongs to (stale/forged values fall through
275 // to today's session-org canonicalization).
276 const lastOrg = await authorizeLastOrgSlug(session.userId, lastOrgSlug);
277 if (lastOrg) {
278 return redirect(`/${lastOrgSlug}${pathname === "/" ? "" : pathname}${url.search}`, {
279 refreshedSession: session.refreshedSession,
280 });
281 }
282 }
283 }
284
285 // Serve the shell, minting the auth-hint cookie when the browser lacks a
286 // current one so the client's post-mount cookie read seeds the verified
287 // identity. Set-cookie writes ride on the shell response.
238 const { hint, mint } = await resolveAuthHint(session, cookieHeader);288 const { hint, mint } = await resolveAuthHint(session, cookieHeader);
239 const result = await next({289 const result = await next();
240 context: { authHint: hint, origin: browserOriginFromRequest(request) },
241 });
242 if (!mint && !session.refreshedSession) return result;290 if (!mint && !session.refreshedSession) return result;
243 291
244 const response = new Response(result.response.body, result.response);292 const response = new Response(result.response.body, result.response);
apps/cloud/src/auth/errors.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.
7import { describe, expect, it } from "@effect/vitest";
8import {
9 GenericServerException,
10 NotFoundException,
11 RateLimitExceededException,
12 UnauthorizedException,
13} from "@workos-inc/node/worker";
14
15import {
16 ServiceAdapterError,
17 isDefinitiveWorkOSDenial,
18 isDefinitiveWorkOSDenialStatus,
19 workosErrorFromFailure,
20} from "./errors";
21
22describe("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
49describe("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 @@
1import { Data, Effect, Schema } from "effect";1import { Data, Effect, Option, Predicate, Schema } from "effect";
2 2
3// How a user-store call failed, classified from the driver cause. Safe to put
4// on the wire and on a Sentry tag: it names a failure MODE, never a query, a
5// value, or a customer.
6export const USER_STORE_FAILURE_REASONS = [
7 "connect_timeout",
8 "connection_closed",
9 "query",
10 "unknown",
11] as const;
12
13export type UserStoreFailureReason = (typeof USER_STORE_FAILURE_REASONS)[number];
14
15/**
16 * The public failure of every cloud user-store call.
17 *
18 * It carries the two fields that make an issue diagnosable from the error
19 * alone: which store call failed, and how. Before those existed the error had
20 * an empty field set, so Sentry showed a titleless, messageless issue and the
21 * only cause detail (the pretty-printed Effect cause in a Sentry `extra`) is
22 * scrubbed server-side — the failing operation and the driver reason existed
23 * only in the trace store. Same shape as `WorkOSError.status`: a small, safe
24 * classification field threaded at the service boundary.
25 */
3export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()(26export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()(
4 "UserStoreError",27 "UserStoreError",
5 {},28 {
29 /** The store call that failed, e.g. `getOrganization`. */
30 operation: Schema.String,
31 /** How it failed, classified from the driver cause chain. */
32 reason: Schema.Literals(USER_STORE_FAILURE_REASONS),
33 },
6 { httpApiStatus: 500 },34 { httpApiStatus: 500 },
7) {}35) {
36 override get message(): string {
37 return `user store ${this.operation} failed: ${this.reason}`;
38 }
39}
40
41/** Reasons a retry can plausibly clear: the query never reached a healthy
42 * server. A `query` failure is deterministic and must not be retried. */
43export const isTransientUserStoreReason = (reason: UserStoreFailureReason): boolean =>
44 reason === "connect_timeout" || reason === "connection_closed";
45
46// postgres.js tags its connection failures with a string `code`
47// (`CONNECT_TIMEOUT`, `CONNECTION_CLOSED`, …) and its query failures with the
48// SQLSTATE. Drizzle re-throws both wrapped in its own "Failed query" error with
49// the driver error in `.cause`, and the service adapter wraps that again, so
50// the classification walks the chain rather than inspecting one level.
51const MAX_CAUSE_DEPTH = 8;
52
53const REASON_BY_DRIVER_CODE: Readonly<Record<string, UserStoreFailureReason>> = {
54 CONNECT_TIMEOUT: "connect_timeout",
55 ETIMEDOUT: "connect_timeout",
56 CONNECTION_CLOSED: "connection_closed",
57 CONNECTION_ENDED: "connection_closed",
58 CONNECTION_DESTROYED: "connection_closed",
59 ECONNREFUSED: "connection_closed",
60 ECONNRESET: "connection_closed",
61};
62
63const stringCodeOf = (value: unknown): string | undefined => {
64 if (typeof value !== "object" || value === null) return undefined;
65 const code = (value as { readonly code?: unknown }).code;
66 return typeof code === "string" ? code : undefined;
67};
68
69const driverCodesOf = (failure: unknown): readonly string[] => {
70 const codes: string[] = [];
71 let current: unknown = isServiceAdapterError(failure) ? failure.cause : failure;
72 for (
73 let depth = 0;
74 depth < MAX_CAUSE_DEPTH && current !== undefined && current !== null;
75 depth++
76 ) {
77 const code = stringCodeOf(current);
78 if (code !== undefined) codes.push(code);
79 current =
80 typeof current === "object" ? (current as { readonly cause?: unknown }).cause : undefined;
81 }
82 return codes;
83};
84
85/** Classify a raw store failure. A recognised connection code wins; any other
86 * driver code (a SQLSTATE) is a deterministic query failure; nothing at all is
87 * `unknown`. */
88export const userStoreReasonFromCause = (failure: unknown): UserStoreFailureReason => {
89 const codes = driverCodesOf(failure);
90 for (const code of codes) {
91 const reason = REASON_BY_DRIVER_CODE[code];
92 if (reason !== undefined) return reason;
93 }
94 return codes.length > 0 ? "query" : "unknown";
95};
96
97/** Build the public `UserStoreError` for a store-adapter failure, naming the
98 * operation the call site already knows and classifying the driver cause. */
99export const userStoreErrorFromFailure = (operation: string, failure: unknown): UserStoreError =>
100 new UserStoreError({ operation, reason: userStoreReasonFromCause(failure) });
8 101
9export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()(102export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()(
10 "WorkOSError",103 "WorkOSError",
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 },
12 { httpApiStatus: 500 },114 { httpApiStatus: 500 },
13) {}115) {}
14 116
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.
122const DEFINITIVE_DENIAL_STATUSES: ReadonlySet<number> = new Set([401, 403, 404]);
123
124export 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.
130const 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. */
134export 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).
143const CauseWithStatusSchema = Schema.Struct({ status: Schema.Number });
144const decodeCauseWithStatusOption = Schema.decodeUnknownOption(CauseWithStatusSchema);
145
146export 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 */
158const isServiceAdapterError = Predicate.isTagged("ServiceAdapterError") as (
159 failure: unknown,
160) => failure is ServiceAdapterError;
161
162export const workosErrorFromFailure = (failure: unknown): WorkOSError =>
163 new WorkOSError({
164 status: statusFromWorkOSCause(isServiceAdapterError(failure) ? failure.cause : failure),
165 });
166
15export class ApiKeyManagementError extends Schema.TaggedErrorClass<ApiKeyManagementError>()(167export class ApiKeyManagementError extends Schema.TaggedErrorClass<ApiKeyManagementError>()(
16 "ApiKeyManagementError",168 "ApiKeyManagementError",
17 { cause: Schema.Unknown },169 { cause: Schema.Unknown },
@@ -47,7 +199,11 @@ export const tryPromiseService = <A>(fn: () => Promise<A>): Effect.Effect<A, Ser · 29 unmodified lines
47 */199 */
48export const withServiceLogging = <A, E, R>(200export const withServiceLogging = <A, E, R>(
49 name: string,201 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,
51 effect: Effect.Effect<A, unknown, R>,207 effect: Effect.Effect<A, unknown, R>,
52): Effect.Effect<A, E, R> =>208): Effect.Effect<A, E, R> =>
53 effect.pipe(209 effect.pipe(
apps/cloud/src/auth/handlers.ts+186−25
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 CloudAuthPublicApi,9 CloudAuthPublicApi,
10 McpExecutionNotFoundError,10 McpExecutionNotFoundError,
11 McpSessionForbiddenError,11 McpSessionForbiddenError,
12 OrganizationDeletionForbidden,
12} from "./api";13} from "./api";
13import { NoOrganization } from "@executor-js/api/server";14import { NoOrganization } from "@executor-js/api/server";
14// Pure constants/codec module (no React) — safe in the backend graph.15// Pure constants/codec module (no React) — safe in the backend graph.
@@ -21,11 +22,14 @@ import { env } from "cloudflare:workers"; · 6 unmodified lines
21import { WorkOSError } from "./errors";22import { WorkOSError } from "./errors";
22import { WorkOSClient } from "./workos";23import { WorkOSClient } from "./workos";
23import { AutumnService } from "../extensions/billing/service";24import { AutumnService } from "../extensions/billing/service";
25import { forkReportMemberSeats } from "../extensions/billing/member-seats";
26import { captureCauseEffect } from "../observability";
24import {27import {
25 hasPaidOrganizationSubscription,28 hasPaidOrganizationSubscription,
26 isOverFreeOrganizationLimit,29 isOverFreeOrganizationLimit,
27 shouldApplyFreeOrganizationLimit,30 shouldApplyFreeOrganizationLimit,
28} from "../extensions/billing/plans";31} from "../extensions/billing/plans";
32import { LAST_ORG_COOKIE } from "./last-org-cookie";
29import {33import {
30 ORG_SELECTOR_HEADER,34 ORG_SELECTOR_HEADER,
31 authorizeOrganization,35 authorizeOrganization,
@@ -142,8 +146,8 @@ const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, n · 110 unmodified lines
142 HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS);146 HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS);
143 147
144// ---------------------------------------------------------------------------148// ---------------------------------------------------------------------------
145// Single non-protected API surface — public (login/callback) + session149// Single non-protected API surface — public (login/callback/logout) + session
146// (me/logout/organizations/switch-organization). The session group has SessionAuth on it.150// (me/organizations/switch-organization). The session group has SessionAuth on it.
147// ---------------------------------------------------------------------------151// ---------------------------------------------------------------------------
148 152
149export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi);153export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi);
@@ -200,7 +204,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 50 unmodified lines
200 const result = yield* workos.authenticateWithCode(query.code);204 const result = yield* workos.authenticateWithCode(query.code);
201 205
202 // Mirror the account locally206 // 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));
204 208
205 let sealedSession = result.sealedSession;209 let sealedSession = result.sealedSession;
206 210
@@ -217,17 +221,40 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 10 unmodified lines
217 : null;221 : null;
218 222
219 // Prefer the org in the URL that sent the user to login. If the URL223 // 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 to224 // is bare, or not an org route, prefer the org this browser last
221 // the first active membership for org-less sessions. Pending225 // worked in (the last-org cookie — it outlives the session precisely
222 // memberships are skipped because refreshing into one 400s and would226 // so a fresh login lands where the user left off), then WorkOS's
223 // bypass invite consent.227 // org, then the first active membership for org-less sessions.
224 let targetOrganizationId = requestedOrg?.id ?? result.organizationId ?? null;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;
225 if (!targetOrganizationId && !requestedOrgSelector) {243 if (!targetOrganizationId && !requestedOrgSelector) {
226 const memberships = yield* workos.listUserMemberships(result.user.id);244 const memberships = yield* workos.listUserMemberships(result.user.id);
227 const existingActive = memberships.data.find((m) => m.status === "active");245 const existingActive = memberships.data.find((m) => m.status === "active");
228 targetOrganizationId = existingActive?.organizationId ?? null;246 targetOrganizationId = existingActive?.organizationId ?? null;
229 }247 }
230 248
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
231 if (258 if (
232 targetOrganizationId &&259 targetOrganizationId &&
233 targetOrganizationId !== result.organizationId &&260 targetOrganizationId !== result.organizationId &&
@@ -242,7 +269,9 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 8 unmodified lines
242 }269 }
243 270
244 if (!sealedSession) {271 if (!sealedSession) {
245 return HttpServerResponse.text("Failed to create session", { status: 500 });272 return HttpServerResponse.text("Failed to create session", {
273 status: 500,
274 });
246 }275 }
247 276
248 return deleteResponseCookie(277 return deleteResponseCookie(
@@ -256,6 +285,42 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 7 unmodified lines
256 );285 );
257 }),286 }),
258 )287 )
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 )
259 // CLI device-login discovery. The WorkOS device endpoints live on the324 // CLI device-login discovery. The WorkOS device endpoints live on the
260 // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production,325 // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production,
261 // the SAME base the SDK uses, so e2e points the CLI at the emulator with326 // 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
301 };366 };
302 }),367 }),
303 )368 )
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 )
315 .handle("organizations", () =>369 .handle("organizations", () =>
316 Effect.gen(function* () {370 Effect.gen(function* () {
317 const workos = yield* WorkOSClient;371 const workos = yield* WorkOSClient;
@@ -323,7 +377,11 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 5 unmodified lines
323 const organizations = yield* Effect.all(377 const organizations = yield* Effect.all(
324 memberships.data.map((m) =>378 memberships.data.map((m) =>
325 resolveOrganization(m.organizationId).pipe(379 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 })),
327 Effect.orElseSucceed(() => null),385 Effect.orElseSucceed(() => null),
328 ),386 ),
329 ),387 ),
@@ -354,7 +412,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 24 unmodified lines
354 activeMemberships.map((membership) =>412 activeMemberships.map((membership) =>
355 autumn413 autumn
356 .use((client) =>414 .use((client) =>
357 client.customers.getOrCreate({ customerId: membership.organizationId }),415 client.customers.getOrCreate({
416 customerId: membership.organizationId,
417 }),
358 )418 )
359 .pipe(419 .pipe(
360 Effect.map((customer) =>420 Effect.map((customer) =>
@@ -366,7 +426,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 5 unmodified lines
366 ),426 ),
367 { concurrency: 3 },427 { concurrency: 3 },
368 ).pipe(428 ).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()),
370 Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))),432 Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))),
371 );433 );
372 434
@@ -378,10 +440,30 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 5 unmodified lines
378 const org = yield* workos.createOrganization(name);440 const org = yield* workos.createOrganization(name);
379 yield* workos.createMembership(org.id, session.accountId, "admin");441 yield* workos.createMembership(org.id, session.accountId, "admin");
380 // `upsertOrganization` mints the slug at insert — no separate heal step.442 // `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) =>
382 s.upsertOrganization({ id: org.id, name: org.name }),444 s.upsertOrganization({ id: org.id, name: org.name }),
383 );445 );
384 446
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
385 // Try to attach the new org to the current session. This can fail467 // Try to attach the new org to the current session. This can fail
386 // (or silently return a session still scoped to the old org) when468 // (or silently return a session still scoped to the old org) when
387 // the caller's current session is stale — most commonly after the469 // the caller's current session is stale — most commonly after the
@@ -410,6 +492,80 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 22 unmodified lines
410 return { id: org.id, name: org.name, slug: mirrored.slug };492 return { id: org.id, name: org.name, slug: mirrored.slug };
411 }),493 }),
412 )494 )
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 )
413 .handle("pendingInvitations", () =>569 .handle("pendingInvitations", () =>
414 Effect.gen(function* () {570 Effect.gen(function* () {
415 const workos = yield* WorkOSClient;571 const workos = yield* WorkOSClient;
@@ -479,10 +635,15 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 63 unmodified lines
479 // Mirror the org locally so domain tables can FK against it; the635 // Mirror the org locally so domain tables can FK against it; the
480 // upsert mints the slug at insert — no separate heal step.636 // upsert mints the slug at insert — no separate heal step.
481 const org = yield* workos.getOrganization(invitation.organizationId);637 const org = yield* workos.getOrganization(invitation.organizationId);
482 const mirrored = yield* users.use((s) =>638 const mirrored = yield* users.use("upsertOrganization", (s) =>
483 s.upsertOrganization({ id: org.id, name: org.name }),639 s.upsertOrganization({ id: org.id, name: org.name }),
484 );640 );
485 641
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
486 // Attach the just-accepted org to the current session. Same shape647 // Attach the just-accepted org to the current session. Same shape
487 // as createOrganization: refresh + verify; if we can't pin the648 // as createOrganization: refresh + verify; if we can't pin the
488 // session in-place, clear the cookie and let the user bounce649 // 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
9 type KeyLike,9 type KeyLike,
10} from "jose";10} from "jose";
11 11
12import { createCachedRemoteJWKSet } from "./jwks-cache";12import { createCachedRemoteJWKSet, type JwksStore, type StoredJwks } from "./jwks-cache";
13 13
14const issuer = "https://test-authkit.example.com";14const issuer = "https://test-authkit.example.com";
15const audience = "client_test_fixture";15const audience = "client_test_fixture";
@@ -65,6 +65,40 @@ const makeFetchHarness = (initialKeys: ReadonlyArray<JWK>): FetchHarness => { · 49 unmodified lines
65 };65 };
66};66};
67 67
68interface StoreHarness extends JwksStore {
69 readonly seed: (stored: StoredJwks) => void;
70 readonly reads: () => number;
71 readonly writes: () => number;
72}
73
74/** Stands in for the Workers Cache API: shared across "isolates", in memory. */
75const makeStoreHarness = (): StoreHarness => {
76 const entries = new Map<string, StoredJwks>();
77 let reads = 0;
78 let writes = 0;
79 return {
80 get: async (url) => {
81 reads++;
82 return entries.get(url.toString()) ?? null;
83 },
84 put: async (url, stored) => {
85 writes++;
86 entries.set(url.toString(), stored);
87 },
88 seed: (stored) => {
89 entries.set(jwksUrl.toString(), stored);
90 },
91 reads: () => reads,
92 writes: () => writes,
93 };
94};
95
96/** A fetch that always fails, standing in for a slow/down key server. */
97const failingFetch: typeof globalThis.fetch = async () => {
98 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test double: `fetch` signals an unreachable key server by rejecting
99 throw new Error("JWKS endpoint unreachable");
100};
101
68describe("createCachedRemoteJWKSet", () => {102describe("createCachedRemoteJWKSet", () => {
69 it("FAILING-WITHOUT-CACHE: N verifications hit JWKS endpoint only once within TTL", async () => {103 it("FAILING-WITHOUT-CACHE: N verifications hit JWKS endpoint only once within TTL", async () => {
70 const kp = await generateRotatableKeypair("k1");104 const kp = await generateRotatableKeypair("k1");
@@ -168,4 +202,151 @@ describe("createCachedRemoteJWKSet", () => { · 97 unmodified lines
168 await jwtVerify(t1, jwks, { issuer, audience });202 await jwtVerify(t1, jwks, { issuer, audience });
169 expect(harness.callCount()).toBe(2);203 expect(harness.callCount()).toBe(2);
170 });204 });
205
206 // -------------------------------------------------------------------------
207 // Cross-isolate store — the cold-isolate path that caused the 2026-08-18
208 // 500s: no module-scope entry, so every verify paid an upstream fetch.
209 // -------------------------------------------------------------------------
210
211 it("a cold isolate serves from the store instead of going upstream", async () => {
212 const kp = await generateRotatableKeypair("k1");
213 const store = makeStoreHarness();
214
215 // First isolate: cold everywhere, so it fetches and populates the store.
216 const warm = makeFetchHarness([kp.publicJwk]);
217 const first = createCachedRemoteJWKSet(jwksUrl, { fetch: warm.fetch, store });
218 const t1 = await sign(kp);
219 await jwtVerify(t1, first, { issuer, audience });
220 expect(warm.callCount()).toBe(1);
221 expect(store.writes()).toBe(1);
222
223 // A brand-new isolate (fresh module scope). If it reaches upstream at all
224 // the fetch fails, so verifying proves the store answered.
225 const second = createCachedRemoteJWKSet(jwksUrl, { fetch: failingFetch, store });
226 const { payload } = await jwtVerify(t1, second, { issuer, audience });
227 expect(payload.sub).toBe("user_test");
228 expect(store.reads()).toBeGreaterThan(0);
229 });
230
231 it("keeps serving the last good keys when the key server is down", async () => {
232 const kp = await generateRotatableKeypair("k1");
233 const harness = makeFetchHarness([kp.publicJwk]);
234 let upstreamUp = true;
235 const flaky: typeof globalThis.fetch = (...args) =>
236 upstreamUp ? harness.fetch(...args) : failingFetch(...args);
237
238 // ttl short enough that the next call is past it, stale window generous.
239 const jwks = createCachedRemoteJWKSet(jwksUrl, {
240 fetch: flaky,
241 store: null,
242 ttlMs: 10,
243 staleMaxMs: 60_000,
244 });
245
246 const token = await sign(kp);
247 await jwtVerify(token, jwks, { issuer, audience });
248
249 upstreamUp = false;
250 await new Promise((r) => setTimeout(r, 20));
251
252 // Past the TTL with a dead upstream: the cached keys still verify.
253 const { payload } = await jwtVerify(token, jwks, { issuer, audience });
254 expect(payload.sub).toBe("user_test");
255 });
256
257 it("stops serving stale keys once the stale window closes", async () => {
258 const kp = await generateRotatableKeypair("k1");
259 const harness = makeFetchHarness([kp.publicJwk]);
260 let upstreamUp = true;
261 const flaky: typeof globalThis.fetch = (...args) =>
262 upstreamUp ? harness.fetch(...args) : failingFetch(...args);
263
264 const jwks = createCachedRemoteJWKSet(jwksUrl, {
265 fetch: flaky,
266 store: null,
267 ttlMs: 5,
268 staleMaxMs: 10,
269 });
270
271 const token = await sign(kp);
272 await jwtVerify(token, jwks, { issuer, audience });
273
274 upstreamUp = false;
275 await new Promise((r) => setTimeout(r, 30));
276
277 // Beyond staleMaxMs the keys are no longer trustworthy — fail, don't
278 // silently honour a key set we can no longer confirm.
279 await expect(jwtVerify(token, jwks, { issuer, audience })).rejects.toThrow();
280 });
281
282 it("attributes background revalidation to fetchCount but not blockingFetchCount", async () => {
283 const kp = await generateRotatableKeypair("k1");
284 const harness = makeFetchHarness([kp.publicJwk]);
285 const jwks = createCachedRemoteJWKSet(jwksUrl, {
286 fetch: harness.fetch,
287 store: null,
288 ttlMs: 10,
289 staleMaxMs: 60_000,
290 });
291
292 const token = await sign(kp);
293 await jwtVerify(token, jwks, { issuer, audience });
294 expect(jwks.inspect().blockingFetchCount).toBe(1);
295
296 await new Promise((r) => setTimeout(r, 20));
297
298 // Past the TTL: served from the stale entry, revalidated behind it. The
299 // caller waited on nothing, so latency must not be attributed to it.
300 const before = jwks.inspect();
301 await jwtVerify(token, jwks, { issuer, audience });
302 const after = jwks.inspect();
303
304 expect(after.blockingFetchCount).toBe(before.blockingFetchCount);
305 expect(after.fetchCount).toBeGreaterThan(before.fetchCount);
306 });
307
308 it("records a cold-isolate store read as a store hit, not a fetch", async () => {
309 const kp = await generateRotatableKeypair("k1");
310 const store = makeStoreHarness();
311 const warm = makeFetchHarness([kp.publicJwk]);
312
313 const first = createCachedRemoteJWKSet(jwksUrl, { fetch: warm.fetch, store });
314 const token = await sign(kp);
315 await jwtVerify(token, first, { issuer, audience });
316
317 const second = createCachedRemoteJWKSet(jwksUrl, { fetch: failingFetch, store });
318 await jwtVerify(token, second, { issuer, audience });
319
320 const stats = second.inspect();
321 expect(stats.storeHitCount).toBe(1);
322 expect(stats.blockingFetchCount).toBe(0);
323 });
324
325 it("does not block a request on revalidation once keys are cached", async () => {
326 const kp = await generateRotatableKeypair("k1");
327 const harness = makeFetchHarness([kp.publicJwk]);
328 let hang = false;
329 const slowAfterFirst: typeof globalThis.fetch = async (...args) => {
330 if (hang) await new Promise((r) => setTimeout(r, 5_000));
331 return harness.fetch(...args);
332 };
333
334 const jwks = createCachedRemoteJWKSet(jwksUrl, {
335 fetch: slowAfterFirst,
336 store: null,
337 ttlMs: 10,
338 staleMaxMs: 60_000,
339 });
340
341 const token = await sign(kp);
342 await jwtVerify(token, jwks, { issuer, audience });
343
344 hang = true;
345 await new Promise((r) => setTimeout(r, 20));
346
347 // The revalidation behind this call hangs for 5s; the verify must not.
348 const startedAt = Date.now();
349 await jwtVerify(token, jwks, { issuer, audience });
350 expect(Date.now() - startedAt).toBeLessThan(1_000);
351 });
171});352});
apps/cloud/src/auth/jwks-cache.ts+256−26
@@ -1,5 +1,5 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// In-memory JWKS cache for MCP JWT verification.2// JWKS cache for JWT verification (session + MCP auth).
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4//4//
5// Cloudflare Workers boot many short-lived isolates. `createRemoteJWKSet`'s5// Cloudflare Workers boot many short-lived isolates. `createRemoteJWKSet`'s
@@ -7,14 +7,32 @@ · 1 unmodified line
7// fetches per hour because each new isolate starts cold. Production p99 for7// fetches per hour because each new isolate starts cold. Production p99 for
8// `mcp.auth.jwt_verify` was 1.7s — almost entirely the JWKS fetch.8// `mcp.auth.jwt_verify` was 1.7s — almost entirely the JWKS fetch.
9//9//
10// This module offers a drop-in `createCachedRemoteJWKSet` that:10// Module-scope memory alone only helps while an isolate stays warm. When
11// `workos.session.local_verify` started missing ~92% of the time (2026-08-18),
12// every miss paid a fresh upstream fetch — p50 3s, and the tail crossed the 5s
13// request timeout, turning a slow key-server into `AbortError` → 500 on every
14// authenticated API route and 503 on `/mcp`. A transient key-server blip must
15// never read as an auth failure, and a cold isolate must not have to go
16// upstream at all.
11//17//
12// * Caches the JSON Web Key Set in module-scope memory for a configurable18// So this module layers three caches, fastest first:
13// TTL (default 1 hour).19//
14// * Single-flights concurrent fetches so a stampede of verifies during a20// 1. Module-scope memory — free, but dies with the isolate.
15// cache miss only fires one upstream request.21// 2. A cross-isolate store (the Workers Cache API by default) — colo-local,
16// * Force-refreshes once when verification fails with a cached key, so22// survives isolate recycling, so a cold isolate reads keys in ~1ms
17// genuine key rotation isn't blocked by the TTL.23// instead of paying an upstream round trip.
24// 3. The upstream JWKS endpoint.
25//
26// On top of that it is stale-while-revalidate: once past `ttlMs` a usable key
27// set is served immediately and refreshed in the background, and if a refresh
28// fails we keep serving the last good keys until `staleMaxMs`. Only a fully
29// cold path (no memory, no store) ever blocks on the network.
30//
31// Serving stale keys is safe in a way that serving a stale *token* would not
32// be: key sets rotate on the order of days, tokens are still signature- and
33// expiry-checked against them, and a token whose `kid` is absent forces a real
34// refresh before it is rejected (see `get`). `staleMaxMs` bounds how long a
35// retired key can still be honoured.
18//36//
19// The returned function is a `JWTVerifyGetKey` and slots directly into37// The returned function is a `JWTVerifyGetKey` and slots directly into
20// `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can38// `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can
@@ -32,6 +50,21 @@ import { · 11 unmodified lines
32import { Schema } from "effect";50import { Schema } from "effect";
33import { JWKSNoMatchingKey } from "jose/errors";51import { JWKSNoMatchingKey } from "jose/errors";
34 52
53/**
54 * A cross-isolate key-set store. Defaults to the Workers Cache API; tests and
55 * non-Workers hosts pass their own, or `null` to stay memory-only.
56 */
57export interface JwksStore {
58 readonly get: (url: URL) => Promise<StoredJwks | null>;
59 readonly put: (url: URL, stored: StoredJwks) => Promise<void>;
60}
61
62export interface StoredJwks {
63 readonly jwks: JSONWebKeySet;
64 /** Epoch ms of the upstream fetch these keys came from. */
65 readonly fetchedAt: number;
66}
67
35export interface CachedRemoteJWKSetOptions {68export interface CachedRemoteJWKSetOptions {
36 /**69 /**
37 * How long a successful fetch is considered fresh. Defaults to 1 hour —70 * How long a successful fetch is considered fresh. Defaults to 1 hour —
@@ -39,20 +72,56 @@ export interface CachedRemoteJWKSetOptions { · 1 unmodified line
39 * failure handles unscheduled rotations.72 * failure handles unscheduled rotations.
40 */73 */
41 readonly ttlMs?: number;74 readonly ttlMs?: number;
75 /**
76 * How long past `ttlMs` a key set may still be served while refreshes are
77 * failing. Defaults to 24h — long enough to ride out a key-server outage,
78 * short enough to bound how long a retired key stays honoured.
79 */
80 readonly staleMaxMs?: number;
42 /** Override the fetch implementation for tests. */81 /** Override the fetch implementation for tests. */
43 readonly fetch?: typeof globalThis.fetch;82 readonly fetch?: typeof globalThis.fetch;
44 /** HTTP request timeout. Defaults to 5s, matching jose. */83 /** HTTP request timeout. Defaults to 5s, matching jose. */
45 readonly timeoutMs?: number;84 readonly timeoutMs?: number;
85 /**
86 * Cross-isolate store. Defaults to the Workers Cache API when available,
87 * `null` disables the layer (memory-only).
88 */
89 readonly store?: JwksStore | null;
46}90}
47 91
48export interface CachedRemoteJWKSet extends JWTVerifyGetKey {92export interface CachedRemoteJWKSet extends JWTVerifyGetKey {
49 /** Drop the cached JWKS so the next call refetches. */93 /** Drop the cached JWKS so the next call refetches. */
50 readonly forceRefresh: () => void;94 readonly forceRefresh: () => void;
51 /** Inspect the current cache state (testing/diagnostics). */95 /**
52 readonly inspect: () => { fetchedAt: number | null; hasJwks: boolean };96 * Inspect the current cache state (testing/diagnostics/span annotation).
97 * `fetchCount`/`fetchFailureCount` are lifetime counters for this resolver
98 * instance; callers snapshot them around a verify to tell a cache hit from
99 * a live upstream fetch (the Aug 2026 latency regression was exactly this
100 * cache silently missing on ~92% of verifies, invisible in traces).
101 *
102 * `blockingFetchCount` counts only fetches a verify actually WAITED on.
103 * Under stale-while-revalidate `fetchCount` also moves for background
104 * revalidation the caller never paid for, so latency attribution wants
105 * `blockingFetchCount`. `storeHitCount` counts cold-isolate reads answered
106 * by the cross-isolate store instead of going upstream.
107 */
108 readonly inspect: () => {
109 fetchedAt: number | null;
110 hasJwks: boolean;
111 fetchCount: number;
112 fetchFailureCount: number;
113 blockingFetchCount: number;
114 storeHitCount: number;
115 lastFetchDurationMs: number | null;
116 /** Wall-clock of the last cross-isolate store read (I/O). */
117 lastStoreReadMs: number | null;
118 /** Wall-clock of the last key resolution — WebCrypto `importKey`. */
119 lastResolveMs: number | null;
120 };
53}121}
54 122
55const DEFAULT_TTL_MS = 60 * 60 * 1000;123const DEFAULT_TTL_MS = 60 * 60 * 1000;
124const DEFAULT_STALE_MAX_MS = 24 * 60 * 60 * 1000;
56const DEFAULT_TIMEOUT_MS = 5000;125const DEFAULT_TIMEOUT_MS = 5000;
57 126
58const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown);127const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown);
@@ -73,6 +142,26 @@ interface CacheEntry { · 14 unmodified lines
73 resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise<KeyLike>;142 resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise<KeyLike>;
74}143}
75 144
145const entryFrom = (stored: StoredJwks): CacheEntry => ({
146 jwks: stored.jwks,
147 fetchedAt: stored.fetchedAt,
148 resolver: createLocalJWKSet(stored.jwks),
149});
150
151/**
152 * Cache upkeep (store writes, background revalidation) is best effort: it must
153 * never fail or delay the verify that happened to trigger it.
154 */
155const ignoreFailure = async (work: Promise<unknown>): Promise<void> => {
156 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort cache upkeep must not surface as a verify failure
157 try {
158 await work;
159 } catch {
160 // Deliberately swallowed — the caller already has usable keys, or will
161 // take the upstream path on its next miss.
162 }
163};
164
76const fetchJwksOnce = async (165const fetchJwksOnce = async (
77 url: URL,166 url: URL,
78 fetchImpl: typeof globalThis.fetch,167 fetchImpl: typeof globalThis.fetch,
@@ -107,17 +196,74 @@ const fetchJwksOnce = async ( · 28 unmodified lines
107 }196 }
108};197};
109 198
199// ---------------------------------------------------------------------------
200// Workers Cache API store
201// ---------------------------------------------------------------------------
202//
203// Keyed by the JWKS URL itself. We store our own Response rather than the
204// upstream one so the body is already-validated JSON and the cache headers are
205// ours: `max-age` covers the stale window, because an entry past `ttlMs` is
206// still useful to us as a stale fallback.
207
208const STORE_HEADER_FETCHED_AT = "x-jwks-fetched-at";
209
210/** `caches.default` is a Workers extension the standard lib type omits. */
211type WorkersCacheStorage = CacheStorage & { readonly default?: Cache };
212
213const workersCacheStore = (staleMaxMs: number): JwksStore | null => {
214 if (typeof caches === "undefined") return null;
215 const open = (): Cache | null => (caches as WorkersCacheStorage).default ?? null;
216
217 return {
218 get: async (url) => {
219 const cache = open();
220 if (!cache) return null;
221 const hit = await cache.match(url.toString());
222 if (!hit) return null;
223 const fetchedAtHeader = hit.headers.get(STORE_HEADER_FETCHED_AT);
224 const fetchedAt = fetchedAtHeader === null ? Number.NaN : Number(fetchedAtHeader);
225 if (!Number.isFinite(fetchedAt)) return null;
226 const body = await hit.json();
227 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a corrupt cache entry must degrade to a miss, never fail the verify
228 try {
229 await decodeJsonWebKeySetPayload(body);
230 } catch {
231 return null;
232 }
233 return { jwks: body as JSONWebKeySet, fetchedAt };
234 },
235 put: async (url, stored) => {
236 const cache = open();
237 if (!cache) return;
238 const maxAgeSeconds = Math.max(1, Math.floor(staleMaxMs / 1000));
239 await cache.put(
240 url.toString(),
241 new Response(JSON.stringify(stored.jwks), {
242 status: 200,
243 headers: {
244 "content-type": "application/json",
245 "cache-control": `max-age=${maxAgeSeconds}`,
246 [STORE_HEADER_FETCHED_AT]: String(stored.fetchedAt),
247 },
248 }),
249 );
250 },
251 };
252};
253
110/**254/**
111 * Creates a cached, single-flight, force-refreshable JWKS resolver compatible255 * Creates a cached, single-flight, force-refreshable JWKS resolver compatible
112 * with `jose.jwtVerify`. Drop-in replacement for `createRemoteJWKSet` for the256 * with `jose.jwtVerify`. Drop-in replacement for `createRemoteJWKSet` for the
113 * MCP auth path — see module header for why we don't just use jose's built-in.257 * auth paths — see module header for why we don't just use jose's built-in.
114 */258 */
115export const createCachedRemoteJWKSet = (259export const createCachedRemoteJWKSet = (
116 url: URL,260 url: URL,
117 options: CachedRemoteJWKSetOptions = {},261 options: CachedRemoteJWKSetOptions = {},
118): CachedRemoteJWKSet => {262): CachedRemoteJWKSet => {
119 const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;263 const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
264 const staleMaxMs = Math.max(options.staleMaxMs ?? DEFAULT_STALE_MAX_MS, ttlMs);
120 const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;265 const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
266 const store = options.store === undefined ? workersCacheStore(staleMaxMs) : options.store;
121 // Capture the fetch impl lazily so consumers can swap globalThis.fetch267 // Capture the fetch impl lazily so consumers can swap globalThis.fetch
122 // (tests do this) without us snapshotting a stale reference.268 // (tests do this) without us snapshotting a stale reference.
123 const fetchImpl = (): typeof globalThis.fetch =>269 const fetchImpl = (): typeof globalThis.fetch =>
@@ -125,39 +271,116 @@ export const createCachedRemoteJWKSet = ( · 1 unmodified line
125 271
126 let entry: CacheEntry | null = null;272 let entry: CacheEntry | null = null;
127 let inflight: Promise<CacheEntry> | null = null;273 let inflight: Promise<CacheEntry> | null = null;
274 let fetchCount = 0;
275 let fetchFailureCount = 0;
276 let blockingFetchCount = 0;
277 let storeHitCount = 0;
278 let lastFetchDurationMs: number | null = null;
279 let lastStoreReadMs: number | null = null;
280 let lastResolveMs: number | null = null;
281
282 const isFresh = (candidate: CacheEntry): boolean => Date.now() - candidate.fetchedAt < ttlMs;
283 const isUsable = (candidate: CacheEntry): boolean =>
284 Date.now() - candidate.fetchedAt < staleMaxMs;
128 285
129 const refresh = (): Promise<CacheEntry> => {286 const refresh = (): Promise<CacheEntry> => {
130 if (inflight) return inflight;287 if (inflight) return inflight;
288 const startedAt = Date.now();
289 fetchCount += 1;
131 inflight = (async () => {290 inflight = (async () => {
132 const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs);291 const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs);
133 const next: CacheEntry = {292 const next = entryFrom({ jwks, fetchedAt: Date.now() });
134 jwks,
135 fetchedAt: Date.now(),
136 resolver: createLocalJWKSet(jwks),
137 };
138 entry = next;293 entry = next;
294 if (store) {
295 await ignoreFailure(store.put(url, { jwks: next.jwks, fetchedAt: next.fetchedAt }));
296 }
139 return next;297 return next;
140 })().finally(() => {298 })()
141 inflight = null;299 .then(
142 });300 (next) => next,
301 (error: unknown) => {
302 fetchFailureCount += 1;
303 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: counting a fetch failure must preserve the original rejection for jose
304 throw error;
305 },
306 )
307 .finally(() => {
308 lastFetchDurationMs = Date.now() - startedAt;
309 inflight = null;
310 });
143 return inflight;311 return inflight;
144 };312 };
145 313
146 const ensureFresh = async (forceRefresh: boolean): Promise<CacheEntry> => {314 /** Fire-and-forget revalidation behind a stale hit. */
147 if (forceRefresh) return refresh();315 const refreshInBackground = (): void => {
148 if (entry && Date.now() - entry.fetchedAt < ttlMs) return entry;316 void ignoreFailure(refresh());
317 };
318
319 const loadFromStore = async (): Promise<CacheEntry | null> => {
320 if (!store) return null;
321 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the L2 store is an optimization; any failure degrades to an upstream fetch
322 try {
323 const storeReadStartedAt = Date.now();
324 const stored = await store.get(url);
325 lastStoreReadMs = Date.now() - storeReadStartedAt;
326 if (!stored) return null;
327 const candidate = entryFrom(stored);
328 if (!isUsable(candidate)) return null;
329 entry = candidate;
330 storeHitCount += 1;
331 return candidate;
332 } catch {
333 return null;
334 }
335 };
336
337 /** A refresh the caller waits on — the only kind that costs it latency. */
338 const refreshBlocking = (): Promise<CacheEntry> => {
339 blockingFetchCount += 1;
149 return refresh();340 return refresh();
150 };341 };
151 342
343 const ensureFresh = async (forceRefresh: boolean): Promise<CacheEntry> => {
344 if (forceRefresh) return refreshBlocking();
345 if (entry && isFresh(entry)) return entry;
346
347 // Memory is stale but still usable: serve it now, revalidate behind it.
348 if (entry && isUsable(entry)) {
349 refreshInBackground();
350 return entry;
351 }
352
353 // Cold isolate — the L2 store saves us the upstream round trip.
354 const stored = await loadFromStore();
355 if (stored) {
356 if (!isFresh(stored)) refreshInBackground();
357 return stored;
358 }
359
360 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a failed refresh must fall back to stale keys rather than fail the verify
361 try {
362 return await refreshBlocking();
363 } catch (error) {
364 // Upstream is slow or down. Last good keys beat failing every request.
365 if (entry && isUsable(entry)) return entry;
366 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: nothing usable is cached, so the upstream failure is the real answer
367 throw error;
368 }
369 };
370
152 const get: JWTVerifyGetKey = async (protectedHeader, token) => {371 const get: JWTVerifyGetKey = async (protectedHeader, token) => {
153 const current = await ensureFresh(false);372 const current = await ensureFresh(false);
154 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures373 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures
155 try {374 try {
156 return await current.resolver(protectedHeader, token);375 const resolveStartedAt = Date.now();
376 const key = await current.resolver(protectedHeader, token);
377 lastResolveMs = Date.now() - resolveStartedAt;
378 return key;
157 } catch (error) {379 } catch (error) {
158 // Likely cause: keys rotated upstream after our TTL window started.380 // Likely cause: keys rotated upstream after our TTL window started, or
159 // Refetch once and try again. Anything still failing bubbles up so381 // we answered from a stale entry. Refetch once and try again. Anything
160 // jose can classify it (we do not silently swallow real failures).382 // still failing bubbles up so jose can classify it (we do not silently
383 // swallow real failures).
161 if (!isJwksNoMatchingKey(error)) {384 if (!isJwksNoMatchingKey(error)) {
162 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey requires preserving upstream resolver rejection385 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey requires preserving upstream resolver rejection
163 throw error;386 throw error;
@@ -177,6 +400,13 @@ export const createCachedRemoteJWKSet = ( · 13 unmodified lines
177 value: () => ({400 value: () => ({
178 fetchedAt: entry?.fetchedAt ?? null,401 fetchedAt: entry?.fetchedAt ?? null,
179 hasJwks: entry !== null,402 hasJwks: entry !== null,
403 fetchCount,
404 fetchFailureCount,
405 blockingFetchCount,
406 storeHitCount,
407 lastFetchDurationMs,
408 lastStoreReadMs,
409 lastResolveMs,
180 }),410 }),
181 });411 });
182 return result;412 return result;
apps/cloud/src/auth/last-org-cookie.ts+32−0
@@ -0,0 +1,32 @@
1// ---------------------------------------------------------------------------
2// Last-visited-org cookie — the browser's "which org was I working in" memory.
3//
4// Org switching is stateless by design (the URL slug scopes every request; see
5// organization.ts), which leaves nothing that remembers the last org across
6// entries: the sealed session still carries the org pinned at LOGIN time, so a
7// bare URL (`executor.sh/`) would always canonicalize onto that first org. This
8// cookie fills the gap: the client records the slug of the org it's verifiably
9// viewing, and the two bare-entry deciders honor it —
10//
11// - the document auth gate redirects bare document paths onto it (doc-gate.ts)
12// - the login callback prefers it when picking the org for a fresh session
13// with a bare returnTo (handlers.ts)
14//
15// It is a PREFERENCE, never an authority: both readers re-check live membership
16// through the same authorize path as any org selector, so a stale or forged
17// value at worst falls back to today's behavior. Not HttpOnly — the client is
18// the writer. Deliberately NOT cleared on logout: surviving the session is what
19// lets the next login land on the last-used org.
20// ---------------------------------------------------------------------------
21
22export const LAST_ORG_COOKIE = "executor-last-org";
23
24/** Outlives the 7d session on purpose — it spans logins. */
25const LAST_ORG_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
26
27/** Browser-side write. Slugs are `[a-z0-9-]` by grammar, so no encoding. */
28export const writeLastOrgCookie = (slug: string): void => {
29 document.cookie = `${LAST_ORG_COOKIE}=${slug}; Path=/; Max-Age=${LAST_ORG_MAX_AGE_SECONDS}; SameSite=Lax${
30 window.location.protocol === "https:" ? "; Secure" : ""
31 }`;
32};
apps/cloud/src/auth/org-api-key-auth.node.test.ts+159−0
@@ -0,0 +1,159 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import { ApiKeyService } from "./api-keys";
5import { UserStoreService } from "./context";
6import { WorkOSClient, type WorkOSClientService } from "./workos";
7import { isPlatformAuth, resolveApiKeyPrincipal, resolveBearerAuth } from "./workos-auth-provider";
8
9// Groundwork for the PRIVILEGED, org-level API key: it resolves to the platform
10// view (tenant-wide, read-only, NO acting member) rather than to a member
11// `Principal`. The product surfaces must reject it outright rather than
12// inventing a subject for it — that is the property these tests pin.
13
14const createdAt = new Date("2026-01-01T00:00:00.000Z");
15
16const stubApiKeys = Layer.succeed(ApiKeyService)({
17 validate: (value: string) => {
18 if (value === "valid_org_key") {
19 return Effect.succeed({
20 scope: "org" as const,
21 accountId: null,
22 organizationId: "org_123",
23 keyId: "api_key_org",
24 });
25 }
26 if (value === "valid_user_key") {
27 return Effect.succeed({
28 scope: "user" as const,
29 accountId: "user_123",
30 organizationId: "org_123",
31 keyId: "api_key_123",
32 });
33 }
34 return Effect.succeed(null);
35 },
36 listUserKeys: () => Effect.succeed([]),
37 createUserKey: () => Effect.die("org api key test does not create API keys"),
38 revokeUserKey: () => Effect.void,
39 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
40 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
41 revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
42});
43
44const stubWorkOS = Layer.succeed(
45 WorkOSClient,
46 new Proxy({} as WorkOSClientService, {
47 get: (_target, prop) => {
48 if (prop === "listUserMemberships") {
49 return (userId: string) =>
50 Effect.succeed({
51 data:
52 userId === "user_123"
53 ? [{ userId, organizationId: "org_123", status: "active" }]
54 : [],
55 });
56 }
57 // An org key must NOT trigger a membership check — there is no user to
58 // check. Any such call dies here, which is the assertion.
59 return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
60 },
61 }),
62);
63
64const stubUsers = Layer.succeed(UserStoreService)({
65 use: (_op, fn) =>
66 Effect.promise(() =>
67 fn({
68 ensureAccount: async (id: string) => ({ id, createdAt }),
69 getAccount: async (id: string) => ({ id, createdAt }),
70 upsertOrganization: async (org: { id: string; name: string }) => ({
71 ...org,
72 slug: `org-slug-${org.id}`,
73 createdAt,
74 }),
75 getOrganization: async (id: string) => ({
76 id,
77 name: `Org ${id}`,
78 slug: `org-slug-${id}`,
79 createdAt,
80 }),
81 getOrganizationBySlug: async (slug: string) => ({
82 id: "org_by_slug",
83 name: `Org ${slug}`,
84 slug,
85 createdAt,
86 }),
87 deleteOrganizationCascade: async () => {},
88 }),
89 ),
90});
91
92const layers = Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers);
93
94const bearer = (token: string) =>
95 new Request("https://executor.test/api/tools", {
96 headers: { authorization: `Bearer ${token}` },
97 });
98
99describe("org-level API keys", () => {
100 it.effect("resolve to the platform view with no acting member", () =>
101 Effect.gen(function* () {
102 const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers));
103
104 expect(isPlatformAuth(auth)).toBe(true);
105 expect(auth).toEqual({
106 kind: "platform",
107 organizationId: "org_123",
108 organizationName: "Org org_123",
109 organizationSlug: "org-slug-org_123",
110 keyId: "api_key_org",
111 });
112 // No `accountId` anywhere in the shape: nothing downstream can bind a
113 // subject from it by accident.
114 expect(auth).not.toHaveProperty("accountId");
115 }),
116 );
117
118 it.effect("user keys still resolve to a bound member principal", () =>
119 Effect.gen(function* () {
120 const auth = yield* resolveBearerAuth(bearer("valid_user_key")).pipe(Effect.provide(layers));
121
122 expect(isPlatformAuth(auth)).toBe(false);
123 expect(auth).toMatchObject({ accountId: "user_123", organizationId: "org_123" });
124 }),
125 );
126
127 it.effect("resolve on the product surface as a platform principal, never a subject", () =>
128 Effect.gen(function* () {
129 // THE security property of the api-key half, updated for platform reads:
130 // a product request carrying an org key resolves to the NEUTRAL platform
131 // shape — which the shared middleware routes to the subject-less,
132 // GET-only platform executor — and must never carry an accountId a
133 // handler could bind as an acting member.
134 const principal = yield* resolveApiKeyPrincipal(bearer("valid_org_key")).pipe(
135 Effect.provide(layers),
136 );
137
138 expect(principal).toEqual({
139 kind: "platform",
140 organizationId: "org_123",
141 organizationName: "Org org_123",
142 organizationSlug: "org-slug-org_123",
143 keyId: "api_key_org",
144 });
145 expect(principal).not.toHaveProperty("accountId");
146 }),
147 );
148
149 it.effect("do not trigger a user membership check", () =>
150 Effect.gen(function* () {
151 // `authorizeOrganization` checks a USER's live membership; there is no
152 // user here. The WorkOS stub dies on any call other than the user path,
153 // so a clean resolution proves the org branch never took it.
154 const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers));
155
156 expect(isPlatformAuth(auth)).toBe(true);
157 }),
158 );
159});
apps/cloud/src/auth/org-api-key-mint.node.test.ts+316−0
@@ -0,0 +1,316 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import { ApiKeyService, OrgApiKeyNotFound } from "./api-keys";
5import { ApiKeyManagementError } from "./errors";
6import { WorkOSClient, type WorkOSClientService } from "./workos";
7
8// ---------------------------------------------------------------------------
9// Minting, listing, and revoking ORGANIZATION-owned api keys — the privileged
10// credential that resolves to the read-only platform view (`/admin/*`).
11//
12// The decode boundary is what these pin. WorkOS returns an org-owned key with
13// the ORG in `owner.id` and no member at all, which is a different shape from
14// the user-owned keys the console's personal-key CRUD handles. The workspace
15// api key is also workspace-WIDE, so a response naming a different org than the
16// one asked for must not be surfaced as that org's key.
17//
18// NOTE (emulator gap): the WorkOS emulator's `serializeApiKey` hardcodes
19// `owner.type: "user"` and exposes no org-key mint route, so this path cannot
20// be exercised over the wire in e2e today. These stubs encode the REAL WorkOS
21// API shape (`organizations.createOrganizationApiKey` /
22// `listOrganizationApiKeys`, per the installed SDK's types).
23// ---------------------------------------------------------------------------
24
25const ORG = "org_123";
26
27const orgKeyResponse = (organizationId: string, id: string, name: string) => ({
28 object: "api_key",
29 id,
30 owner: { type: "organization", id: organizationId },
31 name,
32 obfuscatedValue: "sk_org…a1b2",
33 lastUsedAt: null,
34 permissions: [],
35 createdAt: "2026-01-01T00:00:00.000Z",
36 updatedAt: "2026-01-01T00:00:00.000Z",
37});
38
39const stubWorkOS = (overrides: Record<string, unknown> = {}) =>
40 Layer.succeed(
41 WorkOSClient,
42 new Proxy({} as WorkOSClientService, {
43 get: (_target, prop) => {
44 const override = overrides[String(prop)];
45 if (override) return override;
46 return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
47 },
48 }),
49 );
50
51const serviceWith = (overrides: Record<string, unknown>) =>
52 ApiKeyService.asEffect().pipe(
53 Effect.provide(ApiKeyService.WorkOS.pipe(Layer.provide(stubWorkOS(overrides)))),
54 );
55
56describe("organization API keys", () => {
57 it.effect("mints an org-owned key and returns the one-time secret", () =>
58 Effect.gen(function* () {
59 const apiKeys = yield* serviceWith({
60 createOrgApiKey: (params: { organizationId: string; name: string }) =>
61 Effect.succeed({
62 ...orgKeyResponse(params.organizationId, "key_org_1", params.name),
63 value: "sk_org_plaintext",
64 }),
65 });
66
67 const created = yield* apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" });
68
69 expect(created.id).toBe("key_org_1");
70 expect(created.name).toBe("acme-backend");
71 expect(created.value, "create returns the one-time plaintext secret").toBe(
72 "sk_org_plaintext",
73 );
74 expect(created.obfuscatedValue, "the display value is masked, not the secret").not.toBe(
75 created.value,
76 );
77 }),
78 );
79
80 it.effect("lists the organization's own keys, masked", () =>
81 Effect.gen(function* () {
82 const apiKeys = yield* serviceWith({
83 listOrgApiKeys: (organizationId: string) =>
84 Effect.succeed({
85 object: "list",
86 data: [
87 orgKeyResponse(organizationId, "key_org_1", "acme-backend"),
88 orgKeyResponse(organizationId, "key_org_2", "ci"),
89 ],
90 listMetadata: { before: null, after: null },
91 }),
92 });
93
94 const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG });
95
96 expect(keys.map((key) => key.name)).toEqual(["acme-backend", "ci"]);
97 expect(JSON.stringify(keys), "the list never carries a plaintext secret").not.toContain(
98 "sk_org_plaintext",
99 );
100 }),
101 );
102
103 it.effect("drops a key owned by a DIFFERENT organization", () =>
104 Effect.gen(function* () {
105 // The WorkOS workspace api key is workspace-wide, so a response naming
106 // another org must never be surfaced as this org's key.
107 const apiKeys = yield* serviceWith({
108 listOrgApiKeys: () =>
109 Effect.succeed({
110 object: "list",
111 data: [
112 orgKeyResponse(ORG, "key_mine", "mine"),
113 orgKeyResponse("org_other", "key_theirs", "theirs"),
114 ],
115 listMetadata: { before: null, after: null },
116 }),
117 });
118
119 const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG });
120
121 expect(
122 keys.map((key) => key.id),
123 "only this org's key survives",
124 ).toEqual(["key_mine"]);
125 }),
126 );
127
128 it.effect("refuses a create response that names another organization", () =>
129 Effect.gen(function* () {
130 const apiKeys = yield* serviceWith({
131 createOrgApiKey: () =>
132 Effect.succeed({
133 ...orgKeyResponse("org_other", "key_theirs", "theirs"),
134 value: "sk_org_plaintext",
135 }),
136 });
137
138 const error = yield* Effect.flip(
139 apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" }),
140 );
141
142 expect(error).toBeInstanceOf(ApiKeyManagementError);
143 }),
144 );
145
146 it.effect("ignores a user-owned key in an org-key listing", () =>
147 Effect.gen(function* () {
148 // The org and user shapes are decoded separately on purpose; a user key
149 // appearing here would mean the caller could mistake a member's personal
150 // credential for a workspace one.
151 const apiKeys = yield* serviceWith({
152 listOrgApiKeys: () =>
153 Effect.succeed({
154 object: "list",
155 data: [
156 orgKeyResponse(ORG, "key_mine", "mine"),
157 {
158 object: "api_key",
159 id: "key_user",
160 owner: { type: "user", id: "user_1", organization_id: ORG },
161 name: "personal",
162 obfuscatedValue: "sk_user…c3d4",
163 lastUsedAt: null,
164 createdAt: "2026-01-01T00:00:00.000Z",
165 updatedAt: "2026-01-01T00:00:00.000Z",
166 },
167 ],
168 listMetadata: { before: null, after: null },
169 }),
170 });
171
172 const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG });
173
174 expect(
175 keys.map((key) => key.id),
176 "a user-owned key is not an org key",
177 ).toEqual(["key_mine"]);
178 }),
179 );
180});
181
182// ---------------------------------------------------------------------------
183// Revoking an org key. The mint is only half a credential lifecycle: an org key
184// reads every user and connection in the tenant, so it has to be destroyable.
185//
186// Revoke cannot go through `revokeUserKey`/`revokeApiKey` — that path resolves
187// ownership against the CALLER's personal keys, so an org key id is never in
188// its owned set. It also cannot just call `deleteApiKey` directly: that route is
189// scope-blind and would happily destroy a member's personal key, or a key
190// belonging to a sibling org in the same WorkOS workspace. So ownership is
191// resolved against the ORG's decoded listing first, and that filter IS the
192// authorization. These pin that filter.
193// ---------------------------------------------------------------------------
194
195describe("revoking an organization API key", () => {
196 const listing = (...keys: ReadonlyArray<unknown>) => ({
197 object: "list",
198 data: keys,
199 listMetadata: { before: null, after: null },
200 });
201
202 it.effect("revokes a key the organization owns", () =>
203 Effect.gen(function* () {
204 const deleted: string[] = [];
205 const apiKeys = yield* serviceWith({
206 listOrgApiKeys: (organizationId: string) =>
207 Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))),
208 deleteApiKey: (id: string) =>
209 Effect.sync(() => {
210 deleted.push(id);
211 }),
212 });
213
214 yield* apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" });
215
216 expect(deleted, "the delete reaches WorkOS exactly once, for that key").toEqual([
217 "key_org_1",
218 ]);
219 }),
220 );
221
222 it.effect("refuses a USER-owned key id, without deleting anything", () =>
223 Effect.gen(function* () {
224 // The regression this guards: a member's personal credential must not be
225 // destroyable through the org route just because an admin can call it.
226 const deleted: string[] = [];
227 const apiKeys = yield* serviceWith({
228 listOrgApiKeys: () =>
229 Effect.succeed(
230 listing(orgKeyResponse(ORG, "key_org_1", "acme-backend"), {
231 object: "api_key",
232 id: "key_user",
233 owner: { type: "user", id: "user_1", organization_id: ORG },
234 name: "personal",
235 obfuscatedValue: "sk_user…c3d4",
236 lastUsedAt: null,
237 createdAt: "2026-01-01T00:00:00.000Z",
238 updatedAt: "2026-01-01T00:00:00.000Z",
239 }),
240 ),
241 deleteApiKey: (id: string) =>
242 Effect.sync(() => {
243 deleted.push(id);
244 }),
245 });
246
247 const error = yield* Effect.flip(
248 apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_user" }),
249 );
250
251 expect(error).toBeInstanceOf(OrgApiKeyNotFound);
252 expect(deleted, "no delete is issued for a key the org does not own").toEqual([]);
253 }),
254 );
255
256 it.effect("refuses a key belonging to ANOTHER organization", () =>
257 Effect.gen(function* () {
258 // The WorkOS workspace key is workspace-wide, so an admin of one org must
259 // not be able to revoke a sibling org's credential by id.
260 const deleted: string[] = [];
261 const apiKeys = yield* serviceWith({
262 listOrgApiKeys: () =>
263 Effect.succeed(
264 listing(
265 orgKeyResponse(ORG, "key_mine", "mine"),
266 orgKeyResponse("org_other", "key_theirs", "theirs"),
267 ),
268 ),
269 deleteApiKey: (id: string) =>
270 Effect.sync(() => {
271 deleted.push(id);
272 }),
273 });
274
275 const error = yield* Effect.flip(
276 apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_theirs" }),
277 );
278
279 expect(error).toBeInstanceOf(OrgApiKeyNotFound);
280 expect(deleted).toEqual([]);
281 }),
282 );
283
284 it.effect("refuses an unknown key id", () =>
285 Effect.gen(function* () {
286 const apiKeys = yield* serviceWith({
287 listOrgApiKeys: () => Effect.succeed(listing()),
288 deleteApiKey: () => Effect.die("nothing should be deleted"),
289 });
290
291 const error = yield* Effect.flip(
292 apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_nope" }),
293 );
294
295 expect(error).toBeInstanceOf(OrgApiKeyNotFound);
296 }),
297 );
298
299 it.effect("surfaces an upstream delete failure as a management error", () =>
300 Effect.gen(function* () {
301 // A failed delete must not read as a successful revoke — the console
302 // would tell an admin a live credential is gone.
303 const apiKeys = yield* serviceWith({
304 listOrgApiKeys: (organizationId: string) =>
305 Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))),
306 deleteApiKey: () => Effect.fail("workos_unavailable"),
307 });
308
309 const error = yield* Effect.flip(
310 apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }),
311 );
312
313 expect(error).toBeInstanceOf(ApiKeyManagementError);
314 }),
315 );
316});
apps/cloud/src/auth/org-selector-auth.node.test.ts+33−10
@@ -7,9 +7,11 @@ import { resolveSessionPrincipal } from "./workos-auth-provider"; · 6 unmodified lines
7import { WorkOSClient, type WorkOSClientService } from "./workos";7import { WorkOSClient, type WorkOSClientService } from "./workos";
8 8
9// The org a console request resolves to is the URL's org (sent in the9// The org a console request resolves to is the URL's org (sent in the
10// `x-executor-organization` selector header), not the session's stored org —10// `x-executor-organization` selector header) — NEVER the session's stored org.
11// with the session org as the fallback for non-console callers, and live11// The sealed cookie's org is a browser-global pinned to whichever org WorkOS
12// membership re-checked either way. This is what makes two browser tabs on12// last touched, so a fallback to it silently scopes a multi-org user's request
13// to the wrong org; a header-less request fails closed instead. Live
14// membership is re-checked either way. This is what makes two browser tabs on
13// different orgs independent.15// different orgs independent.
14 16
15const createdAt = new Date("2026-01-01T00:00:00.000Z");17const createdAt = new Date("2026-01-01T00:00:00.000Z");
@@ -27,6 +29,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 11 unmodified lines
27 listUserKeys: () => Effect.succeed([]),29 listUserKeys: () => Effect.succeed([]),
28 createUserKey: () => Effect.die("not used"),30 createUserKey: () => Effect.die("not used"),
29 revokeUserKey: () => Effect.void,31 revokeUserKey: () => Effect.void,
32 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
33 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
34 revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
30});35});
31 36
32const stubWorkOS = Layer.succeed(37const stubWorkOS = Layer.succeed(
@@ -35,7 +40,11 @@ const stubWorkOS = Layer.succeed( · 2 unmodified lines
35 get: (_t, prop) => {40 get: (_t, prop) => {
36 if (prop === "authenticateRequest") {41 if (prop === "authenticateRequest") {
37 return () =>42 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 });
39 }48 }
40 if (prop === "listUserMemberships") {49 if (prop === "listUserMemberships") {
41 return (userId: string) =>50 return (userId: string) =>
@@ -55,7 +64,7 @@ const stubWorkOS = Layer.succeed( · 13 unmodified lines
55);64);
56 65
57const stubUsers = Layer.succeed(UserStoreService)({66const stubUsers = Layer.succeed(UserStoreService)({
58 use: (fn) =>67 use: (_op, fn) =>
59 Effect.promise(() =>68 Effect.promise(() =>
60 fn({69 fn({
61 ensureAccount: async (id: string) => ({ id, createdAt }),70 ensureAccount: async (id: string) => ({ id, createdAt }),
@@ -66,7 +75,12 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 4 unmodified lines
66 slug: org.id,75 slug: org.id,
67 createdAt,76 createdAt,
68 }),77 }),
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 }),
70 // The URL slug maps to URL_ORG (the member's other org); any other slug84 // The URL slug maps to URL_ORG (the member's other org); any other slug
71 // maps to an org the caller is NOT a member of, so membership rejects it.85 // maps to an org the caller is NOT a member of, so membership rejects it.
72 getOrganizationBySlug: async (slug: string) => ({86 getOrganizationBySlug: async (slug: string) => ({
@@ -75,6 +89,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 2 unmodified lines
75 slug,89 slug,
76 createdAt,90 createdAt,
77 }),91 }),
92 deleteOrganizationCascade: async () => {},
78 }),93 }),
79 ),94 ),
80});95});
@@ -85,10 +100,15 @@ const run = (headers: Record<string, string>) => · 4 unmodified lines
85 );100 );
86 101
87describe("resolveSessionPrincipal · URL org selector", () => {102describe("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", () =>
89 Effect.gen(function* () {104 Effect.gen(function* () {
90 const principal = yield* run({ cookie: "wos-session=x" });105 // No fallback to the session org: the cookie's org is browser-global
91 expect(principal.organizationId, "scopes to the session org").toBe(SESSION_ORG);106 // and can name a different org than the tab's URL for a multi-org user.
107 const error = yield* Effect.flip(run({ cookie: "wos-session=x" }));
108 expect(error, "rejects instead of scoping to the session org").toMatchObject({
109 _tag: "NoOrganization",
110 code: "no_organization",
111 });
92 }),112 }),
93 );113 );
94 114
@@ -117,7 +137,10 @@ describe("resolveSessionPrincipal · URL org selector", () => { · 22 unmodified lines
117 // The slug resolves to a real org id, but membership is re-checked — a137 // The slug resolves to a real org id, but membership is re-checked — a
118 // slug is a selector, not a trust boundary, so a non-member is rejected.138 // slug is a selector, not a trust boundary, so a non-member is rejected.
119 const error = yield* Effect.flip(139 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 }),
121 );144 );
122 expect(error).toMatchObject({ _tag: "NoOrganization" });145 expect(error).toMatchObject({ _tag: "NoOrganization" });
123 }),146 }),
apps/cloud/src/auth/organization.ts+18−6
@@ -38,12 +38,14 @@ import { WorkOSClient } from "./workos"; · 37 unmodified lines
38export const resolveOrganization = (organizationId: string) =>38export const resolveOrganization = (organizationId: string) =>
39 Effect.gen(function* () {39 Effect.gen(function* () {
40 const users = yield* UserStoreService;40 const users = yield* UserStoreService;
41 const existing = yield* users.use((s) => s.getOrganization(organizationId));41 const existing = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId));
42 if (existing) return existing;42 if (existing) return existing;
43 43
44 const workos = yield* WorkOSClient;44 const workos = yield* WorkOSClient;
45 const fresh = yield* workos.getOrganization(organizationId);45 const fresh = yield* workos.getOrganization(organizationId);
46 return yield* users.use((s) => s.upsertOrganization({ id: fresh.id, name: fresh.name }));46 return yield* users.use("upsertOrganization", (s) =>
47 s.upsertOrganization({ id: fresh.id, name: fresh.name }),
48 );
47 });49 });
48 50
49// ---------------------------------------------------------------------------51// ---------------------------------------------------------------------------
@@ -57,9 +59,19 @@ export const resolveOrganization = (organizationId: string) => · 7 unmodified lines
57// until their access token naturally expires (~10 min).59// until their access token naturally expires (~10 min).
58//60//
59// To close that gap we verify membership live on every protected request.61// To close that gap we verify membership live on every protected request.
60// `listUserMemberships` is one WorkOS call per request. If this becomes a62// `listUserMemberships` is one WorkOS call per request.
61// hot path we can layer a short per-(user, org) TTL cache underneath, or63//
62// swap it for a local memberships table fed by the WorkOS Events API.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.
63//75//
64// Returns the resolved organization (via resolveOrganization) if the user76// Returns the resolved organization (via resolveOrganization) if the user
65// currently holds an *active* membership in it, otherwise null. Callers77// currently holds an *active* membership in it, otherwise null. Callers
@@ -115,7 +127,7 @@ export const authorizeOrganizationSelector = (userId: string, selector: string) · 49 unmodified lines
115 return yield* authorizeOrganization(userId, selector);127 return yield* authorizeOrganization(userId, selector);
116 }128 }
117 const users = yield* UserStoreService;129 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));
119 if (!org) return null;131 if (!org) return null;
120 return yield* authorizeOrganization(userId, org.id);132 return yield* authorizeOrganization(userId, org.id);
121 });133 });
apps/cloud/src/auth/user-store-error.node.test.ts+128−0
@@ -0,0 +1,128 @@
1// `UserStoreError` is the public failure of every cloud user-store call, and it
2// used to carry NOTHING: no operation, no reason, no message. Sentry grouped
3// every store failure — a connect timeout, a missing table, a constraint
4// violation — into one titleless issue, and the only cause detail (the
5// pretty-printed Effect cause stuffed into a Sentry `extra`) is scrubbed
6// server-side, so the issue was undiagnosable from Sentry alone.
7//
8// This pins the two safe classification fields it must carry instead:
9// `operation` (already in hand at the call site) and `reason` (classified from
10// the driver cause the way `statusFromWorkOSCause` classifies WorkOS causes).
11import { createServer, type Server, type Socket } from "node:net";
12
13import { describe, expect, it } from "@effect/vitest";
14import { Effect, Layer, Result } from "effect";
15import postgres from "postgres";
16
17import { UserStoreService } from "./context";
18import { ServiceAdapterError, userStoreReasonFromCause, type UserStoreError } from "./errors";
19import { DbService } from "../db/db";
20
21// A socket that completes the TCP handshake and then says nothing — exactly
22// what a wedged Hyperdrive/Postgres endpoint looks like to postgres.js, and the
23// only way to obtain the driver's REAL connect-timeout error object rather than
24// a hand-written imitation of it.
25const blackHolePort = async (): Promise<{
26 readonly port: number;
27 readonly close: () => Promise<void>;
28}> => {
29 const sockets = new Set<Socket>();
30 const server: Server = createServer((socket) => {
31 sockets.add(socket);
32 socket.on("close", () => sockets.delete(socket));
33 });
34 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
35 const address = server.address();
36 // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: the fixture cannot run without a bound port
37 if (address === null || typeof address === "string") throw new Error("no port");
38 return {
39 port: address.port,
40 close: () =>
41 new Promise<void>((resolve) => {
42 // The timed-out client leaves its half-open socket attached; without
43 // dropping it first, `close` waits for a peer that will never speak.
44 for (const socket of sockets) socket.destroy();
45 server.close(() => resolve());
46 }),
47 };
48};
49
50const realConnectTimeoutError = async (): Promise<unknown> => {
51 const hole = await blackHolePort();
52 const sql = postgres(`postgresql://postgres:postgres@127.0.0.1:${hole.port}/postgres`, {
53 max: 1,
54 connect_timeout: 1,
55 fetch_types: false,
56 onnotice: () => undefined,
57 });
58 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: capturing the driver's own thrown error IS the fixture
59 try {
60 await sql`select 1`;
61 // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: the fixture is unusable if the socket answered
62 throw new Error("expected the connect to time out");
63 } catch (error) {
64 return error;
65 } finally {
66 // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort teardown of a connection that never opened
67 await sql.end({ timeout: 0 }).catch(() => undefined);
68 await hole.close();
69 }
70};
71
72// Drizzle re-throws driver failures wrapped in its own error with the failing
73// SQL in the message and the driver error in `.cause` — the shape production
74// actually reports (`Failed query: select … -> write CONNECT_TIMEOUT …`).
75const wrappedLikeDrizzle = (cause: unknown): Error =>
76 // oxlint-disable-next-line executor/no-error-constructor -- boundary: reproducing the driver wrapper shape the store really fails with
77 Object.assign(new Error('Failed query: select "id" from "organizations" where "id" = $1'), {
78 cause,
79 });
80
81const stubDb = Layer.succeed(DbService)({ db: {} as never });
82
83const failingStoreCall = (
84 operation: string,
85 failure: unknown,
86): Effect.Effect<Result.Result<never, UserStoreError>> =>
87 Effect.gen(function* () {
88 const users = yield* UserStoreService;
89 // oxlint-disable-next-line executor/no-promise-reject -- boundary: the store adapter lifts a REJECTING promise; rejecting is the fixture
90 return yield* users.use(operation, () => Promise.reject(failure));
91 }).pipe(
92 Effect.provide(UserStoreService.Live.pipe(Layer.provide(stubDb))),
93 Effect.result,
94 ) as Effect.Effect<Result.Result<never, UserStoreError>>;
95
96describe("UserStoreError classification", () => {
97 it("classifies the driver cause chain", async () => {
98 const driverError = await realConnectTimeoutError();
99
100 expect(userStoreReasonFromCause(wrappedLikeDrizzle(driverError))).toBe("connect_timeout");
101 expect(
102 userStoreReasonFromCause(new ServiceAdapterError({ cause: wrappedLikeDrizzle(driverError) })),
103 ).toBe("connect_timeout");
104 expect(
105 userStoreReasonFromCause(
106 // oxlint-disable-next-line executor/no-error-constructor -- boundary: a SQLSTATE failure shape
107 Object.assign(new Error('relation "organizations" does not exist'), { code: "42P01" }),
108 ),
109 ).toBe("query");
110 // oxlint-disable-next-line executor/no-error-constructor -- boundary: a failure with nothing to classify
111 expect(userStoreReasonFromCause(new Error("something else"))).toBe("unknown");
112 }, 20_000);
113
114 it("carries operation, reason and a stable message on the public error", async () => {
115 const driverError = await realConnectTimeoutError();
116
117 const result = await Effect.runPromise(
118 failingStoreCall("getOrganization", wrappedLikeDrizzle(driverError)),
119 );
120
121 expect(Result.isFailure(result)).toBe(true);
122 if (!Result.isFailure(result)) return;
123 expect(result.failure.operation).toBe("getOrganization");
124 expect(result.failure.reason).toBe("connect_timeout");
125 expect(result.failure.message).toContain("getOrganization");
126 expect(result.failure.message).toContain("connect_timeout");
127 }, 20_000);
128});
apps/cloud/src/auth/user-store.ts+6−0
@@ -13,6 +13,7 @@ import { generateOrgSlug } from "@executor-js/api"; · 12 unmodified lines
13 13
14import { accounts, organizations } from "../db/schema";14import { accounts, organizations } from "../db/schema";
15import type { DrizzleDb } from "../db/db";15import type { DrizzleDb } from "../db/db";
16import { purgeOrganizationData } from "../db/org-deletion";
16 17
17export type Account = typeof accounts.$inferSelect;18export type Account = typeof accounts.$inferSelect;
18export type Organization = typeof organizations.$inferSelect;19export type Organization = typeof organizations.$inferSelect;
@@ -96,5 +97,10 @@ export const makeUserStore = (db: DrizzleDb) => { · 77 unmodified lines
96 const rows = await db.select().from(organizations).where(eq(organizations.slug, slug));97 const rows = await db.select().from(organizations).where(eq(organizations.slug, slug));
97 return rows[0] ?? null;98 return rows[0] ?? null;
98 },99 },
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),
99 };105 };
100};106};
apps/cloud/src/auth/workos-auth-provider.ts+132−19
@@ -16,6 +16,7 @@ · 15 unmodified lines
16// - invalid api key -> Unauthorized 401 invalid_api_key16// - invalid api key -> Unauthorized 401 invalid_api_key
17// - api-key org not authorized -> NoOrganization 403 no_organization17// - api-key org not authorized -> NoOrganization 403 no_organization
18// - no/invalid session -> NoOrganization 403 no_organization18// - no/invalid session -> NoOrganization 403 no_organization
19// - session without org header -> NoOrganization 403 no_organization (fail closed)
19// - session org not authorized -> NoOrganization 403 no_organization20// - session org not authorized -> NoOrganization 403 no_organization
20// - no auth header -> falls through to the sealed-session path21// - no auth header -> falls through to the sealed-session path
21// The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are22// The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are
@@ -37,7 +38,13 @@ import { · 15 unmodified lines
37 Unauthorized,38 Unauthorized,
38 Unavailable,39 Unavailable,
39} from "@executor-js/api/server";40} from "@executor-js/api/server";
40import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server";41import type {
42 FailureRenderingStrategy,
43 IdentityFailure,
44 PlatformPrincipal,
45 Principal,
46 ResolvedPrincipal,
47} from "@executor-js/api/server";
41 48
42import { ApiKeyService } from "./api-keys";49import { ApiKeyService } from "./api-keys";
43import { workosApiJwtBearerConfig } from "./api-jwt-bearer";50import { workosApiJwtBearerConfig } from "./api-jwt-bearer";
@@ -46,6 +53,7 @@ import { · 2 unmodified lines
46 authorizeOrganization,53 authorizeOrganization,
47 authorizeOrganizationSelector,54 authorizeOrganizationSelector,
48 orgSelectorFromRequest,55 orgSelectorFromRequest,
56 resolveOrganization,
49} from "./organization";57} from "./organization";
50import { UserStoreService } from "./context";58import { UserStoreService } from "./context";
51import { sealedSessionDisplayName } from "./middleware";59import { sealedSessionDisplayName } from "./middleware";
@@ -86,6 +94,10 @@ const NO_ORGANIZATION_IN_SESSION = { · 34 unmodified lines
86 code: "no_organization",94 code: "no_organization",
87 message: "No organization in session",95 message: "No organization in session",
88};96};
97const NO_ORGANIZATION_SELECTOR = {
98 code: "no_organization",
99 message: "No organization selector in request",
100};
89const INVALID_ACCESS_TOKEN = {101const INVALID_ACCESS_TOKEN = {
90 code: "invalid_access_token",102 code: "invalid_access_token",
91 message: "Invalid or expired access token",103 message: "Invalid or expired access token",
@@ -98,7 +110,6 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { · 6 unmodified lines
98 code: "no_organization",110 code: "no_organization",
99 message: "No organization in access token",111 message: "No organization in access token",
100};112};
101
102// A bearer value with three dot-separated segments is a JWT (a WorkOS access113// A bearer value with three dot-separated segments is a JWT (a WorkOS access
103// token from the CLI device-login); anything else is treated as an API key.114// token from the CLI device-login); anything else is treated as an API key.
104// Same discriminator the MCP plane uses (`mcp/auth.ts`).115// Same discriminator the MCP plane uses (`mcp/auth.ts`).
@@ -134,6 +145,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 29 unmodified lines
134 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN);145 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN);
135 146
136 return {147 return {
148 kind: "member",
137 accountId: verified.accountId,149 accountId: verified.accountId,
138 organizationId: org.id,150 organizationId: org.id,
139 organizationName: org.name,151 organizationName: org.name,
@@ -146,13 +158,49 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 6 unmodified lines
146 });158 });
147 159
148/**160/**
149 * Resolve a `Bearer` credential into a `Principal`: a WorkOS access-token JWT161 * What an ORG-level API key resolves to: the platform view. Deliberately NOT a
150 * (when `jwt` config is supplied and the value looks like a JWT) or a WorkOS162 * `Principal` — a `Principal` names an acting member (`accountId: string`), and
151 * API key. Returns `null` when there is no `Authorization` header, so the163 * an org key has none. Keeping it a separate shape means no product code path
152 * caller falls through to the sealed-session path. (Kept the historical name,164 * can accidentally treat it as a member, and the executor built from it binds
153 * the re-export and resolver tests reference it.)165 * `subject: null` + the read-only tenant reach rather than inventing a subject.
166 *
167 * The `/admin/*` mount turns this into an executor with `{ tenant:
168 * organizationId, subject: undefined, platformView: true }`.
169 */
170export interface PlatformAuth {
171 readonly kind: "platform";
172 readonly organizationId: string;
173 readonly organizationName: string;
174 readonly organizationSlug?: string;
175 readonly keyId: string;
176}
177
178/** A resolved bearer credential: an acting member, or the org-level platform
179 * view. `null` means no `Authorization` header at all. */
180export type BearerAuth = Principal | PlatformAuth | null;
181
182export const isPlatformAuth = (value: BearerAuth): value is PlatformAuth =>
183 value !== null && "kind" in value && value.kind === "platform";
184
185/**
186 * Resolve a `Bearer` credential into either a member `Principal` or the
187 * org-level {@link PlatformAuth}. Returns `null` when there is no
188 * `Authorization` header, so the caller falls through to the sealed-session
189 * path.
190 *
191 * The org branch does NOT call `authorizeOrganization`: that checks a USER's
192 * live membership, and there is no user here. The key itself is the authority —
193 * WorkOS validated it and reported which org owns it — so the org row is merely
194 * resolved (mirrored on first read) for its name and slug.
154 */195 */
155export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | null = null) =>196export const resolveBearerAuth = (
197 request: Request,
198 jwt: JwtBearerConfig | null = null,
199): Effect.Effect<
200 BearerAuth,
201 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
202 WorkOSClient | ApiKeyService | UserStoreService
203> =>
156 Effect.gen(function* () {204 Effect.gen(function* () {
157 const authHeader = request.headers.get("authorization");205 const authHeader = request.headers.get("authorization");
158 if (!authHeader) return null;206 if (!authHeader) return null;
@@ -167,7 +215,7 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 8 unmodified lines
167 if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt);215 if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt);
168 216
169 const apiKeys = yield* ApiKeyService;217 const apiKeys = yield* ApiKeyService;
170 const principal = yield* apiKeys218 const owner = yield* apiKeys
171 .validate(value)219 .validate(value)
172 .pipe(220 .pipe(
173 Effect.catchTag("ApiKeyValidationError", () =>221 Effect.catchTag("ApiKeyValidationError", () =>
@@ -175,13 +223,29 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 1 unmodified line
175 ),223 ),
176 );224 );
177 225
178 if (!principal) return yield* new Unauthorized(INVALID_API_KEY);226 if (!owner) return yield* new Unauthorized(INVALID_API_KEY);
179 227
180 const org = yield* authorizeOrganization(principal.accountId, principal.organizationId);228 if (owner.scope === "org") {
229 const org = yield* resolveOrganization(owner.organizationId);
230 return {
231 kind: "platform",
232 organizationId: org.id,
233 organizationName: org.name,
234 ...(org.slug === undefined || org.slug === null ? {} : { organizationSlug: org.slug }),
235 keyId: owner.keyId,
236 } satisfies PlatformAuth;
237 }
238
239 // A `"user"` key always carries an accountId (see `ownerFromApiKey`); the
240 // guard keeps the narrowing honest rather than asserting.
241 if (owner.accountId == null) return yield* new Unauthorized(INVALID_API_KEY);
242
243 const org = yield* authorizeOrganization(owner.accountId, owner.organizationId);
181 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY);244 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY);
182 245
183 return {246 return {
184 accountId: principal.accountId,247 kind: "member",
248 accountId: owner.accountId,
185 organizationId: org.id,249 organizationId: org.id,
186 organizationName: org.name,250 organizationName: org.name,
187 organizationSlug: org.slug,251 organizationSlug: org.slug,
@@ -192,6 +256,40 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 4 unmodified lines
192 } satisfies Principal;256 } satisfies Principal;
193 });257 });
194 258
259/**
260 * The PRODUCT-plane bearer resolver. An org-level key resolves to the neutral
261 * seam's {@link PlatformPrincipal} — NOT a member `Principal` — and the shared
262 * middleware routes it to the subject-less, read-only platform executor
263 * (refusing non-GET up front). Previously the product plane rejected org keys
264 * outright; serving tenant-level READS to them is deliberate: the catalog,
265 * tools, policies, and org-owned connection listings are tenant-shared answers
266 * a machine credential can honestly receive, while everything subject-bound
267 * stays structurally out of reach (a platform executor binds no subject, so no
268 * member's personal rows resolve). (Kept the historical name; the re-export and
269 * resolver tests reference it.)
270 */
271export const resolveApiKeyPrincipal = (
272 request: Request,
273 jwt: JwtBearerConfig | null = null,
274): Effect.Effect<
275 ResolvedPrincipal | null,
276 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
277 WorkOSClient | ApiKeyService | UserStoreService
278> =>
279 Effect.gen(function* () {
280 const auth = yield* resolveBearerAuth(request, jwt);
281 if (isPlatformAuth(auth)) {
282 return {
283 kind: "platform",
284 organizationId: auth.organizationId,
285 organizationName: auth.organizationName,
286 ...(auth.organizationSlug === undefined ? {} : { organizationSlug: auth.organizationSlug }),
287 keyId: auth.keyId,
288 } satisfies PlatformPrincipal;
289 }
290 return auth;
291 });
292
195export const resolveSessionPrincipal = (request: Request) =>293export const resolveSessionPrincipal = (request: Request) =>
196 Effect.gen(function* () {294 Effect.gen(function* () {
197 const workos = yield* WorkOSClient;295 const workos = yield* WorkOSClient;
@@ -199,17 +297,27 @@ export const resolveSessionPrincipal = (request: Request) => · 1 unmodified line
199 if (!session) {297 if (!session) {
200 return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);298 return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
201 }299 }
202 // The console URL's org is the scope authority (sent as a header); the300 // The console URL's org is the scope authority, sent as a header on every
203 // session's own org is the fallback for non-console callers. Membership is301 // request. FAIL CLOSED when it's missing: the sealed cookie's org is
204 // re-checked live either way — the header is a selector, not a trust302 // browser-global and pinned to whichever org WorkOS last touched, so
205 // boundary (see organization.ts).303 // falling back to it silently serves ANOTHER org's data to a multi-org
206 const selector = orgSelectorFromRequest(request) ?? session.organizationId;304 // user (the wrong-tenant connection-list bug, 2026-07). A header-less
305 // session call gets a clear 403 instead. Membership is re-checked live —
306 // the header is a selector, not a trust boundary (see organization.ts).
307 // A bare-URL first paint (no org in the path yet) may 403 here; that's
308 // the safe outcome — OrgSlugGate immediately canonicalizes the URL onto
309 // an org slug, the org-keyed atom registry remounts, and everything
310 // refetches with the header. `/account/me` — which deliberately DOES fall
311 // back to the session org to pick that landing org — lives on the account
312 // plane, not here.
313 const selector = orgSelectorFromRequest(request);
207 if (!selector) {314 if (!selector) {
208 return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);315 return yield* new NoOrganization(NO_ORGANIZATION_SELECTOR);
209 }316 }
210 const org = yield* authorizeOrganizationSelector(session.userId, selector);317 const org = yield* authorizeOrganizationSelector(session.userId, selector);
211 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);318 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
212 return {319 return {
320 kind: "member",
213 accountId: session.userId,321 accountId: session.userId,
214 organizationId: org.id,322 organizationId: org.id,
215 organizationName: org.name,323 organizationName: org.name,
@@ -236,7 +344,7 @@ export const resolveProtectedPrincipal = ( · 20 unmodified lines
236 request: Request,344 request: Request,
237 jwt: JwtBearerConfig | null = null,345 jwt: JwtBearerConfig | null = null,
238): Effect.Effect<346): Effect.Effect<
239 Principal,347 ResolvedPrincipal,
240 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,348 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
241 WorkOSClient | ApiKeyService | UserStoreService349 WorkOSClient | ApiKeyService | UserStoreService
242> =>350> =>
@@ -317,6 +425,11 @@ export const cloudIdentityFailureStrategy: FailureRenderingStrategy<IdentityFail · 74 unmodified lines
317 "service_unavailable",425 "service_unavailable",
318 "Service temporarily unavailable",426 "Service temporarily unavailable",
319 ),427 ),
428 ReadOnlyCredential: renderIdentityFailure(
429 403,
430 "read_only_credential",
431 "Organization API keys are read-only",
432 ),
320 }),433 }),
321 ),434 ),
322};435};
apps/cloud/src/auth/workos.node.test.ts+282−1
@@ -1,6 +1,287 @@
1import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2import type { AddressInfo } from "node:net";
3
1import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
5import { env } from "cloudflare:workers";
6import { Effect, Schema } from "effect";
7import { defaults as ironDefaults, seal as sealIron } from "iron-webcrypto";
8import {
9 SignJWT,
10 exportJWK,
11 generateKeyPair,
12 type JSONWebKeySet,
13 type JWK,
14 type KeyLike,
15} from "jose";
16
17import { WorkOSClient, collectRawWorkOSList, collectWorkOSList } from "./workos";
18
19const COOKIE_PASSWORD = "test_cookie_password_at_least_32_chars!";
20const CLIENT_ID = "client_test";
21const API_KEY = "sk_test";
22const USER = {
23 id: "user_test",
24 email: "test@example.com",
25 firstName: "Test",
26 lastName: "User",
27 profilePictureUrl: "https://example.com/avatar.png",
28} as const;
29
30interface Keypair {
31 readonly kid: string;
32 readonly publicJwk: JWK;
33 readonly privateKey: KeyLike;
34}
35
36interface RecordedRequest {
37 readonly method: string;
38 readonly path: string;
39 readonly body: unknown;
40}
41
42interface WorkOSStub {
43 readonly baseUrl: string;
44 readonly requests: () => readonly RecordedRequest[];
45 readonly setKeys: (keys: ReadonlyArray<JWK>) => void;
46}
47
48const generateKeypair = async (kid: string): Promise<Keypair> => {
49 const { publicKey, privateKey } = await generateKeyPair("RS256");
50 const jwk = await exportJWK(publicKey);
51 return { kid, publicJwk: { ...jwk, kid, alg: "RS256" }, privateKey };
52};
53
54const signAccessToken = (
55 keypair: Keypair,
56 claims: {
57 readonly subject?: string;
58 readonly organizationId?: string;
59 readonly sessionId?: string;
60 readonly expiresIn?: string | number;
61 } = {},
62) => {
63 const jwt = new SignJWT({
64 org_id: claims.organizationId ?? "org_test",
65 sid: claims.sessionId ?? "session_test",
66 })
67 .setProtectedHeader({ alg: "RS256", kid: keypair.kid })
68 .setSubject(claims.subject ?? USER.id)
69 .setIssuedAt();
70
71 return (
72 typeof claims.expiresIn === "number"
73 ? jwt.setExpirationTime(claims.expiresIn)
74 : jwt.setExpirationTime(claims.expiresIn ?? "5m")
75 ).sign(keypair.privateKey);
76};
77
78const sealSession = async (accessToken: string, refreshToken = "refresh_test"): Promise<string> =>
79 `${await sealIron(
80 {
81 accessToken,
82 refreshToken,
83 user: USER,
84 },
85 { id: "1", secret: COOKIE_PASSWORD },
86 { ...ironDefaults, ttl: 0, encode: JSON.stringify },
87 )}~2`;
88
89const workosUserResponse = {
90 object: "user",
91 id: USER.id,
92 email: USER.email,
93 email_verified: true,
94 profile_picture_url: USER.profilePictureUrl,
95 first_name: USER.firstName,
96 last_name: USER.lastName,
97 last_sign_in_at: null,
98 locale: null,
99 created_at: "2026-07-09T00:00:00.000Z",
100 updated_at: "2026-07-09T00:00:00.000Z",
101 external_id: null,
102 metadata: {},
103} as const;
104
105const decodeJsonBody = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
106
107const readJsonBody = async (request: IncomingMessage): Promise<unknown> => {
108 const chunks: Buffer[] = [];
109 for await (const chunk of request) {
110 chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
111 }
112 const body = Buffer.concat(chunks).toString("utf8");
113 return body ? decodeJsonBody(body) : null;
114};
115
116const writeJson = (response: ServerResponse, status: number, body: unknown): void => {
117 response.writeHead(status, { "content-type": "application/json" });
118 response.end(JSON.stringify(body));
119};
120
121const withWorkOSStub = async <A>(
122 keypair: Keypair,
123 use: (stub: WorkOSStub) => Promise<A>,
124): Promise<A> => {
125 let keys: ReadonlyArray<JWK> = [keypair.publicJwk];
126 const requests: RecordedRequest[] = [];
127 const server = createServer((request, response) => {
128 // oxlint-disable-next-line executor/no-promise-catch -- boundary: Node's request callback cannot await, so the fixture converts async failures into a stable HTTP 500
129 void (async () => {
130 const path = request.url ?? "/";
131 const body = request.method === "POST" ? await readJsonBody(request) : null;
132 requests.push({ method: request.method ?? "GET", path, body });
133
134 if (request.method === "GET" && path === `/sso/jwks/${CLIENT_ID}`) {
135 writeJson(response, 200, { keys: keys.map((key) => ({ ...key })) } satisfies JSONWebKeySet);
136 return;
137 }
138
139 if (request.method === "POST" && path === "/user_management/authenticate") {
140 const refreshed = await signAccessToken(keypair, {
141 sessionId: "session_refreshed",
142 });
143 writeJson(response, 200, {
144 user: workosUserResponse,
145 organization_id: "org_test",
146 access_token: refreshed,
147 refresh_token: "refresh_next",
148 authentication_method: "Password",
149 });
150 return;
151 }
152
153 writeJson(response, 404, { error: "unexpected request" });
154 })().catch((_error: unknown) => {
155 writeJson(response, 500, {
156 error: "fixture server failed",
157 });
158 });
159 });
160
161 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
162 const address = server.address() as AddressInfo;
163 const stub: WorkOSStub = {
164 baseUrl: `http://127.0.0.1:${address.port}`,
165 requests: () => requests,
166 setKeys: (next) => {
167 keys = next;
168 },
169 };
170
171 return use(stub).finally(() => {
172 server.closeAllConnections();
173 server.close();
174 });
175};
2 176
3import { collectRawWorkOSList, collectWorkOSList } from "./workos";177const runAuthenticate = (sessionData: string, baseUrl: string) => {
178 Object.assign(env, {
179 WORKOS_API_KEY: API_KEY,
180 WORKOS_CLIENT_ID: CLIENT_ID,
181 WORKOS_COOKIE_PASSWORD: COOKIE_PASSWORD,
182 WORKOS_API_URL: baseUrl,
183 });
184
185 return Effect.runPromise(
186 Effect.gen(function* () {
187 const workos = yield* WorkOSClient;
188 return yield* workos.authenticateSealedSession(sessionData);
189 }).pipe(Effect.provide(WorkOSClient.Default)),
190 );
191};
192
193describe("authenticateSealedSession", () => {
194 it("validates a sealed session locally with the cached JWKS", async () => {
195 const keypair = await generateKeypair("k_valid");
196 await withWorkOSStub(keypair, async (stub) => {
197 const token = await signAccessToken(keypair, {
198 organizationId: "org_test",
199 sessionId: "session_valid",
200 });
201 const session = await sealSession(token);
202
203 const result = await runAuthenticate(session, stub.baseUrl);
204
205 expect(result).toEqual({
206 userId: USER.id,
207 email: USER.email,
208 firstName: USER.firstName,
209 lastName: USER.lastName,
210 avatarUrl: USER.profilePictureUrl,
211 organizationId: "org_test",
212 sessionId: "session_valid",
213 refreshedSession: undefined,
214 });
215 expect(stub.requests()).toEqual([
216 { method: "GET", path: `/sso/jwks/${CLIENT_ID}`, body: null },
217 ]);
218 });
219 });
220
221 it("reuses the module cached JWKS for a second sealed session", async () => {
222 const keypair = await generateKeypair("k_cached");
223 await withWorkOSStub(keypair, async (stub) => {
224 const first = await sealSession(
225 await signAccessToken(keypair, {
226 sessionId: "session_first",
227 }),
228 );
229 const second = await sealSession(
230 await signAccessToken(keypair, {
231 sessionId: "session_second",
232 }),
233 );
234
235 await runAuthenticate(first, stub.baseUrl);
236 const afterFirst = stub.requests().length;
237 const result = await runAuthenticate(second, stub.baseUrl);
238
239 expect(afterFirst).toBe(1);
240 expect(result?.sessionId).toBe("session_second");
241 expect(stub.requests()).toEqual([
242 { method: "GET", path: `/sso/jwks/${CLIENT_ID}`, body: null },
243 ]);
244 });
245 });
246
247 it("falls through to SDK refresh when the access token is expired", async () => {
248 const keypair = await generateKeypair("k_expired");
249 await withWorkOSStub(keypair, async (stub) => {
250 const expiredAt = Math.floor(Date.now() / 1000) - 60;
251 const expired = await signAccessToken(keypair, {
252 expiresIn: expiredAt,
253 sessionId: "session_expired",
254 });
255 const session = await sealSession(expired, "refresh_expired");
256
257 const result = await runAuthenticate(session, stub.baseUrl);
258
259 expect(result?.sessionId).toBe("session_refreshed");
260 expect(result?.refreshedSession).toEqual(expect.any(String));
261 expect(stub.requests().map((request) => [request.method, request.path])).toEqual([
262 ["GET", `/sso/jwks/${CLIENT_ID}`],
263 ["POST", "/user_management/authenticate"],
264 ]);
265 expect(stub.requests()[1]?.body).toMatchObject({
266 grant_type: "refresh_token",
267 client_id: CLIENT_ID,
268 client_secret: API_KEY,
269 refresh_token: "refresh_expired",
270 organization_id: "org_test",
271 });
272 });
273 });
274
275 it("returns null for garbage session data", async () => {
276 const keypair = await generateKeypair("k_garbage");
277 await withWorkOSStub(keypair, async (stub) => {
278 const result = await runAuthenticate("not-a-sealed-session", stub.baseUrl);
279
280 expect(result).toBeNull();
281 expect(stub.requests()).toEqual([]);
282 });
283 });
284});
4 285
5describe("collectWorkOSList", () => {286describe("collectWorkOSList", () => {
6 it("collects memberships beyond the first WorkOS page", async () => {287 it("collects memberships beyond the first WorkOS page", async () => {
apps/cloud/src/auth/workos.ts+420−46
@@ -3,13 +3,23 @@ · 2 unmodified lines
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4 4
5import { env } from "cloudflare:workers";5import { env } from "cloudflare:workers";
6import { Context, Data, Effect, Layer, Option, Schema } from "effect";6import { Context, Data, Effect, Layer, Option, Predicate, Schema } from "effect";
7import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";7import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";
8import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto";
9import { decodeJwt, jwtVerify } from "jose";
10import { JWKSInvalid, JWKSNoMatchingKey, JWKSTimeout } from "jose/errors";
8import { parseCookie } from "./cookies";11import { parseCookie } from "./cookies";
9import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors";12import { createCachedRemoteJWKSet, type CachedRemoteJWKSet } from "./jwks-cache";
13import {
14 ServiceAdapterError,
15 tryPromiseService,
16 withServiceLogging,
17 workosErrorFromFailure,
18} from "./errors";
10 19
11const COOKIE_NAME = "wos-session";20const COOKIE_NAME = "wos-session";
12const INVALID_COOKIE_PASSWORD_MESSAGE = "WORKOS_COOKIE_PASSWORD must be at least 32 characters";21const INVALID_COOKIE_PASSWORD_MESSAGE = "WORKOS_COOKIE_PASSWORD must be at least 32 characters";
22const WORKOS_SEAL_VERSION_DELIMITER = "~";
13 23
14type RawWorkOS = WorkOS & {24type RawWorkOS = WorkOS & {
15 readonly get: (25 readonly get: (
@@ -59,6 +69,239 @@ const RawWorkOSListResponse = Schema.Struct({ · 43 unmodified lines
59 69
60const decodeRawWorkOSListResponse = Schema.decodeUnknownOption(RawWorkOSListResponse);70const decodeRawWorkOSListResponse = Schema.decodeUnknownOption(RawWorkOSListResponse);
61 71
72const SealedSessionUser = Schema.Struct({
73 id: Schema.String,
74 email: Schema.String,
75 firstName: Schema.NullOr(Schema.String),
76 lastName: Schema.NullOr(Schema.String),
77 profilePictureUrl: Schema.NullOr(Schema.String),
78});
79
80const SealedSessionPayload = Schema.Struct({
81 accessToken: Schema.String,
82 refreshToken: Schema.String,
83 user: SealedSessionUser,
84 authenticationMethod: Schema.optional(Schema.Unknown),
85 impersonator: Schema.optional(Schema.Unknown),
86});
87
88const decodeSealedSessionPayload = Schema.decodeUnknownOption(SealedSessionPayload);
89
90const JwtClaims = Schema.Struct({
91 sid: Schema.String,
92 org_id: Schema.optional(Schema.String),
93});
94
95const decodeJwtClaims = Schema.decodeUnknownOption(JwtClaims);
96
97const LegacySealedSessionPayload = Schema.Struct({
98 persistent: Schema.Unknown,
99});
100
101const decodeLegacySealedSessionPayload = Schema.decodeUnknownOption(LegacySealedSessionPayload);
102
103type SealedSessionPayload = typeof SealedSessionPayload.Type;
104
105type LocalSessionVerification =
106 | {
107 readonly _tag: "Valid";
108 readonly session: SealedSessionPayload;
109 readonly organizationId: string | undefined;
110 readonly sessionId: string;
111 }
112 | { readonly _tag: "Refresh" }
113 | { readonly _tag: "InvalidCookie" };
114
115class LocalSessionCookieError extends Data.TaggedError("LocalSessionCookieError")<{
116 readonly cause: unknown;
117}> {}
118
119const isLocalSessionValid = Predicate.isTagged("Valid") as (
120 value: LocalSessionVerification,
121) => value is Extract<LocalSessionVerification, { readonly _tag: "Valid" }>;
122
123const isLocalSessionInvalidCookie = Predicate.isTagged("InvalidCookie") as (
124 value: LocalSessionVerification,
125) => value is Extract<LocalSessionVerification, { readonly _tag: "InvalidCookie" }>;
126
127const ErrorWithCode = Schema.Struct({ code: Schema.String });
128const isErrorWithCode = Schema.is(ErrorWithCode);
129
130const isJwtVerificationFailure = (cause: unknown): boolean =>
131 isErrorWithCode(cause) &&
132 (cause.code.startsWith("ERR_JWT_") ||
133 cause.code.startsWith("ERR_JWS_") ||
134 cause.code === JWKSNoMatchingKey.code);
135
136const isJwksSystemFailure = (cause: unknown): boolean =>
137 isErrorWithCode(cause) && (cause.code === JWKSTimeout.code || cause.code === JWKSInvalid.code);
138
139const parseWorkOSSeal = (
140 seal: string,
141): { readonly sealWithoutVersion: string; readonly tokenVersion: number | null } => {
142 const [sealWithoutVersion = "", tokenVersionAsString] = seal.split(WORKOS_SEAL_VERSION_DELIMITER);
143 return {
144 sealWithoutVersion,
145 tokenVersion:
146 tokenVersionAsString === undefined ? null : Number.parseInt(tokenVersionAsString, 10),
147 };
148};
149
150const unsealWorkOSSession = async (
151 sessionData: string,
152 cookiePassword: string,
153): Promise<unknown> => {
154 const { sealWithoutVersion, tokenVersion } = parseWorkOSSeal(sessionData);
155 const data =
156 (await unsealIron(sealWithoutVersion, { 1: cookiePassword }, { ...ironDefaults, ttl: 0 })) ??
157 {};
158 // Mirrors the SDK's unsealData version handling: current (v2) seals hold the
159 // payload directly, OTHER versioned seals nest it under `persistent`, and an
160 // unversioned seal is the payload itself.
161 if (tokenVersion === 2 || tokenVersion === null) return data;
162 return Option.match(decodeLegacySealedSessionPayload(data), {
163 onNone: () => data,
164 onSome: (legacy) => legacy.persistent,
165 });
166};
167
168const getWorkOSSessionJwks = (() => {
169 const resolvers = new Map<string, CachedRemoteJWKSet>();
170 return (url: URL): CachedRemoteJWKSet => {
171 const key = url.toString();
172 const existing = resolvers.get(key);
173 if (existing) return existing;
174 const created = createCachedRemoteJWKSet(url);
175 resolvers.set(key, created);
176 return created;
177 };
178})();
179
180const verifyJwtOnce = (accessToken: string, jwks: CachedRemoteJWKSet) =>
181 Effect.tryPromise({
182 try: () => jwtVerify(accessToken, jwks),
183 catch: (cause) => new ServiceAdapterError({ cause }),
184 });
185
186const verifyJwtWithRefreshRetry = (
187 accessToken: string,
188 jwks: CachedRemoteJWKSet,
189): Effect.Effect<boolean, ServiceAdapterError> =>
190 verifyJwtOnce(accessToken, jwks).pipe(
191 Effect.as(true),
192 Effect.catchTag("ServiceAdapterError", (error) => {
193 if (isErrorWithCode(error.cause) && error.cause.code === JWKSNoMatchingKey.code) {
194 return Effect.sync(() => jwks.forceRefresh()).pipe(
195 Effect.flatMap(() => verifyJwtOnce(accessToken, jwks)),
196 Effect.as(true),
197 Effect.catchTag("ServiceAdapterError", (retryError) =>
198 isJwtVerificationFailure(retryError.cause)
199 ? Effect.succeed(false)
200 : Effect.fail(retryError),
201 ),
202 );
203 }
204 if (isJwtVerificationFailure(error.cause)) return Effect.succeed(false);
205 if (isJwksSystemFailure(error.cause)) return Effect.fail(error);
206 return Effect.fail(error);
207 }),
208 );
209
210const verifySealedSessionLocally = (
211 sessionData: string,
212 cookiePassword: string,
213 jwks: CachedRemoteJWKSet,
214): Effect.Effect<LocalSessionVerification, ServiceAdapterError> =>
215 Effect.gen(function* () {
216 // Phase timings, not just child spans. `local_verify` is a leaf in
217 // production traces, so a ~3.3s verify has nothing under it to blame —
218 // and it stayed 3.3s after the JWKS fetch was eliminated entirely
219 // (jwks.fetch_count == 0), so the cost is one of the phases below. Under
220 // workerd `Date.now()` only advances at I/O boundaries, which is exactly
221 // what makes a raw span duration misleading here: recording each phase
222 // explicitly says which await the wall-clock actually crossed.
223 const verifyStartedAt = Date.now();
224
225 const unsealStartedAt = Date.now();
226 const unsealed = yield* Effect.tryPromise({
227 try: () => unsealWorkOSSession(sessionData, cookiePassword),
228 catch: (cause) => new LocalSessionCookieError({ cause }),
229 }).pipe(
230 Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null as unknown | null)),
231 Effect.withSpan("workos.session.unseal"),
232 );
233 const unsealMs = Date.now() - unsealStartedAt;
234 yield* Effect.annotateCurrentSpan({ "verify.unseal_ms": unsealMs });
235 if (!unsealed) return { _tag: "InvalidCookie" };
236
237 const decodeStartedAt = Date.now();
238 const session = Option.match(decodeSealedSessionPayload(unsealed), {
239 onNone: (): SealedSessionPayload | null => null,
240 onSome: (payload) => payload,
241 });
242 yield* Effect.annotateCurrentSpan({ "verify.decode_ms": Date.now() - decodeStartedAt });
243 if (!session) return { _tag: "InvalidCookie" };
244
245 // Snapshot the JWKS cache around the verify so the `local_verify` span
246 // says whether THIS verify was a warm-cache signature check or paid for a
247 // live upstream JWKS fetch. The Aug 2026 latency regression was the cache
248 // silently missing on most verifies, and no span attribute distinguished
249 // the two paths.
250 const jwksBefore = jwks.inspect();
251 // Entry-state annotation goes on BEFORE the verify so a failing verify
252 // (the case worth debugging) still records whether the cache was warm.
253 yield* Effect.annotateCurrentSpan({
254 "jwks.cache_populated_at_start": jwksBefore.hasJwks,
255 ...(jwksBefore.fetchedAt === null
256 ? {}
257 : { "jwks.cache_age_ms": Date.now() - jwksBefore.fetchedAt }),
258 });
259 const jwtStartedAt = Date.now();
260 const verified = yield* verifyJwtWithRefreshRetry(session.accessToken, jwks).pipe(
261 Effect.withSpan("workos.session.jwt_verify"),
262 Effect.onExit(() => {
263 const jwksAfter = jwks.inspect();
264 const finishedAt = Date.now();
265 return Effect.annotateCurrentSpan({
266 "verify.jwt_ms": finishedAt - jwtStartedAt,
267 "verify.total_ms": finishedAt - verifyStartedAt,
268 // Blocking, not total: under stale-while-revalidate a background
269 // refresh moves `fetchCount` without costing this verify anything.
270 // Attribute latency to what the caller actually waited on.
271 "jwks.fetched_during_verify":
272 jwksAfter.blockingFetchCount > jwksBefore.blockingFetchCount,
273 "jwks.served_from_store": jwksAfter.storeHitCount > jwksBefore.storeHitCount,
274 "jwks.fetch_count": jwksAfter.fetchCount,
275 "jwks.blocking_fetch_count": jwksAfter.blockingFetchCount,
276 "jwks.fetch_failure_count": jwksAfter.fetchFailureCount,
277 ...(jwksAfter.lastFetchDurationMs === null
278 ? {}
279 : { "jwks.last_fetch_ms": jwksAfter.lastFetchDurationMs }),
280 // Splits the ~3.4s that sits inside jwt_verify with zero upstream
281 // fetches: the cross-isolate store read (I/O) vs WebCrypto key
282 // import vs the signature check itself.
283 ...(jwksAfter.lastStoreReadMs === null
284 ? {}
285 : { "jwks.store_read_ms": jwksAfter.lastStoreReadMs }),
286 ...(jwksAfter.lastResolveMs === null
287 ? {}
288 : { "jwks.key_resolve_ms": jwksAfter.lastResolveMs }),
289 });
290 }),
291 );
292 if (!verified) return { _tag: "Refresh" };
293
294 const claims = Option.getOrNull(decodeJwtClaims(decodeJwt(session.accessToken)));
295 if (!claims) return { _tag: "Refresh" };
296
297 return {
298 _tag: "Valid",
299 session,
300 organizationId: claims.org_id,
301 sessionId: claims.sid,
302 };
303 });
304
62const completedListMetadata = {305const completedListMetadata = {
63 before: null,306 before: null,
64 after: null,307 after: null,
@@ -145,12 +388,24 @@ const make = Effect.gen(function* () { · 80 unmodified lines
145 });388 });
146 }389 }
147 390
148 const workos = new WorkOS({ apiKey, clientId, ...workosApiUrlOptions(env.WORKOS_API_URL) });391 const workos = new WorkOS({
149 392 apiKey,
150 const use = <A>(fn: (wos: WorkOS) => Promise<A>) =>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>) =>
151 withServiceLogging(406 withServiceLogging(
152 "workos",407 `workos.${op}`,
153 () => new WorkOSError(),408 workosErrorFromFailure,
154 tryPromiseService(() => fn(workos)),409 tryPromiseService(() => fn(workos)),
155 );410 );
156 411
@@ -163,25 +418,29 @@ const make = Effect.gen(function* () { · 6 unmodified lines
163 cookiePassword,418 cookiePassword,
164 });419 });
165 420
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 );
167 426
168 if (result.authenticated) {427 if (isLocalSessionValid(local)) {
169 return {428 return {
170 userId: result.user.id,429 userId: local.session.user.id,
171 email: result.user.email,430 email: local.session.user.email,
172 firstName: result.user.firstName,431 firstName: local.session.user.firstName,
173 lastName: result.user.lastName,432 lastName: local.session.user.lastName,
174 avatarUrl: result.user.profilePictureUrl,433 avatarUrl: local.session.user.profilePictureUrl,
175 organizationId: result.organizationId,434 organizationId: local.organizationId,
176 sessionId: result.sessionId,435 sessionId: local.sessionId,
177 refreshedSession: undefined as string | undefined,436 refreshedSession: undefined as string | undefined,
178 };437 };
179 }438 }
180 439
181 if (result.reason === "no_session_cookie_provided") return null;440 if (isLocalSessionInvalidCookie(local)) return null;
182 441
183 // Try refreshing442 // Try refreshing
184 const refreshed = yield* use(() => session.refresh()).pipe(443 const refreshed = yield* use("session.refresh", () => session.refresh()).pipe(
185 Effect.orElseSucceed(() => ({ authenticated: false as const })),444 Effect.orElseSucceed(() => ({ authenticated: false as const })),
186 );445 );
187 446
@@ -210,7 +469,7 @@ const make = Effect.gen(function* () { · 22 unmodified lines
210 }),469 }),
211 470
212 authenticateWithCode: (code: string) =>471 authenticateWithCode: (code: string) =>
213 use((wos) =>472 use("userManagement.authenticateWithCode", (wos) =>
214 wos.userManagement.authenticateWithCode({473 wos.userManagement.authenticateWithCode({
215 code,474 code,
216 clientId,475 clientId,
@@ -220,11 +479,13 @@ const make = Effect.gen(function* () { · 3 unmodified lines
220 479
221 /** Create a new organization in WorkOS. */480 /** Create a new organization in WorkOS. */
222 createOrganization: (name: string) =>481 createOrganization: (name: string) =>
223 use((wos) => wos.organizations.createOrganization({ name })),482 use("organizations.createOrganization", (wos) =>
483 wos.organizations.createOrganization({ name }),
484 ),
224 485
225 /** Add a user to an organization. */486 /** Add a user to an organization. */
226 createMembership: (organizationId: string, userId: string, roleSlug?: string) =>487 createMembership: (organizationId: string, userId: string, roleSlug?: string) =>
227 use((wos) =>488 use("userManagement.createOrganizationMembership", (wos) =>
228 wos.userManagement.createOrganizationMembership({489 wos.userManagement.createOrganizationMembership({
229 organizationId,490 organizationId,
230 userId,491 userId,
@@ -234,7 +495,7 @@ const make = Effect.gen(function* () { · 3 unmodified lines
234 495
235 /** List organization memberships for a user. */496 /** List organization memberships for a user. */
236 listUserMemberships: (userId: string) =>497 listUserMemberships: (userId: string) =>
237 use(async (wos) =>498 use("userManagement.listOrganizationMemberships", async (wos) =>
238 collectWorkOSList(499 collectWorkOSList(
239 await wos.userManagement.listOrganizationMemberships({500 await wos.userManagement.listOrganizationMemberships({
240 userId,501 userId,
@@ -253,13 +514,53 @@ const make = Effect.gen(function* () { · 12 unmodified lines
253 sessionData,514 sessionData,
254 cookiePassword,515 cookiePassword,
255 });516 });
256 const refreshed = yield* use(() =>517 const refreshed = yield* use("session.refresh", () =>
257 session.refresh(organizationId ? { organizationId } : undefined),518 session.refresh(organizationId ? { organizationId } : undefined),
258 );519 );
259 if (!refreshed.authenticated || !("sealedSession" in refreshed)) return null;520 if (!refreshed.authenticated || !("sealedSession" in refreshed)) return null;
260 return refreshed.sealedSession ?? null;521 return refreshed.sealedSession ?? null;
261 }),522 }),
262 523
524 /**
525 * The WorkOS logout URL for a sealed session, or null when the cookie
526 * doesn't unseal or carries no session id (fail-open: the caller clears
527 * local cookies and redirects home instead). The access token is decoded
528 * WITHOUT signature verification, deliberately — WorkOS's documented
529 * logout flow reads `sid` from a possibly-expired token, and logout must
530 * still work for a stale session. (The SDK's `session.getLogoutUrl()` is
531 * deliberately NOT used: it re-authenticates first and throws on an
532 * expired token.) Pure URL construction, no network call; hitting the
533 * URL is the browser's navigation, which is what ends the AuthKit
534 * session upstream.
535 */
536 logoutUrl: (sessionData: string, returnTo?: string) =>
537 Effect.gen(function* () {
538 const unsealed = yield* Effect.tryPromise({
539 try: () => unsealWorkOSSession(sessionData, cookiePassword),
540 catch: (cause) => new LocalSessionCookieError({ cause }),
541 }).pipe(
542 Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null as unknown | null)),
543 );
544 if (!unsealed) return null;
545
546 const session = Option.getOrNull(decodeSealedSessionPayload(unsealed));
547 if (!session) return null;
548
549 const claims = yield* Effect.try({
550 try: () => decodeJwt(session.accessToken),
551 catch: (cause) => new LocalSessionCookieError({ cause }),
552 }).pipe(
553 Effect.map((jwt) => Option.getOrNull(decodeJwtClaims(jwt))),
554 Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null)),
555 );
556 if (!claims) return null;
557
558 return workos.userManagement.getLogoutUrl({
559 sessionId: claims.sid,
560 ...(returnTo ? { returnTo } : {}),
561 });
562 }),
563
263 /**564 /**
264 * Authenticate a sealed session string. Returns the user info plus565 * Authenticate a sealed session string. Returns the user info plus
265 * any refreshed session that needs to be set on the response.566 * any refreshed session that needs to be set on the response.
@@ -282,10 +583,13 @@ const make = Effect.gen(function* () { · 16 unmodified lines
282 * auth/api-keys.ts.583 * auth/api-keys.ts.
283 */584 */
284 validateApiKey: (value: string) =>585 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 ),
286 590
287 listUserApiKeys: (userId: string, organizationId: string) =>591 listUserApiKeys: (userId: string, organizationId: string) =>
288 use(async (wos) => {592 use("userManagement.listUserApiKeys", async (wos) => {
289 const raw = wos as RawWorkOS;593 const raw = wos as RawWorkOS;
290 return collectRawWorkOSList(async (after) => {594 return collectRawWorkOSList(async (after) => {
291 const response = await raw.get(`/user_management/users/${userId}/api_keys`, {595 const response = await raw.get(`/user_management/users/${userId}/api_keys`, {
@@ -300,7 +604,7 @@ const make = Effect.gen(function* () { · 8 unmodified lines
300 }),604 }),
301 605
302 createUserApiKey: (params: { userId: string; organizationId: string; name: string }) =>606 createUserApiKey: (params: { userId: string; organizationId: string; name: string }) =>
303 use(async (wos) => {607 use("userManagement.createUserApiKey", async (wos) => {
304 const raw = wos as RawWorkOS;608 const raw = wos as RawWorkOS;
305 const response = await raw.post(`/user_management/users/${params.userId}/api_keys`, {609 const response = await raw.post(`/user_management/users/${params.userId}/api_keys`, {
306 name: params.name,610 name: params.name,
@@ -309,11 +613,40 @@ const make = Effect.gen(function* () { · 2 unmodified lines
309 return response.data;613 return response.data;
310 }),614 }),
311 615
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)),
313 646
314 /** List organization memberships with user details. */647 /** List organization memberships with user details. */
315 listOrgMembers: (organizationId: string) =>648 listOrgMembers: (organizationId: string) =>
316 use(async (wos) =>649 use("userManagement.listOrganizationMemberships", async (wos) =>
317 collectWorkOSList(650 collectWorkOSList(
318 await wos.userManagement.listOrganizationMemberships({651 await wos.userManagement.listOrganizationMemberships({
319 organizationId,652 organizationId,
@@ -324,7 +657,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines
324 657
325 /** Get a user's membership in an organization. */658 /** Get a user's membership in an organization. */
326 getUserOrgMembership: (organizationId: string, userId: string) =>659 getUserOrgMembership: (organizationId: string, userId: string) =>
327 use(async (wos) => {660 use("userManagement.listOrganizationMemberships", async (wos) => {
328 const response = await wos.userManagement.listOrganizationMemberships({661 const response = await wos.userManagement.listOrganizationMemberships({
329 organizationId,662 organizationId,
330 userId,663 userId,
@@ -334,11 +667,23 @@ const make = Effect.gen(function* () { · 3 unmodified lines
334 }),667 }),
335 668
336 /** Get a user by ID. */669 /** 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 ),
338 683
339 /** Send an organization invitation. */684 /** Send an organization invitation. */
340 sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) =>685 sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) =>
341 use((wos) =>686 use("userManagement.sendInvitation", (wos) =>
342 wos.userManagement.sendInvitation({687 wos.userManagement.sendInvitation({
343 email: params.email,688 email: params.email,
344 organizationId: params.organizationId,689 organizationId: params.organizationId,
@@ -352,7 +697,7 @@ const make = Effect.gen(function* () { · 7 unmodified lines
352 * API level, so we filter after.697 * API level, so we filter after.
353 */698 */
354 listPendingInvitations: (organizationId: string) =>699 listPendingInvitations: (organizationId: string) =>
355 use(async (wos) =>700 use("userManagement.listInvitations", async (wos) =>
356 collectWorkOSList(701 collectWorkOSList(
357 await wos.userManagement.listInvitations({702 await wos.userManagement.listInvitations({
358 organizationId,703 organizationId,
@@ -367,7 +712,7 @@ const make = Effect.gen(function* () { · 8 unmodified lines
367 712
368 /** List invitations for an email address (across all orgs). */713 /** List invitations for an email address (across all orgs). */
369 listInvitationsByEmail: (email: string) =>714 listInvitationsByEmail: (email: string) =>
370 use(async (wos) =>715 use("userManagement.listInvitations", async (wos) =>
371 collectWorkOSList(716 collectWorkOSList(
372 await wos.userManagement.listInvitations({717 await wos.userManagement.listInvitations({
373 email,718 email,
@@ -377,35 +722,63 @@ const make = Effect.gen(function* () { · 3 unmodified lines
377 722
378 /** Accept an invitation; returns the (now accepted) invitation. */723 /** Accept an invitation; returns the (now accepted) invitation. */
379 acceptInvitation: (invitationId: string) =>724 acceptInvitation: (invitationId: string) =>
380 use((wos) => wos.userManagement.acceptInvitation(invitationId)),725 use("userManagement.acceptInvitation", (wos) =>
726 wos.userManagement.acceptInvitation(invitationId),
727 ),
381 728
382 /** Remove an organization membership. */729 /** Remove an organization membership. */
383 deleteOrgMembership: (membershipId: string) =>730 deleteOrgMembership: (membershipId: string) =>
384 use((wos) => wos.userManagement.deleteOrganizationMembership(membershipId)),731 use("userManagement.deleteOrganizationMembership", (wos) =>
732 wos.userManagement.deleteOrganizationMembership(membershipId),
733 ),
385 734
386 /** Get the role for a membership. */735 /** Get the role for a membership. */
387 getOrgMembership: (membershipId: string) =>736 getOrgMembership: (membershipId: string) =>
388 use((wos) => wos.userManagement.getOrganizationMembership(membershipId)),737 use("userManagement.getOrganizationMembership", (wos) =>
738 wos.userManagement.getOrganizationMembership(membershipId),
739 ),
389 740
390 /** Update a membership's role. */741 /** Update a membership's role. */
391 updateOrgMembershipRole: (membershipId: string, roleSlug: string) =>742 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 ),
393 748
394 /** List available roles for an organization. */749 /** List available roles for an organization. */
395 listOrgRoles: (organizationId: string) =>750 listOrgRoles: (organizationId: string) =>
396 use((wos) => wos.organizations.listOrganizationRoles({ organizationId })),751 use("organizations.listOrganizationRoles", (wos) =>
752 wos.organizations.listOrganizationRoles({ organizationId }),
753 ),
397 754
398 /** Get an organization (includes domains). */755 /** Get an organization (includes domains). */
399 getOrganization: (organizationId: string) =>756 getOrganization: (organizationId: string) =>
400 use((wos) => wos.organizations.getOrganization(organizationId)),757 use("organizations.getOrganization", (wos) =>
758 wos.organizations.getOrganization(organizationId),
759 ),
401 760
402 /** Update an organization. */761 /** Update an organization. */
403 updateOrganization: (organizationId: string, name: string) =>762 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 ),
405 778
406 /** Generate an Admin Portal link for domain verification. */779 /** Generate an Admin Portal link for domain verification. */
407 generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) =>780 generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) =>
408 use((wos) =>781 use("portal.generateLink", (wos) =>
409 wos.portal.generateLink({782 wos.portal.generateLink({
410 organization: organizationId,783 organization: organizationId,
411 intent: GeneratePortalLinkIntent.DomainVerification,784 intent: GeneratePortalLinkIntent.DomainVerification,
@@ -415,11 +788,11 @@ const make = Effect.gen(function* () { · 3 unmodified lines
415 788
416 /** Get a domain by ID. */789 /** Get a domain by ID. */
417 getOrganizationDomain: (domainId: string) =>790 getOrganizationDomain: (domainId: string) =>
418 use((wos) => wos.organizationDomains.get(domainId)),791 use("organizationDomains.get", (wos) => wos.organizationDomains.get(domainId)),
419 792
420 /** Delete a domain claim. */793 /** Delete a domain claim. */
421 deleteOrganizationDomain: (domainId: string) =>794 deleteOrganizationDomain: (domainId: string) =>
422 use((wos) => wos.organizationDomains.delete(domainId)),795 use("organizationDomains.delete", (wos) => wos.organizationDomains.delete(domainId)),
423 };796 };
424});797});
425 798
@@ -428,9 +801,10 @@ export type WorkOSClientService = Effect.Success<typeof make>; · 2 unmodified lines
428export class WorkOSClient extends Context.Service<WorkOSClient, WorkOSClientService>()(801export class WorkOSClient extends Context.Service<WorkOSClient, WorkOSClientService>()(
429 "@executor-js/cloud/WorkOSClient",802 "@executor-js/cloud/WorkOSClient",
430) {803) {
431 static Default = Layer.effect(this)(make).pipe(804 // Deliberately unspanned: client construction is synchronous and ran per
432 Layer.withSpan("WorkOSClient", { attributes: { module: "WorkOSClient" } }),805 // layer build, which produced one of the highest-volume zero-duration span
433 );806 // names in the whole trace corpus while telling us nothing.
807 static Default = Layer.effect(this)(make);
434}808}
435 809
436// The boot-scoped WorkOS client root — the one neutral service the stateless810// The boot-scoped WorkOS client root — the one neutral service the stateless
apps/cloud/src/db/db.schema.test.ts+2−2
@@ -2,7 +2,7 @@ · 1 unmodified line
2// merge of cloud + executor schemas. If ANY call site builds its own2// merge of cloud + executor schemas. If ANY call site builds its own
3// `{ schema }` without spreading both, `db._.fullSchema` comes back3// `{ schema }` without spreading both, `db._.fullSchema` comes back
4// missing tables and the drizzle adapter throws4// missing tables and the drizzle adapter throws
5// `unknown model "source"` at the first request that touches scoped data.5// `unknown model "integration"` at the first request that touches scoped data.
6//6//
7// We hit this in prod (MCP endpoint) when `mcp-session.ts` built7// We hit this in prod (MCP endpoint) when `mcp-session.ts` built
8// `combinedSchema = { ...cloudSchema }` and forgot to spread the executor8// `combinedSchema = { ...cloudSchema }` and forgot to spread the executor
@@ -56,7 +56,7 @@ describe("combinedSchema", () => { · 47 unmodified lines
56 }56 }
57 });57 });
58 58
59 // Executor-schema drives the scope-sharded tables (source/tool/etc). If59 // Executor-schema drives the scope-sharded tables (integration/tool/etc). If
60 // any of these go missing the drizzle adapter's `getTable` lookup throws.60 // any of these go missing the drizzle adapter's `getTable` lookup throws.
61 it("includes scope-sharded executor tables", () => {61 it("includes scope-sharded executor tables", () => {
62 for (const key of Object.keys(executorSchema)) {62 for (const key of Object.keys(executorSchema)) {
apps/cloud/src/db/db.ts+1−1
@@ -24,7 +24,7 @@ import * as executorSchema from "./executor-schema"; · 23 unmodified lines
24 24
25// Exported so every drizzle() call in the cloud app shares one schema25// Exported so every drizzle() call in the cloud app shares one schema
26// object. Historically `mcp-session.ts` built its own and forgot to spread26// object. Historically `mcp-session.ts` built its own and forgot to spread
27// `executorSchema`, producing runtime "unknown model source" errors that27// `executorSchema`, producing runtime "unknown model integration" errors that
28// only surfaced in prod. See apps/cloud/src/db/db.schema.test.ts.28// only surfaced in prod. See apps/cloud/src/db/db.schema.test.ts.
29export const combinedSchema = { ...cloudSchema, ...executorSchema };29export const combinedSchema = { ...cloudSchema, ...executorSchema };
30 30
apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts+272−8
@@ -21,6 +21,8 @@ · 20 unmodified lines
21// with DIFFERENT parameter counts (the exact drizzle/postgres-js shape) through21// with DIFFERENT parameter counts (the exact drizzle/postgres-js shape) through
22// one PGLiteSocketServer and asserts zero protocol corruption.22// one PGLiteSocketServer and asserts zero protocol corruption.
23 23
24import { setTimeout as sleep } from "node:timers/promises";
25import { connect, type Socket } from "node:net";
24import { describe, expect, it } from "@effect/vitest";26import { describe, expect, it } from "@effect/vitest";
25import { PGlite } from "@electric-sql/pglite";27import { PGlite } from "@electric-sql/pglite";
26import { PGLiteSocketServer } from "@electric-sql/pglite-socket";28import { PGLiteSocketServer } from "@electric-sql/pglite-socket";
@@ -30,6 +32,50 @@ const PORT = 45998; · 3 unmodified lines
30const CLIENTS = 6;32const CLIENTS = 6;
31const QUERIES_PER_CLIENT = 40;33const QUERIES_PER_CLIENT = 40;
32 34
35const makeClient = (port: number, connectTimeout = 5) =>
36 postgres(`postgres://postgres:postgres@127.0.0.1:${port}/postgres`, {
37 max: 1,
38 idle_timeout: 0,
39 connect_timeout: connectTimeout,
40 fetch_types: false,
41 prepare: true,
42 onnotice: () => undefined,
43 });
44
45// Hand-rolled wire client: connect and complete the trust-auth startup, so a
46// test can then speak raw protocol frames (e.g. a lone Parse) that postgres.js
47// would never emit on its own. Resolves after ReadyForQuery so the next write
48// is its own data event — and its own queue entry — on the server.
49const openWireClient = async (port: number): Promise<Socket> => {
50 const socket: Socket = connect(port, "127.0.0.1");
51 await new Promise<void>((res, rej) => {
52 socket.once("connect", res);
53 socket.once("error", rej);
54 });
55 const startupBody = Buffer.concat([
56 Buffer.from([0, 3, 0, 0]),
57 Buffer.from("user\0postgres\0database\0postgres\0\0"),
58 ]);
59 const startup = Buffer.concat([Buffer.alloc(4), startupBody]);
60 startup.writeInt32BE(startup.length, 0);
61 socket.write(startup);
62 await new Promise<void>((res) => {
63 socket.on("data", (chunk: Buffer) => {
64 if (chunk.includes(0x5a)) res(); // 'Z' = ReadyForQuery
65 });
66 });
67 return socket;
68};
69
70// A Parse frame for an unnamed statement: opens an extended-protocol pipeline
71// that only a later Sync (or the server's recovery) closes.
72const parseFrame = (query: string): Buffer => {
73 const body = Buffer.concat([Buffer.from(`\0${query}\0`), Buffer.from([0, 0])]);
74 const frame = Buffer.concat([Buffer.from("P"), Buffer.alloc(4), body]);
75 frame.writeInt32BE(4 + body.length, 1);
76 return frame;
77};
78
33describe("dev-db PGlite socket under concurrent connections", () => {79describe("dev-db PGlite socket under concurrent connections", () => {
34 it(80 it(
35 "serves interleaved multi-connection pipelines without protocol corruption",81 "serves interleaved multi-connection pipelines without protocol corruption",
@@ -48,14 +94,7 @@ describe("dev-db PGlite socket under concurrent connections", () => { · 12 unmodified lines
48 const errors: string[] = [];94 const errors: string[] = [];
49 95
50 const worker = async (id: number) => {96 const worker = async (id: number) => {
51 const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, {97 const sql = makeClient(PORT, 10);
52 max: 1,
53 idle_timeout: 0,
54 connect_timeout: 10,
55 fetch_types: false,
56 prepare: true,
57 onnotice: () => undefined,
58 });
59 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: postgres.js is promise-native and the socket must be closed on every path98 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: postgres.js is promise-native and the socket must be closed on every path
60 try {99 try {
61 for (let q = 0; q < QUERIES_PER_CLIENT; q++) {100 for (let q = 0; q < QUERIES_PER_CLIENT; q++) {
@@ -91,4 +130,229 @@ describe("dev-db PGlite socket under concurrent connections", () => { · 29 unmodified lines
91 expect(ok).toBe(CLIENTS * QUERIES_PER_CLIENT);130 expect(ok).toBe(CLIENTS * QUERIES_PER_CLIENT);
92 },131 },
93 );132 );
133
134 // Regression for the CI e2e "cloud signIn: callback set no session (500)"
135 // cascade: QueryQueueManager.processQueue used to `return` out of its drain
136 // loop when a query REJECTED (as opposed to returning a wire-level
137 // ErrorResponse), leaving `processing` latched true. From then on every
138 // enqueue — including brand-new connections' startup packets — sat in the
139 // queue forever: in-flight requests hung, postgres.js reconnects died with
140 // CONNECT_TIMEOUT, and the whole dev stack was bricked until restart. The
141 // patch rejects the one entry, drops pipeline affinity, and keeps draining.
142 it(
143 "a rejected query fails one client, not the whole socket server",
144 { timeout: 30_000 },
145 async () => {
146 const port = 45997;
147 const db = await PGlite.create();
148 const server = new PGLiteSocketServer({ db, port, host: "127.0.0.1", maxConnections: 100 });
149 await server.start();
150
151 const first = makeClient(port);
152 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path
153 try {
154 expect((await first.unsafe(`select 1 as one`))[0]).toEqual({ one: 1 });
155
156 // Force the NEXT protocol exchange to reject at the JS level, the shape
157 // PGlite produces when the shared session is broken mid-run.
158 const real = db.execProtocolRawStream.bind(db);
159 let arm = true;
160 (db as { execProtocolRawStream: typeof real }).execProtocolRawStream = (...args) => {
161 if (arm) {
162 arm = false;
163 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: simulating a PGlite internal failure requires a raw throw
164 throw new Error("synthetic PGlite failure");
165 }
166 return real(...args);
167 };
168
169 await expect(first.unsafe(`select 2 as two`)).rejects.toThrow();
170
171 // The poisoned entry must take down only its own connection: a fresh
172 // client (new socket, full startup handshake) still gets served.
173 const second = makeClient(port);
174 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path
175 try {
176 expect((await second.unsafe(`select 3 as three`))[0]).toEqual({ three: 3 });
177 } finally {
178 // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion
179 await second.end({ timeout: 5 }).catch(() => {});
180 }
181 } finally {
182 // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion
183 await first.end({ timeout: 5 }).catch(() => {});
184 await server.stop();
185 await db.close();
186 }
187 },
188 );
189
190 // Regression for the sporadic `write CONNECTION_ENDED` 500s: the server's
191 // idleTimeout backstop used to kill ANY connection with no traffic for the
192 // window, which is the resting state of every healthy postgres.js pool
193 // connection (idle_timeout: 0) held by a long-lived scope. The backstop now
194 // only fires on a connection that is actually blocking the shared session —
195 // an open pipeline or an open transaction.
196 it("an idle-at-rest connection outlives the idle backstop", { timeout: 30_000 }, async () => {
197 const port = 45996;
198 const db = await PGlite.create();
199 const server = new PGLiteSocketServer({
200 db,
201 port,
202 host: "127.0.0.1",
203 maxConnections: 100,
204 idleTimeout: 250,
205 });
206 await server.start();
207
208 const sql = makeClient(port);
209 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path
210 try {
211 expect((await sql.unsafe(`select 1 as one`))[0]).toEqual({ one: 1 });
212 await sleep(900);
213 expect((await sql.unsafe(`select 2 as two`))[0]).toEqual({ two: 2 });
214 } finally {
215 // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion
216 await sql.end({ timeout: 5 }).catch(() => {});
217 await server.stop();
218 await db.close();
219 }
220 });
221
222 // The backstop's actual job still works: a client that opens a pipeline
223 // (Parse sent, never Sync) and goes silent holds queue affinity, which
224 // starves every other connection. The idle timer must reap exactly that
225 // client and hand the queue back.
226 it(
227 "a client stalled mid-pipeline is reaped and the queue recovers",
228 { timeout: 30_000 },
229 async () => {
230 const port = 45995;
231 const db = await PGlite.create();
232 const server = new PGLiteSocketServer({
233 db,
234 port,
235 host: "127.0.0.1",
236 maxConnections: 100,
237 idleTimeout: 250,
238 });
239 await server.start();
240
241 // Hand-rolled wire client: complete the trust-auth startup, then send a
242 // lone Parse. Its last frame type ('P') marks the pipeline open, so the
243 // handler takes affinity and every other connection queues behind it.
244 const staller = await openWireClient(port);
245 staller.write(parseFrame("select 1"));
246
247 const bystander = makeClient(port, 10);
248 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path
249 try {
250 // Connects and queries only once the staller is reaped (~250ms).
251 expect((await bystander.unsafe(`select 4 as four`))[0]).toEqual({ four: 4 });
252 } finally {
253 // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion
254 await bystander.end({ timeout: 5 }).catch(() => {});
255 staller.destroy();
256 await server.stop();
257 await db.close();
258 }
259 },
260 );
261
262 // Regression for the reap SLOT LEAK: detach(true) removes the socket's
263 // listeners before destroying it, so a server-initiated teardown (the idle
264 // backstop) never fired the server's 'close' bookkeeping — the reaped
265 // handler stayed in the server's handlers set forever, burning one
266 // maxConnections slot per reap. Enough reaps over a long run and the server
267 // answers every NEW connection with "Too many connections" while the
268 // process, the port, and PGlite are all healthy — postgres.js surfaces that
269 // as the same CONNECT_TIMEOUT cascade as the queue wedges. The server now
270 // drops the handler when it dispatches its terminal error.
271 it("reaped handlers release their connection slots", { timeout: 30_000 }, async () => {
272 const port = 45993;
273 const db = await PGlite.create();
274 const server = new PGLiteSocketServer({
275 db,
276 port,
277 host: "127.0.0.1",
278 maxConnections: 2,
279 idleTimeout: 250,
280 });
281 await server.start();
282
283 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path
284 try {
285 // Burn through more reaps than there are slots: each staller opens a
286 // pipeline and goes silent, so the idle backstop reaps it (the server
287 // destroys the socket — its 'close' marks that reap complete).
288 for (let i = 0; i < 3; i++) {
289 const staller = await openWireClient(port);
290 staller.write(parseFrame(`select ${i + 1}`));
291 await new Promise<void>((res) => staller.once("close", res));
292 }
293
294 expect(
295 server.getStats().activeConnections,
296 "reaped handlers stay counted against maxConnections",
297 ).toBe(0);
298
299 const sql = makeClient(port);
300 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path
301 try {
302 expect((await sql.unsafe(`select 6 as six`))[0]).toEqual({ six: 6 });
303 } finally {
304 // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion
305 await sql.end({ timeout: 5 }).catch(() => {});
306 }
307 } finally {
308 await server.stop();
309 await db.close();
310 }
311 });
312
313 // Regression for the second wedge mode behind the same CI cascade: a client
314 // whose socket dies WHILE its pipeline-opening entry is executing. detach()
315 // clears pipeline affinity before the entry finishes, so the queue then
316 // assigned affinity to the already-dead handler — and nothing ever cleared
317 // it: the dead handler has no timers left, and every other connection
318 // (including fresh startups) queued behind the ghost forever. The queue now
319 // tracks detached handlers and repairs affinity they can no longer release.
320 it(
321 "a client that dies mid-execution does not leave the queue pinned to its ghost",
322 { timeout: 30_000 },
323 async () => {
324 const port = 45994;
325 const db = await PGlite.create();
326
327 // Hold the marker query in flight long enough that the disconnect below
328 // reliably lands while the entry is EXECUTING (after detach's cleanup,
329 // before the queue takes affinity for it).
330 const real = db.execProtocolRawStream.bind(db);
331 (db as { execProtocolRawStream: typeof real }).execProtocolRawStream = async (...args) => {
332 if (Buffer.from(args[0]).includes("ghost_marker")) await sleep(300);
333 return real(...args);
334 };
335
336 const server = new PGLiteSocketServer({ db, port, host: "127.0.0.1", maxConnections: 100 });
337 await server.start();
338
339 const ghost = await openWireClient(port);
340 ghost.write(parseFrame("select 'ghost_marker'"));
341 // Give the data event time to reach the queue and start executing, then
342 // die without a trace mid-flight.
343 await sleep(100);
344 ghost.destroy();
345
346 const bystander = makeClient(port);
347 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path
348 try {
349 expect((await bystander.unsafe(`select 5 as five`))[0]).toEqual({ five: 5 });
350 } finally {
351 // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion
352 await bystander.end({ timeout: 5 }).catch(() => {});
353 await server.stop();
354 await db.close();
355 }
356 },
357 );
94});358});
apps/cloud/src/db/executor-schema.ts+40−1
@@ -33,6 +33,22 @@ export const integration = pgTable( · 32 unmodified lines
33 (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)],33 (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)],
34);34);
35 35
36export const subject = pgTable(
37 "subject",
38 {
39 external_id: varchar("external_id", { length: 255 }).notNull(),
40 created_at: timestamp("created_at").notNull(),
41 last_seen_at: bigint("last_seen_at", { mode: "bigint" }),
42 status: text("status"),
43 row_id: varchar("row_id", { length: 255 })
44 .primaryKey()
45 .notNull()
46 .$defaultFn(() => createId()),
47 tenant: varchar("tenant", { length: 255 }).notNull(),
48 },
49 (table) => [uniqueIndex("subject_uidx").on(table.tenant, table.external_id)],
50);
51
36export const connection = pgTable(52export const connection = pgTable(
37 "connection",53 "connection",
38 {54 {
@@ -86,6 +102,7 @@ export const oauth_client = pgTable( · 47 unmodified lines
86 origin_kind: text("origin_kind"),102 origin_kind: text("origin_kind"),
87 origin_integration: text("origin_integration"),103 origin_integration: text("origin_integration"),
88 origin_issuer: text("origin_issuer"),104 origin_issuer: text("origin_issuer"),
105 origin_redirect_uri: text("origin_redirect_uri"),
89 created_at: timestamp("created_at").notNull(),106 created_at: timestamp("created_at").notNull(),
90 row_id: varchar("row_id", { length: 255 })107 row_id: varchar("row_id", { length: 255 })
91 .primaryKey()108 .primaryKey()
@@ -164,7 +181,7 @@ export const definition = pgTable( · 72 unmodified lines
164 integration: varchar("integration", { length: 255 }).notNull(),181 integration: varchar("integration", { length: 255 }).notNull(),
165 connection: varchar("connection", { length: 255 }).notNull(),182 connection: varchar("connection", { length: 255 }).notNull(),
166 plugin_id: text("plugin_id").notNull(),183 plugin_id: text("plugin_id").notNull(),
167 name: varchar("name", { length: 255 }).notNull(),184 name: text("name").notNull(),
168 schema: json("schema").notNull(),185 schema: json("schema").notNull(),
169 created_at: timestamp("created_at").notNull(),186 created_at: timestamp("created_at").notNull(),
170 row_id: varchar("row_id", { length: 255 })187 row_id: varchar("row_id", { length: 255 })
@@ -209,6 +226,28 @@ export const tool_policy = pgTable( · 38 unmodified lines
209 ],226 ],
210);227);
211 228
229export const artifact = pgTable(
230 "artifact",
231 {
232 id: varchar("id", { length: 255 }).notNull(),
233 title: text("title").notNull(),
234 description: text("description"),
235 code: text("code").notNull(),
236 bindings: json("bindings"),
237 preview: text("preview"),
238 created_at: timestamp("created_at").notNull(),
239 updated_at: timestamp("updated_at").notNull(),
240 row_id: varchar("row_id", { length: 255 })
241 .primaryKey()
242 .notNull()
243 .$defaultFn(() => createId()),
244 tenant: varchar("tenant", { length: 255 }).notNull(),
245 owner: varchar("owner", { length: 255 }).notNull(),
246 subject: varchar("subject", { length: 255 }).notNull(),
247 },
248 (table) => [uniqueIndex("artifact_uidx").on(table.tenant, table.owner, table.subject, table.id)],
249);
250
212export const plugin_storage = pgTable(251export const plugin_storage = pgTable(
213 "plugin_storage",252 "plugin_storage",
214 {253 {
apps/cloud/src/db/org-deletion.test.ts+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
20import { describe, it, expect } from "@effect/vitest";
21import { Effect } from "effect";
22import { eq, getTableColumns, getTableName, inArray } from "drizzle-orm";
23
24import { DbService } from "./db";
25import type { DrizzleDb } from "./db";
26import { makeUserStore } from "../auth/user-store";
27import * as cloudSchema from "./schema";
28import * as executorSchema from "./executor-schema";
29import { memberships, accounts } from "./schema";
30import {
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
44const 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.
51const 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
177const 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.
193const NOT_ORG_OWNED: Record<string, string> = {
194 // Purged, but by NAMESPACE PREFIX (`o:<org>/…` / `u:<org>:…`) rather than a
195 // tenant column, so it cannot be checked by the column rule.
196 blob: "org-scoped by namespace prefix, purged via the LIKE match",
197 // Instance-wide, not owned by any org.
198 private_executor_cloud_settings: "singleton instance settings, not org-scoped",
199 // Identity mirror. `organizations` is deleted directly and `memberships`
200 // cascades from its FK; `accounts` deliberately outlives the org.
201 organizations: "the identity row itself, deleted directly",
202 memberships: "cascades from the organizations FK",
203 accounts: "shared across orgs — deliberately survives",
204};
205
206const 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
221describe("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.
311describe("purge coverage", () => {
312 // Every drizzle table the cloud DB is built from (the same two modules
313 // `combinedSchema` spreads), by table name.
314 const allTables = Object.values({ ...cloudSchema, ...executorSchema });
315
316 const orgOwnedTables = allTables.filter((table) => "tenant" in getTableColumns(table));
317 const orgOwnedNames = orgOwnedTables.map(getTableName).sort();
318 const seededNames = TENANT_TABLES.map(getTableName).sort();
319
320 it("classifies every schema table as org-owned or explicitly exempt", () => {
321 const unclassified = allTables
322 .map(getTableName)
323 .filter((name) => !orgOwnedNames.includes(name) && !(name in NOT_ORG_OWNED));
324 expect(
325 unclassified,
326 "a new table must either carry `tenant` (and be purged) or be listed in NOT_ORG_OWNED with a reason",
327 ).toEqual([]);
328 });
329
330 it("purges every org-owned table in the schema", () => {
331 // `TENANT_TABLES` is what this suite seeds and then asserts is empty after
332 // the purge, so keeping it equal to the schema-derived set is what makes
333 // the cascade test above cover the WHOLE schema rather than a snapshot of
334 // it. A tenant table added tomorrow fails here first — the fix is to seed
335 // it, which then fails the cascade test until `purgeOrganizationData`
336 // deletes it too.
337 expect(seededNames, "every org-owned table is seeded and checked by the cascade test").toEqual(
338 orgOwnedNames,
339 );
340 });
341
342 it("seeds a row in every org-owned table, so the cascade assertions are not vacuous", async () => {
343 // Guards the other half: a table listed in TENANT_TABLES but never seeded
344 // would count zero rows before AND after the purge, passing while proving
345 // nothing. Assert the seed actually populates each one.
346 const tenant = `org_seed_${crypto.randomUUID().slice(0, 8)}`;
347 const empty = await program(
348 Effect.gen(function* () {
349 const { db } = yield* DbService;
350 return yield* Effect.promise(async () => {
351 await seedTenant(db, tenant, `seed${crypto.randomUUID().slice(0, 6)}`);
352 const missing: Array<string> = [];
353 for (const table of TENANT_TABLES) {
354 const rows = await db.select().from(table).where(eq(table.tenant, tenant));
355 if (rows.length === 0) missing.push(getTableName(table));
356 }
357 return missing;
358 });
359 }),
360 );
361 expect(empty, "seedTenant must insert a row into every table the cascade checks").toEqual([]);
362 }, 20_000);
363});
apps/cloud/src/db/org-deletion.ts+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
14import { eq, or, sql } from "drizzle-orm";
15
16import type { DrizzleDb } from "./db";
17import { organizations } from "./schema";
18import {
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.
35const 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 */
42export 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
6// base path, so the pathname is forwarded UNCHANGED — only the host/proto swap6// base path, so the pathname is forwarded UNCHANGED — only the host/proto swap
7// to the upstream origin (unlike the PostHog proxy, which strips its prefix).7// to the upstream origin (unlike the PostHog proxy, which strips its prefix).
8//8//
9// Like the PostHog/Sentry tunnels (and unlike the marketing proxy, which needs9// The matching, upstream construction, and client span live in `./passthrough`,
10// the prod-only `env.MARKETING` service binding), this is a plain external10// which server.ts dispatches BEFORE Start loads: forwarding a docs page must
11// `fetch`, so it runs on every host — `/docs` previews against live Mintlify in11// not pay for the whole server graph. This middleware stays registered so hosts
12// local dev too. `/docs` is distinct from the app-owned `/api/docs` (Swagger),12// that reach Start by another entry (local dev) keep identical behavior; in the
13// so this never shadows an Effect-served route.13// deployed Worker it is unreachable. `/docs` is distinct from the app-owned
14// `/api/docs` (Swagger), so this never shadows an Effect-served route.
14// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
15 16
16import { createMiddleware } from "@tanstack/react-start";17import { createMiddleware } from "@tanstack/react-start";
17 18
18const DOCS_UPSTREAM_HOST = "executor.mintlify.dev";19import { docsProxyResponse, isDocsPath } from "./passthrough";
19 20
20export const isDocsPath = (pathname: string) =>21export { buildDocsUpstream, isDocsPath } from "./passthrough";
21 pathname === "/docs" || pathname.startsWith("/docs/");
22
23// Build the upstream request for an already-classified `/docs` path. Caller
24// guarantees `isDocsPath(pathname)` — we only swap the origin and fix up the
25// forwarding headers, preserving method, body, path, and query.
26export const buildDocsUpstream = (request: Request): Request => {
27 const url = new URL(request.url);
28 const forwardedHost = url.host;
29
30 url.hostname = DOCS_UPSTREAM_HOST;
31 url.protocol = "https:";
32 url.port = "";
33
34 const upstream = new Request(url, request);
35 // Mintlify keys canonical links off the public host; tell it the real one.
36 upstream.headers.set("X-Forwarded-Host", forwardedHost);
37 upstream.headers.set("X-Forwarded-Proto", "https");
38 // Never leak the executor.sh session cookie to the docs origin.
39 upstream.headers.delete("cookie");
40 return upstream;
41};
42 22
43export const docsProxyMiddleware = createMiddleware({ type: "request" }).server(23export const docsProxyMiddleware = createMiddleware({ type: "request" }).server(
44 ({ pathname, request, next }) => {24 ({ pathname, request, next }) => {
45 if (!isDocsPath(pathname)) return next();25 if (!isDocsPath(pathname)) return next();
46 return fetch(buildDocsUpstream(request));26 return docsProxyResponse(request, pathname);
47 },27 },
48);28);
apps/cloud/src/edge/index.ts+4−5
@@ -1,11 +1,10 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Edge concerns — the analytics/marketing/docs request middlewares that run at2// Edge concerns — request middleware that runs before the app's own mcp + api
3// the worker edge BEFORE the app's own mcp + api dispatch. None of these touch3// dispatch. These proxy or tunnel to external services without touching the
4// the Effect app layer; they proxy or tunnel to external services (the4// Effect app layer. Marketing is dispatched even earlier, from server.ts, so a
5// marketing worker, Sentry, PostHog, Mintlify docs).5// public page never loads the TanStack Start graph.
6// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
7 7
8export { marketingMiddleware } from "./marketing";
9export { sentryTunnelMiddleware } from "./sentry-tunnel";8export { sentryTunnelMiddleware } from "./sentry-tunnel";
10export { posthogProxyMiddleware } from "./posthog";9export { posthogProxyMiddleware } from "./posthog";
11export { docsProxyMiddleware } from "./docs";10export { docsProxyMiddleware } from "./docs";
apps/cloud/src/edge/marketing.test.ts+55−1
@@ -1,6 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { isMarketingPath } from "./marketing";3import { isMarketingPath, marketingProxyRequest } from "./marketing";
4 4
5// On executor.sh the marketing middleware proxies an allow-list of paths to the5// On executor.sh the marketing middleware proxies an allow-list of paths to the
6// `executor-marketing` worker; everything else falls through to the auth-gated6// `executor-marketing` worker; everything else falls through to the auth-gated
@@ -12,6 +12,9 @@ describe("isMarketingPath", () => { · 5 unmodified lines
12 "/home",12 "/home",
13 "/privacy",13 "/privacy",
14 "/terms",14 "/terms",
15 "/about-executor",
16 "/google-oauth",
17 "/google-workspace",
15 "/blog",18 "/blog",
16 "/blog/",19 "/blog/",
17 "/blog/some-post",20 "/blog/some-post",
@@ -37,3 +40,54 @@ describe("isMarketingPath", () => { · 19 unmodified lines
37 });40 });
38 }41 }
39});42});
43
44describe("marketingProxyRequest", () => {
45 it("routes a signed-out homepage request", () => {
46 const request = new Request("https://executor.sh/?source=test");
47
48 const proxied = marketingProxyRequest(request);
49
50 expect(proxied?.url).toBe("https://executor.sh/?source=test");
51 });
52
53 it("leaves the signed-in homepage with the cloud application", () => {
54 const request = new Request("https://executor.sh/", {
55 headers: { cookie: "other=value; wos-session=sealed" },
56 });
57
58 expect(marketingProxyRequest(request)).toBeNull();
59 });
60
61 it("routes public content even when a session cookie is present", () => {
62 const request = new Request("https://executor.sh/blog/post", {
63 headers: { cookie: "wos-session=sealed" },
64 });
65
66 expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/blog/post");
67 });
68
69 it("rewrites the public home alias to the marketing root", () => {
70 const request = new Request("https://executor.sh/home?source=test");
71
72 expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/?source=test");
73 });
74
75 it("preserves the request method, headers, and body", async () => {
76 const request = new Request("https://executor.sh/_astro/_ph/capture", {
77 method: "POST",
78 headers: { "content-type": "application/json", "x-request-id": "request-1" },
79 body: JSON.stringify({ event: "test" }),
80 });
81
82 const proxied = marketingProxyRequest(request);
83
84 expect(proxied?.method).toBe("POST");
85 expect(proxied?.headers.get("x-request-id")).toBe("request-1");
86 await expect(proxied?.json()).resolves.toEqual({ event: "test" });
87 });
88
89 it("does not proxy non-production hosts or app-owned paths", () => {
90 expect(marketingProxyRequest(new Request("http://executor-cloud.localhost/"))).toBeNull();
91 expect(marketingProxyRequest(new Request("https://executor.sh/login"))).toBeNull();
92 });
93});
apps/cloud/src/edge/marketing.ts+25−34
@@ -3,14 +3,10 @@ · 2 unmodified lines
3//3//
4// On the production domain (`executor.sh`), marketing paths and the4// On the production domain (`executor.sh`), marketing paths and the
5// unauthenticated landing page are served by the separate `executor-marketing`5// unauthenticated landing page are served by the separate `executor-marketing`
6// worker (bound as `env.MARKETING`). In local dev that worker isn't running, so6// worker. This module deliberately has no TanStack Start or cloud application
7// unauthenticated visits fall through to the cloud app's routes (the sign-in7// imports: the Worker entry calls it before loading the Start server graph.
8// page).
9// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
10 9
11import { env } from "cloudflare:workers";
12import { createMiddleware } from "@tanstack/react-start";
13
14import { parseCookie } from "../auth/cookies";10import { parseCookie } from "../auth/cookies";
15 11
16const MARKETING_PATHS = [12const MARKETING_PATHS = [
@@ -18,6 +14,9 @@ const MARKETING_PATHS = [ · 1 unmodified line
18 "/setup",14 "/setup",
19 "/privacy",15 "/privacy",
20 "/terms",16 "/terms",
17 "/about-executor",
18 "/google-oauth",
19 "/google-workspace",
21 "/blog",20 "/blog",
22 "/llms.txt",21 "/llms.txt",
23 "/api/detect",22 "/api/detect",
@@ -27,33 +26,25 @@ const MARKETING_PATHS = [ · 3 unmodified lines
27 "/pattern-graph-paper.svg",26 "/pattern-graph-paper.svg",
28];27];
29 28
30export const isMarketingPath = (pathname: string) =>29const SESSION_COOKIE = "wos-session";
31 MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
32
33const getMarketingWorker = () => env.MARKETING as { fetch: typeof fetch } | undefined;
34
35export 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();
42 30
43 const shouldProxyToMarketing =31/** Whether an exact pathname belongs to the public marketing worker. */
44 isMarketingPath(pathname) ||32export const isMarketingPath = (pathname: string): boolean =>
45 (pathname === "/" && !parseCookie(request.headers.get("cookie"), "wos-session"));33 MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
46
47 if (!shouldProxyToMarketing) return next();
48
49 const marketing = getMarketingWorker();
50 if (!marketing) return next();
51 34
52 const url = new URL(request.url);35/**
53 // Rewrite /home to / so marketing worker serves its homepage36 * Project a production request onto the marketing service-binding request.
54 if (pathname === "/home") {37 * Returns `null` when the cloud application owns the request instead.
55 url.pathname = "/";38 */
56 }39export const marketingProxyRequest = (request: Request): Request | null => {
57 return marketing.fetch(new Request(url, request));40 const url = new URL(request.url);
58 },41 if (url.hostname !== "executor.sh") return null;
59);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 @@
1import { readFileSync } from "node:fs";
2import { describe, expect, it } from "@effect/vitest";
3
4import {
5 buildDocsUpstream,
6 buildPosthogUpstream,
7 isDocsPath,
8 isPosthogPath,
9 passthroughResponse,
10 POSTHOG_PROXY_PATH,
11} from "./passthrough";
12
13describe("passthrough matching", () => {
14 it("claims /docs and everything under it, but not /api/docs", () => {
15 expect(isDocsPath("/docs")).toBe(true);
16 expect(isDocsPath("/docs/concepts/policies")).toBe(true);
17 // The app-owned Swagger route must keep reaching the Effect app.
18 expect(isDocsPath("/api/docs")).toBe(false);
19 expect(isDocsPath("/docsearch")).toBe(false);
20 });
21
22 it("claims the PostHog proxy path and its subtree only", () => {
23 expect(isPosthogPath(POSTHOG_PROXY_PATH)).toBe(true);
24 expect(isPosthogPath(`${POSTHOG_PROXY_PATH}/i/v0/e/`)).toBe(true);
25 expect(isPosthogPath(`${POSTHOG_PROXY_PATH}extra`)).toBe(false);
26 expect(isPosthogPath("/api/connections")).toBe(false);
27 });
28
29 it("returns null for app paths so they fall through to normal dispatch", () => {
30 expect(passthroughResponse(new Request("https://executor.sh/"), "/")).toBeNull();
31 expect(
32 passthroughResponse(new Request("https://executor.sh/api/connections"), "/api/connections"),
33 ).toBeNull();
34 expect(passthroughResponse(new Request("https://executor.sh/mcp"), "/mcp")).toBeNull();
35 });
36});
37
38describe("upstream construction", () => {
39 it("forwards the docs path unchanged and strips the session cookie", () => {
40 const upstream = buildDocsUpstream(
41 new Request("https://executor.sh/docs/concepts/policies?x=1", {
42 headers: { cookie: "wos-session=secret" },
43 }),
44 );
45 const url = new URL(upstream.url);
46 expect(url.hostname).toBe("executor.mintlify.dev");
47 expect(url.pathname).toBe("/docs/concepts/policies");
48 expect(url.search).toBe("?x=1");
49 expect(upstream.headers.get("X-Forwarded-Host")).toBe("executor.sh");
50 expect(upstream.headers.get("cookie")).toBeNull();
51 });
52
53 it("strips the proxy prefix for PostHog and splits ingest from assets", () => {
54 const ingest = buildPosthogUpstream(
55 new Request(`https://executor.sh${POSTHOG_PROXY_PATH}/i/v0/e/`),
56 `${POSTHOG_PROXY_PATH}/i/v0/e/`,
57 );
58 expect(new URL(ingest.url).hostname).toBe("us.i.posthog.com");
59 expect(new URL(ingest.url).pathname).toBe("/i/v0/e/");
60
61 const assets = buildPosthogUpstream(
62 new Request(`https://executor.sh${POSTHOG_PROXY_PATH}/static/array.js`),
63 `${POSTHOG_PROXY_PATH}/static/array.js`,
64 );
65 expect(new URL(assets.url).hostname).toBe("us-assets.i.posthog.com");
66 expect(new URL(assets.url).pathname).toBe("/static/array.js");
67 });
68});
69
70describe("Start-graph independence", () => {
71 // The entire point of this module is that server.ts can answer a proxy
72 // request WITHOUT importing TanStack Start. An import of the app or of
73 // `@tanstack/react-start` here silently reintroduces the ~3.1s cold-isolate
74 // `loadEntries` cost this module exists to avoid, and nothing else would
75 // catch it — the behavior stays correct, only slow.
76 it("imports neither TanStack Start nor an app module", () => {
77 const source = readFileSync(new URL("./passthrough.ts", import.meta.url), "utf8");
78 const imports = [...source.matchAll(/^\s*import[^"']*["']([^"']+)["']/gm)].map((m) => m[1]);
79 expect(imports.length).toBeGreaterThan(0);
80 for (const specifier of imports) {
81 expect(specifier).not.toMatch(/@tanstack/);
82 expect(specifier).not.toMatch(/^\.\.\//);
83 }
84 });
85});
apps/cloud/src/edge/passthrough.ts+138−0
@@ -0,0 +1,138 @@
1// ---------------------------------------------------------------------------
2// Pure passthrough proxies — dispatched from the Worker entry, before Start.
3// ---------------------------------------------------------------------------
4//
5// `/docs` and the PostHog proxy forward to an external origin and never touch
6// the router, React, or the Effect app. They lived in Start's request
7// middleware, which meant each one still paid Start's lazy `loadEntries`
8// import of the whole server graph before it could forward a request.
9//
10// Measured on production (2026-08-18), splitting the two costs apart on a cold
11// isolate: the graph import is p50 **3.1s** while the request's own work is
12// p50 **33ms**. And essentially every request is cold — `worker.dispatch` ran
13// 1,666 requests across 1,608 isolates (1.04 req/isolate), because `/mcp`
14// dispatches before `fetchHandler` and so never warms the graph. A `/docs`
15// page took 3-6s through the Worker against 0.098s straight from the upstream.
16//
17// So these move to the Worker entry, exactly as marketing did: classify and
18// forward before anything imports Start. This module therefore must NOT import
19// `@tanstack/react-start` or any app module — that import is the cost it
20// exists to avoid.
21//
22// The middleware wrappers in `./docs` and `./posthog` stay registered. In the
23// deployed Worker they become unreachable (server.ts answers first), but they
24// keep the behavior identical on any host that reaches Start by another entry
25// (local dev), and they source their matching from here so the two can't drift.
26// ---------------------------------------------------------------------------
27
28import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
29
30const DOCS_UPSTREAM_HOST = "executor.mintlify.dev";
31/** PostHog's US ingest origin. Exported because the server-side feature-flag
32 * gate (`../analytics/ema-rollout`) calls the same origin directly, and the
33 * two must not be able to drift onto different PostHog regions. */
34export const POSTHOG_INGEST_HOST = "us.i.posthog.com";
35const POSTHOG_ASSETS_HOST = "us-assets.i.posthog.com";
36
37export const POSTHOG_PROXY_PATH = `/api/${(
38 import.meta.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a"
39).replace(/^\/+|\/+$/g, "")}`;
40
41// The proxy fetch gets its own client span: `/docs` requests otherwise render
42// as a single opaque server span, and during the Aug 2026 regression there
43// was no way to tell upstream (Mintlify/Vercel) latency from worker-side
44// dispatch cost. The noop tracer applies when no provider is installed
45// (local dev without AXIOM_TOKEN), so this is free there.
46const tracer = trace.getTracer("executor-cloud-docs-proxy");
47
48export const isDocsPath = (pathname: string): boolean =>
49 pathname === "/docs" || pathname.startsWith("/docs/");
50
51export const isPosthogPath = (pathname: string): boolean =>
52 pathname === POSTHOG_PROXY_PATH || pathname.startsWith(`${POSTHOG_PROXY_PATH}/`);
53
54/**

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.