From e55d51f91fe79b6311392f5756e1c86aff197ae8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 20:10:56 +0000 Subject: [PATCH] docs(webhooks): custom headers live in the encrypted `headers_secret`, not `definition_json` (#9928) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --- content/docs/automation/webhooks.mdx | 73 ++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/content/docs/automation/webhooks.mdx b/content/docs/automation/webhooks.mdx index 81422245c8..8a59da4ca6 100644 --- a/content/docs/automation/webhooks.mdx +++ b/content/docs/automation/webhooks.mdx @@ -84,11 +84,14 @@ CRUD, permissions, audit, and Studio UI without bespoke code. ### 3.1 `sys_webhook` The subscription record. One row per "I want webhook X to fire for object Y". -The transport configuration (headers, timeout, method) is carried in -`definition_json`, a serialised `Webhook` JSON (canonical schema: -`WebhookSchema`, exported from `@objectstack/spec/automation`). The signing -secret is the one authored value that does **not** live in that blob — it has -its own encrypted column, see below. +The authored envelope — the per-attempt `timeoutMs` and the rest of the +`Webhook` config — is carried in `definition_json`, a serialised `Webhook` JSON +(canonical schema: `WebhookSchema`, exported from +`@objectstack/spec/automation`). Two authored values do **not** live in that +blob: the signing secret and the custom HTTP headers each have their own +encrypted column (`signing_secret`, `headers_secret`). Both are the ordinary +place a credential goes, and `definition_json` is an ordinary `textarea` +returned in full by `GET /api/v1/data/sys_webhook`. | Field | Type | Notes | |-------------------|-----------|------------------------------------------------------------------------------------| @@ -101,24 +104,62 @@ its own encrypted column, see below. | `method` | select | HTTP method — one of `GET` / `POST` / `PUT` / `PATCH` / `DELETE`. Default `POST`. | | `description` | textarea | Free-text description. | | `active` | boolean | Inactive webhooks are skipped by the dispatcher. Default `true`. | -| `definition_json` | textarea | Serialised `Webhook` JSON (`WebhookSchema` from `@objectstack/spec/automation`) — carries the transport config: custom `headers` and `timeoutMs`. **Not** the signing secret. | +| `definition_json` | textarea | Serialised `Webhook` JSON (`WebhookSchema` from `@objectstack/spec/automation`) — the per-attempt `timeoutMs` and the rest of the authored envelope. **Not** the signing secret and **not** the custom headers: each has its own encrypted column below. | +| `headers_secret` | secret | Custom HTTP headers sent with every delivery, authored as a JSON object of string values (`{"Authorization": "Bearer …"}`) — a `Field.secret`, the same channel as `signing_secret`. Encrypted on write into `sys_secret`; the row keeps only an opaque ref and every read path returns a mask, so the headers are not recoverable over the data API. Leave the mask untouched when editing to keep the current value. A plaintext that is not a flat map of string values is refused at the write door (`VALIDATION_ERROR` / `400`), not at the next delivery. | | `signing_secret` | secret | The HMAC-SHA256 key (`Field.secret`). Encrypted on write into `sys_secret`; the row keeps only an opaque ref and every read path returns a mask, so the key is not recoverable over the data API. Leave the mask untouched when editing to keep the current value. | | `created_at` | datetime | Standard audit columns. | | `updated_at` | datetime | | Matching at runtime is purely `object_name` + the multi-select `triggers` -list; the headers and per-attempt timeout are parsed out of `definition_json` -when an event is enqueued, and the signing key is dereferenced server-side from -`signing_secret`. There is no per-row org/tenant column, no `events[]` glob -field, no stored `retry_policy`, and no `secret_hint` — see §6 for the actual -signing model and §11 for the (single) retry budget. +list; the per-attempt timeout is parsed out of `definition_json` when an event +is enqueued, and **both** credentials are dereferenced server-side when the +enqueuer refreshes its subscription cache — the signing key from +`signing_secret`, the custom header map from `headers_secret`. There is no +per-row org/tenant column, no `events[]` glob field, no stored `retry_policy`, +and no `secret_hint` — see §6 for the actual signing model and §11 for the +(single) retry budget. + +**What a read of the two encrypted columns returns, and to whom.** Both are +masked on *every* generic read — `find` / `findOne` / `$expand`, and therefore +the REST data API and the Studio alike — unconditionally, after hooks, with no +persona carve-out and no privileged-caller exception on that path. A set value +comes back as the mask (`SECRET_MASK`, eight `•` characters, ADR-0100); an +unset one comes back as `null`, so a form can render "configured" vs "not +configured" without seeing either value. Writing that exact mask string back +means "unchanged" and is dropped, which is what makes editing the rest of the +record around an untouched mask safe. The plaintext is reachable **only +in-process**, through the engine's privileged `resolveSecretField()` +dereference: the auto-enqueuer calls it on each cache refresh, keeps the values +in memory, signs the body with one and attaches the other to the outbound +request. No query string reaches that method, so nothing on the data API hands +either value back, for any persona. + +Both columns are equally fail-closed on the way in and on the way out: with no +`CryptoProvider` registered the engine **refuses** the write rather than storing +cleartext, and a stored header map that cannot be resolved back into a flat +string map parks the subscription — reported at `error` — rather than +delivering it without the headers it was authored with. The per-delivery *copy* +of those headers is a different mechanism with its own rule: it is snapshotted +onto `sys_http_delivery.headers_json`, which is `internal` rather than +encrypted — see §3.2. -Until v17 the authored `secret` was serialised into `definition_json` along with -the rest of the envelope, which made it readable through an ordinary -`GET /api/v1/data/sys_webhook`. It now goes to `signing_secret`; existing rows -are migrated on boot. Authoring does not change — `defineWebhook({ secret })` is -written exactly as before. +Until v17 the authored `secret` **and** the authored `headers` were serialised +into `definition_json` along with the rest of the envelope, which made both +readable through an ordinary `GET /api/v1/data/sys_webhook`. They now go to +`signing_secret` and `headers_secret`; existing rows are migrated on boot by one +idempotent sweep that moves both passengers and strips them from the blob in a +single update. Authoring does not change — `defineWebhook({ secret, headers })` +is written exactly as before, and the boot materializer routes each value to its +own column. + +A row the sweep has not converted — for example a pre-v17 row on a runtime with +no `CryptoProvider` wired, where the encrypted write is refused and the row is +deliberately left intact — is still delivered from the blob, with a `warn` +naming those headers as cleartext. That fallback exists for rows written before +the move; it is **not** a supported place to author. Never type a token into +`definition_json`: it is returned in full by the generic data API, and nothing +ages it out. ### 3.2 `sys_http_delivery`