@podosoft/podokit 1.1.5 → 1.2.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 (168) hide show
  1. package/dist/deploy-render.js +5 -0
  2. package/dist/templates/fullstack/apps/api/package.json +2 -2
  3. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  4. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  5. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  6. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  7. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  8. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  9. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  10. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  11. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  12. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  13. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  14. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  15. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  16. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  17. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  18. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  19. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  20. package/dist/templates/fullstack/dot-env.docker +3 -0
  21. package/dist/templates/fullstack/dot-env.example +14 -0
  22. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
  30. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  31. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  32. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  66. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  67. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  68. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  69. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  72. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
  81. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  82. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  83. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  84. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
  85. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  86. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  87. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  88. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  90. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
  91. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  93. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  94. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +17 -9
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  104. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  106. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +13 -0
  107. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +5 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
  109. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -0
  112. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  113. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  114. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  115. package/dist/templates/modules/auth/module.manifest.json +2 -2
  116. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  117. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  118. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  119. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  120. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  121. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  122. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  123. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  124. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  125. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  126. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  127. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  128. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  129. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  130. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  131. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  132. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  133. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  134. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  135. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  136. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  137. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  138. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  139. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  140. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  141. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  142. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  143. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  144. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  145. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  146. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  147. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  148. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  149. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  150. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  151. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  152. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  153. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  154. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  155. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  156. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  157. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  158. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  159. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  160. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
  161. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
  162. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
  163. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
  164. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  165. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  166. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
  167. package/dist/templates/modules/sse/module.manifest.json +17 -4
  168. package/package.json +2 -2
@@ -1,8 +1,15 @@
1
1
  # Containerized Bun development environment.
2
+ #
3
+ # Every service has a memory limit equal to its swap limit, so a runaway process
4
+ # is OOM-killed inside its container instead of exhausting the host. Override a
5
+ # limit with the matching *_MEM_LIMIT variable from the shell or the project's
6
+ # .env file (not .env.docker), e.g. API_MEM_LIMIT=3g podo dev watch.
2
7
  name: {{projectName}}-dev
3
8
 
4
9
  services:
5
10
  traefik:
11
+ mem_limit: ${TRAEFIK_MEM_LIMIT:-256m}
12
+ memswap_limit: ${TRAEFIK_MEM_LIMIT:-256m}
6
13
  profiles: [podokit-legacy-proxy]
7
14
  image: traefik:v3.5
8
15
  command:
@@ -19,6 +26,8 @@ services:
19
26
  networks: [podokit]
20
27
 
21
28
  web:
29
+ mem_limit: ${WEB_MEM_LIMIT:-2g}
30
+ memswap_limit: ${WEB_MEM_LIMIT:-2g}
22
31
  build:
23
32
  context: .
24
33
  dockerfile: Dockerfile.dev
@@ -42,6 +51,8 @@ services:
42
51
  - { action: rebuild, path: ./apps/web/package.json }
43
52
 
44
53
  api:
54
+ mem_limit: ${API_MEM_LIMIT:-2g}
55
+ memswap_limit: ${API_MEM_LIMIT:-2g}
45
56
  build:
46
57
  context: .
47
58
  dockerfile: Dockerfile.dev
@@ -61,6 +72,8 @@ services:
61
72
  - { action: rebuild, path: ./apps/api/package.json }
62
73
 
63
74
  worker:
75
+ mem_limit: ${WORKER_MEM_LIMIT:-1g}
76
+ memswap_limit: ${WORKER_MEM_LIMIT:-1g}
64
77
  profiles: [queue]
65
78
  build:
66
79
  context: .
@@ -81,6 +94,8 @@ services:
81
94
  - { action: rebuild, path: ./apps/api/package.json }
82
95
 
83
96
  postgres:
97
+ mem_limit: ${POSTGRES_MEM_LIMIT:-1g}
98
+ memswap_limit: ${POSTGRES_MEM_LIMIT:-1g}
84
99
  image: postgres:16-alpine
85
100
  environment:
86
101
  POSTGRES_USER: podokit
@@ -96,11 +111,15 @@ services:
96
111
  networks: [podokit]
97
112
 
98
113
  redis:
114
+ mem_limit: ${REDIS_MEM_LIMIT:-256m}
115
+ memswap_limit: ${REDIS_MEM_LIMIT:-256m}
99
116
  profiles: [cache]
