@openwop/openwop-conformance 1.24.0 → 1.25.0

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,235 @@
1
+ /**
2
+ * External-event trigger ingestion (RFC 0099) — `trigger-bridge.md` §F.
3
+ *
4
+ * Verifies the additive external-event ingestion leg of the RFC 0083
5
+ * durable-trigger bridge: the normalized in-run `TriggerEvent` envelope,
6
+ * the `TriggerSubscriptionRegistration` create contract, and the two new
7
+ * SECURITY invariants `trigger-ingestion-ssrf` +
8
+ * `trigger-ingestion-content-redaction`.
9
+ *
10
+ * Two layers:
11
+ *
12
+ * A. Always-on, server-free schema probes:
13
+ * - `trigger-event.schema.json` round-trips a conforming per-source
14
+ * `TriggerEvent` and enforces the §F.1 one-of rule (a
15
+ * `source:"email"` event carrying a `webhook` sub-object fails).
16
+ * - `AttachmentRef` requires a host-internal `ref` and rejects a raw
17
+ * external `url` field (the public test for `trigger-ingestion-ssrf`
18
+ * at the schema layer — the host never hands the run a fetchable URL).
19
+ * - `contentTrust` is the const `"untrusted"`.
20
+ * - the durable `trigger.delivery.attempted` payload carries ONLY the
21
+ * content-free `{subscriptionId,dedupKey,attempt,outcome}` shape (the
22
+ * public test for `trigger-ingestion-content-redaction` — the inbound
23
+ * body has no slot on the durable event).
24
+ * - `trigger-subscription-registration.schema.json` validates + requires
25
+ * `source` + `workflowId`.
26
+ *
27
+ * B. Capability-gated behavioral leg (`triggerBridge.ingestion`, via the
28
+ * `POST /v1/host/sample/trigger-bridge/ingest` seam): a simulated
29
+ * external event starts a run whose `ctx.triggerData` matches the
30
+ * `TriggerEvent` shape and whose `trigger.delivery.attempted` is
31
+ * content-free; an ingestion-path fetch to a private address is refused
32
+ * (`trigger-ingestion-ssrf`); a `webhook.headers.Authorization`
33
+ * passthrough is stripped (`trigger-ingestion-content-redaction`).
34
+ * Soft-skips when the capability is unadvertised or the seam is unwired.
35
+ *
36
+ * @see spec/v1/trigger-bridge.md §F
37
+ * @see SECURITY/invariants.yaml ids trigger-ingestion-ssrf, trigger-ingestion-content-redaction
38
+ * @see RFCS/0099-external-event-trigger-ingestion.md
39
+ */
40
+
41
+ import { describe, it, expect } from 'vitest';
42
+ import { readFileSync } from 'node:fs';
43
+ import { join } from 'node:path';
44
+ import Ajv2020 from 'ajv/dist/2020.js';
45
+ import addFormats from 'ajv-formats';
46
+ import { SCHEMAS_DIR, FIXTURES_DIR } from '../lib/paths.js';
47
+ import { driver } from '../lib/driver.js';
48
+ import { behaviorGate } from '../lib/behavior-gate.js';
49
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
50
+
51
+ const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
52
+
53
+ const EVENT_SCHEMA_PATH = join(SCHEMAS_DIR, 'trigger-event.schema.json');
54
+ const REG_SCHEMA_PATH = join(SCHEMAS_DIR, 'trigger-subscription-registration.schema.json');
55
+ const EVENT_FIXTURE = join(FIXTURES_DIR, 'trigger-events', 'trigger-event-email.json');
56
+ const REG_FIXTURE = join(FIXTURES_DIR, 'trigger-events', 'trigger-subscription-registration-email.json');
57
+
58
+ function buildAjv(): Ajv2020 {
59
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
60
+ addFormats(ajv);
61
+ // The registration schema $refs the trigger-subscription schema for
62
+ // `retryPolicy`; register it so the absolute $ref resolves offline.
63
+ for (const name of ['trigger-subscription.schema.json']) {
64
+ ajv.addSchema(JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')));
65
+ }
66
+ return ajv;
67
+ }
68
+
69
+ describe('trigger-ingestion: TriggerEvent schema (always-on, server-free)', () => {
70
+ const ajv = buildAjv();
71
+ const validate = ajv.compile(JSON.parse(readFileSync(EVENT_SCHEMA_PATH, 'utf8')));
72
+
73
+ it('the canonical email TriggerEvent fixture validates', () => {
74
+ const ev = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
75
+ expect(
76
+ validate(ev),
77
+ `trigger-event.schema.json MUST accept a conforming email TriggerEvent. Errors: ${JSON.stringify(validate.errors)}`,
78
+ ).toBe(true);
79
+ });
80
+
81
+ it('the §F.1 one-of rule holds — a source:"email" event carrying a webhook sub-object fails', () => {
82
+ const ev = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
83
+ ev.webhook = { method: 'POST', body: { x: 1 } };
84
+ expect(
85
+ validate(ev),
86
+ 'trigger-bridge.md §F.1 — a TriggerEvent MUST carry exactly the per-source sub-object matching its `source` and MUST NOT carry the others',
87
+ ).toBe(false);
88
+ });
89
+
90
+ it('contentTrust MUST be the const "untrusted"', () => {
91
+ const ev = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
92
+ ev.contentTrust = 'trusted';
93
+ expect(
94
+ validate(ev),
95
+ 'trigger-bridge.md §F.1 — `contentTrust` MUST be `"untrusted"`',
96
+ ).toBe(false);
97
+ });
98
+
99
+ it('AttachmentRef requires a host-internal `ref` and rejects a raw external `url` (trigger-ingestion-ssrf)', () => {
100
+ // Missing ref → invalid.
101
+ const noRef = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
102
+ noRef.email.attachments = [{ filename: 'x.png' }];
103
+ expect(
104
+ validate(noRef),
105
+ 'SECURITY invariant trigger-ingestion-ssrf — an AttachmentRef MUST carry a host-internal `ref`',
106
+ ).toBe(false);
107
+
108
+ // A raw external `url` the run would fetch itself → invalid
109
+ // (additionalProperties:false structurally forbids it).
110
+ const rawUrl = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
111
+ rawUrl.email.attachments = [{ ref: 'blob_a1', url: 'http://169.254.169.254/latest/meta-data/' }];
112
+ expect(
113
+ validate(rawUrl),
114
+ 'SECURITY invariant trigger-ingestion-ssrf — the host MUST NOT hand the run an external URL to fetch itself; AttachmentRef is a host-internal handle only',
115
+ ).toBe(false);
116
+ });
117
+
118
+ it('a webhook TriggerEvent with an allowlisted header validates', () => {
119
+ const ev = {
120
+ source: 'webhook',
121
+ subscriptionId: 'sub_9',
122
+ deliveryId: 'dlv_c3d4',
123
+ receivedAt: '2026-06-13T18:10:00Z',
124
+ verified: true,
125
+ contentTrust: 'untrusted',
126
+ webhook: { method: 'POST', headers: { 'X-Event-Type': 'issue.created' }, body: { issue: { id: 42 } } },
127
+ };
128
+ expect(
129
+ validate(ev),
130
+ `a conforming webhook TriggerEvent MUST validate. Errors: ${JSON.stringify(validate.errors)}`,
131
+ ).toBe(true);
132
+ });
133
+ });
134
+
135
+ describe('trigger-ingestion: content-freeness of the durable trigger.delivery.attempted payload (trigger-ingestion-content-redaction)', () => {
136
+ // The public, schema-layer proof for `trigger-ingestion-content-redaction`:
137
+ // the durable event payload defines ONLY content-free fields. The inbound
138
+ // body/headers/email/form content has NO slot — it lives only in the in-run
139
+ // TriggerEvent (`ctx.triggerData`), never on the event log.
140
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
141
+ addFormats(ajv);
142
+ const payloads = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'run-event-payloads.schema.json'), 'utf8'));
143
+ const deliveryDef = payloads.$defs?.triggerDeliveryAttempted;
144
+
145
+ it('trigger.delivery.attempted declares only the content-free RFC 0083 §C fields', () => {
146
+ expect(deliveryDef, 'run-event-payloads.schema.json MUST define triggerDeliveryAttempted').toBeDefined();
147
+ const props = Object.keys(deliveryDef.properties ?? {});
148
+ // The complete content-free field set — no `body`, `headers`, `email`,
149
+ // `form`, `fields`, or any inbound-content carrier.
150
+ expect(props.sort()).toEqual(['attempt', 'dedupKey', 'outcome', 'runId', 'subscriptionId'].sort());
151
+ for (const banned of ['body', 'headers', 'email', 'form', 'fields', 'html', 'text', 'subject']) {
152
+ expect(
153
+ props.includes(banned),
154
+ `trigger-ingestion-content-redaction — the durable trigger.delivery.attempted payload MUST NOT carry an inbound-content field ("${banned}")`,
155
+ ).toBe(false);
156
+ }
157
+ });
158
+ });
159
+
160
+ describe('trigger-ingestion: TriggerSubscriptionRegistration schema (always-on, server-free)', () => {
161
+ const ajv = buildAjv();
162
+ const validate = ajv.compile(JSON.parse(readFileSync(REG_SCHEMA_PATH, 'utf8')));
163
+
164
+ it('the canonical registration fixture validates', () => {
165
+ const reg = JSON.parse(readFileSync(REG_FIXTURE, 'utf8'));
166
+ expect(
167
+ validate(reg),
168
+ `trigger-subscription-registration.schema.json MUST accept a conforming registration. Errors: ${JSON.stringify(validate.errors)}`,
169
+ ).toBe(true);
170
+ });
171
+
172
+ it('requires source + workflowId and rejects an out-of-enum source', () => {
173
+ expect(validate({ workflowId: 'w' }), 'registration MUST require `source`').toBe(false);
174
+ expect(validate({ source: 'email' }), 'registration MUST require `workflowId`').toBe(false);
175
+ expect(
176
+ validate({ source: 'schedule', workflowId: 'w' }),
177
+ 'registration `source` MUST be one of webhook/email/form (schedule is not externally registerable)',
178
+ ).toBe(false);
179
+ });
180
+ });
181
+
182
+ describe.skipIf(HTTP_SKIP)('trigger-ingestion: behavioral ingestion + SSRF (capability-gated)', () => {
183
+ it('an ingestion-path fetch to a private address is refused; a delivered event is content-free', async () => {
184
+ const tb = await readCapabilityFamily<{ ingestion?: { externalSources?: string[] } }>('triggerBridge');
185
+ const ingests = Array.isArray(tb?.ingestion?.externalSources) && tb!.ingestion!.externalSources!.length > 0;
186
+ if (!behaviorGate('triggerBridge.ingestion', ingests)) return;
187
+
188
+ // SSRF leg — an attachment whose resolution would hit a private address
189
+ // MUST be refused by the host's safeFetch guard (trigger-ingestion-ssrf).
190
+ const ssrf = await driver.post('/v1/host/sample/trigger-bridge/ingest', {
191
+ source: 'email',
192
+ verification: { mode: 'none' },
193
+ attachmentUrl: 'http://169.254.169.254/latest/meta-data/',
194
+ });
195
+ if (ssrf.status === 404 || ssrf.status === 403) return; // seam unwired — soft-skip
196
+
197
+ const ssrfBody = ssrf.json as { triggerEvent?: { email?: { attachments?: unknown[] } }; ssrfRefused?: boolean } | undefined;
198
+ expect(
199
+ ssrfBody?.ssrfRefused === true ||
200
+ (ssrfBody?.triggerEvent?.email?.attachments ?? []).length === 0,
201
+ driver.describe(
202
+ 'trigger-bridge.md §F.4',
203
+ 'trigger-ingestion-ssrf — an ingestion-path fetch to a private address MUST be refused (attachment dropped), the run still starting on the rest of the event',
204
+ ),
205
+ ).toBe(true);
206
+
207
+ // Content-redaction leg — the durable trigger.delivery.attempted MUST be
208
+ // content-free even when the inbound carried a credential-bearing header.
209
+ const del = await driver.post('/v1/host/sample/trigger-bridge/ingest', {
210
+ source: 'webhook',
211
+ verification: { mode: 'none' },
212
+ webhook: { method: 'POST', headers: { Authorization: 'Bearer canary' }, body: { x: 1 } },
213
+ });
214
+ if (del.status === 404 || del.status === 403) return;
215
+ const delBody = del.json as
216
+ | { deliveryEvent?: Record<string, unknown>; triggerEvent?: { webhook?: { headers?: Record<string, string> } } }
217
+ | undefined;
218
+ const evtJson = JSON.stringify(delBody?.deliveryEvent ?? {});
219
+ expect(
220
+ evtJson.includes('Bearer canary') === false && evtJson.includes('Authorization') === false,
221
+ driver.describe(
222
+ 'trigger-bridge.md §F.4',
223
+ 'trigger-ingestion-content-redaction — the durable trigger.delivery.attempted MUST NOT carry inbound body/header content',
224
+ ),
225
+ ).toBe(true);
226
+ const passedHeaders = delBody?.triggerEvent?.webhook?.headers ?? {};
227
+ expect(
228
+ Object.keys(passedHeaders).every((k) => k.toLowerCase() !== 'authorization'),
229
+ driver.describe(
230
+ 'trigger-bridge.md §F.1',
231
+ 'webhook.headers MUST be a host-curated allowlist — Authorization MUST NOT pass through',
232
+ ),
233
+ ).toBe(true);
234
+ });
235
+ });