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
fix(plugin-webhooks): park a subscription whose stored header map resolves to nothing (#8558) (#8565)
`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.
Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-webhooks): a stored header map that cannot be recovered parks the subscription instead of arming it and delivering the headers MISSING (#8558)
6
+
7
+
A webhook whose `sys_webhook.headers_secret` held a value that did not come back
8
+
as a header map was treated as **authored without custom headers**. The
9
+
subscription armed, every matching record change was delivered, and the entire
10
+
authored map — the ordinary place an `Authorization: Bearer …` goes — was
11
+
silently absent. Nothing logged, nothing dropped, and
12
+
`GET /api/v1/data/sys_webhook` kept reporting `active: true` with the header
13
+
column masked, so both the operator and the Setup UI still read "custom headers
14
+
are configured".
15
+
16
+
Measured end to end against a real engine, what reached the receiver was worse
17
+
than "a delivery with something missing": the request SUCCEEDED
18
+
(`sys_http_delivery.status = 'success'`) carrying a byte-correct
19
+
`X-Objectstack-Signature`. The signature is the receiver's proof the request is
20
+
genuinely ours, so a receiver that authenticates by signature had every reason
21
+
to accept a request that no longer matched the configuration its operator wrote.
22
+
Against an endpoint that requires the credential the result is a 401 nobody
23
+
attributes correctly; against one that does not — a routing `X-Tenant-Id`, an
24
+
`X-Environment: staging` — the delivery is simply wrong and nobody finds out.
25
+
26
+
The cause was one return value carrying two facts. `resolveWebhookHeaders`
27
+
answered `undefined` both for *"the author configured no custom headers"* —
28
+
legitimate, `headers` is optional on the envelope — and for *"a map is stored
29
+
and did not come back as one"*. Its caller acts on the first reading, so the
30
+
second became the first. This is the sibling of the signing-secret collapse
31
+
(#8542) on the same seam's other credential, and the file's own header comment
32
+
already promised the opposite: *"It does not deliver partially. A row whose
33
+
stored headers cannot be resolved DROPS the subscription."*
34
+
35
+
**This path is wider than the signing-secret one, not symmetric to it.** A
36
+
signing secret is an opaque scalar, so any non-empty answer is a usable key and
37
+
only the empty string collapsed. A header map's CONTENT decides, and
38
+
`parseStoredHeaders` answers `undefined` — correctly, for its own job — for every
39
+
string that is not a flat JSON object of string values. Four states reach the
40
+
seam, all confirmed against a real engine:
41
+
42
+
- the `sys_webhook` row is deleted between the enqueuer's cache read and the
43
+
per-row dereference;
44
+
- the column holds something that is not a `secret:` ref — reachable only
45
+
through a write that bypasses the engine (a column edited in SQL, a dump
46
+
restored without its `sys_secret` rows, a seed script writing at driver level);
47
+
- the stored value decrypts to an **empty string**;
48
+
- the stored value decrypts to a perfectly readable string that is **not a flat
49
+
string map** — `{}`, `[]`, `{"X-Count": 5}`, a nested object, or any typo.
50
+
This is the widest road rather than an exotic one: `headers_secret` is an
51
+
admin-authorable field whose own description instructs the author to type a
52
+
JSON object into it, and every one of these spellings is accepted by the
53
+
ordinary data API, encrypted like any other value, and left behind a
54
+
perfectly valid ref that reads back as the mask.
55
+
56
+
The fix is at the seam, so no consumer has to re-derive the rule: presence is
57
+
already decidable there (`headers_secret` is a map only in the plaintext — at
58
+
the storage layer it is an ordinary scalar `secret` column, so a set map comes
59
+
back from the generic read path as the engine's mask and an unset one as `null`),
60
+
and stored headers that do not come back as a map now raise rather than
61
+
answering `undefined`. They therefore reach `AutoEnqueuer.attachHeaders` exactly
62
+
the way a throwing resolver already did — the subscription is parked, the
63
+
discarded event lands in `sys_http_delivery` with a cause (#8069), and the
64
+
operator gets the existing remedy-bearing say-once `error` carrying
65
+
`INTERNAL_ERROR` / `500` (ADR-0112) and naming `headers_secret`, so it cannot be
66
+
confused with the signing secret's identical-looking drop.
67
+
68
+
**Unchanged:** a webhook authored with no custom headers at all still arms and
69
+
delivers — that is a legitimate authored configuration, and it is pinned as the
70
+
control for this change, as is a webhook whose stored map resolves normally and
71
+
still delivers every header including the credential entry.
72
+
73
+
**What an operator sees after upgrading.** A webhook that was quietly delivering
74
+
without its headers stops delivering and starts reporting. Re-save the headers
75
+
as a flat JSON object of string values so the column holds a fresh ref, or
76
+
**clear** the field to `null` if the webhook is meant to send no custom headers
77
+
— an empty or unparseable header map is not the same thing as no header map,
78
+
and only the second one means "send nothing extra".
0 commit comments