@openwop/openwop-conformance 2.45.9 → 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.
@@ -6,15 +6,17 @@
6
6
  * under `OPENWOP_REQUIRE_BEHAVIOR=true`. The always-on wire-shape coverage lives
7
7
  * in `trigger-bridge-shape.test.ts`.
8
8
  *
9
- * Four legs, each its own requirement (RFC 0230 split the one combined `it`):
9
+ * Five legs, each its own requirement (RFC 0230 split the one combined `it`;
10
+ * RFC 0232 split the fourth):
10
11
  *
11
12
  * 1. DEDUP (§C-1) — a repeated delivery is effectively-once.
12
13
  * 2. DEAD-LETTER (§C-2 + RFC 0053) — an exhausted/refused delivery starts no run.
13
14
  * 3. CAUSATION (§C / RFC 0040) — a delivered run carries the delivery as
14
15
  * `run.started.causationId`.
15
- * 4. RUN-LESS CONTENT-FREENESS (SR-1) — the run-less `trigger.*` events
16
- * (a dead-lettered attempt, `trigger.subscription.state.changed`) carry no
17
- * inbound content.
16
+ * 4a. RUN-LESS ATTEMPT (SR-1) — a dead-lettered `trigger.delivery.attempted`
17
+ * carries no inbound content.
18
+ * 4b. RUN-LESS STATE CHANGE (SR-1) — a `trigger.subscription.state.changed`
19
+ * carries no inbound content.
18
20
  *
19
21
  * TWO WITNESS PATHS, and a host is measured on EVERY path it offers (2.45.8).
20
22
  * The SEAM path drives `POST /v1/host/sample/trigger-bridge/deliver` and reads
@@ -31,18 +33,24 @@
31
33
  * certify, and an uncertified bundle supplies no acceptance evidence
32
34
  * (RFC 0174 §B.1).
33
35
  *
34
- * Leg 4 has no normative-surface path — the run-less events are on no run's
35
- * log — so it stays seam-witnessed and records seam-absent otherwise; it
36
- * remains in the floor (the floor never narrows).
36
+ * The run-less events are on no run's log. Leg 4a's normative-surface path is
37
+ * RFC 0232's read: on a host advertising `inboundSigning` AND
38
+ * `triggerBridge.deadLetter`, it posts a canary with a bad signature and reads
39
+ * the subscription's dead letters (`lib/trigger-dead-letter-witness.ts`).
40
+ * Leg 4b has no such path: no wire surface causes a subscription state change
41
+ * (a refused post must not change state; nothing pauses a subscription), so it
42
+ * is seam-witnessed and, on a host without the seam, records `inapplicable`
43
+ * (RFC 0232 Unresolved question 1, decided (a)). Both stay in the floor.
37
44
  *
38
45
  * Spec references:
39
46
  * - spec/v1/trigger-bridge.md (§C, §F.2, §F.6)
40
- * - RFCS/0083-durable-trigger-and-channel-bridge-profile.md, RFCS/0230-inbound-webhook-ingest-contract.md
47
+ * - RFCS/0083-durable-trigger-and-channel-bridge-profile.md, RFCS/0230-inbound-webhook-ingest-contract.md,
48
+ * RFCS/0232-trigger-dead-letter-read.md
41
49
  * - spec/v1/profiles.md (§openwop-trigger-bridge)
42
50
  */
43
51
 
44
52
  import { describe, it, expect } from 'vitest';
45
- import { seamAbsent } from '../lib/soft-skip.js';
53
+ import { seamAbsent, softSkip } from '../lib/soft-skip.js';
46
54
  import { behaviorGate } from '../lib/behavior-gate.js';
