@openwop/openwop-conformance 2.44.9 → 2.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.45.0] — 2026-09-29 — a host that grants an origin admits the contract's request headers in preflight
4
+
5
+ - **New scenario `v2-cors-preflight`: `openwop.requirement.headers.cors-preflight-admits`** (#1763; minor, since a scenario is added). It witnesses the new `headers.md` §Cross-origin preflight (Class 3).
6
+ - **Probe.** For every operation in `spec/v2/path-manifest.json` it sends `OPTIONS <path>` with `Origin`, `Access-Control-Request-Method`, and `Access-Control-Request-Headers`. The headers requested are that operation's declared request headers, plus `authorization` when authenticated and `content-type` with a body.
7
+ - **Reading.** It reads the answer as the Fetch standard's CORS-preflight check does. A CORS-safelisted method needs no listing. `*` admits nothing once credentials are allowed, and never admits `authorization`.
8
+ - **Gate.** It is self-gated on the host's grant: an operation whose preflight does not grant the origin is not judged, and the row is `inapplicable` when none is. The origin is `OPENWOP_CORS_ORIGIN` (default `https://conformance.invalid`), so an allowlisting host's operator can name an allowed one.
9
+ - **Verdicts.** Fails naming each granted operation and the missing names. Passes with an `observed:` detail (granted count).
10
+ - **Measured on stub hosts.** A copy of openwop-app's current middleware (reflect any origin, explicit list) records `executed-fail` on four operations: `getCapabilities` and `getRun` (`If-None-Match`), `createRun` (`OpenWOP-Force-Engine-Version`), `getContentPage` (`Accept-Language`). A host that reflects the request headers passes. A host that answers no CORS is `inapplicable`.
11
+ - **The per-operation sets come from the manifest.** `spec/v2/path-manifest.json` now carries `requestHeaders`, `authenticated` and `requestBody` per operation, written by `derive-v2-api.py` from `api/v2/openapi.yaml`, so the suite reads them without a YAML parser.
12
+
3
13
  ## [2.44.9] — 2026-09-29 — a floor gated on a withheld fixture records `blocked`, not nothing
4
14
 
5
15
  - **Nine v1 floor files no longer skip at describe level** (#1686). Each of the following gated every test with `describe.skipIf(!isFixtureAdvertised(…))`: `eventOrdering`, `failure-path`, `idempotency`, `interrupt-clarification`, `replay-fork`, `runs-lifecycle`, `stream-modes`, `stream-modes-buffer` and `stream-modes-mixed`. When every test in a file is describe-skipped, vitest runs none of `setup.ts`'s hooks for it, so the file's floor requirement got no ledger row. `--certify` then counted the floor unclassified and rejected the whole certification, not just the profile. `byok-roundtrip` hit this on openwop-app's 2026-09-28 major-1 cut (`certified: none`) and was fixed in #1708. Each test in the nine files now opens with `if (SKIP) return softSkip('blocked', NO_FIXTURE_REASON)`, so a withheld fixture records `blocked` with the fixture named. That denies only the affected profile. It is `blocked`, not `inapplicable`: the host advertises the capability the floor witnesses, so the requirement applies to the captured discovery, and a withheld fixture is exactly RFC 0148 §A's `blocked` ("a required seam, fixture … was unavailable"). `byok-roundtrip`'s `blocked` (#1708) stands for the same reason. Measured against a stub host that advertises no fixtures, with the ledger on: before, `runs-lifecycle` wrote no ledger row; after, `openwop.floor.runs-lifecycle` is `blocked` with the reason. A test that passed before still passes: the gates are unchanged, only where they sit.
package/README.md CHANGED
@@ -135,7 +135,8 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
135
135
 
136
136
  ## What's Covered
137
137
 
138
- The current suite has 574 scenario files under `src/scenarios/`.
138
+ The current suite has 575 scenario files under `src/scenarios/`.
139
+ - 2026-09-29 (suite 2.45.0, openwop#1763): NEW `v2-cors-preflight.test.ts` — `headers.md` §Cross-origin preflight. For every operation it preflights with `Origin`, the method, and the operation's declared request headers (plus `authorization` / `content-type` where they apply, from `spec/v2/path-manifest.json`), and reads the answer as the Fetch standard's CORS-preflight check does. Self-gated: `inapplicable` when no operation's preflight grants `OPENWOP_CORS_ORIGIN` (default `https://conformance.invalid`).
139
140
  - 2026-09-23 (suite 2.37.0 cycle, RFC 0213): NEW `v2-sse-last-event-id-cursor.test.ts` (a `Last-Event-ID` past the log is an exclusive cursor; a malformed id, when refused, is `400 validation_error`; the cursor never changes the answer for an unknown or foreign-tenant run — public test of `event-cursor-after-authorization`), `v2-idempotency-in-flight.test.ts` (five concurrent same-key creates yield one run; each loser is a marked replay or `409 idempotency_in_flight` with no retry timing in `details`; `partial-witness` when no loser was refused in flight) and `v2-interrupt-resolve-terminal.test.ts` (a run-scoped resolve after cancel or completion is `409 interrupt_already_resolved`, never `interrupt_cancelled`). All three sit off the core-standard floor until measured on the three bundle hosts.
140
141
  - 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
141
142
  - 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
@@ -481,7 +482,7 @@ Server-required (added in 1.7.0):
481
482
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
482
483
  | **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
483
484
 
484
- Current source tree: 574 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
485
+ Current source tree: 575 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
485
486
 
486
487
  ## Remaining Gaps
487
488
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.44.9",
4
- "stampSha256": "7803a5db18abdf8649235a2665ae9bb801aea6ee7584cdd13ada047fd25a8993"
3
+ "version": "2.45.0",
4
+ "stampSha256": "f4646a61110c8c6132dfc23c78868cdfaaa6e18cda1d8247fac0b123f29c693e"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.44.9",
3
+ "version": "2.45.0",
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.44.9"
59
+ "@openwop/spec-artifacts": "2.45.0"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
- "files": 649,
6
- "tests": 2495,
7
- "withStableId": 2495,
5
+ "files": 650,
6
+ "tests": 2496,
7
+ "withStableId": 2496,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2374
9
+ "explicitIds": 2375
10
10
  },
11
11
  "records": [
12
12
  {
@@ -28995,6 +28995,20 @@
28995
28995
  }
28996
28996
  ]
28997
28997
  },
28998
+ {
28999
+ "id": "openwop.it.v2-cors-preflight.every-operation-a-granted-origin-preflights-admits-its-method-and-every-request",
29000
+ "file": "v2-cors-preflight.test.ts",
29001
+ "line": 110,
29002
+ "title": "every operation a granted origin preflights admits its method and every request header the contract declares for it",
29003
+ "explicitId": "openwop.requirement.headers.cors-preflight-admits",
29004
+ "citations": [
29005
+ {
29006
+ "section": "spec/v2/core/headers.md §Cross-origin preflight",
29007
+ "requirement": null,
29008
+ "interpolated": true
29009
+ }
29010
+ ]
29011
+ },
28998
29012
  {
28999
29013
  "id": "openwop.it.v2-created-run-readable.a-run-created-at-this-base-is-readable-and-pollable-at-this-base-by-the-id-it-re",
29000
29014
  "file": "v2-created-run-readable.test.ts",
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
3
3
  "counts": {
4
- "files": 574,
4
+ "files": 575,
5
5
  "v1": 452,
6
- "v2": 137
6
+ "v2": 138
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1347,6 +1347,9 @@
1347
1347
  "v2-conversation-turn-parts.test.ts": [
1348
1348
  2
1349
1349
  ],
1350
+ "v2-cors-preflight.test.ts": [
1351
+ 2
1352
+ ],
1350
1353
  "v2-created-run-readable.test.ts": [
1351
1354
  2
1352
1355
  ],
@@ -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.44.9",
5
- "corpusTag": "v2.44.9",
4
+ "version": "2.45.0",
5
+ "corpusTag": "v2.45.0",
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": "69464bc0e1e71ef3b543a73d4fa4f67202343d2603c80af8033f6c3619ef1b30",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "57cf1d472f2bf9bf61bcd2239b9fd43277072d02a367d7a85725aea0e01300db",
13
- "api/v2/asyncapi.yaml": "40cb3ee66657a736b92c6f79241169e3669486058a65e143d99509d41d691d67",
14
- "api/v2/openapi.yaml": "08581315aa90da54f9a1c56f8b972a801eec99bda752d92c3ea2b16113b33a92",
12
+ "api/seams-v2.yaml": "92744ab58f83798c63b96700475bd55c201e7af14c3fd230c31ab0bbd86f6a60",
13
+ "api/v2/asyncapi.yaml": "4f00d2847c3ffb526af9c294f9e318b762f197e86cfd607934e677f12a1fc8bc",
14
+ "api/v2/openapi.yaml": "396fe10fc350cbae59190c4bd0e4cd24e18b16b4aac9db2f6c78e54344db4709",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -204,13 +204,13 @@
204
204
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
205
205
  "spec/v1/alias-detectors.json": "0b2f28808ffe0b9d98a187775038c3406bb70919747a788a6e4f866e293ae6d3",
206
206
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
207
- "spec/v1/core-standard-manifest.json": "cdc802f553a24b2ea8288370a54a9cf9a88d65bb80ec33c258c8fe2d2737edd1",
207
+ "spec/v1/core-standard-manifest.json": "684dff535d3536673129ef594b97717cbc30fd066a6ea4d2c52d9fbbde69836d",
208
208
  "spec/v1/deprecations.json": "a3293072ea951857395d05f0437264e88965f1e9019ff61c55bacfa9c1aaa17c",
209
209
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
210
210
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
211
211
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
212
212
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
213
- "spec/v1/gaps.json": "7ba482a850d94e185f12b69929e35054d24b72131a997d0ef70d59c10b83f7eb",
213
+ "spec/v1/gaps.json": "5be0fd0a8055f5cac76346fc2a98c44553f73d3f28a39ce12dcf1979d55d1409",
214
214
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
215
215
  "spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
216
216
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
@@ -226,7 +226,7 @@
226
226
  "spec/v2/core/events.md": "c1a20e3aa6736119ea07aa2cf66faa808a1874d2cde4933033b65506d1742883",
227
227
  "spec/v2/core/execution.md": "8a0c9db1bbfb97c008ab9328b7333c1cc3318196e78bfbefb49e781bdf4f9d44",
228
228
  "spec/v2/core/form-content-packs.md": "02931227899676fd4581a27017be2a6c4953904717be9b81c29d12f87ab3917d",
229
- "spec/v2/core/headers.md": "5cb1624fa63acc81992b6a6d842ec392cf7271aaf2f65d1f83c8c4fa286e6730",
229
+ "spec/v2/core/headers.md": "21d6c8220c3ac75b10e31c4cf6179abb539e2558af5006e40228d8c383ed5c92",
230
230
  "spec/v2/core/host-services.md": "18389b55286875922c26e13fbcd6c787fca2568f68b7e6f65176f1938404a218",
231
231
  "spec/v2/core/i18n.md": "882904b7d59f992ab1e5f7346167161a54cf665b8fee47e64a3862cf9133a580",
232
232
  "spec/v2/core/idempotency.md": "adbd317a6ebb57c2759c076297b31f67def9e391576bccd0bbea787b13e85872",
@@ -300,12 +300,12 @@
300
300
  "spec/v2/interop-map.schema.json": "0017c882df34ee215e2e0729de051490e09d43aac3d2c949285698ed51832f12",
301
301
  "spec/v2/migrations.json": "651954938a1e154c0b6f1e3dd6e105889d30de3bb65789f21de7f77a12869e9e",
302
302
  "spec/v2/migrations.schema.json": "b91ade6320ad1b1185c9495b9424f3aa63438ed8e412c45171c227d82c0afd86",
303
- "spec/v2/path-manifest.json": "17298a245c5da9a70aa7ab0b7f2db4277e06209d6fcdd7187a8b4cea179c08f3",
303
+ "spec/v2/path-manifest.json": "27c2307b84376b2f22bea4e2213fc7e33ef3bf8fd1d18039f7955edb00dc9c9a",
304
304
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
305
305
  "spec/v2/profiles.json": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
306
- "spec/v2/release.json": "0219a1874302b4f3f2e8494d3c972cb649483838f53076e612c04bdf298c56dd",
306
+ "spec/v2/release.json": "89c093ee04d4aaceef641782e53fbc368e21123e244246f1727fa8ac3b151101",
307
307
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
308
- "spec/v2/surface-baseline.json": "88e9e211ec1c8d532c413b7407a15f15a3001099b9cda77f2bcc694028a72544"
308
+ "spec/v2/surface-baseline.json": "85c44cc57bfc9f8242e77dc27d7b28a1b239c694ef761cce4748ab15d6a3290f"
309
309
  },
310
- "corpusCommit": "16a175bf4ad0485d8c1eefe97a83bfbc8d1057bc"
310
+ "corpusCommit": "1ac68fd60069d0b9729312976de4946171281f87"
311
311
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `headers.md` §Cross-origin preflight — a host that grants an origin admits
3
+ * what a conforming browser client sends (suite 2.45.0, target major 2;
4
+ * openwop#1763; self-gated on the host's own grant).
5
+ *
6
+ * A browser client MUST send `OpenWOP-Version` and `OpenWOP-Client-Version` on
7
+ * every request (versioning.md §1.3, §1.5), `Authorization` on an
8
+ * authenticated operation and `Content-Type: application/json` with a body.
9
+ * None of them is CORS-safelisted, so the browser preflights. When the host
10
+ * grants the origin but leaves one out of `Access-Control-Allow-Headers`, the
11
+ * browser never sends the request: the operation is unreachable from that
12
+ * origin and the host's logs show nothing. Both production hosts shipped that
13
+ * outage with explicit lists: openwop-app on `OpenWOP-Version` (2026-09-18) and
14
+ * both on `OpenWOP-Client-Version` at the SDK 2.5.0 release (RFC 0219 G8).
15
+ *
16
+ * The admitted set per operation comes from `spec/v2/path-manifest.json`, which
17
+ * `derive-v2-api.py` writes from `api/v2/openapi.yaml`: the declared header
18
+ * parameters, plus `Authorization` when authenticated and `Content-Type` when
19
+ * the operation takes a body. For each operation the leg sends
20
+ * `OPTIONS <path>` (path parameters filled with a placeholder) with `Origin`,
21
+ * `Access-Control-Request-Method` and `Access-Control-Request-Headers`, and
22
+ * reads the answer the way the Fetch standard's CORS-preflight check does:
23
+ *
24
+ * - granted: `Access-Control-Allow-Origin` is the origin or `*`. Anything
25
+ * else and the host does not serve that origin: that operation is not
26
+ * judged (a host MAY grant no origin at all; the rule is conditional).
27
+ * - method: a CORS-safelisted method (GET, HEAD, POST) is admitted without
28
+ * listing; any other must be listed, or `*` when credentials are not
29
+ * allowed. Compared case-sensitively, as Fetch does.
30
+ * - headers: every requested name listed (case-insensitive), or `*` when
31
+ * credentials are not allowed, and `*` never admits `authorization`.
32
+ *
33
+ * The row passes when at least one operation was granted and every granted one
34
+ * admits its set, fails when any granted one does not (naming the operation and
35
+ * the missing names), and is `inapplicable` when no operation was granted. The
36
+ * origin is `OPENWOP_CORS_ORIGIN` (default `https://conformance.invalid`): a
37
+ * host with an allowlist is inapplicable at the default, and its operator sets
38
+ * an allowlisted origin to be witnessed. A pass carries an `observed:` detail.
39
+ *
40
+ * @see spec/v2/core/headers.md §Cross-origin preflight
41
+ */
42
+ import { describe, it, expect } from 'vitest';
43
+ import { readFileSync } from 'node:fs';
44
+ import { join } from 'node:path';
45
+ import { loadEnv } from '../lib/env.js';
46
+ import { v2Discovery } from '../lib/v2.js';
47
+ import { softSkip } from '../lib/soft-skip.js';
48
+ import { req } from '../lib/requirement-ids.js';
49
+ import { noteObservation } from '../lib/row-observation.js';
50
+ import { SCHEMAS_DIR } from '../lib/paths.js';
51
+
52
+ const ID = 'openwop.requirement.headers.cors-preflight-admits';
53
+ const DOC = 'spec/v2/core/headers.md §Cross-origin preflight';
54
+ const ORIGIN_ENV = 'OPENWOP_CORS_ORIGIN';
55
+ const DEFAULT_ORIGIN = 'https://conformance.invalid';
56
+ /** Fetch standard: a CORS-safelisted method passes the preflight method check without being listed. */
57
+ const SAFELISTED_METHODS = new Set(['GET', 'HEAD', 'POST']);
58
+ const PLACEHOLDER = 'conformance-cors-probe';
59
+
60
+ interface ManifestOp { readonly method: string; readonly path: string; readonly operationId: string; readonly requestHeaders?: readonly string[]; readonly authenticated?: boolean; readonly requestBody?: boolean }
61
+ interface Preflight { readonly status: number | null; readonly allowOrigin: string | null; readonly allowMethods: string | null; readonly allowHeaders: string | null; readonly allowCredentials: string | null }
62
+
63
+ function operations(): ManifestOp[] | null {
64
+ try {
65
+ const manifest = JSON.parse(readFileSync(join(SCHEMAS_DIR, '..', 'spec', 'v2', 'path-manifest.json'), 'utf8')) as { operations?: ManifestOp[] };
66
+ const ops = manifest.operations ?? [];
67
+ return ops.length > 0 && ops.every((o) => Array.isArray(o.requestHeaders) && typeof o.authenticated === 'boolean' && typeof o.requestBody === 'boolean') ? ops : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** The names a conforming browser client puts in Access-Control-Request-Headers for this operation, lowercased. */
74
+ export function requestedHeaders(op: ManifestOp): string[] {
75
+ const names = new Set((op.requestHeaders ?? []).map((h) => h.toLowerCase()));
76
+ if (op.authenticated === true) names.add('authorization');
77
+ if (op.requestBody === true) names.add('content-type');
78
+ return [...names].sort();
79
+ }
80
+
81
+ const tokens = (v: string | null): string[] => (v ?? '').split(',').map((t) => t.trim()).filter((t) => t.length > 0);
82
+
83
+ /** What the preflight answer fails to admit, per the Fetch standard's CORS-preflight check; empty when it admits everything. */
84
+ export function unadmitted(op: ManifestOp, p: Preflight): { method: string | null; headers: string[] } {
85
+ const credentials = p.allowCredentials === 'true';
86
+ const methods = tokens(p.allowMethods);
87
+ const methodOk = SAFELISTED_METHODS.has(op.method) || methods.includes(op.method) || (!credentials && methods.includes('*'));
88
+ const listed = new Set(tokens(p.allowHeaders).map((h) => h.toLowerCase()));
89
+ const star = !credentials && listed.has('*');
90
+ const headers = requestedHeaders(op).filter((h) => !listed.has(h) && !(star && h !== 'authorization'));
91
+ return { method: methodOk ? null : op.method, headers };
92
+ }
93
+
94
+ async function preflight(baseUrl: string, op: ManifestOp, origin: string): Promise<Preflight> {
95
+ const path = op.path.replace(/\{[^}]+\}/g, PLACEHOLDER);
96
+ try {
97
+ const res = await fetch(`${baseUrl.replace(/\/$/, '')}${path}`, {
98
+ method: 'OPTIONS',
99
+ headers: { Origin: origin, 'Access-Control-Request-Method': op.method, 'Access-Control-Request-Headers': requestedHeaders(op).join(',') },
100
+ });
101
+ await res.arrayBuffer().catch(() => undefined);
102
+ const h = res.headers;
103
+ return { status: res.status, allowOrigin: h.get('access-control-allow-origin'), allowMethods: h.get('access-control-allow-methods'), allowHeaders: h.get('access-control-allow-headers'), allowCredentials: h.get('access-control-allow-credentials') };
104
+ } catch {
105
+ return { status: null, allowOrigin: null, allowMethods: null, allowHeaders: null, allowCredentials: null };
106
+ }
107
+ }
108
+
109
+ describe('headers.md §Cross-origin preflight (self-gated on the host granting the origin)', () => {
110
+ it('every operation a granted origin preflights admits its method and every request header the contract declares for it', async () => {
111
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
112
+ const ops = operations();
113
+ if (ops === null) return softSkip('blocked', 'spec/v2/path-manifest.json carries no per-operation requestHeaders / authenticated / requestBody (a corpus older than openwop#1763), so the admitted sets cannot be derived');
114
+ const origin = process.env[ORIGIN_ENV]?.trim() || DEFAULT_ORIGIN;
115
+ const { baseUrl } = loadEnv();
116
+
117
+ const granted: string[] = [];
118
+ const failures: string[] = [];
119
+ for (const op of ops) {
120
+ const p = await preflight(baseUrl, op, origin);
121
+ if (p.allowOrigin !== origin && p.allowOrigin !== '*') continue;
122
+ granted.push(op.operationId);
123
+ const miss = unadmitted(op, p);
124
+ if (miss.method !== null || miss.headers.length > 0) {
125
+ const parts = [
126
+ ...(miss.method === null ? [] : [`method ${miss.method} not in Access-Control-Allow-Methods (${JSON.stringify(p.allowMethods)})`]),
127
+ ...(miss.headers.length === 0 ? [] : [`${miss.headers.join(', ')} not in Access-Control-Allow-Headers (${JSON.stringify(p.allowHeaders)}${p.allowCredentials === 'true' ? ', credentials allowed so * admits nothing' : ''})`]),
128
+ ];
129
+ failures.push(`${op.operationId} (${op.method} ${op.path}): ${parts.join('; ')}`);
130
+ }
131
+ }
132
+ noteObservation(`origin ${origin}: ${granted.length}/${ops.length} operation(s) granted, ${failures.length} not admitting their set`);
133
+ if (granted.length === 0) {
134
+ return softSkip('inapplicable', `no operation's preflight granted origin ${origin} (Access-Control-Allow-Origin neither it nor *): the host does not serve that origin cross-origin, which is host policy. An operator whose host allowlists origins sets ${ORIGIN_ENV} to one of them to be witnessed`);
135
+ }
136
+ expect(
137
+ failures,
138
+ req(ID, DOC, `a host that grants an origin MUST admit, per operation, its method and every request header api/v2/openapi.yaml declares for it, Authorization when authenticated and Content-Type with a body; a browser that is refused never sends the request. ${failures.length} of ${granted.length} granted operation(s) do not: ${failures.join(' | ')}`),
139
+ ).toEqual([]);
140
+ });
141
+ });