@pierre/storage 0.0.5 → 0.0.6

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/index.ts CHANGED
@@ -5,7 +5,11 @@
5
5
  */
6
6
 
7
7
  import { importPKCS8, SignJWT } from 'jose';
8
+ import snakecaseKeys from 'snakecase-keys';
9
+ import { ApiFetcher } from './fetch';
8
10
  import type {
11
+ CreateRepoOptions,
12
+ FindOneOptions,
9
13
  GetBranchDiffOptions,
10
14
  GetBranchDiffResponse,
11
15
  GetCommitDiffOptions,
@@ -14,60 +18,56 @@ import type {
14
18
  GetCommitResponse,
15
19
  GetFileOptions,
16
20
  GetFileResponse,
21
+ GetRemoteURLOptions,
22
+ GitStorageOptions,
17
23
  ListBranchesOptions,
18
24
  ListBranchesResponse,
19
25
  ListCommitsOptions,
20
26
  ListCommitsResponse,
21
27
  ListFilesOptions,
22
28
  ListFilesResponse,
29
+ OverrideableGitStorageOptions,
23
30
  Repo,
31
+ RepullOptions,
32
+ ValidAPIVersion,
24
33
  } from './types';
25
34
 
26
35
  /**
27
36
  * Type definitions for Pierre Git Storage SDK
28
37
  */
29
38
 
30
- export interface GitStorageOptions {
31
- key: string;
32
- name: string;
33
- }
34
-
35
- export interface GetRemoteURLOptions {
36
- permissions?: ('git:write' | 'git:read' | 'repo:write')[];
37
- ttl?: number;
38
- }
39
-
40
- // Repo interface is now imported from types.ts
41
-
42
- export interface FindOneOptions {
43
- id: string;
44
- }
45
-
46
- export interface CreateRepoOptions {
47
- id?: string;
48
- }
49
-
50
- export interface CreateRepoResponse {
51
- repo_id: string;
52
- url: string;
53
- }
54
-
55
39
  // Import additional types from types.ts
56
40
  export * from './types';
57
41
 
42
+ // Export webhook validation utilities
43
+ export { parseSignatureHeader, validateWebhook, validateWebhookSignature } from './webhook';
44
+
58
45
  /**
59
46
  * Git Storage API
60
47
  */
61
- declare const __API_BASE_URL__: string;
48
+
62
49
  declare const __STORAGE_BASE_URL__: string;
50
+ declare const __API_BASE_URL__: string;
63
51
 
64
52
  const API_BASE_URL = __API_BASE_URL__;
65
53
  const STORAGE_BASE_URL = __STORAGE_BASE_URL__;
54
+ const API_VERSION: ValidAPIVersion = 1;
55
+
56
+ const apiInstanceMap = new Map<string, ApiFetcher>();
57
+
58
+ function getApiInstance(baseUrl: string, version: ValidAPIVersion) {
59
+ if (!apiInstanceMap.has(`${baseUrl}--${version}`)) {
60
+ apiInstanceMap.set(`${baseUrl}--${version}`, new ApiFetcher(baseUrl, version));
61
+ }
62
+ return apiInstanceMap.get(`${baseUrl}--${version}`)!;
63
+ }
66
64
 
67
65
  /**
68
66
  * Implementation of the Repo interface
69
67
  */
70
68
  class RepoImpl implements Repo {
69
+ private readonly api: ApiFetcher;
70
+
71
71
  constructor(
72
72
  public readonly id: string,
73
73
  private readonly options: GitStorageOptions,
@@ -75,10 +75,16 @@ class RepoImpl implements Repo {
75
75
  repoId: string,
76
76
  options?: GetRemoteURLOptions,
77
77
  ) => Promise<string>,
78
- ) {}
78
+ ) {
79
+ this.api = getApiInstance(
80
+ this.options.apiBaseUrl ?? API_BASE_URL,
81
+ this.options.apiVersion ?? API_VERSION,
82
+ );
83
+ }
79
84
 
80
85
  async getRemoteURL(urlOptions?: GetRemoteURLOptions): Promise<string> {
81
- const url = new URL(`https://${this.options.name}.${STORAGE_BASE_URL}/${this.id}.git`);
86
+ const storageBaseUrl = this.options.storageBaseUrl ?? STORAGE_BASE_URL;
87
+ const url = new URL(`https://${this.options.name}.${storageBaseUrl}/${this.id}.git`);
82
88
  url.username = `t`;
83
89
  url.password = await this.generateJWT(this.id, urlOptions);
84
90
  return url.toString();
@@ -87,25 +93,18 @@ class RepoImpl implements Repo {
87
93
  async getFile(options: GetFileOptions): Promise<GetFileResponse> {
88
94
  const jwt = await this.generateJWT(this.id, {
89
95
  permissions: ['git:read'],
90
- ttl: 1 * 60 * 60, // 1hr in seconds
96
+ ttl: options?.ttl ?? 1 * 60 * 60, // 1hr in seconds
91
97
  });
92
98
 
93
- const url = new URL(`${API_BASE_URL}/api/v1/repos/file`);
94
- url.searchParams.set('path', options.path);
99
+ const params: Record<string, string> = {
100
+ path: options.path,
101
+ };
102
+
95
103
  if (options.ref) {
96
- url.searchParams.set('ref', options.ref);
104
+ params.ref = options.ref;
97
105
  }
98
106
 
99
- const response = await fetch(url.toString(), {
100
- method: 'GET',
101
- headers: {
102
- Authorization: `Bearer ${jwt}`,
103
- },
104
- });
105
-
106
- if (!response.ok) {
107
- throw new Error(`Failed to get file: ${response.statusText}`);
108
- }
107
+ const response = await this.api.get({ path: 'repos/file', params }, jwt);
109
108
 
110
109
  return (await response.json()) as GetFileResponse;
111
110
  }
@@ -113,24 +112,13 @@ class RepoImpl implements Repo {
113
112
  async listFiles(options?: ListFilesOptions): Promise<ListFilesResponse> {
114
113
  const jwt = await this.generateJWT(this.id, {
115
114
  permissions: ['git:read'],
116
- ttl: 1 * 60 * 60, // 1hr in seconds
117
- });
118
-
119
- const url = new URL(`${API_BASE_URL}/api/v1/repos/files`);
120
- if (options?.ref) {
121
- url.searchParams.set('ref', options.ref);
122
- }
123
-
124
- const response = await fetch(url.toString(), {
125
- method: 'GET',
126
- headers: {
127
- Authorization: `Bearer ${jwt}`,
128
- },
115
+ ttl: options?.ttl ?? 1 * 60 * 60, // 1hr in seconds
129
116
  });
130
117
 
131
- if (!response.ok) {
132
- throw new Error(`Failed to list files: ${response.statusText}`);
133
- }
118
+ const params: Record<string, string> | undefined = options?.ref
119
+ ? { ref: options.ref }
120
+ : undefined;
121
+ const response = await this.api.get({ path: 'repos/files', params }, jwt);
134
122
 
135
123
  return (await response.json()) as ListFilesResponse;
136
124
  }
@@ -138,58 +126,48 @@ class RepoImpl implements Repo {
138
126
  async listBranches(options?: ListBranchesOptions): Promise<ListBranchesResponse> {
139
127
  const jwt = await this.generateJWT(this.id, {
140
128
  permissions: ['git:read'],
141
- ttl: 1 * 60 * 60, // 1hr in seconds
129
+ ttl: options?.ttl ?? 1 * 60 * 60, // 1hr in seconds
142
130
  });
143
131
 
144
- const url = new URL(`${API_BASE_URL}/api/v1/repos/branches`);
145
- if (options?.cursor) {
146
- url.searchParams.set('cursor', options.cursor);
147
- }
148
- if (options?.limit) {
149
- url.searchParams.set('limit', options.limit.toString());
150
- }
151
-
152
- const response = await fetch(url.toString(), {
153
- method: 'GET',
154
- headers: {
155
- Authorization: `Bearer ${jwt}`,
156
- },
157
- });
132
+ let params: Record<string, string> | undefined;
158
133
 
159
- if (!response.ok) {
160
- throw new Error(`Failed to list branches: ${response.statusText}`);
134
+ if (options?.cursor || !options?.limit) {
135
+ params = {};
136
+ if (options?.cursor) {
137
+ params.cursor = options.cursor;
138
+ }
139
+ if (typeof options?.limit == 'number') {
140
+ params.limit = options.limit.toString();
141
+ }
161
142
  }
162
143
 
144
+ const response = await this.api.get({ path: 'repos/branches', params }, jwt);
145
+
163
146
  return (await response.json()) as ListBranchesResponse;
164
147
  }
165
148
 
166
149
  async listCommits(options?: ListCommitsOptions): Promise<ListCommitsResponse> {
167
150
  const jwt = await this.generateJWT(this.id, {
168
151
  permissions: ['git:read'],
169
- ttl: 1 * 60 * 60, // 1hr in seconds
152
+ ttl: options?.ttl ?? 1 * 60 * 60, // 1hr in seconds
170
153
  });
171
154
 
172
- const url = new URL(`${API_BASE_URL}/api/v1/repos/commits`);
173
- if (options?.branch) {
174
- url.searchParams.set('branch', options.branch);
175
- }
176
- if (options?.cursor) {
177
- url.searchParams.set('cursor', options.cursor);
178
- }
179
- if (options?.limit) {
180
- url.searchParams.set('limit', options.limit.toString());
155
+ let params: Record<string, string> | undefined;
156
+
157
+ if (options?.branch || options?.cursor || options?.limit) {
158
+ params = {};
159
+ if (options?.branch) {
160
+ params.branch = options.branch;
161
+ }
162
+ if (options?.cursor) {
163
+ params.cursor = options.cursor;
164
+ }
165
+ if (typeof options?.limit == 'number') {
166
+ params.limit = options.limit.toString();
167
+ }
181
168
  }
182
169
 
183
- const response = await fetch(url.toString(), {
184
- method: 'GET',
185
- headers: {
186
- Authorization: `Bearer ${jwt}`,
187
- },
188
- });
189
-
190
- if (!response.ok) {
191
- throw new Error(`Failed to list commits: ${response.statusText}`);
192
- }
170
+ const response = await this.api.get({ path: 'repos/commits', params }, jwt);
193
171
 
194
172
  return (await response.json()) as ListCommitsResponse;
195
173
  }
@@ -197,25 +175,18 @@ class RepoImpl implements Repo {
197
175
  async getBranchDiff(options: GetBranchDiffOptions): Promise<GetBranchDiffResponse> {
198
176
  const jwt = await this.generateJWT(this.id, {
199
177
  permissions: ['git:read'],
200
- ttl: 1 * 60 * 60, // 1hr in seconds
178
+ ttl: options?.ttl ?? 1 * 60 * 60, // 1hr in seconds
201
179
  });
202
180
 
203
- const url = new URL(`${API_BASE_URL}/api/v1/repos/branches/diff`);
204
- url.searchParams.set('branch', options.branch);
181
+ const params: Record<string, string> = {
182
+ branch: options.branch,
183
+ };
184
+
205
185
  if (options.base) {
206
- url.searchParams.set('base', options.base);
186
+ params.base = options.base;
207
187
  }
208
188
 
209
- const response = await fetch(url.toString(), {
210
- method: 'GET',
211
- headers: {
212
- Authorization: `Bearer ${jwt}`,
213
- },
214
- });
215
-
216
- if (!response.ok) {
217
- throw new Error(`Failed to get branch diff: ${response.statusText}`);
218
- }
189
+ const response = await this.api.get({ path: 'repos/branches/diff', params }, jwt);
219
190
 
220
191
  return (await response.json()) as GetBranchDiffResponse;
221
192
  }
@@ -223,22 +194,14 @@ class RepoImpl implements Repo {
223
194
  async getCommitDiff(options: GetCommitDiffOptions): Promise<GetCommitDiffResponse> {
224
195
  const jwt = await this.generateJWT(this.id, {
225
196
  permissions: ['git:read'],
226
- ttl: 1 * 60 * 60, // 1hr in seconds
197
+ ttl: options?.ttl ?? 1 * 60 * 60, // 1hr in seconds
227
198
  });
228
199
 
229
- const url = new URL(`${API_BASE_URL}/api/v1/repos/diff`);
230
- url.searchParams.set('sha', options.sha);
231
-
232
- const response = await fetch(url.toString(), {
233
- method: 'GET',
234
- headers: {
235
- Authorization: `Bearer ${jwt}`,
236
- },
237
- });
200
+ const params: Record<string, string> = {
201
+ sha: options.sha,
202
+ };
238
203
 
239
- if (!response.ok) {
240
- throw new Error(`Failed to get commit diff: ${response.statusText}`);
241
- }
204
+ const response = await this.api.get({ path: 'repos/diff', params }, jwt);
242
205
 
243
206
  return (await response.json()) as GetCommitDiffResponse;
244
207
  }
@@ -246,30 +209,44 @@ class RepoImpl implements Repo {
246
209
  async getCommit(options: GetCommitOptions): Promise<GetCommitResponse> {
247
210
  const jwt = await this.generateJWT(this.id, {
248
211
  permissions: ['git:read'],
249
- ttl: 1 * 60 * 60, // 1hr in seconds
212
+ ttl: options?.ttl ?? 1 * 60 * 60, // 1hr in seconds
250
213
  });
251
214
 
252
- const url = new URL(`${API_BASE_URL}/commit`);
253
- url.searchParams.set('repo', this.id);
254
- url.searchParams.set('sha', options.sha);
215
+ const params: Record<string, string> = {
216
+ repo: this.id,
217
+ sha: options.sha,
218
+ };
219
+ const response = await this.api.get({ path: 'commit', params }, jwt);
220
+
221
+ return (await response.json()) as GetCommitResponse;
222
+ }
255
223
 
256
- const response = await fetch(url.toString(), {
257
- method: 'GET',
258
- headers: {
259
- Authorization: `Bearer ${jwt}`,
260
- },
224
+ async repull(options: RepullOptions): Promise<void> {
225
+ const jwt = await this.generateJWT(this.id, {
226
+ permissions: ['git:write'],
227
+ ttl: options?.ttl ?? 1 * 60 * 60, // 1hr in seconds
261
228
  });
262
229
 
263
- if (!response.ok) {
264
- throw new Error(`Failed to get commit: ${response.statusText}`);
230
+ const body: Record<string, string> = {};
231
+
232
+ if (options.ref) {
233
+ body.ref = options.ref;
265
234
  }
266
235
 
267
- return (await response.json()) as GetCommitResponse;
236
+ const response = await this.api.post({ path: 'repos/repull', body }, jwt);
237
+
238
+ if (response.status !== 202) {
239
+ throw new Error(`Repull failed: ${response.status} ${await response.text()}`);
240
+ }
241
+
242
+ return;
268
243
  }
269
244
  }
270
245
 
271
246
  export class GitStorage {
247
+ private static overrides: OverrideableGitStorageOptions = {};
272
248
  private options: GitStorageOptions;
249
+ private api: ApiFetcher;
273
250
 
274
251
  constructor(options: GitStorageOptions) {
275
252
  if (
@@ -292,12 +269,27 @@ export class GitStorage {
292
269
  throw new Error('GitStorage key must be a non-empty string.');
293
270
  }
294
271
 
272
+ const resolvedApiBaseUrl =
273
+ options.apiBaseUrl ?? GitStorage.overrides.apiBaseUrl ?? API_BASE_URL;
274
+ const resolvedApiVersion = options.apiVersion ?? GitStorage.overrides.apiVersion ?? API_VERSION;
275
+ const resolvedStorageBaseUrl =
276
+ options.storageBaseUrl ?? GitStorage.overrides.storageBaseUrl ?? STORAGE_BASE_URL;
277
+
278
+ this.api = getApiInstance(resolvedApiBaseUrl, resolvedApiVersion);
279
+
295
280
  this.options = {
296
281
  key: options.key,
297
282
  name: options.name,
283
+ apiBaseUrl: resolvedApiBaseUrl,
284
+ apiVersion: resolvedApiVersion,
285
+ storageBaseUrl: resolvedStorageBaseUrl,
298
286
  };
299
287
  }
300
288
 
289
+ static override(options: OverrideableGitStorageOptions): void {
290
+ this.overrides = Object.assign({}, this.overrides, options);
291
+ }
292
+
301
293
  /**
302
294
  * Create a new repository
303
295
  * @returns The created repository
@@ -307,18 +299,29 @@ export class GitStorage {
307
299
 
308
300
  const jwt = await this.generateJWT(repoId, {
309
301
  permissions: ['repo:write'],
310
- ttl: 1 * 60 * 60, // 1hr in seconds
311
- });
312
-
313
- const response = await fetch(`${API_BASE_URL}/api/v1/repos`, {
314
- method: 'POST',
315
- headers: {
316
- Authorization: `Bearer ${jwt}`,
317
- },
302
+ ttl: options?.ttl ?? 1 * 60 * 60, // 1hr in seconds
318
303
  });
319
304
 
320
- if (!response.ok) {
321
- throw new Error(`Failed to create repository: ${response.statusText}`);
305
+ const baseRepoOptions = options?.baseRepo
306
+ ? {
307
+ provider: 'github',
308
+ ...snakecaseKeys(options.baseRepo as unknown as Record<string, unknown>),
309
+ }
310
+ : null;
311
+
312
+ const createRepoPath = baseRepoOptions
313
+ ? {
314
+ path: 'repos',
315
+ body: {
316
+ base_repo: baseRepoOptions,
317
+ },
318
+ }
319
+ : 'repos';
320
+
321
+ // Allow 409 so we can map it to a clearer error message
322
+ const resp = await this.api.post(createRepoPath, jwt, { allowedStatus: [409] });
323
+ if (resp.status === 409) {
324
+ throw new Error('Repository already exists');
322
325
  }
323
326
 
324
327
  return new RepoImpl(repoId, this.options, this.generateJWT.bind(this));
@@ -330,6 +333,18 @@ export class GitStorage {
330
333
  * @returns The found repository
331
334
  */
332
335
  async findOne(options: FindOneOptions): Promise<Repo | null> {
336
+ const jwt = await this.generateJWT(options.id, {
337
+ permissions: ['git:read'],
338
+ ttl: 1 * 60 * 60,
339
+ });
340
+
341
+ // Allow 404 to indicate "not found" without throwing
342
+ const resp = await this.api.get('repo', jwt, { allowedStatus: [404] });
343
+ if (resp.status === 404) {
344
+ return null;
345
+ }
346
+ // On 200, we could validate response, but RepoImpl only needs the repo URL/id
347
+ // const body = await resp.json(); // not required for now
333
348
  return new RepoImpl(options.id, this.options, this.generateJWT.bind(this));
334
349
  }
335
350
 
package/src/types.ts CHANGED
@@ -2,11 +2,20 @@
2
2
  * Type definitions for Pierre Git Storage SDK
3
3
  */
4
4
 
5
- export interface GitStorageOptions {
5
+ export interface OverrideableGitStorageOptions {
6
+ apiBaseUrl?: string;
7
+ storageBaseUrl?: string;
8
+ apiVersion?: ValidAPIVersion;
9
+ }
10
+
11
+ export interface GitStorageOptions extends OverrideableGitStorageOptions {
6
12
  key: string;
7
13
  name: string;
14
+ defaultTTL?: number;
8
15
  }
9
16
 
17
+ export type ValidAPIVersion = 1;
18
+
10
19
  export interface GetRemoteURLOptions {
11
20
  permissions?: ('git:write' | 'git:read' | 'repo:write')[];
12
21
  ttl?: number;
@@ -24,12 +33,38 @@ export interface Repo {
24
33
  getCommit(options: GetCommitOptions): Promise<GetCommitResponse>;
25
34
  }
26
35
 
36
+ export type ValidMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
37
+ type SimplePath = string;
38
+ type ComplexPath = {
39
+ path: string;
40
+ params?: Record<string, string>;
41
+ body?: Record<string, any>;
42
+ };
43
+ export type ValidPath = SimplePath | ComplexPath;
44
+
45
+ interface GitStorageInvocationOptions {
46
+ ttl?: number;
47
+ }
48
+
27
49
  export interface FindOneOptions {
28
50
  id: string;
29
51
  }
30
52
 
31
- export interface CreateRepoOptions {
53
+ export type SupportedRepoProvider = 'github';
54
+
55
+ export interface BaseRepo {
56
+ /**
57
+ * @default github
58
+ */
59
+ provider?: SupportedRepoProvider;
60
+ owner: string;
61
+ name: string;
62
+ defaultBranch?: string;
63
+ }
64
+
65
+ export interface CreateRepoOptions extends GitStorageInvocationOptions {
32
66
  id?: string;
67
+ baseRepo?: BaseRepo;
33
68
  }
34
69
 
35
70
  export interface CreateRepoResponse {
@@ -38,7 +73,7 @@ export interface CreateRepoResponse {
38
73
  }
39
74
 
40
75
  // Get File API types
41
- export interface GetFileOptions {
76
+ export interface GetFileOptions extends GitStorageInvocationOptions {
42
77
  path: string;
43
78
  ref?: string;
44
79
  }
@@ -51,8 +86,12 @@ export interface GetFileResponse {
51
86
  is_binary: boolean;
52
87
  }
53
88
 
89
+ export interface RepullOptions extends GitStorageInvocationOptions {
90
+ ref?: string;
91
+ }
92
+
54
93
  // List Files API types
55
- export interface ListFilesOptions {
94
+ export interface ListFilesOptions extends GitStorageInvocationOptions {
56
95
  ref?: string;
57
96
  }
58
97
 
@@ -62,7 +101,7 @@ export interface ListFilesResponse {
62
101
  }
63
102
 
64
103
  // List Branches API types
65
- export interface ListBranchesOptions {
104
+ export interface ListBranchesOptions extends GitStorageInvocationOptions {
66
105
  cursor?: string;
67
106
  limit?: number;
68
107
  }
@@ -81,7 +120,7 @@ export interface ListBranchesResponse {
81
120
  }
82
121
 
83
122
  // List Commits API types
84
- export interface ListCommitsOptions {
123
+ export interface ListCommitsOptions extends GitStorageInvocationOptions {
85
124
  branch?: string;
86
125
  cursor?: string;
87
126
  limit?: number;
@@ -104,7 +143,7 @@ export interface ListCommitsResponse {
104
143
  }
105
144
 
106
145
  // Branch Diff API types
107
- export interface GetBranchDiffOptions {
146
+ export interface GetBranchDiffOptions extends GitStorageInvocationOptions {
108
147
  branch: string;
109
148
  base?: string;
110
149
  }
@@ -118,7 +157,7 @@ export interface GetBranchDiffResponse {
118
157
  }
119
158
 
120
159
  // Commit Diff API types
121
- export interface GetCommitDiffOptions {
160
+ export interface GetCommitDiffOptions extends GitStorageInvocationOptions {
122
161
  sha: string;
123
162
  }
124
163
 
@@ -155,7 +194,7 @@ export interface FilteredFile {
155
194
  }
156
195
 
157
196
  // Get Commit API types
158
- export interface GetCommitOptions {
197
+ export interface GetCommitOptions extends GitStorageInvocationOptions {
159
198
  sha: string;
160
199
  }
161
200
 
@@ -177,3 +216,51 @@ export interface GetCommitResponse {
177
216
  total: number;
178
217
  };
179
218
  }
219
+
220
+ // Webhook types
221
+ export interface WebhookValidationOptions {
222
+ /**
223
+ * Maximum age of webhook in seconds (default: 300 seconds / 5 minutes)
224
+ * Set to 0 to disable timestamp validation
225
+ */
226
+ maxAgeSeconds?: number;
227
+ }
228
+
229
+ export interface WebhookValidationResult {
230
+ /**
231
+ * Whether the webhook signature and timestamp are valid
232
+ */
233
+ valid: boolean;
234
+ /**
235
+ * Error message if validation failed
236
+ */
237
+ error?: string;
238
+ /**
239
+ * The parsed webhook event type (e.g., "push")
240
+ */
241
+ eventType?: string;
242
+ /**
243
+ * The timestamp from the signature (Unix seconds)
244
+ */
245
+ timestamp?: number;
246
+ }
247
+
248
+ // Webhook event payloads
249
+ export interface WebhookPushEvent {
250
+ repository: {
251
+ id: string;
252
+ url: string;
253
+ };
254
+ ref: string;
255
+ before: string;
256
+ after: string;
257
+ customer_id: string;
258
+ pushed_at: string; // RFC3339 timestamp
259
+ }
260
+
261
+ export type WebhookEventPayload = WebhookPushEvent;
262
+
263
+ export interface ParsedWebhookSignature {
264
+ timestamp: string;
265
+ signature: string;
266
+ }
package/src/util.ts ADDED
@@ -0,0 +1,62 @@
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;
4
+
5
+ if (bufferA.length !== bufferB.length) return false;
6
+
7
+ let result = 0;
8
+ for (let i = 0; i < bufferA.length; i++) {
9
+ result |= bufferA[i] ^ bufferB[i];
10
+ }
11
+ return result === 0;
12
+ }
13
+
14
+ export async function getEnvironmentCrypto() {
15
+ if (!globalThis.crypto) {
16
+ const { webcrypto } = await import('node:crypto');
17
+ return webcrypto;
18
+ }
19
+ return globalThis.crypto;
20
+ }
21
+
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
+ }
29
+
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
+ );
39
+
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('');
44
+ }
45
+
46
+ // Keep the legacy async function for backward compatibility
47
+ export async function createHmacAsync(secret: string, data: string): Promise<string> {
48
+ const crypto = await getEnvironmentCrypto();
49
+ const encoder = new TextEncoder();
50
+ const key = await crypto.subtle.importKey(
51
+ 'raw',
52
+ encoder.encode(secret),
53
+ { name: 'HMAC', hash: 'SHA-256' },
54
+ false,
55
+ ['sign'],
56
+ );
57
+
58
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
59
+ return Array.from(new Uint8Array(signature))
60
+ .map((b) => b.toString(16).padStart(2, '0'))
61
+ .join('');
62
+ }