toolroll 0.9.63 → 0.9.67

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 (302) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +33 -5
  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 +117 -2
  16. package/dist/agent-fence.js +439 -12
  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 +39 -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/batch-checks.js +3 -2
  32. package/dist/browser/THIRD_PARTY_NOTICES.txt +94 -0
  33. package/dist/browser/terminal.css +37 -0
  34. package/dist/browser/terminal.js +228 -0
  35. package/dist/browser/workspace.css +2 -2
  36. package/dist/browser/workspace.js +42 -38
  37. package/dist/browser-shell.js +6 -3
  38. package/dist/browser-workspace.d.ts +235 -1
  39. package/dist/browser-workspace.js +60 -0
  40. package/dist/builder.d.ts +10 -1
  41. package/dist/builder.js +87 -24
  42. package/dist/chat-actions.d.ts +13 -1
  43. package/dist/chat-actions.js +197 -15
  44. package/dist/chat-ask.d.ts +4 -0
  45. package/dist/chat-ask.js +14 -0
  46. package/dist/chat-channel.js +20 -0
  47. package/dist/chat-continuity.js +5 -4
  48. package/dist/chat-decide.d.ts +0 -14
  49. package/dist/chat-decide.js +4 -1
  50. package/dist/chat-delivery.d.ts +9 -0
  51. package/dist/chat-delivery.js +45 -6
  52. package/dist/chat-messages.d.ts +8 -0
  53. package/dist/chat-messages.js +36 -4
  54. package/dist/check-levels-ui.js +2 -2
  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 +19 -0
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +4 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +21 -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/console-api.d.ts +13 -4
  71. package/dist/contracts/console-api.js +13 -4
  72. package/dist/contracts/flow-card.d.ts +4 -0
  73. package/dist/contracts/flow-card.js +3 -0
  74. package/dist/contracts/flow.d.ts +46 -0
  75. package/dist/contracts/flow.js +11 -2
  76. package/dist/contracts/lead-tools.d.ts +92 -0
  77. package/dist/contracts/lead-tools.js +13 -0
  78. package/dist/contracts/provider.d.ts +11 -1
  79. package/dist/contracts/provider.js +1 -1
  80. package/dist/contracts/route.d.ts +76 -1
  81. package/dist/contracts/route.js +1 -1
  82. package/dist/contracts/store-json.d.ts +2 -0
  83. package/dist/control-setup.d.ts +1 -1
  84. package/dist/control-ui.js +2 -2
  85. package/dist/converse.d.ts +16 -0
  86. package/dist/converse.js +32 -4
  87. package/dist/demo-chat.js +4 -4
  88. package/dist/demo.js +2 -2
  89. package/dist/desktop-update-gate.js +6 -0
  90. package/dist/desktop-update.d.ts +1 -1
  91. package/dist/discord-chat.js +4 -1
  92. package/dist/doctor.js +1 -1
  93. package/dist/egress-bridge.d.ts +17 -0
  94. package/dist/egress-bridge.js +111 -0
  95. package/dist/egress.d.ts +119 -0
  96. package/dist/egress.js +423 -0
  97. package/dist/exec.d.ts +78 -0
  98. package/dist/exec.js +367 -9
  99. package/dist/export-ui.js +2 -2
  100. package/dist/flow-engine.js +24 -5
  101. package/dist/flow-gallery-ui.js +2 -2
  102. package/dist/flow-gallery.js +27 -8
  103. package/dist/flow-share.js +16 -5
  104. package/dist/flow-triggers.d.ts +77 -8
  105. package/dist/flow-triggers.js +517 -70
  106. package/dist/flows-cli.js +8 -2
  107. package/dist/flows-ui.d.ts +0 -2
  108. package/dist/flows-ui.js +52 -4
  109. package/dist/flows.d.ts +4 -0
  110. package/dist/flows.js +9 -1
  111. package/dist/html.d.ts +9 -0
  112. package/dist/html.js +11 -0
  113. package/dist/invoke.d.ts +35 -0
  114. package/dist/invoke.js +347 -28
  115. package/dist/keys.d.ts +18 -14
  116. package/dist/keys.js +5 -0
  117. package/dist/lead-capabilities.js +3 -2
  118. package/dist/lead-contract.js +2 -0
  119. package/dist/lead-doors.d.ts +2 -0
  120. package/dist/lead-doors.js +7 -0
  121. package/dist/lead-follow.d.ts +2 -0
  122. package/dist/lead-follow.js +58 -2
  123. package/dist/lead-progress.js +1 -0
  124. package/dist/lead-queue-runtime.d.ts +46 -0
  125. package/dist/lead-queue-runtime.js +191 -0
  126. package/dist/lead-queue.d.ts +104 -0
  127. package/dist/lead-queue.js +187 -0
  128. package/dist/lead-sweep.d.ts +67 -0
  129. package/dist/lead-sweep.js +99 -0
  130. package/dist/lead-tools.d.ts +22 -0
  131. package/dist/lead-tools.js +152 -1
  132. package/dist/lead.d.ts +13 -5
  133. package/dist/lead.js +89 -29
  134. package/dist/limits-ui.d.ts +8 -1
  135. package/dist/limits-ui.js +26 -0
  136. package/dist/live-bus.d.ts +8 -0
  137. package/dist/live-bus.js +40 -4
  138. package/dist/live-detail.d.ts +27 -0
  139. package/dist/live-detail.js +57 -0
  140. package/dist/live-notes.d.ts +20 -0
  141. package/dist/live-notes.js +47 -0
  142. package/dist/live-records.d.ts +140 -0
  143. package/dist/live-records.js +935 -0
  144. package/dist/live-terminal.d.ts +269 -0
  145. package/dist/live-terminal.js +686 -0
  146. package/dist/live.d.ts +38 -27
  147. package/dist/live.js +142 -124
  148. package/dist/mcp-oauth.d.ts +6 -3
  149. package/dist/mcp-oauth.js +8 -4
  150. package/dist/memory-pass.js +2 -2
  151. package/dist/mobile-viewport.d.ts +5 -1
  152. package/dist/mobile-viewport.js +18 -2
  153. package/dist/model-catalog.js +4 -0
  154. package/dist/monitoring-ui.js +2 -2
  155. package/dist/needs-you.d.ts +1 -1
  156. package/dist/needs-you.js +11 -4
  157. package/dist/operate.d.ts +4 -0
  158. package/dist/operate.js +335 -19
  159. package/dist/passwordless.d.ts +23 -0
  160. package/dist/passwordless.js +26 -0
  161. package/dist/peek-cli.d.ts +12 -4
  162. package/dist/peek-cli.js +19 -11
  163. package/dist/phase-routing.js +10 -3
  164. package/dist/planner-source.d.ts +3 -0
  165. package/dist/planner-source.js +14 -0
  166. package/dist/planner.d.ts +2 -1
  167. package/dist/planner.js +7 -1
  168. package/dist/policy-ui.js +2 -2
  169. package/dist/policy.d.ts +1 -20
  170. package/dist/policy.js +29 -2
  171. package/dist/project-delete-ui.d.ts +18 -0
  172. package/dist/project-delete-ui.js +60 -9
  173. package/dist/project-delete.js +2 -2
  174. package/dist/project-memory.d.ts +3 -1
  175. package/dist/project-memory.js +57 -25
  176. package/dist/project-tools.d.ts +5 -3
  177. package/dist/project-tools.js +12 -9
  178. package/dist/provider-auth.d.ts +4 -3
  179. package/dist/provider-auth.js +5 -4
  180. package/dist/provider-connection.d.ts +2 -1
  181. package/dist/provider-connection.js +5 -0
  182. package/dist/provider.d.ts +25 -2
  183. package/dist/provider.js +63 -9
  184. package/dist/pull-request-ui.js +2 -2
  185. package/dist/push.js +1 -1
  186. package/dist/quick-start.d.ts +24 -0
  187. package/dist/quick-start.js +127 -0
  188. package/dist/release-adapters.d.ts +6 -0
  189. package/dist/release-adapters.js +70 -3
  190. package/dist/release.d.ts +10 -1
  191. package/dist/release.js +34 -15
  192. package/dist/request-budget-ui.js +2 -2
  193. package/dist/result-follow-ups.js +3 -2
  194. package/dist/result-review.d.ts +1 -1
  195. package/dist/result-review.js +9 -3
  196. package/dist/retention-ui.js +2 -2
  197. package/dist/retention.js +10 -1
  198. package/dist/run-efficiency.d.ts +73 -0
  199. package/dist/run-efficiency.js +228 -0
  200. package/dist/runner.d.ts +5 -1
  201. package/dist/runner.js +22 -1
  202. package/dist/scope.d.ts +183 -5
  203. package/dist/scope.js +256 -31
  204. package/dist/scout-net.d.ts +4 -1
  205. package/dist/scout-net.js +37 -12
  206. package/dist/scout.d.ts +20 -6
  207. package/dist/scout.js +27 -3
  208. package/dist/serve.js +248 -40
  209. package/dist/server/chat.d.ts +1 -1
  210. package/dist/server/chat.js +122 -31
  211. package/dist/server/chrome.d.ts +16 -2
  212. package/dist/server/chrome.js +63 -11
  213. package/dist/server/console.css +107 -42
  214. package/dist/server/flows.js +38 -8
  215. package/dist/server/guards.d.ts +1 -0
  216. package/dist/server/guards.js +23 -9
  217. package/dist/server/http.d.ts +7 -0
  218. package/dist/server/http.js +3 -2
  219. package/dist/server/live.d.ts +34 -1
  220. package/dist/server/live.js +39 -17
  221. package/dist/server/pages-assets.js +5 -0
  222. package/dist/server/pages.js +90 -13
  223. package/dist/server/people-tokens.d.ts +3 -0
  224. package/dist/server/people-tokens.js +55 -17
  225. package/dist/server/remote-hooks.js +12 -6
  226. package/dist/server/remote.d.ts +2 -1
  227. package/dist/server/remote.js +14 -5
  228. package/dist/server/render-chat.d.ts +12 -3
  229. package/dist/server/render-chat.js +47 -16
  230. package/dist/server/render-pages.d.ts +32 -4
  231. package/dist/server/render-pages.js +83 -13
  232. package/dist/server/render-people.d.ts +3 -2
  233. package/dist/server/render-people.js +3 -2
  234. package/dist/server/render-results.d.ts +18 -2
  235. package/dist/server/render-results.js +37 -28
  236. package/dist/server/render-settings.js +6 -5
  237. package/dist/server/render-tasks.d.ts +35 -10
  238. package/dist/server/render-tasks.js +111 -27
  239. package/dist/server/request-context.d.ts +2 -0
  240. package/dist/server/request-context.js +2 -1
  241. package/dist/server/route-table.d.ts +3 -0
  242. package/dist/server/route-table.js +47 -5
  243. package/dist/server/runtime.d.ts +33 -2
  244. package/dist/server/session.d.ts +25 -1
  245. package/dist/server/session.js +18 -4
  246. package/dist/server/settings.js +142 -10
  247. package/dist/server/sweeps.d.ts +4 -0
  248. package/dist/server/sweeps.js +58 -0
  249. package/dist/server/task-views.d.ts +2 -1
  250. package/dist/server/task-views.js +96 -6
  251. package/dist/server/tasks.js +327 -78
  252. package/dist/server/terminals.d.ts +31 -0
  253. package/dist/server/terminals.js +365 -0
  254. package/dist/setup-guide.js +3 -2
  255. package/dist/slack-chat.js +4 -1
  256. package/dist/spend-ui.js +3 -3
  257. package/dist/spend.js +2 -1
  258. package/dist/steer-capabilities.d.ts +32 -0
  259. package/dist/steer-capabilities.js +60 -0
  260. package/dist/steer-controls.d.ts +14 -0
  261. package/dist/steer-controls.js +36 -0
  262. package/dist/steer-status.js +2 -2
  263. package/dist/storage-ui.js +4 -4
  264. package/dist/store-baseline.d.ts +1 -1
  265. package/dist/store-baseline.js +1 -1
  266. package/dist/store.d.ts +440 -15
  267. package/dist/store.js +1072 -52
  268. package/dist/subagent-desk.js +1 -1
  269. package/dist/summary.js +3 -0
  270. package/dist/task-source.d.ts +58 -0
  271. package/dist/task-source.js +93 -0
  272. package/dist/team-leads.js +1 -1
  273. package/dist/teams-chat.js +4 -1
  274. package/dist/teardown-git.d.ts +22 -0
  275. package/dist/teardown-git.js +136 -2
  276. package/dist/telegram-lead.d.ts +3 -0
  277. package/dist/telegram-lead.js +44 -7
  278. package/dist/telegram.d.ts +11 -2
  279. package/dist/telegram.js +51 -17
  280. package/dist/terminal-catalog.d.ts +11 -0
  281. package/dist/terminal-catalog.js +129 -0
  282. package/dist/text-limits.d.ts +2 -0
  283. package/dist/text-limits.js +2 -0
  284. package/dist/tool-broker.d.ts +14 -0
  285. package/dist/tool-broker.js +67 -2
  286. package/dist/toolroll-update-ui.js +2 -2
  287. package/dist/toolroll-update.js +13 -3
  288. package/dist/tools-ui.js +2 -2
  289. package/dist/unfenced-test-support.d.ts +2 -0
  290. package/dist/unfenced-test-support.js +11 -0
  291. package/dist/webhook-limits.d.ts +23 -0
  292. package/dist/webhook-limits.js +30 -0
  293. package/dist/webhook-readers.d.ts +56 -0
  294. package/dist/webhook-readers.js +138 -0
  295. package/dist/webhook-signing.d.ts +27 -0
  296. package/dist/webhook-signing.js +119 -0
  297. package/dist/work-index.d.ts +29 -0
  298. package/dist/work-index.js +16 -2
  299. package/dist/workspace-revision.js +3 -1
  300. package/dist/writes-mode.d.ts +9 -0
  301. package/dist/writes-mode.js +8 -0
  302. package/package.json +15 -2
