alepha 0.19.2 → 0.19.4

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 (241) hide show
  1. package/assets/swagger-ui/swagger-ui-bundle.js +1 -1
  2. package/dist/api/audits/index.d.ts +8 -8
  3. package/dist/api/invitations/index.d.ts +790 -0
  4. package/dist/api/invitations/index.d.ts.map +1 -0
  5. package/dist/api/invitations/index.js +665 -0
  6. package/dist/api/invitations/index.js.map +1 -0
  7. package/dist/api/jobs/index.browser.js +8 -9
  8. package/dist/api/jobs/index.browser.js.map +1 -1
  9. package/dist/api/jobs/index.d.ts +90 -34
  10. package/dist/api/jobs/index.d.ts.map +1 -1
  11. package/dist/api/jobs/index.js +267 -44
  12. package/dist/api/jobs/index.js.map +1 -1
  13. package/dist/api/notifications/index.browser.js +0 -1
  14. package/dist/api/notifications/index.browser.js.map +1 -1
  15. package/dist/api/notifications/index.d.ts +3 -3
  16. package/dist/api/notifications/index.d.ts.map +1 -1
  17. package/dist/api/notifications/index.js +0 -1
  18. package/dist/api/notifications/index.js.map +1 -1
  19. package/dist/api/parameters/index.browser.js +112 -1
  20. package/dist/api/parameters/index.browser.js.map +1 -1
  21. package/dist/api/parameters/index.d.ts +90 -3
  22. package/dist/api/parameters/index.d.ts.map +1 -1
  23. package/dist/api/parameters/index.js +79 -12
  24. package/dist/api/parameters/index.js.map +1 -1
  25. package/dist/{billing → api/payments}/index.d.ts +67 -49
  26. package/dist/api/payments/index.d.ts.map +1 -0
  27. package/dist/{billing → api/payments}/index.js +108 -74
  28. package/dist/api/payments/index.js.map +1 -0
  29. package/dist/api/subscriptions/index.d.ts +1692 -0
  30. package/dist/api/subscriptions/index.d.ts.map +1 -0
  31. package/dist/api/subscriptions/index.js +1870 -0
  32. package/dist/api/subscriptions/index.js.map +1 -0
  33. package/dist/api/users/index.d.ts +27 -21
  34. package/dist/api/users/index.d.ts.map +1 -1
  35. package/dist/api/users/index.js +167 -34
  36. package/dist/api/users/index.js.map +1 -1
  37. package/dist/api/workflows/index.browser.js +246 -0
  38. package/dist/api/workflows/index.browser.js.map +1 -0
  39. package/dist/api/workflows/index.d.ts +1618 -0
  40. package/dist/api/workflows/index.d.ts.map +1 -0
  41. package/dist/api/workflows/index.js +1504 -0
  42. package/dist/api/workflows/index.js.map +1 -0
  43. package/dist/cli/config/index.d.ts +6 -28
  44. package/dist/cli/config/index.d.ts.map +1 -1
  45. package/dist/cli/config/index.js +5 -10
  46. package/dist/cli/config/index.js.map +1 -1
  47. package/dist/cli/core/index.d.ts +11669 -208
  48. package/dist/cli/core/index.d.ts.map +1 -1
  49. package/dist/cli/core/index.js +60 -69
  50. package/dist/cli/core/index.js.map +1 -1
  51. package/dist/cli/devtools/index.d.ts +5 -0
  52. package/dist/cli/devtools/index.d.ts.map +1 -1
  53. package/dist/cli/devtools/index.js +4 -0
  54. package/dist/cli/devtools/index.js.map +1 -1
  55. package/dist/cli/platform/index.d.ts +69 -64
  56. package/dist/cli/platform/index.d.ts.map +1 -1
  57. package/dist/cli/platform/index.js +6 -2
  58. package/dist/cli/platform/index.js.map +1 -1
  59. package/dist/cli/vendor/index.d.ts +38 -10
  60. package/dist/cli/vendor/index.d.ts.map +1 -1
  61. package/dist/cli/vendor/index.js +85 -26
  62. package/dist/cli/vendor/index.js.map +1 -1
  63. package/dist/core/index.browser.js +21 -2
  64. package/dist/core/index.browser.js.map +1 -1
  65. package/dist/core/index.d.ts +33 -2
  66. package/dist/core/index.d.ts.map +1 -1
  67. package/dist/core/index.js +25 -2
  68. package/dist/core/index.js.map +1 -1
  69. package/dist/core/index.native.js +25 -2
  70. package/dist/core/index.native.js.map +1 -1
  71. package/dist/core/index.workerd.js +25 -2
  72. package/dist/core/index.workerd.js.map +1 -1
  73. package/dist/email/smtp/index.js +24 -8
  74. package/dist/email/smtp/index.js.map +1 -1
  75. package/dist/logger/index.d.ts.map +1 -1
  76. package/dist/logger/index.js +1 -1
  77. package/dist/logger/index.js.map +1 -1
  78. package/dist/orm/core/index.browser.js +0 -18
  79. package/dist/orm/core/index.browser.js.map +1 -1
  80. package/dist/orm/core/index.bun.js +25 -73
  81. package/dist/orm/core/index.bun.js.map +1 -1
  82. package/dist/orm/core/index.d.ts +10 -32
  83. package/dist/orm/core/index.d.ts.map +1 -1
  84. package/dist/orm/core/index.js +25 -73
  85. package/dist/orm/core/index.js.map +1 -1
  86. package/dist/orm/postgres/index.bun.js +3 -3
  87. package/dist/orm/postgres/index.bun.js.map +1 -1
  88. package/dist/orm/postgres/index.d.ts +2 -1
  89. package/dist/orm/postgres/index.d.ts.map +1 -1
  90. package/dist/orm/postgres/index.js +3 -3
  91. package/dist/orm/postgres/index.js.map +1 -1
  92. package/dist/react/router/index.browser.js +25 -3
  93. package/dist/react/router/index.browser.js.map +1 -1
  94. package/dist/react/router/index.d.ts +16 -1
  95. package/dist/react/router/index.d.ts.map +1 -1
  96. package/dist/react/router/index.js +25 -3
  97. package/dist/react/router/index.js.map +1 -1
  98. package/dist/security/index.d.ts +28 -0
  99. package/dist/security/index.d.ts.map +1 -1
  100. package/dist/security/index.js +28 -0
  101. package/dist/security/index.js.map +1 -1
  102. package/package.json +37 -20
  103. package/src/api/invitations/__tests__/InvitationService.spec.ts +439 -0
  104. package/src/api/invitations/controllers/AdminInvitationController.ts +86 -0
  105. package/src/api/invitations/controllers/InvitationController.ts +84 -0
  106. package/src/api/invitations/entities/invitations.ts +33 -0
  107. package/src/api/invitations/index.ts +65 -0
  108. package/src/api/invitations/jobs/InvitationJobs.ts +37 -0
  109. package/src/api/invitations/providers/InvitationProvider.ts +45 -0
  110. package/src/api/invitations/schemas/createInvitationSchema.ts +12 -0
  111. package/src/api/invitations/schemas/invitationConfigAtom.ts +20 -0
  112. package/src/api/invitations/schemas/invitationQuerySchema.ts +15 -0
  113. package/src/api/invitations/schemas/invitationResourceSchema.ts +6 -0
  114. package/src/api/invitations/schemas/invitationWithResourceInfoSchema.ts +22 -0
  115. package/src/api/invitations/schemas/myInvitationsQuerySchema.ts +10 -0
  116. package/src/api/invitations/services/InvitationService.ts +556 -0
  117. package/src/api/jobs/__tests__/$job.spec.ts +876 -0
  118. package/src/api/jobs/controllers/AdminJobController.ts +44 -0
  119. package/src/api/jobs/entities/jobExecutionEntity.ts +0 -2
  120. package/src/api/jobs/index.ts +0 -3
  121. package/src/api/jobs/primitives/$job.ts +22 -11
  122. package/src/api/jobs/providers/JobProvider.ts +239 -25
  123. package/src/api/jobs/schemas/jobConfigAtom.ts +4 -0
  124. package/src/api/jobs/schemas/jobCronInfoSchema.ts +1 -0
  125. package/src/api/jobs/schemas/jobExecutionQuerySchema.ts +0 -1
  126. package/src/api/jobs/schemas/jobQueueDepthSchema.ts +1 -0
  127. package/src/api/jobs/schemas/jobRegistrationSchema.ts +1 -6
  128. package/src/api/jobs/services/JobService.ts +51 -12
  129. package/src/api/notifications/schemas/notificationQuerySchema.ts +0 -1
  130. package/src/api/parameters/__tests__/$parameter.spec.ts +327 -0
  131. package/src/api/parameters/controllers/AdminParameterController.ts +29 -3
  132. package/src/api/parameters/index.browser.ts +12 -0
  133. package/src/api/parameters/primitives/$parameter.ts +20 -3
  134. package/src/api/parameters/services/ParameterProvider.ts +48 -7
  135. package/src/{billing → api/payments}/__tests__/PaymentMethodService.spec.ts +32 -6
  136. package/src/api/payments/__tests__/PaymentService.spec.ts +279 -0
  137. package/src/{billing/controllers/AdminBillingController.ts → api/payments/controllers/AdminPaymentController.ts} +26 -21
  138. package/src/{billing/controllers/BillingController.ts → api/payments/controllers/PaymentController.ts} +23 -11
  139. package/src/{billing → api/payments}/entities/paymentIntents.ts +1 -0
  140. package/src/{billing/errors/BillingError.ts → api/payments/errors/PaymentError.ts} +1 -1
  141. package/src/{billing → api/payments}/index.ts +31 -25
  142. package/src/{billing/providers/MemoryBillingProvider.ts → api/payments/providers/MemoryPaymentProvider.ts} +4 -4
  143. package/src/{billing/providers/BillingProvider.ts → api/payments/providers/PaymentProvider.ts} +9 -2
  144. package/src/{billing → api/payments}/services/PaymentMethodService.ts +5 -5
  145. package/src/{billing/services/BillingService.ts → api/payments/services/PaymentService.ts} +94 -18
  146. package/src/api/subscriptions/__tests__/BillingService.spec.ts +218 -0
  147. package/src/api/subscriptions/__tests__/SubscriptionService.spec.ts +278 -0
  148. package/src/api/subscriptions/controllers/AdminSubscriptionController.ts +212 -0
  149. package/src/api/subscriptions/controllers/SubscriptionController.ts +189 -0
  150. package/src/api/subscriptions/entities/subscriptionEvents.ts +54 -0
  151. package/src/api/subscriptions/entities/subscriptions.ts +68 -0
  152. package/src/api/subscriptions/index.ts +144 -0
  153. package/src/api/subscriptions/jobs/SubscriptionJobs.ts +382 -0
  154. package/src/api/subscriptions/middleware/$requireLimit.ts +50 -0
  155. package/src/api/subscriptions/middleware/$requirePlan.ts +49 -0
  156. package/src/api/subscriptions/notifications/SubscriptionNotifications.ts +110 -0
  157. package/src/api/subscriptions/schemas/cancelSubscriptionSchema.ts +8 -0
  158. package/src/api/subscriptions/schemas/changePlanSchema.ts +9 -0
  159. package/src/api/subscriptions/schemas/createSubscriptionSchema.ts +11 -0
  160. package/src/api/subscriptions/schemas/entitlementsSchema.ts +21 -0
  161. package/src/api/subscriptions/schemas/mrrSchema.ts +13 -0
  162. package/src/api/subscriptions/schemas/planDefinitionSchema.ts +71 -0
  163. package/src/api/subscriptions/schemas/planResourceSchema.ts +25 -0
  164. package/src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts +8 -0
  165. package/src/api/subscriptions/schemas/subscriptionQuerySchema.ts +19 -0
  166. package/src/api/subscriptions/schemas/subscriptionResourceSchema.ts +6 -0
  167. package/src/api/subscriptions/schemas/subscriptionSettingsSchema.ts +32 -0
  168. package/src/api/subscriptions/schemas/subscriptionStatsSchema.ts +23 -0
  169. package/src/api/subscriptions/services/BillingService.ts +437 -0
  170. package/src/api/subscriptions/services/SubscriptionConfig.ts +56 -0
  171. package/src/api/subscriptions/services/SubscriptionService.ts +867 -0
  172. package/src/api/subscriptions/services/UsageService.ts +118 -0
  173. package/src/api/users/__tests__/AdminUserController.spec.ts +80 -1
  174. package/src/api/users/__tests__/CredentialService.spec.ts +177 -0
  175. package/src/api/users/__tests__/EmailVerification.spec.ts +29 -18
  176. package/src/api/users/__tests__/PasswordReset.spec.ts +3 -0
  177. package/src/api/users/__tests__/RegistrationService.spec.ts +148 -1
  178. package/src/api/users/__tests__/SessionService.spec.ts +142 -1
  179. package/src/api/users/atoms/realmAuthSettingsAtom.ts +10 -1
  180. package/src/api/users/controllers/UserController.ts +3 -8
  181. package/src/api/users/notifications/UserNotifications.ts +23 -0
  182. package/src/api/users/schemas/loginSchema.ts +1 -1
  183. package/src/api/users/services/CredentialService.ts +51 -4
  184. package/src/api/users/services/RegistrationService.ts +38 -9
  185. package/src/api/users/services/SessionService.ts +62 -9
  186. package/src/api/users/services/UserService.ts +21 -12
  187. package/src/api/workflows/__tests__/$workflow.spec.ts +616 -0
  188. package/src/api/workflows/controllers/AdminWorkflowController.ts +191 -0
  189. package/src/api/workflows/entities/workflowExecutions.ts +74 -0
  190. package/src/api/workflows/entities/workflowStepExecutions.ts +74 -0
  191. package/src/api/workflows/entities/workflowStepLogs.ts +13 -0
  192. package/src/api/workflows/index.browser.ts +22 -0
  193. package/src/api/workflows/index.ts +124 -0
  194. package/src/api/workflows/jobs/WorkflowJobs.ts +77 -0
  195. package/src/api/workflows/primitives/$workflow.ts +202 -0
  196. package/src/api/workflows/providers/WorkflowProvider.ts +1284 -0
  197. package/src/api/workflows/schemas/workflowActivitySchema.ts +15 -0
  198. package/src/api/workflows/schemas/workflowConfigAtom.ts +51 -0
  199. package/src/api/workflows/schemas/workflowExecutionDetailSchema.ts +18 -0
  200. package/src/api/workflows/schemas/workflowExecutionQuerySchema.ts +26 -0
  201. package/src/api/workflows/schemas/workflowExecutionResourceSchema.ts +30 -0
  202. package/src/api/workflows/schemas/workflowRegistrationSchema.ts +26 -0
  203. package/src/api/workflows/schemas/workflowStatsSchema.ts +16 -0
  204. package/src/api/workflows/schemas/workflowStepExecutionResourceSchema.ts +15 -0
  205. package/src/api/workflows/services/WorkflowService.ts +382 -0
  206. package/src/cli/config/defineConfig.ts +17 -46
  207. package/src/cli/core/providers/ViteDevServerProvider.ts +45 -3
  208. package/src/cli/core/services/PackageManagerUtils.ts +3 -1
  209. package/src/cli/core/services/ProjectScaffolder.ts +5 -5
  210. package/src/cli/core/templates/agentMd.ts +14 -5
  211. package/src/cli/core/templates/webAppRouterTs.ts +5 -58
  212. package/src/cli/devtools/index.ts +21 -1
  213. package/src/cli/platform/index.ts +23 -2
  214. package/src/cli/vendor/__tests__/VendorService.spec.ts +283 -178
  215. package/src/cli/vendor/index.ts +20 -3
  216. package/src/cli/vendor/services/VendorService.ts +126 -27
  217. package/src/core/Alepha.ts +10 -0
  218. package/src/core/__tests__/TypeProvider.spec.ts +4 -2
  219. package/src/core/providers/SchemaValidator.ts +1 -1
  220. package/src/core/providers/TypeProvider.ts +46 -3
  221. package/src/logger/index.ts +6 -1
  222. package/src/orm/__tests__/enums.spec.ts +22 -29
  223. package/src/orm/__tests__/orm-showcase-tests.ts +430 -0
  224. package/src/orm/__tests__/orm-showcase.spec.ts +167 -0
  225. package/src/orm/core/providers/DatabaseTypeProvider.ts +0 -29
  226. package/src/orm/core/providers/DrizzleKitProvider.ts +56 -105
  227. package/src/orm/postgres/services/PostgresModelBuilder.ts +3 -6
  228. package/src/react/router/__tests__/$page.browser.spec.tsx +157 -0
  229. package/src/react/router/providers/ReactBrowserProvider.ts +39 -0
  230. package/src/react/router/providers/ReactBrowserRouterProvider.ts +22 -0
  231. package/src/security/__tests__/$secure-combinations.spec.ts +945 -0
  232. package/src/security/primitives/$secure.ts +28 -0
  233. package/tsconfig.base.json +0 -1
  234. package/dist/billing/index.d.ts.map +0 -1
  235. package/dist/billing/index.js.map +0 -1
  236. package/src/billing/__tests__/BillingService.spec.ts +0 -136
  237. /package/src/{billing → api/payments}/entities/paymentMethods.ts +0 -0
  238. /package/src/{billing → api/payments}/entities/refunds.ts +0 -0
  239. /package/src/{billing → api/payments}/schemas/intentSchemas.ts +0 -0
  240. /package/src/{billing → api/payments}/schemas/paymentMethodSchemas.ts +0 -0
  241. /package/src/{billing → api/payments}/schemas/refundSchemas.ts +0 -0
