toolroll 0.9.62 → 0.9.65

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 (345) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +66 -8
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +203 -10
  16. package/dist/agent-fence.js +623 -41
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +36 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/browser/THIRD_PARTY_NOTICES.txt +48 -0
  32. package/dist/browser/terminal.css +37 -0
  33. package/dist/browser/terminal.js +117 -0
  34. package/dist/browser/workspace.css +2 -2
  35. package/dist/browser/workspace.js +42 -38
  36. package/dist/browser-shell.js +6 -3
  37. package/dist/browser-workspace.d.ts +263 -1
  38. package/dist/browser-workspace.js +60 -0
  39. package/dist/builder.d.ts +6 -1
  40. package/dist/builder.js +34 -13
  41. package/dist/chat-actions.d.ts +13 -1
  42. package/dist/chat-actions.js +199 -17
  43. package/dist/chat-ask.d.ts +4 -0
  44. package/dist/chat-ask.js +14 -0
  45. package/dist/chat-channel.js +20 -0
  46. package/dist/chat-continuity.js +5 -4
  47. package/dist/chat-decide.d.ts +0 -14
  48. package/dist/chat-decide.js +4 -1
  49. package/dist/chat-delivery.d.ts +9 -0
  50. package/dist/chat-delivery.js +45 -6
  51. package/dist/chat-messages.d.ts +8 -0
  52. package/dist/chat-messages.js +36 -4
  53. package/dist/check-levels-ui.js +2 -2
  54. package/dist/check-levels.js +3 -0
  55. package/dist/checkout-proof.d.ts +28 -0
  56. package/dist/checkout-proof.js +62 -0
  57. package/dist/claim.d.ts +12 -0
  58. package/dist/claim.js +20 -1
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +5 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +24 -2
  63. package/dist/coding-ui.js +6 -4
  64. package/dist/console-instance.d.ts +124 -0
  65. package/dist/console-instance.js +337 -0
  66. package/dist/contracts/chat-actions.d.ts +16 -0
  67. package/dist/contracts/chat-actions.js +4 -0
  68. package/dist/contracts/chat-content.d.ts +19 -0
  69. package/dist/contracts/chat-content.js +6 -0
  70. package/dist/contracts/cli.d.ts +8 -0
  71. package/dist/contracts/cli.js +3 -1
  72. package/dist/contracts/console-api.d.ts +30 -4
  73. package/dist/contracts/console-api.js +13 -4
  74. package/dist/contracts/flow-card.d.ts +4 -0
  75. package/dist/contracts/flow-card.js +3 -0
  76. package/dist/contracts/flow.d.ts +46 -0
  77. package/dist/contracts/flow.js +11 -2
  78. package/dist/contracts/integration-metadata.d.ts +2 -1
  79. package/dist/contracts/integration-metadata.js +2 -1
  80. package/dist/contracts/lead-tools.d.ts +92 -0
  81. package/dist/contracts/lead-tools.js +13 -0
  82. package/dist/contracts/provider.d.ts +11 -1
  83. package/dist/contracts/provider.js +1 -1
  84. package/dist/contracts/route.d.ts +76 -1
  85. package/dist/contracts/route.js +1 -1
  86. package/dist/contracts/store-json.d.ts +2 -0
  87. package/dist/control-setup.d.ts +1 -1
  88. package/dist/control-ui.js +2 -2
  89. package/dist/converse.d.ts +16 -0
  90. package/dist/converse.js +32 -4
  91. package/dist/demo-chat.js +4 -4
  92. package/dist/demo.js +2 -2
  93. package/dist/desktop-update-gate.js +6 -0
  94. package/dist/discord-chat.js +4 -1
  95. package/dist/dispatch.js +7 -4
  96. package/dist/doctor.d.ts +53 -0
  97. package/dist/doctor.js +427 -0
  98. package/dist/egress-bridge.d.ts +17 -0
  99. package/dist/egress-bridge.js +111 -0
  100. package/dist/egress.d.ts +119 -0
  101. package/dist/egress.js +423 -0
  102. package/dist/evidence-pack.d.ts +1 -1
  103. package/dist/evidence-pack.js +2 -2
  104. package/dist/exec.d.ts +81 -0
  105. package/dist/exec.js +368 -9
  106. package/dist/export-ui.js +2 -2
  107. package/dist/flow-code.d.ts +2 -2
  108. package/dist/flow-code.js +4 -15
  109. package/dist/flow-engine.js +35 -11
  110. package/dist/flow-gallery-ui.js +2 -2
  111. package/dist/flow-gallery.js +27 -8
  112. package/dist/flow-share.js +16 -5
  113. package/dist/flow-triggers.d.ts +84 -11
  114. package/dist/flow-triggers.js +554 -87
  115. package/dist/flow-unattended-words.d.ts +28 -0
  116. package/dist/flow-unattended-words.js +50 -0
  117. package/dist/flow-unattended.d.ts +227 -0
  118. package/dist/flow-unattended.js +776 -0
  119. package/dist/flows-cli.d.ts +28 -1
  120. package/dist/flows-cli.js +63 -12
  121. package/dist/flows-ui.d.ts +2 -3
  122. package/dist/flows-ui.js +56 -5
  123. package/dist/flows.d.ts +4 -0
  124. package/dist/flows.js +9 -1
  125. package/dist/google-mail.d.ts +1 -0
  126. package/dist/google-mail.js +61 -18
  127. package/dist/html.d.ts +9 -0
  128. package/dist/html.js +11 -0
  129. package/dist/integrations-ui.d.ts +2 -1
  130. package/dist/integrations-ui.js +6 -3
  131. package/dist/integrations.d.ts +17 -3
  132. package/dist/integrations.js +38 -18
  133. package/dist/invoke.d.ts +17 -0
  134. package/dist/invoke.js +510 -182
  135. package/dist/keys.d.ts +18 -14
  136. package/dist/keys.js +5 -0
  137. package/dist/landed-cleanup.d.ts +3 -2
  138. package/dist/landed-cleanup.js +12 -5
  139. package/dist/lead-capabilities.js +3 -2
  140. package/dist/lead-contract.js +2 -0
  141. package/dist/lead-doors.d.ts +2 -0
  142. package/dist/lead-doors.js +7 -0
  143. package/dist/lead-follow.d.ts +2 -0
  144. package/dist/lead-follow.js +58 -2
  145. package/dist/lead-progress.js +1 -0
  146. package/dist/lead-queue-runtime.d.ts +46 -0
  147. package/dist/lead-queue-runtime.js +191 -0
  148. package/dist/lead-queue.d.ts +104 -0
  149. package/dist/lead-queue.js +187 -0
  150. package/dist/lead-sweep.d.ts +67 -0
  151. package/dist/lead-sweep.js +99 -0
  152. package/dist/lead-tools.d.ts +22 -0
  153. package/dist/lead-tools.js +163 -4
  154. package/dist/lead.d.ts +13 -5
  155. package/dist/lead.js +89 -29
  156. package/dist/limits-ui.d.ts +8 -1
  157. package/dist/limits-ui.js +26 -0
  158. package/dist/live-bus.d.ts +8 -0
  159. package/dist/live-bus.js +40 -4
  160. package/dist/live-detail.d.ts +27 -0
  161. package/dist/live-detail.js +57 -0
  162. package/dist/live-notes.d.ts +20 -0
  163. package/dist/live-notes.js +47 -0
  164. package/dist/live-records.d.ts +140 -0
  165. package/dist/live-records.js +935 -0
  166. package/dist/live-terminal.d.ts +258 -0
  167. package/dist/live-terminal.js +649 -0
  168. package/dist/live.d.ts +38 -27
  169. package/dist/live.js +142 -124
  170. package/dist/mcp-connect.d.ts +8 -7
  171. package/dist/mcp-connect.js +49 -51
  172. package/dist/mcp-oauth.d.ts +6 -3
  173. package/dist/mcp-oauth.js +8 -4
  174. package/dist/memory-pass.js +2 -2
  175. package/dist/mobile-viewport.d.ts +5 -1
  176. package/dist/mobile-viewport.js +18 -2
  177. package/dist/model-catalog.js +4 -0
  178. package/dist/monitoring-ui.js +2 -2
  179. package/dist/needs-you.d.ts +1 -1
  180. package/dist/needs-you.js +11 -4
  181. package/dist/oauth-run-token.d.ts +51 -0
  182. package/dist/oauth-run-token.js +127 -0
  183. package/dist/operate.d.ts +2 -0
  184. package/dist/operate.js +282 -18
  185. package/dist/passwordless.d.ts +23 -0
  186. package/dist/passwordless.js +26 -0
  187. package/dist/peek-cli.d.ts +12 -4
  188. package/dist/peek-cli.js +19 -11
  189. package/dist/phase-routing.js +10 -3
  190. package/dist/plan-auto.d.ts +11 -0
  191. package/dist/plan-auto.js +62 -11
  192. package/dist/planner-source.d.ts +3 -0
  193. package/dist/planner-source.js +14 -0
  194. package/dist/planner.d.ts +2 -1
  195. package/dist/planner.js +7 -1
  196. package/dist/policy-ui.js +2 -2
  197. package/dist/policy.d.ts +1 -20
  198. package/dist/policy.js +29 -2
  199. package/dist/process-recovery-eligibility.d.ts +1 -1
  200. package/dist/project-access.d.ts +3 -0
  201. package/dist/project-access.js +3 -0
  202. package/dist/project-delete-ui.d.ts +12 -0
  203. package/dist/project-delete-ui.js +41 -9
  204. package/dist/project-delete.d.ts +3 -1
  205. package/dist/project-delete.js +12 -6
  206. package/dist/project-file.d.ts +3 -0
  207. package/dist/project-file.js +16 -0
  208. package/dist/project-memory.d.ts +3 -1
  209. package/dist/project-memory.js +57 -25
  210. package/dist/project-tools.d.ts +103 -27
  211. package/dist/project-tools.js +399 -100
  212. package/dist/provider-auth.d.ts +4 -3
  213. package/dist/provider-auth.js +5 -4
  214. package/dist/provider-connection.d.ts +2 -1
  215. package/dist/provider-connection.js +5 -0
  216. package/dist/provider-home.d.ts +22 -0
  217. package/dist/provider-home.js +99 -0
  218. package/dist/provider.d.ts +42 -5
  219. package/dist/provider.js +97 -19
  220. package/dist/pull-request-ui.js +2 -2
  221. package/dist/push.js +1 -1
  222. package/dist/quick-start.d.ts +24 -0
  223. package/dist/quick-start.js +127 -0
  224. package/dist/release.d.ts +3 -0
  225. package/dist/release.js +14 -8
  226. package/dist/remote-main.d.ts +4 -0
  227. package/dist/remote-main.js +3 -3
  228. package/dist/request-budget-ui.js +2 -2
  229. package/dist/result-review.d.ts +1 -1
  230. package/dist/result-review.js +9 -3
  231. package/dist/retention-ui.js +2 -2
  232. package/dist/retention.js +10 -1
  233. package/dist/run-efficiency.d.ts +73 -0
  234. package/dist/run-efficiency.js +228 -0
  235. package/dist/runner.d.ts +5 -1
  236. package/dist/runner.js +22 -1
  237. package/dist/scope.d.ts +171 -6
  238. package/dist/scope.js +251 -31
  239. package/dist/scout-net.d.ts +4 -1
  240. package/dist/scout-net.js +37 -12
  241. package/dist/scout.d.ts +20 -6
  242. package/dist/scout.js +27 -3
  243. package/dist/serve.js +258 -43
  244. package/dist/server/chat.d.ts +1 -1
  245. package/dist/server/chat.js +122 -31
  246. package/dist/server/chrome.d.ts +6 -1
  247. package/dist/server/chrome.js +18 -7
  248. package/dist/server/console.css +52 -38
  249. package/dist/server/flows.js +65 -13
  250. package/dist/server/guards.d.ts +1 -0
  251. package/dist/server/guards.js +23 -9
  252. package/dist/server/http.d.ts +7 -0
  253. package/dist/server/http.js +3 -2
  254. package/dist/server/live.d.ts +34 -1
  255. package/dist/server/live.js +39 -17
  256. package/dist/server/pages-assets.js +5 -0
  257. package/dist/server/pages.js +90 -13
  258. package/dist/server/people-tokens.d.ts +3 -0
  259. package/dist/server/people-tokens.js +55 -17
  260. package/dist/server/remote-hooks.js +12 -6
  261. package/dist/server/remote.d.ts +2 -1
  262. package/dist/server/remote.js +14 -5
  263. package/dist/server/render-chat.d.ts +12 -3
  264. package/dist/server/render-chat.js +47 -16
  265. package/dist/server/render-pages.d.ts +4 -1
  266. package/dist/server/render-pages.js +5 -4
  267. package/dist/server/render-people.d.ts +3 -2
  268. package/dist/server/render-people.js +3 -2
  269. package/dist/server/render-results.d.ts +18 -2
  270. package/dist/server/render-results.js +37 -28
  271. package/dist/server/render-settings.js +6 -5
  272. package/dist/server/render-tasks.d.ts +35 -10
  273. package/dist/server/render-tasks.js +110 -27
  274. package/dist/server/request-context.d.ts +2 -0
  275. package/dist/server/request-context.js +2 -1
  276. package/dist/server/route-table.d.ts +3 -0
  277. package/dist/server/route-table.js +49 -5
  278. package/dist/server/runtime.d.ts +34 -3
  279. package/dist/server/session.d.ts +25 -1
  280. package/dist/server/session.js +18 -4
  281. package/dist/server/settings.js +132 -13
  282. package/dist/server/sweeps.d.ts +4 -0
  283. package/dist/server/sweeps.js +58 -0
  284. package/dist/server/task-views.d.ts +2 -1
  285. package/dist/server/task-views.js +96 -6
  286. package/dist/server/tasks.js +320 -74
  287. package/dist/server/terminals.d.ts +31 -0
  288. package/dist/server/terminals.js +365 -0
  289. package/dist/setup-guide.js +3 -2
  290. package/dist/slack-chat.js +4 -1
  291. package/dist/spend-ui.js +3 -3
  292. package/dist/spend.js +2 -1
  293. package/dist/steer-capabilities.d.ts +32 -0
  294. package/dist/steer-capabilities.js +60 -0
  295. package/dist/steer-controls.d.ts +14 -0
  296. package/dist/steer-controls.js +36 -0
  297. package/dist/steer-status.js +2 -2
  298. package/dist/storage-sweep-child.js +3 -1
  299. package/dist/storage-sweep-process.d.ts +12 -5
  300. package/dist/storage-sweep-process.js +23 -6
  301. package/dist/storage-ui.js +4 -4
  302. package/dist/store-baseline.d.ts +1 -1
  303. package/dist/store-baseline.js +1 -1
  304. package/dist/store.d.ts +466 -19
  305. package/dist/store.js +1185 -63
  306. package/dist/subagent-desk.js +1 -1
  307. package/dist/subagent-tools.d.ts +1 -1
  308. package/dist/summary.js +3 -0
  309. package/dist/surface.js +6 -4
  310. package/dist/task-source.d.ts +58 -0
  311. package/dist/task-source.js +93 -0
  312. package/dist/team-leads.js +1 -1
  313. package/dist/teams-chat.js +4 -1
  314. package/dist/teardown-git.d.ts +14 -0
  315. package/dist/teardown-git.js +119 -0
  316. package/dist/telegram-lead.d.ts +3 -0
  317. package/dist/telegram-lead.js +44 -7
  318. package/dist/telegram.d.ts +11 -2
  319. package/dist/telegram.js +51 -17
  320. package/dist/terminal-catalog.d.ts +11 -0
  321. package/dist/terminal-catalog.js +129 -0
  322. package/dist/text-limits.d.ts +2 -0
  323. package/dist/text-limits.js +2 -0
  324. package/dist/tool-broker.d.ts +39 -0
  325. package/dist/tool-broker.js +370 -0
  326. package/dist/tool-launcher.js +5 -4
  327. package/dist/tool-secret-store.d.ts +170 -0
  328. package/dist/tool-secret-store.js +834 -0
  329. package/dist/toolroll-update-ui.js +2 -2
  330. package/dist/toolroll-update.js +14 -3
  331. package/dist/tools-ui.d.ts +7 -1
  332. package/dist/tools-ui.js +19 -10
  333. package/dist/webhook-limits.d.ts +23 -0
  334. package/dist/webhook-limits.js +30 -0
  335. package/dist/webhook-readers.d.ts +56 -0
  336. package/dist/webhook-readers.js +138 -0
  337. package/dist/webhook-signing.d.ts +27 -0
  338. package/dist/webhook-signing.js +119 -0
  339. package/dist/work-index.d.ts +29 -0
  340. package/dist/work-index.js +21 -5
  341. package/dist/workspace-revision.js +3 -1
  342. package/dist/worktree.d.ts +10 -4
  343. package/dist/worktree.js +70 -29
  344. package/package.json +13 -2
  345. package/scripts/provider-canary.mjs +210 -31