@@ -37,6 +37,9 @@ import { join } from "node:path";
37
37
  import { scanForSecrets } from "./evidence.js";
38
38
  import { flowCardText, flowDefinitionOf } from "./flow-engine.js";
39
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";
40
43
  import { parseContract } from "./contracts/contract.js";
41
44
  import { FLOW_ALIASES, FLOW_TRIGGER_KINDS, SECRETS_MAX, triggerConfigSchema, triggerInputSchema } from "./contracts/flow.js";
42
45
  import { describeSchedule, fileStandingOrder, firstFireAt, nextFireAt, parseSchedule, StandingOrderRefused, WEEKDAYS } from "./flow-schedule.js";
@@ -207,7 +210,29 @@ export function validateTriggerConfig(raw, context) {
207
210
  return { kind: "flow", flow: flow.id, when: stage.id, zone };
208
211
  }
209
212
  case "webhook":
210
- 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
+ }
211
236
  case "plane-review": {
212
237
  const at = said(input.at) ?? "07:30";
213
238
  const clock = /^(\d{1,2}):(\d{2})$/.exec(at);
@@ -271,7 +296,7 @@ export function describeTrigger(config, store) {
271
296
  const zone = flow === null ? null : flowDefinitionOf(flow)?.stages.find(one => one.id === config.when)?.title ?? null;
272
297
  return `Cards reaching ${zone ?? "the end"} in ${flow?.name ?? "another flow"}`;
273
298
  }
274
- case "webhook": return "Anything posted to its webhook address";
299
+ case "webhook": return webhookWords(config);
275
300
  case "chat": return `Every new message in a ${CHAT_APP_NAMES[config.app]} channel; replies in a card's thread join its discussion`;
276
301
  case "plane-review": {
277
302
  const schedule = parseSchedule(config.schedule);
@@ -295,7 +320,7 @@ export function triggerHeadline(config, store) {
295
320
  const flow = store.getFlow(config.flow);
296
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"}` };
297
322
  }
298
- 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) };
299
324
  case "chat": return { name: `${CHAT_APP_NAMES[config.app]} channel`, detail: "Each message is a card" };
