@vellumai/assistant 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (463) hide show
  1. package/bun.lock +40 -40
  2. package/bunfig.toml +3 -0
  3. package/docker-entrypoint.sh +12 -2
  4. package/docs/architecture/memory.md +1 -1
  5. package/node_modules/@vellumai/ces-contracts/src/handles.ts +7 -9
  6. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +42 -0
  7. package/openapi.yaml +184 -69
  8. package/package.json +41 -41
  9. package/scripts/generate-openapi.ts +1 -2
  10. package/src/__tests__/acp-session.test.ts +43 -0
  11. package/src/__tests__/app-builder-tool-scripts.test.ts +1 -0
  12. package/src/__tests__/app-executors.test.ts +1 -0
  13. package/src/__tests__/app-source-watcher.test.ts +37 -11
  14. package/src/__tests__/approval-routes-http.test.ts +178 -1
  15. package/src/__tests__/assistant-event-hub.test.ts +30 -0
  16. package/src/__tests__/browser-fill-credential.test.ts +229 -94
  17. package/src/__tests__/browser-manager.test.ts +40 -27
  18. package/src/__tests__/catalog-files.test.ts +862 -0
  19. package/src/__tests__/channel-approvals.test.ts +53 -0
  20. package/src/__tests__/checker.test.ts +104 -170
  21. package/src/__tests__/cli-command-risk-guard.test.ts +1 -1
  22. package/src/__tests__/config-managed-gemini-defaults.test.ts +326 -0
  23. package/src/__tests__/config-schema-cmd.test.ts +2 -2
  24. package/src/__tests__/config-schema.test.ts +125 -48
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +23 -0
  26. package/src/__tests__/context-overflow-approval.test.ts +21 -6
  27. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
  28. package/src/__tests__/conversation-agent-loop.test.ts +1 -1
  29. package/src/__tests__/conversation-analysis-routes.test.ts +169 -0
  30. package/src/__tests__/conversation-attachments.test.ts +80 -4
  31. package/src/__tests__/conversation-confirmation-signals.test.ts +155 -0
  32. package/src/__tests__/conversation-directories-parse.test.ts +105 -0
  33. package/src/__tests__/conversation-fork-crud.test.ts +17 -0
  34. package/src/__tests__/conversation-history-web-search.test.ts +1 -0
  35. package/src/__tests__/conversation-host-access-routes.test.ts +229 -0
  36. package/src/__tests__/conversation-inject-context.test.ts +103 -0
  37. package/src/__tests__/conversation-queue.test.ts +45 -2
  38. package/src/__tests__/conversation-routes-disk-view.test.ts +5 -0
  39. package/src/__tests__/conversation-routes-guardian-reply.test.ts +16 -0
  40. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  41. package/src/__tests__/conversation-runtime-assembly.test.ts +269 -46
  42. package/src/__tests__/conversation-starter-routes.test.ts +126 -0
  43. package/src/__tests__/conversation-starters-cadence.test.ts +161 -0
  44. package/src/__tests__/conversation-store.test.ts +195 -0
  45. package/src/__tests__/conversation-workspace-cache-state.test.ts +193 -0
  46. package/src/__tests__/credential-execution-approval-bridge.test.ts +32 -3
  47. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  48. package/src/__tests__/credential-vault-unit.test.ts +4 -4
  49. package/src/__tests__/credential-vault.test.ts +152 -13
  50. package/src/__tests__/credentials-cli.test.ts +2 -2
  51. package/src/__tests__/date-context.test.ts +4 -4
  52. package/src/__tests__/embedding-managed-proxy-selection.test.ts +256 -0
  53. package/src/__tests__/extension-id-sync-guard.test.ts +155 -0
  54. package/src/__tests__/fixtures/mock-chrome-extension.ts +375 -0
  55. package/src/__tests__/gateway-only-guard.test.ts +3 -0
  56. package/src/__tests__/gemini-provider.test.ts +2 -2
  57. package/src/__tests__/guardian-routing-invariants.test.ts +70 -2
  58. package/src/__tests__/headless-browser-interactions.test.ts +707 -371
  59. package/src/__tests__/headless-browser-navigate.test.ts +389 -47
  60. package/src/__tests__/headless-browser-read-tools.test.ts +266 -103
  61. package/src/__tests__/headless-browser-snapshot.test.ts +240 -77
  62. package/src/__tests__/host-bash-proxy.test.ts +150 -1
  63. package/src/__tests__/host-browser-e2e-cloud.test.ts +462 -0
  64. package/src/__tests__/host-browser-e2e-self-hosted-capability.test.ts +286 -0
  65. package/src/__tests__/host-browser-e2e-self-hosted.test.ts +374 -0
  66. package/src/__tests__/host-browser-event-routes.test.ts +350 -0
  67. package/src/__tests__/host-browser-proxy.test.ts +444 -0
  68. package/src/__tests__/host-browser-routes.test.ts +198 -0
  69. package/src/__tests__/host-browser-ws-events-e2e.test.ts +320 -0
  70. package/src/__tests__/host-cu-proxy.test.ts +171 -1
  71. package/src/__tests__/host-file-proxy.test.ts +185 -1
  72. package/src/__tests__/host-file-read-tool.test.ts +52 -0
  73. package/src/__tests__/host-proxy-interface.test.ts +165 -0
  74. package/src/__tests__/host-shell-tool.test.ts +1 -11
  75. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  76. package/src/__tests__/init-feature-flag-overrides.test.ts +167 -0
  77. package/src/__tests__/inline-command-runner.test.ts +7 -5
  78. package/src/__tests__/integration-status.test.ts +6 -7
  79. package/src/__tests__/list-messages-tool-merge.test.ts +37 -12
  80. package/src/__tests__/log-export-workspace.test.ts +190 -0
  81. package/src/__tests__/managed-credential-catalog-cli.test.ts +12 -14
  82. package/src/__tests__/mcp-client-auth.test.ts +40 -4
  83. package/src/__tests__/mcp-health-check.test.ts +10 -3
  84. package/src/__tests__/migration-cross-version-compatibility.test.ts +3 -1
  85. package/src/__tests__/migration-export-http.test.ts +61 -2
  86. package/src/__tests__/migration-export-streaming.test.ts +66 -0
  87. package/src/__tests__/migration-import-commit-http.test.ts +101 -1
  88. package/src/__tests__/native-host-marker-sync-guard.test.ts +157 -0
  89. package/src/__tests__/navigate-settings-tab.test.ts +14 -1
  90. package/src/__tests__/notification-broadcaster.test.ts +65 -0
  91. package/src/__tests__/oauth-apps-routes.test.ts +17 -12
  92. package/src/__tests__/oauth-cli.test.ts +707 -60
  93. package/src/__tests__/oauth-connect-orchestrator.test.ts +116 -24
  94. package/src/__tests__/oauth-provider-seed-logos.test.ts +23 -0
  95. package/src/__tests__/oauth-provider-serializer.test.ts +146 -10
  96. package/src/__tests__/oauth-provider-visibility.test.ts +19 -21
  97. package/src/__tests__/oauth-providers-routes.test.ts +50 -14
  98. package/src/__tests__/oauth-store.test.ts +1386 -182
  99. package/src/__tests__/oauth2-gateway-transport.test.ts +211 -20
  100. package/src/__tests__/onboarding-template-contract.test.ts +74 -55
  101. package/src/__tests__/openai-provider.test.ts +2 -2
  102. package/src/__tests__/outlook-categories.test.ts +1 -1
  103. package/src/__tests__/outlook-client-automation.test.ts +1 -1
  104. package/src/__tests__/outlook-compose-tools.test.ts +1 -1
  105. package/src/__tests__/outlook-email-watcher.test.ts +1 -1
  106. package/src/__tests__/outlook-follow-up.test.ts +1 -1
  107. package/src/__tests__/outlook-messaging-provider.test.ts +2 -2
  108. package/src/__tests__/outlook-trash.test.ts +1 -1
  109. package/src/__tests__/outlook-unsubscribe.test.ts +1 -1
  110. package/src/__tests__/permission-checker-host-gate.test.ts +74 -14
  111. package/src/__tests__/permission-mode.test.ts +28 -56
  112. package/src/__tests__/pkb-autoinject.test.ts +96 -0
  113. package/src/__tests__/platform-callback-registration.test.ts +19 -0
  114. package/src/__tests__/post-turn-tool-result-truncation.test.ts +296 -0
  115. package/src/__tests__/proxy-approval-callback.test.ts +18 -0
  116. package/src/__tests__/require-fresh-approval.test.ts +40 -3
  117. package/src/__tests__/sandbox-diagnostics.test.ts +1 -32
  118. package/src/__tests__/sanitize-config-for-transfer.test.ts +132 -0
  119. package/src/__tests__/schedule-routes.test.ts +162 -0
  120. package/src/__tests__/secret-detection-handler.test.ts +84 -0
  121. package/src/__tests__/secret-ingress-http.test.ts +1 -0
  122. package/src/__tests__/send-endpoint-busy.test.ts +3 -0
  123. package/src/__tests__/set-permission-mode.test.ts +13 -250
  124. package/src/__tests__/skills-file-content-endpoint.test.ts +670 -0
  125. package/src/__tests__/skills-files-catalog-fallback.test.ts +450 -0
  126. package/src/__tests__/slack-channel-config.test.ts +12 -15
  127. package/src/__tests__/subagent-detail.test.ts +44 -2
  128. package/src/__tests__/subagent-disposal.test.ts +1 -0
  129. package/src/__tests__/subagent-fork-notifications.test.ts +291 -0
  130. package/src/__tests__/subagent-fork-spawn.test.ts +384 -0
  131. package/src/__tests__/subagent-manager-notify.test.ts +1 -0
  132. package/src/__tests__/subagent-notify-parent.test.ts +1 -0
  133. package/src/__tests__/subagent-spawn-tool-fork.test.ts +411 -0
  134. package/src/__tests__/subagent-tools.test.ts +1 -0
  135. package/src/__tests__/subagent-types.test.ts +1 -0
  136. package/src/__tests__/system-prompt-ask-mode.test.ts +27 -71
  137. package/src/__tests__/system-prompt.test.ts +72 -1
  138. package/src/__tests__/task-scheduler.test.ts +32 -6
  139. package/src/__tests__/telegram-config.test.ts +10 -13
  140. package/src/__tests__/terminal-sandbox.test.ts +1 -1
  141. package/src/__tests__/terminal-tools.test.ts +11 -5
  142. package/src/__tests__/test-preload.ts +14 -0
  143. package/src/__tests__/tool-approval-handler.test.ts +73 -0
  144. package/src/__tests__/tool-domain-event-publisher.test.ts +0 -1
  145. package/src/__tests__/tool-executor-lifecycle-events.test.ts +1 -8
  146. package/src/__tests__/tool-executor.test.ts +0 -1
  147. package/src/__tests__/tool-side-effects-slack-dm.test.ts +22 -0
  148. package/src/__tests__/top-level-renderer.test.ts +73 -1
  149. package/src/__tests__/transport-hints-queue.test.ts +62 -0
  150. package/src/__tests__/trust-store.test.ts +4 -4
  151. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +109 -0
  152. package/src/__tests__/v2-consent-policy.test.ts +103 -0
  153. package/src/__tests__/workspace-migration-030-seed-pkb-autoinject.test.ts +168 -0
  154. package/src/__tests__/workspace-policy.test.ts +2 -7
  155. package/src/acp/client-handler.ts +30 -4
  156. package/src/agent/loop.ts +12 -35
  157. package/src/approvals/guardian-request-resolvers.ts +21 -15
  158. package/src/browser-session/__tests__/manager.test.ts +297 -0
  159. package/src/browser-session/backends/cdp-inspect.ts +30 -0
  160. package/src/browser-session/backends/extension.ts +26 -0
  161. package/src/browser-session/backends/local.ts +24 -0
  162. package/src/browser-session/events.ts +164 -0
  163. package/src/browser-session/index.ts +27 -0
  164. package/src/browser-session/manager.ts +159 -0
  165. package/src/browser-session/types.ts +28 -0
  166. package/src/channels/__tests__/types.test.ts +134 -0
  167. package/src/channels/types.ts +55 -0
  168. package/src/cli/__tests__/run-assistant-command.ts +34 -7
  169. package/src/cli/__tests__/unknown-command.test.ts +33 -0
  170. package/src/cli/commands/browser-relay.ts +339 -409
  171. package/src/cli/commands/credentials.ts +3 -3
  172. package/src/cli/commands/default-action.ts +68 -1
  173. package/src/cli/commands/email.ts +18 -13
  174. package/src/cli/commands/mcp.ts +16 -4
  175. package/src/cli/commands/oauth/__tests__/connect.test.ts +68 -41
  176. package/src/cli/commands/oauth/__tests__/disconnect.test.ts +21 -21
  177. package/src/cli/commands/oauth/__tests__/mode.test.ts +17 -17
  178. package/src/cli/commands/oauth/__tests__/ping.test.ts +16 -16
  179. package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +31 -33
  180. package/src/cli/commands/oauth/__tests__/providers-register.test.ts +329 -0
  181. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +116 -12
  182. package/src/cli/commands/oauth/__tests__/status.test.ts +10 -10
  183. package/src/cli/commands/oauth/__tests__/token.test.ts +7 -7
  184. package/src/cli/commands/oauth/apps.ts +7 -4
  185. package/src/cli/commands/oauth/connect.ts +16 -2
  186. package/src/cli/commands/oauth/disconnect.ts +1 -1
  187. package/src/cli/commands/oauth/providers.ts +200 -36
  188. package/src/cli/commands/oauth/shared.ts +5 -5
  189. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +259 -0
  190. package/src/cli/commands/platform/__tests__/connect.test.ts +1 -1
  191. package/src/cli/commands/platform/__tests__/disconnect.test.ts +1 -1
  192. package/src/cli/commands/platform/__tests__/status.test.ts +1 -1
  193. package/src/cli/commands/platform/index.ts +107 -10
  194. package/src/cli/commands/usage.ts +10 -9
  195. package/src/cli/lib/daemon-credential-client.ts +4 -0
  196. package/src/cli/program.ts +10 -3
  197. package/src/config/assistant-feature-flags.ts +59 -55
  198. package/src/config/bundled-skills/app-builder/SKILL.md +33 -173
  199. package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +105 -0
  200. package/src/config/bundled-skills/app-builder/references/INTERACTION_HOOKS.md +56 -0
  201. package/src/config/bundled-skills/app-builder/references/WIDGETS.md +125 -0
  202. package/src/config/bundled-skills/contacts/SKILL.md +3 -0
  203. package/src/config/bundled-skills/document/SKILL.md +4 -0
  204. package/src/config/bundled-skills/gmail/SKILL.md +12 -7
  205. package/src/config/bundled-skills/gmail/TOOLS.json +1 -1
  206. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +2 -1
  207. package/src/config/bundled-skills/outlook/SKILL.md +7 -0
  208. package/src/config/bundled-skills/settings/TOOLS.json +1 -1
  209. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +8 -3
  210. package/src/config/bundled-skills/subagent/SKILL.md +21 -0
  211. package/src/config/bundled-skills/subagent/TOOLS.json +8 -4
  212. package/src/config/bundled-skills/tasks/SKILL.md +5 -0
  213. package/src/config/env-registry.ts +14 -0
  214. package/src/config/env.ts +21 -0
  215. package/src/config/feature-flag-registry.json +46 -7
  216. package/src/config/loader.ts +56 -1
  217. package/src/config/sanitize-for-transfer.ts +47 -0
  218. package/src/config/schema.ts +46 -5
  219. package/src/config/schemas/host-browser.ts +66 -0
  220. package/src/config/schemas/memory-lifecycle.ts +1 -1
  221. package/src/config/schemas/memory-retrieval.ts +103 -0
  222. package/src/config/schemas/security.ts +0 -6
  223. package/src/config/schemas/services.ts +16 -0
  224. package/src/config/types.ts +0 -1
  225. package/src/context/post-turn-tool-result-truncation.ts +176 -0
  226. package/src/context/window-manager.ts +19 -1
  227. package/src/credential-execution/approval-bridge.ts +49 -16
  228. package/src/credential-execution/managed-catalog.ts +3 -7
  229. package/src/daemon/__tests__/conversation-tool-setup.test.ts +186 -0
  230. package/src/daemon/app-source-watcher.ts +35 -0
  231. package/src/daemon/config-watcher.ts +6 -2
  232. package/src/daemon/context-overflow-approval.ts +5 -1
  233. package/src/daemon/conversation-agent-loop-handlers.ts +17 -2
  234. package/src/daemon/conversation-agent-loop.ts +74 -19
  235. package/src/daemon/conversation-attachments.ts +40 -1
  236. package/src/daemon/conversation-messaging.ts +3 -0
  237. package/src/daemon/conversation-process.ts +66 -3
  238. package/src/daemon/conversation-queue-manager.ts +8 -0
  239. package/src/daemon/conversation-runtime-assembly.ts +159 -20
  240. package/src/daemon/conversation-surfaces.ts +78 -12
  241. package/src/daemon/conversation-tool-setup.ts +74 -11
  242. package/src/daemon/conversation-workspace.ts +12 -0
  243. package/src/daemon/conversation.ts +227 -11
  244. package/src/daemon/date-context.ts +10 -10
  245. package/src/daemon/first-greeting.ts +3 -2
  246. package/src/daemon/handlers/conversations.ts +9 -139
  247. package/src/daemon/handlers/shared.ts +65 -0
  248. package/src/daemon/handlers/skills.ts +232 -37
  249. package/src/daemon/host-bash-proxy.ts +48 -13
  250. package/src/daemon/host-browser-proxy.ts +191 -0
  251. package/src/daemon/host-cu-proxy.ts +36 -11
  252. package/src/daemon/host-file-proxy.ts +57 -9
  253. package/src/daemon/lifecycle.ts +86 -12
  254. package/src/daemon/message-protocol.ts +7 -0
  255. package/src/daemon/message-types/conversations.ts +59 -13
  256. package/src/daemon/message-types/host-browser.ts +100 -0
  257. package/src/daemon/message-types/messages.ts +5 -6
  258. package/src/daemon/message-types/notifications.ts +12 -0
  259. package/src/daemon/message-types/settings.ts +12 -0
  260. package/src/daemon/message-types/skills.ts +10 -0
  261. package/src/daemon/message-types/subagents.ts +2 -0
  262. package/src/daemon/server.ts +112 -35
  263. package/src/daemon/tool-side-effects.ts +6 -0
  264. package/src/daemon/transport-hints.ts +14 -0
  265. package/src/inbound/platform-callback-registration.ts +18 -17
  266. package/src/index.ts +1 -1
  267. package/src/mcp/client.ts +59 -24
  268. package/src/memory/app-store.ts +31 -1
  269. package/src/memory/conversation-crud.ts +38 -10
  270. package/src/memory/conversation-directories.ts +39 -0
  271. package/src/memory/conversation-group-migration.ts +65 -5
  272. package/src/memory/conversation-starters-cadence.ts +76 -0
  273. package/src/memory/conversation-title-service.ts +5 -2
  274. package/src/memory/db-init.ts +12 -0
  275. package/src/memory/embedding-backend.test.ts +75 -0
  276. package/src/memory/embedding-backend.ts +131 -5
  277. package/src/memory/embedding-gemini.test.ts +54 -0
  278. package/src/memory/embedding-gemini.ts +20 -9
  279. package/src/memory/embedding-local.ts +177 -18
  280. package/src/memory/graph/capability-seed.ts +3 -5
  281. package/src/memory/graph/consolidation.ts +10 -23
  282. package/src/memory/graph/extraction-job.ts +15 -0
  283. package/src/memory/graph/retriever.ts +40 -22
  284. package/src/memory/graph/store.test.ts +7 -3
  285. package/src/memory/graph/store.ts +47 -12
  286. package/src/memory/group-crud.ts +25 -9
  287. package/src/memory/llm-usage-store.ts +45 -4
  288. package/src/memory/migrations/213-oauth-providers-scope-separator.ts +13 -0
  289. package/src/memory/migrations/214-oauth-providers-refresh-url.ts +11 -0
  290. package/src/memory/migrations/215-oauth-providers-revoke.ts +14 -0
  291. package/src/memory/migrations/216-oauth-providers-token-auth-method.ts +30 -0
  292. package/src/memory/migrations/217-conversation-host-access.ts +40 -0
  293. package/src/memory/migrations/218-oauth-providers-logo-url.ts +11 -0
  294. package/src/memory/migrations/index.ts +6 -0
  295. package/src/memory/migrations/registry.ts +8 -0
  296. package/src/memory/schema/conversations.ts +1 -0
  297. package/src/memory/schema/oauth.ts +18 -13
  298. package/src/messaging/provider.ts +1 -1
  299. package/src/notifications/broadcaster.ts +6 -0
  300. package/src/notifications/conversation-pairing.ts +12 -4
  301. package/src/notifications/emit-signal.ts +14 -0
  302. package/src/notifications/signal.ts +11 -0
  303. package/src/oauth/AGENTS.md +76 -0
  304. package/src/oauth/__tests__/identity-verifier.test.ts +24 -19
  305. package/src/oauth/__tests__/seed-providers-managed.test.ts +32 -0
  306. package/src/oauth/byo-connection.test.ts +8 -8
  307. package/src/oauth/byo-connection.ts +7 -7
  308. package/src/oauth/connect-orchestrator.ts +23 -21
  309. package/src/oauth/connect-types.ts +3 -3
  310. package/src/oauth/connection-resolver.test.ts +17 -4
  311. package/src/oauth/connection-resolver.ts +16 -16
  312. package/src/oauth/connection.ts +1 -1
  313. package/src/oauth/manual-token-connection.ts +13 -13
  314. package/src/oauth/oauth-store.ts +214 -100
  315. package/src/oauth/platform-connection.test.ts +5 -5
  316. package/src/oauth/platform-connection.ts +4 -4
  317. package/src/oauth/provider-serializer.ts +31 -5
  318. package/src/oauth/revoke.ts +76 -0
  319. package/src/oauth/seed-providers.ts +127 -87
  320. package/src/oauth/token-persistence.ts +1 -1
  321. package/src/permissions/checker.ts +3 -3
  322. package/src/permissions/defaults.ts +7 -8
  323. package/src/permissions/permission-mode.ts +4 -11
  324. package/src/permissions/prompter.ts +13 -3
  325. package/src/permissions/v2-consent-policy.ts +87 -0
  326. package/src/platform/client.ts +1 -1
  327. package/src/prompts/system-prompt.ts +18 -21
  328. package/src/prompts/templates/BOOTSTRAP-REFERENCE.md +3 -65
  329. package/src/prompts/templates/BOOTSTRAP.md +59 -96
  330. package/src/prompts/templates/SOUL.md +11 -11
  331. package/src/providers/anthropic/client.ts +1 -0
  332. package/src/providers/types.ts +1 -1
  333. package/src/runtime/AGENTS.md +23 -0
  334. package/src/runtime/__tests__/browser-extension-pair-routes.test.ts +715 -0
  335. package/src/runtime/__tests__/capability-tokens.test.ts +258 -0
  336. package/src/runtime/__tests__/chrome-extension-registry.test.ts +518 -0
  337. package/src/runtime/assistant-event-hub.ts +24 -2
  338. package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -0
  339. package/src/runtime/auth/__tests__/middleware.test.ts +116 -1
  340. package/src/runtime/auth/__tests__/route-policy.test.ts +8 -0
  341. package/src/runtime/auth/middleware.ts +98 -0
  342. package/src/runtime/auth/route-policy.ts +6 -7
  343. package/src/runtime/auth/token-service.ts +8 -0
  344. package/src/runtime/capability-tokens.ts +414 -0
  345. package/src/runtime/channel-approvals.ts +18 -5
  346. package/src/runtime/chrome-extension-registry.ts +332 -0
  347. package/src/runtime/confirmation-request-guardian-bridge.ts +6 -0
  348. package/src/runtime/guardian-decision-types.ts +7 -0
  349. package/src/runtime/http-server.ts +425 -70
  350. package/src/runtime/migrations/__tests__/rebind-secrets-credentials.test.ts +172 -0
  351. package/src/runtime/migrations/__tests__/vbundle-builder-credentials.test.ts +276 -0
  352. package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +162 -0
  353. package/src/runtime/migrations/migration-transport.ts +6 -0
  354. package/src/runtime/migrations/migration-wizard.ts +22 -2
  355. package/src/runtime/migrations/rebind-secrets-screen.ts +76 -15
  356. package/src/runtime/migrations/vbundle-builder.ts +145 -38
  357. package/src/runtime/migrations/vbundle-import-analyzer.ts +19 -0
  358. package/src/runtime/migrations/vbundle-importer.ts +55 -5
  359. package/src/runtime/pending-interactions.ts +29 -13
  360. package/src/runtime/routes/approval-routes.ts +90 -16
  361. package/src/runtime/routes/browser-cdp-routes.ts +229 -0
  362. package/src/runtime/routes/browser-extension-pair-routes.ts +497 -0
  363. package/src/runtime/routes/conversation-analysis-routes.ts +18 -5
  364. package/src/runtime/routes/conversation-management-routes.ts +108 -0
  365. package/src/runtime/routes/conversation-routes.ts +308 -28
  366. package/src/runtime/routes/conversation-starter-routes.ts +78 -16
  367. package/src/runtime/routes/group-routes.ts +22 -8
  368. package/src/runtime/routes/guardian-action-routes.ts +24 -13
  369. package/src/runtime/routes/host-browser-routes.ts +279 -0
  370. package/src/runtime/routes/host-file-routes.ts +9 -1
  371. package/src/runtime/routes/identity-routes.ts +259 -16
  372. package/src/runtime/routes/log-export/AGENTS.md +104 -0
  373. package/src/runtime/routes/log-export/__tests__/workspace-allowlist-error-contract.test.ts +103 -0
  374. package/src/runtime/routes/log-export/__tests__/workspace-allowlist.test.ts +716 -0
  375. package/src/runtime/routes/log-export/workspace-allowlist.ts +458 -0
  376. package/src/runtime/routes/log-export-routes.ts +60 -25
  377. package/src/runtime/routes/memory-item-routes.ts +1 -7
  378. package/src/runtime/routes/migration-routes.ts +87 -2
  379. package/src/runtime/routes/oauth-apps.ts +15 -17
  380. package/src/runtime/routes/oauth-providers.ts +4 -0
  381. package/src/runtime/routes/schedule-routes.ts +24 -11
  382. package/src/runtime/routes/settings-routes.ts +9 -97
  383. package/src/runtime/routes/skills-routes.ts +52 -2
  384. package/src/runtime/routes/subagents-routes.ts +14 -10
  385. package/src/runtime/routes/usage-routes.ts +8 -7
  386. package/src/runtime/routes/workspace-routes.test.ts +22 -0
  387. package/src/runtime/routes/workspace-routes.ts +8 -1
  388. package/src/runtime/routes/workspace-utils.ts +2 -0
  389. package/src/schedule/scheduler.ts +7 -5
  390. package/src/security/ces-credential-client.ts +20 -0
  391. package/src/security/ces-rpc-credential-backend.ts +17 -0
  392. package/src/security/credential-backend.ts +5 -0
  393. package/src/security/oauth2.ts +42 -25
  394. package/src/security/secure-keys.ts +118 -25
  395. package/src/security/token-manager.ts +23 -10
  396. package/src/skills/catalog-files.ts +492 -0
  397. package/src/skills/inline-command-runner.ts +12 -14
  398. package/src/subagent/manager.ts +131 -26
  399. package/src/subagent/types.ts +19 -0
  400. package/src/tools/apps/executors.ts +11 -2
  401. package/src/tools/browser/__tests__/auth-detector.test.ts +202 -108
  402. package/src/tools/browser/auth-detector.ts +43 -12
  403. package/src/tools/browser/browser-execution.ts +645 -340
  404. package/src/tools/browser/browser-manager.ts +36 -12
  405. package/src/tools/browser/cdp-client/__tests__/accessibility-snapshot.test.ts +318 -0
  406. package/src/tools/browser/cdp-client/__tests__/cdp-dom-helpers.test.ts +1175 -0
  407. package/src/tools/browser/cdp-client/__tests__/cdp-inspect-client.test.ts +870 -0
  408. package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +330 -0
  409. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +377 -0
  410. package/src/tools/browser/cdp-client/__tests__/fixtures/ax-tree-nested-frames.json +64 -0
  411. package/src/tools/browser/cdp-client/__tests__/fixtures/ax-tree-simple.json +69 -0
  412. package/src/tools/browser/cdp-client/__tests__/local-cdp-client.test.ts +310 -0
  413. package/src/tools/browser/cdp-client/__tests__/types.test.ts +96 -0
  414. package/src/tools/browser/cdp-client/accessibility-snapshot.ts +387 -0
  415. package/src/tools/browser/cdp-client/cdp-dom-helpers.ts +695 -0
  416. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/discovery.test.ts +743 -0
  417. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +580 -0
  418. package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +578 -0
  419. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +579 -0
  420. package/src/tools/browser/cdp-client/cdp-inspect-client.ts +635 -0
  421. package/src/tools/browser/cdp-client/errors.ts +34 -0
  422. package/src/tools/browser/cdp-client/extension-cdp-client.ts +125 -0
  423. package/src/tools/browser/cdp-client/factory.ts +204 -0
  424. package/src/tools/browser/cdp-client/index.ts +14 -0
  425. package/src/tools/browser/cdp-client/local-cdp-client.ts +187 -0
  426. package/src/tools/browser/cdp-client/types.ts +52 -0
  427. package/src/tools/filesystem/edit.ts +1 -1
  428. package/src/tools/filesystem/list.ts +1 -1
  429. package/src/tools/filesystem/read.ts +1 -1
  430. package/src/tools/filesystem/write.ts +2 -1
  431. package/src/tools/host-filesystem/edit.ts +1 -1
  432. package/src/tools/host-filesystem/read.ts +12 -15
  433. package/src/tools/host-filesystem/write.ts +1 -1
  434. package/src/tools/host-terminal/host-shell.ts +21 -16
  435. package/src/tools/permission-checker.ts +77 -100
  436. package/src/tools/registry.ts +0 -2
  437. package/src/tools/secret-detection-handler.ts +34 -1
  438. package/src/tools/shared/filesystem/image-read.ts +61 -40
  439. package/src/tools/skills/sandbox-runner.ts +3 -6
  440. package/src/tools/subagent/spawn.ts +47 -3
  441. package/src/tools/subagent/status.ts +2 -0
  442. package/src/tools/system/register.ts +2 -16
  443. package/src/tools/terminal/safe-env.ts +7 -0
  444. package/src/tools/terminal/sandbox-diagnostics.ts +4 -4
  445. package/src/tools/terminal/sandbox.ts +4 -1
  446. package/src/tools/terminal/shell.ts +24 -21
  447. package/src/tools/tool-approval-handler.ts +48 -2
  448. package/src/tools/types.ts +2 -3
  449. package/src/util/platform.ts +14 -19
  450. package/src/watcher/provider-types.ts +1 -1
  451. package/src/workspace/migrations/029-seed-pkb.ts +1 -0
  452. package/src/workspace/migrations/030-seed-pkb-autoinject.ts +73 -0
  453. package/src/workspace/migrations/registry.ts +2 -0
  454. package/src/workspace/top-level-renderer.ts +19 -1
  455. package/src/__tests__/chrome-cdp.test.ts +0 -419
  456. package/src/__tests__/permission-mode-sse.test.ts +0 -418
  457. package/src/__tests__/permission-mode-store.test.ts +0 -277
  458. package/src/browser-extension-relay/protocol.ts +0 -63
  459. package/src/browser-extension-relay/server.ts +0 -203
  460. package/src/config/schemas/sandbox.ts +0 -14
  461. package/src/permissions/permission-mode-store.ts +0 -180
  462. package/src/tools/browser/chrome-cdp.ts +0 -239
  463. package/src/tools/system/set-permission-mode.ts +0 -103
