@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/src/util.ts CHANGED
@@ -1,44 +1,51 @@
1
- export function timingSafeEqual(a: string | Uint8Array, b: string | Uint8Array): boolean {
2
- const bufferA = typeof a === 'string' ? new TextEncoder().encode(a) : a;
3
- const bufferB = typeof b === 'string' ? new TextEncoder().encode(b) : b;
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
- if (bufferA.length !== bufferB.length) return false;
8
+ if (bufferA.length !== bufferB.length) return false;
6
9
 
7
- let result = 0;
8
- for (let i = 0; i < bufferA.length; i++) {
9
- result |= bufferA[i] ^ bufferB[i];
10
- }
11
- return result === 0;
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
- if (!globalThis.crypto) {
16
- const { webcrypto } = await import('node:crypto');
17
- return webcrypto;
18
- }
19
- return globalThis.crypto;
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(algorithm: string, secret: string, data: string): Promise<string> {
23
- if (algorithm !== 'sha256') {
24
- throw new Error('Only sha256 algorithm is supported');
25
- }
26
- if (!secret || secret.length === 0) {
27
- throw new Error('Secret is required');
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
- const crypto = await getEnvironmentCrypto();
31
- const encoder = new TextEncoder();
32
- const key = await crypto.subtle.importKey(
33
- 'raw',
34
- encoder.encode(secret),
35
- { name: 'HMAC', hash: 'SHA-256' },
36
- false,
37
- ['sign'],
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
- const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
41
- return Array.from(new Uint8Array(signature))
42
- .map((b) => b.toString(16).padStart(2, '0'))
43
- .join('');
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
@@ -4,5 +4,5 @@ export const PACKAGE_NAME = 'code-storage-sdk';
4
4
  export const PACKAGE_VERSION = packageJson.version;
5
5
 
6
6
  export function getUserAgent(): string {
7
- return `${PACKAGE_NAME}/${PACKAGE_VERSION}`;
7
+ return `${PACKAGE_NAME}/${PACKAGE_VERSION}`;
8
8
  }
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
- ParsedWebhookSignature,
7
- RawWebhookPushEvent,
8
- WebhookEventPayload,
9
- WebhookPushEvent,
10
- WebhookValidationOptions,
11
- WebhookValidationResult,
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(header: string): 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 };
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
- payload: string | Buffer,
82
- signatureHeader: string,
83
- secret: string,
84
- options: WebhookValidationOptions = {},
81
+ payload: string | Buffer,
82
+ signatureHeader: string,
83
+ secret: string,
84
+ options: WebhookValidationOptions = {}
85
85
  ): Promise<WebhookValidationResult> {
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 = typeof payload === 'string' ? payload : payload.toString('utf8');
137
-
138
- // Compute expected signature
139
- // Format: HMAC-SHA256(secret, timestamp + "." + payload)
140
- const signedData = `${parsed.timestamp}.${payloadStr}`;
141
- const expectedSignature = await createHmac('sha256', secret, signedData);
142
-
143
- // Compare signatures using constant-time comparison
144
- const expectedBuffer = Buffer.from(expectedSignature);
145
- const actualBuffer = Buffer.from(parsed.signature);
146
-
147
- // Ensure both buffers are the same length for timing-safe comparison
148
- if (expectedBuffer.length !== actualBuffer.length) {
149
- return {
150
- valid: false,
151
- error: 'Invalid signature',
152
- timestamp,
153
- };
154
- }
155
-
156
- const signaturesMatch = timingSafeEqual(expectedBuffer, actualBuffer);
157
- if (!signaturesMatch) {
158
- return {
159
- valid: false,
160
- error: 'Invalid signature',
161
- timestamp,
162
- };
163
- }
164
-
165
- return {
166
- valid: true,
167
- timestamp,
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
- payload: string | Buffer,
200
- headers: Record<string, string | string[] | undefined>,
201
- secret: string,
202
- options: WebhookValidationOptions = {},
200
+ payload: string | Buffer,
201
+ headers: Record<string, string | string[] | undefined>,
202
+ secret: string,
203
+ options: WebhookValidationOptions = {}
203
204
  ): Promise<WebhookValidationResult & { payload?: WebhookEventPayload }> {
204
- // Get signature header
205
- const signatureHeader = headers['x-pierre-signature'] || headers['X-Pierre-Signature'];
206
- if (!signatureHeader || Array.isArray(signatureHeader)) {
207
- return {
208
- valid: false,
209
- error: 'Missing or invalid X-Pierre-Signature header',
210
- };
211
- }
212
-
213
- // Get event type header
214
- const eventType = headers['x-pierre-event'] || headers['X-Pierre-Event'];
215
- if (!eventType || Array.isArray(eventType)) {
216
- return {
217
- valid: false,
218
- error: 'Missing or invalid X-Pierre-Event header',
219
- };
220
- }
221
-
222
- // Validate signature
223
- const validationResult = await validateWebhookSignature(
224
- payload,
225
- signatureHeader,
226
- secret,
227
- options,
228
- );
229
-
230
- if (!validationResult.valid) {
231
- return validationResult;
232
- }
233
-
234
- // Parse payload
235
- const payloadStr = typeof payload === 'string' ? payload : payload.toString('utf8');
236
- let parsedJson: unknown;
237
- try {
238
- parsedJson = JSON.parse(payloadStr);
239
- } catch {
240
- return {
241
- valid: false,
242
- error: 'Invalid JSON payload',
243
- timestamp: validationResult.timestamp,
244
- };
245
- }
246
-
247
- const conversion = convertWebhookPayload(String(eventType), parsedJson);
248
- if (!conversion.valid) {
249
- return {
250
- valid: false,
251
- error: conversion.error,
252
- timestamp: validationResult.timestamp,
253
- };
254
- }
255
-
256
- return {
257
- valid: true,
258
- eventType,
259
- timestamp: validationResult.timestamp,
260
- payload: conversion.payload,
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
- eventType: string,
266
- raw: unknown,
267
- ): { valid: true; payload: WebhookEventPayload } | { valid: false; error: string } {
268
- if (eventType === 'push') {
269
- if (!isRawWebhookPushEvent(raw)) {
270
- return {
271
- valid: false,
272
- error: 'Invalid push payload',
273
- };
274
- }
275
- return {
276
- valid: true,
277
- payload: transformPushEvent(raw),
278
- };
279
- }
280
- const fallbackPayload = { type: eventType, raw };
281
- return {
282
- valid: true,
283
- payload: fallbackPayload,
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
- return {
289
- type: 'push' as const,
290
- repository: {
291
- id: raw.repository.id,
292
- url: raw.repository.url,
293
- },
294
- ref: raw.ref,
295
- before: raw.before,
296
- after: raw.after,
297
- customerId: raw.customer_id,
298
- pushedAt: new Date(raw.pushed_at),
299
- rawPushedAt: raw.pushed_at,
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
- if (!isRecord(value)) {
305
- return false;
306
- }
307
- if (!isRecord(value.repository)) {
308
- return false;
309
- }
310
- return (
311
- typeof value.repository.id === 'string' &&
312
- typeof value.repository.url === 'string' &&
313
- typeof value.ref === 'string' &&
314
- typeof value.before === 'string' &&
315
- typeof value.after === 'string' &&
316
- typeof value.customer_id === 'string' &&
317
- typeof value.pushed_at === 'string'
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
- return typeof value === 'object' && value !== null;
327
+ return typeof value === 'object' && value !== null;
323
328
  }