@wardby/cli 0.5.3 → 0.6.0

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 (370) hide show
  1. package/.env.example +33 -0
  2. package/README.md +25 -14
  3. package/dist/claude-coding-worker/context.d.ts +22 -0
  4. package/dist/claude-coding-worker/context.js +92 -0
  5. package/dist/claude-coding-worker/driver.d.ts +7 -1
  6. package/dist/claude-coding-worker/driver.js +38 -5
  7. package/dist/claude-coding-worker/sdk.js +13 -4
  8. package/dist/cli-help.d.ts +1 -1
  9. package/dist/cli-help.js +5 -2
  10. package/dist/cli-native-mode.d.ts +9 -0
  11. package/dist/cli-native-mode.js +20 -0
  12. package/dist/cli.d.ts +3 -1
  13. package/dist/cli.js +141 -5
  14. package/dist/coding/claude-context.d.ts +45 -0
  15. package/dist/coding/claude-context.js +434 -0
  16. package/dist/coding/profile.d.ts +12 -0
  17. package/dist/coding/profile.js +6 -0
  18. package/dist/coding/protocol.d.ts +104 -0
  19. package/dist/coding/protocol.js +87 -3
  20. package/dist/coding-worker/artifact.d.ts +8 -0
  21. package/dist/coding-worker/artifact.js +2 -2
  22. package/dist/coding-worker/driver.js +2 -0
  23. package/dist/coding-worker/sdk.d.ts +6 -0
  24. package/dist/coding-worker/sdk.js +1 -0
  25. package/dist/coding-worker/skills.d.ts +23 -0
  26. package/dist/coding-worker/skills.js +117 -0
  27. package/dist/coding-worker/types.d.ts +2 -0
  28. package/dist/config/github-allowed-accounts.d.ts +15 -0
  29. package/dist/config/github-allowed-accounts.js +36 -0
  30. package/dist/config/low-power.d.ts +12 -0
  31. package/dist/config/low-power.js +18 -0
  32. package/dist/config/mobile.d.ts +12 -0
  33. package/dist/config/mobile.js +18 -0
  34. package/dist/config/providers.d.ts +53 -0
  35. package/dist/config/providers.js +92 -6
  36. package/dist/core/db.d.ts +16 -6
  37. package/dist/core/db.js +12 -10
  38. package/dist/core/dispatch.d.ts +11 -0
  39. package/dist/core/dispatch.js +36 -1
  40. package/dist/core/engine-native.js +9 -5
  41. package/dist/core/host-events.d.ts +49 -3
  42. package/dist/core/host-events.js +289 -15
  43. package/dist/core/issue-bridge.js +42 -2
  44. package/dist/core/issue-events.js +12 -1
  45. package/dist/core/issue-status.js +14 -3
  46. package/dist/core/merge-order-check.d.ts +129 -0
  47. package/dist/core/merge-order-check.js +336 -0
  48. package/dist/core/notification-dispatcher.d.ts +70 -0
  49. package/dist/core/notification-dispatcher.js +368 -0
  50. package/dist/core/notification-templates.d.ts +25 -0
  51. package/dist/core/notification-templates.js +105 -0
  52. package/dist/core/notifications.d.ts +10 -0
  53. package/dist/core/notifications.js +38 -0
  54. package/dist/core/pg-auth.d.ts +22 -0
  55. package/dist/core/pg-auth.js +152 -0
  56. package/dist/core/power-composition.d.ts +16 -0
  57. package/dist/core/power-composition.js +37 -0
  58. package/dist/core/power-idle.d.ts +14 -0
  59. package/dist/core/power-idle.js +30 -0
  60. package/dist/core/power.d.ts +71 -0
  61. package/dist/core/power.js +202 -0
  62. package/dist/core/reconciler.d.ts +4 -2
  63. package/dist/core/reconciler.js +29 -5
  64. package/dist/core/related-pull-requests.d.ts +48 -4
  65. package/dist/core/related-pull-requests.js +113 -11
  66. package/dist/core/repo-access.d.ts +15 -0
  67. package/dist/core/repo-access.js +66 -0
  68. package/dist/core/review-fix.d.ts +37 -0
  69. package/dist/core/review-fix.js +207 -6
  70. package/dist/core/review-host-tools.d.ts +20 -0
  71. package/dist/core/review-host-tools.js +58 -2
  72. package/dist/core/runner.d.ts +336 -3
  73. package/dist/core/runner.js +894 -317
  74. package/dist/core/secret-broker-config.d.ts +103 -0
  75. package/dist/core/secret-broker-config.js +112 -0
  76. package/dist/core/secrets.d.ts +39 -4
  77. package/dist/core/secrets.js +148 -40
  78. package/dist/core/workflow-event-recorder.d.ts +14 -0
  79. package/dist/core/workflow-event-recorder.js +120 -0
  80. package/dist/core/workflow-events.d.ts +82 -0
  81. package/dist/core/workflow-events.js +44 -0
  82. package/dist/core/workflow-run-events.d.ts +16 -0
  83. package/dist/core/workflow-run-events.js +40 -0
  84. package/dist/generated/prisma/browser.d.ts +58 -0
  85. package/dist/generated/prisma/client.d.ts +58 -0
  86. package/dist/generated/prisma/commonInputTypes.d.ts +150 -0
  87. package/dist/generated/prisma/enums.d.ts +27 -0
  88. package/dist/generated/prisma/enums.js +23 -0
  89. package/dist/generated/prisma/internal/class.d.ts +88 -0
  90. package/dist/generated/prisma/internal/class.js +4 -4
  91. package/dist/generated/prisma/internal/prismaNamespace.d.ts +772 -1
  92. package/dist/generated/prisma/internal/prismaNamespace.js +135 -4
  93. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +139 -0
  94. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +135 -4
  95. package/dist/generated/prisma/models/Agent.d.ts +360 -1
  96. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +73 -1
  97. package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
  98. package/dist/generated/prisma/models/DeferredReview.d.ts +78 -1
  99. package/dist/generated/prisma/models/NativeGatewayCall.d.ts +1675 -0
  100. package/dist/generated/prisma/models/NativeGatewayCall.js +1 -0
  101. package/dist/generated/prisma/models/NativeGatewaySession.d.ts +1517 -0
  102. package/dist/generated/prisma/models/NativeGatewaySession.js +1 -0
  103. package/dist/generated/prisma/models/NativeWarmWorker.d.ts +1296 -0
  104. package/dist/generated/prisma/models/NativeWarmWorker.js +1 -0
  105. package/dist/generated/prisma/models/NotificationChannel.d.ts +1543 -0
  106. package/dist/generated/prisma/models/NotificationChannel.js +1 -0
  107. package/dist/generated/prisma/models/NotificationDelivery.d.ts +1502 -0
  108. package/dist/generated/prisma/models/NotificationDelivery.js +1 -0
  109. package/dist/generated/prisma/models/NotificationThread.d.ts +1090 -0
  110. package/dist/generated/prisma/models/NotificationThread.js +1 -0
  111. package/dist/generated/prisma/models/Run.d.ts +826 -1
  112. package/dist/generated/prisma/models/RunHostCheck.d.ts +36 -1
  113. package/dist/generated/prisma/models/Secret.d.ts +37 -1
  114. package/dist/generated/prisma/models/SecretBrokerChange.d.ts +1101 -0
  115. package/dist/generated/prisma/models/SecretBrokerChange.js +1 -0
  116. package/dist/generated/prisma/models/WorkflowEvent.d.ts +1422 -0
  117. package/dist/generated/prisma/models/WorkflowEvent.js +1 -0
  118. package/dist/generated/prisma/models.d.ts +8 -0
  119. package/dist/help/build.js +4 -2
  120. package/dist/help/catalog.d.ts +1 -1
  121. package/dist/help/catalog.js +31 -6
  122. package/dist/help-index.json +1363 -176
  123. package/dist/import/create.js +17 -0
  124. package/dist/import/neutral-schema.d.ts +6 -2
  125. package/dist/import/neutral-schema.js +2 -0
  126. package/dist/mcp/auth/access.d.ts +1 -0
  127. package/dist/mcp/auth/repo-authorization.js +14 -0
  128. package/dist/mcp/auth/resource-server.d.ts +2 -1
  129. package/dist/mcp/auth/resource-server.js +7 -2
  130. package/dist/mcp/auth/self-hosted/browser.d.ts +1 -1
  131. package/dist/mcp/auth/self-hosted/browser.js +19 -6
  132. package/dist/mcp/context.d.ts +6 -0
  133. package/dist/mcp/host-events/github-ingress.d.ts +23 -1
  134. package/dist/mcp/host-events/github-ingress.js +68 -0
  135. package/dist/mcp/index.d.ts +13 -0
  136. package/dist/mcp/index.js +59 -10
  137. package/dist/mcp/tools/agents.js +82 -11
  138. package/dist/mcp/tools/notification-channels.d.ts +2 -0
  139. package/dist/mcp/tools/notification-channels.js +293 -0
  140. package/dist/mcp/tools/power.d.ts +2 -0
  141. package/dist/mcp/tools/power.js +77 -0
  142. package/dist/mcp/tools/runs.d.ts +0 -6
  143. package/dist/mcp/tools/runs.js +7 -2
  144. package/dist/mcp/tools/secret-broker-compat.d.ts +12 -0
  145. package/dist/mcp/tools/secret-broker-compat.js +23 -0
  146. package/dist/mcp/tools/secret-elicitation-form.d.ts +22 -2
  147. package/dist/mcp/tools/secret-elicitation-form.js +200 -10
  148. package/dist/mcp/tools/secret-elicitation-server.js +8 -1
  149. package/dist/mcp/tools/secret-elicitation.d.ts +38 -3
  150. package/dist/mcp/tools/secret-elicitation.js +110 -12
  151. package/dist/mcp/tools/secrets.js +184 -7
  152. package/dist/mcp/tools/tools.d.ts +1 -1
  153. package/dist/mcp/tools/tools.js +22 -2
  154. package/dist/native-worker/composition.d.ts +23 -0
  155. package/dist/native-worker/composition.js +77 -0
  156. package/dist/native-worker/docker-isolation.d.ts +65 -0
  157. package/dist/native-worker/docker-isolation.js +183 -0
  158. package/dist/native-worker/docker-launcher.d.ts +74 -0
  159. package/dist/native-worker/docker-launcher.js +237 -0
  160. package/dist/native-worker/gateway-server.d.ts +39 -0
  161. package/dist/native-worker/gateway-server.js +315 -0
  162. package/dist/native-worker/gateway.d.ts +44 -0
  163. package/dist/native-worker/gateway.js +197 -0
  164. package/dist/native-worker/http-server.d.ts +22 -0
  165. package/dist/native-worker/http-server.js +109 -0
  166. package/dist/native-worker/http-transport.d.ts +17 -0
  167. package/dist/native-worker/http-transport.js +88 -0
  168. package/dist/native-worker/kubernetes-isolation.d.ts +90 -0
  169. package/dist/native-worker/kubernetes-isolation.js +218 -0
  170. package/dist/native-worker/kubernetes-launcher.d.ts +102 -0
  171. package/dist/native-worker/kubernetes-launcher.js +335 -0
  172. package/dist/native-worker/launch.d.ts +45 -0
  173. package/dist/native-worker/launch.js +44 -0
  174. package/dist/native-worker/ledger.d.ts +109 -0
  175. package/dist/native-worker/ledger.js +192 -0
  176. package/dist/native-worker/loopback.d.ts +12 -0
  177. package/dist/native-worker/loopback.js +49 -0
  178. package/dist/native-worker/main.d.ts +7 -0
  179. package/dist/native-worker/main.js +74 -0
  180. package/dist/native-worker/protocol.d.ts +744 -0
  181. package/dist/native-worker/protocol.js +200 -0
  182. package/dist/native-worker/sandbox-executor.d.ts +68 -0
  183. package/dist/native-worker/sandbox-executor.js +210 -0
  184. package/dist/native-worker/stdio.d.ts +29 -0
  185. package/dist/native-worker/stdio.js +188 -0
  186. package/dist/native-worker/warm-delivery.d.ts +16 -0
  187. package/dist/native-worker/warm-delivery.js +33 -0
  188. package/dist/native-worker/warm-pool-ledger.d.ts +39 -0
  189. package/dist/native-worker/warm-pool-ledger.js +73 -0
  190. package/dist/native-worker/warm-pool.d.ts +107 -0
  191. package/dist/native-worker/warm-pool.js +283 -0
  192. package/dist/native-worker/worker.d.ts +17 -0
  193. package/dist/native-worker/worker.js +115 -0
  194. package/dist/providers/auth/authorization-server.d.ts +3 -0
  195. package/dist/providers/auth/index.js +2 -0
  196. package/dist/providers/auth/self-hosted.d.ts +13 -1
  197. package/dist/providers/auth/self-hosted.js +54 -4
  198. package/dist/providers/chat/fake.d.ts +46 -0
  199. package/dist/providers/chat/fake.js +39 -0
  200. package/dist/providers/chat/index.d.ts +4 -0
  201. package/dist/providers/chat/index.js +8 -0
  202. package/dist/providers/chat/slack.d.ts +37 -0
  203. package/dist/providers/chat/slack.js +105 -0
  204. package/dist/providers/chat/types.d.ts +42 -0
  205. package/dist/providers/chat/types.js +19 -0
  206. package/dist/providers/engine/types.d.ts +19 -0
  207. package/dist/providers/engine/types.js +22 -0
  208. package/dist/providers/executor/composition.d.ts +24 -1
  209. package/dist/providers/executor/composition.js +39 -6
  210. package/dist/providers/executor/container.d.ts +24 -1
  211. package/dist/providers/executor/container.js +101 -11
  212. package/dist/providers/executor/dbos.d.ts +42 -0
  213. package/dist/providers/executor/dbos.js +118 -12
  214. package/dist/providers/executor/deferred.d.ts +56 -0
  215. package/dist/providers/executor/deferred.js +119 -0
  216. package/dist/providers/executor/routing.d.ts +37 -10
  217. package/dist/providers/executor/routing.js +73 -32
  218. package/dist/providers/executor/types.d.ts +12 -0
  219. package/dist/providers/jobs/docker.js +3 -3
  220. package/dist/providers/jobs/fake-kubernetes-api.d.ts +21 -2
  221. package/dist/providers/jobs/fake-kubernetes-api.js +42 -1
  222. package/dist/providers/jobs/kubernetes-api.d.ts +17 -1
  223. package/dist/providers/jobs/kubernetes-api.js +7 -0
  224. package/dist/providers/jobs/kubernetes-client.d.ts +10 -1
  225. package/dist/providers/jobs/kubernetes-client.js +52 -3
  226. package/dist/providers/jobs/kubernetes-dry-run-fixture.d.ts +6 -1
  227. package/dist/providers/jobs/kubernetes-dry-run-fixture.js +6 -3
  228. package/dist/providers/jobs/kubernetes-isolation.d.ts +3 -1
  229. package/dist/providers/jobs/kubernetes-isolation.js +6 -0
  230. package/dist/providers/jobs/kubernetes-platform.d.ts +4 -1
  231. package/dist/providers/jobs/kubernetes-platform.js +38 -1
  232. package/dist/providers/jobs/kubernetes-preflight.d.ts +2 -1
  233. package/dist/providers/jobs/kubernetes-preflight.js +4 -2
  234. package/dist/providers/jobs/kubernetes.d.ts +36 -1
  235. package/dist/providers/jobs/kubernetes.js +74 -4
  236. package/dist/providers/jobs/metadata-probes.d.ts +11 -0
  237. package/dist/providers/jobs/metadata-probes.js +13 -0
  238. package/dist/providers/jobs/types.d.ts +9 -0
  239. package/dist/providers/review-host/github.d.ts +11 -1
  240. package/dist/providers/review-host/github.js +50 -0
  241. package/dist/providers/review-host/types.d.ts +23 -0
  242. package/dist/providers/vcs/github.d.ts +32 -6
  243. package/dist/providers/vcs/github.js +74 -16
  244. package/dist/quickstart/coding-seed.d.ts +10 -3
  245. package/dist/quickstart/coding-seed.js +74 -3
  246. package/dist/quickstart/images.d.ts +2 -0
  247. package/dist/quickstart/images.js +7 -1
  248. package/dist/quickstart/reviewer-prompt.d.ts +1 -1
  249. package/dist/quickstart/reviewer-prompt.js +1 -1
  250. package/dist/quickstart-images.json +1 -1
  251. package/dist/sandbox/brokered-fetch.d.ts +23 -0
  252. package/dist/sandbox/brokered-fetch.js +37 -0
  253. package/dist/sandbox/host-functions.d.ts +34 -0
  254. package/dist/sandbox/host-functions.js +144 -80
  255. package/dist/sandbox/prelude.js +7 -1
  256. package/dist/sandbox/run-in-sandbox.d.ts +19 -0
  257. package/dist/sandbox/run-in-sandbox.js +28 -10
  258. package/dist/sandbox/safe-fetch.d.ts +2 -0
  259. package/dist/sandbox/safe-fetch.js +3 -1
  260. package/dist/sandbox/secret-broker-sigv4.d.ts +2 -0
  261. package/dist/sandbox/secret-broker-sigv4.js +49 -0
  262. package/dist/sandbox/secret-broker.d.ts +34 -0
  263. package/dist/sandbox/secret-broker.js +162 -0
  264. package/dist/sandbox/user-tool.d.ts +14 -0
  265. package/dist/sandbox/user-tool.js +32 -0
  266. package/dist/serve.js +18 -0
  267. package/dist/viewer/api-schema.d.ts +210 -14
  268. package/dist/viewer/api-schema.js +22 -0
  269. package/dist/viewer/build-schemas.js +4 -1
  270. package/dist/viewer/event-bus.d.ts +6 -0
  271. package/dist/viewer/event-bus.js +16 -7
  272. package/dist/viewer/graph.d.ts +5 -2
  273. package/dist/viewer/graph.js +7 -2
  274. package/dist/viewer/http.d.ts +0 -1
  275. package/dist/viewer/http.js +2 -2
  276. package/dist/viewer/infra.js +25 -2
  277. package/dist/viewer/limits.d.ts +4 -0
  278. package/dist/viewer/limits.js +4 -0
  279. package/dist/viewer/openapi.d.ts +15 -0
  280. package/dist/viewer/openapi.js +172 -0
  281. package/docs/README.md +7 -0
  282. package/docs/agent-recipes.md +57 -8
  283. package/docs/architecture-runtime.md +16 -1
  284. package/docs/code-review-agents.md +139 -5
  285. package/docs/coding-agent-setup.md +113 -5
  286. package/docs/coding-packages.md +4 -2
  287. package/docs/coding-services.md +3 -1
  288. package/docs/coding-worker-byo-images.md +198 -6
  289. package/docs/coding-worker-isolation.md +68 -14
  290. package/docs/getting-started-gke.md +225 -9
  291. package/docs/getting-started-identity-provider.md +21 -10
  292. package/docs/getting-started.md +5 -4
  293. package/docs/jira-agents.md +5 -1
  294. package/docs/native-sandbox.md +635 -0
  295. package/docs/release-verification.md +9 -0
  296. package/docs/security-deployment.md +113 -12
  297. package/docs/slack-notifications.md +252 -0
  298. package/docs/tool-secrets.md +252 -0
  299. package/docs/viewer-api.md +46 -5
  300. package/docs/viewer-api.openapi.yml +1404 -0
  301. package/help/admin-viewer.md +6 -2
  302. package/help/agent-recipes.md +27 -0
  303. package/help/architecture-agent.md +1 -1
  304. package/help/brokered-secrets.md +79 -0
  305. package/help/build-claude-code-image.md +131 -0
  306. package/help/build-worker-image.md +3 -6
  307. package/help/code-review-agents.md +68 -6
  308. package/help/deploy-gke.md +8 -1
  309. package/help/deployment-targets.md +25 -14
  310. package/help/errors/coding-provider-not-configured.md +2 -2
  311. package/help/errors/database-auth-invalid.md +28 -0
  312. package/help/errors/github-account-not-allowed.md +25 -0
  313. package/help/errors/low-power-disabled.md +29 -0
  314. package/help/errors/low-power-scale-forbidden.md +35 -0
  315. package/help/errors/low-power-wake-timeout.md +30 -0
  316. package/help/errors/mobile-disabled.md +31 -0
  317. package/help/errors/native-sandbox-capacity.md +22 -0
  318. package/help/errors/native-sandbox-deadline-exceeded.md +19 -0
  319. package/help/errors/native-sandbox-docker-failed.md +20 -0
  320. package/help/errors/native-sandbox-gateway-unavailable.md +21 -0
  321. package/help/errors/native-sandbox-image-not-pinned.md +19 -0
  322. package/help/errors/native-sandbox-isolation-mismatch.md +20 -0
  323. package/help/errors/native-sandbox-network-unenforced.md +22 -0
  324. package/help/errors/native-sandbox-requires-catalog.md +31 -0
  325. package/help/errors/native-sandbox-unavailable.md +19 -0
  326. package/help/errors/native-sandbox-warm-delivery-failed.md +21 -0
  327. package/help/errors/native-sandbox-worker-exited.md +20 -0
  328. package/help/errors/native-sandbox-worker-lost.md +20 -0
  329. package/help/errors/native-sandbox-worker-unready.md +22 -0
  330. package/help/errors/secret-broker-body-invalid.md +26 -0
  331. package/help/errors/secret-broker-changed.md +20 -0
  332. package/help/errors/secret-broker-config-invalid.md +30 -0
  333. package/help/errors/secret-broker-conflict.md +27 -0
  334. package/help/errors/secret-broker-destination-denied.md +28 -0
  335. package/help/errors/secret-broker-value-invalid.md +28 -0
  336. package/help/errors/secret-brokered.md +24 -0
  337. package/help/errors/secret-not-brokered.md +25 -0
  338. package/help/errors/slack-auth-failed.md +48 -0
  339. package/help/errors/slack-channel-unreachable.md +39 -0
  340. package/help/errors/slack-not-configured.md +33 -0
  341. package/help/getting-started.md +4 -5
  342. package/help/github.md +18 -1
  343. package/help/identity-and-access.md +5 -1
  344. package/help/local-repositories.md +2 -3
  345. package/help/low-power-mode.md +125 -0
  346. package/help/merge-order-check.md +117 -0
  347. package/help/mobile-app.md +54 -0
  348. package/help/native-sandbox.md +136 -0
  349. package/help/observability.md +8 -0
  350. package/help/related-pull-requests.md +70 -9
  351. package/help/repo-instructions-and-skills.md +91 -0
  352. package/help/review-fix-rounds.md +10 -0
  353. package/help/slack-notifications.md +62 -0
  354. package/help/troubleshooting/coding-workers.md +21 -0
  355. package/package.json +11 -4
  356. package/prisma/migrations/20261009000003_native_execution_mode/migration.sql +12 -0
  357. package/prisma/migrations/20261009000004_native_gateway_ledger/migration.sql +65 -0
  358. package/prisma/migrations/20261009000005_native_gateway_call_child_agent/migration.sql +5 -0
  359. package/prisma/migrations/20261009000006_run_deferred_start_stop/migration.sql +14 -0
  360. package/prisma/migrations/20261009000007_native_gateway_network_ready/migration.sql +5 -0
  361. package/prisma/migrations/20261010000001_workflow_notifications/migration.sql +99 -0
  362. package/prisma/migrations/20261011000001_native_gateway_session_status_index/migration.sql +2 -0
  363. package/prisma/migrations/20261012000001_native_warm_worker/migration.sql +32 -0
  364. package/prisma/migrations/20261013000001_coding_run_merge_order/migration.sql +2 -0
  365. package/prisma/migrations/20261013000001_secret_broker/migration.sql +17 -0
  366. package/prisma/migrations/20261013000002_deferred_review_request/migration.sql +4 -0
  367. package/prisma/migrations/20261014000000_repo_skills/migration.sql +15 -0
  368. package/prisma/migrations/20261015000001_coding_run_merge_order_index/migration.sql +2 -0
  369. package/prisma/migrations/20261015000002_no_change_rereview/migration.sql +2 -0
  370. package/prisma/schema.prisma +362 -91
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A static, best-effort check: a tool granted a brokered secret that still reads secrets with
3
+ * secrets.get() will fail at run time. Warns; never blocks (the scan can't see dynamic code).
4
+ */
5
+ export function toolCallsSecretsGet(code) {
6
+ return /\bsecrets\s*\.\s*get\s*\(/.test(code);
7
+ }
8
+ export function brokerCompatibilityWarnings(tools, brokeredNames) {
9
+ const warnings = [];
10
+ for (const tool of tools) {
11
+ const brokered = tool.allowedSecrets.filter((s) => brokeredNames.has(s));
12
+ if (!brokered.length || !toolCallsSecretsGet(tool.code))
13
+ continue;
14
+ const list = brokered.map((s) => `"${s}"`).join(", ");
15
+ const example = JSON.stringify(brokered);
16
+ warnings.push(`Tool "${tool.name}" reads secrets with secrets.get(), but ${list} ${brokered.length > 1 ? "are" : "is"} brokered: that call will fail with secret_brokered. Update the tool to send it with fetch(url, { secrets: ${example} }).`);
17
+ }
18
+ return warnings;
19
+ }
20
+ /** An attachment's `allowedSecrets` JSON column as names (anything else is ignored). */
21
+ export function allowedSecretNames(json) {
22
+ return Array.isArray(json) ? json.filter((s) => typeof s === "string") : [];
23
+ }
@@ -2,15 +2,26 @@
2
2
  * The one-time browser form a secret-elicitation URL points to — shared by
3
3
  * both hosts: stdio's ephemeral loopback server (secret-elicitation-server.ts)
4
4
  * and the HTTP transport's mounted route (wired in streamable-http.ts). No
5
- * JavaScript: a plain HTML form POST is enough for one password field, so
5
+ * JavaScript: a plain HTML form POST is enough for one password field (plus
6
+ * the optional broker fields, shown and hidden by a CSS `:checked` rule), so
6
7
  * CSP can forbid scripts outright rather than needing a nonce.
8
+ *
9
+ * Two pages share the route, chosen by the signed payload's `kind`: "create"
10
+ * (enter a value, optionally brokered) and "unbroker" (a person confirms
11
+ * removing a secret's broker config by typing its name). An unbroker link
12
+ * never shows or accepts a value.
7
13
  */
8
14
  import type { IncomingMessage, ServerResponse } from "node:http";
9
15
  import type { PrismaClient } from "#prisma";
10
16
  import type { SecretCipher } from "../../providers/secrets/types.js";
11
17
  import { type SecretElicitationPayload } from "./secret-elicitation.js";
18
+ import { type SecretBrokerConfig } from "../../core/secret-broker-config.js";
12
19
  /** The HTTP-transport route path this form is mounted at (see streamable-http.ts). */
13
20
  export declare const SECRET_ELICITATION_PATH = "/elicit/secret";
21
+ /** The "Brokered (recommended)" checkbox, warning, compatibility note, and broker fields, pre-filled from `prefill`. */
22
+ export declare function brokerFieldset(prefill?: SecretBrokerConfig): string;
23
+ /** The form's broker config: null when the box is unchecked; throws secret_broker_config_invalid when it doesn't validate. */
24
+ export declare function brokerConfigFromForm(body: URLSearchParams): SecretBrokerConfig | null;
14
25
  export interface SecretFormDeps {
15
26
  verify: (token: string) => Promise<SecretElicitationPayload>;
16
27
  secrets: SecretCipher;
@@ -18,5 +29,14 @@ export interface SecretFormDeps {
18
29
  }
19
30
  /** Handles one GET (render) or POST (submit) for the secret-entry form at the given token. */
20
31
  export declare function handleSecretElicitationForm(method: string | undefined, token: string | null, readFormBody: () => Promise<URLSearchParams>, res: ServerResponse, deps: SecretFormDeps): Promise<void>;
21
- /** Reads a small `application/x-www-form-urlencoded` body (8 KiB cap — a secret value, nothing more). */
32
+ /**
33
+ * The largest form body accepted: room for the largest valid AWS SigV4 value
34
+ * (an 8 KiB session token, up to 3x after URL encoding) plus the broker fields.
35
+ */
36
+ export declare const FORM_BODY_LIMIT: number;
37
+ /**
38
+ * Reads a small `application/x-www-form-urlencoded` body. Over FORM_BODY_LIMIT it
39
+ * rejects with `body_too_large` and discards the rest of the upload, so the
40
+ * handler can still answer 413 on the same connection.
41
+ */
22
42
  export declare function readFormBody(req: IncomingMessage): Promise<URLSearchParams>;
@@ -1,11 +1,107 @@
1
- import { fulfillSecretElicitation } from "./secret-elicitation.js";
1
+ import { fulfillSecretElicitation, fulfillUnbrokerElicitation, } from "./secret-elicitation.js";
2
+ import { listSecrets } from "../../core/secrets.js";
3
+ import { parseSecretBrokerConfig } from "../../core/secret-broker-config.js";
2
4
  import { PAGE_STYLE } from "../shared/page-style.js";
3
5
  /** The HTTP-transport route path this form is mounted at (see streamable-http.ts). */
4
6
  export const SECRET_ELICITATION_PATH = "/elicit/secret";
5
7
  function escape(value) {
6
8
  return value.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
7
9
  }
8
- function html(res, status, body) {
10
+ const BROKER_WARNING = "If this is not brokered, any tool attached with this secret can read its value and send it anywhere that tool can fetch — including when a prompt-injected agent tells it to.";
11
+ const BROKER_COMPAT = 'Brokered secrets only work with tools that call <code>fetch(url, { secrets: ["NAME"] })</code>. A tool that reads the value with <code>secrets.get()</code> will fail with <code>secret_brokered</code>.';
12
+ /** Script-free toggle: the fields after the checkbox show only while it is checked. */
13
+ const BROKER_STYLE = `
14
+ .broker-toggle { display: inline-block; margin: 0 0 12px 6px; color: var(--ink); }
15
+ .broker-fields { display: none; }
16
+ #brokered:checked ~ .broker-fields { display: block; }
17
+ .warning { border-left: 3px solid #b45309; padding-left: 8px; color: var(--ink); }
18
+ .broker-fields input, .broker-fields textarea, .broker-fields select, input[name="confirm"] {
19
+ display: block;
20
+ width: 100%;
21
+ margin-top: 8px;
22
+ padding: 10px 12px;
23
+ font: inherit;
24
+ font-size: 14px;
25
+ color: var(--ink);
26
+ background: var(--paper);
27
+ border: 1px solid var(--line);
28
+ border-radius: 10px;
29
+ }
30
+ code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
31
+ code { overflow-wrap: anywhere; }
32
+ pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 10px; background: var(--paper); border: 1px solid var(--line); border-radius: 10px; }
33
+ .hint { margin: 4px 0 12px; }
34
+ .broker-summary { margin: 0 0 12px; padding: 10px 10px 10px 28px; background: var(--paper); border: 1px solid var(--line); border-radius: 10px; color: var(--ink); }
35
+ .broker-summary li { margin: 2px 0; }
36
+ /* Show only the selected placement's fields; browsers without :has() show them all. */
37
+ @supports selector(:has(*)) {
38
+ .placement { display: none; }
39
+ .broker-fields:has(option[value="header"]:checked) .placement-header,
40
+ .broker-fields:has(option[value="query"]:checked) .placement-query,
41
+ .broker-fields:has(option[value="body"]:checked) .placement-body,
42
+ .broker-fields:has(option[value="aws-sigv4"]:checked) .placement-aws-sigv4 { display: block; }
43
+ }
44
+ `;
45
+ /** The "Brokered (recommended)" checkbox, warning, compatibility note, and broker fields, pre-filled from `prefill`. */
46
+ export function brokerFieldset(prefill) {
47
+ const p = prefill?.placement;
48
+ const sel = (kind) => (p?.kind === kind ? " selected" : "");
49
+ return (`<input type="checkbox" id="brokered" name="brokered" value="1"${prefill ? " checked" : ""}>` +
50
+ `<label class="broker-toggle" for="brokered">Brokered (recommended)</label>` +
51
+ `<p class="warning">${escape(BROKER_WARNING)}</p>` +
52
+ `<p>${BROKER_COMPAT}</p>` +
53
+ `<div class="broker-fields">` +
54
+ `<label>Allowed hosts (one per line)<textarea name="hosts" rows="2">${escape(prefill?.hosts.join("\n") ?? "")}</textarea></label>` +
55
+ `<label>Path prefixes (optional, one per line)<textarea name="pathPrefixes" rows="2">${escape(prefill?.pathPrefixes?.join("\n") ?? "")}</textarea></label>` +
56
+ `<label>Placement<select name="placement">` +
57
+ `<option value="header"${sel("header")}>Header</option><option value="query"${sel("query")}>Query parameter</option>` +
58
+ `<option value="body"${sel("body")}>Body field</option><option value="aws-sigv4"${sel("aws-sigv4")}>AWS SigV4</option></select></label>` +
59
+ `<div class="placement placement-header">` +
60
+ `<label>Header name<input name="headerName" value="${escape(p?.kind === "header" ? p.name : "Authorization")}"></label>` +
61
+ `<label>Header format<input name="headerFormat" value="${escape(p?.kind === "header" ? p.format : "Bearer {value}")}"></label>` +
62
+ `<p class="hint"><code>{value}</code> is replaced with the secret.</p>` +
63
+ `</div>` +
64
+ `<div class="placement placement-query">` +
65
+ `<label>Query parameter name<input name="queryName" value="${escape(p?.kind === "query" ? p.name : "")}"></label>` +
66
+ `</div>` +
67
+ `<div class="placement placement-body">` +
68
+ `<label>Body field name<input name="bodyField" value="${escape(p?.kind === "body" ? p.field : "")}"></label>` +
69
+ `<p class="hint">A top-level field of a JSON-object or form-encoded body.</p>` +
70
+ `</div>` +
71
+ `<div class="placement placement-aws-sigv4">` +
72
+ `<label>AWS region<input name="awsRegion" value="${escape(p?.kind === "aws-sigv4" ? p.region : "")}"></label>` +
73
+ `<label>AWS service<input name="awsService" value="${escape(p?.kind === "aws-sigv4" ? p.service : "")}"></label>` +
74
+ `<p class="hint">For AWS SigV4 the secret value is JSON (session token optional):</p>` +
75
+ `<pre>{\n "accessKeyId": "…",\n "secretAccessKey": "…",\n "sessionToken": "…"\n}</pre>` +
76
+ `</div>` +
77
+ `</div>`);
78
+ }
79
+ const lines = (v) => (v ?? "")
80
+ .split(/\r?\n/)
81
+ .map((s) => s.trim())
82
+ .filter(Boolean);
83
+ /** The form's broker config: null when the box is unchecked; throws secret_broker_config_invalid when it doesn't validate. */
84
+ export function brokerConfigFromForm(body) {
85
+ if (body.get("brokered") !== "1")
86
+ return null;
87
+ const kind = body.get("placement");
88
+ const placement = kind === "header"
89
+ ? { kind, name: body.get("headerName") ?? "", format: body.get("headerFormat") ?? "" }
90
+ : kind === "query"
91
+ ? { kind, name: body.get("queryName") ?? "" }
92
+ : kind === "body"
93
+ ? { kind, field: body.get("bodyField") ?? "" }
94
+ : kind === "aws-sigv4"
95
+ ? { kind, region: body.get("awsRegion") ?? "", service: body.get("awsService") ?? "" }
96
+ : { kind };
97
+ const prefixes = lines(body.get("pathPrefixes"));
98
+ return parseSecretBrokerConfig({
99
+ hosts: lines(body.get("hosts")),
100
+ ...(prefixes.length > 0 && { pathPrefixes: prefixes }),
101
+ placement,
102
+ });
103
+ }
104
+ function html(res, status, body, extraStyle = "") {
9
105
  res.setHeader("content-security-policy", "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'");
10
106
  res.setHeader("cache-control", "no-store");
11
107
  res.setHeader("x-content-type-options", "nosniff");
@@ -18,7 +114,7 @@ function html(res, status, body) {
18
114
  res
19
115
  .writeHead(status, { "content-type": "text/html; charset=utf-8" })
20
116
  .end(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">` +
21
- `<title>wardby secret entry</title><style>${PAGE_STYLE}</style><body><div class="card"><div class="kicker">wardby</div>${body}</div></body></html>`);
117
+ `<title>wardby secret entry</title><style>${PAGE_STYLE}${extraStyle}</style><body><div class="card"><div class="kicker">wardby</div>${body}</div></body></html>`);
22
118
  }
23
119
  /** Handles one GET (render) or POST (submit) for the secret-entry form at the given token. */
24
120
  export async function handleSecretElicitationForm(method, token, readFormBody, res, deps) {
@@ -32,21 +128,36 @@ export async function handleSecretElicitationForm(method, token, readFormBody, r
32
128
  html(res, 400, "<h1>Link expired</h1><p>This link is invalid or has expired. Return to your MCP client and try again.</p>");
33
129
  return;
34
130
  }
131
+ if ((payload.kind ?? "create") === "unbroker") {
132
+ await handleUnbroker(method, payload, readFormBody, res, deps);
133
+ return;
134
+ }
35
135
  if (method === "GET") {
36
136
  html(res, 200, `<h1>Enter secret value</h1><p>Name: <strong>${escape(payload.secretName)}</strong></p>` +
37
137
  `<form method="post"><label>Secret value` +
38
138
  `<input type="password" name="value" autocomplete="off" required autofocus placeholder="Paste the value here"></label>` +
39
- `<button type="submit">Save</button></form>`);
139
+ brokerFieldset(payload.broker) +
140
+ `<button type="submit">Save</button></form>`, BROKER_STYLE);
40
141
  return;
41
142
  }
42
143
  if (method === "POST") {
43
- const body = await readFormBody();
144
+ const body = await readBodyOr413(readFormBody, res);
145
+ if (!body)
146
+ return;
44
147
  const value = body.get("value") ?? "";
45
148
  if (!value) {
46
149
  html(res, 400, "<h1>A value is required.</h1>");
47
150
  return;
48
151
  }
49
- const outcome = await fulfillSecretElicitation(payload, value, deps.secrets, deps.db);
152
+ let broker;
153
+ try {
154
+ broker = brokerConfigFromForm(body);
155
+ }
156
+ catch (err) {
157
+ html(res, 400, `<h1>Could not save</h1><p>${escape(err instanceof Error ? err.message : String(err))}</p>`);
158
+ return;
159
+ }
160
+ const outcome = await fulfillSecretElicitation(payload, value, deps.secrets, deps.db, broker);
50
161
  if (outcome.ok)
51
162
  html(res, 200, "<h1>Saved</h1><p>You can close this tab and return to your MCP client.</p>");
52
163
  else
@@ -55,21 +166,100 @@ export async function handleSecretElicitationForm(method, token, readFormBody, r
55
166
  }
56
167
  res.writeHead(405).end();
57
168
  }
58
- /** Reads a small `application/x-www-form-urlencoded` body (8 KiB cap — a secret value, nothing more). */
169
+ /** Reads the POST body, or answers 413 (over the cap) and returns undefined. */
170
+ async function readBodyOr413(readFormBody, res) {
171
+ try {
172
+ return await readFormBody();
173
+ }
174
+ catch (err) {
175
+ if (!(err instanceof Error && err.message === "body_too_large"))
176
+ throw err;
177
+ res.setHeader("connection", "close");
178
+ html(res, 413, "<h1>Value too large</h1><p>The form was larger than this page accepts.</p>");
179
+ return undefined;
180
+ }
181
+ }
182
+ /** A readable, escaped summary of a broker config for the unbroker page. */
183
+ function describeBroker(broker) {
184
+ if (!broker)
185
+ return "<p>(not brokered)</p>";
186
+ const p = broker.placement;
187
+ const placement = p.kind === "header"
188
+ ? `header <code>${escape(p.name)}: ${escape(p.format)}</code>`
189
+ : p.kind === "query"
190
+ ? `query parameter <code>${escape(p.name)}</code>`
191
+ : p.kind === "body"
192
+ ? `body field <code>${escape(p.field)}</code>`
193
+ : `AWS SigV4 (region <code>${escape(p.region)}</code>, service <code>${escape(p.service)}</code>)`;
194
+ const list = (items) => items.map((item) => `<code>${escape(item)}</code>`).join(", ");
195
+ return (`<ul class="broker-summary">` +
196
+ `<li>Hosts: ${list(broker.hosts)}</li>` +
197
+ (broker.pathPrefixes?.length ? `<li>Paths: ${list(broker.pathPrefixes)}</li>` : "") +
198
+ `<li>Sent as: ${placement}</li>` +
199
+ `</ul>`);
200
+ }
201
+ /** The unbroker link's page: show what is brokered now, and remove it only when the person types the secret's name. */
202
+ async function handleUnbroker(method, payload, readFormBody, res, deps) {
203
+ if (method === "GET") {
204
+ const current = (await listSecrets(payload.ownerId, deps.db)).find((s) => s.name === payload.secretName);
205
+ html(res, 200, `<h1>Remove brokering</h1><p>Name: <strong>${escape(payload.secretName)}</strong></p>` +
206
+ `<p>Currently brokered as:</p>${describeBroker(current?.broker ?? null)}` +
207
+ `<p class="warning">${escape(BROKER_WARNING)}</p>` +
208
+ `<form method="post"><label>Type the secret's name to confirm` +
209
+ `<input name="confirm" autocomplete="off" required autofocus></label>` +
210
+ `<button type="submit">Remove brokering</button></form>`, BROKER_STYLE);
211
+ return;
212
+ }
213
+ if (method === "POST") {
214
+ const body = await readBodyOr413(readFormBody, res);
215
+ if (!body)
216
+ return;
217
+ if (body.get("confirm") !== payload.secretName) {
218
+ html(res, 400, `<h1>${escape("Type the secret's name to confirm.")}</h1>`);
219
+ return;
220
+ }
221
+ const outcome = await fulfillUnbrokerElicitation(payload, deps.secrets, deps.db);
222
+ if (outcome.ok) {
223
+ html(res, 200, "<h1>Brokering removed</h1><p>You can close this tab and return to your MCP client.</p>");
224
+ }
225
+ else {
226
+ html(res, 400, `<h1>Could not remove brokering</h1><p>${escape(outcome.error)}</p>`);
227
+ }
228
+ return;
229
+ }
230
+ res.writeHead(405).end();
231
+ }
232
+ /**
233
+ * The largest form body accepted: room for the largest valid AWS SigV4 value
234
+ * (an 8 KiB session token, up to 3x after URL encoding) plus the broker fields.
235
+ */
236
+ export const FORM_BODY_LIMIT = 32 * 1024;
237
+ /**
238
+ * Reads a small `application/x-www-form-urlencoded` body. Over FORM_BODY_LIMIT it
239
+ * rejects with `body_too_large` and discards the rest of the upload, so the
240
+ * handler can still answer 413 on the same connection.
241
+ */
59
242
  export function readFormBody(req) {
60
243
  return new Promise((resolve, reject) => {
61
244
  let size = 0;
245
+ let tooLarge = false;
62
246
  const chunks = [];
63
247
  req.on("data", (chunk) => {
248
+ if (tooLarge)
249
+ return;
64
250
  size += chunk.length;
65
- if (size > 8192) {
251
+ if (size > FORM_BODY_LIMIT) {
252
+ tooLarge = true;
253
+ chunks.length = 0;
66
254
  reject(new Error("body_too_large"));
67
- req.destroy();
68
255
  return;
69
256
  }
70
257
  chunks.push(chunk);
71
258
  });
72
- req.on("end", () => resolve(new URLSearchParams(Buffer.concat(chunks).toString("utf8"))));
259
+ req.on("end", () => {
260
+ if (!tooLarge)
261
+ resolve(new URLSearchParams(Buffer.concat(chunks).toString("utf8")));
262
+ });
73
263
  req.on("error", reject);
74
264
  });
75
265
  }
@@ -36,7 +36,14 @@ export function createStdioSecretElicitationHost(deps) {
36
36
  res.writeHead(404).end();
37
37
  return;
38
38
  }
39
- void handleSecretElicitationForm(req.method, url.searchParams.get("t"), () => readFormBody(req), res, deps);
39
+ // A failure here must never become an unhandled rejection: that would take
40
+ // down the whole stdio MCP process, not just this request.
41
+ handleSecretElicitationForm(req.method, url.searchParams.get("t"), () => readFormBody(req), res, deps).catch(() => {
42
+ if (!res.headersSent)
43
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
44
+ if (!res.writableEnded)
45
+ res.end("Internal error");
46
+ });
40
47
  });
41
48
  server = s;
42
49
  await new Promise((resolve, reject) => {
@@ -14,25 +14,60 @@
14
14
  * elicitation nobody completes or polls within that window is abandoned).
15
15
  * Expiry is swept lazily on every read and write via a `deleteMany` rather
16
16
  * than a timer, so an idle process holds no interval.
17
+ *
18
+ * Two kinds share the table: "create" (enter a secret's value, optionally
19
+ * brokered) and "unbroker" (a person confirms removing a secret's broker
20
+ * config: `set_secret_broker { broker: null }` never removes it directly).
21
+ * Payloads and outcomes written before `kind` existed have none and count as
22
+ * "create".
17
23
  */
18
24
  import type { PrismaClient } from "#prisma";
19
25
  import { type SecretMetadata } from "../../core/secrets.js";
26
+ import { type SecretBrokerConfig } from "../../core/secret-broker-config.js";
20
27
  import type { SecretCipher } from "../../providers/secrets/types.js";
28
+ export type SecretElicitationKind = "create" | "unbroker";
21
29
  export interface SecretElicitationPayload {
22
30
  ownerId: string;
23
31
  secretName: string;
32
+ /** Missing on links minted before brokered secrets: those are "create". */
33
+ kind?: SecretElicitationKind;
34
+ /** "create" only: the broker config to save with the value (the form pre-fills it). */
35
+ broker?: SecretBrokerConfig;
36
+ /**
37
+ * "unbroker" only: brokerConfigHash of the config the link was minted for. The
38
+ * removal is refused if the secret's config has changed since (or the link has none).
39
+ */
40
+ brokerHash?: string;
24
41
  }
25
42
  export type SecretElicitationOutcome = {
26
43
  ok: true;
44
+ kind: SecretElicitationKind;
27
45
  secret: SecretMetadata;
28
46
  } | {
29
47
  ok: false;
48
+ kind: SecretElicitationKind;
30
49
  error: string;
31
50
  };
32
- export declare function getSecretElicitationOutcome(ownerId: string, secretName: string, db: PrismaClient): Promise<SecretElicitationOutcome | undefined>;
51
+ export declare function getSecretElicitationOutcome(ownerId: string, secretName: string, db: PrismaClient, kind?: SecretElicitationKind): Promise<SecretElicitationOutcome | undefined>;
33
52
  /**
34
53
  * Called by the browser form's POST handler. Idempotent — a resubmit for
35
54
  * the same (ownerId, secretName) that's already fulfilled returns the
36
- * recorded outcome rather than writing again.
55
+ * recorded outcome rather than writing again. A value or broker config that
56
+ * fails validation is returned but not recorded, so the person can correct it. `broker` (the form's,
57
+ * pre-filled from the payload) makes the new secret brokered.
58
+ */
59
+ export declare function fulfillSecretElicitation(payload: SecretElicitationPayload, value: string, cipher: SecretCipher, db: PrismaClient, broker?: SecretBrokerConfig | null): Promise<SecretElicitationOutcome>;
60
+ /**
61
+ * Called by the unbroker form's POST: removes brokering (audited, via
62
+ * "browser", the owner as actor). Idempotent like fulfillSecretElicitation.
63
+ * A link only removes the config it was minted for: if brokering changed
64
+ * since (or the link carries no brokerHash) it is refused without recording
65
+ * an outcome, so a fresh link for the current config still works.
66
+ */
67
+ export declare function fulfillUnbrokerElicitation(payload: SecretElicitationPayload, cipher: SecretCipher, db: PrismaClient): Promise<SecretElicitationOutcome>;
68
+ /**
69
+ * Forgets a recorded unbroker outcome for this secret: called when brokering
70
+ * is set again, so a removal confirmed earlier can't answer (or short-circuit)
71
+ * a later removal request.
37
72
  */
38
- export declare function fulfillSecretElicitation(payload: SecretElicitationPayload, value: string, cipher: SecretCipher, db: PrismaClient): Promise<SecretElicitationOutcome>;
73
+ export declare function clearUnbrokerOutcome(ownerId: string, secretName: string, db: PrismaClient): Promise<void>;
@@ -1,34 +1,72 @@
1
- import { createSecret } from "../../core/secrets.js";
1
+ import { createSecret, listSecrets, setSecretBroker } from "../../core/secrets.js";
2
+ import { parseSecretBrokerConfig, SECRET_BROKER_CHANGED, } from "../../core/secret-broker-config.js";
3
+ /**
4
+ * A link only does what it was minted for: a create link can't confirm an
5
+ * unbroker, nor the reverse. Refused without recording an outcome.
6
+ */
7
+ function kindMismatch(payload, expected) {
8
+ const actual = payload.kind ?? "create";
9
+ if (actual === expected)
10
+ return undefined;
11
+ return {
12
+ ok: false,
13
+ kind: expected,
14
+ error: `elicitation_kind_mismatch: this link is for a "${actual}" request, not "${expected}"`,
15
+ };
16
+ }
17
+ /** A stored outcome's kind; one written before `kind` existed is a "create". */
18
+ function kindOf(outcome) {
19
+ return outcome.kind ?? "create";
20
+ }
2
21
  const ELICITATION_TTL_MS = 600_000;
3
22
  /** Sweeps every expired entry, not just the one being looked up, so a set-and-never-polled-again entry doesn't linger forever. */
4
23
  async function pruneExpired(db, now) {
5
24
  await db.secretElicitationOutcome.deleteMany({ where: { expiresAt: { lte: now } } });
6
25
  }
7
- export async function getSecretElicitationOutcome(ownerId, secretName, db) {
26
+ export async function getSecretElicitationOutcome(ownerId, secretName, db, kind = "create") {
8
27
  const now = new Date();
9
28
  await pruneExpired(db, now);
10
29
  const row = await db.secretElicitationOutcome.findUnique({ where: { ownerId_secretName: { ownerId, secretName } } });
11
- return row?.outcome;
30
+ if (!row || kindOf(row.outcome) !== kind)
31
+ return undefined;
32
+ return row.outcome;
33
+ }
34
+ async function recordOutcome(db, ownerId, secretName, outcome, now) {
35
+ const expiresAt = new Date(now.getTime() + ELICITATION_TTL_MS);
36
+ await db.secretElicitationOutcome.upsert({
37
+ where: { ownerId_secretName: { ownerId, secretName } },
38
+ create: { ownerId, secretName, outcome, expiresAt },
39
+ update: { outcome, expiresAt },
40
+ });
12
41
  }
13
42
  /**
14
43
  * Called by the browser form's POST handler. Idempotent — a resubmit for
15
44
  * the same (ownerId, secretName) that's already fulfilled returns the
16
- * recorded outcome rather than writing again.
45
+ * recorded outcome rather than writing again. A value or broker config that
46
+ * fails validation is returned but not recorded, so the person can correct it. `broker` (the form's,
47
+ * pre-filled from the payload) makes the new secret brokered.
17
48
  */
18
- export async function fulfillSecretElicitation(payload, value, cipher, db) {
49
+ export async function fulfillSecretElicitation(payload, value, cipher, db, broker) {
50
+ const mismatch = kindMismatch(payload, "create");
51
+ if (mismatch)
52
+ return mismatch;
19
53
  const now = new Date();
20
54
  await pruneExpired(db, now);
21
55
  const { ownerId, secretName } = payload;
22
56
  const existing = await db.secretElicitationOutcome.findUnique({
23
57
  where: { ownerId_secretName: { ownerId, secretName } },
24
58
  });
25
- if (existing)
59
+ if (existing && kindOf(existing.outcome) === "create")
26
60
  return existing.outcome;
27
61
  let outcome;
28
62
  try {
29
- const secret = await createSecret(secretName, value, ownerId, cipher, db);
63
+ const secret = await createSecret(secretName, value, ownerId, cipher, db, {
64
+ broker: broker ?? undefined,
65
+ via: "browser",
66
+ });
30
67
  outcome = {
31
68
  ok: true,
69
+ kind: "create",
32
70
  secret: {
33
71
  id: secret.id,
34
72
  name: secret.name,
@@ -36,17 +74,77 @@ export async function fulfillSecretElicitation(payload, value, cipher, db) {
36
74
  ownerId: secret.ownerId,
37
75
  createdAt: secret.createdAt,
38
76
  updatedAt: secret.updatedAt,
77
+ // A rotation without a broker keeps the stored config, so report what was saved.
78
+ broker: secret.broker === null || secret.broker === undefined ? null : parseSecretBrokerConfig(secret.broker),
39
79
  },
40
80
  };
41
81
  }
42
82
  catch (err) {
43
- outcome = { ok: false, error: err instanceof Error ? err.message : String(err) };
83
+ outcome = { ok: false, kind: "create", error: err instanceof Error ? err.message : String(err) };
84
+ // A value or config the person can fix: not recorded, so the same link takes a corrected
85
+ // submission and create_secret keeps waiting rather than replaying this error.
86
+ if (isCorrectableInput(outcome.error))
87
+ return outcome;
44
88
  }
45
- const expiresAt = new Date(now.getTime() + ELICITATION_TTL_MS);
46
- await db.secretElicitationOutcome.upsert({
89
+ await recordOutcome(db, ownerId, secretName, outcome, now);
90
+ return outcome;
91
+ }
92
+ const CORRECTABLE_ERRORS = ["secret_broker_value_invalid", "secret_broker_config_invalid", "bridge_input_limit"];
93
+ function isCorrectableInput(message) {
94
+ return CORRECTABLE_ERRORS.some((code) => message.startsWith(code));
95
+ }
96
+ /**
97
+ * Called by the unbroker form's POST: removes brokering (audited, via
98
+ * "browser", the owner as actor). Idempotent like fulfillSecretElicitation.
99
+ * A link only removes the config it was minted for: if brokering changed
100
+ * since (or the link carries no brokerHash) it is refused without recording
101
+ * an outcome, so a fresh link for the current config still works.
102
+ */
103
+ export async function fulfillUnbrokerElicitation(payload, cipher, db) {
104
+ const mismatch = kindMismatch(payload, "unbroker");
105
+ if (mismatch)
106
+ return mismatch;
107
+ const changed = { ok: false, kind: "unbroker", error: SECRET_BROKER_CHANGED };
108
+ if (!payload.brokerHash)
109
+ return changed;
110
+ const now = new Date();
111
+ await pruneExpired(db, now);
112
+ const { ownerId, secretName } = payload;
113
+ const existing = await db.secretElicitationOutcome.findUnique({
47
114
  where: { ownerId_secretName: { ownerId, secretName } },
48
- create: { ownerId, secretName, outcome, expiresAt },
49
- update: { outcome, expiresAt },
50
115
  });
116
+ if (existing && kindOf(existing.outcome) === "unbroker")
117
+ return existing.outcome;
118
+ let outcome;
119
+ try {
120
+ await setSecretBroker(db, cipher, {
121
+ ownerId,
122
+ name: secretName,
123
+ broker: null,
124
+ actorId: ownerId,
125
+ via: "browser",
126
+ expectedBrokerHash: payload.brokerHash,
127
+ });
128
+ const secret = (await listSecrets(ownerId, db)).find((s) => s.name === secretName);
129
+ if (!secret)
130
+ throw new Error(`secret_not_found: no secret named "${secretName}"`);
131
+ outcome = { ok: true, kind: "unbroker", secret };
132
+ }
133
+ catch (err) {
134
+ if (err instanceof Error && err.message === SECRET_BROKER_CHANGED)
135
+ return changed;
136
+ outcome = { ok: false, kind: "unbroker", error: err instanceof Error ? err.message : String(err) };
137
+ }
138
+ await recordOutcome(db, ownerId, secretName, outcome, now);
51
139
  return outcome;
52
140
  }
141
+ /**
142
+ * Forgets a recorded unbroker outcome for this secret: called when brokering
143
+ * is set again, so a removal confirmed earlier can't answer (or short-circuit)
144
+ * a later removal request.
145
+ */
146
+ export async function clearUnbrokerOutcome(ownerId, secretName, db) {
147
+ await db.secretElicitationOutcome.deleteMany({
148
+ where: { ownerId, secretName, outcome: { path: ["kind"], equals: "unbroker" } },
149
+ });
150
+ }