@openwop/openwop-conformance 2.35.0 → 2.36.0

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.
Files changed (123) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -3
  3. package/coverage.md +13 -1
  4. package/dist/lib/certification-bundle-v3.js +21 -4
  5. package/dist/spec-artifacts.lock.json +2 -2
  6. package/fixtures/a2ui-v09/negative-event-name-deleteall.json +139 -0
  7. package/fixtures/a2ui-v09/negative-extra-property.json +140 -0
  8. package/fixtures/a2ui-v09/negative-foreign-catalog.json +139 -0
  9. package/fixtures/a2ui-v09/negative-formatstring-label.json +145 -0
  10. package/fixtures/a2ui-v09/negative-functioncall-openurl.json +129 -0
  11. package/fixtures/a2ui-v09/negative-image-component.json +145 -0
  12. package/fixtures/a2ui-v09/negative-textfield-obscured.json +140 -0
  13. package/fixtures/a2ui-v09/negative-theme-iconurl.json +142 -0
  14. package/fixtures/a2ui-v09/positive-approve-brief.json +139 -0
  15. package/fixtures/conformance-artifact-emit.json +28 -0
  16. package/fixtures/conformance-clarification-nested.json +53 -0
  17. package/fixtures/conformance-clarification-sensitive.json +49 -0
  18. package/fixtures/conformance-credential.json +25 -0
  19. package/fixtures/conformance-mcp-client.json +28 -0
  20. package/fixtures/interrupt-payloads/interrupt-payload-approval.json +1 -0
  21. package/fixtures/interrupt-payloads/interrupt-payload-clarification.json +1 -0
  22. package/fixtures/interrupt-payloads/interrupt-payload-conversation-close.json +1 -0
  23. package/fixtures/interrupt-payloads/interrupt-payload-conversation-exchange.json +1 -0
  24. package/fixtures/interrupt-payloads/interrupt-payload-conversation-start.json +1 -0
  25. package/fixtures/interrupt-payloads/interrupt-payload-custom.json +1 -0
  26. package/fixtures/interrupt-payloads/interrupt-payload-external-event.json +1 -0
  27. package/fixtures/interrupt-payloads/interrupt-payload-low-confidence.json +1 -0
  28. package/fixtures/interrupt-payloads/negative-approval-with-clarification-data.json +1 -0
  29. package/fixtures/interrupt-payloads/negative-conversation-close-with-conversation-start-data.json +1 -0
  30. package/fixtures/interrupt-payloads/negative-conversation-start-with-conversation-exchange-data.json +1 -0
  31. package/fixtures/interrupt-payloads/negative-custom-with-conversation-start-data.json +1 -0
  32. package/fixtures/interrupt-payloads/negative-low-confidence-with-custom-data.json +1 -0
  33. package/fixtures/node-pack-runtime/negative-entry-mismatch.json +31 -0
  34. package/fixtures/node-pack-runtime/negative-headers.json +37 -0
  35. package/fixtures/node-pack-runtime/negative-http.json +31 -0
  36. package/fixtures/node-pack-runtime/negative-icons.json +37 -0
  37. package/fixtures/node-pack-runtime/negative-meta.json +36 -0
  38. package/fixtures/node-pack-runtime/negative-non-remote-language.json +31 -0
  39. package/fixtures/node-pack-runtime/negative-packages.json +41 -0
  40. package/fixtures/node-pack-runtime/negative-sse.json +31 -0
  41. package/fixtures/node-pack-runtime/negative-templated-url.json +31 -0
  42. package/fixtures/node-pack-runtime/negative-two-remotes.json +35 -0
  43. package/fixtures/node-pack-runtime/negative-variables.json +38 -0
  44. package/fixtures/node-pack-runtime/negative-version-range.json +31 -0
  45. package/fixtures/node-pack-runtime/positive-every-optional-member.json +40 -0
  46. package/fixtures/node-pack-runtime/positive-rfc-example.json +31 -0
  47. package/fixtures/node-pack-runtime/upstream/mcp-registry-server-2025-12-11.schema.json +574 -0
  48. package/fixtures/oauth-providers/synthetic.json +36 -4
  49. package/fixtures/upstream/a2a-v1.0.1/README.md +24 -0
  50. package/fixtures/upstream/a2a-v1.0.1/a2a.proto +811 -0
  51. package/fixtures/upstream/a2ui-v0.9/README.md +33 -0
  52. package/fixtures/upstream/a2ui-v0.9/catalog.json +1383 -0
  53. package/fixtures/upstream/a2ui-v0.9/common_types.json +305 -0
  54. package/fixtures/upstream/a2ui-v0.9/server_to_client.json +132 -0
  55. package/fixtures.md +139 -0
  56. package/package.json +2 -2
  57. package/requirements.json +4480 -97
  58. package/scenario-majors.json +93 -6
  59. package/schemas/CORPUS-STAMP.json +103 -89
  60. package/src/lib/behavior-gate.ts +7 -1
  61. package/src/lib/certification-bundle-v3.ts +18 -4
  62. package/src/lib/localized-content.ts +53 -0
  63. package/src/lib/mcp-fake-server.ts +105 -4
  64. package/src/lib/node-pack-runtime.ts +41 -0
  65. package/src/lib/oauth-as-double.ts +316 -0
  66. package/src/lib/protected-resource.ts +88 -0
  67. package/src/lib/standard-webhooks.ts +120 -0
  68. package/src/lib/toolCatalog.ts +54 -4
  69. package/src/lib/trace-context.ts +98 -0
  70. package/src/lib/v2.ts +6 -0
  71. package/src/lib/webhook-receiver.ts +201 -1
  72. package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +10 -1
  73. package/src/scenarios/auth-challenge-no-oracle.test.ts +122 -0
  74. package/src/scenarios/auth-oauth2-client-credentials.test.ts +46 -5
  75. package/src/scenarios/cross-host-traceparent-propagation.test.ts +92 -58
  76. package/src/scenarios/debugBundle.test.ts +41 -0
  77. package/src/scenarios/fixtures-valid.test.ts +72 -0
  78. package/src/scenarios/inbound-credential-no-passthrough.test.ts +115 -0
  79. package/src/scenarios/interrupt-approver-routing.test.ts +3 -0
  80. package/src/scenarios/localized-content-delivery.test.ts +26 -15
  81. package/src/scenarios/mcp-current-auth-boundary.test.ts +38 -0
  82. package/src/scenarios/mcp-tool-roundtrip.test.ts +29 -0
  83. package/src/scenarios/otel-mcp-semconv-projection.test.ts +85 -0
  84. package/src/scenarios/tool-catalog-compact-projection.test.ts +28 -14
  85. package/src/scenarios/tool-catalog-projection.test.ts +48 -12
  86. package/src/scenarios/tool-descriptor-shape.test.ts +34 -13
  87. package/src/scenarios/v2-a2a-agent-cards.test.ts +305 -0
  88. package/src/scenarios/v2-a2a-operation-map.test.ts +310 -0
  89. package/src/scenarios/v2-a2ui-v09-surface.test.ts +283 -0
  90. package/src/scenarios/v2-advertised-fixtures-exist.test.ts +2 -2
  91. package/src/scenarios/v2-artifact-a2a-shape.test.ts +122 -0
  92. package/src/scenarios/v2-auth-challenge.test.ts +119 -0
  93. package/src/scenarios/v2-bound-id-kinds.test.ts +1 -1
  94. package/src/scenarios/v2-callback-url-guarded.test.ts +2 -2
  95. package/src/scenarios/v2-capability-maturity-bounded.test.ts +108 -0
  96. package/src/scenarios/v2-configurable-closed.test.ts +1 -1
  97. package/src/scenarios/v2-content-locale-keys.test.ts +92 -0
  98. package/src/scenarios/v2-conversation-turn-parts.test.ts +74 -0
  99. package/src/scenarios/v2-credential-interrupt.test.ts +253 -0
  100. package/src/scenarios/v2-effect-identity-business-key.test.ts +2 -2
  101. package/src/scenarios/v2-fork-a-v1-run.test.ts +1 -1
  102. package/src/scenarios/v2-implementation-informational.test.ts +33 -0
  103. package/src/scenarios/v2-interop-trace-context.test.ts +351 -0
  104. package/src/scenarios/v2-lane-exp-only-bound.test.ts +236 -0
  105. package/src/scenarios/v2-lane-issuer-advertised.test.ts +52 -3
  106. package/src/scenarios/v2-mcp-client-results.test.ts +202 -0
  107. package/src/scenarios/v2-mcp-mount-map.test.ts +308 -0
  108. package/src/scenarios/v2-mcp-tasks.test.ts +503 -0
  109. package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +203 -0
  110. package/src/scenarios/v2-oauth-mcp-reach-discovery.test.ts +186 -0
  111. package/src/scenarios/v2-oidc-id-token-audience.test.ts +108 -0
  112. package/src/scenarios/v2-protected-resource-metadata.test.ts +145 -0
  113. package/src/scenarios/v2-run-cancel.test.ts +1 -1
  114. package/src/scenarios/v2-run-fork-prefix.test.ts +4 -4
  115. package/src/scenarios/v2-run-pause-resume.test.ts +1 -1
  116. package/src/scenarios/v2-tool-catalog-annotations.test.ts +100 -0
  117. package/src/scenarios/v2-unmapped-type-refused.test.ts +1 -1
  118. package/src/scenarios/v2-v1-events-translated.test.ts +1 -1
  119. package/src/scenarios/v2-webhook-egress-refusal.test.ts +5 -5
  120. package/src/scenarios/v2-webhook-endpoint-verification.test.ts +124 -0
  121. package/src/scenarios/v2-webhook-message-id-stable.test.ts +124 -0
  122. package/src/scenarios/v2-webhook-secret-rotation.test.ts +132 -0
  123. package/src/scenarios/v2-webhook-standard-webhooks-delivery.test.ts +195 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * RFC 0201 §E — multi-signature secret rotation (suite 2.36.0, target major 2;
3
+ * gated on `webhooks.signatureAlgorithms` listing `standard-webhooks-1` AND the
4
+ * `webhooks.secretRotation` facet).
5
+ *
6
+ * After `POST /webhooks/{webhookId}/rotate-secret`, for `overlapSeconds`, every
7
+ * delivery's `webhook-signature` MUST carry exactly one entry under the new
8
+ * secret and one under the previous secret, and `OpenWOP-Signature` stays on the
9
+ * previous secret; afterwards only the new secret signs. The route is tenant-
10
+ * checked before lookup (`403 id_tenant_mismatch`), refuses a subscription that
11
+ * did not opt in (`400 validation_error`), and returns no secret.
12
+ *
13
+ * The post-overlap leg waits for `previousSecretExpiresAt` only when
14
+ * `overlapSeconds` fits inside the suite's retry-wait cap
15
+ * (`OPENWOP_WEBHOOK_RETRY_WAIT_MS`, operator-raisable). Past the cap it is not
16
+ * observed, and the row says so with a `partial-witness:` detail — which the
17
+ * acceptance predicate refuses to cite as `executed-pass` (RFC 0201 §Falsifiability).
18
+ *
19
+ * How it FAILS: signing only with the new secret (one entry); moving
20
+ * `OpenWOP-Signature` to the new secret at rotation; a foreign-tenant id that
21
+ * reaches the lookup (`404` instead of `403`); a response that echoes the secret.
22
+ *
23
+ * @see spec/v2/core/webhooks.md §Standard Webhooks
24
+ * @see RFCS/0201-standard-webhooks-signature-scheme.md §E
25
+ */
26
+
27
+ import { afterEach, describe, it, expect } from 'vitest';
28
+ import { createHmac } from 'node:crypto';
29
+ import { driver } from '../lib/driver.js';
30
+ import { req } from '../lib/requirement-ids.js';
31
+ import { readErrorCode } from '../lib/error-envelope.js';
32
+ import { softSkip } from '../lib/soft-skip.js';
33
+ import { projectBoundId } from '../lib/bound-id.js';
34
+ import { retryWaitCapMs } from '../lib/webhook-retry-window.js';
35
+ import { hitHeader, mintWhsec, startModalReceiver, verifyStandardWebhooks, type ModalHit } from '../lib/webhook-receiver.js';
36
+ import {
37
+ STANDARD_WEBHOOKS_ALG,
38
+ deliveriesFor,
39
+ driveRun,
40
+ loopbackRefusal,
41
+ registerSw,
42
+ swGate,
43
+ unregisterAllSw,
44
+ waitFor,
45
+ } from '../lib/standard-webhooks.js';
46
+
47
+ const CAP_MS = retryWaitCapMs();
48
+
49
+ let closeReceiver: (() => Promise<void>) | null = null;
50
+ afterEach(async () => {
51
+ await unregisterAllSw();
52
+ if (closeReceiver) { const c = closeReceiver; closeReceiver = null; await c(); }
53
+ });
54
+
55
+ function v1SignedBy(secret: string, h: ModalHit): boolean {
56
+ const ts = hitHeader(h, 'openwop-timestamp');
57
+ return ts !== undefined && hitHeader(h, 'openwop-signature') === `sha256=${createHmac('sha256', secret).update(`${ts}.${h.body}`, 'utf8').digest('hex')}`;
58
+ }
59
+
60
+ async function oneDelivery(rx: { hits: ModalHit[] }, webhookId: string): Promise<{ runStatus: number; delivery: ModalHit | undefined }> {
61
+ const run = await driveRun();
62
+ await waitFor(() => deliveriesFor(rx.hits, webhookId, run.runId).length > 0, 15_000);
63
+ return { runStatus: run.status, delivery: deliveriesFor(rx.hits, webhookId, run.runId)[0] };
64
+ }
65
+
66
+ describe('RFC 0201 §E — secret rotation overlaps, then retires (gated on webhooks.secretRotation)', () => {
67
+ it('a rotation dual-signs for overlapSeconds, keeps OpenWOP-Signature on the previous secret, and is tenant-checked', async () => {
68
+ const g = await swGate();
69
+ if (!g.ok) return softSkip(g.kind, g.reason);
70
+ const rot = g.facet['secretRotation'] as { overlapSeconds?: unknown } | undefined;
71
+ if (!rot || typeof rot.overlapSeconds !== 'number') return softSkip('inapplicable', 'webhooks.secretRotation not advertised (RFC 0201 §E.18) — rotateWebhookSecret is not offered');
72
+ const overlapSeconds = rot.overlapSeconds;
73
+ const rx = await startModalReceiver();
74
+ closeReceiver = rx.close;
75
+ const target = rx.urlFor('echo');
76
+ const s1 = mintWhsec();
77
+ const s2 = mintWhsec();
78
+ const reg = await registerSw({ url: target.url, events: ['run.completed'], signatureAlgorithms: ['v1', STANDARD_WEBHOOKS_ALG], secret: s1 });
79
+ const blocked = loopbackRefusal(reg, target.tunnelled);
80
+ if (blocked) return softSkip('blocked', blocked);
81
+ if (reg.status !== 201) return softSkip('blocked', `the opt-in registration answered ${reg.status} ${readErrorCode(reg.json) ?? ''} — nothing to rotate`);
82
+ const webhookId = (reg.json as { webhookId: string }).webhookId;
83
+ const plain = await registerSw({ url: rx.urlFor('no-echo').url, events: ['run.completed'] });
84
+
85
+ const ID = 'openwop.requirement.0201.secret-rotation';
86
+ // Tenant check BEFORE lookup: a well-formed id in a tenant that is not the
87
+ // caller's, which therefore cannot exist for this caller.
88
+ const ownTenant = webhookId.slice(0, webhookId.indexOf('/'));
89
+ const foreign = `${ownTenant === 'openwop-conformance-foreign' ? 'openwop-conformance-other' : 'openwop-conformance-foreign'}/${'a'.repeat(22)}`;
90
+ const cross = await driver.post(`/webhooks/${projectBoundId(foreign)}/rotate-secret`, { secret: s2 });
91
+ expect(cross.status, req(ID, 'RFC 0201 §E.18 / identity.md §5', `a foreign-tenant webhookId MUST be refused 403 before the lookup (got ${cross.status})`)).toBe(403);
92
+ expect(readErrorCode(cross.json), req(ID, 'RFC 0201 §E.18', 'the foreign-tenant refusal MUST carry id_tenant_mismatch')).toBe('id_tenant_mismatch');
93
+ if (plain.status === 201) {
94
+ const plainId = (plain.json as { webhookId: string }).webhookId;
95
+ const notOpted = await driver.post(`/webhooks/${projectBoundId(plainId)}/rotate-secret`, { secret: s2 });
96
+ expect(notOpted.status, req(ID, 'RFC 0201 §E.18', `rotating a subscription that did not opt in MUST be refused 400 validation_error (got ${notOpted.status})`)).toBe(400);
97
+ }
98
+
99
+ const rotated = await driver.post(`/webhooks/${projectBoundId(webhookId)}/rotate-secret`, { secret: s2 });
100
+ expect(rotated.status, req(ID, 'RFC 0201 §E.18', `rotateWebhookSecret MUST answer 200 on an opted-in subscription (got ${rotated.status} ${readErrorCode(rotated.json) ?? ''})`)).toBe(200);
101
+ const body = rotated.json as { rotatedAt?: string; previousSecretExpiresAt?: string };
102
+ const span = Date.parse(body.previousSecretExpiresAt ?? '') - Date.parse(body.rotatedAt ?? '');
103
+ expect(Math.abs(span - overlapSeconds * 1000) <= 1000, req(ID, 'RFC 0201 §E.19', `previousSecretExpiresAt MUST be rotatedAt + overlapSeconds (${overlapSeconds}s; got ${span}ms)`)).toBe(true);
104
+ expect(rotated.text.includes(s2) || rotated.text.includes(s2.slice(6)), req(ID, 'RFC 0201 §E.19', 'the rotation response MUST carry no secret')).toBe(false);
105
+
106
+ const during = await oneDelivery(rx, webhookId);
107
+ expect(during.runStatus, req(ID, 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
108
+ expect(during.delivery, req(ID, 'webhooks.md §Durability', 'the rotated subscription MUST keep receiving deliveries')).toBeDefined();
109
+ const d = during.delivery!;
110
+ const underNew = verifyStandardWebhooks(d.body, d.headers, s2);
111
+ const underOld = verifyStandardWebhooks(d.body, d.headers, s1);
112
+ expect(underNew.entries.length, req(ID, 'RFC 0201 §E.20', `during the overlap webhook-signature MUST carry exactly two entries (got ${underNew.entries.length})`)).toBe(2);
113
+ expect(underNew.matched, req(ID, 'RFC 0201 §E.20', 'one entry MUST verify under the new secret')).toBe(1);
114
+ expect(underOld.matched, req(ID, 'RFC 0201 §E.20', 'one entry MUST verify under the previous secret')).toBe(1);
115
+ expect(v1SignedBy(s1, d), req(ID, 'RFC 0201 §E.20', 'OpenWOP-Signature MUST stay on the previous secret during the overlap')).toBe(true);
116
+
117
+ // After the overlap: only when it fits the operator's wait cap.
118
+ const expiresAt = Date.parse(body.previousSecretExpiresAt ?? '');
119
+ if (expiresAt - Date.now() > CAP_MS) {
120
+ return softSkip('inapplicable', `the post-overlap leg is not observed: overlapSeconds (${overlapSeconds}) exceeds the suite wait cap (${CAP_MS}ms; raise OPENWOP_WEBHOOK_RETRY_WAIT_MS to witness it)`);
121
+ }
122
+ await new Promise((r) => setTimeout(r, Math.max(0, expiresAt - Date.now()) + 1_500));
123
+ const after = await oneDelivery(rx, webhookId);
124
+ expect(after.delivery, req(ID, 'webhooks.md §Durability', 'the subscription MUST keep receiving deliveries after the overlap')).toBeDefined();
125
+ const a = after.delivery!;
126
+ const aNew = verifyStandardWebhooks(a.body, a.headers, s2);
127
+ expect(aNew.entries.length, req(ID, 'RFC 0201 §E.20', `after previousSecretExpiresAt webhook-signature MUST carry one entry (got ${aNew.entries.length})`)).toBe(1);
128
+ expect(aNew.matched, req(ID, 'RFC 0201 §E.20', 'the one entry MUST verify under the new secret')).toBe(1);
129
+ expect(verifyStandardWebhooks(a.body, a.headers, s1).matched, req(ID, 'RFC 0201 §E.20', 'the previous secret MUST NOT sign anything after previousSecretExpiresAt')).toBe(0);
130
+ expect(v1SignedBy(s2, a), req(ID, 'RFC 0201 §E.20', 'OpenWOP-Signature MUST move to the new secret at previousSecretExpiresAt')).toBe(true);
131
+ }, CAP_MS + 90_000);
132
+ });
@@ -0,0 +1,195 @@
1
+ /**
2
+ * RFC 0201 §A–§C — the Standard Webhooks companion scheme on the wire
3
+ * (suite 2.36.0, target major 2; gated on `webhooks.signatureAlgorithms`
4
+ * listing `standard-webhooks-1`).
5
+ *
6
+ * Three legs, one requirement id each:
7
+ *
8
+ * 1. `opt-in-only` — a registration WITHOUT `signatureAlgorithms`, aimed at a
9
+ * receiver that never echoes a challenge, still gets `201`; the receiver
10
+ * sees no verification request; its deliveries carry no `webhook-*`
11
+ * header. A host that verifies, or adds the headers for, everyone fails.
12
+ * 2. `registration-validated` — the opt-in is checked (no `v1`, a repeated
13
+ * value, an unadvertised id, a missing / non-`whsec_` / 8-byte secret are
14
+ * each `400 validation_error`), a valid opt-in's `201` echoes the applied
15
+ * list, and no response body carries the secret or its key bytes.
16
+ * 3. `delivery-signed` — an opted-in delivery carries the five `OpenWOP-*`
17
+ * headers verifying under scheme `v1` with `OpenWOP-Signature-Algorithm:
18
+ * v1`, AND a `webhook-signature` entry verifying over
19
+ * `{webhook-id}.{webhook-timestamp}.{rawBody}` with the decoded secret, a
20
+ * `webhook-timestamp` equal to `OpenWOP-Timestamp`, and a `webhook-id`
21
+ * that is not the subscription id. The verifier is the suite's own
22
+ * (`webhook-receiver.ts`), pinned to the upstream library's test vector.
23
+ *
24
+ * How each FAILS (the sabotage run before citing a row):
25
+ * (a) webhook-* added to every subscription → leg 1
26
+ * (b) verification run for a non-opted registration → leg 1 (no-echo ⇒ 400)
27
+ * (c) `webhook-signature` computed over `ts.body` → leg 3
28
+ * (d) `standard-webhooks-1` in OpenWOP-Signature-Algorithm → leg 3
29
+ * (e) webhook-timestamp ≠ OpenWOP-Timestamp → leg 3
30
+ * (f) `["standard-webhooks-1"]` accepted without `v1` → leg 2
31
+ *
32
+ * @see spec/v2/core/webhooks.md §Standard Webhooks
33
+ * @see RFCS/0201-standard-webhooks-signature-scheme.md §B, §C
34
+ */
35
+
36
+ import { afterEach, describe, it, expect } from 'vitest';
37
+ import { createHmac } from 'node:crypto';
38
+ import { req } from '../lib/requirement-ids.js';
39
+ import { readErrorCode } from '../lib/error-envelope.js';
40
+ import { softSkip } from '../lib/soft-skip.js';
41
+ import {
42
+ decodeWhsec,
43
+ hitHeader,
44
+ mintWhsec,
45
+ startModalReceiver,
46
+ STANDARD_WEBHOOKS_ID,
47
+ verifyStandardWebhooks,
48
+ } from '../lib/webhook-receiver.js';
49
+ import {
50
+ STANDARD_WEBHOOKS_ALG,
51
+ deliveriesFor,
52
+ driveRun,
53
+ loopbackRefusal,
54
+ registerSw,
55
+ standardWebhooksHeaderNames,
56
+ swGate,
57
+ unregisterAllSw,
58
+ waitFor,
59
+ } from '../lib/standard-webhooks.js';
60
+
61
+ const DELIVERY_WAIT_MS = 15_000;
62
+
63
+ let closeReceiver: (() => Promise<void>) | null = null;
64
+ afterEach(async () => {
65
+ await unregisterAllSw();
66
+ if (closeReceiver) { const c = closeReceiver; closeReceiver = null; await c(); }
67
+ });
68
+
69
+ function v1Verifies(secret: string, h: { headers: Record<string, string | string[] | undefined>; body: string }): boolean {
70
+ const ts = hitHeader(h, 'openwop-timestamp');
71
+ const sig = hitHeader(h, 'openwop-signature');
72
+ if (ts === undefined || sig === undefined) return false;
73
+ return sig === `sha256=${createHmac('sha256', secret).update(`${ts}.${h.body}`, 'utf8').digest('hex')}`;
74
+ }
75
+
76
+ describe('RFC 0201 — Standard Webhooks companion scheme (gated on standard-webhooks-1)', () => {
77
+ it('a subscription that did not opt in is neither verified nor given webhook-* headers', async () => {
78
+ const g = await swGate();
79
+ if (!g.ok) return softSkip(g.kind, g.reason);
80
+ const rx = await startModalReceiver();
81
+ closeReceiver = rx.close;
82
+ const target = rx.urlFor('no-echo');
83
+ const reg = await registerSw({ url: target.url, events: ['run.completed'] });
84
+ const blockedReg = loopbackRefusal(reg, target.tunnelled);
85
+ if (blockedReg) return softSkip('blocked', blockedReg);
86
+ const ID = 'openwop.requirement.0201.opt-in-only';
87
+ expect(
88
+ reg.status,
89
+ req(ID, 'RFC 0201 §B.8 / §D.15', `a registration without signatureAlgorithms MUST NOT be verified — against an endpoint that never echoes a challenge it MUST still answer 201 (got ${reg.status} ${readErrorCode(reg.json) ?? ''})`),
90
+ ).toBe(201);
91
+ const webhookId = (reg.json as { webhookId: string }).webhookId;
92
+ const run = await driveRun();
93
+ expect(run.status, req(ID, 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
94
+ await waitFor(() => deliveriesFor(rx.hits, webhookId, run.runId).length > 0, DELIVERY_WAIT_MS);
95
+ expect(
96
+ rx.hits.filter((h) => h.mode === 'no-echo' && h.verification).length,
97
+ req(ID, 'RFC 0201 §D.15', 'the receiver MUST see no verification request for a subscription that did not opt in'),
98
+ ).toBe(0);
99
+ const ours = deliveriesFor(rx.hits, webhookId, run.runId);
100
+ expect(ours.length, req(ID, 'webhooks.md §Durability', 'the host MUST deliver run.completed for this run to the registered subscriber (a leg with no delivery would witness nothing)')).toBeGreaterThan(0);
101
+ for (const d of ours) {
102
+ expect(
103
+ standardWebhooksHeaderNames(d),
104
+ req(ID, 'RFC 0201 §B.8', 'a delivery to a subscription that did not opt in MUST NOT carry any webhook-* header'),
105
+ ).toEqual([]);
106
+ expect(hitHeader(d, 'openwop-signature-algorithm'), req(ID, 'webhooks.md §Headers', 'OpenWOP-Signature-Algorithm stays v1')).toBe('v1');
107
+ }
108
+ }, 45_000);
109
+
110
+ it('the opt-in is validated, its 201 echoes the applied list, and no response carries the secret', async () => {
111
+ const g = await swGate();
112
+ if (!g.ok) return softSkip(g.kind, g.reason);
113
+ const rx = await startModalReceiver();
114
+ closeReceiver = rx.close;
115
+ const target = rx.urlFor('echo');
116
+ const secret = mintWhsec();
117
+ const base = { url: target.url, events: ['run.completed'] };
118
+ const ID = 'openwop.requirement.0201.registration-validated';
119
+ const refusals: Array<[string, Record<string, unknown>]> = [
120
+ ['standard-webhooks-1 without v1', { ...base, signatureAlgorithms: [STANDARD_WEBHOOKS_ALG], secret }],
121
+ ['a repeated value', { ...base, signatureAlgorithms: ['v1', 'v1', STANDARD_WEBHOOKS_ALG], secret }],
122
+ ['an id the host does not list', { ...base, signatureAlgorithms: ['v1', 'openwop-conformance-unlisted-9'], secret }],
123
+ ['an opt-in with no secret', { ...base, signatureAlgorithms: ['v1', STANDARD_WEBHOOKS_ALG] }],
124
+ ['a secret not in whsec_ form', { ...base, signatureAlgorithms: ['v1', STANDARD_WEBHOOKS_ALG], secret: 'plain-shared-secret-of-sufficient-length' }],
125
+ ['a whsec_ secret of 8 bytes', { ...base, signatureAlgorithms: ['v1', STANDARD_WEBHOOKS_ALG], secret: `whsec_${Buffer.alloc(8, 7).toString('base64')}` }],
126
+ ];
127
+ const bodies: string[] = [];
128
+ for (const [label, body] of refusals) {
129
+ const res = await registerSw(body);
130
+ const blockedRes = loopbackRefusal(res, target.tunnelled);
131
+ if (blockedRes) return softSkip('blocked', blockedRes);
132
+ bodies.push(res.text);
133
+ expect(res.status, req(ID, 'RFC 0201 §B.5–§B.6', `${label} MUST be refused 400 validation_error (got ${res.status})`)).toBe(400);
134
+ expect(readErrorCode(res.json), req(ID, 'RFC 0201 §B.5–§B.6', `${label} MUST carry the code validation_error`)).toBe('validation_error');
135
+ }
136
+ const ok = await registerSw({ ...base, signatureAlgorithms: ['v1', STANDARD_WEBHOOKS_ALG], secret });
137
+ const blockedOk = loopbackRefusal(ok, target.tunnelled);
138
+ if (blockedOk) return softSkip('blocked', blockedOk);
139
+ bodies.push(ok.text);
140
+ expect(ok.status, req(ID, 'RFC 0201 §B / §D.14', `a valid opt-in against an echoing endpoint MUST answer 201 (got ${ok.status} ${readErrorCode(ok.json) ?? ''})`)).toBe(201);
141
+ const applied = (ok.json as { signatureAlgorithms?: unknown } | null)?.signatureAlgorithms;
142
+ expect(
143
+ Array.isArray(applied) ? [...(applied as string[])].sort() : applied,
144
+ req(ID, 'RFC 0201 §B.7', 'the 201 MUST carry signatureAlgorithms — the list the dispatcher will apply'),
145
+ ).toEqual(['standard-webhooks-1', 'v1']);
146
+ const key = decodeWhsec(secret)!;
147
+ const leaks = [secret, secret.slice('whsec_'.length), key.toString('hex'), key.toString('base64url')];
148
+ for (const b of bodies) {
149
+ for (const l of leaks) {
150
+ expect(b.includes(l), req(ID, 'RFC 0201 §B.6', 'the host MUST NOT return the supplied secret (or its key bytes) in any response')).toBe(false);
151
+ }
152
+ }
153
+ }, 45_000);
154
+
155
+ it('an opted-in delivery is dual-signed and its Standard Webhooks signature covers the id', async () => {
156
+ const g = await swGate();
157
+ if (!g.ok) return softSkip(g.kind, g.reason);
158
+ const rx = await startModalReceiver();
159
+ closeReceiver = rx.close;
160
+ const target = rx.urlFor('echo');
161
+ const secret = mintWhsec();
162
+ const reg = await registerSw({ url: target.url, events: ['run.completed'], signatureAlgorithms: ['v1', STANDARD_WEBHOOKS_ALG], secret });
163
+ const blockedReg = loopbackRefusal(reg, target.tunnelled);
164
+ if (blockedReg) return softSkip('blocked', blockedReg);
165
+ const ID = 'openwop.requirement.0201.delivery-signed';
166
+ expect(reg.status, req(ID, 'RFC 0201 §B', `the opt-in registration MUST answer 201 (got ${reg.status} ${readErrorCode(reg.json) ?? ''})`)).toBe(201);
167
+ const webhookId = (reg.json as { webhookId: string }).webhookId;
168
+ const run = await driveRun();
169
+ expect(run.status, req(ID, 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
170
+ await waitFor(() => deliveriesFor(rx.hits, webhookId, run.runId).length > 0, DELIVERY_WAIT_MS);
171
+ const ours = deliveriesFor(rx.hits, webhookId, run.runId);
172
+ expect(ours.length, req(ID, 'webhooks.md §Durability', 'the host MUST deliver run.completed for this run to the opted-in subscriber')).toBeGreaterThan(0);
173
+ const d = ours[0]!;
174
+ expect(hitHeader(d, 'openwop-signature-algorithm'), req(ID, 'RFC 0201 §A.2', 'OpenWOP-Signature-Algorithm MUST stay v1 on an opted-in delivery (never standard-webhooks-1)')).toBe('v1');
175
+ for (const h of ['openwop-webhook-id', 'openwop-event-type', 'openwop-timestamp', 'openwop-signature']) {
176
+ expect(hitHeader(d, h), req(ID, 'RFC 0201 §C.9', `every header the host sends today MUST still be sent — ${h}`)).toBeDefined();
177
+ }
178
+ expect(v1Verifies(secret, d), req(ID, 'RFC 0201 §C.9', 'OpenWOP-Signature MUST verify under scheme v1 keyed by the secret string as issued')).toBe(true);
179
+ const wid = hitHeader(d, 'webhook-id');
180
+ expect(wid !== undefined && STANDARD_WEBHOOKS_ID.test(wid), req(ID, 'RFC 0201 §C.10', `webhook-id MUST match ^[A-Za-z0-9_-]{16,128}$ (got ${String(wid)})`)).toBe(true);
181
+ expect(wid, req(ID, 'RFC 0201 §C.11', 'webhook-id MUST NOT be the subscription id carried in OpenWOP-Webhook-Id')).not.toBe(hitHeader(d, 'openwop-webhook-id'));
182
+ expect(hitHeader(d, 'webhook-timestamp'), req(ID, 'RFC 0201 §C.9', 'webhook-timestamp MUST equal OpenWOP-Timestamp')).toBe(hitHeader(d, 'openwop-timestamp'));
183
+ const verdict = verifyStandardWebhooks(d.body, d.headers, secret);
184
+ expect(
185
+ verdict.matched,
186
+ req(ID, 'RFC 0201 §C.9', `at least one webhook-signature entry MUST verify over {webhook-id}.{webhook-timestamp}.{rawBody} with the decoded secret (verifier: ${verdict.reason ?? 'ok'}; ${verdict.entries.length} entr${verdict.entries.length === 1 ? 'y' : 'ies'})`),
187
+ ).toBeGreaterThan(0);
188
+ // The control that separates "signs the id" from "signs ts.body": the
189
+ // verifier recomputes over the id, so a host that left the id out of the
190
+ // signed bytes already failed above. Recompute with a different id and
191
+ // confirm it does NOT match, so the pass above is attributable to the id.
192
+ const forged = verifyStandardWebhooks(d.body, { ...d.headers, 'webhook-id': `${wid ?? ''}X` }, secret);
193
+ expect(forged.matched, req(ID, 'RFC 0201 §C.9', 'the signature MUST cover webhook-id — recomputing with a different id MUST NOT match')).toBe(0);
194
+ }, 45_000);
195
+ });