fix(service-automation): sign the http node's inline request with the one scheme, and refuse a secret that did not resolve - #20640
Conversation
…the unfixed executor) Measuring pins, committed before the fix. A real local receiver checks what arrived: with signingSecret set, the inline arm and both durable no-outbox fallbacks deliver no X-Objectstack-Signature (19 red), a secret whose template resolves to nothing is sent unsigned instead of refused, and the guard inventory row routes to the fault handler. The no-key, empty-string and outbox-wired controls are green. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…e scheme, and refuse a secret that did not resolve The outbound HTTP signature scheme (signHttpBody, HTTP_SIGNATURE_HEADER) moves to @objectstack/core, the runtime floor both senders stand on; service-messaging re-exports the same bindings under its published names. The flow http node's inline arm, and the durable arm's no-outbox fallback that degrades to it, now send X-Objectstack-Signature over the exact bytes they hand fetch (the empty body when there is none). A non-empty signingSecret that renders to nothing at run time refuses the node instead of sending unsigned; an authored '' still sends unsigned on purpose, on every arm. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…ation and messaging patch Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check6 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7fe99aef3fbe8b80db85a876f3f63a8efbcb607d && git checkout 7fe99aef3fbe8b80db85a876f3f63a8efbcb607d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 04b202e5cb8c642d881615f8a276edb5e7ceb1c3 62f989f1c227c94050384a95aa632ae6b6d19bb4 && git checkout -B drift-repro 04b202e5cb8c642d881615f8a276edb5e7ceb1c3 && git merge --no-ff 62f989f1c227c94050384a95aa632ae6b6d19bb4
node scripts/docs-audit/affected-docs.mjs --json 04b202e5cb8c642d881615f8a276edb5e7ceb1c3 |
Fixes #20628
Clause-②: yes (widening).
@objectstack/coregains the exported scheme ⇒ at leastminorforcore.What changed
A flow
httpnode'ssigningSecretis 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.@objectstack/core(the seat's ruling on the claim). Two new exports on the@objectstack/coreroot come frompackages/core/src/security/http-signature.tsthroughpackages/core/src/security/index.ts:signHttpBody(body: string, secret: string): stringreturnssha256=plus the lowercase hex HMAC-SHA256 of the exact body bytes.HTTP_SIGNATURE_HEADERis'X-Objectstack-Signature'.@objectstack/runtimere-exports the core root withexport *, so both names appear there too.@objectstack/service-messagingkeeps its published names,signHttpBodyandHTTP_SIGNATURE_HEADER. They are now re-exports of the core bindings.http-sender.tsre-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 pinsmessaging.signHttpBody === core.signHttpBodyagainst the built packages.http-nodes.ts, the inline arm (also the no-outbox fallback) sendsX-Objectstack-SignaturewheneversigningSecretis set. The value issignHttpBodyover the exact string it passes asfetch'sbody, or over the empty string when there is no body.signingSecretthat 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
MemoryHttpOutbox.enqueueandSqlHttpOutbox.enqueuesigndeliveryBody(payload)at enqueue, andsendOncepostsdeliveryBody(payload). The node passespayload: body ?? {}:JSON.stringify;{}.JSON.stringify(body), so a string body goes out JSON-quoted;signHttpBody(receivedBytes, secret).sha256=28c9179f…bd5187f7under the test secret). So the fallback can't drift to signing{}ornullwhile it sends nothing.The refusal condition, from the measurement
What
signingSecretbecomes afterinterpolate(...)and the contract parse. The "after" column was measured in a scratch run at the fixed head. The two refused rows were also measured onmain.signingSecretmain''''{token}with no value in the runundefined(the parse accepts it)success: true{token}whose value is'', or several tokens that all render empty''success: true{token}whose value isnullor a numberconfig.signingSecretk_{token}with no value'k_'k_. The receiver's check then fails, so the error is loud there. The executor can't tell this apart from a real literal.undefinedor''.refuseNode, a guard refusal. That is the same class as this file'surlrefusal and the Flow node filters silently blank date macros: the template engine consumes{…}before the query engine sees it #3810 collapsed-filter precedent, so a fault edge does not route it. A new row inguard-refusal-inventory.test.tspins this.Evidence (final head
62f989f1c)b9a7d9115adds only the pins, on the unfixed executor at542670da6.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 aMessagingServiceand no outbox.AssertionError: no X-Objectstack-Signature arrived: expected undefined to be type of 'string'. The GET pin readexpected undefined to be 'sha256=28c9179fd9763c0e7d41dc5241d9d7…'.expected true to be falseon each arm and on the outbox arm. The run succeeded and the request left unsigned.''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.http-nodes.test.tsandhttp-delivery-outcome.integration.test.ts: 4 files, 56 passed.scripts/ablation-replace.mjsreplaced the inline arm's signing expression with? headers: anchor 1 → 0, blob2137f1ab2056→061481dd31ee.http-node-signing.test.tsthen gave 12 failed, 16 passed: exactly the 4 signing pins × 3 in-process arms. The quoted failures wereno X-Objectstack-Signature arrived: expected undefined to be type of 'string'andexpected undefined to be 'sha256=28c9179fd9763c0e7d41dc5241d9d7…'.2137f1ab2056equals HEAD, andgit diff HEADis empty. The trap proved it a second time.14828798f.git diff --stat 14828798f 62f989f1conhttp-nodes.tsand the test file prints nothing.src/, which vitest reads directly.62f989f1c, after mergingorigin/mainat3f45b6cc1:@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.tsalone: 3 passed.typecheckon the three packages: exit 0. Both test layers compile, with no new debt.pnpm build --concurrency=2at62f989f1c: 72 of 72 tasks succeeded. That includes every package downstream of@objectstack/core(the...@objectstack/coreconsumer direction). So the two new root names collide with noexport *consumer (@objectstack/runtime,@objectstack/plugin-hono-server).62f989f1c.dispatch-gates --commandsderived 65 families. All 65 ran, and every exit code was captured before any pipe: 65 exit 0.--ranreconciles to "65 run, 0 NOT-MEASURED (a DERIVED zero)".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.Acceptance notes
GETnever delivers. This is outside this card, a different defect, so it is not fixed here. It is recorded for the seat.durable: truenode withmethod: 'GET'enqueuespayload: {}. The dispatcher's send then refuses a GET that has a body, withRequest with GET/HEAD method cannot have body..MessagingService,MemoryHttpOutboxandHttpDispatcherand a local receiver. After one tick the row showedstatus: pending,attempts: 1, that error, and the receiver had nothing.{}, not the empty body. That is moot while such a request cannot be sent.flow-credential-projection.tsdocblock. It says the durable arm handssigningSecretto 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.guard-refusal-inventory.test.tssits outsidebuiltin/. I read "http-nodes.tsand 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.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.Cross-lane
packages/corebelongs todomain:engine. The new exports, exactly:signHttpBodyandHTTP_SIGNATURE_HEADERon the@objectstack/coreroot, which@objectstack/runtimealso carries through itsexport *. No existing core export changed.Changeset
.changeset/20628-http-node-signs-both-arms.md:@objectstack/coreminor,@objectstack/service-automationand@objectstack/service-messagingpatch. It carries theClause-②: yes (widening)line.Generated by Claude Code