@openwop/openwop-conformance 2.45.25 → 2.45.26
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,11 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.45.26] — 2026-10-08 — RFC 0236's legs run without the seam (RFC 0241 Active)
|
|
4
|
+
|
|
5
|
+
- **No scenario change.** RFC 0237 goes `Accepted` on the v2 reference host's certified 2.45.25 cut; its gaps G1 and G2 close, which changes the packed `spec/v1/gaps.json`.
|
|
6
|
+
- **`v2-host-event-delivery` causes events through RFC 0241's `POST /host/events/test`** when the host lists the reserved `host-test.durable-triggered` / `host-test.ephemeral-triggered` types, else through the §G seam, so a seam-free production host witnesses all six `openwop.requirement.0236.*` rows. New leg `openwop.requirement.0241.trigger.bound-by-listing`: each listed class is accepted with its reserved type, an unlisted class is `400 validation_error`, and nothing is served (`404`/`405`) when no `host-test.*` type is listed. Proven on a seams-off v2 reference host: all eight rows `executed-pass`; a trigger answering a real type, and a frame reaching a second tenant, each `executed-fail`. The self-test double gains the trigger choice and the binding judge.
|
|
7
|
+
- **`v2-host-event-delivery`: `0236.webhook.ephemeral-refused` no longer gates on the seams profile.** It registers a webhook naming an ephemeral type and expects `400 validation_error`; it causes no event, so the §G emit seam was never needed. It now runs on any host advertising `hostEvents` with an ephemeral type, preferring an `example.*` one (a production host's `channel.presence` will do), and records `inapplicable` when none is listed. Proven on a seam-free v2 reference host: `executed-pass`, and `executed-fail` with the refusal removed. The five legs that cause an event use RFC 0241's trigger when the host lists it (next bullet).
|
|
8
|
+
|
|
3
9
|
## [2.45.25] — 2026-10-07 — `v2-wasm-abi-reject`; the frontend-plugin schema legs leave host bundles
|
|
4
10
|
|
|
5
11
|
- **New `v2-wasm-abi-reject` (`openwop.requirement.node-pack-runtimes.wasm-abi-reject`).** Gated on `nodePackRuntimes.wasm` and the new operator-installed fixture `conformance-wasm-pack-abi-mismatch`, which invokes `vendor.openwop.misbehaving.abi-bomb` (the `misbehaving-abi` pack, ABI version 999). Loading is not a protocol operation, so the leg reads what a rejection at load prevents: the pack in an advertised `loadedPacks`, and the node completing. The run must be refused at creation (4xx) or end without that node completing; a 401/403/429/5xx is `blocked`. Proof: a 9-case self-test double (`lib/wasm-abi-reject-witness.test.ts`). No RFC was needed: both observations already exist.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.45.
|
|
3
|
+
"version": "2.45.26",
|
|
4
4
|
"description": "Black-box conformance suite for OpenWOP hosts (v2 current; v1 scenarios retained for the frozen v1 tree).",
|
|
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.45.
|
|
59
|
+
"@openwop/spec-artifacts": "2.45.26"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
5
|
"files": 737,
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
6
|
+
"tests": 2782,
|
|
7
|
+
"withStableId": 2782,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
9
|
"explicitIds": 2644
|
|
10
10
|
},
|
|
@@ -31353,7 +31353,7 @@
|
|
|
31353
31353
|
{
|
|
31354
31354
|
"id": "openwop.it.v2-host-event-delivery.an-emitted-durable-host-event-arrives-on-host-events-as-a-valid-envelope",
|
|
31355
31355
|
"file": "v2-host-event-delivery.test.ts",
|
|
31356
|
-
"line":
|
|
31356
|
+
"line": 160,
|
|
31357
31357
|
"title": "an emitted durable host event arrives on /host/events as a valid envelope",
|
|
31358
31358
|
"explicitId": "openwop.requirement.0236.host-event.envelope",
|
|
31359
31359
|
"citations": [
|
|
@@ -31366,7 +31366,7 @@
|
|
|
31366
31366
|
{
|
|
31367
31367
|
"id": "openwop.it.v2-host-event-delivery.an-ephemeral-host-event-has-no-id-and-is-not-redelivered-after-last-event-id",
|
|
31368
31368
|
"file": "v2-host-event-delivery.test.ts",
|
|
31369
|
-
"line":
|
|
31369
|
+
"line": 174,
|
|
31370
31370
|
"title": "an ephemeral host event has no id: and is not redelivered after Last-Event-ID",
|
|
31371
31371
|
"explicitId": null,
|
|
31372
31372
|
"citations": []
|
|
@@ -31374,7 +31374,7 @@
|
|
|
31374
31374
|
{
|
|
31375
31375
|
"id": "openwop.it.v2-host-event-delivery.registerwebhook-refuses-an-ephemeral-host-event-type-with-400-validation-error",
|
|
31376
31376
|
"file": "v2-host-event-delivery.test.ts",
|
|
31377
|
-
"line":
|
|
31377
|
+
"line": 191,
|
|
31378
31378
|
"title": "registerWebhook refuses an ephemeral host-event type with 400 validation_error",
|
|
31379
31379
|
"explicitId": "openwop.requirement.0236.webhook.ephemeral-refused",
|
|
31380
31380
|
"citations": [
|
|
@@ -31392,7 +31392,7 @@
|
|
|
31392
31392
|
{
|
|
31393
31393
|
"id": "openwop.it.v2-host-event-delivery.a-subscribed-durable-host-event-is-delivered-as-hostevent-signed",
|
|
31394
31394
|
"file": "v2-host-event-delivery.test.ts",
|
|
31395
|
-
"line":
|
|
31395
|
+
"line": 207,
|
|
31396
31396
|
"title": "a subscribed durable host event is delivered as { hostEvent }, signed",
|
|
31397
31397
|
"explicitId": "openwop.requirement.0236.webhook.host-variant",
|
|
31398
31398
|
"citations": [
|
|
@@ -31406,15 +31406,23 @@
|
|
|
31406
31406
|
{
|
|
31407
31407
|
"id": "openwop.it.v2-host-event-delivery.the-same-subscription-never-receives-the-ephemeral-host-event",
|
|
31408
31408
|
"file": "v2-host-event-delivery.test.ts",
|
|
31409
|
-
"line":
|
|
31409
|
+
"line": 243,
|
|
31410
31410
|
"title": "the same subscription never receives the ephemeral host event",
|
|
31411
31411
|
"explicitId": null,
|
|
31412
31412
|
"citations": []
|
|
31413
31413
|
},
|
|
31414
|
+
{
|
|
31415
|
+
"id": "openwop.it.v2-host-event-delivery.emittesthostevent-is-served-exactly-for-the-listed-host-test-classes",
|
|
31416
|
+
"file": "v2-host-event-delivery.test.ts",
|
|
31417
|
+
"line": 256,
|
|
31418
|
+
"title": "emitTestHostEvent is served exactly for the listed host-test classes",
|
|
31419
|
+
"explicitId": null,
|
|
31420
|
+
"citations": []
|
|
31421
|
+
},
|
|
31414
31422
|
{
|
|
31415
31423
|
"id": "openwop.it.v2-host-event-delivery.a-second-tenant-receives-nothing-for-the-first-tenant-s-host-event",
|
|
31416
31424
|
"file": "v2-host-event-delivery.test.ts",
|
|
31417
|
-
"line":
|
|
31425
|
+
"line": 270,
|
|
31418
31426
|
"title": "a second tenant receives nothing for the first tenant’s host event",
|
|
31419
31427
|
"explicitId": null,
|
|
31420
31428
|
"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.45.
|
|
5
|
-
"corpusTag": "v2.45.
|
|
4
|
+
"version": "2.45.26",
|
|
5
|
+
"corpusTag": "v2.45.26",
|
|
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": "ac6de5303872664b78ff419c035915593e01599e02835e777a0123de1baeb398",
|
|
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": "d3b59eb1f87f42ba118bdbbdfb74e6f499081d8d5ed5fba3b06d5ae561bc90bd",
|
|
13
|
+
"api/v2/asyncapi.yaml": "dc2ed7cd47e0ca326e89badc1f0b0c3a64b2030859559786c711230e5e7474f1",
|
|
14
|
+
"api/v2/openapi.yaml": "3fbb8672105a847d457e13cde09bf65b02b02163cf1082f08b3cec68088a416a",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "276fc1d51eb08e14e9127b9fd27761d7046ee33c12000f3a64a801322db8bf58",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
|
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
"schemas/v2/goal.schema.json": "ad104ed5a3866736b8fed9d7c0b23dc82af526b5bef4afe848f01fc3af2161e9",
|
|
153
153
|
"schemas/v2/heartbeat-evaluated.schema.json": "c897b0792fd518d6e12879851027252af1529f5e420c3eaf434ad7af284ecc05",
|
|
154
154
|
"schemas/v2/heartbeat-state-changed.schema.json": "eb68d0082aa996650e3a6b8ccf32457f959cebc7e6728cc7c331b9d171a4bef6",
|
|
155
|
-
"schemas/v2/host-event.schema.json": "
|
|
155
|
+
"schemas/v2/host-event.schema.json": "655b5475b1aeb41c8fc4ff7b6fc7e31a0fcc18fd59f368336ccbc138e6ba395e",
|
|
156
156
|
"schemas/v2/ids.schema.json": "65cfaa193be0ca9cf48af39c18c553979e3ec1f7fc04509aaa513b322d7b7e4a",
|
|
157
157
|
"schemas/v2/localized-content-language-settings.schema.json": "3c8350a178037a460f5d87466ccaf9ed5fe3d41497786d2504cc66e42152c8cc",
|
|
158
158
|
"schemas/v2/localized-content-page-response.schema.json": "ef48329749758afb21e4d3cbc3f017abb911f75726819457f35b9f1390332c75",
|
|
@@ -208,14 +208,14 @@
|
|
|
208
208
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
209
209
|
"spec/v1/alias-detectors.json": "22444c22bb88c568339ef512553f9fc38146e22b566d11ab8608f655d688d19c",
|
|
210
210
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
211
|
-
"spec/v1/core-standard-manifest.json": "
|
|
211
|
+
"spec/v1/core-standard-manifest.json": "cd739e168fa36dd811fd9ed1f506e0716c592e7a61c0d12164f82732ca366177",
|
|
212
212
|
"spec/v1/deprecations.json": "e723bc11d7272c71789ad1303bb7f21809a8a024abf57594f9d5c35dc199ece9",
|
|
213
213
|
"spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
|
|
214
214
|
"spec/v1/end-of-support.json": "35f0bf3b70fe78b16e2a2fd81a153fc27e703f6c16376c6fe7cdf1f4f2c4e6d4",
|
|
215
215
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
216
216
|
"spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
|
|
217
217
|
"spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
|
|
218
|
-
"spec/v1/gaps.json": "
|
|
218
|
+
"spec/v1/gaps.json": "a0aec8f5ddf1002d46a461a9c7d930c8f0656a19b68bb1f52d183b97cc1962cf",
|
|
219
219
|
"spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
|
|
220
220
|
"spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
|
|
221
221
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
@@ -228,10 +228,10 @@
|
|
|
228
228
|
"spec/v2/core/connection-packs.md": "c80f95bd6a03698f35c72cdb2b46bd973f49203bbd786c26760e35fa86e6f23d",
|
|
229
229
|
"spec/v2/core/conversation.md": "8d6abedf6af408a74577753bfca24719d318e2632e10b45ff5d63bdcf31a8aaa",
|
|
230
230
|
"spec/v2/core/errors.md": "e48838701b86bbfe0ffee77b18a8ffc4e5f04724b4318507d9771da653f8da0f",
|
|
231
|
-
"spec/v2/core/events.md": "
|
|
231
|
+
"spec/v2/core/events.md": "d0f902edc080e9789e4fd05850a1264d905ebe5b06045b6a7a22486e22ef3091",
|
|
232
232
|
"spec/v2/core/execution.md": "8a0c9db1bbfb97c008ab9328b7333c1cc3318196e78bfbefb49e781bdf4f9d44",
|
|
233
233
|
"spec/v2/core/form-content-packs.md": "02931227899676fd4581a27017be2a6c4953904717be9b81c29d12f87ab3917d",
|
|
234
|
-
"spec/v2/core/headers.md": "
|
|
234
|
+
"spec/v2/core/headers.md": "f69fe81e4a3ad21879bb85948660c16860eb05bf152928c31aa4898f6d7d1887",
|
|
235
235
|
"spec/v2/core/host-services.md": "168a40935a03867ee82461b5d83d05578be25ee3e5af4eb080587b891231e242",
|
|
236
236
|
"spec/v2/core/i18n.md": "882904b7d59f992ab1e5f7346167161a54cf665b8fee47e64a3862cf9133a580",
|
|
237
237
|
"spec/v2/core/idempotency.md": "adbd317a6ebb57c2759c076297b31f67def9e391576bccd0bbea787b13e85872",
|
|
@@ -310,12 +310,12 @@
|
|
|
310
310
|
"spec/v2/interop-map.schema.json": "0017c882df34ee215e2e0729de051490e09d43aac3d2c949285698ed51832f12",
|
|
311
311
|
"spec/v2/migrations.json": "651954938a1e154c0b6f1e3dd6e105889d30de3bb65789f21de7f77a12869e9e",
|
|
312
312
|
"spec/v2/migrations.schema.json": "b91ade6320ad1b1185c9495b9424f3aa63438ed8e412c45171c227d82c0afd86",
|
|
313
|
-
"spec/v2/path-manifest.json": "
|
|
313
|
+
"spec/v2/path-manifest.json": "7b5679a4dad98c80d855361d88a98c8a4e045af15e310862a05b285b815dc9fc",
|
|
314
314
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
315
315
|
"spec/v2/profiles.json": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
|
|
316
|
-
"spec/v2/release.json": "
|
|
316
|
+
"spec/v2/release.json": "7ce8ba7234bfe7eff4c767cc0aa4ddea0495fae08212fe98f63ae312cab06c74",
|
|
317
317
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
318
|
-
"spec/v2/surface-baseline.json": "
|
|
318
|
+
"spec/v2/surface-baseline.json": "da9da09f5133be09b75820d138c5b4422365b2b8d113597c7fa89c99913069b3"
|
|
319
319
|
},
|
|
320
|
-
"corpusCommit": "
|
|
320
|
+
"corpusCommit": "906642cfb7cea6d4be59f0471866f8ffa3e4c702"
|
|
321
321
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* self-test (`host-event-witness.test.ts`) can feed each one a defect and show
|
|
5
5
|
* it convicts.
|
|
6
6
|
*
|
|
7
|
-
* The scenario owns the I/O (the `/host/events` stream, the emit seam, the
|
|
7
|
+
* The scenario owns the I/O (the `/host/events` stream, the RFC 0241 trigger or the emit seam, the
|
|
8
8
|
* scoped webhook receiver). This file owns only "given what arrived, which
|
|
9
9
|
* `events.md` §Host events / `webhooks.md` §Delivery rule failed?".
|
|
10
10
|
*/
|
|
@@ -30,6 +30,52 @@ export function exampleType(types: readonly AdvertisedType[], delivery: Advertis
|
|
|
30
30
|
return types.find((t) => t.type.startsWith('example.') && t.delivery === delivery)?.type ?? null;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Any advertised type of the class, preferring the conformance `example.*` one. For a leg
|
|
35
|
+
* that only names a type and causes no event, so it runs on a host without the §G seam.
|
|
36
|
+
*/
|
|
37
|
+
export function advertisedTypeOf(types: readonly AdvertisedType[], delivery: AdvertisedType['delivery']): string | null {
|
|
38
|
+
return exampleType(types, delivery) ?? types.find((t) => t.delivery === delivery)?.type ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** RFC 0241 §A — the reserved test types, one per delivery class. */
|
|
42
|
+
export const TEST_TYPES = { durable: 'host-test.durable-triggered', ephemeral: 'host-test.ephemeral-triggered' } as const;
|
|
43
|
+
|
|
44
|
+
/** The reserved test type of a class, when the host lists it (RFC 0241 §B.1: listing it binds the trigger). */
|
|
45
|
+
export function testTypeListed(types: readonly AdvertisedType[], delivery: AdvertisedType['delivery']): string | null {
|
|
46
|
+
return types.some((t) => t.type === TEST_TYPES[delivery] && t.delivery === delivery) ? TEST_TYPES[delivery] : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** How the suite causes a host event: the normative trigger when a test type is listed, else the §G seam. */
|
|
50
|
+
export type Trigger = 'normative' | 'seam';
|
|
51
|
+
export function triggerOf(types: readonly AdvertisedType[], seamsProfile: boolean): Trigger | null {
|
|
52
|
+
if (testTypeListed(types, 'durable') !== null || testTypeListed(types, 'ephemeral') !== null) return 'normative';
|
|
53
|
+
return seamsProfile ? 'seam' : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* RFC 0241 §B.1–§B.2 — `emitTestHostEvent` is served exactly for the listed classes: a class whose
|
|
58
|
+
* type is not listed is refused (`404`/`405` when none is listed, `400 validation_error` otherwise),
|
|
59
|
+
* and a listed class is accepted with `202` and its reserved type.
|
|
60
|
+
*/
|
|
61
|
+
export function judgeTriggerBinding(types: readonly AdvertisedType[], delivery: AdvertisedType['delivery'], status: number, code: string | undefined, returnedType: unknown): Finding[] {
|
|
62
|
+
const listed = testTypeListed(types, delivery) !== null;
|
|
63
|
+
const anyListed = triggerOf(types, false) === 'normative';
|
|
64
|
+
if (listed) {
|
|
65
|
+
return [status === 202 && returnedType === TEST_TYPES[delivery]
|
|
66
|
+
? ok(`emitTestHostEvent accepts the listed ${delivery} class`)
|
|
67
|
+
: bad(`emitTestHostEvent MUST accept the listed ${delivery} class with 202 and type ${TEST_TYPES[delivery]}; got ${status} ${String(code ?? returnedType ?? '')}`.trim())];
|
|
68
|
+
}
|
|
69
|
+
if (!anyListed) {
|
|
70
|
+
return [status === 404 || status === 405
|
|
71
|
+
? ok('emitTestHostEvent is not served while no host-test type is listed')
|
|
72
|
+
: bad(`a host listing no host-test.* type MUST NOT serve emitTestHostEvent; got ${status}`)];
|
|
73
|
+
}
|
|
74
|
+
return [status === 400 && code === 'validation_error'
|
|
75
|
+
? ok(`emitTestHostEvent refuses the unlisted ${delivery} class`)
|
|
76
|
+
: bad(`emitTestHostEvent MUST refuse the unlisted ${delivery} class with 400 validation_error; got ${status} ${code ?? ''}`.trim())];
|
|
77
|
+
}
|
|
78
|
+
|
|
33
79
|
/** The frame carrying `eventId`, parsed, or null. */
|
|
34
80
|
export function frameFor(frames: readonly Frame[], eventId: string): { frame: Frame; envelope: Record<string, unknown> } | null {
|
|
35
81
|
for (const frame of frames) {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* v2 — host events (RFC 0236; `spec/v2/core/events.md` §Host events,
|
|
3
|
-
* `webhooks.md` §Delivery; suite 2.45.20, target major 2;
|
|
3
|
+
* `webhooks.md` §Delivery; suite 2.45.20, target major 2; RFC 0241 trigger in 2.45.26).
|
|
4
4
|
*
|
|
5
5
|
* A host event belongs to no run: its envelope has no `runId` and no
|
|
6
6
|
* `sequence`. It rides `/host/events` beside the heartbeat messages and, when
|
|
7
|
-
* durable, webhooks. The suite
|
|
8
|
-
*
|
|
7
|
+
* durable, webhooks. The suite causes one through `POST /host/events/test`
|
|
8
|
+
* (RFC 0241) when the host lists the reserved `host-test.*` types, so a
|
|
9
|
+
* seam-free production host witnesses every leg; otherwise through the RFC 0236
|
|
10
|
+
* §G emit seam with the `example.*` types a seams-profile host advertises.
|
|
9
11
|
*
|
|
10
12
|
* 0236.host-event.envelope an emitted durable event arrives as a valid
|
|
11
13
|
* envelope, `event:` = type, `id:` = eventId;
|
|
@@ -22,16 +24,22 @@
|
|
|
22
24
|
* the host-variant leg observed: a bundle
|
|
23
25
|
* records one requirement id per `it`);
|
|
24
26
|
* 0236.host-event.tenant-isolation a second tenant's stream receives nothing
|
|
25
|
-
* for the first tenant's event
|
|
27
|
+
* for the first tenant's event;
|
|
28
|
+
* 0241.trigger.bound-by-listing `emitTestHostEvent` accepts each listed
|
|
29
|
+
* `host-test.*` class and refuses the rest
|
|
30
|
+
* (`404`/`405` when none is listed).
|
|
26
31
|
*
|
|
27
32
|
* Judged by `lib/host-event-witness.ts`, whose self-test convicts each defect.
|
|
28
33
|
*
|
|
29
|
-
* Dispositions: discovery unreadable ⇒ `blocked`;
|
|
30
|
-
*
|
|
31
|
-
*
|
|
34
|
+
* Dispositions: discovery unreadable ⇒ `blocked`; `hostEvents` not advertised ⇒
|
|
35
|
+
* `inapplicable`; neither a listed `host-test.*` type nor the seams profile ⇒
|
|
36
|
+
* `inapplicable` for every event-causing leg (not `ephemeral-refused`, which causes no event and runs on any advertised
|
|
37
|
+
* ephemeral type, none listed ⇒ `inapplicable`); a listed trigger that fails ⇒ `blocked`; seam unwired, or no `example.*`
|
|
38
|
+
* type of the needed class advertised ⇒ `seamAbsent`; no second-tenant credential
|
|
32
39
|
* (`OPENWOP_TEST_TENANT_B_API_KEY`) ⇒ `inapplicable` for the tenant leg.
|
|
33
40
|
*
|
|
34
41
|
* @see RFCS/0236-host-events.md
|
|
42
|
+
* @see RFCS/0241-host-event-test-trigger.md
|
|
35
43
|
* @see spec/v2/core/events.md §Host events
|
|
36
44
|
*/
|
|
37
45
|
|
|
@@ -46,7 +54,7 @@ import { readErrorCode } from '../lib/error-envelope.js';
|
|
|
46
54
|
import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedHit, type ScopedReceiver } from '../lib/scoped-receiver.js';
|
|
47
55
|
import { createReceiverState, verifyWebhookDelivery } from '../lib/webhook-receiver.js';
|
|
48
56
|
import {
|
|
49
|
-
advertisedTypes, exampleType, judgeEnvelope, judgeHostBody, judgeNoFanOut, judgeNoResume, judgeTenantScope, type AdvertisedType, type Finding,
|
|
57
|
+
TEST_TYPES, advertisedTypeOf, advertisedTypes, exampleType, judgeEnvelope, judgeTriggerBinding, testTypeListed, triggerOf, type Trigger, judgeHostBody, judgeNoFanOut, judgeNoResume, judgeTenantScope, type AdvertisedType, type Finding,
|
|
50
58
|
} from '../lib/host-event-witness.js';
|
|
51
59
|
|
|
52
60
|
export const REQUIRES_HOST_CALLBACK = 'the host POSTs host-event webhook deliveries to the suite-owned scoped receiver behind OPENWOP_WEBHOOK_RECEIVER_URL';
|
|
@@ -60,6 +68,8 @@ const ID_HOST_BODY = 'openwop.requirement.0236.webhook.host-variant';
|
|
|
60
68
|
const ID_NO_FAN_OUT = 'openwop.requirement.0236.ephemeral.no-fan-out';
|
|
61
69
|
const ID_TENANT = 'openwop.requirement.0236.host-event.tenant-isolation';
|
|
62
70
|
const SEAM = seamPath('/v1/host/sample/host-events/emit');
|
|
71
|
+
const TRIGGER = '/host/events/test';
|
|
72
|
+
const ID_BINDING = 'openwop.requirement.0241.trigger.bound-by-listing';
|
|
63
73
|
const V2 = { 'OpenWOP-Version': '2.0' };
|
|
64
74
|
const STREAM_MS = 3_000;
|
|
65
75
|
const SECRET = 'openwop-conformance-host-event-secret-0236';
|
|
@@ -77,30 +87,49 @@ const assertAll = (id: string, doc: string, findings: readonly Finding[]): void
|
|
|
77
87
|
type Skip = { kind: 'blocked' | 'inapplicable' | 'seam'; reason: string };
|
|
78
88
|
const skip = (s: Skip): undefined => (s.kind === 'seam' ? seamAbsent(s.reason) : softSkip(s.kind, s.reason));
|
|
79
89
|
|
|
80
|
-
/** The
|
|
81
|
-
async function
|
|
90
|
+
/** The family gate: the advertised types, or why the leg cannot run. Needs no seam. */
|
|
91
|
+
async function familyGate(): Promise<{ types: AdvertisedType[]; record: Record<string, unknown>; doc: Record<string, unknown> } | Skip> {
|
|
82
92
|
let doc: Record<string, unknown> | null;
|
|
83
93
|
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
84
94
|
if (!doc) return { kind: 'blocked', reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0' };
|
|
85
95
|
const record = await familyAdvertised('hostEvents');
|
|
86
96
|
if (record === null) return { kind: 'inapplicable', reason: 'the host does not advertise hostEvents' };
|
|
87
|
-
|
|
88
|
-
return { types: advertisedTypes(record), record };
|
|
97
|
+
return { types: advertisedTypes(record), record, doc };
|
|
89
98
|
}
|
|
90
99
|
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
/**
|
|
101
|
+
* The shared gate for the legs that cause an event: the family gate plus a way to cause one —
|
|
102
|
+
* the RFC 0241 trigger when the host lists a `host-test.*` type, else the RFC 0236 §G seam.
|
|
103
|
+
*/
|
|
104
|
+
async function gate(): Promise<{ types: AdvertisedType[]; record: Record<string, unknown>; trigger: Trigger } | Skip> {
|
|
105
|
+
const g = await familyGate();
|
|
106
|
+
if ('kind' in g) return g;
|
|
107
|
+
const trigger = triggerOf(g.types, seamsProfileAdvertised(g.doc));
|
|
108
|
+
if (trigger === null) return { kind: 'inapplicable', reason: 'no way to cause a host event: hostEvents.types lists no host-test.* type (RFC 0241) and the seams profile is not advertised (RFC 0236 §G)' };
|
|
109
|
+
return { types: g.types, record: g.record, trigger };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The type the suite causes for a class: the reserved test type under the trigger, the `example.*` type under the seam. */
|
|
113
|
+
function pick(g: { types: readonly AdvertisedType[]; trigger: Trigger }, delivery: AdvertisedType['delivery']): AdvertisedType | Skip {
|
|
114
|
+
if (g.trigger === 'normative') {
|
|
115
|
+
const type = testTypeListed(g.types, delivery);
|
|
116
|
+
return type === null ? { kind: 'inapplicable', reason: `hostEvents.types lists no ${TEST_TYPES[delivery]}, so the suite cannot cause a ${delivery} host event (RFC 0241 §B.1)` } : { type, delivery };
|
|
117
|
+
}
|
|
118
|
+
const type = exampleType(g.types, delivery);
|
|
93
119
|
return type === null
|
|
94
120
|
? { kind: 'seam', reason: `no example.* ${delivery} type is advertised in hostEvents.types — RFC 0236 §G: a seams-profile host advertises one for the suite to drive` }
|
|
95
121
|
: { type, delivery };
|
|
96
122
|
}
|
|
97
123
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
124
|
+
/** Cause one host event of `t` through the gate's trigger; its `eventId`, or why none was produced. */
|
|
125
|
+
async function emit(trigger: Trigger, t: AdvertisedType): Promise<string | Skip> {
|
|
126
|
+
const path = trigger === 'normative' ? TRIGGER : SEAM;
|
|
127
|
+
const body = trigger === 'normative' ? { delivery: t.delivery } : { type: t.type };
|
|
128
|
+
const res = await http(() => driver.post(path, body));
|
|
129
|
+
if (res === null) return { kind: 'blocked', reason: `${path} unreachable (fetch failed)` };
|
|
130
|
+
if (trigger === 'seam' && (res.status === 404 || res.status === 405)) return { kind: 'seam', reason: `${SEAM} not mounted (${res.status}) — RFC 0236 §G` };
|
|
102
131
|
const eventId = (res.json as { eventId?: unknown } | null)?.eventId;
|
|
103
|
-
if (res.status !== 202 || typeof eventId !== 'string') return { kind: 'blocked', reason: `${
|
|
132
|
+
if (res.status !== 202 || typeof eventId !== 'string') return { kind: 'blocked', reason: `${path} answered ${res.status} ${readErrorCode(res.json) ?? ''} for ${t.type}, so no event was produced`.trim() };
|
|
104
133
|
return eventId;
|
|
105
134
|
}
|
|
106
135
|
|
|
@@ -127,14 +156,14 @@ afterEach(async () => {
|
|
|
127
156
|
if (receiver) { receiver.server.close(); receiver = null; }
|
|
128
157
|
});
|
|
129
158
|
|
|
130
|
-
describe('v2 host events (RFC 0236
|
|
159
|
+
describe('v2 host events (RFC 0236; caused through RFC 0241 or the §G seam)', () => {
|
|
131
160
|
it('an emitted durable host event arrives on /host/events as a valid envelope', async () => {
|
|
132
161
|
const g = await gate();
|
|
133
162
|
if ('kind' in g) return skip(g);
|
|
134
|
-
const d = pick(g
|
|
163
|
+
const d = pick(g, 'durable');
|
|
135
164
|
if ('kind' in d) return skip(d);
|
|
136
165
|
let eventId: string | Skip = '';
|
|
137
|
-
const frames = await streamWhile(async () => { eventId = await emit(d
|
|
166
|
+
const frames = await streamWhile(async () => { eventId = await emit(g.trigger, d); });
|
|
138
167
|
if (typeof eventId !== 'string') return skip(eventId);
|
|
139
168
|
assertAll(ID_ENVELOPE, DOC, judgeEnvelope(frames, eventId, d, hostEvent));
|
|
140
169
|
if (await familyAdvertised('channelPresence')) {
|
|
@@ -145,13 +174,13 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
|
|
|
145
174
|
it('an ephemeral host event has no id: and is not redelivered after Last-Event-ID', async () => {
|
|
146
175
|
const g = await gate();
|
|
147
176
|
if ('kind' in g) return skip(g);
|
|
148
|
-
const d = pick(g
|
|
149
|
-
const e = pick(g
|
|
177
|
+
const d = pick(g, 'durable');
|
|
178
|
+
const e = pick(g, 'ephemeral');
|
|
150
179
|
if ('kind' in d) return skip(d);
|
|
151
180
|
if ('kind' in e) return skip(e);
|
|
152
181
|
let durableId: string | Skip = '';
|
|
153
182
|
let ephemeralId: string | Skip = '';
|
|
154
|
-
const frames = await streamWhile(async () => { durableId = await emit(d
|
|
183
|
+
const frames = await streamWhile(async () => { durableId = await emit(g.trigger, d); await sleep(150); ephemeralId = await emit(g.trigger, e); });
|
|
155
184
|
if (typeof durableId !== 'string') return skip(durableId);
|
|
156
185
|
if (typeof ephemeralId !== 'string') return skip(ephemeralId);
|
|
157
186
|
assertAll(ID_NO_RESUME, DOC, judgeEnvelope(frames, ephemeralId, e, hostEvent));
|
|
@@ -160,10 +189,13 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
|
|
|
160
189
|
}, 20_000);
|
|
161
190
|
|
|
162
191
|
it('registerWebhook refuses an ephemeral host-event type with 400 validation_error', async () => {
|
|
163
|
-
|
|
192
|
+
// Causes no event, so it needs no §G seam: any advertised ephemeral type will do (a
|
|
193
|
+
// production host with `channel.presence` witnesses it).
|
|
194
|
+
const g = await familyGate();
|
|
164
195
|
if ('kind' in g) return skip(g);
|
|
165
|
-
const
|
|
166
|
-
if (
|
|
196
|
+
const type = advertisedTypeOf(g.types, 'ephemeral');
|
|
197
|
+
if (type === null) return softSkip('inapplicable', 'hostEvents.types lists no ephemeral type, so there is none to refuse');
|
|
198
|
+
const e = { type, delivery: 'ephemeral' as const };
|
|
167
199
|
const res = await http(() => driver.post('/webhooks', { url: 'https://example.com/openwop-conformance-0236', events: [e.type], secret: SECRET }));
|
|
168
200
|
if (res === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
|
|
169
201
|
const id = (res.json as { webhookId?: unknown } | null)?.webhookId;
|
|
@@ -177,8 +209,8 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
|
|
|
177
209
|
const g = await gate();
|
|
178
210
|
if ('kind' in g) return skip(g);
|
|
179
211
|
if ((await familyAdvertised('webhooks')) === null) return softSkip('inapplicable', 'the host does not advertise webhooks');
|
|
180
|
-
const d = pick(g
|
|
181
|
-
const e = pick(g
|
|
212
|
+
const d = pick(g, 'durable');
|
|
213
|
+
const e = pick(g, 'ephemeral');
|
|
182
214
|
if ('kind' in d) return skip(d);
|
|
183
215
|
if ('kind' in e) return skip(e);
|
|
184
216
|
const hits: ScopedHit[] = [];
|
|
@@ -192,9 +224,9 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
|
|
|
192
224
|
const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
|
|
193
225
|
expect(reg.status, req(ID_HOST_BODY, 'spec/v2/core/webhooks.md §Surfaces', `events[] naming the advertised durable host-event type ${d.type} MUST be accepted; got ${reg.status} ${readErrorCode(reg.json) ?? ''}`)).toBe(201);
|
|
194
226
|
if (typeof webhookId === 'string') registered.push(webhookId);
|
|
195
|
-
const ephemeralId = await emit(e
|
|
227
|
+
const ephemeralId = await emit(g.trigger, e);
|
|
196
228
|
if (typeof ephemeralId !== 'string') return skip(ephemeralId);
|
|
197
|
-
const durableId = await emit(d
|
|
229
|
+
const durableId = await emit(g.trigger, d);
|
|
198
230
|
if (typeof durableId !== 'string') return skip(durableId);
|
|
199
231
|
for (let waited = 0; waited < 15_000 && !hits.some((h) => h.body.includes(durableId)); waited += 250) await sleep(250);
|
|
200
232
|
const hit = hits.find((h) => h.body.includes(durableId));
|
|
@@ -213,25 +245,39 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
|
|
|
213
245
|
const g = await gate();
|
|
214
246
|
if ('kind' in g) return skip(g);
|
|
215
247
|
if ((await familyAdvertised('webhooks')) === null) return softSkip('inapplicable', 'the host does not advertise webhooks');
|
|
216
|
-
const d = pick(g
|
|
217
|
-
const e = pick(g
|
|
248
|
+
const d = pick(g, 'durable');
|
|
249
|
+
const e = pick(g, 'ephemeral');
|
|
218
250
|
if ('kind' in d) return skip(d);
|
|
219
251
|
if ('kind' in e) return skip(e);
|
|
220
252
|
if ('skipped' in fanOut) return softSkip('blocked', `no-fan-out reads the deliveries the host-variant leg observed, and ${fanOut.skipped}`);
|
|
221
253
|
assertAll(ID_NO_FAN_OUT, DOC, [judgeNoFanOut(fanOut.bodies, fanOut.ephemeralId)]);
|
|
222
254
|
});
|
|
223
255
|
|
|
256
|
+
it('emitTestHostEvent is served exactly for the listed host-test classes', async () => {
|
|
257
|
+
// RFC 0241 §B.1–§B.2: listing a host-test type binds the trigger for that class, and only it.
|
|
258
|
+
const g = await familyGate();
|
|
259
|
+
if ('kind' in g) return skip(g);
|
|
260
|
+
const findings: Finding[] = [];
|
|
261
|
+
for (const delivery of ['durable', 'ephemeral'] as const) {
|
|
262
|
+
// Each class: a listed one is accepted with its reserved type, an unlisted one refused.
|
|
263
|
+
const res = await http(() => driver.post(TRIGGER, { delivery }));
|
|
264
|
+
if (res === null) return softSkip('blocked', `${TRIGGER} unreachable (fetch failed)`);
|
|
265
|
+
findings.push(...judgeTriggerBinding(g.types, delivery, res.status, readErrorCode(res.json) ?? undefined, (res.json as { type?: unknown } | null)?.type));
|
|
266
|
+
}
|
|
267
|
+
assertAll(ID_BINDING, 'spec/v2/core/events.md §Host events', findings);
|
|
268
|
+
}, 20_000);
|
|
269
|
+
|
|
224
270
|
it('a second tenant receives nothing for the first tenant’s host event', async () => {
|
|
225
271
|
const g = await gate();
|
|
226
272
|
if ('kind' in g) return skip(g);
|
|
227
|
-
const d = pick(g
|
|
273
|
+
const d = pick(g, 'durable');
|
|
228
274
|
if ('kind' in d) return skip(d);
|
|
229
275
|
const other = process.env['OPENWOP_TEST_TENANT_B_API_KEY']?.trim();
|
|
230
276
|
if (!other) return softSkip('inapplicable', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-tenant check needs it');
|
|
231
277
|
if (other === process.env['OPENWOP_API_KEY']?.trim()) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY equals OPENWOP_API_KEY — the second credential must resolve to a different tenant');
|
|
232
278
|
let eventId: string | Skip = '';
|
|
233
279
|
const otherStream = streamWhile(async () => undefined, { bearer: other });
|
|
234
|
-
const own = await streamWhile(async () => { eventId = await emit(d
|
|
280
|
+
const own = await streamWhile(async () => { eventId = await emit(g.trigger, d); });
|
|
235
281
|
const theirs = await otherStream;
|
|
236
282
|
if (typeof eventId !== 'string') return skip(eventId);
|
|
237
283
|
assertAll(ID_TENANT, DOC, judgeTenantScope(own, theirs, eventId));
|