@podosoft/podokit 1.1.6 → 1.3.0

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 (203) hide show
  1. package/dist/add.d.ts +3 -1
  2. package/dist/add.js +116 -14
  3. package/dist/deploy-render.js +5 -0
  4. package/dist/index.js +8 -0
  5. package/dist/lib.d.ts +2 -2
  6. package/dist/lib.js +3 -2
  7. package/dist/lockfile.d.ts +3 -1
  8. package/dist/lockfile.js +17 -6
  9. package/dist/templates/fullstack/apps/api/package.json +2 -2
  10. package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
  11. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  12. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  13. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  14. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
  15. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  16. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  17. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  18. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  19. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  20. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  21. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  22. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  23. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  24. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  25. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  26. package/dist/templates/fullstack/apps/web/package.json +1 -1
  27. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  28. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  29. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  30. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  31. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  32. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
  33. package/dist/templates/fullstack/dot-env.docker +3 -0
  34. package/dist/templates/fullstack/dot-env.example +14 -0
  35. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  36. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  37. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  47. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +22 -28
  50. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  53. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  54. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  57. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +102 -0
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +141 -0
  66. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  67. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  68. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  69. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  70. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  75. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  76. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  77. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  78. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  79. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  80. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  81. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  82. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  83. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  85. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  86. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +79 -37
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  88. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  89. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  90. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  91. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  92. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  93. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  94. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  95. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  96. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +35 -1
  97. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  98. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  99. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  100. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -2
  101. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  102. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  103. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  104. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  105. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +16 -5
  106. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +45 -0
  107. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  108. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +29 -0
  109. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  110. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +32 -1
  111. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  112. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  113. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  114. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  115. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +126 -3
  116. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  117. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  118. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  119. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  120. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  121. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +57 -0
  122. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  123. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  124. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  125. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  126. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  127. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  128. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  129. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  130. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  131. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  132. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  133. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  134. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  135. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  136. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  137. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  138. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  139. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +21 -37
  140. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  141. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  142. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  143. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  144. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  145. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  146. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  147. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  148. package/dist/templates/modules/auth/module.manifest.json +2 -2
  149. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  150. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  151. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  152. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  153. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  154. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  155. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  156. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  157. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  158. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  159. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  160. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  161. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  162. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  163. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  164. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  165. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  166. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  167. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  168. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  169. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  170. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  171. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  172. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  173. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  174. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  175. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  176. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  177. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  178. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  179. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  180. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  181. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  182. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  183. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  184. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  185. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  186. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  187. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  188. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  189. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  190. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  191. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  192. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  193. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +250 -0
  194. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +121 -15
  195. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +407 -18
  196. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +153 -40
  197. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  198. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  199. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +227 -70
  200. package/dist/templates/modules/sse/module.manifest.json +20 -5
  201. package/dist/update.d.ts +17 -0
  202. package/dist/update.js +33 -8
  203. package/package.json +2 -2
