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