@openwop/openwop-conformance 2.37.0 → 2.37.1
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.
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/dist/lib/requirement-ledger.js +44 -3
- package/dist/lib/scenario-disposition.js +37 -8
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +80 -28
- package/schemas/CORPUS-STAMP.json +11 -11
- package/src/lib/front-mux.ts +8 -1
- package/src/lib/requirement-ledger.ts +82 -4
- package/src/lib/scenario-disposition.ts +41 -3
- package/src/lib/scoped-receiver.ts +223 -0
- package/src/lib/triggerBridge.ts +49 -0
- package/src/scenarios/auth-subject-link.test.ts +18 -1
- package/src/scenarios/trigger-bridge-delivery.test.ts +17 -2
- package/src/scenarios/trigger-stream-cdc-sources.test.ts +17 -2
- package/src/scenarios/v2-bound-id-kinds.test.ts +35 -22
- package/src/scenarios/v2-idempotency-in-flight.test.ts +81 -26
- package/src/scenarios/v2-webhook-delivery-shape.test.ts +31 -34
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +63 -47
- package/src/scenarios/webhook-signed-delivery.test.ts +55 -42
- package/src/setup.ts +24 -4
|
@@ -27,12 +27,11 @@
|
|
|
27
27
|
* @see RFCS/0187-host-found-bindings.md §A
|
|
28
28
|
*/
|
|
29
29
|
import { describe, it, expect } from 'vitest';
|
|
30
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
31
30
|
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
32
31
|
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
33
32
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
34
33
|
import { softSkip } from '../lib/soft-skip.js';
|
|
35
|
-
import {
|
|
34
|
+
import { noDeliveryCause, startScopedReceiver, unservedDestination, type ScopedReceiver } from '../lib/scoped-receiver.js';
|
|
36
35
|
import { scaledTimeoutMs } from '../lib/polling.js';
|
|
37
36
|
import { req } from '../lib/requirement-ids.js';
|
|
38
37
|
import { projectBoundId, BOUND_ID } from '../lib/bound-id.js';
|
|
@@ -50,20 +49,21 @@ const PER_KIND = 'openwop.requirement.0187.bound-id-kinds.per-kind';
|
|
|
50
49
|
const WEBHOOK_EMITTED = 'openwop.requirement.0187.bound-id-kinds.webhook-emitted';
|
|
51
50
|
|
|
52
51
|
type Delivery = { body: string; headers: Record<string, string | string[] | undefined> };
|
|
53
|
-
/**
|
|
54
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The suite's receiver for THIS exercise — pinned port and public front
|
|
54
|
+
* honoured, plus a per-exercise nonce path (2.37.0). Four webhook files used to
|
|
55
|
+
* register the same byte-identical front URL, and a webhook subscription
|
|
56
|
+
* outlives the file that made it, so a sibling's leftovers arrived here
|
|
57
|
+
* indistinguishable from this run's deliveries.
|
|
58
|
+
*/
|
|
59
|
+
async function startReceiver(): Promise<ScopedReceiver & { deliveries: Delivery[] }> {
|
|
55
60
|
const deliveries: Delivery[] = [];
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
const rx = await startScopedReceiver((hit, res) => {
|
|
62
|
+
deliveries.push({ body: hit.body, headers: hit.headers });
|
|
63
|
+
res.writeHead(204);
|
|
64
|
+
res.end();
|
|
60
65
|
});
|
|
61
|
-
|
|
62
|
-
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
63
|
-
const binding = receiverBinding();
|
|
64
|
-
await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
|
|
65
|
-
const addr = server.address();
|
|
66
|
-
return { server, url: `http://${binding.advertise}:${typeof addr === 'object' && addr ? addr.port : 0}/hook`, deliveries };
|
|
66
|
+
return { ...rx, deliveries };
|
|
67
67
|
}
|
|
68
68
|
const header = (d: Delivery, name: string): string | undefined => { const v = d.headers[name]; return Array.isArray(v) ? v[0] : v; };
|
|
69
69
|
|
|
@@ -89,13 +89,21 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
|
|
|
89
89
|
it('subscriptionId: POST /webhooks mints a bound webhookId, the projected segment is accepted, and a foreign tenant segment is refused 403', async () => {
|
|
90
90
|
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
91
91
|
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised — no subscription to mint (gate recorded under openwop.family.webhooks)');
|
|
92
|
-
// Route the mint through
|
|
92
|
+
// Route the mint through the operator's front so an operator who sets
|
|
93
93
|
// OPENWOP_WEBHOOK_RECEIVER_URL actually gets a reachable registration here.
|
|
94
|
-
// Before
|
|
94
|
+
// Before that, the blocked note TOLD them to set the variable and this file
|
|
95
95
|
// never read it — inoperative advice in a suite where one blocked row denies
|
|
96
96
|
// every claimed profile (RFC 0168 §E.1). The fallback stays a reserved
|
|
97
97
|
// `.invalid` host: this leg only needs the mint, never a delivery.
|
|
98
|
-
|
|
98
|
+
//
|
|
99
|
+
// `unservedDestination`, not `resolveRegistrationUrl` (2.37.0): the latter
|
|
100
|
+
// returns the front VERBATIM, so this subscription had the same identity as
|
|
101
|
+
// every other exercise's, and it is live from the 201 until the DELETE
|
|
102
|
+
// below. Anything the host fanned out in that window landed on whichever
|
|
103
|
+
// listener held the pinned port and was read as THAT exercise's traffic. The
|
|
104
|
+
// nonce is served by nobody on purpose — this leg wants no delivery, and a
|
|
105
|
+
// delivery to it is now answered 404 instead of absorbed.
|
|
106
|
+
const registration = unservedDestination('https://subscriber.invalid/hook');
|
|
99
107
|
const reg = await http(() => driver.post('/webhooks', { url: registration.url, events: ['run.completed'] }));
|
|
100
108
|
if (reg === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
|
|
101
109
|
if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') return softSkip('blocked', `host SSRF guard rejected the registration URL ${registration.url}${registration.tunnelled ? ' (from OPENWOP_WEBHOOK_RECEIVER_URL)' : ' — set OPENWOP_WEBHOOK_RECEIVER_URL to a public https receiver, which THIS leg now honours'}`);
|
|
@@ -136,11 +144,14 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
|
|
|
136
144
|
const rx = await startReceiver();
|
|
137
145
|
let webhookId: string | null = null;
|
|
138
146
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
147
|
+
// `rx.url` is already this exercise's destination — the front (when
|
|
148
|
+
// wired) plus its nonce path — so it is not run through
|
|
149
|
+
// `resolveRegistrationUrl`, which returns the front VERBATIM and would
|
|
150
|
+
// drop the path that makes the subscription ours.
|
|
151
|
+
const reg = await http(() => driver.post('/webhooks', { url: rx.url, events: ['run.completed'] }));
|
|
141
152
|
if (reg === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
|
|
142
153
|
if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') {
|
|
143
|
-
return softSkip('blocked', `host SSRF guard rejected the suite receiver ${
|
|
154
|
+
return softSkip('blocked', `host SSRF guard rejected the suite receiver ${rx.url} (webhooks.md §Egress requires it) — set OPENWOP_WEBHOOK_RECEIVER_URL to a public https front for the receiver to witness what the host emits`);
|
|
144
155
|
}
|
|
145
156
|
const minted = (reg.json as { webhookId?: unknown } | null)?.webhookId;
|
|
146
157
|
if (reg.status !== 201 || typeof minted !== 'string') return softSkip('blocked', `POST /webhooks answered ${reg.status} without a webhookId — the mint leg above owns that obligation; this leg needs an id to compare`);
|
|
@@ -155,7 +166,7 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
|
|
|
155
166
|
const deadline = Date.now() + scaledTimeoutMs(20_000);
|
|
156
167
|
while (ours().length === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 250));
|
|
157
168
|
const delivery = ours()[0];
|
|
158
|
-
if (delivery === undefined) return softSkip('blocked', `no delivery for run ${runId} reached the suite receiver within ${scaledTimeoutMs(20_000)}ms — what the host emits was not observed`);
|
|
169
|
+
if (delivery === undefined) return softSkip('blocked', `no delivery for run ${runId} reached the suite receiver within ${scaledTimeoutMs(20_000)}ms — what the host emits was not observed. ${noDeliveryCause(rx, `run.completed delivery for run ${runId}`)}`);
|
|
159
170
|
expect(
|
|
160
171
|
header(delivery, 'openwop-webhook-id'),
|
|
161
172
|
req(WEBHOOK_EMITTED, 'webhooks.md §Headers (RFC 0187 §A.1)', `the delivery's OpenWOP-Webhook-Id MUST equal the tenant-bound webhookId the host minted (${minted}) — a subscriber identifies its deliveries by the header, not by the 201 it received once`),
|
|
@@ -172,7 +183,9 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
|
|
|
172
183
|
}
|
|
173
184
|
} finally {
|
|
174
185
|
if (webhookId !== null) await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId as string)}`));
|
|
175
|
-
|
|
186
|
+
// Closed through the receiver: `close()` also drops this exercise's
|
|
187
|
+
// nonce from the front-mux registry.
|
|
188
|
+
await rx.close();
|
|
176
189
|
}
|
|
177
190
|
});
|
|
178
191
|
|
|
@@ -19,8 +19,15 @@
|
|
|
19
19
|
* `details.retryAfter*`, and a `Retry-After`, when present, parses.
|
|
20
20
|
*
|
|
21
21
|
* Non-vacuity: overlap is not guaranteed. When no request was refused in flight
|
|
22
|
-
* the
|
|
23
|
-
* not claimed for it.
|
|
22
|
+
* the LOSER leg records `partial-witness` — the 409 branch never ran, so a pass
|
|
23
|
+
* is not claimed for it.
|
|
24
|
+
*
|
|
25
|
+
* Two `it`s over ONE race (2.37.x). §B names two requirement ids and both were
|
|
26
|
+
* cited from a single `it`, so only the last of them could ever get a ledger
|
|
27
|
+
* row; the winner id was recorded on no host. The race is driven once and
|
|
28
|
+
* memoised — driving it twice would measure two unrelated races and halve the
|
|
29
|
+
* chance that either overlaps — and each leg now carries its own id, so the
|
|
30
|
+
* winner clause keeps its verdict when the 409 branch does not run.
|
|
24
31
|
*
|
|
25
32
|
* @see spec/v2/core/idempotency.md
|
|
26
33
|
* @see RFCS/0213-three-unstated-v2-outcomes.md §B
|
|
@@ -43,35 +50,83 @@ async function discovery(): Promise<Record<string, unknown> | null> { try { retu
|
|
|
43
50
|
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
44
51
|
function parsesRetryAfter(v: string): boolean { return /^\d+$/.test(v.trim()) || !Number.isNaN(Date.parse(v)); }
|
|
45
52
|
|
|
53
|
+
/** The race's outcome, or the reason it could not be driven. */
|
|
54
|
+
type Race =
|
|
55
|
+
| { readonly kind: 'ran'; readonly all: readonly OpenWOPResponse[]; readonly successes: readonly OpenWOPResponse[]; readonly refusals: readonly OpenWOPResponse[] }
|
|
56
|
+
| { readonly kind: 'blocked'; readonly reason: string };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ONE race, read by both legs.
|
|
60
|
+
*
|
|
61
|
+
* RFC 0213 §B names two requirement ids, and until 2.37.x both were cited from
|
|
62
|
+
* a SINGLE `it()`. The ledger keys on the id and `setup.ts` takes one
|
|
63
|
+
* `explicitId` per test, so only the LAST one cited got a row:
|
|
64
|
+
* `0213.in-flight-one-winner` was never recorded on any host, and nothing said
|
|
65
|
+
* so. (Found by `check-req-only.mjs` rule (d) the moment it learned to resolve a
|
|
66
|
+
* `const` handed to `req()` — it had compared only call-site literals, so the
|
|
67
|
+
* violation was invisible when this file was written.)
|
|
68
|
+
*
|
|
69
|
+
* Splitting the legs must not split the EXERCISE: the record is in flight only
|
|
70
|
+
* while the winning create is being handled, so driving the race twice would
|
|
71
|
+
* measure two unrelated races and halve the chance that either overlaps. The
|
|
72
|
+
* race is therefore memoised here and both legs await the same result — the
|
|
73
|
+
* house pattern from `v2-subject-link-record.test.ts`.
|
|
74
|
+
*/
|
|
75
|
+
let race: Promise<Race> | undefined;
|
|
76
|
+
function theRace(): Promise<Race> {
|
|
77
|
+
return (race ??= drive());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function drive(): Promise<Race> {
|
|
81
|
+
if (!(await discovery())) return { kind: 'blocked', reason: 'v2 discovery unreachable' };
|
|
82
|
+
const key = `openwopconf-inflight-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
83
|
+
const body = { workflowId: FIXTURE, inputs: { delayMs: 1500 } };
|
|
84
|
+
const results = await Promise.all(Array.from({ length: N }, () => http(() => driver.post('/runs', body, { headers: { 'Idempotency-Key': key } }))));
|
|
85
|
+
if (results.some((r) => r === null)) return { kind: 'blocked', reason: 'POST /runs unreachable (fetch failed)' };
|
|
86
|
+
const rs = results as OpenWOPResponse[];
|
|
87
|
+
const first = rs.find((r) => r.status >= 200 && r.status < 300);
|
|
88
|
+
if (first === undefined) {
|
|
89
|
+
const codes = rs.map((r) => `${r.status} ${readErrorCode(r.json) ?? ''}`.trim()).join(', ');
|
|
90
|
+
if (rs.every((r) => r.status === 404 || r.status === 422)) return { kind: 'blocked', reason: `the ${FIXTURE} fixture is not runnable (${codes})` };
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
kind: 'ran',
|
|
94
|
+
all: rs,
|
|
95
|
+
successes: rs.filter((r) => r.status >= 200 && r.status < 300),
|
|
96
|
+
refusals: rs.filter((r) => !(r.status >= 200 && r.status < 300)),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
46
100
|
describe('v2 idempotency-in-flight (idempotency.md Concurrency, RFC 0213 §B)', () => {
|
|
47
|
-
it('concurrent same-key creates yield one run
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
if (results.some((r) => r === null)) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
|
|
53
|
-
const rs = results as OpenWOPResponse[];
|
|
54
|
-
const first = rs.find((r) => r.status >= 200 && r.status < 300);
|
|
55
|
-
if (first === undefined) {
|
|
56
|
-
const codes = rs.map((r) => `${r.status} ${readErrorCode(r.json) ?? ''}`.trim()).join(', ');
|
|
57
|
-
if (rs.every((r) => r.status === 404 || r.status === 422)) return softSkip('blocked', `the ${FIXTURE} fixture is not runnable (${codes})`);
|
|
58
|
-
}
|
|
59
|
-
const successes = rs.filter((r) => r.status >= 200 && r.status < 300);
|
|
60
|
-
const refusals = rs.filter((r) => !(r.status >= 200 && r.status < 300));
|
|
61
|
-
expect(successes.length, req(ID_ONE, DOC, `at least one of ${N} same-key creates MUST complete (statuses: ${rs.map((r) => r.status).join(',')})`)).toBeGreaterThan(0);
|
|
62
|
-
const runIds = new Set(successes.map((r) => (r.json as { runId?: unknown } | null)?.runId).filter((x): x is string => typeof x === 'string'));
|
|
101
|
+
it('concurrent same-key creates yield exactly one run', async () => {
|
|
102
|
+
const r = await theRace();
|
|
103
|
+
if (r.kind === 'blocked') return softSkip('blocked', r.reason);
|
|
104
|
+
expect(r.successes.length, req(ID_ONE, DOC, `at least one of ${N} same-key creates MUST complete (statuses: ${r.all.map((x) => x.status).join(',')})`)).toBeGreaterThan(0);
|
|
105
|
+
const runIds = new Set(r.successes.map((x) => (x.json as { runId?: unknown } | null)?.runId).filter((x): x is string => typeof x === 'string'));
|
|
63
106
|
expect(runIds.size, req(ID_ONE, DOC, `a host MUST NOT process two same-key requests: distinct runIds ${[...runIds].join(', ')}`)).toBe(1);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
107
|
+
}, 60_000);
|
|
108
|
+
|
|
109
|
+
it('each loser is a marked replay of a final outcome, or 409 idempotency_in_flight with no retry timing in details', async () => {
|
|
110
|
+
const r = await theRace();
|
|
111
|
+
if (r.kind === 'blocked') return softSkip('blocked', r.reason);
|
|
112
|
+
const unmarked = r.successes.filter((x) => x.headers.get('openwop-idempotent-replay') !== 'true');
|
|
113
|
+
expect(unmarked.length, req(ID_LOSER, DOC, `exactly one success is the winner; every other MUST carry OpenWOP-Idempotent-Replay: true (${unmarked.length} of ${r.successes.length} unmarked)`)).toBe(1);
|
|
114
|
+
for (const x of r.refusals) {
|
|
115
|
+
const code = readErrorCode(x.json);
|
|
116
|
+
expect({ status: x.status, code }, req(ID_LOSER, DOC, `a loser that is not a replay MUST be 409 idempotency_in_flight (got ${x.status} ${String(code)})`)).toEqual({ status: 409, code: 'idempotency_in_flight' });
|
|
117
|
+
const details = (x.json as { details?: Record<string, unknown> } | null)?.details ?? {};
|
|
70
118
|
const timing = Object.keys(details).filter((k) => /^retryAfter/i.test(k));
|
|
71
119
|
expect(timing, req(ID_LOSER, 'spec/v2/core/errors.md §Retry timing', `retry timing MUST NOT travel in details (found ${timing.join(', ')})`)).toEqual([]);
|
|
72
|
-
const ra =
|
|
120
|
+
const ra = x.headers.get('retry-after');
|
|
73
121
|
if (ra !== null) expect(parsesRetryAfter(ra), req(ID_LOSER, DOC, `a Retry-After that is present MUST parse (got ${ra})`)).toBe(true);
|
|
74
122
|
}
|
|
75
|
-
|
|
123
|
+
// Every loser replayed the winner: RFC 0213 §B permits exactly this (a
|
|
124
|
+
// loser MAY wait and receive a final outcome, marked). The 409 branch did
|
|
125
|
+
// not run, so the row is a partial witness — never `blocked`, which would
|
|
126
|
+
// deny certification (RFC 0168 §E.1) to a host that did nothing wrong.
|
|
127
|
+
// (#1525's fix, kept verbatim; `r.refusals` is the split form's spelling of
|
|
128
|
+
// its `refusals`, and it now lands on the LOSER id it is about rather than
|
|
129
|
+
// on a row shared with the winner clause.)
|
|
130
|
+
if (r.refusals.length === 0) return softSkip('inapplicable', `no loser was refused in flight — all ${N} answers were successes (each loser a marked replay, which §B permits), so the 409 branch did not run on this host`);
|
|
76
131
|
}, 60_000);
|
|
77
132
|
});
|
|
@@ -44,14 +44,13 @@
|
|
|
44
44
|
* @see spec/v2/core/versioning.md §1.2
|
|
45
45
|
*/
|
|
46
46
|
import { afterEach, describe, it, expect } from 'vitest';
|
|
47
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
48
47
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
49
48
|
import { join } from 'node:path';
|
|
50
49
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
51
50
|
import addFormats from 'ajv-formats';
|
|
52
51
|
import { driver } from '../lib/driver.js';
|
|
53
52
|
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
54
|
-
import {
|
|
53
|
+
import { noDeliveryCause, startScopedReceiver, type ScopedReceiver } from '../lib/scoped-receiver.js';
|
|
55
54
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
56
55
|
import { softSkip } from '../lib/soft-skip.js';
|
|
57
56
|
import { req } from '../lib/requirement-ids.js';
|
|
@@ -67,32 +66,23 @@ const EVENT_TYPE = 'run.started';
|
|
|
67
66
|
type Delivery = { body: string; headers: Record<string, string | string[] | undefined> };
|
|
68
67
|
type Validator = (doc: unknown) => { ok: boolean; errors: string };
|
|
69
68
|
|
|
70
|
-
async function startReceiver(): Promise<
|
|
69
|
+
async function startReceiver(): Promise<ScopedReceiver & { deliveries: Delivery[] }> {
|
|
71
70
|
const deliveries: Delivery[] = [];
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
request.on('end', () => {
|
|
76
|
-
deliveries.push({ body: Buffer.concat(chunks).toString('utf8'), headers: request.headers });
|
|
77
|
-
res.writeHead(204); res.end();
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
// Honour OPENWOP_WEBHOOK_RECEIVER_PORT like `v2-webhook-durable-delivery`
|
|
81
|
-
// does. Without it, a tunnelled run registers the tunnel URL here and the
|
|
71
|
+
// `startScopedReceiver` (2.37.0) replaces this file's own `createServer` +
|
|
72
|
+
// pinned-port binding. The comment that stood here described the collision
|
|
73
|
+
// from the inside: "a tunnelled run registers the tunnel URL here and the
|
|
82
74
|
// tunnel forwards to the PINNED port — held by the other receiver, which
|
|
83
75
|
// answers 500 by design — so this file's `deliveries` stays empty, its legs
|
|
84
76
|
// soft-skip, and (because `register()` already asserted) the rows resolve
|
|
85
|
-
// `executed-pass`.
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
95
|
-
return { server, url: `http://${binding.advertise}:${port}/hook`, deliveries };
|
|
77
|
+
// `executed-pass`." Four files registered that one byte-identical URL. Each
|
|
78
|
+
// now registers its own nonce path, and `front-mux` routes a delivery to the
|
|
79
|
+
// exercise it was addressed to whichever listener holds the port.
|
|
80
|
+
const rx = await startScopedReceiver((hit, res) => {
|
|
81
|
+
deliveries.push({ body: hit.body, headers: hit.headers });
|
|
82
|
+
res.writeHead(204);
|
|
83
|
+
res.end();
|
|
84
|
+
});
|
|
85
|
+
return { ...rx, deliveries };
|
|
96
86
|
}
|
|
97
87
|
|
|
98
88
|
/**
|
|
@@ -103,8 +93,11 @@ async function startReceiver(): Promise<{ server: Server; url: string; deliverie
|
|
|
103
93
|
* for leg 2, not a failure: there is no v1 wire to keep still.
|
|
104
94
|
*/
|
|
105
95
|
async function register(url: string, major: 1 | 2): Promise<string | null> {
|
|
106
|
-
|
|
107
|
-
|
|
96
|
+
// `url` is already the destination for THIS exercise — the public front (when
|
|
97
|
+
// one is wired) plus this receiver's nonce path. It is no longer run through
|
|
98
|
+
// `resolveRegistrationUrl`, which returned the front VERBATIM and so dropped
|
|
99
|
+
// the path that makes the subscription this exercise's.
|
|
100
|
+
const reg = await driver.post(major === 2 ? '/webhooks' : '/v1/webhooks', { url, events: [EVENT_TYPE] }, { headers: { 'OpenWOP-Version': major === 2 ? '2.0' : '1.0' } });
|
|
108
101
|
if (major === 1 && reg.status === 404) {
|
|
109
102
|
softSkip('inapplicable', 'host serves no 1.x webhook surface (POST /v1/webhooks not_found) — no v1 wire to keep still');
|
|
110
103
|
return null;
|
|
@@ -214,18 +207,22 @@ const V2 = 'https://openwop.dev/spec/v2/';
|
|
|
214
207
|
const V1 = 'https://openwop.dev/spec/v1/';
|
|
215
208
|
|
|
216
209
|
describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versioning.md §1.2)', () => {
|
|
217
|
-
|
|
218
|
-
|
|
210
|
+
// Closed through the receiver, not the raw server: `close()` also drops this
|
|
211
|
+
// exercise's nonce from the front-mux registry, so a retry that arrives after
|
|
212
|
+
// the leg has finished is answered 404 by whoever holds the port rather than
|
|
213
|
+
// being handed to the next exercise's recorder.
|
|
214
|
+
let active: ScopedReceiver | null = null;
|
|
215
|
+
afterEach(async () => { await unregisterAll(); const rx = active; active = null; if (rx) await rx.close(); });
|
|
219
216
|
|
|
220
217
|
it('a major-2 subscriber receives the v2 rendering: the delivery validates, and run.started.owner carries subject, never principal', async () => {
|
|
221
218
|
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
222
219
|
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
223
|
-
const receiver = await startReceiver(); active = receiver
|
|
220
|
+
const receiver = await startReceiver(); active = receiver;
|
|
224
221
|
const webhookId = await register(receiver.url, 2);
|
|
225
222
|
if (webhookId === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
226
223
|
const runId = await driveRun();
|
|
227
224
|
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
|
|
228
|
-
if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s — durability is v2-webhook-durable-delivery's claim, not this file's`);
|
|
225
|
+
if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s — durability is v2-webhook-durable-delivery's claim, not this file's. ${noDeliveryCause(receiver, `${EVENT_TYPE} delivery`)}`);
|
|
229
226
|
const v2 = validators(2);
|
|
230
227
|
const envelope = v2.ref(`${V2}webhook-delivery.schema.json`)(d.envelope);
|
|
231
228
|
expect(envelope.ok, req(ID, DOC, `a major-2 delivery MUST validate against webhook-delivery.schema.json (v2) — { runId, workspaceId?, event } with event the verbatim v2 run event. ${envelope.errors}`)).toBe(true);
|
|
@@ -245,12 +242,12 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
|
|
|
245
242
|
// `versions.supported` that no discovery document has, so this leg was inapplicable on every host.
|
|
246
243
|
const versions = Array.isArray(disc?.['protocolVersions']) ? (disc?.['protocolVersions'] as unknown[]).map(String) : [];
|
|
247
244
|
if (!versions.some((v) => v.startsWith('1.'))) return softSkip('inapplicable', `host advertises [${versions.join(', ') || 'no protocolVersions'}] — no 1.x member, so there is no v1 wire to keep still`);
|
|
248
|
-
const receiver = await startReceiver(); active = receiver
|
|
245
|
+
const receiver = await startReceiver(); active = receiver;
|
|
249
246
|
const webhookId = await register(receiver.url, 1);
|
|
250
247
|
if (webhookId === null) return softSkip('blocked', 'registration refused or inapplicable (disposition recorded above)');
|
|
251
248
|
const runId = await driveRun();
|
|
252
249
|
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
|
|
253
|
-
if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s`);
|
|
250
|
+
if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s. ${noDeliveryCause(receiver, `${EVENT_TYPE} delivery`)}`);
|
|
254
251
|
// The v1 definition is the discriminator, not the owner's keys: v1's owner admits `subject` (RFC 0165
|
|
255
252
|
// §B, echoed verbatim when present) alongside `principal`, so a v2 owner is ALSO a valid v1 owner.
|
|
256
253
|
// What the v1 wire cannot carry is the v2 payload's integer `engineVersion` (string on v1) — a fan-out
|
|
@@ -269,7 +266,7 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
|
|
|
269
266
|
// read null as absence, so this leg was inapplicable on exactly the hosts that could witness it.
|
|
270
267
|
const gate = era2Gate(disc);
|
|
271
268
|
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
272
|
-
const receiver = await startReceiver(); active = receiver
|
|
269
|
+
const receiver = await startReceiver(); active = receiver;
|
|
273
270
|
const webhookId = await register(receiver.url, 2);
|
|
274
271
|
if (webhookId === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
275
272
|
const log = await seedEra2Log(v1FixtureLog(FIXTURE), 'completed');
|
|
@@ -279,7 +276,7 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
|
|
|
279
276
|
// The seam appends HISTORY — rows that already happened — and a host MAY not fan out history (the
|
|
280
277
|
// reference host's seam appends with fan-out suppressed by design). No delivery inside 15s means the
|
|
281
278
|
// era-2 fan-out branch is unobservable on this host, not that a measurement failed: inapplicable.
|
|
282
|
-
if (!d) return softSkip('inapplicable', `no ${EVENT_TYPE} delivery for the seeded era-2 run inside 15s — this host does not fan out seeded history, so the era-2 fan-out branch is unobservable here (recorded under openwop.family.conformance)`);
|
|
279
|
+
if (!d) return softSkip('inapplicable', `no ${EVENT_TYPE} delivery for the seeded era-2 run inside 15s — this host does not fan out seeded history, so the era-2 fan-out branch is unobservable here (recorded under openwop.family.conformance). ${noDeliveryCause(receiver, `${EVENT_TYPE} delivery`)}`);
|
|
283
280
|
const payload = validators(2).ref(`${V2}run-event-payloads.schema.json#/$defs/runStarted`)(d.event['payload']);
|
|
284
281
|
expect(payload.ok, req(ID, DOC, `an era-2 row delivered to a major-2 subscriber MUST be projected — the read projection applies at the fan-out as at poll/SSE (events.md §Era-2). ${payload.errors}`)).toBe(true);
|
|
285
282
|
const owner = ownerOf(d.event);
|
|
@@ -28,11 +28,10 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { afterEach, describe, it, expect } from 'vitest';
|
|
31
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
32
31
|
import { driver } from '../lib/driver.js';
|
|
33
32
|
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
34
33
|
import { projectBoundId } from '../lib/bound-id.js';
|
|
35
|
-
import {
|
|
34
|
+
import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedReceiver } from '../lib/scoped-receiver.js';
|
|
36
35
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
37
36
|
import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
|
|
38
37
|
import { req } from '../lib/requirement-ids.js';
|
|
@@ -48,47 +47,48 @@ interface Attempt { readonly key: string; readonly runId: string | null; readonl
|
|
|
48
47
|
* (`Infinity` ⇒ always fails). The key is `(webhookId, runId, sequence)` —
|
|
49
48
|
* the dedup triple webhooks.md §Verification names.
|
|
50
49
|
*/
|
|
51
|
-
async function startReceiver(failFirst: number): Promise<
|
|
50
|
+
async function startReceiver(failFirst: number): Promise<ScopedReceiver & { attempts: Attempt[] }> {
|
|
52
51
|
const attempts: Attempt[] = [];
|
|
53
52
|
const seen = new Map<string, number>();
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
53
|
+
// `startScopedReceiver` (2.37.0). This receiver answers 500 BY DESIGN, and
|
|
54
|
+
// until now it advertised the same byte-identical destination as the other
|
|
55
|
+
// three webhook files on a tunnelled cut — so on a shared pinned port the
|
|
56
|
+
// exercise that happened to register alongside it saw failures it never
|
|
57
|
+
// caused. The `ours()` filter below was the workaround; the nonce removes the
|
|
58
|
+
// cause. The measured case is in that filter's own comment: a tier-2 host
|
|
59
|
+
// counted 6 attempts against a maxAttempts of 5 because another scenario's
|
|
60
|
+
// subscription delivered into this budget.
|
|
61
|
+
const rx = await startScopedReceiver((hit, res) => {
|
|
62
|
+
let runId: string | null = null;
|
|
63
|
+
let sequence: unknown = null;
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(hit.body) as { runId?: unknown; event?: { sequence?: unknown } };
|
|
66
|
+
runId = typeof parsed.runId === 'string' ? parsed.runId : null;
|
|
67
|
+
sequence = parsed.event?.sequence ?? null;
|
|
68
|
+
} catch { /* not JSON — still an attempt */ }
|
|
69
|
+
const h = hit.headers;
|
|
70
|
+
const webhookId = String(h['openwop-webhook-id'] ?? h['x-openwop-webhook-id'] ?? '');
|
|
71
|
+
const key = `${webhookId}|${runId ?? ''}|${String(sequence)}`;
|
|
72
|
+
const n = (seen.get(key) ?? 0) + 1;
|
|
73
|
+
seen.set(key, n);
|
|
74
|
+
const status = n <= failFirst ? 500 : 204;
|
|
75
|
+
attempts.push({ key, runId, webhookId, status, at: Date.now() });
|
|
76
|
+
res.writeHead(status);
|
|
77
|
+
res.end();
|
|
76
78
|
});
|
|
77
|
-
|
|
78
|
-
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
79
|
-
const binding = receiverBinding();
|
|
80
|
-
await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
|
|
81
|
-
const addr = server.address();
|
|
82
|
-
if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
|
|
83
|
-
return { server, url: `http://${binding.advertise}:${addr.port}/`, attempts };
|
|
79
|
+
return { ...rx, attempts };
|
|
84
80
|
}
|
|
85
81
|
|
|
86
|
-
|
|
82
|
+
// Closed through the receiver: `close()` also drops this exercise's nonce from
|
|
83
|
+
// the front-mux registry, so the retries this file deliberately provokes are
|
|
84
|
+
// answered 404 by whoever next holds the port instead of being handed to the
|
|
85
|
+
// next exercise's recorder.
|
|
86
|
+
let active: ScopedReceiver | null = null;
|
|
87
87
|
afterEach(async () => {
|
|
88
88
|
if (active) {
|
|
89
|
-
const
|
|
89
|
+
const rx = active;
|
|
90
90
|
active = null;
|
|
91
|
-
await
|
|
91
|
+
await rx.close();
|
|
92
92
|
}
|
|
93
93
|
});
|
|
94
94
|
|
|
@@ -215,15 +215,18 @@ const RETRY_TEST_TIMEOUT_MS = RETRY_WAIT_CAP_MS + WAIT_SLACK_MS;
|
|
|
215
215
|
const DEAD_LETTER_TEST_TIMEOUT_MS = RETRY_WAIT_CAP_MS * 2 + WAIT_SLACK_MS;
|
|
216
216
|
|
|
217
217
|
/** Register the suite receiver; null (with a note) when the host's SSRF guard refuses a loopback URL. */
|
|
218
|
-
async function register(
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
async function register(rx: ScopedReceiver): Promise<{ webhookId: string } | null> {
|
|
219
|
+
// `rx.url` is this exercise's own destination already — the front (when
|
|
220
|
+
// wired) plus this receiver's nonce path. It is no longer run through
|
|
221
|
+
// `resolveRegistrationUrl`, which returned the front VERBATIM and so dropped
|
|
222
|
+
// the path that makes the subscription ours.
|
|
223
|
+
const reg = await driver.post('/webhooks', { url: rx.url, events: ['run.completed'] });
|
|
221
224
|
if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') {
|
|
222
|
-
if (!
|
|
225
|
+
if (!rx.tunnelled) {
|
|
223
226
|
softSkip('blocked', 'host SSRF guard rejected the loopback receiver (webhooks.md §Egress requires it); set OPENWOP_WEBHOOK_RECEIVER_URL to a public https tunnel in front of the suite receiver');
|
|
224
227
|
return null;
|
|
225
228
|
}
|
|
226
|
-
expect.fail(`host rejected the operator-supplied public https receiver (${
|
|
229
|
+
expect.fail(`host rejected the operator-supplied public https receiver (${rx.url}) with webhook_url_rejected — a public https destination is legitimate under webhooks.md §Egress`);
|
|
227
230
|
}
|
|
228
231
|
expect(reg.status, req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
|
|
229
232
|
const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
|
|
@@ -239,8 +242,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
239
242
|
if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to deliver`);
|
|
240
243
|
|
|
241
244
|
const receiver = await startReceiver(FAIL_FIRST); // 500, 500, then 204
|
|
242
|
-
active = receiver
|
|
243
|
-
const sub = await register(receiver
|
|
245
|
+
active = receiver;
|
|
246
|
+
const sub = await register(receiver);
|
|
244
247
|
if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
245
248
|
|
|
246
249
|
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
@@ -258,9 +261,22 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
258
261
|
const ours = () => receiver.attempts.filter((a) => a.runId === runId && a.webhookId === sub.webhookId);
|
|
259
262
|
const retried = await waitFor(() => ours().some((a) => a.status === 204), retryWaitMs(doc));
|
|
260
263
|
const attempts = ours();
|
|
264
|
+
// A zero that is PROVABLY not a verdict about the host records `blocked`
|
|
265
|
+
// with its cause, not `executed-fail` (2.37.0). `absenceIsUnmeasured` is
|
|
266
|
+
// true only when other traffic reached this listener — the path from the
|
|
267
|
+
// host to this process works, so what is absent is this exercise's
|
|
268
|
+
// IDENTITY, not delivery. That was the ordinary case on a tunnelled cut
|
|
269
|
+
// until this file stopped sharing one byte-identical destination with three
|
|
270
|
+
// others. `blocked` denies certification exactly as a failure does (RFC
|
|
271
|
+
// 0168 §E.1), so nothing is softened. When nothing reached the listener at
|
|
272
|
+
// all the reading is still ambiguous, and the hard assertion below stands —
|
|
273
|
+
// now carrying the address it was waiting on.
|
|
274
|
+
if (attempts.length === 0 && absenceIsUnmeasured(receiver)) {
|
|
275
|
+
return blockedDespiteAssertions(noDeliveryCause(receiver, 'run.completed attempt for this run'));
|
|
276
|
+
}
|
|
261
277
|
expect(
|
|
262
278
|
attempts.length,
|
|
263
|
-
req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability',
|
|
279
|
+
req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability', `the host MUST attempt delivery of run.completed for THIS run to the registered subscriber — ${noDeliveryCause(receiver, 'run.completed attempt for this run')}`),
|
|
264
280
|
).toBeGreaterThan(0);
|
|
265
281
|
const failedThenSucceeded = attempts.filter((a) => a.status === 500).length;
|
|
266
282
|
expect(
|
|
@@ -327,8 +343,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
327
343
|
if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to deliver`);
|
|
328
344
|
|
|
329
345
|
const receiver = await startReceiver(Number.POSITIVE_INFINITY); // never succeeds
|
|
330
|
-
active = receiver
|
|
331
|
-
const sub = await register(receiver
|
|
346
|
+
active = receiver;
|
|
347
|
+
const sub = await register(receiver);
|
|
332
348
|
if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
333
349
|
|
|
334
350
|
// The row id is the FIRST thing this leg names: `register()` asserts under
|
|
@@ -459,8 +475,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
459
475
|
if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no delivery to exhaust`);
|
|
460
476
|
|
|
461
477
|
const receiver = await startReceiver(Number.POSITIVE_INFINITY); // never succeeds
|
|
462
|
-
active = receiver
|
|
463
|
-
const sub = await register(receiver
|
|
478
|
+
active = receiver;
|
|
479
|
+
const sub = await register(receiver);
|
|
464
480
|
if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
465
481
|
// Same two traps as the leg above (2.34.1): `register()` asserts under the base
|
|
466
482
|
// id, so name this row first; and every `blocked` after it must STAND rather
|