@@ -24,6 +24,8 @@ mock.module("../security/secure-keys.js", () => ({
24
24
  import { eq } from "drizzle-orm";
25
25
 
26
26
  import { getDb, initializeDb, resetDb, resetTestTables } from "../memory/db.js";
27
+ import { getSqliteFrom } from "../memory/db-connection.js";
28
+ import { migrateOAuthProvidersTokenAuthMethodDefault } from "../memory/migrations/216-oauth-providers-token-auth-method.js";
27
29
  import { oauthProviders } from "../memory/schema/oauth.js";
28
30
  import {
29
31
  createConnection,
@@ -43,18 +45,21 @@ import {
43
45
  registerProvider,
44
46
  seedProviders,
45
47
  updateConnection,
48
+ updateProvider,
46
49
  upsertApp,
47
50
  } from "../oauth/oauth-store.js";
51
+ import { seedOAuthProviders } from "../oauth/seed-providers.js";
52
+ import { getMockFetchCalls, mockFetch, resetMockFetch } from "./mock-fetch.js";
48
53
 
49
54
  initializeDb();
50
55
 
51
56
  /** Seed a minimal provider row for FK satisfaction. */
52
- function seedTestProvider(providerKey = "github"): void {
57
+ function seedTestProvider(provider = "github"): void {
53
58
  seedProviders([
54
59
  {
55
- providerKey,
56
- authUrl: `https://${providerKey}.example.com/authorize`,
57
- tokenUrl: `https://${providerKey}.example.com/token`,
60
+ provider,
61
+ authorizeUrl: `https://${provider}.example.com/authorize`,
62
+ tokenExchangeUrl: `https://${provider}.example.com/token`,
58
63
  defaultScopes: ["read"],
59
64
  scopePolicy: {},
60
65
  },
@@ -62,9 +67,9 @@ function seedTestProvider(providerKey = "github"): void {
62
67
  }
63
68
 
64
69
  /** Create an app linked to the given provider. Returns the app row. */
65
- async function createTestApp(providerKey = "github", clientId = "client-1") {
66
- seedTestProvider(providerKey);
67
- return await upsertApp(providerKey, clientId);
70
+ async function createTestApp(provider = "github", clientId = "client-1") {
71
+ seedTestProvider(provider);
72
+ return await upsertApp(provider, clientId);
68
73
  }
69
74
 
70
75
  beforeEach(() => {
@@ -74,6 +79,7 @@ beforeEach(() => {
74
79
  mockDeleteSecureKeyAsync.mockClear();
75
80
  mockSetSecureKeyAsync.mockClear();
76
81
  secureKeyValues.clear();
82
+ resetMockFetch();
77
83
  });
78
84
 
79
85
  afterAll(() => {
@@ -89,34 +95,36 @@ describe("provider operations", () => {
89
95
  test("creates rows for new providers", () => {
90
96
  seedProviders([
91
97
  {
92
- providerKey: "github",
93
- authUrl: "https://github.com/login/oauth/authorize",
94
- tokenUrl: "https://github.com/login/oauth/access_token",
98
+ provider: "github",
99
+ authorizeUrl: "https://github.com/login/oauth/authorize",
100
+ tokenExchangeUrl: "https://github.com/login/oauth/access_token",
95
101
  defaultScopes: ["repo", "user"],
96
102
  scopePolicy: { required: ["repo"] },
97
103
  },
98
104
  {
99
- providerKey: "google",
100
- authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
101
- tokenUrl: "https://oauth2.googleapis.com/token",
105
+ provider: "google",
106
+ authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
107
+ tokenExchangeUrl: "https://oauth2.googleapis.com/token",
102
108
  defaultScopes: ["openid", "email"],
103
109
  scopePolicy: {},
104
- extraParams: { access_type: "offline" },
110
+ authorizeParams: { access_type: "offline" },
105
111
  },
106
112
  ]);
107
113
 
108
114
  const gh = getProvider("github");
109
115
  expect(gh).toBeDefined();
110
- expect(gh!.providerKey).toBe("github");
111
- expect(gh!.authUrl).toBe("https://github.com/login/oauth/authorize");
112
- expect(gh!.tokenUrl).toBe("https://github.com/login/oauth/access_token");
116
+ expect(gh!.provider).toBe("github");
117
+ expect(gh!.authorizeUrl).toBe("https://github.com/login/oauth/authorize");
118
+ expect(gh!.tokenExchangeUrl).toBe(
119
+ "https://github.com/login/oauth/access_token",
120
+ );
113
121
  expect(JSON.parse(gh!.defaultScopes)).toEqual(["repo", "user"]);
114
122
  expect(JSON.parse(gh!.scopePolicy)).toEqual({ required: ["repo"] });
115
123
 
116
124
  const goog = getProvider("google");
117
125
  expect(goog).toBeDefined();
118
- expect(goog!.providerKey).toBe("google");
119
- expect(JSON.parse(goog!.extraParams!)).toEqual({
126
+ expect(goog!.provider).toBe("google");
127
+ expect(JSON.parse(goog!.authorizeParams!)).toEqual({
120
128
  access_type: "offline",
121
129
  });
122
130
  });
@@ -124,9 +132,9 @@ describe("provider operations", () => {
124
132
  test("updates implementation fields while preserving user-customizable fields on re-seed", () => {
125
133
  seedProviders([
126
134
  {
127
- providerKey: "github",
128
- authUrl: "https://github.com/login/oauth/authorize",
129
- tokenUrl: "https://github.com/login/oauth/access_token",
135
+ provider: "github",
136
+ authorizeUrl: "https://github.com/login/oauth/authorize",
137
+ tokenExchangeUrl: "https://github.com/login/oauth/access_token",
130
138
  defaultScopes: ["repo"],
131
139
  scopePolicy: {},
132
140
  baseUrl: "https://api.github.com",
@@ -141,9 +149,9 @@ describe("provider operations", () => {
141
149
  // Re-seed with corrected values (simulates a code fix deployed on upgrade)
142
150
  seedProviders([
143
151
  {
144
- providerKey: "github",
145
- authUrl: "https://github.com/login/oauth/authorize-v2",
146
- tokenUrl: "https://github.com/login/oauth/access_token-v2",
152
+ provider: "github",
153
+ authorizeUrl: "https://github.com/login/oauth/authorize-v2",
154
+ tokenExchangeUrl: "https://github.com/login/oauth/access_token-v2",
147
155
  defaultScopes: ["repo", "user"],
148
156
  scopePolicy: { required: ["repo"] },
149
157
  baseUrl: "https://api.github.com/v2",
@@ -153,8 +161,10 @@ describe("provider operations", () => {
153
161
  const row = getProvider("github");
154
162
  expect(row).toBeDefined();
155
163
  // Implementation fields should be overwritten by the re-seed
156
- expect(row!.authUrl).toBe("https://github.com/login/oauth/authorize-v2");
157
- expect(row!.tokenUrl).toBe(
164
+ expect(row!.authorizeUrl).toBe(
165
+ "https://github.com/login/oauth/authorize-v2",
166
+ );
167
+ expect(row!.tokenExchangeUrl).toBe(
158
168
  "https://github.com/login/oauth/access_token-v2",
159
169
  );
160
170
  // User-customizable fields (baseUrl, defaultScopes, scopePolicy) are
@@ -166,172 +176,984 @@ describe("provider operations", () => {
166
176
  expect(row!.createdAt).toBe(originalCreatedAt);
167
177
  });
168
178
 
169
- test("persists pingUrl when provided", () => {
179
+ test("persists pingUrl when provided", () => {
180
+ seedProviders([
181
+ {
182
+ provider: "github",
183
+ authorizeUrl: "https://github.com/authorize",
184
+ tokenExchangeUrl: "https://github.com/token",
185
+ defaultScopes: ["repo"],
186
+ scopePolicy: {},
187
+ pingUrl: "https://api.github.com/user",
188
+ },
189
+ ]);
190
+ const row = getProvider("github");
191
+ expect(row!.pingUrl).toBe("https://api.github.com/user");
192
+ });
193
+
194
+ test("pingUrl defaults to null when omitted", () => {
195
+ seedProviders([
196
+ {
197
+ provider: "github",
198
+ authorizeUrl: "https://github.com/authorize",
199
+ tokenExchangeUrl: "https://github.com/token",
200
+ defaultScopes: ["repo"],
201
+ scopePolicy: {},
202
+ },
203
+ ]);
204
+ const row = getProvider("github");
205
+ expect(row!.pingUrl).toBeNull();
206
+ });
207
+
208
+ test("preserves user-customizable fields while overwriting implementation fields on re-seed", () => {
209
+ // Initial seed with all fields
210
+ seedProviders([
211
+ {
212
+ provider: "github",
213
+ authorizeUrl: "https://github.com/authorize",
214
+ tokenExchangeUrl: "https://github.com/token",
215
+ tokenEndpointAuthMethod: "client_secret_post",
216
+ defaultScopes: ["repo"],
217
+ scopePolicy: { required: ["repo"] },
218
+ userinfoUrl: "https://api.github.com/user",
219
+ baseUrl: "https://api.github.com",
220
+ authorizeParams: { prompt: "consent" },
221
+
222
+ pingUrl: "https://api.github.com/user",
223
+ },
224
+ ]);
225
+
226
+ // Manually update user-customizable fields to simulate user edits
227
+ const db = getDb();
228
+ db.update(oauthProviders)
229
+ .set({
230
+ defaultScopes: JSON.stringify(["repo", "user", "gist"]),
231
+ scopePolicy: JSON.stringify({
232
+ required: ["repo"],
233
+ allowAdditionalScopes: true,
234
+ }),
235
+ baseUrl: "https://custom.github.com/api",
236
+ })
237
+ .where(eq(oauthProviders.provider, "github"))
238
+ .run();
239
+
240
+ // Verify the manual updates took effect
241
+ const beforeReseed = getProvider("github");
242
+ expect(JSON.parse(beforeReseed!.defaultScopes)).toEqual([
243
+ "repo",
244
+ "user",
245
+ "gist",
246
+ ]);
247
+ expect(beforeReseed!.baseUrl).toBe("https://custom.github.com/api");
248
+
249
+ // Re-seed with updated implementation fields
250
+ seedProviders([
251
+ {
252
+ provider: "github",
253
+ authorizeUrl: "https://github.com/authorize-v2",
254
+ tokenExchangeUrl: "https://github.com/token-v2",
255
+ tokenEndpointAuthMethod: "client_secret_basic",
256
+ defaultScopes: ["repo-only"],
257
+ scopePolicy: {},
258
+ userinfoUrl: "https://api.github.com/user-v2",
259
+ baseUrl: "https://api.github.com/v2",
260
+ authorizeParams: { prompt: "login" },
261
+
262
+ pingUrl: "https://api.github.com/user-v2",
263
+ },
264
+ ]);
265
+
266
+ const row = getProvider("github");
267
+ expect(row).toBeDefined();
268
+
269
+ // User-customizable fields should retain their manual values
270
+ expect(JSON.parse(row!.defaultScopes)).toEqual(["repo", "user", "gist"]);
271
+ expect(JSON.parse(row!.scopePolicy)).toEqual({
272
+ required: ["repo"],
273
+ allowAdditionalScopes: true,
274
+ });
275
+ expect(row!.baseUrl).toBe("https://custom.github.com/api");
276
+
277
+ // Implementation fields should be overwritten from the seed data
278
+ expect(row!.authorizeUrl).toBe("https://github.com/authorize-v2");
279
+ expect(row!.tokenExchangeUrl).toBe("https://github.com/token-v2");
280
+ expect(row!.tokenEndpointAuthMethod).toBe("client_secret_basic");
281
+ expect(row!.userinfoUrl).toBe("https://api.github.com/user-v2");
282
+ expect(JSON.parse(row!.authorizeParams!)).toEqual({ prompt: "login" });
283
+ expect(row!.pingUrl).toBe("https://api.github.com/user-v2");
284
+ });
285
+
286
+ test("persists custom scopeSeparator when provided", () => {
287
+ seedProviders([
288
+ {
289
+ provider: "test-provider",
290
+ authorizeUrl: "https://example.com/authorize",
291
+ tokenExchangeUrl: "https://example.com/token",
292
+ defaultScopes: ["read", "write"],
293
+ scopePolicy: {},
294
+ scopeSeparator: ",",
295
+ },
296
+ ]);
297
+
298
+ const row = getProvider("test-provider");
299
+ expect(row).toBeDefined();
300
+ expect(row!.scopeSeparator).toBe(",");
301
+ });
302
+
303
+ test("scopeSeparator defaults to ' ' when omitted", () => {
304
+ seedProviders([
305
+ {
306
+ provider: "github",
307
+ authorizeUrl: "https://github.com/authorize",
308
+ tokenExchangeUrl: "https://github.com/token",
309
+ defaultScopes: ["repo"],
310
+ scopePolicy: {},
311
+ },
312
+ ]);
313
+
314
+ const row = getProvider("github");
315
+ expect(row).toBeDefined();
316
+ expect(row!.scopeSeparator).toBe(" ");
317
+ });
318
+
319
+ test("re-seeding with a changed scopeSeparator overwrites the stored value", () => {
320
+ seedProviders([
321
+ {
322
+ provider: "linear",
323
+ authorizeUrl: "https://linear.app/oauth/authorize",
324
+ tokenExchangeUrl: "https://api.linear.app/oauth/token",
325
+ defaultScopes: ["read"],
326
+ scopePolicy: {},
327
+ scopeSeparator: " ",
328
+ },
329
+ ]);
330
+
331
+ const first = getProvider("linear");
332
+ expect(first!.scopeSeparator).toBe(" ");
333
+
334
+ // Re-seed with a different separator — it should be overwritten,
335
+ // proving scopeSeparator is in the onConflictDoUpdate set clause.
336
+ seedProviders([
337
+ {
338
+ provider: "linear",
339
+ authorizeUrl: "https://linear.app/oauth/authorize",
340
+ tokenExchangeUrl: "https://api.linear.app/oauth/token",
341
+ defaultScopes: ["read"],
342
+ scopePolicy: {},
343
+ scopeSeparator: ",",
344
+ },
345
+ ]);
346
+
347
+ const row = getProvider("linear");
348
+ expect(row!.scopeSeparator).toBe(",");
349
+ });
350
+
351
+ test("persists refreshUrl when provided", () => {
352
+ seedProviders([
353
+ {
354
+ provider: "test-provider",
355
+ authorizeUrl: "https://example.com/authorize",
356
+ tokenExchangeUrl: "https://example.com/token",
357
+ refreshUrl: "https://refresh.example.com/token",
358
+ defaultScopes: ["read"],
359
+ scopePolicy: {},
360
+ },
361
+ ]);
362
+
363
+ const row = getProvider("test-provider");
364
+ expect(row).toBeDefined();
365
+ expect(row!.refreshUrl).toBe("https://refresh.example.com/token");
366
+ });
367
+
368
+ test("refreshUrl defaults to null when omitted", () => {
369
+ seedProviders([
370
+ {
371
+ provider: "test-provider",
372
+ authorizeUrl: "https://example.com/authorize",
373
+ tokenExchangeUrl: "https://example.com/token",
374
+ defaultScopes: ["read"],
375
+ scopePolicy: {},
376
+ },
377
+ ]);
378
+
379
+ const row = getProvider("test-provider");
380
+ expect(row).toBeDefined();
381
+ expect(row!.refreshUrl).toBeNull();
382
+ });
383
+
384
+ test("re-seeding with a changed refreshUrl overwrites the stored value", () => {
385
+ seedProviders([
386
+ {
387
+ provider: "test-provider",
388
+ authorizeUrl: "https://example.com/authorize",
389
+ tokenExchangeUrl: "https://example.com/token",
390
+ refreshUrl: "https://refresh.example.com/token",
391
+ defaultScopes: ["read"],
392
+ scopePolicy: {},
393
+ },
394
+ ]);
395
+
396
+ const first = getProvider("test-provider");
397
+ expect(first!.refreshUrl).toBe("https://refresh.example.com/token");
398
+
399
+ // Re-seed with a different refreshUrl — it should be overwritten,
400
+ // proving refreshUrl is in the onConflictDoUpdate set clause (not
401
+ // preserved like defaultScopes).
402
+ seedProviders([
403
+ {
404
+ provider: "test-provider",
405
+ authorizeUrl: "https://example.com/authorize",
406
+ tokenExchangeUrl: "https://example.com/token",
407
+ refreshUrl: "https://refresh-v2.example.com/token",
408
+ defaultScopes: ["read"],
409
+ scopePolicy: {},
410
+ },
411
+ ]);
412
+
413
+ const row = getProvider("test-provider");
414
+ expect(row!.refreshUrl).toBe("https://refresh-v2.example.com/token");
415
+ });
416
+
417
+ test("persists revokeUrl and revokeBodyTemplate when provided", () => {
418
+ seedProviders([
419
+ {
420
+ provider: "test-provider",
421
+ authorizeUrl: "https://example.com/authorize",
422
+ tokenExchangeUrl: "https://example.com/token",
423
+ revokeUrl: "https://revoke.example.com",
424
+ revokeBodyTemplate: { token: "{access_token}" },
425
+ defaultScopes: ["read"],
426
+ scopePolicy: {},
427
+ },
428
+ ]);
429
+
430
+ const row = getProvider("test-provider");
431
+ expect(row).toBeDefined();
432
+ expect(row!.revokeUrl).toBe("https://revoke.example.com");
433
+ expect(JSON.parse(row!.revokeBodyTemplate!)).toEqual({
434
+ token: "{access_token}",
435
+ });
436
+ });
437
+
438
+ test("revokeUrl and revokeBodyTemplate default to null when omitted", () => {
439
+ seedProviders([
440
+ {
441
+ provider: "test-provider",
442
+ authorizeUrl: "https://example.com/authorize",
443
+ tokenExchangeUrl: "https://example.com/token",
444
+ defaultScopes: ["read"],
445
+ scopePolicy: {},
446
+ },
447
+ ]);
448
+
449
+ const row = getProvider("test-provider");
450
+ expect(row).toBeDefined();
451
+ expect(row!.revokeUrl).toBeNull();
452
+ expect(row!.revokeBodyTemplate).toBeNull();
453
+ });
454
+
455
+ test("writes logoUrl on insert", () => {
456
+ seedProviders([
457
+ {
458
+ provider: "google",
459
+ authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
460
+ tokenExchangeUrl: "https://oauth2.googleapis.com/token",
461
+ defaultScopes: ["openid"],
462
+ scopePolicy: {},
463
+ logoUrl: "https://cdn.simpleicons.org/google",
464
+ },
465
+ ]);
466
+
467
+ const row = getProvider("google");
468
+ expect(row).toBeDefined();
469
+ expect(row!.logoUrl).toBe("https://cdn.simpleicons.org/google");
470
+ });
471
+
472
+ test("overwrites logoUrl on conflict", () => {
473
+ seedProviders([
474
+ {
475
+ provider: "google",
476
+ authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
477
+ tokenExchangeUrl: "https://oauth2.googleapis.com/token",
478
+ defaultScopes: ["openid"],
479
+ scopePolicy: {},
480
+ logoUrl: "https://cdn.simpleicons.org/google",
481
+ },
482
+ ]);
483
+
484
+ expect(getProvider("google")!.logoUrl).toBe(
485
+ "https://cdn.simpleicons.org/google",
486
+ );
487
+
488
+ // Re-seed with a different logoUrl — it should be overwritten,
489
+ // proving logoUrl is in the onConflictDoUpdate set clause alongside
490
+ // the other display-metadata fields.
491
+ seedProviders([
492
+ {
493
+ provider: "google",
494
+ authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
495
+ tokenExchangeUrl: "https://oauth2.googleapis.com/token",
496
+ defaultScopes: ["openid"],
497
+ scopePolicy: {},
498
+ logoUrl: "https://cdn.simpleicons.org/google-v2",
499
+ },
500
+ ]);
501
+
502
+ const row = getProvider("google");
503
+ expect(row!.logoUrl).toBe("https://cdn.simpleicons.org/google-v2");
504
+ });
505
+
506
+ test("re-seeding with a changed revokeUrl overwrites the stored value", () => {
507
+ seedProviders([
508
+ {
509
+ provider: "test-provider",
510
+ authorizeUrl: "https://example.com/authorize",
511
+ tokenExchangeUrl: "https://example.com/token",
512
+ revokeUrl: "https://revoke.example.com",
513
+ defaultScopes: ["read"],
514
+ scopePolicy: {},
515
+ },
516
+ ]);
517
+
518
+ const first = getProvider("test-provider");
519
+ expect(first!.revokeUrl).toBe("https://revoke.example.com");
520
+
521
+ // Re-seed with a different revokeUrl — it should be overwritten,
522
+ // proving revokeUrl is in the onConflictDoUpdate set clause (not
523
+ // preserved like defaultScopes).
524
+ seedProviders([
525
+ {
526
+ provider: "test-provider",
527
+ authorizeUrl: "https://example.com/authorize",
528
+ tokenExchangeUrl: "https://example.com/token",
529
+ revokeUrl: "https://revoke-v2.example.com",
530
+ defaultScopes: ["read"],
531
+ scopePolicy: {},
532
+ },
533
+ ]);
534
+
535
+ const row = getProvider("test-provider");
536
+ expect(row!.revokeUrl).toBe("https://revoke-v2.example.com");
537
+ });
538
+
539
+ test("seedOAuthProviders seeds Google, Twitter, and Linear with revoke config and leaves other providers null", () => {
540
+ seedOAuthProviders();
541
+
542
+ const google = getProvider("google");
543
+ expect(google).toBeDefined();
544
+ expect(google!.revokeUrl).toBe("https://oauth2.googleapis.com/revoke");
545
+ expect(JSON.parse(google!.revokeBodyTemplate!)).toEqual({
546
+ token: "{access_token}",
547
+ });
548
+
549
+ const twitter = getProvider("twitter");
550
+ expect(twitter).toBeDefined();
551
+ expect(twitter!.revokeUrl).toBe("https://api.x.com/2/oauth2/revoke");
552
+ expect(JSON.parse(twitter!.revokeBodyTemplate!)).toEqual({
553
+ token: "{access_token}",
554
+ token_type_hint: "access_token",
555
+ client_id: "{client_id}",
556
+ });
557
+
558
+ const linear = getProvider("linear");
559
+ expect(linear).toBeDefined();
560
+ expect(linear!.revokeUrl).toBe("https://api.linear.app/oauth/revoke");
561
+ expect(JSON.parse(linear!.revokeBodyTemplate!)).toEqual({
562
+ token: "{access_token}",
563
+ });
564
+
565
+ const slack = getProvider("slack");
566
+ expect(slack).toBeDefined();
567
+ expect(slack!.revokeUrl).toBeNull();
568
+ expect(slack!.revokeBodyTemplate).toBeNull();
569
+
570
+ const github = getProvider("github");
571
+ expect(github).toBeDefined();
572
+ expect(github!.revokeUrl).toBeNull();
573
+
574
+ const outlook = getProvider("outlook");
575
+ expect(outlook).toBeDefined();
576
+ expect(outlook!.revokeUrl).toBeNull();
577
+ });
578
+
579
+ test("applies client_secret_post default when tokenEndpointAuthMethod is omitted from seed", () => {
580
+ seedProviders([
581
+ {
582
+ provider: "no-auth-method-provider",
583
+ authorizeUrl: "https://example.com/authorize",
584
+ tokenExchangeUrl: "https://example.com/token",
585
+ defaultScopes: [],
586
+ scopePolicy: {},
587
+ // Note: tokenEndpointAuthMethod intentionally omitted
588
+ },
589
+ ]);
590
+ const row = getProvider("no-auth-method-provider");
591
+ expect(row).toBeDefined();
592
+ expect(row!.tokenEndpointAuthMethod).toBe("client_secret_post");
593
+ });
594
+
595
+ test("migration 216 backfills NULL token_endpoint_auth_method to client_secret_post", () => {
596
+ // Use raw SQLite to bypass Drizzle's NOT NULL enforcement and insert
597
+ // a legacy-shaped row with NULL token_endpoint_auth_method.
598
+ const db = getDb();
599
+ const raw = getSqliteFrom(db);
600
+ raw.exec(`
601
+ INSERT INTO oauth_providers (
602
+ provider_key, auth_url, token_url, token_endpoint_auth_method,
603
+ default_scopes, scope_policy, scope_separator, requires_client_secret,
604
+ created_at, updated_at
605
+ ) VALUES (
606
+ 'legacy-null-provider',
607
+ 'https://example.com/authorize',
608
+ 'https://example.com/token',
609
+ NULL,
610
+ '[]',
611
+ '{}',
612
+ ' ',
613
+ 1,
614
+ ${Date.now()},
615
+ ${Date.now()}
616
+ )
617
+ `);
618
+
619
+ // Run the migration directly
620
+ migrateOAuthProvidersTokenAuthMethodDefault(db);
621
+
622
+ // Verify the row was backfilled
623
+ const row = raw
624
+ .prepare(
625
+ `SELECT token_endpoint_auth_method FROM oauth_providers WHERE provider_key = 'legacy-null-provider'`,
626
+ )
627
+ .get() as { token_endpoint_auth_method: string };
628
+ expect(row.token_endpoint_auth_method).toBe("client_secret_post");
629
+ });
630
+
631
+ test("migration 216 is idempotent — running twice on backfilled rows is a no-op", () => {
632
+ seedProviders([
633
+ {
634
+ provider: "already-set-provider",
635
+ authorizeUrl: "https://example.com/authorize",
636
+ tokenExchangeUrl: "https://example.com/token",
637
+ tokenEndpointAuthMethod: "client_secret_basic",
638
+ defaultScopes: [],
639
+ scopePolicy: {},
640
+ },
641
+ ]);
642
+
643
+ const db = getDb();
644
+ migrateOAuthProvidersTokenAuthMethodDefault(db);
645
+ migrateOAuthProvidersTokenAuthMethodDefault(db);
646
+
647
+ const row = getProvider("already-set-provider");
648
+ expect(row!.tokenEndpointAuthMethod).toBe("client_secret_basic");
649
+ });
650
+ });
651
+
652
+ describe("getProvider", () => {
653
+ test("returns the correct row", () => {
654
+ seedProviders([
655
+ {
656
+ provider: "github",
657
+ authorizeUrl: "https://github.com/authorize",
658
+ tokenExchangeUrl: "https://github.com/token",
659
+ defaultScopes: ["repo"],
660
+ scopePolicy: {},
661
+ },
662
+ ]);
663
+
664
+ const row = getProvider("github");
665
+ expect(row).toBeDefined();
666
+ expect(row!.provider).toBe("github");
667
+ });
668
+
669
+ test("returns undefined for unknown keys", () => {
670
+ expect(getProvider("nonexistent")).toBeUndefined();
671
+ });
672
+ });
673
+
674
+ describe("registerProvider", () => {
675
+ test("creates a new row", () => {
676
+ const row = registerProvider({
677
+ provider: "linear",
678
+ authorizeUrl: "https://linear.app/oauth/authorize",
679
+ tokenExchangeUrl: "https://api.linear.app/oauth/token",
680
+ defaultScopes: ["read"],
681
+ scopePolicy: {},
682
+ });
683
+
684
+ expect(row.provider).toBe("linear");
685
+ expect(row.authorizeUrl).toBe("https://linear.app/oauth/authorize");
686
+
687
+ const fetched = getProvider("linear");
688
+ expect(fetched).toBeDefined();
689
+ expect(fetched!.provider).toBe("linear");
690
+ });
691
+
692
+ test("throws for duplicate provider_key", () => {
693
+ registerProvider({
694
+ provider: "linear",
695
+ authorizeUrl: "https://linear.app/oauth/authorize",
696
+ tokenExchangeUrl: "https://api.linear.app/oauth/token",
697
+ defaultScopes: ["read"],
698
+ scopePolicy: {},
699
+ });
700
+
701
+ expect(() =>
702
+ registerProvider({
703
+ provider: "linear",
704
+ authorizeUrl: "https://linear.app/oauth/authorize",
705
+ tokenExchangeUrl: "https://api.linear.app/oauth/token",
706
+ defaultScopes: ["read"],
707
+ scopePolicy: {},
708
+ }),
709
+ ).toThrow(/already exists.*linear/);
710
+ });
711
+
712
+ test("persists scopeSeparator and round-trips via getProvider", () => {
713
+ registerProvider({
714
+ provider: "linear",
715
+ authorizeUrl: "https://linear.app/oauth/authorize",
716
+ tokenExchangeUrl: "https://api.linear.app/oauth/token",
717
+ defaultScopes: ["read"],
718
+ scopePolicy: {},
719
+ scopeSeparator: ";",
720
+ });
721
+
722
+ const fetched = getProvider("linear");
723
+ expect(fetched).toBeDefined();
724
+ expect(fetched!.scopeSeparator).toBe(";");
725
+ });
726
+
727
+ test("scopeSeparator defaults to ' ' when omitted", () => {
728
+ registerProvider({
729
+ provider: "linear",
730
+ authorizeUrl: "https://linear.app/oauth/authorize",
731
+ tokenExchangeUrl: "https://api.linear.app/oauth/token",
732
+ defaultScopes: ["read"],
733
+ scopePolicy: {},
734
+ });
735
+
736
+ const fetched = getProvider("linear");
737
+ expect(fetched).toBeDefined();
738
+ expect(fetched!.scopeSeparator).toBe(" ");
739
+ });
740
+
741
+ test("persists refreshUrl and round-trips via getProvider", () => {
742
+ registerProvider({
743
+ provider: "linear",
744
+ authorizeUrl: "https://linear.app/oauth/authorize",
745
+ tokenExchangeUrl: "https://api.linear.app/oauth/token",
746
+ refreshUrl: "https://api.linear.app/oauth/refresh",
747
+ defaultScopes: ["read"],
748
+ scopePolicy: {},
749
+ });
750
+
751
+ const fetched = getProvider("linear");
752
+ expect(fetched).toBeDefined();
753
+ expect(fetched!.refreshUrl).toBe("https://api.linear.app/oauth/refresh");
754
+ });
755
+
756
+ test("refreshUrl defaults to null when omitted", () => {
757
+ registerProvider({
758
+ provider: "linear",
759
+ authorizeUrl: "https://linear.app/oauth/authorize",
760
+ tokenExchangeUrl: "https://api.linear.app/oauth/token",
761
+ defaultScopes: ["read"],
762
+ scopePolicy: {},
763
+ });
764
+
765
+ const fetched = getProvider("linear");
766
+ expect(fetched).toBeDefined();
767
+ expect(fetched!.refreshUrl).toBeNull();
768
+ });
769
+
770
+ test("persists revokeUrl and revokeBodyTemplate and round-trips via getProvider", () => {
771
+ registerProvider({
772
+ provider: "linear",
773
+ authorizeUrl: "https://linear.app/oauth/authorize",
774
+ tokenExchangeUrl: "https://api.linear.app/oauth/token",
775
+ revokeUrl: "https://api.linear.app/oauth/revoke",
776
+ revokeBodyTemplate: { token: "{access_token}" },
777
+ defaultScopes: ["read"],
778
+ scopePolicy: {},
779
+ });
780
+
781
+ const fetched = getProvider("linear");
782
+ expect(fetched).toBeDefined();
783
+ expect(fetched!.revokeUrl).toBe("https://api.linear.app/oauth/revoke");
784
+ expect(JSON.parse(fetched!.revokeBodyTemplate!)).toEqual({
785
+ token: "{access_token}",
786
+ });
787
+ });
788
+
789
+ test("applies client_secret_post default when tokenEndpointAuthMethod is omitted", () => {
790
+ const row = registerProvider({
791
+ provider: "custom-default-test",
792
+ authorizeUrl: "https://example.com/authorize",
793
+ tokenExchangeUrl: "https://example.com/token",
794
+ defaultScopes: [],
795
+ scopePolicy: {},
796
+ // Note: tokenEndpointAuthMethod intentionally omitted
797
+ });
798
+ expect(row.tokenEndpointAuthMethod).toBe("client_secret_post");
799
+
800
+ const fetched = getProvider("custom-default-test");
801
+ expect(fetched!.tokenEndpointAuthMethod).toBe("client_secret_post");
802
+ });
803
+
804
+ test("preserves explicit client_secret_basic when registering a provider", () => {
805
+ const row = registerProvider({
806
+ provider: "custom-basic-test",
807
+ authorizeUrl: "https://example.com/authorize",
808
+ tokenExchangeUrl: "https://example.com/token",
809
+ defaultScopes: [],
810
+ scopePolicy: {},
811
+ tokenEndpointAuthMethod: "client_secret_basic",
812
+ });
813
+ expect(row.tokenEndpointAuthMethod).toBe("client_secret_basic");
814
+ });
815
+
816
+ test("stores logoUrl when provided", () => {
817
+ registerProvider({
818
+ provider: "notion",
819
+ authorizeUrl: "https://api.notion.com/v1/oauth/authorize",
820
+ tokenExchangeUrl: "https://api.notion.com/v1/oauth/token",
821
+ defaultScopes: ["read"],
822
+ scopePolicy: {},
823
+ logoUrl: "https://cdn.simpleicons.org/notion",
824
+ });
825
+
826
+ const fetched = getProvider("notion");
827
+ expect(fetched).toBeDefined();
828
+ expect(fetched!.logoUrl).toBe("https://cdn.simpleicons.org/notion");
829
+ });
830
+
831
+ test("defaults logoUrl to null when omitted", () => {
832
+ registerProvider({
833
+ provider: "linear",
834
+ authorizeUrl: "https://linear.app/oauth/authorize",
835
+ tokenExchangeUrl: "https://api.linear.app/oauth/token",
836
+ defaultScopes: ["read"],
837
+ scopePolicy: {},
838
+ });
839
+
840
+ const fetched = getProvider("linear");
841
+ expect(fetched).toBeDefined();
842
+ expect(fetched!.logoUrl).toBeNull();
843
+ });
844
+ });
845
+
846
+ describe("updateProvider", () => {
847
+ test("updates scopeSeparator on an existing row", () => {
848
+ seedProviders([
849
+ {
850
+ provider: "github",
851
+ authorizeUrl: "https://github.com/authorize",
852
+ tokenExchangeUrl: "https://github.com/token",
853
+ defaultScopes: ["repo"],
854
+ scopePolicy: {},
855
+ },
856
+ ]);
857
+
858
+ const before = getProvider("github");
859
+ expect(before!.scopeSeparator).toBe(" ");
860
+
861
+ const updated = updateProvider("github", { scopeSeparator: "," });
862
+ expect(updated).toBeDefined();
863
+ expect(updated!.scopeSeparator).toBe(",");
864
+
865
+ const fetched = getProvider("github");
866
+ expect(fetched!.scopeSeparator).toBe(",");
867
+ });
868
+
869
+ test("coerces empty-string scopeSeparator to default ' '", () => {
870
+ // An empty separator would join scopes into a single concatenated token
871
+ // (e.g. ["read","write"].join("") === "readwrite") which is never a
872
+ // valid OAuth authorize URL value. Coerce to the default.
873
+ seedProviders([
874
+ {
875
+ provider: "github",
876
+ authorizeUrl: "https://github.com/authorize",
877
+ tokenExchangeUrl: "https://github.com/token",
878
+ defaultScopes: ["repo"],
879
+ scopePolicy: {},
880
+ scopeSeparator: ",",
881
+ },
882
+ ]);
883
+
884
+ expect(getProvider("github")!.scopeSeparator).toBe(",");
885
+
886
+ const updated = updateProvider("github", { scopeSeparator: "" });
887
+ expect(updated).toBeDefined();
888
+ expect(updated!.scopeSeparator).toBe(" ");
889
+ expect(getProvider("github")!.scopeSeparator).toBe(" ");
890
+ });
891
+
892
+ test("sets refreshUrl on an existing row where it was previously null", () => {
893
+ seedProviders([
894
+ {
895
+ provider: "github",
896
+ authorizeUrl: "https://github.com/authorize",
897
+ tokenExchangeUrl: "https://github.com/token",
898
+ defaultScopes: ["repo"],
899
+ scopePolicy: {},
900
+ },
901
+ ]);
902
+
903
+ const before = getProvider("github");
904
+ expect(before!.refreshUrl).toBeNull();
905
+
906
+ const updated = updateProvider("github", {
907
+ refreshUrl: "https://github.com/login/oauth/refresh",
908
+ });
909
+ expect(updated).toBeDefined();
910
+ expect(updated!.refreshUrl).toBe(
911
+ "https://github.com/login/oauth/refresh",
912
+ );
913
+
914
+ const fetched = getProvider("github");
915
+ expect(fetched!.refreshUrl).toBe(
916
+ "https://github.com/login/oauth/refresh",
917
+ );
918
+ });
919
+
920
+ test("leaves refreshUrl unchanged when not passed to updateProvider", () => {
921
+ seedProviders([
922
+ {
923
+ provider: "github",
924
+ authorizeUrl: "https://github.com/authorize",
925
+ tokenExchangeUrl: "https://github.com/token",
926
+ refreshUrl: "https://github.com/login/oauth/refresh",
927
+ defaultScopes: ["repo"],
928
+ scopePolicy: {},
929
+ },
930
+ ]);
931
+
932
+ expect(getProvider("github")!.refreshUrl).toBe(
933
+ "https://github.com/login/oauth/refresh",
934
+ );
935
+
936
+ // Update a different field — refreshUrl should be left alone.
937
+ const updated = updateProvider("github", {
938
+ displayLabel: "GitHub (updated)",
939
+ });
940
+ expect(updated).toBeDefined();
941
+ expect(updated!.refreshUrl).toBe(
942
+ "https://github.com/login/oauth/refresh",
943
+ );
944
+ expect(updated!.displayLabel).toBe("GitHub (updated)");
945
+ });
946
+
947
+ test("sets revokeUrl on an existing row where it was previously null", () => {
170
948
  seedProviders([
171
949
  {
172
- providerKey: "github",
173
- authUrl: "https://github.com/authorize",
174
- tokenUrl: "https://github.com/token",
950
+ provider: "github",
951
+ authorizeUrl: "https://github.com/authorize",
952
+ tokenExchangeUrl: "https://github.com/token",
175
953
  defaultScopes: ["repo"],
176
954
  scopePolicy: {},
177
- pingUrl: "https://api.github.com/user",
178
955
  },
179
956
  ]);
180
- const row = getProvider("github");
181
- expect(row!.pingUrl).toBe("https://api.github.com/user");
957
+
958
+ const before = getProvider("github");
959
+ expect(before!.revokeUrl).toBeNull();
960
+
961
+ const updated = updateProvider("github", {
962
+ revokeUrl: "https://github.com/login/oauth/revoke",
963
+ });
964
+ expect(updated).toBeDefined();
965
+ expect(updated!.revokeUrl).toBe("https://github.com/login/oauth/revoke");
966
+
967
+ const fetched = getProvider("github");
968
+ expect(fetched!.revokeUrl).toBe("https://github.com/login/oauth/revoke");
182
969
  });
183
970
 
184
- test("pingUrl defaults to null when omitted", () => {
971
+ test("sets revokeBodyTemplate on an existing row and JSON round-trips", () => {
185
972
  seedProviders([
186
973
  {
187
- providerKey: "github",
188
- authUrl: "https://github.com/authorize",
189
- tokenUrl: "https://github.com/token",
974
+ provider: "github",
975
+ authorizeUrl: "https://github.com/authorize",
976
+ tokenExchangeUrl: "https://github.com/token",
190
977
  defaultScopes: ["repo"],
191
978
  scopePolicy: {},
192
979
  },
193
980
  ]);
194
- const row = getProvider("github");
195
- expect(row!.pingUrl).toBeNull();
981
+
982
+ const before = getProvider("github");
983
+ expect(before!.revokeBodyTemplate).toBeNull();
984
+
985
+ const updated = updateProvider("github", {
986
+ revokeBodyTemplate: {
987
+ token: "{access_token}",
988
+ client_id: "{client_id}",
989
+ },
990
+ });
991
+ expect(updated).toBeDefined();
992
+ expect(JSON.parse(updated!.revokeBodyTemplate!)).toEqual({
993
+ token: "{access_token}",
994
+ client_id: "{client_id}",
995
+ });
996
+
997
+ const fetched = getProvider("github");
998
+ expect(JSON.parse(fetched!.revokeBodyTemplate!)).toEqual({
999
+ token: "{access_token}",
1000
+ client_id: "{client_id}",
1001
+ });
196
1002
  });
197
1003
 
198
- test("preserves user-customizable fields while overwriting implementation fields on re-seed", () => {
199
- // Initial seed with all fields
1004
+ test("leaves revokeUrl and revokeBodyTemplate unchanged when not passed to updateProvider", () => {
200
1005
  seedProviders([
201
1006
  {
202
- providerKey: "github",
203
- authUrl: "https://github.com/authorize",
204
- tokenUrl: "https://github.com/token",
205
- tokenEndpointAuthMethod: "client_secret_post",
1007
+ provider: "github",
1008
+ authorizeUrl: "https://github.com/authorize",
1009
+ tokenExchangeUrl: "https://github.com/token",
1010
+ revokeUrl: "https://github.com/login/oauth/revoke",
1011
+ revokeBodyTemplate: { token: "{access_token}" },
206
1012
  defaultScopes: ["repo"],
207
- scopePolicy: { required: ["repo"] },
208
- userinfoUrl: "https://api.github.com/user",
209
- baseUrl: "https://api.github.com",
210
- extraParams: { prompt: "consent" },
211
-
212
- pingUrl: "https://api.github.com/user",
1013
+ scopePolicy: {},
213
1014
  },
214
1015
  ]);
215
1016
 
216
- // Manually update user-customizable fields to simulate user edits
217
- const db = getDb();
218
- db.update(oauthProviders)
219
- .set({
220
- defaultScopes: JSON.stringify(["repo", "user", "gist"]),
221
- scopePolicy: JSON.stringify({
222
- required: ["repo"],
223
- allowAdditionalScopes: true,
224
- }),
225
- baseUrl: "https://custom.github.com/api",
226
- })
227
- .where(eq(oauthProviders.providerKey, "github"))
228
- .run();
1017
+ expect(getProvider("github")!.revokeUrl).toBe(
1018
+ "https://github.com/login/oauth/revoke",
1019
+ );
1020
+ expect(JSON.parse(getProvider("github")!.revokeBodyTemplate!)).toEqual({
1021
+ token: "{access_token}",
1022
+ });
229
1023
 
230
- // Verify the manual updates took effect
231
- const beforeReseed = getProvider("github");
232
- expect(JSON.parse(beforeReseed!.defaultScopes)).toEqual([
233
- "repo",
234
- "user",
235
- "gist",
236
- ]);
237
- expect(beforeReseed!.baseUrl).toBe("https://custom.github.com/api");
1024
+ // Update a different field revoke fields should be left alone.
1025
+ const updated = updateProvider("github", {
1026
+ displayLabel: "GitHub (updated)",
1027
+ });
1028
+ expect(updated).toBeDefined();
1029
+ expect(updated!.revokeUrl).toBe("https://github.com/login/oauth/revoke");
1030
+ expect(JSON.parse(updated!.revokeBodyTemplate!)).toEqual({
1031
+ token: "{access_token}",
1032
+ });
1033
+ expect(updated!.displayLabel).toBe("GitHub (updated)");
1034
+ });
238
1035
 
239
- // Re-seed with updated implementation fields
1036
+ test("coerces empty string tokenEndpointAuthMethod to client_secret_post", () => {
240
1037
  seedProviders([
241
1038
  {
242
- providerKey: "github",
243
- authUrl: "https://github.com/authorize-v2",
244
- tokenUrl: "https://github.com/token-v2",
1039
+ provider: "update-empty-test",
1040
+ authorizeUrl: "https://example.com/authorize",
1041
+ tokenExchangeUrl: "https://example.com/token",
245
1042
  tokenEndpointAuthMethod: "client_secret_basic",
246
- defaultScopes: ["repo-only"],
1043
+ defaultScopes: [],
247
1044
  scopePolicy: {},
248
- userinfoUrl: "https://api.github.com/user-v2",
249
- baseUrl: "https://api.github.com/v2",
250
- extraParams: { prompt: "login" },
251
-
252
- pingUrl: "https://api.github.com/user-v2",
253
1045
  },
254
1046
  ]);
255
1047
 
256
- const row = getProvider("github");
257
- expect(row).toBeDefined();
1048
+ expect(getProvider("update-empty-test")!.tokenEndpointAuthMethod).toBe(
1049
+ "client_secret_basic",
1050
+ );
258
1051
 
259
- // User-customizable fields should retain their manual values
260
- expect(JSON.parse(row!.defaultScopes)).toEqual(["repo", "user", "gist"]);
261
- expect(JSON.parse(row!.scopePolicy)).toEqual({
262
- required: ["repo"],
263
- allowAdditionalScopes: true,
1052
+ const updated = updateProvider("update-empty-test", {
1053
+ tokenEndpointAuthMethod: "",
264
1054
  });
265
- expect(row!.baseUrl).toBe("https://custom.github.com/api");
1055
+ expect(updated).toBeDefined();
1056
+ expect(updated!.tokenEndpointAuthMethod).toBe("client_secret_post");
266
1057
 
267
- // Implementation fields should be overwritten from the seed data
268
- expect(row!.authUrl).toBe("https://github.com/authorize-v2");
269
- expect(row!.tokenUrl).toBe("https://github.com/token-v2");
270
- expect(row!.tokenEndpointAuthMethod).toBe("client_secret_basic");
271
- expect(row!.userinfoUrl).toBe("https://api.github.com/user-v2");
272
- expect(JSON.parse(row!.extraParams!)).toEqual({ prompt: "login" });
273
- expect(row!.pingUrl).toBe("https://api.github.com/user-v2");
1058
+ const row = getProvider("update-empty-test");
1059
+ expect(row!.tokenEndpointAuthMethod).toBe("client_secret_post");
274
1060
  });
275
- });
276
1061
 
277
- describe("getProvider", () => {
278
- test("returns the correct row", () => {
279
- seedProviders([
280
- {
281
- providerKey: "github",
282
- authUrl: "https://github.com/authorize",
283
- tokenUrl: "https://github.com/token",
284
- defaultScopes: ["repo"],
285
- scopePolicy: {},
286
- },
287
- ]);
1062
+ test("sets logoUrl on an existing row where it was previously null", () => {
1063
+ registerProvider({
1064
+ provider: "linear",
1065
+ authorizeUrl: "https://linear.app/oauth/authorize",
1066
+ tokenExchangeUrl: "https://api.linear.app/oauth/token",
1067
+ defaultScopes: ["read"],
1068
+ scopePolicy: {},
1069
+ });
288
1070
 
289
- const row = getProvider("github");
290
- expect(row).toBeDefined();
291
- expect(row!.providerKey).toBe("github");
292
- });
1071
+ expect(getProvider("linear")!.logoUrl).toBeNull();
293
1072
 
294
- test("returns undefined for unknown keys", () => {
295
- expect(getProvider("nonexistent")).toBeUndefined();
1073
+ const updated = updateProvider("linear", {
1074
+ logoUrl: "https://cdn.simpleicons.org/linear",
1075
+ });
1076
+ expect(updated).toBeDefined();
1077
+ expect(updated!.logoUrl).toBe("https://cdn.simpleicons.org/linear");
1078
+
1079
+ const fetched = getProvider("linear");
1080
+ expect(fetched!.logoUrl).toBe("https://cdn.simpleicons.org/linear");
296
1081
  });
297
- });
298
1082
 
299
- describe("registerProvider", () => {
300
- test("creates a new row", () => {
301
- const row = registerProvider({
302
- providerKey: "linear",
303
- authUrl: "https://linear.app/oauth/authorize",
304
- tokenUrl: "https://api.linear.app/oauth/token",
1083
+ test("clears logoUrl when passed null", () => {
1084
+ registerProvider({
1085
+ provider: "notion",
1086
+ authorizeUrl: "https://api.notion.com/v1/oauth/authorize",
1087
+ tokenExchangeUrl: "https://api.notion.com/v1/oauth/token",
305
1088
  defaultScopes: ["read"],
306
1089
  scopePolicy: {},
1090
+ logoUrl: "https://cdn.simpleicons.org/notion",
307
1091
  });
308
1092
 
309
- expect(row.providerKey).toBe("linear");
310
- expect(row.authUrl).toBe("https://linear.app/oauth/authorize");
1093
+ expect(getProvider("notion")!.logoUrl).toBe(
1094
+ "https://cdn.simpleicons.org/notion",
1095
+ );
311
1096
 
312
- const fetched = getProvider("linear");
313
- expect(fetched).toBeDefined();
314
- expect(fetched!.providerKey).toBe("linear");
1097
+ const updated = updateProvider("notion", { logoUrl: null });
1098
+ expect(updated).toBeDefined();
1099
+ expect(updated!.logoUrl).toBeNull();
1100
+
1101
+ expect(getProvider("notion")!.logoUrl).toBeNull();
315
1102
  });
316
1103
 
317
- test("throws for duplicate provider_key", () => {
1104
+ test("leaves logoUrl unchanged when not passed to updateProvider", () => {
318
1105
  registerProvider({
319
- providerKey: "linear",
320
- authUrl: "https://linear.app/oauth/authorize",
321
- tokenUrl: "https://api.linear.app/oauth/token",
1106
+ provider: "notion",
1107
+ authorizeUrl: "https://api.notion.com/v1/oauth/authorize",
1108
+ tokenExchangeUrl: "https://api.notion.com/v1/oauth/token",
322
1109
  defaultScopes: ["read"],
323
1110
  scopePolicy: {},
1111
+ logoUrl: "https://cdn.simpleicons.org/notion",
324
1112
  });
325
1113
 
326
- expect(() =>
327
- registerProvider({
328
- providerKey: "linear",
329
- authUrl: "https://linear.app/oauth/authorize",
330
- tokenUrl: "https://api.linear.app/oauth/token",
1114
+ expect(getProvider("notion")!.logoUrl).toBe(
1115
+ "https://cdn.simpleicons.org/notion",
1116
+ );
1117
+
1118
+ // Update a different field — logoUrl should be left alone.
1119
+ const updated = updateProvider("notion", {
1120
+ displayLabel: "Notion (updated)",
1121
+ });
1122
+ expect(updated).toBeDefined();
1123
+ expect(updated!.logoUrl).toBe("https://cdn.simpleicons.org/notion");
1124
+ expect(updated!.displayLabel).toBe("Notion (updated)");
1125
+ });
1126
+ });
1127
+
1128
+ describe("scopeSeparator empty-string coercion", () => {
1129
+ test("seedProviders coerces empty-string scopeSeparator to ' '", () => {
1130
+ seedProviders([
1131
+ {
1132
+ provider: "linear",
1133
+ authorizeUrl: "https://linear.app/oauth/authorize",
1134
+ tokenExchangeUrl: "https://api.linear.app/oauth/token",
331
1135
  defaultScopes: ["read"],
332
1136
  scopePolicy: {},
333
- }),
334
- ).toThrow(/already exists.*linear/);
1137
+ scopeSeparator: "",
1138
+ },
1139
+ ]);
1140
+
1141
+ const row = getProvider("linear");
1142
+ expect(row!.scopeSeparator).toBe(" ");
1143
+ });
1144
+
1145
+ test("registerProvider coerces empty-string scopeSeparator to ' '", () => {
1146
+ registerProvider({
1147
+ provider: "linear",
1148
+ authorizeUrl: "https://linear.app/oauth/authorize",
1149
+ tokenExchangeUrl: "https://api.linear.app/oauth/token",
1150
+ defaultScopes: ["read"],
1151
+ scopePolicy: {},
1152
+ scopeSeparator: "",
1153
+ });
1154
+
1155
+ const row = getProvider("linear");
1156
+ expect(row!.scopeSeparator).toBe(" ");
335
1157
  });
336
1158
  });
337
1159
  });
@@ -351,13 +1173,13 @@ describe("app operations", () => {
351
1173
  expect(app.id).toMatch(
352
1174
  /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
353
1175
  );
354
- expect(app.providerKey).toBe("github");
1176
+ expect(app.provider).toBe("github");
355
1177
  expect(app.clientId).toBe("client-abc");
356
1178
  expect(app.createdAt).toBeGreaterThan(0);
357
1179
  expect(app.updatedAt).toBeGreaterThan(0);
358
1180
  });
359
1181
 
360
- test("returns the existing app when called again with same (providerKey, clientId)", async () => {
1182
+ test("returns the existing app when called again with same (provider, clientId)", async () => {
361
1183
  seedTestProvider("github");
362
1184
  const first = await upsertApp("github", "client-abc");
363
1185
  const second = await upsertApp("github", "client-abc");
@@ -476,7 +1298,7 @@ describe("app operations", () => {
476
1298
 
477
1299
  expect(fetched).toBeDefined();
478
1300
  expect(fetched!.id).toBe(app.id);
479
- expect(fetched!.providerKey).toBe("github");
1301
+ expect(fetched!.provider).toBe("github");
480
1302
  expect(fetched!.clientId).toBe("client-1");
481
1303
  });
482
1304
 
@@ -564,7 +1386,7 @@ describe("connection operations", () => {
564
1386
  const app = await createTestApp("github", "client-1");
565
1387
  const conn = createConnection({
566
1388
  oauthAppId: app.id,
567
- providerKey: "github",
1389
+ provider: "github",
568
1390
  grantedScopes: ["repo", "user"],
569
1391
  hasRefreshToken: true,
570
1392
  accountInfo: "user@example.com",
@@ -574,7 +1396,7 @@ describe("connection operations", () => {
574
1396
 
575
1397
  expect(conn.id).toBeTruthy();
576
1398
  expect(conn.oauthAppId).toBe(app.id);
577
- expect(conn.providerKey).toBe("github");
1399
+ expect(conn.provider).toBe("github");
578
1400
  expect(conn.status).toBe("active");
579
1401
  expect(JSON.parse(conn.grantedScopes)).toEqual(["repo", "user"]);
580
1402
  expect(conn.hasRefreshToken).toBe(1);
@@ -590,7 +1412,7 @@ describe("connection operations", () => {
590
1412
  const app = await createTestApp("github", "client-1");
591
1413
  const conn = createConnection({
592
1414
  oauthAppId: app.id,
593
- providerKey: "github",
1415
+ provider: "github",
594
1416
  grantedScopes: ["repo"],
595
1417
  hasRefreshToken: false,
596
1418
  });
@@ -598,7 +1420,7 @@ describe("connection operations", () => {
598
1420
  const fetched = getConnection(conn.id);
599
1421
  expect(fetched).toBeDefined();
600
1422
  expect(fetched!.id).toBe(conn.id);
601
- expect(fetched!.providerKey).toBe("github");
1423
+ expect(fetched!.provider).toBe("github");
602
1424
  });
603
1425
 
604
1426
  test("returns undefined for unknown id", () => {
@@ -612,7 +1434,7 @@ describe("connection operations", () => {
612
1434
 
613
1435
  createConnection({
614
1436
  oauthAppId: app.id,
615
- providerKey: "github",
1437
+ provider: "github",
616
1438
  grantedScopes: ["repo"],
617
1439
  hasRefreshToken: false,
618
1440
  createdAt: 1000,
@@ -620,7 +1442,7 @@ describe("connection operations", () => {
620
1442
 
621
1443
  const conn2 = createConnection({
622
1444
  oauthAppId: app.id,
623
- providerKey: "github",
1445
+ provider: "github",
624
1446
  grantedScopes: ["repo", "user"],
625
1447
  hasRefreshToken: true,
626
1448
  createdAt: 2000,
@@ -636,7 +1458,7 @@ describe("connection operations", () => {
636
1458
 
637
1459
  const conn1 = createConnection({
638
1460
  oauthAppId: app.id,
639
- providerKey: "github",
1461
+ provider: "github",
640
1462
  accountInfo: "user1@example.com",
641
1463
  grantedScopes: ["repo"],
642
1464
  hasRefreshToken: false,
@@ -645,7 +1467,7 @@ describe("connection operations", () => {
645
1467
 
646
1468
  createConnection({
647
1469
  oauthAppId: app.id,
648
- providerKey: "github",
1470
+ provider: "github",
649
1471
  accountInfo: "user2@example.com",
650
1472
  grantedScopes: ["repo"],
651
1473
  hasRefreshToken: false,
@@ -665,7 +1487,7 @@ describe("connection operations", () => {
665
1487
 
666
1488
  const conn1 = createConnection({
667
1489
  oauthAppId: app1.id,
668
- providerKey: "github",
1490
+ provider: "github",
669
1491
  grantedScopes: ["repo"],
670
1492
  hasRefreshToken: false,
671
1493
  createdAt: 1000,
@@ -673,7 +1495,7 @@ describe("connection operations", () => {
673
1495
 
674
1496
  createConnection({
675
1497
  oauthAppId: app2.id,
676
- providerKey: "github",
1498
+ provider: "github",
677
1499
  grantedScopes: ["repo"],
678
1500
  hasRefreshToken: false,
679
1501
  createdAt: 2000,
@@ -689,7 +1511,7 @@ describe("connection operations", () => {
689
1511
 
690
1512
  createConnection({
691
1513
  oauthAppId: app.id,
692
- providerKey: "github",
1514
+ provider: "github",
693
1515
  grantedScopes: ["repo"],
694
1516
  hasRefreshToken: false,
695
1517
  });
@@ -705,7 +1527,7 @@ describe("connection operations", () => {
705
1527
 
706
1528
  const conn = createConnection({
707
1529
  oauthAppId: app.id,
708
- providerKey: "github",
1530
+ provider: "github",
709
1531
  grantedScopes: ["repo"],
710
1532
  hasRefreshToken: false,
711
1533
  });
@@ -727,7 +1549,7 @@ describe("connection operations", () => {
727
1549
  // Create two connections with explicit timestamps so ordering is deterministic
728
1550
  createConnection({
729
1551
  oauthAppId: app.id,
730
- providerKey: "github",
1552
+ provider: "github",
731
1553
  grantedScopes: ["repo"],
732
1554
  hasRefreshToken: false,
733
1555
  createdAt: 1000,
@@ -735,7 +1557,7 @@ describe("connection operations", () => {
735
1557
 
736
1558
  const conn2 = createConnection({
737
1559
  oauthAppId: app.id,
738
- providerKey: "github",
1560
+ provider: "github",
739
1561
  grantedScopes: ["repo", "user"],
740
1562
  hasRefreshToken: true,
741
1563
  createdAt: 2000,
@@ -751,14 +1573,14 @@ describe("connection operations", () => {
751
1573
 
752
1574
  const conn1 = createConnection({
753
1575
  oauthAppId: app.id,
754
- providerKey: "github",
1576
+ provider: "github",
755
1577
  grantedScopes: ["repo"],
756
1578
  hasRefreshToken: false,
757
1579
  });
758
1580
 
759
1581
  const conn2 = createConnection({
760
1582
  oauthAppId: app.id,
761
- providerKey: "github",
1583
+ provider: "github",
762
1584
  grantedScopes: ["repo", "user"],
763
1585
  hasRefreshToken: true,
764
1586
  });
@@ -776,7 +1598,7 @@ describe("connection operations", () => {
776
1598
 
777
1599
  const conn = createConnection({
778
1600
  oauthAppId: app.id,
779
- providerKey: "github",
1601
+ provider: "github",
780
1602
  grantedScopes: ["repo"],
781
1603
  hasRefreshToken: false,
782
1604
  });
@@ -798,7 +1620,7 @@ describe("connection operations", () => {
798
1620
 
799
1621
  const conn1 = createConnection({
800
1622
  oauthAppId: app.id,
801
- providerKey: "github",
1623
+ provider: "github",
802
1624
  accountInfo: "user1@example.com",
803
1625
  grantedScopes: ["repo"],
804
1626
  hasRefreshToken: false,
@@ -807,7 +1629,7 @@ describe("connection operations", () => {
807
1629
 
808
1630
  createConnection({
809
1631
  oauthAppId: app.id,
810
- providerKey: "github",
1632
+ provider: "github",
811
1633
  accountInfo: "user2@example.com",
812
1634
  grantedScopes: ["repo"],
813
1635
  hasRefreshToken: false,
@@ -827,7 +1649,7 @@ describe("connection operations", () => {
827
1649
 
828
1650
  const conn = createConnection({
829
1651
  oauthAppId: app.id,
830
- providerKey: "github",
1652
+ provider: "github",
831
1653
  accountInfo: "user@example.com",
832
1654
  grantedScopes: ["repo"],
833
1655
  hasRefreshToken: false,
@@ -843,7 +1665,7 @@ describe("connection operations", () => {
843
1665
 
844
1666
  createConnection({
845
1667
  oauthAppId: app.id,
846
- providerKey: "github",
1668
+ provider: "github",
847
1669
  accountInfo: "user@example.com",
848
1670
  grantedScopes: ["repo"],
849
1671
  hasRefreshToken: false,
@@ -861,7 +1683,7 @@ describe("connection operations", () => {
861
1683
 
862
1684
  const conn = createConnection({
863
1685
  oauthAppId: app.id,
864
- providerKey: "github",
1686
+ provider: "github",
865
1687
  accountInfo: "user@example.com",
866
1688
  grantedScopes: ["repo"],
867
1689
  hasRefreshToken: false,
@@ -882,7 +1704,7 @@ describe("connection operations", () => {
882
1704
 
883
1705
  createConnection({
884
1706
  oauthAppId: app.id,
885
- providerKey: "github",
1707
+ provider: "github",
886
1708
  accountInfo: "user1@example.com",
887
1709
  grantedScopes: ["repo"],
888
1710
  hasRefreshToken: false,
@@ -890,7 +1712,7 @@ describe("connection operations", () => {
890
1712
 
891
1713
  createConnection({
892
1714
  oauthAppId: app.id,
893
- providerKey: "github",
1715
+ provider: "github",
894
1716
  accountInfo: "user2@example.com",
895
1717
  grantedScopes: ["repo"],
896
1718
  hasRefreshToken: false,
@@ -905,7 +1727,7 @@ describe("connection operations", () => {
905
1727
 
906
1728
  createConnection({
907
1729
  oauthAppId: app.id,
908
- providerKey: "github",
1730
+ provider: "github",
909
1731
  accountInfo: "user1@example.com",
910
1732
  grantedScopes: ["repo"],
911
1733
  hasRefreshToken: false,
@@ -913,7 +1735,7 @@ describe("connection operations", () => {
913
1735
 
914
1736
  const conn2 = createConnection({
915
1737
  oauthAppId: app.id,
916
- providerKey: "github",
1738
+ provider: "github",
917
1739
  accountInfo: "user2@example.com",
918
1740
  grantedScopes: ["repo"],
919
1741
  hasRefreshToken: false,
@@ -936,7 +1758,7 @@ describe("connection operations", () => {
936
1758
  const app = await createTestApp("github", "client-1");
937
1759
  const conn = createConnection({
938
1760
  oauthAppId: app.id,
939
- providerKey: "github",
1761
+ provider: "github",
940
1762
  grantedScopes: ["repo"],
941
1763
  hasRefreshToken: false,
942
1764
  });
@@ -950,7 +1772,7 @@ describe("connection operations", () => {
950
1772
  const app = await createTestApp("github", "client-1");
951
1773
  createConnection({
952
1774
  oauthAppId: app.id,
953
- providerKey: "github",
1775
+ provider: "github",
954
1776
  grantedScopes: ["repo"],
955
1777
  hasRefreshToken: false,
956
1778
  });
@@ -967,7 +1789,7 @@ describe("connection operations", () => {
967
1789
  const app = await createTestApp("github", "client-1");
968
1790
  const conn = createConnection({
969
1791
  oauthAppId: app.id,
970
- providerKey: "github",
1792
+ provider: "github",
971
1793
  grantedScopes: ["repo"],
972
1794
  hasRefreshToken: false,
973
1795
  });
@@ -984,7 +1806,7 @@ describe("connection operations", () => {
984
1806
  const app = await createTestApp("github", "client-1");
985
1807
  const conn = createConnection({
986
1808
  oauthAppId: app.id,
987
- providerKey: "github",
1809
+ provider: "github",
988
1810
  grantedScopes: ["repo"],
989
1811
  hasRefreshToken: false,
990
1812
  });
@@ -1021,7 +1843,7 @@ describe("connection operations", () => {
1021
1843
 
1022
1844
  const conn = createConnection({
1023
1845
  oauthAppId: app1.id,
1024
- providerKey: "github",
1846
+ provider: "github",
1025
1847
  grantedScopes: ["repo"],
1026
1848
  hasRefreshToken: false,
1027
1849
  });
@@ -1051,13 +1873,13 @@ describe("connection operations", () => {
1051
1873
 
1052
1874
  createConnection({
1053
1875
  oauthAppId: ghApp.id,
1054
- providerKey: "github",
1876
+ provider: "github",
1055
1877
  grantedScopes: ["repo"],
1056
1878
  hasRefreshToken: false,
1057
1879
  });
1058
1880
  createConnection({
1059
1881
  oauthAppId: googApp.id,
1060
- providerKey: "google",
1882
+ provider: "google",
1061
1883
  grantedScopes: ["email"],
1062
1884
  hasRefreshToken: true,
1063
1885
  });
@@ -1073,24 +1895,24 @@ describe("connection operations", () => {
1073
1895
 
1074
1896
  createConnection({
1075
1897
  oauthAppId: ghApp.id,
1076
- providerKey: "github",
1898
+ provider: "github",
1077
1899
  grantedScopes: ["repo"],
1078
1900
  hasRefreshToken: false,
1079
1901
  });
1080
1902
  createConnection({
1081
1903
  oauthAppId: googApp.id,
1082
- providerKey: "google",
1904
+ provider: "google",
1083
1905
  grantedScopes: ["email"],
1084
1906
  hasRefreshToken: true,
1085
1907
  });
1086
1908
 
1087
1909
  const ghConns = listConnections("github");
1088
1910
  expect(ghConns).toHaveLength(1);
1089
- expect(ghConns[0].providerKey).toBe("github");
1911
+ expect(ghConns[0].provider).toBe("github");
1090
1912
 
1091
1913
  const googConns = listConnections("google");
1092
1914
  expect(googConns).toHaveLength(1);
1093
- expect(googConns[0].providerKey).toBe("google");
1915
+ expect(googConns[0].provider).toBe("google");
1094
1916
  });
1095
1917
 
1096
1918
  test("returns empty array when no connections exist", () => {
@@ -1103,7 +1925,7 @@ describe("connection operations", () => {
1103
1925
  const app = await createTestApp("github", "client-1");
1104
1926
  const conn = createConnection({
1105
1927
  oauthAppId: app.id,
1106
- providerKey: "github",
1928
+ provider: "github",
1107
1929
  grantedScopes: ["repo"],
1108
1930
  hasRefreshToken: false,
1109
1931
  });
@@ -1124,17 +1946,40 @@ describe("connection operations", () => {
1124
1946
  // ---------------------------------------------------------------------------
1125
1947
 
1126
1948
  describe("disconnectOAuthProvider", () => {
1949
+ /**
1950
+ * Seed a provider with revokeUrl and (optionally) a revokeBodyTemplate.
1951
+ */
1952
+ function seedProviderWithRevoke(
1953
+ provider: string,
1954
+ revokeUrl: string | null,
1955
+ revokeBodyTemplate?: Record<string, string>,
1956
+ ): void {
1957
+ seedProviders([
1958
+ {
1959
+ provider,
1960
+ authorizeUrl: `https://${provider}.example.com/authorize`,
1961
+ tokenExchangeUrl: `https://${provider}.example.com/token`,
1962
+ defaultScopes: ["read"],
1963
+ scopePolicy: {},
1964
+ ...(revokeUrl ? { revokeUrl } : {}),
1965
+ ...(revokeBodyTemplate ? { revokeBodyTemplate } : {}),
1966
+ },
1967
+ ]);
1968
+ }
1969
+
1127
1970
  test("returns 'not-found' when no connection exists for the provider", async () => {
1128
1971
  const result = await disconnectOAuthProvider("github");
1129
1972
  expect(result).toBe("not-found");
1130
1973
  expect(mockDeleteSecureKeyAsync).not.toHaveBeenCalled();
1974
+ // No upstream call should be made when there is no connection at all.
1975
+ expect(getMockFetchCalls().length).toBe(0);
1131
1976
  });
1132
1977
 
1133
1978
  test("returns 'disconnected' and deletes connection row and secure keys when connection exists", async () => {
1134
1979
  const app = await createTestApp("github", "client-1");
1135
1980
  const conn = createConnection({
1136
1981
  oauthAppId: app.id,
1137
- providerKey: "github",
1982
+ provider: "github",
1138
1983
  grantedScopes: ["repo"],
1139
1984
  hasRefreshToken: true,
1140
1985
  });
@@ -1154,6 +1999,365 @@ describe("disconnectOAuthProvider", () => {
1154
1999
  // Verify connection row was deleted
1155
2000
  expect(getConnection(conn.id)).toBeUndefined();
1156
2001
  });
2002
+
2003
+ test("calls upstream revoke when provider has revokeUrl and access token exists", async () => {
2004
+ seedProviderWithRevoke("google", "https://oauth2.googleapis.com/revoke", {
2005
+ token: "{access_token}",
2006
+ client_id: "{client_id}",
2007
+ });
2008
+ const app = await upsertApp("google", "client-1");
2009
+ const conn = createConnection({
2010
+ oauthAppId: app.id,
2011
+ provider: "google",
2012
+ grantedScopes: ["email"],
2013
+ hasRefreshToken: true,
2014
+ });
2015
+ secureKeyValues.set(
2016
+ `oauth_connection/${conn.id}/access_token`,
2017
+ "fake-token-xyz",
2018
+ );
2019
+
2020
+ mockFetch(
2021
+ "https://oauth2.googleapis.com/revoke",
2022
+ { method: "POST" },
2023
+ { status: 200, body: {} },
2024
+ );
2025
+
2026
+ const result = await disconnectOAuthProvider("google");
2027
+ expect(result).toBe("disconnected");
2028
+
2029
+ const calls = getMockFetchCalls();
2030
+ expect(calls.length).toBe(1);
2031
+ expect(calls[0]!.path).toContain("https://oauth2.googleapis.com/revoke");
2032
+
2033
+ const body = String(calls[0]!.init.body ?? "");
2034
+ const params = new URLSearchParams(body);
2035
+ expect(params.get("token")).toBe("fake-token-xyz");
2036
+ expect(params.get("client_id")).toBe("client-1");
2037
+ });
2038
+
2039
+ test("skips upstream revoke when provider has no revokeUrl", async () => {
2040
+ // GitHub seeded by createTestApp via seedTestProvider — no revokeUrl.
2041
+ const app = await createTestApp("github", "client-1");
2042
+ const conn = createConnection({
2043
+ oauthAppId: app.id,
2044
+ provider: "github",
2045
+ grantedScopes: ["repo"],
2046
+ hasRefreshToken: false,
2047
+ });
2048
+ secureKeyValues.set(
2049
+ `oauth_connection/${conn.id}/access_token`,
2050
+ "github-token",
2051
+ );
2052
+
2053
+ const result = await disconnectOAuthProvider("github");
2054
+ expect(result).toBe("disconnected");
2055
+ expect(getMockFetchCalls().length).toBe(0);
2056
+ });
2057
+
2058
+ test("skips upstream revoke when no access token exists in secure storage", async () => {
2059
+ seedProviderWithRevoke("google", "https://oauth2.googleapis.com/revoke", {
2060
+ token: "{access_token}",
2061
+ });
2062
+ const app = await upsertApp("google", "client-1");
2063
+ createConnection({
2064
+ oauthAppId: app.id,
2065
+ provider: "google",
2066
+ grantedScopes: ["email"],
2067
+ hasRefreshToken: false,
2068
+ });
2069
+ // No access token seeded into secureKeyValues.
2070
+
2071
+ const result = await disconnectOAuthProvider("google");
2072
+ expect(result).toBe("disconnected");
2073
+ expect(getMockFetchCalls().length).toBe(0);
2074
+ });
2075
+
2076
+ test("continues local cleanup when upstream revoke returns non-2xx", async () => {
2077
+ seedProviderWithRevoke("google", "https://oauth2.googleapis.com/revoke", {
2078
+ token: "{access_token}",
2079
+ });
2080
+ const app = await upsertApp("google", "client-1");
2081
+ const conn = createConnection({
2082
+ oauthAppId: app.id,
2083
+ provider: "google",
2084
+ grantedScopes: ["email"],
2085
+ hasRefreshToken: true,
2086
+ });
2087
+ secureKeyValues.set(
2088
+ `oauth_connection/${conn.id}/access_token`,
2089
+ "fake-token-xyz",
2090
+ );
2091
+
2092
+ mockFetch(
2093
+ "https://oauth2.googleapis.com/revoke",
2094
+ { method: "POST" },
2095
+ { status: 400, body: { error: "invalid_token" } },
2096
+ );
2097
+
2098
+ const result = await disconnectOAuthProvider("google");
2099
+ expect(result).toBe("disconnected");
2100
+ expect(getMockFetchCalls().length).toBe(1);
2101
+ // Local cleanup still happened
2102
+ expect(mockDeleteSecureKeyAsync).toHaveBeenCalledWith(
2103
+ `oauth_connection/${conn.id}/access_token`,
2104
+ );
2105
+ expect(getConnection(conn.id)).toBeUndefined();
2106
+ });
2107
+
2108
+ test("continues local cleanup when upstream revoke throws (network error)", async () => {
2109
+ seedProviderWithRevoke("google", "https://oauth2.googleapis.com/revoke", {
2110
+ token: "{access_token}",
2111
+ });
2112
+ const app = await upsertApp("google", "client-1");
2113
+ const conn = createConnection({
2114
+ oauthAppId: app.id,
2115
+ provider: "google",
2116
+ grantedScopes: ["email"],
2117
+ hasRefreshToken: true,
2118
+ });
2119
+ secureKeyValues.set(
2120
+ `oauth_connection/${conn.id}/access_token`,
2121
+ "fake-token-xyz",
2122
+ );
2123
+
2124
+ // 500 exercises the same swallow path as a network error.
2125
+ mockFetch(
2126
+ "https://oauth2.googleapis.com/revoke",
2127
+ { method: "POST" },
2128
+ { status: 500, body: { error: "server_error" } },
2129
+ );
2130
+
2131
+ const result = await disconnectOAuthProvider("google");
2132
+ expect(result).toBe("disconnected");
2133
+ expect(getConnection(conn.id)).toBeUndefined();
2134
+ });
2135
+
2136
+ test("substitutes {access_token} and {client_id} in body template values", async () => {
2137
+ seedProviderWithRevoke("google", "https://oauth2.googleapis.com/revoke", {
2138
+ token: "{access_token}",
2139
+ client_id: "{client_id}",
2140
+ token_type_hint: "access_token",
2141
+ });
2142
+ const app = await upsertApp("google", "client-substitution");
2143
+ const conn = createConnection({
2144
+ oauthAppId: app.id,
2145
+ provider: "google",
2146
+ grantedScopes: ["email"],
2147
+ hasRefreshToken: false,
2148
+ });
2149
+ secureKeyValues.set(
2150
+ `oauth_connection/${conn.id}/access_token`,
2151
+ "tok-substitution",
2152
+ );
2153
+
2154
+ mockFetch(
2155
+ "https://oauth2.googleapis.com/revoke",
2156
+ { method: "POST" },
2157
+ { status: 200, body: {} },
2158
+ );
2159
+
2160
+ await disconnectOAuthProvider("google");
2161
+
2162
+ const calls = getMockFetchCalls();
2163
+ expect(calls.length).toBe(1);
2164
+ const body = String(calls[0]!.init.body ?? "");
2165
+ const params = new URLSearchParams(body);
2166
+ expect(params.get("token")).toBe("tok-substitution");
2167
+ expect(params.get("client_id")).toBe("client-substitution");
2168
+ expect(params.get("token_type_hint")).toBe("access_token");
2169
+ });
2170
+
2171
+ test("treats $-prefixed patterns in access token as literal text (String.replace gotcha)", async () => {
2172
+ // String.prototype.replace interprets $-prefixed patterns in the
2173
+ // replacement string as special sequences ($& = matched substring,
2174
+ // $' = after match, $` = before match, $$ = literal $). If the access
2175
+ // token contains "$&", a naive `.replace("{access_token}", accessToken)`
2176
+ // would expand it to "{access_token}" (the matched string) instead of
2177
+ // substituting literally. This test guards against that by asserting
2178
+ // the captured body contains the literal "tok$&abc" — which only holds
2179
+ // when we use a function-replacement callback that preserves literal
2180
+ // semantics and mirrors Python's str.replace() behavior.
2181
+ seedProviderWithRevoke("google", "https://revoke.example.com/r", {
2182
+ token: "{access_token}",
2183
+ });
2184
+ const app = await upsertApp("google", "client-1");
2185
+ const conn = createConnection({
2186
+ oauthAppId: app.id,
2187
+ provider: "google",
2188
+ grantedScopes: ["email"],
2189
+ hasRefreshToken: false,
2190
+ });
2191
+ secureKeyValues.set(`oauth_connection/${conn.id}/access_token`, "tok$&abc");
2192
+
2193
+ mockFetch(
2194
+ "https://revoke.example.com/r",
2195
+ { method: "POST" },
2196
+ { status: 200, body: {} },
2197
+ );
2198
+
2199
+ await disconnectOAuthProvider("google");
2200
+
2201
+ const calls = getMockFetchCalls();
2202
+ expect(calls.length).toBe(1);
2203
+ const body = String(calls[0]!.init.body ?? "");
2204
+ const params = new URLSearchParams(body);
2205
+ // The literal access token — not the $&-expanded version, which would
2206
+ // be "tok{access_token}abc" (where $& matched "{access_token}").
2207
+ expect(params.get("token")).toBe("tok$&abc");
2208
+ });
2209
+
2210
+ test("replaces all occurrences of {access_token} in body template values (matching Python str.replace)", async () => {
2211
+ // Python's str.replace(old, new) replaces ALL occurrences by default,
2212
+ // whereas JavaScript's String.prototype.replace with a string pattern
2213
+ // only replaces the FIRST occurrence. The platform's try_revoke_token
2214
+ // is implemented in Python, so any template value containing repeated
2215
+ // placeholders must have ALL of them substituted to preserve parity.
2216
+ // This test guards against a regression to .replace(), which would
2217
+ // leave the second {access_token} as a literal placeholder.
2218
+ seedProviderWithRevoke("google", "https://revoke.example.com/r", {
2219
+ token: "token={access_token}&also={access_token}",
2220
+ });
2221
+ const app = await upsertApp("google", "client-1");
2222
+ const conn = createConnection({
2223
+ oauthAppId: app.id,
2224
+ provider: "google",
2225
+ grantedScopes: ["email"],
2226
+ hasRefreshToken: false,
2227
+ });
2228
+ secureKeyValues.set(`oauth_connection/${conn.id}/access_token`, "fake-abc");
2229
+
2230
+ mockFetch(
2231
+ "https://revoke.example.com/r",
2232
+ { method: "POST" },
2233
+ { status: 200, body: {} },
2234
+ );
2235
+
2236
+ await disconnectOAuthProvider("google");
2237
+
2238
+ const calls = getMockFetchCalls();
2239
+ expect(calls.length).toBe(1);
2240
+ const body = String(calls[0]!.init.body ?? "");
2241
+ const params = new URLSearchParams(body);
2242
+ // Both {access_token} placeholders must be substituted. With the buggy
2243
+ // .replace() (single-occurrence), this would be
2244
+ // "token=fake-abc&also={access_token}" instead.
2245
+ expect(params.get("token")).toBe("token=fake-abc&also=fake-abc");
2246
+ });
2247
+
2248
+ test("coerces non-string body template values to strings", async () => {
2249
+ // seedProviderWithRevoke restricts to Record<string, string>; bypass it
2250
+ // here by inserting a template with a numeric value via a direct seed.
2251
+ seedProviders([
2252
+ {
2253
+ provider: "google",
2254
+ authorizeUrl: "https://google.example.com/authorize",
2255
+ tokenExchangeUrl: "https://google.example.com/token",
2256
+ defaultScopes: ["email"],
2257
+ scopePolicy: {},
2258
+ revokeUrl: "https://oauth2.googleapis.com/revoke",
2259
+ revokeBodyTemplate: {
2260
+ token: "{access_token}",
2261
+ // expires_in is a number — must be coerced via String(value).
2262
+ expires_in: 3600,
2263
+ } as unknown as Record<string, string>,
2264
+ },
2265
+ ]);
2266
+ const app = await upsertApp("google", "client-1");
2267
+ const conn = createConnection({
2268
+ oauthAppId: app.id,
2269
+ provider: "google",
2270
+ grantedScopes: ["email"],
2271
+ hasRefreshToken: false,
2272
+ });
2273
+ secureKeyValues.set(
2274
+ `oauth_connection/${conn.id}/access_token`,
2275
+ "fake-token-xyz",
2276
+ );
2277
+
2278
+ mockFetch(
2279
+ "https://oauth2.googleapis.com/revoke",
2280
+ { method: "POST" },
2281
+ { status: 200, body: {} },
2282
+ );
2283
+
2284
+ await disconnectOAuthProvider("google");
2285
+
2286
+ const calls = getMockFetchCalls();
2287
+ expect(calls.length).toBe(1);
2288
+ const body = String(calls[0]!.init.body ?? "");
2289
+ const params = new URLSearchParams(body);
2290
+ expect(params.get("token")).toBe("fake-token-xyz");
2291
+ expect(params.get("expires_in")).toBe("3600");
2292
+ });
2293
+
2294
+ test("revokes BEFORE deleting tokens from secure storage", async () => {
2295
+ seedProviderWithRevoke("google", "https://oauth2.googleapis.com/revoke", {
2296
+ token: "{access_token}",
2297
+ });
2298
+ const app = await upsertApp("google", "client-1");
2299
+ const conn = createConnection({
2300
+ oauthAppId: app.id,
2301
+ provider: "google",
2302
+ grantedScopes: ["email"],
2303
+ hasRefreshToken: true,
2304
+ });
2305
+ secureKeyValues.set(
2306
+ `oauth_connection/${conn.id}/access_token`,
2307
+ "fake-token-xyz",
2308
+ );
2309
+
2310
+ const order: string[] = [];
2311
+
2312
+ // Wrap the mockFetch entry's response handler by registering a fetch
2313
+ // mock that records the call order via the existing mockFetch helper.
2314
+ // The mock fetch records to getMockFetchCalls; we tag ordering by
2315
+ // pushing a marker as soon as the response is constructed below.
2316
+ mockFetch(
2317
+ "https://oauth2.googleapis.com/revoke",
2318
+ { method: "POST" },
2319
+ new Response(JSON.stringify({}), {
2320
+ status: 200,
2321
+ headers: { "Content-Type": "application/json" },
2322
+ }),
2323
+ );
2324
+
2325
+ // Wrap delete to record its order. We replace the mock implementation
2326
+ // for the duration of this test only.
2327
+ mockDeleteSecureKeyAsync.mockImplementation(() => {
2328
+ order.push("delete");
2329
+ return Promise.resolve("deleted" as const);
2330
+ });
2331
+
2332
+ // Wrap fetch one more layer: tap into the actual fetch call to record
2333
+ // ordering. We do this by overriding globalThis.fetch with a wrapper
2334
+ // that calls through to the existing mock and records ordering first.
2335
+ const wrappedFetch = globalThis.fetch;
2336
+ globalThis.fetch = (async (
2337
+ input: RequestInfo | URL,
2338
+ init?: RequestInit,
2339
+ ) => {
2340
+ order.push("fetch");
2341
+ return wrappedFetch(input, init);
2342
+ }) as typeof globalThis.fetch;
2343
+
2344
+ try {
2345
+ const result = await disconnectOAuthProvider("google");
2346
+ expect(result).toBe("disconnected");
2347
+ } finally {
2348
+ // Restore the wrapper layer; resetMockFetch in beforeEach will reset
2349
+ // the underlying mock for the next test.
2350
+ globalThis.fetch = wrappedFetch;
2351
+ mockDeleteSecureKeyAsync.mockImplementation(
2352
+ (): Promise<"deleted" | "not-found" | "error"> =>
2353
+ Promise.resolve("deleted" as const),
2354
+ );
2355
+ }
2356
+
2357
+ expect(order[0]).toBe("fetch");
2358
+ expect(order).toContain("delete");
2359
+ expect(order.indexOf("fetch")).toBeLessThan(order.indexOf("delete"));
2360
+ });
1157
2361
  });
1158
2362
 
1159
2363
  // ---------------------------------------------------------------------------
@@ -1172,7 +2376,7 @@ describe("FK constraints", () => {
1172
2376
  expect(() =>
1173
2377
  createConnection({
1174
2378
  oauthAppId: "nonexistent-app-id",
1175
- providerKey: "github",
2379
+ provider: "github",
1176
2380
  grantedScopes: ["repo"],
1177
2381
  hasRefreshToken: false,
1178
2382
  }),