@@ -4,7 +4,7 @@
4
4
  * and a one-time "What's new" card afterwards. The work itself is the
5
5
  * `toolroll update` command (toolroll-update.ts), started as its own job.
6
6
  */
7
- import { html, postForm, replaceMarkup } from "./html.js";
7
+ import { html, postForm, replaceMarkup, reauth } from "./html.js";
8
8
  import { STEP_WORDS, UPDATE_STEPS, runtimeUpdateTerminal } from "./toolroll-update.js";
9
9
  const clock = (iso) => { const d = new Date(iso); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; };
10
10
  export const UPDATES_CSS = `.updates{max-width:640px;min-width:0;overflow-wrap:anywhere}.updates .card{margin:0 0 16px}.updates h2{margin:0;font-size:1.0625rem}.updates .card>p{margin:4px 0 0}` +
@@ -69,7 +69,7 @@ export function updatesHtml(view, notice) {
69
69
  : latest === null ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Couldn’t check for a newer release: ${"problem" in view.latest ? view.latest.problem : ""}</p>`
70
70
  : html `<h2>Toolroll ${view.current} is up to date</h2>`;
71
71
  const form = newer && !["npx", "source", "desktop"].includes(view.method.kind)
72
- ? postForm("/settings/updates", html `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button><button type="submit" name="when" value="when-idle">When idle</button><button type="submit" name="when" value="tonight">Tonight (03:00)</button></div>`, { hidden: { version: latest } })
72
+ ? postForm("/settings/updates", html `${reauth(html `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}<div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button><button type="submit" name="when" value="when-idle">When idle</button><button type="submit" name="when" value="tonight">Tonight (03:00)</button></div>`, { hidden: { version: latest } })
73
73
  : "";
74
74
  const rollback = view.rollbackTo !== null ? html `<p class="meta">To go back to ${view.rollbackTo}: <code>toolroll update --rollback</code></p>` : "";
75
75
  return html `<section class="updates">${note}${whatsNew}${j ? outcomeHtml(j) : ""}<div class="card" data-update-state="${newer ? "available" : "current"}">${state}${form}${rollback}</div></section>`;
@@ -32,10 +32,11 @@ import { createRequire } from "node:module";
32
32
  import { clearWords, durableJson, LINGERING_LIMIT_MS, lingeringRun, lingeringWords, settleLeftoverRecords, sqliteLock, desktopUpdateWaitingOf, stoppedWaitingWords, updateWaitingWords, verifiedDatabaseBackup } from "./desktop-update.js";
33
33
  import { activeUpdateWork, freezeUpdateGate, installUpdateGate, removeUpdateGate, updateAdmissionPaused, updateGateOwned } from "./desktop-update-gate.js";
34
34
  import { processMayBeAlive } from "./process-liveness.js";
35
+ import { removeTerminalUpdateGate } from "./terminal-catalog.js";
35
36
  import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner, removeCodingUpdateGate } from "./coding-update.js";
36
37
  import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
37
38
  import { NAME } from "./names.js";
38
- import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS } from "./store.js";
39
+ import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS } from "./store.js";
39
40
  import { isNewer, REGISTRY } from "./releases.js";
