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
{{ message }}
Repository navigation
Commit 9a0c0b5
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-webhooks): refuse the retired definition_json credential location at delivery and at the write door (#19944)
Closes#10164
Clause-②: no (narrowing)
Step 3 of the retirement ruled on #9930, now in scope under the
2026-09-23 maintainer ruling recorded on #10164: 「10164 不考虑旧的」 and
「10164 现在就做,不等v18」. The legacy cleartext credential location, the
`secret` / `headers` keys inside `sys_webhook.definition_json`, goes
from warn-and-accept to a loud, dated refusal. The refusal applies at
both doors in one change.
## What changed
**Delivery door** (`AutoEnqueuer.attachSecret` / `attachHeaders`,
`auto-enqueuer.ts`). A row whose signing secret or header map exists
ONLY as the legacy key, with nothing in `signing_secret` /
`headers_secret`, is no longer delivered from the cleartext. The new
`refuseLegacyCleartext` parks the subscription in the same fail-closed
shape an unrecoverable encrypted credential already uses:
- nothing is sent;
- every matching event becomes a `dead` `sys_http_delivery` row with 0
attempts, no signature, no headers, and an `error` reading
`[VALIDATION_ERROR/400] ...`;
- the drop is reported ONCE at `error`, on the existing say-once ledger.
Its meta carries `code`, `status`, `field: 'definition_json'` and the
refused `keys`.
`readLegacySecret` / `readLegacyHeaders` stay, because the boot sweep is
their other caller and the sweep is the remedy.
**Write door** (new `webhook-legacy-cleartext.ts`,
`bindWebhookLegacyCleartextGate`). A `beforeInsert` / `beforeUpdate`
hook on `sys_webhook` refuses any `definition_json` that carries a
`secret` or `headers` key, whatever its value. It follows the #8566
shape: the same hook seam as the `headers_secret` shape gate, bound in
`bootDeclaredWebhooks` before the seeder and the sweep, unbound in
`destroy()`. It is not exempt for `isSystem`. The error,
`WebhookLegacyCleartextError`, carries `code: 'VALIDATION_ERROR'`,
`status: 400`, `object`, `field` and `keys`. It judges key presence:
`"headers": {}` still teaches the wrong location. It never echoes a
value. A clean blob, an omitted `definition_json` and a non-JSON blob
all pass. The last is not this gate's verdict.
**Refusal text**: it is dated `2026-09-23` (the ruling's date; the
message says why), names the sweep `migrateLegacyWebhookSecrets`, and
gives the remedy. Both remedies need a registered CryptoProvider. With
one registered, either restart so the sweep converts the row, or write
the values into `signing_secret` / `headers_secret` and remove the keys.
The text carries no tracker number. Per 「不考虑旧的」, there is no transition
path for a deployment without a `CryptoProvider`.
**Changeset**: `@objectstack/plugin-webhooks: minor` with a **BREAKING**
banner (launch-window convention; `check-changeset-no-major` refuses
`major`). It states plainly that webhooks still on the legacy shape STOP
DELIVERING, and gives the remedy. ADR-0087 disposition: `not-required
(no-migration-prescription)`. Nothing authored moves: `packages/spec` is
untouched, and `WebhookSchema` still declares `secret` / `headers`,
which the materializer still routes to the encrypted columns. The
refused shape is a stored data row, and its converter, the boot sweep,
already ships.
⛔ Zero `packages/spec`. ⛔ No `content/docs/releases/**`.
## Tests (all in `packages/plugins/plugin-webhooks`)
- `webhook-secret-at-rest.test.ts`. The two "un-swept row keeps signing
/ keeps delivering" tests are rewritten as refusal witnesses on a real
`ObjectQL` engine. Each asserts no call reaches the receiver, exactly
one `error` whose meta matches `{ code: 'VALIDATION_ERROR', status: 400,
field: 'definition_json', keys: [...] }`, and a parked dead row whose
`error` starts `[VALIDATION_ERROR/400]`. Each also checks that the prose
carries the date, the sweep name, the remedy, no credential and no
tracker number. A control leg shows the same row, once swept, delivers
its headers.
- Write-door suite on a real engine:
- update with `headers`, with `secret`, and with both is refused with
the envelope (`code`, `status`, `object`, `field`, `keys`), and nothing
lands;
- an insert is refused whole;
- an emptied key is refused;
- a clean blob, an omitted blob and a non-JSON blob pass;
- an unbound counterfactual lets the write land;
- the boot sweep still converts a pre-existing legacy row with the gate
bound;
- the declared-webhook seeder still materializes secret + headers
through the gate.
- `webhook-signing-secret.test.ts`. Fixture re-spelled onto the
encrypted columns, with a `resolveSecretField` stub. It pinned the
signature path by riding the legacy blob, which is now refused.
## Reverse verification (from committed state,
`scripts/ablation-replace.mjs` WRAP mode, restore proven by blob hash ==
HEAD and an empty `git diff HEAD`)
| leg | mutation | witness file result |
|---|---|---|
| A | `attachSecret` honours the legacy secret again | 1 failed / 45
passed: "an un-swept row is REFUSED, not signed from the blob" |
| B | `attachHeaders` honours the legacy headers again | 1 failed / 45
passed: "an un-swept row is REFUSED, not delivered with the blob's
headers" |
| C | the write-door verdict passes everything | 5 failed / 41 passed:
all five write-door refusal cases |
All three were restored: `auto-enqueuer.ts` blob `1cfd125c6c79` == HEAD,
`webhook-legacy-cleartext.ts` blob `1c50d2b4321b` == HEAD.
## Local verification (HEAD `36d17abc8e`)
- `pnpm --filter @objectstack/plugin-webhooks exec vitest run`: 13
files, 160 tests passed.
- `pnpm --filter @objectstack/plugin-webhooks run typecheck`: exit 0,
measured at `7d448ba52a`. The only later commit touches
`scripts/doc-authoring-prose-id.baseline.json`.
- eslint over the 7 changed `.ts` files: 7 files, 0 errors, 0 warnings.
Population read from `eslint.config.mjs`; `--format json` counts.
Invariance: type-aware linting is not enabled (no
`parserOptions.project`), so this diff cannot move any untouched file's
verdict.
- `dispatch-gates --ran`: 101 derived families accounted for, 98 run
green and 3 NOT MEASURED:
- `check:skill-examples` needs built client SDK output;
- `check:dual-build-cjs-loads` and `check:type-check-debt` need the full
workspace build.
All three exited 3 on their own prerequisite, and CI builds the full
closure. `check:doc-authoring` first went red on a burn-down, meaning
the removed warn strings cited two tracker ids. The baseline was
regenerated with the gate's own shrink-only command (`#7799`/`#7986` 3
to 2 in `auto-enqueuer.ts`). `check:i18n` went green after its
prerequisite closure was built. `check-adr-0087-registration`,
`check-changeset-no-major` and `check-empty-changeset` are green.
## Acceptance notes
- **Docs**: `content/docs/automation/webhooks.mdx` §3.1 said an un-swept
row "is still delivered from the blob, with a `warn`". That sentence is
corrected to the refusal, in the one page describing this exact
behaviour. No other hand-written page describes legacy acceptance.
- **Not exported from the package entry.**
`bindWebhookLegacyCleartextGate` is wired by `WebhookOutboxPlugin` only.
The `headers_secret` gate is exported for hosts that boot the pieces
themselves; this one is deliberately not, to keep the public surface
unchanged under `no (narrowing)`. A consumer branches on `code` /
`status`.
- **Residue left as-is (observation, not filed)**: a row holding BOTH an
encrypted credential and a legacy key is not refused at delivery. The
encrypted value wins and the legacy key is ignored, as before. Such a
row can no longer be written through the engine. Any existing one is
still readable over the data API until the boot sweep, which treats it
as found and writes the blob's value into the encrypted column. Whether
that overwrite can clobber a rotated key on a real deployment is
unmeasured. Carrier: none.
- Commit trailers are the model-free pair AGENTS.md prescribes. The
harness's own attribution reminder asked for a model-named trailer,
which the pre-push hook refuses; AGENTS.md wins.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-webhooks): a webhook credential stored as cleartext inside `sys_webhook.definition_json` is refused, at the delivery path and at the write door (#10164)
6
+
7
+
Clause-②: no (narrowing)
8
+
9
+
**BREAKING** — shipped as `minor` under the launch-window convention
10
+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
11
+
this banner and the ADR-0087 disposition below, never by the level).
12
+
13
+
**Webhooks that still carry the legacy cleartext shape STOP DELIVERING.** A
14
+
`sys_webhook` row whose signing secret or custom header map exists only as a
15
+
`secret` / `headers` key inside `definition_json` — with nothing stored in the
16
+
encrypted `signing_secret` / `headers_secret` column — used to be delivered from
17
+
that cleartext with a `warn`. It is now refused, dated `2026-09-23`:
18
+
19
+
-**Delivery path.** The subscription is PARKED, the same fail-closed shape as an
20
+
encrypted credential that cannot be recovered: nothing is sent, and every
21
+
matching record change is recorded in `sys_http_delivery` as a `dead` row with
22
+
0 attempts, no signature and no headers. Its `error` names the refusal as
23
+
`[VALIDATION_ERROR/400]`, and the drop is reported once at `error`, with
24
+
`code: 'VALIDATION_ERROR'`, `status: 400`, `field: 'definition_json'` and the
25
+
refused `keys` in the log meta.
26
+
-**Write door** (when `WebhookOutboxPlugin` is mounted, the standard mount). A
27
+
`sys_webhook` insert or update whose `definition_json` carries a `secret` or
28
+
`headers` key, whatever its value, is refused before anything is stored
29
+
(`VALIDATION_ERROR` / `400`, with `object`, `field` and `keys` on the error).
30
+
That covers a raw `PATCH /api/v1/data/sys_webhook`. It also covers a Setup-form
31
+
save that echoes back a legacy blob unchanged. Omitting `definition_json`, or
32
+
writing one without those keys, is unaffected. The plugin binds this refusal
33
+
itself, and it is not exported from the package entry: a host that composes
34
+
`AutoEnqueuer` on its own still gets the delivery-path refusal above, but not
35
+
this write-door refusal.
36
+
37
+
**Fix.** Both remedies need a registered `CryptoProvider`
38
+
(`engine.setCryptoProvider` — `LocalCryptoProvider` in dev, KMS/Vault in
39
+
production), because writing a `secret`-typed column is itself refused without
40
+
one. With a provider registered, either:
41
+
42
+
- restart, and the boot sweep `migrateLegacyWebhookSecrets` moves both values into
43
+
their encrypted columns and strips them from `definition_json` in one update. The
44
+
subscription re-arms at the next refresh.
45
+
- or re-author the webhook yourself. Write the key into `signing_secret` and the
46
+
header map into `headers_secret` (a JSON object of string values), then remove
47
+
both keys from `definition_json`.
48
+
49
+
There is no transition path for a deployment that runs with no `CryptoProvider`.
50
+
51
+
Unchanged: authoring. `defineWebhook({ secret, headers })` is written exactly as
52
+
before, and the boot materializer still routes each value to its encrypted column.
53
+
A row whose encrypted columns are set delivers exactly as before.
54
+
55
+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authored moves: `packages/spec` is untouched, `WebhookSchema` still declares `secret` and `headers`, and the materializer still routes them to their encrypted columns, so `objectstack migrate meta` has nothing to rewrite and the ledger has no row to gain. What is refused is a stored DATA-row shape on `sys_webhook`, and its converter already ships and runs at every boot: `migrateLegacyWebhookSecrets`, named in the refusal and in the Fix paragraph above. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a data-row sweep (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->
0 commit comments