alepha 0.20.4 → 0.20.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.
Files changed (192) hide show
  1. package/dist/api/audits/index.d.ts +391 -359
  2. package/dist/api/audits/index.d.ts.map +1 -1
  3. package/dist/api/audits/index.js +23 -1
  4. package/dist/api/audits/index.js.map +1 -1
  5. package/dist/api/files/index.d.ts +18 -0
  6. package/dist/api/files/index.d.ts.map +1 -1
  7. package/dist/api/files/index.js +51 -0
  8. package/dist/api/files/index.js.map +1 -1
  9. package/dist/api/jobs/index.browser.js +33 -14
  10. package/dist/api/jobs/index.browser.js.map +1 -1
  11. package/dist/api/jobs/index.d.ts +452 -155
  12. package/dist/api/jobs/index.d.ts.map +1 -1
  13. package/dist/api/jobs/index.js +474 -159
  14. package/dist/api/jobs/index.js.map +1 -1
  15. package/dist/api/keys/index.d.ts +32 -4
  16. package/dist/api/keys/index.d.ts.map +1 -1
  17. package/dist/api/keys/index.js +53 -0
  18. package/dist/api/keys/index.js.map +1 -1
  19. package/dist/api/notifications/index.d.ts +29 -1
  20. package/dist/api/notifications/index.d.ts.map +1 -1
  21. package/dist/api/notifications/index.js +55 -13
  22. package/dist/api/notifications/index.js.map +1 -1
  23. package/dist/api/organizations/index.js.map +1 -1
  24. package/dist/api/parameters/index.d.ts +15 -0
  25. package/dist/api/parameters/index.d.ts.map +1 -1
  26. package/dist/api/parameters/index.js +37 -0
  27. package/dist/api/parameters/index.js.map +1 -1
  28. package/dist/api/payments/index.js.map +1 -1
  29. package/dist/api/users/index.d.ts +150 -9
  30. package/dist/api/users/index.d.ts.map +1 -1
  31. package/dist/api/users/index.js +237 -28
  32. package/dist/api/users/index.js.map +1 -1
  33. package/dist/api/verifications/index.d.ts +3 -3
  34. package/dist/api/verifications/index.js.map +1 -1
  35. package/dist/batch/index.js.map +1 -1
  36. package/dist/bin/index.js +0 -0
  37. package/dist/bucket/index.d.ts +18 -0
  38. package/dist/bucket/index.d.ts.map +1 -1
  39. package/dist/bucket/index.js +47 -0
  40. package/dist/bucket/index.js.map +1 -1
  41. package/dist/bucket/index.workerd.js +24 -0
  42. package/dist/bucket/index.workerd.js.map +1 -1
  43. package/dist/cache/core/index.d.ts +20 -3
  44. package/dist/cache/core/index.d.ts.map +1 -1
  45. package/dist/cache/core/index.js.map +1 -1
  46. package/dist/cache/core/index.workerd.js.map +1 -1
  47. package/dist/cache/database/index.d.ts +155 -0
  48. package/dist/cache/database/index.d.ts.map +1 -0
  49. package/dist/cache/database/index.js +266 -0
  50. package/dist/cache/database/index.js.map +1 -0
  51. package/dist/cache/redis/index.js.map +1 -1
  52. package/dist/captcha/index.js.map +1 -1
  53. package/dist/cli/config/index.js.map +1 -1
  54. package/dist/cli/core/index.d.ts +35 -5
  55. package/dist/cli/core/index.d.ts.map +1 -1
  56. package/dist/cli/core/index.js +85 -6
  57. package/dist/cli/core/index.js.map +1 -1
  58. package/dist/cli/devtools/index.js.map +1 -1
  59. package/dist/cli/platform/index.js +1 -1
  60. package/dist/cli/platform/index.js.map +1 -1
  61. package/dist/cli/vendor/index.js.map +1 -1
  62. package/dist/command/index.js.map +1 -1
  63. package/dist/core/index.browser.js.map +1 -1
  64. package/dist/core/index.js.map +1 -1
  65. package/dist/core/index.native.js.map +1 -1
  66. package/dist/core/index.workerd.js.map +1 -1
  67. package/dist/crypto/index.browser.js.map +1 -1
  68. package/dist/crypto/index.js.map +1 -1
  69. package/dist/datetime/index.js.map +1 -1
  70. package/dist/email/brevo/index.js.map +1 -1
  71. package/dist/email/core/index.js.map +1 -1
  72. package/dist/email/core/index.workerd.js.map +1 -1
  73. package/dist/email/smtp/index.js.map +1 -1
  74. package/dist/fake/index.js.map +1 -1
  75. package/dist/lock/core/index.js.map +1 -1
  76. package/dist/lock/redis/index.js.map +1 -1
  77. package/dist/logger/index.js.map +1 -1
  78. package/dist/mcp/index.js.map +1 -1
  79. package/dist/orm/core/index.browser.js.map +1 -1
  80. package/dist/orm/core/index.bun.js.map +1 -1
  81. package/dist/orm/core/index.js.map +1 -1
  82. package/dist/orm/postgres/index.bun.js.map +1 -1
  83. package/dist/orm/postgres/index.js.map +1 -1
  84. package/dist/queue/core/index.js.map +1 -1
  85. package/dist/queue/core/index.workerd.js.map +1 -1
  86. package/dist/queue/redis/index.js.map +1 -1
  87. package/dist/react/auth/index.browser.js.map +1 -1
  88. package/dist/react/auth/index.js.map +1 -1
  89. package/dist/react/core/index.js.map +1 -1
  90. package/dist/react/form/index.js +2 -0
  91. package/dist/react/form/index.js.map +1 -1
  92. package/dist/react/head/index.browser.js.map +1 -1
  93. package/dist/react/head/index.js.map +1 -1
  94. package/dist/react/i18n/index.js.map +1 -1
  95. package/dist/react/intro/index.js.map +1 -1
  96. package/dist/react/router/index.browser.js.map +1 -1
  97. package/dist/react/router/index.js.map +1 -1
  98. package/dist/react/testing/index.js.map +1 -1
  99. package/dist/react/ui/index.js.map +1 -1
  100. package/dist/react/websocket/index.js.map +1 -1
  101. package/dist/redis/index.bun.js.map +1 -1
  102. package/dist/redis/index.js.map +1 -1
  103. package/dist/retry/index.js.map +1 -1
  104. package/dist/router/index.js.map +1 -1
  105. package/dist/scheduler/index.d.ts +22 -0
  106. package/dist/scheduler/index.d.ts.map +1 -1
  107. package/dist/scheduler/index.js +12 -0
  108. package/dist/scheduler/index.js.map +1 -1
  109. package/dist/scheduler/index.workerd.js +12 -0
  110. package/dist/scheduler/index.workerd.js.map +1 -1
  111. package/dist/security/index.browser.js.map +1 -1
  112. package/dist/security/index.js.map +1 -1
  113. package/dist/server/auth/index.js.map +1 -1
  114. package/dist/server/cookies/index.browser.js.map +1 -1
  115. package/dist/server/cookies/index.js.map +1 -1
  116. package/dist/server/core/index.browser.js.map +1 -1
  117. package/dist/server/core/index.js.map +1 -1
  118. package/dist/server/cors/index.js.map +1 -1
  119. package/dist/server/etag/index.js.map +1 -1
  120. package/dist/server/health/index.js.map +1 -1
  121. package/dist/server/links/index.browser.js.map +1 -1
  122. package/dist/server/links/index.js.map +1 -1
  123. package/dist/server/metrics/index.js.map +1 -1
  124. package/dist/server/proxy/index.js.map +1 -1
  125. package/dist/server/rate-limit/index.js.map +1 -1
  126. package/dist/server/static/index.js.map +1 -1
  127. package/dist/server/swagger/index.js.map +1 -1
  128. package/dist/sms/index.js.map +1 -1
  129. package/dist/system/index.browser.js.map +1 -1
  130. package/dist/system/index.js.map +1 -1
  131. package/dist/system/index.workerd.js.map +1 -1
  132. package/dist/topic/core/index.js.map +1 -1
  133. package/dist/topic/redis/index.js.map +1 -1
  134. package/dist/websocket/index.browser.js +4 -0
  135. package/dist/websocket/index.browser.js.map +1 -1
  136. package/dist/websocket/index.js +10 -0
  137. package/dist/websocket/index.js.map +1 -1
  138. package/package.json +282 -272
  139. package/src/api/audits/controllers/AdminAuditController.ts +29 -0
  140. package/src/api/files/controllers/FileController.ts +24 -0
  141. package/src/api/files/services/FileService.ts +41 -0
  142. package/src/api/jobs/__tests__/$job.spec.ts +427 -2
  143. package/src/api/jobs/entities/jobExecutionEntity.ts +3 -3
  144. package/src/api/jobs/index.ts +47 -10
  145. package/src/api/jobs/primitives/$job.ts +22 -9
  146. package/src/api/jobs/providers/DirectJobDispatcher.ts +71 -0
  147. package/src/api/jobs/providers/JobDispatcher.ts +49 -0
  148. package/src/api/jobs/providers/JobProvider.ts +365 -142
  149. package/src/api/jobs/providers/JobQueueProvider.ts +43 -18
  150. package/src/api/jobs/schemas/jobConfigAtom.ts +4 -3
  151. package/src/api/jobs/schemas/jobExecutionResourceSchema.ts +11 -0
  152. package/src/api/jobs/schemas/jobRegistrationSchema.ts +4 -2
  153. package/src/api/jobs/services/JobService.ts +21 -11
  154. package/src/api/keys/controllers/AdminApiKeyController.ts +23 -0
  155. package/src/api/keys/services/ApiKeyService.ts +42 -0
  156. package/src/api/notifications/__tests__/AlephaApiNotifications.spec.ts +63 -0
  157. package/src/api/notifications/controllers/AdminNotificationController.ts +48 -1
  158. package/src/api/notifications/index.ts +13 -3
  159. package/src/api/notifications/jobs/NotificationJobs.ts +0 -6
  160. package/src/api/parameters/controllers/AdminParameterController.ts +26 -0
  161. package/src/api/parameters/services/ParameterProvider.ts +18 -0
  162. package/src/api/users/__tests__/Registration-emailMode.spec.ts +203 -0
  163. package/src/api/users/__tests__/UsernameSlugger.spec.ts +138 -0
  164. package/src/api/users/atoms/realmAuthSettingsAtom.ts +41 -3
  165. package/src/api/users/controllers/AdminSessionController.ts +29 -0
  166. package/src/api/users/controllers/AdminUserController.ts +32 -0
  167. package/src/api/users/index.ts +3 -0
  168. package/src/api/users/services/CredentialService.ts +5 -0
  169. package/src/api/users/services/RegistrationService.ts +49 -1
  170. package/src/api/users/services/SessionCrudService.ts +16 -0
  171. package/src/api/users/services/SessionService.ts +17 -59
  172. package/src/api/users/services/UsernameSlugger.ts +195 -0
  173. package/src/bucket/primitives/$bucket.ts +21 -0
  174. package/src/bucket/providers/CloudflareR2Provider.ts +15 -0
  175. package/src/bucket/providers/FileStorageProvider.ts +9 -0
  176. package/src/bucket/providers/LocalFileStorageProvider.ts +14 -0
  177. package/src/bucket/providers/MemoryFileStorageProvider.ts +9 -0
  178. package/src/bucket/providers/NodeS3BucketProvider.ts +35 -0
  179. package/src/cache/core/primitives/$cache.ts +20 -3
  180. package/src/cache/database/__tests__/DatabaseCacheProvider.behavior.spec.ts +203 -0
  181. package/src/cache/database/__tests__/DatabaseCacheProvider.spec.ts +110 -0
  182. package/src/cache/database/entities/cacheEntries.ts +55 -0
  183. package/src/cache/database/index.ts +36 -0
  184. package/src/cache/database/providers/DatabaseCacheProvider.ts +348 -0
  185. package/src/cli/core/services/ProjectScaffolder.ts +0 -2
  186. package/src/cli/core/tasks/BuildCloudflareTask.ts +17 -3
  187. package/src/cli/core/tasks/BuildSitemapTask.ts +7 -0
  188. package/src/cli/core/tasks/BuildVercelTask.ts +82 -3
  189. package/src/cli/platform/__tests__/detectResources.spec.ts +96 -0
  190. package/src/cli/platform/commands/platform.ts +7 -1
  191. package/src/scheduler/index.ts +14 -0
  192. package/src/scheduler/providers/CronProvider.ts +13 -0
