najm-auth 4.0.3 → 4.0.5
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 +34 -1
- package/dist/client/edge.d.ts +3 -3
- package/dist/client/server/index.d.ts +5 -3
- package/dist/client/server/index.js +1 -0
- package/dist/index.d.ts +26 -1
- package/dist/index.js +85 -10
- package/dist/schema/pg.js +6 -1
- package/dist/schema/sqlite.js +4 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -140,6 +140,13 @@ auth({
|
|
|
140
140
|
|
|
141
141
|
// Frontend
|
|
142
142
|
frontendUrl?: string // Password reset link base URL
|
|
143
|
+
appName?: string // Security email brand (default: 'Your app')
|
|
144
|
+
accountInviteLogo?: { // Optional CID-backed inline mark
|
|
145
|
+
alt?: string
|
|
146
|
+
contentBase64: string
|
|
147
|
+
contentType: string
|
|
148
|
+
filename: string
|
|
149
|
+
}
|
|
143
150
|
|
|
144
151
|
// Login identifier normalization (see "Identity presets")
|
|
145
152
|
identity?: {
|
|
@@ -601,7 +608,7 @@ users
|
|
|
601
608
|
|
|
602
609
|
roles
|
|
603
610
|
├── id (string, primary key)
|
|
604
|
-
├── name (string, unique)
|
|
611
|
+
├── name (string, unique — `roles_name_unique` in both dialects)
|
|
605
612
|
├── description (string, nullable)
|
|
606
613
|
├── createdAt (timestamp)
|
|
607
614
|
└── updatedAt (timestamp)
|
|
@@ -693,6 +700,21 @@ const customUsers = sqliteTable('users', {
|
|
|
693
700
|
|
|
694
701
|
## Seeding
|
|
695
702
|
|
|
703
|
+
Role names are database-enforced identities. `authSeed()` reconciles the `roles`
|
|
704
|
+
entry **by name**, not by the primary key it proposes, so a role that already
|
|
705
|
+
exists under a legacy or randomly generated ID is reused rather than inserted a
|
|
706
|
+
second time. Downstream resolvers receive the row the database actually holds,
|
|
707
|
+
which keeps `users.role_id` and `role_permissions.role_id` pointing at the live
|
|
708
|
+
ID. Repeat seeding is idempotent and never rewrites a role's primary key —
|
|
709
|
+
`users.role_id` carries no `ON UPDATE CASCADE` contract.
|
|
710
|
+
|
|
711
|
+
> **Adopting this from an earlier version:** the unique index is a persistence
|
|
712
|
+
> invariant, not only a validation change. Consolidate any duplicate role names
|
|
713
|
+
> **before** you apply a migration that adds it, or the migration fails. Consumer
|
|
714
|
+
> migrations are application-owned; this package ships the schema declaration,
|
|
715
|
+
> not your migration.
|
|
716
|
+
|
|
717
|
+
|
|
696
718
|
### Low-Level Seeding (authSeed)
|
|
697
719
|
|
|
698
720
|
```typescript
|
|
@@ -1142,6 +1164,17 @@ activates the account when its status is `pending`. An ordinary password reset
|
|
|
1142
1164
|
changes neither verification nor lifecycle status, and an explicitly inactive
|
|
1143
1165
|
invited account remains inactive.
|
|
1144
1166
|
|
|
1167
|
+
Set `appName` on `auth()` to brand the invitation subject and email card. The
|
|
1168
|
+
provisioned role is presented as the account type, so a sponsor invitation can
|
|
1169
|
+
say “Activate your sponsor account” without application-owned HTML. The shared
|
|
1170
|
+
template uses inline critical styles for Gmail and keeps the raw token URL out
|
|
1171
|
+
of visible fallback copy.
|
|
1172
|
+
|
|
1173
|
+
For a branded mark that works in email clients without a public asset URL, set
|
|
1174
|
+
`accountInviteLogo` to base64 content plus its MIME type and filename. Najm
|
|
1175
|
+
attaches it inline and points the shared template at a stable CID; when omitted,
|
|
1176
|
+
the template renders `appName` as text.
|
|
1177
|
+
|
|
1145
1178
|
The built-in memory and Redis drivers implement the required atomic primitive.
|
|
1146
1179
|
A custom cache driver may omit `compareAndDelete()` for compatibility with
|
|
1147
1180
|
unrelated cache usage, but reset and invite consumption then fails closed. Do
|
package/dist/client/edge.d.ts
CHANGED
|
@@ -18,13 +18,13 @@ interface AuthProxyOptions {
|
|
|
18
18
|
}
|
|
19
19
|
interface AuthMiddlewareConfig {
|
|
20
20
|
/** Routes that require authentication (glob patterns) */
|
|
21
|
-
protectedRoutes?: string[];
|
|
21
|
+
protectedRoutes?: readonly string[];
|
|
22
22
|
/** Always-public routes (glob patterns) */
|
|
23
|
-
publicRoutes?: string[];
|
|
23
|
+
publicRoutes?: readonly string[];
|
|
24
24
|
/** Route to redirect unauthenticated users to */
|
|
25
25
|
loginRoute?: string;
|
|
26
26
|
/** Routes restricted to specific roles: { '/admin/*': ['admin'] } */
|
|
27
|
-
roleRoutes?: Record<string, string[]
|
|
27
|
+
roleRoutes?: Readonly<Record<string, readonly string[]>>;
|
|
28
28
|
/** Refresh token cookie name (default: 'refreshToken') */
|
|
29
29
|
cookieName?: string;
|
|
30
30
|
/** API base URL used by session recovery (default: '/api'). */
|
|
@@ -158,6 +158,8 @@ interface DefineAuthConfig {
|
|
|
158
158
|
authPrefix?: string;
|
|
159
159
|
/** Refresh token cookie name (default: 'refreshToken') */
|
|
160
160
|
cookieName?: string;
|
|
161
|
+
/** Remember Me preference cookie used by routeHandlers; per-handler options override it. */
|
|
162
|
+
rememberCookieName?: string;
|
|
161
163
|
/** Proactive refresh at this fraction of token lifetime (default: 0.8) */
|
|
162
164
|
refreshThreshold?: number;
|
|
163
165
|
/** Enable multi-tab sync via BroadcastChannel (default: true) */
|
|
@@ -183,11 +185,11 @@ interface DefineAuthConfig {
|
|
|
183
185
|
*/
|
|
184
186
|
forbiddenRoute?: string;
|
|
185
187
|
/** Routes that are always public (glob patterns) */
|
|
186
|
-
publicRoutes?: string[];
|
|
188
|
+
publicRoutes?: readonly string[];
|
|
187
189
|
/** Routes that require authentication (glob patterns) */
|
|
188
|
-
protectedRoutes?: string[];
|
|
190
|
+
protectedRoutes?: readonly string[];
|
|
189
191
|
/** Routes restricted to specific roles: { '/admin/:path*': ['admin'] } */
|
|
190
|
-
roleRoutes?: Record<string, string[]
|
|
192
|
+
roleRoutes?: Readonly<Record<string, readonly string[]>>;
|
|
191
193
|
/** Session cookie name (default: 'najm.session') */
|
|
192
194
|
sessionCookieName?: string;
|
|
193
195
|
/** Secret for verifying session cookie HMAC. Falls back to env vars. */
|
|
@@ -1734,6 +1734,7 @@ function defineAuth(authConfig = {}) {
|
|
|
1734
1734
|
const routeHandlers = /* @__PURE__ */ __name((handler, options = {}) => {
|
|
1735
1735
|
const persistentHandler = withAuthCookiePersistence(handler, {
|
|
1736
1736
|
...options,
|
|
1737
|
+
rememberCookieName: options.rememberCookieName ?? authConfig.rememberCookieName,
|
|
1737
1738
|
authCookieNames: options.authCookieNames ?? [cookieName2, sessionCookieName]
|
|
1738
1739
|
});
|
|
1739
1740
|
return {
|
package/dist/index.d.ts
CHANGED
|
@@ -198,6 +198,15 @@ interface AuthConfig {
|
|
|
198
198
|
defaultRole: string | null;
|
|
199
199
|
/** Frontend URL for password reset links (default: 'http://localhost:3000') */
|
|
200
200
|
frontendUrl: string;
|
|
201
|
+
/** Product name used in security email subjects and templates. */
|
|
202
|
+
appName: string;
|
|
203
|
+
/** Optional inline logo embedded in account invitation emails. */
|
|
204
|
+
accountInviteLogo?: {
|
|
205
|
+
alt?: string;
|
|
206
|
+
contentBase64: string;
|
|
207
|
+
contentType: string;
|
|
208
|
+
filename: string;
|
|
209
|
+
};
|
|
201
210
|
/** Registration mode: 'active' auto-activates, 'pending' requires admin approval (default: 'active') */
|
|
202
211
|
registrationMode: 'active' | 'pending';
|
|
203
212
|
/** Whether the unauthenticated POST /auth/register route is mounted. */
|
|
@@ -265,6 +274,15 @@ type AuthPluginConfig = {
|
|
|
265
274
|
defaultRole?: string | null;
|
|
266
275
|
/** Frontend URL for password reset links. Falls back to FRONTEND_URL env var, then 'http://localhost:3000' */
|
|
267
276
|
frontendUrl?: string;
|
|
277
|
+
/** Product name used in account invitation emails (default: 'Your app'). */
|
|
278
|
+
appName?: string;
|
|
279
|
+
/** Optional inline logo embedded in account invitation emails. */
|
|
280
|
+
accountInviteLogo?: {
|
|
281
|
+
alt?: string;
|
|
282
|
+
contentBase64: string;
|
|
283
|
+
contentType: string;
|
|
284
|
+
filename: string;
|
|
285
|
+
};
|
|
268
286
|
/** Registration mode: 'active' auto-activates new users, 'pending' requires admin approval (default: 'active') */
|
|
269
287
|
registrationMode?: 'active' | 'pending';
|
|
270
288
|
/**
|
|
@@ -432,7 +450,7 @@ var auth = {
|
|
|
432
450
|
subject: "Reset your password"
|
|
433
451
|
},
|
|
434
452
|
accountInvite: {
|
|
435
|
-
subject: "
|
|
453
|
+
subject: "{{appName}}: activate your {{accountLabel}}"
|
|
436
454
|
}
|
|
437
455
|
}
|
|
438
456
|
};
|
|
@@ -959,6 +977,13 @@ declare class RoleService {
|
|
|
959
977
|
updatedAt: string;
|
|
960
978
|
}[]>;
|
|
961
979
|
getRoleIdByName(name: any): Promise<string>;
|
|
980
|
+
/**
|
|
981
|
+
* checkNameUnique() reads before it writes, so two concurrent requests can
|
|
982
|
+
* both pass it and race to insert the same name. The database rejects the
|
|
983
|
+
* loser; answer it with the same 409 the validator would have given rather
|
|
984
|
+
* than leaking a raw driver error as a 500.
|
|
985
|
+
*/
|
|
986
|
+
private onDuplicateName;
|
|
962
987
|
}
|
|
963
988
|
|
|
964
989
|
declare class TokenRepository {
|
package/dist/index.js
CHANGED
|
@@ -40,7 +40,12 @@ var rolesTable = pgTable("roles", {
|
|
|
40
40
|
...baseFields(5),
|
|
41
41
|
name: text("name").notNull(),
|
|
42
42
|
description: text("description")
|
|
43
|
-
})
|
|
43
|
+
}, (table) => ({
|
|
44
|
+
// A role name is a domain identity: guards match `admin` by name, and seeding
|
|
45
|
+
// reconciles roles by name. Two rows sharing a name make authorization
|
|
46
|
+
// ambiguous, so the database — not a check-then-insert query — enforces it.
|
|
47
|
+
nameUnique: uniqueIndex("roles_name_unique").on(table.name)
|
|
48
|
+
}));
|
|
44
49
|
var usersTable = pgTable("users", {
|
|
45
50
|
...baseFields(8),
|
|
46
51
|
name: text("name"),
|
|
@@ -144,7 +149,10 @@ var rolesTable2 = sqliteTable("roles", {
|
|
|
144
149
|
...baseFields2(5),
|
|
145
150
|
name: text2("name").notNull(),
|
|
146
151
|
description: text2("description")
|
|
147
|
-
})
|
|
152
|
+
}, (table) => ({
|
|
153
|
+
// Same domain identity as the PostgreSQL dialect; see schema/pg.ts.
|
|
154
|
+
nameUnique: uniqueIndex2("roles_name_unique").on(table.name)
|
|
155
|
+
}));
|
|
148
156
|
var usersTable2 = sqliteTable("users", {
|
|
149
157
|
...baseFields2(8),
|
|
150
158
|
name: text2("name"),
|
|
@@ -1096,7 +1104,7 @@ var RoleService = class RoleService2 {
|
|
|
1096
1104
|
}
|
|
1097
1105
|
async create(data) {
|
|
1098
1106
|
await this.roleValidator.checkNameUnique(data.name);
|
|
1099
|
-
return await this.roleRepository.create(data);
|
|
1107
|
+
return await this.onDuplicateName(() => this.roleRepository.create(data));
|
|
1100
1108
|
}
|
|
1101
1109
|
async update(id, data) {
|
|
1102
1110
|
const role = await this.roleValidator.checkRoleExists(id);
|
|
@@ -1104,7 +1112,7 @@ var RoleService = class RoleService2 {
|
|
|
1104
1112
|
Err3(this.t("errors.cannotRenameSystem"), 403);
|
|
1105
1113
|
}
|
|
1106
1114
|
await this.roleValidator.checkNameUnique(data.name, id);
|
|
1107
|
-
return await this.roleRepository.update(id, data);
|
|
1115
|
+
return await this.onDuplicateName(() => this.roleRepository.update(id, data));
|
|
1108
1116
|
}
|
|
1109
1117
|
async delete(id) {
|
|
1110
1118
|
const role = await this.roleValidator.checkRoleExists(id);
|
|
@@ -1131,6 +1139,22 @@ var RoleService = class RoleService2 {
|
|
|
1131
1139
|
const role = await this.getByName(name);
|
|
1132
1140
|
return role?.id;
|
|
1133
1141
|
}
|
|
1142
|
+
/**
|
|
1143
|
+
* checkNameUnique() reads before it writes, so two concurrent requests can
|
|
1144
|
+
* both pass it and race to insert the same name. The database rejects the
|
|
1145
|
+
* loser; answer it with the same 409 the validator would have given rather
|
|
1146
|
+
* than leaking a raw driver error as a 500.
|
|
1147
|
+
*/
|
|
1148
|
+
async onDuplicateName(write) {
|
|
1149
|
+
try {
|
|
1150
|
+
return await write();
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
if (isDuplicateRoleName(error)) {
|
|
1153
|
+
Err3(this.t("errors.exists"), 409);
|
|
1154
|
+
}
|
|
1155
|
+
throw error;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1134
1158
|
};
|
|
1135
1159
|
__decorate7([
|
|
1136
1160
|
I18n3("roles"),
|
|
@@ -1140,6 +1164,18 @@ RoleService = __decorate7([
|
|
|
1140
1164
|
Injectable4(),
|
|
1141
1165
|
__metadata7("design:paramtypes", [typeof (_a4 = typeof RoleRepository !== "undefined" && RoleRepository) === "function" ? _a4 : Object, typeof (_b2 = typeof RoleValidator !== "undefined" && RoleValidator) === "function" ? _b2 : Object])
|
|
1142
1166
|
], RoleService);
|
|
1167
|
+
function isDuplicateRoleName(error) {
|
|
1168
|
+
if (!error || typeof error !== "object")
|
|
1169
|
+
return false;
|
|
1170
|
+
const candidate = error;
|
|
1171
|
+
if (candidate.code === "23505")
|
|
1172
|
+
return true;
|
|
1173
|
+
if (candidate.code === "SQLITE_CONSTRAINT_UNIQUE")
|
|
1174
|
+
return true;
|
|
1175
|
+
const message = typeof candidate.message === "string" ? candidate.message : "";
|
|
1176
|
+
return /unique constraint failed/i.test(message) || /duplicate key value violates unique constraint/i.test(message);
|
|
1177
|
+
}
|
|
1178
|
+
__name(isDuplicateRoleName, "isDuplicateRoleName");
|
|
1143
1179
|
|
|
1144
1180
|
// src/users/UserService.ts
|
|
1145
1181
|
import { nanoid as nanoid3 } from "nanoid";
|
|
@@ -3488,13 +3524,36 @@ var AuthService = class AuthService2 {
|
|
|
3488
3524
|
});
|
|
3489
3525
|
const { token } = await this.tokenService.generateInviteToken(user.id);
|
|
3490
3526
|
const inviteLink = `${this.config.frontendUrl}/reset-password?token=${token}`;
|
|
3527
|
+
const accountType = body.role?.trim().toLowerCase();
|
|
3528
|
+
const accountLabel = accountType ? `${accountType} account` : "account";
|
|
3491
3529
|
let emailSent = false;
|
|
3492
3530
|
try {
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3531
|
+
const logo = this.config.accountInviteLogo;
|
|
3532
|
+
const logoCid = logo ? "najm-account-invite-logo" : void 0;
|
|
3533
|
+
const result = await this.emailService.send({
|
|
3534
|
+
to: body.email,
|
|
3535
|
+
subject: this.t("emails.accountInvite.subject", {
|
|
3536
|
+
accountLabel,
|
|
3537
|
+
appName: this.config.appName
|
|
3538
|
+
}),
|
|
3539
|
+
html: accountInviteTemplate({
|
|
3540
|
+
accountType,
|
|
3541
|
+
appName: this.config.appName,
|
|
3542
|
+
inviteLink,
|
|
3543
|
+
logoAlt: logo?.alt,
|
|
3544
|
+
logoSrc: logoCid ? `cid:${logoCid}` : void 0,
|
|
3545
|
+
userName: user.name || body.email
|
|
3546
|
+
}),
|
|
3547
|
+
attachments: logo ? [{
|
|
3548
|
+
filename: logo.filename,
|
|
3549
|
+
content: logo.contentBase64,
|
|
3550
|
+
contentType: logo.contentType,
|
|
3551
|
+
cid: logoCid,
|
|
3552
|
+
disposition: "inline",
|
|
3553
|
+
encoding: "base64"
|
|
3554
|
+
}] : void 0
|
|
3555
|
+
});
|
|
3556
|
+
emailSent = result.success;
|
|
3498
3557
|
} catch (error) {
|
|
3499
3558
|
this.logger.warn("Account invite email failed", { email: body.email, error });
|
|
3500
3559
|
}
|
|
@@ -6346,7 +6405,7 @@ var en_default = {
|
|
|
6346
6405
|
subject: "Reset your password"
|
|
6347
6406
|
},
|
|
6348
6407
|
accountInvite: {
|
|
6349
|
-
subject: "
|
|
6408
|
+
subject: "{{appName}}: activate your {{accountLabel}}"
|
|
6350
6409
|
}
|
|
6351
6410
|
}
|
|
6352
6411
|
},
|
|
@@ -7478,6 +7537,13 @@ var validateCallbackUrl = /* @__PURE__ */ __name((value, name) => {
|
|
|
7478
7537
|
}
|
|
7479
7538
|
return callback.toString();
|
|
7480
7539
|
}, "validateCallbackUrl");
|
|
7540
|
+
var resolveAppName = /* @__PURE__ */ __name((value) => {
|
|
7541
|
+
const appName = value?.trim() || "Your app";
|
|
7542
|
+
if (appName.length > 80 || /[\u0000-\u001f\u007f]/.test(appName)) {
|
|
7543
|
+
throw new Error("auth.appName must be at most 80 characters without control characters");
|
|
7544
|
+
}
|
|
7545
|
+
return appName;
|
|
7546
|
+
}, "resolveAppName");
|
|
7481
7547
|
var resolveGoogleConfig = /* @__PURE__ */ __name((config) => {
|
|
7482
7548
|
const configuredGoogle = config?.oauth?.google;
|
|
7483
7549
|
if (!configuredGoogle)
|
|
@@ -7554,6 +7620,8 @@ var resolveAuthConfig = /* @__PURE__ */ __name((config) => {
|
|
|
7554
7620
|
blacklistPrefix: config?.blacklistPrefix ?? "auth:blacklist:",
|
|
7555
7621
|
defaultRole: config?.defaultRole ?? null,
|
|
7556
7622
|
frontendUrl: config?.frontendUrl ?? process.env.FRONTEND_URL ?? "http://localhost:3000",
|
|
7623
|
+
appName: resolveAppName(config?.appName),
|
|
7624
|
+
accountInviteLogo: config?.accountInviteLogo,
|
|
7557
7625
|
registrationMode: config?.registrationMode ?? "active",
|
|
7558
7626
|
publicRegistration: config?.publicRegistration ?? true,
|
|
7559
7627
|
requireVerifiedEmail: config?.requireVerifiedEmail ?? false,
|
|
@@ -7619,6 +7687,13 @@ var toSeedId = /* @__PURE__ */ __name((prefix, value) => {
|
|
|
7619
7687
|
var authSeed = /* @__PURE__ */ __name((config) => ({
|
|
7620
7688
|
roles: {
|
|
7621
7689
|
schema: createRoleDto,
|
|
7690
|
+
// Roles are identified by name, not by the proposed primary key. Without
|
|
7691
|
+
// this the seeder falls back to the primary key, and a role that already
|
|
7692
|
+
// exists under a legacy/random ID is inserted a second time under the
|
|
7693
|
+
// deterministic one. Query-back by name then hands downstream resolvers the
|
|
7694
|
+
// live row, so users and role_permissions reference the ID the database
|
|
7695
|
+
// actually holds — never a proposed ID that was skipped on conflict.
|
|
7696
|
+
by: ["name"],
|
|
7622
7697
|
rows: (config.roles ?? [
|
|
7623
7698
|
{ name: "admin", description: "System administrator with full access" },
|
|
7624
7699
|
{ name: "user", description: "Regular user with limited access" }
|
package/dist/schema/pg.js
CHANGED
|
@@ -24,7 +24,12 @@ var rolesTable = pgTable("roles", {
|
|
|
24
24
|
...baseFields(5),
|
|
25
25
|
name: text("name").notNull(),
|
|
26
26
|
description: text("description")
|
|
27
|
-
})
|
|
27
|
+
}, (table) => ({
|
|
28
|
+
// A role name is a domain identity: guards match `admin` by name, and seeding
|
|
29
|
+
// reconciles roles by name. Two rows sharing a name make authorization
|
|
30
|
+
// ambiguous, so the database — not a check-then-insert query — enforces it.
|
|
31
|
+
nameUnique: uniqueIndex("roles_name_unique").on(table.name)
|
|
32
|
+
}));
|
|
28
33
|
var usersTable = pgTable("users", {
|
|
29
34
|
...baseFields(8),
|
|
30
35
|
name: text("name"),
|
package/dist/schema/sqlite.js
CHANGED
|
@@ -14,7 +14,10 @@ var rolesTable = sqliteTable("roles", {
|
|
|
14
14
|
...baseFields(5),
|
|
15
15
|
name: text("name").notNull(),
|
|
16
16
|
description: text("description")
|
|
17
|
-
})
|
|
17
|
+
}, (table) => ({
|
|
18
|
+
// Same domain identity as the PostgreSQL dialect; see schema/pg.ts.
|
|
19
|
+
nameUnique: uniqueIndex("roles_name_unique").on(table.name)
|
|
20
|
+
}));
|
|
18
21
|
var usersTable = sqliteTable("users", {
|
|
19
22
|
...baseFields(8),
|
|
20
23
|
name: text("name"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "najm-auth",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.5",
|
|
4
4
|
"description": "Authentication and authorization library for najm framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"najm-guard": "^2.0.2",
|
|
98
98
|
"najm-i18n": "^2.1.2",
|
|
99
99
|
"najm-cache": "^2.2.0",
|
|
100
|
-
"najm-email": "^2.0.
|
|
100
|
+
"najm-email": "^2.0.4",
|
|
101
101
|
"najm-rate": "^2.1.1",
|
|
102
102
|
"najm-validation": "^2.0.2",
|
|
103
103
|
"jsonwebtoken": "^9.0.3",
|