@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 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.3 @openwop/spec-artifacts@2.39.3
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
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.39.3",
4
- "stampSha256": "16b07c57b04e10df5de4d4bcf3cfbac8fbda75c1410aac9348f14a823ba31f68"
3
+ "version": "2.39.5",
4
+ "stampSha256": "db86d304e8d1c9042e51cc62a10c3d952826f9f3d8eddbcee08c29128c4e4252"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.39.3",
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.3"
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": 2424,
7
- "withStableId": 2424,
6
+ "tests": 2425,
7
+ "withStableId": 2425,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2304
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": 92,
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": 108,
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": 128,
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": 145,
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": 168,
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": 188,
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": 203,
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": 231,
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": 101,
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": 109,
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": 150,
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": 165,
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": 200,
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": 218,
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": 75,
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": 238,
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": 339,
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": 433,
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": 454,
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.3",
5
- "corpusTag": "v2.39.3",
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": "e51c61c5d0fe876b5851e6583a08a61e0563d44985654299db98dbcc5d7ff6ce",
13
- "api/v2/asyncapi.yaml": "150a48f15f5c3b984aff61055304f30299a75f5b9561ff1b4c788c4a40b64325",
14
- "api/v2/openapi.yaml": "c3969b2fbfde19dfd9a42af4a67da8d34cf3135ad8314fe7211173a37d58ed96",
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": "796de9d8793ff2243fee9c5ffe3b9adc49b0f2e663147b965043de92594352e9",
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": "76b986a5d12be1c5dc9368bc79c6d8aa99a5e916b3cca3fc3eb6a84577f1f32a",
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": "a8988f480ec268554cdc9cb0a6019172c89aa09cd588f81bfd6166ab5c65a260",
295
+ "spec/v2/release.json": "c3d35fbfae242c3fa3a504edb08fbdbc7ee9dd003250ea7225a0f6641dc614b9",
296
296
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
297
- "spec/v2/surface-baseline.json": "7a67a1a935db83145b1fffad3828e09c38f89170d360d2a793ae0634c3164c11"
297
+ "spec/v2/surface-baseline.json": "4273f2b75cfde4c620c995b5a4c334cfc15d936dfa7f5b6fa0aa4bf257e8b8b5"
298
298
  },
299
- "corpusCommit": "c62b05884b9b83f4016890582e8606e3bc74ad6a"
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
+ }
@@ -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>; refs: number }
114
+ interface Listener { server: Server; port: number; origin: string; members: Set<Member> }
115
115
  interface Member { nonce: string; foreign: number }
116
- const sharedByPort = new Map<string, Promise<Listener>>();
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, refs: 0 };
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
- let p = sharedByPort.get(key);
186
- if (!p) {
187
- p = listen(pinned, binding);
188
- sharedByPort.set(key, p);
189
- p.catch(() => { if (sharedByPort.get(key!) === p) sharedByPort.delete(key!); });
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
- listener = await p;
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
- listener.refs -= 1;
215
- if (listener.refs > 0) return;
216
- if (key !== undefined && sharedByPort.get(key) !== undefined) sharedByPort.delete(key);
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 parsed = new URL(harnessUrl);
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
- expect(connect.origin, req(id, `${DOC} (connectUrl is host-owned)`, `connectUrl MUST be on the host's own origin (${new URL(loadEnv().baseUrl).origin})`)).toBe(new URL(loadEnv().baseUrl).origin);
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(parsed.port ? Number.parseInt(parsed.port, 10) : 80, '127.0.0.1', () => resolve());
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(parsed.port ? Number.parseInt(parsed.port, 10) : 80, '127.0.0.1', () => resolve());
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 per-lane revoke seam RFC 0170 §B.3 names is not specified in host-sample-test-seams.md and this host does not serve it`);
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 receiver = await startReceiver(FAIL_FIRST); // 500, 500, then 204
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 `FAIL_FIRST + 1`, so reaching it
291
- // costs the SUM of the first FAIL_FIRST backoff intervals, not the largest
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 ${FAIL_FIRST + 1}, which costs the SUM of the first ${FAIL_FIRST} 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).`);
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.