@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 +9 -0
- package/README.md +1 -1
- package/dist/cli.js +8 -0
- package/dist/lib/pinned-ports.js +44 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +17 -17
- package/schemas/CORPUS-STAMP.json +9 -9
- package/src/cli.ts +8 -0
- package/src/lib/host-public-origin.ts +71 -0
- package/src/lib/oidc-issuer.ts +27 -0
- package/src/lib/pinned-ports.ts +48 -0
- package/src/lib/scoped-receiver.ts +26 -13
- package/src/scenarios/auth-oidc-user-bearer.test.ts +2 -2
- package/src/scenarios/v2-credential-interrupt.test.ts +8 -1
- package/src/scenarios/v2-lane-exp-only-bound.test.ts +2 -3
- package/src/scenarios/v2-oidc-id-token-audience.test.ts +2 -3
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +23 -4
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.
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.39.
|
|
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.
|
|
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":
|
|
5709
|
+
"line": 93,
|
|
5710
5710
|
"title": "host claiming OIDC profile advertises required fields",
|
|
5711
5711
|
"explicitId": "openwop.it.auth-oidc-user-bearer.host-claiming-oidc-profile-advertises-required-fields",
|
|
5712
5712
|
"citations": [
|
|
@@ -28290,7 +28290,7 @@
|
|
|
28290
28290
|
{
|
|
28291
28291
|
"id": "openwop.it.v2-credential-interrupt.a-node-with-no-credential-suspends-on-a-credential-interrupt-with-a-closed-crede",
|
|
28292
28292
|
"file": "v2-credential-interrupt.test.ts",
|
|
28293
|
-
"line":
|
|
28293
|
+
"line": 109,
|
|
28294
28294
|
"title": "a node with no credential suspends on a credential interrupt with a closed CredentialData, status waiting-input",
|
|
28295
28295
|
"explicitId": null,
|
|
28296
28296
|
"citations": [
|
|
@@ -28327,7 +28327,7 @@
|
|
|
28327
28327
|
{
|
|
28328
28328
|
"id": "openwop.it.v2-credential-interrupt.a-caller-resolve-of-authorized-is-refused-while-no-credential-resolves-and-a-res",
|
|
28329
28329
|
"file": "v2-credential-interrupt.test.ts",
|
|
28330
|
-
"line":
|
|
28330
|
+
"line": 135,
|
|
28331
28331
|
"title": "a caller resolve of authorized is refused while no credential resolves, and a resume value cannot carry a credential",
|
|
28332
28332
|
"explicitId": null,
|
|
28333
28333
|
"citations": [
|
|
@@ -28358,7 +28358,7 @@
|
|
|
28358
28358
|
{
|
|
28359
28359
|
"id": "openwop.it.v2-credential-interrupt.connecturl-is-not-pre-authenticated-and-begins-the-grant-only-for-the-initiating",
|
|
28360
28360
|
"file": "v2-credential-interrupt.test.ts",
|
|
28361
|
-
"line":
|
|
28361
|
+
"line": 152,
|
|
28362
28362
|
"title": "connectUrl is not pre-authenticated and begins the grant only for the initiating Subject",
|
|
28363
28363
|
"explicitId": null,
|
|
28364
28364
|
"citations": [
|
|
@@ -28391,7 +28391,7 @@
|
|
|
28391
28391
|
{
|
|
28392
28392
|
"id": "openwop.it.v2-credential-interrupt.the-grant-completes-through-connecturl-the-host-resolves-the-interrupt-itself-an",
|
|
28393
28393
|
"file": "v2-credential-interrupt.test.ts",
|
|
28394
|
-
"line":
|
|
28394
|
+
"line": 175,
|
|
28395
28395
|
"title": "the grant completes through connectUrl, the host resolves the interrupt itself and the run continues",
|
|
28396
28396
|
"explicitId": null,
|
|
28397
28397
|
"citations": [
|
|
@@ -28423,7 +28423,7 @@
|
|
|
28423
28423
|
{
|
|
28424
28424
|
"id": "openwop.it.v2-credential-interrupt.declined-fails-the-node-with-connector-auth-declined",
|
|
28425
28425
|
"file": "v2-credential-interrupt.test.ts",
|
|
28426
|
-
"line":
|
|
28426
|
+
"line": 195,
|
|
28427
28427
|
"title": "declined fails the node with connector_auth_declined",
|
|
28428
28428
|
"explicitId": null,
|
|
28429
28429
|
"citations": [
|
|
@@ -28450,7 +28450,7 @@
|
|
|
28450
28450
|
{
|
|
28451
28451
|
"id": "openwop.it.v2-credential-interrupt.a-terminal-refresh-failure-emits-connector-auth-expired-then-suspends-with-reaso",
|
|
28452
28452
|
"file": "v2-credential-interrupt.test.ts",
|
|
28453
|
-
"line":
|
|
28453
|
+
"line": 210,
|
|
28454
28454
|
"title": "a terminal refresh failure emits connector.auth-expired, then suspends with reason expired",
|
|
28455
28455
|
"explicitId": null,
|
|
28456
28456
|
"citations": [
|
|
@@ -28487,7 +28487,7 @@
|
|
|
28487
28487
|
{
|
|
28488
28488
|
"id": "openwop.it.v2-credential-interrupt.without-oauth-credentialinterrupt-a-terminal-refresh-failure-fails-the-node-conn",
|
|
28489
28489
|
"file": "v2-credential-interrupt.test.ts",
|
|
28490
|
-
"line":
|
|
28490
|
+
"line": 238,
|
|
28491
28491
|
"title": "without oauth.credentialInterrupt, a terminal refresh failure fails the node connector_auth_expired and raises no interrupt",
|
|
28492
28492
|
"explicitId": null,
|
|
28493
28493
|
"citations": [
|
|
@@ -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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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.
|
|
5
|
-
"corpusTag": "v2.39.
|
|
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": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
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": "
|
|
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": "
|
|
295
|
+
"spec/v2/release.json": "8fc2cf9fee817def51a3fc494b3bb602fc80ab270cdc5c94f682851468a65503",
|
|
296
296
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
297
|
-
"spec/v2/surface-baseline.json": "
|
|
297
|
+
"spec/v2/surface-baseline.json": "68f4a5b6b121d58be3d8baf0d3a58fa7cb4b1c0067e989f036f8f0dd129b46b7"
|
|
298
298
|
},
|
|
299
|
-
"corpusCommit": "
|
|
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
|
+
}
|
package/src/lib/oidc-issuer.ts
CHANGED
|
@@ -152,6 +152,33 @@ function signCompact(
|
|
|
152
152
|
return base64UrlEncode(signature);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The LOCAL port a scenario binds the synthetic issuer's JWKS/discovery
|
|
157
|
+
* listener on (2.39.4).
|
|
158
|
+
*
|
|
159
|
+
* `OPENWOP_TEST_OIDC_ISSUER_URL` is the URL the HOST is told to trust — for a
|
|
160
|
+
* remote host a public https front (a tunnel), which carries no port. Until
|
|
161
|
+
* 2.39.4 every scenario bound `127.0.0.1` at the port PARSED from that URL, so a
|
|
162
|
+
* tunnelled issuer (`https://x.trycloudflare.com`) bound :80 and a non-root
|
|
163
|
+
* operator got EACCES: measured by openwop-app-ce on 2026-09-25 against a remote
|
|
164
|
+
* openwop-app instance. No remote host could then execute the RFC 0200 / 0210
|
|
165
|
+
* issuer rows, and none could certify. The webhook receiver has always
|
|
166
|
+
* separated the two (`OPENWOP_WEBHOOK_RECEIVER_URL` + `_PORT`); this is the same
|
|
167
|
+
* split: `OPENWOP_TEST_OIDC_ISSUER_PORT`, when set, is the local port the
|
|
168
|
+
* operator's front forwards to. Unset, the old rule stands unchanged (the URL's
|
|
169
|
+
* port, else 80), so a loopback operator whose URL names its port is unaffected.
|
|
170
|
+
*/
|
|
171
|
+
export function issuerListenPort(issuerUrl: string): number {
|
|
172
|
+
const raw = process.env['OPENWOP_TEST_OIDC_ISSUER_PORT']?.trim();
|
|
173
|
+
if (raw !== undefined && raw !== '') {
|
|
174
|
+
const n = Number(raw);
|
|
175
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`OPENWOP_TEST_OIDC_ISSUER_PORT must be an integer port 1..65535 (got ${JSON.stringify(raw)})`);
|
|
176
|
+
return n;
|
|
177
|
+
}
|
|
178
|
+
const parsed = new URL(issuerUrl);
|
|
179
|
+
return parsed.port ? Number.parseInt(parsed.port, 10) : 80;
|
|
180
|
+
}
|
|
181
|
+
|
|
155
182
|
export function createSyntheticOIDCIssuer(
|
|
156
183
|
opts: SyntheticOIDCIssuerOptions,
|
|
157
184
|
): SyntheticOIDCIssuer {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A certification run with pinned fixture ports MUST be single-worker.
|
|
3
|
+
*
|
|
4
|
+
* Every suite fixture reached through an operator's public front listens on a
|
|
5
|
+
* pinned port (`*_PORT`), and the fixture registry that routes a nonce-pathed
|
|
6
|
+
* request to the exercise that minted it (`front-mux.ts`, `scoped-receiver.ts`)
|
|
7
|
+
* lives in ONE vitest worker's memory. With two workers only one can own each
|
|
8
|
+
* pinned port; an exercise in the other worker is unreachable, or its traffic is
|
|
9
|
+
* answered by a registry nobody reads. `cut-bundle.sh` has always passed
|
|
10
|
+
* `--max-workers 1`, and `effect-receiver.ts` assumes it — but nothing enforced
|
|
11
|
+
* it. Measured 2026-09-25: an openwop-app production cut run with
|
|
12
|
+
* `--max-workers 2` recorded `0173.webhook-durable-delivery` and
|
|
13
|
+
* `0187.bound-id-kinds.webhook-emitted` as test timeouts while the host had in
|
|
14
|
+
* fact delivered; the same host passed both single-worker. A certification that
|
|
15
|
+
* silently loses the host's traffic convicts the host, so the CLI refuses it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Every pinned-port variable a suite fixture honours. The OAuth doubles derive
|
|
19
|
+
* theirs from `<FRONT>_URL` at runtime, so they are listed rather than grepped. */
|
|
20
|
+
export const PINNED_PORT_ENVS = [
|
|
21
|
+
'OPENWOP_WEBHOOK_RECEIVER_PORT',
|
|
22
|
+
'OPENWOP_A2A_FAKE_PEER_PORT',
|
|
23
|
+
'OPENWOP_MCP_FAKE_SERVER_PORT',
|
|
24
|
+
'OPENWOP_OAUTH_AS_PORT',
|
|
25
|
+
'OPENWOP_OAUTH_AS2_PORT',
|
|
26
|
+
'OPENWOP_OAUTH_RESOURCE_PORT',
|
|
27
|
+
'OPENWOP_OTEL_COLLECTOR_PORT',
|
|
28
|
+
'OPENWOP_OTEL_COLLECTOR_GRPC_PORT',
|
|
29
|
+
// 2.39.4: the synthetic OIDC issuer's local bind port (`issuerListenPort`,
|
|
30
|
+
// oidc-issuer.ts). Two scenarios stand an issuer up on it.
|
|
31
|
+
'OPENWOP_TEST_OIDC_ISSUER_PORT',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The refusal message when a `--certify` run pins fixture ports without being
|
|
36
|
+
* single-worker, or `null` when the run is acceptable. `maxWorkers` undefined is
|
|
37
|
+
* vitest's default — one worker per CPU — so it is refused too.
|
|
38
|
+
*/
|
|
39
|
+
export function pinnedPortWorkerConflict(
|
|
40
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
41
|
+
maxWorkers: number | undefined,
|
|
42
|
+
certifying: boolean,
|
|
43
|
+
): string | null {
|
|
44
|
+
if (!certifying) return null;
|
|
45
|
+
const pinned = PINNED_PORT_ENVS.filter((k) => (env[k] ?? '').trim() !== '');
|
|
46
|
+
if (pinned.length === 0 || maxWorkers === 1) return null;
|
|
47
|
+
return `--certify with pinned fixture ports (${pinned.join(', ')}) requires --max-workers 1 (got ${maxWorkers === undefined ? "vitest's default, one per CPU" : maxWorkers}): a pinned port is owned by one worker, so an exercise in another worker cannot receive the host's traffic and its rows would convict the host of a delivery it made`;
|
|
48
|
+
}
|
|
@@ -111,9 +111,16 @@ export interface ScopedReceiver {
|
|
|
111
111
|
*
|
|
112
112
|
* Unpinned, each receiver keeps a listener of its own (ephemeral port).
|
|
113
113
|
*/
|
|
114
|
-
interface Listener { server: Server; port: number; origin: string; members: Set<Member
|
|
114
|
+
interface Listener { server: Server; port: number; origin: string; members: Set<Member> }
|
|
115
115
|
interface Member { nonce: string; foreign: number }
|
|
116
|
-
|
|
116
|
+
/**
|
|
117
|
+
* A pinned port's shared listener and its claim count. The claim is taken
|
|
118
|
+
* SYNCHRONOUSLY, before the first await (2.39.4): a sibling that read the entry
|
|
119
|
+
* and was still awaiting the bind used to be invisible to a closing holder, which
|
|
120
|
+
* dropped the count to zero and shut the server under it.
|
|
121
|
+
*/
|
|
122
|
+
interface Shared { listening: Promise<Listener>; refs: number }
|
|
123
|
+
const sharedByPort = new Map<string, Shared>();
|
|
117
124
|
|
|
118
125
|
function handlerFor(members: Set<Member>) {
|
|
119
126
|
return (request: IncomingMessage, res: ServerResponse): void => {
|
|
@@ -144,7 +151,7 @@ async function listen(port: number, binding: { bind: string; advertise: string }
|
|
|
144
151
|
});
|
|
145
152
|
const addr = server.address();
|
|
146
153
|
if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
|
|
147
|
-
return { server, port: addr.port, origin: `http://${binding.advertise}:${addr.port}`, members
|
|
154
|
+
return { server, port: addr.port, origin: `http://${binding.advertise}:${addr.port}`, members };
|
|
148
155
|
}
|
|
149
156
|
|
|
150
157
|
function shut(server: Server): Promise<void> {
|
|
@@ -179,22 +186,26 @@ export async function startScopedReceiver(
|
|
|
179
186
|
const isPinned = Number.isInteger(pinned) && pinned > 0 && pinned < 65536;
|
|
180
187
|
const binding = receiverBinding();
|
|
181
188
|
let listener: Listener;
|
|
189
|
+
let shared: Shared | undefined;
|
|
182
190
|
let key: string | undefined;
|
|
183
191
|
if (isPinned) {
|
|
184
192
|
key = `${binding.bind}:${pinned}`;
|
|
185
|
-
|
|
186
|
-
if (!
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
193
|
+
shared = sharedByPort.get(key);
|
|
194
|
+
if (!shared) {
|
|
195
|
+
const listening = listen(pinned, binding);
|
|
196
|
+
shared = { listening, refs: 0 };
|
|
197
|
+
sharedByPort.set(key, shared);
|
|
198
|
+
const entry = shared;
|
|
199
|
+
listening.catch(() => { if (sharedByPort.get(key!) === entry) sharedByPort.delete(key!); });
|
|
190
200
|
}
|
|
191
|
-
|
|
201
|
+
shared.refs += 1; // claimed before any await — a closing sibling now sees this receiver
|
|
202
|
+
try { listener = await shared.listening; }
|
|
203
|
+
catch (err) { shared.refs -= 1; throw err; }
|
|
192
204
|
} else {
|
|
193
205
|
listener = await listen(0, binding);
|
|
194
206
|
}
|
|
195
207
|
const member: Member = { nonce, foreign: 0 };
|
|
196
208
|
listener.members.add(member);
|
|
197
|
-
listener.refs += 1;
|
|
198
209
|
registerBehindFront(WEBHOOK_FRONT_ENV, nonce, own);
|
|
199
210
|
const front = resolvePublicFront(WEBHOOK_FRONT_ENV, listener.origin);
|
|
200
211
|
let closed = false;
|
|
@@ -211,9 +222,11 @@ export async function startScopedReceiver(
|
|
|
211
222
|
closed = true;
|
|
212
223
|
unregisterBehindFront(WEBHOOK_FRONT_ENV, nonce);
|
|
213
224
|
listener.members.delete(member);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
225
|
+
if (shared !== undefined) {
|
|
226
|
+
shared.refs -= 1;
|
|
227
|
+
if (shared.refs > 0) return;
|
|
228
|
+
if (key !== undefined && sharedByPort.get(key) === shared) sharedByPort.delete(key);
|
|
229
|
+
}
|
|
217
230
|
await shut(listener.server);
|
|
218
231
|
},
|
|
219
232
|
};
|
|
@@ -46,6 +46,7 @@ import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
|
46
46
|
import {
|
|
47
47
|
createSyntheticOIDCIssuer,
|
|
48
48
|
type SyntheticOIDCIssuer,
|
|
49
|
+
issuerListenPort,
|
|
49
50
|
} from '../lib/oidc-issuer.js';
|
|
50
51
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
51
52
|
import { req } from '../lib/requirement-ids.js';
|
|
@@ -174,8 +175,7 @@ describe('auth-oidc-user-bearer: harness-driven token validation', () => {
|
|
|
174
175
|
|
|
175
176
|
// Bind the harness's JWKS + discovery endpoints so the host can
|
|
176
177
|
// fetch them when validating tokens.
|
|
177
|
-
const
|
|
178
|
-
const port = parsed.port ? Number.parseInt(parsed.port, 10) : 80;
|
|
178
|
+
const port = issuerListenPort(harnessUrl);
|
|
179
179
|
|
|
180
180
|
server = createServer((reqBody, res) => {
|
|
181
181
|
if (!issuer) {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { describe, it, expect, afterAll } from 'vitest';
|
|
28
28
|
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
29
29
|
import { loadEnv } from '../lib/env.js';
|
|
30
|
+
import { hostPublicOrigin } from '../lib/host-public-origin.js';
|
|
30
31
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
31
32
|
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
32
33
|
import { req } from '../lib/requirement-ids.js';
|
|
@@ -121,7 +122,13 @@ describe('RFC 0199 §C — v2-credential-interrupt (gated on oauth + provider sy
|
|
|
121
122
|
expect([data['provider'], data['reason']], req(id, DOC, 'data names the provider and reason missing')).toEqual([PROVIDER, 'missing']);
|
|
122
123
|
const connect = new URL(String(data['connectUrl']));
|
|
123
124
|
expect(connect.protocol, req(id, DOC, 'connectUrl MUST be https')).toBe('https:');
|
|
124
|
-
|
|
125
|
+
// The host's own origin is where a USER AGENT reaches it: --base-url, or the
|
|
126
|
+
// operator-declared OPENWOP_HOST_PUBLIC_URL once shown to serve this host
|
|
127
|
+
// (lib/host-public-origin.ts, 2.39.4). A declared front that is not shown to
|
|
128
|
+
// serve it records `blocked`, never a pass on some other origin.
|
|
129
|
+
const own = await hostPublicOrigin(loadEnv().baseUrl);
|
|
130
|
+
if (!own.ok) return softSkip('blocked', own.reason);
|
|
131
|
+
expect(connect.origin, req(id, `${DOC} (connectUrl is host-owned)`, `connectUrl MUST be on the host's own origin (${own.origin}${own.declared ? ', declared via OPENWOP_HOST_PUBLIC_URL and verified to serve this host' : ''})`)).toBe(own.origin);
|
|
125
132
|
await driver.post(`/runs/${encodeURIComponent(runId)}:cancel`, {}, auth(r.bearer));
|
|
126
133
|
});
|
|
127
134
|
|
|
@@ -60,7 +60,7 @@ import { softSkip } from '../lib/soft-skip.js';
|
|
|
60
60
|
import { req } from '../lib/requirement-ids.js';
|
|
61
61
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
62
62
|
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
63
|
-
import { createSyntheticOIDCIssuer, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
|
|
63
|
+
import { createSyntheticOIDCIssuer, issuerListenPort, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
|
|
64
64
|
|
|
65
65
|
export const HOST_CALLBACK_NOT_REQUIRED =
|
|
66
66
|
'the suite stands up the synthetic OIDC issuer and the host fetches its JWKS; no request returns to the suite\'s own API, so no host-reachable callback is needed';
|
|
@@ -120,7 +120,6 @@ async function gate(): Promise<Gate | { readonly kind: 'inapplicable' | 'blocked
|
|
|
120
120
|
const audience = process.env['OPENWOP_TEST_OIDC_AUDIENCE']?.trim() ?? 'openwop-conformance';
|
|
121
121
|
if (issuer === null) {
|
|
122
122
|
const made = createSyntheticOIDCIssuer({ issuer: url, audience, algorithm: 'RS256' });
|
|
123
|
-
const parsed = new URL(url);
|
|
124
123
|
const srv = createServer((r, res) => {
|
|
125
124
|
if (r.url === '/.well-known/jwks.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.jwksJson); return; }
|
|
126
125
|
if (r.url === '/.well-known/openid-configuration') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.discoveryJson); return; }
|
|
@@ -128,7 +127,7 @@ async function gate(): Promise<Gate | { readonly kind: 'inapplicable' | 'blocked
|
|
|
128
127
|
});
|
|
129
128
|
await new Promise<void>((resolve, reject) => {
|
|
130
129
|
srv.once('error', reject);
|
|
131
|
-
srv.listen(
|
|
130
|
+
srv.listen(issuerListenPort(url), '127.0.0.1', () => resolve());
|
|
132
131
|
});
|
|
133
132
|
server = srv;
|
|
134
133
|
issuer = made;
|
|
@@ -34,7 +34,7 @@ import { driver } from '../lib/driver.js';
|
|
|
34
34
|
import { softSkip } from '../lib/soft-skip.js';
|
|
35
35
|
import { req } from '../lib/requirement-ids.js';
|
|
36
36
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
37
|
-
import { createSyntheticOIDCIssuer, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
|
|
37
|
+
import { createSyntheticOIDCIssuer, issuerListenPort, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
|
|
38
38
|
import { prmGate } from '../lib/protected-resource.js';
|
|
39
39
|
|
|
40
40
|
export const HOST_CALLBACK_NOT_REQUIRED =
|
|
@@ -56,7 +56,6 @@ async function harness(audience: string): Promise<{ url: string; issuer: Synthet
|
|
|
56
56
|
if (!url) return null;
|
|
57
57
|
if (issuer !== null) return { url, issuer };
|
|
58
58
|
const made = createSyntheticOIDCIssuer({ issuer: url, audience, algorithm: 'RS256' });
|
|
59
|
-
const parsed = new URL(url);
|
|
60
59
|
const srv = createServer((r, res) => {
|
|
61
60
|
if (r.url === '/.well-known/jwks.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.jwksJson); return; }
|
|
62
61
|
if (r.url === '/.well-known/openid-configuration') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.discoveryJson); return; }
|
|
@@ -64,7 +63,7 @@ async function harness(audience: string): Promise<{ url: string; issuer: Synthet
|
|
|
64
63
|
});
|
|
65
64
|
await new Promise<void>((resolve, reject) => {
|
|
66
65
|
srv.once('error', reject);
|
|
67
|
-
srv.listen(
|
|
66
|
+
srv.listen(issuerListenPort(url), '127.0.0.1', () => resolve());
|
|
68
67
|
});
|
|
69
68
|
server = srv;
|
|
70
69
|
issuer = made;
|
|
@@ -208,6 +208,24 @@ function retryWaitMs(doc: Record<string, unknown>): number {
|
|
|
208
208
|
*/
|
|
209
209
|
const FAIL_FIRST = 2;
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* How many attempts THIS host's advertised policy leaves room to refuse
|
|
213
|
+
* (suite 2.39.4). The constant above assumed at least three attempts, but
|
|
214
|
+
* `webhooks.retryPolicy.maxAttempts` is schema-valid from 1, so a host that
|
|
215
|
+
* honestly advertised `maxAttempts: 2` was refused twice, never reached its
|
|
216
|
+
* 204, and failed a leg it conformed to. The receiver now refuses
|
|
217
|
+
* `maxAttempts − 1` attempts, at most FAIL_FIRST and at least 1. A host with no
|
|
218
|
+
* advertised policy is measured exactly as before (FAIL_FIRST), and
|
|
219
|
+
* `maxAttempts: 1` still refuses the first attempt, so a host that never
|
|
220
|
+
* retries still fails: webhooks.md §Durability says best-effort delivery is
|
|
221
|
+
* not a conforming mode.
|
|
222
|
+
*/
|
|
223
|
+
function failFirstFor(policy: { maxAttempts?: number } | null): number {
|
|
224
|
+
const m = policy?.maxAttempts;
|
|
225
|
+
if (typeof m !== 'number' || !Number.isInteger(m)) return FAIL_FIRST;
|
|
226
|
+
return Math.max(1, Math.min(FAIL_FIRST, m - 1));
|
|
227
|
+
}
|
|
228
|
+
|
|
211
229
|
const WAIT_SLACK_MS = 30_000;
|
|
212
230
|
/** One `retryWaitMs` wait (the retry leg). */
|
|
213
231
|
const RETRY_TEST_TIMEOUT_MS = RETRY_WAIT_CAP_MS + WAIT_SLACK_MS;
|
|
@@ -241,7 +259,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
241
259
|
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
242
260
|
if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to deliver`);
|
|
243
261
|
|
|
244
|
-
const
|
|
262
|
+
const failFirst = failFirstFor(advertisedRetryPolicy(doc));
|
|
263
|
+
const receiver = await startReceiver(failFirst); // 500 × failFirst, then 204
|
|
245
264
|
active = receiver;
|
|
246
265
|
const sub = await register(receiver);
|
|
247
266
|
if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
@@ -287,8 +306,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
287
306
|
// inside our window this records `blocked`, NOT `executed-fail` — suite
|
|
288
307
|
// 2.0.3, and this is the third time this file has had to learn it.
|
|
289
308
|
//
|
|
290
|
-
// The receiver answers 204 only on attempt `
|
|
291
|
-
// costs the SUM of the first
|
|
309
|
+
// The receiver answers 204 only on attempt `failFirst + 1`, so reaching it
|
|
310
|
+
// costs the SUM of the first failFirst backoff intervals, not the largest
|
|
292
311
|
// one. On an exponential-from-30s policy that is 30 + 60 = 90 s, which is
|
|
293
312
|
// exactly RETRY_WAIT_CAP_MS — a host loses by the width of one delivery.
|
|
294
313
|
// The obvious fix is to derive the wait from the intervals, and it cannot
|
|
@@ -312,7 +331,7 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
312
331
|
// only in a changelog.
|
|
313
332
|
if (!retried) {
|
|
314
333
|
const policyNote = advertisedRetryPolicy(doc);
|
|
315
|
-
return softSkip('blocked', `the retry was observed (${attempts.length} attempts) but the receiver's 204 did not land inside the ${retryWaitMs(doc)}ms window: it answers 204 only on attempt ${
|
|
334
|
+
return softSkip('blocked', `the retry was observed (${attempts.length} attempts) but the receiver's 204 did not land inside the ${retryWaitMs(doc)}ms window: it answers 204 only on attempt ${failFirst + 1}, which costs the SUM of the first ${failFirst} backoff intervals, and webhooks.retryPolicy carries only { maxAttempts, backoff${policyNote ? `: ${String(policyNote.backoff)}` : ''} } — the base interval is not advertised, so the suite cannot derive how long to wait. Unmeasured, not unmet (RFC 0148 §A).`);
|
|
316
335
|
}
|
|
317
336
|
// Backoff: the retry MUST NOT be a tight loop — consecutive attempts for one
|
|
318
337
|
// key are spaced. Only asserted when the host advertises a non-`none` backoff.
|