@openwop/openwop-conformance 2.39.3 → 2.39.4

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,14 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.39.4] — 2026-09-25 — RFC 0199 can be witnessed behind a public front, and the pinned-port claim is race-free
4
+
5
+ - **`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.
6
+ - **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.
7
+ - **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.
8
+ - **`--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.
9
+ - **`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).
10
+ - **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.
11
+
3
12
  ## [2.39.3] — 2026-09-25 — a pinned receiver port is shared, and tenant B really is tenant B
4
13
 
5
14
  - **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.4 @openwop/spec-artifacts@2.39.4
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.4",
4
+ "stampSha256": "7f6c8bfaa20466e744bdab3e23ab0b27653b6e74e115ca4dccdfd8a4ba4fa70a"
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.4",
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.4"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -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": [
@@ -29585,7 +29585,7 @@
29585
29585
  {
29586
29586
  "id": "openwop.it.v2-lane-exp-only-bound.a-credential-inside-the-advertised-window-is-accepted-the-control-the-two-refusa",
29587
29587
  "file": "v2-lane-exp-only-bound.test.ts",
29588
- "line": 150,
29588
+ "line": 149,
29589
29589
  "title": "a credential inside the advertised window is accepted — the control the two refusals rest on",
29590
29590
  "explicitId": "openwop.requirement.0210.exp-only-control-accepted",
29591
29591
  "citations": [
@@ -29599,7 +29599,7 @@
29599
29599
  {
29600
29600
  "id": "openwop.it.v2-lane-exp-only-bound.a-credential-whose-total-lifetime-exceeds-the-window-is-refused-401-and-so-is-on",
29601
29601
  "file": "v2-lane-exp-only-bound.test.ts",
29602
- "line": 165,
29602
+ "line": 164,
29603
29603
  "title": "a credential whose TOTAL lifetime exceeds the window is refused 401, and so is one carrying no iat",
29604
29604
  "explicitId": "openwop.requirement.0210.exp-only-lifetime-bound",
29605
29605
  "citations": [
@@ -29618,7 +29618,7 @@
29618
29618
  {
29619
29619
  "id": "openwop.it.v2-lane-exp-only-bound.the-over-lifetime-refusal-names-credential-lifetime-exceeded-not-a-generic-unaut",
29620
29620
  "file": "v2-lane-exp-only-bound.test.ts",
29621
- "line": 200,
29621
+ "line": 199,
29622
29622
  "title": "the over-lifetime refusal names credential_lifetime_exceeded, not a generic unauthenticated",
29623
29623
  "explicitId": "openwop.requirement.0210.credential-lifetime-code",
29624
29624
  "citations": [
@@ -29632,7 +29632,7 @@
29632
29632
  {
29633
29633
  "id": "openwop.it.v2-lane-exp-only-bound.a-credential-whose-remaining-lifetime-exceeds-the-window-is-refused-though-its-t",
29634
29634
  "file": "v2-lane-exp-only-bound.test.ts",
29635
- "line": 218,
29635
+ "line": 217,
29636
29636
  "title": "a credential whose REMAINING lifetime exceeds the window is refused, though its total lifetime does not",
29637
29637
  "explicitId": "openwop.requirement.0210.exp-only-remaining-bound",
29638
29638
  "citations": [
@@ -31179,7 +31179,7 @@
31179
31179
  {
31180
31180
  "id": "openwop.it.v2-oidc-id-token-audience.a-same-audience-id-token-is-admissible-and-one-minted-for-another-relying-party",
31181
31181
  "file": "v2-oidc-id-token-audience.test.ts",
31182
- "line": 75,
31182
+ "line": 74,
31183
31183
  "title": "a same-audience ID token is admissible, and one minted for another relying party is audience_mismatch",
31184
31184
  "explicitId": "openwop.requirement.0200.id-token-aud",
31185
31185
  "citations": [
@@ -33188,7 +33188,7 @@
33188
33188
  {
33189
33189
  "id": "openwop.it.v2-webhook-durable-delivery.a-failed-attempt-is-retried-and-the-event-is-delivered-at-least-once",
33190
33190
  "file": "v2-webhook-durable-delivery.test.ts",
33191
- "line": 238,
33191
+ "line": 256,
33192
33192
  "title": "a failed attempt is retried and the event is delivered at least once",
33193
33193
  "explicitId": "openwop.requirement.0173.webhook-durable-delivery",
33194
33194
  "citations": [
@@ -33220,7 +33220,7 @@
33220
33220
  {
33221
33221
  "id": "openwop.it.v2-webhook-durable-delivery.an-exhausted-delivery-is-dead-lettered-never-dropped",
33222
33222
  "file": "v2-webhook-durable-delivery.test.ts",
33223
- "line": 339,
33223
+ "line": 358,
33224
33224
  "title": "an exhausted delivery is dead-lettered, never dropped",
33225
33225
  "explicitId": "openwop.requirement.0173.webhook-durable-delivery.dead-letter",
33226
33226
  "citations": [
@@ -33254,7 +33254,7 @@
33254
33254
  {
33255
33255
  "id": "openwop.it.v2-webhook-durable-delivery.the-dead-letter-read-is-served-and-its-records-carry-no-payload",
33256
33256
  "file": "v2-webhook-durable-delivery.test.ts",
33257
- "line": 433,
33257
+ "line": 452,
33258
33258
  "title": "the dead-letter read is served and its records carry no payload",
33259
33259
  "explicitId": "openwop.requirement.0188.dead-letter-read",
33260
33260
  "citations": [
@@ -33267,7 +33267,7 @@
33267
33267
  {
33268
33268
  "id": "openwop.it.v2-webhook-durable-delivery.a-dead-letter-record-carries-no-delivered-payload",
33269
33269
  "file": "v2-webhook-durable-delivery.test.ts",
33270
- "line": 454,
33270
+ "line": 473,
33271
33271
  "title": "a dead-letter record carries no delivered payload",
33272
33272
  "explicitId": "openwop.requirement.0188.dead-letter-content-free",
33273
33273
  "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.4",
5
+ "corpusTag": "v2.39.4",
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": "4d94700c79e047b6cc215e6fef77fb80d0ffaabf94c23d41b30d17061f4405f6",
13
+ "api/v2/asyncapi.yaml": "7189ece9f1c5170d53b81b83ddb173173df3c7ddaec650b3edc696896c1aa01a",
14
+ "api/v2/openapi.yaml": "873705c51c0fe43bc21e88c53b21d9e7689617c7a6e77135a967f642564e7266",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -204,7 +204,7 @@
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": "b221f615c14c340e2a17143a75f88256b7ac1f31cf7a3aeb237a277724ff445c",
208
208
  "spec/v1/deprecations.json": "2d03f4729810280147ea08c630ea65434f2dc5370567d0aac41c3595f8337d40",
209
209
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
210
210
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -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": "8fc2cf9fee817def51a3fc494b3bb602fc80ab270cdc5c94f682851468a65503",
296
296
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
297
- "spec/v2/surface-baseline.json": "7a67a1a935db83145b1fffad3828e09c38f89170d360d2a793ae0634c3164c11"
297
+ "spec/v2/surface-baseline.json": "68f4a5b6b121d58be3d8baf0d3a58fa7cb4b1c0067e989f036f8f0dd129b46b7"
298
298
  },
299
- "corpusCommit": "c62b05884b9b83f4016890582e8606e3bc74ad6a"
299
+ "corpusCommit": "dd6d1218b745d600af9095a0a7458ba6cdeab3d1"
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,71 @@
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
+ /** Key-order-independent JSON equality — the two fetches may serialise differently. */
39
+ function canonical(v: unknown): string {
40
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
41
+ 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(',')}}`;
42
+ return JSON.stringify(v);
43
+ }
44
+
45
+ /**
46
+ * Resolve the host's own origin. `baseUrl` is the suite's `--base-url`; `fetcher`
47
+ * is injectable so the self-test can run without a host.
48
+ */
49
+ export async function hostPublicOrigin(baseUrl: string, fetcher: Fetcher = defaultFetch): Promise<HostOrigin> {
50
+ const base = new URL(baseUrl);
51
+ const front = resolvePublicFront(HOST_FRONT_ENV, base.origin);
52
+ if (!front.tunnelled) return { ok: true, origin: base.origin, declared: false };
53
+ const frontOrigin = new URL(front.url).origin;
54
+ const path = '/.well-known/openwop';
55
+ let viaFront: { status: number; text: string };
56
+ let viaBase: { status: number; text: string };
57
+ try {
58
+ [viaFront, viaBase] = await Promise.all([fetcher(`${frontOrigin}${path}`), fetcher(`${base.origin}${path}`)]);
59
+ } catch (e) {
60
+ 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` };
61
+ }
62
+ if (viaFront.status !== 200 || viaBase.status !== 200) {
63
+ 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` };
64
+ }
65
+ let same = false;
66
+ try { same = canonical(JSON.parse(viaFront.text)) === canonical(JSON.parse(viaBase.text)); } catch { same = false; }
67
+ if (!same) {
68
+ return { ok: false, reason: `the discovery document through ${HOST_FRONT_ENV}=${frontOrigin} differs from the one over --base-url — the front does not serve the host under test, so an origin claim through it would witness some other host` };
69
+ }
70
+ return { ok: true, origin: frontOrigin, declared: true };
71
+ }
@@ -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
 
@@ -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;
@@ -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.