Skip to content

Commit 6bb4d3b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20594-cli-dead-citations
2 parents 5b7745d + 0cb72cf commit 6bb4d3b

16 files changed

Lines changed: 533 additions & 36 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): `os migrate meta` guidance for the two padded list-view field-name entries states the contract-first rule in words, and the notification/embed retirement drops a sweep batch ordinal
6+
7+
Clause-②: no
8+
9+
The ADR-0087 semantic entries are printed by `os migrate meta` as the header, `why:` and
10+
`verify:` lines of a manual change. Two of them —
11+
`ui-list-view-grouping-field-padded-refused` and `ui-list-view-groupbyfield-padded-refused` —
12+
explained why a padded field name is refused rather than trimmed by pointing at a rule number
13+
in a contributor guide, a number that names nothing in this repository's guide. Their `why:`
14+
text now states the rule itself: fix the metadata, not the renderer — off-spec metadata is
15+
refused where it is authored, never coerced into working.
16+
17+
`ui-notification-action-embed-config-retired` named the batch of the v17 unknown-key
18+
strictness sweep that measured the two retired shapes by its ordinal. The sentence already
19+
says what that batch measured and decided, so the ordinal is dropped.
20+
21+
Text only: no entry id, `surface`, `from` / `to`, conversion or matching logic changes, and the
22+
chain rewrites exactly what it rewrote before. The generated migration registry,
23+
`spec-changes.json` and the protocol upgrade guide carry the same text.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/service-automation": patch
4+
"@objectstack/service-messaging": patch
5+
---
6+
7+
**A flow `http` node's `signingSecret` now signs the request on every arm, with one scheme, and a secret that does not resolve refuses the node instead of letting the request leave unsigned.**
8+
9+
`signingSecret` is declared as "HMAC-SHA256 secret → X-Objectstack-Signature", with no arm named. Only the durable arm honoured it, because only the messaging outbox signed. The default inline request, and a `durable: true` node on a host with no messaging HTTP outbox (which degrades to that inline request), were sent without the header while the run reported success.
10+
11+
- `@objectstack/core`: **new exports** `signHttpBody(body, secret)` and `HTTP_SIGNATURE_HEADER`, the outbound HTTP signature scheme: `X-Objectstack-Signature: sha256=<lowercase hex HMAC-SHA256 of the exact body bytes>`, where a request with no body is signed over the empty string. They were `@objectstack/service-messaging`'s own, and they moved here so a sender with no outbox can sign with the same code.
12+
- `@objectstack/service-messaging`: `signHttpBody` and `HTTP_SIGNATURE_HEADER` are still exported under the same names. They are now re-exports of the `@objectstack/core` bindings, not a second implementation. Delivery rows and the headers the outbox sends are unchanged.
13+
- `@objectstack/service-automation`: the `http` node's inline request carries `X-Objectstack-Signature` whenever `signingSecret` is set. It is computed over the exact body the node sends (its JSON serialization of `config.body`, or the empty string when there is none), so a receiver that verifies with `signHttpBody` over the bytes it received accepts it on every arm.
14+
- A non-empty `signingSecret` that renders to nothing at run time now fails the node with a guard refusal naming `config.signingSecret`, and nothing is sent. This covers a `{token}` with no value in the run, or one that renders the empty string. The refusal is on every arm, including the outbox arm, which used to enqueue such a delivery unsigned. A fault edge does not route it. The fix is to give the run the value the template reads.
15+
- An authored `signingSecret: ''` still sends unsigned on purpose, on every arm.
16+
17+
Clause-②: yes (widening) — two new exports on `@objectstack/core`'s root. Nothing is removed or renamed on any package. The one newly refused case is a node whose authored secret did not resolve, which the published contract already said signs.