@@ -12,6 +12,7 @@ import { $authApple, $authCredentials, $authFacebook, $authFranceConnect, $authG
12
12
  import { $etag } from "alepha/server/etag";
13
13
  import { randomInt, randomUUID } from "node:crypto";
14
14
  import { $cache, CacheProvider } from "alepha/cache";
15
+ import { DatabaseCacheProvider } from "alepha/cache/database";
15
16
  import { DateTimeProvider } from "alepha/datetime";
16
17
  import { $job } from "alepha/api/jobs";
17
18
  import { FileSystemProvider } from "alepha/system";
@@ -155,6 +156,12 @@ const fieldRequirement = (description) => t.union([
155
156
  t.const("optional"),
156
157
  t.const("required")
157
158
  ], { description });
159
+ const usernameFieldRequirement = (description) => t.union([
160
+ t.const("none"),
161
+ t.const("optional"),
162
+ t.const("required"),
163
+ t.const("email")
164
+ ], { description });
158
165
  const realmAuthSettingsAtom = $atom({
159
166
  name: "alepha.api.users.realmAuthSettings",
160
167
  schema: t.object({
@@ -163,8 +170,9 @@ const realmAuthSettingsAtom = $atom({
163
170
  logoUrl: t.optional(t.string({ description: "Logo URL for auth pages" })),
164
171
  registrationAllowed: t.boolean({ description: "Enable user self-registration" }),
165
172
  email: fieldRequirement("Email address field requirement for user accounts"),
166
- username: fieldRequirement("Username field requirement for user accounts"),
173
+ username: usernameFieldRequirement("Username field requirement for user accounts"),
167
174
  usernameRegExp: t.string({ description: "Regular expression that usernames must match (if username is enabled)" }),
175
+ usernameBlocklist: t.array(t.text(), { description: "Usernames that the slugger / manual registration must reject. Default empty so apps can register `admin`/`root`/`me`/etc. if they want; populate it explicitly for handles you want to keep off-limits." }),
168
176
  phoneNumber: fieldRequirement("Phone number field requirement for user accounts"),
169
177
  verifyEmailRequired: t.boolean({ description: "Require email verification for user accounts" }),
170
178
  verifyPhoneRequired: t.boolean({ description: "Require phone verification for user accounts" }),
@@ -212,7 +220,8 @@ const realmAuthSettingsAtom = $atom({
212
220
  registrationAllowed: true,
213
221
  email: "required",
214
222
  username: "none",
215
- usernameRegExp: "^[a-zA-Z0-9_]{3,30}$",
223
+ usernameRegExp: "^[a-zA-Z0-9_-]{3,30}$",
224
+ usernameBlocklist: [],
216
225
  phoneNumber: "none",
217
226
  verifyEmailRequired: false,
218
227
  verifyPhoneRequired: false,
@@ -562,6 +571,19 @@ var SessionCrudService = class {
562
571
  await this.sessions(userRealmName).deleteById(id);
563
572
  this.log.info("Session deleted", { id });
564
573
  }
574
+ /**
575
+ * Delete many sessions by ID in one repository call.
576
+ */
577
+ async deleteSessions(ids, userRealmName) {
578
+ if (ids.length === 0) return [];
579
+ this.log.trace("Deleting sessions", {
580
+ count: ids.length,
581
+ userRealmName
582
+ });
583
+ const deleted = await this.sessions(userRealmName).deleteMany({ id: { inArray: ids } });
584
+ this.log.info("Sessions deleted", { count: deleted.length });
585
+ return deleted.map(String);
586
+ }
565
587
  };
566
588
  //#endregion
567
589
  //#region ../../src/api/users/controllers/AdminSessionController.ts
@@ -623,6 +645,27 @@ var AdminSessionController = class {
623
645
  };
624
646
  }
625
647
  });
648
+ /**
649
+ * Delete many sessions in one repository call.
650
+ */
651
+ deleteSessions = $action({
652
+ method: "POST",
653
+ path: `${this.url}/delete`,
654
+ group: this.group,
655
+ use: [$secure({ permissions: ["admin:session:delete"] })],
656
+ description: "Delete many sessions",
657
+ schema: {
658
+ query: t.object({ userRealmName: t.optional(t.string()) }),
659
+ body: t.object({ ids: t.array(t.uuid(), {
660
+ minItems: 1,
661
+ maxItems: 1e3
662
+ }) }),
663
+ response: t.object({ deleted: t.array(t.string()) })
664
+ },
665
+ handler: async ({ body, query }) => {
666
+ return { deleted: await this.sessionService.deleteSessions(body.ids, query.userRealmName) };
667
+ }
668
+ });
626
669
  };
627
670
  //#endregion
628
671
  //#region ../../src/api/users/schemas/createUserSchema.ts
@@ -1176,6 +1219,34 @@ var AdminUserController = class {
1176
1219
  };
1177
1220
  }
1178
1221
  });
1222
+ /**
1223
+ * Delete many users in one request. Each id is processed sequentially so
1224
+ * cascades and side-effects run as if called one-by-one. Errors on a single
1225
+ * id surface with that id in the response.
1226
+ */
1227
+ deleteUsers = $action({
1228
+ method: "POST",
1229
+ path: `${this.url}/delete`,
1230
+ group: this.group,
1231
+ use: [$secure({ permissions: ["admin:user:delete"] })],
1232
+ description: "Delete many users",
1233
+ schema: {
1234
+ query: t.object({ userRealmName: t.optional(t.string()) }),
1235
+ body: t.object({ ids: t.array(t.uuid(), {
1236
+ minItems: 1,
1237
+ maxItems: 1e3
1238
+ }) }),
1239
+ response: t.object({ deleted: t.array(t.uuid()) })
1240
+ },
1241
+ handler: async ({ body, query }) => {
1242
+ const deleted = [];
1243
+ for (const id of body.ids) {
1244
+ await this.userService.deleteUser(id, query.userRealmName);
1245
+ deleted.push(id);
1246
+ }
1247
+ return { deleted };
1248
+ }
1249
+ });
1179
1250
  };
