@openwop/openwop-conformance 2.45.10 → 2.45.12

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.
@@ -9,7 +9,19 @@
9
9
  * precedence is gone. A connector MAY name a provider by the qualified form
10
10
  * `<packName>#<id>`, which resolves only to the named pack's definition.
11
11
  *
12
- * Seam-gated: driven through the RFC 0095 install/resolve seams
12
+ * TWO WITNESS PATHS (RFC 0233), and a host is measured on every path it offers.
13
+ * The NORMATIVE-SURFACE path reads `GET /connection-providers` and
14
+ * `GET /connection-providers/{providerId}` when the host advertises
15
+ * `connections.providerRead`; the conflict was caused operator-side by
16
+ * installing the §D fixture pair, and the registry describes the outcome
17
+ * (`lib/provider-registry-witness.ts`). Without the fixture installed, its
18
+ * legs are `inapplicable`; the uniqueness leg runs on any host with the facet.
19
+ * With neither `providerRead` nor the seams the legs are `inapplicable`
20
+ * (RFC 0233 §Decisions 2) — a host advertising `packsSupported` SHOULD
21
+ * advertise `providerRead`. Until 2.45.12 that case recorded `blocked`, a
22
+ * seam-only MUST that `conformance.md` §Witness class forbids.
23
+ *
24
+ * The SEAM path is driven through the RFC 0095 install/resolve seams
13
25
  * (`POST /v1/host/sample/connection-packs/{install,resolve}`,
14
26
  * `spec/v1/host-sample-test-seams.md` §10; the driver rewrites the v1 seam path
15
27
  * to `/conformance/seams/sample/…` under target major 2), gated on
@@ -37,6 +49,7 @@
37
49
  * rather than assumed.
38
50
  *
39
51
  * @see RFCS/0177-v2-registry-packs-and-extension-tail.md §D.1
52
+ * @see RFCS/0233-connection-provider-registry-read.md
40
53
  * @see spec/v2/core/connection-packs.md
41
54
  * @see spec/v1/host-sample-test-seams.md §10
42
55
  */
@@ -50,7 +63,9 @@ import { behaviorGate } from '../lib/behavior-gate.js';
50
63
  import { req } from '../lib/requirement-ids.js';
51
64
  import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
52
65
  import { targetMajor } from '../lib/seams.js';
53
- import { v2Discovery, familyAdvertised } from '../lib/v2.js';
66
+ import { v2Discovery, familyAdvertised, v2Validator } from '../lib/v2.js';
67
+ import { noteObservation } from '../lib/row-observation.js';
68
+ import { failClosedLeg, providerReadAdvertised, qualifiedLeg, uniqueLeg, type RegistryOutcome } from '../lib/provider-registry-witness.js';
54
69
 
55
70
  const SECTION = 'connection-packs.md §"Provider identity" (RFC 0177 §D.1)';
56
71
  const FIXTURE = join(FIXTURES_DIR, 'connection-packs', 'connection-pack-acme-widgets.json');
