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
@@ -0,0 +1,203 @@
1
+ import { Alepha } from "alepha";
2
+ import { AlephaApiVerification } from "alepha/api/verifications";
3
+ import { AlephaEmail, MemoryEmailProvider } from "alepha/email";
4
+ import { AlephaOrmPostgres } from "alepha/orm/postgres";
5
+ import { AlephaSecurity } from "alepha/security";
6
+ import { BadRequestError } from "alepha/server";
7
+ import { describe, expect, it } from "vitest";
8
+ import {
9
+ AlephaApiUsers,
10
+ RealmProvider,
11
+ RegistrationService,
12
+ UserNotifications,
13
+ UserService,
14
+ } from "../index.ts";
15
+
16
+ const setup = async (
17
+ realmName: string,
18
+ realmSettings?: Record<string, unknown>,
19
+ ) => {
20
+ const alepha = Alepha.create();
21
+ alepha.with(AlephaOrmPostgres);
22
+ alepha.with(AlephaSecurity);
23
+ alepha.with(AlephaEmail);
24
+ alepha.with(AlephaApiVerification);
25
+ alepha.with(AlephaApiUsers);
26
+ alepha.with(UserNotifications);
27
+ await alepha.start();
28
+
29
+ const realmProvider = alepha.inject(RealmProvider);
30
+ if (realmSettings) {
31
+ realmProvider.register(realmName, {
32
+ settings: realmSettings as never,
33
+ });
34
+ }
35
+
36
+ // Wipe between cases.
37
+ await realmProvider.userRepository(realmName).deleteMany({});
38
+ alepha.inject(MemoryEmailProvider).records = [];
39
+
40
+ return {
41
+ alepha,
42
+ registrationService: alepha.inject(RegistrationService),
43
+ userService: alepha.inject(UserService),
44
+ realmProvider,
45
+ };
46
+ };
47
+
48
+ // ---------------------------------------------------------------------------------------------------------------------
49
+
50
+ describe("RegistrationService — username: 'email' mode", () => {
51
+ it("derives the username from the email and ignores any client-sent value", async () => {
52
+ const { registrationService, userService } = await setup("email-mode-1", {
53
+ username: "email",
54
+ });
55
+
56
+ const intent = await registrationService.createRegistrationIntent(
57
+ {
58
+ email: "ni.foures+testkv@gmail.com",
59
+ password: "SecurePassword123!",
60
+ // The client sneaks a custom username into the request — server
61
+ // must drop it on the floor and use the slugger.
62
+ username: "i-am-the-admin" as never,
63
+ },
64
+ "email-mode-1",
65
+ );
66
+
67
+ const user = await registrationService.completeRegistration({
68
+ intentId: intent.intentId,
69
+ });
70
+
71
+ // Slug rule: gmail "+suffix" preserved, dots → "-".
72
+ expect(user.username).toBe("ni-foures-testkv");
73
+
74
+ const reloaded = await userService
75
+ .users("email-mode-1")
76
+ .findOne({ where: { email: { eq: "ni.foures+testkv@gmail.com" } } });
77
+ expect(reloaded?.username).toBe("ni-foures-testkv");
78
+ });
79
+
80
+ it("appends a 4-char random suffix on collision instead of failing", async () => {
81
+ const { registrationService, userService } = await setup("email-mode-2", {
82
+ username: "email",
83
+ });
84
+
85
+ // First user takes the slug.
86
+ await userService.users("email-mode-2").create({
87
+ realm: "email-mode-2",
88
+ username: "alice",
89
+ email: "preexisting@example.com",
90
+ roles: ["user"],
91
+ });
92
+
93
+ const intent = await registrationService.createRegistrationIntent(
94
+ {
95
+ email: "alice@example.com",
96
+ password: "SecurePassword123!",
97
+ },
98
+ "email-mode-2",
99
+ );
100
+ const created = await registrationService.completeRegistration({
101
+ intentId: intent.intentId,
102
+ });
103
+
104
+ expect(created.username).toMatch(/^alice-[a-z0-9]{4}$/);
105
+ });
106
+
107
+ it("rejects email-mode when no email is provided", async () => {
108
+ const { registrationService } = await setup("email-mode-3", {
109
+ username: "email",
110
+ email: "optional",
111
+ });
112
+
113
+ await expect(
114
+ registrationService.createRegistrationIntent(
115
+ {
116
+ password: "SecurePassword123!",
117
+ },
118
+ "email-mode-3",
119
+ ),
120
+ ).rejects.toThrowError(BadRequestError);
121
+ });
122
+
123
+ it("blocklist applies even though the client never sees the field", async () => {
124
+ const { registrationService } = await setup("email-mode-blocklist", {
125
+ username: "email",
126
+ usernameBlocklist: ["admin"],
127
+ });
128
+
129
+ const intent = await registrationService.createRegistrationIntent(
130
+ {
131
+ email: "admin@example.com",
132
+ password: "SecurePassword123!",
133
+ },
134
+ "email-mode-blocklist",
135
+ );
136
+ const user = await registrationService.completeRegistration({
137
+ intentId: intent.intentId,
138
+ });
139
+
140
+ // Slug "admin" is blocked → falls through to the suffix path.
141
+ expect(user.username).not.toBe("admin");
142
+ expect(user.username).toMatch(/^admin-[a-z0-9]{4}$/);
143
+ });
144
+ });
145
+
146
+ // ---------------------------------------------------------------------------------------------------------------------
147
+
148
+ describe("RegistrationService — blocklist applies in 'required' mode too", () => {
149
+ it("rejects a manual username that hits the blocklist", async () => {
150
+ const { registrationService } = await setup("manual-mode", {
151
+ username: "required",
152
+ usernameBlocklist: ["admin"],
153
+ });
154
+
155
+ await expect(
156
+ registrationService.createRegistrationIntent(
157
+ {
158
+ username: "admin",
159
+ email: "someone@example.com",
160
+ password: "SecurePassword123!",
161
+ },
162
+ "manual-mode",
163
+ ),
164
+ ).rejects.toThrowError(BadRequestError);
165
+ });
166
+
167
+ it("blocklist match is case-insensitive in manual mode", async () => {
168
+ const { registrationService } = await setup("manual-case", {
169
+ username: "required",
170
+ usernameBlocklist: ["Admin"],
171
+ });
172
+
173
+ await expect(
174
+ registrationService.createRegistrationIntent(
175
+ {
176
+ username: "ADMIN",
177
+ email: "someone@example.com",
178
+ password: "SecurePassword123!",
179
+ },
180
+ "manual-case",
181
+ ),
182
+ ).rejects.toThrowError(BadRequestError);
183
+ });
184
+
185
+ it("default (empty) blocklist does not reject special names", async () => {
186
+ const { registrationService } = await setup("manual-empty", {
187
+ username: "required",
188
+ });
189
+
190
+ const intent = await registrationService.createRegistrationIntent(
191
+ {
192
+ username: "admin",
193
+ email: "admin-empty@example.com",
194
+ password: "SecurePassword123!",
195
+ },
196
+ "manual-empty",
197
+ );
198
+ const created = await registrationService.completeRegistration({
199
+ intentId: intent.intentId,
200
+ });
201
+ expect(created.username).toBe("admin");
202
+ });
203
+ });
@@ -0,0 +1,138 @@
1
+ import { Alepha } from "alepha";
2
+ import { AlephaOrmPostgres } from "alepha/orm/postgres";
3
+ import { AlephaSecurity } from "alepha/security";
4
+ import { describe, expect, it } from "vitest";
5
+ import { realmAuthSettingsAtom } from "../atoms/realmAuthSettingsAtom.ts";
6
+ import { AlephaApiUsers, RealmProvider, UsernameSlugger } from "../index.ts";
7
+
8
+ const setup = async (realmSettings?: Record<string, unknown>) => {
9
+ const alepha = Alepha.create();
10
+ alepha.with(AlephaOrmPostgres);
11
+ alepha.with(AlephaSecurity);
12
+ alepha.with(AlephaApiUsers);
13
+
14
+ await alepha.start();
15
+
16
+ if (realmSettings) {
17
+ alepha.inject(RealmProvider).register("default", {
18
+ settings: realmSettings as never,
19
+ });
20
+ }
21
+
22
+ // Wipe users table between cases so collision tests don't interfere.
23
+ const users = alepha.inject(RealmProvider).userRepository();
24
+ await users.deleteMany({});
25
+
26
+ return {
27
+ alepha,
28
+ slugger: alepha.inject(UsernameSlugger),
29
+ users,
30
+ };
31
+ };
32
+
33
+ // ---------------------------------------------------------------------------------------------------------------------
34
+
35
+ describe("UsernameSlugger.slug — pure rule", () => {
36
+ it("strips the domain and the gmail '+suffix' is kept verbatim", async () => {
37
+ const { slugger } = await setup();
38
+ expect(slugger.slug("ni.foures+testkv@gmail.com")).toBe("ni-foures-testkv");
39
+ });
40
+
41
+ it("collapses runs of non-alphanumeric chars and trims edges", async () => {
42
+ const { slugger } = await setup();
43
+ expect(slugger.slug("john...doe@example.com")).toBe("john-doe");
44
+ expect(slugger.slug("---weird---name---@x.io")).toBe("weird-name");
45
+ });
46
+
47
+ it("lowercases the result", async () => {
48
+ const { slugger } = await setup();
49
+ expect(slugger.slug("Alice.SMITH@Example.com")).toBe("alice-smith");
50
+ });
51
+
52
+ it("drops every non-[a-z0-9] char (no NFD normalize)", async () => {
53
+ // `é` is dropped, so `josé` becomes `jos` — and then padded to MIN_LENGTH.
54
+ const { slugger } = await setup();
55
+ const result = slugger.slug("josé@example.com");
56
+ expect(result.startsWith("jos")).toBe(true);
57
+ expect(result.length).toBeGreaterThanOrEqual(UsernameSlugger.MIN_LENGTH);
58
+ });
59
+
60
+ it("pads short slugs with random alphanumerics", async () => {
61
+ const { slugger } = await setup();
62
+ const result = slugger.slug("a@example.com");
63
+ expect(result.startsWith("a")).toBe(true);
64
+ expect(result.length).toBeGreaterThanOrEqual(UsernameSlugger.MIN_LENGTH);
65
+ expect(result).toMatch(/^[a-z0-9-]+$/);
66
+ });
67
+
68
+ it("falls back to the EMPTY_LOCAL_FALLBACK when the local part has no [a-z0-9]", async () => {
69
+ const { slugger } = await setup();
70
+ expect(slugger.slug("é@example.com")).toBe(
71
+ UsernameSlugger.EMPTY_LOCAL_FALLBACK,
72
+ );
73
+ });
74
+
75
+ it("clamps to MAX_LENGTH", async () => {
76
+ const { slugger } = await setup();
77
+ const long = `${"a".repeat(60)}@example.com`;
78
+ expect(slugger.slug(long).length).toBe(UsernameSlugger.MAX_LENGTH);
79
+ });
80
+
81
+ it("treats null/empty input by returning the fallback", async () => {
82
+ const { slugger } = await setup();
83
+ expect(slugger.slug(null)).toBe(UsernameSlugger.EMPTY_LOCAL_FALLBACK);
84
+ expect(slugger.slug("")).toBe(UsernameSlugger.EMPTY_LOCAL_FALLBACK);
85
+ expect(slugger.slug(undefined)).toBe(UsernameSlugger.EMPTY_LOCAL_FALLBACK);
86
+ });
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------------------------------------------------
90
+
91
+ describe("UsernameSlugger.pickAvailable — DB-backed availability + retry", () => {
92
+ it("returns the base when nothing collides", async () => {
93
+ const { slugger } = await setup();
94
+ const picked = await slugger.pickAvailable("default", "alice");
95
+ expect(picked).toBe("alice");
96
+ });
97
+
98
+ it("appends a 4-char random suffix when the base is taken", async () => {
99
+ const { slugger, users } = await setup();
100
+ await users.create({ realm: "default", username: "alice", email: "a@x" });
101
+
102
+ const picked = await slugger.pickAvailable("default", "alice");
103
+ expect(picked).toMatch(/^alice-[a-z0-9]{4}$/);
104
+ });
105
+
106
+ it("treats blocklisted candidates as collisions and falls through to the suffix path", async () => {
107
+ const { slugger } = await setup({ usernameBlocklist: ["admin", "root"] });
108
+
109
+ const picked = await slugger.pickAvailable("default", "admin");
110
+ expect(picked).toMatch(/^admin-[a-z0-9]{4}$/);
111
+ });
112
+
113
+ it("trims the base before adding the suffix when MAX_LENGTH would be exceeded", async () => {
114
+ const { slugger, users } = await setup();
115
+ const long = "a".repeat(UsernameSlugger.MAX_LENGTH);
116
+ await users.create({ realm: "default", username: long, email: "a@x" });
117
+
118
+ const picked = await slugger.pickAvailable("default", long);
119
+ expect(picked.length).toBeLessThanOrEqual(UsernameSlugger.MAX_LENGTH);
120
+ expect(picked).toMatch(/-[a-z0-9]{4}$/);
121
+ });
122
+
123
+ it("blocklist match is case-insensitive", async () => {
124
+ const { slugger } = await setup({ usernameBlocklist: ["Admin"] });
125
+ expect(await slugger.isBlocked("default", "admin")).toBe(true);
126
+ expect(await slugger.isBlocked("default", "ADMIN")).toBe(true);
127
+ expect(await slugger.isBlocked("default", "user")).toBe(false);
128
+ });
129
+
130
+ it("default blocklist is empty — no name is implicitly reserved", async () => {
131
+ const { alepha, slugger } = await setup();
132
+ const settings = alepha.get(realmAuthSettingsAtom);
133
+ expect(settings.usernameBlocklist).toEqual([]);
134
+
135
+ const picked = await slugger.pickAvailable("default", "admin");
136
+ expect(picked).toBe("admin");
137
+ });
138
+ });
@@ -14,6 +14,31 @@ const fieldRequirement = (description: string) =>
14
14
  description,
15
15
  });
