@openwop/openwop-conformance 2.45.24 → 2.45.26

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.
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * v2 — host events (RFC 0236; `spec/v2/core/events.md` §Host events,
3
- * `webhooks.md` §Delivery; suite 2.45.20, target major 2; seam-gated).
3
+ * `webhooks.md` §Delivery; suite 2.45.20, target major 2; RFC 0241 trigger in 2.45.26).
4
4
  *
5
5
  * A host event belongs to no run: its envelope has no `runId` and no
6
6
  * `sequence`. It rides `/host/events` beside the heartbeat messages and, when
7
- * durable, webhooks. The suite cannot cause one unaided, so every leg drives
8
- * the §G emit seam with the `example.*` types a seams-profile host advertises.
7
+ * durable, webhooks. The suite causes one through `POST /host/events/test`
8
+ * (RFC 0241) when the host lists the reserved `host-test.*` types, so a
9
+ * seam-free production host witnesses every leg; otherwise through the RFC 0236
10
+ * §G emit seam with the `example.*` types a seams-profile host advertises.
9
11
  *
10
12
  * 0236.host-event.envelope an emitted durable event arrives as a valid
11
13
  * envelope, `event:` = type, `id:` = eventId;
@@ -22,16 +24,22 @@
22
24
  * the host-variant leg observed: a bundle
23
25
  * records one requirement id per `it`);
24
26
  * 0236.host-event.tenant-isolation a second tenant's stream receives nothing
25
- * for the first tenant's event.
27
+ * for the first tenant's event;
28
+ * 0241.trigger.bound-by-listing `emitTestHostEvent` accepts each listed
29
+ * `host-test.*` class and refuses the rest
30
+ * (`404`/`405` when none is listed).
26
31
  *
27
32
  * Judged by `lib/host-event-witness.ts`, whose self-test convicts each defect.
28
33
  *
29
- * Dispositions: discovery unreadable ⇒ `blocked`; seams profile or `hostEvents`
30
- * not advertised ⇒ `inapplicable`; seam unwired, or no `example.*` type of the
31
- * needed class advertised ⇒ `seamAbsent`; no second-tenant credential
34
+ * Dispositions: discovery unreadable ⇒ `blocked`; `hostEvents` not advertised ⇒
35
+ * `inapplicable`; neither a listed `host-test.*` type nor the seams profile ⇒
36
+ * `inapplicable` for every event-causing leg (not `ephemeral-refused`, which causes no event and runs on any advertised
37
+ * ephemeral type, none listed ⇒ `inapplicable`); a listed trigger that fails ⇒ `blocked`; seam unwired, or no `example.*`
38
+ * type of the needed class advertised ⇒ `seamAbsent`; no second-tenant credential
32
39
  * (`OPENWOP_TEST_TENANT_B_API_KEY`) ⇒ `inapplicable` for the tenant leg.
33
40
  *
34
41
  * @see RFCS/0236-host-events.md
42
+ * @see RFCS/0241-host-event-test-trigger.md
35
43
  * @see spec/v2/core/events.md §Host events
36
44
  */
37
45
 
@@ -46,7 +54,7 @@ import { readErrorCode } from '../lib/error-envelope.js';
46
54
  import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedHit, type ScopedReceiver } from '../lib/scoped-receiver.js';
47
55
  import { createReceiverState, verifyWebhookDelivery } from '../lib/webhook-receiver.js';
48
56
  import {
49
- advertisedTypes, exampleType, judgeEnvelope, judgeHostBody, judgeNoFanOut, judgeNoResume, judgeTenantScope, type AdvertisedType, type Finding,
57
+ TEST_TYPES, advertisedTypeOf, advertisedTypes, exampleType, judgeEnvelope, judgeTriggerBinding, testTypeListed, triggerOf, type Trigger, judgeHostBody, judgeNoFanOut, judgeNoResume, judgeTenantScope, type AdvertisedType, type Finding,
50
58
  } from '../lib/host-event-witness.js';
51
59
 
52
60
  export const REQUIRES_HOST_CALLBACK = 'the host POSTs host-event webhook deliveries to the suite-owned scoped receiver behind OPENWOP_WEBHOOK_RECEIVER_URL';
