@openwop/openwop-conformance 2.42.7 → 2.42.9

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,124 @@
1
+ /**
2
+ * v2 — `OpenWOP-Client-Version` (suite 2.42.8; RFC 0219;
3
+ * `spec/v2/core/versioning.md` §1.5 "Client precedence and minClientVersion").
4
+ *
5
+ * A client announces the protocol version it implements in
6
+ * `OpenWOP-Client-Version: <major>.<minor>[.<patch>]`. A host that advertises
7
+ * `minClientVersion` MAY refuse a client below it with `426
8
+ * client_version_unsupported`, and MUST NOT refuse anything else on version
9
+ * grounds. Every leg probes `GET /.well-known/openwop` under
10
+ * `OpenWOP-Version: 2.0` (the discovery document is not exempt from refusal).
11
+ *
12
+ * floor-comparison (gated on minClientVersion) `<floor>`, `<floor>.0` and
13
+ * `<floor>.99` are served: equal on major.minor is not
14
+ * below, and the patch never decides.
15
+ * malformed-not-refused (gated on a host that refuses `0.0.1` with 426) the
16
+ * malformed values `not-a-version`, `1`, `01.0` and
17
+ * `1.0-rc.1` are treated as absent and served — never
18
+ * 426, never 400. A host that serves `0.0.1` does not
19
+ * exercise the refusal, so a served malformed value
20
+ * proves nothing there: `inapplicable`.
21
+ * absent-not-refused a header-less request is never 426 (every host); on a
22
+ * host that refuses `0.0.1` with 426, it is also served.
23
+ * no-floor-no-refusal (hosts advertising NO minClientVersion) `0.0.1` is
24
+ * served, not 426. None of today's certified hosts is in
25
+ * this state, so it records `inapplicable` on each.
26
+ *
27
+ * Unwitnessed and recorded in RFC 0219's falsifiability table: a host choosing
28
+ * a major or a representation from the header, and the header used as an
29
+ * authentication or authorization input.
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest';
33
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
34
+ import { v2Discovery } from '../lib/v2.js';
35
+ import { softSkip } from '../lib/soft-skip.js';
36
+ import { req } from '../lib/requirement-ids.js';
37
+
38
+ const DOC = 'spec/v2/core/versioning.md §1.5';
39
+ const PATH = '/.well-known/openwop';
40
+ const FLOOR = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
41
+ /** Below any floor a host can advertise except `0.0`, which nothing is below. */
42
+ const BELOW = '0.0.1';
43
+ const MALFORMED = ['not-a-version', '1', '01.0', '1.0-rc.1'] as const;
44
+
45
+ async function discovery(): Promise<Record<string, unknown> | null> {
46
+ try { return await v2Discovery(); } catch { return null; }
47
+ }
48
+ async function probe(clientVersion: string | null): Promise<OpenWOPResponse | null> {
49
+ const headers: Record<string, string> = { 'OpenWOP-Version': '2.0' };
50
+ if (clientVersion !== null) headers['OpenWOP-Client-Version'] = clientVersion;
51
+ try { return await driver.get(PATH, { authenticated: false, headers }); } catch { return null; }
52
+ }
53
+ const served = (r: OpenWOPResponse): boolean => r.status >= 200 && r.status < 300;
54
+
55
+ /** The advertised floor, or a skip reason. */
56
+ async function advertisedFloor(): Promise<{ floor: string } | { skip: [kind: 'blocked' | 'inapplicable', reason: string] }> {
57
+ const doc = await discovery();
58
+ if (!doc) return { skip: ['blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0'] };
59
+ const floor = doc['minClientVersion'];
60
+ if (floor === undefined) return { skip: ['inapplicable', 'the host does not advertise minClientVersion — the floor legs are gated on a host that sets it'] };
61
+ // The floor's own grammar is v2-min-client-version's row; a floor this leg cannot read compares nothing.
62
+ if (typeof floor !== 'string' || !FLOOR.test(floor)) return { skip: ['inapplicable', `minClientVersion ${JSON.stringify(floor)} is not <major>.<minor> — judged by v2-min-client-version, nothing to compare here`] };
63
+ if (floor === '0.0') return { skip: ['inapplicable', 'minClientVersion is 0.0 — no well-formed client version is below it, so no refusal can be provoked'] };
64
+ return { floor };
65
+ }
66
+
67
+ /** Gate for the legs that need a host which actually refuses a below-floor client. */
68
+ async function refusingHost(): Promise<{ floor: string } | { skip: [kind: 'blocked' | 'inapplicable', reason: string] }> {
69
+ const f = await advertisedFloor();
70
+ if ('skip' in f) return f;
71
+ const control = await probe(BELOW);
72
+ if (control === null) return { skip: ['blocked', `GET ${PATH} with OpenWOP-Client-Version: ${BELOW} unreachable (fetch failed)`] };
73
+ if (served(control)) return { skip: ['inapplicable', `the host advertises minClientVersion ${f.floor} but served a client announcing ${BELOW} (${control.status}) — refusal is a MAY; a served header-less or malformed request distinguishes nothing on a host that refuses nobody`] };
74
+ if (control.status !== 426) return { skip: ['blocked', `the control (${BELOW}, below minClientVersion ${f.floor}) answered ${control.status} — neither served nor 426; v2-min-client-version records that as a failure, and this leg has no refusal to compare against`] };
75
+ return f;
76
+ }
77
+
78
+ describe('v2 OpenWOP-Client-Version (RFC 0219 — versioning.md §1.5)', () => {
79
+ it('a client at the floor is not below it: equal on major.minor, the patch never decides', async () => {
80
+ const f = await advertisedFloor();
81
+ if ('skip' in f) return softSkip(...f.skip);
82
+ for (const v of [f.floor, `${f.floor}.0`, `${f.floor}.99`]) {
83
+ const res = await probe(v);
84
+ if (res === null) return softSkip('blocked', `GET ${PATH} with OpenWOP-Client-Version: ${v} unreachable (fetch failed)`);
85
+ expect(res.status, req('openwop.requirement.0219.floor-comparison', DOC, `a client announcing ${v} is not below minClientVersion ${f.floor} (major and minor compared as integers; the patch never decides) and MUST be served, not refused (got ${res.status})`)).not.toBe(426);
86
+ expect(served(res), req('openwop.requirement.0219.floor-comparison', DOC, `a well-formed OpenWOP-Client-Version at the floor MUST NOT make the request fail — ${v} got ${res.status}`)).toBe(true);
87
+ }
88
+ });
89
+
90
+ it('a malformed OpenWOP-Client-Version is treated as absent: never 426, never 400', async () => {
91
+ const f = await refusingHost();
92
+ if ('skip' in f) return softSkip(...f.skip);
93
+ for (const v of MALFORMED) {
94
+ const res = await probe(v);
95
+ if (res === null) return softSkip('blocked', `GET ${PATH} with OpenWOP-Client-Version: ${v} unreachable (fetch failed)`);
96
+ expect(res.status, req('openwop.requirement.0219.malformed-not-refused', DOC, `a value outside the grammar (${JSON.stringify(v)}) MUST be treated as absent, so it MUST NOT be refused 426 client_version_unsupported (floor ${f.floor})`)).not.toBe(426);
97
+ expect(res.status, req('openwop.requirement.0219.malformed-not-refused', DOC, `a value outside the grammar (${JSON.stringify(v)}) MUST NOT produce a 400`)).not.toBe(400);
98
+ expect(served(res), req('openwop.requirement.0219.malformed-not-refused', DOC, `a malformed value is treated as absent and a header-less request is served — ${JSON.stringify(v)} got ${res.status}`)).toBe(true);
99
+ }
100
+ });
101
+
102
+ it('a request without OpenWOP-Client-Version is served where a below-floor one is refused', async () => {
103
+ // A 426 to a header-less request is a violation on ANY host, floor or not — probe it before the gate,
104
+ // because a host that refuses it also refuses the header-less discovery read the gate depends on.
105
+ const res = await probe(null);
106
+ if (res === null) return softSkip('blocked', `GET ${PATH} without OpenWOP-Client-Version unreachable (fetch failed)`);
107
+ expect(res.status, req('openwop.requirement.0219.absent-not-refused', DOC, 'a host MUST NOT answer 426 client_version_unsupported to a request that carries no OpenWOP-Client-Version')).not.toBe(426);
108
+ const f = await refusingHost();
109
+ // partial-witness-ok: the header-less request was not refused 426 (asserted above, binding on every host);
110
+ // that it is SERVED distinguishes something only on a host that refuses a below-floor client.
111
+ if ('skip' in f) return softSkip(...f.skip);
112
+ expect(served(res), req('openwop.requirement.0219.absent-not-refused', DOC, `a host MUST NOT refuse a request because it omits OpenWOP-Client-Version (got ${res.status})`)).toBe(true);
113
+ });
114
+
115
+ it('a host that advertises no minClientVersion refuses no client on version grounds', async () => {
116
+ const doc = await discovery();
117
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
118
+ if (doc['minClientVersion'] !== undefined) return softSkip('inapplicable', `the host advertises minClientVersion ${JSON.stringify(doc['minClientVersion'])} — this leg is for a host with no floor`);
119
+ const res = await probe(BELOW);
120
+ if (res === null) return softSkip('blocked', `GET ${PATH} with OpenWOP-Client-Version: ${BELOW} unreachable (fetch failed)`);
121
+ expect(res.status, req('openwop.requirement.0219.no-floor-no-refusal', DOC, `a host that advertises no minClientVersion MUST NOT answer 426 client_version_unsupported (a client announcing ${BELOW})`)).not.toBe(426);
122
+ expect(served(res), req('openwop.requirement.0219.no-floor-no-refusal', DOC, `a well-formed OpenWOP-Client-Version MUST NOT make the request fail on a host with no floor (got ${res.status})`)).toBe(true);
123
+ });
124
+ });
@@ -28,9 +28,11 @@ import { readErrorCode } from '../lib/error-envelope.js';
28
28
  import { softSkip } from '../lib/soft-skip.js';
