@pierre/storage 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,16 +1,261 @@
1
1
  import { importPKCS8, SignJWT } from 'jose';
2
+ import snakecaseKeys from 'snakecase-keys';
3
+
4
+ // src/index.ts
5
+
6
+ // src/fetch.ts
7
+ var ApiFetcher = class {
8
+ constructor(API_BASE_URL2, version) {
9
+ this.API_BASE_URL = API_BASE_URL2;
10
+ this.version = version;
11
+ console.log("api fetcher created", API_BASE_URL2, version);
12
+ }
13
+ getBaseUrl() {
14
+ return `${this.API_BASE_URL}/api/v${this.version}`;
15
+ }
16
+ getRequestUrl(path) {
17
+ if (typeof path === "string") {
18
+ return `${this.getBaseUrl()}/${path}`;
19
+ } else if (path.params) {
20
+ const paramStr = new URLSearchParams(path.params).toString();
21
+ return `${this.getBaseUrl()}/${path.path}${paramStr ? `?${paramStr}` : ""}`;
22
+ } else {
23
+ return `${this.getBaseUrl()}/${path.path}`;
24
+ }
25
+ }
26
+ async fetch(path, method, jwt, options) {
27
+ const requestUrl = this.getRequestUrl(path);
28
+ const requestOptions = {
29
+ method,
30
+ headers: {
31
+ Authorization: `Bearer ${jwt}`,
32
+ "Content-Type": "application/json"
33
+ }
34
+ };
35
+ if (method !== "GET" && typeof path !== "string" && path.body) {
36
+ requestOptions.body = JSON.stringify(path.body);
37
+ }
38
+ const response = await fetch(requestUrl, requestOptions);
39
+ if (!response.ok) {
40
+ const allowed = options?.allowedStatus ?? [];
41
+ if (!allowed.includes(response.status)) {
42
+ throw new Error(`Failed to fetch ${method} ${requestUrl}: ${response.statusText}`);
43
+ }
44
+ }
45
+ return response;
46
+ }
47
+ async get(path, jwt, options) {
48
+ return this.fetch(path, "GET", jwt, options);
49
+ }
50
+ async post(path, jwt, options) {
51
+ return this.fetch(path, "POST", jwt, options);
52
+ }
53
+ async put(path, jwt, options) {
54
+ return this.fetch(path, "PUT", jwt, options);
55
+ }
56
+ async delete(path, jwt, options) {
57
+ return this.fetch(path, "DELETE", jwt, options);
58
+ }
59
+ };
60
+
61
+ // src/util.ts
62
+ function timingSafeEqual(a, b) {
63
+ const bufferA = typeof a === "string" ? new TextEncoder().encode(a) : a;
64
+ const bufferB = typeof b === "string" ? new TextEncoder().encode(b) : b;
65
+ if (bufferA.length !== bufferB.length) return false;
66
+ let result = 0;
67
+ for (let i = 0; i < bufferA.length; i++) {
68
+ result |= bufferA[i] ^ bufferB[i];
69
+ }
70
+ return result === 0;
71
+ }
72
+ async function getEnvironmentCrypto() {
73
+ if (!globalThis.crypto) {
74
+ const { webcrypto } = await import('crypto');
75
+ return webcrypto;
76
+ }
77
+ return globalThis.crypto;
78
+ }
79
+ async function createHmac(algorithm, secret, data) {
80
+ if (!secret || secret.length === 0) {
81
+ throw new Error("Secret is required");
82
+ }
83
+ const crypto2 = await getEnvironmentCrypto();
84
+ const encoder = new TextEncoder();
85
+ const key = await crypto2.subtle.importKey(
86
+ "raw",
87
+ encoder.encode(secret),
88
+ { name: "HMAC", hash: "SHA-256" },
89
+ false,
90
+ ["sign"]
91
+ );
92
+ const signature = await crypto2.subtle.sign("HMAC", key, encoder.encode(data));
93
+ return Array.from(new Uint8Array(signature)).map((b) => b.toString(16).padStart(2, "0")).join("");
94
+ }
95
+
96
+ // src/webhook.ts
97
+ var DEFAULT_MAX_AGE_SECONDS = 300;
98
+ function parseSignatureHeader(header) {
99
+ if (!header || typeof header !== "string") {
100
+ return null;
101
+ }
102
+ let timestamp = "";
103
+ let signature = "";
104
+ const elements = header.split(",");
105
+ for (const element of elements) {
106
+ const trimmedElement = element.trim();
107
+ const parts = trimmedElement.split("=", 2);
108
+ if (parts.length !== 2) {
109
+ continue;
110
+ }
111
+ const [key, value] = parts;
112
+ switch (key) {
113
+ case "t":
114
+ timestamp = value;
115
+ break;
116
+ case "sha256":
117
+ signature = value;
118
+ break;
119
+ }
120
+ }
121
+ if (!timestamp || !signature) {
122
+ return null;
123
+ }
124
+ return { timestamp, signature };
125
+ }
126
+ async function validateWebhookSignature(payload, signatureHeader, secret, options = {}) {
127
+ if (!secret || secret.length === 0) {
128
+ return {
129
+ valid: false,
130
+ error: "Empty secret is not allowed"
131
+ };
132
+ }
133
+ const parsed = parseSignatureHeader(signatureHeader);
134
+ if (!parsed) {
135
+ return {
136
+ valid: false,
137
+ error: "Invalid signature header format"
138
+ };
139
+ }
140
+ const timestamp = Number.parseInt(parsed.timestamp, 10);
141
+ if (isNaN(timestamp)) {
142
+ return {
143
+ valid: false,
144
+ error: "Invalid timestamp in signature"
145
+ };
146
+ }
147
+ const maxAge = options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
148
+ if (maxAge > 0) {
149
+ const now = Math.floor(Date.now() / 1e3);
150
+ const age = now - timestamp;
151
+ if (age > maxAge) {
152
+ return {
153
+ valid: false,
154
+ error: `Webhook timestamp too old (${age} seconds)`,
155
+ timestamp
156
+ };
157
+ }
158
+ if (age < -60) {
159
+ return {
160
+ valid: false,
161
+ error: "Webhook timestamp is in the future",
162
+ timestamp
163
+ };
164
+ }
165
+ }
166
+ const payloadStr = typeof payload === "string" ? payload : payload.toString("utf8");
167
+ const signedData = `${parsed.timestamp}.${payloadStr}`;
168
+ const expectedSignature = await createHmac("sha256", secret, signedData);
169
+ const expectedBuffer = Buffer.from(expectedSignature);
170
+ const actualBuffer = Buffer.from(parsed.signature);
171
+ if (expectedBuffer.length !== actualBuffer.length) {
172
+ return {
173
+ valid: false,
174
+ error: "Invalid signature",
175
+ timestamp
176
+ };
177
+ }
178
+ const signaturesMatch = timingSafeEqual(expectedBuffer, actualBuffer);
179
+ if (!signaturesMatch) {
180
+ return {
181
+ valid: false,
182
+ error: "Invalid signature",
183
+ timestamp
184
+ };
185
+ }
186
+ return {
187
+ valid: true,
188
+ timestamp
189
+ };
190
+ }
191
+ async function validateWebhook(payload, headers, secret, options = {}) {
192
+ const signatureHeader = headers["x-pierre-signature"] || headers["X-Pierre-Signature"];
193
+ if (!signatureHeader || Array.isArray(signatureHeader)) {
194
+ return {
195
+ valid: false,
196
+ error: "Missing or invalid X-Pierre-Signature header"
197
+ };
198
+ }
199
+ const eventType = headers["x-pierre-event"] || headers["X-Pierre-Event"];
200
+ if (!eventType || Array.isArray(eventType)) {
201
+ return {
202
+ valid: false,
203
+ error: "Missing or invalid X-Pierre-Event header"
204
+ };
205
+ }
206
+ const validationResult = await validateWebhookSignature(
207
+ payload,
208
+ signatureHeader,
209
+ secret,
210
+ options
211
+ );
212
+ if (!validationResult.valid) {
213
+ return validationResult;
214
+ }
215
+ const payloadStr = typeof payload === "string" ? payload : payload.toString("utf8");
216
+ let parsedPayload;
217
+ try {
218
+ parsedPayload = JSON.parse(payloadStr);
219
+ } catch {
220
+ return {
221
+ valid: false,
222
+ error: "Invalid JSON payload",
223
+ timestamp: validationResult.timestamp
224
+ };
225
+ }
226
+ return {
227
+ valid: true,
228
+ eventType,
229
+ timestamp: validationResult.timestamp,
230
+ payload: parsedPayload
231
+ };
232
+ }
2
233
 
