@podosoft/podokit 1.4.1 → 1.5.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-compose-profile.js +2 -2
- package/dist/deploy-compose-render.js +3 -1
- package/dist/deploy-profile.js +2 -2
- package/dist/deploy-render.js +16 -8
- package/dist/deploy-schema.d.ts +7 -0
- package/dist/deploy-schema.js +40 -0
- package/dist/lockfile.d.ts +8 -1
- package/dist/lockfile.js +23 -4
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +5 -2
- package/dist/templates/fullstack/AGENTS.md +27 -2
- package/dist/templates/fullstack/README.md +1 -1
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -2
- package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
- package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
- package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +66 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
- package/dist/templates/fullstack/apps/web/Dockerfile +5 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +436 -6
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +352 -38
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
- package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
- package/dist/templates/fullstack/dot-env.docker.example +6 -0
- package/dist/templates/fullstack/dot-env.example +23 -0
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
- package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
- package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +54 -47
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +19 -8
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
- package/dist/templates/todo/AGENTS.md +41 -5
- package/dist/templates/todo/README.md +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,152 @@
|
|
|
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 { AdminSessionsService } from "../admin-sessions/admin-sessions.service";
|
|
7
|
+
import {
|
|
8
|
+
accountSessionRoutes,
|
|
9
|
+
type AccountSessionPage,
|
|
10
|
+
type OwnSessionRevoker,
|
|
11
|
+
} from "./account-sessions.routes";
|
|
12
|
+
|
|
13
|
+
// More sessions than Better Auth's /list-sessions returns.
|
|
14
|
+
const OTHER_SESSIONS = 150;
|
|
15
|
+
const DAY_MS = 86_400_000;
|
|
16
|
+
|
|
17
|
+
// Callers name their session with a test header; its row says whose it is.
|
|
18
|
+
function authFor(db: SQL): Pick<AuthService, "requireSession"> {
|
|
19
|
+
return {
|
|
20
|
+
requireSession: async (request: Request): Promise<AuthSession> => {
|
|
21
|
+
const id = request.headers.get("x-test-session");
|
|
22
|
+
const [row] = id
|
|
23
|
+
? await db<Array<{ userId: string; createdAt: string }>>`
|
|
24
|
+
SELECT "userId", "createdAt" FROM "session" WHERE "id" = ${id}
|
|
25
|
+
`
|
|
26
|
+
: [];
|
|
27
|
+
if (!row) throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
|
|
28
|
+
return { user: { id: row.userId }, session: { id, createdAt: row.createdAt } };
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("account session routes", () => {
|
|
34
|
+
let db: SQL;
|
|
35
|
+
let revoke: Mock<OwnSessionRevoker>;
|
|
36
|
+
let freshAge: number;
|
|
37
|
+
let handle: (request: Request) => Promise<Response>;
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
db = new SQL(":memory:");
|
|
42
|
+
await db`CREATE TABLE "user" ("id" TEXT PRIMARY KEY, "email" TEXT NOT NULL, "name" TEXT NOT NULL)`;
|
|
43
|
+
await db`
|
|
44
|
+
CREATE TABLE "session" (
|
|
45
|
+
"id" TEXT PRIMARY KEY, "userId" TEXT NOT NULL, "token" TEXT NOT NULL,
|
|
46
|
+
"ipAddress" TEXT, "userAgent" TEXT, "impersonatedBy" TEXT,
|
|
47
|
+
"createdAt" TEXT NOT NULL, "expiresAt" TEXT NOT NULL
|
|
48
|
+
)
|
|
49
|
+
`;
|
|
50
|
+
await db`INSERT INTO "user" VALUES ('user-1', 'one@example.com', 'One'), ('user-2', 'two@example.com', 'Two')`;
|
|
51
|
+
const expires = new Date(now + DAY_MS).toISOString();
|
|
52
|
+
// The current session is the oldest, so newest-first paging would put it last.
|
|
53
|
+
await db`
|
|
54
|
+
INSERT INTO "session" VALUES ('current', 'user-1', 'secret-current', '203.0.113.7', 'Agent',
|
|
55
|
+
NULL, ${new Date(now - 60_000).toISOString()}, ${expires})
|
|
56
|
+
`;
|
|
57
|
+
for (let index = 0; index < OTHER_SESSIONS; index += 1) {
|
|
58
|
+
await db`
|
|
59
|
+
INSERT INTO "session" VALUES (${`other-${index}`}, 'user-1', ${`secret-${index}`}, NULL, NULL, NULL,
|
|
60
|
+
${new Date(now - 30_000 + index).toISOString()}, ${expires})
|
|
61
|
+
`;
|
|
62
|
+
}
|
|
63
|
+
await db`
|
|
64
|
+
INSERT INTO "session" VALUES ('foreign', 'user-2', 'secret-foreign', NULL, NULL, NULL,
|
|
65
|
+
${new Date(now).toISOString()}, ${expires})
|
|
66
|
+
`;
|
|
67
|
+
revoke = mock<OwnSessionRevoker>(async () => undefined);
|
|
68
|
+
freshAge = 86_400;
|
|
69
|
+
const app = new Elysia()
|
|
70
|
+
.onError(({ code, error, set }) => {
|
|
71
|
+
if (error instanceof AppException) {
|
|
72
|
+
set.status = error.statusCode;
|
|
73
|
+
return { code: error.code };
|
|
74
|
+
}
|
|
75
|
+
if (code === "VALIDATION") {
|
|
76
|
+
set.status = 400;
|
|
77
|
+
return { code: "VALIDATION_ERROR" };
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
})
|
|
81
|
+
.use(accountSessionRoutes({
|
|
82
|
+
auth: authFor(db),
|
|
83
|
+
sessions: new AdminSessionsService(db, "sqlite"),
|
|
84
|
+
revoke,
|
|
85
|
+
freshAgeSeconds: async () => freshAge,
|
|
86
|
+
}));
|
|
87
|
+
handle = (request) => app.handle(request);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(async () => {
|
|
91
|
+
await db.close();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
async function send(path: string, init: RequestInit = {}, session: string | null = "current"): Promise<Response> {
|
|
95
|
+
const headers = new Headers(init.headers);
|
|
96
|
+
if (session) headers.set("x-test-session", session);
|
|
97
|
+
return handle(new Request(`http://localhost${path}`, { ...init, headers }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test("returns the current session on every page and pages through every other one", async () => {
|
|
101
|
+
const seen: string[] = [];
|
|
102
|
+
let cursor: string | null = null;
|
|
103
|
+
let pages = 0;
|
|
104
|
+
do {
|
|
105
|
+
const response = await send(`/account/sessions?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`);
|
|
106
|
+
expect(response.status).toBe(200);
|
|
107
|
+
const text = await response.text();
|
|
108
|
+
expect(text).not.toContain("secret-");
|
|
109
|
+
const page = JSON.parse(text) as AccountSessionPage;
|
|
110
|
+
expect(page.current).toMatchObject({ id: "current", ipAddress: "203.0.113.7", userAgent: "Agent" });
|
|
111
|
+
expect(page.total).toBe(OTHER_SESSIONS + 1);
|
|
112
|
+
seen.push(...page.items.map((item) => item.id));
|
|
113
|
+
cursor = page.nextCursor;
|
|
114
|
+
pages += 1;
|
|
115
|
+
} while (cursor);
|
|
116
|
+
|
|
117
|
+
expect(pages).toBe(2);
|
|
118
|
+
expect(seen).toHaveLength(OTHER_SESSIONS);
|
|
119
|
+
expect(new Set(seen).size).toBe(OTHER_SESSIONS);
|
|
120
|
+
expect(seen).not.toContain("current");
|
|
121
|
+
expect(seen).not.toContain("foreign");
|
|
122
|
+
expect(seen[0]).toBe(`other-${OTHER_SESSIONS - 1}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("refuses anonymous callers and a session too old to list sessions", async () => {
|
|
126
|
+
expect((await send("/account/sessions", {}, null)).status).toBe(401);
|
|
127
|
+
await db`UPDATE "session" SET "createdAt" = ${new Date(now - 2 * DAY_MS).toISOString()} WHERE "id" = 'current'`;
|
|
128
|
+
const stale = await send("/account/sessions");
|
|
129
|
+
expect(stale.status).toBe(403);
|
|
130
|
+
expect(await stale.json()).toEqual({ code: "SESSION_NOT_FRESH" });
|
|
131
|
+
freshAge = 0;
|
|
132
|
+
expect((await send("/account/sessions")).status).toBe(200);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("revokes one of the caller's sessions by id with the token looked up on the server", async () => {
|
|
136
|
+
const response = await send("/account/sessions/other-3", { method: "DELETE", headers: { cookie: "session=1" } });
|
|
137
|
+
|
|
138
|
+
expect(response.status).toBe(204);
|
|
139
|
+
expect(revoke).toHaveBeenCalledTimes(1);
|
|
140
|
+
const [token, headers] = revoke.mock.calls[0] ?? [];
|
|
141
|
+
expect(token).toBe("secret-3");
|
|
142
|
+
expect(headers?.get("cookie")).toBe("session=1");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("does not reveal or revoke another user's session", async () => {
|
|
146
|
+
const response = await send("/account/sessions/foreign", { method: "DELETE" });
|
|
147
|
+
|
|
148
|
+
expect(response.status).toBe(404);
|
|
149
|
+
expect(await response.json()).toEqual({ code: "ACCOUNT_SESSION_NOT_FOUND" });
|
|
150
|
+
expect(revoke).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
|
+
import { Elysia, t } from "elysia";
|
|
3
|
+
import type { AuthService, AuthSession } from "../auth/auth.service";
|
|
4
|
+
import type { ElysiaPlugin } from "../core/services";
|
|
5
|
+
import {
|
|
6
|
+
ADMIN_SESSIONS_MAX_LIMIT,
|
|
7
|
+
type AdminSessionsService,
|
|
8
|
+
type AdminSessionView,
|
|
9
|
+
} from "../admin-sessions/admin-sessions.service";
|
|
10
|
+
|
|
11
|
+
export const ACCOUNT_SESSION_NOT_FOUND = "ACCOUNT_SESSION_NOT_FOUND";
|
|
12
|
+
/** Better Auth's code for a session older than `session.freshAge`. */
|
|
13
|
+
export const SESSION_NOT_FRESH = "SESSION_NOT_FRESH";
|
|
14
|
+
|
|
15
|
+
/** One of the caller's own sessions. The bearer token never leaves the server. */
|
|
16
|
+
export interface AccountSessionView {
|
|
17
|
+
id: string;
|
|
18
|
+
ipAddress: string | null;
|
|
19
|
+
userAgent: string | null;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
expiresAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AccountSessionPage {
|
|
25
|
+
/** The session making the request, on every page. */
|
|
26
|
+
current: AccountSessionView | null;
|
|
27
|
+
/** The caller's other active sessions, newest first. */
|
|
28
|
+
items: AccountSessionView[];
|
|
29
|
+
nextCursor: string | null;
|
|
30
|
+
/** Every active session of the caller, the current one included. */
|
|
31
|
+
total: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Revokes one of the caller's sessions by its bearer token. */
|
|
35
|
+
export type OwnSessionRevoker = (sessionToken: string, headers: Headers) => Promise<void>;
|
|
36
|
+
|
|
37
|
+
export interface AccountSessionRoutesOptions {
|
|
38
|
+
auth: Pick<AuthService, "requireSession">;
|
|
39
|
+
sessions: AdminSessionsService;
|
|
40
|
+
revoke: OwnSessionRevoker;
|
|
41
|
+
/** Better Auth's `session.freshAge` in seconds; 0 turns the check off. */
|
|
42
|
+
freshAgeSeconds: () => Promise<number>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function accountView(session: AdminSessionView): AccountSessionView {
|
|
46
|
+
return {
|
|
47
|
+
id: session.id,
|
|
48
|
+
ipAddress: session.ipAddress,
|
|
49
|
+
userAgent: session.userAgent,
|
|
50
|
+
createdAt: session.createdAt,
|
|
51
|
+
expiresAt: session.expiresAt,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Better Auth lists sessions only for a fresh session; this list keeps that rule.
|
|
56
|
+
function requireFresh(session: AuthSession, freshAgeSeconds: number): void {
|
|
57
|
+
if (freshAgeSeconds === 0) return;
|
|
58
|
+
const createdAt = new Date(session.session.createdAt as string | Date).getTime();
|
|
59
|
+
if (Date.now() - createdAt >= freshAgeSeconds * 1000) {
|
|
60
|
+
throw new AppException(SESSION_NOT_FRESH, "Session is not fresh", 403);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The signed-in user's own sessions, paged on the server. Better Auth's
|
|
66
|
+
* `/list-sessions` returns at most 100 sessions, which could leave out the
|
|
67
|
+
* current one.
|
|
68
|
+
*/
|
|
69
|
+
export function accountSessionRoutes({
|
|
70
|
+
auth,
|
|
71
|
+
sessions,
|
|
72
|
+
revoke,
|
|
73
|
+
freshAgeSeconds,
|
|
74
|
+
}: AccountSessionRoutesOptions): ElysiaPlugin {
|
|
75
|
+
return new Elysia({ name: "podokit.account.sessions" })
|
|
76
|
+
.get("/account/sessions", async ({ request, query }) => {
|
|
77
|
+
const session = await auth.requireSession(request);
|
|
78
|
+
requireFresh(session, await freshAgeSeconds());
|
|
79
|
+
const currentId = String(session.session.id);
|
|
80
|
+
const [page, current] = await Promise.all([
|
|
81
|
+
sessions.list({ ...query, userId: session.user.id, excludeId: currentId }),
|
|
82
|
+
sessions.find(currentId),
|
|
83
|
+
]);
|
|
84
|
+
const result: AccountSessionPage = {
|
|
85
|
+
current: current ? accountView(current) : null,
|
|
86
|
+
items: page.items.map(accountView),
|
|
87
|
+
nextCursor: page.nextCursor,
|
|
88
|
+
total: page.total + (current ? 1 : 0),
|
|
89
|
+
};
|
|
90
|
+
return result;
|
|
91
|
+
}, {
|
|
92
|
+
query: t.Object({
|
|
93
|
+
cursor: t.Optional(t.String({ minLength: 1, maxLength: 512 })),
|
|
94
|
+
limit: t.Optional(t.Numeric({ minimum: 1, maximum: ADMIN_SESSIONS_MAX_LIMIT })),
|
|
95
|
+
}),
|
|
96
|
+
detail: {
|
|
97
|
+
tags: ["account"],
|
|
98
|
+
summary: "List the current user's active sessions",
|
|
99
|
+
description: "`current` is the session making the request; `items` are the other sessions, newest first, keyset-paged with `cursor`. Session tokens are never returned. A session older than Better Auth's `session.freshAge` gets 403 `SESSION_NOT_FRESH`, as with `/api/auth/list-sessions`.",
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
.delete("/account/sessions/:id", async ({ request, params, set }) => {
|
|
103
|
+
const session = await auth.requireSession(request);
|
|
104
|
+
const token = await sessions.tokenOf(params.id, session.user.id);
|
|
105
|
+
if (!token) throw new AppException(ACCOUNT_SESSION_NOT_FOUND, "Session not found.", 404);
|
|
106
|
+
await revoke(token, request.headers);
|
|
107
|
+
set.status = 204;
|
|
108
|
+
}, {
|
|
109
|
+
params: t.Object({ id: t.String({ minLength: 1, maxLength: 255 }) }),
|
|
110
|
+
detail: { tags: ["account"], summary: "Revoke one of the current user's sessions by id" },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
2
|
import { APIError } from "better-auth/api";
|
|
3
|
+
import type { OwnSessionRevoker } from "../account-sessions/account-sessions.routes";
|
|
3
4
|
import type { SessionRevoker } from "./admin-sessions.routes";
|
|
4
5
|
|
|
5
6
|
export const ADMIN_SESSION_REVOKE_FAILED = "ADMIN_SESSION_REVOKE_FAILED";
|
|
7
|
+
export const ACCOUNT_SESSION_REVOKE_FAILED = "ACCOUNT_SESSION_REVOKE_FAILED";
|
|
6
8
|
|
|
7
9
|
export interface RevokeUserSessionApi {
|
|
8
10
|
revokeUserSession(input: { body: { sessionToken: string }; headers: Headers }): Promise<unknown>;
|
|
9
11
|
}
|
|
10
12
|
|
|
13
|
+
export interface RevokeSessionApi {
|
|
14
|
+
revokeSession(input: { body: { token: string }; headers: Headers }): Promise<unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
/**
|
|
12
18
|
* Revokes through the Better Auth admin endpoint rather than deleting the row, so
|
|
13
19
|
* its permission check and after-hooks (including the audit log) still run.
|
|
@@ -24,3 +30,21 @@ export function createSessionRevoker(api: () => RevokeUserSessionApi): SessionRe
|
|
|
24
30
|
}
|
|
25
31
|
};
|
|
26
32
|
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Revokes one of the caller's own sessions through Better Auth's
|
|
36
|
+
* `/revoke-session`, so its ownership check and after-hooks (including the
|
|
37
|
+
* audit log's `session.revoke`) still run.
|
|
38
|
+
*/
|
|
39
|
+
export function createOwnSessionRevoker(api: () => RevokeSessionApi): OwnSessionRevoker {
|
|
40
|
+
return async (token, headers) => {
|
|
41
|
+
try {
|
|
42
|
+
await api().revokeSession({ body: { token }, headers });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error instanceof APIError) {
|
|
45
|
+
throw new AppException(ACCOUNT_SESSION_REVOKE_FAILED, error.message, error.statusCode);
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -183,5 +183,23 @@ describe("AdminSessionsService", () => {
|
|
|
183
183
|
|
|
184
184
|
expect(await service.tokenOf("a")).toBe("token-a");
|
|
185
185
|
expect(await service.tokenOf("missing")).toBeNull();
|
|
186
|
+
// With a user, only that user's session.
|
|
187
|
+
expect(await service.tokenOf("a", "alice")).toBe("token-a");
|
|
188
|
+
expect(await service.tokenOf("a", "bob")).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("leaves one session out and finds an active session by id", async () => {
|
|
192
|
+
await seed(db, [
|
|
193
|
+
{ id: "a", userId: "alice", createdAt: "2026-01-01T00:00:01.000Z" },
|
|
194
|
+
{ id: "b", userId: "alice", createdAt: "2026-01-01T00:00:02.000Z" },
|
|
195
|
+
{ id: "old", userId: "alice", createdAt: "2026-01-01T00:00:03.000Z", expiresAt: "2020-01-01T00:00:00.000Z" },
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
const page = await service.list({ userId: "alice", excludeId: "b" });
|
|
199
|
+
expect(page.items.map((item) => item.id)).toEqual(["a"]);
|
|
200
|
+
expect(page.total).toBe(1);
|
|
201
|
+
expect(await service.find("b")).toMatchObject({ id: "b", userId: "alice", ipAddress: "203.0.113.7" });
|
|
202
|
+
expect(await service.find("old")).toBeNull();
|
|
203
|
+
expect(await service.find("missing")).toBeNull();
|
|
186
204
|
});
|
|
187
205
|
});
|
|
@@ -30,6 +30,8 @@ export interface AdminSessionQuery {
|
|
|
30
30
|
cursor?: string;
|
|
31
31
|
search?: string;
|
|
32
32
|
userId?: string;
|
|
33
|
+
/** A session to leave out, such as the caller's own. */
|
|
34
|
+
excludeId?: string;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
interface SessionRow {
|
|
@@ -76,6 +78,20 @@ function decodeCursor(value: string): Cursor {
|
|
|
76
78
|
throw new AppException(ADMIN_SESSION_CURSOR_INVALID, "The sessions cursor is invalid.", 400);
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
function view(row: SessionRow): AdminSessionView {
|
|
82
|
+
return {
|
|
83
|
+
id: row.id,
|
|
84
|
+
userId: row.userId,
|
|
85
|
+
userEmail: row.userEmail,
|
|
86
|
+
userName: row.userName ?? "",
|
|
87
|
+
ipAddress: row.ipAddress,
|
|
88
|
+
userAgent: row.userAgent,
|
|
89
|
+
impersonatedBy: row.impersonatedBy,
|
|
90
|
+
createdAt: iso(row.createdAt),
|
|
91
|
+
expiresAt: iso(row.expiresAt),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
function likePattern(search: string): string {
|
|
80
96
|
return `%${search.replace(/[\\%_]/g, (character) => `\\${character}`)}%`;
|
|
81
97
|
}
|
|
@@ -100,6 +116,7 @@ export class AdminSessionsService {
|
|
|
100
116
|
const filters = (): SQL.Query<unknown> => this.sql`
|
|
101
117
|
WHERE s."expiresAt" > ${now}
|
|
102
118
|
${query.userId ? this.sql`AND s."userId" = ${query.userId}` : this.sql``}
|
|
119
|
+
${query.excludeId ? this.sql`AND s."id" <> ${query.excludeId}` : this.sql``}
|
|
103
120
|
${search ? this.searchFilter(likePattern(search)) : this.sql``}
|
|
104
121
|
`;
|
|
105
122
|
const [rows, totals] = await Promise.all([
|
|
@@ -127,17 +144,7 @@ export class AdminSessionsService {
|
|
|
127
144
|
const page = rows.slice(0, limit);
|
|
128
145
|
const last = page.at(-1);
|
|
129
146
|
return {
|
|
130
|
-
items: page.map(
|
|
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
|
-
})),
|
|
147
|
+
items: page.map(view),
|
|
141
148
|
// The cursor keeps the database's own text form so sub-millisecond
|
|
142
149
|
// timestamps cannot skip or repeat rows at a page boundary.
|
|
143
150
|
nextCursor: rows.length > limit && last ? encodeCursor({ createdAt: last.cursorAt, id: last.id }) : null,
|
|
@@ -145,9 +152,25 @@ export class AdminSessionsService {
|
|
|
145
152
|
};
|
|
146
153
|
}
|
|
147
154
|
|
|
148
|
-
|
|
155
|
+
/** One active session, or null when it does not exist or has expired. */
|
|
156
|
+
async find(sessionId: string): Promise<AdminSessionView | null> {
|
|
157
|
+
const now = new Date().toISOString();
|
|
158
|
+
const rows = await this.sql<SessionRow[]>`
|
|
159
|
+
SELECT s."id", s."userId", u."email" AS "userEmail", u."name" AS "userName",
|
|
160
|
+
s."ipAddress", s."userAgent", s."impersonatedBy", s."createdAt", s."expiresAt",
|
|
161
|
+
CAST(s."createdAt" AS TEXT) AS "cursorAt"
|
|
162
|
+
FROM "session" s
|
|
163
|
+
JOIN "user" u ON u."id" = s."userId"
|
|
164
|
+
WHERE s."id" = ${sessionId} AND s."expiresAt" > ${now}
|
|
165
|
+
`;
|
|
166
|
+
return rows[0] ? view(rows[0]) : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The token of a session, only when `userId` is given and owns it. */
|
|
170
|
+
async tokenOf(sessionId: string, userId?: string): Promise<string | null> {
|
|
149
171
|
const rows = await this.sql<Array<{ token: string }>>`
|
|
150
|
-
SELECT "token" FROM "session"
|
|
172
|
+
SELECT "token" FROM "session"
|
|
173
|
+
WHERE "id" = ${sessionId} ${userId === undefined ? this.sql`` : this.sql`AND "userId" = ${userId}`}
|
|
151
174
|
`;
|
|
152
175
|
return rows[0]?.token ?? null;
|
|
153
176
|
}
|
|
@@ -5,8 +5,14 @@ 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 { accountSessionRoutes } from "./account-sessions/account-sessions.routes";
|
|
8
9
|
import { adminSessionRoutes } from "./admin-sessions/admin-sessions.routes";
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createOwnSessionRevoker,
|
|
12
|
+
createSessionRevoker,
|
|
13
|
+
type RevokeSessionApi,
|
|
14
|
+
type RevokeUserSessionApi,
|
|
15
|
+
} from "./admin-sessions/admin-sessions.revoker";
|
|
10
16
|
import { AdminSessionsService } from "./admin-sessions/admin-sessions.service";
|
|
11
17
|
import { adminUserRoutes } from "./admin-users/admin-users.routes";
|
|
12
18
|
import { AdminUsersService } from "./admin-users/admin-users.service";
|
|
@@ -57,6 +63,12 @@ const adminPlugin: AppPlugin = ({ services }) => {
|
|
|
57
63
|
sessions: services.resolve(ADMIN_SESSIONS),
|
|
58
64
|
revoke: createSessionRevoker(() => getAuth().api as unknown as RevokeUserSessionApi),
|
|
59
65
|
}))
|
|
66
|
+
.use(accountSessionRoutes({
|
|
67
|
+
auth,
|
|
68
|
+
sessions: services.resolve(ADMIN_SESSIONS),
|
|
69
|
+
revoke: createOwnSessionRevoker(() => getAuth().api as unknown as RevokeSessionApi),
|
|
70
|
+
freshAgeSeconds: async () => (await getAuth().$context).sessionConfig.freshAge,
|
|
71
|
+
}))
|
|
60
72
|
.use(adminUserRoutes({ auth, users: services.resolve(ADMIN_USERS) }))
|
|
61
73
|
.use(siteAssetRoutes(site, profileImages))
|
|
62
74
|
.get("/site/settings", async () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ApiError } from "@podosoft/podokit-api-client";
|
|
2
2
|
import type { RequestEvent } from "@sveltejs/kit";
|
|
3
3
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { BackendUnavailableError } from "#lib/server/api.js";
|
|
4
5
|
import { handle } from "./hooks.server";
|
|
5
6
|
|
|
6
7
|
type SessionResult = {
|
|
@@ -13,7 +14,8 @@ const client = vi.hoisted(() => ({
|
|
|
13
14
|
getSession: vi.fn<() => Promise<SessionResult>>(),
|
|
14
15
|
}));
|
|
15
16
|
|
|
16
|
-
vi.mock("#lib/server/api.js", () => ({
|
|
17
|
+
vi.mock("#lib/server/api.js", async (importOriginal) => ({
|
|
18
|
+
...(await importOriginal<typeof import("#lib/server/api.js")>()),
|
|
17
19
|
serverApiClient: () => ({ get: client.get, auth: { getSession: client.getSession } }),
|
|
18
20
|
}));
|
|
19
21
|
|
|
@@ -98,6 +100,16 @@ describe("handle", () => {
|
|
|
98
100
|
});
|
|
99
101
|
});
|
|
100
102
|
|
|
103
|
+
it("treats an API known to be unreachable as an outage", async () => {
|
|
104
|
+
client.get.mockRejectedValue(new BackendUnavailableError());
|
|
105
|
+
client.getSession.mockRejectedValue(new BackendUnavailableError());
|
|
106
|
+
|
|
107
|
+
await expect(handle({ event: requestEvent("/account"), resolve })).rejects.toMatchObject({ status: 503 });
|
|
108
|
+
const event = requestEvent("/");
|
|
109
|
+
await handle({ event, resolve });
|
|
110
|
+
expect(event.locals).toMatchObject({ site: null, siteUnavailable: true, authUnavailable: true, user: null });
|
|
111
|
+
});
|
|
112
|
+
|
|
101
113
|
it("confirms an anonymous visitor on a 401 session response", async () => {
|
|
102
114
|
client.get.mockResolvedValue({ locale: null });
|
|
103
115
|
client.getSession.mockResolvedValue({ data: null, error: { status: 401 } });
|
|
@@ -121,4 +133,15 @@ describe("handle", () => {
|
|
|
121
133
|
expect(client.get).not.toHaveBeenCalled();
|
|
122
134
|
expect(client.getSession).not.toHaveBeenCalled();
|
|
123
135
|
});
|
|
136
|
+
|
|
137
|
+
it("answers the health route without runtime lookups", async () => {
|
|
138
|
+
client.get.mockRejectedValue(new BackendUnavailableError());
|
|
139
|
+
client.getSession.mockRejectedValue(new BackendUnavailableError());
|
|
140
|
+
|
|
141
|
+
await handle({ event: requestEvent("/healthz"), resolve });
|
|
142
|
+
|
|
143
|
+
expect(client.get).not.toHaveBeenCalled();
|
|
144
|
+
expect(client.getSession).not.toHaveBeenCalled();
|
|
145
|
+
expect(resolve).toHaveBeenCalledTimes(1);
|
|
146
|
+
});
|
|
124
147
|
});
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { listAccountSessions, revokeAccountSession } from "./account-sessions";
|
|
3
|
+
|
|
4
|
+
describe("account sessions", () => {
|
|
5
|
+
it("asks for one page with the cursor", async () => {
|
|
6
|
+
const paths: string[] = [];
|
|
7
|
+
const client = {
|
|
8
|
+
get: <T>(path: string): Promise<T> => {
|
|
9
|
+
paths.push(path);
|
|
10
|
+
return Promise.resolve({ current: null, items: [], nextCursor: null, total: 0 } as unknown as T);
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
await listAccountSessions(client, { limit: 20, cursor: "abc" });
|
|
15
|
+
await listAccountSessions(client, { limit: 20, cursor: null });
|
|
16
|
+
|
|
17
|
+
expect(paths).toEqual(["/account/sessions?limit=20&cursor=abc", "/account/sessions?limit=20"]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("revokes by id", async () => {
|
|
21
|
+
const paths: string[] = [];
|
|
22
|
+
const client = {
|
|
23
|
+
del: <T>(path: string): Promise<T> => {
|
|
24
|
+
paths.push(path);
|
|
25
|
+
return Promise.resolve(undefined as unknown as T);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
await revokeAccountSession(client, "id/with slash");
|
|
30
|
+
|
|
31
|
+
expect(paths).toEqual(["/account/sessions/id%2Fwith%20slash"]);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ApiClient } from "@podosoft/podokit-api-client";
|
|
2
|
+
|
|
3
|
+
/** One of the signed-in user's sessions from `GET /account/sessions`. The API never returns the token. */
|
|
4
|
+
export type AccountSession = {
|
|
5
|
+
id: string;
|
|
6
|
+
ipAddress: string | null;
|
|
7
|
+
userAgent: string | null;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
expiresAt: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type AccountSessionPage = {
|
|
13
|
+
/** The session making the request, on every page. */
|
|
14
|
+
current: AccountSession | null;
|
|
15
|
+
/** The other sessions, newest first. */
|
|
16
|
+
items: AccountSession[];
|
|
17
|
+
nextCursor: string | null;
|
|
18
|
+
/** Every active session, the current one included. */
|
|
19
|
+
total: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function listAccountSessions(
|
|
23
|
+
client: Pick<ApiClient, "get">,
|
|
24
|
+
query: { limit: number; cursor?: string | null },
|
|
25
|
+
): Promise<AccountSessionPage> {
|
|
26
|
+
const params = new URLSearchParams({ limit: String(query.limit) });
|
|
27
|
+
if (query.cursor) params.set("cursor", query.cursor);
|
|
28
|
+
return client.get<AccountSessionPage>(`/account/sessions?${params.toString()}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Revokes one of the user's own sessions by id; the server looks up the token. */
|
|
32
|
+
export function revokeAccountSession(client: Pick<ApiClient, "del">, id: string): Promise<void> {
|
|
33
|
+
return client.del<void>(`/account/sessions/${encodeURIComponent(id)}`);
|
|
34
|
+
}
|