300
325
  case "plane-review": {
301
326
  const schedule = parseSchedule(config.schedule);
@@ -318,7 +343,7 @@ export function triggerConfigOf(trigger) {
318
343
  /** What a saved trigger leaves out, it had as these (every release has saved them whole; a hand-made row may not). */
319
344
  const SAVED_DEFAULTS = {
320
345
  button: { questions: ["What needs doing?", "Details"] }, schedule: { description: null }, github: { label: null, branch: null, from: "team", delivery: "poll" },
321
- 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 },
322
347
  };
323
348
  /** A saved trigger's config (`configJson`, which carries no version) read by its one schema, what it leaves out filled. */
324
349
  export function readTriggerConfig(raw) {
@@ -326,21 +351,50 @@ export function readTriggerConfig(raw) {
326
351
  const body = isRecord(raw) ? { zone: null, ...SAVED_DEFAULTS[kind], ...raw } : raw;
327
352
  return parseContract(triggerConfigSchema, body);
328
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";
329
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
+ }
330
371
  export function takesDeliveries(config) {
331
372
  return config.kind === "webhook" || ((config.kind === "github" || config.kind === "linear") && config.delivery === "webhook");
332
373
  }
333
374
  // ------------------------------------------------------ secrets on disk
334
375
  const hookHash = (token) => createHash("sha256").update(token).digest("hex");
335
- /** A webhook's signing secret: kept by Toolroll (tool-secret-store.ts) as `flow-hook.<trigger>`, in a file of that name before. */
336
- const hookSecret = (trigger) => ({ name: `flow-hook.${trigger}`, file: ["flow-hooks", `${trigger}.json`] });
337
- /** A webhook's signing secret (GitHub: made here; Linear: pasted from Linear). In the keychain (or an owner-only file where there's none), never a column. */
338
- export function saveHookSecret(dir, trigger, secret) {
339
- const held = hookSecret(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");
340
391
  writeHeldSecret(dir, held.name, held.file, `${JSON.stringify({ secret })}\n`);
341
392
  }
342
- export function readHookSecret(dir, trigger) {
343
- const held = hookSecret(trigger);
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;
344
398
  try {
345
399
  const parsed = JSON.parse(readHeldSecret(dir, held.name, held.file) ?? "null");
346
400
  return typeof parsed?.secret === "string" && parsed.secret !== "" ? parsed.secret : null;
@@ -349,12 +403,15 @@ export function readHookSecret(dir, trigger) {
349
403
  return null;
350
404
  }
351
405
  }
352
- function dropHookSecret(dir, trigger) {
353
- const held = hookSecret(trigger);
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;
354
411
  try {
355
412
  writeHeldSecret(dir, held.name, held.file, null);
356
413
  }
357
- catch { /* the trigger is gone; an orphan is never read */ }
414
+ catch { /* never read: nothing points at it */ }
358
415
  }
359
416
  const LINEAR = { name: "linear-key", file: ["linear-key"] };
360
417
  export const LINEAR_ENV = "TOOLROLL_LINEAR_KEY";
@@ -419,7 +476,12 @@ export function saveHooksBase(dir, value) {
419
476
  }
420
477
  export const HOOK_PATH = "/hooks/flow/";
421
478
  /** Add a trigger to a flow. A webhook trigger's address (and GitHub's signing secret) is returned once and never stored readable. */
422
- 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 = {}) {
423
485
  const definition = flowDefinitionOf(flow);
424
486
  if (definition === null || flow.state !== "active")
425
487
  return { ok: false, message: "This flow can't take triggers right now." };
@@ -432,7 +494,11 @@ export function addFlowTriggerTo(store, flow, raw, actor, now, dir) {
432
494
  }
433
495
  if (takesDeliveries(config) && dir === null)
434
496
  return { ok: false, message: "Webhook addresses are set up on the console." };
435
- 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;
436
502
  const schedule = config.kind === "schedule" || config.kind === "plane-review" ? parseSchedule(config.schedule) : null;
437
503
  const id = store.addFlowTrigger({
438
504
  // The table's kinds were fixed before plane reviews: one is kept as the daily schedule it is.
@@ -448,6 +514,9 @@ export function addFlowTriggerTo(store, flow, raw, actor, now, dir) {
448
514
  secret = randomBytes(24).toString("hex");
449
515
  saveHookSecret(dir, id, secret);
450
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);
451
520
  const reveal = token === null ? null : { path: `${HOOK_PATH}${token}`, address: dir === null || readHooksBase(dir) === null ? null : `${readHooksBase(dir)}${HOOK_PATH}${token}`, secret };
452
521
  return { ok: true, id, said: token === null ? "Trigger added." : "Trigger added. Copy its address now; it isn't shown again.", reveal };
453
522
  }
@@ -457,11 +526,18 @@ export function renewFlowHook(store, trigger, now, dir) {
457
526
  if (config === null || !takesDeliveries(config) || trigger.state === "removed")
458
527
  return { ok: false, message: "That trigger has no webhook address." };
459
528
  const token = randomBytes(24).toString("base64url");
460
- store.updateFlowTrigger(trigger.id, { hookHash: hookHash(token) }, now);
461
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.
462
533
  if (config.kind === "github") {
463
534
  secret = randomBytes(24).toString("hex");
464
- 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." };
465
541
  }
466
542
  const base = readHooksBase(dir);
467
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 } };
@@ -478,7 +554,7 @@ export function setFlowTriggerOn(store, trigger, on, now, by) {
478
554
  export function removeFlowTrigger(store, trigger, now, dir, by) {
479
555
  store.updateFlowTrigger(trigger.id, { state: "removed", hookHash: null }, now, by);
480
556
  if (dir !== null)
481
- dropHookSecret(dir, trigger.id);
557
+ dropHookSecret(dir, trigger.id, trigger.hookCredential);
482
558
  }
483
559
  /** Checked by the worker's pass (rather than pushed, fired on a schedule or followed). */
484
560
  function polled(config) {
@@ -494,7 +570,7 @@ function checksCardOf(store, repo, key) {
494
570
  return row === undefined ? null : `“${String(row["name"])}”`;
495
571
  }
496
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. */
497
- function makeCard(store, trigger, config, item, by, now) {
573
+ function makeCard(store, trigger, config, item, by, now, byKind = "automation") {
498
574
  const flow = store.getFlow(trigger.flow);
499
575
  const definition = flow === null ? null : flowDefinitionOf(flow);
500
576
  if (flow === null || definition === null || flow.state !== "active")
@@ -518,7 +594,7 @@ function makeCard(store, trigger, config, item, by, now) {
518
594
  store.recordFlowTriggerEvent(trigger.id, item.key, null, note, now);
519
595
  return { made: "skipped", note: `${item.source.label}: ${note}`, card: null };
520
596
  }
521
- const card = store.addFlowCard({ flow: flow.id, title: text.title, description: text.description, stage: startZone(config, definition), by, source: item.source }, 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);
522
598
  store.recordFlowTriggerEvent(trigger.id, item.key, card, null, now, null, item.onTime === true, item.ranScript ?? null);
523
599
  return { made: "added", note: null, card };
524
600
  });
@@ -550,7 +626,7 @@ function fileOrderCard(store, trigger, config, order, item, slot, now) {
550
626
  store.recordFlowTriggerEvent(trigger.id, item.key, null, fired.note, now);
551
627
  return { made: "skipped", note: fired.note, card: null };
552
628
  }
553
- 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);
554
630
  store.updateFlowCard(card, { task: fired.taskId, primaryTask: fired.taskId, waiting: "Filed as a task" }, now);
555
631
  // The firing keeps its task: one-at-a-time and the weekly limit count it however the card moves later.
556
632
  store.recordFlowTriggerEvent(trigger.id, item.key, card, null, now, fired.taskId);
@@ -579,7 +655,8 @@ export function addTriggerCard(store, trigger, item, by, now) {
579
655
  const config = triggerConfigOf(trigger);
580
656
  if (config === null || trigger.state !== "active")
581
657
  return { made: "skipped", note: "the trigger isn't active", card: null };
582
- 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");
583
660
  }
584
661
  /** Press a button trigger: the first answer is the card's title, the rest its details. */
585
662
  export function pressFlowButton(store, trigger, answers, actor, now) {
@@ -590,7 +667,7 @@ export function pressFlowButton(store, trigger, answers, actor, now) {
590
667
  if (said[0] === "")
591
668
  return { ok: false, message: `Answer “${config.questions[0]}”.` };
592
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");
593
- 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");
594
671
  if (result.made !== "added")
595
672
  return { ok: false, message: result.note ?? "That didn't make a card." };
596
673
  store.setFlowCardWatcher(result.card, actor, true, now);
@@ -608,6 +685,9 @@ export async function runFlowTriggers(store, repo, now, io) {
608
685
  if (config === null)
609
686
  continue;
610
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);
611
691
  if (config.kind === "schedule" && config.script !== undefined && trigger.nextAt !== null && Date.parse(trigger.nextAt) <= now.getTime()) {
612
692
  const fired = await fireScript(store, trigger, config, now, io, true);
613
693
  pass.added += fired.added;
@@ -827,12 +907,15 @@ async function checkTrigger(store, trigger, config, now, io) {
827
907
  return { ok: false, said: fetched.problem, added: 0 };
828
908
  }
829
909
  const by = config.kind === "github" ? "GitHub" : config.kind === "linear" ? "Linear" : "Email";
830
- 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);
831
914
  const results = due.map(item => "skip" in item
832
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 })
833
916
  : makeCard(store, trigger, config, item, by, now));
834
917
  // A mailbox says itself whether more is waiting; its cursor is already just past what it read.
835
- const more = fetched.more ?? fetched.items.length > CARDS_PER_CHECK;
918
+ const more = fetched.more ?? fresh.length > CARDS_PER_CHECK;
836
919
  // The cursor moves past what was handled, never past what is still waiting.
837
920
  const cursor = fetched.more !== undefined ? fetched.cursor ?? trigger.cursor : more ? due.at(-1)?.at ?? trigger.cursor : fetched.cursor ?? trigger.cursor;
838
921
  const said = results.length === 0 && fetched.said !== undefined ? fetched.said : outcomeWords(results, more);
@@ -1008,24 +1091,81 @@ async function fetchLinear(config, trigger, io) {
1008
1091
  const items = issues.map(one => linearItem(config, one)).filter((one) => one !== null);
1009
1092
  return { ok: true, items, cursor: issues.map(one => one.updatedAt ?? "").filter(one => one !== "").at(-1) ?? trigger.cursor };
1010
1093
  }
1011
- 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] ?? "";
1012
1096
  function sameHex(expected, given) {
1013
1097
  const a = Buffer.from(expected, "utf8"), b = Buffer.from(given, "utf8");
1014
1098
  return a.length === b.length && timingSafeEqual(a, b);
1015
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
+ }
1016
1128
  /** A webhook delivery at a trigger's secret address: prove it, read it, make at most the cards it names. Nothing runs here. */
1017
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
+ };
1018
1136
  if (!/^[A-Za-z0-9_-]{20,64}$/.test(token))
1019
- return { status: 404, said: "No such address." };
1137
+ return unproven(UNKNOWN_ADDRESS, { status: 404, said: "No such address." });
1020
1138
  const trigger = store.flowTriggerByHook(hookHash(token));
1021
1139
  const config = trigger === null ? null : triggerConfigOf(trigger);
1022
1140
  if (trigger === null || config === null || !takesDeliveries(config))
1023
- return { status: 404, said: "No such address." };
1141
+ return unproven(UNKNOWN_ADDRESS, { status: 404, said: "No such address." });
1024
1142
  if (!adapterPolicy({ caller: "service", capability: "none" }, undefined, source => source === "flow" && store.getFlow(trigger.flow) !== null).ok)
1025
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
+ }
1026
1152
  if (delivery.body.length > HOOK_BYTES)
1027
- 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);
1028
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
+ });
1029
1169
  let payload;