3
234
  // src/index.ts
4
235
  var API_BASE_URL = "https://api.git.storage";
5
236
  var STORAGE_BASE_URL = "git.storage";
237
+ var API_VERSION = 1;
238
+ var apiInstanceMap = /* @__PURE__ */ new Map();
239
+ function getApiInstance(baseUrl, version) {
240
+ if (!apiInstanceMap.has(`${baseUrl}--${version}`)) {
241
+ apiInstanceMap.set(`${baseUrl}--${version}`, new ApiFetcher(baseUrl, version));
242
+ }
243
+ return apiInstanceMap.get(`${baseUrl}--${version}`);
244
+ }
6
245
  var RepoImpl = class {
7
246
  constructor(id, options, generateJWT) {
8
247
  this.id = id;
9
248
  this.options = options;
10
249
  this.generateJWT = generateJWT;
250
+ this.api = getApiInstance(
251
+ this.options.apiBaseUrl ?? API_BASE_URL,
252
+ this.options.apiVersion ?? API_VERSION
253
+ );
11
254
  }
255
+ api;
12
256
  async getRemoteURL(urlOptions) {
13
- const url = new URL(`https://${this.options.name}.${STORAGE_BASE_URL}/${this.id}.git`);
257
+ const storageBaseUrl = this.options.storageBaseUrl ?? STORAGE_BASE_URL;
258
+ const url = new URL(`https://${this.options.name}.${storageBaseUrl}/${this.id}.git`);
14
259
  url.username = `t`;
15
260
  url.password = await this.generateJWT(this.id, urlOptions);
16
261
  return url.toString();
@@ -18,161 +263,130 @@ var RepoImpl = class {
18
263
  async getFile(options) {
19
264
  const jwt = await this.generateJWT(this.id, {
20
265
  permissions: ["git:read"],
21
- ttl: 1 * 60 * 60
266
+ ttl: options?.ttl ?? 1 * 60 * 60
22
267
  // 1hr in seconds
23
268
  });
24
- const url = new URL(`${API_BASE_URL}/api/v1/repos/file`);
25
- url.searchParams.set("path", options.path);
269
+ const params = {
270
+ path: options.path
271
+ };
26
272
  if (options.ref) {
27
- url.searchParams.set("ref", options.ref);
28
- }
29
- const response = await fetch(url.toString(), {
30
- method: "GET",
31
- headers: {
32
- Authorization: `Bearer ${jwt}`
33
- }
34
- });
35
- if (!response.ok) {
36
- throw new Error(`Failed to get file: ${response.statusText}`);
273
+ params.ref = options.ref;
37
274
  }
275
+ const response = await this.api.get({ path: "repos/file", params }, jwt);
38
276
  return await response.json();
39
277
  }
40
278
  async listFiles(options) {
41
279
  const jwt = await this.generateJWT(this.id, {
42
280
  permissions: ["git:read"],
43
- ttl: 1 * 60 * 60
281
+ ttl: options?.ttl ?? 1 * 60 * 60
44
282
  // 1hr in seconds
45
283
  });
46
- const url = new URL(`${API_BASE_URL}/api/v1/repos/files`);
47
- if (options?.ref) {
48
- url.searchParams.set("ref", options.ref);
49
- }
50
- const response = await fetch(url.toString(), {
51
- method: "GET",
52
- headers: {
53
- Authorization: `Bearer ${jwt}`
54
- }
55
- });
56
- if (!response.ok) {
57
- throw new Error(`Failed to list files: ${response.statusText}`);
58
- }
284
+ const params = options?.ref ? { ref: options.ref } : void 0;
285
+ const response = await this.api.get({ path: "repos/files", params }, jwt);
59
286
  return await response.json();
60
287
  }
61
288
  async listBranches(options) {
62
289
  const jwt = await this.generateJWT(this.id, {
63
290
  permissions: ["git:read"],
64
- ttl: 1 * 60 * 60
291
+ ttl: options?.ttl ?? 1 * 60 * 60
65
292
  // 1hr in seconds
66
293
  });
67
- const url = new URL(`${API_BASE_URL}/api/v1/repos/branches`);
68
- if (options?.cursor) {
69
- url.searchParams.set("cursor", options.cursor);
70
- }
71
- if (options?.limit) {
72
- url.searchParams.set("limit", options.limit.toString());
73
- }
74
- const response = await fetch(url.toString(), {
75
- method: "GET",
76
- headers: {
77
- Authorization: `Bearer ${jwt}`
294
+ let params;
295
+ if (options?.cursor || !options?.limit) {
296
+ params = {};
297
+ if (options?.cursor) {
298
+ params.cursor = options.cursor;
299
+ }
300
+ if (typeof options?.limit == "number") {
301
+ params.limit = options.limit.toString();
78
302
  }
79
- });
80
- if (!response.ok) {
81
- throw new Error(`Failed to list branches: ${response.statusText}`);
82
303
  }
304
+ const response = await this.api.get({ path: "repos/branches", params }, jwt);
83
305
  return await response.json();
84
306
  }
85
307
  async listCommits(options) {
86
308
  const jwt = await this.generateJWT(this.id, {
87
309
  permissions: ["git:read"],
88
- ttl: 1 * 60 * 60
310
+ ttl: options?.ttl ?? 1 * 60 * 60
89
311
  // 1hr in seconds
90
312
  });
91
- const url = new URL(`${API_BASE_URL}/api/v1/repos/commits`);
92
- if (options?.branch) {
93
- url.searchParams.set("branch", options.branch);
94
- }
95
- if (options?.cursor) {
96
- url.searchParams.set("cursor", options.cursor);
97
- }
98
- if (options?.limit) {
99
- url.searchParams.set("limit", options.limit.toString());
100
- }
101
- const response = await fetch(url.toString(), {
102
- method: "GET",
103
- headers: {
104
- Authorization: `Bearer ${jwt}`
313
+ let params;
314
+ if (options?.branch || options?.cursor || options?.limit) {
315
+ params = {};
316
+ if (options?.branch) {
317
+ params.branch = options.branch;
318
+ }
319
+ if (options?.cursor) {
320
+ params.cursor = options.cursor;
321
+ }
322
+ if (typeof options?.limit == "number") {
323
+ params.limit = options.limit.toString();
105
324
  }
106
- });
107
- if (!response.ok) {
108
- throw new Error(`Failed to list commits: ${response.statusText}`);
109
325
  }
326
+ const response = await this.api.get({ path: "repos/commits", params }, jwt);
110
327
  return await response.json();
111
328
  }
112
329
  async getBranchDiff(options) {
113
330
  const jwt = await this.generateJWT(this.id, {
114
331
  permissions: ["git:read"],
115
- ttl: 1 * 60 * 60
332
+ ttl: options?.ttl ?? 1 * 60 * 60
116
333
  // 1hr in seconds
117
334
  });
118
- const url = new URL(`${API_BASE_URL}/api/v1/repos/branches/diff`);
119
- url.searchParams.set("branch", options.branch);
335
+ const params = {
336
+ branch: options.branch
337
+ };
120
338
  if (options.base) {
121
- url.searchParams.set("base", options.base);
122
- }
123
- const response = await fetch(url.toString(), {
124
- method: "GET",
125
- headers: {
126
- Authorization: `Bearer ${jwt}`
127
- }
128
- });
129
- if (!response.ok) {
130
- throw new Error(`Failed to get branch diff: ${response.statusText}`);
339
+ params.base = options.base;
131
340
  }
341
+ const response = await this.api.get({ path: "repos/branches/diff", params }, jwt);
132
342
  return await response.json();
133
343
  }
134
344
  async getCommitDiff(options) {
135
345
  const jwt = await this.generateJWT(this.id, {
136
346
  permissions: ["git:read"],
137
- ttl: 1 * 60 * 60
347
+ ttl: options?.ttl ?? 1 * 60 * 60
138
348
  // 1hr in seconds
139
349
  });
140
- const url = new URL(`${API_BASE_URL}/api/v1/repos/diff`);
141
- url.searchParams.set("sha", options.sha);
142
- const response = await fetch(url.toString(), {
143
- method: "GET",
144
- headers: {
145
- Authorization: `Bearer ${jwt}`
146
- }
147
- });
148
- if (!response.ok) {
149
- throw new Error(`Failed to get commit diff: ${response.statusText}`);
150
- }
350
+ const params = {
351
+ sha: options.sha
352
+ };
353
+ const response = await this.api.get({ path: "repos/diff", params }, jwt);
151
354
  return await response.json();
152
355
  }
153
356
  async getCommit(options) {
154
357
  const jwt = await this.generateJWT(this.id, {
155
358
  permissions: ["git:read"],
156
- ttl: 1 * 60 * 60
359
+ ttl: options?.ttl ?? 1 * 60 * 60
157
360
  // 1hr in seconds
158
361
  });
159
- const url = new URL(`${API_BASE_URL}/commit`);
160
- url.searchParams.set("repo", this.id);
161
- url.searchParams.set("sha", options.sha);
162
- const response = await fetch(url.toString(), {
163
- method: "GET",
164
- headers: {
165
- Authorization: `Bearer ${jwt}`
166
- }
362
+ const params = {
363
+ repo: this.id,
364
+ sha: options.sha
365
+ };
366
+ const response = await this.api.get({ path: "commit", params }, jwt);
367
+ return await response.json();
368
+ }
369
+ async repull(options) {
370
+ const jwt = await this.generateJWT(this.id, {
371
+ permissions: ["git:write"],
372
+ ttl: options?.ttl ?? 1 * 60 * 60
373
+ // 1hr in seconds
167
374
  });
168
- if (!response.ok) {
169
- throw new Error(`Failed to get commit: ${response.statusText}`);
375
+ const body = {};
376
+ if (options.ref) {
377
+ body.ref = options.ref;
170
378
  }
171
- return await response.json();
379
+ const response = await this.api.post({ path: "repos/repull", body }, jwt);
380
+ if (response.status !== 202) {
381
+ throw new Error(`Repull failed: ${response.status} ${await response.text()}`);
382
+ }
383
+ return;
172
384
  }
173
385
  };
174
- var GitStorage = class {
386
+ var GitStorage = class _GitStorage {
387
+ static overrides = {};
175
388
  options;
389
+ api;
176
390
  constructor(options) {
177
391
  if (!options || options.name === void 0 || options.key === void 0 || options.name === null || options.key === null) {
178
392
  throw new Error(
@@ -185,11 +399,21 @@ var GitStorage = class {
185
399
  if (typeof options.key !== "string" || options.key.trim() === "") {
186
400
  throw new Error("GitStorage key must be a non-empty string.");
187
401
  }
402
+ const resolvedApiBaseUrl = options.apiBaseUrl ?? _GitStorage.overrides.apiBaseUrl ?? API_BASE_URL;
403
+ const resolvedApiVersion = options.apiVersion ?? _GitStorage.overrides.apiVersion ?? API_VERSION;
404
+ const resolvedStorageBaseUrl = options.storageBaseUrl ?? _GitStorage.overrides.storageBaseUrl ?? STORAGE_BASE_URL;
405
+ this.api = getApiInstance(resolvedApiBaseUrl, resolvedApiVersion);
188
406
  this.options = {
189
407
  key: options.key,
190
- name: options.name
408
+ name: options.name,
409
+ apiBaseUrl: resolvedApiBaseUrl,
410
+ apiVersion: resolvedApiVersion,
411
+ storageBaseUrl: resolvedStorageBaseUrl
191
412
  };
192
413
  }
414
+ static override(options) {
415
+ this.overrides = Object.assign({}, this.overrides, options);
416
+ }
193
417
  /**
194
418
  * Create a new repository
195
419
  * @returns The created repository
@@ -198,17 +422,24 @@ var GitStorage = class {
198
422
  const repoId = options?.id || crypto.randomUUID();
199
423
  const jwt = await this.generateJWT(repoId, {
200
424
  permissions: ["repo:write"],
201
- ttl: 1 * 60 * 60
425
+ ttl: options?.ttl ?? 1 * 60 * 60
202
426
  // 1hr in seconds
203
427
  });
204
- const response = await fetch(`${API_BASE_URL}/api/v1/repos`, {
205
- method: "POST",
206
- headers: {
207
- Authorization: `Bearer ${jwt}`
428
+ const baseRepoOptions = options?.baseRepo ? {
429
+ provider: "github",
430
+ ...snakecaseKeys(options.baseRepo)
431
+ } : null;
432
+ const defaultBranch = options?.defaultBranch ?? "main";
433
+ const createRepoPath = baseRepoOptions || defaultBranch ? {
434
+ path: "repos",
435
+ body: {
436
+ ...baseRepoOptions && { base_repo: baseRepoOptions },
437
+ default_branch: defaultBranch
208
438
  }
209
- });
210
- if (!response.ok) {
211
- throw new Error(`Failed to create repository: ${response.statusText}`);
439
+ } : "repos";
440
+ const resp = await this.api.post(createRepoPath, jwt, { allowedStatus: [409] });
441
+ if (resp.status === 409) {
442
+ throw new Error("Repository already exists");
212
443
  }
213
444
  return new RepoImpl(repoId, this.options, this.generateJWT.bind(this));
214
445
  }
@@ -218,6 +449,14 @@ var GitStorage = class {
218
449
  * @returns The found repository
219
450
  */
220
451
  async findOne(options) {
452
+ const jwt = await this.generateJWT(options.id, {
453
+ permissions: ["git:read"],
454
+ ttl: 1 * 60 * 60
455
+ });
456
+ const resp = await this.api.get("repo", jwt, { allowedStatus: [404] });
457
+ if (resp.status === 404) {
458
+ return null;
459
+ }
221
460
  return new RepoImpl(options.id, this.options, this.generateJWT.bind(this));
222
461
  }
223
462
  /**
@@ -252,6 +491,6 @@ function createClient(options) {
252
491
  return new GitStorage(options);
253
492
  }
254
493
 
255
- export { GitStorage, createClient };
494
+ export { GitStorage, createClient, parseSignatureHeader, validateWebhook, validateWebhookSignature };
256
495
  //# sourceMappingURL=index.js.map
257
496
  //# sourceMappingURL=index.js.map