@openwop/openwop-conformance 2.42.2 → 2.42.4
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 +46 -0
- package/README.md +1 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +353 -133
- package/schemas/CORPUS-STAMP.json +10 -10
- package/src/lib/agentOrgChart.ts +21 -4
- package/src/lib/effect-receiver.ts +23 -2
- package/src/lib/toolCatalog.ts +29 -5
- package/src/scenarios/agent-manifest-runtime.test.ts +17 -4
- package/src/scenarios/agent-org-chart-scoping.test.ts +27 -4
- package/src/scenarios/agent-roster-attribution.test.ts +28 -4
- package/src/scenarios/agentPackExport.test.ts +33 -0
- package/src/scenarios/agentPackInstall.test.ts +32 -0
- package/src/scenarios/agentReasoningEvents.test.ts +22 -13
- package/src/scenarios/agents-run-tool-allowlist.test.ts +6 -2
- package/src/scenarios/ai-envelope-shape.test.ts +9 -8
- package/src/scenarios/anonymous-actor-write-gated.test.ts +12 -6
- package/src/scenarios/audit-log-integrity.test.ts +37 -0
- package/src/scenarios/context-budget-transcript-bound.test.ts +14 -4
- package/src/scenarios/context-summarization-replay.test.ts +17 -8
- package/src/scenarios/conversationLifecycle.test.ts +12 -1
- package/src/scenarios/conversationReplayDeterminism.test.ts +19 -3
- package/src/scenarios/cost-attribution.test.ts +12 -0
- package/src/scenarios/debug-bundle-truncation.test.ts +16 -3
- package/src/scenarios/distillation-shape.test.ts +30 -0
- package/src/scenarios/envelope-truncated.test.ts +6 -2
- package/src/scenarios/feedback-fork-not-copied.test.ts +28 -7
- package/src/scenarios/idempotency-concurrent-claim.test.ts +27 -9
- package/src/scenarios/idempotency-key-determinism.test.ts +6 -2
- package/src/scenarios/inbound-credential-no-passthrough.test.ts +13 -2
- package/src/scenarios/mcp-server-untrusted-args.test.ts +20 -5
- package/src/scenarios/mcp-tool-roundtrip.test.ts +7 -21
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +11 -3
- package/src/scenarios/orchestratorDispatch.test.ts +18 -2
- package/src/scenarios/pack-registry.test.ts +8 -1
- package/src/scenarios/production-backpressure.test.ts +17 -11
- package/src/scenarios/redaction.test.ts +8 -2
- package/src/scenarios/replay-fanout-suppression.test.ts +18 -2
- package/src/scenarios/runner-ledger.test.ts +7 -2
- package/src/scenarios/stream-modes.test.ts +27 -0
- package/src/scenarios/tool-catalog-projection.test.ts +35 -12
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +17 -3
- package/src/scenarios/v2-artifact-a2a-shape.test.ts +8 -2
- package/src/scenarios/v2-bound-id-kinds.test.ts +13 -4
- package/src/scenarios/v2-effect-identity-business-key.test.ts +71 -13
- package/src/scenarios/v2-effect-seam-manifest.test.ts +3 -9
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +52 -9
- package/src/scenarios/v2-idempotency-in-flight.test.ts +16 -0
- package/src/scenarios/v2-interop-trace-context.test.ts +23 -0
- package/src/scenarios/v2-mcp-tasks.test.ts +33 -6
- package/src/scenarios/v2-min-client-version.test.ts +11 -1
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +11 -0
- package/src/scenarios/v2-poll-cursor-v2.test.ts +28 -5
- package/src/scenarios/v2-profiles-derived-only.test.ts +9 -2
- package/src/scenarios/v2-run-diff-identical.test.ts +13 -5
- package/src/scenarios/v2-run-pause-resume.test.ts +4 -1
- package/src/scenarios/v2-sse-last-event-id.test.ts +7 -0
- package/src/scenarios/v2-version-header-honored.test.ts +22 -3
- package/src/scenarios/v2-webhook-delivery-isolation.test.ts +28 -8
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +24 -0
- package/src/scenarios/v2-webhook-endpoint-verification.test.ts +25 -6
- package/src/scenarios/v2-webhook-message-id-stable.test.ts +10 -2
- package/src/scenarios/v2-webhook-unregister-stops-delivery.test.ts +6 -1
- package/src/scenarios/version-negotiation.test.ts +12 -1
- package/src/scenarios/webhook-signed-delivery.test.ts +18 -3
|
@@ -69,6 +69,13 @@ describe('v2 sse-last-event-id (events.md §SSE frames)', () => {
|
|
|
69
69
|
expect(ids.every((n) => n !== null), req(ID, DOC, `every frame MUST carry id: = its sequence (frames without a parseable id: ${ids.filter((n) => n === null).length} of ${ids.length})`)).toBe(true);
|
|
70
70
|
const seqs = ids as number[];
|
|
71
71
|
expect([...seqs].sort((a, b) => a - b), req(ID, DOC, 'frames MUST arrive in log order')).toEqual(seqs);
|
|
72
|
+
// unfailable-leg audit wave 2, 2026-09-27: with a single frame, `first` ===
|
|
73
|
+
// `last` and the mid-stream resume leg compared two empty sets — a host
|
|
74
|
+
// that ignored Last-Event-ID on a resume with backlog could not fail it. A
|
|
75
|
+
// completed run's log holds at least `run.started` (events.md: it carries
|
|
76
|
+
// the owner block) and its terminal event (§The terminal event), and debug
|
|
77
|
+
// mode streams every event, so ≥2 frames is required, not assumed.
|
|
78
|
+
expect(seqs.length, req(ID, DOC, `a completed run's debug stream MUST carry at least run.started and its terminal event (got ${seqs.length} frame(s))`)).toBeGreaterThanOrEqual(2);
|
|
72
79
|
const first = seqs[0]!; const last = seqs[seqs.length - 1]!;
|
|
73
80
|
|
|
74
81
|
const resumed = await subscribe(path, { timeoutMs: 8_000, extraHeaders: V2, lastEventId: String(first) });
|
|
@@ -145,10 +145,29 @@ describe('v2-version-header-honored (RFC 0172 §A.3)', () => {
|
|
|
145
145
|
// A refusal is a correct answer and ends the check: the host has told the
|
|
146
146
|
// truth about not serving major 2.
|
|
147
147
|
if (asked.status === 406) {
|
|
148
|
+
// unfailable-leg audit wave 2, 2026-09-27: this branch asserted only the
|
|
149
|
+
// status it had just branched on (`406 === 406`), so ANY 406 passed — a
|
|
150
|
+
// host that lists `2.x` in protocolVersions[] and still refuses it
|
|
151
|
+
// (§1.3 row 1: MUST serve a listed major), or one answering 406 with a
|
|
152
|
+
// bare `{}` body. The refusal is now held to §1.3 row 2 exactly as the
|
|
153
|
+
// retired-host branch above is: code `protocol_version_unsupported`,
|
|
154
|
+
// `details.protocolVersions[]` echoing the list, and major 2 NOT listed.
|
|
155
|
+
let refusal: unknown;
|
|
156
|
+
try {
|
|
157
|
+
refusal = JSON.parse(asked.body);
|
|
158
|
+
} catch {
|
|
159
|
+
refusal = undefined;
|
|
160
|
+
}
|
|
161
|
+
const echoed406 = (refusal as { details?: { protocolVersions?: unknown } } | undefined)?.details?.protocolVersions;
|
|
148
162
|
expect(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
163
|
+
{
|
|
164
|
+
status: asked.status,
|
|
165
|
+
code: readErrorCode(refusal),
|
|
166
|
+
echoed: Array.isArray(echoed406) ? [...echoed406].map(String).sort() : echoed406,
|
|
167
|
+
listsMajor2: majors.has('2'),
|
|
168
|
+
},
|
|
169
|
+
req(ID, DOC, `a host that does not serve major 2 MUST refuse OpenWOP-Version: 2.0 with 406 protocol_version_unsupported and details.protocolVersions[] echoing [${advertised.join(', ')}]; a host that LISTS a 2.x member MUST serve it (§1.3 row 1), not refuse it`),
|
|
170
|
+
).toEqual({ status: 406, code: 'protocol_version_unsupported', echoed: [...advertised].sort(), listsMajor2: false });
|
|
152
171
|
return softSkip('inapplicable', 'the host refused major 2 with the specified 406 — a correct answer, and there is no second representation to compare bytes against');
|
|
153
172
|
}
|
|
154
173
|
if (asked.status !== 200) {
|
|
@@ -26,11 +26,13 @@
|
|
|
26
26
|
* host released one to make room), or never within the window, AND the held
|
|
27
27
|
* attempts had stayed open until the run was terminal: the contention the
|
|
28
28
|
* floor names existed when the healthy delivery fell due, and it waited.
|
|
29
|
-
* -
|
|
30
|
-
* before the run was terminal.
|
|
31
|
-
* healthy delivery fell due, so this
|
|
32
|
-
* timeout is the host's to choose; a
|
|
33
|
-
* DELAY_MS is simply not measured by this
|
|
29
|
+
* - blocked (unjudged) — the host had all 8 held attempts open at once, but
|
|
30
|
+
* its own delivery timeout closed them before the run was terminal.
|
|
31
|
+
* Contention was not sustained when the healthy delivery fell due, so this
|
|
32
|
+
* run of the leg cannot judge §A. The timeout is the host's to choose; a
|
|
33
|
+
* host with a timeout shorter than DELAY_MS is simply not measured by this
|
|
34
|
+
* instrument. If fewer than 8 were EVER open at once (a serial or small
|
|
35
|
+
* pool), it fails instead (2026-09-26, unfailable-leg audit).
|
|
34
36
|
* - blocked — fewer than 8 held attempts ever arrived AND the healthy one
|
|
35
37
|
* never did either: nothing reached the suite, which says nothing about
|
|
36
38
|
* isolation (`noDeliveryCause`).
|
|
@@ -121,6 +123,10 @@ describe('RFC 0215 §A — one subscription\'s receiver does not hold another\'s
|
|
|
121
123
|
let healthyId: string | null = null;
|
|
122
124
|
let healthyAt: number | null = null;
|
|
123
125
|
let openAtHealthy = -1;
|
|
126
|
+
// The most held attempts ever open at once. Sampled at each held arrival:
|
|
127
|
+
// the open count only rises there (closes only lower it), so the max over
|
|
128
|
+
// arrivals is the true peak.
|
|
129
|
+
let peakOpen = 0;
|
|
124
130
|
const openCount = (): number => [...held.values()].filter((h) => h.closedAt === null).length;
|
|
125
131
|
|
|
126
132
|
const rx = await startScopedReceiver((hit, res) => {
|
|
@@ -137,6 +143,7 @@ describe('RFC 0215 §A — one subscription\'s receiver does not hold another\'s
|
|
|
137
143
|
}
|
|
138
144
|
const h: Held = { webhookId, res, openedAt: Date.now(), closedAt: null };
|
|
139
145
|
held.set(webhookId, h);
|
|
146
|
+
peakOpen = Math.max(peakOpen, openCount());
|
|
140
147
|
// The HOST closing the socket before we answer is the host abandoning the attempt.
|
|
141
148
|
res.on('close', () => { if (h.closedAt === null) h.closedAt = Date.now(); });
|
|
142
149
|
});
|
|
@@ -175,7 +182,7 @@ describe('RFC 0215 §A — one subscription\'s receiver does not hold another\'s
|
|
|
175
182
|
const everOpened = held.size;
|
|
176
183
|
await waitFor(() => healthyAt !== null, WINDOW_MS);
|
|
177
184
|
const earliestClose = Math.min(...[...held.values()].map((h) => h.closedAt ?? Infinity));
|
|
178
|
-
const detail = `${everOpened} held attempt(s) arrived; ${openAtTerminal} were still open when the run was seen terminal; `
|
|
185
|
+
const detail = `${everOpened} held attempt(s) arrived (at most ${peakOpen} open at once); ${openAtTerminal} were still open when the run was seen terminal; `
|
|
179
186
|
+ (healthyAt === null ? `the healthy attempt did not arrive within ${WINDOW_MS}ms of that` : `the healthy attempt arrived ${healthyAt - terminalAt}ms after it, with ${openAtHealthy} held open`);
|
|
180
187
|
|
|
181
188
|
if (healthyAt === null && everOpened === 0) {
|
|
@@ -193,8 +200,21 @@ describe('RFC 0215 §A — one subscription\'s receiver does not hold another\'s
|
|
|
193
200
|
// delivery timeout released the held attempts early (its choice), or it
|
|
194
201
|
// never opened FLOOR of them at once. The second is the defect, the first
|
|
195
202
|
// is not, and they differ in WHEN the first held attempt closed.
|
|
196
|
-
|
|
197
|
-
|
|
203
|
+
//
|
|
204
|
+
// Unfailable-leg audit (2026-09-26): this branch asserted everOpened > 0
|
|
205
|
+
// and then `softSkip('skipped')`, which records `executed-pass` with a
|
|
206
|
+
// `partial-witness:` detail. A serial dispatcher (pool of 1) with a
|
|
207
|
+
// sub-2s delivery timeout landed here and passed — every held attempt
|
|
208
|
+
// arrived eventually (everOpened reached FLOOR), one at a time, while the
|
|
209
|
+
// healthy attempt queued behind them: head-of-line blocking. peakOpen
|
|
210
|
+
// separates the two cases the comment above names: below FLOOR the host
|
|
211
|
+
// never had FLOOR attempts outstanding at once, which is the defect; at
|
|
212
|
+
// FLOOR the host's timeout released them early, which is unjudged.
|
|
213
|
+
expect(
|
|
214
|
+
peakOpen,
|
|
215
|
+
req(ID, DOC, `a host MUST sustain ${FLOOR} subscriptions' attempts outstanding at once — at most ${peakOpen} were ever open together, so the held attempts were dispatched below the floor — ${detail}`),
|
|
216
|
+
).toBeGreaterThanOrEqual(FLOOR);
|
|
217
|
+
return blockedDespiteAssertions(`unjudged: ${FLOOR} held attempts were open at once, but the host closed held attempts ${Number.isFinite(earliestClose) ? `${terminalAt - earliestClose}ms ` : ''}before the run was terminal (its delivery timeout is shorter than this leg's ${DELAY_MS}ms delay), so ${FLOOR} attempts were not outstanding when the healthy delivery fell due — ${detail}`);
|
|
198
218
|
} else {
|
|
199
219
|
expect.fail(req(ID, DOC, `the host never had ${FLOOR} subscriptions' attempts outstanding at once, and the healthy attempt waited — a bounded dispatcher below the floor — ${detail}`));
|
|
200
220
|
}
|
|
@@ -348,6 +348,22 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
348
348
|
req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability', `retry attempts for ${key} MUST be spaced by the advertised ${policy.backoff} backoff (attempt ${i + 1} followed attempt ${i} after ${times[i]! - times[i - 1]!}ms)`),
|
|
349
349
|
).toBeGreaterThan(0);
|
|
350
350
|
}
|
|
351
|
+
// Unfailable-leg audit (2026-09-26): `> 0 ms` alone let a host advertising
|
|
352
|
+
// `backoff: exponential` that retried in a tight loop (any constant
|
|
353
|
+
// spacing, even a few ms) pass. An exponential backoff MUST widen, so with
|
|
354
|
+
// three or more attempts the second gap is at least 1.5x the first (not
|
|
355
|
+
// 2x: jitter tolerance). Only the first two gaps are compared, so a host
|
|
356
|
+
// whose schedule hits its cap later is not convicted. `fixed` gets no
|
|
357
|
+
// equivalent: its base interval is not on the wire (see the comment above
|
|
358
|
+
// the `!retried` branch), so there is no floor to measure against.
|
|
359
|
+
if (policy.backoff === 'exponential' && times.length >= 3) {
|
|
360
|
+
const gap1 = times[1]! - times[0]!;
|
|
361
|
+
const gap2 = times[2]! - times[1]!;
|
|
362
|
+
expect(
|
|
363
|
+
gap2,
|
|
364
|
+
req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability', `retry attempts for ${key} MUST follow the advertised exponential backoff — the second gap (${gap2}ms) MUST be at least 1.5x the first (${gap1}ms); a constant spacing is not exponential`),
|
|
365
|
+
).toBeGreaterThanOrEqual(1.5 * gap1);
|
|
366
|
+
}
|
|
351
367
|
}
|
|
352
368
|
} else {
|
|
353
369
|
softSkip('inapplicable', 'no advertised retryPolicy.backoff other than none — the spacing leg is not asserted');
|
|
@@ -443,6 +459,14 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
443
459
|
).toBeLessThanOrEqual(policy.maxAttempts);
|
|
444
460
|
}
|
|
445
461
|
if (inSink === null) {
|
|
462
|
+
// partial-witness-ok: the retry half of §Durability is observed above
|
|
463
|
+
// (retried, and capped at maxAttempts). The sink half has no read on a
|
|
464
|
+
// host without the webhooks.deadLetter facet (RFC 0188 §A.5 makes the read
|
|
465
|
+
// a 404, not an obligation), so it is unwitnessable here BY DESIGN. It is
|
|
466
|
+
// not a harness gap that `blocked` would honestly name: a bundle-fatal
|
|
467
|
+
// `blocked` would deny certification to every conforming host that
|
|
468
|
+
// declines the optional read. Reviewed in the unfailable-leg audit wave 2
|
|
469
|
+
// (2026-09-27); the acceptance predicate still refuses this row.
|
|
446
470
|
return softSkip('inapplicable', 'host does not advertise the webhooks.deadLetter facet — RFC 0188 §A.5 makes the read a 404 rather than an obligation, so the sink half of §Durability is unwitnessable here (the retry half above passed)');
|
|
447
471
|
}
|
|
448
472
|
expect(
|
|
@@ -38,9 +38,10 @@
|
|
|
38
38
|
import { afterEach, describe, it, expect } from 'vitest';
|
|
39
39
|
import { req } from '../lib/requirement-ids.js';
|
|
40
40
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
41
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
41
|
+
import { softSkip, blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
42
42
|
import { v2Validator } from '../lib/v2.js';
|
|
43
|
-
import {
|
|
43
|
+
import { scaledTimeoutMs } from '../lib/polling.js';
|
|
44
|
+
import { hitHeader, mintWhsec, startModalReceiver, verifyStandardWebhooks, type ModalHit } from '../lib/webhook-receiver.js';
|
|
44
45
|
import {
|
|
45
46
|
STANDARD_WEBHOOKS_ALG,
|
|
46
47
|
deliveriesFor,
|
|
@@ -53,6 +54,9 @@ import {
|
|
|
53
54
|
} from '../lib/standard-webhooks.js';
|
|
54
55
|
|
|
55
56
|
const REFUSED = ['no-echo', 'wrong-echo', 'redirect'] as const;
|
|
57
|
+
/** A second echo subscription, registered AFTER the refused modes (routed as `echo`, told apart by this trailing segment). */
|
|
58
|
+
const LATE_CONTROL = 'late-control';
|
|
59
|
+
const isLateControl = (h: ModalHit): boolean => h.path.split('?')[0]!.endsWith(`/echo/${LATE_CONTROL}`);
|
|
56
60
|
|
|
57
61
|
let closeReceiver: (() => Promise<void>) | null = null;
|
|
58
62
|
afterEach(async () => {
|
|
@@ -80,10 +84,18 @@ describe('RFC 0201 §D — endpoint verification refuses without consent (opted-
|
|
|
80
84
|
const res = await registerSw(body(mode));
|
|
81
85
|
refused.set(mode, { status: res.status, code: readErrorCode(res.json), id: (res.json as { webhookId?: unknown } | null)?.webhookId });
|
|
82
86
|
}
|
|
87
|
+
// Unfailable-leg audit, 2026-09-26: the persisted-nothing check synced only
|
|
88
|
+
// on the FIRST echo subscription plus a 1 s grace, so a host that answered
|
|
89
|
+
// 400 but kept the rows, delivering serially in registration order, could
|
|
90
|
+
// land those deliveries after the window. A second echo control registered
|
|
91
|
+
// AFTER the refused modes sits behind them in that order.
|
|
92
|
+
const late = await registerSw({ ...body('echo'), url: `${rx.urlFor('echo').url}/${LATE_CONTROL}` });
|
|
83
93
|
|
|
84
94
|
const ID = 'openwop.requirement.0201.endpoint-verification';
|
|
85
95
|
expect(ok.status, req(ID, 'RFC 0201 §D.14', `an endpoint that echoes the challenge MUST be registered 201 (got ${ok.status} ${readErrorCode(ok.json) ?? ''})`)).toBe(201);
|
|
86
96
|
const echoId = (ok.json as { webhookId: string }).webhookId;
|
|
97
|
+
expect(late.status, req(ID, 'RFC 0201 §D.14', `a second echoing endpoint (the late control) MUST be registered 201 (got ${late.status} ${readErrorCode(late.json) ?? ''})`)).toBe(201);
|
|
98
|
+
const lateId = (late.json as { webhookId: string }).webhookId;
|
|
87
99
|
for (const [mode, r] of refused) {
|
|
88
100
|
expect(r.status, req(ID, 'RFC 0201 §D.14', `a ${mode} endpoint MUST be refused 400 before any 201 (got ${r.status})`)).toBe(400);
|
|
89
101
|
expect(r.code, req(ID, 'RFC 0201 §D.14', `a ${mode} refusal MUST carry webhook_endpoint_unverified`)).toBe('webhook_endpoint_unverified');
|
|
@@ -91,7 +103,7 @@ describe('RFC 0201 §D — endpoint verification refuses without consent (opted-
|
|
|
91
103
|
|
|
92
104
|
const validate = v2Validator('webhook-verification');
|
|
93
105
|
for (const mode of ['echo', ...REFUSED]) {
|
|
94
|
-
const vr = rx.hits.filter((h) => h.mode === mode && h.verification);
|
|
106
|
+
const vr = rx.hits.filter((h) => h.mode === mode && h.verification && !isLateControl(h));
|
|
95
107
|
expect(vr.length, req(ID, 'RFC 0201 §D.13–§D.14', `the host MUST send exactly one verification request to the ${mode} endpoint and MUST NOT retry it (saw ${vr.length})`)).toBe(1);
|
|
96
108
|
const h = vr[0]!;
|
|
97
109
|
expect(h.method, req(ID, 'RFC 0201 §D.13', 'the verification request MUST be a POST')).toBe('POST');
|
|
@@ -113,12 +125,19 @@ describe('RFC 0201 §D — endpoint verification refuses without consent (opted-
|
|
|
113
125
|
// delivery; by then any persisted refused subscription would have one too.
|
|
114
126
|
const run = await driveRun();
|
|
115
127
|
expect(run.status, req(ID, 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
|
|
116
|
-
await waitFor(() => deliveriesFor(rx.hits, echoId, run.runId).length > 0, 15_000);
|
|
128
|
+
await waitFor(() => deliveriesFor(rx.hits, echoId, run.runId).length > 0 && deliveriesFor(rx.hits, lateId, run.runId).length > 0, 15_000);
|
|
117
129
|
expect(deliveriesFor(rx.hits, echoId, run.runId).length, req(ID, 'webhooks.md §Durability', 'the verified subscription MUST receive run.completed — the sync point for the persisted-nothing check')).toBeGreaterThan(0);
|
|
118
|
-
|
|
130
|
+
// The late control is a SYNC POINT, not a requirement: a public receiver
|
|
131
|
+
// front that drops its extra path segment would starve it through no fault
|
|
132
|
+
// of the host. Unobserved ⇒ the persisted-nothing check is unjudged, never
|
|
133
|
+
// failed and never passed.
|
|
134
|
+
if (deliveriesFor(rx.hits, lateId, run.runId).length === 0) {
|
|
135
|
+
return blockedDespiteAssertions(`the late-control subscription (${lateId}) received no run.completed within the wait — the second sync point for the persisted-nothing check is missing, so that check is unjudged`);
|
|
136
|
+
}
|
|
137
|
+
await new Promise((r) => setTimeout(r, scaledTimeoutMs(3_000)));
|
|
119
138
|
for (const mode of REFUSED) {
|
|
120
139
|
const late = rx.hits.filter((h) => h.mode === mode && !h.verification);
|
|
121
140
|
expect(late.length, req(ID, 'RFC 0201 §D.14', `a refused (${mode}) registration MUST persist no subscription — it received ${late.length} later request(s)`)).toBe(0);
|
|
122
141
|
}
|
|
123
|
-
},
|
|
142
|
+
}, 75_000);
|
|
124
143
|
});
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
import { afterEach, describe, it, expect } from 'vitest';
|
|
32
32
|
import { req } from '../lib/requirement-ids.js';
|
|
33
33
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
34
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
34
|
+
import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
|
|
35
35
|
import { hitHeader, mintWhsec, startModalReceiver, STANDARD_WEBHOOKS_ID, type ModalHit } from '../lib/webhook-receiver.js';
|
|
36
36
|
import { retryWaitCapMs, retryWaitFor } from '../lib/webhook-retry-window.js';
|
|
37
37
|
import {
|
|
@@ -108,11 +108,19 @@ describe('RFC 0201 §C.10 — webhook-id is stable across retries, distinct acro
|
|
|
108
108
|
req(ID, 'RFC 0201 §C.10', `webhook-id MUST be identical on every attempt of one (webhookId, runId, sequence) — ${attempts.length} attempts of ${key} carried ${ids.size} value(s)`),
|
|
109
109
|
).toHaveLength(1);
|
|
110
110
|
}
|
|
111
|
+
// Unfailable-leg audit, 2026-09-26: the distinctness half asserted only
|
|
112
|
+
// `keys.size >= 2`, so a host coalescing the two subscriptions (or keeping a
|
|
113
|
+
// per-subscription constant id with one delivery each) passed on a partial
|
|
114
|
+
// set. Distinctness is judged only over the full expected set; fewer keys
|
|
115
|
+
// is unmeasured, and the retry-stability assertions above already ran.
|
|
116
|
+
if (keys.size < expectedKeys) {
|
|
117
|
+
return blockedDespiteAssertions(`only ${keys.size} of ${expectedKeys} expected deliveries (${subs.length} subscriptions × ${EVENTS.join(' + ')}) arrived inside the ${window}ms window — distinctness across deliveries cannot be judged on a partial set`);
|
|
118
|
+
}
|
|
111
119
|
const perKey = [...keys.entries()].map(([key, a]) => ({ key, id: hitHeader(a[0]!, 'webhook-id') ?? '' }));
|
|
112
120
|
for (const { key, id } of perKey) {
|
|
113
121
|
expect(STANDARD_WEBHOOKS_ID.test(id), req(ID, 'RFC 0201 §C.10', `webhook-id MUST match ^[A-Za-z0-9_-]{16,128}$ (${key}: ${JSON.stringify(id)})`)).toBe(true);
|
|
114
122
|
}
|
|
115
|
-
expect(keys.size, req(ID, 'RFC 0201 §C.10', `two opted-in subscriptions on ${EVENTS.join(' + ')} MUST yield ${expectedKeys} distinct deliveries of one run`)).
|
|
123
|
+
expect(keys.size, req(ID, 'RFC 0201 §C.10', `two opted-in subscriptions on ${EVENTS.join(' + ')} MUST yield ${expectedKeys} distinct deliveries of one run`)).toBe(expectedKeys);
|
|
116
124
|
expect(
|
|
117
125
|
new Set(perKey.map((k) => k.id)).size,
|
|
118
126
|
req(ID, 'RFC 0201 §C.10', `webhook-id MUST differ between distinct deliveries, by event and by subscription — ${perKey.length} deliveries carried ${new Set(perKey.map((k) => k.id)).size} distinct id(s)`),
|
|
@@ -171,8 +171,13 @@ describe('RFC 0215 §B — unregistering stops the attempts (gated on webhooks)'
|
|
|
171
171
|
targetLate.length,
|
|
172
172
|
req(ID, DOC, `after unregisterWebhook answers 204 the host MUST NOT start another attempt for that subscription, including retries already scheduled — ${detail}`),
|
|
173
173
|
).toBe(0);
|
|
174
|
+
// unfailable-leg audit wave 2, 2026-09-27: a plain softSkip after the
|
|
175
|
+
// `targetLate === 0` assert recorded a partial-witness `executed-pass`, so a
|
|
176
|
+
// host that KEPT delivering to an unregistered subscription — but whose
|
|
177
|
+
// retries fell outside the window — certified. Without a late control
|
|
178
|
+
// attempt the target's silence proves nothing: `blocked`.
|
|
174
179
|
if (controlLate.length === 0) {
|
|
175
|
-
return
|
|
180
|
+
return blockedDespiteAssertions(`the control subscription was not retried after the 204 + ${GRACE_MS}ms within ${windowMs}ms either, so the target's silence shows nothing — the schedule finished inside the grace or runs past the window (raise OPENWOP_WEBHOOK_RETRY_WAIT_MS above the host's backoff sum) — ${detail}`);
|
|
176
181
|
}
|
|
177
182
|
}, CAP_MS * 2 + 90_000);
|
|
178
183
|
});
|
|
@@ -74,7 +74,18 @@ describe('version-negotiation: Capabilities advertises a protocolVersion', () =>
|
|
|
74
74
|
'capabilities.md §3 + version-negotiation.md',
|
|
75
75
|
'Capabilities.protocolVersion MUST be a non-empty string',
|
|
76
76
|
)).toBe('string');
|
|
77
|
-
|
|
77
|
+
// unfailable-leg audit wave 2, 2026-09-27: only non-emptiness was checked,
|
|
78
|
+
// so `v1.0`, `1.0.0`, `1` and `banana` passed. RFC 0149 §C constrains
|
|
79
|
+
// protocolVersion to ASCII `<major>.<minor>` with no leading zeros —
|
|
80
|
+
// capabilities.schema.json `protocolVersion.pattern` (the same grammar
|
|
81
|
+
// lib/profiles.ts PROTOCOL_VERSION_GRAMMAR applies to the core predicate).
|
|
82
|
+
expect(
|
|
83
|
+
typeof caps.protocolVersion === 'string' && /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(caps.protocolVersion),
|
|
84
|
+
req('openwop.it.version-negotiation.get-well-known-openwop-returns-capabilities-with-protocolversion-string',
|
|
85
|
+
'RFC 0149 §C; capabilities.schema.json protocolVersion.pattern',
|
|
86
|
+
`Capabilities.protocolVersion MUST be ASCII <major>.<minor> with no leading zeros (got ${JSON.stringify(caps.protocolVersion)})`,
|
|
87
|
+
),
|
|
88
|
+
).toBe(true);
|
|
78
89
|
});
|
|
79
90
|
});
|
|
80
91
|
|
|
@@ -118,6 +118,8 @@ export const REQUIRES_HOST_CALLBACK = 'the host POSTs a signed webhook delivery
|
|
|
118
118
|
interface DeliveredRequest {
|
|
119
119
|
readonly headers: Record<string, string>;
|
|
120
120
|
readonly body: string;
|
|
121
|
+
/** Suite clock (ms) when the receiver recorded the delivery. */
|
|
122
|
+
readonly receivedAtMs: number;
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
async function startReceiver(): Promise<ScopedReceiver & { received: DeliveredRequest[] }> {
|
|
@@ -141,7 +143,7 @@ async function startReceiver(): Promise<ScopedReceiver & { received: DeliveredRe
|
|
|
141
143
|
if (typeof v === 'string') headers[k.toLowerCase()] = v;
|
|
142
144
|
else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(',');
|
|
143
145
|
}
|
|
144
|
-
received.push({ headers, body: hit.body });
|
|
146
|
+
received.push({ headers, body: hit.body, receivedAtMs: Date.now() });
|
|
145
147
|
res.writeHead(204);
|
|
146
148
|
res.end();
|
|
147
149
|
});
|
|
@@ -352,10 +354,23 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
|
|
|
352
354
|
).toBe(sub.webhookId);
|
|
353
355
|
|
|
354
356
|
const timestamp = first.headers['x-openwop-timestamp'];
|
|
357
|
+
// unfailable-leg audit wave 2, 2026-09-27: this only required a non-empty
|
|
358
|
+
// string, so an ISO-8601 date, a milliseconds value, or a stale constant
|
|
359
|
+
// passed "MUST be a Unix-seconds integer" (the HMAC below signs whatever
|
|
360
|
+
// string was sent, so it could not catch it). Now: digits only (≤ 11 —
|
|
361
|
+
// seconds, not ms) and within the ±5 min window webhooks.md §"Verification
|
|
362
|
+
// recipe" tells every subscriber to enforce (a delivery outside it is
|
|
363
|
+
// rejected by a conforming subscriber, so the host's "when the dispatcher
|
|
364
|
+
// signed the body" value must fall inside it at receipt).
|
|
355
365
|
expect(
|
|
356
|
-
typeof timestamp === 'string' && timestamp
|
|
357
|
-
req('openwop.it.webhook-signed-delivery.host-posts-run-events-to-subscriber-with-valid-x-openwop-signature', 'webhooks.md §"Delivery headers"',
|
|
366
|
+
typeof timestamp === 'string' && /^\d{1,11}$/.test(timestamp),
|
|
367
|
+
req('openwop.it.webhook-signed-delivery.host-posts-run-events-to-subscriber-with-valid-x-openwop-signature', 'webhooks.md §"Delivery headers"', `X-openwop-Timestamp MUST be a Unix-seconds integer string (got ${JSON.stringify(timestamp)})`),
|
|
358
368
|
).toBe(true);
|
|
369
|
+
const skewSeconds = Math.abs(Number(timestamp) - first.receivedAtMs / 1000);
|
|
370
|
+
expect(
|
|
371
|
+
skewSeconds,
|
|
372
|
+
req('openwop.it.webhook-signed-delivery.host-posts-run-events-to-subscriber-with-valid-x-openwop-signature', 'webhooks.md §"Delivery headers"; §"Verification recipe" step 1 (±5 minutes)', `X-openwop-Timestamp MUST be the signing time — within ±300 s of receipt (off by ${Math.round(skewSeconds)} s)`),
|
|
373
|
+
).toBeLessThan(300);
|
|
359
374
|
|
|
360
375
|
const signature = first.headers['x-openwop-signature'] ?? '';
|
|
361
376
|
expect(
|