Repository navigation
fix(plugin-webhooks): refuse the retired definition_json credential location at delivery and at the write door - #19944
Conversation
…ocation Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
…ocation A sys_webhook row whose signing secret or header map exists only as a secret/headers key inside definition_json is parked with a dated VALIDATION_ERROR/400 refusal instead of being delivered from the cleartext, and a write that puts either key into definition_json is refused at the door. Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 30 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7d3e090bf72b417aaf00dbd98068091ea3868cf1 && git checkout 7d3e090bf72b417aaf00dbd98068091ea3868cf1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 43460b95aa196410b1acfcaa0bb9af8905066ddc 4c78b034d56d2386d771eec10e87d2cacce9daab && git checkout -B drift-repro 43460b95aa196410b1acfcaa0bb9af8905066ddc && git merge --no-ff 4c78b034d56d2386d771eec10e87d2cacce9daab
node scripts/docs-audit/affected-docs.mjs --json 43460b95aa196410b1acfcaa0bb9af8905066ddc
|
…es under a CryptoProvider Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: An isolated reviewer ran at the served tier. It saw only card #10164, ruling 5792274752, claim 5805810063, and this PR. It first reviewed ① Derived judgmentsEvery changeset and docs claim is TRUE against source:
② Semver level
③ Boundary flagsZero Implemented-by: VERDICT: PASS Non-blocking follow-ups:
Generated by Claude Code |
Closes #10164
Clause-②: no (narrowing)
Step 3 of the retirement ruled on #9930, now in scope under the 2026-09-23 maintainer ruling recorded on #10164: 「10164 不考虑旧的」 and 「10164 现在就做,不等v18」. The legacy cleartext credential location, the
secret/headerskeys insidesys_webhook.definition_json, goes from warn-and-accept to a loud, dated refusal. The refusal applies at both doors in one change.What changed
Delivery door (
AutoEnqueuer.attachSecret/attachHeaders,auto-enqueuer.ts). A row whose signing secret or header map exists ONLY as the legacy key, with nothing insigning_secret/headers_secret, is no longer delivered from the cleartext. The newrefuseLegacyCleartextparks the subscription in the same fail-closed shape an unrecoverable encrypted credential already uses:deadsys_http_deliveryrow with 0 attempts, no signature, no headers, and anerrorreading[VALIDATION_ERROR/400] ...;error, on the existing say-once ledger. Its meta carriescode,status,field: 'definition_json'and the refusedkeys.readLegacySecret/readLegacyHeadersstay, because the boot sweep is their other caller and the sweep is the remedy.Write door (new
webhook-legacy-cleartext.ts,bindWebhookLegacyCleartextGate). AbeforeInsert/beforeUpdatehook onsys_webhookrefuses anydefinition_jsonthat carries asecretorheaderskey, whatever its value. It follows the #8566 shape: the same hook seam as theheaders_secretshape gate, bound inbootDeclaredWebhooksbefore the seeder and the sweep, unbound indestroy(). It is not exempt forisSystem. The error,WebhookLegacyCleartextError, carriescode: 'VALIDATION_ERROR',status: 400,object,fieldandkeys. It judges key presence:"headers": {}still teaches the wrong location. It never echoes a value. A clean blob, an omitteddefinition_jsonand a non-JSON blob all pass. The last is not this gate's verdict.Refusal text: it is dated
2026-09-23(the ruling's date; the message says why), names the sweepmigrateLegacyWebhookSecrets, and gives the remedy. Both remedies need a registered CryptoProvider. With one registered, either restart so the sweep converts the row, or write the values intosigning_secret/headers_secretand remove the keys. The text carries no tracker number. Per 「不考虑旧的」, there is no transition path for a deployment without aCryptoProvider.Changeset:
@objectstack/plugin-webhooks: minorwith a BREAKING banner (launch-window convention;check-changeset-no-majorrefusesmajor). It states plainly that webhooks still on the legacy shape STOP DELIVERING, and gives the remedy. ADR-0087 disposition:not-required (no-migration-prescription). Nothing authored moves:packages/specis untouched, andWebhookSchemastill declaressecret/headers, which the materializer still routes to the encrypted columns. The refused shape is a stored data row, and its converter, the boot sweep, already ships.⛔ Zero
packages/spec. ⛔ Nocontent/docs/releases/**.Tests (all in
packages/plugins/plugin-webhooks)webhook-secret-at-rest.test.ts. The two "un-swept row keeps signing / keeps delivering" tests are rewritten as refusal witnesses on a realObjectQLengine. Each asserts no call reaches the receiver, exactly oneerrorwhose meta matches{ code: 'VALIDATION_ERROR', status: 400, field: 'definition_json', keys: [...] }, and a parked dead row whoseerrorstarts[VALIDATION_ERROR/400]. Each also checks that the prose carries the date, the sweep name, the remedy, no credential and no tracker number. A control leg shows the same row, once swept, delivers its headers.headers, withsecret, and with both is refused with the envelope (code,status,object,field,keys), and nothing lands;webhook-signing-secret.test.ts. Fixture re-spelled onto the encrypted columns, with aresolveSecretFieldstub. It pinned the signature path by riding the legacy blob, which is now refused.Reverse verification (from committed state,
scripts/ablation-replace.mjsWRAP mode, restore proven by blob hash == HEAD and an emptygit diff HEAD)attachSecrethonours the legacy secret againattachHeadershonours the legacy headers againAll three were restored:
auto-enqueuer.tsblob1cfd125c6c79== HEAD,webhook-legacy-cleartext.tsblob1c50d2b4321b== HEAD.Local verification (HEAD
36d17abc8e)pnpm --filter @objectstack/plugin-webhooks exec vitest run: 13 files, 160 tests passed.pnpm --filter @objectstack/plugin-webhooks run typecheck: exit 0, measured at7d448ba52a. The only later commit touchesscripts/doc-authoring-prose-id.baseline.json.eslint over the 7 changed
.tsfiles: 7 files, 0 errors, 0 warnings. Population read fromeslint.config.mjs;--format jsoncounts. Invariance: type-aware linting is not enabled (noparserOptions.project), so this diff cannot move any untouched file's verdict.dispatch-gates --ran: 101 derived families accounted for, 98 run green and 3 NOT MEASURED:check:skill-examplesneeds built client SDK output;check:dual-build-cjs-loadsandcheck:type-check-debtneed the full workspace build.All three exited 3 on their own prerequisite, and CI builds the full closure.
check:doc-authoringfirst went red on a burn-down, meaning the removed warn strings cited two tracker ids. The baseline was regenerated with the gate's own shrink-only command (#7799/#79863 to 2 inauto-enqueuer.ts).check:i18nwent green after its prerequisite closure was built.check-adr-0087-registration,check-changeset-no-majorandcheck-empty-changesetare green.Acceptance notes
content/docs/automation/webhooks.mdx§3.1 said an un-swept row "is still delivered from the blob, with awarn". That sentence is corrected to the refusal, in the one page describing this exact behaviour. No other hand-written page describes legacy acceptance.bindWebhookLegacyCleartextGateis wired byWebhookOutboxPluginonly. Theheaders_secretgate is exported for hosts that boot the pieces themselves; this one is deliberately not, to keep the public surface unchanged underno (narrowing). A consumer branches oncode/status.Generated by Claude Code