29
29
  import { req } from '../lib/requirement-ids.js';
30
30
  import { BOUND_ID as RUN_ID } from '../lib/bound-id.js';
31
+ import { scaledTimeoutMs } from '../lib/polling.js';
31
32
 
32
33
  const DOC = 'spec/v2/core/identity.md §5';
33
34
  const NOOP_WORKFLOW_ID = 'conformance-noop';
35
+ const EVENTS_DEADLINE_MS = 20_000;
34
36
  const FOREIGN_RUN_ID = 'openwop-conformance-foreign-tenant/foreignopaque0123456789abcdef';
35
37
 
36
38
  async function discovery(): Promise<Record<string, unknown> | null> {
@@ -63,17 +65,27 @@ describe('v2 id-grammar (RFC 0170 §D.1)', () => {
63
65
  it('every id on the run events matches its ids.schema.json kind', async () => {
64
66
  const c = await createRun();
65
67
  if ('reason' in c) return softSkip('blocked', c.reason);
66
- const res = await http(() => driver.get(`/runs/${encodeURIComponent(c.runId)}/events/poll?timeout=1`));
67
- if (res === null || res.status !== 200) return softSkip('blocked', `GET /runs/{runId}/events/poll answered ${res?.status ?? 'no response'}`);
68
- const events = (res.json as { events?: unknown } | undefined)?.events;
69
- if (!Array.isArray(events) || events.length === 0) return softSkip('blocked', 'the poll returned no events for the run just created — nothing to check the eventId / runId / nodeId grammars against');
68
+ // Poll until the run has recorded an event. A single 1 s poll straight after
69
+ // the create raced the host's dispatch: a host that queues the run (Cloud
70
+ // Tasks on MyndHyve) can legitimately have no event yet, and `blocked` is
71
+ // bundle-fatal (2.42.8; MyndHyve cut 3 on 2.42.7). A host that records
72
+ // nothing for a noop run inside the scaled deadline is still `blocked`.
73
+ const deadline = Date.now() + scaledTimeoutMs(EVENTS_DEADLINE_MS);
74
+ let res: OpenWOPResponse | null = null;
75
+ let events: unknown;
76
+ do {
77
+ res = await http(() => driver.get(`/runs/${encodeURIComponent(c.runId)}/events/poll?timeout=5`));
78
+ if (res === null || res.status !== 200) return softSkip('blocked', `GET /runs/{runId}/events/poll answered ${res?.status ?? 'no response'}`);
79
+ events = (res.json as { events?: unknown } | undefined)?.events;
80
+ } while ((!Array.isArray(events) || events.length === 0) && Date.now() < deadline);
81
+ if (!Array.isArray(events) || events.length === 0) return softSkip('blocked', `the poll returned no events for the run just created within ${scaledTimeoutMs(EVENTS_DEADLINE_MS)} ms — nothing to check the eventId / runId / nodeId grammars against`);
70
82
  const validate = v2Validator('run-event');
71
83
  for (const ev of events) {
72
84
  const r = validate(ev);
73
85
  expect(r.ok, req('openwop.requirement.0170.id-grammar.events', DOC, `every RunEventDoc id (eventId, runId, nodeId, causationId) MUST match its kind — event ${String((ev as { eventId?: unknown }).eventId)} (${r.errors})`)).toBe(true);
74
86
  expect((ev as { runId?: unknown }).runId, req('openwop.requirement.0170.id-grammar.events', DOC, 'every event MUST carry the run\'s own runId')).toBe(c.runId);
75
87
  }
76
- });
88
+ }, scaledTimeoutMs(EVENTS_DEADLINE_MS) + 15_000);
77
89
 
78
90
  it('a run id whose tenant segment is not the caller\'s is refused', async () => {
79
91
  if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
@@ -9,16 +9,19 @@
9
9
  * any advertised floor) is sent; when the host refuses, the refusal MUST be the
10
10
  * registered code at its registered status in the closed envelope; a host that
11
11
  * exercises the MAY by serving the request records `inapplicable` with that
12
- * reason. `OpenWOP-Client-Version` is the `OpenWOP-<Name>` header this
13
- * scenario uses to announce the client; it is not yet declared in
14
- * `api/v2/openapi.yaml` / `headers.md`.
12
+ * reason. `OpenWOP-Client-Version` is declared in `api/v2/openapi.yaml` /
13
+ * `headers.md` and specified in versioning.md §1.5 (RFC 0219), so a non-`2xx`,
14
+ * non-`426` answer to a well-formed below-floor value is a refusal in an
15
+ * unregistered form: a failure, not `blocked` (RFC 0219, Unresolved question 4,
16
+ * decided 2026-09-27). The header's other legs (at-floor, absent, malformed, no
17
+ * floor) are `v2-client-version-header`.
15
18
  */
16
19
 
17
20
  import { describe, it, expect } from 'vitest';
18
21
  import { driver, type OpenWOPResponse } from '../lib/driver.js';
19
22
  import { v2Discovery, v2Validator } from '../lib/v2.js';
20
23
  import { readErrorCode } from '../lib/error-envelope.js';
21
- import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
24
+ import { softSkip } from '../lib/soft-skip.js';
22
25
  import { req } from '../lib/requirement-ids.js';
23
26
 
24
27
  const DOC = 'spec/v2/core/versioning.md §1.5';
@@ -40,21 +43,22 @@ describe('v2 min-client-version (RFC 0172 §A.5 — gated on minClientVersion)',
40
43
  expect(typeof floor === 'string' && VERSION.test(floor), req('openwop.requirement.0172.min-client-version', DOC, 'minClientVersion MUST use the <major>.<minor> grammar (axis 15, as axis 1)')).toBe(true);
41
44
  const res = await http(() => driver.get('/.well-known/openwop', { authenticated: false, headers: { 'OpenWOP-Client-Version': '0.0.1' } }));
42
45
  if (res === null) return softSkip('blocked', 'GET /.well-known/openwop with OpenWOP-Client-Version unreachable (fetch failed)');
43
- // unfailable-leg audit wave 2, 2026-09-27: every non-426 answer — including
44
- // a 4xx/5xx that refused the client in an unregistered form — recorded
45
- // `inapplicable` AFTER the grammar assert, i.e. a partial-witness PASS of
46
- // the 426 refusal row. Only a SERVED request (2xx) is the MAY exercised; any
47
- // other non-426 answer is `blocked`. Not asserted as a failure:
48
- // `OpenWOP-Client-Version` is not yet declared in api/v2/openapi.yaml /
49
- // headers.md, so a non-2xx here is not provably a version refusal.
50
- if (res.status !== 426 && (res.status < 200 || res.status >= 300)) {
51
- return blockedDespiteAssertions(`the host advertises minClientVersion ${String(floor)} and answered ${res.status} to a client announcing 0.0.1 — neither served (the MAY) nor refused with the registered 426 client_version_unsupported; the refusal form was not observed`);
52
- }
46
+ // RFC 0219 Q4 (2026-09-27): the header is declared, so a host that answers a
47
+ // well-formed below-floor value with anything but 2xx (the MAY: served) or
48
+ // 426 client_version_unsupported refuses a declared header in an
49
+ // unregistered form. That is a failure. Before RFC 0219 it recorded `blocked`.
50
+ expect(res.status === 426 || (res.status >= 200 && res.status < 300), req('openwop.requirement.0172.min-client-version', DOC, `a client announcing 0.0.1 below minClientVersion ${String(floor)} MUST be served or refused with 426 client_version_unsupported — the host answered ${res.status}`)).toBe(true);
53
51
  // partial-witness-ok: the minClientVersion grammar MUST was asserted above; refusing a
54
52
  // below-floor client is a MAY and this host served it, so the 426 shape binds nothing.
55
53
  if (res.status !== 426) return softSkip('inapplicable', `the host advertises minClientVersion ${String(floor)} but served a client announcing 0.0.1 (${res.status}) — refusal is a MAY; nothing further is observable`);
56
54
  expect(readErrorCode(res.json), req('openwop.requirement.0172.min-client-version', DOC, 'a 426 refusal MUST carry client_version_unsupported')).toBe('client_version_unsupported');
57
55
  const r = v2Validator('error-envelope')(res.json);
58
56
  expect(r.ok, req('openwop.requirement.0172.min-client-version', 'spec/v2/core/errors.md §The envelope', `the refusal MUST be the closed error envelope (${r.errors})`)).toBe(true);
57
+ // RFC 0219 Q5: details.minClientVersion is OPTIONAL; when a host sends it, it names the floor
58
+ // (errors.json registers its grammar, which the envelope validator above already checked).
59
+ const details = (res.json as { details?: { minClientVersion?: unknown } } | null)?.details;
60
+ if (details && details.minClientVersion !== undefined) {
61
+ expect(details.minClientVersion, req('openwop.requirement.0172.min-client-version', DOC, `a refusal's details.minClientVersion MUST name the advertised floor ${String(floor)}`)).toBe(floor);
62
+ }
59
63
  });
