Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-webhooks): park a subscription whose stored header map resolves to nothing (#8558) - #8565
Conversation
…olves to nothing (#8558) `resolveWebhookHeaders` answered `undefined` for two different facts — "the author configured no custom headers" and "a map IS stored and did not come back as one" — and `AutoEnqueuer.attachHeaders` acts on the first reading. So the second became the first: the subscription armed and every delivery went out missing its entire authored header map, the ordinary place an `Authorization` goes, while the row kept reading `active: true` with `headers_secret` masked. Measured end to end: the delivery SUCCEEDED carrying a byte-correct `X-Objectstack-Signature`, which is the worst available combination — the signature tells the receiver the request is genuinely ours while it no longer matches the configuration its operator wrote. Sibling of #8542 on the same seam's other credential, but WIDER rather than symmetric: a signing secret is an opaque scalar so only `''` collapsed, while a header map's content decides and every string that is not a flat JSON object of string values collapses — through the ordinary data API, on a field whose own description tells the admin to type JSON into it. Fixed at the seam, so no caller re-derives the rule: stored headers that do not come back as a map now raise `WebhookHeadersUnresolvableError`, reaching `attachHeaders`' existing `catch` exactly the way a throwing resolver already did. The park (#8069), the durable `sys_http_delivery` record and the say-once ADR-0112 `error` all apply unchanged; `attachHeaders` needed no new branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
os-zhuang
commented
Aug 13, 2026
PM review — ⭐ The dispatch said "do not assume symmetry with #8542." This is why.The card carried #8542's three triggers over by construction. All three do reach this path — and measurement found a fourth with no signing analogue at all, which is the widest of the set:
⭐ And the reason it has no counterpart is the crisp part: a signing secret is an opaque scalar, so any non-empty answer is a usable key and only
⭐ The make-or-break question, answered with the mechanismI flagged the presence asymmetry as the thing the whole fix shape depends on, and warned that a map might not give the same decidable signal as a scalar. It holds — and the reason is what makes it trustworthy rather than lucky:
So the generic read path returns the mask for a set map and ⭐ What reached the wire — and why this direction is worse than it soundsMeasured end to end through the production enqueue wiring: the request SUCCEEDED ( ⭐ And the closing detail: this file's own header comment had already committed to the correct behaviour — "It does not deliver partially. A row whose stored headers cannot be resolved DROPS the subscription" — it simply did not keep it. Declared-versus-delivered, stated by the code about itself. Judgment calls, both right
VerificationNine defect pins RED on ⭐ The changeset carries the operator-facing upgrade note — re-save as a flat JSON object of string values, or clear to Generated by Claude Code |
os-zhuang
commented
Aug 13, 2026
Docs drift check — measured, ⛔ no action. Do not open a patch round. Checked rather than forwarded, and checked separately from #8560's — that one asked about the signing secret; this PR changes header behaviour, so the earlier "nothing to fix" does not transfer.
⛔ Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8558
Sibling of #8542 on the same seam's other credential. The card was honest that its evidence was a construction argument (
resolveSecretFieldis field-agnostic) and that the header path had not been exercised. It has been now — and the measurement changed the story in one important direction.The premise holds, and the path is WIDER than the card claimed
resolveWebhookHeadersansweredundefinedfor two different facts — "the author configured no custom headers" and "a map IS stored and did not come back as one" — andAutoEnqueuer.attachHeadersacts on the first reading. So the second became the first: the subscription armed and delivered.The card carried #8542's three triggers over by construction. All three do reach this path, but they are not the whole set and not the widest one:
undefined, no throwsecret:refThe fourth has no counterpart on the signing side. A signing secret is an opaque scalar, so any non-empty answer is a usable key and only
""collapsed. A header map's content decides, andparseStoredHeadersanswersundefined— correctly, for its own job — for every string that is not a flat JSON object of string values. Measured accepted by the ordinary data API, encrypted, valid ref minted, row reading back masked andactive: true, delivery going out header-less, for all of:{},[],{"X-Count": 5},{"X-Team":{"name":"crm"}}, and plain typos.That makes it the widest road here rather than an exotic one:
sys_webhook.headers_secretis an admin-authorableField.secretwhose own description instructs the author to type "a JSON object ({"Authorization": "Bearer …"})" into it. The realistic trigger is an admin mistyping JSON into the box the product told them to type JSON into.The presence asymmetry does hold for a map
This was the open question the fix shape depended on, since
headers_secretis a map wheresigning_secretis a scalar. It holds, and the reason is worth stating:headers_secretis a map only in the plaintext. At the storage layer it is an ordinary scalarsecretcolumn holding the serialized map, so the generic read path returns the engine's mask for a set map andnullfor an unset one — the same decidable signal, for the same reason. Measured:viaApi[headers_secret] === SECRET_MASK,active: true, columnsecret:sec_3at rest. So #8542's fix shape applies unchanged.What actually reached the wire
Not merely "a delivery with something missing". Measured end to end through the production enqueue wiring: the request SUCCEEDED (
sys_http_delivery.status = 'success',attempts: 1) carrying a byte-correctX-Objectstack-Signature, with the entire authored map —Authorizationincluded — absent, and zeroerrorlogged.The valid signature is what makes this direction worse than it looks. It tells the receiver the request is genuinely ours, so a receiver that authenticates by signature has every reason to accept a request that no longer matches the configuration its operator wrote. Grading the two consequences against each other, as the card asks: a missing signature and a missing header map are not the same harm, but they fail in the same direction — silent and open — and this file's own header comment had already committed to the answer ("It does not deliver partially. A row whose stored headers cannot be resolved DROPS the subscription"). It simply did not keep it.
The fix
One rule, one place, at the seam. Stored headers that do not come back as a map now raise
WebhookHeadersUnresolvableErrorinstead of answeringundefined, so they reachattachHeaders' existingcatchexactly the way a throwing resolver already did.attachHeadersneeded no new branch — same as [security] a stored webhook signing secret that resolves to null is treated as "authored unsigned" — the subscription arms and delivers UNSIGNED #8542. The [security] a dropped webhook subscription leaves no durable record — and the naive fix hands an operator aredeliverbutton that sends UNSIGNED #8069 park, the durablesys_http_deliveryrecord and the say-once ADR-0112error(INTERNAL_ERROR/500, namingheaders_secretso it cannot be confused with the signing secret's identical-looking drop) all applied unchanged.resolveSecretFieldthe column IS the serialized map, so reading it verbatim is correct — but the parse can still fail, and that arm answeredundefinedtoo.RedeliverGuard(which reads the signing seam only). ⛔ Nothing widened into [security] webhook customheadersare still cleartext in two JSON blobs — the sibling of #7799 that PR #7901 did not close #7986 / [security] sys_http_delivery.headers_json still stores webhook credentials in cleartext — and every services-lane shape for fixing it is structurally wrong #8118 / [security]sys_email.headers_jsonstores custom headers cleartext — same shape assys_http_delivery; adopt whatever remedy #8118 lands, do not decide it twice #8149 territory — this is about a map that cannot be resolved, not one stored in the clear.Reverse verification
Predicted before running, then measured on
origin/main(719a21b) with the tests added andwebhook-headers.tsuntouched. Predicted RED for all nine defect pins, GREEN for both controls.Nine failed, and in the predicted shape: the seven wire pins failed because a delivery happened, the two seam pins because the seam resolved instead of rejecting. Both controls passed on the unfixed tree, which is what makes them controls. With the fix:
Tests 81 passed (81).Anti-vacuity: every defect pin asserts its own precondition before asserting the refusal — the row reads back as the mask, the column is a real
secret:ref at rest,active: true. A pin whose webhook had quietly lost its headers would exercise only the legitimate no-headers arm and pass against a completely unfixed tree.Controls: a webhook authored with no headers still arms and delivers (
status: 'success', 0 errors, still signed), and a webhook whose stored map resolves normally still delivers every header including the credential entry.Verification
pnpm --filter @objectstack/plugin-webhooks test— 81 passed (6 files)pnpm --filter @objectstack/plugin-webhooks typecheck— clean--filter '...@objectstack/plugin-webhooks', prefix form = consumers): 6/6 green — plugin-webhooks, cli, app-crm, app-showcase, app-todo, qa/dogfoodcheck:nul-bytes,check:error-code-casing,check:durability-log-level,check:engine-double-contract,check:test-source-alias,check:type-source-resolution,check:query-options-erasure,check:type-check-coverage,check:i18n(green afterturbo run build --filter=@objectstack/cli) — all passChangeset:
.changeset/webhook-stored-headers-unresolvable.md, including the operator-facing upgrade note (re-save as a flat JSON object of string values, or clear tonull— an empty or unparseable header map is not the same thing as no header map).Generated by Claude Code