@insureco/bio 0.1.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/dist/index.mjs ADDED
@@ -0,0 +1,611 @@
1
+ // src/auth.ts
2
+ import crypto2 from "crypto";
3
+
4
+ // src/errors.ts
5
+ var BioError = class extends Error {
6
+ /** HTTP status code (if from an API response) */
7
+ statusCode;
8
+ /** Machine-readable error code (e.g. 'invalid_grant', 'token_expired') */
9
+ code;
10
+ /** Additional error details from the API */
11
+ details;
12
+ constructor(message, code, statusCode, details) {
13
+ super(message);
14
+ this.name = "BioError";
15
+ this.code = code;
16
+ this.statusCode = statusCode;
17
+ this.details = details;
18
+ }
19
+ };
20
+
21
+ // src/pkce.ts
22
+ import crypto from "crypto";
23
+ function generatePKCE() {
24
+ const codeVerifier = crypto.randomBytes(32).toString("base64url");
25
+ const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
26
+ return { codeVerifier, codeChallenge };
27
+ }
28
+
29
+ // src/utils.ts
30
+ function retryDelay(attempt) {
31
+ const baseDelay = Math.min(1e3 * 2 ** attempt, 5e3);
32
+ return baseDelay * (0.5 + Math.random() * 0.5);
33
+ }
34
+ function sleep(ms) {
35
+ return new Promise((resolve) => setTimeout(resolve, ms));
36
+ }
37
+ async function parseJsonResponse(response) {
38
+ try {
39
+ return await response.json();
40
+ } catch {
41
+ throw new BioError(
42
+ `Bio-ID returned ${response.status} with non-JSON body`,
43
+ "parse_error",
44
+ response.status
45
+ );
46
+ }
47
+ }
48
+
49
+ // src/auth.ts
50
+ var DEFAULT_ISSUER = "https://bio.tawa.insureco.io";
51
+ var DEFAULT_SCOPES = ["openid", "profile", "email"];
52
+ var DEFAULT_TIMEOUT_MS = 1e4;
53
+ var BioAuth = class _BioAuth {
54
+ clientId;
55
+ clientSecret;
56
+ issuer;
57
+ retries;
58
+ timeoutMs;
59
+ constructor(config) {
60
+ if (!config.clientId) {
61
+ throw new BioError("clientId is required", "config_error");
62
+ }
63
+ if (!config.clientSecret) {
64
+ throw new BioError("clientSecret is required", "config_error");
65
+ }
66
+ this.clientId = config.clientId;
67
+ this.clientSecret = config.clientSecret;
68
+ this.issuer = (config.issuer ?? DEFAULT_ISSUER).replace(/\/$/, "");
69
+ this.retries = config.retries ?? 2;
70
+ this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
71
+ }
72
+ /**
73
+ * Create a BioAuth from environment variables.
74
+ *
75
+ * Reads: BIO_CLIENT_ID, BIO_CLIENT_SECRET, BIO_ID_URL
76
+ */
77
+ static fromEnv() {
78
+ const clientId = process.env.BIO_CLIENT_ID;
79
+ const clientSecret = process.env.BIO_CLIENT_SECRET;
80
+ if (!clientId) {
81
+ throw new BioError(
82
+ "BIO_CLIENT_ID environment variable is required",
83
+ "config_error"
84
+ );
85
+ }
86
+ if (!clientSecret) {
87
+ throw new BioError(
88
+ "BIO_CLIENT_SECRET environment variable is required",
89
+ "config_error"
90
+ );
91
+ }
92
+ return new _BioAuth({
93
+ clientId,
94
+ clientSecret,
95
+ issuer: process.env.BIO_ID_URL
96
+ });
97
+ }
98
+ /**
99
+ * Build an authorization URL with PKCE for redirecting the user to Bio-ID.
100
+ *
101
+ * Returns the URL, state, and PKCE verifier/challenge.
102
+ * Store the state and codeVerifier securely (e.g. in a cookie) for the callback.
103
+ */
104
+ getAuthorizationUrl(opts) {
105
+ if (!opts.redirectUri) {
106
+ throw new BioError("redirectUri is required", "validation_error");
107
+ }
108
+ const state = opts.state ?? crypto2.randomBytes(16).toString("hex");
109
+ const { codeVerifier, codeChallenge } = generatePKCE();
110
+ const scopes = opts.scopes ?? DEFAULT_SCOPES;
111
+ const params = new URLSearchParams({
112
+ client_id: this.clientId,
113
+ redirect_uri: opts.redirectUri,
114
+ response_type: "code",
115
+ scope: scopes.join(" "),
116
+ state,
117
+ code_challenge: codeChallenge,
118
+ code_challenge_method: "S256"
119
+ });
120
+ return {
121
+ url: `${this.issuer}/oauth/authorize?${params.toString()}`,
122
+ state,
123
+ codeVerifier,
124
+ codeChallenge
125
+ };
126
+ }
127
+ /**
128
+ * Exchange an authorization code for tokens.
129
+ *
130
+ * Called in your OAuth callback handler after the user authorizes.
131
+ */
132
+ async exchangeCode(code, codeVerifier, redirectUri) {
133
+ if (!code) throw new BioError("code is required", "validation_error");
134
+ if (!codeVerifier) throw new BioError("codeVerifier is required", "validation_error");
135
+ if (!redirectUri) throw new BioError("redirectUri is required", "validation_error");
136
+ return this.tokenRequest({
137
+ grant_type: "authorization_code",
138
+ code,
139
+ redirect_uri: redirectUri,
140
+ client_id: this.clientId,
141
+ client_secret: this.clientSecret,
142
+ code_verifier: codeVerifier
143
+ });
144
+ }
145
+ /**
146
+ * Refresh an expired access token using a refresh token.
147
+ *
148
+ * Returns new access_token and a rotated refresh_token.
149
+ */
150
+ async refreshToken(refreshToken) {
151
+ if (!refreshToken) throw new BioError("refreshToken is required", "validation_error");
152
+ return this.tokenRequest({
153
+ grant_type: "refresh_token",
154
+ refresh_token: refreshToken,
155
+ client_id: this.clientId,
156
+ client_secret: this.clientSecret
157
+ });
158
+ }
159
+ /**
160
+ * Get a client credentials token for service-to-service auth.
161
+ *
162
+ * No user context — the token identifies your OAuth client only.
163
+ */
164
+ async getClientCredentialsToken(scopes) {
165
+ const params = {
166
+ grant_type: "client_credentials",
167
+ client_id: this.clientId,
168
+ client_secret: this.clientSecret
169
+ };
170
+ if (scopes?.length) {
171
+ params.scope = scopes.join(" ");
172
+ }
173
+ return this.tokenRequest(params);
174
+ }
175
+ /**
176
+ * Fetch the authenticated user's profile from the userinfo endpoint.
177
+ */
178
+ async getUserInfo(accessToken) {
179
+ const raw = await this.request(
180
+ "GET",
181
+ "/api/oauth/userinfo",
182
+ void 0,
183
+ { Authorization: `Bearer ${accessToken}` }
184
+ );
185
+ return mapUserInfoResponse(raw);
186
+ }
187
+ /**
188
+ * Revoke a token (typically the refresh token on logout).
189
+ */
190
+ async revokeToken(token, hint) {
191
+ const body = new URLSearchParams({
192
+ token,
193
+ client_id: this.clientId,
194
+ client_secret: this.clientSecret
195
+ });
196
+ if (hint) {
197
+ body.set("token_type_hint", hint);
198
+ }
199
+ const response = await this.fetchWithRetry("POST", `${this.issuer}/api/oauth/revoke`, {
200
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
201
+ body: body.toString()
202
+ });
203
+ if (!response.ok) {
204
+ const json = await parseJsonResponse(response);
205
+ throw new BioError(
206
+ json.error_description ?? json.error ?? `Revocation failed (${response.status})`,
207
+ "revocation_error",
208
+ response.status
209
+ );
210
+ }
211
+ }
212
+ /**
213
+ * Introspect a token to check if it's active and get user/client context.
214
+ *
215
+ * Does not require JWT_SECRET — validates against Bio-ID server.
216
+ */
217
+ async introspect(token) {
218
+ const raw = await this.request("POST", "/api/auth/introspect", { token });
219
+ return mapIntrospectResponse(raw);
220
+ }
221
+ // ── Private helpers ──────────────────────────────────────────────────────
222
+ async tokenRequest(params) {
223
+ const response = await this.fetchWithRetry(
224
+ "POST",
225
+ `${this.issuer}/api/oauth/token`,
226
+ {
227
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
228
+ body: new URLSearchParams(params).toString()
229
+ }
230
+ );
231
+ const json = await parseJsonResponse(response);
232
+ if (!response.ok) {
233
+ throw new BioError(
234
+ json.error_description ?? json.error ?? `Token request failed (${response.status})`,
235
+ json.error ?? "token_error",
236
+ response.status,
237
+ json
238
+ );
239
+ }
240
+ return json;
241
+ }
242
+ async request(method, path, body, extraHeaders) {
243
+ const headers = {
244
+ ...extraHeaders
245
+ };
246
+ if (body) {
247
+ headers["Content-Type"] = "application/json";
248
+ }
249
+ const response = await this.fetchWithRetry(
250
+ method,
251
+ `${this.issuer}${path}`,
252
+ {
253
+ headers,
254
+ body: body ? JSON.stringify(body) : void 0
255
+ }
256
+ );
257
+ const json = await parseJsonResponse(response);
258
+ if (!response.ok) {
259
+ throw new BioError(
260
+ json.error ?? `Bio-ID returned ${response.status}`,
261
+ json.error ?? "api_error",
262
+ response.status,
263
+ json
264
+ );
265
+ }
266
+ return json;
267
+ }
268
+ async fetchWithRetry(method, url, init, attempt = 0) {
269
+ try {
270
+ const response = await fetch(url, {
271
+ method,
272
+ headers: init.headers,
273
+ body: init.body,
274
+ signal: AbortSignal.timeout(this.timeoutMs)
275
+ });
276
+ if (response.status >= 500 && attempt < this.retries) {
277
+ await sleep(retryDelay(attempt));
278
+ return this.fetchWithRetry(method, url, init, attempt + 1);
279
+ }
280
+ return response;
281
+ } catch (err) {
282
+ if (attempt < this.retries) {
283
+ await sleep(retryDelay(attempt));
284
+ return this.fetchWithRetry(method, url, init, attempt + 1);
285
+ }
286
+ const isTimeout = err instanceof DOMException && err.name === "TimeoutError";
287
+ throw new BioError(
288
+ isTimeout ? `Request timed out after ${this.timeoutMs}ms` : err instanceof Error ? err.message : "Network error",
289
+ isTimeout ? "timeout" : "network_error"
290
+ );
291
+ }
292
+ }
293
+ };
294
+ function mapUserInfoResponse(raw) {
295
+ return {
296
+ sub: raw.sub,
297
+ bioId: raw.bio_id ?? raw.sub,
298
+ email: raw.email,
299
+ emailVerified: raw.email_verified ?? false,
300
+ name: raw.name,
301
+ firstName: raw.given_name,
302
+ lastName: raw.family_name,
303
+ userType: raw.user_type ?? "user",
304
+ roles: raw.roles ?? [],
305
+ permissions: raw.permissions ?? [],
306
+ status: raw.status ?? "active",
307
+ orgId: raw.org_id,
308
+ orgSlug: raw.org_slug,
309
+ organizationId: raw.organization_id,
310
+ organizationName: raw.organization_name,
311
+ departmentId: raw.department_id,
312
+ departmentName: raw.department_name,
313
+ managerId: raw.manager_id,
314
+ enabledModules: raw.enabled_modules,
315
+ jobTitle: raw.job_title,
316
+ phoneHome: raw.phone_home,
317
+ phoneWork: raw.phone_work,
318
+ phoneCell: raw.phone_cell,
319
+ phone: raw.phone_number,
320
+ addressHome: raw.address_home,
321
+ addressWork: raw.address_work,
322
+ messaging: raw.messaging,
323
+ preferences: raw.preferences,
324
+ lastLoginAt: raw.last_login_at
325
+ };
326
+ }
327
+ function mapIntrospectResponse(raw) {
328
+ return {
329
+ active: raw.active,
330
+ user: raw.user,
331
+ tokenType: raw.token_type,
332
+ clientId: raw.client_id,
333
+ scopes: raw.scopes,
334
+ orgId: raw.org_id ?? raw.orgId,
335
+ orgSlug: raw.org_slug ?? raw.orgSlug,
336
+ organizationName: raw.organization_name ?? raw.organizationName
337
+ };
338
+ }
339
+
340
+ // src/admin.ts
341
+ var DEFAULT_BASE_URL = "https://bio.tawa.insureco.io";
342
+ var DEFAULT_TIMEOUT_MS2 = 1e4;
343
+ var BioAdmin = class _BioAdmin {
344
+ baseUrl;
345
+ internalKey;
346
+ accessTokenFn;
347
+ retries;
348
+ timeoutMs;
349
+ constructor(config) {
350
+ if (!config.internalKey && !config.accessTokenFn) {
351
+ throw new BioError(
352
+ "Either internalKey or accessTokenFn is required",
353
+ "config_error"
354
+ );
355
+ }
356
+ this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
357
+ this.internalKey = config.internalKey;
358
+ this.accessTokenFn = config.accessTokenFn;
359
+ this.retries = config.retries ?? 2;
360
+ this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
361
+ }
362
+ /**
363
+ * Create a BioAdmin from environment variables.
364
+ *
365
+ * Reads: BIO_ID_URL, INTERNAL_API_KEY
366
+ */
367
+ static fromEnv() {
368
+ const internalKey = process.env.INTERNAL_API_KEY;
369
+ if (!internalKey) {
370
+ throw new BioError(
371
+ "INTERNAL_API_KEY environment variable is required",
372
+ "config_error"
373
+ );
374
+ }
375
+ return new _BioAdmin({
376
+ baseUrl: process.env.BIO_ID_URL,
377
+ internalKey
378
+ });
379
+ }
380
+ // ── Users ────────────────────────────────────────────────────────────────
381
+ /** List users with optional filters */
382
+ async listUsers(filters) {
383
+ const params = new URLSearchParams();
384
+ if (filters?.search) params.set("search", filters.search);
385
+ if (filters?.status) params.set("status", filters.status);
386
+ if (filters?.userType) params.set("userType", filters.userType);
387
+ if (filters?.organizationId) params.set("organizationId", filters.organizationId);
388
+ if (filters?.page) params.set("page", String(filters.page));
389
+ if (filters?.limit) params.set("limit", String(filters.limit));
390
+ const qs = params.toString();
391
+ const path = qs ? `/api/admin/users?${qs}` : "/api/admin/users";
392
+ const result = await this.request("GET", path);
393
+ return result.data ?? [];
394
+ }
395
+ /** Get a single user by bioId */
396
+ async getUser(bioId) {
397
+ const result = await this.request(
398
+ "GET",
399
+ `/api/admin/users/${encodeURIComponent(bioId)}`
400
+ );
401
+ if (!result.data) {
402
+ throw new BioError(`User ${bioId} not found`, "not_found", 404);
403
+ }
404
+ return result.data;
405
+ }
406
+ /** Update a user's profile, roles, or status */
407
+ async updateUser(bioId, data) {
408
+ const result = await this.request(
409
+ "PATCH",
410
+ `/api/admin/users/${encodeURIComponent(bioId)}`,
411
+ data
412
+ );
413
+ if (!result.data) {
414
+ throw new BioError(`Failed to update user ${bioId}`, "update_failed");
415
+ }
416
+ return result.data;
417
+ }
418
+ // ── Departments ──────────────────────────────────────────────────────────
419
+ /** List all departments */
420
+ async listDepartments() {
421
+ const result = await this.request(
422
+ "GET",
423
+ "/api/admin/departments"
424
+ );
425
+ return result.data ?? [];
426
+ }
427
+ /** Create a new department */
428
+ async createDepartment(data) {
429
+ const result = await this.request(
430
+ "POST",
431
+ "/api/admin/departments",
432
+ data
433
+ );
434
+ if (!result.data) {
435
+ throw new BioError("Failed to create department", "create_failed");
436
+ }
437
+ return result.data;
438
+ }
439
+ // ── Roles ────────────────────────────────────────────────────────────────
440
+ /** List all roles */
441
+ async listRoles() {
442
+ const result = await this.request(
443
+ "GET",
444
+ "/api/admin/roles"
445
+ );
446
+ return result.data ?? [];
447
+ }
448
+ /** Create a new role */
449
+ async createRole(data) {
450
+ const result = await this.request(
451
+ "POST",
452
+ "/api/admin/roles",
453
+ data
454
+ );
455
+ if (!result.data) {
456
+ throw new BioError("Failed to create role", "create_failed");
457
+ }
458
+ return result.data;
459
+ }
460
+ // ── OAuth Clients ────────────────────────────────────────────────────────
461
+ /** List all OAuth clients */
462
+ async listClients() {
463
+ const result = await this.request(
464
+ "GET",
465
+ "/api/admin/oauth-clients"
466
+ );
467
+ return result.data ?? [];
468
+ }
469
+ /** Create a new OAuth client */
470
+ async createClient(data) {
471
+ const result = await this.request(
472
+ "POST",
473
+ "/api/admin/oauth-clients",
474
+ data
475
+ );
476
+ if (!result.data) {
477
+ throw new BioError("Failed to create OAuth client", "create_failed");
478
+ }
479
+ return result.data;
480
+ }
481
+ // ── Private helpers ──────────────────────────────────────────────────────
482
+ async request(method, path, body, attempt = 0) {
483
+ const headers = {};
484
+ if (this.internalKey) {
485
+ headers["x-internal-key"] = this.internalKey;
486
+ } else if (this.accessTokenFn) {
487
+ headers["Authorization"] = `Bearer ${await this.accessTokenFn()}`;
488
+ }
489
+ if (body) {
490
+ headers["Content-Type"] = "application/json";
491
+ }
492
+ let response;
493
+ try {
494
+ response = await fetch(`${this.baseUrl}${path}`, {
495
+ method,
496
+ headers,
497
+ body: body ? JSON.stringify(body) : void 0,
498
+ signal: AbortSignal.timeout(this.timeoutMs)
499
+ });
500
+ } catch (err) {
501
+ if (attempt < this.retries) {
502
+ await sleep(retryDelay(attempt));
503
+ return this.request(method, path, body, attempt + 1);
504
+ }
505
+ const isTimeout = err instanceof DOMException && err.name === "TimeoutError";
506
+ throw new BioError(
507
+ isTimeout ? `Request timed out after ${this.timeoutMs}ms` : err instanceof Error ? err.message : "Network error",
508
+ isTimeout ? "timeout" : "network_error"
509
+ );
510
+ }
511
+ let json;
512
+ try {
513
+ json = await response.json();
514
+ } catch {
515
+ if (response.status >= 500 && attempt < this.retries) {
516
+ await sleep(retryDelay(attempt));
517
+ return this.request(method, path, body, attempt + 1);
518
+ }
519
+ throw new BioError(
520
+ `Bio-ID returned ${response.status} with non-JSON body`,
521
+ "parse_error",
522
+ response.status
523
+ );
524
+ }
525
+ if (!response.ok) {
526
+ if (response.status >= 500 && attempt < this.retries) {
527
+ await sleep(retryDelay(attempt));
528
+ return this.request(method, path, body, attempt + 1);
529
+ }
530
+ const errorBody = json;
531
+ throw new BioError(
532
+ errorBody.error ?? `Bio-ID returned ${response.status}`,
533
+ errorBody.error ?? "api_error",
534
+ response.status,
535
+ errorBody
536
+ );
537
+ }
538
+ return json;
539
+ }
540
+ };
541
+
542
+ // src/jwt.ts
543
+ import crypto3 from "crypto";
544
+ var DEFAULT_ISSUERS = [
545
+ "https://bio.insureco.io",
546
+ "https://bio.tawa.insureco.io",
547
+ "http://localhost:6100"
548
+ ];
549
+ function base64UrlDecode(str) {
550
+ return Buffer.from(str, "base64url").toString("utf8");
551
+ }
552
+ function verifyToken(token, secret, options) {
553
+ const parts = token.split(".");
554
+ if (parts.length !== 3) {
555
+ throw new BioError("Malformed JWT: expected 3 parts", "invalid_token");
556
+ }
557
+ const [headerB64, payloadB64, signatureB64] = parts;
558
+ const header = JSON.parse(base64UrlDecode(headerB64));
559
+ if (header.alg !== "HS256") {
560
+ throw new BioError(`Unsupported algorithm: ${header.alg}`, "unsupported_alg");
561
+ }
562
+ const data = `${headerB64}.${payloadB64}`;
563
+ const expectedBuf = crypto3.createHmac("sha256", secret).update(data).digest();
564
+ const actualBuf = Buffer.from(signatureB64, "base64url");
565
+ if (expectedBuf.length !== actualBuf.length || !crypto3.timingSafeEqual(expectedBuf, actualBuf)) {
566
+ throw new BioError("Invalid JWT signature", "invalid_signature");
567
+ }
568
+ const payload = JSON.parse(base64UrlDecode(payloadB64));
569
+ const now = Math.floor(Date.now() / 1e3);
570
+ if (!payload.exp) {
571
+ throw new BioError("Token missing expiration claim", "invalid_token");
572
+ }
573
+ if (payload.exp < now) {
574
+ throw new BioError("Token has expired", "token_expired");
575
+ }
576
+ if (options?.issuer) {
577
+ if (payload.iss !== options.issuer) {
578
+ throw new BioError(`Invalid issuer: ${payload.iss}`, "invalid_issuer");
579
+ }
580
+ } else if (!DEFAULT_ISSUERS.includes(payload.iss)) {
581
+ throw new BioError(`Unknown issuer: ${payload.iss}`, "invalid_issuer");
582
+ }
583
+ if (options?.audience && payload.aud !== options.audience) {
584
+ throw new BioError(`Invalid audience: ${payload.aud}`, "invalid_audience");
585
+ }
586
+ return payload;
587
+ }
588
+ function decodeToken(token) {
589
+ try {
590
+ const parts = token.split(".");
591
+ if (parts.length !== 3) return null;
592
+ return JSON.parse(base64UrlDecode(parts[1]));
593
+ } catch {
594
+ return null;
595
+ }
596
+ }
597
+ function isTokenExpired(token, bufferSeconds = 30) {
598
+ const payload = decodeToken(token);
599
+ if (!payload?.exp) return true;
600
+ const now = Math.floor(Date.now() / 1e3);
601
+ return payload.exp < now + bufferSeconds;
602
+ }
603
+ export {
604
+ BioAdmin,
605
+ BioAuth,
606
+ BioError,
607
+ decodeToken,
608
+ generatePKCE,
609
+ isTokenExpired,
610
+ verifyToken
611
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@insureco/bio",
3
+ "version": "0.1.0",
4
+ "description": "SDK for Bio-ID SSO integration on the Tawa platform",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "lint": "tsc --noEmit",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "keywords": [
26
+ "insureco",
27
+ "tawa",
28
+ "bio-id",
29
+ "oauth",
30
+ "oidc",
31
+ "sso",
32
+ "authentication"
33
+ ],
34
+ "author": "InsurEco",
35
+ "license": "MIT",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.0.0",
41
+ "tsup": "^8.0.0",
42
+ "typescript": "^5.4.0",
43
+ "vitest": "^2.0.0"
44
+ },
45
+ "engines": {
46
+ "node": ">=18.0.0"
47
+ }
48
+ }