toolroll 0.9.63 → 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.
- package/CHANGELOG.md +51 -0
- package/README.md +15 -5
- package/dist/acp-agents.d.ts +43 -0
- package/dist/acp-agents.js +62 -0
- package/dist/acp-attest.d.ts +44 -0
- package/dist/acp-attest.js +269 -0
- package/dist/acp-check.d.ts +28 -0
- package/dist/acp-check.js +410 -0
- package/dist/acp-client.d.ts +163 -0
- package/dist/acp-client.js +738 -0
- package/dist/acp-inspect.d.ts +23 -0
- package/dist/acp-inspect.js +109 -0
- package/dist/agent-board.d.ts +19 -6
- package/dist/agent-board.js +30 -9
- package/dist/agent-fence.d.ts +50 -2
- package/dist/agent-fence.js +157 -10
- package/dist/agent-pulse.js +9 -2
- package/dist/agent-state-words.d.ts +19 -0
- package/dist/agent-state-words.js +20 -0
- package/dist/agent-state.d.ts +65 -0
- package/dist/agent-state.js +229 -0
- package/dist/agentconfig.js +36 -13
- package/dist/agents-board.d.ts +34 -2
- package/dist/agents-board.js +110 -1
- package/dist/approval-rules-ui.js +2 -2
- package/dist/assignment-delivery.d.ts +14 -0
- package/dist/assignment-delivery.js +13 -0
- package/dist/assignment.js +13 -0
- package/dist/attest.js +7 -0
- package/dist/backup-ui.js +2 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +48 -0
- package/dist/browser/terminal.css +37 -0
- package/dist/browser/terminal.js +117 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +42 -38
- package/dist/browser-shell.js +6 -3
- package/dist/browser-workspace.d.ts +235 -1
- package/dist/browser-workspace.js +60 -0
- package/dist/builder.d.ts +6 -1
- package/dist/builder.js +34 -13
- package/dist/chat-actions.d.ts +13 -1
- package/dist/chat-actions.js +197 -15
- package/dist/chat-ask.d.ts +4 -0
- package/dist/chat-ask.js +14 -0
- package/dist/chat-channel.js +20 -0
- package/dist/chat-continuity.js +5 -4
- package/dist/chat-decide.d.ts +0 -14
- package/dist/chat-decide.js +4 -1
- package/dist/chat-delivery.d.ts +9 -0
- package/dist/chat-delivery.js +45 -6
- package/dist/chat-messages.d.ts +8 -0
- package/dist/chat-messages.js +36 -4
- package/dist/check-levels-ui.js +2 -2
- package/dist/checkout-proof.d.ts +28 -0
- package/dist/checkout-proof.js +62 -0
- package/dist/claim.d.ts +12 -0
- package/dist/claim.js +19 -0
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +4 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +21 -2
- package/dist/coding-ui.js +6 -4
- package/dist/console-instance.d.ts +124 -0
- package/dist/console-instance.js +337 -0
- package/dist/contracts/chat-actions.d.ts +16 -0
- package/dist/contracts/chat-actions.js +4 -0
- package/dist/contracts/chat-content.d.ts +19 -0
- package/dist/contracts/chat-content.js +6 -0
- package/dist/contracts/console-api.d.ts +12 -4
- package/dist/contracts/console-api.js +12 -4
- package/dist/contracts/flow-card.d.ts +4 -0
- package/dist/contracts/flow-card.js +3 -0
- package/dist/contracts/flow.d.ts +46 -0
- package/dist/contracts/flow.js +11 -2
- package/dist/contracts/lead-tools.d.ts +92 -0
- package/dist/contracts/lead-tools.js +13 -0
- package/dist/contracts/provider.d.ts +11 -1
- package/dist/contracts/provider.js +1 -1
- package/dist/contracts/route.d.ts +76 -1
- package/dist/contracts/route.js +1 -1
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/control-setup.d.ts +1 -1
- package/dist/control-ui.js +2 -2
- package/dist/converse.d.ts +16 -0
- package/dist/converse.js +32 -4
- package/dist/demo-chat.js +4 -4
- package/dist/demo.js +2 -2
- package/dist/desktop-update-gate.js +6 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-chat.js +4 -1
- package/dist/doctor.js +1 -1
- package/dist/egress-bridge.d.ts +17 -0
- package/dist/egress-bridge.js +111 -0
- package/dist/egress.d.ts +119 -0
- package/dist/egress.js +423 -0
- package/dist/exec.d.ts +78 -0
- package/dist/exec.js +367 -9
- package/dist/export-ui.js +2 -2
- package/dist/flow-engine.js +24 -5
- package/dist/flow-gallery-ui.js +2 -2
- package/dist/flow-gallery.js +27 -8
- package/dist/flow-share.js +16 -5
- package/dist/flow-triggers.d.ts +77 -8
- package/dist/flow-triggers.js +517 -70
- package/dist/flows-cli.js +8 -2
- package/dist/flows-ui.d.ts +0 -2
- package/dist/flows-ui.js +52 -4
- package/dist/flows.d.ts +4 -0
- package/dist/flows.js +9 -1
- package/dist/html.d.ts +9 -0
- package/dist/html.js +11 -0
- package/dist/invoke.d.ts +17 -0
- package/dist/invoke.js +217 -21
- package/dist/keys.d.ts +18 -14
- package/dist/keys.js +5 -0
- package/dist/lead-capabilities.js +3 -2
- package/dist/lead-contract.js +2 -0
- package/dist/lead-doors.d.ts +2 -0
- package/dist/lead-doors.js +7 -0
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +58 -2
- package/dist/lead-progress.js +1 -0
- package/dist/lead-queue-runtime.d.ts +46 -0
- package/dist/lead-queue-runtime.js +191 -0
- package/dist/lead-queue.d.ts +104 -0
- package/dist/lead-queue.js +187 -0
- package/dist/lead-sweep.d.ts +67 -0
- package/dist/lead-sweep.js +99 -0
- package/dist/lead-tools.d.ts +22 -0
- package/dist/lead-tools.js +152 -1
- package/dist/lead.d.ts +13 -5
- package/dist/lead.js +89 -29
- package/dist/limits-ui.d.ts +8 -1
- package/dist/limits-ui.js +26 -0
- package/dist/live-bus.d.ts +8 -0
- package/dist/live-bus.js +40 -4
- package/dist/live-detail.d.ts +27 -0
- package/dist/live-detail.js +57 -0
- package/dist/live-notes.d.ts +20 -0
- package/dist/live-notes.js +47 -0
- package/dist/live-records.d.ts +140 -0
- package/dist/live-records.js +935 -0
- package/dist/live-terminal.d.ts +258 -0
- package/dist/live-terminal.js +649 -0
- package/dist/live.d.ts +38 -27
- package/dist/live.js +142 -124
- package/dist/mcp-oauth.d.ts +6 -3
- package/dist/mcp-oauth.js +8 -4
- package/dist/memory-pass.js +2 -2
- package/dist/mobile-viewport.d.ts +5 -1
- package/dist/mobile-viewport.js +18 -2
- package/dist/model-catalog.js +4 -0
- package/dist/monitoring-ui.js +2 -2
- package/dist/needs-you.d.ts +1 -1
- package/dist/needs-you.js +11 -4
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +275 -17
- package/dist/passwordless.d.ts +23 -0
- package/dist/passwordless.js +26 -0
- package/dist/peek-cli.d.ts +12 -4
- package/dist/peek-cli.js +19 -11
- package/dist/phase-routing.js +10 -3
- package/dist/planner-source.d.ts +3 -0
- package/dist/planner-source.js +14 -0
- package/dist/planner.d.ts +2 -1
- package/dist/planner.js +7 -1
- package/dist/policy-ui.js +2 -2
- package/dist/policy.d.ts +1 -20
- package/dist/policy.js +29 -2
- package/dist/project-delete-ui.d.ts +12 -0
- package/dist/project-delete-ui.js +41 -9
- package/dist/project-delete.js +2 -2
- package/dist/project-memory.d.ts +3 -1
- package/dist/project-memory.js +57 -25
- package/dist/project-tools.d.ts +5 -3
- package/dist/project-tools.js +12 -9
- package/dist/provider-auth.d.ts +4 -3
- package/dist/provider-auth.js +5 -4
- package/dist/provider-connection.d.ts +2 -1
- package/dist/provider-connection.js +5 -0
- package/dist/provider.d.ts +25 -2
- package/dist/provider.js +63 -9
- package/dist/pull-request-ui.js +2 -2
- package/dist/push.js +1 -1
- package/dist/quick-start.d.ts +24 -0
- package/dist/quick-start.js +127 -0
- package/dist/release.d.ts +3 -0
- package/dist/release.js +14 -8
- package/dist/request-budget-ui.js +2 -2
- package/dist/result-review.d.ts +1 -1
- package/dist/result-review.js +9 -3
- package/dist/retention-ui.js +2 -2
- package/dist/retention.js +10 -1
- package/dist/run-efficiency.d.ts +73 -0
- package/dist/run-efficiency.js +228 -0
- package/dist/runner.d.ts +5 -1
- package/dist/runner.js +22 -1
- package/dist/scope.d.ts +170 -5
- package/dist/scope.js +251 -31
- package/dist/scout-net.d.ts +4 -1
- package/dist/scout-net.js +37 -12
- package/dist/scout.d.ts +20 -6
- package/dist/scout.js +27 -3
- package/dist/serve.js +247 -40
- package/dist/server/chat.d.ts +1 -1
- package/dist/server/chat.js +122 -31
- package/dist/server/chrome.d.ts +6 -1
- package/dist/server/chrome.js +18 -7
- package/dist/server/console.css +52 -38
- package/dist/server/flows.js +38 -8
- package/dist/server/guards.d.ts +1 -0
- package/dist/server/guards.js +23 -9
- package/dist/server/http.d.ts +7 -0
- package/dist/server/http.js +3 -2
- package/dist/server/live.d.ts +34 -1
- package/dist/server/live.js +39 -17
- package/dist/server/pages-assets.js +5 -0
- package/dist/server/pages.js +90 -13
- package/dist/server/people-tokens.d.ts +3 -0
- package/dist/server/people-tokens.js +55 -17
- package/dist/server/remote-hooks.js +12 -6
- package/dist/server/remote.d.ts +2 -1
- package/dist/server/remote.js +14 -5
- package/dist/server/render-chat.d.ts +12 -3
- package/dist/server/render-chat.js +47 -16
- package/dist/server/render-pages.d.ts +4 -1
- package/dist/server/render-pages.js +5 -4
- package/dist/server/render-people.d.ts +3 -2
- package/dist/server/render-people.js +3 -2
- package/dist/server/render-results.d.ts +18 -2
- package/dist/server/render-results.js +37 -28
- package/dist/server/render-settings.js +6 -5
- package/dist/server/render-tasks.d.ts +35 -10
- package/dist/server/render-tasks.js +110 -27
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/request-context.js +2 -1
- package/dist/server/route-table.d.ts +3 -0
- package/dist/server/route-table.js +46 -5
- package/dist/server/runtime.d.ts +33 -2
- package/dist/server/session.d.ts +25 -1
- package/dist/server/session.js +18 -4
- package/dist/server/settings.js +121 -10
- package/dist/server/sweeps.d.ts +4 -0
- package/dist/server/sweeps.js +58 -0
- package/dist/server/task-views.d.ts +2 -1
- package/dist/server/task-views.js +96 -6
- package/dist/server/tasks.js +320 -74
- package/dist/server/terminals.d.ts +31 -0
- package/dist/server/terminals.js +365 -0
- package/dist/setup-guide.js +3 -2
- package/dist/slack-chat.js +4 -1
- package/dist/spend-ui.js +3 -3
- package/dist/spend.js +2 -1
- package/dist/steer-capabilities.d.ts +32 -0
- package/dist/steer-capabilities.js +60 -0
- package/dist/steer-controls.d.ts +14 -0
- package/dist/steer-controls.js +36 -0
- package/dist/steer-status.js +2 -2
- package/dist/storage-ui.js +4 -4
- package/dist/store-baseline.d.ts +1 -1
- package/dist/store-baseline.js +1 -1
- package/dist/store.d.ts +430 -15
- package/dist/store.js +1041 -52
- package/dist/subagent-desk.js +1 -1
- package/dist/summary.js +3 -0
- package/dist/task-source.d.ts +58 -0
- package/dist/task-source.js +93 -0
- package/dist/team-leads.js +1 -1
- package/dist/teams-chat.js +4 -1
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +44 -7
- package/dist/telegram.d.ts +11 -2
- package/dist/telegram.js +51 -17
- package/dist/terminal-catalog.d.ts +11 -0
- package/dist/terminal-catalog.js +129 -0
- package/dist/text-limits.d.ts +2 -0
- package/dist/text-limits.js +2 -0
- package/dist/tool-broker.d.ts +2 -0
- package/dist/tool-broker.js +1 -0
- package/dist/toolroll-update-ui.js +2 -2
- package/dist/toolroll-update.js +13 -3
- package/dist/tools-ui.js +2 -2
- package/dist/webhook-limits.d.ts +23 -0
- package/dist/webhook-limits.js +30 -0
- package/dist/webhook-readers.d.ts +56 -0
- package/dist/webhook-readers.js +138 -0
- package/dist/webhook-signing.d.ts +27 -0
- package/dist/webhook-signing.js +119 -0
- package/dist/work-index.d.ts +29 -0
- package/dist/work-index.js +16 -2
- package/dist/workspace-revision.js +3 -1
- package/package.json +11 -2
package/dist/flow-triggers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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: "
|
|
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
|
-
/**
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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 { /*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
1077
|
-
const
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
/**
|
|
1096
|
-
|
|
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 (
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
-
|
|
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/";
|