47
55
  import {
48
56
  isTriggerBridgeProfileAdvertised,
@@ -59,11 +67,16 @@ import { queryTestEvents, requireEvents, isEventLogSeamAvailable, resetTestSeam
59
67
  import { req } from '../lib/requirement-ids.js';
60
68
  import { noteObservation } from '../lib/row-observation.js';
61
69
  import { driver } from '../lib/driver.js';
70
+ import { deadLetterFacet, freshCanary, judge as judgeDeadLetters, readDeadLetters } from '../lib/trigger-dead-letter-witness.js';
62
71
 
63
72
  const R_DEDUP = 'openwop.requirement.0083.trigger-delivery.dedup';
64
73
  const R_DEAD_LETTER = 'openwop.requirement.0083.trigger-delivery.dead-letter';
65
74
  const R_CAUSATION = 'openwop.requirement.0083.trigger-delivery.causation';
66
- const R_RUNLESS = 'openwop.requirement.0083.trigger-delivery.runless-content-free';
75
+ const R_RUNLESS_ATTEMPT = 'openwop.requirement.0083.trigger-delivery.runless-attempt-content-free';
76
+ const R_RUNLESS_STATE = 'openwop.requirement.0083.trigger-delivery.runless-state-change-content-free';
77
+
78
+ /** The bad signature every refused-post probe sends. */
79
+ const BAD_SIGNATURE = 'v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
67
80
 
68
81
  const CONTENT_FREE_FORBIDDEN = ['body', 'headers', 'payload', 'secret', 'credentials', 'token', 'apiKey'];
69
82
 
@@ -191,7 +204,7 @@ describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
191
204
  const sub = await registerSignedWebhook('required');
192
205
  if ('reason' in sub) throw new Error(req(R_DEAD_LETTER, 'trigger-bridge.md §F.6', `an inboundSigning host MUST return the §B binding on webhook registration: ${sub.reason}`));
193
206
  const body = JSON.stringify({ conformance: 'dead-letter' });
194
- const bad = await signedIngest(sub, body, { signature: 'v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' });
207
+ const bad = await signedIngest(sub, body, { signature: BAD_SIGNATURE });
195
208
  expect(bad.status, req(R_DEAD_LETTER, 'trigger-bridge.md §F.6', `a bad signature under required verification MUST answer 401 (got ${bad.status})`)).toBe(401);
196
209
  expect(bad.json?.runId, req(R_DEAD_LETTER, 'trigger-bridge.md §F.2', 'a required-verification failure MUST NOT start a run')).toBeUndefined();
197
210
  const read = await driver.get(`/v1/trigger-subscriptions/${encodeURIComponent(sub.subscriptionId)}`);
@@ -231,17 +244,53 @@ describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
231
244
  notePaths(paths.seam, true, deliveredEvent === undefined ? '; on the normative-surface path the delivered attempt event is not on the run log, so equality was not checked there' : '');
232
245
  });
233
246
 
