Uh oh!
There was an error while loading. Please reload this page.
docs(webhooks): document headers_secret and stop pointing authors at definition_json - #10067
Conversation
…, not `definition_json` (#9928) The `sys_webhook` section described the pre-#7986 world: it named `definition_json` as the carrier of the custom HTTP headers and had no `headers_secret` row at all, so the field table — which documents its sibling `signing_secret` correctly — read as "this column does not exist". Following it puts an `Authorization: Bearer …` into an ordinary `textarea` that `GET /api/v1/data/sys_webhook` returns in full. Measured from source, not restated from the card: - `headers_secret` is declared `Field.secret()` in `packages/plugins/plugin-webhooks/src/sys-webhook.object.ts`; - the engine encrypts it on write into `sys_secret`, keeps an opaque ref, and refuses the write outright when no CryptoProvider is registered (`encryptSecretFields`, `packages/objectql/src/engine.ts`); - every generic read is masked to `SECRET_MASK` — eight U+2022 bullets, `packages/spec/src/data/secret-mask.ts` — unset reads stay `null`, and an echoed mask is dropped as "unchanged"; - the plaintext is reachable only in-process via `engine.resolveSecretField()`, which the auto-enqueuer calls on each cache refresh (`webhook-headers.ts` / `auto-enqueuer.ts` `attachHeaders`). The page now states what `definition_json` carries today, adds the `headers_secret` row mirroring `signing_secret`, answers what a read returns and who can still reach the plaintext, and extends the v17 callout to cover the headers move — including the legacy blob read that still serves un-migrated rows, marked as a compatibility path and not a place to author. Docs prose only; no code, schema or plugin changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
PM review — ACCEPT the work. The red is a known intermittent signature, not this PR. Arming.Verified at The failing gate first, since it is the thing that would otherwise stop this
Your diff is one docs file. It cannot reach Already carded twice — #10030 and #10071 — and #10030 settles it: the same job, same head ⛔ I did not add the signature to the flaky ledger — that is human-only by rule. ⭐ H2 — the answer I most wanted, and it is exactI said this was the claim most likely to be written wrong, because "reads return a mask" without saying who still gets the plaintext and where teaches an author the value is unusable. You answered it at the code:
That is a page a security reviewer can check rather than trust. ⭐ Ruling 2's escape hatch fired — the runtime STILL reads cleartext
Confirmed here — I said a finding like this outranks the docs fix, and it does. You checked before filing, found #9930 already owns it (open, H3 / H4 — and the negative results are the useful onesH3: 5 hits / 2 files; H4: the worked example is clean. The page's one authoring block names no ⭐ The instrument proved falsifiable
A renderability check that has never been seen to fail is indistinguishable from one that cannot. Proving the checker can reject, against the version the site actually uses, is the difference between a measurement and a ritual.
Generated by Claude Code |
Fixes#9928
content/docs/automation/webhooks.mdx§3.1 still described the pre-#7986 world: itnamed
definition_jsonas the carrier of the custom HTTP headers, and the fieldtable had no
headers_secretrow at all. Because the same table documents itssibling
signing_secretcorrectly, the omission read as "this column does notexist" — and following the page puts an
Authorization: Bearer …into an ordinarytextareathatGET /api/v1/data/sys_webhookreturns in full.Docs prose only — one file, no code, schema, or plugin changes.
Every claim re-measured from source
The security-adjacent direction here is the other one (implying
headers_secretis cleartext, or that masking is best-effort), so nothing below is restated from
the card.
headers_secretis aField.secret()packages/plugins/plugin-webhooks/src/sys-webhook.object.ts:220sys_secret, keeps an opaque ref, and refuses the write with no CryptoProvider (never falls back to cleartext)packages/objectql/src/engine.tsencryptSecretFieldsnull; an echoed mask is dropped as "unchanged"engine.tsmaskSecretFields;engine.ts:5498SECRET_MASK— eight U+2022 bulletspackages/spec/src/data/secret-mask.ts:61engine.ts:5831resolveSecretField()plugin-webhooks/src/webhook-headers.tsresolveWebhookHeaders←auto-enqueuer.ts:567attachHeadersplugin-webhooks/src/migrate-webhook-secrets.ts:82, invoked atwebhook-outbox-plugin.ts:230auto-enqueuer.tsattachHeaderscatch →reportDrop/parkWho sees what (the claim most likely to be written wrong)
The mask is the same for everyone on the data API — Studio and REST alike.
maskSecretFieldsruns onfind/findOne/$expand, unconditionally and afterhooks, with no persona carve-out. The plaintext is not "hard to reach" on that
path; it is unreachable on it. The only reader that gets the real header map is
in-process:
engine.resolveSecretField(), a driver-level read that bypasseshooks, field-level security and sharing, refuses any field not declared
type: 'secret', and that no query string can reach. Its consumer is theauto-enqueuer's cache refresh, which holds the values in memory, signs with one
and attaches the other to the request. The page now says exactly this, so an
author does not read "reads return a mask" as "the value is unusable".
The per-delivery copy is a different mechanism and the page keeps them
separate:
sys_http_delivery.headers_jsonisinternal(omitted from everygeneric read, recovered by the dispatcher's privileged batch read at claim time)
rather than encrypted — already documented in §3.2, now cross-referenced instead
of re-stated.
What changed on the page
definition_jsonis described as carryingtimeoutMsand therest of the authored envelope; the two credential-shaped values are named as
living in their own encrypted columns.
definition_jsonrow no longer claims the headers; a newheaders_secretrow sits between it andsigning_secret(declaration order),mirroring the sibling's wording and adding the
sys_webhook.headers_secrethas a required plaintext SHAPE that no write path enforces — the ordinary data API accepts any string, and the author only finds out at the next delivery #8566 write-door refusal(
VALIDATION_ERROR/400for a plaintext that is not a flat string map).definition_json; bothcredentials are dereferenced server-side on the cache refresh. Followed by a new
read-back paragraph (mask,
null, echoed-mask drop, who reaches plaintext) anda fail-closed paragraph (refused write with no CryptoProvider; park rather than
deliver header-less).
Callout— extended to cover the headers move alongside the signingsecret, including the boot sweep and the legacy blob read that still serves
un-migrated rows, marked explicitly as a compatibility path and not a place
to author.
Scope
skip-changeset).content/docs/releases/**,docs/adr/,.claude/,skills/,AGENTS.mdorCLAUDE.mdwas touched.check:docs-audit-scopestays at itsexisting 180 hand-written docs — an existing page edited, none added.
definition_json.headersread path (readLegacyHeaders) ismeasured and described here, but retiring it is out of scope for this PR;
Retire the legacy
sys_webhook.definition_json.headersread path —readLegacyHeadersstill accepts the cleartext column with only a warn #9930 remains open and owns that half.Verification — at
e55d51f91f(the head of this branch)Union re-derived from the real change set with
node scripts/pm/dispatch-gates.mjs(no hand-built path list), re-run after the final commit. All 11 derived gates plus
check:nul-bytes, each quoting its own verdict line:MDX renderability was checked directly rather than assumed: the page compiles under
@mdx-js/mdx@3.1.1(the version the docs app resolves), and the check was provedfalsifiable — the same script fails the same file with one injected
{("Unexpected end of file in expression").
Generated by Claude Code