60
64
  });
@@ -8,7 +8,8 @@
8
8
  * served `preferredVersion`'s major, so a header-less `GET /.well-known/openwop`
9
9
  * is the `preferredVersion` representation and its `OpenWOP-Version` response
10
10
  * header names that major. The header-less fetch bypasses the driver (which
11
- * stamps `OpenWOP-Version: 2.0` under target major 2).
11
+ * stamps `OpenWOP-Version: 2.0` under target major 2). Any OTHER unversioned path
12
+ * is the v2 surface (§1.2), so a header-less request there is served major 2.
12
13
  */
13
14
 
14
15
  import { describe, it, expect } from 'vitest';
@@ -67,6 +68,23 @@ describe('v2 preferred-version-default (RFC 0172 §A.1, §A.3)', () => {
67
68
  expect(Array.isArray(body?.['protocolVersions']) && typeof body?.['preferredVersion'] === 'string', req('openwop.requirement.0172.preferred-version-default.headerless', 'spec/v2/core/capabilities.md §1', 'the v1 representation MUST carry protocolVersions[] and preferredVersion additively so a single fetch names the other major')).toBe(true);
68
69
  }
69
70
  });
71
+ it('a header-less request on any other unversioned path is served major 2', async () => {
72
+ const doc = await discovery();
73
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
74
+ // versioning.md §1.2/§1.3: an unversioned operation path is the v2 surface, so the
75
+ // header-less default (preferredVersion) applies to /.well-known/openwop only. Any
76
+ // answer carries OpenWOP-Version (§1.4), so an unknown run id is enough to read it.
77
+ const path = `/runs/openwop-conformance~2F${'h'.repeat(22)}`;
78
+ let version: string | null = null;
79
+ try {
80
+ const res = await fetch(`${loadEnv().baseUrl}${path}`, { headers: { Accept: 'application/json', Authorization: `Bearer ${loadEnv().apiKey}` } });
81
+ await res.text();
82
+ version = res.headers.get('openwop-version');
83
+ } catch {
84
+ return softSkip('blocked', `header-less GET ${path} unreachable (fetch failed)`);
85
+ }
86
+ expect(version?.trim().split('.')[0], req('openwop.requirement.0172.preferred-version-default.unversioned-is-v2', 'spec/v2/core/versioning.md §1.2, §1.3', `a header-less request on an unversioned operation path MUST be served major 2 and say so in OpenWOP-Version — the preferredVersion default is for /.well-known/openwop only (got ${JSON.stringify(version)})`)).toBe('2');
87
+ });
70
88
  it('through the overlap preferredVersion names a 1.x member', async () => {
71
89
  const doc = await discovery();
72
90
  if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
@@ -234,6 +234,11 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
234
234
  expect(owner !== null, req(ID, DOC, 'a major-2 run.started payload MUST carry the owner echo { tenant, subject } (identity.md §1; run-event-payloads runStarted.owner requires both)')).toBe(true);
235
235
  expect(Object.keys(owner ?? {}).filter((k) => k === 'principal' || k === 'principalKind'), req(ID, DOC, 'a major-2 owner echo MUST NOT carry the v1 keys principal / principalKind — that is the fan-out forwarding the in-process dialect instead of projecting')).toEqual([]);
236
236
  expect(typeof owner?.['tenant'] === 'string' && owner?.['subject'] !== undefined, req(ID, DOC, 'a major-2 owner echo carries tenant and subject')).toBe(true);
237
+ // webhooks.md §Delivery: `workspaceId` is present exactly when RunSnapshot.owner.workspace is,
238
+ // equal to it, and a host MUST NOT substitute its tenant id (or any placeholder) for an absent one.
239
+ const ws = owner?.['workspace'];
240
+ const sent = (d.envelope as Record<string, unknown>)['workspaceId'];
241
+ expect(ws === undefined ? sent === undefined : sent === ws, req(ID, DOC, `workspaceId MUST be present exactly when owner.workspace is, and equal to it — nothing is substituted for an absent workspace (owner.workspace ${JSON.stringify(ws)}, delivered workspaceId ${JSON.stringify(sent)})`)).toBe(true);
237
242
  });
