alepha 0.20.2 → 0.20.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 (304) hide show
  1. package/README.md +0 -1
  2. package/assets/swagger-ui/swagger-ui-bundle.js +1 -1
  3. package/assets/swagger-ui/swagger-ui.css +1 -1
  4. package/dist/api/audits/index.browser.js +49 -0
  5. package/dist/api/audits/index.browser.js.map +1 -1
  6. package/dist/api/audits/index.js +49 -0
  7. package/dist/api/audits/index.js.map +1 -1
  8. package/dist/api/files/index.js.map +1 -1
  9. package/dist/api/jobs/index.d.ts +2 -61
  10. package/dist/api/jobs/index.d.ts.map +1 -1
  11. package/dist/api/jobs/index.js.map +1 -1
  12. package/dist/api/keys/index.d.ts +4 -4
  13. package/dist/api/keys/index.js.map +1 -1
  14. package/dist/api/notifications/index.d.ts +1 -10
  15. package/dist/api/notifications/index.d.ts.map +1 -1
  16. package/dist/api/parameters/index.browser.js +37 -0
  17. package/dist/api/parameters/index.browser.js.map +1 -1
  18. package/dist/api/parameters/index.d.ts +12 -68
  19. package/dist/api/parameters/index.d.ts.map +1 -1
  20. package/dist/api/parameters/index.js +57 -4
  21. package/dist/api/parameters/index.js.map +1 -1
  22. package/dist/api/payments/index.js.map +1 -1
  23. package/dist/api/users/index.browser.js +6 -0
  24. package/dist/api/users/index.browser.js.map +1 -1
  25. package/dist/api/users/index.d.ts +148 -227
  26. package/dist/api/users/index.d.ts.map +1 -1
  27. package/dist/api/users/index.js +60 -14
  28. package/dist/api/users/index.js.map +1 -1
  29. package/dist/api/verifications/index.d.ts.map +1 -1
  30. package/dist/api/verifications/index.js +2 -1
  31. package/dist/api/verifications/index.js.map +1 -1
  32. package/dist/bucket/index.d.ts +77 -107
  33. package/dist/bucket/index.d.ts.map +1 -1
  34. package/dist/bucket/index.js +153 -5
  35. package/dist/bucket/index.js.map +1 -1
  36. package/dist/bucket/index.workerd.js +12 -2
  37. package/dist/bucket/index.workerd.js.map +1 -1
  38. package/dist/cache/core/index.d.ts +26 -0
  39. package/dist/cache/core/index.d.ts.map +1 -1
  40. package/dist/cache/core/index.js +11 -1
  41. package/dist/cache/core/index.js.map +1 -1
  42. package/dist/cache/core/index.workerd.js +11 -1
  43. package/dist/cache/core/index.workerd.js.map +1 -1
  44. package/dist/captcha/index.js.map +1 -1
  45. package/dist/cli/config/index.d.ts +7 -5
  46. package/dist/cli/config/index.d.ts.map +1 -1
  47. package/dist/cli/config/index.js +2 -3
  48. package/dist/cli/config/index.js.map +1 -1
  49. package/dist/cli/core/index.d.ts +637 -11660
  50. package/dist/cli/core/index.d.ts.map +1 -1
  51. package/dist/cli/core/index.js +707 -532
  52. package/dist/cli/core/index.js.map +1 -1
  53. package/dist/cli/devtools/index.d.ts +4 -8
  54. package/dist/cli/devtools/index.d.ts.map +1 -1
  55. package/dist/cli/devtools/index.js +20 -16
  56. package/dist/cli/devtools/index.js.map +1 -1
  57. package/dist/cli/platform/index.d.ts +51 -77
  58. package/dist/cli/platform/index.d.ts.map +1 -1
  59. package/dist/cli/platform/index.js +65 -15
  60. package/dist/cli/platform/index.js.map +1 -1
  61. package/dist/cli/vendor/index.d.ts +10 -13
  62. package/dist/cli/vendor/index.d.ts.map +1 -1
  63. package/dist/cli/vendor/index.js +30 -12
  64. package/dist/cli/vendor/index.js.map +1 -1
  65. package/dist/command/index.js +1 -1
  66. package/dist/command/index.js.map +1 -1
  67. package/dist/core/index.browser.js +27 -3
  68. package/dist/core/index.browser.js.map +1 -1
  69. package/dist/core/index.d.ts +8 -11
  70. package/dist/core/index.d.ts.map +1 -1
  71. package/dist/core/index.js +27 -3
  72. package/dist/core/index.js.map +1 -1
  73. package/dist/core/index.native.js +27 -3
  74. package/dist/core/index.native.js.map +1 -1
  75. package/dist/core/index.workerd.js +27 -3
  76. package/dist/core/index.workerd.js.map +1 -1
  77. package/dist/crypto/index.js.map +1 -1
  78. package/dist/datetime/index.d.ts +69 -10
  79. package/dist/datetime/index.d.ts.map +1 -1
  80. package/dist/datetime/index.js +135 -13
  81. package/dist/datetime/index.js.map +1 -1
  82. package/dist/email/core/index.js.map +1 -1
  83. package/dist/email/smtp/index.js +130 -16
  84. package/dist/email/smtp/index.js.map +1 -1
  85. package/dist/fake/index.js.map +1 -1
  86. package/dist/lock/core/index.d.ts +30 -2
  87. package/dist/lock/core/index.d.ts.map +1 -1
  88. package/dist/lock/core/index.js +35 -12
  89. package/dist/lock/core/index.js.map +1 -1
  90. package/dist/lock/redis/index.js.map +1 -1
  91. package/dist/logger/index.js +32 -1
  92. package/dist/logger/index.js.map +1 -1
  93. package/dist/mcp/index.d.ts +238 -31
  94. package/dist/mcp/index.d.ts.map +1 -1
  95. package/dist/mcp/index.js +198 -67
  96. package/dist/mcp/index.js.map +1 -1
  97. package/dist/orm/core/index.browser.js +2 -362
  98. package/dist/orm/core/index.browser.js.map +1 -1
  99. package/dist/orm/core/index.bun.js +18 -409
  100. package/dist/orm/core/index.bun.js.map +1 -1
  101. package/dist/orm/core/index.d.ts +41 -194
  102. package/dist/orm/core/index.d.ts.map +1 -1
  103. package/dist/orm/core/index.js +27 -422
  104. package/dist/orm/core/index.js.map +1 -1
  105. package/dist/orm/postgres/index.bun.js +17 -20
  106. package/dist/orm/postgres/index.bun.js.map +1 -1
  107. package/dist/orm/postgres/index.d.ts +1 -5
  108. package/dist/orm/postgres/index.d.ts.map +1 -1
  109. package/dist/orm/postgres/index.js +17 -20
  110. package/dist/orm/postgres/index.js.map +1 -1
  111. package/dist/react/core/index.d.ts +102 -1
  112. package/dist/react/core/index.d.ts.map +1 -1
  113. package/dist/react/core/index.js +65 -1
  114. package/dist/react/core/index.js.map +1 -1
  115. package/dist/react/form/index.d.ts +6 -0
  116. package/dist/react/form/index.d.ts.map +1 -1
  117. package/dist/react/form/index.js +7 -7
  118. package/dist/react/form/index.js.map +1 -1
  119. package/dist/react/i18n/index.d.ts +7 -1
  120. package/dist/react/i18n/index.d.ts.map +1 -1
  121. package/dist/react/i18n/index.js +6 -0
  122. package/dist/react/i18n/index.js.map +1 -1
  123. package/dist/react/intro/index.js +22 -17
  124. package/dist/react/intro/index.js.map +1 -1
  125. package/dist/react/router/index.browser.js +98 -4
  126. package/dist/react/router/index.browser.js.map +1 -1
  127. package/dist/react/router/index.d.ts +58 -5
  128. package/dist/react/router/index.d.ts.map +1 -1
  129. package/dist/react/router/index.js +122 -6
  130. package/dist/react/router/index.js.map +1 -1
  131. package/dist/react/testing/{chunk-DBEY4PJZ.js → chunk-6Ep1yQYe.js} +1 -1
  132. package/dist/react/testing/index.js +1 -1
  133. package/dist/react/testing/index.js.map +1 -1
  134. package/dist/react/ui/index.d.ts +195 -1
  135. package/dist/react/ui/index.d.ts.map +1 -1
  136. package/dist/react/ui/index.js +64 -1
  137. package/dist/react/ui/index.js.map +1 -1
  138. package/dist/react/websocket/index.js.map +1 -1
  139. package/dist/redis/index.js.map +1 -1
  140. package/dist/scheduler/index.d.ts +1 -2
  141. package/dist/scheduler/index.d.ts.map +1 -1
  142. package/dist/scheduler/index.js +1 -1
  143. package/dist/scheduler/index.js.map +1 -1
  144. package/dist/scheduler/index.workerd.js +1 -1
  145. package/dist/scheduler/index.workerd.js.map +1 -1
  146. package/dist/security/index.browser.js.map +1 -1
  147. package/dist/security/index.d.ts.map +1 -1
  148. package/dist/security/index.js +2 -2
  149. package/dist/security/index.js.map +1 -1
  150. package/dist/server/auth/index.d.ts.map +1 -1
  151. package/dist/server/auth/index.js +24 -10
  152. package/dist/server/auth/index.js.map +1 -1
  153. package/dist/server/cookies/index.js.map +1 -1
  154. package/dist/server/core/index.browser.js +10 -3
  155. package/dist/server/core/index.browser.js.map +1 -1
  156. package/dist/server/core/index.d.ts +1 -4
  157. package/dist/server/core/index.d.ts.map +1 -1
  158. package/dist/server/core/index.js +47 -9
  159. package/dist/server/core/index.js.map +1 -1
  160. package/dist/server/links/index.browser.js.map +1 -1
  161. package/dist/server/links/index.js.map +1 -1
  162. package/dist/server/metrics/index.js +19 -1
  163. package/dist/server/metrics/index.js.map +1 -1
  164. package/dist/server/rate-limit/index.js.map +1 -1
  165. package/dist/server/static/index.js.map +1 -1
  166. package/dist/server/swagger/index.d.ts.map +1 -1
  167. package/dist/server/swagger/index.js +4 -5
  168. package/dist/server/swagger/index.js.map +1 -1
  169. package/dist/sms/index.js.map +1 -1
  170. package/dist/system/index.browser.js.map +1 -1
  171. package/dist/system/index.js.map +1 -1
  172. package/dist/system/index.workerd.js.map +1 -1
  173. package/dist/topic/core/index.js.map +1 -1
  174. package/dist/websocket/index.browser.js +32 -5
  175. package/dist/websocket/index.browser.js.map +1 -1
  176. package/dist/websocket/index.d.ts +3 -1
  177. package/dist/websocket/index.d.ts.map +1 -1
  178. package/dist/websocket/index.js +42 -6
  179. package/dist/websocket/index.js.map +1 -1
  180. package/package.json +685 -274
  181. package/src/api/files/__tests__/FileController.spec.ts +1 -1
  182. package/src/api/jobs/__tests__/$job.spec.ts +5 -1
  183. package/src/api/parameters/services/ParameterProvider.ts +21 -4
  184. package/src/api/users/__tests__/SessionService.spec.ts +99 -0
  185. package/src/api/users/__tests__/UserJobs.spec.ts +67 -0
  186. package/src/api/users/atoms/realmAuthSettingsAtom.ts +15 -0
  187. package/src/api/users/entities/sessions.ts +6 -0
  188. package/src/api/users/jobs/UserJobs.ts +44 -17
  189. package/src/api/users/providers/RealmProvider.ts +4 -0
  190. package/src/api/users/schemas/userQuerySchema.ts +0 -1
  191. package/src/api/users/services/SessionService.ts +27 -0
  192. package/src/api/users/services/UserService.ts +1 -5
  193. package/src/api/verifications/__tests__/CodeVerification.spec.ts +14 -0
  194. package/src/api/verifications/__tests__/LinkVerification.spec.ts +14 -0
  195. package/src/api/verifications/services/VerificationService.ts +1 -0
  196. package/src/bucket/__tests__/NodeS3BucketProvider.spec.ts +74 -0
  197. package/src/bucket/index.ts +19 -2
  198. package/src/bucket/primitives/$bucket.ts +9 -1
  199. package/src/bucket/providers/CloudflareR2Provider.ts +2 -137
  200. package/src/bucket/providers/NodeS3BucketProvider.ts +218 -0
  201. package/src/cache/core/index.ts +29 -0
  202. package/src/cache/core/primitives/$cache.ts +14 -1
  203. package/src/cli/config/defineConfig.ts +13 -15
  204. package/src/cli/core/__tests__/init.spec.ts +214 -7
  205. package/src/cli/core/commands/init.ts +12 -0
  206. package/src/cli/core/services/PackageManagerUtils.ts +23 -6
  207. package/src/cli/core/services/ProjectScaffolder.ts +315 -33
  208. package/src/cli/core/tasks/BuildCloudflareTask.ts +5 -0
  209. package/src/cli/core/tasks/BuildDockerTask.ts +9 -10
  210. package/src/cli/core/tasks/BuildServerTask.ts +8 -0
  211. package/src/cli/core/templates/agentMd.ts +2 -10
  212. package/src/cli/core/templates/apiIndexTs.ts +23 -1
  213. package/src/cli/core/templates/componentsJsonTs.ts +39 -0
  214. package/src/cli/core/templates/mainCss.ts +1 -0
  215. package/src/cli/core/templates/saasAdminLayoutTsx.ts +77 -0
  216. package/src/cli/core/templates/saasAdminPagesTsx.ts +26 -0
  217. package/src/cli/core/templates/saasAuthLayoutTsx.ts +20 -0
  218. package/src/cli/core/templates/saasAuthPagesTsx.ts +62 -0
  219. package/src/cli/core/templates/saasRealmProviderTs.ts +46 -0
  220. package/src/cli/core/templates/webAppRouterTs.ts +104 -1
  221. package/src/cli/core/templates/webIndexTs.ts +23 -1
  222. package/src/cli/devtools/index.ts +12 -26
  223. package/src/cli/platform/__tests__/SecretsCommand.spec.ts +2 -0
  224. package/src/cli/platform/index.ts +15 -24
  225. package/src/cli/vendor/atoms/vendorOptions.ts +1 -1
  226. package/src/cli/vendor/index.ts +14 -23
  227. package/src/command/providers/CliProvider.ts +1 -1
  228. package/src/core/Alepha.ts +11 -1
  229. package/src/core/helpers/ref.ts +18 -0
  230. package/src/core/index.shared.ts +1 -0
  231. package/src/core/interfaces/Service.ts +3 -1
  232. package/src/core/providers/SchemaValidator.ts +9 -1
  233. package/src/core/providers/TypeProvider.ts +2 -3
  234. package/src/datetime/REFACTORING.md +118 -0
  235. package/src/datetime/providers/DateTimeProvider.ts +203 -24
  236. package/src/lock/core/index.ts +31 -0
  237. package/src/lock/core/primitives/$lock.ts +14 -1
  238. package/src/logger/services/Logger.ts +1 -1
  239. package/src/mcp/__tests__/$resource.spec.ts +1 -1
  240. package/src/mcp/__tests__/$tool.spec.ts +1 -1
  241. package/src/mcp/__tests__/McpServerProvider.spec.ts +1 -1
  242. package/src/mcp/__tests__/jsonrpc.spec.ts +1 -1
  243. package/src/mcp/helpers/jsonrpc.ts +26 -1
  244. package/src/mcp/index.ts +10 -5
  245. package/src/mcp/interfaces/McpTypes.ts +83 -6
  246. package/src/mcp/primitives/$prompt.ts +18 -1
  247. package/src/mcp/primitives/$resource.ts +18 -1
  248. package/src/mcp/primitives/$tool.ts +83 -7
  249. package/src/mcp/providers/McpServerProvider.ts +74 -16
  250. package/src/mcp/transports/StreamableHttpMcpTransport.ts +226 -0
  251. package/src/orm/REFACTORING.md +330 -0
  252. package/src/orm/__tests__/$repository-tests.ts +1 -0
  253. package/src/orm/__tests__/orm-next-tests.ts +2 -67
  254. package/src/orm/__tests__/orm-next.spec.ts +0 -21
  255. package/src/orm/core/index.shared.ts +0 -2
  256. package/src/orm/core/index.ts +1 -2
  257. package/src/orm/core/primitives/$repository.ts +3 -6
  258. package/src/orm/core/primitives/$transactional.ts +11 -0
  259. package/src/orm/core/providers/drivers/DatabaseProvider.ts +0 -5
  260. package/src/orm/core/providers/drivers/NodeSqliteProvider.ts +11 -13
  261. package/src/orm/core/schemas/updateSchema.ts +1 -1
  262. package/src/orm/core/services/ModelBuilder.ts +1 -13
  263. package/src/orm/core/services/PgRelationManager.ts +4 -2
  264. package/src/orm/core/services/Repository.ts +1 -42
  265. package/src/orm/core/services/SqliteModelBuilder.ts +2 -33
  266. package/src/orm/postgres/services/PostgresModelBuilder.ts +10 -45
  267. package/src/react/core/__tests__/useQuery.browser.spec.tsx +86 -0
  268. package/src/react/core/hooks/useQuery.ts +153 -0
  269. package/src/react/core/index.ts +1 -0
  270. package/src/react/form/services/FormModel.ts +15 -6
  271. package/src/react/form/services/parseField.ts +8 -0
  272. package/src/react/i18n/providers/I18nProvider.ts +8 -2
  273. package/src/react/intro/components/GettingStartedAuthSlide.tsx +11 -4
  274. package/src/react/router/__tests__/$page.spec.tsx +0 -16
  275. package/src/react/router/__tests__/ReactBrowserProvider.browser.spec.ts +213 -2
  276. package/src/react/router/__tests__/ssr.spec.tsx +339 -0
  277. package/src/react/router/primitives/$page.ts +28 -4
  278. package/src/react/router/providers/ReactBrowserProvider.ts +73 -0
  279. package/src/react/router/providers/ReactBrowserRouterProvider.ts +1 -1
  280. package/src/react/router/providers/ReactPageProvider.ts +27 -9
  281. package/src/react/router/providers/ReactPreloadProvider.ts +1 -1
  282. package/src/react/router/providers/ReactServerProvider.ts +1 -0
  283. package/src/react/ui/atoms/uiThemeListAtom.ts +36 -0
  284. package/src/react/ui/index.ts +6 -0
  285. package/src/react/ui/services/SchemaControl.ts +209 -0
  286. package/src/scheduler/providers/CronProvider.ts +1 -1
  287. package/src/security/primitives/$basicAuth.ts +1 -1
  288. package/src/security/primitives/$issuer.ts +6 -3
  289. package/src/server/auth/providers/ServerAuthProvider.ts +5 -1
  290. package/src/server/core/__tests__/ServerRouterProvider-serializationError.spec.ts +75 -0
  291. package/src/server/core/__tests__/ServerRouterProvider-validationError.spec.ts +306 -0
  292. package/src/server/core/errors/ValidationError.ts +13 -1
  293. package/src/server/core/interfaces/ServerRequest.ts +1 -0
  294. package/src/server/core/primitives/$action.ts +16 -5
  295. package/src/server/core/providers/ServerProvider.ts +1 -1
  296. package/src/server/core/providers/ServerRouterProvider.ts +28 -6
  297. package/src/server/core/services/HttpClient.ts +1 -1
  298. package/src/server/swagger/providers/ServerSwaggerProvider.ts +6 -8
  299. package/src/websocket/providers/NodeWebSocketServerProvider.ts +10 -4
  300. package/src/websocket/services/WebSocketClient.ts +11 -5
  301. package/src/mcp/transports/SseMcpTransport.ts +0 -182
  302. package/src/orm/core/__tests__/parseQueryString.spec.ts +0 -196
  303. package/src/orm/core/helpers/parseQueryString.ts +0 -502
  304. package/src/orm/core/primitives/$view.ts +0 -88