1030
1170
  try {
1031
1171
  payload = JSON.parse(delivery.body.toString("utf8"));
@@ -1035,16 +1175,10 @@ export function receiveFlowHook(store, token, delivery, dir, now) {
1035
1175
  }
1036
1176
  let items = [];
1037
1177
  if (config.kind === "github") {
1038
- const secret = readHookSecret(dir, trigger.id);
1039
- const signature = header(delivery, "x-hub-signature-256");
1040
- if (secret === null || !sameHex(`sha256=${createHmac("sha256", secret).update(delivery.body).digest("hex")}`, signature))
1041
- return { status: 401, said: "Signature doesn't match." };
1042
1178
  const event = header(delivery, "x-github-event"), body = readGithubEvent(payload), action = body.action ?? "";
1043
1179
  const repository = body.repository?.full_name ?? "";
1044
- if (event === "ping") {
1045
- note("GitHub connected.");
1046
- return { status: 200, said: "Connected." };
1047
- }
1180
+ if (event === "ping")
1181
+ return written(() => { note("GitHub connected."); return { status: 200, said: "Connected." }; });
1048
1182
  if (repository.toLowerCase() !== config.repo.toLowerCase())
1049
1183
  return { status: 202, said: "Not this repository." };
1050
1184
  // A label trigger takes the moment its label is added (or an issue opened with it); a plain one takes new issues.
@@ -1055,14 +1189,10 @@ export function receiveFlowHook(store, token, delivery, dir, now) {
1055
1189
  items = found === null ? [] : [found];
1056
1190
  }
1057
1191
  else if (config.kind === "linear") {
1058
- const secret = readHookSecret(dir, trigger.id);
1059
- if (secret === null)
1060
- return { status: 401, said: "Paste Linear's signing secret on the flow's Triggers panel first." };
1061
- if (!sameHex(createHmac("sha256", secret).update(delivery.body).digest("hex"), header(delivery, "linear-signature")))
1062
- return { status: 401, said: "Signature doesn't match." };
1063
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.
1064
1194
  if (!Number.isFinite(sent) || Math.abs(now.getTime() - sent) > 5 * 60_000)
1065
- return { status: 401, said: "Too old." };
1195
+ return unproven(address, { status: 401, said: "Too old." });
1066
1196
  if (body.type !== "Issue" || !["create", "update"].includes(body.action ?? ""))
1067
1197
  return { status: 202, said: "Ignored." };
1068
1198
  // A state trigger fires when the issue arrives in that state, not on every later edit there.
@@ -1071,46 +1201,363 @@ export function receiveFlowHook(store, token, delivery, dir, now) {
1071
1201
  items = found === null ? [] : [found];
1072
1202
  }
1073
1203
  else if (config.kind === "webhook") {
1074
- // 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.
1075
1206
  const body = readWebhookPayload(payload);
1076
- const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
1077
- const pick = (path) => path === null ? null : path.split(".").reduce((node, part) => record(node)[part], body);
1078
- const title = pick(config.titleField ?? "title") ?? pick("summary") ?? pick("message");
1079
- const bodyText = pick(config.bodyField ?? "description") ?? pick("body") ?? pick("text");
1080
- const id = header(delivery, "x-request-id") || header(delivery, "x-delivery-id") || header(delivery, "idempotency-key") || createHash("sha256").update(delivery.body).digest("hex");
1081
- items = [{ key: `hook:${id.slice(0, 120)}`, at: null, title: typeof title === "string" && title.trim() !== "" ? title : config.title,
1082
- description: `From a webhook:\n\n${typeof bodyText === "string" ? bodyText : JSON.stringify(body, null, 2)}`.slice(0, 4000),
1083
- 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
+ }
1084
1243
  }
1085
1244
  if (items.length === 0)
1086
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
+ });
1087
1265
  const by = config.kind === "github" ? "GitHub" : config.kind === "linear" ? "Linear" : "Webhook";
1088
- const results = items.slice(0, CARDS_PER_CHECK).map(item => "skip" in item
1089
- ? (store.recordFlowTriggerEvent(trigger.id, item.key, null, item.skip, now) ? { made: "skipped", note: `${item.label}: ${item.skip}` } : { made: "seen", note: null })
1090
- : makeCard(store, trigger, config, item, by, now));
1091
- const said = outcomeWords(results, false);
1092
- note(said);
1093
- 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
+ }
1343
+ }
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 };
1094
1436
  }
