@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.
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { capabilityFamily } from './discovery-capabilities.js';
22
22
  import { codemapV1toV2 } from './era2-seed.js';
23
+ import { projectBoundId } from './bound-id.js';
23
24
 
24
25
  export interface MajorProfile {
25
26
  readonly major: number;
@@ -49,6 +50,16 @@ export interface MajorProfile {
49
50
  * unread observation and never guesses a name.
50
51
  */
51
52
  eventType(era1Name: string): string | undefined;
53
+ /** The trigger subscription collection, e.g. `/v1/trigger-subscriptions` or `/trigger-subscriptions`. */
54
+ readonly triggerSubscriptionsPath: string;
55
+ /** An id as one URL path segment: percent-encoded at v1, the `~` bound-id projection at v2 (`identity.md` §5). */
56
+ idSegment(id: string): string;
57
+ /**
58
+ * How a read of another tenant's id is refused. `not_found`: as an id never
59
+ * minted (v1, bare ids). `id_tenant_mismatch`: `403`, because a v2 bound id
60
+ * names its tenant and `identity.md` §5 requires the refusal to say so.
61
+ */
62
+ readonly foreignTenantRead: 'not_found' | 'id_tenant_mismatch';
52
63
  }
53
64
 
54
65
  const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
@@ -68,6 +79,9 @@ export const MAJOR_PROFILES: Readonly<Record<number, MajorProfile>> = {
68
79
  // v1 budgets are driven through a host seam, not `createRun`.
69
80
  runBudget: () => null,
70
81
  eventType: (era1Name) => era1Name,
82
+ triggerSubscriptionsPath: '/v1/trigger-subscriptions',
83
+ idSegment: (id) => encodeURIComponent(id),
84
+ foreignTenantRead: 'not_found',
71
85
  },
72
86
  2: {
73
87
  major: 2,
@@ -82,6 +96,9 @@ export const MAJOR_PROFILES: Readonly<Record<number, MajorProfile>> = {
82
96
  },
83
97
  runBudget: (policy) => ({ configurable: { version: 1, budget: { ...policy } } }),
84
98
  eventType: (era1Name) => codemapV1toV2().get(era1Name),
99
+ triggerSubscriptionsPath: '/trigger-subscriptions',
100
+ idSegment: (id) => projectBoundId(id),
101
+ foreignTenantRead: 'id_tenant_mismatch',
85
102
  },
86
103
  };
