@openwop/openwop-conformance 2.39.3 → 2.39.5
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 +17 -0
- package/README.md +1 -1
- package/dist/cli.js +8 -0
- package/dist/lib/pinned-ports.js +44 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +54 -22
- package/schemas/CORPUS-STAMP.json +10 -10
- package/src/cli.ts +8 -0
- package/src/lib/host-public-origin.ts +91 -0
- package/src/lib/oidc-issuer.ts +27 -0
- package/src/lib/pinned-ports.ts +48 -0
- package/src/lib/scoped-receiver.ts +26 -13
- package/src/scenarios/auth-oidc-user-bearer.test.ts +2 -2
- package/src/scenarios/v2-credential-interrupt.test.ts +8 -1
- package/src/scenarios/v2-idempotency-in-flight.test.ts +31 -0
- package/src/scenarios/v2-lane-exp-only-bound.test.ts +2 -3
- package/src/scenarios/v2-oidc-id-token-audience.test.ts +2 -3
- package/src/scenarios/v2-revocation-honored.test.ts +1 -1
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +23 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.39.5] — 2026-09-26 — RFC 0213 §B has a deterministic witness, and a host front whose URLs follow the request origin verifies
|
|
4
|
+
|
|
5
|
+
- **RFC 0213 §B has a deterministic witness: `0213.in-flight-refused-under-hold`.** On a host whose create answers in milliseconds the unaided `v2-idempotency-in-flight` leg records `partial-witness` — same-key creates never overlap. The new leg arms the seams-profile seam `armIdempotencyHold` (`POST /conformance/seams/sample/test/idempotency/hold`, host-sample-test-seams.md §26), which holds the caller's next real create's claim in flight; a concurrent same-key create MUST then be refused `409 idempotency_in_flight` by the host's own path (no retry timing in `details`), and the held create still wins. Seams not advertised ⇒ `inapplicable`; advertised but the path unserved ⇒ `blocked`.
|
|
6
|
+
- **`OPENWOP_HOST_PUBLIC_URL` verifies a host whose advertised URLs follow the request origin (fixes #1567).** 2.39.4 required the discovery document fetched through the declared front to be byte-identical to the one fetched over `--base-url`. A host that derives `agentCardUrl`, MCP `serverUrls` and similar values from the REQUEST origin embeds the loopback origin in one and the front's origin in the other, so the check could never pass on exactly the hosts it exists for. On the v2-reference public cut on suite 2.39.4 it was the only blocked row (`0199.credential-interrupt`), and it denied certification of an otherwise 397-pass / 0-fail bundle (openwop-1f). Both origins are now replaced by one placeholder in both documents before the structural comparison, because a host with a public base may embed either origin in either response. Everything else must still match, so a front serving another host is still refused. Checked against real v2-reference discovery fetched both ways (a local boot with a public base); two new self-tests cover the measured shape, and removing the normalisation fails the regression test.
|
|
7
|
+
- **The RFC 0170 credential mint/revoke seams are in the seams contract.** `v2-revocation-honored` drives `POST …/auth/credential/mint` and `…/auth/credential/revoke`. RFC 0170 §B.3 names them, and the v2 reference host has served them. Until now neither `api/seams-v2.yaml` nor `host-sample-test-seams.md` defined them, so a host advertising `openwop-conformance-seams-v2` could not know it owed them, and under `--require-behavior` their absence was a hard fail. openwop-app's seams-on in-memory boot hit exactly that on 2026-09-25. Added as `mintLaneCredential` / `revokeLaneCredential`, in the shape the reference host already serves (not a new shape), with the prose bullet beside the RFC 0199 seams. The scenario's `seamAbsent` message now names the contract instead of calling the seam unspecified. No committed bundle changes: MyndHyve's and openwop-app's do not advertise the seams profile (the row is `inapplicable`), and v2-reference's passes it.
|
|
8
|
+
- **RFC 0215 gaps G3 and G6 close in the contract peer.** `@openwop/spec-artifacts`' `spec/v1/gaps.json` records the prior-art survey and the threat-model home (`threat-model-secret-leakage.md` §4.12). No scenario change.
|
|
9
|
+
- **Version moved ahead of publication.** `@openwop/openwop-conformance` and its exact-pinned peer `@openwop/spec-artifacts` move to `2.39.5` because `2.39.4` is tagged. Not tagged, not published.
|
|
10
|
+
|
|
11
|
+
## [2.39.4] — 2026-09-25 — RFC 0199 can be witnessed behind a public front, and the pinned-port claim is race-free
|
|
12
|
+
|
|
13
|
+
- **`0199.credential-interrupt` can be witnessed on a host with a public front: `OPENWOP_HOST_PUBLIC_URL`.** RFC 0199 §C.2 requires `connectUrl` to be an https URL "on the host's own origin". The leg compared it to `--base-url`, but a certification cut drives the host over loopback, which can never be the https origin a user agent is sent to. So the v2 reference host, which implements all of §C, could never advertise `oauth.credentialInterrupt` in a cut, and RFC 0199's four credential rows never ran. New `src/lib/host-public-origin.ts` reads an operator-declared front for the host, validated like every other suite front (https, publicly resolvable). It counts as the host's own origin only once the discovery document fetched through it equals the one over `--base-url`; otherwise the leg records `blocked`, so a front pointing at another host can never let a `connectUrl` on that host's origin pass. Unset, behaviour is unchanged (`--base-url`'s origin, and no fetch). Self-test: 5 cases, including a front that serves a different host.
|
|
14
|
+
- **The synthetic OIDC issuer binds a local port the operator names, not the port parsed from the URL the host trusts.** `v2-lane-exp-only-bound`, `v2-oidc-id-token-audience` and `auth-oidc-user-bearer` bound `127.0.0.1` at the port parsed from `OPENWOP_TEST_OIDC_ISSUER_URL`. For a remote host that URL is a public https front with no port, so the suite bound :80, and a non-root operator got `EACCES`. This was measured by openwop-app-ce on 2026-09-25 against a remote openwop-app instance. No remote host could execute the RFC 0200 / RFC 0210 issuer rows, so none could certify. New `issuerListenPort()` (`lib/oidc-issuer.ts`) reads `OPENWOP_TEST_OIDC_ISSUER_PORT` when set: the local port the front forwards to, the same split the webhook receiver has always had (`_URL` + `_PORT`). Unset, the old rule is unchanged. A malformed value is refused rather than silently falling back. The variable is added to `PINNED_PORT_ENVS`, so a multi-worker `--certify` that pins it is refused. Host-free legs in `oidc-issuer.test.ts` and `pinned-ports.test.ts` go red with the fix removed.
|
|
15
|
+
- **The shared pinned-port listener's claim is taken before the first await.** In 2.39.3 a scoped receiver read the shared listener's promise and awaited it; if the last holder closed in that window it dropped the count to zero and shut the server, and the starting receiver then counted itself onto a closed listener — its deliveries could never arrive. The claim (a per-port entry's `refs`) is now incremented synchronously, and released if the bind fails. New self-test `a sibling that is STARTING while the last holder closes still gets a live listener` reproduces the race (ECONNREFUSED before the fix; sabotage-proven by moving the claim back after the await). Found in review of #1559; it was NOT the cause of the 2.39.3 public-cut failures — a pinned-port loopback A/B on 2.39.3 matched the ephemeral rehearsal exactly (376 / 7 / 4, no new non-pass), which points those at tunnel/load starvation.
|
|
16
|
+
- **`--certify` refuses pinned fixture ports unless `--max-workers 1`.** Every fixture reached through an operator's public front listens on a pinned `*_PORT`, and the registry that routes a nonce-pathed request to its exercise lives in one vitest worker. With more than one worker, only one can own each port, and an exercise in another worker loses the host's traffic. `cut-bundle.sh` always passed `--max-workers 1`, but nothing enforced it: an openwop-app production cut run with 2 workers recorded `0173.webhook-durable-delivery` and `0187.bound-id-kinds.webhook-emitted` as timeouts for deliveries the host had made, and the same host passed both single-worker (measured by openwop-app-ce, 2026-09-25). The CLI now exits 2 before running (`src/lib/pinned-ports.ts`, with a self-test), naming the pinned variables it found, including the OAuth doubles' runtime-derived ports.
|
|
17
|
+
- **`v2-webhook-durable-delivery` refuses only as many attempts as the host's advertised policy allows.** The receiver refused 2 attempts regardless, but `webhooks.retryPolicy.maxAttempts` is schema-valid from 1, so a host honestly advertising `maxAttempts: 2` never reached its 204 and failed a leg it conformed to. It now refuses `maxAttempts − 1`, capped at 2, with a minimum of 1. A host with no policy is measured as before, and `maxAttempts: 1` still refuses the first attempt, so a host that never retries still fails (webhooks.md §Durability: best-effort delivery is not a conforming mode).
|
|
18
|
+
- **Version moved ahead of publication.** `@openwop/openwop-conformance` and its exact-pinned peer `@openwop/spec-artifacts` move to `2.39.4` because `2.39.3` is tagged and published. Not tagged, not published.
|
|
19
|
+
|
|
3
20
|
## [2.39.3] — 2026-09-25 — a pinned receiver port is shared, and tenant B really is tenant B
|
|
4
21
|
|
|
5
22
|
- **A pinned receiver port is shared, never re-bound — the public-cut hang behind three RFC 0214 legs.** On a public cut `OPENWOP_WEBHOOK_RECEIVER_PORT` is pinned (the port the front forwards to), and `lib/scoped-receiver.ts` had every receiver call `server.listen` on it. A second receiver alive at the same time — the §B redirect and §A after-Delete legs start two, and the §A/§C delivery receiver stays open until `afterAll` — hit `EADDRINUSE` with no `error` listener attached, so its `listen` promise never settled and the leg hung to vitest's timeout: `v2-a2a-push-delivery` §A-delete, §B-redirect and §D-fork on both the 2.39.0 and 2.39.2 public cuts of the v2 reference host. Loopback binds ephemeral ports, which is why no rehearsal showed it. All receivers on a pinned port now share one ref-counted listener (routing was already per nonce); a bind failure now rejects at once with a message naming the port; `close()` no longer waits on keep-alive sockets. Three lib self-tests pin it (two concurrent receivers; close one, the sibling keeps serving; a squatted port rejects in < 5 s) — reverting to per-receiver `listen` fails the first two in milliseconds.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# --legacy-peer-deps is REQUIRED, not optional: the exact peer pin is what npm's
|
|
12
12
|
# default resolver refuses. npm 10.9 fails outright with
|
|
13
13
|
# "Cannot read properties of null (reading 'edgesOut')" — use npm >= 11.
|
|
14
|
-
npm install --legacy-peer-deps @openwop/openwop-conformance@2.39.
|
|
14
|
+
npm install --legacy-peer-deps @openwop/openwop-conformance@2.39.5 @openwop/spec-artifacts@2.39.5
|
|
15
15
|
# or run without install:
|
|
16
16
|
npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
|
|
17
17
|
```
|
package/dist/cli.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* 1 one or more scenarios failed
|
|
32
32
|
* 2 suite couldn't start (missing required args, etc)
|
|
33
33
|
*/
|
|
34
|
+
import { pinnedPortWorkerConflict } from './lib/pinned-ports.js';
|
|
34
35
|
import { spawnSync } from 'node:child_process';
|
|
35
36
|
import { fileURLToPath } from 'node:url';
|
|
36
37
|
import { dirname, resolve as resolvePath, join } from 'node:path';
|
|
@@ -855,6 +856,13 @@ async function runCertify(args, baseUrl, apiKey) {
|
|
|
855
856
|
}
|
|
856
857
|
async function main() {
|
|
857
858
|
const args = parseArgs(process.argv.slice(2));
|
|
859
|
+
// Refused before anything runs: a pinned-port certification that is not
|
|
860
|
+
// single-worker loses the host's traffic to a worker nobody reads (lib/pinned-ports.ts).
|
|
861
|
+
const pinnedConflict = pinnedPortWorkerConflict(process.env, args.maxWorkers, args.certify !== undefined);
|
|
862
|
+
if (pinnedConflict !== null) {
|
|
863
|
+
process.stderr.write(`openwop-conformance: ${pinnedConflict}\n`);
|
|
864
|
+
process.exit(2);
|
|
865
|
+
}
|
|
858
866
|
if (args.help) {
|
|
859
867
|
process.stdout.write(HELP_TEXT);
|
|
860
868
|
process.exit(0);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A certification run with pinned fixture ports MUST be single-worker.
|
|
3
|
+
*
|
|
4
|
+
* Every suite fixture reached through an operator's public front listens on a
|
|
5
|
+
* pinned port (`*_PORT`), and the fixture registry that routes a nonce-pathed
|
|
6
|
+
* request to the exercise that minted it (`front-mux.ts`, `scoped-receiver.ts`)
|
|
7
|
+
* lives in ONE vitest worker's memory. With two workers only one can own each
|
|
8
|
+
* pinned port; an exercise in the other worker is unreachable, or its traffic is
|
|
9
|
+
* answered by a registry nobody reads. `cut-bundle.sh` has always passed
|
|
10
|
+
* `--max-workers 1`, and `effect-receiver.ts` assumes it — but nothing enforced
|
|
11
|
+
* it. Measured 2026-09-25: an openwop-app production cut run with
|
|
12
|
+
* `--max-workers 2` recorded `0173.webhook-durable-delivery` and
|
|
13
|
+
* `0187.bound-id-kinds.webhook-emitted` as test timeouts while the host had in
|
|
14
|
+
* fact delivered; the same host passed both single-worker. A certification that
|
|
15
|
+
* silently loses the host's traffic convicts the host, so the CLI refuses it.
|
|
16
|
+
*/
|
|
17
|
+
/** Every pinned-port variable a suite fixture honours. The OAuth doubles derive
|
|
18
|
+
* theirs from `<FRONT>_URL` at runtime, so they are listed rather than grepped. */
|
|
19
|
+
export const PINNED_PORT_ENVS = [
|
|
20
|
+
'OPENWOP_WEBHOOK_RECEIVER_PORT',
|
|
21
|
+
'OPENWOP_A2A_FAKE_PEER_PORT',
|
|
22
|
+
'OPENWOP_MCP_FAKE_SERVER_PORT',
|
|
23
|
+
'OPENWOP_OAUTH_AS_PORT',
|
|
24
|
+
'OPENWOP_OAUTH_AS2_PORT',
|
|
25
|
+
'OPENWOP_OAUTH_RESOURCE_PORT',
|
|
26
|
+
'OPENWOP_OTEL_COLLECTOR_PORT',
|
|
27
|
+
'OPENWOP_OTEL_COLLECTOR_GRPC_PORT',
|
|
28
|
+
// 2.39.4: the synthetic OIDC issuer's local bind port (`issuerListenPort`,
|
|
29
|
+
// oidc-issuer.ts). Two scenarios stand an issuer up on it.
|
|
30
|
+
'OPENWOP_TEST_OIDC_ISSUER_PORT',
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* The refusal message when a `--certify` run pins fixture ports without being
|
|
34
|
+
* single-worker, or `null` when the run is acceptable. `maxWorkers` undefined is
|
|
35
|
+
* vitest's default — one worker per CPU — so it is refused too.
|
|
36
|
+
*/
|
|
37
|
+
export function pinnedPortWorkerConflict(env, maxWorkers, certifying) {
|
|
38
|
+
if (!certifying)
|
|
39
|
+
return null;
|
|
40
|
+
const pinned = PINNED_PORT_ENVS.filter((k) => (env[k] ?? '').trim() !== '');
|
|
41
|
+
if (pinned.length === 0 || maxWorkers === 1)
|
|
42
|
+
return null;
|
|
43
|
+
return `--certify with pinned fixture ports (${pinned.join(', ')}) requires --max-workers 1 (got ${maxWorkers === undefined ? "vitest's default, one per CPU" : maxWorkers}): a pinned port is owned by one worker, so an exercise in another worker cannot receive the host's traffic and its rows would convict the host of a delivery it made`;
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.39.
|
|
3
|
+
"version": "2.39.5",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.39.
|
|
59
|
+
"@openwop/spec-artifacts": "2.39.5"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
5
|
"files": 633,
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
6
|
+
"tests": 2425,
|
|
7
|
+
"withStableId": 2425,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
|
-
"explicitIds":
|
|
9
|
+
"explicitIds": 2305
|
|
10
10
|
},
|
|
11
11
|
"records": [
|
|
12
12
|
{
|
|
@@ -5706,7 +5706,7 @@
|
|
|
5706
5706
|
{
|
|
5707
5707
|
"id": "openwop.it.auth-oidc-user-bearer.host-claiming-oidc-profile-advertises-required-fields",
|
|
5708
5708
|
"file": "auth-oidc-user-bearer.test.ts",
|
|
5709
|
-
"line":
|
|
5709
|
+
"line": 93,
|
|
5710
5710
|
"title": "host claiming OIDC profile advertises required fields",
|
|
5711
5711
|
"explicitId": "openwop.it.auth-oidc-user-bearer.host-claiming-oidc-profile-advertises-required-fields",
|
|
5712
5712
|
"citations": [
|
|
@@ -28290,7 +28290,7 @@
|
|
|
28290
28290
|
{
|
|
28291
28291
|
"id": "openwop.it.v2-credential-interrupt.a-node-with-no-credential-suspends-on-a-credential-interrupt-with-a-closed-crede",
|
|
28292
28292
|
"file": "v2-credential-interrupt.test.ts",
|
|
28293
|
-
"line":
|
|
28293
|
+
"line": 109,
|
|
28294
28294
|
"title": "a node with no credential suspends on a credential interrupt with a closed CredentialData, status waiting-input",
|
|
28295
28295
|
"explicitId": null,
|
|
28296
28296
|
"citations": [
|
|
@@ -28327,7 +28327,7 @@
|
|
|
28327
28327
|
{
|
|
28328
28328
|
"id": "openwop.it.v2-credential-interrupt.a-caller-resolve-of-authorized-is-refused-while-no-credential-resolves-and-a-res",
|
|
28329
28329
|
"file": "v2-credential-interrupt.test.ts",
|
|
28330
|
-
"line":
|
|
28330
|
+
"line": 135,
|
|
28331
28331
|
"title": "a caller resolve of authorized is refused while no credential resolves, and a resume value cannot carry a credential",
|
|
28332
28332
|
"explicitId": null,
|
|
28333
28333
|
"citations": [
|
|
@@ -28358,7 +28358,7 @@
|
|
|
28358
28358
|
{
|
|
28359
28359
|
"id": "openwop.it.v2-credential-interrupt.connecturl-is-not-pre-authenticated-and-begins-the-grant-only-for-the-initiating",
|
|
28360
28360
|
"file": "v2-credential-interrupt.test.ts",
|
|
28361
|
-
"line":
|
|
28361
|
+
"line": 152,
|
|
28362
28362
|
"title": "connectUrl is not pre-authenticated and begins the grant only for the initiating Subject",
|
|
28363
28363
|
"explicitId": null,
|
|
28364
28364
|
"citations": [
|
|
@@ -28391,7 +28391,7 @@
|
|
|
28391
28391
|
{
|
|
28392
28392
|
"id": "openwop.it.v2-credential-interrupt.the-grant-completes-through-connecturl-the-host-resolves-the-interrupt-itself-an",
|
|
28393
28393
|
"file": "v2-credential-interrupt.test.ts",
|
|
28394
|
-
"line":
|
|
28394
|
+
"line": 175,
|
|
28395
28395
|
"title": "the grant completes through connectUrl, the host resolves the interrupt itself and the run continues",
|
|
28396
28396
|
"explicitId": null,
|
|
28397
28397
|
"citations": [
|
|
@@ -28423,7 +28423,7 @@
|
|
|
28423
28423
|
{
|
|
28424
28424
|
"id": "openwop.it.v2-credential-interrupt.declined-fails-the-node-with-connector-auth-declined",
|
|
28425
28425
|
"file": "v2-credential-interrupt.test.ts",
|
|
28426
|
-
"line":
|
|
28426
|
+
"line": 195,
|
|
28427
28427
|
"title": "declined fails the node with connector_auth_declined",
|
|
28428
28428
|
"explicitId": null,
|
|
28429
28429
|
"citations": [
|
|
@@ -28450,7 +28450,7 @@
|
|
|
28450
28450
|
{
|
|
28451
28451
|
"id": "openwop.it.v2-credential-interrupt.a-terminal-refresh-failure-emits-connector-auth-expired-then-suspends-with-reaso",
|
|
28452
28452
|
"file": "v2-credential-interrupt.test.ts",
|
|
28453
|
-
"line":
|
|
28453
|
+
"line": 210,
|
|
28454
28454
|
"title": "a terminal refresh failure emits connector.auth-expired, then suspends with reason expired",
|
|
28455
28455
|
"explicitId": null,
|
|
28456
28456
|
"citations": [
|
|
@@ -28487,7 +28487,7 @@
|
|
|
28487
28487
|
{
|
|
28488
28488
|
"id": "openwop.it.v2-credential-interrupt.without-oauth-credentialinterrupt-a-terminal-refresh-failure-fails-the-node-conn",
|
|
28489
28489
|
"file": "v2-credential-interrupt.test.ts",
|
|
28490
|
-
"line":
|
|
28490
|
+
"line": 238,
|
|
28491
28491
|
"title": "without oauth.credentialInterrupt, a terminal refresh failure fails the node connector_auth_expired and raises no interrupt",
|
|
28492
28492
|
"explicitId": null,
|
|
28493
28493
|
"citations": [
|
|
@@ -29279,7 +29279,7 @@
|
|
|
29279
29279
|
{
|
|
29280
29280
|
"id": "openwop.it.v2-idempotency-in-flight.concurrent-same-key-creates-yield-exactly-one-run",
|
|
29281
29281
|
"file": "v2-idempotency-in-flight.test.ts",
|
|
29282
|
-
"line":
|
|
29282
|
+
"line": 105,
|
|
29283
29283
|
"title": "concurrent same-key creates yield exactly one run",
|
|
29284
29284
|
"explicitId": "openwop.requirement.0213.in-flight-one-winner",
|
|
29285
29285
|
"citations": [
|
|
@@ -29298,7 +29298,7 @@
|
|
|
29298
29298
|
{
|
|
29299
29299
|
"id": "openwop.it.v2-idempotency-in-flight.each-loser-is-a-marked-replay-of-a-final-outcome-or-409-idempotency-in-flight-wi",
|
|
29300
29300
|
"file": "v2-idempotency-in-flight.test.ts",
|
|
29301
|
-
"line":
|
|
29301
|
+
"line": 113,
|
|
29302
29302
|
"title": "each loser is a marked replay of a final outcome, or 409 idempotency_in_flight with no retry timing in details",
|
|
29303
29303
|
"explicitId": "openwop.requirement.0213.in-flight-loser-outcome",
|
|
29304
29304
|
"citations": [
|
|
@@ -29324,6 +29324,38 @@
|
|
|
29324
29324
|
}
|
|
29325
29325
|
]
|
|
29326
29326
|
},
|
|
29327
|
+
{
|
|
29328
|
+
"id": "openwop.it.v2-idempotency-in-flight.while-the-seam-holds-the-claim-a-same-key-create-is-refused-409-idempotency-in-f",
|
|
29329
|
+
"file": "v2-idempotency-in-flight.test.ts",
|
|
29330
|
+
"line": 138,
|
|
29331
|
+
"title": "while the seam holds the claim, a same-key create is refused 409 idempotency_in_flight by the host and the held create still wins",
|
|
29332
|
+
"explicitId": "openwop.requirement.0213.in-flight-refused-under-hold",
|
|
29333
|
+
"citations": [
|
|
29334
|
+
{
|
|
29335
|
+
"section": "spec/v2/core/idempotency.md Concurrency",
|
|
29336
|
+
"requirement": null,
|
|
29337
|
+
"interpolated": true
|
|
29338
|
+
},
|
|
29339
|
+
{
|
|
29340
|
+
"section": "spec/v2/core/errors.md §Retry timing",
|
|
29341
|
+
"requirement": "retry timing MUST NOT travel in details"
|
|
29342
|
+
},
|
|
29343
|
+
{
|
|
29344
|
+
"section": "spec/v2/core/idempotency.md Concurrency",
|
|
29345
|
+
"requirement": null,
|
|
29346
|
+
"interpolated": true
|
|
29347
|
+
},
|
|
29348
|
+
{
|
|
29349
|
+
"section": "spec/v2/core/idempotency.md Concurrency",
|
|
29350
|
+
"requirement": null,
|
|
29351
|
+
"interpolated": true
|
|
29352
|
+
},
|
|
29353
|
+
{
|
|
29354
|
+
"section": "spec/v2/core/idempotency.md Concurrency",
|
|
29355
|
+
"requirement": "after the held create completes, a same-key create is a marked replay of the one run"
|
|
29356
|
+
}
|
|
29357
|
+
]
|
|
29358
|
+
},
|
|
29327
29359
|
{
|
|
29328
29360
|
"id": "openwop.it.v2-idempotency-key-grammar.a-22-character-key-inside-the-grammar-is-accepted",
|
|
29329
29361
|
"file": "v2-idempotency-key-grammar.test.ts",
|
|
@@ -29585,7 +29617,7 @@
|
|
|
29585
29617
|
{
|
|
29586
29618
|
"id": "openwop.it.v2-lane-exp-only-bound.a-credential-inside-the-advertised-window-is-accepted-the-control-the-two-refusa",
|
|
29587
29619
|
"file": "v2-lane-exp-only-bound.test.ts",
|
|
29588
|
-
"line":
|
|
29620
|
+
"line": 149,
|
|
29589
29621
|
"title": "a credential inside the advertised window is accepted — the control the two refusals rest on",
|
|
29590
29622
|
"explicitId": "openwop.requirement.0210.exp-only-control-accepted",
|
|
29591
29623
|
"citations": [
|
|
@@ -29599,7 +29631,7 @@
|
|
|
29599
29631
|
{
|
|
29600
29632
|
"id": "openwop.it.v2-lane-exp-only-bound.a-credential-whose-total-lifetime-exceeds-the-window-is-refused-401-and-so-is-on",
|
|
29601
29633
|
"file": "v2-lane-exp-only-bound.test.ts",
|
|
29602
|
-
"line":
|
|
29634
|
+
"line": 164,
|
|
29603
29635
|
"title": "a credential whose TOTAL lifetime exceeds the window is refused 401, and so is one carrying no iat",
|
|
29604
29636
|
"explicitId": "openwop.requirement.0210.exp-only-lifetime-bound",
|
|
29605
29637
|
"citations": [
|
|
@@ -29618,7 +29650,7 @@
|
|
|
29618
29650
|
{
|
|
29619
29651
|
"id": "openwop.it.v2-lane-exp-only-bound.the-over-lifetime-refusal-names-credential-lifetime-exceeded-not-a-generic-unaut",
|
|
29620
29652
|
"file": "v2-lane-exp-only-bound.test.ts",
|
|
29621
|
-
"line":
|
|
29653
|
+
"line": 199,
|
|
29622
29654
|
"title": "the over-lifetime refusal names credential_lifetime_exceeded, not a generic unauthenticated",
|
|
29623
29655
|
"explicitId": "openwop.requirement.0210.credential-lifetime-code",
|
|
29624
29656
|
"citations": [
|
|
@@ -29632,7 +29664,7 @@
|
|
|
29632
29664
|
{
|
|
29633
29665
|
"id": "openwop.it.v2-lane-exp-only-bound.a-credential-whose-remaining-lifetime-exceeds-the-window-is-refused-though-its-t",
|
|
29634
29666
|
"file": "v2-lane-exp-only-bound.test.ts",
|
|
29635
|
-
"line":
|
|
29667
|
+
"line": 217,
|
|
29636
29668
|
"title": "a credential whose REMAINING lifetime exceeds the window is refused, though its total lifetime does not",
|
|
29637
29669
|
"explicitId": "openwop.requirement.0210.exp-only-remaining-bound",
|
|
29638
29670
|
"citations": [
|
|
@@ -31179,7 +31211,7 @@
|
|
|
31179
31211
|
{
|
|
31180
31212
|
"id": "openwop.it.v2-oidc-id-token-audience.a-same-audience-id-token-is-admissible-and-one-minted-for-another-relying-party",
|
|
31181
31213
|
"file": "v2-oidc-id-token-audience.test.ts",
|
|
31182
|
-
"line":
|
|
31214
|
+
"line": 74,
|
|
31183
31215
|
"title": "a same-audience ID token is admissible, and one minted for another relying party is audience_mismatch",
|
|
31184
31216
|
"explicitId": "openwop.requirement.0200.id-token-aud",
|
|
31185
31217
|
"citations": [
|
|
@@ -33188,7 +33220,7 @@
|
|
|
33188
33220
|
{
|
|
33189
33221
|
"id": "openwop.it.v2-webhook-durable-delivery.a-failed-attempt-is-retried-and-the-event-is-delivered-at-least-once",
|
|
33190
33222
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
33191
|
-
"line":
|
|
33223
|
+
"line": 256,
|
|
33192
33224
|
"title": "a failed attempt is retried and the event is delivered at least once",
|
|
33193
33225
|
"explicitId": "openwop.requirement.0173.webhook-durable-delivery",
|
|
33194
33226
|
"citations": [
|
|
@@ -33220,7 +33252,7 @@
|
|
|
33220
33252
|
{
|
|
33221
33253
|
"id": "openwop.it.v2-webhook-durable-delivery.an-exhausted-delivery-is-dead-lettered-never-dropped",
|
|
33222
33254
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
33223
|
-
"line":
|
|
33255
|
+
"line": 358,
|
|
33224
33256
|
"title": "an exhausted delivery is dead-lettered, never dropped",
|
|
33225
33257
|
"explicitId": "openwop.requirement.0173.webhook-durable-delivery.dead-letter",
|
|
33226
33258
|
"citations": [
|
|
@@ -33254,7 +33286,7 @@
|
|
|
33254
33286
|
{
|
|
33255
33287
|
"id": "openwop.it.v2-webhook-durable-delivery.the-dead-letter-read-is-served-and-its-records-carry-no-payload",
|
|
33256
33288
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
33257
|
-
"line":
|
|
33289
|
+
"line": 452,
|
|
33258
33290
|
"title": "the dead-letter read is served and its records carry no payload",
|
|
33259
33291
|
"explicitId": "openwop.requirement.0188.dead-letter-read",
|
|
33260
33292
|
"citations": [
|
|
@@ -33267,7 +33299,7 @@
|
|
|
33267
33299
|
{
|
|
33268
33300
|
"id": "openwop.it.v2-webhook-durable-delivery.a-dead-letter-record-carries-no-delivered-payload",
|
|
33269
33301
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
33270
|
-
"line":
|
|
33302
|
+
"line": 473,
|
|
33271
33303
|
"title": "a dead-letter record carries no delivered payload",
|
|
33272
33304
|
"explicitId": "openwop.requirement.0188.dead-letter-content-free",
|
|
33273
33305
|
"citations": [
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
|
|
3
3
|
"package": "@openwop/spec-artifacts",
|
|
4
|
-
"version": "2.39.
|
|
5
|
-
"corpusTag": "v2.39.
|
|
4
|
+
"version": "2.39.5",
|
|
5
|
+
"corpusTag": "v2.39.5",
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
8
8
|
"api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
|
|
9
9
|
"api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
|
|
10
10
|
"api/openapi.yaml": "6366b5514be71098e9ea41fc81ff52bc39db1a547b353c0b344cc2a2bf5370aa",
|
|
11
11
|
"api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
|
|
12
|
-
"api/seams-v2.yaml": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "ed925b14e1e3e6f82ec0d0c844c035ae1a3b0dbcf10b3e53b96106327298f5cb",
|
|
13
|
+
"api/v2/asyncapi.yaml": "a3ba4c688bd4ee33400ef23718eac3d03bc333df2b3d74644adc6f0b2b3a5b2e",
|
|
14
|
+
"api/v2/openapi.yaml": "e4dd5dafff70be8c64de8344c3ae5dd5525515a069fef6f421a226813a305344",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
|
|
@@ -204,13 +204,13 @@
|
|
|
204
204
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
205
205
|
"spec/v1/alias-detectors.json": "2401fcb1c18cdd688c018b3d716ae6bca85c5e356220ee2b793bd9d81872412d",
|
|
206
206
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
207
|
-
"spec/v1/core-standard-manifest.json": "
|
|
207
|
+
"spec/v1/core-standard-manifest.json": "a2f4f866565b5da642b36384a235bf57a293366b7f9faeb0e7f62c3132337932",
|
|
208
208
|
"spec/v1/deprecations.json": "2d03f4729810280147ea08c630ea65434f2dc5370567d0aac41c3595f8337d40",
|
|
209
209
|
"spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
|
|
210
210
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
211
211
|
"spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
|
|
212
212
|
"spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
|
|
213
|
-
"spec/v1/gaps.json": "
|
|
213
|
+
"spec/v1/gaps.json": "9424f94441ba5c4d1a8027e1f59be832f46af1a67ff0ca140adca9bb501ee993",
|
|
214
214
|
"spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
|
|
215
215
|
"spec/v1/migrations.json": "8839497d6a4830b53fa7867014a8336f0d725ef2346352e54fb580b7947897af",
|
|
216
216
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
@@ -292,9 +292,9 @@
|
|
|
292
292
|
"spec/v2/path-manifest.json": "ae9b56d03a701061fd48ad0b73cb0373493f6a4547da92763b422295b5e19535",
|
|
293
293
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
294
294
|
"spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
|
|
295
|
-
"spec/v2/release.json": "
|
|
295
|
+
"spec/v2/release.json": "c3d35fbfae242c3fa3a504edb08fbdbc7ee9dd003250ea7225a0f6641dc614b9",
|
|
296
296
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
297
|
-
"spec/v2/surface-baseline.json": "
|
|
297
|
+
"spec/v2/surface-baseline.json": "4273f2b75cfde4c620c995b5a4c334cfc15d936dfa7f5b6fa0aa4bf257e8b8b5"
|
|
298
298
|
},
|
|
299
|
-
"corpusCommit": "
|
|
299
|
+
"corpusCommit": "718fae2de800b73f48a59237cfb843578b0433f1"
|
|
300
300
|
}
|
package/src/cli.ts
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
* 2 suite couldn't start (missing required args, etc)
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
+
import { pinnedPortWorkerConflict } from './lib/pinned-ports.js';
|
|
35
36
|
import { spawnSync } from 'node:child_process';
|
|
36
37
|
import { fileURLToPath } from 'node:url';
|
|
37
38
|
import { dirname, resolve as resolvePath, join } from 'node:path';
|
|
@@ -907,6 +908,13 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
907
908
|
|
|
908
909
|
async function main(): Promise<never> {
|
|
909
910
|
const args = parseArgs(process.argv.slice(2));
|
|
911
|
+
// Refused before anything runs: a pinned-port certification that is not
|
|
912
|
+
// single-worker loses the host's traffic to a worker nobody reads (lib/pinned-ports.ts).
|
|
913
|
+
const pinnedConflict = pinnedPortWorkerConflict(process.env, args.maxWorkers, args.certify !== undefined);
|
|
914
|
+
if (pinnedConflict !== null) {
|
|
915
|
+
process.stderr.write(`openwop-conformance: ${pinnedConflict}\n`);
|
|
916
|
+
process.exit(2);
|
|
917
|
+
}
|
|
910
918
|
|
|
911
919
|
if (args.help) {
|
|
912
920
|
process.stdout.write(HELP_TEXT);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The origin a USER AGENT reaches the host under test on — for rows whose
|
|
3
|
+
* normative text is about the host's own origin rather than the suite's
|
|
4
|
+
* transport (RFC 0199 §C.2: `connectUrl` "MUST be an `https` URL on the host's
|
|
5
|
+
* own origin").
|
|
6
|
+
*
|
|
7
|
+
* Until 2.39.4 those rows compared against `--base-url`. A certification cut
|
|
8
|
+
* drives the host over loopback (`http://127.0.0.1:…`), which can never be the
|
|
9
|
+
* https origin a user agent is sent to — so a host with a public front could not
|
|
10
|
+
* pass, and the v2 reference host, which implements all of §C, advertised none of
|
|
11
|
+
* it (it needs an https public base to advertise `oauth.credentialInterrupt`).
|
|
12
|
+
*
|
|
13
|
+
* `OPENWOP_HOST_PUBLIC_URL` names that front, validated like every other suite
|
|
14
|
+
* front (`resolvePublicFront`: https, publicly resolvable). A declared front is
|
|
15
|
+
* EVIDENCE only once it is shown to serve THIS host: the discovery document
|
|
16
|
+
* fetched through it must equal the one fetched over `--base-url`. Otherwise the
|
|
17
|
+
* rows that depend on it record `blocked` with that cause — a front pointing at
|
|
18
|
+
* some other host must not let a `connectUrl` on that other origin pass.
|
|
19
|
+
*
|
|
20
|
+
* Unset, this returns `--base-url`'s origin exactly as before: no behaviour
|
|
21
|
+
* changes for a run that does not declare a front.
|
|
22
|
+
*/
|
|
23
|
+
import { resolvePublicFront } from './webhook-receiver.js';
|
|
24
|
+
|
|
25
|
+
export const HOST_FRONT_ENV = 'OPENWOP_HOST_PUBLIC_URL';
|
|
26
|
+
|
|
27
|
+
export type HostOrigin =
|
|
28
|
+
| { readonly ok: true; readonly origin: string; readonly declared: boolean }
|
|
29
|
+
| { readonly ok: false; readonly reason: string };
|
|
30
|
+
|
|
31
|
+
type Fetcher = (url: string) => Promise<{ status: number; text: string }>;
|
|
32
|
+
|
|
33
|
+
const defaultFetch: Fetcher = async (url) => {
|
|
34
|
+
const res = await fetch(url, { headers: { accept: 'application/json', 'OpenWOP-Version': '2.0' }, signal: AbortSignal.timeout(15_000) });
|
|
35
|
+
return { status: res.status, text: await res.text() };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Replace every occurrence of either origin with one placeholder, in every
|
|
40
|
+
* string value. A host that derives its advertised URLs (`agentCardUrl`, MCP
|
|
41
|
+
* `serverUrls`, …) from the REQUEST origin embeds `http://127.0.0.1:…` in the
|
|
42
|
+
* document fetched over loopback and the tunnel origin in the one fetched
|
|
43
|
+
* through the front — so byte equality could never hold on exactly the hosts
|
|
44
|
+
* this helper exists for (measured on the v2-reference public cut on suite
|
|
45
|
+
* 2.39.4, openwop-1f: the only blocked row). A host with a public base may also
|
|
46
|
+
* embed EITHER origin in EITHER response, so both origins are normalised in both
|
|
47
|
+
* documents. Everything else must still match: a front serving another host
|
|
48
|
+
* differs in far more than its origin.
|
|
49
|
+
*/
|
|
50
|
+
function withoutOrigins(v: unknown, origins: readonly string[]): unknown {
|
|
51
|
+
if (typeof v === 'string') return origins.reduce((acc, o) => acc.split(o).join('<origin>'), v);
|
|
52
|
+
if (Array.isArray(v)) return v.map((x) => withoutOrigins(x, origins));
|
|
53
|
+
if (v !== null && typeof v === 'object') return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, withoutOrigins(x, origins)]));
|
|
54
|
+
return v;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Key-order-independent JSON equality — the two fetches may serialise differently. */
|
|
58
|
+
function canonical(v: unknown): string {
|
|
59
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
60
|
+
if (v !== null && typeof v === 'object') return `{${Object.keys(v as Record<string, unknown>).sort().map((k) => `${JSON.stringify(k)}:${canonical((v as Record<string, unknown>)[k])}`).join(',')}}`;
|
|
61
|
+
return JSON.stringify(v);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the host's own origin. `baseUrl` is the suite's `--base-url`; `fetcher`
|
|
66
|
+
* is injectable so the self-test can run without a host.
|
|
67
|
+
*/
|
|
68
|
+
export async function hostPublicOrigin(baseUrl: string, fetcher: Fetcher = defaultFetch): Promise<HostOrigin> {
|
|
69
|
+
const base = new URL(baseUrl);
|
|
70
|
+
const front = resolvePublicFront(HOST_FRONT_ENV, base.origin);
|
|
71
|
+
if (!front.tunnelled) return { ok: true, origin: base.origin, declared: false };
|
|
72
|
+
const frontOrigin = new URL(front.url).origin;
|
|
73
|
+
const path = '/.well-known/openwop';
|
|
74
|
+
let viaFront: { status: number; text: string };
|
|
75
|
+
let viaBase: { status: number; text: string };
|
|
76
|
+
try {
|
|
77
|
+
[viaFront, viaBase] = await Promise.all([fetcher(`${frontOrigin}${path}`), fetcher(`${base.origin}${path}`)]);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return { ok: false, reason: `${HOST_FRONT_ENV}=${frontOrigin} could not be fetched (${(e as Error).message}) — the declared front is not shown to serve the host under test` };
|
|
80
|
+
}
|
|
81
|
+
if (viaFront.status !== 200 || viaBase.status !== 200) {
|
|
82
|
+
return { ok: false, reason: `discovery answered ${viaFront.status} through ${HOST_FRONT_ENV}=${frontOrigin} and ${viaBase.status} over --base-url — the declared front is not shown to serve the host under test` };
|
|
83
|
+
}
|
|
84
|
+
let same = false;
|
|
85
|
+
const origins = [frontOrigin, base.origin];
|
|
86
|
+
try { same = canonical(withoutOrigins(JSON.parse(viaFront.text), origins)) === canonical(withoutOrigins(JSON.parse(viaBase.text), origins)); } catch { same = false; }
|
|
87
|
+
if (!same) {
|
|
88
|
+
return { ok: false, reason: `the discovery document through ${HOST_FRONT_ENV}=${frontOrigin} differs from the one over --base-url beyond their origins — the front does not serve the host under test, so an origin claim through it would witness some other host` };
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, origin: frontOrigin, declared: true };
|
|
91
|
+
}
|
package/src/lib/oidc-issuer.ts
CHANGED
|
@@ -152,6 +152,33 @@ function signCompact(
|
|
|
152
152
|
return base64UrlEncode(signature);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The LOCAL port a scenario binds the synthetic issuer's JWKS/discovery
|
|
157
|
+
* listener on (2.39.4).
|
|
158
|
+
*
|
|
159
|
+
* `OPENWOP_TEST_OIDC_ISSUER_URL` is the URL the HOST is told to trust — for a
|
|
160
|
+
* remote host a public https front (a tunnel), which carries no port. Until
|
|
161
|
+
* 2.39.4 every scenario bound `127.0.0.1` at the port PARSED from that URL, so a
|
|
162
|
+
* tunnelled issuer (`https://x.trycloudflare.com`) bound :80 and a non-root
|
|
163
|
+
* operator got EACCES: measured by openwop-app-ce on 2026-09-25 against a remote
|
|
164
|
+
* openwop-app instance. No remote host could then execute the RFC 0200 / 0210
|
|
165
|
+
* issuer rows, and none could certify. The webhook receiver has always
|
|
166
|
+
* separated the two (`OPENWOP_WEBHOOK_RECEIVER_URL` + `_PORT`); this is the same
|
|
167
|
+
* split: `OPENWOP_TEST_OIDC_ISSUER_PORT`, when set, is the local port the
|
|
168
|
+
* operator's front forwards to. Unset, the old rule stands unchanged (the URL's
|
|
169
|
+
* port, else 80), so a loopback operator whose URL names its port is unaffected.
|
|
170
|
+
*/
|
|
171
|
+
export function issuerListenPort(issuerUrl: string): number {
|
|
172
|
+
const raw = process.env['OPENWOP_TEST_OIDC_ISSUER_PORT']?.trim();
|
|
173
|
+
if (raw !== undefined && raw !== '') {
|
|
174
|
+
const n = Number(raw);
|
|
175
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`OPENWOP_TEST_OIDC_ISSUER_PORT must be an integer port 1..65535 (got ${JSON.stringify(raw)})`);
|
|
176
|
+
return n;
|
|
177
|
+
}
|
|
178
|
+
const parsed = new URL(issuerUrl);
|
|
179
|
+
return parsed.port ? Number.parseInt(parsed.port, 10) : 80;
|
|
180
|
+
}
|
|
181
|
+
|
|
155
182
|
export function createSyntheticOIDCIssuer(
|
|
156
183
|
opts: SyntheticOIDCIssuerOptions,
|
|
157
184
|
): SyntheticOIDCIssuer {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A certification run with pinned fixture ports MUST be single-worker.
|
|
3
|
+
*
|
|
4
|
+
* Every suite fixture reached through an operator's public front listens on a
|
|
5
|
+
* pinned port (`*_PORT`), and the fixture registry that routes a nonce-pathed
|
|
6
|
+
* request to the exercise that minted it (`front-mux.ts`, `scoped-receiver.ts`)
|
|
7
|
+
* lives in ONE vitest worker's memory. With two workers only one can own each
|
|
8
|
+
* pinned port; an exercise in the other worker is unreachable, or its traffic is
|
|
9
|
+
* answered by a registry nobody reads. `cut-bundle.sh` has always passed
|
|
10
|
+
* `--max-workers 1`, and `effect-receiver.ts` assumes it — but nothing enforced
|
|
11
|
+
* it. Measured 2026-09-25: an openwop-app production cut run with
|
|
12
|
+
* `--max-workers 2` recorded `0173.webhook-durable-delivery` and
|
|
13
|
+
* `0187.bound-id-kinds.webhook-emitted` as test timeouts while the host had in
|
|
14
|
+
* fact delivered; the same host passed both single-worker. A certification that
|
|
15
|
+
* silently loses the host's traffic convicts the host, so the CLI refuses it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Every pinned-port variable a suite fixture honours. The OAuth doubles derive
|
|
19
|
+
* theirs from `<FRONT>_URL` at runtime, so they are listed rather than grepped. */
|
|
20
|
+
export const PINNED_PORT_ENVS = [
|
|
21
|
+
'OPENWOP_WEBHOOK_RECEIVER_PORT',
|
|
22
|
+
'OPENWOP_A2A_FAKE_PEER_PORT',
|
|
23
|
+
'OPENWOP_MCP_FAKE_SERVER_PORT',
|
|
24
|
+
'OPENWOP_OAUTH_AS_PORT',
|
|
25
|
+
'OPENWOP_OAUTH_AS2_PORT',
|
|
26
|
+
'OPENWOP_OAUTH_RESOURCE_PORT',
|
|
27
|
+
'OPENWOP_OTEL_COLLECTOR_PORT',
|
|
28
|
+
'OPENWOP_OTEL_COLLECTOR_GRPC_PORT',
|
|
29
|
+
// 2.39.4: the synthetic OIDC issuer's local bind port (`issuerListenPort`,
|
|
30
|
+
// oidc-issuer.ts). Two scenarios stand an issuer up on it.
|
|
31
|
+
'OPENWOP_TEST_OIDC_ISSUER_PORT',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The refusal message when a `--certify` run pins fixture ports without being
|
|
36
|
+
* single-worker, or `null` when the run is acceptable. `maxWorkers` undefined is
|
|
37
|
+
* vitest's default — one worker per CPU — so it is refused too.
|
|
38
|
+
*/
|
|
39
|
+
export function pinnedPortWorkerConflict(
|
|
40
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
41
|
+
maxWorkers: number | undefined,
|
|
42
|
+
certifying: boolean,
|
|
43
|
+
): string | null {
|
|
44
|
+
if (!certifying) return null;
|
|
45
|
+
const pinned = PINNED_PORT_ENVS.filter((k) => (env[k] ?? '').trim() !== '');
|
|
46
|
+
if (pinned.length === 0 || maxWorkers === 1) return null;
|
|
47
|
+
return `--certify with pinned fixture ports (${pinned.join(', ')}) requires --max-workers 1 (got ${maxWorkers === undefined ? "vitest's default, one per CPU" : maxWorkers}): a pinned port is owned by one worker, so an exercise in another worker cannot receive the host's traffic and its rows would convict the host of a delivery it made`;
|
|
48
|
+
}
|
|
@@ -111,9 +111,16 @@ export interface ScopedReceiver {
|
|
|
111
111
|
*
|
|
112
112
|
* Unpinned, each receiver keeps a listener of its own (ephemeral port).
|
|
113
113
|
*/
|
|
114
|
-
interface Listener { server: Server; port: number; origin: string; members: Set<Member
|
|
114
|
+
interface Listener { server: Server; port: number; origin: string; members: Set<Member> }
|
|
115
115
|
interface Member { nonce: string; foreign: number }
|
|
116
|
-
|
|
116
|
+
/**
|
|
117
|
+
* A pinned port's shared listener and its claim count. The claim is taken
|
|
118
|
+
* SYNCHRONOUSLY, before the first await (2.39.4): a sibling that read the entry
|
|
119
|
+
* and was still awaiting the bind used to be invisible to a closing holder, which
|
|
120
|
+
* dropped the count to zero and shut the server under it.
|
|
121
|
+
*/
|
|
122
|
+
interface Shared { listening: Promise<Listener>; refs: number }
|
|
123
|
+
const sharedByPort = new Map<string, Shared>();
|
|
117
124
|
|
|
118
125
|
function handlerFor(members: Set<Member>) {
|
|
119
126
|
return (request: IncomingMessage, res: ServerResponse): void => {
|
|
@@ -144,7 +151,7 @@ async function listen(port: number, binding: { bind: string; advertise: string }
|
|
|
144
151
|
});
|
|
145
152
|
const addr = server.address();
|
|
146
153
|
if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
|
|
147
|
-
return { server, port: addr.port, origin: `http://${binding.advertise}:${addr.port}`, members
|
|
154
|
+
return { server, port: addr.port, origin: `http://${binding.advertise}:${addr.port}`, members };
|
|
148
155
|
}
|
|
149
156
|
|
|
150
157
|
function shut(server: Server): Promise<void> {
|
|
@@ -179,22 +186,26 @@ export async function startScopedReceiver(
|
|
|
179
186
|
const isPinned = Number.isInteger(pinned) && pinned > 0 && pinned < 65536;
|
|
180
187
|
const binding = receiverBinding();
|
|
181
188
|
let listener: Listener;
|
|
189
|
+
let shared: Shared | undefined;
|
|
182
190
|
let key: string | undefined;
|
|
183
191
|
if (isPinned) {
|
|
184
192
|
key = `${binding.bind}:${pinned}`;
|
|
185
|
-
|
|
186
|
-
if (!
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
193
|
+
shared = sharedByPort.get(key);
|
|
194
|
+
if (!shared) {
|
|
195
|
+
const listening = listen(pinned, binding);
|
|
196
|
+
shared = { listening, refs: 0 };
|
|
197
|
+
sharedByPort.set(key, shared);
|
|
198
|
+
const entry = shared;
|
|
199
|
+
listening.catch(() => { if (sharedByPort.get(key!) === entry) sharedByPort.delete(key!); });
|
|
190
200
|
}
|
|
191
|
-
|
|
201
|
+
shared.refs += 1; // claimed before any await — a closing sibling now sees this receiver
|
|
202
|
+
try { listener = await shared.listening; }
|
|
203
|
+
catch (err) { shared.refs -= 1; throw err; }
|
|
192
204
|
} else {
|
|
193
205
|
listener = await listen(0, binding);
|
|
194
206
|
}
|
|
195
207
|
const member: Member = { nonce, foreign: 0 };
|
|
196
208
|
listener.members.add(member);
|
|
197
|
-
listener.refs += 1;
|
|
198
209
|
registerBehindFront(WEBHOOK_FRONT_ENV, nonce, own);
|
|
199
210
|
const front = resolvePublicFront(WEBHOOK_FRONT_ENV, listener.origin);
|
|
200
211
|
let closed = false;
|
|
@@ -211,9 +222,11 @@ export async function startScopedReceiver(
|
|
|
211
222
|
closed = true;
|
|
212
223
|
unregisterBehindFront(WEBHOOK_FRONT_ENV, nonce);
|
|
213
224
|
listener.members.delete(member);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
225
|
+
if (shared !== undefined) {
|
|
226
|
+
shared.refs -= 1;
|
|
227
|
+
if (shared.refs > 0) return;
|
|
228
|
+
if (key !== undefined && sharedByPort.get(key) === shared) sharedByPort.delete(key);
|
|
229
|
+
}
|
|
217
230
|
await shut(listener.server);
|
|
218
231
|
},
|
|
219
232
|
};
|
|
@@ -46,6 +46,7 @@ import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
|
46
46
|
import {
|
|
47
47
|
createSyntheticOIDCIssuer,
|
|
48
48
|
type SyntheticOIDCIssuer,
|
|
49
|
+
issuerListenPort,
|
|
49
50
|
} from '../lib/oidc-issuer.js';
|
|
50
51
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
51
52
|
import { req } from '../lib/requirement-ids.js';
|
|
@@ -174,8 +175,7 @@ describe('auth-oidc-user-bearer: harness-driven token validation', () => {
|
|
|
174
175
|
|
|
175
176
|
// Bind the harness's JWKS + discovery endpoints so the host can
|
|
176
177
|
// fetch them when validating tokens.
|
|
177
|
-
const
|
|
178
|
-
const port = parsed.port ? Number.parseInt(parsed.port, 10) : 80;
|
|
178
|
+
const port = issuerListenPort(harnessUrl);
|
|
179
179
|
|
|
180
180
|
server = createServer((reqBody, res) => {
|
|
181
181
|
if (!issuer) {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { describe, it, expect, afterAll } from 'vitest';
|
|
28
28
|
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
29
29
|
import { loadEnv } from '../lib/env.js';
|
|
30
|
+
import { hostPublicOrigin } from '../lib/host-public-origin.js';
|
|
30
31
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
31
32
|
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
32
33
|
import { req } from '../lib/requirement-ids.js';
|
|
@@ -121,7 +122,13 @@ describe('RFC 0199 §C — v2-credential-interrupt (gated on oauth + provider sy
|
|
|
121
122
|
expect([data['provider'], data['reason']], req(id, DOC, 'data names the provider and reason missing')).toEqual([PROVIDER, 'missing']);
|
|
122
123
|
const connect = new URL(String(data['connectUrl']));
|
|
123
124
|
expect(connect.protocol, req(id, DOC, 'connectUrl MUST be https')).toBe('https:');
|
|
124
|
-
|
|
125
|
+
// The host's own origin is where a USER AGENT reaches it: --base-url, or the
|
|
126
|
+
// operator-declared OPENWOP_HOST_PUBLIC_URL once shown to serve this host
|
|
127
|
+
// (lib/host-public-origin.ts, 2.39.4). A declared front that is not shown to
|
|
128
|
+
// serve it records `blocked`, never a pass on some other origin.
|
|
129
|
+
const own = await hostPublicOrigin(loadEnv().baseUrl);
|
|
130
|
+
if (!own.ok) return softSkip('blocked', own.reason);
|
|
131
|
+
expect(connect.origin, req(id, `${DOC} (connectUrl is host-owned)`, `connectUrl MUST be on the host's own origin (${own.origin}${own.declared ? ', declared via OPENWOP_HOST_PUBLIC_URL and verified to serve this host' : ''})`)).toBe(own.origin);
|
|
125
132
|
await driver.post(`/runs/${encodeURIComponent(runId)}:cancel`, {}, auth(r.bearer));
|
|
126
133
|
});
|
|
127
134
|
|
|
@@ -39,10 +39,14 @@ import { v2Discovery } from '../lib/v2.js';
|
|
|
39
39
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
40
40
|
import { softSkip } from '../lib/soft-skip.js';
|
|
41
41
|
import { req } from '../lib/requirement-ids.js';
|
|
42
|
+
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
42
43
|
|
|
43
44
|
const DOC = 'spec/v2/core/idempotency.md Concurrency';
|
|
44
45
|
const ID_ONE = 'openwop.requirement.0213.in-flight-one-winner';
|
|
45
46
|
const ID_LOSER = 'openwop.requirement.0213.in-flight-loser-outcome';
|
|
47
|
+
const ID_HELD = 'openwop.requirement.0213.in-flight-refused-under-hold';
|
|
48
|
+
const HOLD_SEAM = `${SEAMS_PREFIX}/sample/test/idempotency/hold`;
|
|
49
|
+
const HOLD_MS = 4000;
|
|
46
50
|
const FIXTURE = 'conformance-delay';
|
|
47
51
|
const N = 5;
|
|
48
52
|
|
|
@@ -129,4 +133,31 @@ describe('v2 idempotency-in-flight (idempotency.md Concurrency, RFC 0213 §B)',
|
|
|
129
133
|
// on a row shared with the winner clause.)
|
|
130
134
|
if (r.refusals.length === 0) return softSkip('inapplicable', `no loser was refused in flight — all ${N} answers were successes (each loser a marked replay, which §B permits), so the 409 branch did not run on this host`);
|
|
131
135
|
}, 60_000);
|
|
136
|
+
// The deterministic §B witness (host-sample-test-seams.md §26): the seam only
|
|
137
|
+
// ARMS a hold on the next real create; the 409 is the host's own in-flight branch.
|
|
138
|
+
it('while the seam holds the claim, a same-key create is refused 409 idempotency_in_flight by the host and the held create still wins', async () => {
|
|
139
|
+
const doc = await discovery();
|
|
140
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
141
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'the held-claim leg is driven through the seams profile (RFC 0213 §B) — seams profile not advertised; the unaided leg above is this host\'s witness');
|
|
142
|
+
const key = `openwopconf-held-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
143
|
+
const armed = await http(() => driver.post(HOLD_SEAM, { key, holdMs: HOLD_MS }));
|
|
144
|
+
if (armed === null) return softSkip('blocked', `${HOLD_SEAM} unreachable (fetch failed)`);
|
|
145
|
+
if (armed.status === 404 || armed.status === 405 || armed.status === 403) return softSkip('blocked', `the host advertises the seams profile but does not serve ${HOLD_SEAM} (answered ${armed.status}) — the held-claim leg cannot run`);
|
|
146
|
+
if (armed.status !== 201) return softSkip('blocked', `${HOLD_SEAM} answered ${armed.status} ${JSON.stringify(armed.json)} — the seam contract (api/seams-v2.yaml armIdempotencyHold) is 201 { key, holdMs }`);
|
|
147
|
+
const body = { workflowId: 'conformance-noop' };
|
|
148
|
+
const winner = http(() => driver.post('/runs', body, { headers: { 'Idempotency-Key': key } }));
|
|
149
|
+
await new Promise((ok) => setTimeout(ok, 750));
|
|
150
|
+
const loser = await http(() => driver.post('/runs', body, { headers: { 'Idempotency-Key': key } }));
|
|
151
|
+
const won = await winner;
|
|
152
|
+
if (loser === null || won === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
|
|
153
|
+
expect({ status: loser.status, code: readErrorCode(loser.json) }, req(ID_HELD, DOC, `a same-key create while the claim is in flight MUST be refused 409 idempotency_in_flight (got ${loser.status} ${String(readErrorCode(loser.json))})`)).toEqual({ status: 409, code: 'idempotency_in_flight' });
|
|
154
|
+
const details = (loser.json as { details?: Record<string, unknown> } | null)?.details ?? {};
|
|
155
|
+
expect(Object.keys(details).filter((k) => /^retryAfter/i.test(k)), req(ID_HELD, 'spec/v2/core/errors.md §Retry timing', 'retry timing MUST NOT travel in details')).toEqual([]);
|
|
156
|
+
const ra = loser.headers.get('retry-after');
|
|
157
|
+
if (ra !== null) expect(parsesRetryAfter(ra), req(ID_HELD, DOC, `a Retry-After that is present MUST parse (got ${ra})`)).toBe(true);
|
|
158
|
+
expect(won.status >= 200 && won.status < 300, req(ID_HELD, DOC, `the held create MUST complete as the one winner (got ${won.status})`)).toBe(true);
|
|
159
|
+
const replay = await http(() => driver.post('/runs', body, { headers: { 'Idempotency-Key': key } }));
|
|
160
|
+
if (replay === null) return softSkip('blocked', 'POST /runs unreachable after the hold (fetch failed)');
|
|
161
|
+
expect({ replay: replay.headers.get('openwop-idempotent-replay'), runId: (replay.json as { runId?: unknown } | null)?.runId }, req(ID_HELD, DOC, 'after the held create completes, a same-key create is a marked replay of the one run')).toEqual({ replay: 'true', runId: (won.json as { runId?: unknown } | null)?.runId });
|
|
162
|
+
}, 30_000);
|
|
132
163
|
});
|
|
@@ -60,7 +60,7 @@ import { softSkip } from '../lib/soft-skip.js';
|
|
|
60
60
|
import { req } from '../lib/requirement-ids.js';
|
|
61
61
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
62
62
|
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
63
|
-
import { createSyntheticOIDCIssuer, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
|
|
63
|
+
import { createSyntheticOIDCIssuer, issuerListenPort, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
|
|
64
64
|
|
|
65
65
|
export const HOST_CALLBACK_NOT_REQUIRED =
|
|
66
66
|
'the suite stands up the synthetic OIDC issuer and the host fetches its JWKS; no request returns to the suite\'s own API, so no host-reachable callback is needed';
|
|
@@ -120,7 +120,6 @@ async function gate(): Promise<Gate | { readonly kind: 'inapplicable' | 'blocked
|
|
|
120
120
|
const audience = process.env['OPENWOP_TEST_OIDC_AUDIENCE']?.trim() ?? 'openwop-conformance';
|
|
121
121
|
if (issuer === null) {
|
|
122
122
|
const made = createSyntheticOIDCIssuer({ issuer: url, audience, algorithm: 'RS256' });
|
|
123
|
-
const parsed = new URL(url);
|
|
124
123
|
const srv = createServer((r, res) => {
|
|
125
124
|
if (r.url === '/.well-known/jwks.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.jwksJson); return; }
|
|
126
125
|
if (r.url === '/.well-known/openid-configuration') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.discoveryJson); return; }
|
|
@@ -128,7 +127,7 @@ async function gate(): Promise<Gate | { readonly kind: 'inapplicable' | 'blocked
|
|
|
128
127
|
});
|
|
129
128
|
await new Promise<void>((resolve, reject) => {
|
|
130
129
|
srv.once('error', reject);
|
|
131
|
-
srv.listen(
|
|
130
|
+
srv.listen(issuerListenPort(url), '127.0.0.1', () => resolve());
|
|
132
131
|
});
|
|
133
132
|
server = srv;
|
|
134
133
|
issuer = made;
|
|
@@ -34,7 +34,7 @@ import { driver } from '../lib/driver.js';
|
|
|
34
34
|
import { softSkip } from '../lib/soft-skip.js';
|
|
35
35
|
import { req } from '../lib/requirement-ids.js';
|
|
36
36
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
37
|
-
import { createSyntheticOIDCIssuer, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
|
|
37
|
+
import { createSyntheticOIDCIssuer, issuerListenPort, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
|
|
38
38
|
import { prmGate } from '../lib/protected-resource.js';
|
|
39
39
|
|
|
40
40
|
export const HOST_CALLBACK_NOT_REQUIRED =
|
|
@@ -56,7 +56,6 @@ async function harness(audience: string): Promise<{ url: string; issuer: Synthet
|
|
|
56
56
|
if (!url) return null;
|
|
57
57
|
if (issuer !== null) return { url, issuer };
|
|
58
58
|
const made = createSyntheticOIDCIssuer({ issuer: url, audience, algorithm: 'RS256' });
|
|
59
|
-
const parsed = new URL(url);
|
|
60
59
|
const srv = createServer((r, res) => {
|
|
61
60
|
if (r.url === '/.well-known/jwks.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.jwksJson); return; }
|
|
62
61
|
if (r.url === '/.well-known/openid-configuration') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.discoveryJson); return; }
|
|
@@ -64,7 +63,7 @@ async function harness(audience: string): Promise<{ url: string; issuer: Synthet
|
|
|
64
63
|
});
|
|
65
64
|
await new Promise<void>((resolve, reject) => {
|
|
66
65
|
srv.once('error', reject);
|
|
67
|
-
srv.listen(
|
|
66
|
+
srv.listen(issuerListenPort(url), '127.0.0.1', () => resolve());
|
|
68
67
|
});
|
|
69
68
|
server = srv;
|
|
70
69
|
issuer = made;
|
|
@@ -47,7 +47,7 @@ describe('v2 revocation-honored (RFC 0170 §B.3 — seam-gated)', () => {
|
|
|
47
47
|
for (const lane of nextRequest) {
|
|
48
48
|
const minted = await http(() => driver.post(MINT, { lane }));
|
|
49
49
|
if (minted === null) return softSkip('blocked', `${MINT} unreachable (fetch failed)`);
|
|
50
|
-
if (minted.status === 404 || minted.status === 403) return seamAbsent(`${MINT} not mounted (${minted.status}) — the
|
|
50
|
+
if (minted.status === 404 || minted.status === 403) return seamAbsent(`${MINT} not mounted (${minted.status}) — the host advertises the seams profile but does not serve the RFC 0170 §B.3 credential mint/revoke seams (api/seams-v2.yaml mintLaneCredential / revokeLaneCredential; host-sample-test-seams.md)`);
|
|
51
51
|
const credential = (minted.json as { credential?: unknown } | undefined)?.credential;
|
|
52
52
|
expect(typeof credential, req('openwop.requirement.0170.revocation-honored', DOC, `the mint seam MUST answer { credential } for lane ${lane}`)).toBe('string');
|
|
53
53
|
|
|
@@ -208,6 +208,24 @@ function retryWaitMs(doc: Record<string, unknown>): number {
|
|
|
208
208
|
*/
|
|
209
209
|
const FAIL_FIRST = 2;
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* How many attempts THIS host's advertised policy leaves room to refuse
|
|
213
|
+
* (suite 2.39.4). The constant above assumed at least three attempts, but
|
|
214
|
+
* `webhooks.retryPolicy.maxAttempts` is schema-valid from 1, so a host that
|
|
215
|
+
* honestly advertised `maxAttempts: 2` was refused twice, never reached its
|
|
216
|
+
* 204, and failed a leg it conformed to. The receiver now refuses
|
|
217
|
+
* `maxAttempts − 1` attempts, at most FAIL_FIRST and at least 1. A host with no
|
|
218
|
+
* advertised policy is measured exactly as before (FAIL_FIRST), and
|
|
219
|
+
* `maxAttempts: 1` still refuses the first attempt, so a host that never
|
|
220
|
+
* retries still fails: webhooks.md §Durability says best-effort delivery is
|
|
221
|
+
* not a conforming mode.
|
|
222
|
+
*/
|
|
223
|
+
function failFirstFor(policy: { maxAttempts?: number } | null): number {
|
|
224
|
+
const m = policy?.maxAttempts;
|
|
225
|
+
if (typeof m !== 'number' || !Number.isInteger(m)) return FAIL_FIRST;
|
|
226
|
+
return Math.max(1, Math.min(FAIL_FIRST, m - 1));
|
|
227
|
+
}
|
|
228
|
+
|
|
211
229
|
const WAIT_SLACK_MS = 30_000;
|
|
212
230
|
/** One `retryWaitMs` wait (the retry leg). */
|
|
213
231
|
const RETRY_TEST_TIMEOUT_MS = RETRY_WAIT_CAP_MS + WAIT_SLACK_MS;
|
|
@@ -241,7 +259,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
241
259
|
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
242
260
|
if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to deliver`);
|
|
243
261
|
|
|
244
|
-
const
|
|
262
|
+
const failFirst = failFirstFor(advertisedRetryPolicy(doc));
|
|
263
|
+
const receiver = await startReceiver(failFirst); // 500 × failFirst, then 204
|
|
245
264
|
active = receiver;
|
|
246
265
|
const sub = await register(receiver);
|
|
247
266
|
if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
@@ -287,8 +306,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
287
306
|
// inside our window this records `blocked`, NOT `executed-fail` — suite
|
|
288
307
|
// 2.0.3, and this is the third time this file has had to learn it.
|
|
289
308
|
//
|
|
290
|
-
// The receiver answers 204 only on attempt `
|
|
291
|
-
// costs the SUM of the first
|
|
309
|
+
// The receiver answers 204 only on attempt `failFirst + 1`, so reaching it
|
|
310
|
+
// costs the SUM of the first failFirst backoff intervals, not the largest
|
|
292
311
|
// one. On an exponential-from-30s policy that is 30 + 60 = 90 s, which is
|
|
293
312
|
// exactly RETRY_WAIT_CAP_MS — a host loses by the width of one delivery.
|
|
294
313
|
// The obvious fix is to derive the wait from the intervals, and it cannot
|
|
@@ -312,7 +331,7 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
312
331
|
// only in a changelog.
|
|
313
332
|
if (!retried) {
|
|
314
333
|
const policyNote = advertisedRetryPolicy(doc);
|
|
315
|
-
return softSkip('blocked', `the retry was observed (${attempts.length} attempts) but the receiver's 204 did not land inside the ${retryWaitMs(doc)}ms window: it answers 204 only on attempt ${
|
|
334
|
+
return softSkip('blocked', `the retry was observed (${attempts.length} attempts) but the receiver's 204 did not land inside the ${retryWaitMs(doc)}ms window: it answers 204 only on attempt ${failFirst + 1}, which costs the SUM of the first ${failFirst} backoff intervals, and webhooks.retryPolicy carries only { maxAttempts, backoff${policyNote ? `: ${String(policyNote.backoff)}` : ''} } — the base interval is not advertised, so the suite cannot derive how long to wait. Unmeasured, not unmet (RFC 0148 §A).`);
|
|
316
335
|
}
|
|
317
336
|
// Backoff: the retry MUST NOT be a tight loop — consecutive attempts for one
|
|
318
337
|
// key are spaced. Only asserted when the host advertises a non-`none` backoff.
|