@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.
- package/CHANGELOG.md +21 -0
- package/README.md +2 -2
- package/coverage.md +1 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirement-aliases.json +1 -0
- package/requirements.json +238 -12
- package/scenario-majors.json +12 -3
- package/schemas/CORPUS-STAMP.json +27 -25
- package/src/lib/major-profile.ts +17 -0
- package/src/lib/trigger-dead-letter-witness.ts +143 -0
- package/src/lib/trigger-delivery-witness.ts +277 -0
- package/src/lib/trigger-double.ts +131 -0
- package/src/scenarios/trigger-bridge-delivery.test.ts +68 -19
- package/src/scenarios/trigger-dead-letter-read.test.ts +112 -0
- package/src/scenarios/v2-trigger-bridge-delivery.test.ts +107 -0
- package/src/scenarios/v2-trigger-dead-letter-read.test.ts +78 -0
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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:
|
|
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
|
|
247
|
+
it('keeps a dead-lettered run-less attempt content-free', async () => {
|
|
235
248
|
if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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(
|
|
289
|
+
if (exhaust === null) return seamAbsent(SEAM_UNWIRED);
|
|
241
290
|
const exKey = exhaust.runId ?? '__exhaust__';
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
});
|