40
41
  import { markNeverIndex } from "./never-index.js";
41
42
  import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
@@ -373,6 +374,8 @@ function addedColumns(...versions) {
373
374
  }
374
375
  return rules;
375
376
  }
377
+ /** Rules on tables that later gained declared columns (addedColumns merges into these instead of replacing them). */
378
+ const EARLIER_RULES = { flow_trigger: { fromRoutines: "trigger" } };
376
379
  const HISTORY_RULES = {
377
380
  ...Object.fromEntries(Object.entries(CHAT_MOVES).map(([table, moved]) => [table, { moved }])),
378
381
  action_ledger: { appendOnly: true }, ledger_seal: { appendOnly: true }, ledger_checkpoint: { appendOnly: true },
@@ -400,7 +403,12 @@ const HISTORY_RULES = {
400
403
  // saved before them reads none.
401
404
  // v121: live steering's columns and a run's attempt key read none, and no sends, the same way.
402
405
  // v122: a project's recorded remote reads none the same way.
403
- ...addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS),
406
+ // v123: a plan authorization's basis reads the operating mode, and a trigger firing reads not on time and no script ran.
407
+ // v126: a receipt's state, acceptance time and held item, a trigger's credential generation and turned-away count,
408
+ // and who made a card read none, 0, or none. A table with an earlier rule (flow_trigger's v115 move) keeps it too.
409
+ // v128: a lead message's pending flag reads waiting exactly for a team queue's unadmitted operator message.
410
+ ...Object.fromEntries(Object.entries(addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS))
411
+ .map(([table, rule]) => [table, { ...EARLIER_RULES[table], ...rule }])),
404
412
  };
405
413
  /** The tables a migration may remove whole (v115's removed features). */
406
414
  export const RETIRED_TABLES = Object.freeze(Object.keys(HISTORY_RULES).filter(name => HISTORY_RULES[name].retired));
@@ -907,8 +915,10 @@ function ungate(j) {
907
915
  db.exec("PRAGMA busy_timeout=5000");
908
916
  if (updateGateOwned(db, j.id))
909
917
  removeUpdateGate(db, j.id);
910
- else
918
+ else {
911
919
  removeCodingUpdateGate(db, j.id);
920
+ removeTerminalUpdateGate(db, j.id);
921
+ }
912
922
  }