‎docs/protocol-upgrade-guide.md‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { HTTP_SIGNATURE_HEADER, signHttpBody } from '../index.js';
5+
6+
/**
7+
* The scheme's wire form, pinned by literal values rather than by recomputing
8+
* an HMAC here: a test that rebuilt the value with the same `createHmac` call
9+
* would agree with any change made to both. Every sender and every receiver of
10+
* `X-Objectstack-Signature` relies on exactly these bytes.
11+
*/
12+
describe('the outbound HTTP signature scheme', () => {
13+
it('is carried in X-Objectstack-Signature', () => {
14+
expect(HTTP_SIGNATURE_HEADER).toBe('X-Objectstack-Signature');
15+
});
16+
17+
it('signs the empty body a bodyless request carries', () => {
18+
expect(signHttpBody('', 'flow-hook-secret')).toBe(
19+
'sha256=28c9179fd9763c0e7d41dc5241d9d77607270f8912e3b7692426f677bd5187f7',
20+
);
21+
});
22+
23+
it('is sha256= plus the lowercase hex HMAC-SHA256 of the exact body bytes', () => {
24+
expect(signHttpBody('{"a":1}', 'shh')).toBe(
25+
'sha256=dfb8cf3fc9778c70386e30f5e0776d37f9ee9c8756d3cbd7df0902150644358d',
26+
);
27+
// One byte of difference in the body is a different signature — the
28+
// receiver verifies over what it received, so a sender must sign what
29+
// it sends, not an equivalent re-serialization.
30+
expect(signHttpBody('{"a": 1}', 'shh')).not.toBe(signHttpBody('{"a":1}', 'shh'));
31+
});
32+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { createHmac } from 'node:crypto';
4+
5+
/**
6+
* The outbound HTTP signature scheme — the ONE definition every ObjectStack
7+
* sender signs with and every receiver verifies against.
8+
*
9+
* A request carries {@link HTTP_SIGNATURE_HEADER} whose value is
10+
* {@link signHttpBody} of the exact bytes of its body under the shared secret:
11+
* `sha256=` followed by the lowercase hex HMAC-SHA256. A request with no body is
12+
* signed over the empty string, which is what its receiver reads.
13+
*
14+
* It lives in `@objectstack/core` because two senders that cannot import each
15+
* other at runtime both sign with it: `@objectstack/service-messaging`'s durable
16+
* HTTP outbox (which signs at enqueue) and `@objectstack/service-automation`'s
17+
* flow `http` node, whose inline arm — and its durable arm's fallback when no
18+
* outbox is wired — calls `fetch` itself. `service-messaging` re-exports both
19+
* names unchanged, so its published surface still carries them. ⛔ Never a
20+
* second copy of this HMAC input anywhere: two copies are how one key comes to
21+
* mean two things on two arms.
22+
*
23+
* What the scheme does NOT own is WHICH bytes are the body — each sender signs
24+
* the serialization it actually sends.
25+
*/
26+
27+
/** Header carrying the HMAC-SHA256 signature of the request body. */
28+
export const HTTP_SIGNATURE_HEADER = 'X-Objectstack-Signature';
29+
30+
/**
31+
* Compute the {@link HTTP_SIGNATURE_HEADER} value for a body: `sha256=<hex>` of
32+
* `HMAC-SHA256(body, secret)`.
33+
*
34+
* The output is safe to persist (it is handed to the receiver on the wire
35+
* anyway); the `secret` argument is NOT.
36+
*/
37+
export function signHttpBody(body: string, secret: string): string {
38+
return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
39+
}

‎packages/core/src/security/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ export {
4848
type VerifyIntegrityResult,
4949
} from './plugin-artifact-integrity.js';
5050

51+
// The outbound HTTP signature scheme (`X-Objectstack-Signature`) — one
52+
// definition shared by the messaging outbox and the flow `http` node's inline
53+
// arm, which cannot import each other; `@objectstack/service-messaging`
54+
// re-exports both names unchanged.
55+
export { HTTP_SIGNATURE_HEADER, signHttpBody } from './http-signature.js';
56+
5157
// `PluginConfigValidator` / `createPluginConfigValidator` were RETIRED here on
5258
// 2026-08-27 (#11982, ADR-0049 enforce-or-remove; recorded in ADR-0025 §3.7).
5359
// The kernel never received a plugin's config to validate — factories close

0 commit comments

Comments
 (0)