@@ -472,7 +472,7 @@ describe("FileController", () => {
472
472
  expect(results.content.every((f) => f.creator === user1Id)).toBe(true);
473
473
  });
474
474
 
475
- it("should filter by date range", async () => {
475
+ it("should filter by date range", { retry: 3 }, async () => {
476
476
  const { service, ctrl, dtp } = await setup();
477
477
 
478
478
  const startTime = dtp.nowISOString();
@@ -232,7 +232,11 @@ describe("$job — queue mode (outbox)", () => {
232
232
  { payload: { n: 3 } },
233
233
  ]);
234
234
  expect(ids).toHaveLength(3);
235
- await new Promise((r) => setTimeout(r, 100));
235
+ // Poll: outbox dispatch is async, 100ms can be tight under CI load.
236
+ const deadline = Date.now() + 2000;
237
+ while (seen.length < 3 && Date.now() < deadline) {
238
+ await new Promise((r) => setTimeout(r, 25));
239
+ }
236
240
  expect(seen.sort()).toEqual([1, 2, 3]);
237
241
  });
238
242
 
@@ -3,10 +3,10 @@ import {
3
3
  $inject,
4
4
  Alepha,
5
5
  AlephaError,
6
+ SchemaValidator,
6
7
  type Static,
7
8
  type TObject,
8
9
  t,
9
- Value,
10
10
  } from "alepha";
11
11
  import { CryptoProvider } from "alepha/crypto";
12
12
  import { DateTimeProvider } from "alepha/datetime";
@@ -49,6 +49,7 @@ export class ParameterProvider {
49
49
  protected readonly dateTimeProvider = $inject(DateTimeProvider);
50
50
  protected readonly crypto = $inject(CryptoProvider);
51
51
  protected readonly lockProvider = $inject(LockProvider);
52
+ protected readonly schemaValidator = $inject(SchemaValidator);
52
53
  protected readonly repo = $repository(parameters);
53
54
 
54
55
  /**
@@ -757,7 +758,7 @@ export class ParameterProvider {
757
758
  if (param.options.migrate) {
758
759
  try {
759
760
  const migrated = param.options.migrate(dbValue);
760
- if (Value.Check(schema, migrated)) {
761
+ if (this.isValid(schema, migrated)) {
761
762
  if (JSON.stringify(migrated) === JSON.stringify(dbValue)) {
762
763
  return null;
763
764
  }
@@ -785,7 +786,7 @@ export class ParameterProvider {
785
786
  schemaKeys,
786
787
  );
787
788
 
788
- if (Value.Check(schema, stripped)) {
789
+ if (this.isValid(schema, stripped)) {
789
790
  if (JSON.stringify(stripped) === JSON.stringify(dbValue)) {
790
791
  return null;
791
792
  }
@@ -805,7 +806,7 @@ export class ParameterProvider {
805
806
  schemaKeys,
806
807
  );
807
808
 
808
- if (Value.Check(schema, merged)) {
809
+ if (this.isValid(schema, merged)) {
809
810
  return {
810
811
  value: merged,
811
812
  description: "Auto-migrated: merged with defaults",
@@ -857,6 +858,22 @@ export class ParameterProvider {
857
858
  }
858
859
  }
859
860
 
861
+ /**
862
+ * Probe whether a value matches the schema, without throwing or mutating.
863
+ */
864
+ protected isValid(schema: TObject, value: unknown): boolean {
865
+ try {
866
+ this.schemaValidator.validate(schema, value, {
867
+ trim: false,
868
+ nullToUndefined: false,
869
+ deleteUndefined: false,
870
+ });
871
+ return true;
872
+ } catch {
873
+ return false;
874
+ }
875
+ }
876
+
860
877
  /**
861
878
  * Return a new object containing only keys present in the schema.
862
879
  */
@@ -916,4 +916,103 @@ describe("alepha/api/users - SessionService.refreshSession", () => {
916
916
  sessionService.refreshSession(refreshToken),
917
917
  ).rejects.toThrowError();
918
918
  });
919
+
920
+ describe("refresh-token idle timeout", () => {
921
+ it("should refresh successfully when used within the idle window", async ({
922
+ expect,
923
+ }) => {
924
+ const { sessionService, userService, alepha, dateTimeProvider } =
925
+ await setup();
926
+
927
+ const realmProvider = alepha.inject(RealmProvider);
928
+ realmProvider.register("idle-ok", {
929
+ settings: {
930
+ refreshToken: { expirationIdle: 60 * 60 * 1000 }, // 1 hour
931
+ } as never,
932
+ });
933
+
934
+ const user = await userService.users("idle-ok").create({
935
+ realm: "idle-ok",
936
+ email: "idle-ok@example.com",
937
+ roles: ["user"],
938
+ });
939
+
940
+ const { refreshToken } = await sessionService.createSession(
941
+ user,
942
+ 24 * 3600,
943
+ "idle-ok",
944
+ );
945
+
946
+ // Half the idle window — still well inside.
947
+ await dateTimeProvider.travel(30, "minutes");
948
+
949
+ const result = await sessionService.refreshSession(
950
+ refreshToken,
951
+ "idle-ok",
952
+ );
953
+ expect(result.user.id).toBe(user.id);
954
+ });
955
+
956
+ it("should reject refresh and delete session when idle window is exceeded", async ({
957
+ expect,
958
+ }) => {
959
+ const { sessionService, userService, alepha, dateTimeProvider } =
960
+ await setup();
961
+
962
+ const realmProvider = alepha.inject(RealmProvider);
963
+ realmProvider.register("idle-strict", {
964
+ settings: {
965
+ refreshToken: { expirationIdle: 5 * 60 * 1000 }, // 5 minutes
966
+ } as never,
967
+ });
968
+
969
+ const user = await userService.users("idle-strict").create({
970
+ realm: "idle-strict",
971
+ email: "idle-strict@example.com",
972
+ roles: ["user"],
973
+ });
974
+
975
+ const { refreshToken } = await sessionService.createSession(
976
+ user,
977
+ 7 * 24 * 3600, // 7 day absolute expiry — well beyond idle threshold
978
+ "idle-strict",
979
+ );
980
+
981
+ // Idle past the threshold (but absolute expiry is still days away).
982
+ await dateTimeProvider.travel(10, "minutes");
983
+
984
+ await expect(
985
+ sessionService.refreshSession(refreshToken, "idle-strict"),
986
+ ).rejects.toThrowError(UnauthorizedError);
987
+
988
+ // Session deleted — second refresh fails too (row gone).
989
+ await expect(
990
+ sessionService.refreshSession(refreshToken, "idle-strict"),
991
+ ).rejects.toThrowError();
992
+ });
993
+
994
+ it("should not enforce idle when expirationIdle is undefined (default)", async ({
995
+ expect,
996
+ }) => {
997
+ const { sessionService, userService, dateTimeProvider } = await setup();
998
+
999
+ const user = await userService.users().create({
1000
+ username: "idle-defaultuser",
1001
+ email: "idle-default@example.com",
1002
+ roles: ["user"],
1003
+ });
1004
+
1005
+ const { refreshToken } = await sessionService.createSession(
1006
+ user,
1007
+ 7 * 24 * 3600,
1008
+ );
1009
+
1010
+ // Travel days into the future. With no idleTtl configured, refresh
1011
+ // should still succeed (only absolute expiresAt matters).
1012
+ await dateTimeProvider.travel(3, "days");
1013
+
1014
+ const result = await sessionService.refreshSession(refreshToken);
1015
+ expect(result.user.id).toBe(user.id);
1016
+ });
1017
+ });
919
1018
  });