@@ -0,0 +1,76 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { AppException } from "@podosoft/podokit-contracts";
4
+ import { Elysia } from "elysia";
5
+ import type { AuthService, AuthSession } from "../auth/auth.service";
6
+ import { adminUserRoutes } from "./admin-users.routes";
7
+ import { AdminUsersService } from "./admin-users.service";
8
+
9
+ // Callers identify themselves with a test header; only "admin" passes.
10
+ const auth: Pick<AuthService, "requireAdmin"> = {
11
+ requireAdmin: async (request: Request): Promise<AuthSession> => {
12
+ const role = request.headers.get("x-test-role");
13
+ if (!role) throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
14
+ if (role !== "admin") throw new AppException("ADMIN_REQUIRED", "Administrator access is required", 403);
15
+ return { user: { id: "admin-1", role: "admin" }, session: {} };
16
+ },
17
+ };
18
+
19
+ describe("admin user routes", () => {
20
+ let db: SQL;
21
+ let handle: (path: string, role?: string) => Promise<Response>;
22
+
23
+ beforeEach(async () => {
24
+ db = new SQL(":memory:");
25
+ await db`
26
+ CREATE TABLE "user" (
27
+ "id" TEXT PRIMARY KEY, "name" TEXT NOT NULL, "email" TEXT NOT NULL,
28
+ "emailVerified" INTEGER NOT NULL, "createdAt" DATE NOT NULL, "role" TEXT,
29
+ "banned" INTEGER, "banReason" TEXT, "banExpires" DATE, "signupApproved" INTEGER
30
+ )
31
+ `;
32
+ await db`
33
+ INSERT INTO "user" VALUES
34
+ ('u1', 'Ada', 'ada@example.com', 1, '2026-01-01T00:00:01.000Z', 'admin', 0, NULL, NULL, 1),
35
+ ('u2', 'Bob', 'bob@example.com', 1, '2026-01-01T00:00:02.000Z', 'admin', 1, 'spam', NULL, 1)
36
+ `;
37
+ const app = new Elysia()
38
+ .onError(({ code, error, set }) => {
39
+ if (error instanceof AppException) {
40
+ set.status = error.statusCode;
41
+ return { code: error.code };
42
+ }
43
+ if (code === "VALIDATION") {
44
+ set.status = 400;
45
+ return { code: "VALIDATION_ERROR" };
46
+ }
47
+ return undefined;
48
+ })
49
+ .use(adminUserRoutes({ auth, users: new AdminUsersService(db, "sqlite") }));
50
+ handle = (path, role) =>
51
+ app.handle(new Request(`http://localhost${path}`, { headers: role ? { "x-test-role": role } : {} }));
52
+ });
53
+
54
+ afterEach(async () => {
55
+ await db.close();
56
+ });
57
+
58
+ test("requires an administrator", async () => {
59
+ expect((await handle("/admin/users")).status).toBe(401);
60
+ expect((await handle("/admin/users", "user")).status).toBe(403);
61
+ });
62
+
63
+ test("applies role and status together", async () => {
64
+ const response = await handle("/admin/users?role=admin&status=active&limit=10&offset=0", "admin");
65
+ expect(response.status).toBe(200);
66
+ const page = await response.json() as { items: Array<{ id: string; banned: boolean }>; total: number };
67
+ expect(page.items).toEqual([expect.objectContaining({ id: "u1", banned: false })]);
68
+ expect(page.total).toBe(1);
69
+ });
70
+
71
+ test("rejects unknown sort fields, statuses, and out-of-range limits", async () => {
72
+ for (const query of ["sortBy=password", "status=deleted", "sortDirection=up", "limit=0", "limit=101", "offset=-1", "searchField=role"]) {
73
+ expect((await handle(`/admin/users?${query}`, "admin")).status, query).toBe(400);
74
+ }
75
+ });
76
+ });
@@ -0,0 +1,41 @@
1
+ import { Elysia, t } from "elysia";
2
+ import type { AuthService } from "../auth/auth.service";
3
+ import type { ElysiaPlugin } from "../core/services";
4
+ import {
5
+ ADMIN_USER_SORT_FIELDS,
6
+ ADMIN_USER_STATUSES,
7
+ ADMIN_USERS_MAX_LIMIT,
8
+ type AdminUsersService,
9
+ } from "./admin-users.service";
10
+
11
+ export interface AdminUserRoutesOptions {
12
+ auth: Pick<AuthService, "requireAdmin">;
13
+ users: AdminUsersService;
14
+ }
15
+
16
+ export function adminUserRoutes({ auth, users }: AdminUserRoutesOptions): ElysiaPlugin {
17
+ return new Elysia({ name: "podokit.admin.users" })
18
+ .get("/admin/users", async ({ request, query }) => {
19
+ await auth.requireAdmin(request);
20
+ return users.list(query);
21
+ }, {
22
+ query: t.Object({
23
+ limit: t.Optional(t.Numeric({ minimum: 1, maximum: ADMIN_USERS_MAX_LIMIT })),
24
+ offset: t.Optional(t.Numeric({ minimum: 0 })),
25
+ search: t.Optional(t.String({ maxLength: 200 })),
26
+ searchField: t.Optional(t.Union([t.Literal("email"), t.Literal("name")])),
27
+ role: t.Optional(t.String({ minLength: 1, maxLength: 64 })),
28
+ status: t.Optional(t.Union(ADMIN_USER_STATUSES.map((status) => t.Literal(status)))),
29
+ sortBy: t.Optional(t.Union(ADMIN_USER_SORT_FIELDS.map((field) => t.Literal(field)))),
30
+ sortDirection: t.Optional(t.Union([t.Literal("asc"), t.Literal("desc")])),
31
+ }),
32
+ detail: {
33
+ tags: ["admin"],
34
+ summary: "List users",
35
+ description:
36
+ "Administrators only. Offset-paged with `limit` and `offset`; newest first unless " +
37
+ "`sortBy`/`sortDirection` say otherwise. `search` matches `searchField` (email by " +
38
+ "default) case-insensitively. `role` and `status` (active, pending, banned) combine.",
39
+ },
40
+ });
41
+ }
@@ -0,0 +1,124 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { AdminUsersService, type AdminUserQuery } from "./admin-users.service";
4
+
5
+ interface SeedUser {
6
+ id: string;
7
+ name: string;
8
+ email: string;
9
+ role?: string | null;
10
+ banned?: boolean | null;
11
+ signupApproved?: boolean | null;
12
+ emailVerified?: boolean;
13
+ createdAt: string;
14
+ }
15
+
16
+ // Better Auth stores booleans as 0 or 1 and dates as ISO text on SQLite.
17
+ async function createSchema(db: SQL): Promise<void> {
18
+ await db`
19
+ CREATE TABLE "user" (
20
+ "id" TEXT PRIMARY KEY, "name" TEXT NOT NULL, "email" TEXT NOT NULL,
21
+ "emailVerified" INTEGER NOT NULL, "image" TEXT, "createdAt" DATE NOT NULL,
22
+ "updatedAt" DATE NOT NULL, "role" TEXT, "banned" INTEGER, "banReason" TEXT,
23
+ "banExpires" DATE, "signupApproved" INTEGER
24
+ )
25
+ `;
26
+ }
27
+
28
+ function flag(value: boolean | null | undefined): number | null {
29
+ return value === undefined || value === null ? null : value ? 1 : 0;
30
+ }
31
+
32
+ async function seed(db: SQL, users: SeedUser[]): Promise<void> {
33
+ for (const user of users) {
34
+ await db`
35
+ INSERT INTO "user" ("id", "name", "email", "emailVerified", "createdAt", "updatedAt",
36
+ "role", "banned", "banReason", "signupApproved")
37
+ VALUES (${user.id}, ${user.name}, ${user.email}, ${user.emailVerified ? 1 : 0},
38
+ ${user.createdAt}, ${user.createdAt}, ${user.role ?? null}, ${flag(user.banned)},
39
+ ${user.banned ? "spam" : null}, ${flag(user.signupApproved)})
40
+ `;
41
+ }
42
+ }
43
+
44
+ const at = (minute: number): string => `2026-01-01T00:${String(minute).padStart(2, "0")}:00.000Z`;
45
+
46
+ const USERS: SeedUser[] = [
47
+ { id: "u1", name: "Ada Admin", email: "ada@example.com", role: "admin", signupApproved: true, createdAt: at(1) },
48
+ { id: "u2", name: "Bob", email: "bob@example.com", role: "user", signupApproved: true, createdAt: at(2) },
49
+ { id: "u3", name: "Cy Banned", email: "cy@example.com", role: "user", banned: true, signupApproved: true, createdAt: at(3) },
50
+ { id: "u4", name: "Di Pending", email: "di@example.com", role: "user", signupApproved: false, createdAt: at(4) },
51
+ { id: "u5", name: "Eve Banned Admin", email: "eve@example.com", role: "admin,user", banned: true, createdAt: at(5) },
52
+ { id: "u6", name: "Fay Legacy", email: "fay@example.com", role: null, banned: null, signupApproved: null, createdAt: at(6) },
53
+ ];
54
+
55
+ describe("AdminUsersService", () => {
56
+ let db: SQL;
57
+ let service: AdminUsersService;
58
+
59
+ beforeEach(async () => {
60
+ db = new SQL(":memory:");
61
+ await createSchema(db);
62
+ await seed(db, USERS);
63
+ service = new AdminUsersService(db, "sqlite");
64
+ });
65
+
66
+ afterEach(async () => {
67
+ await db.close();
68
+ });
69
+
70
+ const ids = async (query: AdminUserQuery): Promise<string[]> =>
71
+ (await service.list(query)).items.map((user) => user.id);
72
+
73
+ test("pages newest first by default with a total", async () => {
74
+ const page = await service.list({ limit: 2, offset: 2 });
75
+ expect(page.items.map((user) => user.id)).toEqual(["u4", "u3"]);
76
+ expect(page.total).toBe(6);
77
+ });
78
+
79
+ test("combines the role and status filters", async () => {
80
+ expect(await ids({ role: "admin", status: "banned" })).toEqual(["u5"]);
81
+ expect(await ids({ role: "admin", status: "active" })).toEqual(["u1"]);
82
+ expect(await ids({ role: "user", status: "pending" })).toEqual(["u4"]);
83
+ expect((await service.list({ role: "user", status: "active" })).total).toBe(1);
84
+ });
85
+
86
+ test("treats unset approval and ban flags as an active user", async () => {
87
+ expect(await ids({ status: "active" })).toEqual(["u6", "u2", "u1"]);
88
+ expect(await ids({ status: "banned" })).toEqual(["u5", "u3"]);
89
+ expect(await ids({ status: "pending" })).toEqual(["u4"]);
90
+ });
91
+
92
+ test("matches one role in a comma-separated role list", async () => {
93
+ expect(await ids({ role: "user" })).toEqual(["u5", "u4", "u3", "u2"]);
94
+ expect(await ids({ role: "use" })).toEqual([]);
95
+ });
96
+
97
+ test("searches email or name case-insensitively with literal wildcards", async () => {
98
+ expect(await ids({ search: "ADA@EXAMPLE", searchField: "email" })).toEqual(["u1"]);
99
+ expect(await ids({ search: "banned", searchField: "name" })).toEqual(["u5", "u3"]);
100
+ expect(await ids({ search: "%", searchField: "name" })).toEqual([]);
101
+ expect(await ids({ search: "banned", searchField: "name", status: "banned", role: "admin" })).toEqual(["u5"]);
102
+ });
103
+
104
+ test("sorts by an allowed column with the id as a tiebreaker", async () => {
105
+ expect(await ids({ sortBy: "name", sortDirection: "asc", limit: 3 })).toEqual(["u1", "u2", "u3"]);
106
+ expect(await ids({ sortBy: "email", sortDirection: "desc", limit: 2 })).toEqual(["u6", "u5"]);
107
+ });
108
+
109
+ test("returns booleans and ISO dates without exposing other columns", async () => {
110
+ const [pending] = (await service.list({ status: "pending" })).items;
111
+ expect(pending).toEqual({
112
+ id: "u4",
113
+ name: "Di Pending",
114
+ email: "di@example.com",
115
+ role: "user",
116
+ emailVerified: false,
117
+ signupApproved: false,
118
+ banned: false,
119
+ banReason: null,
120
+ banExpires: null,
121
+ createdAt: at(4),
122
+ });
123
+ });
124
+ });
@@ -0,0 +1,153 @@
1
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
2
+ import type { SQL } from "bun";
3
+
4
+ export const ADMIN_USERS_DEFAULT_LIMIT = 15;
5
+ export const ADMIN_USERS_MAX_LIMIT = 100;
6
+ export const ADMIN_USER_SORT_FIELDS = ["createdAt", "name", "email", "role"] as const;
7
+ export const ADMIN_USER_STATUSES = ["active", "pending", "banned"] as const;
8
+
9
+ export type AdminUserSortField = (typeof ADMIN_USER_SORT_FIELDS)[number];
10
+ export type AdminUserStatus = (typeof ADMIN_USER_STATUSES)[number];
11
+
12
+ /** A user as the admin list shows it. Credentials and sessions stay on the server. */
13
+ export interface AdminUserView {
14
+ id: string;
15
+ name: string;
16
+ email: string;
17
+ role: string | null;
18
+ emailVerified: boolean;
19
+ signupApproved: boolean;
20
+ banned: boolean;
21
+ banReason: string | null;
22
+ banExpires: string | null;
23
+ createdAt: string;
24
+ }
25
+
26
+ export interface AdminUserPage {
27
+ items: AdminUserView[];
28
+ total: number;
29
+ }
30
+
31
+ export interface AdminUserQuery {
32
+ limit?: number;
33
+ offset?: number;
34
+ search?: string;
35
+ searchField?: "email" | "name";
36
+ role?: string;
37
+ status?: AdminUserStatus;
38
+ sortBy?: AdminUserSortField;
39
+ sortDirection?: "asc" | "desc";
40
+ }
41
+
42
+ type Flag = boolean | number | null;
43
+
44
+ interface UserRow {
45
+ id: string;
46
+ name: string;
47
+ email: string;
48
+ role: string | null;
49
+ emailVerified: Flag;
50
+ signupApproved: Flag;
51
+ banned: Flag;
52
+ banReason: string | null;
53
+ banExpires: Date | string | null;
54
+ createdAt: Date | string;
55
+ }
56
+
57
+ // SQLite stores booleans as 0 or 1. An unset approval predates sign-up approval.
58
+ function flag(value: Flag, unset: boolean): boolean {
59
+ return value === null ? unset : value === true || value === 1;
60
+ }
61
+
62
+ function iso(value: Date | string): string {
63
+ return (value instanceof Date ? value : new Date(value)).toISOString();
64
+ }
65
+
66
+ function likePattern(search: string): string {
67
+ return `%${search.replace(/[\\%_]/g, (character) => `\\${character}`)}%`;
68
+ }
69
+
70
+ /**
71
+ * Lists users for the admin console with every filter applied in one query.
72
+ * Better Auth `listUsers` accepts a single filter, so role and status could not
73
+ * combine there. Mutations stay on Better Auth so its permission checks and the
74
+ * audit hooks still run.
75
+ */
76
+ export class AdminUsersService {
77
+ constructor(
78
+ private readonly sql: SQL,
79
+ private readonly provider: DatabaseProviderName,
80
+ ) {}
81
+
82
+ async list(query: AdminUserQuery = {}): Promise<AdminUserPage> {
83
+ const limit = Math.min(Math.max(query.limit ?? ADMIN_USERS_DEFAULT_LIMIT, 1), ADMIN_USERS_MAX_LIMIT);
84
+ const offset = Math.max(query.offset ?? 0, 0);
85
+ const search = query.search?.trim() ?? "";
86
+ const filters = (): SQL.Query<unknown> => this.sql`
87
+ WHERE 1 = 1
88
+ ${search ? this.searchFilter(query.searchField ?? "email", likePattern(search)) : this.sql``}
89
+ ${query.role ? this.sql`AND (',' || u."role" || ',') LIKE ${`%,${query.role},%`}` : this.sql``}
90
+ ${query.status ? this.statusFilter(query.status) : this.sql``}
91
+ `;
92
+ const [rows, totals] = await Promise.all([
93
+ this.sql<UserRow[]>`
94
+ SELECT u."id", u."name", u."email", u."role", u."emailVerified", u."signupApproved",
95
+ u."banned", u."banReason", u."banExpires", u."createdAt"
96
+ FROM "user" u
97
+ ${filters()}
98
+ ${this.order(query.sortBy ?? "createdAt", query.sortDirection ?? "desc")}
99
+ LIMIT ${limit} OFFSET ${offset}
100
+ `,
101
+ this.sql<Array<{ total: number | string | bigint }>>`
102
+ SELECT COUNT(*) AS "total" FROM "user" u ${filters()}
103
+ `,
104
+ ]);
105
+ return {
106
+ items: rows.map((row) => ({
107
+ id: row.id,
108
+ name: row.name,
109
+ email: row.email,
110
+ role: row.role,
111
+ emailVerified: flag(row.emailVerified, false),
112
+ signupApproved: flag(row.signupApproved, true),
113
+ banned: flag(row.banned, false),
114
+ banReason: row.banReason,
115
+ banExpires: row.banExpires === null ? null : iso(row.banExpires),
116
+ createdAt: iso(row.createdAt),
117
+ })),
118
+ total: Number(totals[0]?.total ?? 0),
119
+ };
120
+ }
121
+
122
+ private searchFilter(field: "email" | "name", pattern: string): SQL.Query<unknown> {
123
+ const column = field === "name" ? this.sql`u."name"` : this.sql`u."email"`;
124
+ // SQLite LIKE ignores ASCII case; PostgreSQL needs ILIKE.
125
+ return this.provider === "sqlite"
126
+ ? this.sql`AND ${column} LIKE ${pattern} ESCAPE '\\'`
127
+ : this.sql`AND ${column} ILIKE ${pattern} ESCAPE '\\'`;
128
+ }
129
+
130
+ private statusFilter(status: AdminUserStatus): SQL.Query<unknown> {
131
+ switch (status) {
132
+ case "pending":
133
+ return this.sql`AND u."signupApproved" = FALSE`;
134
+ case "banned":
135
+ return this.sql`AND u."banned" = TRUE`;
136
+ case "active":
137
+ return this.sql`AND COALESCE(u."banned", FALSE) = FALSE AND COALESCE(u."signupApproved", TRUE) = TRUE`;
138
+ }
139
+ }
140
+
141
+ private order(field: AdminUserSortField, direction: "asc" | "desc"): SQL.Query<unknown> {
142
+ const column = {
143
+ createdAt: this.sql`u."createdAt"`,
144
+ name: this.sql`u."name"`,
145
+ email: this.sql`u."email"`,
146
+ role: this.sql`u."role"`,
147
+ }[field];
148
+ // The id breaks ties so offset pages neither skip nor repeat users.
149
+ return direction === "asc"
150
+ ? this.sql`ORDER BY ${column} ASC, u."id" ASC`
151
+ : this.sql`ORDER BY ${column} DESC, u."id" DESC`;
152
+ }
153
+ }
@@ -5,7 +5,13 @@ import {
5
5
  } from "@podosoft/podokit-contracts";
