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.
- package/LICENSE +201 -0
- package/README.md +12 -0
- package/dist/cjs/envelope.d.ts +91 -0
- package/dist/cjs/envelope.js +54 -0
- package/dist/cjs/envelope.js.map +1 -0
- package/dist/cjs/index.d.ts +20 -0
- package/dist/cjs/index.js +29 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/postgres.d.ts +152 -0
- package/dist/cjs/postgres.js +373 -0
- package/dist/cjs/postgres.js.map +1 -0
- package/dist/cjs/signing.d.ts +66 -0
- package/dist/cjs/signing.js +108 -0
- package/dist/cjs/signing.js.map +1 -0
- package/dist/cjs/subjects.d.ts +53 -0
- package/dist/cjs/subjects.js +88 -0
- package/dist/cjs/subjects.js.map +1 -0
- package/dist/cjs/webhooks.d.ts +100 -0
- package/dist/cjs/webhooks.js +214 -0
- package/dist/cjs/webhooks.js.map +1 -0
- package/dist/esm/envelope.d.ts +91 -0
- package/dist/esm/envelope.js +50 -0
- package/dist/esm/envelope.js.map +1 -0
- package/dist/esm/index.d.ts +20 -0
- package/dist/esm/index.js +21 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/postgres.d.ts +152 -0
- package/dist/esm/postgres.js +366 -0
- package/dist/esm/postgres.js.map +1 -0
- package/dist/esm/signing.d.ts +66 -0
- package/dist/esm/signing.js +101 -0
- package/dist/esm/signing.js.map +1 -0
- package/dist/esm/subjects.d.ts +53 -0
- package/dist/esm/subjects.js +83 -0
- package/dist/esm/subjects.js.map +1 -0
- package/dist/esm/webhooks.d.ts +100 -0
- package/dist/esm/webhooks.js +209 -0
- package/dist/esm/webhooks.js.map +1 -0
- package/package.json +108 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InvalidSubjectsError = exports.SUBJECT_LIMITS = void 0;
|
|
4
|
+
exports.normalizeSubjects = normalizeSubjects;
|
|
5
|
+
/**
|
|
6
|
+
* Sized so normal business modelling never meets them; they exist to stop pathological
|
|
7
|
+
* usage, not to ration subjects. The evidence behind each number — measured throughput,
|
|
8
|
+
* storage and lookup curves, and what still needs re-measuring at production scale —
|
|
9
|
+
* is docs/benchmarks/event-subjects-limits.md; change them there first.
|
|
10
|
+
*/
|
|
11
|
+
exports.SUBJECT_LIMITS = {
|
|
12
|
+
maxPerEvent: 100,
|
|
13
|
+
maxTypeLength: 200,
|
|
14
|
+
maxIdLength: 500,
|
|
15
|
+
};
|
|
16
|
+
/** Registered globally by description, so every copy of this package agrees on it. */
|
|
17
|
+
const BRAND = Symbol.for('commitrail.InvalidSubjectsError');
|
|
18
|
+
/** Branded like `InvalidDeliveryError`, and for the same dual-package reason. */
|
|
19
|
+
class InvalidSubjectsError extends Error {
|
|
20
|
+
static brand = BRAND;
|
|
21
|
+
[BRAND] = true;
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'InvalidSubjectsError';
|
|
25
|
+
}
|
|
26
|
+
static is(error) {
|
|
27
|
+
return (typeof error === 'object' &&
|
|
28
|
+
error !== null &&
|
|
29
|
+
error[BRAND] === true);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
exports.InvalidSubjectsError = InvalidSubjectsError;
|
|
33
|
+
/**
|
|
34
|
+
* Validate and canonicalise a subjects value.
|
|
35
|
+
*
|
|
36
|
+
* Returns `null` for "no subjects" (undefined, null, or an empty array) so callers store
|
|
37
|
+
* nothing rather than an empty list. Exact duplicate `(type, id)` pairs are dropped —
|
|
38
|
+
* duplicates carry no meaning, and producers often assemble subjects from more than one
|
|
39
|
+
* code path — with first-occurrence order preserved. Anything malformed throws: a subject
|
|
40
|
+
* that cannot be represented is an error at the boundary, never a silent truncation.
|
|
41
|
+
*
|
|
42
|
+
* Takes `unknown` because the acceptance side re-validates whatever a producer actually
|
|
43
|
+
* wrote to the outbox, which no type annotation can vouch for.
|
|
44
|
+
*/
|
|
45
|
+
function normalizeSubjects(subjects) {
|
|
46
|
+
if (subjects === undefined || subjects === null) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (!Array.isArray(subjects)) {
|
|
50
|
+
throw new InvalidSubjectsError('subjects must be an array of { type, id } pairs');
|
|
51
|
+
}
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const normalized = [];
|
|
54
|
+
for (const subject of subjects) {
|
|
55
|
+
if (typeof subject !== 'object' || subject === null || Array.isArray(subject)) {
|
|
56
|
+
throw new InvalidSubjectsError('each subject must be an object with string type and id');
|
|
57
|
+
}
|
|
58
|
+
const { type, id } = subject;
|
|
59
|
+
if (typeof type !== 'string' || type.length === 0) {
|
|
60
|
+
throw new InvalidSubjectsError('a subject type must be a non-empty string');
|
|
61
|
+
}
|
|
62
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
63
|
+
throw new InvalidSubjectsError('a subject id must be a non-empty string');
|
|
64
|
+
}
|
|
65
|
+
if (type.length > exports.SUBJECT_LIMITS.maxTypeLength) {
|
|
66
|
+
throw new InvalidSubjectsError(`a subject type may be at most ${exports.SUBJECT_LIMITS.maxTypeLength} characters`);
|
|
67
|
+
}
|
|
68
|
+
if (id.length > exports.SUBJECT_LIMITS.maxIdLength) {
|
|
69
|
+
throw new InvalidSubjectsError(`a subject id may be at most ${exports.SUBJECT_LIMITS.maxIdLength} characters`);
|
|
70
|
+
}
|
|
71
|
+
// A joining delimiter cannot be trusted when it may appear in the values themselves;
|
|
72
|
+
// length-prefixing makes the key unambiguous whatever the strings contain.
|
|
73
|
+
const key = `${type.length}:${type}${id}`;
|
|
74
|
+
if (seen.has(key)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
seen.add(key);
|
|
78
|
+
normalized.push({ type, id });
|
|
79
|
+
}
|
|
80
|
+
if (normalized.length === 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (normalized.length > exports.SUBJECT_LIMITS.maxPerEvent) {
|
|
84
|
+
throw new InvalidSubjectsError(`an event may declare at most ${exports.SUBJECT_LIMITS.maxPerEvent} subjects`);
|
|
85
|
+
}
|
|
86
|
+
return normalized;
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=subjects.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subjects.js","sourceRoot":"","sources":["../../src/subjects.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { type CommitRailEvent } from './envelope.js';
|
|
2
|
+
/** Registered globally by description, so every copy of this package agrees on it. */
|
|
3
|
+
declare const BRAND: unique symbol;
|
|
4
|
+
/**
|
|
5
|
+
* How much clock skew is tolerated by default, in seconds.
|
|
6
|
+
*
|
|
7
|
+
* Five minutes. It bounds replay: without it a captured request stays valid forever. Too tight
|
|
8
|
+
* and ordinary clock drift between two correct machines starts rejecting real deliveries.
|
|
9
|
+
*/
|
|
10
|
+
export declare const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
11
|
+
/** Why a delivery was rejected. Stable values — new ones may be added. */
|
|
12
|
+
export type InvalidDeliveryCode = 'missing_signature' | 'missing_delivery_id'
|
|
13
|
+
/** The signature header could not be parsed at all. */
|
|
14
|
+
| 'malformed_signature'
|
|
15
|
+
/** The signature was well formed but the timestamp is outside your tolerance. */
|
|
16
|
+
| 'timestamp_out_of_range'
|
|
17
|
+
/** The signature did not match: the wrong secret, or a body altered after signing. */
|
|
18
|
+
| 'signature_mismatch' | 'malformed_body'
|
|
19
|
+
/** A header CommitRail sent disagrees with the signed envelope. */
|
|
20
|
+
| 'header_mismatch';
|
|
21
|
+
/**
|
|
22
|
+
* A delivery that could not be trusted.
|
|
23
|
+
*
|
|
24
|
+
* `error instanceof InvalidDeliveryError` is the ordinary way to catch this and works in
|
|
25
|
+
* every normal application. It can fail in one specific case, and the case is created by
|
|
26
|
+
* this package being dual-published: an application that both `import`s and `require()`s
|
|
27
|
+
* `commitrail` loads two copies, with two distinct classes, and an error thrown by one
|
|
28
|
+
* is not an `instanceof` the other. `InvalidDeliveryError.is(error)` checks a shared
|
|
29
|
+
* symbol instead and is true across copies.
|
|
30
|
+
*/
|
|
31
|
+
export declare class InvalidDeliveryError extends Error {
|
|
32
|
+
static readonly brand: symbol;
|
|
33
|
+
readonly [BRAND] = true;
|
|
34
|
+
/**
|
|
35
|
+
* Which check failed, for your logs.
|
|
36
|
+
*
|
|
37
|
+
* The message stays deliberately vague about *why* a signature did not verify, because the
|
|
38
|
+
* most common mistake is echoing it to the caller — and a wrong secret, a stale timestamp and
|
|
39
|
+
* a tampered body are three very different hints to hand an attacker. The code exists so your
|
|
40
|
+
* own logs do not have to be as careful as your responses.
|
|
41
|
+
*
|
|
42
|
+
* The distinction that matters in practice: `signature_invalid` after a deploy is almost
|
|
43
|
+
* always the wrong secret, and `malformed_body` is almost always a framework that parsed and
|
|
44
|
+
* re-serialised the body instead of giving you the bytes that were signed.
|
|
45
|
+
*
|
|
46
|
+
* Return a bare 400 or 401 to the caller. Log the code.
|
|
47
|
+
*/
|
|
48
|
+
readonly code: InvalidDeliveryCode;
|
|
49
|
+
constructor(code: InvalidDeliveryCode, reason: string);
|
|
50
|
+
static is(error: unknown): error is InvalidDeliveryError;
|
|
51
|
+
}
|
|
52
|
+
export interface VerifyRequestInput {
|
|
53
|
+
/** Request headers, however your framework exposes them. Case-insensitive. */
|
|
54
|
+
headers: Record<string, string | string[] | undefined>;
|
|
55
|
+
/**
|
|
56
|
+
* The RAW request body, exactly as received.
|
|
57
|
+
*
|
|
58
|
+
* Not a parsed object re-serialised: `JSON.stringify` of a parsed body can differ from
|
|
59
|
+
* the bytes that were signed — key order, number formatting, whitespace — and the
|
|
60
|
+
* signature would fail for no reason a reader could see. Most frameworks need to be told
|
|
61
|
+
* to keep the raw body.
|
|
62
|
+
*
|
|
63
|
+
* Bytes are preferred, because a signature is over bytes: hand Express its `Buffer` or a
|
|
64
|
+
* `Uint8Array` straight through rather than calling `.toString()`, which is one more place to
|
|
65
|
+
* pick the wrong encoding. A string is accepted and equivalent — JSON is UTF-8, which
|
|
66
|
+
* round-trips exactly.
|
|
67
|
+
*/
|
|
68
|
+
body: string | Uint8Array;
|
|
69
|
+
secret: string;
|
|
70
|
+
/** How much clock skew to tolerate, in seconds. Defaults to five minutes. */
|
|
71
|
+
toleranceSeconds?: number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Verify a delivery and return its event.
|
|
75
|
+
*
|
|
76
|
+
* Throws rather than returning false, so a handler that forgets to check the result still
|
|
77
|
+
* fails closed.
|
|
78
|
+
*
|
|
79
|
+
* ```ts
|
|
80
|
+
* app.post('/webhooks/commitrail', async (request, reply) => {
|
|
81
|
+
* const event = verifyRequest({
|
|
82
|
+
* headers: request.headers,
|
|
83
|
+
* body: request.rawBody,
|
|
84
|
+
* secret: process.env.COMMITRAIL_SIGNING_SECRET!,
|
|
85
|
+
* });
|
|
86
|
+
*
|
|
87
|
+
* if (await alreadyProcessed(event.delivery.id)) return reply.code(200).send();
|
|
88
|
+
*
|
|
89
|
+
* await handle(event);
|
|
90
|
+
* await recordProcessed(event.delivery.id);
|
|
91
|
+
* return reply.code(200).send();
|
|
92
|
+
* });
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* The deduplication is not decoration. Delivery is at-least-once: a timeout or a dropped
|
|
96
|
+
* connection means CommitRail never learned whether you processed the event, and it will
|
|
97
|
+
* try again.
|
|
98
|
+
*/
|
|
99
|
+
export declare function verifyRequest<TData = unknown>(input: VerifyRequestInput): CommitRailEvent<TData>;
|
|
100
|
+
export {};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InvalidDeliveryError = exports.DEFAULT_TOLERANCE_SECONDS = void 0;
|
|
4
|
+
exports.verifyRequest = verifyRequest;
|
|
5
|
+
const envelope_js_1 = require("./envelope.js");
|
|
6
|
+
const signing_js_1 = require("./signing.js");
|
|
7
|
+
/** Registered globally by description, so every copy of this package agrees on it. */
|
|
8
|
+
const BRAND = Symbol.for('commitrail.InvalidDeliveryError');
|
|
9
|
+
/**
|
|
10
|
+
* How much clock skew is tolerated by default, in seconds.
|
|
11
|
+
*
|
|
12
|
+
* Five minutes. It bounds replay: without it a captured request stays valid forever. Too tight
|
|
13
|
+
* and ordinary clock drift between two correct machines starts rejecting real deliveries.
|
|
14
|
+
*/
|
|
15
|
+
exports.DEFAULT_TOLERANCE_SECONDS = 300;
|
|
16
|
+
/**
|
|
17
|
+
* A delivery that could not be trusted.
|
|
18
|
+
*
|
|
19
|
+
* `error instanceof InvalidDeliveryError` is the ordinary way to catch this and works in
|
|
20
|
+
* every normal application. It can fail in one specific case, and the case is created by
|
|
21
|
+
* this package being dual-published: an application that both `import`s and `require()`s
|
|
22
|
+
* `commitrail` loads two copies, with two distinct classes, and an error thrown by one
|
|
23
|
+
* is not an `instanceof` the other. `InvalidDeliveryError.is(error)` checks a shared
|
|
24
|
+
* symbol instead and is true across copies.
|
|
25
|
+
*/
|
|
26
|
+
class InvalidDeliveryError extends Error {
|
|
27
|
+
static brand = BRAND;
|
|
28
|
+
[BRAND] = true;
|
|
29
|
+
/**
|
|
30
|
+
* Which check failed, for your logs.
|
|
31
|
+
*
|
|
32
|
+
* The message stays deliberately vague about *why* a signature did not verify, because the
|
|
33
|
+
* most common mistake is echoing it to the caller — and a wrong secret, a stale timestamp and
|
|
34
|
+
* a tampered body are three very different hints to hand an attacker. The code exists so your
|
|
35
|
+
* own logs do not have to be as careful as your responses.
|
|
36
|
+
*
|
|
37
|
+
* The distinction that matters in practice: `signature_invalid` after a deploy is almost
|
|
38
|
+
* always the wrong secret, and `malformed_body` is almost always a framework that parsed and
|
|
39
|
+
* re-serialised the body instead of giving you the bytes that were signed.
|
|
40
|
+
*
|
|
41
|
+
* Return a bare 400 or 401 to the caller. Log the code.
|
|
42
|
+
*/
|
|
43
|
+
code;
|
|
44
|
+
constructor(code, reason) {
|
|
45
|
+
super(`CommitRail delivery rejected: ${reason}`);
|
|
46
|
+
this.name = 'InvalidDeliveryError';
|
|
47
|
+
this.code = code;
|
|
48
|
+
}
|
|
49
|
+
static is(error) {
|
|
50
|
+
return (typeof error === 'object' &&
|
|
51
|
+
error !== null &&
|
|
52
|
+
error[BRAND] === true);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
exports.InvalidDeliveryError = InvalidDeliveryError;
|
|
56
|
+
/**
|
|
57
|
+
* Verify a delivery and return its event.
|
|
58
|
+
*
|
|
59
|
+
* Throws rather than returning false, so a handler that forgets to check the result still
|
|
60
|
+
* fails closed.
|
|
61
|
+
*
|
|
62
|
+
* ```ts
|
|
63
|
+
* app.post('/webhooks/commitrail', async (request, reply) => {
|
|
64
|
+
* const event = verifyRequest({
|
|
65
|
+
* headers: request.headers,
|
|
66
|
+
* body: request.rawBody,
|
|
67
|
+
* secret: process.env.COMMITRAIL_SIGNING_SECRET!,
|
|
68
|
+
* });
|
|
69
|
+
*
|
|
70
|
+
* if (await alreadyProcessed(event.delivery.id)) return reply.code(200).send();
|
|
71
|
+
*
|
|
72
|
+
* await handle(event);
|
|
73
|
+
* await recordProcessed(event.delivery.id);
|
|
74
|
+
* return reply.code(200).send();
|
|
75
|
+
* });
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* The deduplication is not decoration. Delivery is at-least-once: a timeout or a dropped
|
|
79
|
+
* connection means CommitRail never learned whether you processed the event, and it will
|
|
80
|
+
* try again.
|
|
81
|
+
*/
|
|
82
|
+
function verifyRequest(input) {
|
|
83
|
+
const signature = header(input.headers, envelope_js_1.HEADERS.signature);
|
|
84
|
+
const deliveryId = header(input.headers, envelope_js_1.HEADERS.deliveryId);
|
|
85
|
+
if (signature === undefined) {
|
|
86
|
+
throw new InvalidDeliveryError('missing_signature', 'no signature header');
|
|
87
|
+
}
|
|
88
|
+
if (deliveryId === undefined) {
|
|
89
|
+
throw new InvalidDeliveryError('missing_delivery_id', 'no delivery id header');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The timestamp first, and separately, so the code can say which it was.
|
|
93
|
+
*
|
|
94
|
+
* One *message* for every cryptographic failure is right: the common mistake is echoing it to
|
|
95
|
+
* the caller, and "your clock is wrong" versus "your secret is wrong" are different hints to
|
|
96
|
+
* hand an attacker. That argument does not extend to a field you have to read deliberately —
|
|
97
|
+
* and these are the two an integrator most needs to tell apart, because a stale timestamp
|
|
98
|
+
* during setup is a clock problem and a mismatch is a secret or a raw-body problem, and they
|
|
99
|
+
* are fixed in completely different places.
|
|
100
|
+
*/
|
|
101
|
+
const tolerance = input.toleranceSeconds ?? exports.DEFAULT_TOLERANCE_SECONDS;
|
|
102
|
+
const timestamp = signedTimestamp(signature);
|
|
103
|
+
if (timestamp === undefined) {
|
|
104
|
+
throw new InvalidDeliveryError('malformed_signature', 'signature did not verify');
|
|
105
|
+
}
|
|
106
|
+
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > tolerance) {
|
|
107
|
+
throw new InvalidDeliveryError('timestamp_out_of_range', 'signature did not verify');
|
|
108
|
+
}
|
|
109
|
+
const valid = (0, signing_js_1.verifySignatureHeader)({
|
|
110
|
+
header: signature,
|
|
111
|
+
secret: input.secret,
|
|
112
|
+
deliveryId,
|
|
113
|
+
body: input.body,
|
|
114
|
+
toleranceSeconds: tolerance,
|
|
115
|
+
});
|
|
116
|
+
if (!valid) {
|
|
117
|
+
throw new InvalidDeliveryError('signature_mismatch', 'signature did not verify');
|
|
118
|
+
}
|
|
119
|
+
let event;
|
|
120
|
+
try {
|
|
121
|
+
event = JSON.parse(typeof input.body === 'string' ? input.body : Buffer.from(input.body).toString('utf8'));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new InvalidDeliveryError('malformed_body', 'body was not valid JSON');
|
|
125
|
+
}
|
|
126
|
+
// Before touching a member of it. `JSON.parse('null')` is null and `null.delivery` is a
|
|
127
|
+
// TypeError, not an InvalidDeliveryError — so a handler doing `if (InvalidDeliveryError.is(e))`
|
|
128
|
+
// would miss it and rethrow, turning a rejected delivery into a 500. A verifier must never
|
|
129
|
+
// throw a type its caller has not been told about.
|
|
130
|
+
if (typeof event !== 'object' || event === null || Array.isArray(event)) {
|
|
131
|
+
throw new InvalidDeliveryError('malformed_body', 'the body is not a CommitRail envelope');
|
|
132
|
+
}
|
|
133
|
+
if (event.delivery?.id !== deliveryId) {
|
|
134
|
+
throw new InvalidDeliveryError('header_mismatch', 'delivery id in the body does not match the header');
|
|
135
|
+
}
|
|
136
|
+
assertHeadersAgree(input.headers, event, signature);
|
|
137
|
+
return event;
|
|
138
|
+
}
|
|
139
|
+
function header(headers, name) {
|
|
140
|
+
const direct = headers[name] ?? headers[name.toLowerCase()];
|
|
141
|
+
const value = Array.isArray(direct) ? direct[0] : direct;
|
|
142
|
+
if (value !== undefined) {
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
// Fall back to a case-insensitive scan for frameworks that preserve the original casing.
|
|
146
|
+
const match = Object.entries(headers).find(([key]) => key.toLowerCase() === name);
|
|
147
|
+
const found = match?.[1];
|
|
148
|
+
return Array.isArray(found) ? found[0] : found;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The envelope is signed. Most of the headers are not.
|
|
152
|
+
*
|
|
153
|
+
* `canonicalPayload` covers the timestamp, the delivery id and the body — so the body cannot be
|
|
154
|
+
* altered and the delivery id cannot be swapped. Everything else CommitRail sends is a
|
|
155
|
+
* convenience copy of something already inside the envelope, carried in a header so a consumer
|
|
156
|
+
* can route or log without parsing. Those copies are **not** covered by the signature.
|
|
157
|
+
*
|
|
158
|
+
* That matters because the obvious thing to do with `commitrail-idempotency-key` is deduplicate
|
|
159
|
+
* on it, and a value an attacker can edit is a poor thing to deduplicate on: replay a captured
|
|
160
|
+
* delivery with a fresh key and a consumer trusting the header processes it twice; send two
|
|
161
|
+
* genuine deliveries with one key and it drops one. Neither needs the signing secret.
|
|
162
|
+
*
|
|
163
|
+
* So every header that duplicates a signed field is checked against the envelope here, and the
|
|
164
|
+
* advice becomes true rather than merely stated: after `verifyRequest` returns, the headers
|
|
165
|
+
* agree with the envelope, and the envelope is what was signed.
|
|
166
|
+
*
|
|
167
|
+
* **Only a header that is present and disagrees is rejected.** An absent one is fine, and that is
|
|
168
|
+
* deliberate: CommitRail must stay free to stop sending a convenience header without every
|
|
169
|
+
* deployed verifier refusing the delivery. This is the same reasoning as `verifyRequest` never
|
|
170
|
+
* reading `specVersion` to decide anything — comparing two things CommitRail sent is not the same
|
|
171
|
+
* as requiring a particular version, and it must not quietly become that.
|
|
172
|
+
*
|
|
173
|
+
* `commitrail-attempt-id` is the one header with nothing in the envelope to check it against. It
|
|
174
|
+
* stays unauthenticated; do not make a decision on it.
|
|
175
|
+
*/
|
|
176
|
+
function assertHeadersAgree(headers, event, signature) {
|
|
177
|
+
const timestamp = signedTimestamp(signature);
|
|
178
|
+
const expected = [
|
|
179
|
+
[envelope_js_1.HEADERS.specVersion, event.specVersion],
|
|
180
|
+
[envelope_js_1.HEADERS.idempotencyKey, event.delivery?.id],
|
|
181
|
+
[envelope_js_1.HEADERS.eventId, event.id],
|
|
182
|
+
[envelope_js_1.HEADERS.eventType, event.type],
|
|
183
|
+
[envelope_js_1.HEADERS.attemptNumber, event.delivery?.attempt?.toString()],
|
|
184
|
+
[envelope_js_1.HEADERS.timestamp, timestamp?.toString()],
|
|
185
|
+
];
|
|
186
|
+
for (const [name, value] of expected) {
|
|
187
|
+
const sent = header(headers, name);
|
|
188
|
+
if (sent !== undefined && sent !== value) {
|
|
189
|
+
// Named, because unlike a signature failure this one tells an attacker nothing they did
|
|
190
|
+
// not already choose — and it is the sort of thing a proxy causes by rewriting headers.
|
|
191
|
+
throw new InvalidDeliveryError('header_mismatch', `${name} does not match the signed envelope`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** The `t=` a signature header carries, which is authenticated: it is inside the signed string. */
|
|
196
|
+
function signedTimestamp(header) {
|
|
197
|
+
const part = header
|
|
198
|
+
.split(',')
|
|
199
|
+
.map((p) => p.trim())
|
|
200
|
+
.find((p) => p.startsWith('t='));
|
|
201
|
+
if (part === undefined) {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
const value = part.slice(2);
|
|
205
|
+
// Digits only, matching `verifySignatureHeader`. See the note on `parseTimestamp` there: a
|
|
206
|
+
// lenient parse means several header strings verify against one signature, and a verifier
|
|
207
|
+
// written from the specification would not agree with us about which.
|
|
208
|
+
if (!/^\d+$/.test(value)) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
const parsed = Number.parseInt(value, 10);
|
|
212
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
213
|
+
}
|
|
214
|
+
//# sourceMappingURL=webhooks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhooks.js","sourceRoot":"","sources":["../../src/webhooks.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire contract.
|
|
3
|
+
*
|
|
4
|
+
* This package is the single definition of it. CommitRail signs and sends using exactly
|
|
5
|
+
* what customers import to receive and verify, so the two cannot drift — the same reason
|
|
6
|
+
* the control plane serialises through its route schemas rather than describing them
|
|
7
|
+
* separately.
|
|
8
|
+
*/
|
|
9
|
+
import type { EventSubject } from './subjects.js';
|
|
10
|
+
export declare const SPEC_VERSION = "1";
|
|
11
|
+
export interface CommitRailEvent<TData = unknown> {
|
|
12
|
+
specVersion: string;
|
|
13
|
+
/**
|
|
14
|
+
* The producer's own event identity, carried through untouched.
|
|
15
|
+
*
|
|
16
|
+
* Unique within its source, and not beyond it: a destination fed by two sources can see
|
|
17
|
+
* the same id from unrelated events. Deduplicate on `delivery.id`.
|
|
18
|
+
*/
|
|
19
|
+
id: string;
|
|
20
|
+
type: string;
|
|
21
|
+
version: number;
|
|
22
|
+
/** When the event happened, as the producer recorded it. */
|
|
23
|
+
occurredAt: string;
|
|
24
|
+
/**
|
|
25
|
+
* The logical operation this event belongs to, and the event that caused it.
|
|
26
|
+
*
|
|
27
|
+
* Absent when the producer named neither — an absent member says "this event names no
|
|
28
|
+
* operation" more clearly than a null does. Put the same `correlationId` on whatever you
|
|
29
|
+
* emit in response and CommitRail joins the chain without being told anything else.
|
|
30
|
+
*/
|
|
31
|
+
correlationId?: string;
|
|
32
|
+
causationId?: string;
|
|
33
|
+
/**
|
|
34
|
+
* The business identities the producer declared this event concerns.
|
|
35
|
+
*
|
|
36
|
+
* Absent when none were declared, for the same reason as the members above — and never
|
|
37
|
+
* inferred: these are exactly what the producer wrote, so a consumer can route or index
|
|
38
|
+
* on them with the same trust the producer's own code would get.
|
|
39
|
+
*/
|
|
40
|
+
subjects?: EventSubject[];
|
|
41
|
+
delivery: {
|
|
42
|
+
/**
|
|
43
|
+
* The obligation this request is fulfilling, stable across every retry.
|
|
44
|
+
*
|
|
45
|
+
* **This is the idempotency key.** One event legitimately becomes two obligations when
|
|
46
|
+
* two routes point at the same destination, so deduplicating on `id` would silently
|
|
47
|
+
* drop the second.
|
|
48
|
+
*/
|
|
49
|
+
id: string;
|
|
50
|
+
attempt: number;
|
|
51
|
+
};
|
|
52
|
+
data: TData;
|
|
53
|
+
}
|
|
54
|
+
/** Header names CommitRail sends. Lowercase, as Node normalises them. */
|
|
55
|
+
export declare const HEADERS: {
|
|
56
|
+
readonly specVersion: "commitrail-spec-version";
|
|
57
|
+
readonly deliveryId: "commitrail-delivery-id";
|
|
58
|
+
readonly idempotencyKey: "commitrail-idempotency-key";
|
|
59
|
+
readonly attemptId: "commitrail-attempt-id";
|
|
60
|
+
readonly attemptNumber: "commitrail-attempt-number";
|
|
61
|
+
readonly eventId: "commitrail-event-id";
|
|
62
|
+
readonly eventType: "commitrail-event-type";
|
|
63
|
+
readonly timestamp: "commitrail-timestamp";
|
|
64
|
+
readonly signature: "commitrail-signature";
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Serialise the wire envelope.
|
|
68
|
+
*
|
|
69
|
+
* Built by hand rather than with `JSON.stringify` on an object, for one reason: `data` is
|
|
70
|
+
* the customer's payload as PostgreSQL rendered it, and it must be spliced in as text.
|
|
71
|
+
* Parsing it to put it in an object would run it through `JSON.parse`, which is float64 and
|
|
72
|
+
* silently rewrites any integer past 2^53 — an order id of 12345678901234567890 came back
|
|
73
|
+
* as 12345678901234567000. See docs/defects.md.
|
|
74
|
+
*
|
|
75
|
+
* Every other member still goes through `JSON.stringify`, which is what escapes them. The
|
|
76
|
+
* field order is fixed and visible on purpose: these bytes are what gets signed, so the
|
|
77
|
+
* order is part of the contract rather than an implementation detail of an object literal.
|
|
78
|
+
*/
|
|
79
|
+
export declare function serialiseEnvelope(input: {
|
|
80
|
+
id: string;
|
|
81
|
+
type: string;
|
|
82
|
+
version: number;
|
|
83
|
+
occurredAt: string;
|
|
84
|
+
correlationId?: string | null;
|
|
85
|
+
causationId?: string | null;
|
|
86
|
+
subjects?: EventSubject[] | null;
|
|
87
|
+
deliveryId: string;
|
|
88
|
+
attempt: number;
|
|
89
|
+
/** JSON text. Never a parsed value — see the note above. */
|
|
90
|
+
data: string;
|
|
91
|
+
}): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const SPEC_VERSION = '1';
|
|
2
|
+
/** Header names CommitRail sends. Lowercase, as Node normalises them. */
|
|
3
|
+
export const HEADERS = {
|
|
4
|
+
specVersion: 'commitrail-spec-version',
|
|
5
|
+
deliveryId: 'commitrail-delivery-id',
|
|
6
|
+
idempotencyKey: 'commitrail-idempotency-key',
|
|
7
|
+
attemptId: 'commitrail-attempt-id',
|
|
8
|
+
attemptNumber: 'commitrail-attempt-number',
|
|
9
|
+
eventId: 'commitrail-event-id',
|
|
10
|
+
eventType: 'commitrail-event-type',
|
|
11
|
+
timestamp: 'commitrail-timestamp',
|
|
12
|
+
signature: 'commitrail-signature',
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Serialise the wire envelope.
|
|
16
|
+
*
|
|
17
|
+
* Built by hand rather than with `JSON.stringify` on an object, for one reason: `data` is
|
|
18
|
+
* the customer's payload as PostgreSQL rendered it, and it must be spliced in as text.
|
|
19
|
+
* Parsing it to put it in an object would run it through `JSON.parse`, which is float64 and
|
|
20
|
+
* silently rewrites any integer past 2^53 — an order id of 12345678901234567890 came back
|
|
21
|
+
* as 12345678901234567000. See docs/defects.md.
|
|
22
|
+
*
|
|
23
|
+
* Every other member still goes through `JSON.stringify`, which is what escapes them. The
|
|
24
|
+
* field order is fixed and visible on purpose: these bytes are what gets signed, so the
|
|
25
|
+
* order is part of the contract rather than an implementation detail of an object literal.
|
|
26
|
+
*/
|
|
27
|
+
export function serialiseEnvelope(input) {
|
|
28
|
+
// Omitted rather than sent as null, so the envelope keeps the shape it has always had for
|
|
29
|
+
// events that name no operation.
|
|
30
|
+
const optional = (name, value) => value === null || value === undefined ? '' : `"${name}":${JSON.stringify(value)},`;
|
|
31
|
+
return ('{' +
|
|
32
|
+
`"specVersion":${JSON.stringify(SPEC_VERSION)},` +
|
|
33
|
+
`"id":${JSON.stringify(input.id)},` +
|
|
34
|
+
`"type":${JSON.stringify(input.type)},` +
|
|
35
|
+
`"version":${JSON.stringify(input.version)},` +
|
|
36
|
+
`"occurredAt":${JSON.stringify(input.occurredAt)},` +
|
|
37
|
+
optional('correlationId', input.correlationId) +
|
|
38
|
+
optional('causationId', input.causationId) +
|
|
39
|
+
// A new member is added to the fixed order, never inserted into it: envelopes without
|
|
40
|
+
// subjects keep exactly the bytes they have always had, which is what lets deployed
|
|
41
|
+
// verifiers stay deployed. `JSON.stringify` of the array is safe — subjects are strings
|
|
42
|
+
// by contract, so there is no float64 hazard here, unlike `data`.
|
|
43
|
+
(input.subjects === null || input.subjects === undefined || input.subjects.length === 0
|
|
44
|
+
? ''
|
|
45
|
+
: `"subjects":${JSON.stringify(input.subjects)},`) +
|
|
46
|
+
`"delivery":{"id":${JSON.stringify(input.deliveryId)},"attempt":${JSON.stringify(input.attempt)}},` +
|
|
47
|
+
`"data":${input.data}` +
|
|
48
|
+
'}');
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=envelope.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"envelope.js","sourceRoot":"","sources":["../../src/envelope.ts"],"names":[],"mappings":"AAUA,MAAM,CAAC,MAAM,YAAY,GAAG,GAAG,CAAC;AAqDhC,yEAAyE;AACzE,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,WAAW,EAAE,yBAAyB;IACtC,UAAU,EAAE,wBAAwB;IACpC,cAAc,EAAE,4BAA4B;IAC5C,SAAS,EAAE,uBAAuB;IAClC,aAAa,EAAE,2BAA2B;IAC1C,OAAO,EAAE,qBAAqB;IAC9B,SAAS,EAAE,uBAAuB;IAClC,SAAS,EAAE,sBAAsB;IACjC,SAAS,EAAE,sBAAsB;CACzB,CAAC;AAEX;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAYjC;IACC,0FAA0F;IAC1F,iCAAiC;IACjC,MAAM,QAAQ,GAAG,CAAC,IAAY,EAAE,KAAgC,EAAE,EAAE,CAClE,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI,KAAK,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC;IAErF,OAAO,CACL,GAAG;QACH,iBAAiB,IAAI,CAAC,SAAS,CAAC,YAAY,CAAC,GAAG;QAChD,QAAQ,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG;QACnC,UAAU,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG;QACvC,aAAa,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG;QAC7C,gBAAgB,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG;QACnD,QAAQ,CAAC,eAAe,EAAE,KAAK,CAAC,aAAa,CAAC;QAC9C,QAAQ,CAAC,aAAa,EAAE,KAAK,CAAC,WAAW,CAAC;QAC1C,sFAAsF;QACtF,oFAAoF;QACpF,wFAAwF;QACxF,kEAAkE;QAClE,CAAC,KAAK,CAAC,QAAQ,KAAK,IAAI,IAAI,KAAK,CAAC,QAAQ,KAAK,SAAS,IAAI,KAAK,CAAC,QAAQ,CAAC,MAAM,KAAK,CAAC;YACrF,CAAC,CAAC,EAAE;YACJ,CAAC,CAAC,cAAc,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC;QACpD,oBAAoB,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,cAAc,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI;QACnG,UAAU,KAAK,CAAC,IAAI,EAAE;QACtB,GAAG,CACJ,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The protocol, as a customer needs it.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is something you use to integrate with CommitRail. Nothing is here because
|
|
5
|
+
* CommitRail's own server happens to need it: the server implements this same protocol from its
|
|
6
|
+
* own private code and imports nothing from this package, so the public surface is a decision
|
|
7
|
+
* about your integration rather than a projection of our internals.
|
|
8
|
+
*
|
|
9
|
+
* What that costs is the ability to say "we sign with exactly the function you verify with".
|
|
10
|
+
* What it buys is that every export here is one we intend to support, and the guarantee is kept
|
|
11
|
+
* where it can actually be kept for every language — by conformance vectors both implementations
|
|
12
|
+
* are tested against, and by end-to-end tests that verify what CommitRail sends using this
|
|
13
|
+
* verifier. A Go verifier could never have shared our functions either.
|
|
14
|
+
*
|
|
15
|
+
* Each side has a subpath of its own: `commitrail/postgres` produces, `commitrail/webhooks`
|
|
16
|
+
* receives, and neither is re-exported here.
|
|
17
|
+
*/
|
|
18
|
+
export { SPEC_VERSION, HEADERS, type CommitRailEvent } from './envelope.js';
|
|
19
|
+
export { InvalidSubjectsError, SUBJECT_LIMITS, type EventSubject } from './subjects.js';
|
|
20
|
+
export { verifySignatureHeader } from './signing.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The protocol, as a customer needs it.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is something you use to integrate with CommitRail. Nothing is here because
|
|
5
|
+
* CommitRail's own server happens to need it: the server implements this same protocol from its
|
|
6
|
+
* own private code and imports nothing from this package, so the public surface is a decision
|
|
7
|
+
* about your integration rather than a projection of our internals.
|
|
8
|
+
*
|
|
9
|
+
* What that costs is the ability to say "we sign with exactly the function you verify with".
|
|
10
|
+
* What it buys is that every export here is one we intend to support, and the guarantee is kept
|
|
11
|
+
* where it can actually be kept for every language — by conformance vectors both implementations
|
|
12
|
+
* are tested against, and by end-to-end tests that verify what CommitRail sends using this
|
|
13
|
+
* verifier. A Go verifier could never have shared our functions either.
|
|
14
|
+
*
|
|
15
|
+
* Each side has a subpath of its own: `commitrail/postgres` produces, `commitrail/webhooks`
|
|
16
|
+
* receives, and neither is re-exported here.
|
|
17
|
+
*/
|
|
18
|
+
export { SPEC_VERSION, HEADERS } from './envelope.js';
|
|
19
|
+
export { InvalidSubjectsError, SUBJECT_LIMITS } from './subjects.js';
|
|
20
|
+
export { verifySignatureHeader } from './signing.js';
|
|
21
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAAE,YAAY,EAAE,OAAO,EAAwB,MAAM,eAAe,CAAC;AAC5E,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAqB,MAAM,eAAe,CAAC;AACxF,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC"}
|