@@ -5,7 +5,9 @@ import { AlephaOrmPostgres } from "alepha/orm/postgres";
5
5
  import { describe, test } from "vitest";
6
6
  import { sessions } from "../entities/sessions.ts";
7
7
  import { users } from "../entities/users.ts";
8
+ import { AlephaApiUsers } from "../index.ts";
8
9
  import { UserJobs } from "../jobs/UserJobs.ts";
10
+ import { RealmProvider } from "../providers/RealmProvider.ts";
9
11
 
10
12
  describe("UserJobs", () => {
11
13
  describe("purgeExpiredSessions", () => {
@@ -93,5 +95,70 @@ describe("UserJobs", () => {
93
95
 
94
96
  expect(await repos.sessionRepository.findMany()).toHaveLength(1);
95
97
  });
98
+
99
+ test("purges idle sessions when expirationIdle is configured", async ({
100
+ expect,
101
+ }) => {
102
+ const alepha = Alepha.create()
103
+ .with(AlephaOrmPostgres)
104
+ .with(AlephaApiJobs)
105
+ .with(AlephaApiUsers);
106
+
107
+ class TestRepositories {
108
+ userRepository = $repository(users);
109
+ sessionRepository = $repository(sessions);
110
+ }
111
+
112
+ const userJobs = alepha.inject(UserJobs);
113
+ const repos = alepha.inject(TestRepositories);
114
+ const realmProvider = alepha.inject(RealmProvider);
115
+ realmProvider.register("default", {
116
+ settings: {
117
+ refreshToken: { expirationIdle: 5 * 60 * 1000 }, // 5 minutes
118
+ } as never,
119
+ });
120
+ await alepha.start();
121
+
122
+ const user = await repos.userRepository.create({
123
+ email: "idle-sweep@example.com",
124
+ });
125
+
126
+ const farFuture = new Date(
127
+ Date.now() + 30 * 24 * 60 * 60 * 1000,
128
+ ).toISOString();
129
+ const oldUsed = new Date(Date.now() - 30 * 60 * 1000).toISOString(); // 30 min ago
130
+ const recentUsed = new Date(Date.now() - 60 * 1000).toISOString(); // 1 min ago
131
+
132
+ // Idle by lastUsedAt — should be purged (lastUsedAt > 5 min ago).
133
+ await repos.sessionRepository.create({
134
+ userId: user.id,
135
+ refreshToken: crypto.randomUUID(),
136
+ expiresAt: farFuture,
137
+ lastUsedAt: oldUsed,
138
+ });
139
+
140
+ // Recently used — within idle window, should remain.
141
+ await repos.sessionRepository.create({
142
+ userId: user.id,
143
+ refreshToken: crypto.randomUUID(),
144
+ expiresAt: farFuture,
145
+ lastUsedAt: recentUsed,
146
+ });
147
+
148
+ // Pre-migration row (lastUsedAt null) with old createdAt — should be
149
+ // purged via createdAt fallback. We can't directly set createdAt via
150
+ // create() (auto-managed), so simulate by creating a row and then
151
+ // updating lastUsedAt to null while the row is fresh — the createdAt
152
+ // fallback will not trip yet (createdAt is now). Skip this case in this
153
+ // test; covered logically by the deleteMany filter shape.
154
+
155
+ expect(await repos.sessionRepository.findMany()).toHaveLength(2);
156
+
157
+ await userJobs.purgeExpiredSessions.trigger();
158
+
159
+ const remaining = await repos.sessionRepository.findMany();
160
+ expect(remaining).toHaveLength(1);
161
+ expect(remaining[0].lastUsedAt).toBe(recentUsed);
162
+ });
96
163
  });
97
164
  });
