@openwop/openwop-conformance 2.45.10 → 2.45.11
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 +11 -0
- package/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +115 -4
- package/scenario-majors.json +8 -2
- package/schemas/CORPUS-STAMP.json +10 -10
- package/src/lib/major-profile.ts +17 -0
- package/src/lib/trigger-delivery-witness.ts +277 -0
- package/src/lib/trigger-double.ts +131 -0
- package/src/scenarios/v2-trigger-bridge-delivery.test.ts +107 -0
- package/src/scenarios/v2-trigger-dead-letter-read.test.ts +78 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.45.11] — 2026-10-03 — the trigger bridge is witnessed at major 2
|
|
4
|
+
|
|
5
|
+
- **New scenarios `v2-trigger-bridge-delivery` and `v2-trigger-dead-letter-read`** (major 2). They port the trigger delivery legs and RFC 0232's read rules to the v2 surface (`/trigger-subscriptions/…`, bound ids `~`-projected). They share their requirement ids with the v1 twins, as the v2 secrets witness does. Each runs only the normative-surface path: there is no trigger seam at major 2.
|
|
6
|
+
- Legs: `0083.trigger-delivery.dedup`, `.dead-letter`, `.causation` and `.runless-attempt-content-free`, through the signed ingest and `GET /trigger-subscriptions/{subscriptionId}/dead-letters`. `.runless-state-change-content-free` is `inapplicable`: no wire surface causes a state change and no seam drives one (RFC 0232 gap G2).
|
|
7
|
+
- Read rules: `0232.trigger-dead-letters.paging`, `.cursor-bound` and `.tenant-bound` (`OPENWOP_TEST_TENANT_B_API_KEY`, else `blocked`). At major 2, another tenant's bound id must be refused `403 id_tenant_mismatch` (`identity.md` §5), not `404`; the profile row says which refusal each major uses (`foreignTenantRead`). openwop-app caught this before release.
|
|
8
|
+
- **Dispositions:** `triggerBridge` absent, or `inboundSigning` not listing `standard-webhooks-1` ⇒ `inapplicable`, since major 2 has no other delivery surface. The read legs also need `triggerBridge.deadLetter`.
|
|
9
|
+
- **Why.** openwop-app's seam-free production cut (build 062381a5, suite 2.45.10) passes every trigger leg at major 1, but that bundle carries 350 seam-precondition `blocked` rows and cannot certify. Its v2 bundle certifies with 0 blocked but had no trigger rows. With these scenarios, a host that advertises `triggerBridge` at major 2 witnesses RFC 0230 and RFC 0232 on a certifiable bundle.
|
|
10
|
+
- **Shared witness.** `lib/trigger-delivery-witness.ts` takes a `MajorProfile`. The profile gains `triggerSubscriptionsPath` and `idSegment`. The major-1 files keep their own inline legs for now.
|
|
11
|
+
- **Sabotage.** `lib/trigger-delivery-witness.test.ts` runs 16 cases against the scratch double in `lib/trigger-double.ts`, and each of twelve defects fails exactly the legs that own the rule. Both scenario files were also run against a standalone double. Conforming: 7 pass, the state-change row `inapplicable`. Seven defects (dedup, refused state, causation, canary leak, foreign cursor, tenant leak, a `404` where v2 requires `403`) each fail only their own leg. With no family, in strict mode, every row is `inapplicable` and none fails. Scenarios gate on `familyAdvertised`, not `gateFamily` (the 2.45.7 strict-mode defect).
|
|
12
|
+
- **Host impact.** MyndHyve advertises `triggerBridge` at major 2 without `inboundSigning`, so every new row records `inapplicable`. openwop-app and the v2 reference host don't advertise the family at major 2. No host gains a `blocked` or failing row.
|
|
13
|
+
|
|
3
14
|
## [2.45.10] — 2026-10-03 — RFC 0232 Active: a seam-free path for the run-less trigger leg
|
|
4
15
|
|
|
5
16
|
- **`trigger-bridge-delivery` leg 4 splits in two (RFC 0232, decided question 1 (a)).** Both ids stay in the `openwop-trigger-bridge` floor; the old title-derived id is aliased to the first.
|
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ 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
|
|
138
|
+
The current suite has 594 scenario files under `src/scenarios/`.
|
|
139
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`).
|
|
140
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.
|
|
141
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.
|
|
@@ -482,7 +482,7 @@ Server-required (added in 1.7.0):
|
|
|
482
482
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
483
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. |
|
|
484
484
|
|
|
485
|
-
Current source tree:
|
|
485
|
+
Current source tree: 594 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
486
486
|
|
|
487
487
|
## Remaining Gaps
|
|
488
488
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.45.
|
|
3
|
+
"version": "2.45.11",
|
|
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.45.
|
|
59
|
+
"@openwop/spec-artifacts": "2.45.11"
|
|
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":
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
5
|
+
"files": 669,
|
|
6
|
+
"tests": 2557,
|
|
7
|
+
"withStableId": 2557,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
|
-
"explicitIds":
|
|
9
|
+
"explicitIds": 2434
|
|
10
10
|
},
|
|
11
11
|
"records": [
|
|
12
12
|
{
|
|
@@ -35267,6 +35267,117 @@
|
|
|
35267
35267
|
}
|
|
35268
35268
|
]
|
|
35269
35269
|
},
|
|
35270
|
+
{
|
|
35271
|
+
"id": "openwop.it.v2-trigger-bridge-delivery.de-dups-a-repeated-webhook-id-effectively-once",
|
|
35272
|
+
"file": "v2-trigger-bridge-delivery.test.ts",
|
|
35273
|
+
"line": 59,
|
|
35274
|
+
"title": "de-dups a repeated webhook-id: effectively-once",
|
|
35275
|
+
"explicitId": "openwop.requirement.0083.trigger-delivery.dedup",
|
|
35276
|
+
"citations": [
|
|
35277
|
+
{
|
|
35278
|
+
"section": null,
|
|
35279
|
+
"requirement": null,
|
|
35280
|
+
"interpolated": true
|
|
35281
|
+
}
|
|
35282
|
+
]
|
|
35283
|
+
},
|
|
35284
|
+
{
|
|
35285
|
+
"id": "openwop.it.v2-trigger-bridge-delivery.a-refused-post-starts-no-run-and-leaves-the-subscription-active",
|
|
35286
|
+
"file": "v2-trigger-bridge-delivery.test.ts",
|
|
35287
|
+
"line": 69,
|
|
35288
|
+
"title": "a refused post starts no run and leaves the subscription active",
|
|
35289
|
+
"explicitId": "openwop.requirement.0083.trigger-delivery.dead-letter",
|
|
35290
|
+
"citations": [
|
|
35291
|
+
{
|
|
35292
|
+
"section": null,
|
|
35293
|
+
"requirement": null,
|
|
35294
|
+
"interpolated": true
|
|
35295
|
+
}
|
|
35296
|
+
]
|
|
35297
|
+
},
|
|
35298
|
+
{
|
|
35299
|
+
"id": "openwop.it.v2-trigger-bridge-delivery.links-delivery-to-run-run-started-carries-the-delivery-as-causationid",
|
|
35300
|
+
"file": "v2-trigger-bridge-delivery.test.ts",
|
|
35301
|
+
"line": 79,
|
|
35302
|
+
"title": "links delivery to run: run.started carries the delivery as causationId",
|
|
35303
|
+
"explicitId": "openwop.requirement.0083.trigger-delivery.causation",
|
|
35304
|
+
"citations": [
|
|
35305
|
+
{
|
|
35306
|
+
"section": null,
|
|
35307
|
+
"requirement": null,
|
|
35308
|
+
"interpolated": true
|
|
35309
|
+
}
|
|
35310
|
+
]
|
|
35311
|
+
},
|
|
35312
|
+
{
|
|
35313
|
+
"id": "openwop.it.v2-trigger-bridge-delivery.keeps-a-dead-lettered-run-less-attempt-content-free",
|
|
35314
|
+
"file": "v2-trigger-bridge-delivery.test.ts",
|
|
35315
|
+
"line": 89,
|
|
35316
|
+
"title": "keeps a dead-lettered run-less attempt content-free",
|
|
35317
|
+
"explicitId": "openwop.requirement.0083.trigger-delivery.runless-attempt-content-free",
|
|
35318
|
+
"citations": [
|
|
35319
|
+
{
|
|
35320
|
+
"section": null,
|
|
35321
|
+
"requirement": null,
|
|
35322
|
+
"interpolated": true
|
|
35323
|
+
}
|
|
35324
|
+
]
|
|
35325
|
+
},
|
|
35326
|
+
{
|
|
35327
|
+
"id": "openwop.it.v2-trigger-bridge-delivery.keeps-a-run-less-subscription-state-change-content-free",
|
|
35328
|
+
"file": "v2-trigger-bridge-delivery.test.ts",
|
|
35329
|
+
"line": 99,
|
|
35330
|
+
"title": "keeps a run-less subscription state change content-free",
|
|
35331
|
+
"explicitId": "openwop.requirement.0083.trigger-delivery.runless-state-change-content-free",
|
|
35332
|
+
"citations": [
|
|
35333
|
+
{
|
|
35334
|
+
"section": "webhooks.md §Inbound triggers",
|
|
35335
|
+
"requirement": "unreachable: no major-2 surface causes a state change"
|
|
35336
|
+
}
|
|
35337
|
+
]
|
|
35338
|
+
},
|
|
35339
|
+
{
|
|
35340
|
+
"id": "openwop.it.v2-trigger-dead-letter-read.clamps-limit-and-continues-a-short-page-with-nextcursor",
|
|
35341
|
+
"file": "v2-trigger-dead-letter-read.test.ts",
|
|
35342
|
+
"line": 52,
|
|
35343
|
+
"title": "clamps limit and continues a short page with nextCursor",
|
|
35344
|
+
"explicitId": "openwop.requirement.0232.trigger-dead-letters.paging",
|
|
35345
|
+
"citations": [
|
|
35346
|
+
{
|
|
35347
|
+
"section": null,
|
|
35348
|
+
"requirement": null,
|
|
35349
|
+
"interpolated": true
|
|
35350
|
+
}
|
|
35351
|
+
]
|
|
35352
|
+
},
|
|
35353
|
+
{
|
|
35354
|
+
"id": "openwop.it.v2-trigger-dead-letter-read.refuses-a-cursor-minted-for-another-subscription",
|
|
35355
|
+
"file": "v2-trigger-dead-letter-read.test.ts",
|
|
35356
|
+
"line": 61,
|
|
35357
|
+
"title": "refuses a cursor minted for another subscription",
|
|
35358
|
+
"explicitId": "openwop.requirement.0232.trigger-dead-letters.cursor-bound",
|
|
35359
|
+
"citations": [
|
|
35360
|
+
{
|
|
35361
|
+
"section": null,
|
|
35362
|
+
"requirement": null,
|
|
35363
|
+
"interpolated": true
|
|
35364
|
+
}
|
|
35365
|
+
]
|
|
35366
|
+
},
|
|
35367
|
+
{
|
|
35368
|
+
"id": "openwop.it.v2-trigger-dead-letter-read.refuses-another-tenant-s-bound-id-403-id-tenant-mismatch-and-discloses-nothing",
|
|
35369
|
+
"file": "v2-trigger-dead-letter-read.test.ts",
|
|
35370
|
+
"line": 70,
|
|
35371
|
+
"title": "refuses another tenant's bound id 403 id_tenant_mismatch and discloses nothing",
|
|
35372
|
+
"explicitId": "openwop.requirement.0232.trigger-dead-letters.tenant-bound",
|
|
35373
|
+
"citations": [
|
|
35374
|
+
{
|
|
35375
|
+
"section": null,
|
|
35376
|
+
"requirement": null,
|
|
35377
|
+
"interpolated": true
|
|
35378
|
+
}
|
|
35379
|
+
]
|
|
35380
|
+
},
|
|
35270
35381
|
{
|
|
35271
35382
|
"id": "openwop.it.v2-unadvertised-operation-not-found.get-prompts-answers-404-not-found-when-prompts-endpointssupported-is-not-adverti",
|
|
35272
35383
|
"file": "v2-unadvertised-operation-not-found.test.ts",
|
package/scenario-majors.json
CHANGED
|
@@ -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":
|
|
4
|
+
"files": 594,
|
|
5
5
|
"v1": 455,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 154
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -1635,6 +1635,12 @@
|
|
|
1635
1635
|
"v2-tool-catalog-annotations.test.ts": [
|
|
1636
1636
|
2
|
|
1637
1637
|
],
|
|
1638
|
+
"v2-trigger-bridge-delivery.test.ts": [
|
|
1639
|
+
2
|
|
1640
|
+
],
|
|
1641
|
+
"v2-trigger-dead-letter-read.test.ts": [
|
|
1642
|
+
2
|
|
1643
|
+
],
|
|
1638
1644
|
"v2-unadvertised-operation-not-found.test.ts": [
|
|
1639
1645
|
2
|
|
1640
1646
|
],
|
|
@@ -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.11",
|
|
5
|
+
"corpusTag": "v2.45.11",
|
|
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": "4d81cca7f95187c62e05ffe9ad738134c5253334f794da200182b0d6f82123a5",
|
|
13
|
+
"api/v2/asyncapi.yaml": "6cc853cc6fd98213b9d3db9fa23f56ae40d974c8730a9a5288cf7187eb402d94",
|
|
14
|
+
"api/v2/openapi.yaml": "7bf3b02498d378ae3fc242c79422e8843820b4a519ba8119aa7d7fcaaaf6dcbf",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "bd1c00fae7b6fda506a75aff841bbdc438d4ce092642a72041203bee999b88de",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
|
|
@@ -206,7 +206,7 @@
|
|
|
206
206
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
207
207
|
"spec/v1/alias-detectors.json": "0b2f28808ffe0b9d98a187775038c3406bb70919747a788a6e4f866e293ae6d3",
|
|
208
208
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
209
|
-
"spec/v1/core-standard-manifest.json": "
|
|
209
|
+
"spec/v1/core-standard-manifest.json": "9cf3fe4d67b3e110f4666fe8d4c3a22e715a7b4ef88afb99470031303e364b8b",
|
|
210
210
|
"spec/v1/deprecations.json": "a3293072ea951857395d05f0437264e88965f1e9019ff61c55bacfa9c1aaa17c",
|
|
211
211
|
"spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
|
|
212
212
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -246,7 +246,7 @@
|
|
|
246
246
|
"spec/v2/core/security-defaults.md": "887d8c4bc592094e3b931c23f1c2175c18ebbd47528b074016f9d2091bc513b2",
|
|
247
247
|
"spec/v2/core/storage.md": "dc94b985847c1341c7a064f04a6010f0fdbe6c3ab3e555d4362878c81d764c18",
|
|
248
248
|
"spec/v2/core/tool-catalog.md": "d16a9004418719c48301804aefa4d7ab6f41112a4ed83727f552aa1b92b418b0",
|
|
249
|
-
"spec/v2/core/versioning.md": "
|
|
249
|
+
"spec/v2/core/versioning.md": "fe48f4514b9c9f998d5da1930b8faa02ca4de89dcd49b07d95414fb04c67d438",
|
|
250
250
|
"spec/v2/core/webhooks.md": "daf6ec03092f4b3fde1eb74a5e2e801ec48db5139a13fcc0fd7fb4a004635ae3",
|
|
251
251
|
"spec/v2/core/workflow-chain-packs.md": "fdce7a8477e269950a2bac05ee6eddbd66eb0b670b464d00054f7196ec05d29c",
|
|
252
252
|
"spec/v2/corrections.json": "3a8d93e33865b692d26a3b6bfd3819766e206b2e0c2a69669baa4bf833d05a49",
|
|
@@ -305,9 +305,9 @@
|
|
|
305
305
|
"spec/v2/path-manifest.json": "36363105280139dad095dc4f64b5b272121e9759cdcf6de011358dd1c1a90540",
|
|
306
306
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
307
307
|
"spec/v2/profiles.json": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
|
|
308
|
-
"spec/v2/release.json": "
|
|
308
|
+
"spec/v2/release.json": "1bb3db70c49debd541741939c8dcfdf31c0076a54c5fb23e7c6564f89b10505c",
|
|
309
309
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
310
|
-
"spec/v2/surface-baseline.json": "
|
|
310
|
+
"spec/v2/surface-baseline.json": "30b8f92461b67517b7a20c7125ce0c6ef1af834e24c09a70be43cda6a25ccd99"
|
|
311
311
|
},
|
|
312
|
-
"corpusCommit": "
|
|
312
|
+
"corpusCommit": "bc768ee37bcf463c49186a7fec7a5c5ad06f2fff"
|
|
313
313
|
}
|
package/src/lib/major-profile.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { capabilityFamily } from './discovery-capabilities.js';
|
|
22
22
|
import { codemapV1toV2 } from './era2-seed.js';
|
|
23
|
+
import { projectBoundId } from './bound-id.js';
|
|
23
24
|
|
|
24
25
|
export interface MajorProfile {
|
|
25
26
|
readonly major: number;
|
|
@@ -49,6 +50,16 @@ export interface MajorProfile {
|
|
|
49
50
|
* unread observation and never guesses a name.
|
|
50
51
|
*/
|
|
51
52
|
eventType(era1Name: string): string | undefined;
|
|
53
|
+
/** The trigger subscription collection, e.g. `/v1/trigger-subscriptions` or `/trigger-subscriptions`. */
|
|
54
|
+
readonly triggerSubscriptionsPath: string;
|
|
55
|
+
/** An id as one URL path segment: percent-encoded at v1, the `~` bound-id projection at v2 (`identity.md` §5). */
|
|
56
|
+
idSegment(id: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* How a read of another tenant's id is refused. `not_found`: as an id never
|
|
59
|
+
* minted (v1, bare ids). `id_tenant_mismatch`: `403`, because a v2 bound id
|
|
60
|
+
* names its tenant and `identity.md` §5 requires the refusal to say so.
|
|
61
|
+
*/
|
|
62
|
+
readonly foreignTenantRead: 'not_found' | 'id_tenant_mismatch';
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -68,6 +79,9 @@ export const MAJOR_PROFILES: Readonly<Record<number, MajorProfile>> = {
|
|
|
68
79
|
// v1 budgets are driven through a host seam, not `createRun`.
|
|
69
80
|
runBudget: () => null,
|
|
70
81
|
eventType: (era1Name) => era1Name,
|
|
82
|
+
triggerSubscriptionsPath: '/v1/trigger-subscriptions',
|
|
83
|
+
idSegment: (id) => encodeURIComponent(id),
|
|
84
|
+
foreignTenantRead: 'not_found',
|
|
71
85
|
},
|
|
72
86
|
2: {
|
|
73
87
|
major: 2,
|
|
@@ -82,6 +96,9 @@ export const MAJOR_PROFILES: Readonly<Record<number, MajorProfile>> = {
|
|
|
82
96
|
},
|
|
83
97
|
runBudget: (policy) => ({ configurable: { version: 1, budget: { ...policy } } }),
|
|
84
98
|
eventType: (era1Name) => codemapV1toV2().get(era1Name),
|
|
99
|
+
triggerSubscriptionsPath: '/trigger-subscriptions',
|
|
100
|
+
idSegment: (id) => projectBoundId(id),
|
|
101
|
+
foreignTenantRead: 'id_tenant_mismatch',
|
|
85
102
|
},
|
|
86
103
|
};
|
|
87
104
|
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trigger-bridge delivery witness on the normative surface (RFC 0230's
|
|
3
|
+
* signed ingest, RFC 0232's dead-letter read), shared by every major that
|
|
4
|
+
* serves it. What differs between majors — the subscription path, how an id
|
|
5
|
+
* becomes a path segment, event names, the page schema — is the profile row
|
|
6
|
+
* in `major-profile.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Each leg OBSERVES and returns findings; it asserts nothing. The scenario file
|
|
9
|
+
* maps findings to requirement ids, and `trigger-delivery-witness.test.ts`
|
|
10
|
+
* proves every leg against a scratch double with one defect turned on.
|
|
11
|
+
*
|
|
12
|
+
* There is no seam path here. At major 2 no seam drives a trigger delivery, so
|
|
13
|
+
* a leg whose only surface is a seam is `inapplicable` (no party can cause the
|
|
14
|
+
* condition), never `blocked`.
|
|
15
|
+
*
|
|
16
|
+
* Today only `v2-trigger-bridge-delivery` and `v2-trigger-dead-letter-read` use
|
|
17
|
+
* it; the major-1 files keep their own inline legs, which the production hosts
|
|
18
|
+
* have already witnessed.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { driver } from './driver.js';
|
|
22
|
+
import { readErrorCode } from './error-envelope.js';
|
|
23
|
+
import type { MajorProfile } from './major-profile.js';
|
|
24
|
+
import { freshWebhookId, standardWebhooksSignature } from './triggerBridge.js';
|
|
25
|
+
import { freshCanary, judge as judgeDeadLetters, type DeadLetterRead } from './trigger-dead-letter-witness.js';
|
|
26
|
+
|
|
27
|
+
export interface TriggerFinding {
|
|
28
|
+
readonly ok: boolean;
|
|
29
|
+
readonly doc: string;
|
|
30
|
+
readonly message: string;
|
|
31
|
+
}
|
|
32
|
+
export type LegOutcome =
|
|
33
|
+
| { readonly kind: 'skip'; readonly disposition: 'inapplicable' | 'blocked'; readonly reason: string }
|
|
34
|
+
| { readonly kind: 'observed'; readonly findings: readonly TriggerFinding[]; readonly note?: string };
|
|
35
|
+
|
|
36
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
37
|
+
const f = (ok: boolean, doc: string, message: string): TriggerFinding => ({ ok, doc, message });
|
|
38
|
+
|
|
39
|
+
/** What the host advertises for the trigger bridge at this major. */
|
|
40
|
+
export interface TriggerAdverts {
|
|
41
|
+
readonly family: Record<string, unknown> | null;
|
|
42
|
+
readonly inboundSigning: boolean;
|
|
43
|
+
readonly deadLetter: { readonly retentionDays: number; readonly maxPageSize: number } | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function adverts(profile: MajorProfile, doc: unknown): TriggerAdverts {
|
|
47
|
+
const family = profile.family(doc, 'triggerBridge');
|
|
48
|
+
const ingestion = isRecord(family?.['ingestion']) ? family!['ingestion'] as Record<string, unknown> : undefined;
|
|
49
|
+
const signing = ingestion?.['inboundSigning'];
|
|
50
|
+
const dl = isRecord(family?.['deadLetter']) ? family!['deadLetter'] as Record<string, unknown> : undefined;
|
|
51
|
+
return {
|
|
52
|
+
family,
|
|
53
|
+
inboundSigning: Array.isArray(signing) && signing.includes('standard-webhooks-1'),
|
|
54
|
+
deadLetter: dl !== undefined && typeof dl['retentionDays'] === 'number' && typeof dl['maxPageSize'] === 'number'
|
|
55
|
+
? { retentionDays: dl['retentionDays'] as number, maxPageSize: dl['maxPageSize'] as number }
|
|
56
|
+
: null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The gate every signed-path leg shares. */
|
|
61
|
+
export function signedGate(a: TriggerAdverts): LegOutcome | null {
|
|
62
|
+
if (a.family === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise triggerBridge' };
|
|
63
|
+
if (!a.inboundSigning) return { kind: 'skip', disposition: 'inapplicable', reason: 'triggerBridge.ingestion.inboundSigning does not list standard-webhooks-1, and no other wire surface delivers a trigger event at this major' };
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface Sub { readonly subscriptionId: string; readonly ingestUrl: string; readonly signingSecret: string }
|
|
68
|
+
|
|
69
|
+
async function register(profile: MajorProfile, mode: 'none' | 'required'): Promise<Sub | string> {
|
|
70
|
+
const res = await driver.post(profile.triggerSubscriptionsPath, { source: 'webhook', workflowId: 'conformance-noop', verification: { mode } });
|
|
71
|
+
const body = res.json as { subscription?: { subscriptionId?: unknown }; binding?: { ingestUrl?: unknown; signingSecret?: unknown } } | undefined;
|
|
72
|
+
const subscriptionId = body?.subscription?.subscriptionId;
|
|
73
|
+
const ingestUrl = body?.binding?.ingestUrl;
|
|
74
|
+
const signingSecret = body?.binding?.signingSecret;
|
|
75
|
+
if (res.status !== 201 || typeof subscriptionId !== 'string' || typeof ingestUrl !== 'string' || typeof signingSecret !== 'string') {
|
|
76
|
+
return `POST ${profile.triggerSubscriptionsPath} answered ${res.status} without the binding (subscriptionId, ingestUrl, signingSecret)`;
|
|
77
|
+
}
|
|
78
|
+
return { subscriptionId, ingestUrl, signingSecret };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function ingest(sub: Sub, body: string, opts: { webhookId?: string; signature?: string } = {}): Promise<{ status: number; json: Record<string, unknown> | undefined }> {
|
|
82
|
+
const id = opts.webhookId ?? freshWebhookId();
|
|
83
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
84
|
+
const sig = opts.signature ?? standardWebhooksSignature(sub.signingSecret, id, ts, body);
|
|
85
|
+
const res = await driver.post(sub.ingestUrl, body, {
|
|
86
|
+
authenticated: false,
|
|
87
|
+
headers: { 'Content-Type': 'application/json', 'webhook-id': id, 'webhook-timestamp': ts, 'webhook-signature': sig },
|
|
88
|
+
});
|
|
89
|
+
return { status: res.status, json: isRecord(res.json) ? res.json : undefined };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const BAD_SIGNATURE = 'v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
|
93
|
+
const DOC_INGEST = 'webhooks.md §Inbound triggers';
|
|
94
|
+
|
|
95
|
+
/** A registration that did not yield the binding is a host defect on an advertised surface. */
|
|
96
|
+
function bindingFinding(reason: string): LegOutcome {
|
|
97
|
+
return { kind: 'observed', findings: [f(false, DOC_INGEST, `a host advertising inboundSigning MUST return the binding on webhook registration: ${reason}`)] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Leg 1: a repeated `webhook-id` is effectively-once. */
|
|
101
|
+
export async function dedupLeg(profile: MajorProfile, a: TriggerAdverts): Promise<LegOutcome> {
|
|
102
|
+
const gate = signedGate(a); if (gate) return gate;
|
|
103
|
+
const sub = await register(profile, 'none'); if (typeof sub === 'string') return bindingFinding(sub);
|
|
104
|
+
const body = JSON.stringify({ conformance: 'dedup' });
|
|
105
|
+
const webhookId = freshWebhookId();
|
|
106
|
+
const first = await ingest(sub, body, { webhookId });
|
|
107
|
+
const runId = first.json?.['runId'];
|
|
108
|
+
const again = await ingest(sub, body, { webhookId });
|
|
109
|
+
return { kind: 'observed', findings: [
|
|
110
|
+
f(first.status === 202, DOC_INGEST, `a signed delivery MUST answer 202 (got ${first.status})`),
|
|
111
|
+
f(typeof runId === 'string' && runId.length > 0, DOC_INGEST, 'a 202 MUST carry the started runId'),
|
|
112
|
+
f(again.status === 200, DOC_INGEST, `a repeated webhook-id MUST answer 200, starting no new run (got ${again.status})`),
|
|
113
|
+
f(again.json?.['outcome'] === 'duplicate', DOC_INGEST, `the dedup no-op MUST say outcome "duplicate" (got ${String(again.json?.['outcome'])})`),
|
|
114
|
+
f(again.json?.['runId'] === runId, DOC_INGEST, 'a dedup no-op MUST return the prior runId'),
|
|
115
|
+
] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Leg 2: a refused post starts no run and does not change the subscription's state. */
|
|
119
|
+
export async function refusedLeg(profile: MajorProfile, a: TriggerAdverts): Promise<LegOutcome> {
|
|
120
|
+
const gate = signedGate(a); if (gate) return gate;
|
|
121
|
+
const sub = await register(profile, 'required'); if (typeof sub === 'string') return bindingFinding(sub);
|
|
122
|
+
const body = JSON.stringify({ conformance: 'dead-letter' });
|
|
123
|
+
const bad = await ingest(sub, body, { signature: BAD_SIGNATURE });
|
|
124
|
+
const read = await driver.get(`${profile.triggerSubscriptionsPath}/${profile.idSegment(sub.subscriptionId)}`);
|
|
125
|
+
const state = (read.json as { subscription?: { state?: unknown } } | undefined)?.subscription?.state;
|
|
126
|
+
const ok = await ingest(sub, body);
|
|
127
|
+
return { kind: 'observed', findings: [
|
|
128
|
+
f(bad.status === 401, DOC_INGEST, `a bad signature under required verification MUST answer 401 (got ${bad.status})`),
|
|
129
|
+
f(readErrorCode(bad.json) === 'signature_invalid', DOC_INGEST, `the refusal MUST carry signature_invalid (got ${String(readErrorCode(bad.json))})`),
|
|
130
|
+
f(bad.json?.['runId'] === undefined, DOC_INGEST, 'a failed required check MUST NOT start a run'),
|
|
131
|
+
f(state === 'active', DOC_INGEST, `a refused event MUST NOT change the subscription's state (read answered ${read.status}, state ${String(state)})`),
|
|
132
|
+
f(ok.status === 202, DOC_INGEST, `after a refused post the subscription MUST still deliver a correctly signed one (got ${ok.status})`),
|
|
133
|
+
] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
137
|
+
|
|
138
|
+
/** Leg 3: the delivered run's `run.started` carries the delivery as `causationId`. */
|
|
139
|
+
export async function causationLeg(profile: MajorProfile, a: TriggerAdverts, opts: { pollMs?: number; pollTries?: number } = {}): Promise<LegOutcome> {
|
|
140
|
+
const gate = signedGate(a); if (gate) return gate;
|
|
141
|
+
const sub = await register(profile, 'none'); if (typeof sub === 'string') return bindingFinding(sub);
|
|
142
|
+
const res = await ingest(sub, JSON.stringify({ conformance: 'causation' }));
|
|
143
|
+
const runId = res.json?.['runId'];
|
|
144
|
+
if (res.status !== 202 || typeof runId !== 'string' || runId.length === 0) {
|
|
145
|
+
return { kind: 'observed', findings: [f(false, DOC_INGEST, `a signed delivery MUST answer 202 with the started runId (got ${res.status} ${JSON.stringify(res.json ?? null).slice(0, 200)})`)] };
|
|
146
|
+
}
|
|
147
|
+
const startedType = profile.eventType('run.started');
|
|
148
|
+
const attemptType = profile.eventType('trigger.delivery.attempted');
|
|
149
|
+
if (startedType === undefined || attemptType === undefined) return { kind: 'skip', disposition: 'blocked', reason: 'the event codemap is not on disk in this layout, so this major\'s event names are unknown' };
|
|
150
|
+
let events: Array<Record<string, unknown>> = [];
|
|
151
|
+
let pollStatus = 0;
|
|
152
|
+
for (let i = 0; i < (opts.pollTries ?? 10); i++) {
|
|
153
|
+
const poll = await driver.get(`${profile.runsPath}/${profile.idSegment(runId)}/events/poll`);
|
|
154
|
+
pollStatus = poll.status;
|
|
155
|
+
events = ((poll.json as { events?: unknown } | undefined)?.events as Array<Record<string, unknown>> | undefined) ?? [];
|
|
156
|
+
if (events.some((e) => e['type'] === startedType)) break;
|
|
157
|
+
await sleep(opts.pollMs ?? 500);
|
|
158
|
+
}
|
|
159
|
+
const started = events.find((e) => e['type'] === startedType);
|
|
160
|
+
const causationId = started?.['causationId'];
|
|
161
|
+
const delivered = events.find((e) => e['type'] === attemptType && isRecord(e['payload']) && (e['payload'] as Record<string, unknown>)['outcome'] === 'delivered');
|
|
162
|
+
const findings = [
|
|
163
|
+
f(started !== undefined, 'webhooks.md §Inbound triggers', `a delivered run MUST emit ${startedType} (poll answered ${pollStatus}; saw ${events.map((e) => String(e['type'])).join(',') || 'none'})`),
|
|
164
|
+
f(typeof causationId === 'string' && causationId.length > 0, 'webhooks.md §Inbound triggers', `${startedType} MUST carry the delivery id as causationId`),
|
|
165
|
+
];
|
|
166
|
+
if (delivered !== undefined) findings.push(f(causationId === delivered['eventId'], 'webhooks.md §Inbound triggers', `${startedType}.causationId MUST equal the delivered attempt's eventId`));
|
|
167
|
+
return { kind: 'observed', findings, note: delivered === undefined ? 'the delivered attempt event is not on the run log, so equality was not checked' : 'causationId equals the delivered attempt' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** GET one page of a subscription's dead letters at this major. Asserts nothing. */
|
|
171
|
+
export async function readDeadLettersAt(profile: MajorProfile, subscriptionId: string, query: { limit?: number; cursor?: string } = {}, headers?: Record<string, string>): Promise<DeadLetterRead> {
|
|
172
|
+
const qs = new URLSearchParams();
|
|
173
|
+
if (query.limit !== undefined) qs.set('limit', String(query.limit));
|
|
174
|
+
if (query.cursor !== undefined) qs.set('cursor', query.cursor);
|
|
175
|
+
const suffix = qs.toString() === '' ? '' : `?${qs.toString()}`;
|
|
176
|
+
const res = await driver.get(`${profile.triggerSubscriptionsPath}/${profile.idSegment(subscriptionId)}/dead-letters${suffix}`, headers ? { headers } : {});
|
|
177
|
+
return { status: res.status, json: res.json };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Leg 4a: a dead-lettered (run-less) attempt is content-free, read through RFC 0232's read. */
|
|
181
|
+
export async function runlessAttemptLeg(profile: MajorProfile, a: TriggerAdverts, validate: (page: unknown) => { ok: boolean; errors: string }): Promise<LegOutcome> {
|
|
182
|
+
const gate = signedGate(a); if (gate) return gate;
|
|
183
|
+
if (a.deadLetter === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'a dead-lettered attempt is on no run\'s log, and the host does not advertise triggerBridge.deadLetter, the only surface that reads it at this major' };
|
|
184
|
+
const sub = await register(profile, 'required'); if (typeof sub === 'string') return bindingFinding(sub);
|
|
185
|
+
const canary = freshCanary();
|
|
186
|
+
const bad = await ingest(sub, JSON.stringify({ conformance: 'runless-attempt', canary }), { signature: BAD_SIGNATURE });
|
|
187
|
+
const read = await readDeadLettersAt(profile, sub.subscriptionId);
|
|
188
|
+
const findings = [f(bad.status === 401, DOC_INGEST, `a bad signature under required verification MUST answer 401 (got ${bad.status})`)];
|
|
189
|
+
for (const j of judgeDeadLetters(read, { subscriptionId: sub.subscriptionId, canary, signature: BAD_SIGNATURE, signingSecret: sub.signingSecret, retentionDays: a.deadLetter.retentionDays }, validate)) {
|
|
190
|
+
findings.push(f(j.ok, DOC_INGEST, j.message));
|
|
191
|
+
}
|
|
192
|
+
return { kind: 'observed', findings, note: 'RFC 0232 dead-letter read' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Leg 4b: a run-less state change. No surface causes one without a seam, and no seam exists at this major. */
|
|
196
|
+
export function runlessStateChangeLeg(a: TriggerAdverts): LegOutcome {
|
|
197
|
+
if (a.family === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise triggerBridge' };
|
|
198
|
+
return { kind: 'skip', disposition: 'inapplicable', reason: 'no wire surface causes a trigger subscription state change, and no seam drives one at this major (RFC 0232 §E, gap G2)' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// The read's own rules (RFC 0232 §B).
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
function readGate(a: TriggerAdverts): LegOutcome | null {
|
|
206
|
+
if (a.family === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise triggerBridge' };
|
|
207
|
+
if (a.deadLetter === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise triggerBridge.deadLetter' };
|
|
208
|
+
if (!a.inboundSigning) return { kind: 'skip', disposition: 'inapplicable', reason: 'triggerBridge.deadLetter is advertised without inboundSigning, so the suite cannot cause a dead letter unaided' };
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function refusedTwice(profile: MajorProfile, n: number): Promise<Sub | string> {
|
|
213
|
+
const sub = await register(profile, 'required'); if (typeof sub === 'string') return sub;
|
|
214
|
+
for (let i = 0; i < n; i++) await ingest(sub, JSON.stringify({ conformance: 'dead-letter-read', i }), { signature: BAD_SIGNATURE });
|
|
215
|
+
return sub;
|
|
216
|
+
}
|
|
217
|
+
const deliveriesOf = (json: unknown): unknown[] => { const d = (json as { deliveries?: unknown } | undefined)?.deliveries; return Array.isArray(d) ? d : []; };
|
|
218
|
+
const cursorOf = (json: unknown): unknown => (json as { nextCursor?: unknown } | undefined)?.nextCursor;
|
|
219
|
+
|
|
220
|
+
export async function pagingLeg(profile: MajorProfile, a: TriggerAdverts): Promise<LegOutcome> {
|
|
221
|
+
const gate = readGate(a); if (gate) return gate;
|
|
222
|
+
const sub = await refusedTwice(profile, 2); if (typeof sub === 'string') return bindingFinding(sub);
|
|
223
|
+
const first = await readDeadLettersAt(profile, sub.subscriptionId, { limit: 1 });
|
|
224
|
+
const cursor = cursorOf(first.json);
|
|
225
|
+
const second = typeof cursor === 'string' ? await readDeadLettersAt(profile, sub.subscriptionId, { limit: 1, cursor }) : undefined;
|
|
226
|
+
const big = await readDeadLettersAt(profile, sub.subscriptionId, { limit: a.deadLetter!.maxPageSize + 1 });
|
|
227
|
+
return { kind: 'observed', findings: [
|
|
228
|
+
f(first.status === 200, DOC_INGEST, `a host advertising triggerBridge.deadLetter MUST serve the read (got ${first.status})`),
|
|
229
|
+
f(deliveriesOf(first.json).length === 1, DOC_INGEST, `a page MUST NOT exceed limit (got ${deliveriesOf(first.json).length} for limit 1)`),
|
|
230
|
+
f(typeof cursor === 'string' && cursor.length > 0, DOC_INGEST, 'with two dead letters and limit 1, the page MUST carry nextCursor'),
|
|
231
|
+
f(second?.status === 200 && deliveriesOf(second.json).length === 1, DOC_INGEST, `the page's own cursor MUST continue to the second dead letter (got ${String(second?.status)})`),
|
|
232
|
+
f(big.status === 200, DOC_INGEST, `a limit above maxPageSize MUST be clamped, not refused (got ${big.status})`),
|
|
233
|
+
] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function cursorLeg(profile: MajorProfile, a: TriggerAdverts): Promise<LegOutcome> {
|
|
237
|
+
const gate = readGate(a); if (gate) return gate;
|
|
238
|
+
const subA = await refusedTwice(profile, 2); if (typeof subA === 'string') return bindingFinding(subA);
|
|
239
|
+
const page = await readDeadLettersAt(profile, subA.subscriptionId, { limit: 1 });
|
|
240
|
+
const cursor = cursorOf(page.json);
|
|
241
|
+
if (typeof cursor !== 'string' || cursor.length === 0) {
|
|
242
|
+
return { kind: 'observed', findings: [f(false, DOC_INGEST, `with two dead letters and limit 1, the page MUST carry nextCursor (read answered ${page.status})`)] };
|
|
243
|
+
}
|
|
244
|
+
const subB = await refusedTwice(profile, 1); if (typeof subB === 'string') return bindingFinding(subB);
|
|
245
|
+
const cross = await readDeadLettersAt(profile, subB.subscriptionId, { cursor });
|
|
246
|
+
return { kind: 'observed', findings: [
|
|
247
|
+
f(cross.status === 400, DOC_INGEST, `a cursor minted for another subscription MUST be refused 400 (got ${cross.status})`),
|
|
248
|
+
f(readErrorCode(cross.json) === 'validation_error', DOC_INGEST, `the refusal MUST carry validation_error (got ${String(readErrorCode(cross.json))})`),
|
|
249
|
+
] };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function tenantLeg(profile: MajorProfile, a: TriggerAdverts, otherTenantKey: string | undefined, ownKey: string): Promise<LegOutcome> {
|
|
253
|
+
const gate = readGate(a); if (gate) return gate;
|
|
254
|
+
if (!otherTenantKey) return { kind: 'skip', disposition: 'blocked', reason: 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-tenant read cannot run' };
|
|
255
|
+
if (otherTenantKey.trim() === ownKey) return { kind: 'skip', disposition: 'blocked', reason: 'OPENWOP_TEST_TENANT_B_API_KEY equals OPENWOP_API_KEY — the second credential must resolve to a DIFFERENT tenant, or the leg measures nothing' };
|
|
256
|
+
const sub = await refusedTwice(profile, 1); if (typeof sub === 'string') return bindingFinding(sub);
|
|
257
|
+
const foreign = await readDeadLettersAt(profile, sub.subscriptionId, {}, { Authorization: `Bearer ${otherTenantKey}` });
|
|
258
|
+
const unknown = await readDeadLettersAt(profile, `${sub.subscriptionId}-never-minted`);
|
|
259
|
+
const noDisclosure = f(!JSON.stringify(foreign.json ?? null).includes('verification_failed'), DOC_INGEST, 'a foreign read MUST NOT disclose a record');
|
|
260
|
+
const unknownFinding = f(unknown.status === 404, DOC_INGEST, `an id the host never minted MUST answer 404 (got ${unknown.status})`);
|
|
261
|
+
if (profile.foreignTenantRead === 'id_tenant_mismatch') {
|
|
262
|
+
// identity.md §5: a bound id whose tenant segment is not the caller's is
|
|
263
|
+
// refused 403 id_tenant_mismatch, before any lookup, so it discloses nothing.
|
|
264
|
+
return { kind: 'observed', findings: [
|
|
265
|
+
f(foreign.status === 403, 'identity.md §5', `another tenant's bound id MUST be refused 403 (got ${foreign.status})`),
|
|
266
|
+
f(readErrorCode(foreign.json) === 'id_tenant_mismatch', 'identity.md §5', `the refusal MUST carry id_tenant_mismatch (got ${String(readErrorCode(foreign.json))})`),
|
|
267
|
+
noDisclosure,
|
|
268
|
+
unknownFinding,
|
|
269
|
+
] };
|
|
270
|
+
}
|
|
271
|
+
return { kind: 'observed', findings: [
|
|
272
|
+
f(foreign.status === 404, DOC_INGEST, `another tenant's read MUST answer 404 (got ${foreign.status})`),
|
|
273
|
+
noDisclosure,
|
|
274
|
+
unknownFinding,
|
|
275
|
+
f(readErrorCode(foreign.json) === readErrorCode(unknown.json), DOC_INGEST, 'a foreign id MUST be answered exactly as an unknown one'),
|
|
276
|
+
] };
|
|
277
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scratch double for the trigger bridge's normative surface at major 2: the
|
|
3
|
+
* signed ingest (RFC 0230) and the dead-letter read (RFC 0232). Each self-test
|
|
4
|
+
* turns on ONE defect to show `trigger-delivery-witness.ts` convicts it, and a
|
|
5
|
+
* standalone instance lets the scenario files themselves be run against it.
|
|
6
|
+
*
|
|
7
|
+
* A test double, not a host: it executes nothing, and nothing it does is
|
|
8
|
+
* evidence about any implementation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
12
|
+
import type { AddressInfo } from 'node:net';
|
|
13
|
+
import { unprojectBoundId } from './bound-id.js';
|
|
14
|
+
import { standardWebhooksSignature } from './triggerBridge.js';
|
|
15
|
+
|
|
16
|
+
/** The credential the double treats as a second tenant. */
|
|
17
|
+
export const OTHER_TENANT_KEY = 'tenant-b-key';
|
|
18
|
+
|
|
19
|
+
export type TriggerDefect =
|
|
20
|
+
| 'none' | 'dedup-new-run' | 'refused-starts-run' | 'refused-changes-state' | 'no-causation'
|
|
21
|
+
| 'record-leaks-canary' | 'refused-not-recorded' | 'refused-records-state-change' | 'wrong-retention'
|
|
22
|
+
| 'limit-ignored' | 'cursor-not-bound' | 'tenant-leak' | 'tenant-404';
|
|
23
|
+
|
|
24
|
+
interface DoubleSub { id: string; n: number; mode: string; secret: string; state: string; seen: Map<string, string>; dead: Array<Record<string, unknown>> }
|
|
25
|
+
|
|
26
|
+
export class TriggerDouble {
|
|
27
|
+
defect: TriggerDefect = 'none';
|
|
28
|
+
url = '';
|
|
29
|
+
private server: Server | undefined;
|
|
30
|
+
private subs = new Map<string, DoubleSub>();
|
|
31
|
+
private runs = new Map<string, Array<Record<string, unknown>>>();
|
|
32
|
+
private seq = 0;
|
|
33
|
+
discovery: Record<string, unknown> = { triggerBridge: { status: 'experimental', since: '2.45', witness: 'self', ingestion: { inboundSigning: ['standard-webhooks-1'] }, deadLetter: { retentionDays: 7, maxPageSize: 100 } } };
|
|
34
|
+
|
|
35
|
+
async start(): Promise<string> {
|
|
36
|
+
this.server = createServer((req, res) => { void this.handle(req, res); });
|
|
37
|
+
await new Promise<void>((r) => this.server!.listen(0, '127.0.0.1', r));
|
|
38
|
+
this.url = `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`;
|
|
39
|
+
return this.url;
|
|
40
|
+
}
|
|
41
|
+
async stop(): Promise<void> { const s = this.server; if (s) { s.closeAllConnections(); await new Promise<void>((r) => s.close(() => r())); } }
|
|
42
|
+
reset(defect: TriggerDefect): void { this.defect = defect; this.subs.clear(); this.runs.clear(); }
|
|
43
|
+
|
|
44
|
+
private id(prefix: string): string { return `acme/${prefix}-${String(++this.seq).padStart(16, '0')}`; }
|
|
45
|
+
private send(res: ServerResponse, status: number, body: unknown): void { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); }
|
|
46
|
+
private async raw(req: IncomingMessage): Promise<string> { const c: Buffer[] = []; for await (const x of req) c.push(x as Buffer); return Buffer.concat(c).toString('utf8'); }
|
|
47
|
+
|
|
48
|
+
private startRun(sub: DoubleSub): string {
|
|
49
|
+
const runId = this.id('run');
|
|
50
|
+
const attemptId = `evt${String(++this.seq).padStart(16, '0')}`;
|
|
51
|
+
this.runs.set(runId, [
|
|
52
|
+
{ eventId: attemptId, type: 'trigger.delivery-attempted', payload: { subscriptionId: sub.id, dedupKey: 'opaque', attempt: 1, outcome: 'delivered', runId }, sequence: 1 },
|
|
53
|
+
{ eventId: `evt${String(++this.seq).padStart(16, '0')}`, type: 'run.started', ...(this.defect === 'no-causation' ? {} : { causationId: attemptId }), payload: {}, sequence: 2 },
|
|
54
|
+
]);
|
|
55
|
+
return runId;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
59
|
+
const u = new URL(req.url ?? '/', 'http://x');
|
|
60
|
+
const path = u.pathname;
|
|
61
|
+
if (req.method === 'GET' && path === '/.well-known/openwop') return this.send(res, 200, this.discovery);
|
|
62
|
+
|
|
63
|
+
if (req.method === 'POST' && path === '/trigger-subscriptions') {
|
|
64
|
+
const body = JSON.parse(await this.raw(req) || '{}') as { verification?: { mode?: string } };
|
|
65
|
+
const n = this.subs.size + 1;
|
|
66
|
+
const sub: DoubleSub = { id: this.id('sub'), n, mode: body.verification?.mode ?? 'none', secret: `whsec_${Buffer.from(`secret-${n}-bytes`).toString('base64')}`, state: 'active', seen: new Map(), dead: [] };
|
|
67
|
+
this.subs.set(sub.id, sub);
|
|
68
|
+
return this.send(res, 201, { subscription: { subscriptionId: sub.id, source: 'webhook', state: 'active' }, binding: { ingestUrl: `${this.url}/ingest/${n}`, signingSecret: sub.secret } });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ing = /^\/ingest\/(\d+)$/.exec(path);
|
|
72
|
+
if (req.method === 'POST' && ing) {
|
|
73
|
+
const sub = [...this.subs.values()].find((s) => s.n === Number(ing[1]));
|
|
74
|
+
if (!sub) return this.send(res, 404, { error: 'not_found', message: 'no ingest' });
|
|
75
|
+
const body = await this.raw(req);
|
|
76
|
+
const id = String(req.headers['webhook-id'] ?? ''); const ts = String(req.headers['webhook-timestamp'] ?? '');
|
|
77
|
+
const good = req.headers['webhook-signature'] === standardWebhooksSignature(sub.secret, id, ts, body);
|
|
78
|
+
if (!good && sub.mode === 'required') {
|
|
79
|
+
if (this.defect !== 'refused-not-recorded') {
|
|
80
|
+
const at = Date.now();
|
|
81
|
+
sub.dead.unshift({
|
|
82
|
+
subscriptionId: sub.id, attemptEventId: `evt${String(++this.seq).padStart(16, '0')}`,
|
|
83
|
+
attempt: { subscriptionId: sub.id, dedupKey: 'opaque', attempt: 1, outcome: 'dead-lettered' },
|
|
84
|
+
reason: 'verification_failed',
|
|
85
|
+
deadLetteredAt: new Date(at).toISOString(),
|
|
86
|
+
expiresAt: new Date(at + (this.defect === 'wrong-retention' ? 1 : 7) * 86_400_000).toISOString(),
|
|
87
|
+
...(this.defect === 'record-leaks-canary' ? { body } : {}),
|
|
88
|
+
...(this.defect === 'refused-records-state-change' ? { stateChange: { subscriptionId: sub.id, source: 'webhook', fromState: 'active', toState: 'failed', reason: 'signature-invalid' } } : {}),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (this.defect === 'refused-changes-state') sub.state = 'failed';
|
|
92
|
+
return this.send(res, 401, { error: 'signature_invalid', message: 'bad signature', ...(this.defect === 'refused-starts-run' ? { runId: this.startRun(sub) } : {}) });
|
|
93
|
+
}
|
|
94
|
+
const prior = sub.seen.get(id);
|
|
95
|
+
if (prior !== undefined && this.defect !== 'dedup-new-run') return this.send(res, 200, { outcome: 'duplicate', runId: prior });
|
|
96
|
+
const runId = this.startRun(sub);
|
|
97
|
+
sub.seen.set(id, runId);
|
|
98
|
+
return this.send(res, 202, { outcome: 'delivered', runId });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const poll = /^\/runs\/([^/]+)\/events\/poll$/.exec(path);
|
|
102
|
+
if (req.method === 'GET' && poll) {
|
|
103
|
+
const events = this.runs.get(unprojectBoundId(poll[1]!));
|
|
104
|
+
return events ? this.send(res, 200, { events }) : this.send(res, 404, { error: 'not_found', message: 'no run' });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const m = /^\/trigger-subscriptions\/([^/]+)(\/dead-letters)?$/.exec(path);
|
|
108
|
+
if (req.method === 'GET' && m) {
|
|
109
|
+
const auth = String(req.headers['authorization'] ?? '');
|
|
110
|
+
const sub = this.subs.get(unprojectBoundId(m[1]!));
|
|
111
|
+
// identity.md §5: tenant B reading acme's bound id is refused 403 before any lookup.
|
|
112
|
+
if (auth === `Bearer ${OTHER_TENANT_KEY}` && this.defect === 'tenant-404') return this.send(res, 404, { error: 'not_found', message: 'no such subscription' });
|
|
113
|
+
if (auth === `Bearer ${OTHER_TENANT_KEY}` && this.defect !== 'tenant-leak') return this.send(res, 403, { error: 'id_tenant_mismatch', message: 'tenant segment is not the caller\'s' });
|
|
114
|
+
if (!sub) return this.send(res, 404, { error: 'not_found', message: 'no such subscription' });
|
|
115
|
+
if (!m[2]) return this.send(res, 200, { subscription: { subscriptionId: sub.id, source: 'webhook', state: sub.state } });
|
|
116
|
+
const limit = Math.min(Number(u.searchParams.get('limit') ?? '100'), 100);
|
|
117
|
+
const cursor = u.searchParams.get('cursor');
|
|
118
|
+
let offset = 0;
|
|
119
|
+
if (cursor !== null) {
|
|
120
|
+
const [cid, off] = Buffer.from(cursor, 'base64url').toString('utf8').split('|');
|
|
121
|
+
if (cid !== sub.id && this.defect !== 'cursor-not-bound') return this.send(res, 400, { error: 'validation_error', message: 'cursor is for another subscription' });
|
|
122
|
+
offset = Number(off);
|
|
123
|
+
}
|
|
124
|
+
const size = this.defect === 'limit-ignored' ? sub.dead.length : limit;
|
|
125
|
+
const page = sub.dead.slice(offset, offset + size);
|
|
126
|
+
const next = offset + size < sub.dead.length ? Buffer.from(`${sub.id}|${offset + size}`).toString('base64url') : undefined;
|
|
127
|
+
return this.send(res, 200, { deliveries: page, ...(next ? { nextCursor: next } : {}) });
|
|
128
|
+
}
|
|
129
|
+
return this.send(res, 404, { error: 'not_found', message: `no route ${req.method} ${path}` });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the trigger bridge's delivery model on the normative surface
|
|
3
|
+
* (`spec/v2/core/webhooks.md` §Inbound triggers). The v1 twin is
|
|
4
|
+
* `trigger-bridge-delivery`; this file shares its requirement ids, as the v2
|
|
5
|
+
* secrets witness does, so a requirement is one id at every major. The legs
|
|
6
|
+
* live in `lib/trigger-delivery-witness.ts`; what differs between majors is the
|
|
7
|
+
* profile row in `lib/major-profile.ts`.
|
|
8
|
+
*
|
|
9
|
+
* Unaided: the suite registers a `webhook` subscription and posts Standard
|
|
10
|
+
* Webhooks-signed bodies to its `ingestUrl` with no OpenWOP credential.
|
|
11
|
+
*
|
|
12
|
+
* 1. dedup a repeated `webhook-id` is effectively-once.
|
|
13
|
+
* 2. dead-letter a refused post starts no run and leaves the subscription
|
|
14
|
+
* `active`; a correctly signed post still delivers.
|
|
15
|
+
* 3. causation the delivered run's `run.started` carries the delivery.
|
|
16
|
+
* 4a. run-less attempt a dead-lettered attempt is content-free, read through
|
|
17
|
+
* `GET /trigger-subscriptions/{subscriptionId}/dead-letters`.
|
|
18
|
+
* 4b. run-less state change `inapplicable`: no wire surface causes one and
|
|
19
|
+
* no seam drives one at major 2 (RFC 0232 §E, gap G2).
|
|
20
|
+
*
|
|
21
|
+
* Dispositions: `triggerBridge` absent, or `ingestion.inboundSigning` not
|
|
22
|
+
* listing `standard-webhooks-1` ⇒ `inapplicable` (major 2 has no other delivery
|
|
23
|
+
* surface). Leg 4a also needs `triggerBridge.deadLetter`.
|
|
24
|
+
*
|
|
25
|
+
* Proven against a scratch double in `lib/trigger-delivery-witness.test.ts`.
|
|
26
|
+
*
|
|
27
|
+
* @see spec/v2/core/webhooks.md §Inbound triggers
|
|
28
|
+
* @see RFCS/0230-inbound-webhook-ingest-contract.md, RFCS/0232-trigger-dead-letter-read.md
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { familyAdvertised, v2Discovery, v2Validator } from '../lib/v2.js';
|
|
33
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
34
|
+
import { req } from '../lib/requirement-ids.js';
|
|
35
|
+
import { noteObservation } from '../lib/row-observation.js';
|
|
36
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
37
|
+
import { adverts, causationLeg, dedupLeg, refusedLeg, runlessAttemptLeg, runlessStateChangeLeg, type TriggerAdverts } from '../lib/trigger-delivery-witness.js';
|
|
38
|
+
|
|
39
|
+
const PROFILE = majorProfile(2);
|
|
40
|
+
const R_DEDUP = 'openwop.requirement.0083.trigger-delivery.dedup';
|
|
41
|
+
const R_DEAD_LETTER = 'openwop.requirement.0083.trigger-delivery.dead-letter';
|
|
42
|
+
const R_CAUSATION = 'openwop.requirement.0083.trigger-delivery.causation';
|
|
43
|
+
const R_RUNLESS_ATTEMPT = 'openwop.requirement.0083.trigger-delivery.runless-attempt-content-free';
|
|
44
|
+
const R_RUNLESS_STATE = 'openwop.requirement.0083.trigger-delivery.runless-state-change-content-free';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `familyAdvertised`, not `gateFamily`: `triggerBridge` is optional, and a host that does not advertise it
|
|
48
|
+
* has taken on no obligation here. `gateFamily` fails an unadvertised family in strict mode (the 2.45.7
|
|
49
|
+
* `v2-workspace-scope-from-identity` defect).
|
|
50
|
+
*/
|
|
51
|
+
async function discovered(): Promise<TriggerAdverts | string> {
|
|
52
|
+
if (!(await familyAdvertised('triggerBridge'))) return 'GATED';
|
|
53
|
+
let doc: Record<string, unknown> | null;
|
|
54
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
55
|
+
return doc ? adverts(PROFILE, doc) : 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('v2 trigger-bridge delivery (webhooks.md §Inbound triggers)', () => {
|
|
59
|
+
it('de-dups a repeated webhook-id: effectively-once', async () => {
|
|
60
|
+
const a = await discovered();
|
|
61
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
62
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
63
|
+
const out = await dedupLeg(PROFILE, a);
|
|
64
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
65
|
+
for (const x of out.findings) expect(x.ok, req(R_DEDUP, x.doc, x.message)).toBe(true);
|
|
66
|
+
noteObservation(`normative-surface path (RFC 0230)${out.note ? `; ${out.note}` : ''}`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('a refused post starts no run and leaves the subscription active', async () => {
|
|
70
|
+
const a = await discovered();
|
|
71
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
72
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
73
|
+
const out = await refusedLeg(PROFILE, a);
|
|
74
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
75
|
+
for (const x of out.findings) expect(x.ok, req(R_DEAD_LETTER, x.doc, x.message)).toBe(true);
|
|
76
|
+
noteObservation(`normative-surface path (RFC 0230)${out.note ? `; ${out.note}` : ''}`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('links delivery to run: run.started carries the delivery as causationId', async () => {
|
|
80
|
+
const a = await discovered();
|
|
81
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
82
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
83
|
+
const out = await causationLeg(PROFILE, a);
|
|
84
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
85
|
+
for (const x of out.findings) expect(x.ok, req(R_CAUSATION, x.doc, x.message)).toBe(true);
|
|
86
|
+
noteObservation(`normative-surface path (RFC 0230)${out.note ? `; ${out.note}` : ''}`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('keeps a dead-lettered run-less attempt content-free', async () => {
|
|
90
|
+
const a = await discovered();
|
|
91
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
92
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
93
|
+
const out = await runlessAttemptLeg(PROFILE, a, v2Validator('trigger-dead-letter-page'));
|
|
94
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
95
|
+
for (const x of out.findings) expect(x.ok, req(R_RUNLESS_ATTEMPT, x.doc, x.message)).toBe(true);
|
|
96
|
+
noteObservation(`normative-surface path (RFC 0230)${out.note ? `; ${out.note}` : ''}`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('keeps a run-less subscription state change content-free', async () => {
|
|
100
|
+
const a = await discovered();
|
|
101
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
102
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
103
|
+
const out = runlessStateChangeLeg(a);
|
|
104
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
105
|
+
expect(out.kind, req(R_RUNLESS_STATE, 'webhooks.md §Inbound triggers', 'unreachable: no major-2 surface causes a state change')).toBe('skip');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the trigger dead-letter read's own rules (RFC 0232 §B;
|
|
3
|
+
* `spec/v2/core/webhooks.md` §Inbound triggers). The v1 twin is
|
|
4
|
+
* `trigger-dead-letter-read`; the requirement ids are shared, and the legs live
|
|
5
|
+
* in `lib/trigger-delivery-witness.ts`.
|
|
6
|
+
*
|
|
7
|
+
* paging `limit` is clamped, and a short page carries `nextCursor`.
|
|
8
|
+
* cursor-bound a cursor minted for one subscription is refused on another,
|
|
9
|
+
* `400 validation_error`.
|
|
10
|
+
* tenant-bound another tenant's bound id is refused `403 id_tenant_mismatch`
|
|
11
|
+
* and discloses nothing (`identity.md` §5; at v1, with bare ids,
|
|
12
|
+
* the same rule is `404` as for an id never minted). An id never
|
|
13
|
+
* minted answers `404`. Needs `OPENWOP_TEST_TENANT_B_API_KEY`,
|
|
14
|
+
* else `blocked`.
|
|
15
|
+
*
|
|
16
|
+
* Dispositions: `triggerBridge`, `triggerBridge.deadLetter` or
|
|
17
|
+
* `ingestion.inboundSigning` not advertised ⇒ `inapplicable`.
|
|
18
|
+
*
|
|
19
|
+
* Proven against a scratch double in `lib/trigger-delivery-witness.test.ts`.
|
|
20
|
+
*
|
|
21
|
+
* @see spec/v2/core/webhooks.md §Inbound triggers
|
|
22
|
+
* @see RFCS/0232-trigger-dead-letter-read.md §B
|
|
23
|
+
* @see spec/v2/core/identity.md §5
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { familyAdvertised, v2Discovery } from '../lib/v2.js';
|
|
28
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
29
|
+
import { req } from '../lib/requirement-ids.js';
|
|
30
|
+
import { loadEnv } from '../lib/env.js';
|
|
31
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
32
|
+
import { adverts, cursorLeg, pagingLeg, tenantLeg, type TriggerAdverts } from '../lib/trigger-delivery-witness.js';
|
|
33
|
+
|
|
34
|
+
const PROFILE = majorProfile(2);
|
|
35
|
+
const R_PAGING = 'openwop.requirement.0232.trigger-dead-letters.paging';
|
|
36
|
+
const R_CURSOR = 'openwop.requirement.0232.trigger-dead-letters.cursor-bound';
|
|
37
|
+
const R_TENANT = 'openwop.requirement.0232.trigger-dead-letters.tenant-bound';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `familyAdvertised`, not `gateFamily`: `triggerBridge` is optional, and a host that does not advertise it
|
|
41
|
+
* has taken on no obligation here. `gateFamily` fails an unadvertised family in strict mode (the 2.45.7
|
|
42
|
+
* `v2-workspace-scope-from-identity` defect).
|
|
43
|
+
*/
|
|
44
|
+
async function discovered(): Promise<TriggerAdverts | string> {
|
|
45
|
+
if (!(await familyAdvertised('triggerBridge'))) return 'GATED';
|
|
46
|
+
let doc: Record<string, unknown> | null;
|
|
47
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
48
|
+
return doc ? adverts(PROFILE, doc) : 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('v2 trigger dead-letter read (RFC 0232 §B)', () => {
|
|
52
|
+
it('clamps limit and continues a short page with nextCursor', async () => {
|
|
53
|
+
const a = await discovered();
|
|
54
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
55
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
56
|
+
const out = await pagingLeg(PROFILE, a);
|
|
57
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
58
|
+
for (const x of out.findings) expect(x.ok, req(R_PAGING, x.doc, x.message)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('refuses a cursor minted for another subscription', async () => {
|
|
62
|
+
const a = await discovered();
|
|
63
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
64
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
65
|
+
const out = await cursorLeg(PROFILE, a);
|
|
66
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
67
|
+
for (const x of out.findings) expect(x.ok, req(R_CURSOR, x.doc, x.message)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("refuses another tenant's bound id 403 id_tenant_mismatch and discloses nothing", async () => {
|
|
71
|
+
const a = await discovered();
|
|
72
|
+
if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
|
|
73
|
+
if (typeof a === 'string') return softSkip('blocked', a);
|
|
74
|
+
const out = await tenantLeg(PROFILE, a, process.env['OPENWOP_TEST_TENANT_B_API_KEY'], loadEnv().apiKey);
|
|
75
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
76
|
+
for (const x of out.findings) expect(x.ok, req(R_TENANT, x.doc, x.message)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|