234
- it('keeps the run-less trigger events content-free', async () => {
247
+ it('keeps a dead-lettered run-less attempt content-free', async () => {
235
248
  if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
236
- // No normative-surface path: a dead-lettered attempt and a state change are
237
- // on no run's log, so only the seam can read them (RFC 0230 §Conformance).
238
- if (!(await isEventLogSeamAvailable())) return seamAbsent('the run-less trigger events are readable only through the event-log seam, which is absent');
249
+ const seam = await isEventLogSeamAvailable();
250
+ const disco = await driver.get('/.well-known/openwop');
251
+ const facet = disco.status === 200 ? deadLetterFacet(disco.json) : null;
252
+ const normative = facet !== null && (await inboundSigningAdvertised());
253
+ if (!seam && !normative) return seamAbsent('a dead-lettered attempt is on no run\'s log; it is readable through the event-log seam or the RFC 0232 read (triggerBridge.deadLetter with inboundSigning), and the host offers neither');
254
+
255
+ if (seam) {
256
+ const exhaust = await driveDelivery({ scenario: 'exhaust', source: 'webhook' });
257
+ if (exhaust === null) return seamAbsent(SEAM_UNWIRED);
258
+ const exKey = exhaust.runId ?? '__exhaust__';
259
+ const attempts = requireEvents(await queryTestEvents(exKey, { type: 'trigger.delivery.attempted' }), 'trigger.delivery.attempted (exhaust)');
260
+ for (const e of attempts) expectContentFree(R_RUNLESS_ATTEMPT, e.payload, 'trigger.delivery.attempted');
261
+ await resetTestSeam();
262
+ }
263
+ if (!normative) return notePaths(true, false);
264
+
265
+ // Normative-surface path (RFC 0232 §E): cause a dead letter unaided, then read it.
266
+ const sub = await registerSignedWebhook('required');
267
+ if ('reason' in sub) throw new Error(req(R_RUNLESS_ATTEMPT, 'trigger-bridge.md §F.6', `an inboundSigning host MUST return the §B binding on webhook registration: ${sub.reason}`));
268
+ const canary = freshCanary();
269
+ const bad = await signedIngest(sub, JSON.stringify({ conformance: 'runless-attempt', canary }), { signature: BAD_SIGNATURE });
270
+ expect(bad.status, req(R_RUNLESS_ATTEMPT, 'trigger-bridge.md §F.6', `a bad signature under required verification MUST answer 401 (got ${bad.status})`)).toBe(401);
271
+ const read = await readDeadLetters(sub.subscriptionId);
272
+ for (const f of judgeDeadLetters(read, { subscriptionId: sub.subscriptionId, canary, signature: BAD_SIGNATURE, signingSecret: sub.signingSecret, retentionDays: facet!.retentionDays })) {
273
+ expect(f.ok, req(R_RUNLESS_ATTEMPT, 'trigger-bridge.md §C (RFC 0232 §B–§C)', f.message)).toBe(true);
274
+ }
275
+ notePaths(seam, true, ' (RFC 0232 dead-letter read)');
276
+ });
277
+
278
+ it('keeps a run-less subscription state change content-free', async () => {
279
+ if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
280
+ // Seam-only. No wire surface causes a subscription state change: a refused
281
+ // post MUST NOT change state, retry exhaustion needs a delivery the host
282
+ // fails repeatedly, and nothing pauses a subscription (RFC 0232 G2). On a
283
+ // host without the seam no party can cause the condition, so the row is
284
+ // `inapplicable`, not a defect (RFC 0232 Unresolved question 1, decided (a)).
285
+ if (!(await isEventLogSeamAvailable())) {
286
+ return softSkip('inapplicable', 'no wire surface causes a trigger subscription state change on a host without the event-log seam (RFC 0232 §E, gap G2)');
287
+ }
239
288
  const exhaust = await driveDelivery({ scenario: 'exhaust', source: 'webhook' });
240
- if (exhaust === null) return seamAbsent('host advertises openwop-trigger-bridge but the delivery seam is unwired');
289
+ if (exhaust === null) return seamAbsent(SEAM_UNWIRED);
241
290
  const exKey = exhaust.runId ?? '__exhaust__';
242
- for (const type of ['trigger.delivery.attempted', 'trigger.subscription.state.changed'] as const) {
243
- for (const e of requireEvents(await queryTestEvents(exKey, { type }), `${type} (exhaust)`)) expectContentFree(R_RUNLESS, e.payload, type);
244
- }
291
+ const changes = requireEvents(await queryTestEvents(exKey, { type: 'trigger.subscription.state.changed' }), 'trigger.subscription.state.changed (exhaust)');
292
+ expect(changes.length >= 1, req(R_RUNLESS_STATE, 'trigger-bridge.md §B', 'exhaustion MUST record ≥1 trigger.subscription.state.changed')).toBe(true);
293
+ for (const e of changes) expectContentFree(R_RUNLESS_STATE, e.payload, 'trigger.subscription.state.changed');
245
294
  await resetTestSeam();
246
295
  });
247
296
  });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Trigger dead-letter read — the §B rules (RFC 0232) — behavioral.