238
243
 
239
244
  it('a major-1 subscriber still receives the v1 rendering — the v1 wire does not move mid-overlap', async () => {
@@ -0,0 +1,76 @@
1
+ /**
2
+ * RFC 0221 — a secret the host generates is returned once (`spec/v2/core/webhooks.md`
3
+ * §Surfaces; suite 2.42.8, target major 2; gated on the `webhooks` family and the
4
+ * `conformance-noop` fixture).
5
+ *
6
+ * `registerWebhook` makes `secret` optional. When the request omits it, the host
7
+ * MUST generate one and return it in the `201` as `secret`, the only time it
8
+ * appears on the wire, and every delivery to that subscription MUST verify under
9
+ * it (`OpenWOP-Signature`, scheme `v1`). When the request supplies a secret, the
10
+ * `201` MUST NOT echo it (RFC 0201 §B.6, now for every subscription).
11
+ *
12
+ * How it FAILS: a `201` without `secret` for a secret-less registration (the
13
+ * subscriber can never verify a delivery); a returned secret the deliveries are
14
+ * not signed with; a `201` that echoes a supplied secret.
15
+ *
16
+ * @see spec/v2/core/webhooks.md §Surfaces
17
+ * @see RFCS/0221-generated-webhook-secret-returned-once.md
18
+ */
19
+
20
+ import { afterEach, describe, it, expect } from 'vitest';
21
+ import { createHmac } from 'node:crypto';
22
+ import { req } from '../lib/requirement-ids.js';
23
+ import { readErrorCode } from '../lib/error-envelope.js';
24
+ import { softSkip } from '../lib/soft-skip.js';
25
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
26
+ import { hitHeader, startModalReceiver, type ModalHit } from '../lib/webhook-receiver.js';
27
+ import { SW_FIXTURE, deliveriesFor, driveRun, loopbackRefusal, registerSw, unregisterAllSw, waitFor } from '../lib/standard-webhooks.js';
28
+
29
+ const ID = 'openwop.requirement.0221.generated-secret-returned';
30
+ const SPEC = 'RFC 0221 · webhooks.md §Surfaces';
31
+
32
+ let closeReceiver: (() => Promise<void>) | null = null;
33
+ afterEach(async () => {
34
+ await unregisterAllSw();
35
+ if (closeReceiver) { const c = closeReceiver; closeReceiver = null; await c(); }
36
+ });
37
+
38
+ function v1SignedBy(secret: string, h: ModalHit): boolean {
39
+ const ts = hitHeader(h, 'openwop-timestamp');
40
+ return ts !== undefined && hitHeader(h, 'openwop-signature') === `sha256=${createHmac('sha256', secret).update(`${ts}.${h.body}`, 'utf8').digest('hex')}`;
41
+ }
42
+
43
+ describe('RFC 0221 — a host-generated webhook secret is returned once', () => {
44
+ it('a secret-less registration returns the generated secret, and deliveries verify under it; a supplied secret is never echoed', async () => {
45
+ let doc: Record<string, unknown> | null = null;
46
+ try { doc = await v2Discovery(); } catch { doc = null; }
47
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
48
+ if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
49
+ const fixtures = Array.isArray(doc['fixtures']) ? (doc['fixtures'] as unknown[]) : [];
50
+ if (!fixtures.includes(SW_FIXTURE)) return softSkip('inapplicable', `${SW_FIXTURE} fixture not advertised — no run to deliver`);
51
+
52
+ const rx = await startModalReceiver();
53
+ closeReceiver = rx.close;
54
+ const target = rx.urlFor('no-echo');
55
+ const reg = await registerSw({ url: target.url, events: ['run.completed'] });
56
+ const refused = loopbackRefusal(reg, target.tunnelled);
57
+ if (refused) return softSkip('blocked', refused);
58
+ if (reg.status !== 201) return softSkip('blocked', `registerWebhook answered ${reg.status} ${readErrorCode(reg.json) ?? ''} — webhooks.md §Surfaces owns that contract`);
59
+
60
+ const body = reg.json as { webhookId: string; secret?: unknown };
61
+ expect(typeof body.secret === 'string' && body.secret.length > 0, req(ID, SPEC, 'a registration that omits secret MUST get the host-generated secret back in the 201 as a non-empty string')).toBe(true);
62
+ const secret = body.secret as string;
63
+
64
+ const run = await driveRun();
65
+ expect(run.status, req(ID, 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
66
+ await waitFor(() => deliveriesFor(rx.hits, body.webhookId, run.runId).length > 0, 15_000);
67
+ const delivery = deliveriesFor(rx.hits, body.webhookId, run.runId)[0];
68
+ expect(delivery, req(ID, 'webhooks.md §Durability', 'the subscription MUST receive the run.completed delivery')).toBeDefined();
69
+ expect(v1SignedBy(secret, delivery!), req(ID, SPEC, 'the delivery MUST verify (OpenWOP-Signature, scheme v1) under the secret the 201 returned')).toBe(true);
70
+
71
+ const supplied = `conformance-${'s'.repeat(24)}`;
72
+ const own = await registerSw({ url: rx.urlFor('no-echo').url, events: ['run.completed'], secret: supplied });
73
+ expect(own.status, req(ID, SPEC, `a registration with a supplied secret MUST answer 201 (got ${own.status} ${readErrorCode(own.json) ?? ''})`)).toBe(201);
74
+ expect(own.text.includes(supplied) || (own.json as { secret?: unknown }).secret !== undefined, req(ID, 'RFC 0221 · RFC 0201 §B.6', 'the 201 MUST NOT echo a supplied secret')).toBe(false);
75
+ });
76
+ });