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
@@ -31,17 +31,22 @@ import { html, htmlString, postForm, styleElement } from "./html.js";
31
31
  * and a card's work is an ordinary task under the usual approvals.
32
32
  */
33
33
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
34
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
34
+ import { readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
35
+ import { SecretStoreError, readHeldSecret, writeHeldSecret } from "./tool-secret-store.js";
35
36
  import { join } from "node:path";
36
37
  import { scanForSecrets } from "./evidence.js";
37
38
  import { flowCardText, flowDefinitionOf } from "./flow-engine.js";
38
39
  import { FlowContractError } from "./flows.js";
40
+ import { identityOf, looksSecret, readDelivery, safeLink } from "./webhook-readers.js";
41
+ import { CARDS_PER_HOUR, countUnproven, HELD_AT_MOST, UNKNOWN_ADDRESS } from "./webhook-limits.js";
42
+ import { makesSecret, makeSecret, proveDelivery, secretShapeOk, SENDER_WORDS, WEBHOOK_SENDERS } from "./webhook-signing.js";
39
43
  import { parseContract } from "./contracts/contract.js";
40
44
  import { FLOW_ALIASES, FLOW_TRIGGER_KINDS, SECRETS_MAX, triggerConfigSchema, triggerInputSchema } from "./contracts/flow.js";
41
45
  import { describeSchedule, fileStandingOrder, firstFireAt, nextFireAt, parseSchedule, StandingOrderRefused, WEEKDAYS } from "./flow-schedule.js";
42
46
  import { takeReply } from "./flow-replies.js";
43
47
  import { mailboxAccess, mailCursorOf, mailCursorText, readThroughImap } from "./mailbox.js";
44
48
  import { readFlowSecrets } from "./flow-secrets.js";
49
+ import { prepareUnattendedCandidates, grantSignsScriptOf, scriptProvenanceOf, scriptRanDigestOf } from "./flow-unattended.js";
45
50
  import { tmpdir } from "node:os";
46
51
  import { envValue } from "./names.js";
47
52
  import { linearLabelNames, readFormSubmission, readGithubEvent, readGithubIssue, readGithubIssueEventList, readGithubIssueList, readGithubRun, readGithubRunList, readInboundMail, readLinearAnswer, readLinearEvent, readLinearIssue, readWebhookPayload } from "./contracts/trigger-payloads.js";
@@ -205,7 +210,29 @@ export function validateTriggerConfig(raw, context) {
205
210
  return { kind: "flow", flow: flow.id, when: stage.id, zone };
206
211
  }
207
212
  case "webhook":
208
- return { kind: "webhook", title: said(input.title) ?? "Webhook", titleField: said(input.titleField), bodyField: said(input.bodyField), zone };
213
+ {
214
+ // v126: Sentry and Vercel deliveries are read by their own readers, scoped by project (and Vercel's by team); the
215
+ // others pick their fields from the JSON by dot-paths. A setting that the sender doesn't read is refused, not kept.
216
+ const sender = input.sender ?? "none";
217
+ const provider = sender === "sentry" || sender === "vercel";
218
+ const project = said(input.project), team = said(input.team), idField = said(input.idField), urlField = said(input.urlField);
219
+ const titleField = said(input.titleField), bodyField = said(input.bodyField);
220
+ if (provider) {
221
+ for (const [path, value] of [["titleField", titleField], ["bodyField", bodyField], ["idField", idField], ["urlField", urlField]]) {
222
+ if (value !== null)
223
+ refuse(path, `${SENDER_WORDS[sender]} deliveries are read by Toolroll's own ${SENDER_WORDS[sender]} reader; leave it out`);
224
+ }
225
+ }
226
+ else if (project !== null)
227
+ refuse("project", "only a Sentry or Vercel webhook is scoped to a project");
228
+ if (team !== null && sender !== "vercel")
229
+ refuse("team", "only a Vercel webhook is scoped to a team");
230
+ for (const [path, value] of [["titleField", titleField], ["bodyField", bodyField], ["idField", idField], ["urlField", urlField]]) {
231
+ if (value !== null && !/^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/.test(value))
232
+ refuse(path, "a dot-path like data.issue.id");
233
+ }
234
+ return { kind: "webhook", title: said(input.title) ?? "Webhook", titleField, bodyField, sender, project, team, idField, urlField, zone };
235
+ }
209
236
  case "plane-review": {
210
237
  const at = said(input.at) ?? "07:30";
211
238
  const clock = /^(\d{1,2}):(\d{2})$/.exec(at);
@@ -269,7 +296,7 @@ export function describeTrigger(config, store) {
269
296
  const zone = flow === null ? null : flowDefinitionOf(flow)?.stages.find(one => one.id === config.when)?.title ?? null;
270
297
  return `Cards reaching ${zone ?? "the end"} in ${flow?.name ?? "another flow"}`;
271
298
  }
272
- case "webhook": return "Anything posted to its webhook address";
299
+ case "webhook": return webhookWords(config);
273
300
  case "chat": return `Every new message in a ${CHAT_APP_NAMES[config.app]} channel; replies in a card's thread join its discussion`;
274
301
  case "plane-review": {
275
302
  const schedule = parseSchedule(config.schedule);
@@ -293,7 +320,7 @@ export function triggerHeadline(config, store) {
293
320
  const flow = store.getFlow(config.flow);
294
321
  return { name: `From ${flow?.name ?? "another flow"}`, detail: `When a card reaches ${flow === null ? "the end" : flowDefinitionOf(flow)?.stages.find(one => one.id === config.when)?.title ?? "the end"}` };
295
322
  }
296
- case "webhook": return { name: "Webhook", detail: "Anything posted to its address" };
323
+ case "webhook": return { name: senderOf(config) === "sentry" ? "Sentry" : senderOf(config) === "vercel" ? "Vercel" : "Webhook", detail: webhookWords(config) };
297
324
  case "chat": return { name: `${CHAT_APP_NAMES[config.app]} channel`, detail: "Each message is a card" };
298
325
  case "plane-review": {
299
326
  const schedule = parseSchedule(config.schedule);
@@ -316,7 +343,7 @@ export function triggerConfigOf(trigger) {
316
343
  /** What a saved trigger leaves out, it had as these (every release has saved them whole; a hand-made row may not). */
317
344
  const SAVED_DEFAULTS = {
318
345
  button: { questions: ["What needs doing?", "Details"] }, schedule: { description: null }, github: { label: null, branch: null, from: "team", delivery: "poll" },
319
- linear: { team: null, state: null, label: null, delivery: "poll" }, webhook: { title: "Webhook", titleField: null, bodyField: null }, email: { folder: "INBOX", sender: null, subject: null },
346
+ linear: { team: null, state: null, label: null, delivery: "poll" }, webhook: { title: "Webhook", titleField: null, bodyField: null, sender: "none", project: null, team: null, idField: null, urlField: null }, email: { folder: "INBOX", sender: null, subject: null },
320
347
  };
321
348
  /** A saved trigger's config (`configJson`, which carries no version) read by its one schema, what it leaves out filled. */
322
349
  export function readTriggerConfig(raw) {
@@ -324,32 +351,71 @@ export function readTriggerConfig(raw) {
324
351
  const body = isRecord(raw) ? { zone: null, ...SAVED_DEFAULTS[kind], ...raw } : raw;
325
352
  return parseContract(triggerConfigSchema, body);
326
353
  }
354
+ /** v126: what a webhook trigger takes, in words: what its sender sends it, or anything posted when it's unsigned. */
355
+ function webhookWords(config) {
356
+ const sender = senderOf(config);
357
+ const scope = config.project == null ? "" : ` from ${config.project}`;
358
+ return sender === "sentry" ? `Sentry issue alerts and new issues${scope}` : sender === "vercel" ? `Failed Vercel deployments${scope}`
359
+ : sender === "none" ? "Anything posted to its webhook address" : `Requests signed ${sender === "standard" ? "the Standard Webhooks way" : "GitHub-style"} to its webhook address`;
360
+ }
361
+ /** v126: who signs a webhook trigger's deliveries; one saved before senders were chosen is unsigned. */
362
+ export const senderOf = (config) => config.sender ?? "none";
327
363
  /** Whether this trigger takes deliveries at a secret address rather than being checked. */
364
+ /** Whether a raw trigger the caller is about to add would take deliveries (an address): read leniently, before validation. */
365
+ export function rawTakesDeliveries(raw) {
366
+ if (raw === null || typeof raw !== "object")
367
+ return false;
368
+ const one = raw;
369
+ return one["kind"] === "webhook" || ((one["kind"] === "github" || one["kind"] === "linear") && one["delivery"] === "webhook");
370
+ }
328
371
  export function takesDeliveries(config) {
329
372
  return config.kind === "webhook" || ((config.kind === "github" || config.kind === "linear") && config.delivery === "webhook");
330
373
  }
331
374
  // ------------------------------------------------------ secrets on disk
332
375
  const hookHash = (token) => createHash("sha256").update(token).digest("hex");
333
- const secretFile = (dir, trigger) => join(dir, "flow-hooks", `${trigger}.json`);
334
- /** A webhook's signing secret (GitHub: made here; Linear: pasted from Linear). 0600, beside the database, never a column. */
335
- export function saveHookSecret(dir, trigger, secret) {
336
- mkdirSync(join(dir, "flow-hooks"), { recursive: true, mode: 0o700 });
337
- writeFileSync(secretFile(dir, trigger), `${JSON.stringify({ secret })}\n`, { mode: 0o600 });
338
- chmodSync(secretFile(dir, trigger), 0o600);
339
- }
340
- export function readHookSecret(dir, trigger) {
376
+ /**
377
+ * A webhook's signing secret: kept by Toolroll (tool-secret-store.ts) as `flow-hook.<trigger>`, in a file of that name
378
+ * before. v126: each secret has its own address, named by an id made for the one change that writes it, so no two
379
+ * changes ever write the same place; the trigger points at it (flow_trigger.hook_credential) only once it is whole.
380
+ * "legacy" is the address every secret saved before v126 has, so those keep authenticating with nothing moved; ""
381
+ * (or an id that isn't one) has no address at all.
382
+ */
383
+ const hookSecret = (trigger, credential) => credential === "legacy"
384
+ ? { name: `flow-hook.${trigger}`, file: ["flow-hooks", `${trigger}.json`] }
385
+ : /^[0-9a-f]{32}$/.test(credential) ? { name: `flow-hook.${trigger}.${credential}`, file: ["flow-hooks", `${trigger}.${credential}.json`] } : null;
386
+ /** A webhook's signing secret (made here, or pasted from its sender). In the keychain (or an owner-only file where there's none), never a column. */
387
+ export function saveHookSecret(dir, trigger, secret, credential = "legacy") {
388
+ const held = hookSecret(trigger, credential);
389
+ if (held === null)
390
+ throw new Error("not a credential id");
391
+ writeHeldSecret(dir, held.name, held.file, `${JSON.stringify({ secret })}\n`);
392
+ }
393
+ /** Exactly this credential's secret, or null: never another's. */
394
+ export function readHookSecret(dir, trigger, credential = "legacy") {
395
+ const held = hookSecret(trigger, credential);
396
+ if (held === null)
397
+ return null;
341
398
  try {
342
- const parsed = JSON.parse(readFileSync(secretFile(dir, trigger), "utf8"));
343
- return typeof parsed.secret === "string" && parsed.secret !== "" ? parsed.secret : null;
399
+ const parsed = JSON.parse(readHeldSecret(dir, held.name, held.file) ?? "null");
400
+ return typeof parsed?.secret === "string" && parsed.secret !== "" ? parsed.secret : null;
344
401
  }
345
402
  catch {
346
403
  return null;
347
404
  }
348
405
  }
349
- function dropHookSecret(dir, trigger) { rmSync(secretFile(dir, trigger), { force: true }); }
350
- const LINEAR_FILE = "linear-key";
406
+ /** Cleanup only: a secret no trigger points at is never read, whether or not its file is gone. */
407
+ function dropHookSecret(dir, trigger, credential) {
408
+ const held = hookSecret(trigger, credential);
409
+ if (held === null)
410
+ return;
411
+ try {
412
+ writeHeldSecret(dir, held.name, held.file, null);
413
+ }
414
+ catch { /* never read: nothing points at it */ }
415
+ }
416
+ const LINEAR = { name: "linear-key", file: ["linear-key"] };
351
417
  export const LINEAR_ENV = "TOOLROLL_LINEAR_KEY";
352
- /** The Linear API key the worker checks Linear with: the environment, or a 0600 file beside the database. */
418
+ /** The Linear API key the worker checks Linear with: the environment, or kept by Toolroll (the keychain, or an owner-only file beside the database). */
353
419
  export function readLinearKey(dir) {
354
420
  const env = envValue(process.env, "LINEAR_KEY")?.trim();
355
421
  if (env)
@@ -357,7 +423,7 @@ export function readLinearKey(dir) {
357
423
  if (dir === null)
358
424
  return null;
359
425
  try {
360
- const key = readFileSync(join(dir, LINEAR_FILE), "utf8").trim();
426
+ const key = (readHeldSecret(dir, LINEAR.name, LINEAR.file) ?? "").trim();
361
427
  return key === "" ? null : key;
362
428
  }
363
429
  catch {
@@ -368,11 +434,15 @@ export function saveLinearKey(dir, key) {
368
434
  const clean = key.trim();
369
435
  if (!/^lin_(api|oauth)_[A-Za-z0-9]{20,}$/.test(clean))
370
436
  return { ok: false, message: "That doesn't look like a Linear API key. Copy it from Linear → Settings → Security & access → Personal API keys." };
371
- writeFileSync(join(dir, LINEAR_FILE), `${clean}\n`, { mode: 0o600 });
372
- chmodSync(join(dir, LINEAR_FILE), 0o600);
437
+ try {
438
+ writeHeldSecret(dir, LINEAR.name, LINEAR.file, `${clean}\n`);
439
+ }
440
+ catch (error) {
441
+ return { ok: false, message: error instanceof SecretStoreError ? error.message : "The key couldn't be saved." };
442
+ }
373
443
  return { ok: true };
374
444
  }
375
- export function removeLinearKey(dir) { rmSync(join(dir, LINEAR_FILE), { force: true }); }
445
+ export function removeLinearKey(dir) { writeHeldSecret(dir, LINEAR.name, LINEAR.file, null); }
376
446
  const HOOKS_FILE = "hooks-url";
377
447
  /** The public address webhooks reach this console at (a reverse proxy such as Caddy, exposing /hooks/ only). */
378
448
  export function readHooksBase(dir) {
@@ -406,7 +476,12 @@ export function saveHooksBase(dir, value) {
406
476
  }
407
477
  export const HOOK_PATH = "/hooks/flow/";
408
478
  /** Add a trigger to a flow. A webhook trigger's address (and GitHub's signing secret) is returned once and never stored readable. */
409
- export function addFlowTriggerTo(store, flow, raw, actor, now, dir) {
479
+ /**
480
+ * `ingress: "proved"`: the caller proved a password typed now (a credential act, docs/plans/passwordless.md) — only then
481
+ * may a trigger that takes deliveries be made with an address. Without it a webhook is made with none (its panel's
482
+ * set-up, which asks for the password, makes one), and GitHub/Linear webhook delivery is refused.
483
+ */
484
+ export function addFlowTriggerTo(store, flow, raw, actor, now, dir, options = {}) {
410
485
  const definition = flowDefinitionOf(flow);
411
486
  if (definition === null || flow.state !== "active")
412
487
  return { ok: false, message: "This flow can't take triggers right now." };
@@ -419,7 +494,11 @@ export function addFlowTriggerTo(store, flow, raw, actor, now, dir) {
419
494
  }
420
495
  if (takesDeliveries(config) && dir === null)
421
496
  return { ok: false, message: "Webhook addresses are set up on the console." };
422
- const token = takesDeliveries(config) ? randomBytes(24).toString("base64url") : null;
497
+ if (takesDeliveries(config) && config.kind !== "webhook" && options.ingress !== "proved")
498
+ return { ok: false, message: "Webhook delivery is set up on the flow's trigger panel, with your password." };
499
+ // v126: a webhook made where no one is shown its address (the gallery, an import) is made with none: its first
500
+ // set-up or New address makes one and shows it. A webhook made on its panel is shown its address now.
501
+ const token = takesDeliveries(config) && options.ingress === "proved" && (options.shown !== false || config.kind !== "webhook") ? randomBytes(24).toString("base64url") : null;
423
502
  const schedule = config.kind === "schedule" || config.kind === "plane-review" ? parseSchedule(config.schedule) : null;
424
503
  const id = store.addFlowTrigger({
425
504
  // The table's kinds were fixed before plane reviews: one is kept as the daily schedule it is.
@@ -435,6 +514,9 @@ export function addFlowTriggerTo(store, flow, raw, actor, now, dir) {
435
514
  secret = randomBytes(24).toString("hex");
436
515
  saveHookSecret(dir, id, secret);
437
516
  }
517
+ // v126: a webhook starts with no secret at all (not the legacy address): it is set up on its panel, never here.
518
+ if (config.kind === "webhook")
519
+ store.swapFlowHookCredential(id, { credential: "legacy", configJson: JSON.stringify(config), hookHash: token === null ? null : hookHash(token) }, { credential: "" }, null, now);
438
520
  const reveal = token === null ? null : { path: `${HOOK_PATH}${token}`, address: dir === null || readHooksBase(dir) === null ? null : `${readHooksBase(dir)}${HOOK_PATH}${token}`, secret };
439
521
  return { ok: true, id, said: token === null ? "Trigger added." : "Trigger added. Copy its address now; it isn't shown again.", reveal };
440
522
  }
@@ -444,11 +526,18 @@ export function renewFlowHook(store, trigger, now, dir) {
444
526
  if (config === null || !takesDeliveries(config) || trigger.state === "removed")
445
527
  return { ok: false, message: "That trigger has no webhook address." };
446
528
  const token = randomBytes(24).toString("base64url");
447
- store.updateFlowTrigger(trigger.id, { hookHash: hookHash(token) }, now);
448
529
  let secret = null;
530
+ // GitHub's own trigger gets a new secret with its new address (as it always has); a webhook keeps its secret. Either
531
+ // way the address moves in one conditional write with the secret it is proven by, from the pair read here: a renewal
532
+ // that fails (or loses to another) leaves the working address and its secret exactly as they were.
449
533
  if (config.kind === "github") {
450
534
  secret = randomBytes(24).toString("hex");
451
- saveHookSecret(dir, trigger.id, secret);
535
+ const saved = nextHookCredential(store, trigger, secret, null, now, dir, hookHash(token));
536
+ if (!saved.ok)
537
+ return saved;
538
+ }
539
+ else if (!store.swapFlowHookCredential(trigger.id, { credential: trigger.hookCredential, configJson: trigger.configJson, hookHash: trigger.hookHash }, { credential: trigger.hookCredential, hookHash: hookHash(token) }, null, now)) {
540
+ return { ok: false, message: "That trigger changed meanwhile. Open it again." };
452
541
  }
453
542
  const base = readHooksBase(dir);
454
543
  return { ok: true, id: trigger.id, said: "New address made; the old one no longer works. Copy it now; it isn't shown again.", reveal: { path: `${HOOK_PATH}${token}`, address: base === null ? null : `${base}${HOOK_PATH}${token}`, secret } };
@@ -457,15 +546,15 @@ export function renewFlowHook(store, trigger, now, dir) {
457
546
  * Pause a trigger, or turn it on again. A schedule that never had a time (a routine moved to a flow while it waited
458
547
  * for approval, v115) starts counting from now, as a new schedule does; one that was due while it was off fires once.
459
548
  */
460
- export function setFlowTriggerOn(store, trigger, on, now) {
549
+ export function setFlowTriggerOn(store, trigger, on, now, by) {
461
550
  const config = on && trigger.nextAt === null ? triggerConfigOf(trigger) : null;
462
551
  const schedule = config !== null && (config.kind === "schedule" || config.kind === "plane-review") ? parseSchedule(config.schedule) : null;
463
- store.updateFlowTrigger(trigger.id, { state: on ? "active" : "paused", ...(schedule === null ? {} : { nextAt: firstFireAt(schedule, now) }) }, now);
552
+ store.updateFlowTrigger(trigger.id, { state: on ? "active" : "paused", ...(schedule === null ? {} : { nextAt: firstFireAt(schedule, now) }) }, now, by);
464
553
  }
465
- export function removeFlowTrigger(store, trigger, now, dir) {
466
- store.updateFlowTrigger(trigger.id, { state: "removed", hookHash: null }, now);
554
+ export function removeFlowTrigger(store, trigger, now, dir, by) {
555
+ store.updateFlowTrigger(trigger.id, { state: "removed", hookHash: null }, now, by);
467
556
  if (dir !== null)
468
- dropHookSecret(dir, trigger.id);
557
+ dropHookSecret(dir, trigger.id, trigger.hookCredential);
469
558
  }
470
559
  /** Checked by the worker's pass (rather than pushed, fired on a schedule or followed). */
471
560
  function polled(config) {
@@ -481,7 +570,7 @@ function checksCardOf(store, repo, key) {
481
570
  return row === undefined ? null : `“${String(row["name"])}”`;
482
571
  }
483
572
  /** One outside thing into one card, at most once per trigger (a failing run, once per project). Keys and control characters never reach a card. */
484
- function makeCard(store, trigger, config, item, by, now) {
573
+ function makeCard(store, trigger, config, item, by, now, byKind = "automation") {
485
574
  const flow = store.getFlow(trigger.flow);
486
575
  const definition = flow === null ? null : flowDefinitionOf(flow);
487
576
  if (flow === null || definition === null || flow.state !== "active")
@@ -505,8 +594,8 @@ function makeCard(store, trigger, config, item, by, now) {
505
594
  store.recordFlowTriggerEvent(trigger.id, item.key, null, note, now);
506
595
  return { made: "skipped", note: `${item.source.label}: ${note}`, card: null };
507
596
  }
508
- const card = store.addFlowCard({ flow: flow.id, title: text.title, description: text.description, stage: startZone(config, definition), by, source: item.source }, now);
509
- store.recordFlowTriggerEvent(trigger.id, item.key, card, null, now);
597
+ const card = store.addFlowCard({ flow: flow.id, title: text.title, description: text.description, stage: startZone(config, definition), by, byKind, source: item.source }, now);
598
+ store.recordFlowTriggerEvent(trigger.id, item.key, card, null, now, null, item.onTime === true, item.ranScript ?? null);
510
599
  return { made: "added", note: null, card };
511
600
  });
512
601
  }
@@ -537,7 +626,7 @@ function fileOrderCard(store, trigger, config, order, item, slot, now) {
537
626
  store.recordFlowTriggerEvent(trigger.id, item.key, null, fired.note, now);
538
627
  return { made: "skipped", note: fired.note, card: null };
539
628
  }
540
- const card = store.addFlowCard({ flow: flow.id, title: text.title, description: text.description, stage: zone, by: "Schedule", source: item.source }, now);
629
+ const card = store.addFlowCard({ flow: flow.id, title: text.title, description: text.description, stage: zone, by: "Schedule", byKind: "automation", source: item.source }, now);
541
630
  store.updateFlowCard(card, { task: fired.taskId, primaryTask: fired.taskId, waiting: "Filed as a task" }, now);
542
631
  // The firing keeps its task: one-at-a-time and the weekly limit count it however the card moves later.
543
632
  store.recordFlowTriggerEvent(trigger.id, item.key, card, null, now, fired.taskId);
@@ -566,7 +655,8 @@ export function addTriggerCard(store, trigger, item, by, now) {
566
655
  const config = triggerConfigOf(trigger);
567
656
  if (config === null || trigger.state !== "active")
568
657
  return { made: "skipped", note: "the trigger isn't active", card: null };
569
- return makeCard(store, trigger, config, item, by, now);
658
+ // A channel's message is the app's word for who sent it, not a signed-in account: automation (v126).
659
+ return makeCard(store, trigger, config, item, by, now, "automation");
570
660
  }
571
661
  /** Press a button trigger: the first answer is the card's title, the rest its details. */
572
662
  export function pressFlowButton(store, trigger, answers, actor, now) {
@@ -577,7 +667,7 @@ export function pressFlowButton(store, trigger, answers, actor, now) {
577
667
  if (said[0] === "")
578
668
  return { ok: false, message: `Answer “${config.questions[0]}”.` };
579
669
  const details = config.questions.slice(1).map((question, index) => said[index + 1] === "" ? null : `${question}\n${said[index + 1]}`).filter(one => one !== null).join("\n\n");
580
- const result = makeCard(store, trigger, config, { key: `press:${now.toISOString()}:${randomUUID().slice(0, 8)}`, title: said[0], description: details === "" ? null : details, source: { kind: "button", label: config.label, url: null } }, actor, now);
670
+ const result = makeCard(store, trigger, config, { key: `press:${now.toISOString()}:${randomUUID().slice(0, 8)}`, title: said[0], description: details === "" ? null : details, source: { kind: "button", label: config.label, url: null } }, actor, now, "person");
581
671
  if (result.made !== "added")
582
672
  return { ok: false, message: result.note ?? "That didn't make a card." };
583
673
  store.setFlowCardWatcher(result.card, actor, true, now);
@@ -587,11 +677,17 @@ export function pressFlowButton(store, trigger, answers, actor, now) {
587
677
  /** Every trigger in one project that is due: schedules and other flows here, GitHub, Linear and email by asking them. */
588
678
  export async function runFlowTriggers(store, repo, now, io) {
589
679
  const pass = { added: 0, checked: 0, problems: [] };
590
- for (const trigger of store.activeFlowTriggers(repo)) {
680
+ const triggers = store.activeFlowTriggers(repo);
681
+ for (const flow of new Set(triggers.map(one => one.flow)))
682
+ await prepareUnattendedCandidates(store, flow);
683
+ for (const trigger of triggers) {
591
684
  const config = triggerConfigOf(trigger);
592
685
  if (config === null)
593
686
  continue;
594
687
  try {
688
+ // v126: deliveries held over the hourly limit become their cards as the hour makes room.
689
+ if (takesDeliveries(config))
690
+ pass.added += drainHeldDeliveries(store, trigger, now);
595
691
  if (config.kind === "schedule" && config.script !== undefined && trigger.nextAt !== null && Date.parse(trigger.nextAt) <= now.getTime()) {
596
692
  const fired = await fireScript(store, trigger, config, now, io, true);
597
693
  pass.added += fired.added;
@@ -655,7 +751,7 @@ function fireSchedule(store, trigger, config, now) {
655
751
  const day = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: schedule.kind === "every" ? "UTC" : schedule.timezone ?? "UTC" }).format(new Date(slot));
656
752
  const fill = (text) => text.replace(/\{\{\s*date\s*\}\}/g, day);
657
753
  const title = /\{\{\s*date\s*\}\}/.test(config.title) ? fill(config.title) : `${config.title} — ${day}`;
658
- const item = { key: `slot:${slot}`, title, description: config.description === null ? null : fill(config.description), source: { kind: "schedule", label: "Schedule", url: null } };
754
+ const item = { key: `slot:${slot}`, title, description: config.description === null ? null : fill(config.description), source: { kind: "schedule", label: "Schedule", url: null }, onTime: true };
659
755
  const made = config.order === undefined ? makeCard(store, trigger, config, item, "Schedule", now) : fileOrderCard(store, trigger, config, config.order, item, slot, now);
660
756
  store.updateFlowTrigger(trigger.id, { nextAt: next, lastAt: now.toISOString(), lastOutcome: made.made === "added" ? made.note ?? "Added a card." : made.note ?? "Nothing new." }, now);
661
757
  return made.made === "added" ? 1 : 0;
@@ -713,7 +809,7 @@ export async function firePlaneReview(store, trigger, config, now, scheduled) {
713
809
  continue;
714
810
  }
715
811
  const text = problemCard(problem, dayWords);
716
- const made = makeCard(store, trigger, config, { key, title: text.title, description: text.description, source: { kind: "plane-review", label: "Plane review", url: null } }, "Plane review", now);
812
+ const made = makeCard(store, trigger, config, { key, title: text.title, description: text.description, source: { kind: "plane-review", label: "Plane review", url: null }, onTime: scheduled }, "Plane review", now);
717
813
  if (made.made === "added")
718
814
  added++;
719
815
  else if (made.note !== null)
@@ -768,6 +864,9 @@ async function fireScript(store, trigger, config, now, io, scheduled) {
768
864
  const { agentFence } = await import("./agent-fence.js");
769
865
  const scratch = io.scratch ?? join(io.dir ?? tmpdir(), "flow-scratch");
770
866
  const folder = cleanFolder(scratch, `flow-trigger-${trigger.id}`);
867
+ // Its cards run on their own only if it ran the very code a grant signed: pinned just before and just after the run.
868
+ const pin = scheduled && grantSignsScriptOf(store, flow.id, trigger.id, now) ? () => scriptProvenanceOf(store, flow.repo, script.name) : null;
869
+ const before = pin === null ? null : await pin();
771
870
  let ran;
772
871
  try {
773
872
  ran = await runCode({ script, cwd: folder, root: flow.repo, scratch, shell: io.shell ?? io.gh,
@@ -780,9 +879,10 @@ async function fireScript(store, trigger, config, now, io, scheduled) {
780
879
  }
781
880
  if (ran.state === "failed")
782
881
  return done(false, ran.said.split("\n")[0]);
882
+ const ranScript = pin === null ? null : scriptRanDigestOf(before, await pin(), script);
783
883
  const items = cardsFromOutput(ran.printed);
784
884
  const results = items.slice(0, CARDS_PER_CHECK).map(item => makeCard(store, trigger, config, {
785
- key: `script:${item.key}`, title: item.title, description: item.description, source: { kind: "script", label: `The ${script.name} script`, url: null },
885
+ key: `script:${item.key}`, title: item.title, description: item.description, source: { kind: "script", label: `The ${script.name} script`, url: null }, onTime: scheduled, ranScript,
786
886
  }, `The ${script.name} script`, now));
787
887
  return done(true, items.length === 0 ? `${script.name} ran and printed nothing to add.` : outcomeWords(results, items.length > CARDS_PER_CHECK), results.filter(one => one.made === "added").length);
788
888
  }
@@ -807,12 +907,15 @@ async function checkTrigger(store, trigger, config, now, io) {
807
907
  return { ok: false, said: fetched.problem, added: 0 };
808
908
  }
809
909
  const by = config.kind === "github" ? "GitHub" : config.kind === "linear" ? "Linear" : "Email";
810
- const due = fetched.items.slice(0, CARDS_PER_CHECK);
910
+ // Known items are left out before the batch is taken (v126): a page of things already received can't keep the unseen
911
+ // ones behind it waiting.
912
+ const fresh = fetched.items.filter(item => !store.flowTriggerSaw(trigger.id, item.key));
913
+ const due = fresh.slice(0, CARDS_PER_CHECK);
811
914
  const results = due.map(item => "skip" in item
812
915
  ? (store.recordFlowTriggerEvent(trigger.id, item.key, null, item.skip, now) ? { made: "skipped", note: `${item.label}: ${item.skip}`, card: null } : { made: "seen", note: null, card: null })
813
916
  : makeCard(store, trigger, config, item, by, now));
814
917
  // A mailbox says itself whether more is waiting; its cursor is already just past what it read.
815
- const more = fetched.more ?? fetched.items.length > CARDS_PER_CHECK;
918
+ const more = fetched.more ?? fresh.length > CARDS_PER_CHECK;
816
919
  // The cursor moves past what was handled, never past what is still waiting.
817
920
  const cursor = fetched.more !== undefined ? fetched.cursor ?? trigger.cursor : more ? due.at(-1)?.at ?? trigger.cursor : fetched.cursor ?? trigger.cursor;
818
921
  const said = results.length === 0 && fetched.said !== undefined ? fetched.said : outcomeWords(results, more);
@@ -988,24 +1091,81 @@ async function fetchLinear(config, trigger, io) {
988
1091
  const items = issues.map(one => linearItem(config, one)).filter((one) => one !== null);
989
1092
  return { ok: true, items, cursor: issues.map(one => one.updatedAt ?? "").filter(one => one !== "").at(-1) ?? trigger.cursor };
990
1093
  }
991
- const header = (delivery, name) => { const value = delivery.headers[name]; return Array.isArray(value) ? value[0] ?? "" : value ?? ""; };
1094
+ /** A header that only describes the delivery (not a security header): its first value. */
1095
+ const header = (delivery, name) => delivery.headers[name]?.[0] ?? "";
992
1096
  function sameHex(expected, given) {
993
1097
  const a = Buffer.from(expected, "utf8"), b = Buffer.from(given, "utf8");
994
1098
  return a.length === b.length && timingSafeEqual(a, b);
995
1099
  }
1100
+ /** How a webhook trigger's deliveries are proven, before anything in them is read: its sender's signature over the raw
1101
+ * bytes with the current generation's secret. Null when proven (or unsigned); otherwise the refusal, which writes nothing. */
1102
+ function proveHook(trigger, config, delivery, dir, now) {
1103
+ if (config.kind === "github") {
1104
+ const secret = readHookSecret(dir, trigger.id, trigger.hookCredential);
1105
+ if (secret === null)
1106
+ return { status: 401, said: "Signature doesn't match." };
1107
+ const proof = proveDelivery("github-style", secret, delivery.headers, delivery.body, now);
1108
+ return proof.ok ? { signedId: null } : { status: 401, said: proof.said };
1109
+ }
1110
+ if (config.kind === "linear") {
1111
+ const secret = readHookSecret(dir, trigger.id, trigger.hookCredential);
1112
+ if (secret === null)
1113
+ return { status: 401, said: "Paste Linear's signing secret on the flow's Triggers panel first." };
1114
+ const sent = delivery.headers["linear-signature"] ?? [];
1115
+ if (sent.length !== 1)
1116
+ return { status: 401, said: sent.length === 0 ? "The linear-signature header is missing." : "The linear-signature header was sent more than once." };
1117
+ return sameHex(createHmac("sha256", secret).update(delivery.body).digest("hex"), sent[0].trim().toLowerCase()) ? { signedId: null } : { status: 401, said: "Signature doesn't match." };
1118
+ }
1119
+ const sender = config.kind === "webhook" ? senderOf(config) : "none";
1120
+ if (sender === "none")
1121
+ return { signedId: null };
1122
+ const secret = readHookSecret(dir, trigger.id, trigger.hookCredential);
1123
+ if (secret === null)
1124
+ return { status: 401, said: `This webhook isn't set up yet: set up ${SENDER_WORDS[sender]} signing on the flow's Triggers panel first.` };
1125
+ const proof = proveDelivery(sender, secret, delivery.headers, delivery.body, now);
1126
+ return proof.ok ? { signedId: proof.signedId } : { status: 401, said: proof.said };
1127
+ }
996
1128
  /** A webhook delivery at a trigger's secret address: prove it, read it, make at most the cards it names. Nothing runs here. */
997
1129
  export function receiveFlowHook(store, token, delivery, dir, now) {
1130
+ // v126 floods (webhook-limits.ts): what doesn't prove itself is counted per address, and past the minute's limit is
1131
+ // answered 429 and does nothing. A delivery that proves itself is never refused by this count.
1132
+ const unproven = (address, answer) => {
1133
+ const count = countUnproven(store, address, now);
1134
+ return count.over ? { status: 429, said: "Too many. Try again later.", retryAfter: count.retryAfterSeconds } : answer;
1135
+ };
998
1136
  if (!/^[A-Za-z0-9_-]{20,64}$/.test(token))
999
- return { status: 404, said: "No such address." };
1137
+ return unproven(UNKNOWN_ADDRESS, { status: 404, said: "No such address." });
1000
1138
  const trigger = store.flowTriggerByHook(hookHash(token));
1001
1139
  const config = trigger === null ? null : triggerConfigOf(trigger);
1002
1140
  if (trigger === null || config === null || !takesDeliveries(config))
1003
- return { status: 404, said: "No such address." };
1141
+ return unproven(UNKNOWN_ADDRESS, { status: 404, said: "No such address." });
1004
1142
  if (!adapterPolicy({ caller: "service", capability: "none" }, undefined, source => source === "flow" && store.getFlow(trigger.flow) !== null).ok)
1005
1143
  return { status: 403, said: "No access to this flow." };
1144
+ const address = trigger.hookHash ?? UNKNOWN_ADDRESS;
1145
+ // An unsigned webhook has nothing to prove: every delivery to it counts (and it is lossy under a flood, by design).
1146
+ const unsigned = config.kind === "webhook" && senderOf(config) === "none";
1147
+ if (unsigned) {
1148
+ const counted = unproven(address, { status: 0, said: "" });
1149
+ if (counted.status === 429)
1150
+ return counted;
1151
+ }
1006
1152
  if (delivery.body.length > HOOK_BYTES)
1007
- return { status: 413, said: "Too large." };
1153
+ return unsigned ? { status: 413, said: "Too large." } : unproven(address, { status: 413, said: "Too large." });
1154
+ // Proven over the raw bytes before anything in them is read; a refusal writes nothing, not even the last outcome.
1155
+ const proven = proveHook(trigger, config, delivery, dir, now);
1156
+ if ("status" in proven)
1157
+ return unsigned ? proven : unproven(address, proven);
1008
1158
  const note = (said) => { store.updateFlowTrigger(trigger.id, { lastAt: now.toISOString(), lastOutcome: said }, now); };
1159
+ // Every write a delivery makes is bound to the trigger as it was proven against (v126): its secret, settings, address
1160
+ // and state, read again in the same transaction. A change that landed meanwhile (a new secret, another sender, a new
1161
+ // address, a pause) means this delivery writes nothing at all.
1162
+ const written = (write) => store.transact(() => {
1163
+ const current = store.getFlowTrigger(trigger.id);
1164
+ if (current === null || current.state !== "active" || current.hookCredential !== trigger.hookCredential || current.configJson !== trigger.configJson || current.hookHash !== trigger.hookHash) {
1165
+ return { status: 409, said: "This webhook changed while the delivery was read. Send it again." };
1166
+ }
1167
+ return write();
1168
+ });
1009
1169
  let payload;
1010
1170
  try {
1011
1171
  payload = JSON.parse(delivery.body.toString("utf8"));
@@ -1015,16 +1175,10 @@ export function receiveFlowHook(store, token, delivery, dir, now) {
1015
1175
  }
1016
1176
  let items = [];
1017
1177
  if (config.kind === "github") {
1018
- const secret = readHookSecret(dir, trigger.id);
1019
- const signature = header(delivery, "x-hub-signature-256");
1020
- if (secret === null || !sameHex(`sha256=${createHmac("sha256", secret).update(delivery.body).digest("hex")}`, signature))
1021
- return { status: 401, said: "Signature doesn't match." };
1022
1178
  const event = header(delivery, "x-github-event"), body = readGithubEvent(payload), action = body.action ?? "";
1023
1179
  const repository = body.repository?.full_name ?? "";
1024
- if (event === "ping") {
1025
- note("GitHub connected.");
1026
- return { status: 200, said: "Connected." };
1027
- }
1180
+ if (event === "ping")
1181
+ return written(() => { note("GitHub connected."); return { status: 200, said: "Connected." }; });
1028
1182
  if (repository.toLowerCase() !== config.repo.toLowerCase())
1029
1183
  return { status: 202, said: "Not this repository." };
1030
1184
  // A label trigger takes the moment its label is added (or an issue opened with it); a plain one takes new issues.
@@ -1035,14 +1189,10 @@ export function receiveFlowHook(store, token, delivery, dir, now) {
1035
1189
  items = found === null ? [] : [found];
1036
1190
  }
1037
1191
  else if (config.kind === "linear") {
1038
- const secret = readHookSecret(dir, trigger.id);
1039
- if (secret === null)
1040
- return { status: 401, said: "Paste Linear's signing secret on the flow's Triggers panel first." };
1041
- if (!sameHex(createHmac("sha256", secret).update(delivery.body).digest("hex"), header(delivery, "linear-signature")))
1042
- return { status: 401, said: "Signature doesn't match." };
1043
1192
  const body = readLinearEvent(payload), sent = Number(body.webhookTimestamp);
1193
+ // Signed but stale: as unproven as no signature at all, so it counts toward the address's minute.
1044
1194
  if (!Number.isFinite(sent) || Math.abs(now.getTime() - sent) > 5 * 60_000)
1045
- return { status: 401, said: "Too old." };
1195
+ return unproven(address, { status: 401, said: "Too old." });
1046
1196
  if (body.type !== "Issue" || !["create", "update"].includes(body.action ?? ""))
1047
1197
  return { status: 202, said: "Ignored." };
1048
1198
  // A state trigger fires when the issue arrives in that state, not on every later edit there.
@@ -1051,46 +1201,363 @@ export function receiveFlowHook(store, token, delivery, dir, now) {
1051
1201
  items = found === null ? [] : [found];
1052
1202
  }
1053
1203
  else if (config.kind === "webhook") {
1054
- // Any JSON, kept as sent: the trigger's own paths are picked from it, and it is shown whole when they find no text.
1204
+ // v126: read by its sender's reader (webhook-readers.ts): Sentry's and Vercel's check shape, action and scope before
1205
+ // an identity is claimed; the others pick their fields by dot-paths.
1055
1206
  const body = readWebhookPayload(payload);
1056
- const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
1057
- const pick = (path) => path === null ? null : path.split(".").reduce((node, part) => record(node)[part], body);
1058
- const title = pick(config.titleField ?? "title") ?? pick("summary") ?? pick("message");
1059
- const bodyText = pick(config.bodyField ?? "description") ?? pick("body") ?? pick("text");
1060
- const id = header(delivery, "x-request-id") || header(delivery, "x-delivery-id") || header(delivery, "idempotency-key") || createHash("sha256").update(delivery.body).digest("hex");
1061
- items = [{ key: `hook:${id.slice(0, 120)}`, at: null, title: typeof title === "string" && title.trim() !== "" ? title : config.title,
1062
- description: `From a webhook:\n\n${typeof bodyText === "string" ? bodyText : JSON.stringify(body, null, 2)}`.slice(0, 4000),
1063
- source: { kind: "webhook", label: "Webhook", url: null } }];
1207
+ const sender = senderOf(config);
1208
+ const read = readDelivery(body, { sender, title: config.title, titleField: config.titleField, bodyField: config.bodyField, idField: config.idField ?? null, urlField: config.urlField ?? null, project: config.project ?? null, team: config.team ?? null });
1209
+ // A delivery's own key: an unsigned webhook's is what the sender says (as before); a signed one's only what its
1210
+ // signature covers — Standard Webhooks' signed id, or the raw bytes. Keys carry the sender, so nothing unsigned
1211
+ // stands for anything signed.
1212
+ const bodyHash = createHash("sha256").update(delivery.body).digest("hex");
1213
+ // A signed delivery id is outside words too: one that isn't a usable identity (padded, hidden characters, too long,
1214
+ // or looking like a key) is never a key; the delivery is received under its bytes, and made into nothing.
1215
+ const signedId = proven.signedId === null ? null : identityOf(proven.signedId);
1216
+ // An unsigned sender's own id is outside words too: judged as sent, and when it isn't a usable identity (padded,
1217
+ // hidden characters, too long, looking like a key) the delivery keys on its bytes instead, as when there is none.
1218
+ const saidId = header(delivery, "x-request-id") || header(delivery, "x-delivery-id") || header(delivery, "idempotency-key");
1219
+ const headerId = saidId === "" ? null : identityOf(saidId);
1220
+ const deliveryKey = sender === "none"
1221
+ ? `hook:${headerId?.ok === true ? headerId.id : bodyHash}`
1222
+ : signedId?.ok === true ? `v3:${sender}:wid:${signedId.id}` : `v3:${sender}:body:${bodyHash}`;
1223
+ if (signedId?.ok === false)
1224
+ items = [{ key: deliveryKey, skip: `its delivery id: ${signedId.why}`, label: "Webhook", at: null }];
1225
+ // Not for this flow: received under the delivery's own key, so it never uses up the identity of what it names.
1226
+ else if (read.kind === "ignored")
1227
+ return written(() => {
1228
+ const before = store.flowReceipt(trigger.id, deliveryKey);
1229
+ if (before !== null)
1230
+ return repeatAnswer(before);
1231
+ store.recordFlowReceipt(trigger.id, deliveryKey, "ignored", read.why, null, now);
1232
+ return { status: 202, said: `Ignored: ${read.why}.` };
1233
+ });
1234
+ else if (read.kind === "skip")
1235
+ items = [{ key: deliveryKey, skip: read.why, label: "Webhook", at: null }];
1236
+ else {
1237
+ // The outside thing's identity, when it has one, groups its deliveries: one card per issue or deployment.
1238
+ const key = read.identity === null ? deliveryKey : `v3:${sender}:id:${read.identity}`;
1239
+ items = [{ key, at: null, title: read.title, description: read.description,
1240
+ source: { kind: sender === "sentry" || sender === "vercel" ? sender : "webhook", label: read.label, url: read.url,
1241
+ verified: sender, external: { id: read.identity, url: read.url }, receipt: key } }];
1242
+ }
1064
1243
  }
1065
1244
  if (items.length === 0)
1066
1245
  return { status: 202, said: "Nothing for this flow." };
1246
+ // Every key is outside words until judged (v126): a GitHub or Linear identifier, like any identity, that is too long,
1247
+ // carries hidden characters or looks like a key is never a key — the delivery is received under its bytes instead,
1248
+ // and made into nothing.
1249
+ // (A webhook's identities were judged by its reader, raw, before its key was made: only GitHub's and Linear's here.)
1250
+ const deliveryBytes = createHash("sha256").update(delivery.body).digest("hex");
1251
+ if (config.kind !== "webhook")
1252
+ items = items.map(item => {
1253
+ const identity = item.key.slice(item.key.indexOf(":") + 1);
1254
+ const judged = identityOf(identity);
1255
+ // A fixed label: nothing of the refused identity is kept, not even in the words the panel shows.
1256
+ if (!judged.ok)
1257
+ return { key: `v3:${config.kind}:body:${deliveryBytes}`, skip: judged.why, label: "A delivery", at: null };
1258
+ if ("skip" in item)
1259
+ return item;
1260
+ // A signed GitHub or Linear delivery says, at its receipt, who proved it and what it names (v126) — a polled item
1261
+ // never does, so the two stay apart; nothing is inferred later from today's settings.
1262
+ const url = safeLink(item.source.url, config.kind === "github" ? [{ host: "github.com", subdomains: false }] : [{ host: "linear.app", subdomains: false }]);
1263
+ return { ...item, source: { ...item.source, url, verified: config.kind, external: { id: judged.id, url }, receipt: item.key } };
1264
+ });
1067
1265
  const by = config.kind === "github" ? "GitHub" : config.kind === "linear" ? "Linear" : "Webhook";
1068
- const results = items.slice(0, CARDS_PER_CHECK).map(item => "skip" in item
1069
- ? (store.recordFlowTriggerEvent(trigger.id, item.key, null, item.skip, now) ? { made: "skipped", note: `${item.label}: ${item.skip}` } : { made: "seen", note: null })
1070
- : makeCard(store, trigger, config, item, by, now));
1071
- const said = outcomeWords(results, false);
1072
- note(said);
1073
- return { status: 202, said };
1266
+ return written(() => {
1267
+ // Received before (v126): said for what it was, never as a card that isn't there.
1268
+ const first = items[0];
1269
+ const before = items.length === 1 ? store.flowReceipt(trigger.id, first.key) : null;
1270
+ if (before !== null)
1271
+ return repeatAnswer(before);
1272
+ // The hourly card limit (v126), decided in this same transaction, counted when each card was made: a signed delivery
1273
+ // over it is held whole and made into its card when there's room; an unsigned one is turned away.
1274
+ const making = items.filter((item) => !("skip" in item));
1275
+ if (making.length > 0 && store.flowCardsAcceptedSince(trigger.id, new Date(now.getTime() - 3_600_000)) >= CARDS_PER_HOUR) {
1276
+ // Rejections are decided before anything is written: an unsigned one writes nothing at all.
1277
+ if (unsigned)
1278
+ return { status: 429, said: "Too many in the last hour. Try again later.", retryAfter: 3600 };
1279
+ if (store.heldFlowReceiptCount(trigger.id) >= HELD_AT_MOST) {
1280
+ // Turned away, written nowhere but the trigger's lasting count.
1281
+ store.noteFlowDeliveryDropped(trigger.id, now);
1282
+ return { status: 503, said: "Too many waiting already. Resend it later from the sender's delivery log." };
1283
+ }
1284
+ const item = making[0];
1285
+ // Screened and bounded whole before it is kept — its words as a card's are, and where it came from (label, link,
1286
+ // identity) as a delivery's are: a held item is never something a card couldn't hold.
1287
+ const words = flowCardText(item.title, item.description);
1288
+ const source = heldSourceOf(item.source, item.key);
1289
+ const problem = "problem" in words ? (words.problem.startsWith("That looks like a key") ? "it looked like it held a key or password" : words.problem) : source === null ? "where it came from couldn't be kept safely" : null;
1290
+ if (problem !== null || "problem" in words || source === null) {
1291
+ store.recordFlowTriggerEvent(trigger.id, item.key, null, problem, now);
1292
+ return { status: 202, said: `No new cards. Left out 1 item: ${problem}.` };
1293
+ }
1294
+ const held = JSON.stringify({ title: words.title, description: words.description, source, by });
1295
+ if (held.length > HELD_ITEM_BYTES) {
1296
+ store.recordFlowTriggerEvent(trigger.id, item.key, null, "too large to hold", now);
1297
+ return { status: 202, said: "No new cards. Left out 1 item: too large to hold." };
1298
+ }
1299
+ store.recordFlowReceipt(trigger.id, item.key, "held", null, held, now);
1300
+ if (store.claimFlowLimitNote(trigger.id, now))
1301
+ note("Too many in the last hour: new deliveries are held until there's room.");
1302
+ return { status: 202, said: "Held: it will be added when there's room." };
1303
+ }
1304
+ const results = items.slice(0, CARDS_PER_CHECK).map(item => "skip" in item
1305
+ ? (store.recordFlowTriggerEvent(trigger.id, item.key, null, item.skip, now) ? { made: "skipped", note: `${item.label}: ${item.skip}` } : { made: "seen", note: null })
1306
+ : makeCard(store, trigger, config, item, by, now));
1307
+ const said = outcomeWords(results, false);
1308
+ note(said);
1309
+ // The sender's answer is made from public reasons, not edited out of the panel's words.
1310
+ return { status: 202, said: outcomeWords(results.map(one => ({ ...one, note: publicReason(one.note) })), false) };
1311
+ });
1312
+ }
1313
+ /** A held item's whole size, at most, as saved: room for the largest card-sized item there can be (a 200-character
1314
+ * title and 4,000-character details, a label and two 2,000-character links) with every character escaped six-fold in
1315
+ * JSON, so no item a card could hold is ever refused for its size. */
1316
+ const HELD_ITEM_BYTES = 6 * (200 + 4_000 + 120 + 2 * 2_000 + 400) + 2_000;
1317
+ /** Where a held delivery came from, made safe to keep: a bounded label and kind with nothing that looks like a key, a
1318
+ * link made safe (https, no user information, query or fragment), a screened identity. Null when it can't be. */
1319
+ function heldSourceOf(source, key) {
1320
+ const label = source.label.slice(0, 120), kind = source.kind.slice(0, 40);
1321
+ if (looksSecret(label) || looksSecret(kind))
1322
+ return null;
1323
+ const id = source.external?.id == null ? null : identityOf(source.external.id);
1324
+ if (id !== null && !id.ok)
1325
+ return null;
1326
+ const url = safeLink(source.url, null);
1327
+ return { kind, label, url,
1328
+ ...(source.verified === undefined ? {} : { verified: source.verified.slice(0, 20) }),
1329
+ ...(source.external === undefined ? {} : { external: { id: id?.ok === true ? id.id : null, url: safeLink(source.external.url, null) } }),
1330
+ ...(source.receipt === undefined ? {} : { receipt: key.slice(0, 200) }) };
1331
+ }
1332
+ function readHeldItem(json) {
1333
+ try {
1334
+ const raw = JSON.parse(json);
1335
+ const source = raw["source"];
1336
+ if (typeof raw["title"] !== "string" || (raw["description"] !== null && typeof raw["description"] !== "string") || typeof raw["by"] !== "string" || typeof source?.label !== "string" || typeof source.kind !== "string")
1337
+ return null;
1338
+ return { title: raw["title"], description: raw["description"], source, by: raw["by"] };
1339
+ }
1340
+ catch {
1341
+ return null;
1342
+ }
1074
1343
  }
1075
- /** Save Linear's signing secret for a Linear webhook trigger (pasted on the secure panel, never in chat). */
1076
- export function saveLinearSigningSecret(trigger, secret, dir) {
1344
+ /**
1345
+ * v126: make a trigger's held deliveries into their cards while the last hour has room, oldest first. Each card in its
1346
+ * own write transaction, which reads everything it acts on under the write lock: the trigger (still active, still
1347
+ * taking deliveries), the hour's room, the oldest held receipt, the flow; then the same guards intake has (a failing
1348
+ * run already owned by another card in the project is left out). The card and the receipt's move to accepted land
1349
+ * together, or neither does.
1350
+ */
1351
+ export function drainHeldDeliveries(store, trigger, now) {
1352
+ let added = 0;
1353
+ for (;;) {
1354
+ const step = store.transact(() => {
1355
+ const current = store.getFlowTrigger(trigger.id);
1356
+ const config = current === null ? null : triggerConfigOf(current);
1357
+ if (current === null || config === null || current.state !== "active" || !takesDeliveries(config))
1358
+ return "stop";
1359
+ if (store.flowCardsAcceptedSince(current.id, new Date(now.getTime() - 3_600_000)) >= CARDS_PER_HOUR)
1360
+ return "stop";
1361
+ const next = store.heldFlowReceipts(current.id, 1)[0];
1362
+ if (next === undefined)
1363
+ return "stop";
1364
+ const flow = store.getFlow(current.flow);
1365
+ const definition = flow === null ? null : flowDefinitionOf(flow);
1366
+ if (flow === null || definition === null || flow.state !== "active")
1367
+ return "stop";
1368
+ const item = readHeldItem(next.itemJson);
1369
+ const words = item === null ? { problem: "it couldn't be read back" } : flowCardText(item.title, item.description);
1370
+ if (item === null || "problem" in words) {
1371
+ store.skipHeldReceipt(current.id, next.key, "problem" in words ? words.problem : "it couldn't be read back");
1372
+ return "left";
1373
+ }
1374
+ // One failing run is one fix, held or not: a card another trigger in this project already made for it wins.
1375
+ const filed = next.key.startsWith("checks:") ? checksCardOf(store, flow.repo, next.key) : null;
1376
+ if (filed !== null) {
1377
+ store.skipHeldReceipt(current.id, next.key, `already a card in ${filed}`);
1378
+ return "left";
1379
+ }
1380
+ const card = store.addFlowCard({ flow: flow.id, title: words.title, description: words.description, stage: startZone(config, definition), by: item.by, byKind: "automation", source: item.source }, now);
1381
+ if (!store.acceptHeldReceipt(current.id, next.key, card, now))
1382
+ throw new Error("a held delivery changed while its card was made");
1383
+ return "added";
1384
+ });
1385
+ if (step === "stop")
1386
+ return added;
1387
+ if (step === "added")
1388
+ added++;
1389
+ }
1390
+ }
1391
+ /**
1392
+ * v126: a delivery refused before it could be read (too large, a body that wouldn't read): it proves nothing, so it
1393
+ * counts toward its address's minute like any unproven delivery. The answer to give instead when that count is over.
1394
+ */
1395
+ export function hookRefusedUnread(store, token, now) {
1396
+ const trigger = /^[A-Za-z0-9_-]{20,64}$/.test(token) ? store.flowTriggerByHook(hookHash(token)) : null;
1397
+ const count = countUnproven(store, trigger?.hookHash ?? UNKNOWN_ADDRESS, now);
1398
+ return count.over ? { status: 429, said: "Too many. Try again later.", retryAfter: count.retryAfterSeconds } : null;
1399
+ }
1400
+ /** v126: a delivery received before, answered for what it was — never as a card that isn't there. */
1401
+ function repeatAnswer(before) {
1402
+ return { status: 202, said: before.state === "accepted" ? "Already received." : before.state === "held" ? "Already received; waiting for room."
1403
+ : before.state === "skipped" ? `Already received; it wasn't added${before.note === null ? "" : `: ${publicReason(before.note)}`}.` : "Already received." };
1404
+ }
1405
+ /** Why an item was left out, as its sender is told (v126): reasons about what it sent, but never this installation's
1406
+ * flows or cards — a reason that names one is said without it. The full words stay on the signed-in panel. */
1407
+ function publicReason(note) {
1408
+ if (note === null)
1409
+ return null;
1410
+ const at = note.indexOf("already a card in");
1411
+ return at === -1 ? note : `${note.slice(0, at)}already on a card`;
1412
+ }
1413
+ /**
1414
+ * v126: a webhook's credentials change. A new secret is written whole at an address of its own (an id made for this
1415
+ * change alone, so a change racing it never writes there), and only then does the trigger point at it — only from the
1416
+ * secret and settings read here, so of two changes at once exactly one lands. No secret means "": nothing is ever
1417
+ * adopted from a place an unfinished change may have left something. The loser removes only what it wrote; the winner
1418
+ * removes the secret it replaced, as cleanup only (nothing points at it any more).
1419
+ */
1420
+ function nextHookCredential(store, trigger, secret, configJson, now, dir, hookHash) {
1421
+ const credential = secret === null ? "" : randomBytes(16).toString("hex");
1422
+ if (secret !== null) {
1423
+ try {
1424
+ saveHookSecret(dir, trigger.id, secret, credential);
1425
+ }
1426
+ catch (error) {
1427
+ return { ok: false, message: error instanceof SecretStoreError ? error.message : "The secret couldn't be saved." };
1428
+ }
1429
+ }
1430
+ if (!store.swapFlowHookCredential(trigger.id, { credential: trigger.hookCredential, configJson: trigger.configJson, hookHash: trigger.hookHash }, { credential, ...(hookHash === undefined ? {} : { hookHash }) }, configJson, now)) {
1431
+ dropHookSecret(dir, trigger.id, credential);
1432
+ return { ok: false, message: "That trigger changed meanwhile. Open it again." };
1433
+ }
1434
+ dropHookSecret(dir, trigger.id, trigger.hookCredential);
1435
+ return { ok: true };
1436
+ }
1437
+ /** A signing secret pasted on the secure panel behind the password, never in chat: Linear's, or a signed webhook's
1438
+ * sender's (Sentry's or Vercel's own, or a Standard or GitHub-style one made elsewhere). */
1439
+ export function saveSigningSecret(store, trigger, secret, now, dir) {
1077
1440
  const config = triggerConfigOf(trigger);
1078
- if (config?.kind !== "linear" || config.delivery !== "webhook")
1079
- return { ok: false, message: "That trigger doesn't take Linear webhooks." };
1080
1441
  const clean = secret.trim();
1081
- if (!/^[A-Za-z0-9_-]{16,200}$/.test(clean))
1082
- return { ok: false, message: "Paste the signing secret exactly as Linear shows it." };
1083
- saveHookSecret(dir, trigger.id, clean);
1084
- return { ok: true };
1442
+ if (config?.kind === "linear" && config.delivery === "webhook") {
1443
+ if (!/^[A-Za-z0-9_-]{16,200}$/.test(clean))
1444
+ return { ok: false, message: "Paste the signing secret exactly as Linear shows it." };
1445
+ const saved = nextHookCredential(store, trigger, clean, null, now, dir);
1446
+ return saved.ok ? { ok: true, said: "Signing secret saved. Linear's deliveries can be proved now." } : saved;
1447
+ }
1448
+ if (config?.kind !== "webhook" || senderOf(config) === "none")
1449
+ return { ok: false, message: "That trigger doesn't take a signing secret. Choose who signs its deliveries first." };
1450
+ const sender = senderOf(config);
1451
+ if (!secretShapeOk(sender, clean))
1452
+ return { ok: false, message: SECRET_SHAPE_WORDS[sender] };
1453
+ const saved = nextHookCredential(store, trigger, clean, null, now, dir);
1454
+ return saved.ok ? { ok: true, said: `Signing secret saved. ${SENDER_WORDS[sender]} deliveries can be proved now; any earlier secret no longer works.` } : saved;
1085
1455
  }
1086
- /** Whether a webhook trigger can prove its deliveries: GitHub always (its secret is made here), Linear once its secret is pasted. */
1456
+ const SECRET_SHAPE_WORDS = {
1457
+ sentry: "Paste the Client Secret exactly as Sentry shows it on the integration (letters and digits, 32 to 128 of them).",
1458
+ vercel: "Paste the secret exactly as Vercel shows it (16 to 200 letters and digits).",
1459
+ standard: "Paste the secret exactly as the sender shows it: whsec_ followed by its key.",
1460
+ "github-style": "Paste the secret exactly as the sender shows it (16 to 200 characters, no spaces).",
1461
+ };
1462
+ /**
1463
+ * v126: a new secret for a webhook whose sender Toolroll makes secrets for (Standard Webhooks, GitHub-style), shown once.
1464
+ * Its address stays as it is (New address is its own action). Sentry's and Vercel's secrets are theirs, pasted instead.
1465
+ * The only place such a secret is made: creating a trigger (from the panel, the gallery or an import) never does.
1466
+ */
1467
+ export function setUpFlowHook(store, trigger, now, dir) {
1468
+ const config = triggerConfigOf(trigger);
1469
+ if (config?.kind !== "webhook" || trigger.state === "removed")
1470
+ return { ok: false, message: "Only a webhook trigger is set up here." };
1471
+ const sender = senderOf(config);
1472
+ if (sender === "none" || !makesSecret(sender))
1473
+ return { ok: false, message: sender === "none" ? "An unsigned webhook has no secret. Choose who signs its deliveries first." : `${SENDER_WORDS[sender]} makes its own secret: paste it here instead.` };
1474
+ const secret = makeSecret(sender);
1475
+ // A webhook with no address yet (made by the gallery or an import, where no one was shown one) gets its address with
1476
+ // its secret, both shown together once. One that has an address keeps it — a replaced secret, a set-up after its
1477
+ // sender changed — because the sender already has it.
1478
+ const first = trigger.hookHash === null;
1479
+ const token = first ? randomBytes(24).toString("base64url") : null;
1480
+ const saved = nextHookCredential(store, trigger, secret, null, now, dir, token === null ? undefined : hookHash(token));
1481
+ if (!saved.ok)
1482
+ return saved;
1483
+ const base = readHooksBase(dir);
1484
+ return first
1485
+ ? { ok: true, id: trigger.id, said: `Set up. Copy the address and the secret now; neither is shown again. Give both to whatever sends ${SENDER_WORDS[sender]} deliveries here.`,
1486
+ reveal: { path: `${HOOK_PATH}${token}`, address: base === null ? null : `${base}${HOOK_PATH}${token}`, secret } }
1487
+ : { ok: true, id: trigger.id, said: `New secret made; any earlier one no longer works. Copy it now; it isn't shown again. The address stays as it is.`,
1488
+ reveal: { path: "", address: null, secret } };
1489
+ }
1490
+ /**
1491
+ * v126: change who signs a webhook's deliveries, in place: the trigger keeps its id, address and what it has received
1492
+ * (keys carry the sender, so nothing received unsigned stands for anything signed). Its credentials move to the next
1493
+ * generation with no secret: it isn't ready until it is set up again. Settings the new sender doesn't read are left out.
1494
+ */
1495
+ export function changeFlowHookSender(store, trigger, sender, actor, now, dir) {
1496
+ const config = triggerConfigOf(trigger);
1497
+ const flow = store.getFlow(trigger.flow);
1498
+ const definition = flow === null ? null : flowDefinitionOf(flow);
1499
+ if (config?.kind !== "webhook" || flow === null || definition === null || trigger.state === "removed")
1500
+ return { ok: false, message: "Only a webhook trigger's sender can change." };
1501
+ if (typeof sender !== "string" || !WEBHOOK_SENDERS.includes(sender))
1502
+ return { ok: false, message: "Choose who signs its deliveries." };
1503
+ const next = sender;
1504
+ if (next === senderOf(config))
1505
+ return { ok: true, said: "Nothing changed." };
1506
+ const provider = next === "sentry" || next === "vercel";
1507
+ const keep = (value) => value ?? undefined;
1508
+ let changed;
1509
+ try {
1510
+ changed = validateTriggerConfig({ kind: "webhook", title: config.title, sender: next, zone: keep(config.zone),
1511
+ ...(provider ? { project: keep(config.project), ...(next === "vercel" ? { team: keep(config.team) } : {}) }
1512
+ : { titleField: keep(config.titleField), bodyField: keep(config.bodyField), idField: keep(config.idField), urlField: keep(config.urlField) }) }, { store, flow, definition, actor });
1513
+ }
1514
+ catch (error) {
1515
+ return { ok: false, message: error instanceof Error ? error.message : "That sender can't be used here." };
1516
+ }
1517
+ const moved = nextHookCredential(store, trigger, null, JSON.stringify(changed), now, dir);
1518
+ if (!moved.ok)
1519
+ return moved;
1520
+ return { ok: true, said: next === "none" ? "Now unsigned: anyone with its address can add cards."
1521
+ : makesSecret(next) ? `Now signed by ${SENDER_WORDS[next]}. Set it up (its address and secret are shown together) before deliveries are taken: until then they are refused.`
1522
+ : `Now signed by ${SENDER_WORDS[next]}. Paste ${SENDER_WORDS[next]}'s secret before deliveries are taken: until then they are refused.` };
1523
+ }
1524
+ /**
1525
+ * v126: which project (and, for Vercel, team) a Sentry or Vercel webhook takes deliveries from, set in place: the
1526
+ * trigger keeps its id, address, secret and history; blank takes any project the secret covers. Only from the settings
1527
+ * read here, so a change racing it doesn't land on top of it.
1528
+ */
1529
+ export function setFlowHookScope(store, trigger, scope, actor, now) {
1530
+ const config = triggerConfigOf(trigger);
1531
+ const flow = store.getFlow(trigger.flow);
1532
+ const definition = flow === null ? null : flowDefinitionOf(flow);
1533
+ if (config?.kind !== "webhook" || flow === null || definition === null || trigger.state === "removed")
1534
+ return { ok: false, message: "Only a webhook trigger is scoped here." };
1535
+ const sender = senderOf(config);
1536
+ if (sender !== "sentry" && sender !== "vercel")
1537
+ return { ok: false, message: "Only a Sentry or Vercel webhook is scoped to a project." };
1538
+ const words = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
1539
+ let changed;
1540
+ try {
1541
+ changed = validateTriggerConfig({ kind: "webhook", title: config.title, sender, zone: config.zone ?? undefined, project: words(scope.project), ...(sender === "vercel" ? { team: words(scope.team) } : {}) }, { store, flow, definition, actor });
1542
+ }
1543
+ catch (error) {
1544
+ return { ok: false, message: error instanceof Error ? error.message : "That scope can't be used." };
1545
+ }
1546
+ if (!store.swapFlowHookCredential(trigger.id, { credential: trigger.hookCredential, configJson: trigger.configJson, hookHash: trigger.hookHash }, { credential: trigger.hookCredential }, JSON.stringify(changed), now)) {
1547
+ return { ok: false, message: "That trigger changed meanwhile. Open it again." };
1548
+ }
1549
+ const project = changed.kind === "webhook" ? changed.project ?? null : null;
1550
+ return { ok: true, said: project === null ? `Any ${SENDER_WORDS[sender]} project this secret covers can add cards.` : `Only deliveries from ${project} add cards now.` };
1551
+ }
1552
+ /** Whether a webhook trigger can prove its deliveries: an unsigned webhook always (its address is all it has), the others
1553
+ * once the current generation's secret is saved. */
1087
1554
  export function hookReady(trigger, dir) {
1088
1555
  const config = triggerConfigOf(trigger);
1089
1556
  if (config === null || !takesDeliveries(config))
1090
1557
  return false;
1091
- if (config.kind === "webhook")
1558
+ if (config.kind === "webhook" && senderOf(config) === "none")
1092
1559
  return true;
1093
- return dir !== null && existsSync(secretFile(dir, trigger.id));
1560
+ return dir !== null && readHookSecret(dir, trigger.id, trigger.hookCredential) !== null;
1094
1561
  }
1095
1562
  // ------------------------------------------------------ public forms (v84)
1096
1563
  export const FORM_PATH = "/hooks/form/";