@@ -121,6 +121,18 @@ export const realmAuthSettingsAtom = $atom({
121
121
  minimum: 1000,
122
122
  }),
123
123
  }),
124
+ refreshToken: t.object({
125
+ expirationIdle: t.optional(
126
+ t.integer({
127
+ description:
128
+ "Maximum time in milliseconds a refresh token may stay unused before being invalidated. " +
129
+ "When set, sessions whose last refresh is older than this window are rejected and deleted, " +
130
+ "even if the absolute `expiresAt` has not been reached. Recommended for SaaS auth posture " +
131
+ "(SOC2/ISO27001). Leave undefined to disable idle invalidation (default).",
132
+ minimum: 1000,
133
+ }),
134
+ ),
135
+ }),
124
136
  }),
125
137
  default: {
126
138
  // for a fresh hello world setup, we accept registration and email login
@@ -149,6 +161,9 @@ export const realmAuthSettingsAtom = $atom({
149
161
  accountMaxAttempts: 5,
150
162
  windowMs: 15 * 60 * 1000,
151
163
  },
164
+ refreshToken: {
165
+ // expirationIdle: undefined — opt-in
166
+ },
152
167
  },
153
168
  });
154
169
 
@@ -12,6 +12,12 @@ export const sessions = $entity({
12
12
  refreshToken: t.uuid(),
13
13
  userId: db.ref(t.uuid(), () => users.cols.id),
14
14
  expiresAt: t.datetime(),
15
+ /**
16
+ * Last time the session was used to refresh an access token.
17
+ * Used by realm `refreshToken.expirationIdle` to invalidate idle sessions.
18
+ * `null` on existing rows pre-migration — falls back to `createdAt`.
19
+ */
20
+ lastUsedAt: t.optional(t.datetime()),
15
21
  ip: t.optional(t.text()),
16
22
  userAgent: t.optional(
17
23
  t.object({
@@ -4,6 +4,7 @@ import { DateTimeProvider } from "alepha/datetime";
4
4
  import { $logger } from "alepha/logger";
5
5
  import { $repository } from "alepha/orm";
6
6
  import { sessions } from "../entities/sessions.ts";
7
+ import { RealmProvider } from "../providers/RealmProvider.ts";
7
8
 
8
9
  /**
9
10
  * User-specific jobs wrapper service.
@@ -20,11 +21,19 @@ export class UserJobs {
20
21
  protected readonly log = $logger();
21
22
  protected readonly dateTimeProvider = $inject(DateTimeProvider);
22
23
  protected readonly sessionRepository = $repository(sessions);
24
+ protected readonly realmProvider = $inject(RealmProvider);
23
25
 
24
26
  /**
25
27
  * Purge expired sessions from the database.
26
28
  *
27
- * Runs hourly (at :00) and deletes sessions whose `expiresAt` has passed.
29
+ * Runs hourly (at :00) and deletes:
30
+ * - sessions whose absolute `expiresAt` has passed
31
+ * - sessions whose `lastUsedAt` exceeds the realm's `refreshToken.expirationIdle`
32
+ * (when configured). Falls back to `createdAt` for sessions without a
33
+ * recorded `lastUsedAt`.
34
+ *
35
+ * The idle sweep is best-effort cleanup — runtime enforcement happens in
36
+ * `SessionService.refreshSession()`.
28
37
  */
29
38
  public readonly purgeExpiredSessions = $job({
30
39
  name: "api:users:purgeExpiredSessions",
@@ -34,28 +43,46 @@ export class UserJobs {
34
43
 
35
44
  this.log.info("Starting expired sessions purge", { cutoffTime: now });
36
45
 
37
- const expiredSessions = await this.sessionRepository.findMany({
38
- where: {
39
- expiresAt: { lt: now },
40
- },
46
+ const absoluteDeletedIds = await this.sessionRepository.deleteMany({
47
+ expiresAt: { lt: now },
41
48
  });
42
49
 
43
- if (expiredSessions.length === 0) {
44
- this.log.info("No expired sessions found");
45
- return;
50
+ if (absoluteDeletedIds.length > 0) {
51
+ this.log.info("Expired sessions purged (absolute)", {
52
+ deletedCount: absoluteDeletedIds.length,
53
+ });
46
54
  }
47
55
 
48
- this.log.info("Found expired sessions", {
49
- count: expiredSessions.length,
50
- });
56
+ // Idle sweep — only if the default realm has expirationIdle configured.
57
+ // Multi-realm setups with per-realm session tables should add their own
58
+ // job; this default job sweeps the default sessions table.
59
+ const realm = this.realmProvider.getRealm();
60
+ const settings = await realm.getSettings();
61
+ const idleMs = settings.refreshToken?.expirationIdle;
62
+ if (idleMs && idleMs > 0) {
63
+ const cutoff = this.dateTimeProvider
64
+ .now()
65
+ .subtract(idleMs, "milliseconds")
66
+ .toISOString();
51
67
 
52
- const deletedIds = await this.sessionRepository.deleteMany({
53
- expiresAt: { lt: now },
54
- });
68
+ // Two passes: rows with an explicit lastUsedAt, and pre-migration rows
69
+ // where lastUsedAt is null — those fall back to createdAt.
70
+ const lastUsedDeletedIds = await this.sessionRepository.deleteMany({
71
+ lastUsedAt: { lt: cutoff },
72
+ });
73
+ const fallbackDeletedIds = await this.sessionRepository.deleteMany({
74
+ lastUsedAt: { isNull: true },
75
+ createdAt: { lt: cutoff },
76
+ });
55
77
 
56
- this.log.info("Expired sessions purged successfully", {
57
- deletedCount: deletedIds.length,
58
- });
78
+ const idleTotal = lastUsedDeletedIds.length + fallbackDeletedIds.length;
79
+ if (idleTotal > 0) {
80
+ this.log.info("Expired sessions purged (idle)", {
81
+ deletedCount: idleTotal,
82
+ thresholdMs: idleMs,
83
+ });
84
+ }
85
+ }
59
86
  },
60
87
  });
61
88
  }
@@ -71,6 +71,10 @@ export class RealmProvider {
71
71
  ...realmAuthSettingsAtom.options.default.loginRateLimit,
72
72
  ...realmOptions.settings?.loginRateLimit,
73
73
  },
74
+ refreshToken: {
75
+ ...realmAuthSettingsAtom.options.default.refreshToken,
76
+ ...realmOptions.settings?.refreshToken,
77
+ },
74
78
  },
75
79
  features,
76
80
  getSettings: async function () {
@@ -7,7 +7,6 @@ export const userQuerySchema = t.extend(pageQuerySchema, {
7
7
  enabled: t.optional(t.boolean()),
8
8
  emailVerified: t.optional(t.boolean()),
9
9
  roles: t.optional(t.array(t.string())),
10
- query: t.optional(t.text()),
11
10
  });
12
11
 
13
12
  export type UserQuery = Static<typeof userQuerySchema>;
@@ -523,6 +523,7 @@ export class SessionService {
523
523
  const session = await this.sessions(userRealmName).create({
524
524
  userId: user.id,
525
525
  expiresAt,
526
+ lastUsedAt: this.dateTimeProvider.nowISOString(),
526
527
  ip: request?.ip,
527
528
  userAgent: request?.userAgent,
528
529
  refreshToken,
@@ -563,6 +564,27 @@ export class SessionService {
563
564
  throw new UnauthorizedError("Session expired");
564
565
  }
565
566
 
567
+ // Idle timeout check — opt-in via realm settings.
568
+ // Falls back to createdAt when lastUsedAt is null (pre-migration rows or
569
+ // sessions that never refreshed since the column was introduced).
570
+ const realm = this.realmProvider.getRealm(userRealmName);
571
+ const settings = await realm.getSettings();
572
+ const idleMs = settings.refreshToken?.expirationIdle;
573
+ if (idleMs && idleMs > 0) {
574
+ const lastUsedRef = session.lastUsedAt ?? session.createdAt;
575
+ const idleSince = now.diff(this.dateTimeProvider.of(lastUsedRef));
576
+ if (idleSince > idleMs) {
577
+ this.log.info("Session expired (idle timeout)", {
578
+ sessionId: session.id,
579
+ userId: session.userId,
580
+ idleMs: idleSince,
581
+ thresholdMs: idleMs,
582
+ });
583
+ await this.sessions(userRealmName).deleteById(session.id);
584
+ throw new UnauthorizedError("Session expired");
585
+ }
586
+ }
587
+
566
588
  const user = await this.users(userRealmName).getOne({
567
589
  where: {
568
590
  id: { eq: session.userId },
@@ -582,6 +604,11 @@ export class SessionService {
582
604
  // Auto-promote to admin if configured (handles "I promote you admin" case)
583
605
  await this.ensureAdminRole(user, userRealmName);
584
606
 
607
+ // Update lastUsedAt — sliding-window for idle timeout enforcement.
608
+ await this.sessions(userRealmName).updateById(session.id, {
609
+ lastUsedAt: now.toISOString(),
610
+ });
611
+
585
612
  this.log.debug("Session refreshed", {
586
613
  sessionId: session.id,
587
614
  userId: session.userId,
@@ -1,7 +1,7 @@
1
1
  import { $inject, Alepha } from "alepha";
2
2
  import type { VerificationController } from "alepha/api/verifications";
3
3
  import { $logger } from "alepha/logger";
4
- import { type Page, parseQueryString } from "alepha/orm";
4
+ import type { Page } from "alepha/orm";
5
5
  import { BadRequestError } from "alepha/server";
6
6
  import { $client } from "alepha/server/links";
7
7
  import { UserAudits } from "../audits/UserAudits.ts";
@@ -229,10 +229,6 @@ export class UserService {
229
229
  where.roles = { arrayContains: q.roles };
230
230
  }
231
231
 
232
- if (q.query) {
233
- Object.assign(where, parseQueryString(q.query));
234
- }
235
-
236
232
  const result = await this.users(userRealmName).paginate(
237
233
  q,
238
234
  { where },
@@ -198,6 +198,20 @@ describe("Code Verification", () => {
198
198
  const { parameters, controller, dateTimeProvider, target } =
199
199
  await createTest();
200
200
 
201
+ // Anchor test time at noon to keep all `limitPerDay` inserts inside the
202
+ // same calendar day — running near midnight (real wall-clock) used to
203
+ // make the cooldown travel cross day boundaries and reset the window.
204
+ const now = dateTimeProvider.now();
205
+ const noon = now.startOf("day").add(12, "hours");
206
+ if (noon.diff(now) > 0) {
207
+ await dateTimeProvider.travel(noon.diff(now), "milliseconds");
208
+ } else {
209
+ await dateTimeProvider.travel(
210
+ noon.add(1, "day").diff(now),
211
+ "milliseconds",
212
+ );
213
+ }
214
+
201
215
  for (let i = 0; i < parameters.limitPerDay; i++) {
202
216
  await controller.requestVerificationCode({
203
217
  params: {
@@ -198,6 +198,20 @@ describe("Link Verification", () => {
198
198
  const { parameters, controller, dateTimeProvider, target } =
199
199
  await createTest();
200
200
 
201
+ // Anchor test time at noon to keep all `limitPerDay` inserts inside the
202
+ // same calendar day — running near midnight (real wall-clock) used to
203
+ // make the cooldown travel cross day boundaries and reset the window.
204
+ const now = dateTimeProvider.now();
205
+ const noon = now.startOf("day").add(12, "hours");
206
+ if (noon.diff(now) > 0) {
207
+ await dateTimeProvider.travel(noon.diff(now), "milliseconds");
208
+ } else {
209
+ await dateTimeProvider.travel(
210
+ noon.add(1, "day").diff(now),
211
+ "milliseconds",
212
+ );
213
+ }
214
+
201
215
  for (let i = 0; i < parameters.limitPerDay; i++) {
202
216
  await controller.requestVerificationCode({
203
217
  params: {
@@ -134,6 +134,7 @@ export class VerificationService {
134
134
  type: entry.type,
135
135
  target: entry.target,
136
136
  code: this.hashCode(token),
137
+ createdAt: this.dateTimeProvider.nowISOString(),
137
138
  });
138
139
 
139
140
  this.log.info("Verification created", {
@@ -0,0 +1,74 @@
1
+ import { Alepha } from "alepha";
2
+ import { describe, test } from "vitest";
3
+ import {
4
+ AlephaBucket,
5
+ FileStorageProvider,
6
+ NodeS3BucketProvider,
7
+ } from "../index.ts";
8
+ import {
9
+ TestApp,
10
+ testCustomFileId,
11
+ testDeleteFile,
12
+ testDeleteNonExistentFile,
13
+ testDownloadAndMetadata,
14
+ testEmptyFiles,
15
+ testFileExistence,
16
+ testFileStream,
17
+ testNonExistentFile,
18
+ testNonExistentFileError,
19
+ testUploadAndExistence,
20
+ testUploadIntoBuckets,
21
+ } from "./shared.ts";
22
+
23
+ const alepha = Alepha.create()
24
+ .with({ provide: FileStorageProvider, use: NodeS3BucketProvider })
25
+ .with(AlephaBucket)
26
+ .with(TestApp);
27
+
28
+ const provider = alepha.inject(NodeS3BucketProvider);
29
+
30
+ describe("NodeS3BucketProvider", () => {
31
+ test("should upload a file and return a fileId", async () => {
32
+ await testUploadAndExistence(provider);
33
+ });
34
+
35
+ test("should download a file and restore its metadata", async () => {
36
+ await testDownloadAndMetadata(provider);
37
+ });
38
+
39
+ test("exists() should return false for a non-existent file", async () => {
40
+ await testNonExistentFile(provider);
41
+ });
42
+
43
+ test("exists() should return true for an existing file", async () => {
44
+ await testFileExistence(provider);
45
+ });
46
+
47
+ test("should delete a file", async () => {
48
+ await testDeleteFile(provider);
49
+ });
50
+
51
+ test("delete() should not throw for a non-existent file", async () => {
52
+ await testDeleteNonExistentFile(provider);
53
+ });
54
+
55
+ test("download() should throw FileNotFoundError for a non-existent file", async () => {
56
+ await testNonExistentFileError(provider);
57
+ });
58
+
59
+ test("should handle uploading to different buckets", async () => {
60
+ await testUploadIntoBuckets(provider);
61
+ });
62
+
63
+ test("should handle empty files correctly", async () => {
64
+ await testEmptyFiles(provider);
65
+ });
66
+
67
+ test("should be able to upload with a specific fileId", async () => {
68
+ await testCustomFileId(provider);
69
+ });
70
+
71
+ test("should be able to upload, stream with metadata", async () => {
72
+ await testFileStream(provider);
73
+ });
74
+ });