@openwop/openwop-conformance 1.136.10 → 1.138.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,7 @@
17
17
  * delivers body B'. Receiver MUST reject with
18
18
  * `signature_mismatch`.
19
19
  * 2. Tampered HMAC — body is valid; adversary flips a byte of the
20
- * v1=<hex> signature. Receiver MUST reject with
20
+ * sha256=<hex> signature. Receiver MUST reject with
21
21
  * `signature_mismatch`.
22
22
  * 3. Stale timestamp — body + HMAC are valid but timestamp is
23
23
  * older than the default 5-minute window. Receiver MUST reject
@@ -42,6 +42,7 @@ import {
42
42
  createReceiverState,
43
43
  signPayload,
44
44
  verifyWebhookDelivery,
45
+ SIGNATURE_PREFIX,
45
46
  } from '../lib/webhook-receiver.js';
46
47
 
47
48
  describe('webhook-receiver-adversarial: receiver rejects five canonical attacks', () => {
@@ -93,8 +94,13 @@ describe('webhook-receiver-adversarial: receiver rejects five canonical attacks'
93
94
  it('case 2: tampered HMAC → signature_mismatch', () => {
94
95
  const state = createReceiverState();
95
96
  const { signatureHeader, timestampHeader, algorithmHeader } = signPayload(secret, ts, body);
96
- // Flip one hex character of the v1=<hex> signature.
97
- const flipIndex = 5;
97
+ // Flip one hex character of the `sha256=<hex>` signature. Derived from
98
+ // SIGNATURE_PREFIX rather than hard-coded: the literal `5` was inside the
99
+ // hex only while the prefix was the (non-spec) `v1=`, and silently moved
100
+ // INTO the prefix when it was corrected to `sha256=` — turning a
101
+ // tampered-HMAC case into a malformed-header one. The test caught it; the
102
+ // constant is what makes it stay caught.
103
+ const flipIndex = SIGNATURE_PREFIX.length + 2;
98
104
  const orig = signatureHeader[flipIndex]!;
99
105
  const replacement = orig === '0' ? '1' : '0';
100
106
  const tampered = signatureHeader.slice(0, flipIndex) + replacement + signatureHeader.slice(flipIndex + 1);
@@ -7,7 +7,11 @@
7
7
  * 2. `X-openwop-Signature-Algorithm: v1` header is present.
8
8
  * 3. `X-openwop-Signature` is a valid HMAC-SHA256 of
9
9
  * `${timestamp}.${rawBody}` under the subscription secret.
10
- * 4. `X-openwop-Subscription-Id` matches the returned subscription id.
10
+ * 4. `X-openwop-Webhook-Id` matches the returned `webhookId`.
11
+ * (This line said `X-openwop-Subscription-Id` until 2026-08-19, two years
12
+ * after the assertion below stopped checking that header — a docblock
13
+ * asserting more than the code did, in the file that documents a
14
+ * security-relevant contract.)
11
15
  *
12
16
  * Capability-gated: skips when the host does not advertise
13
17
  * `capabilities.webhooks.supported = true`.
@@ -122,10 +126,23 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
122
126
 
123
127
  expect(reg.status, driver.describe(
124
128
  'webhooks.md §"Register"',
125
- 'POST /v1/webhooks MUST return 201 with subscriptionId + secret on success',
129
+ 'POST /v1/webhooks MUST return 201 with webhookId + secret on success',
126
130
  )).toBe(201);
127
- const sub = reg.json as { subscriptionId: string; secret: string };
128
- expect(typeof sub.subscriptionId).toBe('string');
131
+ // `webhookId`, NOT `subscriptionId` (corrected 2026-08-19). `webhooks.md`
132
+ // §"Register" shows `{"webhookId": "wh_a3b9c2", ...}`, `api/openapi.yaml`
133
+ // declares the 201 body `required: [webhookId]` with no `subscriptionId`
134
+ // property at all, and the sibling `webhook-tenant-isolation.test.ts` reads
135
+ // `webhookId`. This file required a field the contract does not define, so a
136
+ // SPEC-CONFORMING host failed at the first assertion — the suite being
137
+ // different from the spec, which is worse than the stricter-than-spec case
138
+ // COMPATIBILITY.md §2.3 forbids. Reported by a tier-2 host emitting exactly
139
+ // what the spec shows. The postgres reference host returns both names, which
140
+ // is why nothing went red here.
141
+ const sub = reg.json as { webhookId: string; secret: string };
142
+ expect(typeof sub.webhookId, driver.describe(
143
+ 'api/openapi.yaml registerWebhook 201',
144
+ 'the 201 body MUST carry `webhookId` (required) — `subscriptionId` is not in the contract',
145
+ )).toBe('string');
129
146
  expect(typeof sub.secret).toBe('string');
130
147
  expect(sub.secret.length).toBeGreaterThan(0);
131
148
 
@@ -174,7 +191,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
174
191
  expect(
175
192
  first.headers['x-openwop-webhook-id'],
176
193
  driver.describe('webhooks.md §"Delivery headers"', 'X-openwop-Webhook-Id MUST carry the subscription id'),
177
- ).toBe(sub.subscriptionId);
194
+ ).toBe(sub.webhookId);
178
195
 
179
196
  const timestamp = first.headers['x-openwop-timestamp'];
180
197
  expect(
@@ -201,7 +218,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
201
218
  expect(event.runId).toBe(runId);
202
219
 
203
220
  // Cleanup: unregister.
204
- const del = await driver.delete(`/v1/webhooks/${encodeURIComponent(sub.subscriptionId)}`);
221
+ const del = await driver.delete(`/v1/webhooks/${encodeURIComponent(sub.webhookId)}`);
205
222
  expect(del.status).toBeGreaterThanOrEqual(200);
206
223
  expect(del.status).toBeLessThan(300);
207
224
  });
package/src/setup.ts CHANGED
@@ -32,7 +32,7 @@ import { McpFakeServer, setMcpFakeServer } from './lib/mcp-fake-server.js';
32
32
  import { A2AFakePeer, setA2AFakePeer } from './lib/a2a-fake-peer.js';
33
33
  import { afterAll, afterEach, beforeAll, expect } from 'vitest';
34
34
  import { basename } from 'node:path';
35
- import { recordRequirement, journalLength, journalSince } from './lib/requirement-ledger.js';
35
+ import { recordRequirement, hasRequirement, journalLength, journalSince } from './lib/requirement-ledger.js';
36
36
  import { requirementIdForFile, resolveFileRecord, type FileTestState } from './lib/scenario-disposition.js';
37
37
  import { softSkipDisposition } from './lib/soft-skip.js';
38
38
  import type { DiscoveryPayload } from './lib/profiles.js';
@@ -279,10 +279,29 @@ afterAll(({}, suite) => {
279
279
  // honest bundle row and the pressure to say why both survive. The rule is
280
280
  // `resolveFileRecord` (pinned by conformance-execution-witness.test.ts).
281
281
  const { disposition, detail } = resolveFileRecord(states, gateReason, assertionCount, softSkipDisposition(file));
282
- try {
283
- recordRequirement(requirementIdForFile(file), disposition, detail, { assertionCount });
284
- } catch {
285
- /* a scenario that recorded its own file id first wins; never fail the file for bookkeeping */
282
+ const fileRequirementId = requirementIdForFile(file);
283
+ // A scenario that classified ITSELF wins outright — including its `detail` and
284
+ // its `assertionCount`.
285
+ //
286
+ // The `catch` below has always made the explicit record win when the two
287
+ // DISAGREE (`recordRequirement` throws on a conflicting disposition). It did
288
+ // not when they AGREE: the automatic call then reached `ledger.set` and
289
+ // silently replaced the scenario's own detail and count with the file-level
290
+ // ones. Invisible until 2026-08-19, when `resolveFileRecord` started attaching
291
+ // a `partial-witness:` marker — a scenario that recorded `executed-pass` for a
292
+ // requirement it really did exercise, in a file whose LAST leg soft-skipped,
293
+ // would have been stamped "may not have witnessed this" over its own explicit
294
+ // finding. That would inject false positives into exactly the measurement the
295
+ // marker exists to produce.
296
+ //
297
+ // So the comment describing this line was true of half the cases. It is true
298
+ // of both now.
299
+ if (!hasRequirement(fileRequirementId)) {
300
+ try {
301
+ recordRequirement(fileRequirementId, disposition, detail, { assertionCount });
302
+ } catch {
303
+ /* never fail a file for bookkeeping */
304
+ }
286
305
  }
287
306
  _fileStates.delete(file);
288
307
  _fileAssertions.delete(file);