commitrail 0.1.0-alpha.0

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.
Files changed (41) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +12 -0
  3. package/dist/cjs/envelope.d.ts +91 -0
  4. package/dist/cjs/envelope.js +54 -0
  5. package/dist/cjs/envelope.js.map +1 -0
  6. package/dist/cjs/index.d.ts +20 -0
  7. package/dist/cjs/index.js +29 -0
  8. package/dist/cjs/index.js.map +1 -0
  9. package/dist/cjs/package.json +3 -0
  10. package/dist/cjs/postgres.d.ts +152 -0
  11. package/dist/cjs/postgres.js +373 -0
  12. package/dist/cjs/postgres.js.map +1 -0
  13. package/dist/cjs/signing.d.ts +66 -0
  14. package/dist/cjs/signing.js +108 -0
  15. package/dist/cjs/signing.js.map +1 -0
  16. package/dist/cjs/subjects.d.ts +53 -0
  17. package/dist/cjs/subjects.js +88 -0
  18. package/dist/cjs/subjects.js.map +1 -0
  19. package/dist/cjs/webhooks.d.ts +100 -0
  20. package/dist/cjs/webhooks.js +214 -0
  21. package/dist/cjs/webhooks.js.map +1 -0
  22. package/dist/esm/envelope.d.ts +91 -0
  23. package/dist/esm/envelope.js +50 -0
  24. package/dist/esm/envelope.js.map +1 -0
  25. package/dist/esm/index.d.ts +20 -0
  26. package/dist/esm/index.js +21 -0
  27. package/dist/esm/index.js.map +1 -0
  28. package/dist/esm/package.json +3 -0
  29. package/dist/esm/postgres.d.ts +152 -0
  30. package/dist/esm/postgres.js +366 -0
  31. package/dist/esm/postgres.js.map +1 -0
  32. package/dist/esm/signing.d.ts +66 -0
  33. package/dist/esm/signing.js +101 -0
  34. package/dist/esm/signing.js.map +1 -0
  35. package/dist/esm/subjects.d.ts +53 -0
  36. package/dist/esm/subjects.js +83 -0
  37. package/dist/esm/subjects.js.map +1 -0
  38. package/dist/esm/webhooks.d.ts +100 -0
  39. package/dist/esm/webhooks.js +209 -0
  40. package/dist/esm/webhooks.js.map +1 -0
  41. package/package.json +108 -0
