@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.
@@ -0,0 +1,340 @@
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; timestamp?: string } = {}): Promise<{ status: number; json: Record<string, unknown> | undefined }> {
82
+ const id = opts.webhookId ?? freshWebhookId();
83
+ const ts = opts.timestamp ?? 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
+ /** How far off the skew probes are: twice the 300 s bound, so ordinary clock drift cannot pass or fail them. */
137
+ export const SKEW_PROBE_SECONDS = 600;
138
+
139
+ /**
140
+ * Leg 2b (RFC 0230 §C): under `required`, a `webhook-timestamp` more than 300 s
141
+ * from the host's clock fails the check, in either direction, even when the
142
+ * signature over that timestamp is correct. The post starts no run and leaves
143
+ * the subscription `active`.
144
+ */
145
+ export async function staleTimestampLeg(profile: MajorProfile, a: TriggerAdverts): Promise<LegOutcome> {
146
+ const gate = signedGate(a); if (gate) return gate;
147
+ const sub = await register(profile, 'required'); if (typeof sub === 'string') return bindingFinding(sub);
148
+ const body = JSON.stringify({ conformance: 'stale-timestamp' });
149
+ const now = Math.floor(Date.now() / 1000);
150
+ const findings: TriggerFinding[] = [];
151
+ for (const [label, ts] of [['stale', now - SKEW_PROBE_SECONDS], ['future', now + SKEW_PROBE_SECONDS]] as const) {
152
+ // Correctly signed over the skewed timestamp: only the skew can refuse it.
153
+ const res = await ingest(sub, body, { timestamp: String(ts) });
154
+ findings.push(
155
+ f(res.status === 401, DOC_INGEST, `a ${label} webhook-timestamp (${SKEW_PROBE_SECONDS} s off) under required verification MUST answer 401 (got ${res.status})`),
156
+ f(readErrorCode(res.json) === 'signature_invalid', DOC_INGEST, `the ${label}-timestamp refusal MUST carry signature_invalid (got ${String(readErrorCode(res.json))})`),
157
+ f(res.json?.['runId'] === undefined, DOC_INGEST, `a ${label}-timestamp post MUST NOT start a run`),
158
+ );
159
+ }
160
+ const read = await driver.get(`${profile.triggerSubscriptionsPath}/${profile.idSegment(sub.subscriptionId)}`);
161
+ const state = (read.json as { subscription?: { state?: unknown } } | undefined)?.subscription?.state;
162
+ const ok = await ingest(sub, body);
163
+ findings.push(
164
+ f(state === 'active', DOC_INGEST, `a skewed post MUST NOT change the subscription's state (read answered ${read.status}, state ${String(state)})`),
165
+ f(ok.status === 202, DOC_INGEST, `after the skewed posts the subscription MUST still deliver a current, correctly signed one (got ${ok.status})`),
166
+ );
167
+ return { kind: 'observed', findings };
168
+ }
169
+
170
+ /** True when `v` holds a `signingSecret` key at any depth. */
171
+ function hasSecretKey(v: unknown): boolean {
172
+ if (Array.isArray(v)) return v.some(hasSecretKey);
173
+ if (!isRecord(v)) return false;
174
+ return Object.entries(v).some(([k, x]) => k === 'signingSecret' || hasSecretKey(x));
175
+ }
176
+
177
+ /**
178
+ * Leg 2c (RFC 0230 §B, SR-1): the `whsec_` secret is returned once, in the
179
+ * registration's binding, and never on a re-read of the subscription. A host
180
+ * that serves no re-read cannot return it there, so the leg is `inapplicable`.
181
+ */
182
+ export async function signingSecretOnceLeg(profile: MajorProfile, a: TriggerAdverts): Promise<LegOutcome> {
183
+ const gate = signedGate(a); if (gate) return gate;
184
+ const sub = await register(profile, 'none'); if (typeof sub === 'string') return bindingFinding(sub);
185
+ const read = await driver.get(`${profile.triggerSubscriptionsPath}/${profile.idSegment(sub.subscriptionId)}`);
186
+ if (read.status === 404 || read.status === 405) {
187
+ return { kind: 'skip', disposition: 'inapplicable', reason: `the host serves no re-read of a trigger subscription at this major (GET answered ${read.status}), so no re-read can return the secret` };
188
+ }
189
+ const text = read.text;
190
+ const keyBytes = sub.signingSecret.replace(/^whsec_/, '');
191
+ return { kind: 'observed', findings: [
192
+ f(read.status === 200, DOC_INGEST, `the re-read of a subscription just registered MUST answer 200 when the host serves it (got ${read.status})`),
193
+ f(!hasSecretKey(read.json), DOC_INGEST, 'a re-read MUST NOT carry signingSecret'),
194
+ f(!text.includes(sub.signingSecret) && (keyBytes.length < 8 || !text.includes(keyBytes)), DOC_INGEST, 'a re-read MUST NOT carry the secret\'s value'),
195
+ f(!text.includes('whsec_'), DOC_INGEST, 'a re-read MUST NOT carry any whsec_ secret'),
196
+ ] };
197
+ }
198
+
199
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
200
+
201
+ /** Leg 3: the delivered run's `run.started` carries the delivery as `causationId`. */
202
+ export async function causationLeg(profile: MajorProfile, a: TriggerAdverts, opts: { pollMs?: number; pollTries?: number } = {}): Promise<LegOutcome> {
203
+ const gate = signedGate(a); if (gate) return gate;
204
+ const sub = await register(profile, 'none'); if (typeof sub === 'string') return bindingFinding(sub);
205
+ const res = await ingest(sub, JSON.stringify({ conformance: 'causation' }));
206
+ const runId = res.json?.['runId'];
207
+ if (res.status !== 202 || typeof runId !== 'string' || runId.length === 0) {
208
+ 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)})`)] };
209
+ }
210
+ const startedType = profile.eventType('run.started');
211
+ const attemptType = profile.eventType('trigger.delivery.attempted');
212
+ 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' };
213
+ let events: Array<Record<string, unknown>> = [];
214
+ let pollStatus = 0;
215
+ for (let i = 0; i < (opts.pollTries ?? 10); i++) {
216
+ const poll = await driver.get(`${profile.runsPath}/${profile.idSegment(runId)}/events/poll`);
217
+ pollStatus = poll.status;
218
+ events = ((poll.json as { events?: unknown } | undefined)?.events as Array<Record<string, unknown>> | undefined) ?? [];
219
+ if (events.some((e) => e['type'] === startedType)) break;
220
+ await sleep(opts.pollMs ?? 500);
221
+ }
222
+ const started = events.find((e) => e['type'] === startedType);
223
+ const causationId = started?.['causationId'];
224
+ const delivered = events.find((e) => e['type'] === attemptType && isRecord(e['payload']) && (e['payload'] as Record<string, unknown>)['outcome'] === 'delivered');
225
+ const findings = [
226
+ 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'})`),
227
+ f(typeof causationId === 'string' && causationId.length > 0, 'webhooks.md §Inbound triggers', `${startedType} MUST carry the delivery id as causationId`),
228
+ ];
229
+ if (delivered !== undefined) findings.push(f(causationId === delivered['eventId'], 'webhooks.md §Inbound triggers', `${startedType}.causationId MUST equal the delivered attempt's eventId`));
230
+ 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' };
231
+ }
232
+
233
+ /** GET one page of a subscription's dead letters at this major. Asserts nothing. */
234
+ export async function readDeadLettersAt(profile: MajorProfile, subscriptionId: string, query: { limit?: number; cursor?: string } = {}, headers?: Record<string, string>): Promise<DeadLetterRead> {
235
+ const qs = new URLSearchParams();
236
+ if (query.limit !== undefined) qs.set('limit', String(query.limit));
237
+ if (query.cursor !== undefined) qs.set('cursor', query.cursor);
238
+ const suffix = qs.toString() === '' ? '' : `?${qs.toString()}`;
239
+ const res = await driver.get(`${profile.triggerSubscriptionsPath}/${profile.idSegment(subscriptionId)}/dead-letters${suffix}`, headers ? { headers } : {});
240
+ return { status: res.status, json: res.json };
241
+ }
242
+
243
+ /** Leg 4a: a dead-lettered (run-less) attempt is content-free, read through RFC 0232's read. */
244
+ export async function runlessAttemptLeg(profile: MajorProfile, a: TriggerAdverts, validate: (page: unknown) => { ok: boolean; errors: string }): Promise<LegOutcome> {
245
+ const gate = signedGate(a); if (gate) return gate;
246
+ 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' };
247
+ const sub = await register(profile, 'required'); if (typeof sub === 'string') return bindingFinding(sub);
248
+ const canary = freshCanary();
249
+ const bad = await ingest(sub, JSON.stringify({ conformance: 'runless-attempt', canary }), { signature: BAD_SIGNATURE });
250
+ const read = await readDeadLettersAt(profile, sub.subscriptionId);
251
+ const findings = [f(bad.status === 401, DOC_INGEST, `a bad signature under required verification MUST answer 401 (got ${bad.status})`)];
252
+ for (const j of judgeDeadLetters(read, { subscriptionId: sub.subscriptionId, canary, signature: BAD_SIGNATURE, signingSecret: sub.signingSecret, retentionDays: a.deadLetter.retentionDays }, validate)) {
253
+ findings.push(f(j.ok, DOC_INGEST, j.message));
254
+ }
255
+ return { kind: 'observed', findings, note: 'RFC 0232 dead-letter read' };
256
+ }
257
+
258
+ /** Leg 4b: a run-less state change. No surface causes one without a seam, and no seam exists at this major. */
259
+ export function runlessStateChangeLeg(a: TriggerAdverts): LegOutcome {
260
+ if (a.family === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise triggerBridge' };
261
+ 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)' };
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // The read's own rules (RFC 0232 §B).
266
+ // ---------------------------------------------------------------------------
267
+
268
+ function readGate(a: TriggerAdverts): LegOutcome | null {
269
+ if (a.family === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise triggerBridge' };
270
+ if (a.deadLetter === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise triggerBridge.deadLetter' };
271
+ if (!a.inboundSigning) return { kind: 'skip', disposition: 'inapplicable', reason: 'triggerBridge.deadLetter is advertised without inboundSigning, so the suite cannot cause a dead letter unaided' };
272
+ return null;
273
+ }
274
+
275
+ async function refusedTwice(profile: MajorProfile, n: number): Promise<Sub | string> {
276
+ const sub = await register(profile, 'required'); if (typeof sub === 'string') return sub;
277
+ for (let i = 0; i < n; i++) await ingest(sub, JSON.stringify({ conformance: 'dead-letter-read', i }), { signature: BAD_SIGNATURE });
278
+ return sub;
279
+ }
280
+ const deliveriesOf = (json: unknown): unknown[] => { const d = (json as { deliveries?: unknown } | undefined)?.deliveries; return Array.isArray(d) ? d : []; };
281
+ const cursorOf = (json: unknown): unknown => (json as { nextCursor?: unknown } | undefined)?.nextCursor;
282
+
283
+ export async function pagingLeg(profile: MajorProfile, a: TriggerAdverts): Promise<LegOutcome> {
284
+ const gate = readGate(a); if (gate) return gate;
285
+ const sub = await refusedTwice(profile, 2); if (typeof sub === 'string') return bindingFinding(sub);
286
+ const first = await readDeadLettersAt(profile, sub.subscriptionId, { limit: 1 });
287
+ const cursor = cursorOf(first.json);
288
+ const second = typeof cursor === 'string' ? await readDeadLettersAt(profile, sub.subscriptionId, { limit: 1, cursor }) : undefined;
289
+ const big = await readDeadLettersAt(profile, sub.subscriptionId, { limit: a.deadLetter!.maxPageSize + 1 });
290
+ return { kind: 'observed', findings: [
291
+ f(first.status === 200, DOC_INGEST, `a host advertising triggerBridge.deadLetter MUST serve the read (got ${first.status})`),
292
+ f(deliveriesOf(first.json).length === 1, DOC_INGEST, `a page MUST NOT exceed limit (got ${deliveriesOf(first.json).length} for limit 1)`),
293
+ f(typeof cursor === 'string' && cursor.length > 0, DOC_INGEST, 'with two dead letters and limit 1, the page MUST carry nextCursor'),
294
+ 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)})`),
295
+ f(big.status === 200, DOC_INGEST, `a limit above maxPageSize MUST be clamped, not refused (got ${big.status})`),
296
+ ] };
297
+ }
298
+
299
+ export async function cursorLeg(profile: MajorProfile, a: TriggerAdverts): Promise<LegOutcome> {
300
+ const gate = readGate(a); if (gate) return gate;
301
+ const subA = await refusedTwice(profile, 2); if (typeof subA === 'string') return bindingFinding(subA);
302
+ const page = await readDeadLettersAt(profile, subA.subscriptionId, { limit: 1 });
303
+ const cursor = cursorOf(page.json);
304
+ if (typeof cursor !== 'string' || cursor.length === 0) {
305
+ return { kind: 'observed', findings: [f(false, DOC_INGEST, `with two dead letters and limit 1, the page MUST carry nextCursor (read answered ${page.status})`)] };
306
+ }
307
+ const subB = await refusedTwice(profile, 1); if (typeof subB === 'string') return bindingFinding(subB);
308
+ const cross = await readDeadLettersAt(profile, subB.subscriptionId, { cursor });
309
+ return { kind: 'observed', findings: [
310
+ f(cross.status === 400, DOC_INGEST, `a cursor minted for another subscription MUST be refused 400 (got ${cross.status})`),
311
+ f(readErrorCode(cross.json) === 'validation_error', DOC_INGEST, `the refusal MUST carry validation_error (got ${String(readErrorCode(cross.json))})`),
312
+ ] };
313
+ }
314
+
315
+ export async function tenantLeg(profile: MajorProfile, a: TriggerAdverts, otherTenantKey: string | undefined, ownKey: string): Promise<LegOutcome> {
316
+ const gate = readGate(a); if (gate) return gate;
317
+ 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' };
318
+ 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' };
319
+ const sub = await refusedTwice(profile, 1); if (typeof sub === 'string') return bindingFinding(sub);
320
+ const foreign = await readDeadLettersAt(profile, sub.subscriptionId, {}, { Authorization: `Bearer ${otherTenantKey}` });
321
+ const unknown = await readDeadLettersAt(profile, `${sub.subscriptionId}-never-minted`);
322
+ const noDisclosure = f(!JSON.stringify(foreign.json ?? null).includes('verification_failed'), DOC_INGEST, 'a foreign read MUST NOT disclose a record');
323
+ const unknownFinding = f(unknown.status === 404, DOC_INGEST, `an id the host never minted MUST answer 404 (got ${unknown.status})`);
324
+ if (profile.foreignTenantRead === 'id_tenant_mismatch') {
325
+ // identity.md §5: a bound id whose tenant segment is not the caller's is
326
+ // refused 403 id_tenant_mismatch, before any lookup, so it discloses nothing.
327
+ return { kind: 'observed', findings: [
328
+ f(foreign.status === 403, 'identity.md §5', `another tenant's bound id MUST be refused 403 (got ${foreign.status})`),
329
+ f(readErrorCode(foreign.json) === 'id_tenant_mismatch', 'identity.md §5', `the refusal MUST carry id_tenant_mismatch (got ${String(readErrorCode(foreign.json))})`),
330
+ noDisclosure,
331
+ unknownFinding,
332
+ ] };
333
+ }
334
+ return { kind: 'observed', findings: [
335
+ f(foreign.status === 404, DOC_INGEST, `another tenant's read MUST answer 404 (got ${foreign.status})`),
336
+ noDisclosure,
337
+ unknownFinding,
338
+ f(readErrorCode(foreign.json) === readErrorCode(unknown.json), DOC_INGEST, 'a foreign id MUST be answered exactly as an unknown one'),
339
+ ] };
340
+ }
@@ -0,0 +1,133 @@
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' | 'skew-accepted' | 'reread-leaks-secret';
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
+ // RFC 0230 §C: a timestamp more than 300 s from the clock fails the check, however well signed.
78
+ const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300 || this.defect === 'skew-accepted';
79
+ const good = fresh && req.headers['webhook-signature'] === standardWebhooksSignature(sub.secret, id, ts, body);
80
+ if (!good && sub.mode === 'required') {
81
+ if (this.defect !== 'refused-not-recorded') {
82
+ const at = Date.now();
83
+ sub.dead.unshift({
84
+ subscriptionId: sub.id, attemptEventId: `evt${String(++this.seq).padStart(16, '0')}`,
85
+ attempt: { subscriptionId: sub.id, dedupKey: 'opaque', attempt: 1, outcome: 'dead-lettered' },
86
+ reason: 'verification_failed',
87
+ deadLetteredAt: new Date(at).toISOString(),
88
+ expiresAt: new Date(at + (this.defect === 'wrong-retention' ? 1 : 7) * 86_400_000).toISOString(),
89
+ ...(this.defect === 'record-leaks-canary' ? { body } : {}),
90
+ ...(this.defect === 'refused-records-state-change' ? { stateChange: { subscriptionId: sub.id, source: 'webhook', fromState: 'active', toState: 'failed', reason: 'signature-invalid' } } : {}),
91
+ });
92
+ }
93
+ if (this.defect === 'refused-changes-state') sub.state = 'failed';
94
+ return this.send(res, 401, { error: 'signature_invalid', message: 'bad signature', ...(this.defect === 'refused-starts-run' ? { runId: this.startRun(sub) } : {}) });
95
+ }
96
+ const prior = sub.seen.get(id);
97
+ if (prior !== undefined && this.defect !== 'dedup-new-run') return this.send(res, 200, { outcome: 'duplicate', runId: prior });
98
+ const runId = this.startRun(sub);
99
+ sub.seen.set(id, runId);
100
+ return this.send(res, 202, { outcome: 'delivered', runId });
101
+ }
102
+
103
+ const poll = /^\/runs\/([^/]+)\/events\/poll$/.exec(path);
104
+ if (req.method === 'GET' && poll) {
105
+ const events = this.runs.get(unprojectBoundId(poll[1]!));
106
+ return events ? this.send(res, 200, { events }) : this.send(res, 404, { error: 'not_found', message: 'no run' });
107
+ }
108
+
109
+ const m = /^\/trigger-subscriptions\/([^/]+)(\/dead-letters)?$/.exec(path);
110
+ if (req.method === 'GET' && m) {
111
+ const auth = String(req.headers['authorization'] ?? '');
112
+ const sub = this.subs.get(unprojectBoundId(m[1]!));
113
+ // identity.md §5: tenant B reading acme's bound id is refused 403 before any lookup.
114
+ if (auth === `Bearer ${OTHER_TENANT_KEY}` && this.defect === 'tenant-404') return this.send(res, 404, { error: 'not_found', message: 'no such subscription' });
115
+ 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' });
116
+ if (!sub) return this.send(res, 404, { error: 'not_found', message: 'no such subscription' });
117
+ if (!m[2]) return this.send(res, 200, { subscription: { subscriptionId: sub.id, source: 'webhook', state: sub.state, ...(this.defect === 'reread-leaks-secret' ? { signingSecret: sub.secret } : {}) } });
118
+ const limit = Math.min(Number(u.searchParams.get('limit') ?? '100'), 100);
119
+ const cursor = u.searchParams.get('cursor');
120
+ let offset = 0;
121
+ if (cursor !== null) {
122
+ const [cid, off] = Buffer.from(cursor, 'base64url').toString('utf8').split('|');
123
+ if (cid !== sub.id && this.defect !== 'cursor-not-bound') return this.send(res, 400, { error: 'validation_error', message: 'cursor is for another subscription' });
124
+ offset = Number(off);
125
+ }
126
+ const size = this.defect === 'limit-ignored' ? sub.dead.length : limit;
127
+ const page = sub.dead.slice(offset, offset + size);
128
+ const next = offset + size < sub.dead.length ? Buffer.from(`${sub.id}|${offset + size}`).toString('base64url') : undefined;
129
+ return this.send(res, 200, { deliveries: page, ...(next ? { nextCursor: next } : {}) });
130
+ }
131
+ return this.send(res, 404, { error: 'not_found', message: `no route ${req.method} ${path}` });
132
+ }
133
+ }
@@ -6,11 +6,18 @@
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
- * Five legs, each its own requirement (RFC 0230 split the one combined `it`;
10
- * RFC 0232 split the fourth):
9
+ * Seven legs, each its own requirement (RFC 0230 split the one combined `it`;
10
+ * RFC 0232 split the fourth; 2.45.12 added 2b and 2c):
11
11
  *
