openvibers/openvibe.services Commits

Services: the webhook tester imitates Events' delivery without a sequence number (#40)

OP OpenVibers committed 11 Oct 2026 · 6003ec1bee · 3 files, +12 −13

Plan T7, ADR-042 decision 7, the last step: Events delivers `{ "event": <envelope> }` with no sequence number and no `X-OpenVibe-Seq` (Contracts 0.136.1, Events#60). The webhook tester and the signed-event inspector at `/tools/webhooks` described and imitated the old shape.

- **Sample delivery:** `{ event }`, without `X-OpenVibe-Seq`; still signed with the SDK's `signDeliveryHeaders`.
- **Inspector:** recognises an Events delivery by `event` alone ("JSON, but not an Events delivery ({ event })") and no longer shows a seq.
- **The page text:** the body is `{"event": <envelope>}`, deduped on the event id.

Test `test/webhooks.test.js`:
- an inspected delivery reports no seq;
- tampering is now detected through the envelope (the old check altered the seq);
- the sample's body has no `seq` and its headers no `X-OpenVibe-Seq`.

The tools-page tests (discovery, auth-ops, secrets) pass.

Changes

ddiff --git a/server/domain/webhooks.js b/server/domain/webhooks.js
iindex 84f3f90..6b0c366 100644
---- a/server/domain/webhooks.js
++++ b/server/domain/webhooks.js
@@@ -3,8 +3,8 @@
/**
* Webhook tester and signed-event inspector.
*
-- * OpenVibe.Events delivers `{ "event": <events.event-envelope@1>, "seq": <n> }` signed under the
-- * subscription secret:
++ * OpenVibe.Events delivers `{ "event": <events.event-envelope@1> }` (no sequence number: a receiver dedupes on
++ * event.event_id; ADR-042) signed under the subscription secret:
* X-OpenVibe-Timestamp: <unix seconds this attempt was sent>
* X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">
* Every consumer requires v2 and refuses it more than 300 s from its own clock (replay window).
@@@ -94,9 +94,9 @@ function inspect({ rawBody, signatureV2, timestamp, secret, now = Date.now() })
const obj = JSON.parse(body);
if (obj && typeof obj === 'object' && obj.event) {
const v = contracts.validate('events.event-envelope@1', obj.event);
-- parsed = { event_type: obj.event.event_type || null, event_id: obj.event.event_id || null, seq: obj.seq ?? null, envelopeValid: v.valid, envelopeErrors: v.errors };
++ parsed = { event_type: obj.event.event_type || null, event_id: obj.event.event_id || null, envelopeValid: v.valid, envelopeErrors: v.errors };
} else {
-- parsed = { note: 'JSON, but not an Events delivery ({ event, seq })' };
++ parsed = { note: 'JSON, but not an Events delivery ({ event })' };
}
} catch { parsed = { note: 'not JSON' }; }
return { accepted: v2.valid, v2, bodyBytes: Buffer.byteLength(body), parsed };
@@@ -123,15 +123,13 @@ function sample({ eventType, secret, producer, now = Date.now() }) {
trace_id: crypto.randomBytes(16).toString('hex'),
};
const check = contracts.validate('events.event-envelope@1', envelope);
-- const seq = 1;
-- const body = JSON.stringify({ event: envelope, seq });
++ const body = JSON.stringify({ event: envelope });
const subscriptionId = `sub_${contracts.ids.ulid(now)}`;
const headers = {
'Content-Type': 'application/json',
'User-Agent': 'OpenVibe.Events/0.1',
'X-OpenVibe-Event-Id': envelope.event_id,
'X-OpenVibe-Event-Type': eventType,
-- 'X-OpenVibe-Seq': String(seq),
'X-OpenVibe-Subscription-Id': subscriptionId,
'X-OpenVibe-Delivery-Attempt': '1',
'X-OpenVibe-Hops': '0',
ddiff --git a/server/http/tools.js b/server/http/tools.js
iindex 0c52276..50b50cd 100644
---- a/server/http/tools.js
++++ b/server/http/tools.js
@@@ -95,7 +95,7 @@ ${got.code ? html`<h2>Exchange it (on your server)</h2>
viewer: req.viewer, config, path: '/tools/webhooks', title: 'Webhook tester', index: true, scripts: ['js/webhook.js'],
crumbs: [{ label: 'Webhooks' }],
body: html`<h1>Webhook tester and signed-event inspector</h1>
--<p>OpenVibe.Events delivers each event as <code>POST</code> with the body <code>{"event": &lt;envelope&gt;, "seq": &lt;n&gt;}</code> and these headers, signed with your subscription secret:</p>
++<p>OpenVibe.Events delivers each event as <code>POST</code> with the body <code>{"event": &lt;envelope&gt;}</code> (no sequence number: dedupe on the event id) and these headers, signed with your subscription secret:</p>
<ul>
<li><code>X-OpenVibe-Timestamp: &lt;unix seconds&gt;</code>, the time this attempt was sent. Every retry gets a new one.</li>
<li><code>X-OpenVibe-Signature-V2: t=&lt;that timestamp&gt;,v2=&lt;hex HMAC-SHA256 of "&lt;t&gt;.&lt;raw body&gt;"&gt;</code> (v2).</li>
@@@ -134,7 +134,7 @@ ${v2.timestampsAgree != null ? html`<dt>X-OpenVibe-Timestamp</dt><dd>${code(v2.s
<dt>Expected</dt><dd>${code(v2.expected || '(needs a t= value)')}</dd><dt>Given</dt><dd>${code(v2.given || '(none)')}</dd></dl>
<h3>Body</h3>
<dl class="facts"><dt>Size</dt><dd>${v.bodyBytes} bytes</dd>
--${v.parsed && v.parsed.event_type ? html`<dt>Event</dt><dd>${code(v.parsed.event_type)} ${code(v.parsed.event_id || '')} seq ${v.parsed.seq}; envelope ${v.parsed.envelopeValid ? 'validates' : html`does not validate: ${v.parsed.envelopeErrors.map((e) => `${e.path} ${e.message}`).join('; ')}`}</dd>` : ''}
++${v.parsed && v.parsed.event_type ? html`<dt>Event</dt><dd>${code(v.parsed.event_type)} ${code(v.parsed.event_id || '')}; envelope ${v.parsed.envelopeValid ? 'validates' : html`does not validate: ${v.parsed.envelopeErrors.map((e) => `${e.path} ${e.message}`).join('; ')}`}</dd>` : ''}
${v.parsed && v.parsed.note ? html`<dt>Shape</dt><dd>${v.parsed.note}</dd>` : ''}</dl></div>`;
}
ddiff --git a/test/webhooks.test.js b/test/webhooks.test.js
iindex c300c6d..615258e 100644
---- a/test/webhooks.test.js
++++ b/test/webhooks.test.js
@@@ -19,7 +19,7 @@ const hmacV1 = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).u
(async () => {
const secret = 'whsec_' + crypto.randomBytes(16).toString('hex');
-- const body = JSON.stringify({ event: { event_id: contracts.ids.newId('event'), event_type: 'network.app.created', version: 1, source: 'network', actor: { type: 'system', id: 'network' }, timestamp: new Date().toISOString(), subject: { type: 'app', id: 'app_1' }, payload: {} }, seq: 7 });
++ const body = JSON.stringify({ event: { event_id: contracts.ids.newId('event'), event_type: 'network.app.created', version: 1, source: 'network', actor: { type: 'system', id: 'network' }, timestamp: new Date().toISOString(), subject: { type: 'app', id: 'app_1' }, payload: {} } });
// A clock 0.7 s into a second: ages are whole seconds, the window is checked on the exact time.
const nowSec = Math.floor(Date.now() / 1000);
@@@ -37,7 +37,7 @@ const hmacV1 = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).u
assert.strictEqual(v.v2.expected, v2(body, secret, nowSec));
assert.strictEqual(v.v2.windowSec, 300);
assert.strictEqual(v.parsed.event_type, 'network.app.created');
-- assert.strictEqual(v.parsed.seq, 7);
++ assert.strictEqual('seq' in v.parsed, false, 'Events sends no sequence number (ADR-042)');
assert.strictEqual(v.bodyBytes, Buffer.byteLength(body));
});
@@@ -81,7 +81,7 @@ const hmacV1 = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).u
assert.strictEqual(wrongSecret.accepted, false);
assert.strictEqual(wrongSecret.v2.signatureValid, false);
assert.match(wrongSecret.v2.reason, /does not match/);
-- assert.strictEqual(run({ rawBody: body.replace('"seq":7', '"seq":8') }).accepted, false);
++ assert.strictEqual(run({ rawBody: body.replace('"source":"network"', '"source":"netw0rk"') }).accepted, false);
// The HMAC for one time moved onto another: the time is covered by the signature.
const moved = run({ signatureV2: good.replace(`t=${nowSec}`, `t=${nowSec - 1}`) });
assert.strictEqual(moved.accepted, false);
@@@ -137,7 +137,8 @@ const hmacV1 = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).u
assert.strictEqual(verifyDeliveryV2(s.body, headers, secret, { now: nowMs }), true);
const parsed = parseDelivery(s.body, headers, secret, { requireV2: true, now: nowMs });
assert.strictEqual(parsed.event.event_type, 'media.object.ready');
-- assert.strictEqual(parsed.seq, 1);
++ assert.strictEqual('seq' in JSON.parse(s.body), false, 'the sample is { event }, like Events sends it');
++ assert.strictEqual(headers['x-openvibe-seq'], undefined, 'and no X-OpenVibe-Seq');
assert.strictEqual(contracts.validate('events.event-envelope@1', parsed.event).valid, true);
assert.strictEqual(parseDelivery(s.body, headers, secret, { requireV2: true, now: nowMs + 301_000 }), null, 'the sample goes stale after 300 s');
assert.strictEqual(parseDelivery(s.body, headers, 'wrong', { requireV2: true, now: nowMs }), null);