16
16
 
17
+ /**
18
+ * Username-specific field requirement, extending {@link FieldRequirement}
19
+ * with an additional auto-derivation mode.
20
+ *
21
+ * - `"none"` / `"optional"` / `"required"`: same as {@link FieldRequirement}.
22
+ * - `"email"`: Field is hidden in the registration UI and the value is
23
+ * auto-derived from the user's email at signup. Same handling on
24
+ * credentials and OAuth flows. See `UsernameSlugger` for the rule and
25
+ * collision behavior.
26
+ */
27
+ export type UsernameFieldRequirement = FieldRequirement | "email";
28
+
29
+ const usernameFieldRequirement = (description: string) =>
30
+ t.union(
31
+ [
32
+ t.const("none"),
33
+ t.const("optional"),
34
+ t.const("required"),
35
+ t.const("email"),
36
+ ],
37
+ {
38
+ description,
39
+ },
40
+ );
41
+
17
42
  export const realmAuthSettingsAtom = $atom({
18
43
  name: "alepha.api.users.realmAuthSettings",
19
44
  schema: t.object({
@@ -42,11 +67,20 @@ export const realmAuthSettingsAtom = $atom({
42
67
  email: fieldRequirement(
43
68
  "Email address field requirement for user accounts",
44
69
  ),
45
- username: fieldRequirement("Username field requirement for user accounts"),
70
+ username: usernameFieldRequirement(
71
+ "Username field requirement for user accounts",
72
+ ),
46
73
  usernameRegExp: t.string({
47
74
  description:
48
75
  "Regular expression that usernames must match (if username is enabled)",
49
76
  }),
77
+ usernameBlocklist: t.array(t.text(), {
78
+ description:
79
+ "Usernames that the slugger / manual registration must reject. " +
80
+ "Default empty so apps can register `admin`/`root`/`me`/etc. if " +
81
+ "they want; populate it explicitly for handles you want to keep " +
82
+ "off-limits.",
83
+ }),
50
84
  phoneNumber: fieldRequirement(
51
85
  "Phone number field requirement for user accounts",
52
86
  ),
@@ -138,8 +172,12 @@ export const realmAuthSettingsAtom = $atom({
138
172
  // for a fresh hello world setup, we accept registration and email login
139
173
  registrationAllowed: true,
140
174
  email: "required" as FieldRequirement,
141
- username: "none" as FieldRequirement,
142
- usernameRegExp: "^[a-zA-Z0-9_]{3,30}$",
175
+ username: "none" as UsernameFieldRequirement,
176
+ // Allow hyphens by default so the UsernameSlugger output (`ni-foures-testkv`)
177
+ // matches without app overrides. Existing `[a-zA-Z0-9_]` usernames remain
178
+ // valid because the new pattern is a strict superset.
179
+ usernameRegExp: "^[a-zA-Z0-9_-]{3,30}$",
180
+ usernameBlocklist: [] as string[],
143
181
  phoneNumber: "none" as FieldRequirement,
144
182
  verifyEmailRequired: false,
145
183
  verifyPhoneRequired: false,
@@ -74,4 +74,33 @@ export class AdminSessionController {
74
74
  return { ok: true, id: params.id };
75
75
  },
76
76
  });
77
+
78
+ /**
79
+ * Delete many sessions in one repository call.
80
+ */
81
+ public readonly deleteSessions = $action({
82
+ method: "POST",
83
+ path: `${this.url}/delete`,
84
+ group: this.group,
85
+ use: [$secure({ permissions: ["admin:session:delete"] })],
86
+ description: "Delete many sessions",
87
+ schema: {
88
+ query: t.object({
89
+ userRealmName: t.optional(t.string()),
90
+ }),
91
+ body: t.object({
92
+ ids: t.array(t.uuid(), { minItems: 1, maxItems: 1000 }),
93
+ }),
94
+ response: t.object({
95
+ deleted: t.array(t.string()),
96
+ }),
97
+ },
98
+ handler: async ({ body, query }) => {
99
+ const deleted = await this.sessionService.deleteSessions(
100
+ body.ids,
101
+ query.userRealmName,
102
+ );
103
+ return { deleted };
104
+ },
105
+ });
77
106
  }
@@ -119,4 +119,36 @@ export class AdminUserController {
119
119
  return { ok: true, id: params.id };
120
120
  },
121
121
  });
122
+
123
+ /**
124
+ * Delete many users in one request. Each id is processed sequentially so
125
+ * cascades and side-effects run as if called one-by-one. Errors on a single
126
+ * id surface with that id in the response.
127
+ */
128
+ public readonly deleteUsers = $action({
129
+ method: "POST",
130
+ path: `${this.url}/delete`,
131
+ group: this.group,
132
+ use: [$secure({ permissions: ["admin:user:delete"] })],
133
+ description: "Delete many users",
134
+ schema: {
135
+ query: t.object({
136
+ userRealmName: t.optional(t.string()),
137
+ }),
138
+ body: t.object({
139
+ ids: t.array(t.uuid(), { minItems: 1, maxItems: 1000 }),
140
+ }),
141
+ response: t.object({
142
+ deleted: t.array(t.uuid()),
143
+ }),
144
+ },
145
+ handler: async ({ body, query }) => {
146
+ const deleted: string[] = [];
147
+ for (const id of body.ids) {
148
+ await this.userService.deleteUser(id, query.userRealmName);
149
+ deleted.push(id);
150
+ }
151
+ return { deleted };
152
+ },
153
+ });
122
154
  }
@@ -14,6 +14,7 @@ import { IdentityService } from "./services/IdentityService.ts";
14
14
  import { RegistrationService } from "./services/RegistrationService.ts";
15
15
  import { SessionCrudService } from "./services/SessionCrudService.ts";
16
16
  import { SessionService } from "./services/SessionService.ts";
17
+ import { UsernameSlugger } from "./services/UsernameSlugger.ts";
17
18
  import { UserService } from "./services/UserService.ts";
18
19
 
19
20
  // ---------------------------------------------------------------------------------------------------------------------
@@ -54,6 +55,7 @@ export * from "./services/IdentityService.ts";
54
55
  export * from "./services/RegistrationService.ts";
55
56
  export * from "./services/SessionCrudService.ts";
56
57
  export * from "./services/SessionService.ts";
58
+ export * from "./services/UsernameSlugger.ts";
57
59
  export * from "./services/UserService.ts";
58
60
 
59
61
  // ---------------------------------------------------------------------------------------------------------------------
@@ -82,6 +84,7 @@ export const AlephaApiUsers = $module({
82
84
  CredentialService,
83
85
  RegistrationService,
84
86
  UserService,
87
+ UsernameSlugger,
85
88
  IdentityService,
86
89
  UserController,
87
90
  AdminUserController,
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { $inject, Alepha } from "alepha";
3
3
  import type { VerificationController } from "alepha/api/verifications";
4
4
  import { $cache } from "alepha/cache";
5
+ import { DatabaseCacheProvider } from "alepha/cache/database";
5
6
  import { DateTimeProvider } from "alepha/datetime";
6
7
  import { $logger } from "alepha/logger";
7
8
  import { CryptoProvider } from "alepha/security";
@@ -52,6 +53,10 @@ export class CredentialService {
52
53
  }
53
54
 
54
55
  protected readonly intentCache = $cache<PasswordResetIntent>({
56
+ // Use the SQL-backed cache so phase-2 reads what phase-1 wrote with
57
+ // strong consistency, and so bare deployments don't need a distributed
58
+ // KV resource just to support password reset.
59
+ provider: DatabaseCacheProvider,
55
60
  name: "api:users:password-reset-intents",
56
61
  ttl: [INTENT_TTL_MINUTES, "minutes"],
57
62
  });
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { $inject, Alepha, AlephaError } from "alepha";
3
3
  import type { VerificationController } from "alepha/api/verifications";
4
4
  import { $cache } from "alepha/cache";
5
+ import { DatabaseCacheProvider } from "alepha/cache/database";
5
6
  import { CaptchaProvider } from "alepha/captcha";
6
7
  import { DateTimeProvider } from "alepha/datetime";
7
8
  import { $logger } from "alepha/logger";
@@ -16,6 +17,7 @@ import type { CompleteRegistrationRequest } from "../schemas/completeRegistratio
16
17
  import type { RegisterRequest } from "../schemas/registerRequestSchema.ts";
17
18
  import type { RegistrationIntentResponse } from "../schemas/registrationIntentResponseSchema.ts";
18
19
  import { CredentialService } from "./CredentialService.ts";
20
+ import { UsernameSlugger } from "./UsernameSlugger.ts";
19
21
 
20
22
  /**
21
23
  * Intent stored in cache during the registration flow.
@@ -50,13 +52,24 @@ export class RegistrationService {
50
52
  protected readonly realmProvider = $inject(RealmProvider);
51
53
  protected readonly credentialService = $inject(CredentialService);
52
54
  protected readonly captchaProvider = $inject(CaptchaProvider);
55
+ protected readonly usernameSlugger = $inject(UsernameSlugger);
53
56
 
54
57
  protected readonly intentCache = $cache<RegistrationIntent>({
58
+ // Pinned to the SQL-backed cache so:
59
+ // - phase 2 reliably reads the partial-registration payload phase 1 wrote;
60
+ // - the password hash held in the intent never lives in a distributed
61
+ // K/V outside the user's own DB unless they explicitly opt in.
62
+ provider: DatabaseCacheProvider,
55
63
  name: "api:users:registrations",
56
64
  ttl: [INTENT_TTL_MINUTES, "minutes"],
57
65
  });
58
66
 
59
67
  protected readonly rateLimitCache = $cache<number>({
68
+ // Use the SQL-backed cache so `incr()` is atomic (`INSERT ... ON CONFLICT
69
+ // DO UPDATE SET count = count + 1`). Cloudflare KV silently coalesces
70
+ // concurrent writes to the same key, which makes the limiter useless
71
+ // against bursts.
72
+ provider: DatabaseCacheProvider,
60
73
  name: "api:users:registration-rate-limit",
61
74
  ttl: [15, "minutes"],
62
75
  });
@@ -128,9 +141,16 @@ export class RegistrationService {
128
141
  throw new BadRequestError("Username is required");
129
142
  }
130
143
 
144
+ // In "email" mode the server is authoritative — any username sent in
145
+ // the request is dropped on the floor and replaced by the slugger
146
+ // output below.
147
+ if (realmSettings?.username === "email") {
148
+ body.username = undefined;
149
+ }
150
+
131
151
  if (body.username) {
132
152
  // Security note: usernameRegExp is admin-controlled (from realmAuthSettingsAtom),
133
- // not user input. Default is ^[a-zA-Z0-9_]{3,30}$ which is ReDoS-safe.
153
+ // not user input. Default is ^[a-zA-Z0-9_-]{3,30}$ which is ReDoS-safe.
134
154
  // No need for regex timeout or safe-regex validation here.
135
155
  const usernameRegExp = realmSettings?.usernameRegExp;
136
156
  if (usernameRegExp) {
@@ -145,6 +165,17 @@ export class RegistrationService {
145
165
  );
146
166
  }
147
167
  }
168
+
169
+ // Manual usernames must also clear the realm blocklist — same set
170
+ // that the slugger uses, so apps can't be sidestepped by clients
171
+ // POSTing the reserved name directly.
172
+ if (await this.usernameSlugger.isBlocked(userRealmName, body.username)) {
173
+ this.log.debug("Registration rejected: username is blocked", {
174
+ userRealmName,
175
+ username: body.username,
176
+ });
177
+ throw new BadRequestError("This username is not available");
178
+ }
148
179
  }
149
180
 
150
181
  if (realmSettings?.email === "required" && !body.email) {
@@ -161,6 +192,23 @@ export class RegistrationService {
161
192
  throw new BadRequestError("Phone number is required");
162
193
  }
163
194
 
195
+ // In "email" mode, derive the username from the email *now* so that
196
+ // checkUserAvailability picks it up too, the DB unique index sees a
197
+ // concrete value, and the persisted intent already carries the final
198
+ // username.
199
+ if (realmSettings?.username === "email") {
200
+ if (!body.email) {
201
+ throw new BadRequestError(
202
+ "Email is required to derive a username from email",
203
+ );
204
+ }
205
+ const base = this.usernameSlugger.slug(body.email);
206
+ body.username = await this.usernameSlugger.pickAvailable(
207
+ userRealmName,
208
+ base,
209
+ );
210
+ }
211
+
164
212
  // Check for existing users (username, email, phone)
165
213
  await this.checkUserAvailability(body, userRealmName);
166
214
 
@@ -71,4 +71,20 @@ export class SessionCrudService {
71
71
  await this.sessions(userRealmName).deleteById(id);
72
72
  this.log.info("Session deleted", { id });
73
73
  }
74
+
75
+ /**
76
+ * Delete many sessions by ID in one repository call.
77
+ */
78
+ public async deleteSessions(
79
+ ids: string[],
80
+ userRealmName?: string,
81
+ ): Promise<string[]> {
82
+ if (ids.length === 0) return [];
83
+ this.log.trace("Deleting sessions", { count: ids.length, userRealmName });
84
+ const deleted = await this.sessions(userRealmName).deleteMany({
85
+ id: { inArray: ids },
86
+ });
87
+ this.log.info("Sessions deleted", { count: deleted.length });
88
+ return deleted.map(String);
89
+ }
74
90
  }