@@ -63,7 +78,8 @@ interface ResolveResult { resolved?: boolean; source?: 'pack' | 'builtin'; versi
63
78
 
64
79
  function fixture(): Manifest {
65
80
  const m = JSON.parse(readFileSync(FIXTURE, 'utf8')) as Manifest;
66
- // The v1 fixture pins `>=1.0.0`; a v2 host reads that as `<2.0.0` (RFC 0177 §A.1).
81
+ // Both fixture files carry `>=2.0.0 <3.0.0` on disk since 2.45.12 (an operator installs them
82
+ // as-is for RFC 0233 §D). The rewrite is kept so an older checked-out fixture still installs.
67
83
  return { ...m, engines: { openwop: '>=2.0.0 <3.0.0' } };
68
84
  }
69
85
  function conflicting(base: Manifest): Manifest {
@@ -73,7 +89,7 @@ function codes(r: InstallResult | undefined): string[] {
73
89
  return (r?.errors ?? []).map((e) => e.code ?? '').filter((c) => c.length > 0);
74
90
  }
75
91
 
76
- async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
92
+ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | { connections: Record<string, unknown> | null }> {
77
93
  if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
78
94
  let doc: Record<string, unknown> | null;
79
95
  try {
@@ -85,7 +101,22 @@ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | nul
85
101
  const connections = await familyAdvertised('connections');
86
102
  if (!behaviorGate('connections.packsSupported', connections?.['packsSupported'] === true)) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise connections.packsSupported (RFC 0095 §C)' };
87
103
  if (!existsSync(FIXTURE)) return { kind: 'blocked', reason: 'fixture connection-packs/connection-pack-acme-widgets.json is absent from this layout' };
88
- return null;
104
+ return { connections };
105
+ }
106
+
107
+ const NO_PATH = 'connections.packsSupported is advertised, but the host serves neither connections.providerRead (RFC 0233) nor the RFC 0095 seams, so no party can observe a provider conflict here (RFC 0233 §Decisions 2: a host advertising packsSupported SHOULD advertise providerRead)';
108
+
109
+ type SeamLeg = 'absent' | 'ran' | { kind: SoftSkipKind; reason: string };
110
+
111
+ /** Map a normative-surface outcome to assertions. `observed` when it asserted; else the skip to fall back on. */
112
+ function recordNormative(label: (doc: string, message: string) => string, out: RegistryOutcome): { kind: SoftSkipKind; reason: string } | 'observed' {
113
+ if (out.kind === 'skip') return { kind: out.disposition, reason: out.reason };
114
+ for (const x of out.findings) expect(x.ok, label(x.doc, x.message)).toBe(true);
115
+ return 'observed';
116
+ }
117
+
118
+ function noteWitnessPaths(normative: boolean, seam: boolean): void {
119
+ noteObservation([normative ? 'normative-surface path (RFC 0233 provider registry read)' : '', seam ? 'seam path (RFC 0095)' : ''].filter((x) => x !== '').join(' + '));
89
120
  }
90
121
 
91
122
  /** Install the fixture; null when the seam is unwired. */
@@ -97,10 +128,39 @@ async function installFixture(): Promise<{ res: InstallResult | undefined; statu
97
128
 
98
129
  describe('v2-provider-conflict (RFC 0177 §D.1)', () => {
99
130
  it('two definitions of one bare provider id fail closed: the later registration is refused with connection_provider_conflict', async () => {
100
- const skip = await preflight();
101
- if (skip) return softSkip(skip.kind, skip.reason);
131
+ const pre = await preflight();
132
+ if ('kind' in pre) return softSkip(pre.kind, pre.reason);
133
+ const normative = providerReadAdvertised(pre.connections) ? recordNormative((doc, m) => req('openwop.requirement.0177.provider-conflict.fail-closed', doc, m), await failClosedLeg()) : null;
134
+ const seam = await seamFailClosed();
135
+ if (typeof seam === 'object') return softSkip(seam.kind, seam.reason);
136
+ if (normative !== 'observed' && seam === 'absent') return softSkip(normative?.kind ?? 'inapplicable', normative?.reason ?? NO_PATH);
137
+ noteWitnessPaths(normative === 'observed', seam === 'ran');
138
+ });
139
+
140
+ it('the qualified form <packName>#acme-widgets resolves to the named pack\'s definition', async () => {
141
+ const pre = await preflight();
142
+ if ('kind' in pre) return softSkip(pre.kind, pre.reason);
143
+ const normative = providerReadAdvertised(pre.connections) ? recordNormative((doc, m) => req('openwop.requirement.0177.provider-conflict.qualified-form', doc, m), await qualifiedLeg()) : null;
144
+ const seam = await seamQualified();
145
+ if (typeof seam === 'object') return softSkip(seam.kind, seam.reason);
146
+ if (normative !== 'observed' && seam === 'absent') return softSkip(normative?.kind ?? 'inapplicable', normative?.reason ?? NO_PATH);
147
+ noteWitnessPaths(normative === 'observed', seam === 'ran');
148
+ });
149
+
150
+ it('the provider registry lists each bare id once and carries no endpoint or credential (RFC 0233 §B)', async () => {
151
+ const pre = await preflight();
152
+ if ('kind' in pre) return softSkip(pre.kind, pre.reason);
153
+ if (!providerReadAdvertised(pre.connections)) return softSkip('inapplicable', 'the host does not advertise connections.providerRead (RFC 0233): no registry read to check');
154
+ const out = recordNormative((doc, m) => req('openwop.requirement.0233.provider-registry.unique', doc, m), await uniqueLeg(v2Validator('connection-provider-registry')));
155
+ if (out !== 'observed') return softSkip(out.kind, out.reason);
156
+ noteWitnessPaths(true, false);
157
+ });
158
+ });
159
+
160
+ /** The seam path for fail-closed. `absent` when the install seam is unwired. */
161
+ async function seamFailClosed(): Promise<SeamLeg> {
102
162
  const first = await installFixture();
103
- if (!first) return softSkip('blocked', 'RFC 0095 install seam not mounted (404/403) — connections.packsSupported advertised but /conformance/seams/sample/connection-packs/install is absent');
163
+ if (!first) return 'absent';
104
164
  // The branch is kept for a host that somehow ships a built-in of this id:
105
165
  // the fixture is then the LATER registration and the rule fires on the
106
166
  // first install. `acme-widgets` is fictional so the normal path is the
@@ -115,19 +175,19 @@ describe('v2-provider-conflict (RFC 0177 §D.1)', () => {
115
175
  })();
116
176
  expect(later?.installed, req('openwop.requirement.0177.provider-conflict.fail-closed', SECTION, 'the later registration of a bare provider id MUST NOT install (no version precedence)')).toBe(false);
117
177
  expect(codes(later), req('openwop.requirement.0177.provider-conflict.fail-closed', SECTION, 'the later registration MUST be refused with connection_provider_conflict')).toContain('connection_provider_conflict');
118
- });
178
+ return 'ran';
179
+ }
119
180
 
120
- it('the qualified form <packName>#acme-widgets resolves to the named pack\'s definition', async () => {
121
- const skip = await preflight();
122
- if (skip) return softSkip(skip.kind, skip.reason);
181
+ /** The seam path for the qualified form. `absent` when the install seam is unwired. */
182
+ async function seamQualified(): Promise<SeamLeg> {
123
183
  const first = await installFixture();
124
- if (!first) return softSkip('blocked', 'RFC 0095 install seam not mounted (404/403) — connections.packsSupported advertised but /conformance/seams/sample/connection-packs/install is absent');
125
- if (first.res?.installed !== true) return softSkip('blocked', `the fixture did not install (${codes(first.res).join(',') || first.status}) — a host with a built-in acme-widgets cannot exercise the qualified form through the pack`);
184
+ if (!first) return 'absent';
185
+ if (first.res?.installed !== true) return { kind: 'blocked', reason: `the fixture did not install (${codes(first.res).join(',') || first.status}) — a host with a built-in acme-widgets cannot exercise the qualified form through the pack` };
126
186
  const packName = fixture().name;
127
187
  const hit = await driver.post(RESOLVE, { provider: `${packName}#acme-widgets` });
128
- if (hit.status === 404) return softSkip('blocked', 'RFC 0095 resolve seam not mounted (404)');
188
+ if (hit.status === 404) return { kind: 'blocked', reason: 'RFC 0095 resolve seam not mounted (404)' };
129
189
  const resolved = hit.json as ResolveResult | undefined;
130
190
  expect(resolved?.resolved, req('openwop.requirement.0177.provider-conflict.qualified-form', 'connection-packs.md §"The qualified form" (RFC 0177 §D.1)', `${packName}#acme-widgets MUST resolve (got ${JSON.stringify(resolved)})`)).toBe(true);
131
191
  expect(resolved?.source, req('openwop.requirement.0177.provider-conflict.qualified-form', 'connection-packs.md §"The qualified form" (RFC 0177 §D.1)', 'a qualified reference resolves only to the named pack\'s definition (source: pack)')).toBe('pack');
132
- });
133
- });
192
+ return 'ran';
193
+ }
@@ -0,0 +1,136 @@
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
+ * 2b. stale timestamp under `required`, a `webhook-timestamp` more than
16
+ * 300 s off, either way, answers `401 signature_invalid`,
17
+ * starts no run and leaves the subscription `active`
18
+ * (RFC 0230 §C).
19
+ * 2c. secret once a re-read of the subscription carries no `signingSecret`
20
+ * and no `whsec_` value (RFC 0230 §B); `inapplicable` where
21
+ * the host serves no re-read.
22
+ * 3. causation the delivered run's `run.started` carries the delivery.
23
+ * 4a. run-less attempt a dead-lettered attempt is content-free, read through
24
+ * `GET /trigger-subscriptions/{subscriptionId}/dead-letters`.
25
+ * 4b. run-less state change `inapplicable`: no wire surface causes one and
26
+ * no seam drives one at major 2 (RFC 0232 §E, gap G2).
27
+ *
28
+ * Dispositions: `triggerBridge` absent, or `ingestion.inboundSigning` not
29
+ * listing `standard-webhooks-1` ⇒ `inapplicable` (major 2 has no other delivery
30
+ * surface). Leg 4a also needs `triggerBridge.deadLetter`.
31
+ *
32
+ * Proven against a scratch double in `lib/trigger-delivery-witness.test.ts`.
33
+ *
34
+ * @see spec/v2/core/webhooks.md §Inbound triggers
35
+ * @see RFCS/0230-inbound-webhook-ingest-contract.md, RFCS/0232-trigger-dead-letter-read.md
36
+ */
37
+
38
+ import { describe, it, expect } from 'vitest';
39
+ import { familyAdvertised, v2Discovery, v2Validator } from '../lib/v2.js';
40
+ import { softSkip } from '../lib/soft-skip.js';
41
+ import { req } from '../lib/requirement-ids.js';
42
+ import { noteObservation } from '../lib/row-observation.js';
43
+ import { majorProfile } from '../lib/major-profile.js';
44
+ import { adverts, causationLeg, dedupLeg, refusedLeg, runlessAttemptLeg, runlessStateChangeLeg, signingSecretOnceLeg, staleTimestampLeg, type TriggerAdverts } from '../lib/trigger-delivery-witness.js';
45
+
46
+ const PROFILE = majorProfile(2);
47
+ const R_DEDUP = 'openwop.requirement.0083.trigger-delivery.dedup';
48
+ const R_DEAD_LETTER = 'openwop.requirement.0083.trigger-delivery.dead-letter';
49
+ const R_STALE_TIMESTAMP = 'openwop.requirement.0230.stale-timestamp-refused';
50
+ const R_SECRET_ONCE = 'openwop.requirement.0230.signing-secret-once';
51
+ const R_CAUSATION = 'openwop.requirement.0083.trigger-delivery.causation';
52
+ const R_RUNLESS_ATTEMPT = 'openwop.requirement.0083.trigger-delivery.runless-attempt-content-free';
53
+ const R_RUNLESS_STATE = 'openwop.requirement.0083.trigger-delivery.runless-state-change-content-free';
54
+
55
+ /**
56
+ * `familyAdvertised`, not `gateFamily`: `triggerBridge` is optional, and a host that does not advertise it
57
+ * has taken on no obligation here. `gateFamily` fails an unadvertised family in strict mode (the 2.45.7
58
+ * `v2-workspace-scope-from-identity` defect).
59
+ */
60
+ async function discovered(): Promise<TriggerAdverts | string> {
61
+ if (!(await familyAdvertised('triggerBridge'))) return 'GATED';
62
+ let doc: Record<string, unknown> | null;
63
+ try { doc = await v2Discovery(); } catch { doc = null; }
64
+ return doc ? adverts(PROFILE, doc) : 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0';
65
+ }
66
+
67
+ describe('v2 trigger-bridge delivery (webhooks.md §Inbound triggers)', () => {
68
+ it('de-dups a repeated webhook-id: effectively-once', async () => {
69
+ const a = await discovered();
70
+ if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
71
+ if (typeof a === 'string') return softSkip('blocked', a);
72
+ const out = await dedupLeg(PROFILE, a);
73
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
74
+ for (const x of out.findings) expect(x.ok, req(R_DEDUP, x.doc, x.message)).toBe(true);
75
+ noteObservation(`normative-surface path (RFC 0230)${out.note ? `; ${out.note}` : ''}`);
76
+ });
77
+
78
+ it('a refused post starts no run and leaves the subscription active', async () => {
79
+ const a = await discovered();
80
+ if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
81
+ if (typeof a === 'string') return softSkip('blocked', a);
82
+ const out = await refusedLeg(PROFILE, a);
83
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
84
+ for (const x of out.findings) expect(x.ok, req(R_DEAD_LETTER, x.doc, x.message)).toBe(true);
85
+ noteObservation(`normative-surface path (RFC 0230)${out.note ? `; ${out.note}` : ''}`);
86
+ });
87
+
88
+ it('refuses a webhook-timestamp more than 300 s off without starting a run', async () => {
89
+ const a = await discovered();
90
+ if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
91
+ if (typeof a === 'string') return softSkip('blocked', a);
92
+ const out = await staleTimestampLeg(PROFILE, a);
93
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
94
+ for (const x of out.findings) expect(x.ok, req(R_STALE_TIMESTAMP, x.doc, x.message)).toBe(true);
95
+ noteObservation('normative-surface path (RFC 0230)');
96
+ });
97
+
98
+ it('returns the signing secret once: a re-read carries no whsec_ value', async () => {
99
+ const a = await discovered();
100
+ if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
101
+ if (typeof a === 'string') return softSkip('blocked', a);
102
+ const out = await signingSecretOnceLeg(PROFILE, a);
103
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
104
+ for (const x of out.findings) expect(x.ok, req(R_SECRET_ONCE, x.doc, x.message)).toBe(true);
105
+ noteObservation('normative-surface path (RFC 0230)');
106
+ });
107
+
108
+ it('links delivery to run: run.started carries the delivery as causationId', async () => {
109
+ const a = await discovered();
110
+ if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
111
+ if (typeof a === 'string') return softSkip('blocked', a);
112
+ const out = await causationLeg(PROFILE, a);
113
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
114
+ for (const x of out.findings) expect(x.ok, req(R_CAUSATION, x.doc, x.message)).toBe(true);
115
+ noteObservation(`normative-surface path (RFC 0230)${out.note ? `; ${out.note}` : ''}`);
116
+ });
117
+
118
+ it('keeps a dead-lettered run-less attempt content-free', async () => {
119
+ const a = await discovered();
120
+ if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
121
+ if (typeof a === 'string') return softSkip('blocked', a);
122
+ const out = await runlessAttemptLeg(PROFILE, a, v2Validator('trigger-dead-letter-page'));
123
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
124
+ for (const x of out.findings) expect(x.ok, req(R_RUNLESS_ATTEMPT, x.doc, x.message)).toBe(true);
125
+ noteObservation(`normative-surface path (RFC 0230)${out.note ? `; ${out.note}` : ''}`);
126
+ });
127
+
128
+ it('keeps a run-less subscription state change content-free', async () => {
129
+ const a = await discovered();
130
+ if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
131
+ if (typeof a === 'string') return softSkip('blocked', a);
132
+ const out = runlessStateChangeLeg(a);
133
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
134
+ expect(out.kind, req(R_RUNLESS_STATE, 'webhooks.md §Inbound triggers', 'unreachable: no major-2 surface causes a state change')).toBe('skip');
135
+ });
136
+ });
@@ -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
+ });