You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Browse filesBrowse the repository at this point in the historyBrowse files
authored
docs(webhooks): custom headers live in the encrypted headers_secret, not definition_json (#9928) (#10067)
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.
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
|`active`| boolean | Inactive webhooks are skipped by the dispatcher. Default `true`. |
104
-
|`definition_json`| textarea | Serialised `Webhook` JSON (`WebhookSchema` from `@objectstack/spec/automation`) — carries the transport config: custom `headers` and `timeoutMs`. **Not** the signing secret. |
107
+
|`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. |
108
+
|`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. |
105
109
|`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. |
106
110
|`created_at`| datetime | Standard audit columns. |
107
111
|`updated_at`| datetime ||
108
112
109
113
Matching at runtime is purely `object_name` + the multi-select `triggers`
110
-
list; the headers and per-attempt timeout are parsed out of `definition_json`
111
-
when an event is enqueued, and the signing key is dereferenced server-side from
112
-
`signing_secret`. There is no per-row org/tenant column, no `events[]` glob
113
-
field, no stored `retry_policy`, and no `secret_hint` — see §6 for the actual
114
-
signing model and §11 for the (single) retry budget.
114
+
list; the per-attempt timeout is parsed out of `definition_json` when an event
115
+
is enqueued, and **both** credentials are dereferenced server-side when the
116
+
enqueuer refreshes its subscription cache — the signing key from
117
+
`signing_secret`, the custom header map from `headers_secret`. There is no
118
+
per-row org/tenant column, no `events[]` glob field, no stored `retry_policy`,
119
+
and no `secret_hint` — see §6 for the actual signing model and §11 for the
120
+
(single) retry budget.
121
+
122
+
**What a read of the two encrypted columns returns, and to whom.** Both are
123
+
masked on *every* generic read — `find` / `findOne` / `$expand`, and therefore
124
+
the REST data API and the Studio alike — unconditionally, after hooks, with no
125
+
persona carve-out and no privileged-caller exception on that path. A set value
126
+
comes back as the mask (`SECRET_MASK`, eight `•` characters, ADR-0100); an
127
+
unset one comes back as `null`, so a form can render "configured" vs "not
128
+
configured" without seeing either value. Writing that exact mask string back
129
+
means "unchanged" and is dropped, which is what makes editing the rest of the
130
+
record around an untouched mask safe. The plaintext is reachable **only
131
+
in-process**, through the engine's privileged `resolveSecretField()`
132
+
dereference: the auto-enqueuer calls it on each cache refresh, keeps the values
133
+
in memory, signs the body with one and attaches the other to the outbound
134
+
request. No query string reaches that method, so nothing on the data API hands
135
+
either value back, for any persona.
136
+
137
+
Both columns are equally fail-closed on the way in and on the way out: with no
138
+
`CryptoProvider` registered the engine **refuses** the write rather than storing
139
+
cleartext, and a stored header map that cannot be resolved back into a flat
140
+
string map parks the subscription — reported at `error` — rather than
141
+
delivering it without the headers it was authored with. The per-delivery *copy*
142
+
of those headers is a different mechanism with its own rule: it is snapshotted
143
+
onto `sys_http_delivery.headers_json`, which is `internal` rather than
144
+
encrypted — see §3.2.
115
145
116
146
<Callouttype="info">
117
-
Until v17 the authored `secret` was serialised into `definition_json` along with
118
-
the rest of the envelope, which made it readable through an ordinary
119
-
`GET /api/v1/data/sys_webhook`. It now goes to `signing_secret`; existing rows
120
-
are migrated on boot. Authoring does not change — `defineWebhook({ secret })` is
121
-
written exactly as before.
147
+
Until v17 the authored `secret`**and** the authored `headers` were serialised
148
+
into `definition_json` along with the rest of the envelope, which made both
149
+
readable through an ordinary `GET /api/v1/data/sys_webhook`. They now go to
150
+
`signing_secret` and `headers_secret`; existing rows are migrated on boot by one
151
+
idempotent sweep that moves both passengers and strips them from the blob in a
152
+
single update. Authoring does not change — `defineWebhook({ secret, headers })`
153
+
is written exactly as before, and the boot materializer routes each value to its
154
+
own column.
155
+
156
+
A row the sweep has not converted — for example a pre-v17 row on a runtime with
157
+
no `CryptoProvider` wired, where the encrypted write is refused and the row is
158
+
deliberately left intact — is still delivered from the blob, with a `warn`
159
+
naming those headers as cleartext. That fallback exists for rows written before
160
+
the move; it is **not** a supported place to author. Never type a token into
161
+
`definition_json`: it is returned in full by the generic data API, and nothing
0 commit comments