@@ -7,7 +7,7 @@ import {
7
7
  CryptoProvider,
8
8
  InvalidCredentialsError,
9
9
  } from "alepha/security";
10
- import { BadRequestError } from "alepha/server";
10
+ import { BadRequestError, UnauthorizedError } from "alepha/server";
11
11
  import { describe, it } from "vitest";
12
12
  import {
13
13
  AlephaApiUsers,
@@ -320,6 +320,32 @@ describe("alepha/api/users - SessionService.login", () => {
320
320
  ).rejects.toThrowError(InvalidCredentialsError);
321
321
  });
322
322
 
323
+ it("should reject login for disabled user", async ({ expect }) => {
324
+ const { sessionService, userService, cryptoProvider, identities } =
325
+ await setup();
326
+
327
+ const password = "disabledUserPass123!";
328
+ const hashedPassword = await cryptoProvider.hashPassword(password);
329
+
330
+ const user = await userService.users().create({
331
+ username: "disableduser",
332
+ email: "disabled@example.com",
333
+ roles: ["user"],
334
+ enabled: false,
335
+ });
336
+
337
+ await identities.create({
338
+ provider: "local",
339
+ providerUserId: "disabled@example.com",
340
+ userId: user.id,
341
+ password: hashedPassword,
342
+ });
343
+
344
+ await expect(
345
+ sessionService.login("local", "disabled@example.com", password),
346
+ ).rejects.toThrowError(InvalidCredentialsError);
347
+ });
348
+
323
349
  describe("login rate limiting", () => {
324
350
  it("should block account after max failed attempts", async ({ expect }) => {
325
351
  const { sessionService, userService, cryptoProvider, identities } =
@@ -775,4 +801,119 @@ describe("alepha/api/users - SessionService.link", () => {
775
801
 
776
802
  expect(result.id).toBe(user.id);
777
803
  });
804
+
805
+ it("should refuse auto-link when OAuth provider says email_verified is false", async ({
806
+ expect,
807
+ }) => {
808
+ const { sessionService, userService } = await setup();
809
+
810
+ await userService.users().create({
811
+ username: "verifieduser",
812
+ email: "verified@example.com",
813
+ roles: ["user"],
814
+ });
815
+
816
+ await expect(
817
+ sessionService.link("google", {
818
+ sub: "google-unverified",
819
+ email: "verified@example.com",
820
+ name: "Unverified User",
821
+ email_verified: false,
822
+ }),
823
+ ).rejects.toThrowError(BadRequestError);
824
+ });
825
+
826
+ it("should allow auto-link when email_verified is true or undefined", async ({
827
+ expect,
828
+ }) => {
829
+ const { sessionService, userService } = await setup();
830
+
831
+ const user = await userService.users().create({
832
+ username: "linkableuser",
833
+ email: "linkable@example.com",
834
+ roles: ["user"],
835
+ });
836
+
837
+ const result = await sessionService.link("google", {
838
+ sub: "google-verified",
839
+ email: "linkable@example.com",
840
+ name: "Verified User",
841
+ email_verified: true,
842
+ });
843
+
844
+ expect(result.id).toBe(user.id);
845
+ });
846
+
847
+ it("should use defaultRoles from realm settings for new OAuth users", async ({
848
+ expect,
849
+ }) => {
850
+ const { sessionService, alepha } = await setup();
851
+
852
+ const realmProvider = alepha.inject(RealmProvider);
853
+ realmProvider.register("custom-roles", {
854
+ settings: {
855
+ registrationAllowed: true,
856
+ defaultRoles: ["member", "viewer"],
857
+ } as never,
858
+ });
859
+
860
+ const result = await sessionService.link(
861
+ "google",
862
+ {
863
+ sub: "google-newuser-roles",
864
+ email: "newuser-roles@example.com",
865
+ name: "New Role User",
866
+ },
867
+ "custom-roles",
868
+ );
869
+
870
+ expect("roles" in result && result.roles).toEqual(["member", "viewer"]);
871
+ });
872
+ });
873
+
874
+ describe("alepha/api/users - SessionService.refreshSession", () => {
875
+ it("should reject refresh for disabled user and delete session", async ({
876
+ expect,
877
+ }) => {
878
+ const { sessionService, userService, cryptoProvider, identities } =
879
+ await setup();
880
+
881
+ const password = "refreshDisabledPass!";
882
+ const hashedPassword = await cryptoProvider.hashPassword(password);
883
+
884
+ const user = await userService.users().create({
885
+ username: "refreshdisableduser",
886
+ email: "refresh-disabled@example.com",
887
+ roles: ["user"],
888
+ });
889
+
890
+ await identities.create({
891
+ provider: "local",
892
+ providerUserId: "refresh-disabled@example.com",
893
+ userId: user.id,
894
+ password: hashedPassword,
895
+ });
896
+
897
+ // Login to get a session with refresh token
898
+ await sessionService.login(
899
+ "local",
900
+ "refresh-disabled@example.com",
901
+ password,
902
+ );
903
+
904
+ const { refreshToken } = await sessionService.createSession(user, 3600);
905
+
906
+ // Disable the user
907
+ await userService.users().updateById(user.id, { enabled: false });
908
+
909
+ // Refresh should fail with UnauthorizedError
910
+ await expect(
911
+ sessionService.refreshSession(refreshToken),
912
+ ).rejects.toThrowError(UnauthorizedError);
913
+
914
+ // Session should be deleted — a second refresh should also fail
915
+ await expect(
916
+ sessionService.refreshSession(refreshToken),
917
+ ).rejects.toThrowError();
918
+ });
778
919
  });
@@ -70,6 +70,15 @@ export const realmAuthSettingsAtom = $atom({
70
70
  description:
71
71
  "List of usernames that are automatically promoted to admin role on login",
72
72
  }),
73
+ defaultRoles: t.array(t.string(), {
74
+ description: "Default roles assigned to newly registered users",
75
+ }),
76
+ verifyEmailUrl: t.optional(
77
+ t.string({
78
+ description:
79
+ "Base URL for email verification links (used when verification method is 'link'). Token and email are appended as query params.",
80
+ }),
81
+ ),
73
82
  passwordPolicy: t.object({
74
83
  minLength: t.integer({
75
84
  description: "Minimum password length",
@@ -122,7 +131,7 @@ export const realmAuthSettingsAtom = $atom({
122
131
  firstNameLastName: "none" as FieldRequirement,
123
132
  adminEmails: [],
124
133
  adminUsernames: [],
125
- // TODO: not implemented yet
134
+ defaultRoles: ["user"],
126
135
  passwordPolicy: {
127
136
  minLength: 8,
128
137
  requireUppercase: true,
@@ -1,4 +1,5 @@
1
1
  import { $inject, t } from "alepha";
2
+ import { $secure } from "alepha/security";
2
3
  import { $action, okSchema } from "alepha/server";
3
4
  import { completePasswordResetRequestSchema } from "../schemas/completePasswordResetRequestSchema.ts";
4
5
  import { completeRegistrationRequestSchema } from "../schemas/completeRegistrationRequestSchema.ts";
@@ -216,13 +217,7 @@ export class UserController {
216
217
  t.enum(["code", "link"], {
217
218
  default: "code",
218
219
  description:
219
- 'Verification method: "code" sends a 6-digit code, "link" sends a clickable verification link.',
220
- }),
221
- ),
222
- verifyUrl: t.optional(
223
- t.string({
224
- description:
225
- 'Base URL for verification link. Required when method is "link". Token and email will be appended as query params.',
220
+ 'Verification method: "code" sends a 6-digit code, "link" sends a clickable verification link. When using "link", configure verifyEmailUrl in realm settings.',
226
221
  }),
227
222
  ),
228
223
  }),
@@ -240,7 +235,6 @@ export class UserController {
240
235
  body.email,
241
236
  query.userRealmName,
242
237
  method,
243
- query.verifyUrl,
244
238
  );
245
239
 
246
240
  return {
@@ -293,6 +287,7 @@ export class UserController {
293
287
  public checkEmailVerification = $action({
294
288
  path: "/users/email-verification/check",
295
289
  group: this.group,
290
+ use: [$secure()],
296
291
  schema: {
297
292
  query: t.object({
298
293
  email: t.email(),
@@ -109,6 +109,29 @@ export class UserNotifications {
109
109
  }),
110
110
  });
111
111
 
112
+ public readonly accountLockout = $notification({
113
+ category: "security",
114
+ description:
115
+ "Email sent to users when their account is temporarily locked due to too many failed login attempts.",
116
+ critical: true,
117
+ sensitive: true,
118
+ email: {
119
+ subject: "Account temporarily locked",
120
+ body: (it) => `
121
+ <h1>Account Temporarily Locked</h1>
122
+ <p>Hi ${it.email},</p>
123
+ <p>Your account has been temporarily locked due to too many failed login attempts.</p>
124
+ <p>If this was you, please wait ${it.lockoutMinutes} minutes before trying again. If you've forgotten your password, you can reset it using the password reset feature.</p>
125
+ <p>If this wasn't you, someone may be trying to access your account. We recommend changing your password as soon as possible.</p>
126
+ <p>Best regards,<br>The Team</p>
127
+ `,
128
+ },
129
+ schema: t.object({
130
+ email: t.string({ format: "email" }),
131
+ lockoutMinutes: t.number(),
132
+ }),
133
+ });
134
+
112
135
  public readonly emailVerificationLink = $notification({
113
136
  category: "security",
114
137
  description:
@@ -8,7 +8,7 @@ export const loginSchema = t.object({
8
8
  description: "Username or email address for login",
9
9
  }),
10
10
  password: t.text({
11
- minLength: 6,
11
+ minLength: 8,
12
12
  description: "User password",
13
13
  }),
14
14
  });
@@ -7,6 +7,7 @@ import { $logger } from "alepha/logger";
7
7
  import { CryptoProvider } from "alepha/security";
8
8
  import { BadRequestError, HttpError } from "alepha/server";
9
9
  import { $client } from "alepha/server/links";
10
+ import type { RealmAuthSettings } from "../atoms/realmAuthSettingsAtom.ts";
10
11
  import { UserAudits } from "../audits/UserAudits.ts";
11
12
  import { UserNotifications } from "../notifications/UserNotifications.ts";
12
13
  import { RealmProvider } from "../providers/RealmProvider.ts";
@@ -67,6 +68,38 @@ export class CredentialService {
67
68
  return this.realmProvider.identityRepository(userRealmName);
68
69
  }
69
70
 
71
+ /**
72
+ * Validate a password against the realm's password policy.
73
+ */
74
+ public validatePasswordPolicy(
75
+ password: string,
76
+ policy: RealmAuthSettings["passwordPolicy"],
77
+ ): void {
78
+ if (password.length < policy.minLength) {
79
+ throw new BadRequestError(
80
+ `Password must be at least ${policy.minLength} characters`,
81
+ );
82
+ }
83
+ if (policy.requireUppercase && !/[A-Z]/.test(password)) {
84
+ throw new BadRequestError(
85
+ "Password must contain at least one uppercase letter",
86
+ );
87
+ }
88
+ if (policy.requireLowercase && !/[a-z]/.test(password)) {
89
+ throw new BadRequestError(
90
+ "Password must contain at least one lowercase letter",
91
+ );
92
+ }
93
+ if (policy.requireNumbers && !/\d/.test(password)) {
94
+ throw new BadRequestError("Password must contain at least one number");
95
+ }
96
+ if (policy.requireSpecialCharacters && !/[^a-zA-Z0-9]/.test(password)) {
97
+ throw new BadRequestError(
98
+ "Password must contain at least one special character",
99
+ );
100
+ }
101
+ }
102
+
70
103
  /**
71
104
  * Phase 1: Create a password reset intent.
72
105
  *
@@ -90,6 +123,14 @@ export class CredentialService {
90
123
  .add(INTENT_TTL_MINUTES, "minutes")
91
124
  .toISOString();
92
125
 
126
+ // Check if password reset is allowed for this realm
127
+ const realm = this.realmProvider.getRealm(userRealmName);
128
+ const realmSettings = await realm.getSettings();
129
+ if (realmSettings.resetPasswordAllowed === false) {
130
+ this.log.debug("Password reset not allowed for realm", { userRealmName });
131
+ return { intentId, expiresAt };
132
+ }
133
+
93
134
  // Find user by email (silent fail for security)
94
135
  const user = await this.users(userRealmName).findOne({
95
136
  where: { email: { eq: email } },
@@ -187,6 +228,11 @@ export class CredentialService {
187
228
  });
188
229
  }
189
230
 
231
+ // Validate password against realm policy before consuming the verification code
232
+ const realm = this.realmProvider.getRealm(intent.realmName);
233
+ const realmSettings = await realm.getSettings();
234
+ this.validatePasswordPolicy(body.newPassword, realmSettings.passwordPolicy);
235
+
190
236
  // Verify code using verification controller
191
237
  const result = await this.verificationController
192
238
  .validateVerificationCode({
@@ -233,8 +279,6 @@ export class CredentialService {
233
279
  email: intent.email,
234
280
  });
235
281
 
236
- const realm = this.realmProvider.getRealm(intent.realmName);
237
-
238
282
  // Audit: password reset
239
283
  await this.userAudits(intent.realmName)?.recordUser("update", {
240
284
  userId: intent.userId,
@@ -320,6 +364,11 @@ export class CredentialService {
320
364
  throw new BadRequestError("Invalid or expired reset token");
321
365
  }
322
366
 
367
+ // Validate password against realm policy
368
+ const realm = this.realmProvider.getRealm(userRealmName);
369
+ const realmSettings = await realm.getSettings();
370
+ this.validatePasswordPolicy(newPassword, realmSettings.passwordPolicy);
371
+
323
372
  // Find user and identity
324
373
  const user = await this.users(userRealmName).getOne({
325
374
  where: { email: { eq: email } },
@@ -345,8 +394,6 @@ export class CredentialService {
345
394
  userId: { eq: user.id },
346
395
  });
347
396
 
348
- const realm = this.realmProvider.getRealm(userRealmName);
349
-
350
397
  // Audit: password reset (legacy method)
351
398
  await this.userAudits(userRealmName)?.recordUser("update", {
352
399
  userId: user.id,
@@ -14,6 +14,7 @@ import { RealmProvider } from "../providers/RealmProvider.ts";
14
14
  import type { CompleteRegistrationRequest } from "../schemas/completeRegistrationRequestSchema.ts";
15
15
  import type { RegisterRequest } from "../schemas/registerRequestSchema.ts";
16
16
  import type { RegistrationIntentResponse } from "../schemas/registrationIntentResponseSchema.ts";
17
+ import { CredentialService } from "./CredentialService.ts";
17
18
 
18
19
  /**
19
20
  * Intent stored in cache during the registration flow.
@@ -46,12 +47,18 @@ export class RegistrationService {
46
47
  protected readonly cryptoProvider = $inject(CryptoProvider);
47
48
  protected readonly verificationController = $client<VerificationController>();
48
49
  protected readonly realmProvider = $inject(RealmProvider);
50
+ protected readonly credentialService = $inject(CredentialService);
49
51
 
50
52
  protected readonly intentCache = $cache<RegistrationIntent>({
51
53
  name: "api:users:registrations",
52
54
  ttl: [INTENT_TTL_MINUTES, "minutes"],
53
55
  });
54
56
 
57
+ protected readonly rateLimitCache = $cache<number>({
58
+ name: "api:users:registration-rate-limit",
59
+ ttl: [15, "minutes"],
60
+ });
61
+
55
62
  protected userAudits(realmName?: string) {
56
63
  const realm = this.realmProvider.getRealm(realmName);
57
64
  if (realm.features.audits) {
@@ -88,6 +95,20 @@ export class RegistrationService {
88
95
  userRealmName,
89
96
  });
90
97
 
98
+ // IP rate limiting
99
+ const request = this.alepha.store.get("alepha.http.request");
100
+ const ipKey = request?.ip ? `register:ip:${request.ip}` : undefined;
101
+ if (ipKey) {
102
+ const count = (await this.rateLimitCache.get(ipKey)) ?? 0;
103
+ if (count >= 10) {
104
+ this.log.warn("Registration rate limit exceeded", { ip: request?.ip });
105
+ throw new BadRequestError(
106
+ "Too many registration attempts, please try again later",
107
+ );
108
+ }
109
+ await this.rateLimitCache.set(ipKey, count + 1);
110
+ }
111
+
91
112
  const realm = this.realmProvider.getRealm(userRealmName);
92
113
  const realmSettings = await realm.getSettings();
93
114
 
@@ -138,6 +159,12 @@ export class RegistrationService {
138
159
  // Check for existing users (username, email, phone)
139
160
  await this.checkUserAvailability(body, userRealmName);
140
161
 
162
+ // Validate password against realm policy
163
+ this.credentialService.validatePasswordPolicy(
164
+ body.password,
165
+ realmSettings.passwordPolicy,
166
+ );
167
+
141
168
  // Hash the password
142
169
  const passwordHash = await this.cryptoProvider.hashPassword(body.password);
143
170
 
@@ -279,23 +306,26 @@ export class RegistrationService {
279
306
  userRealmName,
280
307
  );
281
308
 
282
- // Atomically delete cache key to prevent replay
283
- await this.intentCache.invalidate(body.intentId);
309
+ const realm = this.realmProvider.getRealm(userRealmName);
310
+ const realmSettings = await realm.getSettings();
284
311
 
285
312
  // Create the user
286
313
  const user = await userRepository.create({
287
- realm: userRealmName,
314
+ realm: realm.name,
288
315
  username: intent.data.username,
289
316
  email: intent.data.email,
290
317
  phoneNumber: intent.data.phoneNumber,
291
318
  firstName: intent.data.firstName,
292
319
  lastName: intent.data.lastName,
293
320
  picture: intent.data.picture,
294
- roles: ["user"],
321
+ roles: realmSettings.defaultRoles,
295
322
  enabled: true,
296
323
  emailVerified: intent.requirements.email, // Marked as verified if we verified during registration
297
324
  });
298
325
 
326
+ // Invalidate intent after successful creation to allow retry on failure
327
+ await this.intentCache.invalidate(body.intentId);
328
+
299
329
  // Create credentials identity
300
330
  await identityRepository.create({
301
331
  userId: user.id,
@@ -309,8 +339,6 @@ export class RegistrationService {
309
339
  username: user.username,
310
340
  });
311
341
 
312
- const realm = this.realmProvider.getRealm(userRealmName);
313
-
314
342
  await this.userAudits(userRealmName)?.recordUser("create", {
315
343
  userId: user.id,
316
344
  userEmail: user.email ?? undefined,
@@ -335,11 +363,12 @@ export class RegistrationService {
335
363
  body: Pick<RegisterRequest, "username" | "email" | "phoneNumber">,
336
364
  userRealmName?: string,
337
365
  ): Promise<void> {
366
+ const realm = this.realmProvider.getRealm(userRealmName);
338
367
  const userRepository = this.realmProvider.userRepository(userRealmName);
339
368
 
340
369
  if (body.username) {
341
370
  const existingUser = await userRepository.findOne({
342
- where: { username: { ilike: body.username } },
371
+ where: { realm: realm.name, username: { ilike: body.username } },
343
372
  });
344
373
  if (existingUser) {
345
374
  this.log.debug("Username already taken", { username: body.username });
@@ -349,7 +378,7 @@ export class RegistrationService {
349
378
 
350
379
  if (body.email) {
351
380
  const existingUser = await userRepository.findOne({
352
- where: { email: { eq: body.email } },
381
+ where: { realm: realm.name, email: { eq: body.email } },
353
382
  });
354
383
  if (existingUser) {
355
384
  this.log.debug("Email already taken", { email: body.email });
@@ -359,7 +388,7 @@ export class RegistrationService {
359
388
 
360
389
  if (body.phoneNumber) {
361
390
  const existingUser = await userRepository.findOne({
362
- where: { phoneNumber: { eq: body.phoneNumber } },
391
+ where: { realm: realm.name, phoneNumber: { eq: body.phoneNumber } },
363
392
  });
364
393
  if (existingUser) {
365
394
  this.log.debug("Phone number already taken", {
@@ -15,6 +15,7 @@ import { $client } from "alepha/server/links";
15
15
  import { FileSystemProvider } from "alepha/system";
16
16
  import { UserAudits } from "../audits/UserAudits.ts";
17
17
  import type { UserEntity } from "../entities/users.ts";
18
+ import { UserNotifications } from "../notifications/UserNotifications.ts";
18
19
  import { RealmProvider } from "../providers/RealmProvider.ts";
19
20
 
20
21
  export class SessionService {
@@ -35,6 +36,14 @@ export class SessionService {
35
36
  return undefined;
36
37
  }
37
38
 
39
+ protected userNotifications(realmName?: string) {
40
+ const realm = this.realmProvider.getRealm(realmName);
41
+ if (realm.features.notifications) {
42
+ return this.alepha.inject(UserNotifications);
43
+ }
44
+ return undefined;
45
+ }
46
+
38
47
  public users(userRealmName?: string) {
39
48
  return this.realmProvider.userRepository(userRealmName);
40
49
  }
@@ -141,16 +150,12 @@ export class SessionService {
141
150
  // Truncate to leave room for suffix
142
151
  candidate = candidate.slice(0, maxLength - 2);
143
152
 
144
- // Check uniqueness (case-insensitive)
153
+ // Check uniqueness (case-insensitive exact match)
145
154
  const isAvailable = async (name: string) => {
146
- const existing = await users.findMany({
147
- where: { username: { contains: name } },
148
- limit: 1,
155
+ const existing = await users.findOne({
156
+ where: { username: { ilike: name } },
149
157
  });
150
- // Case-insensitive check
151
- return !existing.some(
152
- (u: any) => u.username?.toLowerCase() === name.toLowerCase(),
153
- );
158
+ return !existing;
154
159
  };
155
160
 
156
161
  if (await isAvailable(candidate)) {
@@ -354,6 +359,23 @@ export class SessionService {
354
359
  throw new InvalidCredentialsError();
355
360
  }
356
361
 
362
+ // Check if user account is enabled
363
+ if (!user.enabled) {
364
+ this.log.warn("Login attempt for disabled account", {
365
+ userId: user.id,
366
+ realm: name,
367
+ });
368
+
369
+ await this.userAudits(userRealmName)?.recordAuth("login_failed", {
370
+ userRealm: name,
371
+ resourceId: user.id,
372
+ description: "Login attempt for disabled account",
373
+ metadata: { provider, username },
374
+ });
375
+
376
+ throw new InvalidCredentialsError();
377
+ }
378
+
357
379
  // Account rate limit check (per-realm)
358
380
  const accountKey = `login:account:${name}:${user.id}`;
359
381
  const accountLocked = await this.isLoginLocked(
@@ -445,6 +467,15 @@ export class SessionService {
445
467
  metadata: { userId: user.id },
446
468
  },
447
469
  );
470
+
471
+ // Notify user about account lockout
472
+ if (user.email) {
473
+ const lockoutMinutes = Math.round(loginRateLimit.windowMs / 60_000);
474
+ await this.userNotifications(userRealmName)?.accountLockout.push({
475
+ contact: user.email,
476
+ variables: { email: user.email, lockoutMinutes },
477
+ });
478
+ }
448
479
  }
449
480
 
450
481
  throw new InvalidCredentialsError();
@@ -536,6 +567,16 @@ export class SessionService {
536
567
  },
537
568
  });
538
569
 
570
+ // Check if user account is still enabled
571
+ if (!user.enabled) {
572
+ this.log.warn("Session refresh for disabled account", {
573
+ userId: user.id,
574
+ sessionId: session.id,
575
+ });
576
+ await this.sessions(userRealmName).deleteById(session.id);
577
+ throw new UnauthorizedError("Account disabled");
578
+ }
579
+
539
580
  // Auto-promote to admin if configured (handles "I promote you admin" case)
540
581
  await this.ensureAdminRole(user, userRealmName);
541
582
 
@@ -636,11 +677,23 @@ export class SessionService {
636
677
 
637
678
  const existing = await users.findOne({
638
679
  where: {
680
+ realm: realm.name,
639
681
  email: profile.email,
640
682
  },
641
683
  });
642
684
 
643
685
  if (existing) {
686
+ // Refuse auto-link if the OAuth provider explicitly says email is not verified
687
+ if (profile.email_verified === false) {
688
+ this.log.warn(
689
+ "OAuth2 profile email not verified by provider, refusing auto-link",
690
+ { provider, email: profile.email, userId: existing.id },
691
+ );
692
+ throw new BadRequestError(
693
+ "Cannot link account: email not verified by provider",
694
+ );
695
+ }
696
+
644
697
  this.log.debug("Linking OAuth2 profile to existing user by email", {
645
698
  provider,
646
699
  profileSub: profile.sub,
@@ -692,7 +745,7 @@ export class SessionService {
692
745
  email: profile.email,
693
746
  // we trust the OAuth2 provider
694
747
  emailVerified: true,
695
- roles: ["user"], // TODO: make default roles configurable via realm settings
748
+ roles: realmSettings.defaultRoles,
696
749
  });
697
750
 
698
751
  if (profile.picture) {