1095
- /** Save Linear's signing secret for a Linear webhook trigger (pasted on the secure panel, never in chat). */
1096
- export function saveLinearSigningSecret(trigger, secret, dir) {
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) {
1097
1440
  const config = triggerConfigOf(trigger);
1098
- if (config?.kind !== "linear" || config.delivery !== "webhook")
1099
- return { ok: false, message: "That trigger doesn't take Linear webhooks." };
1100
1441
  const clean = secret.trim();
1101
- if (!/^[A-Za-z0-9_-]{16,200}$/.test(clean))
1102
- return { ok: false, message: "Paste the signing secret exactly as Linear shows it." };
1103
- saveHookSecret(dir, trigger.id, clean);
1104
- 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;
1105
1455
  }
1106
- /** 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. */
1107
1554
  export function hookReady(trigger, dir) {
1108
1555
  const config = triggerConfigOf(trigger);
1109
1556
  if (config === null || !takesDeliveries(config))
1110
1557
  return false;
1111
- if (config.kind === "webhook")
1558
+ if (config.kind === "webhook" && senderOf(config) === "none")
1112
1559
  return true;
1113
- return dir !== null && readHookSecret(dir, trigger.id) !== null;
1560
+ return dir !== null && readHookSecret(dir, trigger.id, trigger.hookCredential) !== null;
1114
1561
  }
1115
1562
  // ------------------------------------------------------ public forms (v84)
1116
1563
  export const FORM_PATH = "/hooks/form/";