@@ -0,0 +1,373 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EventIdConflictError = exports.OUTBOX_SCHEMA_SQL = exports.OUTBOX_SCHEMA_VERSION = exports.OUTBOX_MIGRATIONS = void 0;
4
+ exports.emit = emit;
5
+ exports.transaction = transaction;
6
+ exports.fromPrisma = fromPrisma;
7
+ const node_crypto_1 = require("node:crypto");
8
+ const subjects_js_1 = require("./subjects.js");
9
+ /** Registered globally by description, so every copy of this package agrees on it. */
10
+ const CONFLICTING_EVENT_BRAND = Symbol.for('commitrail.EventIdConflictError');
11
+ /**
12
+ * The outbox schema, as an append-only list of migrations.
13
+ *
14
+ * A list rather than one script, because the alternative already caused a problem. A single
15
+ * converge-to-latest constant changes when the package changes, so a customer who pasted it
16
+ * into `001_add_commitrail.sql` finds that migration meaning something different a year later
17
+ * — which is the one thing migrations exist not to do. Pinning a version instead gives them an
18
+ * immutable statement of what they applied.
19
+ *
20
+ * **Every entry here is frozen once released.** A released migration has run against databases
21
+ * we do not own and cannot re-run; editing one changes what a version *means* while leaving
22
+ * every already-migrated database untouched. Fix a mistake by appending, never by amending.
23
+ *
24
+ * Each is written to be safely re-runnable — `IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS` — so
25
+ * applying the whole list to any database converges it, whatever it started from.
26
+ */
27
+ exports.OUTBOX_MIGRATIONS = [
28
+ {
29
+ version: 1,
30
+ sql: `
31
+ CREATE SCHEMA IF NOT EXISTS commitrail;
32
+
33
+ CREATE TABLE IF NOT EXISTS commitrail.outbox_events (
34
+ event_id UUID PRIMARY KEY,
35
+ source_sequence BIGINT GENERATED ALWAYS AS IDENTITY,
36
+ transaction_id xid8 NOT NULL DEFAULT pg_current_xact_id(),
37
+ event_type TEXT NOT NULL,
38
+ event_version INTEGER NOT NULL DEFAULT 1,
39
+ payload JSONB NOT NULL,
40
+
41
+ occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
42
+
43
+ -- Which logical operation this event belongs to, and which event caused it.
44
+ --
45
+ -- Supplied by the application or left null; CommitRail never infers either. Chronological
46
+ -- proximity is not causation, and a timeline drawn from a guess is worse than no timeline.
47
+ -- A null correlation groups with nothing rather than with every other null.
48
+ --
49
+ -- Text rather than uuid: a correlation is the customer's own identifier for a business
50
+ -- operation — an order number, a checkout id — and forcing it into a uuid would make them
51
+ -- invent a second one and keep a mapping.
52
+ correlation_id TEXT,
53
+ causation_id TEXT
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS outbox_events_capture_idx
57
+ ON commitrail.outbox_events (transaction_id, source_sequence, event_id);
58
+ `,
59
+ },
60
+ {
61
+ version: 2,
62
+ sql: `
63
+ -- The business identities an event concerns — a JSON array of {"type","id"} pairs, or null
64
+ -- when the event declares none. CommitRail normalises these into its own indexed table at
65
+ -- acceptance; they are never queried in the outbox.
66
+ --
67
+ -- Appended rather than declared in the table above, so that a fresh install and an upgraded
68
+ -- one have the same column order rather than merely the same columns.
69
+ ALTER TABLE commitrail.outbox_events ADD COLUMN IF NOT EXISTS subjects JSONB;
70
+ `,
71
+ },
72
+ {
73
+ version: 3,
74
+ sql: `
75
+ -- Finding every event in one logical operation is the correlation timeline's whole query.
76
+ CREATE INDEX IF NOT EXISTS outbox_events_correlation_idx
77
+ ON commitrail.outbox_events (correlation_id)
78
+ WHERE correlation_id IS NOT NULL;
79
+ `,
80
+ },
81
+ {
82
+ version: 4,
83
+ sql: `
84
+ -- Which migrations have been applied. Bookkeeping, and only bookkeeping.
85
+ --
86
+ -- CommitRail never decides what it can read from this number. Capture inspects the actual
87
+ -- columns and preflight validates the actual structure, because a marker can be wrong — hand
88
+ -- edited, restored from a backup taken mid-migration, or written by a migration that half
89
+ -- applied — and a version that outranks reality is worse than no version at all. What this buys
90
+ -- is the ability to say "you are on 3 of 4" rather than "something is missing", and to give a
91
+ -- customer an immutable thing to pin in their own migration history.
92
+ CREATE TABLE IF NOT EXISTS commitrail.outbox_schema (
93
+ -- One row, enforced rather than assumed.
94
+ singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton),
95
+ version INTEGER NOT NULL,
96
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
97
+ );
98
+ `,
99
+ },
100
+ ];
101
+ /** The newest schema this package knows how to write. */
102
+ exports.OUTBOX_SCHEMA_VERSION = exports.OUTBOX_MIGRATIONS[exports.OUTBOX_MIGRATIONS.length - 1].version;
103
+ /**
104
+ * Record which version has been applied.
105
+ *
106
+ * `GREATEST` so that re-running an older migration cannot walk the marker backwards — applying
107
+ * the whole list to a database that is already current is a supported thing to do, and it must
108
+ * be a no-op rather than a downgrade.
109
+ */
110
+ const RECORD_VERSION = (version) => `
111
+ INSERT INTO commitrail.outbox_schema (version) VALUES (${version})
112
+ ON CONFLICT (singleton) DO UPDATE
113
+ SET version = GREATEST(commitrail.outbox_schema.version, EXCLUDED.version),
114
+ applied_at = now();
115
+ `;
116
+ /** The migration that introduced the marker table; nothing before it can record anything. */
117
+ const FIRST_RECORDED_VERSION = 4;
118
+ /**
119
+ * Every migration, in order, for a fresh install or to bring an old outbox up to date.
120
+ *
121
+ * Safe to run against any database at any version: every statement is conditional, and the
122
+ * marker only moves forward. This is the convenience; `OUTBOX_MIGRATIONS` is the contract.
123
+ *
124
+ * Versions before the marker table existed record nothing — there is nowhere to record it. The
125
+ * migration that creates the table sets the version to its own, which is sound because the list
126
+ * is ordered and everything before it has just run.
127
+ */
128
+ exports.OUTBOX_SCHEMA_SQL = exports.OUTBOX_MIGRATIONS.map((m) => m.version < FIRST_RECORDED_VERSION ? m.sql : `${m.sql}${RECORD_VERSION(m.version)}`).join('\n');
129
+ const INSERT = `
130
+ INSERT INTO commitrail.outbox_events
131
+ (event_id, event_type, event_version, payload, occurred_at, correlation_id, causation_id, subjects)
132
+ VALUES ($1::uuid, $2, $3, $4::jsonb, COALESCE($5::timestamptz, now()), $6, $7, $8::jsonb)
133
+ ON CONFLICT (event_id) DO NOTHING
134
+ `;
135
+ /**
136
+ * What makes two emissions THE SAME EVENT.
137
+ *
138
+ * Every producer-controlled field participates, because every one of them changes what the event
139
+ * means, where it routes, or how it is ordered:
140
+ *
141
+ * event_type, event_version, payload, correlation_id, causation_id, subjects
142
+ * occurred_at — only when the caller supplied one; see below
143
+ *
144
+ * Database-assigned fields never participate: `source_sequence` and `transaction_id` are issued
145
+ * per attempt and can never match, and comparing them would make every retry a conflict.
146
+ *
147
+ * **A new producer-controlled column must be added to `CONFLICTS` in the same change that adds
148
+ * it to the schema.** An ordering key is already planned, and it is exactly the kind of field
149
+ * that changes an event's meaning while being easy to forget here — a forgotten field makes two
150
+ * genuinely different events compare equal, which turns this check back into the silent discard
151
+ * it exists to remove. `tests/integration/sdk/postgres.test.ts` fails if a column appears in the
152
+ * outbox that this comment has not accounted for, so the reminder is a test rather than a hope.
153
+ *
154
+ * Comparison happens in PostgreSQL rather than in JavaScript: `jsonb` equality is semantic, so
155
+ * key order and whitespace do not matter, and `IS DISTINCT FROM` gets NULL right without a
156
+ * special case. Inventing JSON equality rules in JS would mean inventing them twice.
157
+ */
158
+ /**
159
+ * Does an event already exist under this id that is not the event being written?
160
+ *
161
+ * Runs only after the INSERT above did nothing, and only inside the caller's transaction.
162
+ * There is no race: the unique index on `event_id` serialises two transactions emitting the
163
+ * same id, so the second one blocks until the first commits or rolls back and then sees a
164
+ * settled answer.
165
+ *
166
+ * `occurred_at` is compared only when the caller supplied one. It defaults to `now()`, so a
167
+ * legitimate retry that omitted it writes a different timestamp every attempt — comparing it
168
+ * unconditionally would report every such retry as a conflict, which is precisely the case
169
+ * `ON CONFLICT DO NOTHING` exists to allow.
170
+ *
171
+ * `transaction_id` and `source_sequence` are excluded for the same reason and more strongly:
172
+ * they are assigned by the database per attempt and can never match.
173
+ *
174
+ * The comparison is on `jsonb`, so key order and whitespace do not matter — two payloads that
175
+ * differ only in serialisation are the same event, which is what a retrying producer produces.
176
+ */
177
+ const CONFLICTS = `
178
+ SELECT 1
179
+ FROM commitrail.outbox_events e
180
+ WHERE e.event_id = $1::uuid
181
+ AND (
182
+ (e.event_type, e.event_version, e.payload, e.correlation_id, e.causation_id, e.subjects)
183
+ IS DISTINCT FROM ($2, $3::integer, $4::jsonb, $6, $7, $8::jsonb)
184
+ OR ($5::timestamptz IS NOT NULL AND e.occurred_at IS DISTINCT FROM $5::timestamptz)
185
+ )
186
+ `;
187
+ /**
188
+ * A different event was already written under this `eventId`.
189
+ *
190
+ * Supplying an `eventId` is a claim that two calls describe the same event. When they do not,
191
+ * one of them is a mistake — a reused id, or a collision — and CommitRail has no way to know
192
+ * which. Discarding the second silently would leave the producer believing it was written.
193
+ *
194
+ * Thrown from inside the caller's transaction. **When the exception is allowed to propagate
195
+ * out of a `transaction()` callback, the transaction rolls back** and nothing half-happens.
196
+ * That is the intended handling and the reason this throws rather than returning a flag.
197
+ *
198
+ * Note what a throw does not do: it does not poison the PostgreSQL transaction by itself. A
199
+ * caller managing `BEGIN`/`COMMIT` themselves can catch this and commit anyway, and would then
200
+ * have committed business state describing an event that was never written. There is no way for
201
+ * an SDK to prevent that; enforcing it against every writer would mean putting the rule in the
202
+ * customer's database, which is a schema-versioning cost not worth paying yet. Catching this and
203
+ * continuing is almost certainly wrong.
204
+ */
205
+ class EventIdConflictError extends Error {
206
+ eventId;
207
+ static brand = CONFLICTING_EVENT_BRAND;
208
+ [CONFLICTING_EVENT_BRAND] = true;
209
+ constructor(eventId) {
210
+ super(`CommitRail: an event already exists with eventId ${eventId} and different content. ` +
211
+ 'An eventId identifies one event; reusing it for a different one is a producer bug.');
212
+ this.eventId = eventId;
213
+ this.name = 'EventIdConflictError';
214
+ }
215
+ static is(error) {
216
+ return (typeof error === 'object' &&
217
+ error !== null &&
218
+ error[CONFLICTING_EVENT_BRAND] === true);
219
+ }
220
+ }
221
+ exports.EventIdConflictError = EventIdConflictError;
222
+ /**
223
+ * How many rows a driver says it affected.
224
+ *
225
+ * `OutboxWriter` is structural and write-only on purpose, so this is the one place that has to
226
+ * know what real drivers return: `pg` gives a result object with `rowCount`, Prisma's
227
+ * `$executeRawUnsafe` gives a bare number. Anything else returns undefined, and an unknown
228
+ * shape must not be read as "no conflict" — see the caller.
229
+ */
230
+ function affectedRows(result) {
231
+ if (typeof result === 'number') {
232
+ return result;
233
+ }
234
+ if (typeof result === 'object' && result !== null && 'rowCount' in result) {
235
+ const count = result.rowCount;
236
+ return typeof count === 'number' ? count : undefined;
237
+ }
238
+ return undefined;
239
+ }
240
+ /**
241
+ * Write an event to the outbox **inside the caller's transaction**.
242
+ *
243
+ * That is the entire point, and the only thing that can go wrong here. Pass a transaction
244
+ * and the event commits with your business state or not at all. Pass a pool — which also
245
+ * has `.query` — and you have written the event on a separate connection, which is the
246
+ * dual write CommitRail exists to eliminate. Nothing at runtime can tell the two apart, so
247
+ * prefer `transaction()` below, which does not give you the chance.
248
+ *
249
+ * If you already manage transactions yourself, call `emit` on your transaction-scoped client.
250
+ * The example is deliberately not a `BEGIN`/`COMMIT` pair: written short it has no rollback
251
+ * path, and `emit` throwing would leave the connection inside a failed transaction.
252
+ *
253
+ * ```ts
254
+ * // `tx` is whatever your code already uses for the surrounding transaction.
255
+ * await emit(tx, { type: 'order.created', data: { orderId } });
256
+ * ```
257
+ */
258
+ async function emit(writer, event) {
259
+ if (event.type.trim().length === 0) {
260
+ throw new Error('an event must have a type');
261
+ }
262
+ assertNotAPool(writer);
263
+ const supplied = event.eventId !== undefined;
264
+ const eventId = event.eventId ?? (0, node_crypto_1.randomUUID)();
265
+ const subjects = (0, subjects_js_1.normalizeSubjects)(event.subjects);
266
+ // Normalised before it is written AND before it is compared, so the two are the same value.
267
+ // Comparing raw caller input against a canonicalised row would report a producer that reordered
268
+ // its own subjects as a conflict.
269
+ const params = [
270
+ eventId,
271
+ event.type,
272
+ event.version ?? 1,
273
+ JSON.stringify(event.data),
274
+ event.occurredAt ?? null,
275
+ event.correlationId ?? null,
276
+ event.causationId ?? null,
277
+ subjects === null ? null : JSON.stringify(subjects),
278
+ ];
279
+ const inserted = affectedRows(await writer.query(INSERT, params));
280
+ // Only a caller-supplied id can collide. Without one this is a fresh UUID, so there is nothing
281
+ // to check and nothing to pay for — which also means an exotic writer whose results cannot be
282
+ // interpreted keeps working unless the caller opts into explicit ids.
283
+ if (!supplied || inserted === 1) {
284
+ return eventId;
285
+ }
286
+ const conflicting = affectedRows(await writer.query(CONFLICTS, params));
287
+ if (conflicting === undefined) {
288
+ // Loud, because the alternative is the defect this check exists to remove: an event
289
+ // silently discarded while the caller believes it was written.
290
+ throw new Error('CommitRail: this writer returns a query result the SDK cannot interpret, so the eventId ' +
291
+ 'idempotency check cannot be enforced. Return the driver result unchanged — a `pg` result ' +
292
+ 'object or an affected-row count — or omit `eventId` and let CommitRail generate one.');
293
+ }
294
+ if (conflicting > 0) {
295
+ throw new EventIdConflictError(eventId);
296
+ }
297
+ return eventId;
298
+ }
299
+ /**
300
+ * Refuse a connection pool, which is the one mistake this function makes available.
301
+ *
302
+ * `OutboxWriter` is structural so that any driver satisfies it, and a `pg` Pool has the same
303
+ * `.query` method a transaction client does. Passing one writes the event on a whatever
304
+ * connection the pool hands out — a different connection from the business write, outside the
305
+ * transaction. That is the dual write CommitRail exists to eliminate, produced by code that
306
+ * looks correct and behaves correctly until the day a transaction rolls back.
307
+ *
308
+ * The documentation used to say nothing at runtime could tell the two apart. That is true of the
309
+ * interface and false of the object: a `pg` Pool carries `totalCount`, `idleCount` and
310
+ * `waitingCount`, and a client carries none of them. Checking is cheap, catches the exact
311
+ * documented footgun, and cannot produce a false positive on a transaction client.
312
+ *
313
+ * A driver this does not recognise is passed through, so the guard only ever adds safety. It is
314
+ * not a substitute for `transaction()`, which removes the choice instead of policing it.
315
+ */
316
+ function assertNotAPool(writer) {
317
+ const pool = writer;
318
+ if (typeof pool.totalCount === 'number' &&
319
+ typeof pool.idleCount === 'number' &&
320
+ typeof pool.waitingCount === 'number') {
321
+ throw new Error('CommitRail: emit() was given a connection pool, not a transaction. The event would be ' +
322
+ 'written on a different connection from your business write and would survive a ' +
323
+ 'rollback — the dual write CommitRail exists to eliminate. Use transaction(pool, tx => ' +
324
+ 'tx.emit(...)), or pass the client your own transaction is running on.');
325
+ }
326
+ }
327
+ /**
328
+ * Run work in a transaction, with `emit` bound to it.
329
+ *
330
+ * The recommended shape, because it removes the one mistake available: the writer handed
331
+ * to the callback is the transaction, so an event cannot accidentally be written outside
332
+ * it.
333
+ *
334
+ * ```ts
335
+ * await transaction(pool, async (tx) => {
336
+ * await tx.query('INSERT INTO orders (id, total) VALUES ($1, $2)', [id, total]);
337
+ * await tx.emit({ type: 'order.created', data: { orderId: id } });
338
+ * });
339
+ * ```
340
+ */
341
+ async function transaction(pool, work) {
342
+ const client = await pool.connect();
343
+ try {
344
+ await client.query('BEGIN', []);
345
+ const tx = {
346
+ query: (sql, params) => client.query(sql, params),
347
+ emit: (event) => emit(client, event),
348
+ };
349
+ const result = await work(tx);
350
+ await client.query('COMMIT', []);
351
+ return result;
352
+ }
353
+ catch (error) {
354
+ await client.query('ROLLBACK', []);
355
+ throw error;
356
+ }
357
+ finally {
358
+ client.release();
359
+ }
360
+ }
361
+ /**
362
+ * Adapt a Prisma transaction client.
363
+ *
364
+ * Typed structurally so this package does not depend on Prisma. Uses `$executeRawUnsafe`
365
+ * because the SQL is a constant defined above and the values are parameterised — the
366
+ * "unsafe" in the name refers to interpolating the statement, which never happens here.
367
+ */
368
+ function fromPrisma(tx) {
369
+ return {
370
+ query: (sql, params) => tx.$executeRawUnsafe(sql, ...params),
371
+ };
372
+ }
373
+ //# sourceMappingURL=postgres.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.js","sourceRoot":"","sources":["../../src/postgres.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,66 @@
1
+ export interface SigningSecret {
2
+ version: number;
3
+ secret: string;
4
+ }
5
+ export interface SigningMaterial {
6
+ current: SigningSecret;
7
+ /** Present only while a rotation is in progress. */
8
+ previous?: SigningSecret;
9
+ }
10
+ /**
11
+ * The canonical string a signature covers.
12
+ *
13
+ * Consumers reimplement this, so its exact shape matters far less than documenting it
14
+ * precisely — but it must include the delivery id, so that a captured body cannot be
15
+ * replayed as a different obligation.
16
+ */
17
+ export declare function canonicalPayload(input: {
18
+ timestamp: number;
19
+ deliveryId: string;
20
+ body: string;
21
+ }): string;
22
+ /**
23
+ * The same thing, as bytes, for a caller who has the raw body and has not decoded it.
24
+ *
25
+ * A signature is over bytes. Decoding to a string and re-encoding round-trips exactly for the
26
+ * valid UTF-8 that JSON must be, so the two agree — but making the caller decode first asks them
27
+ * to do something the contract does not need, and `.toString()` with a forgotten or wrong
28
+ * encoding is a real way to break verification for reasons nobody can see.
29
+ *
30
+ * Returns `Uint8Array` rather than `Buffer` deliberately: a `Buffer` in a published `.d.ts` makes
31
+ * `@types/node` a requirement to compile against this package, which a library has no business
32
+ * imposing. The packaging gate caught that, which is what it is for.
33
+ */
34
+ export declare function canonicalPayloadBytes(input: {
35
+ timestamp: number;
36
+ deliveryId: string;
37
+ body: Uint8Array;
38
+ }): Uint8Array;
39
+ export declare function sign(secret: string, canonical: string | Uint8Array): string;
40
+ /**
41
+ * Build the `CommitRail-Signature` header.
42
+ *
43
+ * During a rotation this carries a signature under each secret, so a consumer's
44
+ * verification is "does one of these match mine". That code never has to know about
45
+ * versions and never changes shape when a secret rotates — which matters more than
46
+ * elegance for something that gets pasted into middleware once and left there.
47
+ */
48
+ export declare function signatureHeader(input: {
49
+ timestamp: number;
50
+ deliveryId: string;
51
+ body: string;
52
+ material: SigningMaterial;
53
+ }): string;
54
+ /**
55
+ * Verify a header the way a consumer would. Exists so the contract we publish is the one
56
+ * we test against, rather than a description of it.
57
+ */
58
+ export declare function verifySignatureHeader(input: {
59
+ header: string;
60
+ secret: string;
61
+ deliveryId: string;
62
+ /** The raw body. Bytes are preferred; a string is decoded UTF-8 and equivalent for JSON. */
63
+ body: string | Uint8Array;
64
+ toleranceSeconds?: number;
65
+ now?: number;
66
+ }): boolean;
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.canonicalPayload = canonicalPayload;
4
+ exports.canonicalPayloadBytes = canonicalPayloadBytes;
5
+ exports.sign = sign;
6
+ exports.signatureHeader = signatureHeader;
7
+ exports.verifySignatureHeader = verifySignatureHeader;
8
+ const node_crypto_1 = require("node:crypto");
9
+ /**
10
+ * The canonical string a signature covers.
11
+ *
12
+ * Consumers reimplement this, so its exact shape matters far less than documenting it
13
+ * precisely — but it must include the delivery id, so that a captured body cannot be
14
+ * replayed as a different obligation.
15
+ */
16
+ function canonicalPayload(input) {
17
+ return `${input.timestamp}.${input.deliveryId}.${input.body}`;
18
+ }
19
+ /**
20
+ * The same thing, as bytes, for a caller who has the raw body and has not decoded it.
21
+ *
22
+ * A signature is over bytes. Decoding to a string and re-encoding round-trips exactly for the
23
+ * valid UTF-8 that JSON must be, so the two agree — but making the caller decode first asks them
24
+ * to do something the contract does not need, and `.toString()` with a forgotten or wrong
25
+ * encoding is a real way to break verification for reasons nobody can see.
26
+ *
27
+ * Returns `Uint8Array` rather than `Buffer` deliberately: a `Buffer` in a published `.d.ts` makes
28
+ * `@types/node` a requirement to compile against this package, which a library has no business
29
+ * imposing. The packaging gate caught that, which is what it is for.
30
+ */
31
+ function canonicalPayloadBytes(input) {
32
+ return Buffer.concat([
33
+ Buffer.from(`${input.timestamp}.${input.deliveryId}.`, 'utf8'),
34
+ Buffer.from(input.body.buffer, input.body.byteOffset, input.body.byteLength),
35
+ ]);
36
+ }
37
+ function sign(secret, canonical) {
38
+ return (0, node_crypto_1.createHmac)('sha256', secret).update(canonical).digest('hex');
39
+ }
40
+ /**
41
+ * Build the `CommitRail-Signature` header.
42
+ *
43
+ * During a rotation this carries a signature under each secret, so a consumer's
44
+ * verification is "does one of these match mine". That code never has to know about
45
+ * versions and never changes shape when a secret rotates — which matters more than
46
+ * elegance for something that gets pasted into middleware once and left there.
47
+ */
48
+ function signatureHeader(input) {
49
+ const canonical = canonicalPayload(input);
50
+ const signatures = [input.material.current, input.material.previous]
51
+ .filter((s) => s !== undefined)
52
+ .map((s) => `v1=${sign(s.secret, canonical)}`);
53
+ return [`t=${input.timestamp}`, ...signatures].join(',');
54
+ }
55
+ /**
56
+ * Verify a header the way a consumer would. Exists so the contract we publish is the one
57
+ * we test against, rather than a description of it.
58
+ */
59
+ function verifySignatureHeader(input) {
60
+ const tolerance = input.toleranceSeconds ?? 300;
61
+ const now = input.now ?? Math.floor(Date.now() / 1000);
62
+ const parts = input.header.split(',').map((p) => p.trim());
63
+ const timestampPart = parts.find((p) => p.startsWith('t='));
64
+ if (timestampPart === undefined) {
65
+ return false;
66
+ }
67
+ const timestamp = parseTimestamp(timestampPart.slice(2));
68
+ // Bounds replay. A signature stays valid forever otherwise, and a captured request could
69
+ // be resent indefinitely. The bound is inclusive and symmetric: a delivery exactly `tolerance`
70
+ // seconds either side of now is accepted.
71
+ if (timestamp === undefined || Math.abs(now - timestamp) > tolerance) {
72
+ return false;
73
+ }
74
+ const expected = sign(input.secret, typeof input.body === 'string'
75
+ ? canonicalPayload({ timestamp, deliveryId: input.deliveryId, body: input.body })
76
+ : canonicalPayloadBytes({ timestamp, deliveryId: input.deliveryId, body: input.body }));
77
+ return parts
78
+ .filter((p) => p.startsWith('v1='))
79
+ .some((p) => equalsConstantTime(p.slice(3), expected));
80
+ }
81
+ function equalsConstantTime(a, b) {
82
+ const left = Buffer.from(a, 'utf8');
83
+ const right = Buffer.from(b, 'utf8');
84
+ // Length must be compared first — timingSafeEqual throws on a mismatch — and leaking the
85
+ // length of a hex digest tells an attacker nothing.
86
+ return left.length === right.length && (0, node_crypto_1.timingSafeEqual)(left, right);
87
+ }
88
+ /**
89
+ * The `t=` value, parsed strictly.
90
+ *
91
+ * `Number.parseInt` is lenient: it reads `1787000000.5` and `1787000000junk` as 1787000000, so
92
+ * three different header strings would verify against one signature. Harmless on its own — the
93
+ * signature is computed over the parsed integer, so nothing about the body or the window changes
94
+ * — but it is a divergence, and divergence is the thing this protocol cannot afford. A verifier
95
+ * written from PROTOCOL.md with a strict integer parse would reject what this one accepts, and
96
+ * the customer would meet the difference in production.
97
+ *
98
+ * Digits only. A negative timestamp is refused here rather than by the tolerance check, which
99
+ * makes the rule one sentence instead of two.
100
+ */
101
+ function parseTimestamp(value) {
102
+ if (!/^\d+$/.test(value)) {
103
+ return undefined;
104
+ }
105
+ const parsed = Number.parseInt(value, 10);
106
+ return Number.isSafeInteger(parsed) ? parsed : undefined;
107
+ }
108
+ //# sourceMappingURL=signing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signing.js","sourceRoot":"","sources":["../../src/signing.ts"],"names":[],"mappings":";;AAoBA,4CAMC;AAcD,sDASC;AAED,oBAEC;AAUD,0CAaC;AAMD,sDAsCC;AAxHD,6CAA0D;AAa1D;;;;;;GAMG;AACH,SAAgB,gBAAgB,CAAC,KAIhC;IACC,OAAO,GAAG,KAAK,CAAC,SAAS,IAAI,KAAK,CAAC,UAAU,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC;AAChE,CAAC;AAED;;;;;;;;;;;GAWG;AACH,SAAgB,qBAAqB,CAAC,KAIrC;IACC,OAAO,MAAM,CAAC,MAAM,CAAC;QACnB,MAAM,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,SAAS,IAAI,KAAK,CAAC,UAAU,GAAG,EAAE,MAAM,CAAC;QAC9D,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,CAAC,UAAU,EAAE,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC;KAC7E,CAAC,CAAC;AACL,CAAC;AAED,SAAgB,IAAI,CAAC,MAAc,EAAE,SAA8B;IACjE,OAAO,IAAA,wBAAU,EAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACtE,CAAC;AAED;;;;;;;GAOG;AACH,SAAgB,eAAe,CAAC,KAK/B;IACC,MAAM,SAAS,GAAG,gBAAgB,CAAC,KAAK,CAAC,CAAC;IAE1C,MAAM,UAAU,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC;SACjE,MAAM,CAAC,CAAC,CAAC,EAAsB,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC;SAClD,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,MAAM,EAAE,SAAS,CAAC,EAAE,CAAC,CAAC;IAEjD,OAAO,CAAC,KAAK,KAAK,CAAC,SAAS,EAAE,EAAE,GAAG,UAAU,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC3D,CAAC;AAED;;;GAGG;AACH,SAAgB,qBAAqB,CAAC,KAQrC;IACC,MAAM,SAAS,GAAG,KAAK,CAAC,gBAAgB,IAAI,GAAG,CAAC;IAChD,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;IAEvD,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IAC3D,MAAM,aAAa,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC;IAE5D,IAAI,aAAa,KAAK,SAAS,EAAE,CAAC;QAChC,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,SAAS,GAAG,cAAc,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IAEzD,yFAAyF;IACzF,+FAA+F;IAC/F,0CAA0C;IAC1C,IAAI,SAAS,KAAK,SAAS,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,GAAG,SAAS,CAAC,GAAG,SAAS,EAAE,CAAC;QACrE,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,QAAQ,GAAG,IAAI,CACnB,KAAK,CAAC,MAAM,EACZ,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ;QAC5B,CAAC,CAAC,gBAAgB,CAAC,EAAE,SAAS,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC;QACjF,CAAC,CAAC,qBAAqB,CAAC,EAAE,SAAS,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CACzF,CAAC;IAEF,OAAO,KAAK;SACT,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;SAClC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,kBAAkB,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC;AAC3D,CAAC;AAED,SAAS,kBAAkB,CAAC,CAAS,EAAE,CAAS;IAC9C,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;IACpC,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;IAErC,yFAAyF;IACzF,oDAAoD;IACpD,OAAO,IAAI,CAAC,MAAM,KAAK,KAAK,CAAC,MAAM,IAAI,IAAA,6BAAe,EAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AACtE,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,SAAS,cAAc,CAAC,KAAa;IACnC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IAE1C,OAAO,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC;AAC3D,CAAC"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Subjects: the business identities an event concerns.
3
+ *
4
+ * A subject is not what happened (the event type), not the operation it belongs to (the
5
+ * correlation id), and not what caused it (the causation id) — it is *who or what it was
6
+ * about*: an order, a payment, a customer, a document. Declaring them makes those
7
+ * identifiers first-class lookup keys in CommitRail, so an investigation can start from
8
+ * "what happened to order_1264" rather than from an event id nobody has.
9
+ *
10
+ * Subjects are an unordered set of `(type, id)` pairs. Plural on purpose: a
11
+ * `payment.captured` legitimately concerns the payment, the order and the customer, and
12
+ * CommitRail has no business deciding which of them is *the* subject.
13
+ */
14
+ export interface EventSubject {
15
+ /** The kind of thing, in the application's own vocabulary: `order`, `payment`, `customer`. */
16
+ type: string;
17
+ /** The application's identifier for it: `order_1264`, `pay_991`. */
18
+ id: string;
19
+ }
20
+ /**
21
+ * Sized so normal business modelling never meets them; they exist to stop pathological
22
+ * usage, not to ration subjects. The evidence behind each number — measured throughput,
23
+ * storage and lookup curves, and what still needs re-measuring at production scale —
24
+ * is docs/benchmarks/event-subjects-limits.md; change them there first.
25
+ */
26
+ export declare const SUBJECT_LIMITS: {
27
+ readonly maxPerEvent: 100;
28
+ readonly maxTypeLength: 200;
29
+ readonly maxIdLength: 500;
30
+ };
31
+ /** Registered globally by description, so every copy of this package agrees on it. */
32
+ declare const BRAND: unique symbol;
33
+ /** Branded like `InvalidDeliveryError`, and for the same dual-package reason. */
34
+ export declare class InvalidSubjectsError extends Error {
35
+ static readonly brand: symbol;
36
+ readonly [BRAND] = true;
37
+ constructor(message: string);
38
+ static is(error: unknown): error is InvalidSubjectsError;
39
+ }
40
+ /**
41
+ * Validate and canonicalise a subjects value.
42
+ *
43
+ * Returns `null` for "no subjects" (undefined, null, or an empty array) so callers store
44
+ * nothing rather than an empty list. Exact duplicate `(type, id)` pairs are dropped —
45
+ * duplicates carry no meaning, and producers often assemble subjects from more than one
46
+ * code path — with first-occurrence order preserved. Anything malformed throws: a subject
47
+ * that cannot be represented is an error at the boundary, never a silent truncation.
48
+ *
49
+ * Takes `unknown` because the acceptance side re-validates whatever a producer actually
50
+ * wrote to the outbox, which no type annotation can vouch for.
51
+ */
52
+ export declare function normalizeSubjects(subjects: unknown): EventSubject[] | null;
53
+ export {};