@podosoft/podokit 1.1.6 → 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.
- package/dist/deploy-render.js +5 -0
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
- package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
- package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
- package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
- package/dist/templates/fullstack/compose.dev.yaml +21 -0
- package/dist/templates/fullstack/dot-env.docker +3 -0
- package/dist/templates/fullstack/dot-env.example +14 -0
- package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
- package/dist/templates/modules/audit-log/module.manifest.json +7 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
- package/dist/templates/modules/auth/module.manifest.json +2 -2
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
- package/dist/templates/modules/file-upload/module.manifest.json +9 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
- package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
- package/dist/templates/modules/job-progress/module.manifest.json +3 -3
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
- package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
- package/dist/templates/modules/sse/module.manifest.json +17 -4
- 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:
|
|
@@ -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
|
|
|
@@ -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
|
+
}
|