@pierre/storage 0.9.2 → 1.0.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/README.md +77 -43
- package/dist/index.cjs +148 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +12 -7
- package/dist/index.d.ts +12 -7
- package/dist/index.js +148 -43
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/commit-pack.ts +103 -99
- package/src/commit.ts +373 -365
- package/src/diff-commit.ts +272 -259
- package/src/errors.ts +34 -34
- package/src/fetch.ts +146 -141
- package/src/index.ts +1400 -1249
- package/src/schemas.ts +120 -114
- package/src/stream-utils.ts +225 -208
- package/src/types.ts +378 -354
- package/src/util.ts +41 -34
- package/src/version.ts +1 -1
- package/src/webhook.ts +244 -239
package/src/util.ts
CHANGED
|
@@ -1,44 +1,51 @@
|
|
|
1
|
-
export function timingSafeEqual(
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
export function timingSafeEqual(
|
|
2
|
+
a: string | Uint8Array,
|
|
3
|
+
b: string | Uint8Array
|
|
4
|
+
): boolean {
|
|
5
|
+
const bufferA = typeof a === 'string' ? new TextEncoder().encode(a) : a;
|
|
6
|
+
const bufferB = typeof b === 'string' ? new TextEncoder().encode(b) : b;
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
if (bufferA.length !== bufferB.length) return false;
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
let result = 0;
|
|
11
|
+
for (let i = 0; i < bufferA.length; i++) {
|
|
12
|
+
result |= bufferA[i] ^ bufferB[i];
|
|
13
|
+
}
|
|
14
|
+
return result === 0;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export async function getEnvironmentCrypto() {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
if (!globalThis.crypto) {
|
|
19
|
+
const { webcrypto } = await import('node:crypto');
|
|
20
|
+
return webcrypto;
|
|
21
|
+
}
|
|
22
|
+
return globalThis.crypto;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
export async function createHmac(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
export async function createHmac(
|
|
26
|
+
algorithm: string,
|
|
27
|
+
secret: string,
|
|
28
|
+
data: string
|
|
29
|
+
): Promise<string> {
|
|
30
|
+
if (algorithm !== 'sha256') {
|
|
31
|
+
throw new Error('Only sha256 algorithm is supported');
|
|
32
|
+
}
|
|
33
|
+
if (!secret || secret.length === 0) {
|
|
34
|
+
throw new Error('Secret is required');
|
|
35
|
+
}
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const crypto = await getEnvironmentCrypto();
|
|
38
|
+
const encoder = new TextEncoder();
|
|
39
|
+
const key = await crypto.subtle.importKey(
|
|
40
|
+
'raw',
|
|
41
|
+
encoder.encode(secret),
|
|
42
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
43
|
+
false,
|
|
44
|
+
['sign']
|
|
45
|
+
);
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
|
48
|
+
return Array.from(new Uint8Array(signature))
|
|
49
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
50
|
+
.join('');
|
|
44
51
|
}
|
package/src/version.ts
CHANGED
package/src/webhook.ts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Webhook validation utilities for Pierre Git Storage
|
|
3
3
|
*/
|
|
4
|
-
|
|
5
4
|
import type {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
ParsedWebhookSignature,
|
|
6
|
+
RawWebhookPushEvent,
|
|
7
|
+
WebhookEventPayload,
|
|
8
|
+
WebhookPushEvent,
|
|
9
|
+
WebhookValidationOptions,
|
|
10
|
+
WebhookValidationResult,
|
|
12
11
|
} from './types';
|
|
13
|
-
|
|
14
12
|
import { createHmac, timingSafeEqual } from './util';
|
|
15
13
|
|
|
16
14
|
const DEFAULT_MAX_AGE_SECONDS = 300; // 5 minutes
|
|
@@ -19,39 +17,41 @@ const DEFAULT_MAX_AGE_SECONDS = 300; // 5 minutes
|
|
|
19
17
|
* Parse the X-Pierre-Signature header
|
|
20
18
|
* Format: t=<timestamp>,sha256=<signature>
|
|
21
19
|
*/
|
|
22
|
-
export function parseSignatureHeader(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
20
|
+
export function parseSignatureHeader(
|
|
21
|
+
header: string
|
|
22
|
+
): ParsedWebhookSignature | null {
|
|
23
|
+
if (!header || typeof header !== 'string') {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let timestamp = '';
|
|
28
|
+
let signature = '';
|
|
29
|
+
|
|
30
|
+
// Split by comma and parse each element
|
|
31
|
+
const elements = header.split(',');
|
|
32
|
+
for (const element of elements) {
|
|
33
|
+
const trimmedElement = element.trim();
|
|
34
|
+
const parts = trimmedElement.split('=', 2);
|
|
35
|
+
if (parts.length !== 2) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const [key, value] = parts;
|
|
40
|
+
switch (key) {
|
|
41
|
+
case 't':
|
|
42
|
+
timestamp = value;
|
|
43
|
+
break;
|
|
44
|
+
case 'sha256':
|
|
45
|
+
signature = value;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!timestamp || !signature) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { timestamp, signature };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
@@ -78,94 +78,95 @@ export function parseSignatureHeader(header: string): ParsedWebhookSignature | n
|
|
|
78
78
|
* ```
|
|
79
79
|
*/
|
|
80
80
|
export async function validateWebhookSignature(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
payload: string | Buffer,
|
|
82
|
+
signatureHeader: string,
|
|
83
|
+
secret: string,
|
|
84
|
+
options: WebhookValidationOptions = {}
|
|
85
85
|
): Promise<WebhookValidationResult> {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
86
|
+
if (!secret || secret.length === 0) {
|
|
87
|
+
return {
|
|
88
|
+
valid: false,
|
|
89
|
+
error: 'Empty secret is not allowed',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Parse the signature header
|
|
94
|
+
const parsed = parseSignatureHeader(signatureHeader);
|
|
95
|
+
if (!parsed) {
|
|
96
|
+
return {
|
|
97
|
+
valid: false,
|
|
98
|
+
error: 'Invalid signature header format',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Parse timestamp
|
|
103
|
+
const timestamp = Number.parseInt(parsed.timestamp, 10);
|
|
104
|
+
if (isNaN(timestamp)) {
|
|
105
|
+
return {
|
|
106
|
+
valid: false,
|
|
107
|
+
error: 'Invalid timestamp in signature',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Validate timestamp age (prevent replay attacks)
|
|
112
|
+
const maxAge = options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
|
|
113
|
+
if (maxAge > 0) {
|
|
114
|
+
const now = Math.floor(Date.now() / 1000);
|
|
115
|
+
const age = now - timestamp;
|
|
116
|
+
|
|
117
|
+
if (age > maxAge) {
|
|
118
|
+
return {
|
|
119
|
+
valid: false,
|
|
120
|
+
error: `Webhook timestamp too old (${age} seconds)`,
|
|
121
|
+
timestamp,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Also reject timestamps from the future (clock skew tolerance of 60 seconds)
|
|
126
|
+
if (age < -60) {
|
|
127
|
+
return {
|
|
128
|
+
valid: false,
|
|
129
|
+
error: 'Webhook timestamp is in the future',
|
|
130
|
+
timestamp,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Convert payload to string if it's a Buffer
|
|
136
|
+
const payloadStr =
|
|
137
|
+
typeof payload === 'string' ? payload : payload.toString('utf8');
|
|
138
|
+
|
|
139
|
+
// Compute expected signature
|
|
140
|
+
// Format: HMAC-SHA256(secret, timestamp + "." + payload)
|
|
141
|
+
const signedData = `${parsed.timestamp}.${payloadStr}`;
|
|
142
|
+
const expectedSignature = await createHmac('sha256', secret, signedData);
|
|
143
|
+
|
|
144
|
+
// Compare signatures using constant-time comparison
|
|
145
|
+
const expectedBuffer = Buffer.from(expectedSignature);
|
|
146
|
+
const actualBuffer = Buffer.from(parsed.signature);
|
|
147
|
+
|
|
148
|
+
// Ensure both buffers are the same length for timing-safe comparison
|
|
149
|
+
if (expectedBuffer.length !== actualBuffer.length) {
|
|
150
|
+
return {
|
|
151
|
+
valid: false,
|
|
152
|
+
error: 'Invalid signature',
|
|
153
|
+
timestamp,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const signaturesMatch = timingSafeEqual(expectedBuffer, actualBuffer);
|
|
158
|
+
if (!signaturesMatch) {
|
|
159
|
+
return {
|
|
160
|
+
valid: false,
|
|
161
|
+
error: 'Invalid signature',
|
|
162
|
+
timestamp,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
valid: true,
|
|
168
|
+
timestamp,
|
|
169
|
+
};
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
/**
|
|
@@ -196,128 +197,132 @@ export async function validateWebhookSignature(
|
|
|
196
197
|
* ```
|
|
197
198
|
*/
|
|
198
199
|
export async function validateWebhook(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
payload: string | Buffer,
|
|
201
|
+
headers: Record<string, string | string[] | undefined>,
|
|
202
|
+
secret: string,
|
|
203
|
+
options: WebhookValidationOptions = {}
|
|
203
204
|
): Promise<WebhookValidationResult & { payload?: WebhookEventPayload }> {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
205
|
+
// Get signature header
|
|
206
|
+
const signatureHeader =
|
|
207
|
+
headers['x-pierre-signature'] || headers['X-Pierre-Signature'];
|
|
208
|
+
if (!signatureHeader || Array.isArray(signatureHeader)) {
|
|
209
|
+
return {
|
|
210
|
+
valid: false,
|
|
211
|
+
error: 'Missing or invalid X-Pierre-Signature header',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Get event type header
|
|
216
|
+
const eventType = headers['x-pierre-event'] || headers['X-Pierre-Event'];
|
|
217
|
+
if (!eventType || Array.isArray(eventType)) {
|
|
218
|
+
return {
|
|
219
|
+
valid: false,
|
|
220
|
+
error: 'Missing or invalid X-Pierre-Event header',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Validate signature
|
|
225
|
+
const validationResult = await validateWebhookSignature(
|
|
226
|
+
payload,
|
|
227
|
+
signatureHeader,
|
|
228
|
+
secret,
|
|
229
|
+
options
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
if (!validationResult.valid) {
|
|
233
|
+
return validationResult;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Parse payload
|
|
237
|
+
const payloadStr =
|
|
238
|
+
typeof payload === 'string' ? payload : payload.toString('utf8');
|
|
239
|
+
let parsedJson: unknown;
|
|
240
|
+
try {
|
|
241
|
+
parsedJson = JSON.parse(payloadStr);
|
|
242
|
+
} catch {
|
|
243
|
+
return {
|
|
244
|
+
valid: false,
|
|
245
|
+
error: 'Invalid JSON payload',
|
|
246
|
+
timestamp: validationResult.timestamp,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const conversion = convertWebhookPayload(String(eventType), parsedJson);
|
|
251
|
+
if (!conversion.valid) {
|
|
252
|
+
return {
|
|
253
|
+
valid: false,
|
|
254
|
+
error: conversion.error,
|
|
255
|
+
timestamp: validationResult.timestamp,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
valid: true,
|
|
261
|
+
eventType,
|
|
262
|
+
timestamp: validationResult.timestamp,
|
|
263
|
+
payload: conversion.payload,
|
|
264
|
+
};
|
|
262
265
|
}
|
|
263
266
|
|
|
264
267
|
function convertWebhookPayload(
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
):
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
268
|
+
eventType: string,
|
|
269
|
+
raw: unknown
|
|
270
|
+
):
|
|
271
|
+
| { valid: true; payload: WebhookEventPayload }
|
|
272
|
+
| { valid: false; error: string } {
|
|
273
|
+
if (eventType === 'push') {
|
|
274
|
+
if (!isRawWebhookPushEvent(raw)) {
|
|
275
|
+
return {
|
|
276
|
+
valid: false,
|
|
277
|
+
error: 'Invalid push payload',
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
valid: true,
|
|
282
|
+
payload: transformPushEvent(raw),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const fallbackPayload = { type: eventType, raw };
|
|
286
|
+
return {
|
|
287
|
+
valid: true,
|
|
288
|
+
payload: fallbackPayload,
|
|
289
|
+
};
|
|
285
290
|
}
|
|
286
291
|
|
|
287
292
|
function transformPushEvent(raw: RawWebhookPushEvent): WebhookPushEvent {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
293
|
+
return {
|
|
294
|
+
type: 'push' as const,
|
|
295
|
+
repository: {
|
|
296
|
+
id: raw.repository.id,
|
|
297
|
+
url: raw.repository.url,
|
|
298
|
+
},
|
|
299
|
+
ref: raw.ref,
|
|
300
|
+
before: raw.before,
|
|
301
|
+
after: raw.after,
|
|
302
|
+
customerId: raw.customer_id,
|
|
303
|
+
pushedAt: new Date(raw.pushed_at),
|
|
304
|
+
rawPushedAt: raw.pushed_at,
|
|
305
|
+
};
|
|
301
306
|
}
|
|
302
307
|
|
|
303
308
|
function isRawWebhookPushEvent(value: unknown): value is RawWebhookPushEvent {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
309
|
+
if (!isRecord(value)) {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
if (!isRecord(value.repository)) {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
return (
|
|
316
|
+
typeof value.repository.id === 'string' &&
|
|
317
|
+
typeof value.repository.url === 'string' &&
|
|
318
|
+
typeof value.ref === 'string' &&
|
|
319
|
+
typeof value.before === 'string' &&
|
|
320
|
+
typeof value.after === 'string' &&
|
|
321
|
+
typeof value.customer_id === 'string' &&
|
|
322
|
+
typeof value.pushed_at === 'string'
|
|
323
|
+
);
|
|
319
324
|
}
|
|
320
325
|
|
|
321
326
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
322
|
-
|
|
327
|
+
return typeof value === 'object' && value !== null;
|
|
323
328
|
}
|