From 74409daa7868cae8a4090da6a095dada14de7671 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:09:11 -0700 Subject: [PATCH] fix(review): hard-block generic secret assignments --- src/review/content-lane/security-scan.ts | 32 +++---- src/review/safety.ts | 42 +++------ src/review/secret-patterns.ts | 26 ++---- test/unit/content-lane-security-scan.test.ts | 24 +++-- test/unit/safety-wiring.test.ts | 36 +++----- test/unit/secret-patterns.test.ts | 21 +---- worker-configuration.d.ts | 93 ++++++++++++++++---- 7 files changed, 128 insertions(+), 146 deletions(-) diff --git a/src/review/content-lane/security-scan.ts b/src/review/content-lane/security-scan.ts index 4a0d36ccca..1780ff4127 100644 --- a/src/review/content-lane/security-scan.ts +++ b/src/review/content-lane/security-scan.ts @@ -68,11 +68,9 @@ function firstLineMatching(text: string, re: RegExp): { n: number; text: string } function firstSecretLine(text: string): { n: number; kinds: string[] } | null { - // Per-line scan — O(n): catches every LINE-CONTAINED concrete kind (github_token, jwt, …) and cites its + // Per-line scan — O(n): catches every LINE-CONTAINED hard-blocking kind (github_token, jwt, generic_secret_assignment, …) and cites its // exact line. lines[i] is defined for an in-range index (split never yields holes) — assert past - // noUncheckedIndexedAccess. HARD_SECRET_KINDS no longer includes generic_secret_assignment (see - // ../secret-patterns.ts's doc comment) — that kind is handled separately by - // firstGenericSecretAssignmentLine below and routes to MANUAL, not this function's auto-close caller. + // noUncheckedIndexedAccess. Multiline generic assignments are handled by firstGenericSecretAssignmentLine below. const lines = text.split(/\r?\n/); for (let i = 0; i < lines.length; i += 1) { const hits = scanForSecrets(lines[i]!).kinds.filter((k) => HARD_SECRET_KINDS.has(k)); @@ -82,11 +80,9 @@ function firstSecretLine(text: string): { n: number; kinds: string[] } | null { } /** - * generic_secret_assignment is a keyword-plus-quoted-value SHAPE heuristic, not a concrete credential format - * (see ../secret-patterns.ts's HARD_SECRET_KINDS doc comment — split out post-gittensory-PR-#5346, which - * auto-closed a legitimate contributor PR over two inert test-fixture strings). Per this file's own header - * ("only ONE signal is unambiguous enough to hard-close... every other heuristic routes to MANUAL"), a hit - * here routes to MANUAL, never scanSubmissionContent's auto-close. Its keyword-to-value span can wrap across + * generic_secret_assignment is a keyword-plus-quoted-value SHAPE heuristic whose captured value has already + * cleared placeholder filtering, so an in-submission generic-only hit is treated as an embedded secret. Its + * keyword-to-value span can wrap across * lines (`client_secret =\n"…"`), so this is a single whole-blob pass — LINEAR, not a quadratic prefix-rescan * — citing the line where the non-placeholder match COMPLETES. */ @@ -103,9 +99,8 @@ function firstGenericSecretAssignmentLine(text: string): number | null { /** * Deterministic security scan of the SUBMITTED content. Returns: - * - `close` (embedded_secret) on a concrete embedded credential — cited to a line; or - * - `manual` (possible_secret_assignment) on a secret-shaped-but-not-concrete-format assignment — cited to - * a line (see firstGenericSecretAssignmentLine's doc comment for why this is MANUAL, not close); or + * - `close` (embedded_secret) on a concrete embedded credential or generic assignment that clears + * placeholder filtering — cited to a line; or * - `manual` (unsafe_install_pipeline) on a pipe-to-shell install in an executable category; or * - null otherwise. * Prompt-injection / exfiltration prose is intentionally NOT matched here: it is indistinguishable @@ -127,9 +122,9 @@ export function scanSubmissionContent(params: { content: string; category: strin const genericLine = firstGenericSecretAssignmentLine(content); if (genericLine !== null) { return { - verdict: "manual", - reasonCode: "possible_secret_assignment", - summary: `Submission contains a secret-shaped assignment (generic_secret_assignment) at line ${genericLine} that doesn't match a concrete credential format — routing to maintainer review to verify it isn't a real secret.`, + verdict: "close", + reasonCode: "embedded_secret", + summary: `Submission appears to expose a credential (generic_secret_assignment) at line ${genericLine}.`, }; } @@ -159,13 +154,6 @@ export function scanLinkedBodiesForSecrets(bodies: string[]): SecurityFinding | summary: `The linked source appears to expose a credential (${hits.join(", ")}) — routing to maintainer review.`, }; } - if (hasGenericSecretAssignment(body)) { - return { - verdict: "manual", - reasonCode: "possible_secret_assignment", - summary: "The linked source contains a secret-shaped assignment (generic_secret_assignment) that doesn't match a concrete credential format — routing to maintainer review.", - }; - } } return null; } diff --git a/src/review/safety.ts b/src/review/safety.ts index a213aae7b3..a2a0fcf6e0 100644 --- a/src/review/safety.ts +++ b/src/review/safety.ts @@ -7,7 +7,7 @@ import type { AdvisoryFinding } from "../types"; import { neutralizePromptInjection, safeReviewTitle } from "./prompt-injection"; -import { ADVISORY_ONLY_SECRET_KINDS, HARD_SECRET_KINDS } from "./secret-patterns"; +import { HARD_SECRET_KINDS } from "./secret-patterns"; import { scanDiffForSecretsWithLocations, type SecretScanLocationMatch } from "./secrets-scan"; // Concrete credential formats only — NOT the weak heuristics (`seed_or_mnemonic` / `bittensor_key`) that @@ -94,21 +94,12 @@ function locationSummaryFor(hits: SecretScanLocationMatch[]): string { * Scan the PR diff for leaked secrets and, on a hit, return ONE `AdvisoryFinding` (else null). Mapped to * gittensory's {@link AdvisoryFinding} shape. * - * Only CONCRETE credential formats ({@link HARD_SECRET_KINDS}) produce the critical `secret_leak` code that + * Hard-blocking secret kinds ({@link HARD_SECRET_KINDS}) produce the critical `secret_leak` code that * `rules/advisory.ts`'s `isConfiguredGateBlocker` treats as an unconditional hard blocker — the weak * `seed_or_mnemonic` / `bittensor_key` heuristics are ignored entirely here because they false-positive on * legitimate config/workflow content (e.g. `coldkey:` / `hotkey =` lines in *.toml, .github/workflows/**, or - * wrangler/workers config). This is UNCONDITIONAL (#audit-3.4): a concrete, real-format committed credential - * is a leak on any repo, so the caller runs it regardless of the safety flag / review allowlist (unlike the - * prompt-injection defang, which stays flag-gated). - * - * `ADVISORY_ONLY_SECRET_KINDS` (currently just `generic_secret_assignment`) is a keyword-plus-quoted-value - * SHAPE heuristic, not a concrete format — see `secret-patterns.ts`'s `HARD_SECRET_KINDS` doc comment for why - * it was split out (PR #5346 auto-closed a legitimate contributor PR on two inert test-fixture strings). A - * hit on ONLY this kind (no concrete kind present) instead returns a warning-severity `possible_secret_ - * assignment` finding, which `isConfiguredGateBlocker` does not recognize as a blocker code — it surfaces in - * the PR panel for a human/AI reviewer to verify, exactly as REES's own "medium confidence" rating for this - * same signal already treats it, without risking another auto-close false positive. + * wrangler/workers config). This is UNCONDITIONAL (#audit-3.4): a committed credential is a leak on any repo, + * including unknown-format assigned credentials that clear the placeholder filter. * * #3041: scans the RAW diff directly — `scanDiffForSecretsWithLocations` does its own +/- line-type * distinguishing (only added lines and added/renamed file paths are scanned, matching the previous @@ -117,31 +108,20 @@ function locationSummaryFor(hits: SecretScanLocationMatch[]): string { */ export function secretLeakFinding(diff: string): AdvisoryFinding | null { const allHits = scanDiffForSecretsWithLocations(diff); - // Only CONCRETE credential formats hard-block. The raw scanner also returns the weak `seed_or_mnemonic` / - // `bittensor_key` heuristics, which false-positive on `coldkey:` / `hotkey =` / "mnemonic" lines in - // legitimate config/workflow files (RC6); those are filtered out here so they never produce a finding at - // all. A real token (github_token, aws_access_key, …) still blocks regardless of which file it is in. - const concreteHits = allHits.filter((match) => HARD_SECRET_KINDS.has(match.kind)); - if (concreteHits.length > 0) { - const kinds = [...new Set(concreteHits.map((hit) => hit.kind))].sort(); + // The raw scanner also returns the weak `seed_or_mnemonic` / `bittensor_key` heuristics, which false-positive + // on `coldkey:` / `hotkey =` / "mnemonic" lines in legitimate config/workflow files (RC6); those are filtered + // out here so they never produce a finding at all. + const hardHits = allHits.filter((match) => HARD_SECRET_KINDS.has(match.kind)); + if (hardHits.length > 0) { + const kinds = [...new Set(hardHits.map((hit) => hit.kind))].sort(); return { code: "secret_leak", severity: "critical", title: `Possible leaked secret in the diff (${kinds.join(", ")})`, - detail: `The PR diff matches secret pattern(s): ${kinds.join(", ")}. ${locationSummaryFor(concreteHits)}. A committed credential must be rotated and removed from the change before merge.`, + detail: `The PR diff matches secret pattern(s): ${kinds.join(", ")}. ${locationSummaryFor(hardHits)}. A committed credential must be rotated and removed from the change before merge.`, action: "Remove the secret from the diff, rotate the exposed credential, then re-run the gate.", }; } - const advisoryHits = allHits.filter((match) => ADVISORY_ONLY_SECRET_KINDS.has(match.kind)); - if (advisoryHits.length > 0) { - return { - code: "possible_secret_assignment", - severity: "warning", - title: "Possible secret-shaped assignment in the diff (generic_secret_assignment)", - detail: `The PR diff contains a keyword-plus-quoted-value assignment that resembles a credential but doesn't match a concrete credential format. ${locationSummaryFor(advisoryHits)}. This is a medium-confidence heuristic (it also matches inert test/fixture values) and does not block the gate on its own.`, - action: "Verify the value is not a real credential.", - }; - } return null; } diff --git a/src/review/secret-patterns.ts b/src/review/secret-patterns.ts index 6024bc96d9..3113944537 100644 --- a/src/review/secret-patterns.ts +++ b/src/review/secret-patterns.ts @@ -180,26 +180,15 @@ export function secretPatternMatches(pattern: SecretPattern, text: string): bool return false; } -// Concrete credential formats only -- NOT the weak heuristics (seed_or_mnemonic / bittensor_key) that would +// Hard-blocking secret kinds -- NOT the weak heuristics (seed_or_mnemonic / bittensor_key) that would // false-positive on legitimate Bittensor content (a `coldkey:` / `hotkey =` line or the word "mnemonic" in a // .toml, .github/workflows/**, or wrangler/workers config is not a leaked credential; RC6: #1505/#1495/#1485). // #2553: google_api_key/jwt are as format-precise as the original five (near-zero false-positive risk), so // both are safe unconditional hard blockers. voyage_api_key/firecrawl_api_key (#4604) are equally -// format-precise. Shared by both hard-block paths: src/review/safety.ts's secretLeakFinding (PR-diff) and +// format-precise. generic_secret_assignment is also hard-blocking after placeholder filtering: unknown-format +// assigned credentials (passwords/client secrets/passphrases) are common leaks and otherwise bypass the gate. +// Shared by both hard-block paths: src/review/safety.ts's secretLeakFinding (PR-diff) and // src/review/content-lane/security-scan.ts's firstSecretLine/scanLinkedBodiesForSecrets (content-lane). -// -// generic_secret_assignment is deliberately NOT a member (post-PR-5346): unlike every kind above, it is a -// keyword-plus-quoted-value SHAPE heuristic, not a concrete credential format, so isPlaceholderSecretValue's -// closed escape-hatch keyword list can never keep pace with the open-ended ways a contributor phrases an -// inert test value -- this exact gap closed a legitimate contributor PR twice in a row (#5341, then its -// resubmission #5346, on two DIFFERENT non-placeholder-keyword fixture strings) after at least half a dozen -// prior narrow-allowlist patches to this same heuristic (#4587, #3866, #3673, #3178, #2613, #4733) failed to -// stop the pattern for good. REES's own copy of this rule (review-enrichment/src/analyzers/secret-scan.ts) -// already rates it "medium confidence" ("catches real keys but also the occasional long opaque non-secret"), -// and content-lane/security-scan.ts's own header states the underlying design principle this violated: a -// gate that AUTO-CLOSES with no human queue may only hard-close on a signal unambiguous enough that a false -// positive is essentially impossible -- "every other heuristic routes to MANUAL". See -// ADVISORY_ONLY_SECRET_KINDS below for where it still surfaces. export const HARD_SECRET_KINDS = new Set([ "github_token", "github_pat", @@ -215,10 +204,5 @@ export const HARD_SECRET_KINDS = new Set([ "voyage_api_key", "firecrawl_api_key", "jwt", + "generic_secret_assignment", ]); - -// The one kind excluded from HARD_SECRET_KINDS above: still detected and still worth a human's attention, but -// never an unconditional auto-block/auto-close on its own -- see that constant's doc comment for why. Consumed -// by src/review/safety.ts's secretLeakFinding and src/review/content-lane/security-scan.ts to route a hit here -// to an advisory/manual-review signal instead of a hard blocker. -export const ADVISORY_ONLY_SECRET_KINDS = new Set(["generic_secret_assignment"]); diff --git a/test/unit/content-lane-security-scan.test.ts b/test/unit/content-lane-security-scan.test.ts index 422d4492e0..68264d8984 100644 --- a/test/unit/content-lane-security-scan.test.ts +++ b/test/unit/content-lane-security-scan.test.ts @@ -233,16 +233,13 @@ describe("scanSubmissionContent", () => { } }); - // #5346: generic_secret_assignment is a keyword-plus-quoted-value SHAPE heuristic, not a concrete format — - // it routes to MANUAL, never scanSubmissionContent's auto-close (this file's own header states the design - // principle: only a concrete credential is unambiguous enough to hard-close). - it("routes a generic_secret_assignment hit to MANUAL, never close (#5346)", () => { + it("hard-closes on a generic_secret_assignment hit after placeholder filtering", () => { const finding = scanSubmissionContent({ content: `intro line\nclient_secret = "${GENERIC_VALUE}"`, category: "skills", }); - expect(finding?.verdict).toBe("manual"); - expect(finding?.reasonCode).toBe("possible_secret_assignment"); + expect(finding?.verdict).toBe("close"); + expect(finding?.reasonCode).toBe("embedded_secret"); expect(finding?.summary).toContain("line 2"); }); @@ -255,15 +252,14 @@ describe("scanSubmissionContent", () => { } }); - it("routes a MULTILINE generic secret assignment whose value wraps to the next line to MANUAL (#5346)", () => { + it("hard-closes on a MULTILINE generic secret assignment whose value wraps to the next line", () => { // generic_secret_assignment's keyword-to-value span can wrap. scanForSecrets over the whole blob catches - // it; scanSubmissionContent must too, or a wrapped hit bypasses this signal entirely — but it still routes - // to MANUAL (never close), matching the single-line case above. Built from separate literals so this file - // embeds no contiguous secret. + // it; scanSubmissionContent must too, or a wrapped hit bypasses this signal entirely. Built from separate + // literals so this file embeds no contiguous secret. const content = `intro line\nclient_secret =\n"${GENERIC_VALUE}"`; const finding = scanSubmissionContent({ content, category: "guides" }); - expect(finding?.verdict).toBe("manual"); - expect(finding?.reasonCode).toBe("possible_secret_assignment"); + expect(finding?.verdict).toBe("close"); + expect(finding?.reasonCode).toBe("embedded_secret"); expect(finding?.summary).toContain("line 3"); // cited where the wrapped match completes (the value line) }); @@ -282,10 +278,10 @@ describe("scanLinkedBodiesForSecrets", () => { expect(finding?.reasonCode).toBe("embedded_secret"); }); - it("flags a generic_secret_assignment-only hit in a LINKED body as MANUAL too (#5346)", () => { + it("flags a generic_secret_assignment-only hit in a LINKED body as an embedded secret for review", () => { const finding = scanLinkedBodiesForSecrets(["clean body", `client_secret = "${GENERIC_VALUE}"`]); expect(finding?.verdict).toBe("manual"); - expect(finding?.reasonCode).toBe("possible_secret_assignment"); + expect(finding?.reasonCode).toBe("embedded_secret"); }); it("returns null when no linked body leaks", () => { diff --git a/test/unit/safety-wiring.test.ts b/test/unit/safety-wiring.test.ts index be9f24e7a8..866bcfb6ee 100644 --- a/test/unit/safety-wiring.test.ts +++ b/test/unit/safety-wiring.test.ts @@ -352,28 +352,28 @@ describe("secret-leak finding in the advisory build", () => { expect(out).toContain("### ok.ts (added) +2/-1\n@@\n+const a = 1;"); }); - it("surfaces a lowercase-hyphenated password assignment as an advisory (never a hard block, #5346)", () => { + it("hard-blocks a lowercase-hyphenated password assignment", () => { const diff = [ "### config/prod.env (modified) +1/-0", "@@ -0,0 +1 @@", '+password = "alpha-bravo-charlie-delta"', ].join("\n"); const finding = secretLeakFinding(diff); - expect(finding?.code).toBe("possible_secret_assignment"); - expect(finding?.severity).toBe("warning"); + expect(finding?.code).toBe("secret_leak"); + expect(finding?.severity).toBe("critical"); expect(finding?.title).toContain("generic_secret_assignment"); expect(finding?.detail).toContain("config/prod.env:1"); }); - it("surfaces a mixed-case mock-tokenized generic credential as an advisory (never a hard block, #5346)", () => { + it("hard-blocks a mixed-case mock-tokenized generic credential", () => { const diff = [ "### config/prod.env (modified) +1/-0", "@@ -0,0 +1 @@", '+password = "prod-mock-aK9xQ2mZw7Ln4Rv8Pt3Bh6"', ].join("\n"); const finding = secretLeakFinding(diff); - expect(finding?.code).toBe("possible_secret_assignment"); - expect(finding?.severity).toBe("warning"); + expect(finding?.code).toBe("secret_leak"); + expect(finding?.severity).toBe("critical"); expect(finding?.title).toContain("generic_secret_assignment"); expect(finding?.detail).toContain("config/prod.env:1"); }); @@ -468,9 +468,7 @@ describe("gate treats secret_leak as a hard blocker", () => { }); // #2553: the concrete-format widened kinds (google_api_key, jwt) hard-block exactly like the original - // five — same secretLeakFinding -> evaluateGateCheck path, no separate opt-in. generic_secret_assignment is - // deliberately EXCLUDED from this table (see the dedicated describe block below, #5346): it is a - // keyword-plus-quoted-value SHAPE heuristic, not a concrete format, so it no longer hard-blocks. + // five — same secretLeakFinding -> evaluateGateCheck path, no separate opt-in. it.each([ ["google_api_key", `### src/config.ts (modified) +1/-0\n@@\n+const key = "${"AIza" + "SyABCDEFGHIJKLMNOPQRSTUVWXYZ0123456"}";`], [ @@ -494,27 +492,21 @@ describe("gate treats secret_leak as a hard blocker", () => { }); }); -// #5346: gittensory PR #5346 was auto-closed on a "possible leaked secret" that was really two inert -// test-fixture strings matching the generic_secret_assignment SHAPE heuristic. This heuristic (unlike every -// concrete-format kind above) is no longer an unconditional hard blocker — it surfaces as an advisory finding -// that a human/AI reviewer can verify, matching how REES's own copy of this rule already rates it -// "medium confidence". -describe("generic_secret_assignment is advisory-only, never a hard blocker (#5346)", () => { +describe("generic_secret_assignment hard-blocks after placeholder filtering", () => { const diff = `### src/config.ts (modified) +1/-0\n@@\n+secret = "${"sk_live_" + "aK9xQ2mZw7Ln4Rv8Pt3Bh6"}"`; - it("secretLeakFinding returns a warning-severity possible_secret_assignment finding, not secret_leak", () => { + it("secretLeakFinding returns a critical secret_leak finding", () => { const finding = secretLeakFinding(diff); - expect(finding?.code).toBe("possible_secret_assignment"); - expect(finding?.severity).toBe("warning"); + expect(finding?.code).toBe("secret_leak"); + expect(finding?.severity).toBe("critical"); expect(finding?.title).toContain("generic_secret_assignment"); }); - it("does not fail the gate — it surfaces only as a non-blocking warning", () => { + it("fails the gate as an unconditional hard blocker", () => { const finding = secretLeakFinding(diff)!; const gate = evaluateGateCheck(advisory([finding]), { confirmedContributor: true }); - expect(gate.conclusion).toBe("success"); - expect(gate.blockers).toEqual([]); - expect(gate.warnings.map((w) => w.code)).toContain("possible_secret_assignment"); + expect(gate.conclusion).toBe("failure"); + expect(gate.blockers.map((b) => b.code)).toContain("secret_leak"); }); it("a concrete kind alongside a generic-only kind still hard-blocks on the concrete kind (secret_leak wins)", () => { diff --git a/test/unit/secret-patterns.test.ts b/test/unit/secret-patterns.test.ts index 73c253a139..41ea2c56f5 100644 --- a/test/unit/secret-patterns.test.ts +++ b/test/unit/secret-patterns.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - ADVISORY_ONLY_SECRET_KINDS, GENERIC_SECRET_ASSIGNMENT_PATTERN, HARD_SECRET_KINDS, hasGenericSecretAssignment, @@ -31,33 +30,21 @@ describe("secret-patterns — shared secret-detection primitives (#4608)", () => expect(new Set(names).size).toBe(names.length); }); - it("HARD_SECRET_KINDS excludes the weak seed-phrase/bittensor-key heuristics and generic_secret_assignment", () => { + it("HARD_SECRET_KINDS excludes only the weak seed-phrase/bittensor-key heuristics", () => { expect(HARD_SECRET_KINDS.has("seed_or_mnemonic")).toBe(false); expect(HARD_SECRET_KINDS.has("bittensor_key")).toBe(false); - // generic_secret_assignment is a keyword-plus-quoted-value SHAPE heuristic, not a concrete credential - // format -- gittensory PR #5346 auto-closed a legitimate contributor PR over two inert test-fixture - // strings that matched this shape but weren't real secrets. Split out into ADVISORY_ONLY_SECRET_KINDS - // below (regression guard for that incident). - expect(HARD_SECRET_KINDS.has("generic_secret_assignment")).toBe(false); + expect(HARD_SECRET_KINDS.has("generic_secret_assignment")).toBe(true); }); - it("every HARD_SECRET_KINDS entry is a real SECRET_PATTERNS name", () => { + it("every format-based HARD_SECRET_KINDS entry is a real SECRET_PATTERNS name", () => { const patternNames = new Set(SECRET_PATTERNS.map((pattern) => pattern.name)); for (const kind of HARD_SECRET_KINDS) { + if (kind === "generic_secret_assignment") continue; expect(patternNames.has(kind)).toBe(true); } }); }); - describe("ADVISORY_ONLY_SECRET_KINDS", () => { - it("contains exactly generic_secret_assignment, disjoint from HARD_SECRET_KINDS", () => { - expect([...ADVISORY_ONLY_SECRET_KINDS]).toEqual(["generic_secret_assignment"]); - for (const kind of ADVISORY_ONLY_SECRET_KINDS) { - expect(HARD_SECRET_KINDS.has(kind)).toBe(false); - } - }); - }); - describe("secretPatternMatches", () => { const awsPattern = SECRET_PATTERNS.find((pattern) => pattern.name === "aws_access_key")!; diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 34e9be5122..148c43e0e6 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,6 +1,6 @@ /* eslint-disable */ // Generated by Wrangler by running `wrangler types` (hash: d5851e3e7ea2bc8cd084f39b4328cb86) -// Runtime types generated with workerd@1.20260701.1 2026-05-28 nodejs_compat +// Runtime types generated with workerd@1.20260708.1 2026-05-28 nodejs_compat interface __BaseEnv_Env { DB: D1Database; JOBS: Queue; @@ -621,7 +621,7 @@ declare abstract class DurableObjectNamespace; jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace; } -type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high"; +type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high" | "us"; interface DurableObjectNamespaceNewUniqueIdOptions { jurisdiction?: DurableObjectJurisdiction; } @@ -12316,6 +12316,13 @@ interface ForwardableEmailMessage extends EmailMessage { * @returns A promise that resolves when the email message is replied. */ reply(message: EmailMessage): Promise; + /** + * Reply to the sender of this email message with a message built from the given + * fields. Threading headers (In-Reply-To/References) are set automatically. + * @param builder The reply message contents. + * @returns A promise that resolves when the email message is replied. + */ + reply(builder: EmailReplyMessageBuilder): Promise; } /** A file attachment for an email message */ type EmailAttachment = { @@ -12336,23 +12343,46 @@ interface EmailAddress { name: string; email: string; } +/** + * Recipient fields for `SendEmail.send()`. At least one of `to`, `cc`, or + * `bcc` must be provided. + */ +type EmailDestinations = { + to?: string | EmailAddress | (string | EmailAddress)[]; + cc?: string | EmailAddress | (string | EmailAddress)[]; + bcc?: string | EmailAddress | (string | EmailAddress)[]; +} & ({ + to: string | EmailAddress | (string | EmailAddress)[]; +} | { + cc: string | EmailAddress | (string | EmailAddress)[]; +} | { + bcc: string | EmailAddress | (string | EmailAddress)[]; +}); +/** + * Fields shared by all composed emails (no recipients). Used directly by + * `ForwardableEmailMessage.reply()`, which always replies to the original + * sender, and extended by `EmailMessageBuilder` for `SendEmail.send()`. + */ +interface EmailReplyMessageBuilder { + from: string | EmailAddress; + subject: string; + replyTo?: string | EmailAddress; + headers?: Record; + text?: string; + html?: string; + attachments?: EmailAttachment[]; +} +/** + * Fields for composing an email without constructing raw MIME, for + * `SendEmail.send()`. Requires at least one of `to`, `cc`, or `bcc`. + */ +type EmailMessageBuilder = EmailReplyMessageBuilder & EmailDestinations; /** * A binding that allows a Worker to send email messages. */ interface SendEmail { send(message: EmailMessage): Promise; - send(builder: { - from: string | EmailAddress; - to: string | EmailAddress | (string | EmailAddress)[]; - subject: string; - replyTo?: string | EmailAddress; - cc?: string | EmailAddress | (string | EmailAddress)[]; - bcc?: string | EmailAddress | (string | EmailAddress)[]; - headers?: Record; - text?: string; - html?: string; - attachments?: EmailAttachment[]; - }): Promise; + send(builder: EmailMessageBuilder): Promise; } declare abstract class EmailEvent extends ExtendableEvent { readonly message: ForwardableEmailMessage; @@ -13174,6 +13204,11 @@ declare namespace CloudflareWorkersModule { export type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; export type WorkflowDelayDuration = WorkflowSleepDuration; + export type WorkflowDynamicDelayContext = { + ctx: WorkflowStepContext; + error: Error; + }; + export type WorkflowDelayFunction = (input: WorkflowDynamicDelayContext) => WorkflowDelayDuration | Promise; export type WorkflowTimeoutDuration = WorkflowSleepDuration; export type WorkflowRetentionDuration = WorkflowSleepDuration; export type WorkflowBackoff = 'constant' | 'linear' | 'exponential'; @@ -13181,7 +13216,7 @@ declare namespace CloudflareWorkersModule { export type WorkflowStepConfig = { retries?: { limit: number; - delay: WorkflowDelayDuration | number; + delay: WorkflowDelayDuration | number | WorkflowDelayFunction; backoff?: WorkflowBackoff; }; timeout?: WorkflowTimeoutDuration | number; @@ -13207,13 +13242,22 @@ declare namespace CloudflareWorkersModule { type: string; sensitive?: WorkflowStepSensitivity; }; - export type WorkflowStepContext = { + export type WorkflowStepContext = { step: { name: string; count: number; }; attempt: number; - config: WorkflowStepConfig; + config: { + retries?: { + limit: number; + backoff?: WorkflowBackoff; + } & (Delay extends WorkflowDelayFunction ? {} : { + delay: WorkflowDelayDuration | number; + }); + timeout?: WorkflowTimeoutDuration | number; + sensitive?: WorkflowStepSensitivity; + }; }; export type WorkflowRollbackContext = { ctx: WorkflowStepContext; @@ -13229,7 +13273,9 @@ declare namespace CloudflareWorkersModule { }; export abstract class WorkflowStep { do>(name: string, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise; - do>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise; + do, const C extends WorkflowStepConfig>(name: string, config: C, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise; sleep: (name: string, duration: WorkflowSleepDuration) => Promise; sleepUntil: (name: string, timestamp: Date | number) => Promise; waitForEvent>(name: string, options: { @@ -14139,6 +14185,7 @@ declare namespace TailStream { readonly dispatchNamespace?: string; readonly entrypoint?: string; readonly executionModel: string; + readonly durableObjectId?: string; readonly scriptName?: string; readonly scriptTags?: string[]; readonly scriptVersion?: ScriptVersion; @@ -14693,6 +14740,13 @@ interface WorkflowError { code?: number; message: string; } +interface WorkflowInstanceTerminateOptions { + /** + * If true, run registered rollback handlers before terminating the instance. + * Only steps that registered rollback handlers are rolled back. + */ + rollback?: boolean; +} interface WorkflowInstanceRestartOptions { /** * Restart from a specific step. If omitted, the instance restarts from the beginning. @@ -14726,8 +14780,9 @@ declare abstract class WorkflowInstance { public resume(): Promise; /** * Terminate the instance. If it is errored, terminated or complete, an error will be thrown. + * @param options Options for termination, including whether registered rollback handlers should run. */ - public terminate(): Promise; + public terminate(options?: WorkflowInstanceTerminateOptions): Promise; /** * Restart the instance. Optionally restart from a specific step, preserving * cached results for all steps before it.