87
104
 
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The trigger dead-letter read witness (RFC 0232), leg 4's normative-surface
3
+ * path in `trigger-bridge-delivery.test.ts`.
4
+ *
5
+ * A dead-lettered trigger delivery started no run, so it is on no run's log.
6
+ * On a host that serves no test seam, the only place the suite can see it is
7
+ * `GET /v1/trigger-subscriptions/{id}/dead-letters`. The suite causes one
8
+ * unaided: it posts a body carrying a canary with a bad signature to a
9
+ * `required` subscription (RFC 0230 leg 2), then reads the subscription's
10
+ * dead letters.
11
+ *
12
+ * Two halves: {@link readDeadLetters} observes and asserts nothing; {@link judge}
13
+ * is pure, so each defect it convicts is proven in `trigger-dead-letter-witness.test.ts`.
14
+ *
15
+ * Not witnessed here: a record's `stateChange`. No wire surface causes a
16
+ * subscription state change on a host without the seam (RFC 0232 gap G2), so
17
+ * that half of leg 4 stays seam-witnessed.
18
+ */
19
+
20
+ import { readFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { randomBytes } from 'node:crypto';
23
+ import Ajv2020 from 'ajv/dist/2020.js';
24
+ import addFormats from 'ajv-formats';
25
+ import { driver } from './driver.js';
26
+ import { SCHEMAS_DIR } from './paths.js';
27
+
28
+ export interface DeadLetterFacet {
29
+ readonly retentionDays: number;
30
+ readonly maxPageSize: number;
31
+ }
32
+
33
+ /** The advertised `triggerBridge.deadLetter` facet, or null when the host does not advertise it. */
34
+ export function deadLetterFacet(discovery: unknown): DeadLetterFacet | null {
35
+ const d = discovery as { triggerBridge?: unknown; capabilities?: { triggerBridge?: unknown } } | null | undefined;
36
+ const tb = (d?.triggerBridge ?? d?.capabilities?.triggerBridge) as { deadLetter?: unknown } | undefined;
37
+ const f = tb?.deadLetter as { retentionDays?: unknown; maxPageSize?: unknown } | undefined;
38
+ if (f === undefined || f === null || typeof f !== 'object') return null;
39
+ if (typeof f.retentionDays !== 'number' || typeof f.maxPageSize !== 'number') return null;
40
+ return { retentionDays: f.retentionDays, maxPageSize: f.maxPageSize };
41
+ }
42
+
43
+ /** A canary no conforming record can contain: it only ever travels in the refused body. */
44
+ export function freshCanary(): string {
45
+ return `openwop-canary-${randomBytes(9).toString('hex')}`;
46
+ }
47
+
48
+ export interface DeadLetterRead {
49
+ readonly status: number;
50
+ readonly json: unknown;
51
+ }
52
+
53
+ /** GET one page of a subscription's dead letters. Asserts nothing. */
54
+ export async function readDeadLetters(subscriptionId: string, query: { limit?: number; cursor?: string } = {}): Promise<DeadLetterRead> {
55
+ const qs = new URLSearchParams();
56
+ if (query.limit !== undefined) qs.set('limit', String(query.limit));
57
+ if (query.cursor !== undefined) qs.set('cursor', query.cursor);
58
+ const suffix = qs.toString() === '' ? '' : `?${qs.toString()}`;
59
+ const res = await driver.get(`/v1/trigger-subscriptions/${encodeURIComponent(subscriptionId)}/dead-letters${suffix}`);
60
+ return { status: res.status, json: res.json };
61
+ }
62
+
63
+ let pageValidator: ((v: unknown) => boolean) & { errors?: unknown } | undefined;
64
+ /** Validate a page against `trigger-dead-letter-page.schema.json` (v1), resolving the event payload `$ref`s. */
65
+ export function validatePage(page: unknown): { ok: boolean; errors: string } {
66
+ if (pageValidator === undefined) {
67
+ const ajv = addFormats(new Ajv2020({ strict: false }));
68
+ const load = (n: string): Record<string, unknown> => JSON.parse(readFileSync(join(SCHEMAS_DIR, n), 'utf8')) as Record<string, unknown>;
69
+ const payloads = load('run-event-payloads.schema.json');
70
+ // Both carry `https://openwop.dev/spec/v1/…` ids, so the page's relative
71
+ // `run-event-payloads.schema.json#/$defs/…` refs resolve to this schema.
72
+ ajv.addSchema(payloads);
73
+ pageValidator = ajv.compile(load('trigger-dead-letter-page.schema.json'));
74
+ }
75
+ const ok = pageValidator(page) === true;
76
+ return { ok, errors: ok ? '' : JSON.stringify(pageValidator.errors ?? []).slice(0, 400) };
77
+ }
78
+
79
+ /** What the suite posted, so the judge can look for it in the record. */
80
+ export interface RefusedPost {
81
+ readonly subscriptionId: string;
82
+ readonly canary: string;
83
+ readonly signature: string;
84
+ readonly signingSecret: string;
85
+ readonly retentionDays: number;
86
+ }
87
+
88
+ export type DeadLetterRule = 'page-valid' | 'present' | 'content-free' | 'attempt-shape' | 'no-state-change' | 'retention';
89
+
90
+ export interface DeadLetterFinding {
91
+ readonly rule: DeadLetterRule;
92
+ readonly ok: boolean;
93
+ readonly message: string;
94
+ }
95
+
96
+ const DAY_MS = 86_400_000;
97
+ /** One minute of slack either way: hosts round timestamps. */
98
+ const RETENTION_SLACK_MS = 60_000;
99
+
100
+ /**
101
+ * Judge one read against the refused post that should have produced a record.
102
+ * Pure. `validate` defaults to {@link validatePage}; the unit tests pass it in.
103
+ */
104
+ export function judge(read: DeadLetterRead, post: RefusedPost, validate: (page: unknown) => { ok: boolean; errors: string } = validatePage): DeadLetterFinding[] {
105
+ const out: DeadLetterFinding[] = [];
106
+ if (read.status !== 200) {
107
+ out.push({ rule: 'page-valid', ok: false, message: `a host advertising triggerBridge.deadLetter MUST serve the read (answered ${read.status})` });
108
+ return out;
109
+ }
110
+ const v = validate(read.json);
111
+ out.push({ rule: 'page-valid', ok: v.ok, message: `the page MUST validate against trigger-dead-letter-page.schema.json${v.ok ? '' : `: ${v.errors}`}` });
112
+
113
+ const deliveries = ((read.json as { deliveries?: unknown } | undefined)?.deliveries ?? []) as Array<Record<string, unknown>>;
114
+ const mine = Array.isArray(deliveries)
115
+ ? deliveries.filter((r) => r !== null && typeof r === 'object' && r['reason'] === 'verification_failed' && r['subscriptionId'] === post.subscriptionId)
116
+ : [];
117
+ out.push({ rule: 'present', ok: mine.length >= 1, message: `the refused delivery MUST appear with reason "verification_failed" (saw ${Array.isArray(deliveries) ? deliveries.length : 0} record(s), ${mine.length} matching)` });
118
+
119
+ // Content-free: the canary, the signature and the secret appear nowhere in
120
+ // ANY record on the page, not just the matched one — a host that leaked them
121
+ // into a record it mis-labelled is still leaking them.
122
+ const text = JSON.stringify(read.json ?? null);
123
+ const leaked = [['the canary from the refused body', post.canary], ['the signature', post.signature], ['the signing secret', post.signingSecret]]
124
+ .filter(([, needle]) => needle !== '' && text.includes(needle as string)).map(([what]) => what);
125
+ const secretKey = post.signingSecret.replace(/^whsec_/, '');
126
+ if (secretKey !== '' && secretKey !== post.signingSecret && text.includes(secretKey)) leaked.push('the signing key');
127
+ out.push({ rule: 'content-free', ok: leaked.length === 0, message: `a record MUST NOT carry inbound content or credentials (found ${leaked.join(', ') || 'none'})` });
128
+
129
+ const rec = mine[0];
130
+ if (rec !== undefined) {
131
+ const attempt = rec['attempt'] as Record<string, unknown> | undefined;
132
+ const attemptOk = attempt !== undefined && attempt !== null && typeof attempt === 'object'
133
+ && attempt['outcome'] === 'dead-lettered' && attempt['subscriptionId'] === post.subscriptionId;
134
+ out.push({ rule: 'attempt-shape', ok: attemptOk, message: `attempt MUST be the dead-lettered trigger.delivery.attempted payload for this subscription (got ${JSON.stringify(attempt ?? null).slice(0, 200)})` });
135
+ out.push({ rule: 'no-state-change', ok: !('stateChange' in rec), message: 'a delivery refused by verification MUST NOT carry a stateChange (a refused event MUST NOT change the subscription\'s state)' });
136
+ const from = Date.parse(String(rec['deadLetteredAt']));
137
+ const to = Date.parse(String(rec['expiresAt']));
138
+ const want = post.retentionDays * DAY_MS;
139
+ const retentionOk = Number.isFinite(from) && Number.isFinite(to) && Math.abs((to - from) - want) <= RETENTION_SLACK_MS;
140
+ out.push({ rule: 'retention', ok: retentionOk, message: `expiresAt − deadLetteredAt MUST match retentionDays (${post.retentionDays}); got ${Number.isFinite(to - from) ? `${((to - from) / DAY_MS).toFixed(4)} days` : 'unparseable timestamps'}` });
141
+ }
142
+ return out;
143
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * The trigger-bridge delivery witness on the normative surface (RFC 0230's
3
+ * signed ingest, RFC 0232's dead-letter read), shared by every major that
4
+ * serves it. What differs between majors — the subscription path, how an id
5
+ * becomes a path segment, event names, the page schema — is the profile row
6
+ * in `major-profile.ts`.
7
+ *
8
+ * Each leg OBSERVES and returns findings; it asserts nothing. The scenario file
9
+ * maps findings to requirement ids, and `trigger-delivery-witness.test.ts`
10
+ * proves every leg against a scratch double with one defect turned on.
11
+ *
12
+ * There is no seam path here. At major 2 no seam drives a trigger delivery, so
13
+ * a leg whose only surface is a seam is `inapplicable` (no party can cause the
14
+ * condition), never `blocked`.
15
+ *
16
+ * Today only `v2-trigger-bridge-delivery` and `v2-trigger-dead-letter-read` use
17
+ * it; the major-1 files keep their own inline legs, which the production hosts
18
+ * have already witnessed.
19
+ */
20
+
21
+ import { driver } from './driver.js';
22
+ import { readErrorCode } from './error-envelope.js';
23
+ import type { MajorProfile } from './major-profile.js';
24
+ import { freshWebhookId, standardWebhooksSignature } from './triggerBridge.js';
25
+ import { freshCanary, judge as judgeDeadLetters, type DeadLetterRead } from './trigger-dead-letter-witness.js';
26
+
27
+ export interface TriggerFinding {
28
+ readonly ok: boolean;
29
+ readonly doc: string;
30
+ readonly message: string;
31
+ }
32
+ export type LegOutcome =
33
+ | { readonly kind: 'skip'; readonly disposition: 'inapplicable' | 'blocked'; readonly reason: string }
34
+ | { readonly kind: 'observed'; readonly findings: readonly TriggerFinding[]; readonly note?: string };
35
+
36
+ const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
37
+ const f = (ok: boolean, doc: string, message: string): TriggerFinding => ({ ok, doc, message });
38
+
39
+ /** What the host advertises for the trigger bridge at this major. */
40
+ export interface TriggerAdverts {
41
+ readonly family: Record<string, unknown> | null;
42
+ readonly inboundSigning: boolean;
43
+ readonly deadLetter: { readonly retentionDays: number; readonly maxPageSize: number } | null;
44
+ }
45
+
46
+ export function adverts(profile: MajorProfile, doc: unknown): TriggerAdverts {
47
+ const family = profile.family(doc, 'triggerBridge');
48
+ const ingestion = isRecord(family?.['ingestion']) ? family!['ingestion'] as Record<string, unknown> : undefined;
49
+ const signing = ingestion?.['inboundSigning'];
50
+ const dl = isRecord(family?.['deadLetter']) ? family!['deadLetter'] as Record<string, unknown> : undefined;
51
+ return {
52
+ family,
53
+ inboundSigning: Array.isArray(signing) && signing.includes('standard-webhooks-1'),
54
+ deadLetter: dl !== undefined && typeof dl['retentionDays'] === 'number' && typeof dl['maxPageSize'] === 'number'
55
+ ? { retentionDays: dl['retentionDays'] as number, maxPageSize: dl['maxPageSize'] as number }
56
+ : null,
57
+ };
58
+ }
59
+
60
+ /** The gate every signed-path leg shares. */
61
+ export function signedGate(a: TriggerAdverts): LegOutcome | null {
62
+ if (a.family === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise triggerBridge' };
63
+ if (!a.inboundSigning) return { kind: 'skip', disposition: 'inapplicable', reason: 'triggerBridge.ingestion.inboundSigning does not list standard-webhooks-1, and no other wire surface delivers a trigger event at this major' };
64
+ return null;
65
+ }
66
+
67
+ export interface Sub { readonly subscriptionId: string; readonly ingestUrl: string; readonly signingSecret: string }
68
+
69
+ async function register(profile: MajorProfile, mode: 'none' | 'required'): Promise<Sub | string> {
70
+ const res = await driver.post(profile.triggerSubscriptionsPath, { source: 'webhook', workflowId: 'conformance-noop', verification: { mode } });
71
+ const body = res.json as { subscription?: { subscriptionId?: unknown }; binding?: { ingestUrl?: unknown; signingSecret?: unknown } } | undefined;
72
+ const subscriptionId = body?.subscription?.subscriptionId;
73
+ const ingestUrl = body?.binding?.ingestUrl;
74
+ const signingSecret = body?.binding?.signingSecret;
75
+ if (res.status !== 201 || typeof subscriptionId !== 'string' || typeof ingestUrl !== 'string' || typeof signingSecret !== 'string') {
76
+ return `POST ${profile.triggerSubscriptionsPath} answered ${res.status} without the binding (subscriptionId, ingestUrl, signingSecret)`;
77
+ }
78
+ return { subscriptionId, ingestUrl, signingSecret };
79
+ }
80
+
81
+ async function ingest(sub: Sub, body: string, opts: { webhookId?: string; signature?: string } = {}): Promise<{ status: number; json: Record<string, unknown> | undefined }> {
82
+ const id = opts.webhookId ?? freshWebhookId();
83
+ const ts = String(Math.floor(Date.now() / 1000));
84
+ const sig = opts.signature ?? standardWebhooksSignature(sub.signingSecret, id, ts, body);
85
+ const res = await driver.post(sub.ingestUrl, body, {
86
+ authenticated: false,
87
+ headers: { 'Content-Type': 'application/json', 'webhook-id': id, 'webhook-timestamp': ts, 'webhook-signature': sig },
88
+ });
89
+ return { status: res.status, json: isRecord(res.json) ? res.json : undefined };
90
+ }
91
+
92
+ export const BAD_SIGNATURE = 'v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
93
+ const DOC_INGEST = 'webhooks.md §Inbound triggers';
94
+
95
+ /** A registration that did not yield the binding is a host defect on an advertised surface. */
96
+ function bindingFinding(reason: string): LegOutcome {
97
+ return { kind: 'observed', findings: [f(false, DOC_INGEST, `a host advertising inboundSigning MUST return the binding on webhook registration: ${reason}`)] };
98
+ }
99
+
100
+ /** Leg 1: a repeated `webhook-id` is effectively-once. */
101
+ export async function dedupLeg(profile: MajorProfile, a: TriggerAdverts): Promise<LegOutcome> {
102
+ const gate = signedGate(a); if (gate) return gate;
103
+ const sub = await register(profile, 'none'); if (typeof sub === 'string') return bindingFinding(sub);
104
+ const body = JSON.stringify({ conformance: 'dedup' });
105
+ const webhookId = freshWebhookId();
106
+ const first = await ingest(sub, body, { webhookId });
107
+ const runId = first.json?.['runId'];
108
+ const again = await ingest(sub, body, { webhookId });
109
+ return { kind: 'observed', findings: [
110
+ f(first.status === 202, DOC_INGEST, `a signed delivery MUST answer 202 (got ${first.status})`),
111
+ f(typeof runId === 'string' && runId.length > 0, DOC_INGEST, 'a 202 MUST carry the started runId'),
112
+ f(again.status === 200, DOC_INGEST, `a repeated webhook-id MUST answer 200, starting no new run (got ${again.status})`),
113
+ f(again.json?.['outcome'] === 'duplicate', DOC_INGEST, `the dedup no-op MUST say outcome "duplicate" (got ${String(again.json?.['outcome'])})`),
114
+ f(again.json?.['runId'] === runId, DOC_INGEST, 'a dedup no-op MUST return the prior runId'),
115
+ ] };
116
+ }
117
+
118
+ /** Leg 2: a refused post starts no run and does not change the subscription's state. */
119
+ export async function refusedLeg(profile: MajorProfile, a: TriggerAdverts): Promise<LegOutcome> {
120
+ const gate = signedGate(a); if (gate) return gate;
121
+ const sub = await register(profile, 'required'); if (typeof sub === 'string') return bindingFinding(sub);
122
+ const body = JSON.stringify({ conformance: 'dead-letter' });
123
+ const bad = await ingest(sub, body, { signature: BAD_SIGNATURE });
124
+ const read = await driver.get(`${profile.triggerSubscriptionsPath}/${profile.idSegment(sub.subscriptionId)}`);
125
+ const state = (read.json as { subscription?: { state?: unknown } } | undefined)?.subscription?.state;
126
+ const ok = await ingest(sub, body);
127
+ return { kind: 'observed', findings: [
128
+ f(bad.status === 401, DOC_INGEST, `a bad signature under required verification MUST answer 401 (got ${bad.status})`),
129
+ f(readErrorCode(bad.json) === 'signature_invalid', DOC_INGEST, `the refusal MUST carry signature_invalid (got ${String(readErrorCode(bad.json))})`),
130
+ f(bad.json?.['runId'] === undefined, DOC_INGEST, 'a failed required check MUST NOT start a run'),
131
+ f(state === 'active', DOC_INGEST, `a refused event MUST NOT change the subscription's state (read answered ${read.status}, state ${String(state)})`),
132
+ f(ok.status === 202, DOC_INGEST, `after a refused post the subscription MUST still deliver a correctly signed one (got ${ok.status})`),
133
+ ] };
134
+ }
135
+
136
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
137
+
138
+ /** Leg 3: the delivered run's `run.started` carries the delivery as `causationId`. */
139
+ export async function causationLeg(profile: MajorProfile, a: TriggerAdverts, opts: { pollMs?: number; pollTries?: number } = {}): Promise<LegOutcome> {
140
+ const gate = signedGate(a); if (gate) return gate;
141
+ const sub = await register(profile, 'none'); if (typeof sub === 'string') return bindingFinding(sub);
142
+ const res = await ingest(sub, JSON.stringify({ conformance: 'causation' }));
143
+ const runId = res.json?.['runId'];
144
+ if (res.status !== 202 || typeof runId !== 'string' || runId.length === 0) {
145
+ return { kind: 'observed', findings: [f(false, DOC_INGEST, `a signed delivery MUST answer 202 with the started runId (got ${res.status} ${JSON.stringify(res.json ?? null).slice(0, 200)})`)] };
146
+ }
147
+ const startedType = profile.eventType('run.started');
148
+ const attemptType = profile.eventType('trigger.delivery.attempted');
149
+ if (startedType === undefined || attemptType === undefined) return { kind: 'skip', disposition: 'blocked', reason: 'the event codemap is not on disk in this layout, so this major\'s event names are unknown' };
150
+ let events: Array<Record<string, unknown>> = [];
151
+ let pollStatus = 0;
152
+ for (let i = 0; i < (opts.pollTries ?? 10); i++) {
153
+ const poll = await driver.get(`${profile.runsPath}/${profile.idSegment(runId)}/events/poll`);
154
+ pollStatus = poll.status;
155
+ events = ((poll.json as { events?: unknown } | undefined)?.events as Array<Record<string, unknown>> | undefined) ?? [];
156
+ if (events.some((e) => e['type'] === startedType)) break;
157
+ await sleep(opts.pollMs ?? 500);
158
+ }
159
+ const started = events.find((e) => e['type'] === startedType);
160
+ const causationId = started?.['causationId'];
161
+ const delivered = events.find((e) => e['type'] === attemptType && isRecord(e['payload']) && (e['payload'] as Record<string, unknown>)['outcome'] === 'delivered');
162
+ const findings = [
163
+ f(started !== undefined, 'webhooks.md §Inbound triggers', `a delivered run MUST emit ${startedType} (poll answered ${pollStatus}; saw ${events.map((e) => String(e['type'])).join(',') || 'none'})`),
164
+ f(typeof causationId === 'string' && causationId.length > 0, 'webhooks.md §Inbound triggers', `${startedType} MUST carry the delivery id as causationId`),
165
+ ];
166
+ if (delivered !== undefined) findings.push(f(causationId === delivered['eventId'], 'webhooks.md §Inbound triggers', `${startedType}.causationId MUST equal the delivered attempt's eventId`));
167
+ return { kind: 'observed', findings, note: delivered === undefined ? 'the delivered attempt event is not on the run log, so equality was not checked' : 'causationId equals the delivered attempt' };
168
+ }
169
+
170
+ /** GET one page of a subscription's dead letters at this major. Asserts nothing. */
171
+ export async function readDeadLettersAt(profile: MajorProfile, subscriptionId: string, query: { limit?: number; cursor?: string } = {}, headers?: Record<string, string>): Promise<DeadLetterRead> {
172
+ const qs = new URLSearchParams();
173
+ if (query.limit !== undefined) qs.set('limit', String(query.limit));
174
+ if (query.cursor !== undefined) qs.set('cursor', query.cursor);
175
+ const suffix = qs.toString() === '' ? '' : `?${qs.toString()}`;
176
+ const res = await driver.get(`${profile.triggerSubscriptionsPath}/${profile.idSegment(subscriptionId)}/dead-letters${suffix}`, headers ? { headers } : {});
177
+ return { status: res.status, json: res.json };
178
+ }
179
+
180
+ /** Leg 4a: a dead-lettered (run-less) attempt is content-free, read through RFC 0232's read. */
181
+ export async function runlessAttemptLeg(profile: MajorProfile, a: TriggerAdverts, validate: (page: unknown) => { ok: boolean; errors: string }): Promise<LegOutcome> {
182
+ const gate = signedGate(a); if (gate) return gate;
183
+ if (a.deadLetter === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'a dead-lettered attempt is on no run\'s log, and the host does not advertise triggerBridge.deadLetter, the only surface that reads it at this major' };
184
+ const sub = await register(profile, 'required'); if (typeof sub === 'string') return bindingFinding(sub);
185
+ const canary = freshCanary();
186
+ const bad = await ingest(sub, JSON.stringify({ conformance: 'runless-attempt', canary }), { signature: BAD_SIGNATURE });
187
+ const read = await readDeadLettersAt(profile, sub.subscriptionId);
188
+ const findings = [f(bad.status === 401, DOC_INGEST, `a bad signature under required verification MUST answer 401 (got ${bad.status})`)];
189
+ for (const j of judgeDeadLetters(read, { subscriptionId: sub.subscriptionId, canary, signature: BAD_SIGNATURE, signingSecret: sub.signingSecret, retentionDays: a.deadLetter.retentionDays }, validate)) {
190
+ findings.push(f(j.ok, DOC_INGEST, j.message));
191
+ }
192
+ return { kind: 'observed', findings, note: 'RFC 0232 dead-letter read' };
193
+ }
194
+
195
+ /** Leg 4b: a run-less state change. No surface causes one without a seam, and no seam exists at this major. */
196
+ export function runlessStateChangeLeg(a: TriggerAdverts): LegOutcome {
197
+ if (a.family === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise triggerBridge' };
198
+ return { kind: 'skip', disposition: 'inapplicable', reason: 'no wire surface causes a trigger subscription state change, and no seam drives one at this major (RFC 0232 §E, gap G2)' };
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // The read's own rules (RFC 0232 §B).
203
+ // ---------------------------------------------------------------------------
204
+
205
+ function readGate(a: TriggerAdverts): LegOutcome | null {
206
+ if (a.family === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise triggerBridge' };
207
+ if (a.deadLetter === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise triggerBridge.deadLetter' };
208
+ if (!a.inboundSigning) return { kind: 'skip', disposition: 'inapplicable', reason: 'triggerBridge.deadLetter is advertised without inboundSigning, so the suite cannot cause a dead letter unaided' };
209
+ return null;
210
+ }
211
+
212
+ async function refusedTwice(profile: MajorProfile, n: number): Promise<Sub | string> {
213
+ const sub = await register(profile, 'required'); if (typeof sub === 'string') return sub;
214
+ for (let i = 0; i < n; i++) await ingest(sub, JSON.stringify({ conformance: 'dead-letter-read', i }), { signature: BAD_SIGNATURE });
215
+ return sub;
216
+ }
217
+ const deliveriesOf = (json: unknown): unknown[] => { const d = (json as { deliveries?: unknown } | undefined)?.deliveries; return Array.isArray(d) ? d : []; };
218
+ const cursorOf = (json: unknown): unknown => (json as { nextCursor?: unknown } | undefined)?.nextCursor;
219
+
220
+ export async function pagingLeg(profile: MajorProfile, a: TriggerAdverts): Promise<LegOutcome> {
221
+ const gate = readGate(a); if (gate) return gate;
222
+ const sub = await refusedTwice(profile, 2); if (typeof sub === 'string') return bindingFinding(sub);
223
+ const first = await readDeadLettersAt(profile, sub.subscriptionId, { limit: 1 });
224
+ const cursor = cursorOf(first.json);
225
+ const second = typeof cursor === 'string' ? await readDeadLettersAt(profile, sub.subscriptionId, { limit: 1, cursor }) : undefined;
226
+ const big = await readDeadLettersAt(profile, sub.subscriptionId, { limit: a.deadLetter!.maxPageSize + 1 });
227
+ return { kind: 'observed', findings: [
228
+ f(first.status === 200, DOC_INGEST, `a host advertising triggerBridge.deadLetter MUST serve the read (got ${first.status})`),
229
+ f(deliveriesOf(first.json).length === 1, DOC_INGEST, `a page MUST NOT exceed limit (got ${deliveriesOf(first.json).length} for limit 1)`),
230
+ f(typeof cursor === 'string' && cursor.length > 0, DOC_INGEST, 'with two dead letters and limit 1, the page MUST carry nextCursor'),
231
+ f(second?.status === 200 && deliveriesOf(second.json).length === 1, DOC_INGEST, `the page's own cursor MUST continue to the second dead letter (got ${String(second?.status)})`),
232
+ f(big.status === 200, DOC_INGEST, `a limit above maxPageSize MUST be clamped, not refused (got ${big.status})`),
233
+ ] };
234
+ }
235
+
236
+ export async function cursorLeg(profile: MajorProfile, a: TriggerAdverts): Promise<LegOutcome> {
237
+ const gate = readGate(a); if (gate) return gate;
238
+ const subA = await refusedTwice(profile, 2); if (typeof subA === 'string') return bindingFinding(subA);
239
+ const page = await readDeadLettersAt(profile, subA.subscriptionId, { limit: 1 });
240
+ const cursor = cursorOf(page.json);
241
+ if (typeof cursor !== 'string' || cursor.length === 0) {
242
+ return { kind: 'observed', findings: [f(false, DOC_INGEST, `with two dead letters and limit 1, the page MUST carry nextCursor (read answered ${page.status})`)] };
243
+ }
244
+ const subB = await refusedTwice(profile, 1); if (typeof subB === 'string') return bindingFinding(subB);
245
+ const cross = await readDeadLettersAt(profile, subB.subscriptionId, { cursor });
246
+ return { kind: 'observed', findings: [
247
+ f(cross.status === 400, DOC_INGEST, `a cursor minted for another subscription MUST be refused 400 (got ${cross.status})`),
248
+ f(readErrorCode(cross.json) === 'validation_error', DOC_INGEST, `the refusal MUST carry validation_error (got ${String(readErrorCode(cross.json))})`),
249
+ ] };
250
+ }
251
+
252
+ export async function tenantLeg(profile: MajorProfile, a: TriggerAdverts, otherTenantKey: string | undefined, ownKey: string): Promise<LegOutcome> {
253
+ const gate = readGate(a); if (gate) return gate;
254
+ if (!otherTenantKey) return { kind: 'skip', disposition: 'blocked', reason: 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-tenant read cannot run' };
255
+ if (otherTenantKey.trim() === ownKey) return { kind: 'skip', disposition: 'blocked', reason: 'OPENWOP_TEST_TENANT_B_API_KEY equals OPENWOP_API_KEY — the second credential must resolve to a DIFFERENT tenant, or the leg measures nothing' };
256
+ const sub = await refusedTwice(profile, 1); if (typeof sub === 'string') return bindingFinding(sub);
257
+ const foreign = await readDeadLettersAt(profile, sub.subscriptionId, {}, { Authorization: `Bearer ${otherTenantKey}` });
258
+ const unknown = await readDeadLettersAt(profile, `${sub.subscriptionId}-never-minted`);
259
+ const noDisclosure = f(!JSON.stringify(foreign.json ?? null).includes('verification_failed'), DOC_INGEST, 'a foreign read MUST NOT disclose a record');
260
+ const unknownFinding = f(unknown.status === 404, DOC_INGEST, `an id the host never minted MUST answer 404 (got ${unknown.status})`);
261
+ if (profile.foreignTenantRead === 'id_tenant_mismatch') {
262
+ // identity.md §5: a bound id whose tenant segment is not the caller's is
263
+ // refused 403 id_tenant_mismatch, before any lookup, so it discloses nothing.
264
+ return { kind: 'observed', findings: [
265
+ f(foreign.status === 403, 'identity.md §5', `another tenant's bound id MUST be refused 403 (got ${foreign.status})`),
266
+ f(readErrorCode(foreign.json) === 'id_tenant_mismatch', 'identity.md §5', `the refusal MUST carry id_tenant_mismatch (got ${String(readErrorCode(foreign.json))})`),
267
+ noDisclosure,
268
+ unknownFinding,
269
+ ] };
270
+ }
271
+ return { kind: 'observed', findings: [
272
+ f(foreign.status === 404, DOC_INGEST, `another tenant's read MUST answer 404 (got ${foreign.status})`),
273
+ noDisclosure,
274
+ unknownFinding,
275
+ f(readErrorCode(foreign.json) === readErrorCode(unknown.json), DOC_INGEST, 'a foreign id MUST be answered exactly as an unknown one'),
276
+ ] };
277
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * A scratch double for the trigger bridge's normative surface at major 2: the
3
+ * signed ingest (RFC 0230) and the dead-letter read (RFC 0232). Each self-test
4
+ * turns on ONE defect to show `trigger-delivery-witness.ts` convicts it, and a
5
+ * standalone instance lets the scenario files themselves be run against it.
6
+ *
7
+ * A test double, not a host: it executes nothing, and nothing it does is
8
+ * evidence about any implementation.
9
+ */
10
+
11
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
12
+ import type { AddressInfo } from 'node:net';
13
+ import { unprojectBoundId } from './bound-id.js';
14
+ import { standardWebhooksSignature } from './triggerBridge.js';
15
+
16
+ /** The credential the double treats as a second tenant. */
17
+ export const OTHER_TENANT_KEY = 'tenant-b-key';
18
+
19
+ export type TriggerDefect =
20
+ | 'none' | 'dedup-new-run' | 'refused-starts-run' | 'refused-changes-state' | 'no-causation'
21
+ | 'record-leaks-canary' | 'refused-not-recorded' | 'refused-records-state-change' | 'wrong-retention'
22
+ | 'limit-ignored' | 'cursor-not-bound' | 'tenant-leak' | 'tenant-404';
23
+
24
+ interface DoubleSub { id: string; n: number; mode: string; secret: string; state: string; seen: Map<string, string>; dead: Array<Record<string, unknown>> }
25
+
26
+ export class TriggerDouble {
27
+ defect: TriggerDefect = 'none';
28
+ url = '';
29
+ private server: Server | undefined;
30
+ private subs = new Map<string, DoubleSub>();
31
+ private runs = new Map<string, Array<Record<string, unknown>>>();
32
+ private seq = 0;
33
+ discovery: Record<string, unknown> = { triggerBridge: { status: 'experimental', since: '2.45', witness: 'self', ingestion: { inboundSigning: ['standard-webhooks-1'] }, deadLetter: { retentionDays: 7, maxPageSize: 100 } } };
34
+
35
+ async start(): Promise<string> {
36
+ this.server = createServer((req, res) => { void this.handle(req, res); });
37
+ await new Promise<void>((r) => this.server!.listen(0, '127.0.0.1', r));
38
+ this.url = `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`;
39
+ return this.url;
40
+ }
41
+ async stop(): Promise<void> { const s = this.server; if (s) { s.closeAllConnections(); await new Promise<void>((r) => s.close(() => r())); } }
42
+ reset(defect: TriggerDefect): void { this.defect = defect; this.subs.clear(); this.runs.clear(); }
43
+
44
+ private id(prefix: string): string { return `acme/${prefix}-${String(++this.seq).padStart(16, '0')}`; }
45
+ private send(res: ServerResponse, status: number, body: unknown): void { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); }
46
+ private async raw(req: IncomingMessage): Promise<string> { const c: Buffer[] = []; for await (const x of req) c.push(x as Buffer); return Buffer.concat(c).toString('utf8'); }
47
+
48
+ private startRun(sub: DoubleSub): string {
49
+ const runId = this.id('run');
50
+ const attemptId = `evt${String(++this.seq).padStart(16, '0')}`;
51
+ this.runs.set(runId, [
52
+ { eventId: attemptId, type: 'trigger.delivery-attempted', payload: { subscriptionId: sub.id, dedupKey: 'opaque', attempt: 1, outcome: 'delivered', runId }, sequence: 1 },
53
+ { eventId: `evt${String(++this.seq).padStart(16, '0')}`, type: 'run.started', ...(this.defect === 'no-causation' ? {} : { causationId: attemptId }), payload: {}, sequence: 2 },
54
+ ]);
55
+ return runId;
56
+ }
57
+
58
+ private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
59
+ const u = new URL(req.url ?? '/', 'http://x');
60
+ const path = u.pathname;
61
+ if (req.method === 'GET' && path === '/.well-known/openwop') return this.send(res, 200, this.discovery);
62
+
63
+ if (req.method === 'POST' && path === '/trigger-subscriptions') {
64
+ const body = JSON.parse(await this.raw(req) || '{}') as { verification?: { mode?: string } };
65
+ const n = this.subs.size + 1;
66
+ const sub: DoubleSub = { id: this.id('sub'), n, mode: body.verification?.mode ?? 'none', secret: `whsec_${Buffer.from(`secret-${n}-bytes`).toString('base64')}`, state: 'active', seen: new Map(), dead: [] };
67
+ this.subs.set(sub.id, sub);
68
+ return this.send(res, 201, { subscription: { subscriptionId: sub.id, source: 'webhook', state: 'active' }, binding: { ingestUrl: `${this.url}/ingest/${n}`, signingSecret: sub.secret } });
69
+ }
70
+
71
+ const ing = /^\/ingest\/(\d+)$/.exec(path);
72
+ if (req.method === 'POST' && ing) {
73
+ const sub = [...this.subs.values()].find((s) => s.n === Number(ing[1]));
74
+ if (!sub) return this.send(res, 404, { error: 'not_found', message: 'no ingest' });
75
+ const body = await this.raw(req);
76
+ const id = String(req.headers['webhook-id'] ?? ''); const ts = String(req.headers['webhook-timestamp'] ?? '');
77
+ const good = req.headers['webhook-signature'] === standardWebhooksSignature(sub.secret, id, ts, body);
78
+ if (!good && sub.mode === 'required') {
79
+ if (this.defect !== 'refused-not-recorded') {
80
+ const at = Date.now();
81
+ sub.dead.unshift({
82
+ subscriptionId: sub.id, attemptEventId: `evt${String(++this.seq).padStart(16, '0')}`,
83
+ attempt: { subscriptionId: sub.id, dedupKey: 'opaque', attempt: 1, outcome: 'dead-lettered' },
84
+ reason: 'verification_failed',
85
+ deadLetteredAt: new Date(at).toISOString(),
86
+ expiresAt: new Date(at + (this.defect === 'wrong-retention' ? 1 : 7) * 86_400_000).toISOString(),
87
+ ...(this.defect === 'record-leaks-canary' ? { body } : {}),
88
+ ...(this.defect === 'refused-records-state-change' ? { stateChange: { subscriptionId: sub.id, source: 'webhook', fromState: 'active', toState: 'failed', reason: 'signature-invalid' } } : {}),
89
+ });
90
+ }
91
+ if (this.defect === 'refused-changes-state') sub.state = 'failed';
92
+ return this.send(res, 401, { error: 'signature_invalid', message: 'bad signature', ...(this.defect === 'refused-starts-run' ? { runId: this.startRun(sub) } : {}) });
93
+ }
94
+ const prior = sub.seen.get(id);
95
+ if (prior !== undefined && this.defect !== 'dedup-new-run') return this.send(res, 200, { outcome: 'duplicate', runId: prior });
96
+ const runId = this.startRun(sub);
97
+ sub.seen.set(id, runId);
98
+ return this.send(res, 202, { outcome: 'delivered', runId });
99
+ }
100
+
101
+ const poll = /^\/runs\/([^/]+)\/events\/poll$/.exec(path);
102
+ if (req.method === 'GET' && poll) {
103
+ const events = this.runs.get(unprojectBoundId(poll[1]!));
104
+ return events ? this.send(res, 200, { events }) : this.send(res, 404, { error: 'not_found', message: 'no run' });
105
+ }
106
+
107
+ const m = /^\/trigger-subscriptions\/([^/]+)(\/dead-letters)?$/.exec(path);
108
+ if (req.method === 'GET' && m) {
109
+ const auth = String(req.headers['authorization'] ?? '');
110
+ const sub = this.subs.get(unprojectBoundId(m[1]!));
111
+ // identity.md §5: tenant B reading acme's bound id is refused 403 before any lookup.
112
+ if (auth === `Bearer ${OTHER_TENANT_KEY}` && this.defect === 'tenant-404') return this.send(res, 404, { error: 'not_found', message: 'no such subscription' });
113
+ if (auth === `Bearer ${OTHER_TENANT_KEY}` && this.defect !== 'tenant-leak') return this.send(res, 403, { error: 'id_tenant_mismatch', message: 'tenant segment is not the caller\'s' });
114
+ if (!sub) return this.send(res, 404, { error: 'not_found', message: 'no such subscription' });
115
+ if (!m[2]) return this.send(res, 200, { subscription: { subscriptionId: sub.id, source: 'webhook', state: sub.state } });
116
+ const limit = Math.min(Number(u.searchParams.get('limit') ?? '100'), 100);
117
+ const cursor = u.searchParams.get('cursor');
118
+ let offset = 0;
119
+ if (cursor !== null) {
120
+ const [cid, off] = Buffer.from(cursor, 'base64url').toString('utf8').split('|');
121
+ if (cid !== sub.id && this.defect !== 'cursor-not-bound') return this.send(res, 400, { error: 'validation_error', message: 'cursor is for another subscription' });
122
+ offset = Number(off);
123
+ }
124
+ const size = this.defect === 'limit-ignored' ? sub.dead.length : limit;
125
+ const page = sub.dead.slice(offset, offset + size);
126
+ const next = offset + size < sub.dead.length ? Buffer.from(`${sub.id}|${offset + size}`).toString('base64url') : undefined;
127
+ return this.send(res, 200, { deliveries: page, ...(next ? { nextCursor: next } : {}) });
128
+ }
129
+ return this.send(res, 404, { error: 'not_found', message: `no route ${req.method} ${path}` });
130
+ }
131
+ }