3
+ *
4
+ * Gated on `triggerBridge.deadLetter` together with
5
+ * `triggerBridge.ingestion.inboundSigning` (the suite causes a dead letter
6
+ * unaided by posting a bad signature to a `required` subscription). A host that
7
+ * advertises neither records `inapplicable`.
8
+ *
9
+ * The record's content rules (§C) are leg 4a of `trigger-bridge-delivery`,
10
+ * which is in the `openwop-trigger-bridge` floor. These are the read's own
11
+ * rules, each its own requirement:
12
+ *
13
+ * 1. PAGING — `limit` is clamped, and a page that stops short carries
14
+ * `nextCursor`.
15
+ * 2. CURSOR — a cursor minted for one subscription is refused on another,
16
+ * `400 validation_error`.
17
+ * 3. TENANT — another tenant's credential reading this subscription's id gets
18
+ * `404 not_found`, exactly as for an id the host never minted. Needs
19
+ * `OPENWOP_TEST_TENANT_B_API_KEY`; without it the row is `blocked`.
20
+ *
21
+ * Spec references:
22
+ * - spec/v1/trigger-bridge.md §C (Run-less transitions are recorded)
23
+ * - spec/v2/core/webhooks.md §Inbound triggers
24
+ * - RFCS/0232-trigger-dead-letter-read.md §B
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest';
28
+ import { softSkip } from '../lib/soft-skip.js';
29
+ import { driver } from '../lib/driver.js';
30
+ import { loadEnv } from '../lib/env.js';
31
+ import { readErrorCode } from '../lib/error-envelope.js';
32
+ import { req } from '../lib/requirement-ids.js';
33
+ import { inboundSigningAdvertised, registerSignedWebhook, signedIngest, type SignedWebhookSubscription } from '../lib/triggerBridge.js';
34
+ import { deadLetterFacet, readDeadLetters, type DeadLetterFacet } from '../lib/trigger-dead-letter-witness.js';
35
+
36
+ const R_PAGING = 'openwop.requirement.0232.trigger-dead-letters.paging';
37
+ const R_CURSOR = 'openwop.requirement.0232.trigger-dead-letters.cursor-bound';
38
+ const R_TENANT = 'openwop.requirement.0232.trigger-dead-letters.tenant-bound';
39
+
40
+ const BAD_SIGNATURE = 'v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
41
+
42
+ /** The facet, or null with the reason the row is `inapplicable`. */
43
+ async function gate(): Promise<{ facet: DeadLetterFacet } | { reason: string }> {
44
+ const disco = await driver.get('/.well-known/openwop');
45
+ const facet = disco.status === 200 ? deadLetterFacet(disco.json) : null;
46
+ if (facet === null) return { reason: 'the host does not advertise triggerBridge.deadLetter' };
47
+ if (!(await inboundSigningAdvertised())) return { reason: 'triggerBridge.deadLetter is advertised without inboundSigning, so the suite cannot cause a dead letter unaided' };
48
+ return { facet };
49
+ }
50
+
51
+ /** A `required` subscription with `n` refused posts dead-lettered on it. */
52
+ async function withRefused(id: string, n: number): Promise<SignedWebhookSubscription> {
53
+ const sub = await registerSignedWebhook('required');
54
+ if ('reason' in sub) throw new Error(req(id, 'trigger-bridge.md §F.6', `an inboundSigning host MUST return the §B binding on webhook registration: ${sub.reason}`));
55
+ for (let i = 0; i < n; i++) {
56
+ const bad = await signedIngest(sub, JSON.stringify({ conformance: 'dead-letter-read', i }), { signature: BAD_SIGNATURE });
57
+ expect(bad.status, req(id, 'trigger-bridge.md §F.6', `a bad signature under required verification MUST answer 401 (got ${bad.status})`)).toBe(401);
58
+ }
59
+ return sub;
60
+ }
61
+
62
+ function deliveriesOf(json: unknown): unknown[] {
63
+ const d = (json as { deliveries?: unknown } | undefined)?.deliveries;
64
+ return Array.isArray(d) ? d : [];
65
+ }
66
+
67
+ describe('trigger-dead-letter-read (RFC 0232 §B)', () => {
68
+ it('clamps limit and continues a short page with nextCursor', async () => {
69
+ const g = await gate();
70
+ if ('reason' in g) return softSkip('inapplicable', g.reason);
71
+ const sub = await withRefused(R_PAGING, 2);
72
+ const first = await readDeadLetters(sub.subscriptionId, { limit: 1 });
73
+ expect(first.status, req(R_PAGING, 'trigger-bridge.md §C', `a host advertising triggerBridge.deadLetter MUST serve the read (got ${first.status})`)).toBe(200);
74
+ expect(deliveriesOf(first.json).length, req(R_PAGING, 'trigger-bridge.md §C', 'a page MUST NOT exceed limit')).toBe(1);
75
+ const cursor = (first.json as { nextCursor?: unknown } | undefined)?.nextCursor;
76
+ expect(typeof cursor === 'string' && cursor.length > 0, req(R_PAGING, 'trigger-bridge.md §C', 'with two dead letters and limit 1, the page MUST carry nextCursor')).toBe(true);
77
+ const second = await readDeadLetters(sub.subscriptionId, { limit: 1, cursor: cursor as string });
78
+ expect(second.status, req(R_PAGING, 'trigger-bridge.md §C', `the page's own cursor MUST be accepted (got ${second.status})`)).toBe(200);
79
+ expect(deliveriesOf(second.json).length, req(R_PAGING, 'trigger-bridge.md §C', 'the next page MUST carry the second dead letter')).toBe(1);
80
+ const big = await readDeadLetters(sub.subscriptionId, { limit: g.facet.maxPageSize + 1 });
81
+ expect(big.status, req(R_PAGING, 'trigger-bridge.md §C', `a limit above maxPageSize MUST be clamped, not refused (got ${big.status})`)).toBe(200);
82
+ });
83
+
84
+ it('refuses a cursor minted for another subscription', async () => {
85
+ const g = await gate();
86
+ if ('reason' in g) return softSkip('inapplicable', g.reason);
87
+ const a = await withRefused(R_CURSOR, 2);
88
+ const page = await readDeadLetters(a.subscriptionId, { limit: 1 });
89
+ const cursor = (page.json as { nextCursor?: unknown } | undefined)?.nextCursor;
90
+ expect(typeof cursor === 'string' && cursor.length > 0, req(R_CURSOR, 'trigger-bridge.md §C', `with two dead letters and limit 1, the page MUST carry nextCursor (read answered ${page.status})`)).toBe(true);
91
+ const b = await withRefused(R_CURSOR, 1);
92
+ const cross = await readDeadLetters(b.subscriptionId, { cursor: cursor as string });
93
+ expect(cross.status, req(R_CURSOR, 'trigger-bridge.md §C', `a cursor minted for another subscription MUST be refused 400 (got ${cross.status})`)).toBe(400);
94
+ expect(readErrorCode(cross.json), req(R_CURSOR, 'trigger-bridge.md §C', 'the refusal MUST carry validation_error')).toBe('validation_error');
95
+ });
96
+
97
+ it("answers another tenant's read of a subscription 404, as for an id never minted", async () => {
98
+ const g = await gate();
99
+ if ('reason' in g) return softSkip('inapplicable', g.reason);
100
+ const other = process.env['OPENWOP_TEST_TENANT_B_API_KEY'];
101
+ if (!other) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-tenant read cannot run');
102
+ if (other.trim() === loadEnv().apiKey) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY equals OPENWOP_API_KEY — the second credential must resolve to a DIFFERENT tenant, or the leg measures nothing');
103
+ const sub = await withRefused(R_TENANT, 1);
104
+ const path = (id: string): string => `/v1/trigger-subscriptions/${encodeURIComponent(id)}/dead-letters`;
105
+ const foreign = await driver.get(path(sub.subscriptionId), { headers: { Authorization: `Bearer ${other}` } });
106
+ expect(foreign.status, req(R_TENANT, 'trigger-bridge.md §C', `another tenant's read MUST answer 404 (got ${foreign.status})`)).toBe(404);
107
+ expect(JSON.stringify(foreign.json ?? null).includes('verification_failed'), req(R_TENANT, 'trigger-bridge.md §C', 'a foreign read MUST NOT disclose a record')).toBe(false);
108
+ const unknown = await driver.get(path(`${sub.subscriptionId}-never-minted`));
109
+ expect(unknown.status, req(R_TENANT, 'trigger-bridge.md §C', `an id the host never minted MUST answer 404 (got ${unknown.status})`)).toBe(404);
110
+ expect(readErrorCode(foreign.json), req(R_TENANT, 'trigger-bridge.md §C', 'a foreign id MUST be answered exactly as an unknown one')).toBe(readErrorCode(unknown.json));
111
+ });
112
+ });
@@ -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
+ });