913
923
  finally {
914
924
  db.close();
@@ -919,6 +929,7 @@ function ungateCoding(j) {
919
929
  try {
920
930
  db.exec("PRAGMA busy_timeout=5000");
921
931
  removeCodingUpdateGate(db, j.id);
932
+ removeTerminalUpdateGate(db, j.id);
922
933
  }
923
934
  finally {
924
935
  db.close();
@@ -2,7 +2,7 @@
2
2
  * entered here and nowhere else, and never shown again. */
3
3
  import { type Html } from "./html.js";
4
4
  import { type FoundTool, type ProjectTool, type ToolSpec } from "./project-tools.js";
5
- export declare const TOOLS_CSS = ".tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:center;gap:10px}.tools .tool-heading h2{flex:1;min-width:0}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready=\"false\"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools .tool-note{color:var(--so-warning)}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:\"\";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile .brand-mark{grid-row:span 2}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile>span:not(.brand-mark){font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state=\"connected\"]>span:not(.brand-mark){color:var(--so-success)}.tools .connect-local{grid-column:1/-1}.tools .connect-local>span:not(.brand-mark){-webkit-line-clamp:unset}.tools .connect-problem{margin:0}.tools .connect-problem:focus{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile:disabled{cursor:default;opacity:.6}@media(max-width:600px){.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile:not(.connect-local)>span:not(.brand-mark){display:none}.tools .connect-local>span:not(.brand-mark){grid-column:2}.tools .connect-tile{min-height:52px;padding:8px 10px}.tools .connect-tile .brand-mark{grid-row:auto}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}}";
5
+ export declare const TOOLS_CSS = ".tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:center;gap:10px}.tools .tool-heading h2{flex:1;min-width:0}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready=\"false\"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools .card>p.tool-credential{margin-top:4px;font-size:.875rem;color:var(--so-muted)}.tools .tool-note{color:var(--so-warning)}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:\"\";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile .brand-mark{grid-row:span 2}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile>span:not(.brand-mark){font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state=\"connected\"]>span:not(.brand-mark){color:var(--so-success)}.tools .connect-local{grid-column:1/-1}.tools .connect-local>span:not(.brand-mark){-webkit-line-clamp:unset}.tools .connect-problem{margin:0}.tools .connect-problem:focus{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile:disabled{cursor:default;opacity:.6}@media(max-width:600px){.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile:not(.connect-local)>span:not(.brand-mark){display:none}.tools .connect-local>span:not(.brand-mark){grid-column:2}.tools .connect-tile{min-height:52px;padding:8px 10px}.tools .connect-tile .brand-mark{grid-row:auto}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}}";
6
6
  export type ToolsView = {
7
7
  repo: string;
8
8
  project: string;
@@ -26,6 +26,12 @@ export type ToolsView = {
26
26
  label: string;
27
27
  })[];
28
28
  found: FoundTool[];
29
+ /** Where this computer keeps tool secrets (project-tools.ts toolSecretStorage): said plainly when it's files only. */
30
+ storage?: {
31
+ words: string;
32
+ filesOnly: boolean;
33
+ problem?: string;
34
+ };
29
35
  /** The person's other projects and the one-click services still open there: where a connection here can also go. */
30
36
  others?: {
31
37
  repo: string;
package/dist/tools-ui.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /** Settings → Tools: one project's MCP servers, in plain words. Secrets are
2
2
  * entered here and nowhere else, and never shown again. */
3
- import { html, postForm } from "./html.js";
3
+ import { html, postForm, reauth } from "./html.js";
4
4
  import { brandMarkHtml, TILE_PHONE } from "./brand-mark.js";
5
- import { toolCommandLine, toolStanding } from "./project-tools.js";
5
+ import { signedIn } from "./oauth-run-token.js";
6
+ import { credentialModeOf, toolCommandLine, toolStanding } from "./project-tools.js";
6
7
  const hidden = (data) => Object.entries(data).map(([k, v]) => html `<input type="hidden" name="${k}" value="${v}">`);
7
- export const TOOLS_CSS = `.tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:center;gap:10px}.tools .tool-heading h2{flex:1;min-width:0}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready="false"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools .tool-note{color:var(--so-warning)}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:"";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile .brand-mark{grid-row:span 2}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile>span:not(.brand-mark){font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state="connected"]>span:not(.brand-mark){color:var(--so-success)}.tools .connect-local{grid-column:1/-1}.tools .connect-local>span:not(.brand-mark){-webkit-line-clamp:unset}.tools .connect-problem{margin:0}.tools .connect-problem:focus{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile:disabled{cursor:default;opacity:.6}${TILE_PHONE}{.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile:not(.connect-local)>span:not(.brand-mark){display:none}.tools .connect-local>span:not(.brand-mark){grid-column:2}.tools .connect-tile{min-height:52px;padding:8px 10px}.tools .connect-tile .brand-mark{grid-row:auto}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}}`;
8
+ export const TOOLS_CSS = `.tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:center;gap:10px}.tools .tool-heading h2{flex:1;min-width:0}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready="false"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools .card>p.tool-credential{margin-top:4px;font-size:.875rem;color:var(--so-muted)}.tools .tool-note{color:var(--so-warning)}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:"";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile .brand-mark{grid-row:span 2}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile>span:not(.brand-mark){font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state="connected"]>span:not(.brand-mark){color:var(--so-success)}.tools .connect-local{grid-column:1/-1}.tools .connect-local>span:not(.brand-mark){-webkit-line-clamp:unset}.tools .connect-problem{margin:0}.tools .connect-problem:focus{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile:disabled{cursor:default;opacity:.6}${TILE_PHONE}{.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile:not(.connect-local)>span:not(.brand-mark){display:none}.tools .connect-local>span:not(.brand-mark){grid-column:2}.tools .connect-tile{min-height:52px;padding:8px 10px}.tools .connect-tile .brand-mark{grid-row:auto}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}}`;
8
9
  /** The project chooser: it opens the chosen project at once; without scripts, its button does. */
9
10
  export function toolsProjectPicker(projects, chosen, keep = {}) {
10
11
  if (projects.length < 2)
@@ -14,23 +15,27 @@ export function toolsProjectPicker(projects, chosen, keep = {}) {
14
15
  }
15
16
  /** Choosing another project marks every form on the page as stale before it reloads, so a tap racing the reload is refused. */
16
17
  export const TOOLS_PROJECT_SCRIPT = `(function(){var s=document.querySelector('select[data-tools-project]');if(!s)return;s.addEventListener('change',function(){document.querySelectorAll('input[name=shown]').forEach(function(i){i.value=s.value;});});})();`;
17
- const password = html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`;
18
+ const password = html `${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}`;
18
19
  function toolCard(one, base, manage, project, research = null) {
19
20
  const { tool, secretsSet } = one;
20
21
  const standing = toolStanding(tool, secretsSet);
21
- // One line per secret: the disclosure is both where it stands and where it is set.
22
- const secrets = manage ? tool.spec.secrets.map(secret => {
22
+ // How its runs get credentials, in one line; what that means is in Details.
23
+ const mode = credentialModeOf(tool.spec, secretsSet);
24
+ const credential = mode === null ? "" : html `<p class="tool-credential" data-credential="${mode.mode}">${mode.words}</p>`;
25
+ // One line per secret: the disclosure is both where it stands and where it is set. A service connected by signing
26
+ // in gets its tokens only by signing in again (its Connect tile): no token of it is ever pasted here.
27
+ const secrets = manage && !signedIn(tool.spec) ? tool.spec.secrets.map(secret => {
23
28
  const set = secretsSet.includes(secret.name);
24
29
  return html `<details><summary>${set ? `Replace ${secret.name}` : `Set ${secret.name}${secret.optional ? " (optional)" : ""}`}</summary>${postForm("/settings/tools/change", html `<label>${secret.name}<input type="password" name="value" autocomplete="off" required></label>${password}<button>Save</button>`, { hidden: { ...base, action: "secret", name: tool.name, secret: secret.name } })}</details>`;
25
30
  }) : "";
26
31
  const tested = tool.lastTest?.ok ? html `<p>Tested ${tool.lastTest.at.slice(0, 16).replace("T", " ")} UTC: ${tool.lastTest.tools.join(", ") || "it lists no tools"}</p>` : "";
27
- const details = html `<details><summary>Details</summary><div class="tool-facts"><p>Starts: <code>${toolCommandLine(tool.spec)}</code></p><p>From ${tool.source} · added by ${tool.createdBy}</p>${tested}</div></details>`;
32
+ const details = html `<details><summary>Details</summary><div class="tool-facts">${mode?.detail == null ? "" : html `<p>${mode.detail}</p>`}<p>Starts: <code>${toolCommandLine(tool.spec)}</code></p><p>From ${tool.source} · added by ${tool.createdBy}</p>${tested}</div></details>`;
28
33
  const failed = tool.lastTest !== null && !tool.lastTest.ok ? html `<p class="problem" role="status">${tool.lastTest.problem ?? "The last test failed."}</p>` : "";
29
34
  // Connected, but research can't use it (its grant writes): said once, with the one way to fix it.
30
35
  const reconnect = research === null || !manage ? "" : html `<details><summary>Reconnect read-only</summary>${postForm("/settings/tools/connect", html `${password}<button>Reconnect read-only</button>`, { hidden: { ...base, service: tool.name, access: "read" } })}</details>`;
31
36
  const limited = research === null ? "" : html `<p class="tool-note" role="status">${research}</p>${reconnect}`;
32
37
  const controls = manage ? html `<div class="tool-controls">${postForm("/settings/tools/change", html `<button aria-label="Test ${tool.name} in ${project}">Test</button>`, { hidden: { ...base, action: "test", name: tool.name } })}${postForm("/settings/tools/change", html `<button class="secondary">Remove from ${project}</button>`, { hidden: { ...base, action: "remove", name: tool.name } })}</div>` : "";
33
- return html `<article class="card" id="tool-${tool.name}"><div class="tool-heading">${brandMarkHtml(tool.source === "custom" ? null : tool.name, tool.name, standing.ready)}<h2>${tool.name}</h2><span class="tool-state" data-ready="${String(standing.ready)}">${standing.words}</span></div><p>${tool.spec.about}</p>${failed}${limited}${secrets}${details}${controls}</article>`;
38
+ return html `<article class="card" id="tool-${tool.name}"><div class="tool-heading">${brandMarkHtml(tool.source === "custom" ? null : tool.name, tool.name, standing.ready)}<h2>${tool.name}</h2><span class="tool-state" data-ready="${String(standing.ready)}">${standing.words}</span></div><p>${tool.spec.about}</p>${credential}${failed}${limited}${secrets}${details}${controls}</article>`;
34
39
  }
35
40
  /** A service connected here, offered to the person's other projects: each button signs in again for that project (tokens are never copied). */
36
41
  function elsewhere(view, base) {
@@ -44,7 +49,11 @@ export function toolsHtml(view, csrf, canManage, message = {}) {
44
49
  // `shown` is the project this page shows; the server refuses a post whose repo differs from it.
45
50
  const base = { repo: view.repo, shown: view.repo };
46
51
  const to = ` to ${view.project}`;
47
- const intro = html `<p>Builds in ${view.project} use exactly these tools. MCP servers set up elsewhere on this computer aren't used.</p><details><summary>How tools work</summary><ul><li>A tool you add reaches work you approve from now on. Approve a task again to give it a new tool.</li><li>Removing a tool takes it away from every build right away.</li><li>Secrets stay on this computer, are never shown again, and go only to the tool that needs them.</li><li>Reviewers and the lead chat never get tools. Gemini builds don't use tools yet.</li></ul></details>`;
52
+ const keptIn = view.storage === undefined || view.storage.filesOnly ? "Secrets stay on this computer" : view.storage.words.replace(/\.$/, "");
53
+ // No keychain here: said once, plainly, where it can't be missed.
54
+ const storage = view.storage?.problem !== undefined ? html `<p class="problem tool-storage" role="alert">${view.storage.problem}</p>`
55
+ : view.storage?.filesOnly === true ? html `<p class="tool-storage" role="note">${view.storage.words}</p>` : "";
56
+ const intro = html `<p>Builds in ${view.project} use exactly these tools. MCP servers set up elsewhere on this computer aren't used.</p><details><summary>How tools work</summary><ul><li>A tool you add reaches work you approve from now on. Approve a task again to give it a new tool.</li><li>Removing a tool takes it away from every build right away.</li><li>${keptIn}, are never shown again, and go only to the tool that needs them.</li><li>Reviewers and the lead chat never get tools. Gemini builds don't use tools yet.</li></ul></details>`;
48
57
  const cards = view.tools.length === 0 ? html `<p class="meta">No tools yet. Builds in this project get none.</p>` : view.tools.map(one => toolCard(one, base, manage, view.project, view.connections.find(c => c.id === one.tool.name && c.state === "connected")?.research ?? null));
49
58
  const fromList = view.catalog.length === 0 ? "" : postForm("/settings/tools/change", html `<label>Common tools<select name="catalog">${view.catalog.map(one => html `<option value="${one.name}">${one.label}: ${one.about}</option>`)}</select></label>${password}<button>Add${to}</button>`, { hidden: { ...base, action: "add-catalog" } });
50
59
  const custom = html `<details><summary>Add your own</summary>${postForm("/settings/tools/change", html `<label>Name<input name="name" pattern="[a-z0-9][a-z0-9_\\-]{0,39}" maxlength="40" required placeholder="for example: postgres"></label><label>It runs as<select name="transport"><option value="stdio">A program on this computer</option><option value="http">A web address</option></select></label><label>Command or address<input name="target" required placeholder="npx -y some-mcp-server@1.2.3, or https://…"></label><label>Secrets it needs (names, comma separated)<input name="secrets" placeholder="for example: DATABASE_URL"></label><p class="meta">For a web address, the first secret is sent as its sign-in token. Set each secret's value after adding.</p>${password}<button>Add${to}</button>`, { hidden: { ...base, action: "add-custom" } })}</details>`;
@@ -62,5 +71,5 @@ export function toolsHtml(view, csrf, canManage, message = {}) {
62
71
  const add = manage ? html `<details class="card"${view.tools.length === 0 ? html ` open` : ""}><summary>Add a tool</summary>${fromList}${custom}</details>` : "";
63
72
  const found = manage && view.found.length > 0 ? html `<details class="card"><summary>Found on this computer (${view.found.length})</summary><p class="meta">MCP servers your other apps use. Adding one copies its settings, and any secrets it has, into this project.</p>${postForm("/settings/tools/change", html `${view.found.map(one => html `<label class="tool-found"><input type="checkbox" name="import" value="${one.spec.name}"><span><strong>${one.spec.name}</strong> · ${one.source}<br><code>${toolCommandLine(one.spec)}</code>${one.spec.secrets.length === 0 ? "" : html `<br><span class="meta">Brings ${one.spec.secrets.map(s => s.name).join(", ")}</span>`}</span></label>`)}${password}<button>Add selected${to}</button>`, { hidden: { ...base, action: "import" } })}</details>` : "";
64
73
  const note = atButton ? "" : message.problem ? html `<p class="problem" role="alert">${message.problem}</p>` : message.said ? html `<p role="status">${message.said}</p>` : "";
65
- return html `<section class="tools">${note}${intro}${cards}${connect}${add}${found}</section>`;
74
+ return html `<section class="tools">${note}${intro}${storage}${cards}${connect}${add}${found}</section>`;
66
75
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Webhook floods (docs/plans/webhook-tasks.md §4): how many deliveries a trigger's address may fail in a minute. Held
3
+ * in memory per store (a restart forgets it, which is harmless: it protects the process and the database's write rate,
4
+ * not anything durable). It counts only what doesn't prove itself — a bad, missing or stale signature, an address that
5
+ * isn't set up, an unknown address, and every delivery to an unsigned webhook (nothing proves those) — so a stranger
6
+ * holding a leaked address can't crowd out the deliveries that are signed.
7
+ */
8
+ import type { Store } from "./store.js";
9
+ /** At most this many unproven deliveries per address a minute; over it, further ones are answered 429 and do nothing. */
10
+ export declare const UNPROVEN_PER_MINUTE = 60;
11
+ /** Addresses that match no trigger share one count. */
12
+ export declare const UNKNOWN_ADDRESS = "unknown";
13
+ /** Count one unproven delivery to `address` at `now`. Whether it is over the minute's limit, and when to try again. */
14
+ export declare function countUnproven(store: Store, address: string, now: Date): {
15
+ over: false;
16
+ } | {
17
+ over: true;
18
+ retryAfterSeconds: number;
19
+ };
20
+ /** At most this many cards a trigger makes in a rolling hour (counted when each card is made). */
21
+ export declare const CARDS_PER_HOUR = 30;
22
+ /** At most this many signed deliveries a trigger holds while it waits for room; beyond it, deliveries are turned away. */
23
+ export declare const HELD_AT_MOST = 200;
@@ -0,0 +1,30 @@
1
+ /** At most this many unproven deliveries per address a minute; over it, further ones are answered 429 and do nothing. */
2
+ export const UNPROVEN_PER_MINUTE = 60;
3
+ const WINDOW_MS = 60_000;
4
+ /** Addresses that match no trigger share one count. */
5
+ export const UNKNOWN_ADDRESS = "unknown";
6
+ const counters = new WeakMap();
7
+ /** Count one unproven delivery to `address` at `now`. Whether it is over the minute's limit, and when to try again. */
8
+ export function countUnproven(store, address, now) {
9
+ let counter = counters.get(store);
10
+ if (counter === undefined) {
11
+ counter = new Map();
12
+ counters.set(store, counter);
13
+ }
14
+ const at = now.getTime();
15
+ const recent = (counter.get(address) ?? []).filter(one => one > at - WINDOW_MS);
16
+ recent.push(at);
17
+ // Keep at most one over the limit: the count only needs to know it is over, and memory stays bounded per address.
18
+ counter.set(address, recent.slice(-(UNPROVEN_PER_MINUTE + 1)));
19
+ // Bounded across addresses too: the oldest addresses are forgotten first.
20
+ if (counter.size > 10_000)
21
+ counter.delete(counter.keys().next().value);
22
+ if (recent.length <= UNPROVEN_PER_MINUTE)
23
+ return { over: false };
24
+ const oldest = recent[recent.length - UNPROVEN_PER_MINUTE - 1];
25
+ return { over: true, retryAfterSeconds: Math.max(1, Math.ceil((oldest + WINDOW_MS - at) / 1000)) };
26
+ }
27
+ /** At most this many cards a trigger makes in a rolling hour (counted when each card is made). */
28
+ export const CARDS_PER_HOUR = 30;
29
+ /** At most this many signed deliveries a trigger holds while it waits for room; beyond it, deliveries are turned away. */
30
+ export const HELD_AT_MOST = 200;
@@ -0,0 +1,56 @@
1
+ import type { WebhookSender } from "./webhook-signing.js";
2
+ /** A webhook trigger's reading settings (its saved config, defaults filled). */
3
+ export type ReaderSettings = {
4
+ sender: WebhookSender;
5
+ title: string;
6
+ titleField: string | null;
7
+ bodyField: string | null;
8
+ idField: string | null;
9
+ urlField: string | null;
10
+ project: string | null;
11
+ team: string | null;
12
+ };
13
+ /** What one delivery is, once read: an item (with the outside thing's identity and link, when it has them), a delivery
14
+ * whose configured identity couldn't be used (made into nothing, with why), or one that isn't for this flow. */
15
+ export type ReadDelivery = {
16
+ kind: "item";
17
+ identity: string | null;
18
+ url: string | null;
19
+ title: string;
20
+ description: string;
21
+ label: string;
22
+ } | {
23
+ kind: "skip";
24
+ why: string;
25
+ } | {
26
+ kind: "ignored";
27
+ why: string;
28
+ };
29
+ /** Whether outside words could hold a key or password (the same check cards' words get). */
30
+ export declare const looksSecret: (value: string) => boolean;
31
+ /** Where a sender's links may point: its own host, and (for Sentry) the organisation subdomains under it. */
32
+ export type LinkHosts = readonly {
33
+ host: string;
34
+ subdomains: boolean;
35
+ }[];
36
+ export declare const SENTRY_HOSTS: LinkHosts;
37
+ export declare const VERCEL_HOSTS: LinkHosts;
38
+ /**
39
+ * A link kept from a delivery: absolute https, at most 2,000 characters, no user information, and with its query and
40
+ * fragment dropped (where tokens travel). `hosts` narrows it to a sender's own site (and its subdomains). Null when it
41
+ * isn't one, or when what is left could still hold a secret.
42
+ */
43
+ export declare function safeLink(value: unknown, hosts: LinkHosts | null): string | null;
44
+ /** The identity words a key may carry: at most 120 characters, nothing hidden, and nothing that looks like a secret. */
45
+ export declare function identityOf(value: unknown): {
46
+ ok: true;
47
+ id: string;
48
+ } | {
49
+ ok: false;
50
+ why: string;
51
+ };
52
+ /** Whether each provider's reader has read a real, captured delivery (test/fixtures/webhooks/*.captured.json), or only
53
+ * its sender's documented examples so far: the panel says which until a captured one is in the suite. */
54
+ export declare const READER_CAPTURED: Record<"sentry" | "vercel", boolean>;
55
+ /** What a proven delivery says, by its sender's reader. */
56
+ export declare function readDelivery(body: unknown, settings: ReaderSettings): ReadDelivery;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * What a proven webhook delivery says, read by its sender's reader (docs/plans/webhook-tasks.md §2). A signature proves
3
+ * who sent a delivery, not that it belongs in this flow: Sentry's and Vercel's readers check its shape, its action and
4
+ * the configured scope first, and only then give it an identity. Anything else is ignored, with why, and claims no
5
+ * identity. The generic senders pick their fields by dot-paths. Every outside value is screened for secrets before it
6
+ * is used anywhere (a key, a held item, a card, a task), and every link is made safe before it is kept.
7
+ */
8
+ import { scanForSecrets } from "./evidence.js";
9
+ const HIDDEN = /[\u0000-\u001f\u007f​-‏‪-‮⁦-⁩]/;
10
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
11
+ const pick = (body, path) => path.split(".").reduce((node, part) => record(node)[part], body);
12
+ const text = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : null;
13
+ /** A string or a safe integer, as an identity's words exactly as sent; anything else isn't one. */
14
+ const idWords = (value) => typeof value === "string" ? value : typeof value === "number" && Number.isSafeInteger(value) ? String(value) : null;
15
+ /** Whether outside words could hold a key or password (the same check cards' words get). */
16
+ export const looksSecret = (value) => scanForSecrets(value).length > 0;
17
+ export const SENTRY_HOSTS = [{ host: "sentry.io", subdomains: true }];
18
+ export const VERCEL_HOSTS = [{ host: "vercel.com", subdomains: false }];
19
+ /**
20
+ * A link kept from a delivery: absolute https, at most 2,000 characters, no user information, and with its query and
21
+ * fragment dropped (where tokens travel). `hosts` narrows it to a sender's own site (and its subdomains). Null when it
22
+ * isn't one, or when what is left could still hold a secret.
23
+ */
24
+ export function safeLink(value, hosts) {
25
+ if (typeof value !== "string" || value.length > 2000 || HIDDEN.test(value))
26
+ return null;
27
+ let url;
28
+ try {
29
+ url = new URL(value);
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "")
35
+ return null;
36
+ const host = url.hostname.toLowerCase();
37
+ if (hosts !== null && !hosts.some(one => host === one.host || (one.subdomains && host.endsWith(`.${one.host}`))))
38
+ return null;
39
+ const kept = `${url.origin}${url.pathname}`;
40
+ // Judged as it will be kept: an address that only normalises past 2,000 characters (escaped non-ASCII) isn't kept.
41
+ if (kept.length > 2000)
42
+ return null;
43
+ // Screened as kept and as read: a token spelled with percent-escapes is the same token. A path that won't decode isn't kept.
44
+ let decoded;
45
+ try {
46
+ decoded = decodeURIComponent(url.pathname);
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ return looksSecret(kept) || looksSecret(decoded) || HIDDEN.test(decoded) ? null : kept;
52
+ }
53
+ /** The identity words a key may carry: at most 120 characters, nothing hidden, and nothing that looks like a secret. */
54
+ export function identityOf(value) {
55
+ const sent = idWords(value);
56
+ if (sent === null || sent.trim() === "")
57
+ return { ok: false, why: "its identity is missing" };
58
+ // Judged as sent, before anything is trimmed: padding, hidden characters or length make it unusable, never another id.
59
+ if (sent.length > 120 || HIDDEN.test(sent) || sent !== sent.trim())
60
+ return { ok: false, why: "its identity isn't usable" };
61
+ if (looksSecret(sent))
62
+ return { ok: false, why: "it looked like it held a key or password" };
63
+ return { ok: true, id: sent };
64
+ }
65
+ /** Sentry's API address for an event names its project's slug (…/api/0/projects/<org>/<project>/events/<id>/): read
66
+ * only from that exact shape on Sentry's own host. */
67
+ function sentrySlugOf(value) {
68
+ const link = typeof value === "string" ? value : "";
69
+ const match = /^https:\/\/(?:[a-z0-9-]+\.)?sentry\.io\/api\/0\/projects\/[a-z0-9_-]+\/([a-z0-9_-]+)\/events\/[A-Za-z0-9-]+\/?$/.exec(link);
70
+ return match?.[1] ?? null;
71
+ }
72
+ /** Sentry: an issue alert (action "triggered", with the event) or a new issue (action "created", with the issue). */
73
+ function readSentry(body, settings) {
74
+ const action = text(record(body)["action"]);
75
+ const data = record(record(body)["data"]);
76
+ const alert = action === "triggered" && Object.keys(record(data["event"])).length > 0;
77
+ const created = action === "created" && Object.keys(record(data["issue"])).length > 0;
78
+ // A fixed reason: nothing the sender wrote is kept for an event Toolroll doesn't take.
79
+ if (!alert && !created)
80
+ return { kind: "ignored", why: "not an issue alert or a new issue" };
81
+ const thing = record(alert ? data["event"] : data["issue"]);
82
+ // Scope from the body alone (the resource header isn't signed): the project's id or slug, as Sentry sends it.
83
+ if (settings.project !== null) {
84
+ const project = record(thing["project"]);
85
+ // An alert's event names its project by number, and by slug only in its API address; a new issue names both.
86
+ const names = [idWords(thing["project"]), idWords(project["id"]), text(project["slug"]), sentrySlugOf(thing["url"])].filter((one) => one !== null);
87
+ if (!names.some(one => one.toLowerCase() === settings.project.toLowerCase()))
88
+ return { kind: "ignored", why: "from another Sentry project" };
89
+ }
90
+ const identity = identityOf(alert ? thing["issue_id"] : thing["id"]);
91
+ if (!identity.ok)
92
+ return { kind: "skip", why: identity.why };
93
+ const title = text(thing["title"]) ?? settings.title;
94
+ const culprit = text(thing["culprit"]);
95
+ return { kind: "item", identity: identity.id, url: safeLink(thing["web_url"], SENTRY_HOSTS), title, description: culprit === null ? "From a Sentry issue." : `From a Sentry issue, in ${culprit}.`, label: "Sentry issue" };
96
+ }
97
+ /** Vercel: a failed deployment only, from the configured project and team. */
98
+ function readVercel(body, settings) {
99
+ const type = text(record(body)["type"]);
100
+ if (type !== "deployment.error")
101
+ return { kind: "ignored", why: "not a failed deployment" };
102
+ const payload = record(record(body)["payload"]);
103
+ if (settings.project !== null && idWords(record(payload["project"])["id"]) !== settings.project)
104
+ return { kind: "ignored", why: "from another Vercel project" };
105
+ if (settings.team !== null && idWords(record(payload["team"])["id"]) !== settings.team)
106
+ return { kind: "ignored", why: "from another Vercel team" };
107
+ const deployment = record(payload["deployment"]);
108
+ const identity = identityOf(deployment["id"]);
109
+ if (!identity.ok)
110
+ return { kind: "skip", why: identity.why };
111
+ const name = text(deployment["name"]);
112
+ return { kind: "item", identity: identity.id, url: safeLink(record(payload["links"])["deployment"], VERCEL_HOSTS), title: name === null ? settings.title : `${name} failed to deploy`,
113
+ description: "From a failed Vercel deployment.", label: "Vercel deployment" };
114
+ }
115
+ /** Any JSON: the trigger's own paths, then the usual names; shown whole when they find no text. */
116
+ function readGeneric(body, settings) {
117
+ const at = (path) => path === null ? undefined : pick(body, path);
118
+ let identity = null;
119
+ if (settings.idField !== null) {
120
+ const read = identityOf(at(settings.idField));
121
+ // A configured identity that can't be used makes nothing: it never falls back to some other identity.
122
+ if (!read.ok)
123
+ return { kind: "skip", why: read.why };
124
+ identity = read.id;
125
+ }
126
+ const title = at(settings.titleField ?? "title") ?? at("summary") ?? at("message");
127
+ const details = at(settings.bodyField ?? "description") ?? at("body") ?? at("text");
128
+ return { kind: "item", identity, url: settings.urlField === null ? null : safeLink(at(settings.urlField), null),
129
+ title: typeof title === "string" && title.trim() !== "" ? title : settings.title,
130
+ description: `From a webhook:\n\n${typeof details === "string" ? details : JSON.stringify(body, null, 2)}`.slice(0, 4000), label: "Webhook" };
131
+ }
132
+ /** Whether each provider's reader has read a real, captured delivery (test/fixtures/webhooks/*.captured.json), or only
133
+ * its sender's documented examples so far: the panel says which until a captured one is in the suite. */
134
+ export const READER_CAPTURED = { sentry: false, vercel: false };
135
+ /** What a proven delivery says, by its sender's reader. */
136
+ export function readDelivery(body, settings) {
137
+ return settings.sender === "sentry" ? readSentry(body, settings) : settings.sender === "vercel" ? readVercel(body, settings) : readGeneric(body, settings);
138
+ }
@@ -0,0 +1,27 @@
1
+ import { WEBHOOK_SENDERS } from "./contracts/flow.js";
2
+ export { WEBHOOK_SENDERS };
3
+ export type WebhookSender = (typeof WEBHOOK_SENDERS)[number];
4
+ /** The senders whose deliveries carry a signature (everything but `none`). */
5
+ export type SigningSender = Exclude<WebhookSender, "none">;
6
+ export declare const SENDER_WORDS: Record<WebhookSender, string>;
7
+ /** Node's `headersDistinct`: every header's values, one per line it arrived on. */
8
+ export type DistinctHeaders = Record<string, string[] | undefined>;
9
+ export type Proof = {
10
+ ok: true;
11
+ signedId: string | null;
12
+ } | {
13
+ ok: false;
14
+ said: string;
15
+ };
16
+ /** A Standard Webhooks timestamp may be this far from now, either way. */
17
+ export declare const STANDARD_TOLERANCE_MS: number;
18
+ /** A `whsec_` secret's key bytes, or null when it isn't one. */
19
+ export declare function standardKey(secret: string): Buffer | null;
20
+ /** Whether a delivery was signed with this secret by this sender. `none` proves nothing and is never passed here. */
21
+ export declare function proveDelivery(sender: SigningSender, secret: string, headers: DistinctHeaders, body: Buffer, now: Date): Proof;
22
+ /** Whether a pasted secret has its sender's shape (Sentry's and Vercel's come from them; the others can be made here). */
23
+ export declare function secretShapeOk(sender: SigningSender, secret: string): boolean;
24
+ /** A new secret for a sender Toolroll can make one for; Sentry's and Vercel's are pasted from them. */
25
+ export declare function makeSecret(sender: "standard" | "github-style"): string;
26
+ /** Whether Toolroll makes this sender's secret (and shows it once), rather than asking for the sender's own. */
27
+ export declare const makesSecret: (sender: SigningSender) => sender is "standard" | "github-style";
@@ -0,0 +1,119 @@
1
+ /**
2
+ * How a webhook proves who sent it (docs/plans/webhook-tasks.md §1). Each sender's check runs over the raw bytes,
3
+ * before anything is parsed; a security header sent more than once, or not at all, is refused. Nothing here reads or
4
+ * writes the store: the caller passes the current generation's secret, and a refusal writes nothing.
5
+ */
6
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
7
+ import { WEBHOOK_SENDERS } from "./contracts/flow.js";
8
+ export { WEBHOOK_SENDERS };
9
+ export const SENDER_WORDS = {
10
+ none: "Unsigned", standard: "Standard Webhooks", "github-style": "GitHub-style signature", sentry: "Sentry", vercel: "Vercel",
11
+ };
12
+ /** A Standard Webhooks timestamp may be this far from now, either way. */
13
+ export const STANDARD_TOLERANCE_MS = 5 * 60_000;
14
+ const HEX = (bytes) => new RegExp(`^[0-9a-f]{${bytes * 2}}$`);
15
+ const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
16
+ const HIDDEN = /[\u0000-\u001f\u007f]/;
17
+ /** Exactly one value for a security header, or why not. */
18
+ function single(headers, name) {
19
+ const values = headers[name] ?? [];
20
+ if (values.length === 0)
21
+ return { ok: false, said: `The ${name} header is missing.` };
22
+ if (values.length > 1)
23
+ return { ok: false, said: `The ${name} header was sent more than once.` };
24
+ return { ok: true, value: values[0] };
25
+ }
26
+ function same(expected, given) {
27
+ return expected.length === given.length && timingSafeEqual(expected, given);
28
+ }
29
+ /** A `whsec_` secret's key bytes, or null when it isn't one. */
30
+ export function standardKey(secret) {
31
+ if (!secret.startsWith("whsec_"))
32
+ return null;
33
+ const encoded = secret.slice("whsec_".length);
34
+ if (!BASE64.test(encoded) || encoded.length % 4 !== 0)
35
+ return null;
36
+ const key = Buffer.from(encoded, "base64");
37
+ return key.length >= 24 && key.length <= 64 ? key : null;
38
+ }
39
+ /** Whether a delivery was signed with this secret by this sender. `none` proves nothing and is never passed here. */
40
+ export function proveDelivery(sender, secret, headers, body, now) {
41
+ switch (sender) {
42
+ case "github-style": {
43
+ const header = single(headers, "x-hub-signature-256");
44
+ if (!header.ok)
45
+ return header;
46
+ const given = header.value.trim().toLowerCase();
47
+ if (!given.startsWith("sha256=") || !HEX(32).test(given.slice(7)))
48
+ return { ok: false, said: "The signature isn't sha256=<hex>." };
49
+ return same(createHmac("sha256", secret).update(body).digest(), Buffer.from(given.slice(7), "hex")) ? { ok: true, signedId: null } : { ok: false, said: "Signature doesn't match." };
50
+ }
51
+ case "sentry": {
52
+ const header = single(headers, "sentry-hook-signature");
53
+ if (!header.ok)
54
+ return { ok: false, said: header.said.includes("missing") ? "Sentry didn't sign this: use an Internal Integration's webhook." : header.said };
55
+ const given = header.value.trim().toLowerCase();
56
+ if (!HEX(32).test(given))
57
+ return { ok: false, said: "The signature isn't a SHA-256 hex digest." };
58
+ return same(createHmac("sha256", secret).update(body).digest(), Buffer.from(given, "hex")) ? { ok: true, signedId: null } : { ok: false, said: "Signature doesn't match." };
59
+ }
60
+ case "vercel": {
61
+ const header = single(headers, "x-vercel-signature");
62
+ if (!header.ok)
63
+ return header;
64
+ const given = header.value.trim().toLowerCase();
65
+ if (!HEX(20).test(given))
66
+ return { ok: false, said: "The signature isn't a SHA-1 hex digest." };
67
+ return same(createHmac("sha1", secret).update(body).digest(), Buffer.from(given, "hex")) ? { ok: true, signedId: null } : { ok: false, said: "Signature doesn't match." };
68
+ }
69
+ case "standard": {
70
+ const key = standardKey(secret);
71
+ if (key === null)
72
+ return { ok: false, said: "The saved secret isn't a whsec_ secret." };
73
+ const id = single(headers, "webhook-id"), stamp = single(headers, "webhook-timestamp"), signature = single(headers, "webhook-signature");
74
+ for (const header of [id, stamp, signature])
75
+ if (!header.ok)
76
+ return header;
77
+ const idText = id.value, stampText = stamp.value, signatureText = signature.value;
78
+ if (idText === "" || idText.length > 200 || HIDDEN.test(idText))
79
+ return { ok: false, said: "The webhook-id header isn't usable." };
80
+ // Unix seconds, as an integer: milliseconds, decimals and signs are refused, not guessed at.
81
+ if (!/^\d{1,11}$/.test(stampText))
82
+ return { ok: false, said: "The webhook-timestamp isn't Unix seconds." };
83
+ if (Math.abs(now.getTime() - Number(stampText) * 1000) > STANDARD_TOLERANCE_MS)
84
+ return { ok: false, said: "Too old, or from the future." };
85
+ const expected = createHmac("sha256", key).update(Buffer.concat([Buffer.from(`${idText}.${stampText}.`, "utf8"), body])).digest();
86
+ // Any well-formed v1 entry may match; a malformed entry is passed over, and none well-formed is a refusal.
87
+ let wellFormed = 0;
88
+ for (const entry of signatureText.split(" ")) {
89
+ if (!entry.startsWith("v1,"))
90
+ continue;
91
+ const encoded = entry.slice(3);
92
+ if (!BASE64.test(encoded) || encoded.length % 4 !== 0)
93
+ continue;
94
+ const given = Buffer.from(encoded, "base64");
95
+ if (given.length !== 32)
96
+ continue;
97
+ wellFormed++;
98
+ if (same(expected, given))
99
+ return { ok: true, signedId: idText };
100
+ }
101
+ return { ok: false, said: wellFormed === 0 ? "The webhook-signature has no v1 signature." : "Signature doesn't match." };
102
+ }
103
+ }
104
+ }
105
+ /** Whether a pasted secret has its sender's shape (Sentry's and Vercel's come from them; the others can be made here). */
106
+ export function secretShapeOk(sender, secret) {
107
+ switch (sender) {
108
+ case "sentry": return /^[0-9a-fA-F]{32,128}$/.test(secret);
109
+ case "vercel": return /^[A-Za-z0-9]{16,200}$/.test(secret);
110
+ case "standard": return standardKey(secret) !== null;
111
+ case "github-style": return /^[\x21-\x7e]{16,200}$/.test(secret);
112
+ }
113
+ }
114
+ /** A new secret for a sender Toolroll can make one for; Sentry's and Vercel's are pasted from them. */
115
+ export function makeSecret(sender) {
116
+ return sender === "standard" ? `whsec_${randomBytes(32).toString("base64")}` : randomBytes(32).toString("hex");
117
+ }
118
+ /** Whether Toolroll makes this sender's secret (and shows it once), rather than asking for the sender's own. */
119
+ export const makesSecret = (sender) => sender === "standard" || sender === "github-style";