@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.
- package/CHANGELOG.md +27 -0
- package/README.md +3 -3
- package/coverage.md +13 -1
- package/dist/lib/certification-bundle-v3.js +21 -4
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/a2ui-v09/negative-event-name-deleteall.json +139 -0
- package/fixtures/a2ui-v09/negative-extra-property.json +140 -0
- package/fixtures/a2ui-v09/negative-foreign-catalog.json +139 -0
- package/fixtures/a2ui-v09/negative-formatstring-label.json +145 -0
- package/fixtures/a2ui-v09/negative-functioncall-openurl.json +129 -0
- package/fixtures/a2ui-v09/negative-image-component.json +145 -0
- package/fixtures/a2ui-v09/negative-textfield-obscured.json +140 -0
- package/fixtures/a2ui-v09/negative-theme-iconurl.json +142 -0
- package/fixtures/a2ui-v09/positive-approve-brief.json +139 -0
- package/fixtures/conformance-artifact-emit.json +28 -0
- package/fixtures/conformance-clarification-nested.json +53 -0
- package/fixtures/conformance-clarification-sensitive.json +49 -0
- package/fixtures/conformance-credential.json +25 -0
- package/fixtures/conformance-mcp-client.json +28 -0
- package/fixtures/interrupt-payloads/interrupt-payload-approval.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-clarification.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-close.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-exchange.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-start.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-custom.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-external-event.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-low-confidence.json +1 -0
- package/fixtures/interrupt-payloads/negative-approval-with-clarification-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-conversation-close-with-conversation-start-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-conversation-start-with-conversation-exchange-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-custom-with-conversation-start-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-low-confidence-with-custom-data.json +1 -0
- package/fixtures/node-pack-runtime/negative-entry-mismatch.json +31 -0
- package/fixtures/node-pack-runtime/negative-headers.json +37 -0
- package/fixtures/node-pack-runtime/negative-http.json +31 -0
- package/fixtures/node-pack-runtime/negative-icons.json +37 -0
- package/fixtures/node-pack-runtime/negative-meta.json +36 -0
- package/fixtures/node-pack-runtime/negative-non-remote-language.json +31 -0
- package/fixtures/node-pack-runtime/negative-packages.json +41 -0
- package/fixtures/node-pack-runtime/negative-sse.json +31 -0
- package/fixtures/node-pack-runtime/negative-templated-url.json +31 -0
- package/fixtures/node-pack-runtime/negative-two-remotes.json +35 -0
- package/fixtures/node-pack-runtime/negative-variables.json +38 -0
- package/fixtures/node-pack-runtime/negative-version-range.json +31 -0
- package/fixtures/node-pack-runtime/positive-every-optional-member.json +40 -0
- package/fixtures/node-pack-runtime/positive-rfc-example.json +31 -0
- package/fixtures/node-pack-runtime/upstream/mcp-registry-server-2025-12-11.schema.json +574 -0
- package/fixtures/oauth-providers/synthetic.json +36 -4
- package/fixtures/upstream/a2a-v1.0.1/README.md +24 -0
- package/fixtures/upstream/a2a-v1.0.1/a2a.proto +811 -0
- package/fixtures/upstream/a2ui-v0.9/README.md +33 -0
- package/fixtures/upstream/a2ui-v0.9/catalog.json +1383 -0
- package/fixtures/upstream/a2ui-v0.9/common_types.json +305 -0
- package/fixtures/upstream/a2ui-v0.9/server_to_client.json +132 -0
- package/fixtures.md +139 -0
- package/package.json +2 -2
- package/requirements.json +4480 -97
- package/scenario-majors.json +93 -6
- package/schemas/CORPUS-STAMP.json +103 -89
- package/src/lib/behavior-gate.ts +7 -1
- package/src/lib/certification-bundle-v3.ts +18 -4
- package/src/lib/localized-content.ts +53 -0
- package/src/lib/mcp-fake-server.ts +105 -4
- package/src/lib/node-pack-runtime.ts +41 -0
- package/src/lib/oauth-as-double.ts +316 -0
- package/src/lib/protected-resource.ts +88 -0
- package/src/lib/standard-webhooks.ts +120 -0
- package/src/lib/toolCatalog.ts +54 -4
- package/src/lib/trace-context.ts +98 -0
- package/src/lib/v2.ts +6 -0
- package/src/lib/webhook-receiver.ts +201 -1
- package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +10 -1
- package/src/scenarios/auth-challenge-no-oracle.test.ts +122 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +46 -5
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +92 -58
- package/src/scenarios/debugBundle.test.ts +41 -0
- package/src/scenarios/fixtures-valid.test.ts +72 -0
- package/src/scenarios/inbound-credential-no-passthrough.test.ts +115 -0
- package/src/scenarios/interrupt-approver-routing.test.ts +3 -0
- package/src/scenarios/localized-content-delivery.test.ts +26 -15
- package/src/scenarios/mcp-current-auth-boundary.test.ts +38 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +29 -0
- package/src/scenarios/otel-mcp-semconv-projection.test.ts +85 -0
- package/src/scenarios/tool-catalog-compact-projection.test.ts +28 -14
- package/src/scenarios/tool-catalog-projection.test.ts +48 -12
- package/src/scenarios/tool-descriptor-shape.test.ts +34 -13
- package/src/scenarios/v2-a2a-agent-cards.test.ts +305 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +310 -0
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +283 -0
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +2 -2
- package/src/scenarios/v2-artifact-a2a-shape.test.ts +122 -0
- package/src/scenarios/v2-auth-challenge.test.ts +119 -0
- package/src/scenarios/v2-bound-id-kinds.test.ts +1 -1
- package/src/scenarios/v2-callback-url-guarded.test.ts +2 -2
- package/src/scenarios/v2-capability-maturity-bounded.test.ts +108 -0
- package/src/scenarios/v2-configurable-closed.test.ts +1 -1
- package/src/scenarios/v2-content-locale-keys.test.ts +92 -0
- package/src/scenarios/v2-conversation-turn-parts.test.ts +74 -0
- package/src/scenarios/v2-credential-interrupt.test.ts +253 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +2 -2
- package/src/scenarios/v2-fork-a-v1-run.test.ts +1 -1
- package/src/scenarios/v2-implementation-informational.test.ts +33 -0
- package/src/scenarios/v2-interop-trace-context.test.ts +351 -0
- package/src/scenarios/v2-lane-exp-only-bound.test.ts +236 -0
- package/src/scenarios/v2-lane-issuer-advertised.test.ts +52 -3
- package/src/scenarios/v2-mcp-client-results.test.ts +202 -0
- package/src/scenarios/v2-mcp-mount-map.test.ts +308 -0
- package/src/scenarios/v2-mcp-tasks.test.ts +503 -0
- package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +203 -0
- package/src/scenarios/v2-oauth-mcp-reach-discovery.test.ts +186 -0
- package/src/scenarios/v2-oidc-id-token-audience.test.ts +108 -0
- package/src/scenarios/v2-protected-resource-metadata.test.ts +145 -0
- package/src/scenarios/v2-run-cancel.test.ts +1 -1
- package/src/scenarios/v2-run-fork-prefix.test.ts +4 -4
- package/src/scenarios/v2-run-pause-resume.test.ts +1 -1
- package/src/scenarios/v2-tool-catalog-annotations.test.ts +100 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +1 -1
- package/src/scenarios/v2-v1-events-translated.test.ts +1 -1
- package/src/scenarios/v2-webhook-egress-refusal.test.ts +5 -5
- package/src/scenarios/v2-webhook-endpoint-verification.test.ts +124 -0
- package/src/scenarios/v2-webhook-message-id-stable.test.ts +124 -0
- package/src/scenarios/v2-webhook-secret-rotation.test.ts +132 -0
- package/src/scenarios/v2-webhook-standard-webhooks-delivery.test.ts +195 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0200 §A — the host serves RFC 9728 Protected Resource Metadata, derived from the
|
|
3
|
+
* lanes it already advertises (suite 2.36.0, target major 2).
|
|
4
|
+
*
|
|
5
|
+
* **Gate:** an `auth.lanes[]` member with `lane ∈ {oauth2, oidc}`. A host with neither
|
|
6
|
+
* records `inapplicable` — §A.1 does not bind it. A host WITH one that answers `404` at
|
|
7
|
+
* the derived URL records an `executed-fail`: the lane is the advertisement, the document
|
|
8
|
+
* is owed, and a soft-skip here would let the advertisement cost nothing.
|
|
9
|
+
*
|
|
10
|
+
* Three legs, one requirement id each:
|
|
11
|
+
*
|
|
12
|
+
* 1. `prm-served` — the URL RFC 9728 §3/§3.1 forms from the resource identifier answers
|
|
13
|
+
* `200` with a JSON object, fetched with NO credential (§A.2: the document is served
|
|
14
|
+
* without authentication).
|
|
15
|
+
* 2. `prm-consistent` — it is a PROJECTION, not a second declaration: `resource` is
|
|
16
|
+
* identical to the resource identifier used to form the URL; `authorization_servers`
|
|
17
|
+
* is set-equal to the URL-form issuers of those lanes; `scopes_supported`, when
|
|
18
|
+
* present, is a non-empty array of strings; and neither binding claim is `true`
|
|
19
|
+
* unless EVERY such lane requires that binding — a bearer lane advertised as
|
|
20
|
+
* sender-constrained is `sender-constraint-no-bearer-downgrade`.
|
|
21
|
+
* 3. `mcp-mount-prm` — §A.4, the MCP mount as its own resource. Gated further on a
|
|
22
|
+
* mount that REQUIRES OAuth-lane authentication, which the suite can only establish
|
|
23
|
+
* when every advertised lane is `oauth2`/`oidc`: a host whose api-key lane also
|
|
24
|
+
* opens the mount records `inapplicable` with that reason rather than passing a leg
|
|
25
|
+
* whose premise does not hold.
|
|
26
|
+
*
|
|
27
|
+
* How each FAILS (the sabotage run before citing a row):
|
|
28
|
+
* (a) the host serves nothing at the derived URL → leg 1 (404)
|
|
29
|
+
* (b) a sub-path host serves only the ROOT well-known → leg 1 (404 at the inserted path)
|
|
30
|
+
* (c) `authorization_servers` adds an issuer no lane names → leg 2
|
|
31
|
+
* (d) `resource` carries a trailing slash / another origin → leg 2
|
|
32
|
+
* (e) `dpop_bound_access_tokens_required: true` on a bearer lane → leg 2
|
|
33
|
+
*
|
|
34
|
+
* @see spec/v2/core/identity.md §2.5
|
|
35
|
+
* @see RFCS/0200-host-as-oauth-protected-resource.md §A
|
|
36
|
+
* @see SECURITY/invariants.yaml id: auth-challenge-no-oracle (the §B.3 sibling)
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { describe, it, expect } from 'vitest';
|
|
40
|
+
import { driver } from '../lib/driver.js';
|
|
41
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
42
|
+
import { req } from '../lib/requirement-ids.js';
|
|
43
|
+
import { allLanes, parseChallenge, prmGate, prmUrlFor, urlIssuers } from '../lib/protected-resource.js';
|
|
44
|
+
|
|
45
|
+
const DOC = 'spec/v2/core/identity.md §2.5 (RFC 0200 §A)';
|
|
46
|
+
const SERVED = 'openwop.requirement.0200.prm-served';
|
|
47
|
+
const CONSISTENT = 'openwop.requirement.0200.prm-consistent';
|
|
48
|
+
const MOUNT = 'openwop.requirement.0200.mcp-mount-prm';
|
|
49
|
+
|
|
50
|
+
describe('RFC 0200 §A — v2-protected-resource-metadata (gated on an advertised oauth2/oidc lane)', () => {
|
|
51
|
+
it('serves RFC 9728 metadata, unauthenticated, at the URI derived from its resource identifier', async () => {
|
|
52
|
+
const g = await prmGate();
|
|
53
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
54
|
+
const res = await driver.get(g.prmUrl, { authenticated: false });
|
|
55
|
+
expect(
|
|
56
|
+
res.status,
|
|
57
|
+
req(SERVED, `${DOC} §A.1–§A.2`, `a host advertising an oauth2/oidc lane MUST serve RFC 9728 metadata at ${g.prmUrl} — served without authentication (got ${res.status}; a 404 is a failure, not a skip: the lane is advertised, so the document is owed)`),
|
|
58
|
+
).toBe(200);
|
|
59
|
+
expect(
|
|
60
|
+
res.json !== null && typeof res.json === 'object' && !Array.isArray(res.json),
|
|
61
|
+
req(SERVED, `${DOC} §A.2`, `the metadata MUST be a JSON object (got ${typeof res.json}: ${res.text.slice(0, 120)})`),
|
|
62
|
+
).toBe(true);
|
|
63
|
+
}, 30_000);
|
|
64
|
+
|
|
65
|
+
it('the metadata is a projection of auth.lanes[], not a second declaration', async () => {
|
|
66
|
+
const g = await prmGate();
|
|
67
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
68
|
+
const res = await driver.get(g.prmUrl, { authenticated: false });
|
|
69
|
+
if (res.status !== 200 || res.json === null || typeof res.json !== 'object') {
|
|
70
|
+
// The served leg already recorded the failure; this leg has nothing to project from.
|
|
71
|
+
return softSkip('blocked', `the metadata did not parse (status ${res.status}) — the prm-served leg carries that verdict`);
|
|
72
|
+
}
|
|
73
|
+
const prm = res.json as Record<string, unknown>;
|
|
74
|
+
|
|
75
|
+
expect(
|
|
76
|
+
prm['resource'],
|
|
77
|
+
req(CONSISTENT, `${DOC} §A.3`, `\`resource\` MUST be identical to the resource identifier the URL was formed from (RFC 9728 §3.3): expected ${g.resource}`),
|
|
78
|
+
).toBe(g.resource);
|
|
79
|
+
|
|
80
|
+
const expected = urlIssuers(g.lanes);
|
|
81
|
+
const got = [...new Set((Array.isArray(prm['authorization_servers']) ? (prm['authorization_servers'] as unknown[]) : []).map(String))].sort();
|
|
82
|
+
expect(
|
|
83
|
+
got,
|
|
84
|
+
req(CONSISTENT, `${DOC} §A.3`, `\`authorization_servers\` MUST list exactly the URL-form issuers of the oauth2/oidc lanes — an issuer the lanes do not name is a second declaration, and a lane issuer left out hides an authorization server the caller must reach (lanes name [${expected.join(', ')}])`),
|
|
85
|
+
).toEqual(expected);
|
|
86
|
+
|
|
87
|
+
if ('scopes_supported' in prm) {
|
|
88
|
+
const scopes = prm['scopes_supported'];
|
|
89
|
+
expect(
|
|
90
|
+
Array.isArray(scopes) && scopes.length > 0 && scopes.every((s) => typeof s === 'string' && s.length > 0),
|
|
91
|
+
req(CONSISTENT, `${DOC} §A.3`, `\`scopes_supported\`, when present, MUST list the scopes the host enforces — a non-empty array of strings (got ${JSON.stringify(scopes)})`),
|
|
92
|
+
).toBe(true);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// §A.3: a binding claim MAY be true only where EVERY oauth2/oidc lane's
|
|
96
|
+
// minimumAssurance requires that binding. A bearer lane advertised as
|
|
97
|
+
// sender-constrained is the `sender-constraint-no-bearer-downgrade` violation, and it
|
|
98
|
+
// is exactly the claim a host is tempted to make because it reads as "more secure".
|
|
99
|
+
const everyConstrained = g.lanes.every((l) => l.minimumAssurance === 'sender-constrained' || l.minimumAssurance === 'key-bound');
|
|
100
|
+
const proofs = new Set(g.lanes.flatMap((l) => (Array.isArray(l.delegationProofs) ? l.delegationProofs.map(String) : [])));
|
|
101
|
+
if (prm['dpop_bound_access_tokens_required'] === true) {
|
|
102
|
+
expect(
|
|
103
|
+
everyConstrained && proofs.has('dpop'),
|
|
104
|
+
req(CONSISTENT, `${DOC} §A.3`, 'dpop_bound_access_tokens_required MAY be true only where EVERY oauth2/oidc lane requires that binding (minimumAssurance above bearer with dpop in delegationProofs) — invariant sender-constraint-no-bearer-downgrade'),
|
|
105
|
+
).toBe(true);
|
|
106
|
+
}
|
|
107
|
+
if (prm['tls_client_certificate_bound_access_tokens'] === true) {
|
|
108
|
+
expect(
|
|
109
|
+
everyConstrained && proofs.has('mtls-key-binding'),
|
|
110
|
+
req(CONSISTENT, `${DOC} §A.3`, 'tls_client_certificate_bound_access_tokens MAY be true only where EVERY oauth2/oidc lane requires mTLS key binding — invariant sender-constraint-no-bearer-downgrade'),
|
|
111
|
+
).toBe(true);
|
|
112
|
+
}
|
|
113
|
+
}, 30_000);
|
|
114
|
+
|
|
115
|
+
it('an MCP mount that requires OAuth-lane authentication implements one of upstream\'s two discovery mechanisms', async () => {
|
|
116
|
+
const g = await prmGate();
|
|
117
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
118
|
+
const mcp = g.doc['mcp'] as { serverUrls?: unknown; serverMount?: unknown } | undefined;
|
|
119
|
+
const urls = Array.isArray(mcp?.serverUrls) ? (mcp.serverUrls as unknown[]).map(String) : [];
|
|
120
|
+
if (urls.length === 0) return softSkip('inapplicable', 'no mcp.serverUrls[] — this host mounts no MCP server, so §A.4 does not bind it');
|
|
121
|
+
// §A.4 binds a mount that REQUIRES OAuth-lane authentication. From outside, that holds
|
|
122
|
+
// only when every advertised lane is oauth2/oidc: otherwise an api-key credential opens
|
|
123
|
+
// the mount and the mount's resource server is not an OAuth one. Recording the honest
|
|
124
|
+
// `inapplicable` is the point — a leg that passed on a mount an api-key opens would be
|
|
125
|
+
// measuring nothing.
|
|
126
|
+
const every = allLanes(g.doc);
|
|
127
|
+
const onlyOauth = every.length > 0 && every.every((l) => l.lane === 'oauth2' || l.lane === 'oidc');
|
|
128
|
+
if (!onlyOauth) {
|
|
129
|
+
return softSkip('inapplicable', `the mount does not require an oauth2/oidc lane: this host also advertises [${every.map((l) => String(l.lane)).join(', ')}], any of which opens the mount, so §A.4's premise does not hold`);
|
|
130
|
+
}
|
|
131
|
+
const mount = urls[0] as string;
|
|
132
|
+
const unauth = await driver.post(mount, { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, { authenticated: false, headers: { 'MCP-Protocol-Version': '2026-07-28' } });
|
|
133
|
+
const challenge = parseChallenge(unauth.headers.get('www-authenticate'));
|
|
134
|
+
const viaChallenge = unauth.status === 401 && challenge !== null && challenge.scheme === 'bearer' && typeof challenge.params['resource_metadata'] === 'string';
|
|
135
|
+
let viaWellKnown = false;
|
|
136
|
+
if (!viaChallenge) {
|
|
137
|
+
const doc = await driver.get(prmUrlFor(mount), { authenticated: false });
|
|
138
|
+
viaWellKnown = doc.status === 200 && (doc.json as { resource?: unknown } | null)?.resource === mount;
|
|
139
|
+
}
|
|
140
|
+
expect(
|
|
141
|
+
viaChallenge || viaWellKnown,
|
|
142
|
+
req(MOUNT, 'spec/v1/mcp-integration.md §E; spec/v2/interop-map.json mcp.authorization (RFC 0200 §A.4)', `a mount requiring OAuth-lane authentication MUST implement one of upstream's two discovery mechanisms for the mount URL as resource — resource_metadata in its 401 challenge, or PRM at ${prmUrlFor(mount)} with resource === ${mount} (mount answered ${unauth.status}, WWW-Authenticate ${unauth.headers.get('www-authenticate') ?? 'absent'})`),
|
|
143
|
+
).toBe(true);
|
|
144
|
+
}, 30_000);
|
|
145
|
+
});
|
|
@@ -92,6 +92,6 @@ describe('v2 run-cancel (runs.md §Cancel)', () => {
|
|
|
92
92
|
const final = await waitFor(c.runId, (x) => x === 'cancelled', 5_000);
|
|
93
93
|
expect(final, req(ID, DOC, `the snapshot MUST read cancelled within 5 s of an accepted cancel (last: ${final ?? 'unreadable'})`)).toBe('cancelled');
|
|
94
94
|
const types = await eventTypes(c.runId);
|
|
95
|
-
expect(types?.includes('run.cancelled') ?? false, req(ID, 'spec/v2/core/
|
|
95
|
+
expect(types?.includes('run.cancelled') ?? false, req(ID, 'spec/v2/core/runs.md §Cancel', `the log MUST carry run.cancelled once the cascade completes (types: ${(types ?? []).join(', ')})`)).toBe(true);
|
|
96
96
|
}, 30_000);
|
|
97
97
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `spec/v2/core/replay.md` §
|
|
2
|
+
* `spec/v2/core/replay.md` §The surface — the fork prefix boundary is EXCLUSIVE
|
|
3
3
|
* (suite 2.0.0, target major 2; unaided; gated on `replay`; two runs created).
|
|
4
4
|
*
|
|
5
5
|
* "Events with `sequence < fromSeq` are fixed history; events `>= fromSeq` are
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
* The leg runs LAST, after the boundary assertion, so a host that fails this
|
|
100
100
|
* stricter new check still reports the boundary verdict the file was cut for.
|
|
101
101
|
*
|
|
102
|
-
* @see spec/v2/core/replay.md §
|
|
102
|
+
* @see spec/v2/core/replay.md §The surface
|
|
103
103
|
* @see spec/v2/core/replay.md §Byte-equivalence of the prefix
|
|
104
104
|
* @see spec/v2/core/runs.md §Fork
|
|
105
105
|
* @see spec/v2/core/runs.md §Diff and ancestry
|
|
@@ -114,7 +114,7 @@ import { softSkip } from '../lib/soft-skip.js';
|
|
|
114
114
|
import { req } from '../lib/requirement-ids.js';
|
|
115
115
|
|
|
116
116
|
const ID = 'openwop.requirement.0170.fork-prefix-boundary';
|
|
117
|
-
const DOC = 'spec/v2/core/replay.md §
|
|
117
|
+
const DOC = 'spec/v2/core/replay.md §The surface';
|
|
118
118
|
const BYTES = 'spec/v2/core/replay.md §Byte-equivalence of the prefix';
|
|
119
119
|
const MULTI = 'conformance-multi-node';
|
|
120
120
|
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
@@ -168,7 +168,7 @@ async function settle(runId: string, ms = 20_000): Promise<string | null> {
|
|
|
168
168
|
return status;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
describe('v2 run-fork-prefix (replay.md §
|
|
171
|
+
describe('v2 run-fork-prefix (replay.md §The surface — the boundary is exclusive)', () => {
|
|
172
172
|
it('a replay fork inherits exactly [0, fromSeq): the event at fromSeq is re-executed, never carried over', async () => {
|
|
173
173
|
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
174
174
|
if (!(await gateFamily('replay'))) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay) — forkRun is gated on replay (runs.md §Surface)');
|
|
@@ -101,7 +101,7 @@ describe('v2 run-pause-resume (runs.md §Pause and resume)', () => {
|
|
|
101
101
|
expect(rb?.status, req(ID, DOC, `the 202 MUST carry status running (got ${String(rb?.status)})`)).toBe('running');
|
|
102
102
|
const types = await eventTypes(c.runId);
|
|
103
103
|
const p = types?.indexOf('run.paused') ?? -1; const r = types?.indexOf('run.resumed') ?? -1;
|
|
104
|
-
expect(p >= 0 && r > p, req(ID, 'spec/v2/core/
|
|
104
|
+
expect(p >= 0 && r > p, req(ID, 'spec/v2/core/runs.md §Pause and resume', `the log MUST carry run.paused then run.resumed (types: ${(types ?? []).join(', ')})`)).toBe(true);
|
|
105
105
|
// An immediate pause cuts the executing attempt BETWEEN events: no terminal
|
|
106
106
|
// node event for it, ever — a node.failed here makes a replay fold a
|
|
107
107
|
// failure the source never had (runs.md §Pause and resume; replay.md).
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0204 §D.11–§D.12 — the catalog's MCP annotation projection (suite 2.36.0,
|
|
3
|
+
* target major 2; gated on `toolCatalog`).
|
|
4
|
+
*
|
|
5
|
+
* `spec/v2/core/tool-catalog.md` §The descriptor: the host assigns
|
|
6
|
+
* `safetyTier`, `replayPolicy` and `egress` itself and never copies them from
|
|
7
|
+
* an MCP server's `annotations` (untrusted upstream); an unclassified
|
|
8
|
+
* `source: "mcp"` tool is `write`; and any `annotations` it publishes carry all
|
|
9
|
+
* four hints, each a fixed function of those host-assigned fields.
|
|
10
|
+
*
|
|
11
|
+
* annotations-derived every descriptor carrying `annotations` has all
|
|
12
|
+
* four hints and each equals the table's value;
|
|
13
|
+
* `inapplicable` when no descriptor carries any (a
|
|
14
|
+
* catalog without annotations is untouched by §D.12,
|
|
15
|
+
* and passing it would be vacuous);
|
|
16
|
+
* mcp-unclassified-write the suite's fake MCP server lists
|
|
17
|
+
* `conformance_readonly_claim_<nonce>` annotated
|
|
18
|
+
* `readOnlyHint: true`; wherever a host projects it
|
|
19
|
+
* (any `conformance_readonly_claim_*` descriptor with
|
|
20
|
+
* `source: "mcp"` — a host MAY answer from a list it
|
|
21
|
+
* cached from an earlier suite server within `ttlMs`),
|
|
22
|
+
* it MUST be `safetyTier: "write"`. `inapplicable`
|
|
23
|
+
* when the host does not project the server.
|
|
24
|
+
*
|
|
25
|
+
* Sabotage, each run once against the v2 reference host: emit
|
|
26
|
+
* `readOnlyHint: true` on a `write` tool; omit `openWorldHint`; copy the
|
|
27
|
+
* server's `readOnlyHint` into `safetyTier: "read"` — each turns its row red.
|
|
28
|
+
*
|
|
29
|
+
* @see spec/v2/core/tool-catalog.md §The descriptor
|
|
30
|
+
* @see SECURITY/invariants.yaml tool-annotations-untrusted
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect } from 'vitest';
|
|
34
|
+
import { driver } from '../lib/driver.js';
|
|
35
|
+
import { familyAdvertised } from '../lib/v2.js';
|
|
36
|
+
import { getMcpFakeServer, READONLY_CLAIM_PREFIX } from '../lib/mcp-fake-server.js';
|
|
37
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
38
|
+
import { req } from '../lib/requirement-ids.js';
|
|
39
|
+
import { expectedAnnotations, type ToolDescriptor } from '../lib/toolCatalog.js';
|
|
40
|
+
|
|
41
|
+
export const REQUIRES_HOST_CALLBACK = "the host projects the suite's fake MCP server into GET /tools (the unclassified-MCP leg)";
|
|
42
|
+
|
|
43
|
+
const DOC = 'spec/v2/core/tool-catalog.md §The descriptor';
|
|
44
|
+
const ID_DERIVED = 'openwop.requirement.0204.annotations-derived';
|
|
45
|
+
const ID_UNCLASSIFIED = 'openwop.requirement.0204.mcp-unclassified-write';
|
|
46
|
+
/**
|
|
47
|
+
* Recorded `inapplicable`, not through `gateFamily`: under strict mode
|
|
48
|
+
* `gateFamily` FAILS a host that neither advertises nor opts out, and a new
|
|
49
|
+
* gate on an existing family would de-certify every strict v2 cut that does not
|
|
50
|
+
* advertise `toolCatalog` until its operator adds an opt-out — a certification
|
|
51
|
+
* change this RFC does not make.
|
|
52
|
+
*/
|
|
53
|
+
const NOT_ADVERTISED = 'toolCatalog not advertised — the host publishes no catalog';
|
|
54
|
+
const HINTS = ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as const;
|
|
55
|
+
|
|
56
|
+
async function readCatalog(): Promise<ToolDescriptor[] | { reason: string }> {
|
|
57
|
+
const res = await driver.get('/tools').catch(() => null);
|
|
58
|
+
if (res === null) return { reason: 'GET /tools unreachable (fetch failed)' };
|
|
59
|
+
if (res.status !== 200) return { reason: `the host advertises toolCatalog and GET /tools answered ${res.status}` };
|
|
60
|
+
const body = res.json as unknown;
|
|
61
|
+
const tools = Array.isArray(body) ? body : (body as { tools?: unknown } | null)?.tools;
|
|
62
|
+
return Array.isArray(tools) ? (tools as ToolDescriptor[]) : { reason: 'GET /tools returned neither a ToolDescriptor[] nor { tools }' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('RFC 0204 §D — tool annotations are derived, never copied (gated on toolCatalog)', () => {
|
|
66
|
+
it('every published annotations object is the table function of the descriptor', async () => {
|
|
67
|
+
if (!(await familyAdvertised('toolCatalog'))) return softSkip('inapplicable', NOT_ADVERTISED);
|
|
68
|
+
const tools = await readCatalog();
|
|
69
|
+
if (!Array.isArray(tools)) return softSkip('blocked', tools.reason);
|
|
70
|
+
const annotated = tools.filter((t) => t['annotations'] !== undefined);
|
|
71
|
+
if (annotated.length === 0) return softSkip('inapplicable', `none of the ${tools.length} descriptors carries annotations — §D.12 binds only a host that publishes them`);
|
|
72
|
+
for (const t of annotated) {
|
|
73
|
+
const a = (t['annotations'] ?? {}) as Record<string, unknown>;
|
|
74
|
+
const want = expectedAnnotations(t);
|
|
75
|
+
for (const h of HINTS) {
|
|
76
|
+
expect(typeof a[h], req(ID_DERIVED, DOC, `${String(t.toolId)}: annotations MUST carry all four hints — ${h} is ${a[h] === undefined ? 'absent (a consumer would fall through to the MCP default)' : `not a boolean (${JSON.stringify(a[h])})`}`)).toBe('boolean');
|
|
77
|
+
expect(a[h], req(ID_DERIVED, DOC, `${String(t.toolId)}: ${h} MUST be derived from safetyTier=${String(t.safetyTier)} replayPolicy=${String(t['replayPolicy'] ?? '(absent)')} egress=${String(t['egress'] ?? '(absent)')} — want ${String(want[h])}`)).toBe(want[h]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('an MCP tool the host has not classified is safetyTier write, whatever the server claims', async () => {
|
|
83
|
+
const fam = await familyAdvertised('toolCatalog');
|
|
84
|
+
if (!fam) return softSkip('inapplicable', NOT_ADVERTISED);
|
|
85
|
+
const sources = Array.isArray(fam['sources']) ? (fam['sources'] as unknown[]) : [];
|
|
86
|
+
if (!sources.includes('mcp')) return softSkip('inapplicable', 'toolCatalog.sources does not include mcp — the host projects no MCP server');
|
|
87
|
+
const fake = getMcpFakeServer();
|
|
88
|
+
// No suite server ⇒ nothing the host could project: the RFC's `inapplicable`
|
|
89
|
+
// case, not `blocked` — a tier-2 host that projects its own MCP servers and
|
|
90
|
+
// cuts without OPENWOP_MCP_FAKE_SERVER is not made uncertifiable by this row.
|
|
91
|
+
if (fake === null) return softSkip('inapplicable', 'the suite MCP fake server (OPENWOP_MCP_FAKE_SERVER=true) is not started in this run, so there is no suite server for the host to project');
|
|
92
|
+
const tools = await readCatalog();
|
|
93
|
+
if (!Array.isArray(tools)) return softSkip('blocked', tools.reason);
|
|
94
|
+
const claims = tools.filter((t) => t.source === 'mcp' && [t.toolId, t['title']].some((v) => typeof v === 'string' && v.includes(READONLY_CLAIM_PREFIX)));
|
|
95
|
+
if (claims.length === 0) return softSkip('inapplicable', `no source:"mcp" descriptor names ${fake.readonlyClaimToolName()} — the host does not project the suite's fake server as a catalog source`);
|
|
96
|
+
for (const t of claims) {
|
|
97
|
+
expect(t.safetyTier, req(ID_UNCLASSIFIED, DOC, `${String(t.toolId)}: the server annotates it readOnlyHint: true, which is untrusted; a source:"mcp" tool the host has not classified MUST be safetyTier "write" (got ${String(t.safetyTier)})`)).toBe('write');
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* every leg records `blocked` naming it.
|
|
17
17
|
*
|
|
18
18
|
* @see spec/v2/core/persistence.md §The reader rule, §The seat
|
|
19
|
-
* @see spec/v2/core/events.md §
|
|
19
|
+
* @see spec/v2/core/events.md §Era-2 logs
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { describe, it, expect } from 'vitest';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* v2-webhook-egress-refusal — the webhook egress guard REFUSES, at major 2.
|
|
3
3
|
*
|
|
4
|
-
* `spec/v2/core/webhooks.md` §
|
|
4
|
+
* `spec/v2/core/webhooks.md` §Egress: "At registration a host MUST reject (`400
|
|
5
5
|
* webhook_url_rejected`) non-`https://` URLs, RFC 1918 and loopback and
|
|
6
6
|
* link-local ranges, IPv6 ULA, cloud metadata hosts, and `localhost`."
|
|
7
7
|
*
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* deleted at once. Delivery-time re-resolution is a separate MUST that needs a
|
|
40
40
|
* resolver the suite controls; it is not claimed here.
|
|
41
41
|
*
|
|
42
|
-
* @see spec/v2/core/webhooks.md §
|
|
42
|
+
* @see spec/v2/core/webhooks.md §Egress
|
|
43
43
|
* @see spec/v2/core/security-defaults.md §Relaxations
|
|
44
44
|
*/
|
|
45
45
|
|
|
@@ -51,7 +51,7 @@ import { softSkip } from '../lib/soft-skip.js';
|
|
|
51
51
|
import { req } from '../lib/requirement-ids.js';
|
|
52
52
|
|
|
53
53
|
const ID = 'openwop.requirement.0171.webhook-egress-refused';
|
|
54
|
-
const DOC = 'spec/v2/core/webhooks.md §
|
|
54
|
+
const DOC = 'spec/v2/core/webhooks.md §Egress';
|
|
55
55
|
const EVENTS = ['run.completed'];
|
|
56
56
|
|
|
57
57
|
/** One destination per class the sentence names. None is ever contacted. */
|
|
@@ -70,7 +70,7 @@ const PROBES: ReadonlyArray<{ readonly url: string; readonly cls: string }> = [
|
|
|
70
70
|
// form let loopback, metadata and RFC 1918 through at registration and at
|
|
71
71
|
// delivery. Measured on the steward's own reference host.
|
|
72
72
|
// These are loopback / link-local / RFC 1918 addresses by any reading of
|
|
73
|
-
// webhooks.md §
|
|
73
|
+
// webhooks.md §Egress, so they enforce the existing obligation — same id.
|
|
74
74
|
{ url: 'https://[::ffff:7f00:1]/openwop-egress-probe', cls: 'loopback as IPv4-mapped IPv6 (hex)' },
|
|
75
75
|
{ url: 'https://[::ffff:a9fe:a9fe]/latest/meta-data/', cls: 'cloud metadata as IPv4-mapped IPv6 (hex)' },
|
|
76
76
|
{ url: 'https://[::ffff:a00:1]/openwop-egress-probe', cls: 'RFC 1918 (10/8) as IPv4-mapped IPv6 (hex)' },
|
|
@@ -88,7 +88,7 @@ function declaredWebhookRelaxations(): string[] {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
describe('v2-webhook-egress-refusal (webhooks.md §
|
|
91
|
+
describe('v2-webhook-egress-refusal (webhooks.md §Egress — the guard refuses, at major 2)', () => {
|
|
92
92
|
it('a webhook registration naming a destination the guard MUST refuse is refused 400 webhook_url_rejected', async () => {
|
|
93
93
|
const doc = await v2Discovery();
|
|
94
94
|
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0201 §D — endpoint verification, for opted-in registrations only (suite
|
|
3
|
+
* 2.36.0, target major 2; gated on `webhooks.signatureAlgorithms` listing
|
|
4
|
+
* `standard-webhooks-1`).
|
|
5
|
+
*
|
|
6
|
+
* Before it answers `201` to a registration that lists `standard-webhooks-1`,
|
|
7
|
+
* the host sends ONE signed verification request and refuses the registration
|
|
8
|
+
* `400 webhook_endpoint_unverified`, persisting nothing, unless a `2xx` arrives
|
|
9
|
+
* within 10 s whose JSON `challenge` equals the one sent. The receiver is the
|
|
10
|
+
* suite's modal receiver, one path per behaviour:
|
|
11
|
+
*
|
|
12
|
+
* no-echo 200 `{}` → MUST be refused
|
|
13
|
+
* wrong-echo 200 `{ challenge: <other> }` → MUST be refused
|
|
14
|
+
* redirect 307 to redirect-target (echoes) → MUST be refused; the target MUST see nothing
|
|
15
|
+
* echo 200 `{ challenge }` → MUST answer 201
|
|
16
|
+
*
|
|
17
|
+
* Each verification request is checked against
|
|
18
|
+
* `schemas/v2/webhook-verification.schema.json`, verified with the supplied
|
|
19
|
+
* secret by the suite's Standard Webhooks verifier, and MUST carry no
|
|
20
|
+
* `OpenWOP-Event-Type` (it is not a delivery). A host MUST NOT retry it, so each
|
|
21
|
+
* refused path sees exactly one.
|
|
22
|
+
*
|
|
23
|
+
* "Persisting nothing" is checked on the wire, not by reading the host: after
|
|
24
|
+
* the refusals, one run is driven and the echo subscription's delivery is the
|
|
25
|
+
* sync point — by the time it lands, a refused path that had been persisted
|
|
26
|
+
* would have received the same event. That catches "201 then verify later"
|
|
27
|
+
* and "400 but keep the row".
|
|
28
|
+
*
|
|
29
|
+
* How it FAILS: a host that answers 201 without verifying (no-echo is 201); a
|
|
30
|
+
* host that follows the 307 (the target sees a hit); a host that verifies
|
|
31
|
+
* afterwards (a refused path receives a delivery); a host that retries the
|
|
32
|
+
* verification (two requests on one path).
|
|
33
|
+
*
|
|
34
|
+
* @see spec/v2/core/webhooks.md §Standard Webhooks
|
|
35
|
+
* @see RFCS/0201-standard-webhooks-signature-scheme.md §D
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { afterEach, describe, it, expect } from 'vitest';
|
|
39
|
+
import { req } from '../lib/requirement-ids.js';
|
|
40
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
41
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
42
|
+
import { v2Validator } from '../lib/v2.js';
|
|
43
|
+
import { hitHeader, mintWhsec, startModalReceiver, verifyStandardWebhooks } from '../lib/webhook-receiver.js';
|
|
44
|
+
import {
|
|
45
|
+
STANDARD_WEBHOOKS_ALG,
|
|
46
|
+
deliveriesFor,
|
|
47
|
+
driveRun,
|
|
48
|
+
loopbackRefusal,
|
|
49
|
+
registerSw,
|
|
50
|
+
swGate,
|
|
51
|
+
unregisterAllSw,
|
|
52
|
+
waitFor,
|
|
53
|
+
} from '../lib/standard-webhooks.js';
|
|
54
|
+
|
|
55
|
+
const REFUSED = ['no-echo', 'wrong-echo', 'redirect'] as const;
|
|
56
|
+
|
|
57
|
+
let closeReceiver: (() => Promise<void>) | null = null;
|
|
58
|
+
afterEach(async () => {
|
|
59
|
+
await unregisterAllSw();
|
|
60
|
+
if (closeReceiver) { const c = closeReceiver; closeReceiver = null; await c(); }
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('RFC 0201 §D — endpoint verification refuses without consent (opted-in registrations only)', () => {
|
|
64
|
+
it('no-echo, wrong-echo and a redirect are refused and persist nothing; an echo is accepted', async () => {
|
|
65
|
+
const g = await swGate();
|
|
66
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
67
|
+
const rx = await startModalReceiver();
|
|
68
|
+
closeReceiver = rx.close;
|
|
69
|
+
const secret = mintWhsec();
|
|
70
|
+
const body = (mode: string): Record<string, unknown> => ({ url: rx.urlFor(mode).url, events: ['run.completed'], signatureAlgorithms: ['v1', STANDARD_WEBHOOKS_ALG], secret });
|
|
71
|
+
const tunnelled = rx.urlFor('echo').tunnelled;
|
|
72
|
+
|
|
73
|
+
// Register the echo subscription first: its 201 is the positive control, and
|
|
74
|
+
// a loopback refusal here is `blocked` before anything is asserted.
|
|
75
|
+
const ok = await registerSw(body('echo'));
|
|
76
|
+
const blocked = loopbackRefusal(ok, tunnelled);
|
|
77
|
+
if (blocked) return softSkip('blocked', blocked);
|
|
78
|
+
const refused = new Map<string, { status: number; code: string | undefined; id: unknown }>();
|
|
79
|
+
for (const mode of REFUSED) {
|
|
80
|
+
const res = await registerSw(body(mode));
|
|
81
|
+
refused.set(mode, { status: res.status, code: readErrorCode(res.json), id: (res.json as { webhookId?: unknown } | null)?.webhookId });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ID = 'openwop.requirement.0201.endpoint-verification';
|
|
85
|
+
expect(ok.status, req(ID, 'RFC 0201 §D.14', `an endpoint that echoes the challenge MUST be registered 201 (got ${ok.status} ${readErrorCode(ok.json) ?? ''})`)).toBe(201);
|
|
86
|
+
const echoId = (ok.json as { webhookId: string }).webhookId;
|
|
87
|
+
for (const [mode, r] of refused) {
|
|
88
|
+
expect(r.status, req(ID, 'RFC 0201 §D.14', `a ${mode} endpoint MUST be refused 400 before any 201 (got ${r.status})`)).toBe(400);
|
|
89
|
+
expect(r.code, req(ID, 'RFC 0201 §D.14', `a ${mode} refusal MUST carry webhook_endpoint_unverified`)).toBe('webhook_endpoint_unverified');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const validate = v2Validator('webhook-verification');
|
|
93
|
+
for (const mode of ['echo', ...REFUSED]) {
|
|
94
|
+
const vr = rx.hits.filter((h) => h.mode === mode && h.verification);
|
|
95
|
+
expect(vr.length, req(ID, 'RFC 0201 §D.13–§D.14', `the host MUST send exactly one verification request to the ${mode} endpoint and MUST NOT retry it (saw ${vr.length})`)).toBe(1);
|
|
96
|
+
const h = vr[0]!;
|
|
97
|
+
expect(h.method, req(ID, 'RFC 0201 §D.13', 'the verification request MUST be a POST')).toBe('POST');
|
|
98
|
+
const parsed = JSON.parse(h.body) as unknown;
|
|
99
|
+
const v = validate(parsed);
|
|
100
|
+
expect(v.ok, req(ID, 'RFC 0201 §D.13', `the verification body MUST validate against webhook-verification.schema.json (${v.errors})`)).toBe(true);
|
|
101
|
+
expect(hitHeader(h, 'openwop-event-type'), req(ID, 'RFC 0201 §D.13', 'the verification request is not a delivery and MUST carry no OpenWOP-Event-Type')).toBeUndefined();
|
|
102
|
+
const verdict = verifyStandardWebhooks(h.body, h.headers, secret);
|
|
103
|
+
expect(verdict.matched, req(ID, 'RFC 0201 §D.13', `the verification request MUST be signed as §C.9 with the supplied secret (verifier: ${verdict.reason ?? 'ok'})`)).toBeGreaterThan(0);
|
|
104
|
+
}
|
|
105
|
+
const challenges = rx.hits.filter((h) => h.verification).map((h) => (JSON.parse(h.body) as { challenge: string }).challenge);
|
|
106
|
+
expect(new Set(challenges).size, req(ID, 'RFC 0201 §D.13', 'each verification MUST carry a FRESH challenge')).toBe(challenges.length);
|
|
107
|
+
expect(
|
|
108
|
+
rx.hits.filter((h) => h.mode === 'redirect-target').length,
|
|
109
|
+
req(ID, 'RFC 0201 §D.13', 'the verification request MUST NOT follow a redirect — the 307 target MUST see nothing'),
|
|
110
|
+
).toBe(0);
|
|
111
|
+
|
|
112
|
+
// Persisted nothing: drive a run and wait for the echo subscription's
|
|
113
|
+
// delivery; by then any persisted refused subscription would have one too.
|
|
114
|
+
const run = await driveRun();
|
|
115
|
+
expect(run.status, req(ID, 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
|
|
116
|
+
await waitFor(() => deliveriesFor(rx.hits, echoId, run.runId).length > 0, 15_000);
|
|
117
|
+
expect(deliveriesFor(rx.hits, echoId, run.runId).length, req(ID, 'webhooks.md §Durability', 'the verified subscription MUST receive run.completed — the sync point for the persisted-nothing check')).toBeGreaterThan(0);
|
|
118
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
119
|
+
for (const mode of REFUSED) {
|
|
120
|
+
const late = rx.hits.filter((h) => h.mode === mode && !h.verification);
|
|
121
|
+
expect(late.length, req(ID, 'RFC 0201 §D.14', `a refused (${mode}) registration MUST persist no subscription — it received ${late.length} later request(s)`)).toBe(0);
|
|
122
|
+
}
|
|
123
|
+
}, 60_000);
|
|
124
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0201 §C.10 — the signed delivery id is stable across retries and
|
|
3
|
+
* distinct across deliveries (suite 2.36.0, target major 2; gated on
|
|
4
|
+
* `webhooks.signatureAlgorithms` listing `standard-webhooks-1`).
|
|
5
|
+
*
|
|
6
|
+
* `webhook-id` is the receiver's idempotency key, and because it is inside the
|
|
7
|
+
* Standard Webhooks signed bytes it is an AUTHENTICATED one. That only helps if
|
|
8
|
+
* the host keeps it still: a per-attempt id makes a durable host's retries look
|
|
9
|
+
* like new events, and a constant id makes new events look like retries.
|
|
10
|
+
*
|
|
11
|
+
* How the receiver is driven: `fail2` answers `500` to the first two attempts
|
|
12
|
+
* for each `(OpenWOP-Webhook-Id, runId, sequence)` and `204` afterwards, so
|
|
13
|
+
* every delivery is attempted at least twice (retries are a v2 MUST,
|
|
14
|
+
* webhooks.md §Durability). Two opted-in subscriptions to the SAME URL, each on
|
|
15
|
+
* `run.started` + `run.completed`, give four distinct deliveries of one run:
|
|
16
|
+
* two by event, two by subscription.
|
|
17
|
+
*
|
|
18
|
+
* Observe first, then assert (the 2.34.1 lesson): if no delivery key was seen
|
|
19
|
+
* twice inside the window, the leg returns `blocked` before any obligation is
|
|
20
|
+
* asserted, so an unmeasured window can never fold into a partial-witness pass.
|
|
21
|
+
*
|
|
22
|
+
* How it FAILS: a per-attempt `randomUUID()` id (first half); a constant id or
|
|
23
|
+
* the subscription id (the distinctness half); an id containing `.` (grammar).
|
|
24
|
+
* The restart half of §C.10 (an attempt after a host restart) needs the RFC
|
|
25
|
+
* 0158 kill hook and is NOT claimed by this file.
|
|
26
|
+
*
|
|
27
|
+
* @see spec/v2/core/webhooks.md §Standard Webhooks
|
|
28
|
+
* @see RFCS/0201-standard-webhooks-signature-scheme.md §C.10
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { afterEach, describe, it, expect } from 'vitest';
|
|
32
|
+
import { req } from '../lib/requirement-ids.js';
|
|
33
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
34
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
35
|
+
import { hitHeader, mintWhsec, startModalReceiver, STANDARD_WEBHOOKS_ID, type ModalHit } from '../lib/webhook-receiver.js';
|
|
36
|
+
import { retryWaitCapMs, retryWaitFor } from '../lib/webhook-retry-window.js';
|
|
37
|
+
import {
|
|
38
|
+
STANDARD_WEBHOOKS_ALG,
|
|
39
|
+
deliveriesFor,
|
|
40
|
+
driveRun,
|
|
41
|
+
loopbackRefusal,
|
|
42
|
+
registerSw,
|
|
43
|
+
swGate,
|
|
44
|
+
unregisterAllSw,
|
|
45
|
+
waitFor,
|
|
46
|
+
} from '../lib/standard-webhooks.js';
|
|
47
|
+
|
|
48
|
+
const EVENTS = ['run.started', 'run.completed'];
|
|
49
|
+
const CAP_MS = retryWaitCapMs();
|
|
50
|
+
|
|
51
|
+
let closeReceiver: (() => Promise<void>) | null = null;
|
|
52
|
+
afterEach(async () => {
|
|
53
|
+
await unregisterAllSw();
|
|
54
|
+
if (closeReceiver) { const c = closeReceiver; closeReceiver = null; await c(); }
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function keyOf(h: ModalHit): string {
|
|
58
|
+
let runId = '';
|
|
59
|
+
let seq = '';
|
|
60
|
+
try {
|
|
61
|
+
const b = JSON.parse(h.body) as { runId?: unknown; event?: { sequence?: unknown } };
|
|
62
|
+
runId = String(b.runId ?? '');
|
|
63
|
+
seq = String(b.event?.sequence ?? '');
|
|
64
|
+
} catch { /* keyed on the header alone */ }
|
|
65
|
+
return `${hitHeader(h, 'openwop-webhook-id') ?? ''}|${runId}|${seq}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('RFC 0201 §C.10 — webhook-id is stable across retries, distinct across deliveries', () => {
|
|
69
|
+
it('every attempt of one delivery carries one webhook-id, and distinct deliveries carry distinct ids', async () => {
|
|
70
|
+
const g = await swGate();
|
|
71
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
72
|
+
const rx = await startModalReceiver();
|
|
73
|
+
closeReceiver = rx.close;
|
|
74
|
+
const target = rx.urlFor('fail2');
|
|
75
|
+
const subs: string[] = [];
|
|
76
|
+
for (let i = 0; i < 2; i++) {
|
|
77
|
+
const reg = await registerSw({ url: target.url, events: EVENTS, signatureAlgorithms: ['v1', STANDARD_WEBHOOKS_ALG], secret: mintWhsec() });
|
|
78
|
+
const blocked = loopbackRefusal(reg, target.tunnelled);
|
|
79
|
+
if (blocked) return softSkip('blocked', blocked);
|
|
80
|
+
if (reg.status !== 201) return softSkip('blocked', `the opt-in registration answered ${reg.status} ${readErrorCode(reg.json) ?? ''} — no subscription to observe (the registration rules are v2-webhook-standard-webhooks-delivery's rows)`);
|
|
81
|
+
subs.push((reg.json as { webhookId: string }).webhookId);
|
|
82
|
+
}
|
|
83
|
+
const run = await driveRun();
|
|
84
|
+
if (run.runId === null) return softSkip('blocked', `POST /runs answered ${run.status} — no delivery to observe`);
|
|
85
|
+
const ours = (): ModalHit[] => subs.flatMap((s) => deliveriesFor(rx.hits, s, run.runId));
|
|
86
|
+
const byKey = (): Map<string, ModalHit[]> => {
|
|
87
|
+
const m = new Map<string, ModalHit[]>();
|
|
88
|
+
for (const h of ours()) m.set(keyOf(h), [...(m.get(keyOf(h)) ?? []), h]);
|
|
89
|
+
return m;
|
|
90
|
+
};
|
|
91
|
+
const expectedKeys = subs.length * EVENTS.length;
|
|
92
|
+
const window = retryWaitFor((g.facet['retryPolicy'] as { backoff?: string } | undefined) ?? null, CAP_MS);
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
const m = byKey();
|
|
95
|
+
return m.size >= expectedKeys && [...m.values()].every((a) => a.length >= 2);
|
|
96
|
+
}, window);
|
|
97
|
+
const keys = byKey();
|
|
98
|
+
const retried = [...keys.values()].filter((a) => a.length >= 2);
|
|
99
|
+
// OBSERVE FIRST: nothing is asserted until the window question is answered.
|
|
100
|
+
if (retried.length === 0) {
|
|
101
|
+
return softSkip('blocked', `no delivery was attempted twice inside the ${window}ms window (${ours().length} attempt(s) over ${keys.size} key(s)); the receiver answers 500 twice per key, so a retrying host shows two — unmeasured, not unmet (RFC 0148 §A)`);
|
|
102
|
+
}
|
|
103
|
+
const ID = 'openwop.requirement.0201.message-id-stable';
|
|
104
|
+
for (const [key, attempts] of retried.map((a) => [keyOf(a[0]!), a] as const)) {
|
|
105
|
+
const ids = new Set(attempts.map((a) => hitHeader(a, 'webhook-id') ?? '<absent>'));
|
|
106
|
+
expect(
|
|
107
|
+
[...ids],
|
|
108
|
+
req(ID, 'RFC 0201 §C.10', `webhook-id MUST be identical on every attempt of one (webhookId, runId, sequence) — ${attempts.length} attempts of ${key} carried ${ids.size} value(s)`),
|
|
109
|
+
).toHaveLength(1);
|
|
110
|
+
}
|
|
111
|
+
const perKey = [...keys.entries()].map(([key, a]) => ({ key, id: hitHeader(a[0]!, 'webhook-id') ?? '' }));
|
|
112
|
+
for (const { key, id } of perKey) {
|
|
113
|
+
expect(STANDARD_WEBHOOKS_ID.test(id), req(ID, 'RFC 0201 §C.10', `webhook-id MUST match ^[A-Za-z0-9_-]{16,128}$ (${key}: ${JSON.stringify(id)})`)).toBe(true);
|
|
114
|
+
}
|
|
115
|
+
expect(keys.size, req(ID, 'RFC 0201 §C.10', `two opted-in subscriptions on ${EVENTS.join(' + ')} MUST yield ${expectedKeys} distinct deliveries of one run`)).toBeGreaterThanOrEqual(2);
|
|
116
|
+
expect(
|
|
117
|
+
new Set(perKey.map((k) => k.id)).size,
|
|
118
|
+
req(ID, 'RFC 0201 §C.10', `webhook-id MUST differ between distinct deliveries, by event and by subscription — ${perKey.length} deliveries carried ${new Set(perKey.map((k) => k.id)).size} distinct id(s)`),
|
|
119
|
+
).toBe(perKey.length);
|
|
120
|
+
for (const { id } of perKey) {
|
|
121
|
+
expect(subs.includes(id), req(ID, 'RFC 0201 §C.11', 'webhook-id MUST NOT be the subscription id')).toBe(false);
|
|
122
|
+
}
|
|
123
|
+
}, CAP_MS + 45_000);
|
|
124
|
+
});
|