1180
1251
  //#endregion
1181
1252
  //#region ../../src/api/users/schemas/realmConfigSchema.ts
@@ -1331,6 +1402,7 @@ var CredentialService = class {
1331
1402
  if (this.realmProvider.getRealm(realmName).features.notifications) return this.alepha.inject(UserNotifications);
1332
1403
  }
1333
1404
  intentCache = $cache({
1405
+ provider: DatabaseCacheProvider,
1334
1406
  name: "api:users:password-reset-intents",
1335
1407
  ttl: [INTENT_TTL_MINUTES$1, "minutes"]
1336
1408
  });
@@ -1558,6 +1630,139 @@ var CredentialService = class {
1558
1630
  }
1559
1631
  };
1560
1632
  //#endregion
1633
+ //#region ../../src/api/users/services/UsernameSlugger.ts
1634
+ /**
1635
+ * Derive stable, URL-safe usernames from email addresses.
1636
+ *
1637
+ * Used by the registration flow when `realm.settings.username === "email"`,
1638
+ * and reusable from any custom user-creation site (e.g. an OAuth-only flow
1639
+ * that wants the same handle convention).
1640
+ *
1641
+ * **Slug rule** — the local-part of the email is kept as-is (gmail
1642
+ * `+suffix` retained for predictability). Everything outside `[a-z0-9]` is
1643
+ * replaced with `-`, runs of `-` are collapsed, leading/trailing `-` are
1644
+ * trimmed, lowercased. If the result is shorter than {@link MIN_LENGTH} it
1645
+ * is padded with random alphanumerics. Result is clamped to
1646
+ * {@link MAX_LENGTH}.
1647
+ *
1648
+ * **Collision retry** — `pickAvailable()` checks the realm's `users` table
1649
+ * and the realm's `usernameBlocklist`. On hit, it appends `-<4 random>` and
1650
+ * retries up to {@link MAX_RETRIES} times. Best-effort against concurrent
1651
+ * registrations: the unique index on `(realm, lower(username))` is the
1652
+ * authoritative race guard, callers that hit a unique-violation should
1653
+ * call `pickAvailable` again with a fresh suffix.
1654
+ *
1655
+ * @see RegistrationService.createRegistrationIntent
1656
+ */
1657
+ var UsernameSlugger = class UsernameSlugger {
1658
+ realmProvider = $inject(RealmProvider);
1659
+ log = $logger();
1660
+ /**
1661
+ * Floor for derived usernames. Shorter slugs are padded with random
1662
+ * alphanumerics. Matches the lower bound of the default `usernameRegExp`.
1663
+ */
1664
+ static MIN_LENGTH = 3;
1665
+ /**
1666
+ * Ceiling for derived usernames. Matches the upper bound of the default
1667
+ * `usernameRegExp` and gives enough headroom for the random suffix added
1668
+ * on collisions.
1669
+ */
1670
+ static MAX_LENGTH = 30;
1671
+ /**
1672
+ * Length of the random suffix appended on collision (e.g. `-3dp6`).
1673
+ * 36⁴ ≈ 1.6M variants per base — plenty for a tiny number of retries.
1674
+ */
1675
+ static SUFFIX_LENGTH = 4;
1676
+ /**
1677
+ * How many times `pickAvailable` retries before giving up.
1678
+ */
1679
+ static MAX_RETRIES = 5;
1680
+ /**
1681
+ * Alphabet used for the random suffix and for padding short slugs.
1682
+ */
1683
+ static ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
1684
+ /**
1685
+ * Default replacement when the email's local-part contains no
1686
+ * `[a-z0-9]` characters at all (rare but possible: `é@example.com`).
1687
+ */
1688
+ static EMPTY_LOCAL_FALLBACK = "user";
1689
+ /**
1690
+ * Sanitize an email into a base username slug.
1691
+ *
1692
+ * Pure function — no DB access. Always returns a string that satisfies
1693
+ * `^[a-z0-9-]{MIN_LENGTH,MAX_LENGTH}$`.
1694
+ *
1695
+ * @example
1696
+ * slug("ni.foures+testkv@gmail.com") // "ni-foures-testkv"
1697
+ * slug("john.doe@example.com") // "john-doe"
1698
+ * slug("é@example.com") // "user-XXX" (padded)
1699
+ */
1700
+ slug(email) {
1701
+ const raw = (email ?? "").trim();
1702
+ const at = raw.indexOf("@");
1703
+ let result = (at > 0 ? raw.slice(0, at) : raw).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || UsernameSlugger.EMPTY_LOCAL_FALLBACK;
1704
+ if (result.length < UsernameSlugger.MIN_LENGTH) {
1705
+ const needed = UsernameSlugger.MIN_LENGTH - result.length;
1706
+ result += this.randomSuffix(needed);
1707
+ }
1708
+ return this.clamp(result);
1709
+ }
1710
+ /**
1711
+ * Find an available username for the realm, starting from `base`.
1712
+ *
1713
+ * Returns `base` when nothing collides. On a hit (existing row OR
1714
+ * blocklisted name) appends `-<4 random>` and tries again, up to
1715
+ * {@link MAX_RETRIES} times.
1716
+ *
1717
+ * The check is best-effort: a concurrent registration may still claim
1718
+ * the same value before the caller's INSERT runs, in which case the DB
1719
+ * unique index throws and the caller should retry.
1720
+ */
1721
+ async pickAvailable(realmName, base) {
1722
+ const blocklist = await this.getBlocklist(realmName);
1723
+ const repo = this.realmProvider.userRepository(realmName);
1724
+ const realm = this.realmProvider.getRealm(realmName);
1725
+ const isAvailable = async (candidate) => {
1726
+ if (this.isBlockedAgainst(candidate, blocklist)) return false;
1727
+ return !await repo.findOne({ where: {
1728
+ realm: { eq: realm.name },
1729
+ username: { ilike: candidate }
1730
+ } });
1731
+ };
1732
+ if (await isAvailable(base)) return base;
1733
+ const reserve = 1 + UsernameSlugger.SUFFIX_LENGTH;
1734
+ const trimmedBase = base.length > UsernameSlugger.MAX_LENGTH - reserve ? base.slice(0, UsernameSlugger.MAX_LENGTH - reserve) : base;
1735
+ for (let i = 0; i < UsernameSlugger.MAX_RETRIES; i++) {
1736
+ const candidate = `${trimmedBase}-${this.randomSuffix(UsernameSlugger.SUFFIX_LENGTH)}`;
1737
+ if (await isAvailable(candidate)) return candidate;
1738
+ }
1739
+ throw new AlephaError(`Could not find an available username starting from "${base}" after ${UsernameSlugger.MAX_RETRIES} attempts.`);
1740
+ }
1741
+ /**
1742
+ * Check a name against the realm's `usernameBlocklist`. Case-insensitive.
1743
+ */
1744
+ async isBlocked(realmName, name) {
1745
+ const blocklist = await this.getBlocklist(realmName);
1746
+ return this.isBlockedAgainst(name, blocklist);
1747
+ }
1748
+ async getBlocklist(realmName) {
1749
+ const list = (await this.realmProvider.getRealm(realmName).getSettings())?.usernameBlocklist ?? [];
1750
+ return new Set(list.map((b) => b.toLowerCase()));
1751
+ }
1752
+ isBlockedAgainst(name, blocklist) {
1753
+ return blocklist.has(name.toLowerCase());
1754
+ }
1755
+ clamp(s) {
1756
+ return s.length > UsernameSlugger.MAX_LENGTH ? s.slice(0, UsernameSlugger.MAX_LENGTH) : s;
1757
+ }
1758
+ randomSuffix(length) {
1759
+ const chars = UsernameSlugger.ALPHABET;
1760
+ let out = "";
1761
+ for (let i = 0; i < length; i++) out += chars[Math.floor(Math.random() * chars.length)];
1762
+ return out;
1763
+ }
1764
+ };
1765
+ //#endregion
1561
1766
  //#region ../../src/api/users/services/RegistrationService.ts
