Commit 89801cd
fix(service-automation): sign the http node's inline request with the one scheme, and refuse a secret that did not resolve (#20640)
Fixes #20628
Clause-②: yes (widening). `@objectstack/core` gains the exported scheme
⇒ at least `minor` for `core`.
## What changed
A flow `http` node's `signingSecret` is declared as "HMAC-SHA256 secret
→ X-Objectstack-Signature", and no arm is named. Only the durable outbox
arm signed. The inline arm, and the durable arm's fallback when no
messaging HTTP outbox is wired, sent no signature header, and the run
still reported success. After this PR the key means one thing on every
arm.
- **One scheme, moved to `@objectstack/core`** (the seat's ruling on the
claim). Two new exports on the `@objectstack/core` root come from
`packages/core/src/security/http-signature.ts` through
`packages/core/src/security/index.ts`:
- `signHttpBody(body: string, secret: string): string` returns `sha256=`
plus the lowercase hex HMAC-SHA256 of the exact body bytes.
- `HTTP_SIGNATURE_HEADER` is `'X-Objectstack-Signature'`.
- `@objectstack/runtime` re-exports the core root with `export *`, so
both names appear there too.
- **`@objectstack/service-messaging` keeps its published names**,
`signHttpBody` and `HTTP_SIGNATURE_HEADER`. They are now re-exports of
the core bindings. `http-sender.ts` re-exports them under the
module-internal names the two outboxes import (`signBody` /
`SIGNATURE_HEADER`). No second implementation remains, and nothing it
publishes is removed or renamed. A test pins `messaging.signHttpBody ===
core.signHttpBody` against the built packages.
- **`http-nodes.ts`, the inline arm** (also the no-outbox fallback)
sends `X-Objectstack-Signature` whenever `signingSecret` is set. The
value is `signHttpBody` over the exact string it passes as `fetch`'s
`body`, or over the empty string when there is no body.
- **The refusal:** a non-empty authored `signingSecret` that resolves to
no value in the run fails the node before either arm, so nothing is sent
or enqueued. The full condition is below.
## Which bytes each arm signs
- **Outbox arm (unchanged).** `MemoryHttpOutbox.enqueue` and
`SqlHttpOutbox.enqueue` sign `deliveryBody(payload)` at enqueue, and
`sendOnce` posts `deliveryBody(payload)`. The node passes `payload: body
?? {}`:
- an object body is sent as its `JSON.stringify`;
- a string body is sent verbatim;
- no body is sent as `{}`.
- **Inline arm and the no-outbox fallback.** These use the node's own
serialization:
- a non-null body is sent as `JSON.stringify(body)`, so a string body
goes out JSON-quoted;
- otherwise no body is sent, and the signature is over the empty string.
- **The two serializations differ** for a string body and for no body.
Each arm signs what it sends. On every arm the pins check at a real
local receiver that the received header equals
`signHttpBody(receivedBytes, secret)`.
- **The empty-body HMAC is pinned as a literal** in both the core test
and the node test (`sha256=28c9179f…bd5187f7` under the test secret). So
the fallback can't drift to signing `{}` or `null` while it sends
nothing.
## The refusal condition, from the measurement
What `signingSecret` becomes after `interpolate(...)` and the contract
parse. The "after" column was measured in a scratch run at the fixed
head. The two refused rows were also measured on `main`.
| authored `signingSecret` | resolves to | on `main` | after |
|---|---|---|---|
| absent | absent | no header | no header |
| `''` | `''` | no header | no header: the unsigned-on-purpose spelling
(the cleared form PR #20615 defines), on every arm |
| a literal | the literal | inline: no header; outbox: signed | signed
on every arm |
| a whole `{token}` with no value in the run | `undefined` (the parse
accepts it) | sent with no header, `success: true` | **refused** |
| a `{token}` whose value is `''`, or several tokens that all render
empty | `''` | sent with no header, `success: true` | **refused** |
| a `{token}` whose value is `null` or a number | a non-string | refused
by the contract parse, naming `config.signingSecret` | unchanged |
| `k_{token}` with no value | `'k_'` | inline: no header | signed with
`k_`. The receiver's check then fails, so the error is loud there. The
executor can't tell this apart from a real literal. |
- **The rule:** refuse when the authored value is a non-empty string and
the resolved value is `undefined` or `''`.
- **The refusal is `refuseNode`**, a guard refusal. That is the same
class as this file's `url` refusal and the #3810 collapsed-filter
precedent, so a fault edge does not route it. A new row in
`guard-refusal-inventory.test.ts` pins this.
- **It runs before the durable branch.** So the outbox arm also refuses,
where before it enqueued the delivery unsigned.
- **The message names the key** and the unsigned-on-purpose spelling,
and it carries no tracker number.
## Evidence (final head `62f989f1c`)
- **Measured before the fix, RED.** Commit `b9a7d9115` adds only the
pins, on the unfixed executor at `542670da6`.
- `http-node-signing.test.ts`: 19 failed, 8 passed. Every signing pin
failed on all three in-process arms: inline; durable with no messaging
service; durable with a `MessagingService` and no outbox.
- The failures read `AssertionError: no X-Objectstack-Signature arrived:
expected undefined to be type of 'string'`. The GET pin read `expected
undefined to be 'sha256=28c9179fd9763c0e7d41dc5241d9d7…'`.
- Both refusal pins read `expected true to be false` on each arm and on
the outbox arm. The run succeeded and the request left unsigned.
- The 8 greens were the controls: no key and `''` on the three arms,
plus the outbox arm's signature and its `''`.
- `guard-refusal-inventory.test.ts`: the new row failed (1 failed, 15
passed), because the fault edge routed the failure.
- **After the change.** The same two files, plus `http-nodes.test.ts`
and `http-delivery-outcome.integration.test.ts`: 4 files, 56 passed.
- **Ablation.** The fix was committed first, and the restore had its own
trap.
- `scripts/ablation-replace.mjs` replaced the inline arm's signing
expression with `? headers`: anchor 1 → 0, blob `2137f1ab2056` →
`061481dd31ee`.
- `http-node-signing.test.ts` then gave 12 failed, 16 passed: exactly
the 4 signing pins × 3 in-process arms. The quoted failures were `no
X-Objectstack-Signature arrived: expected undefined to be type of
'string'` and `expected undefined to be
'sha256=28c9179fd9763c0e7d41dc5241d9d7…'`.
- Restore: blob `2137f1ab2056` equals HEAD, and `git diff HEAD` is
empty. The trap proved it a second time.
- The ablation ran at `14828798f`. `git diff --stat 1482879 62f989f`
on `http-nodes.ts` and the test file prints nothing.
- There is no build leg: the subject is service-automation's own `src/`,
which vitest reads directly.
- **Package suites at `62f989f1c`**, after merging `origin/main` at
`3f45b6cc1`:
- `@objectstack/service-automation`: 153 files, 1895 tests passed.
- `@objectstack/service-messaging`: 46 files, 507 passed.
- `@objectstack/core` `--project local`: 57 files, 1525 passed.
`http-signature.test.ts` alone: 3 passed.
- `typecheck` on the three packages: exit 0. Both test layers compile,
with no new debt.
- **Whole tree.** `pnpm build --concurrency=2` at `62f989f1c`: 72 of 72
tasks succeeded. That includes every package downstream of
`@objectstack/core` (the `...@objectstack/core` consumer direction). So
the two new root names collide with no `export *` consumer
(`@objectstack/runtime`, `@objectstack/plugin-hono-server`).
- **Gates at `62f989f1c`.**
- `dispatch-gates --commands` derived 65 families. All 65 ran, and every
exit code was captured before any pipe: 65 exit 0. `--ran` reconciles to
"65 run, 0 NOT-MEASURED (a DERIVED zero)".
- The ⛔ artifact rosters under paths in this diff: 4 run, 4 exit 0
(`check-changeset-fixed`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`). Also run:
`check:published-readme-exports`, exit 0.
- `pnpm lint` (repo-wide eslint, `--no-inline-config`): exit 0.
- **Not measured locally; CI runs these:**
- the 5 path-scheduled CI jobs (the Test Core shards, Temporal
Conformance, the Dogfood shards);
- the 11 wide-population families;
- the 6 families whose argv carries a workflow-only value.
## Acceptance notes
- **Durable `GET` never delivers.** This is outside this card, a
different defect, so it is not fixed here. It is recorded for the seat.
- A `durable: true` node with `method: 'GET'` enqueues `payload: {}`.
The dispatcher's send then refuses a GET that has a body, with `Request
with GET/HEAD method cannot have body.`.
- So the row stays pending and retrying, and it never reaches the
receiver. The run meanwhile reports success.
- Measured at the executor seam with a real `MessagingService`,
`MemoryHttpOutbox` and `HttpDispatcher` and a local receiver. After one
tick the row showed `status: pending`, `attempts: 1`, that error, and
the receiver had nothing.
- No public door was measured and no real producer is named, so it is
not filed.
- A side effect: the outbox arm would sign a bodyless GET over `{}`, not
the empty body. That is moot while such a request cannot be sent.
- **`flow-credential-projection.ts` docblock.** It says the durable arm
hands `signingSecret` to the outbox, "which signs every delivery". That
is still true, but now incomplete, because the inline arm signs too. It
is not edited here: the file is outside this card's file surface.
- **File surface.** `guard-refusal-inventory.test.ts` sits outside
`builtin/`. I read "`http-nodes.ts` and its tests" as including the
inventory row that drives the http executor. The inventory's own header
asks that each new guard be added there.
- **Header case.** The inline arm mirrors the outbox: author headers
first, then the signature under the exact name
`X-Objectstack-Signature`. That overrides an author header with the same
casing. A differently-cased author header would travel beside it, on
both arms alike. Noted only.
- **Behaviour change to call out:** a durable node whose authored secret
does not resolve now refuses. Before, it enqueued an unsigned delivery.
## Cross-lane
`packages/core` belongs to `domain:engine`. The new exports, exactly:
`signHttpBody` and `HTTP_SIGNATURE_HEADER` on the `@objectstack/core`
root, which `@objectstack/runtime` also carries through its `export *`.
No existing core export changed.
## Changeset
`.changeset/20628-http-node-signs-both-arms.md`: `@objectstack/core`
minor, `@objectstack/service-automation` and
`@objectstack/service-messaging` patch. It carries the `Clause-②: yes
(widening)` line.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent cd901d7 commit 89801cd
9 files changed
Lines changed: 491 additions & 25 deletions
File tree
- .changeset
- packages
- core/src/security
- services
- service-automation/src
- builtin
- service-messaging/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
51 | 57 | | |
52 | 58 | | |
53 | 59 | | |
| |||
0 commit comments