6
6
  import { OBJECT_STORAGE } from "@podosoft/podokit-runtime";
7
7
  import { Elysia, t } from "elysia";
8
+ import { adminSessionRoutes } from "./admin-sessions/admin-sessions.routes";
9
+ import { createSessionRevoker, type RevokeUserSessionApi } from "./admin-sessions/admin-sessions.revoker";
10
+ import { AdminSessionsService } from "./admin-sessions/admin-sessions.service";
11
+ import { adminUserRoutes } from "./admin-users/admin-users.routes";
12
+ import { AdminUsersService } from "./admin-users/admin-users.service";
8
13
  import { AUTH } from "./auth/auth.module";
14
+ import { getAuth } from "./auth/auth-provider";
9
15
  import {
10
16
  ACCESS_POLICY,
11
17
  DATABASE,
@@ -16,6 +22,7 @@ import {
16
22
  } from "./core/services";
17
23
  import { ProfileImageService } from "./profile-image/profile-image.service";
18
24
  import { PROFILE_IMAGE_POLICY } from "@podosoft/podokit-contracts";
25
+ import { siteAssetRoutes } from "./site-assets/site-assets.routes";
19
26
  import {
20
27
  PUBLIC_SITE_KEYS,
21
28
  SiteSettingsService,
@@ -24,6 +31,8 @@ import { validateSiteSetting } from "./site-settings/site-settings.validation";
24
31
 
25
32
  export const SITE_SETTINGS = Symbol("site-settings") as ServiceKey<SiteSettingsService>;
26
33
  export const PROFILE_IMAGES = Symbol("profile-images") as ServiceKey<ProfileImageService>;
34
+ export const ADMIN_SESSIONS = Symbol("admin-sessions") as ServiceKey<AdminSessionsService>;
35
+ export const ADMIN_USERS = Symbol("admin-users") as ServiceKey<AdminUsersService>;
27
36
 
28
37
  const ICON_TYPES = new Set([
29
38
  "image/svg+xml",
@@ -37,25 +46,26 @@ function currentImage(user: Record<string, unknown>): string | null {
37
46
  return typeof user.image === "string" ? user.image : null;
38
47
  }
39
48
 
40
- function responseBody(buffer: Buffer): ArrayBuffer {
41
- return buffer.buffer.slice(
42
- buffer.byteOffset,
43
- buffer.byteOffset + buffer.byteLength,
44
- ) as ArrayBuffer;
45
- }
46
-
47
49
  const adminPlugin: AppPlugin = ({ services }) => {
48
50
  const auth = services.resolve(AUTH);
49
51
  const site = services.resolve(SITE_SETTINGS);
50
52
  const profileImages = services.resolve(PROFILE_IMAGES);
51
53
 
52
54
  return new Elysia({ name: "podokit.admin" })
55
+ .use(adminSessionRoutes({
56
+ auth,
57
+ sessions: services.resolve(ADMIN_SESSIONS),
58
+ revoke: createSessionRevoker(() => getAuth().api as unknown as RevokeUserSessionApi),
59
+ }))
60
+ .use(adminUserRoutes({ auth, users: services.resolve(ADMIN_USERS) }))
61
+ .use(siteAssetRoutes(site, profileImages))
53
62
  .get("/site/settings", async () => {
63
+ // The favicon fields are site.* rows too, so one read serves the response.
54
64
  const all = await site.getAll();
55
65
  const response: Record<string, string | boolean | null> = {};
56
66
  for (const key of PUBLIC_SITE_KEYS) response[key] = all[key] ?? null;
57
- response.hasFavicon = (await site.get("faviconContentType")) !== null;
58
- response.faviconVersion = await site.faviconVersion();
67
+ response.hasFavicon = all.faviconContentType !== undefined;
68
+ response.faviconVersion = all.faviconUpdatedAt ?? null;
59
69
  return response;
60
70
  }, {
61
71
  detail: { tags: ["site"], summary: "Get public site settings" },
@@ -87,15 +97,6 @@ const adminPlugin: AppPlugin = ({ services }) => {
87
97
  body: t.Object({ file: t.File() }),
88
98
  detail: { tags: ["site"], summary: "Upload the site favicon" },
89
99
  })
90
- .get("/site/favicon", async () => {
91
- const icon = await site.getFavicon();
92
- if (!icon) throw new AppException("FAVICON_NOT_FOUND", "No favicon set", 404);
93
- return new Response(responseBody(icon.body), {
94
- headers: { "content-type": icon.contentType },
95
- });
96
- }, {
97
- detail: { tags: ["site"], summary: "Get the site favicon" },
98
- })
99
100
  .post("/account/profile-image", async ({ request, body, set }) => {
100
101
  const session = await auth.requireSession(request);
101
102
  const file = body.file;
@@ -122,22 +123,15 @@ const adminPlugin: AppPlugin = ({ services }) => {
122
123
  return profileImages.remove(currentImage(session.user), request.headers);
123
124
  }, {
124
125
  detail: { tags: ["account"], summary: "Remove the current user's profile image" },
125
- })
126
- .get("/profile-images/:fileName", async ({ params, set }) => {
127
- const image = await profileImages.get(params.fileName);
128
- set.headers["cache-control"] = "public, max-age=31536000, immutable";
129
- return new Response(responseBody(image.body), {
130
- headers: { "content-type": image.contentType },
131
- });
132
- }, {
133
- params: t.Object({ fileName: t.String({ minLength: 1 }) }),
134
- detail: { tags: ["account"], summary: "Get a public profile image" },
135
126
  });
136
127
  };
137
128
 
138
129
  export const adminModule: PodokitModule = {
139
130
  name: "admin-dashboard",
140
131
  configure: (_env, services): void => {
132
+ const database = services.resolve(DATABASE);
133
+ services.register(ADMIN_SESSIONS, new AdminSessionsService(database.sql, database.provider));
134
+ services.register(ADMIN_USERS, new AdminUsersService(database.sql, database.provider));
141
135
  const storage = services.resolve(OBJECT_STORAGE);
142
136
  const site = new SiteSettingsService(services.resolve(DATABASE).sql, storage);
143
137
  const profileImages = new ProfileImageService(storage, services.resolve(LOGGER));
@@ -0,0 +1,60 @@
1
+ // Bundled entry for scripts/bootstrap-admin.mjs. The build bundles only its
2
+ // top-level targets, so the script cannot import auth modules from dist directly.
3
+ // The command runs against a migrated schema and reports a missing table from its
4
+ // own query. Better Auth's asynchronous startup check would only race the
5
+ // connection the command closes when it finishes.
6
+ import "./auth/skip-schema-validation";
7
+ import { verifyPassword } from "better-auth/crypto";
8
+ import { auth } from "./auth/auth";
9
+ import { closeAuthDatabase, postgresPool, sqliteDatabase } from "./auth/db";
10
+
11
+ export interface BootstrapUser {
12
+ id: string;
13
+ email: string;
14
+ role: string | null;
15
+ emailVerified: boolean;
16
+ signupApproved: boolean;
17
+ passwordHash: string | null;
18
+ }
19
+
20
+ interface BootstrapUserRow extends Omit<BootstrapUser, "emailVerified" | "signupApproved"> {
21
+ // SQLite stores booleans as 0 or 1.
22
+ emailVerified: boolean | number | null;
23
+ signupApproved: boolean | number | null;
24
+ }
25
+
26
+ function userQuery(placeholder: string): string {
27
+ return `SELECT u.id,
28
+ u.email,
29
+ u.role,
30
+ u."emailVerified" AS "emailVerified",
31
+ u."signupApproved" AS "signupApproved",
32
+ a.password AS "passwordHash"
33
+ FROM "user" AS u
34
+ LEFT JOIN "account" AS a
35
+ ON a."userId" = u.id
36
+ AND a."providerId" = 'credential'
37
+ WHERE lower(u.email) = lower(${placeholder})
38
+ LIMIT 1`;
39
+ }
40
+
41
+ function flag(value: boolean | number | null): boolean {
42
+ return value === true || value === 1;
43
+ }
44
+
45
+ export async function findBootstrapUser(email: string): Promise<BootstrapUser | null> {
46
+ let row: BootstrapUserRow | undefined;
47
+ if (postgresPool) {
48
+ row = (await postgresPool.query<BootstrapUserRow>(userQuery("$1"), [email])).rows[0];
49
+ } else if (sqliteDatabase) {
50
+ row = sqliteDatabase.query<BootstrapUserRow, [string]>(userQuery("?1")).get(email) ?? undefined;
51
+ }
52
+ if (!row) return null;
53
+ return {
54
+ ...row,
55
+ emailVerified: flag(row.emailVerified),
56
+ signupApproved: flag(row.signupApproved),
57
+ };
58
+ }
59
+
60
+ export { auth, closeAuthDatabase, verifyPassword };
@@ -0,0 +1,35 @@
1
+ import type { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ // The admin session list pages active sessions newest first with a keyset on
4
+ // ("createdAt", "id"). Better Auth's schema indexes only "userId" and "token",
5
+ // so without this index every page sorts the whole session table.
6
+ const INDEX = "IDX_session_createdAt_id";
7
+
8
+ export class IndexSessionCreatedAt1792060000000 implements MigrationInterface {
9
+ name = "IndexSessionCreatedAt1792060000000";
10
+ // CONCURRENTLY cannot run inside a transaction block. The runner therefore
11
+ // uses the "each" transaction mode. SQLite migrations always use one.
12
+ transaction = false;
13
+
14
+ async up(queryRunner: QueryRunner): Promise<void> {
15
+ if (queryRunner.connection.options.type !== "postgres") {
16
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${INDEX}" ON "session" ("createdAt", "id")`);
17
+ return;
18
+ }
19
+ // Sign-ins keep writing sessions while the index builds. An interrupted
20
+ // build leaves an invalid index that IF NOT EXISTS would keep.
21
+ const invalid = await queryRunner.query(
22
+ `SELECT 1 AS "invalid" FROM pg_index WHERE indexrelid = to_regclass($1) AND NOT indisvalid`,
23
+ [`"${INDEX}"`],
24
+ ) as unknown[];
25
+ if (invalid.length > 0) await queryRunner.query(`DROP INDEX CONCURRENTLY IF EXISTS "${INDEX}"`);
26
+ await queryRunner.query(
27
+ `CREATE INDEX CONCURRENTLY IF NOT EXISTS "${INDEX}" ON "session" ("createdAt", "id")`,
28
+ );
29
+ }
30
+
31
+ // A revert runs inside a transaction, so it uses a plain statement.
32
+ async down(queryRunner: QueryRunner): Promise<void> {
33
+ await queryRunner.query(`DROP INDEX IF EXISTS "${INDEX}"`);
34
+ }
35
+ }
@@ -7,11 +7,9 @@ import { ProfileImageService } from "./profile-image.service";
7
7
  describe("ProfileImageService account deletion cleanup", () => {
8
8
  const storage = {
9
9
  put: mock(async () => undefined),
10
- get: mock(async () => ({
11
- key: "profile-images/example.webp",
12
- size: 0,
13
- body: (async function* (): AsyncGenerator<Uint8Array> { yield Buffer.alloc(0); })(),
14
- })),
10
+ get: mock(async () => null),
11
+ stat: mock(async () => null),
12
+ list: mock(async () => ({ keys: [] })),
15
13
  exists: mock(async () => true),
16
14
  getDownloadUrl: mock(async () => "https://example.com/image"),
17
15
  delete: mock(async () => undefined),
@@ -21,7 +21,7 @@ const CONTENT_TYPES: Record<string, string> = {
21
21
  };
22
22
 
23
23
  export interface StoredProfileImage {
24
- body: Buffer;
24
+ object: ObjectData;
25
25
  contentType: string;
26
26
  }
27
27
 
@@ -33,12 +33,6 @@ export type UpdateAuthUser = (
33
33
  const updateAuthUser: UpdateAuthUser = (image, headers) =>
34
34
  getAuth().api.updateUser({ body: { image }, headers });
35
35
 
36
- async function objectBuffer(object: ObjectData): Promise<Buffer> {
37
- const chunks: Uint8Array[] = [];
38
- for await (const chunk of object.body) chunks.push(chunk);
39
- return Buffer.concat(chunks);
40
- }
41
-
42
36
  export class ProfileImageService {
43
37
  private unregisterUserDeleted?: () => void;
44
38
 
@@ -86,18 +80,13 @@ export class ProfileImageService {
86
80
  return { image: null };
87
81
  }
88
82
 
83
+ /** The image with its content unread. */
89
84
  async get(fileName: string): Promise<StoredProfileImage> {
90
85
  const key = this.keyFromFileName(fileName);
91
- if (!key) throw new AppException(PROFILE_IMAGE_NOT_FOUND, "Profile image not found.", 404);
92
- try {
93
- const extension = fileName.slice(fileName.lastIndexOf(".") + 1);
94
- return {
95
- body: await objectBuffer(await this.storage.get(key)),
96
- contentType: CONTENT_TYPES[extension] ?? "application/octet-stream",
97
- };
98
- } catch {
99
- throw new AppException(PROFILE_IMAGE_NOT_FOUND, "Profile image not found.", 404);
100
- }
86
+ const object = key ? await this.storage.get(key) : null;
87
+ if (!object) throw new AppException(PROFILE_IMAGE_NOT_FOUND, "Profile image not found.", 404);
88
+ const extension = fileName.slice(fileName.lastIndexOf(".") + 1);
89
+ return { object, contentType: CONTENT_TYPES[extension] ?? "application/octet-stream" };
101
90
  }
102
91
 
103
92
  private keyFromFileName(fileName: string): string | null {