1562
1767
  const INTENT_TTL_MINUTES = 10;
1563
1768
  var RegistrationService = class {
@@ -1569,11 +1774,14 @@ var RegistrationService = class {
1569
1774
  realmProvider = $inject(RealmProvider);
1570
1775
  credentialService = $inject(CredentialService);
1571
1776
  captchaProvider = $inject(CaptchaProvider);
1777
+ usernameSlugger = $inject(UsernameSlugger);
1572
1778
  intentCache = $cache({
1779
+ provider: DatabaseCacheProvider,
1573
1780
  name: "api:users:registrations",
1574
1781
  ttl: [INTENT_TTL_MINUTES, "minutes"]
1575
1782
  });
1576
1783
  rateLimitCache = $cache({
1784
+ provider: DatabaseCacheProvider,
1577
1785
  name: "api:users:registration-rate-limit",
1578
1786
  ttl: [15, "minutes"]
1579
1787
  });
@@ -1615,6 +1823,7 @@ var RegistrationService = class {
1615
1823
  this.log.debug("Registration rejected: username required", { userRealmName });
1616
1824
  throw new BadRequestError("Username is required");
1617
1825
  }
1826
+ if (realmSettings?.username === "email") body.username = void 0;
1618
1827
  if (body.username) {
1619
1828
  const usernameRegExp = realmSettings?.usernameRegExp;
1620
1829
  if (usernameRegExp) {
@@ -1626,6 +1835,13 @@ var RegistrationService = class {
1626
1835
  throw new BadRequestError("Username does not meet the required format");
1627
1836
  }
1628
1837
  }
1838
+ if (await this.usernameSlugger.isBlocked(userRealmName, body.username)) {
1839
+ this.log.debug("Registration rejected: username is blocked", {
1840
+ userRealmName,
1841
+ username: body.username
1842
+ });
1843
+ throw new BadRequestError("This username is not available");
1844
+ }
1629
1845
  }
1630
1846
  if (realmSettings?.email === "required" && !body.email) {
1631
1847
  this.log.debug("Registration rejected: email required", { userRealmName });
@@ -1635,6 +1851,11 @@ var RegistrationService = class {
1635
1851
  this.log.debug("Registration rejected: phone required", { userRealmName });
1636
1852
  throw new BadRequestError("Phone number is required");
1637
1853
  }
1854
+ if (realmSettings?.username === "email") {
1855
+ if (!body.email) throw new BadRequestError("Email is required to derive a username from email");
1856
+ const base = this.usernameSlugger.slug(body.email);
1857
+ body.username = await this.usernameSlugger.pickAvailable(userRealmName, base);
1858
+ }
1638
1859
  await this.checkUserAvailability(body, userRealmName);
1639
1860
  this.credentialService.validatePasswordPolicy(body.password, realmSettings.passwordPolicy);
1640
1861
  if (realmSettings?.captchaRequired === true) {
@@ -2166,6 +2387,7 @@ var SessionService = class SessionService {
2166
2387
  realmProvider = $inject(RealmProvider);
2167
2388
  fileController = $client();
2168
2389
  cacheProvider = $inject(CacheProvider);
2390
+ usernameSlugger = $inject(UsernameSlugger);
2169
2391
  userAudits(realmName) {
2170
2392
  if (this.realmProvider.getRealm(realmName).features.audits) return this.alepha.inject(UserAudits);
2171
2393
  }
@@ -2220,30 +2442,16 @@ var SessionService = class SessionService {
2220
2442
  /**
2221
2443
  * Generate a unique username from an OAuth profile.
2222
2444
  *
2223
- * 1. Extract candidate from email prefix
2224
- * 2. Sanitize against realm's usernameRegExp (strip invalid chars, truncate)
2225
- * 3. Check case-insensitive uniqueness, append suffix (2, 3, ...) if taken
2226
- * 4. Fall back to "user" + random 6-char alphanumeric if all else fails
2227
- */
2228
- async generateUniqueUsername(profile, realmSettings, users) {
2229
- const maxLength = 30;
2230
- const maxSuffixAttempts = 10;
2231
- let candidate = profile.email?.split("@")[0] ?? profile.name ?? "";
2232
- candidate = candidate.replace(/[^a-zA-Z0-9_.-]/g, "");
2233
- if (realmSettings?.usernameRegExp) try {
2234
- if (!new RegExp(realmSettings.usernameRegExp).test(candidate)) candidate = candidate.replace(/[^a-zA-Z0-9_]/g, "");
2235
- } catch {}
2236
- if (candidate.length < 3) candidate = `user${candidate}`;
2237
- candidate = candidate.slice(0, maxLength - 2);
2238
- const isAvailable = async (name) => {
2239
- return !await users.findOne({ where: { username: { ilike: name } } });
2240
- };
2241
- if (await isAvailable(candidate)) return candidate;
2242
- for (let i = 2; i <= maxSuffixAttempts + 1; i++) {
2243
- const withSuffix = `${candidate}${i}`;
2244
- if (withSuffix.length <= maxLength && await isAvailable(withSuffix)) return withSuffix;
2245
- }
2246
- return `user${Math.random().toString(36).slice(2, 8)}`;
2445
+ * Routes through {@link UsernameSlugger}, which is the same code path as
2446
+ * `username: "email"` registration. The OAuth profile's email is the
2447
+ * primary signal; if absent (rare most IDPs return one), we fall back
2448
+ * to `profile.name`, then to a random handle. The slugger applies the
2449
+ * realm's `usernameBlocklist` and retries on collision.
2450
+ */
2451
+ async generateUniqueUsername(profile, _realmSettings, _users, realmName) {
2452
+ const seed = profile.email ?? profile.name ?? `user-${Math.random().toString(36).slice(2, 8)}`;
2453
+ const base = this.usernameSlugger.slug(seed);
2454
+ return this.usernameSlugger.pickAvailable(realmName, base);
2247
2455
  }
2248
2456
  /**
2249
2457
  * Random delay to prevent timing attacks (50-200ms)
@@ -2654,7 +2862,7 @@ var SessionService = class SessionService {
2654
2862
  });
2655
2863
  throw new BadRequestError("Account doesn't exist");
2656
2864
  }
2657
- const username = await this.generateUniqueUsername(profile, realmSettings, users);
2865
+ const username = await this.generateUniqueUsername(profile, realmSettings, users, userRealmName);
2658
2866
  const user = await users.create({
2659
2867
  realm: realm.name,
2660
2868
  username,
@@ -2921,6 +3129,7 @@ const AlephaApiUsers = $module({
2921
3129
  CredentialService,
2922
3130
  RegistrationService,
2923
3131
  UserService,
3132
+ UsernameSlugger,
2924
3133
  IdentityService,
2925
3134
  UserController,
2926
3135
  AdminUserController,
@@ -2936,6 +3145,6 @@ const AlephaApiUsers = $module({
2936
3145
  ]
2937
3146
  });
2938
3147
  //#endregion
2939
- export { $realm, AdminIdentityController, AdminSessionController, AdminUserController, AlephaApiUsers, CredentialService, DEFAULT_USER_REALM_NAME, IdentityService, RealmController, RealmProvider, RegistrationService, SessionCrudService, SessionService, UserAudits, UserBuckets, UserController, UserJobs, UserNotifications, UserService, completePasswordResetRequestSchema, completeRegistrationRequestSchema, createUserSchema, identities, identityQuerySchema, identityResourceSchema, loginSchema, passwordResetIntentResponseSchema, realmAuthSettingsAtom, realmConfigSchema, registerSchema, registrationIntentResponseSchema, resetPasswordRequestSchema, resetPasswordSchema, sessionQuerySchema, sessionResourceSchema, sessions, updateUserSchema, userQuerySchema, userResourceSchema, users };
3148
+ export { $realm, AdminIdentityController, AdminSessionController, AdminUserController, AlephaApiUsers, CredentialService, DEFAULT_USER_REALM_NAME, IdentityService, RealmController, RealmProvider, RegistrationService, SessionCrudService, SessionService, UserAudits, UserBuckets, UserController, UserJobs, UserNotifications, UserService, UsernameSlugger, completePasswordResetRequestSchema, completeRegistrationRequestSchema, createUserSchema, identities, identityQuerySchema, identityResourceSchema, loginSchema, passwordResetIntentResponseSchema, realmAuthSettingsAtom, realmConfigSchema, registerSchema, registrationIntentResponseSchema, resetPasswordRequestSchema, resetPasswordSchema, sessionQuerySchema, sessionResourceSchema, sessions, updateUserSchema, userQuerySchema, userResourceSchema, users };
2940
3149
 
2941
3150
  //# sourceMappingURL=index.js.map