najm-auth 4.0.4 → 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 CHANGED
@@ -139,14 +139,14 @@ auth({
139
139
  bcryptRounds?: number // Default: 10 (valid: 4-31)
140
140
 
141
141
  // Frontend
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
- }
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
+ }
150
150
 
151
151
  // Login identifier normalization (see "Identity presets")
152
152
  identity?: {
@@ -608,7 +608,7 @@ users
608
608
 
609
609
  roles
610
610
  ├── id (string, primary key)
611
- ├── name (string, unique)
611
+ ├── name (string, unique — `roles_name_unique` in both dialects)
612
612
  ├── description (string, nullable)
613
613
  ├── createdAt (timestamp)
614
614
  └── updatedAt (timestamp)
@@ -700,6 +700,21 @@ const customUsers = sqliteTable('users', {
700
700
 
701
701
  ## Seeding
702
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
+
703
718
  ### Low-Level Seeding (authSeed)
704
719
 
705
720
  ```typescript
@@ -1152,13 +1167,13 @@ invited account remains inactive.
1152
1167
  Set `appName` on `auth()` to brand the invitation subject and email card. The
1153
1168
  provisioned role is presented as the account type, so a sponsor invitation can
1154
1169
  say “Activate your sponsor account” without application-owned HTML. The shared
1155
- template uses inline critical styles for Gmail and keeps the raw token URL out
1156
- of visible fallback copy.
1157
-
1158
- For a branded mark that works in email clients without a public asset URL, set
1159
- `accountInviteLogo` to base64 content plus its MIME type and filename. Najm
1160
- attaches it inline and points the shared template at a stable CID; when omitted,
1161
- the template renders `appName` as text.
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.
1162
1177
 
1163
1178
  The built-in memory and Redis drivers implement the required atomic primitive.
1164
1179
  A custom cache driver may omit `compareAndDelete()` for compatibility with
@@ -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
@@ -977,6 +977,13 @@ declare class RoleService {
977
977
  updatedAt: string;
978
978
  }[]>;
979
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;
980
987
  }
981
988
 
982
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";
@@ -7651,6 +7687,13 @@ var toSeedId = /* @__PURE__ */ __name((prefix, value) => {
7651
7687
  var authSeed = /* @__PURE__ */ __name((config) => ({
7652
7688
  roles: {
7653
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"],
7654
7697
  rows: (config.roles ?? [
7655
7698
  { name: "admin", description: "System administrator with full access" },
7656
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"),
@@ -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.4",
3
+ "version": "4.0.5",
4
4
  "description": "Authentication and authorization library for najm framework",
5
5
  "type": "module",
6
6
  "files": [