100
117
  image: redis:7-alpine
101
118
  networks: [podokit]
102
119
 
103
120
  minio:
121
+ mem_limit: ${MINIO_MEM_LIMIT:-512m}
122
+ memswap_limit: ${MINIO_MEM_LIMIT:-512m}
104
123
  profiles: [storage]
105
124
  image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z
106
125
  command: server /data --console-address ":9001"
@@ -112,6 +131,8 @@ services:
112
131
  networks: [podokit]
113
132
 
114
133
  minio-init:
134
+ mem_limit: ${MINIO_INIT_MEM_LIMIT:-128m}
135
+ memswap_limit: ${MINIO_INIT_MEM_LIMIT:-128m}
115
136
  profiles: [storage]
116
137
  image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z
117
138
  depends_on:
@@ -1,3 +1,6 @@
1
+ # Container environment for compose.dev.yaml. Compose does not interpolate
2
+ # from this file: set container memory limits such as API_MEM_LIMIT=3g in the
3
+ # shell or the project's .env file instead.
1
4
  NODE_ENV=development
2
5
  PORT=5002
3
6
  POSTGRES_HOST=postgres
@@ -12,6 +12,20 @@ POSTGRES_USER=podokit
12
12
  POSTGRES_PASSWORD=podokit
13
13
  POSTGRES_DB=podokit
14
14
 
15
+ # PostgreSQL pools. Each API replica opens up to DB_POOL_MAX connections for
16
+ # queries, plus DB_AUTH_POOL_MAX for the auth module when it is installed. Keep
17
+ # replicas x (DB_POOL_MAX + DB_AUTH_POOL_MAX), plus a few for migrations, below
18
+ # the server's max_connections (100 by default). The timeouts are milliseconds.
19
+ # DB_POOL_MAX=20
20
+ # DB_CONNECT_TIMEOUT_MS=10000
21
+ # DB_IDLE_TIMEOUT_MS=30000
22
+ # DB_AUTH_POOL_MAX=10
23
+
24
+ # Readiness (GET /health/ready): the longest one dependency check may run, and how
25
+ # long a result is reused across probes (0 checks on every request).
26
+ # READINESS_CHECK_TIMEOUT_MS=800
27
+ # READINESS_CACHE_MS=2000
28
+
15
29
  # Web -> API boundary (server-side only; not exposed to the browser)
16
30
  BACKEND_INTERNAL_URL=http://localhost:5002
17
31
 
@@ -24,6 +24,8 @@ spec:
24
24
  readinessProbe:
25
25
  httpGet: { path: /health/ready, port: 3000 }
26
26
  initialDelaySeconds: 5
27
+ timeoutSeconds: 2
27
28
  livenessProbe:
28
29
  httpGet: { path: /health, port: 3000 }
29
30
  initialDelaySeconds: 15
