@volter/world-core 3.0.152 → 3.0.153
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/dist/src/events.d.ts +4 -4
- package/dist/src/events.js +39 -15
- package/package.json +1 -1
- package/src/events.ts +40 -16
package/dist/src/events.d.ts
CHANGED
|
@@ -140,11 +140,11 @@ export type EventDelivery = {
|
|
|
140
140
|
/** The transport a delivery is made over; the World's by default, a test's own when given. */
|
|
141
141
|
export type EventTransport = (url: string, body: string, headers: Record<string, string>) => Promise<number | void>;
|
|
142
142
|
/** A signature scheme's headers for one delivery: deterministic (the id is derived from the delivery itself). */
|
|
143
|
-
/** `seq` tells apart
|
|
144
|
-
*
|
|
145
|
-
export declare function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq?: number): Record<string, string>;
|
|
143
|
+
/** `seq` tells apart deliveries at one instant; the declared-event path reserves it durably. `identity` supplies that
|
|
144
|
+
* occurrence's namespace. Omitted, existing direct callers retain their deterministic signing contract. */
|
|
145
|
+
export declare function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq?: number, identity?: string): Record<string, string>;
|
|
146
146
|
/** Every scheme's headers for one delivery, merged. */
|
|
147
|
-
export declare function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq?: number): Record<string, string>;
|
|
147
|
+
export declare function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq?: number, identity?: string): Record<string, string>;
|
|
148
148
|
/** A delivery's signature checked as its receiver checks it (a Standard Webhooks verifier, a timestamp-v1 verifier, a plain
|
|
149
149
|
* HMAC header): true when one of its signatures is the one the secret makes (constant time), and its timestamp,
|
|
150
150
|
* when the scheme sends one, is within `tolerance` seconds of `now`. */
|
package/dist/src/events.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Imports stay free of side effects: the delivery's transport (Node's http, the World's egress policy) is loaded when a
|
|
8
8
|
// delivery is made, never when a browser client imports the kernel.
|
|
9
9
|
import { createHash, createHmac } from 'node:crypto';
|
|
10
|
-
import { applyTwinWrite, twinResources } from "./serve.js";
|
|
10
|
+
import { applyTwinWrite, applyTwinWriteAtomic, twinResources } from "./serve.js";
|
|
11
11
|
import { equalSecrets as same, ksuidFrom } from "./signing.js";
|
|
12
12
|
import { deliveryTraceHeaders } from "./trace-context.js";
|
|
13
13
|
/** Whether an endpoint takes an event by its `match`: every field of one match (any, when several) equal to the event's
|
|
@@ -25,16 +25,15 @@ function matches(row, match, filled) {
|
|
|
25
25
|
});
|
|
26
26
|
return Array.isArray(match) ? match.some(one) : one(match);
|
|
27
27
|
}
|
|
28
|
-
const digest = (s) => createHash('sha256').update(s).digest('hex');
|
|
29
28
|
/** A `webhook-id` scheme's key: the base64 part of the secret, after its prefix when it carries one. */
|
|
30
29
|
const webhookKey = (scheme, secret) => scheme.secretKey === 'utf8' ? Buffer.from(secret, 'utf8') : Buffer.from(scheme.secretPrefix && secret.startsWith(scheme.secretPrefix) ? secret.slice(scheme.secretPrefix.length) : secret, 'base64');
|
|
31
30
|
/** A signature scheme's headers for one delivery: deterministic (the id is derived from the delivery itself). */
|
|
32
|
-
/** `seq` tells apart
|
|
33
|
-
*
|
|
34
|
-
export function signEvent(scheme, secret, body, seconds, url, seq = 0) {
|
|
31
|
+
/** `seq` tells apart deliveries at one instant; the declared-event path reserves it durably. `identity` supplies that
|
|
32
|
+
* occurrence's namespace. Omitted, existing direct callers retain their deterministic signing contract. */
|
|
33
|
+
export function signEvent(scheme, secret, body, seconds, url, seq = 0, identity) {
|
|
35
34
|
if (scheme.kind === 'webhook-id') {
|
|
36
35
|
// Svix's `msg_` and a KSUID (its ids' form), derived from the delivery itself
|
|
37
|
-
const id = `msg_${ksuidFrom(seconds, seq, `${url}|${seconds}|${seq}|${body}`)}`;
|
|
36
|
+
const id = `msg_${ksuidFrom(seconds, seq, identity ?? `${url}|${seconds}|${seq}|${body}`)}`;
|
|
38
37
|
const p = scheme.prefix.toLowerCase();
|
|
39
38
|
const signature = createHmac('sha256', webhookKey(scheme, secret)).update(`${id}.${seconds}.${body}`, 'utf8').digest('base64');
|
|
40
39
|
return { [`${p}-id`]: id, [`${p}-timestamp`]: String(seconds), [`${p}-signature`]: `v1,${signature}` };
|
|
@@ -58,8 +57,24 @@ const hashOf = (scheme, secret, body) => {
|
|
|
58
57
|
/** What an `hmac` scheme signs: its `signed` template, else `${t}.${body}` with a timestamp header, else the body. */
|
|
59
58
|
const hmacBase = (scheme, seconds, body) => scheme.signed !== undefined ? scheme.signed.replaceAll('{t}', String(seconds)).replaceAll('{body}', body) : scheme.timestampHeader ? `${seconds}.${body}` : body;
|
|
60
59
|
/** Every scheme's headers for one delivery, merged. */
|
|
61
|
-
export function signEventAll(schemes, secret, body, seconds, url, seq = 0) {
|
|
62
|
-
return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme) => signEvent(scheme, secret, body, seconds, url, seq)));
|
|
60
|
+
export function signEventAll(schemes, secret, body, seconds, url, seq = 0, identity) {
|
|
61
|
+
return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme) => signEvent(scheme, secret, body, seconds, url, seq, identity)));
|
|
62
|
+
}
|
|
63
|
+
/** Reserve occurrences under the same cross-process lock that appends actions. This private retained row is never a
|
|
64
|
+
* vendor resource. Deleted event/delivery rows, concurrent renders and restarts cannot give its ordinals back. */
|
|
65
|
+
async function reserveEventOrdinals(service, root, occurredAt, count) {
|
|
66
|
+
const { value } = await applyTwinWriteAtomic(service, (resources) => {
|
|
67
|
+
const counter = resources.find((r) => r.type === '_world_event_sequence' && r.id === 'sequence');
|
|
68
|
+
const last = counter === undefined ? 0 : counter.last_ordinal;
|
|
69
|
+
if (typeof last !== 'number' || !Number.isSafeInteger(last) || last < 0 || !Number.isSafeInteger(count) || count < 1 || !Number.isSafeInteger(last + count)) {
|
|
70
|
+
throw new Error('Invalid retained declared-event sequence; occurrence ids cannot be reused.');
|
|
71
|
+
}
|
|
72
|
+
return { kind: 'write', value: last + 1, write: {
|
|
73
|
+
operation: 'world_event_sequence.reserve', subjectType: '_world_event_sequence', subjectId: 'sequence',
|
|
74
|
+
fields: { last_ordinal: last + count }, input: { clock: true }, occurredAt, actor: { kind: 'system' },
|
|
75
|
+
} };
|
|
76
|
+
}, root);
|
|
77
|
+
return value;
|
|
63
78
|
}
|
|
64
79
|
/** A delivery's signature checked as its receiver checks it (a Standard Webhooks verifier, a timestamp-v1 verifier, a plain
|
|
65
80
|
* HMAC header): true when one of its signatures is the one the secret makes (constant time), and its timestamp,
|
|
@@ -282,11 +297,19 @@ async function deliverEventType(service, decl, write, data, transport, values, t
|
|
|
282
297
|
const seconds = Math.floor(millis / 1000);
|
|
283
298
|
const eventData = await data(type);
|
|
284
299
|
const extra = values ? await values(type) : {};
|
|
285
|
-
//
|
|
286
|
-
|
|
300
|
+
// Reserve one event and at most one delivery per live endpoint before assigning identities. Unused ordinals are
|
|
301
|
+
// harmless gaps; a failed render/store never gives them back. This is not an atomic originating-write outbox.
|
|
302
|
+
const ordinal = await reserveEventOrdinals(service, write.root, write.occurredAt, 1 + live.length);
|
|
303
|
+
// Fresh/forked Worlds can reserve the same ordinal at a pinned clock. Keep
|
|
304
|
+
// content binding as well as the lineage's occurrence ordinal, so different
|
|
305
|
+
// event payloads cannot reuse a receiver's event/webhook id across those Worlds.
|
|
306
|
+
const eventContent = createHash('sha256').update(JSON.stringify({
|
|
307
|
+
operation: write.operation, type, data: eventData, envelope: decl.envelope,
|
|
308
|
+
values: extra, request: requestValues(write.request),
|
|
309
|
+
}), 'utf8').digest('hex');
|
|
287
310
|
const filled = {
|
|
288
311
|
$type: type, $data: eventData,
|
|
289
|
-
$id: `evt_${ksuidFrom(seconds,
|
|
312
|
+
$id: `evt_${ksuidFrom(seconds, ordinal, `world-events:v1:${service}:${type}:${ordinal}:${eventContent}`)}`,
|
|
290
313
|
'$time.ms': millis, '$time.s': seconds, '$time.iso': write.occurredAt,
|
|
291
314
|
...requestValues(write.request),
|
|
292
315
|
...extra,
|
|
@@ -305,18 +328,19 @@ async function deliverEventType(service, decl, write, data, transport, values, t
|
|
|
305
328
|
const shared = JSON.stringify(envelope);
|
|
306
329
|
const each = perEndpoint(decl.envelope);
|
|
307
330
|
const sent = [];
|
|
308
|
-
const before = decl.record !== undefined ? all.filter((r) => r.type === decl.record).length : 0;
|
|
309
331
|
for (const [i, { row, kind }] of endpoints.entries()) {
|
|
310
332
|
const body = each ? JSON.stringify(fillEnvelope(decl.envelope, filled, row)) : shared;
|
|
311
333
|
const url = String(at(row, kind.url));
|
|
312
|
-
const seq =
|
|
334
|
+
const seq = ordinal + 1 + i;
|
|
335
|
+
const deliveryContent = createHash('sha256').update(url, 'utf8').update('\0').update(body, 'utf8').digest('hex');
|
|
336
|
+
const identity = `world-events:v1:${service}:${seq}:${deliveryContent}`;
|
|
313
337
|
const extraHeaders = decl.headers ? headersFor(decl.headers, filled, row) : {};
|
|
314
|
-
const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq) };
|
|
338
|
+
const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq, identity) };
|
|
315
339
|
sent.push({ url, body, headers });
|
|
316
340
|
// kept with the write, by an id of the delivery itself (two made at once never take the same one)
|
|
317
341
|
if (decl.record !== undefined) {
|
|
318
342
|
await applyTwinWrite(service, {
|
|
319
|
-
operation: `${decl.record.slice(1)}.record`, subjectType: decl.record, subjectId: `${decl.record.slice(1)}
|
|
343
|
+
operation: `${decl.record.slice(1)}.record`, subjectType: decl.record, subjectId: `${decl.record.slice(1)}_v1_${seq}`,
|
|
320
344
|
fields: { url, body, headers, event_type: type, sent_at: millis }, occurredAt: write.occurredAt, actor: { kind: 'system' },
|
|
321
345
|
}, write.root);
|
|
322
346
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.153",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
package/src/events.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Imports stay free of side effects: the delivery's transport (Node's http, the World's egress policy) is loaded when a
|
|
8
8
|
// delivery is made, never when a browser client imports the kernel.
|
|
9
9
|
import { createHash, createHmac } from 'node:crypto';
|
|
10
|
-
import { applyTwinWrite, twinResources } from './serve.ts';
|
|
10
|
+
import { applyTwinWrite, applyTwinWriteAtomic, twinResources } from './serve.ts';
|
|
11
11
|
import { equalSecrets as same, ksuidFrom } from './signing.ts';
|
|
12
12
|
import { deliveryTraceHeaders } from './trace-context.ts';
|
|
13
13
|
|
|
@@ -123,19 +123,17 @@ export type EventDelivery = { url: string; body: string; headers: Record<string,
|
|
|
123
123
|
/** The transport a delivery is made over; the World's by default, a test's own when given. */
|
|
124
124
|
export type EventTransport = (url: string, body: string, headers: Record<string, string>) => Promise<number | void>;
|
|
125
125
|
|
|
126
|
-
const digest = (s: string): string => createHash('sha256').update(s).digest('hex');
|
|
127
|
-
|
|
128
126
|
/** A `webhook-id` scheme's key: the base64 part of the secret, after its prefix when it carries one. */
|
|
129
127
|
const webhookKey = (scheme: { secretPrefix?: string; secretKey?: 'base64' | 'utf8' }, secret: string): Buffer =>
|
|
130
128
|
scheme.secretKey === 'utf8' ? Buffer.from(secret, 'utf8') : Buffer.from(scheme.secretPrefix && secret.startsWith(scheme.secretPrefix) ? secret.slice(scheme.secretPrefix.length) : secret, 'base64');
|
|
131
129
|
|
|
132
130
|
/** A signature scheme's headers for one delivery: deterministic (the id is derived from the delivery itself). */
|
|
133
|
-
/** `seq` tells apart
|
|
134
|
-
*
|
|
135
|
-
export function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq = 0): Record<string, string> {
|
|
131
|
+
/** `seq` tells apart deliveries at one instant; the declared-event path reserves it durably. `identity` supplies that
|
|
132
|
+
* occurrence's namespace. Omitted, existing direct callers retain their deterministic signing contract. */
|
|
133
|
+
export function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq = 0, identity?: string): Record<string, string> {
|
|
136
134
|
if (scheme.kind === 'webhook-id') {
|
|
137
135
|
// Svix's `msg_` and a KSUID (its ids' form), derived from the delivery itself
|
|
138
|
-
const id = `msg_${ksuidFrom(seconds, seq, `${url}|${seconds}|${seq}|${body}`)}`;
|
|
136
|
+
const id = `msg_${ksuidFrom(seconds, seq, identity ?? `${url}|${seconds}|${seq}|${body}`)}`;
|
|
139
137
|
const p = scheme.prefix.toLowerCase();
|
|
140
138
|
const signature = createHmac('sha256', webhookKey(scheme, secret)).update(`${id}.${seconds}.${body}`, 'utf8').digest('base64');
|
|
141
139
|
return { [`${p}-id`]: id, [`${p}-timestamp`]: String(seconds), [`${p}-signature`]: `v1,${signature}` };
|
|
@@ -161,8 +159,25 @@ const hmacBase = (scheme: { timestampHeader?: string; signed?: string }, seconds
|
|
|
161
159
|
scheme.signed !== undefined ? scheme.signed.replaceAll('{t}', String(seconds)).replaceAll('{body}', body) : scheme.timestampHeader ? `${seconds}.${body}` : body;
|
|
162
160
|
|
|
163
161
|
/** Every scheme's headers for one delivery, merged. */
|
|
164
|
-
export function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq = 0): Record<string, string> {
|
|
165
|
-
return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme: EventScheme) => signEvent(scheme, secret, body, seconds, url, seq)));
|
|
162
|
+
export function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq = 0, identity?: string): Record<string, string> {
|
|
163
|
+
return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme: EventScheme) => signEvent(scheme, secret, body, seconds, url, seq, identity)));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Reserve occurrences under the same cross-process lock that appends actions. This private retained row is never a
|
|
167
|
+
* vendor resource. Deleted event/delivery rows, concurrent renders and restarts cannot give its ordinals back. */
|
|
168
|
+
async function reserveEventOrdinals(service: string, root: string | undefined, occurredAt: string, count: number): Promise<number> {
|
|
169
|
+
const { value } = await applyTwinWriteAtomic(service, (resources) => {
|
|
170
|
+
const counter = resources.find((r) => r.type === '_world_event_sequence' && r.id === 'sequence');
|
|
171
|
+
const last = counter === undefined ? 0 : (counter as Record<string, unknown>).last_ordinal;
|
|
172
|
+
if (typeof last !== 'number' || !Number.isSafeInteger(last) || last < 0 || !Number.isSafeInteger(count) || count < 1 || !Number.isSafeInteger(last + count)) {
|
|
173
|
+
throw new Error('Invalid retained declared-event sequence; occurrence ids cannot be reused.');
|
|
174
|
+
}
|
|
175
|
+
return { kind: 'write', value: last + 1, write: {
|
|
176
|
+
operation: 'world_event_sequence.reserve', subjectType: '_world_event_sequence', subjectId: 'sequence',
|
|
177
|
+
fields: { last_ordinal: last + count }, input: { clock: true }, occurredAt, actor: { kind: 'system' },
|
|
178
|
+
} };
|
|
179
|
+
}, root);
|
|
180
|
+
return value;
|
|
166
181
|
}
|
|
167
182
|
|
|
168
183
|
/** A delivery's signature checked as its receiver checks it (a Standard Webhooks verifier, a timestamp-v1 verifier, a plain
|
|
@@ -386,11 +401,19 @@ async function deliverEventType(
|
|
|
386
401
|
const seconds = Math.floor(millis / 1000);
|
|
387
402
|
const eventData = await data(type);
|
|
388
403
|
const extra = values ? await values(type) : {};
|
|
389
|
-
//
|
|
390
|
-
|
|
404
|
+
// Reserve one event and at most one delivery per live endpoint before assigning identities. Unused ordinals are
|
|
405
|
+
// harmless gaps; a failed render/store never gives them back. This is not an atomic originating-write outbox.
|
|
406
|
+
const ordinal = await reserveEventOrdinals(service, write.root, write.occurredAt, 1 + live.length);
|
|
407
|
+
// Fresh/forked Worlds can reserve the same ordinal at a pinned clock. Keep
|
|
408
|
+
// content binding as well as the lineage's occurrence ordinal, so different
|
|
409
|
+
// event payloads cannot reuse a receiver's event/webhook id across those Worlds.
|
|
410
|
+
const eventContent = createHash('sha256').update(JSON.stringify({
|
|
411
|
+
operation: write.operation, type, data: eventData, envelope: decl.envelope,
|
|
412
|
+
values: extra, request: requestValues(write.request),
|
|
413
|
+
}), 'utf8').digest('hex');
|
|
391
414
|
const filled: Record<string, unknown> = {
|
|
392
415
|
$type: type, $data: eventData,
|
|
393
|
-
$id: `evt_${ksuidFrom(seconds,
|
|
416
|
+
$id: `evt_${ksuidFrom(seconds, ordinal, `world-events:v1:${service}:${type}:${ordinal}:${eventContent}`)}`,
|
|
394
417
|
'$time.ms': millis, '$time.s': seconds, '$time.iso': write.occurredAt,
|
|
395
418
|
...requestValues(write.request),
|
|
396
419
|
...extra,
|
|
@@ -407,18 +430,19 @@ async function deliverEventType(
|
|
|
407
430
|
const shared = JSON.stringify(envelope);
|
|
408
431
|
const each = perEndpoint(decl.envelope);
|
|
409
432
|
const sent: EventDelivery[] = [];
|
|
410
|
-
const before = decl.record !== undefined ? all.filter((r) => r.type === decl.record).length : 0;
|
|
411
433
|
for (const [i, { row, kind }] of endpoints.entries()) {
|
|
412
434
|
const body = each ? JSON.stringify(fillEnvelope(decl.envelope, filled, row)) : shared;
|
|
413
435
|
const url = String(at(row, kind.url));
|
|
414
|
-
const seq =
|
|
436
|
+
const seq = ordinal + 1 + i;
|
|
437
|
+
const deliveryContent = createHash('sha256').update(url, 'utf8').update('\0').update(body, 'utf8').digest('hex');
|
|
438
|
+
const identity = `world-events:v1:${service}:${seq}:${deliveryContent}`;
|
|
415
439
|
const extraHeaders = decl.headers ? headersFor(decl.headers, filled, row) : {};
|
|
416
|
-
const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq) };
|
|
440
|
+
const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq, identity) };
|
|
417
441
|
sent.push({ url, body, headers });
|
|
418
442
|
// kept with the write, by an id of the delivery itself (two made at once never take the same one)
|
|
419
443
|
if (decl.record !== undefined) {
|
|
420
444
|
await applyTwinWrite(service, {
|
|
421
|
-
operation: `${decl.record.slice(1)}.record`, subjectType: decl.record, subjectId: `${decl.record.slice(1)}
|
|
445
|
+
operation: `${decl.record.slice(1)}.record`, subjectType: decl.record, subjectId: `${decl.record.slice(1)}_v1_${seq}`,
|
|
422
446
|
fields: { url, body, headers, event_type: type, sent_at: millis }, occurredAt: write.occurredAt, actor: { kind: 'system' },
|
|
423
447
|
}, write.root);
|
|
424
448
|
}
|