12
12
  * 1. DEDUP (§C-1) — a repeated delivery is effectively-once.
13
13
  * 2. DEAD-LETTER (§C-2 + RFC 0053) — an exhausted/refused delivery starts no run.
14
+ * 2b. STALE TIMESTAMP (RFC 0230 §C) — under `required`, a `webhook-timestamp`
15
+ * more than 300 s off, either way, answers `401 signature_invalid`, starts
16
+ * no run and leaves the subscription `active`.
17
+ * 2c. SECRET ONCE (RFC 0230 §B, SR-1) — a re-read of the subscription carries
18
+ * no `signingSecret` and no `whsec_` value.
19
+ * Legs 2b and 2c have only the normative-surface path: they are
20
+ * `inapplicable` on a host that does not advertise `inboundSigning`.
14
21
  * 3. CAUSATION (§C / RFC 0040) — a delivered run carries the delivery as
15
22
  * `run.started.causationId`.
16
23
  * 4a. RUN-LESS ATTEMPT (SR-1) — a dead-lettered `trigger.delivery.attempted`
@@ -71,10 +78,23 @@ import { deadLetterFacet, freshCanary, judge as judgeDeadLetters, readDeadLetter
71
78
 
72
79
  const R_DEDUP = 'openwop.requirement.0083.trigger-delivery.dedup';
73
80
  const R_DEAD_LETTER = 'openwop.requirement.0083.trigger-delivery.dead-letter';
81
+ const R_STALE_TIMESTAMP = 'openwop.requirement.0230.stale-timestamp-refused';
82
+ const R_SECRET_ONCE = 'openwop.requirement.0230.signing-secret-once';
74
83
  const R_CAUSATION = 'openwop.requirement.0083.trigger-delivery.causation';
75
84
  const R_RUNLESS_ATTEMPT = 'openwop.requirement.0083.trigger-delivery.runless-attempt-content-free';
76
85
  const R_RUNLESS_STATE = 'openwop.requirement.0083.trigger-delivery.runless-state-change-content-free';
77
86
 
87
+ /** How far off the skew probes are: twice the 300 s bound, so ordinary clock drift cannot pass or fail them. */
88
+ const SKEW_PROBE_SECONDS = 600;
89
+ const NO_SIGNING = 'the host does not advertise triggerBridge.ingestion.inboundSigning ["standard-webhooks-1"]: this rule binds only the RFC 0230 signed ingest, and no seam path witnesses it';
90
+
91
+ /** True when `v` holds a `signingSecret` key at any depth. */
92
+ function hasSecretKey(v: unknown): boolean {
93
+ if (Array.isArray(v)) return v.some(hasSecretKey);
94
+ if (v === null || typeof v !== 'object') return false;
95
+ return Object.entries(v as Record<string, unknown>).some(([k, x]) => k === 'signingSecret' || hasSecretKey(x));
96
+ }
97
+
78
98
  /** The bad signature every refused-post probe sends. */
79
99
  const BAD_SIGNATURE = 'v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
80
100
 
@@ -215,6 +235,45 @@ describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
215
235
  notePaths(paths.seam, true);
216
236
  });