31
+ timeoutSeconds: 2
@@ -0,0 +1,25 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { APIError } from "better-auth/api";
3
+ import { createSessionRevoker, type RevokeUserSessionApi } from "./admin-sessions.revoker";
4
+
5
+ describe("createSessionRevoker", () => {
6
+ test("revokes through the audited Better Auth admin endpoint as the caller", async () => {
7
+ const revokeUserSession = mock<RevokeUserSessionApi["revokeUserSession"]>(async () => ({ success: true }));
8
+ const headers = new Headers({ cookie: "admin-session=1" });
9
+
10
+ await createSessionRevoker(() => ({ revokeUserSession }))("secret-token", headers);
11
+
12
+ expect(revokeUserSession).toHaveBeenCalledWith({ body: { sessionToken: "secret-token" }, headers });
13
+ });
14
+
15
+ test("reports Better Auth refusals with the standard error shape", async () => {
16
+ const revoker = createSessionRevoker(() => ({
17
+ revokeUserSession: async (): Promise<unknown> => {
18
+ throw new APIError("FORBIDDEN", { message: "You are not allowed to revoke users sessions" });
19
+ },
20
+ }));
21
+
22
+ await expect(revoker("secret-token", new Headers()))
23
+ .rejects.toMatchObject({ code: "ADMIN_SESSION_REVOKE_FAILED", statusCode: 403 });
24
+ });
25
+ });
@@ -0,0 +1,26 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
2
+ import { APIError } from "better-auth/api";
3
+ import type { SessionRevoker } from "./admin-sessions.routes";
4
+
5
+ export const ADMIN_SESSION_REVOKE_FAILED = "ADMIN_SESSION_REVOKE_FAILED";
6
+
7
+ export interface RevokeUserSessionApi {
8
+ revokeUserSession(input: { body: { sessionToken: string }; headers: Headers }): Promise<unknown>;
9
+ }
10
+
11
+ /**
12
+ * Revokes through the Better Auth admin endpoint rather than deleting the row, so
13
+ * its permission check and after-hooks (including the audit log) still run.
14
+ */
15
+ export function createSessionRevoker(api: () => RevokeUserSessionApi): SessionRevoker {
16
+ return async (sessionToken, headers) => {
17
+ try {
18
+ await api().revokeUserSession({ body: { sessionToken }, headers });
19
+ } catch (error) {
20
+ if (error instanceof APIError) {
21
+ throw new AppException(ADMIN_SESSION_REVOKE_FAILED, error.message, error.statusCode);
22
+ }
23
+ throw error;
24
+ }
25
+ };
26
+ }
@@ -0,0 +1,122 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test, type Mock } 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 { adminSessionRoutes, type SessionRevoker } from "./admin-sessions.routes";
7
+ import { AdminSessionsService } from "./admin-sessions.service";
8
+
9
+ type Auth = Pick<AuthService, "requireAdmin">;
10
+
11
+ // Callers identify themselves with a test header; only "admin" passes.
12
+ const auth: Auth = {
13
+ requireAdmin: async (request: Request): Promise<AuthSession> => {
14
+ const role = request.headers.get("x-test-role");
15
+ if (!role) throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
16
+ if (role !== "admin") throw new AppException("ADMIN_REQUIRED", "Administrator access is required", 403);
17
+ return { user: { id: "admin-1", role: "admin" }, session: {} };
18
+ },
19
+ };
20
+
21
+ interface Reply {
22
+ status: number;
23
+ text: string;
24
+ }
25
+
26
+ describe("admin session routes", () => {
27
+ let db: SQL;
28
+ let revoke: Mock<SessionRevoker>;
29
+ let handle: (request: Request) => Promise<Response>;
30
+
31
+ beforeEach(async () => {
32
+ db = new SQL(":memory:");
33
+ await db`CREATE TABLE "user" ("id" TEXT PRIMARY KEY, "email" TEXT NOT NULL, "name" TEXT NOT NULL)`;
34
+ await db`
35
+ CREATE TABLE "session" (
36
+ "id" TEXT PRIMARY KEY, "userId" TEXT NOT NULL, "token" TEXT NOT NULL,
37
+ "ipAddress" TEXT, "userAgent" TEXT, "impersonatedBy" TEXT,
38
+ "createdAt" TEXT NOT NULL, "expiresAt" TEXT NOT NULL
39
+ )
40
+ `;
41
+ await db`INSERT INTO "user" VALUES ('user-1', 'user@example.com', 'User')`;
42
+ const future = new Date(Date.now() + 86_400_000).toISOString();
43
+ await db`
44
+ INSERT INTO "session" VALUES
45
+ ('session-1', 'user-1', 'secret-token-1', NULL, NULL, NULL, '2026-01-01T00:00:01.000Z', ${future}),
46
+ ('session-2', 'user-1', 'secret-token-2', NULL, NULL, NULL, '2026-01-01T00:00:02.000Z', ${future})
47
+ `;
48
+ revoke = mock<SessionRevoker>(async () => undefined);
49
+ const app = new Elysia()
50
+ .onError(({ code, error, set }) => {
51
+ if (error instanceof AppException) {
52
+ set.status = error.statusCode;
53
+ return { code: error.code };
54
+ }
55
+ if (code === "VALIDATION") {
56
+ set.status = 400;
57
+ return { code: "VALIDATION_ERROR" };
58
+ }
59
+ return undefined;
60
+ })
61
+ .use(adminSessionRoutes({ auth, sessions: new AdminSessionsService(db, "sqlite"), revoke }));
62
+ handle = (request) => app.handle(request);
63
+ });
64
+
65
+ afterEach(async () => {
66
+ await db.close();
67
+ });
68
+
69
+ async function send(path: string, init: RequestInit = {}, role: string | null = "admin"): Promise<Reply> {
70
+ const headers = new Headers(init.headers);
71
+ if (role) headers.set("x-test-role", role);
72
+ const response = await handle(new Request(`http://localhost${path}`, { ...init, headers }));
73
+ return { status: response.status, text: await response.text() };
74
+ }
75
+
76
+ test("lists sessions for administrators without exposing tokens", async () => {
77
+ const reply = await send("/admin/sessions?limit=1");
78
+
79
+ expect(reply.status).toBe(200);
80
+ const body = JSON.parse(reply.text) as { items: Array<{ id: string }>; nextCursor: string | null; total: number };
81
+ expect(body.items.map((item) => item.id)).toEqual(["session-2"]);
82
+ expect(body.total).toBe(2);
83
+ expect(reply.text).not.toContain("secret-token");
84
+
85
+ const next = await send(`/admin/sessions?limit=1&cursor=${encodeURIComponent(body.nextCursor ?? "")}`);
86
+ expect(JSON.parse(next.text)).toMatchObject({ items: [{ id: "session-1" }], nextCursor: null });
87
+ });
88
+
89
+ test("refuses anonymous callers and non-administrators", async () => {
90
+ expect((await send("/admin/sessions", {}, null)).status).toBe(401);
91
+ expect((await send("/admin/sessions", {}, "user")).status).toBe(403);
92
+ expect((await send("/admin/sessions/session-1", { method: "DELETE" }, "user")).status).toBe(403);
93
+ expect(revoke).not.toHaveBeenCalled();
94
+ });
95
+
96
+ test("validates paging input", async () => {
97
+ expect((await send("/admin/sessions?limit=1000")).status).toBe(400);
98
+ expect(JSON.parse((await send("/admin/sessions?cursor=forged")).text))
99
+ .toEqual({ code: "ADMIN_SESSION_CURSOR_INVALID" });
100
+ });
101
+
102
+ test("revokes by id with the token looked up on the server", async () => {
103
+ const reply = await send("/admin/sessions/session-1", {
104
+ method: "DELETE",
105
+ headers: { cookie: "admin-session=1" },
106
+ });
107
+
108
+ expect(reply.status).toBe(204);
109
+ expect(revoke).toHaveBeenCalledTimes(1);
110
+ const [token, headers] = revoke.mock.calls[0] ?? [];
111
+ expect(token).toBe("secret-token-1");
112
+ expect(headers?.get("cookie")).toBe("admin-session=1");
113
+ });
114
+
115
+ test("reports an unknown session without revoking anything", async () => {
116
+ const reply = await send("/admin/sessions/missing", { method: "DELETE" });
117
+
118
+ expect(reply.status).toBe(404);
119
+ expect(JSON.parse(reply.text)).toEqual({ code: "ADMIN_SESSION_NOT_FOUND" });
120
+ expect(revoke).not.toHaveBeenCalled();
121
+ });
122
+ });
@@ -0,0 +1,46 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
2
+ import { Elysia, t } from "elysia";
3
+ import type { AuthService } from "../auth/auth.service";
4
+ import type { ElysiaPlugin } from "../core/services";
5
+ import { ADMIN_SESSIONS_MAX_LIMIT, type AdminSessionsService } from "./admin-sessions.service";
6
+
7
+ export const ADMIN_SESSION_NOT_FOUND = "ADMIN_SESSION_NOT_FOUND";
8
+
9
+ /** Revokes one session by its bearer token on behalf of the requesting administrator. */
10
+ export type SessionRevoker = (sessionToken: string, headers: Headers) => Promise<void>;
11
+
12
+ export interface AdminSessionRoutesOptions {
13
+ auth: Pick<AuthService, "requireAdmin">;
14
+ sessions: AdminSessionsService;
15
+ revoke: SessionRevoker;
16
+ }
17
+
18
+ export function adminSessionRoutes({ auth, sessions, revoke }: AdminSessionRoutesOptions): ElysiaPlugin {
19
+ return new Elysia({ name: "podokit.admin.sessions" })
20
+ .get("/admin/sessions", async ({ request, query }) => {
21
+ await auth.requireAdmin(request);
22
+ return sessions.list(query);
23
+ }, {
24
+ query: t.Object({
25
+ cursor: t.Optional(t.String({ minLength: 1, maxLength: 512 })),
26
+ limit: t.Optional(t.Numeric({ minimum: 1, maximum: ADMIN_SESSIONS_MAX_LIMIT })),
27
+ search: t.Optional(t.String({ maxLength: 200 })),
28
+ userId: t.Optional(t.String({ minLength: 1, maxLength: 255 })),
29
+ }),
30
+ detail: {
31
+ tags: ["admin"],
32
+ summary: "List active sessions across users",
33
+ description: "Newest first, keyset-paged with `cursor`. `search` matches the user's email or name case-insensitively. Session tokens are never returned.",
34
+ },
35
+ })
36
+ .delete("/admin/sessions/:id", async ({ request, params, set }) => {
37
+ await auth.requireAdmin(request);
38
+ const token = await sessions.tokenOf(params.id);
39
+ if (!token) throw new AppException(ADMIN_SESSION_NOT_FOUND, "Session not found.", 404);
40
+ await revoke(token, request.headers);
41
+ set.status = 204;
42
+ }, {
43
+ params: t.Object({ id: t.String({ minLength: 1, maxLength: 255 }) }),
44
+ detail: { tags: ["admin"], summary: "Revoke a session by id" },
45
+ });
46
+ }
@@ -0,0 +1,187 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { AdminSessionsService, type AdminSessionView } from "./admin-sessions.service";
4
+
5
+ const DAY_MS = 86_400_000;
6
+
7
+ interface SeedSession {
8
+ id: string;
9
+ userId: string;
10
+ createdAt: string;
11
+ expiresAt?: string;
12
+ }
13
+
14
+ async function createSchema(db: SQL): Promise<void> {
15
+ await db`CREATE TABLE "user" ("id" TEXT PRIMARY KEY, "email" TEXT NOT NULL, "name" TEXT NOT NULL)`;
16
+ await db`
17
+ CREATE TABLE "session" (
18
+ "id" TEXT PRIMARY KEY, "userId" TEXT NOT NULL, "token" TEXT NOT NULL,
19
+ "ipAddress" TEXT, "userAgent" TEXT, "impersonatedBy" TEXT,
20
+ "createdAt" TEXT NOT NULL, "expiresAt" TEXT NOT NULL
21
+ )
22
+ `;
23
+ }
24
+
25
+ async function seed(db: SQL, sessions: SeedSession[]): Promise<void> {
26
+ const future = new Date(Date.now() + DAY_MS).toISOString();
27
+ for (const session of sessions) {
28
+ await db`
29
+ INSERT INTO "session" VALUES (
30
+ ${session.id}, ${session.userId}, ${`token-${session.id}`}, '203.0.113.7', 'Agent', NULL,
31
+ ${session.createdAt}, ${session.expiresAt ?? future}
32
+ )
33
+ `;
34
+ }
35
+ }
36
+
37
+ async function collect(service: AdminSessionsService, limit: number, search?: string): Promise<{
38
+ pages: number;
39
+ items: AdminSessionView[];
40
+ }> {
41
+ const items: AdminSessionView[] = [];
42
+ let cursor: string | undefined;
43
+ let pages = 0;
44
+ do {
45
+ const page = await service.list({ limit, cursor, search });
46
+ pages += 1;
47
+ items.push(...page.items);
48
+ cursor = page.nextCursor ?? undefined;
49
+ } while (cursor);
50
+ return { pages, items };
51
+ }
52
+
53
+ describe("AdminSessionsService", () => {
54
+ let db: SQL;
55
+ let service: AdminSessionsService;
56
+
57
+ beforeEach(async () => {
58
+ db = new SQL(":memory:");
59
+ await createSchema(db);
60
+ await db`
61
+ INSERT INTO "user" VALUES
62
+ ('alice', 'alice@example.com', 'Alice Smith'),
63
+ ('bob', 'bob_admin@example.com', 'Bob'),
64
+ ('carol', 'carol@example.com', 'Carol 100%')
65
+ `;
66
+ service = new AdminSessionsService(db, "sqlite");
67
+ });
68
+
69
+ afterEach(async () => {
70
+ await db.close();
71
+ });
72
+
73
+ test("pages through every active session newest first without gaps or repeats", async () => {
74
+ const base = Date.parse("2026-01-01T00:00:00.000Z");
75
+ const owners = ["alice", "bob", "carol"];
76
+ // Pairs share a creation time so the id tiebreaker decides the order.
77
+ await seed(db, Array.from({ length: 11 }, (_, index) => ({
78
+ id: `s${String(index).padStart(2, "0")}`,
79
+ userId: owners[index % owners.length] ?? "alice",
80
+ createdAt: new Date(base + Math.floor(index / 2) * 1_000).toISOString(),
81
+ })));
82
+
83
+ const { pages, items } = await collect(service, 4);
84
+
85
+ expect(pages).toBe(3);
86
+ expect(items.map((item) => item.id)).toEqual([
87
+ "s10", "s09", "s08", "s07", "s06", "s05", "s04", "s03", "s02", "s01", "s00",
88
+ ]);
89
+ const first = await service.list({ limit: 4 });
90
+ expect(first.total).toBe(11);
91
+ expect(first.nextCursor).toEqual(expect.any(String));
92
+ });
93
+
94
+ test("returns only active sessions", async () => {
95
+ await seed(db, [
96
+ { id: "live", userId: "alice", createdAt: "2026-01-01T00:00:00.000Z" },
97
+ {
98
+ id: "expired",
99
+ userId: "alice",
100
+ createdAt: "2026-01-02T00:00:00.000Z",
101
+ expiresAt: new Date(Date.now() - 1_000).toISOString(),
102
+ },
103
+ ]);
104
+
105
+ const page = await service.list();
106
+
107
+ expect(page.items.map((item) => item.id)).toEqual(["live"]);
108
+ expect(page.total).toBe(1);
109
+ expect(page.nextCursor).toBeNull();
110
+ });
111
+
112
+ test("searches the user's email or name case-insensitively and literally", async () => {
113
+ await seed(db, [
114
+ { id: "a", userId: "alice", createdAt: "2026-01-01T00:00:01.000Z" },
115
+ { id: "b", userId: "bob", createdAt: "2026-01-01T00:00:02.000Z" },
116
+ { id: "c", userId: "carol", createdAt: "2026-01-01T00:00:03.000Z" },
117
+ ]);
118
+
119
+ expect((await service.list({ search: "ALICE@" })).items.map((item) => item.id)).toEqual(["a"]);
120
+ expect((await service.list({ search: "smith" })).items.map((item) => item.id)).toEqual(["a"]);
121
+ // LIKE wildcards in the query match themselves, not every row.
122
+ expect((await service.list({ search: "_" })).items.map((item) => item.id)).toEqual(["b"]);
123
+ expect((await service.list({ search: "%" })).items.map((item) => item.id)).toEqual(["c"]);
124
+ expect((await service.list({ search: " " })).total).toBe(3);
125
+ const searched = await collect(service, 1, "example.com");
126
+ expect(searched.items.map((item) => item.id)).toEqual(["c", "b", "a"]);
127
+ });
128
+
129
+ test("narrows the list to one user", async () => {
130
+ await seed(db, [
131
+ { id: "a1", userId: "alice", createdAt: "2026-01-01T00:00:01.000Z" },
132
+ { id: "b1", userId: "bob", createdAt: "2026-01-01T00:00:02.000Z" },
133
+ { id: "a2", userId: "alice", createdAt: "2026-01-01T00:00:03.000Z" },
134
+ ]);
135
+
136
+ const page = await service.list({ userId: "alice" });
137
+
138
+ expect(page.items.map((item) => item.id)).toEqual(["a2", "a1"]);
139
+ expect(page.total).toBe(2);
140
+ });
141
+
142
+ test("returns user details without the session token", async () => {
143
+ await seed(db, [{ id: "a", userId: "alice", createdAt: "2026-01-01T00:00:01.000Z" }]);
144
+
145
+ const [item] = (await service.list()).items;
146
+
147
+ expect(item).toEqual({
148
+ id: "a",
149
+ userId: "alice",
150
+ userEmail: "alice@example.com",
151
+ userName: "Alice Smith",
152
+ ipAddress: "203.0.113.7",
153
+ userAgent: "Agent",
154
+ impersonatedBy: null,
155
+ createdAt: "2026-01-01T00:00:01.000Z",
156
+ expiresAt: expect.any(String),
157
+ });
158
+ expect(JSON.stringify(item)).not.toContain("token-a");
159
+ });
160
+
161
+ test("rejects a cursor it did not issue", async () => {
162
+ await expect(service.list({ cursor: "not-a-cursor" }))
163
+ .rejects.toMatchObject({ code: "ADMIN_SESSION_CURSOR_INVALID", statusCode: 400 });
164
+ const forged = Buffer.from(JSON.stringify(["not a date", "id"])).toString("base64url");
165
+ await expect(service.list({ cursor: forged }))
166
+ .rejects.toMatchObject({ code: "ADMIN_SESSION_CURSOR_INVALID", statusCode: 400 });
167
+ });
168
+
169
+ test("keeps the page size within bounds", async () => {
170
+ const base = Date.parse("2026-01-01T00:00:00.000Z");
171
+ await seed(db, Array.from({ length: 3 }, (_, index) => ({
172
+ id: `s${index}`,
173
+ userId: "alice",
174
+ createdAt: new Date(base + index * 1_000).toISOString(),
175
+ })));
176
+
177
+ expect((await service.list({ limit: 0 })).items).toHaveLength(1);
178
+ expect((await service.list({ limit: 1_000 })).items).toHaveLength(3);
179
+ });
180
+
181
+ test("looks up a session token by id on the server", async () => {
182
+ await seed(db, [{ id: "a", userId: "alice", createdAt: "2026-01-01T00:00:01.000Z" }]);
183
+
184
+ expect(await service.tokenOf("a")).toBe("token-a");
185
+ expect(await service.tokenOf("missing")).toBeNull();
186
+ });
187
+ });
@@ -0,0 +1,160 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
2
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+ import type { SQL } from "bun";
4
+
5
+ export const ADMIN_SESSION_CURSOR_INVALID = "ADMIN_SESSION_CURSOR_INVALID";
6
+ export const ADMIN_SESSIONS_DEFAULT_LIMIT = 20;
7
+ export const ADMIN_SESSIONS_MAX_LIMIT = 100;
8
+
9
+ /** An active session as administrators see it. The bearer token never leaves the server. */
10
+ export interface AdminSessionView {
11
+ id: string;
12
+ userId: string;
13
+ userEmail: string;
14
+ userName: string;
15
+ ipAddress: string | null;
16
+ userAgent: string | null;
17
+ impersonatedBy: string | null;
18
+ createdAt: string;
19
+ expiresAt: string;
20
+ }
21
+
22
+ export interface AdminSessionPage {
23
+ items: AdminSessionView[];
24
+ nextCursor: string | null;
25
+ total: number;
26
+ }
27
+
28
+ export interface AdminSessionQuery {
29
+ limit?: number;
30
+ cursor?: string;
31
+ search?: string;
32
+ userId?: string;
33
+ }
34
+
35
+ interface SessionRow {
36
+ id: string;
37
+ userId: string;
38
+ userEmail: string;
39
+ userName: string | null;
40
+ ipAddress: string | null;
41
+ userAgent: string | null;
42
+ impersonatedBy: string | null;
43
+ createdAt: Date | string;
44
+ expiresAt: Date | string;
45
+ cursorAt: string;
46
+ }
47
+
48
+ interface Cursor {
49
+ createdAt: string;
50
+ id: string;
51
+ }
52
+
53
+ function iso(value: Date | string): string {
54
+ return (value instanceof Date ? value : new Date(value)).toISOString();
55
+ }
56
+
57
+ function encodeCursor(cursor: Cursor): string {
58
+ return Buffer.from(JSON.stringify([cursor.createdAt, cursor.id])).toString("base64url");
59
+ }
60
+
61
+ function decodeCursor(value: string): Cursor {
62
+ try {
63
+ const parsed: unknown = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
64
+ if (
65
+ Array.isArray(parsed) &&
66
+ parsed.length === 2 &&
67
+ typeof parsed[0] === "string" &&
68
+ typeof parsed[1] === "string" &&
69
+ !Number.isNaN(Date.parse(parsed[0]))
70
+ ) {
71
+ return { createdAt: parsed[0], id: parsed[1] };
72
+ }
73
+ } catch {
74
+ // Reported below as one stable error code.
75
+ }
76
+ throw new AppException(ADMIN_SESSION_CURSOR_INVALID, "The sessions cursor is invalid.", 400);
77
+ }
78
+
79
+ function likePattern(search: string): string {
80
+ return `%${search.replace(/[\\%_]/g, (character) => `\\${character}`)}%`;
81
+ }
82
+
83
+ /**
84
+ * Lists active sessions across users with keyset paging on ("createdAt", "id"),
85
+ * so a page costs one bounded query however many users or sessions exist.
86
+ */
87
+ export class AdminSessionsService {
88
+ constructor(
89
+ private readonly sql: SQL,
90
+ private readonly provider: DatabaseProviderName,
91
+ ) {}
92
+
93
+ async list(query: AdminSessionQuery = {}): Promise<AdminSessionPage> {
94
+ const limit = Math.min(Math.max(query.limit ?? ADMIN_SESSIONS_DEFAULT_LIMIT, 1), ADMIN_SESSIONS_MAX_LIMIT);
95
+ const cursor = query.cursor ? decodeCursor(query.cursor) : undefined;
96
+ const search = query.search?.trim() ?? "";
97
+ // ISO-8601 text matches the SQLite storage format and still compares as a
98
+ // timestamp against PostgreSQL timestamptz columns.
99
+ const now = new Date().toISOString();
100
+ const filters = (): SQL.Query<unknown> => this.sql`
101
+ WHERE s."expiresAt" > ${now}
102
+ ${query.userId ? this.sql`AND s."userId" = ${query.userId}` : this.sql``}
103
+ ${search ? this.searchFilter(likePattern(search)) : this.sql``}
104
+ `;
105
+ const [rows, totals] = await Promise.all([
106
+ this.sql<SessionRow[]>`
107
+ SELECT s."id", s."userId", u."email" AS "userEmail", u."name" AS "userName",
108
+ s."ipAddress", s."userAgent", s."impersonatedBy", s."createdAt", s."expiresAt",
109
+ CAST(s."createdAt" AS TEXT) AS "cursorAt"
110
+ FROM "session" s
111
+ JOIN "user" u ON u."id" = s."userId"
112
+ ${filters()}
113
+ ${cursor
114
+ ? this.sql`AND (s."createdAt" < ${cursor.createdAt}
115
+ OR (s."createdAt" = ${cursor.createdAt} AND s."id" < ${cursor.id}))`
116
+ : this.sql``}
117
+ ORDER BY s."createdAt" DESC, s."id" DESC
118
+ LIMIT ${limit + 1}
119
+ `,
120
+ this.sql<Array<{ total: number | string | bigint }>>`
121
+ SELECT COUNT(*) AS "total"
122
+ FROM "session" s
123
+ JOIN "user" u ON u."id" = s."userId"
124
+ ${filters()}
125
+ `,
126
+ ]);
127
+ const page = rows.slice(0, limit);
128
+ const last = page.at(-1);
129
+ return {
130
+ items: page.map((row) => ({
131
+ id: row.id,
132
+ userId: row.userId,
133
+ userEmail: row.userEmail,
134
+ userName: row.userName ?? "",
135
+ ipAddress: row.ipAddress,
136
+ userAgent: row.userAgent,
137
+ impersonatedBy: row.impersonatedBy,
138
+ createdAt: iso(row.createdAt),
139
+ expiresAt: iso(row.expiresAt),
140
+ })),
141
+ // The cursor keeps the database's own text form so sub-millisecond
142
+ // timestamps cannot skip or repeat rows at a page boundary.
143
+ nextCursor: rows.length > limit && last ? encodeCursor({ createdAt: last.cursorAt, id: last.id }) : null,
144
+ total: Number(totals[0]?.total ?? 0),
145
+ };
146
+ }
147
+
148
+ async tokenOf(sessionId: string): Promise<string | null> {
149
+ const rows = await this.sql<Array<{ token: string }>>`
150
+ SELECT "token" FROM "session" WHERE "id" = ${sessionId}
151
+ `;
152
+ return rows[0]?.token ?? null;
153
+ }
154
+
155
+ private searchFilter(pattern: string): SQL.Query<unknown> {
156
+ return this.provider === "sqlite"
157
+ ? this.sql`AND (u."email" LIKE ${pattern} ESCAPE '\\' OR u."name" LIKE ${pattern} ESCAPE '\\')`
158
+ : this.sql`AND (u."email" ILIKE ${pattern} ESCAPE '\\' OR u."name" ILIKE ${pattern} ESCAPE '\\')`;
159
+ }
160
+ }