@@ -60,6 +68,8 @@ const ID_HOST_BODY = 'openwop.requirement.0236.webhook.host-variant';
60
68
  const ID_NO_FAN_OUT = 'openwop.requirement.0236.ephemeral.no-fan-out';
61
69
  const ID_TENANT = 'openwop.requirement.0236.host-event.tenant-isolation';
62
70
  const SEAM = seamPath('/v1/host/sample/host-events/emit');
71
+ const TRIGGER = '/host/events/test';
72
+ const ID_BINDING = 'openwop.requirement.0241.trigger.bound-by-listing';
63
73
  const V2 = { 'OpenWOP-Version': '2.0' };
64
74
  const STREAM_MS = 3_000;
65
75
  const SECRET = 'openwop-conformance-host-event-secret-0236';
@@ -77,30 +87,49 @@ const assertAll = (id: string, doc: string, findings: readonly Finding[]): void
77
87
  type Skip = { kind: 'blocked' | 'inapplicable' | 'seam'; reason: string };
78
88
  const skip = (s: Skip): undefined => (s.kind === 'seam' ? seamAbsent(s.reason) : softSkip(s.kind, s.reason));
79
89
 
80
- /** The shared gate: the advertised types, or why the leg cannot run. */
81
- async function gate(): Promise<{ types: AdvertisedType[]; record: Record<string, unknown> } | Skip> {
90
+ /** The family gate: the advertised types, or why the leg cannot run. Needs no seam. */
91
+ async function familyGate(): Promise<{ types: AdvertisedType[]; record: Record<string, unknown>; doc: Record<string, unknown> } | Skip> {
82
92
  let doc: Record<string, unknown> | null;
83
93
  try { doc = await v2Discovery(); } catch { doc = null; }
84
94
  if (!doc) return { kind: 'blocked', reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0' };
85
95
  const record = await familyAdvertised('hostEvents');
86
96
  if (record === null) return { kind: 'inapplicable', reason: 'the host does not advertise hostEvents' };
87
- if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'seams profile not advertised — the suite cannot cause a host event without the RFC 0236 §G emit seam' };
88
- return { types: advertisedTypes(record), record };
97
+ return { types: advertisedTypes(record), record, doc };
89
98
  }
90
99
 
91
- function pick(types: readonly AdvertisedType[], delivery: AdvertisedType['delivery']): AdvertisedType | Skip {
92
- const type = exampleType(types, delivery);
100
+ /**
101
+ * The shared gate for the legs that cause an event: the family gate plus a way to cause one —
102
+ * the RFC 0241 trigger when the host lists a `host-test.*` type, else the RFC 0236 §G seam.
103
+ */
104
+ async function gate(): Promise<{ types: AdvertisedType[]; record: Record<string, unknown>; trigger: Trigger } | Skip> {
105
+ const g = await familyGate();
106
+ if ('kind' in g) return g;
107
+ const trigger = triggerOf(g.types, seamsProfileAdvertised(g.doc));
108
+ if (trigger === null) return { kind: 'inapplicable', reason: 'no way to cause a host event: hostEvents.types lists no host-test.* type (RFC 0241) and the seams profile is not advertised (RFC 0236 §G)' };
109
+ return { types: g.types, record: g.record, trigger };
110
+ }
111
+
112
+ /** The type the suite causes for a class: the reserved test type under the trigger, the `example.*` type under the seam. */
113
+ function pick(g: { types: readonly AdvertisedType[]; trigger: Trigger }, delivery: AdvertisedType['delivery']): AdvertisedType | Skip {
114
+ if (g.trigger === 'normative') {
115
+ const type = testTypeListed(g.types, delivery);
116
+ return type === null ? { kind: 'inapplicable', reason: `hostEvents.types lists no ${TEST_TYPES[delivery]}, so the suite cannot cause a ${delivery} host event (RFC 0241 §B.1)` } : { type, delivery };
117
+ }
118
+ const type = exampleType(g.types, delivery);
93
119
  return type === null
94
120
  ? { kind: 'seam', reason: `no example.* ${delivery} type is advertised in hostEvents.types — RFC 0236 §G: a seams-profile host advertises one for the suite to drive` }
95
121
  : { type, delivery };
96
122
  }
97
123
 
98
- async function emit(type: string): Promise<string | Skip> {
99
- const res = await http(() => driver.post(SEAM, { type }));
100
- if (res === null) return { kind: 'blocked', reason: `${SEAM} unreachable (fetch failed)` };
101
- if (res.status === 404 || res.status === 405) return { kind: 'seam', reason: `${SEAM} not mounted (${res.status}) — RFC 0236 §G` };
124
+ /** Cause one host event of `t` through the gate's trigger; its `eventId`, or why none was produced. */
125
+ async function emit(trigger: Trigger, t: AdvertisedType): Promise<string | Skip> {
126
+ const path = trigger === 'normative' ? TRIGGER : SEAM;
127
+ const body = trigger === 'normative' ? { delivery: t.delivery } : { type: t.type };
128
+ const res = await http(() => driver.post(path, body));
129
+ if (res === null) return { kind: 'blocked', reason: `${path} unreachable (fetch failed)` };
130
+ if (trigger === 'seam' && (res.status === 404 || res.status === 405)) return { kind: 'seam', reason: `${SEAM} not mounted (${res.status}) — RFC 0236 §G` };
102
131
  const eventId = (res.json as { eventId?: unknown } | null)?.eventId;
103
- if (res.status !== 202 || typeof eventId !== 'string') return { kind: 'blocked', reason: `${SEAM} answered ${res.status} ${readErrorCode(res.json) ?? ''} for ${type}, so no event was produced`.trim() };
132
+ if (res.status !== 202 || typeof eventId !== 'string') return { kind: 'blocked', reason: `${path} answered ${res.status} ${readErrorCode(res.json) ?? ''} for ${t.type}, so no event was produced`.trim() };
104
133
  return eventId;
105
134
  }
106
135
 
@@ -127,14 +156,14 @@ afterEach(async () => {
127
156
  if (receiver) { receiver.server.close(); receiver = null; }
128
157
  });
129
158
 
130
- describe('v2 host events (RFC 0236 — seam-gated)', () => {
159
+ describe('v2 host events (RFC 0236; caused through RFC 0241 or the §G seam)', () => {
131
160
  it('an emitted durable host event arrives on /host/events as a valid envelope', async () => {
132
161
  const g = await gate();
133
162
  if ('kind' in g) return skip(g);
134
- const d = pick(g.types, 'durable');
163
+ const d = pick(g, 'durable');
135
164
  if ('kind' in d) return skip(d);
136
165
  let eventId: string | Skip = '';
137
- const frames = await streamWhile(async () => { eventId = await emit(d.type); });
166
+ const frames = await streamWhile(async () => { eventId = await emit(g.trigger, d); });
138
167
  if (typeof eventId !== 'string') return skip(eventId);
139
168
  assertAll(ID_ENVELOPE, DOC, judgeEnvelope(frames, eventId, d, hostEvent));
140
169
  if (await familyAdvertised('channelPresence')) {
@@ -145,13 +174,13 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
145
174
  it('an ephemeral host event has no id: and is not redelivered after Last-Event-ID', async () => {
146
175
  const g = await gate();
147
176
  if ('kind' in g) return skip(g);
148
- const d = pick(g.types, 'durable');
149
- const e = pick(g.types, 'ephemeral');
177
+ const d = pick(g, 'durable');
178
+ const e = pick(g, 'ephemeral');
150
179
  if ('kind' in d) return skip(d);
151
180
  if ('kind' in e) return skip(e);
152
181
  let durableId: string | Skip = '';
153
182
  let ephemeralId: string | Skip = '';
154
- const frames = await streamWhile(async () => { durableId = await emit(d.type); await sleep(150); ephemeralId = await emit(e.type); });
183
+ const frames = await streamWhile(async () => { durableId = await emit(g.trigger, d); await sleep(150); ephemeralId = await emit(g.trigger, e); });
155
184
  if (typeof durableId !== 'string') return skip(durableId);
156
185
  if (typeof ephemeralId !== 'string') return skip(ephemeralId);
157
186
  assertAll(ID_NO_RESUME, DOC, judgeEnvelope(frames, ephemeralId, e, hostEvent));
@@ -160,10 +189,13 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
160
189
  }, 20_000);
161
190
 
162
191
  it('registerWebhook refuses an ephemeral host-event type with 400 validation_error', async () => {
163
- const g = await gate();
192
+ // Causes no event, so it needs no §G seam: any advertised ephemeral type will do (a
193
+ // production host with `channel.presence` witnesses it).
194
+ const g = await familyGate();
164
195
  if ('kind' in g) return skip(g);
165
- const e = pick(g.types, 'ephemeral');
166
- if ('kind' in e) return skip(e);
196
+ const type = advertisedTypeOf(g.types, 'ephemeral');
197
+ if (type === null) return softSkip('inapplicable', 'hostEvents.types lists no ephemeral type, so there is none to refuse');
198
+ const e = { type, delivery: 'ephemeral' as const };
167
199
  const res = await http(() => driver.post('/webhooks', { url: 'https://example.com/openwop-conformance-0236', events: [e.type], secret: SECRET }));
168
200
  if (res === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
169
201
  const id = (res.json as { webhookId?: unknown } | null)?.webhookId;
@@ -177,8 +209,8 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
177
209
  const g = await gate();
178
210
  if ('kind' in g) return skip(g);
179
211
  if ((await familyAdvertised('webhooks')) === null) return softSkip('inapplicable', 'the host does not advertise webhooks');
180
- const d = pick(g.types, 'durable');
181
- const e = pick(g.types, 'ephemeral');
212
+ const d = pick(g, 'durable');
213
+ const e = pick(g, 'ephemeral');
182
214
  if ('kind' in d) return skip(d);
183
215
  if ('kind' in e) return skip(e);
184
216
  const hits: ScopedHit[] = [];
@@ -192,9 +224,9 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
192
224
  const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
193
225
  expect(reg.status, req(ID_HOST_BODY, 'spec/v2/core/webhooks.md §Surfaces', `events[] naming the advertised durable host-event type ${d.type} MUST be accepted; got ${reg.status} ${readErrorCode(reg.json) ?? ''}`)).toBe(201);
194
226
  if (typeof webhookId === 'string') registered.push(webhookId);
195
- const ephemeralId = await emit(e.type);
227
+ const ephemeralId = await emit(g.trigger, e);
196
228
  if (typeof ephemeralId !== 'string') return skip(ephemeralId);
197
- const durableId = await emit(d.type);
229
+ const durableId = await emit(g.trigger, d);
198
230
  if (typeof durableId !== 'string') return skip(durableId);
199
231
  for (let waited = 0; waited < 15_000 && !hits.some((h) => h.body.includes(durableId)); waited += 250) await sleep(250);
200
232
  const hit = hits.find((h) => h.body.includes(durableId));
@@ -213,25 +245,39 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
213
245
  const g = await gate();
214
246
  if ('kind' in g) return skip(g);
215
247
  if ((await familyAdvertised('webhooks')) === null) return softSkip('inapplicable', 'the host does not advertise webhooks');
216
- const d = pick(g.types, 'durable');
217
- const e = pick(g.types, 'ephemeral');
248
+ const d = pick(g, 'durable');
249
+ const e = pick(g, 'ephemeral');
218
250
  if ('kind' in d) return skip(d);
219
251
  if ('kind' in e) return skip(e);
220
252
  if ('skipped' in fanOut) return softSkip('blocked', `no-fan-out reads the deliveries the host-variant leg observed, and ${fanOut.skipped}`);
221
253
  assertAll(ID_NO_FAN_OUT, DOC, [judgeNoFanOut(fanOut.bodies, fanOut.ephemeralId)]);
222
254
  });
223
255
 
256
+ it('emitTestHostEvent is served exactly for the listed host-test classes', async () => {
257
+ // RFC 0241 §B.1–§B.2: listing a host-test type binds the trigger for that class, and only it.
258
+ const g = await familyGate();
259
+ if ('kind' in g) return skip(g);
260
+ const findings: Finding[] = [];
261
+ for (const delivery of ['durable', 'ephemeral'] as const) {
262
+ // Each class: a listed one is accepted with its reserved type, an unlisted one refused.
263
+ const res = await http(() => driver.post(TRIGGER, { delivery }));
264
+ if (res === null) return softSkip('blocked', `${TRIGGER} unreachable (fetch failed)`);
265
+ findings.push(...judgeTriggerBinding(g.types, delivery, res.status, readErrorCode(res.json) ?? undefined, (res.json as { type?: unknown } | null)?.type));
266
+ }
267
+ assertAll(ID_BINDING, 'spec/v2/core/events.md §Host events', findings);
268
+ }, 20_000);
269
+
224
270
  it('a second tenant receives nothing for the first tenant’s host event', async () => {
225
271
  const g = await gate();
226
272
  if ('kind' in g) return skip(g);
227
- const d = pick(g.types, 'durable');
273
+ const d = pick(g, 'durable');
228
274
  if ('kind' in d) return skip(d);
229
275
  const other = process.env['OPENWOP_TEST_TENANT_B_API_KEY']?.trim();
230
276
  if (!other) return softSkip('inapplicable', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-tenant check needs it');
231
277
  if (other === process.env['OPENWOP_API_KEY']?.trim()) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY equals OPENWOP_API_KEY — the second credential must resolve to a different tenant');
232
278
  let eventId: string | Skip = '';
233
279
  const otherStream = streamWhile(async () => undefined, { bearer: other });
234
- const own = await streamWhile(async () => { eventId = await emit(d.type); });
280
+ const own = await streamWhile(async () => { eventId = await emit(g.trigger, d); });
235
281
  const theirs = await otherStream;
236
282
  if (typeof eventId !== 'string') return skip(eventId);
237
283
  assertAll(ID_TENANT, DOC, judgeTenantScope(own, theirs, eventId));
@@ -0,0 +1,35 @@
1
+ /**
2
+ * v2 — a host rejecting at load a WASM pack whose ABI version it does not list
3
+ * (`spec/v2/core/node-pack-runtimes.md` §WASM: "MUST reject at load a pack
4
+ * whose `openwop_abi_version()` is not listed").
5
+ *
6
+ * The v1 twin, `wasm-pack-abi-version-rejection`, runs at major 1 only. Here the
7
+ * operator offers the hand-written fixture pack at
8
+ * `conformance/fixtures/wasm-packs/misbehaving-abi/` (`vendor.openwop.misbehaving-abi`,
9
+ * ABI version 999) to the host's loader and advertises
10
+ * `conformance-wasm-pack-abi-mismatch`. Loading is not a protocol operation, so
11
+ * the leg reads what a rejection makes impossible: the pack in
12
+ * `nodePackRuntimes.wasm.loadedPacks`, and its node completing.
13
+ *
14
+ * The leg lives in `lib/wasm-abi-reject-witness.ts`, proven against a double in
15
+ * `lib/wasm-abi-reject-witness.test.ts`.
16
+ *
17
+ * Dispositions: no `nodePackRuntimes.wasm`, or the fixture not advertised ⇒
18
+ * `inapplicable`; discovery unreachable or a 401/403/429/5xx at creation ⇒ `blocked`.
19
+ *
20
+ * @see spec/v2/core/node-pack-runtimes.md §WASM
21
+ * @see RFCS/0008-wasm-abi.md §H
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest';
25
+ import { softSkip } from '../lib/soft-skip.js';
26
+ import { req } from '../lib/requirement-ids.js';
27
+ import { abiRejectLeg } from '../lib/wasm-abi-reject-witness.js';
28
+
29
+ describe('v2 WASM ABI rejection (node-pack-runtimes.md §WASM)', () => {
30
+ it('a pack whose openwop_abi_version() is not listed is not loaded and its node never runs', async () => {
31
+ const out = await abiRejectLeg();
32
+ if (out.kind === 'skip') return softSkip(out.skip, out.reason);
33
+ expect(out.findings.join('; '), req('openwop.requirement.node-pack-runtimes.wasm-abi-reject', 'spec/v2/core/node-pack-runtimes.md §WASM', 'a host MUST reject at load a pack whose openwop_abi_version() is not listed')).toBe('');
34
+ });
35
+ });