217
237
 
238
+ it('refuses a webhook-timestamp more than 300 s off without starting a run', async () => {
239
+ if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
240
+ if (!(await inboundSigningAdvertised())) return softSkip('inapplicable', NO_SIGNING);
241
+ const sub = await registerSignedWebhook('required');
242
+ if ('reason' in sub) throw new Error(req(R_STALE_TIMESTAMP, 'trigger-bridge.md §F.6', `an inboundSigning host MUST return the §B binding on webhook registration: ${sub.reason}`));
243
+ const body = JSON.stringify({ conformance: 'stale-timestamp' });
244
+ const now = Math.floor(Date.now() / 1000);
245
+ for (const [label, ts] of [['stale', now - SKEW_PROBE_SECONDS], ['future', now + SKEW_PROBE_SECONDS]] as const) {
246
+ // Correctly signed over the skewed timestamp: only the skew can refuse it.
247
+ const res = await signedIngest(sub, body, { timestamp: String(ts) });
248
+ expect(res.status, req(R_STALE_TIMESTAMP, 'trigger-bridge.md §F.6', `a ${label} webhook-timestamp (${SKEW_PROBE_SECONDS} s off) under required verification MUST answer 401 (got ${res.status})`)).toBe(401);
249
+ expect((res.json as { error?: unknown } | undefined)?.error, req(R_STALE_TIMESTAMP, 'trigger-bridge.md §F.6', `the ${label}-timestamp refusal MUST carry signature_invalid`)).toBe('signature_invalid');
250
+ expect(res.json?.runId, req(R_STALE_TIMESTAMP, 'trigger-bridge.md §F.6', `a ${label}-timestamp post MUST NOT start a run`)).toBeUndefined();
251
+ }
252
+ const read = await driver.get(`/v1/trigger-subscriptions/${encodeURIComponent(sub.subscriptionId)}`);
253
+ const state = (read.json as { subscription?: { state?: unknown } } | undefined)?.subscription?.state;
254
+ expect(state, req(R_STALE_TIMESTAMP, 'trigger-bridge.md §F.6 (RFC 0230 §C.1)', `a skewed post MUST NOT change the subscription's state (read answered ${read.status}, state ${String(state)})`)).toBe('active');
255
+ const ok = await signedIngest(sub, body);
256
+ expect(ok.status, req(R_STALE_TIMESTAMP, 'trigger-bridge.md §F.6', `after the skewed posts the subscription MUST still deliver a current, correctly signed one (got ${ok.status})`)).toBe(202);
257
+ notePaths(false, true);
258
+ });
259
+
260
+ it('returns the signing secret once: a re-read carries no whsec_ value', async () => {
261
+ if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
262
+ if (!(await inboundSigningAdvertised())) return softSkip('inapplicable', NO_SIGNING);
263
+ const sub = await registerSignedWebhook('none');
264
+ if ('reason' in sub) throw new Error(req(R_SECRET_ONCE, 'trigger-bridge.md §F.6', `an inboundSigning host MUST return the §B binding on webhook registration: ${sub.reason}`));
265
+ const read = await driver.get(`/v1/trigger-subscriptions/${encodeURIComponent(sub.subscriptionId)}`);
266
+ if (read.status === 404 || read.status === 405) {
267
+ return softSkip('inapplicable', `the host serves no re-read of a trigger subscription (GET answered ${read.status}), so no re-read can return the secret`);
268
+ }
269
+ const keyBytes = sub.signingSecret.replace(/^whsec_/, '');
270
+ expect(read.status, req(R_SECRET_ONCE, 'trigger-bridge.md §F.6', `the re-read of a subscription just registered MUST answer 200 when the host serves it (got ${read.status})`)).toBe(200);
271
+ expect(hasSecretKey(read.json), req(R_SECRET_ONCE, 'trigger-bridge.md §F.6 (SR-1)', 'a re-read MUST NOT carry signingSecret')).toBe(false);
272
+ expect(read.text.includes(sub.signingSecret) || (keyBytes.length >= 8 && read.text.includes(keyBytes)), req(R_SECRET_ONCE, 'trigger-bridge.md §F.6 (SR-1)', 'a re-read MUST NOT carry the secret\'s value')).toBe(false);
273
+ expect(read.text.includes('whsec_'), req(R_SECRET_ONCE, 'trigger-bridge.md §F.6 (SR-1)', 'a re-read MUST NOT carry any whsec_ secret')).toBe(false);
274
+ notePaths(false, true);
275
+ });
276
+
218
277
  it('links delivery→run causation on run.started', async () => {
219
278
  if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
220
279
  const paths = await witnessPaths();
@@ -135,6 +135,15 @@ describe('v2-advertised-path-space-served (RFC 0172 §A.1)', () => {
135
135
  if (!new Set(versions.map((v) => v.split('.')[0])).has('2')) {
136
136
  return softSkip('inapplicable', `the host advertises [${versions.join(', ') || 'no protocolVersions'}] — it does not claim major 2, so there is no path space to hold it to`);
137
137
  }
138
+ // versioning.md §1.2's obligation is an OVERLAP rule: reach under major 2
139
+ // what the host already serves under /v1. A host whose protocolVersions[]
140
+ // carries no 1.x member has retired /v1 (§5 "Retirement is atomic"), so
141
+ // nothing is served there and there is nothing to pair. Until 2.45.12 a
142
+ // retired host failed this leg: every /v1 path answers 410 there, and the
143
+ // leg read 410 as "served under /v1" (openwop-app's v1-cut rehearsal).
144
+ if (!new Set(versions.map((v) => v.split('.')[0])).has('1')) {
145
+ return softSkip('inapplicable', `the host advertises [${versions.join(', ')}] with no 1.x member — /v1 is retired (versioning.md §5), so there is no /v1 surface to pair`);
146
+ }
138
147
 
139
148
  const paths = parameterlessGets();
140
149
  if (paths.length === 0) return softSkip('blocked', 'spec/v2/path-manifest.json is unreadable from this layout');
@@ -146,8 +155,9 @@ describe('v2-advertised-path-space-served (RFC 0172 §A.1)', () => {
146
155
  const v1 = await get(`/v1${path}`, false);
147
156
  if (v1.status === null) return softSkip('blocked', `the host became unreachable while probing /v1${path}`);
148
157
  // The host does not serve this surface in EITHER major. Legitimate, and a
149
- // different question from the one asked here.
150
- if (v1.status === 404) continue;
158
+ // different question from the one asked here. 410 Gone says the same of
159
+ // /v1 (RFC 9110 §15.5.11: no longer available), so it is not a pair either.
160
+ if (v1.status === 404 || v1.status === 410) continue;
151
161
  pairable += 1;
152
162
 
153
163
  const v2 = await get(path, true);