@podosoft/podokit 1.4.2 → 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.
Files changed (66) hide show
  1. package/dist/deploy-compose-profile.js +2 -2
  2. package/dist/deploy-compose-render.js +1 -0
  3. package/dist/deploy-profile.js +2 -2
  4. package/dist/deploy-render.js +2 -0
  5. package/dist/deploy-schema.d.ts +7 -0
  6. package/dist/deploy-schema.js +40 -0
  7. package/dist/lockfile.js +3 -1
  8. package/dist/provider.d.ts +1 -0
  9. package/dist/provider.js +5 -2
  10. package/dist/templates/fullstack/AGENTS.md +18 -5
  11. package/dist/templates/fullstack/apps/api/package.json +1 -1
  12. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
  13. package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
  14. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
  15. package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
  16. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  17. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  18. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  19. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  20. package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
  21. package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
  22. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +291 -36
  23. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +196 -41
  24. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  25. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
  26. package/dist/templates/fullstack/dot-env.docker.example +2 -0
  27. package/dist/templates/fullstack/dot-env.example +17 -1
  28. package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  31. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  32. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  39. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  40. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
  41. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
  42. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  43. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  44. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +7 -4
  45. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
  46. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  47. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
  48. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
  49. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
  50. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  51. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  52. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +8 -3
  53. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  54. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  55. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  56. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  57. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  58. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  59. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  60. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  61. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  62. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  63. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
  64. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  65. package/dist/templates/todo/AGENTS.md +41 -5
  66. package/package.json +2 -2
@@ -59,3 +59,28 @@ test("users toolbar can pick the search field", async ({ page }) => {
59
59
  await page.getByRole("option", { name: "Name", exact: true }).click();
60
60
  await expect(fieldSelect).toContainText("Name");
61
61
  });
62
+
63
+ test("users toolbar keeps the search box usable at phone width", async ({ page }) => {
64
+ await page.setViewportSize({ width: 390, height: 844 });
65
+ await ready(page, "/admin/users");
66
+ const input = await page.locator("#toolbar-search").boundingBox();
67
+ const field = await page.locator('button[data-slot="select-trigger"]').last().boundingBox();
68
+ const button = await page.getByRole("button", { name: "Search", exact: true }).boundingBox();
69
+ if (!input || !field || !button) throw new Error("expected the toolbar to render");
70
+ // The input wraps below the field select instead of shrinking beside it.
71
+ expect(input.width).toBeGreaterThanOrEqual(192);
72
+ expect(input.y).toBeGreaterThan(field.y + field.height);
73
+ expect(Math.abs(input.y + input.height / 2 - (button.y + button.height / 2))).toBeLessThan(1);
74
+ expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth))
75
+ .toBe(0);
76
+ });
77
+
78
+ test("users toolbar keeps the desktop search box on one row", async ({ page }) => {
79
+ await page.setViewportSize({ width: 1280, height: 800 });
80
+ await ready(page, "/admin/users");
81
+ const input = await page.locator("#toolbar-search").boundingBox();
82
+ const field = await page.locator('button[data-slot="select-trigger"]').last().boundingBox();
83
+ if (!input || !field) throw new Error("expected the toolbar to render");
84
+ expect(input.width).toBe(320);
85
+ expect(Math.abs(input.y + input.height / 2 - (field.y + field.height / 2))).toBeLessThan(1);
86
+ });
@@ -1,4 +1,5 @@
1
1
  import { createAuthMiddleware } from "better-auth/api";
2
+ import { clientAddressOf } from "../core/client-address";
2
3
  import { recordAudit } from "./audit-events";
3
4
  import { auditEnabled } from "./audit-enabled";
4
5
 
@@ -71,6 +72,6 @@ export const auditAfterHook = createAuthMiddleware(async (ctx) => {
71
72
  actorEmail: actor?.email ?? null,
72
73
  targetId,
73
74
  targetLabel,
74
- ip: ctx.headers?.get("x-forwarded-for") ?? null,
75
+ ip: clientAddressOf(ctx.headers) ?? null,
75
76
  });
76
77
  });
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
2
2
  import { SQL } from "bun";
3
3
  import type { QueryRunner } from "typeorm";
4
4
  import type { AuthSession } from "../auth/auth.service";
5
+ import { CLIENT_ADDRESS_HEADER } from "../core/client-address";
5
6
  import { InitAuditLogs1720300000000 } from "../migrations/1720300000000-InitAuditLogs";
6
7
  import { recordAudit, recordAuditLater } from "./audit-events";
7
8
  import { AppException } from "@podosoft/podokit-contracts";
@@ -176,6 +177,21 @@ describe("AuditService", () => {
176
177
  }
177
178
  });
178
179
 
180
+ test("records the address the API resolved, not the caller's forwarded header", async () => {
181
+ const capture = sqlCapture();
182
+ const service = new AuditService(capture.sql, { dialect: "postgres", enabled: on });
183
+ const session: AuthSession = { user: { id: "user-1" }, session: {} };
184
+ const headers = {
185
+ "x-forwarded-for": "198.51.100.66, 198.51.100.67",
186
+ [CLIENT_ADDRESS_HEADER]: "203.0.113.7",
187
+ };
188
+ await service.recordRequest("todo.create", new Request("http://localhost/todos", { headers }), session);
189
+ await service.recordRequest("todo.delete", new Request("http://localhost/todos", {
190
+ headers: { "x-forwarded-for": "198.51.100.66" },
191
+ }), session);
192
+ expect(capture.batches.map((batch) => batch[0]?.ip)).toEqual(["203.0.113.7", null]);
193
+ });
194
+
179
195
  test("a failing toggle read never breaks the caller", async () => {
180
196
  const sql = await sqliteDatabase();
181
197
  const service = new AuditService(sql, {
@@ -2,6 +2,7 @@ import type { SQL } from "bun";
2
2
  import { AppException } from "@podosoft/podokit-contracts";
3
3
  import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
4
4
  import type { AuthSession } from "../auth/auth.service";
5
+ import { clientAddressOf } from "../core/client-address";
5
6
  import { setAuditRecorder, type AuditEntry } from "./audit-events";
6
7
 
7
8
  export interface AuditLogView extends AuditEntry {
@@ -412,7 +413,7 @@ export class AuditService {
412
413
  targetId: target.id ?? null,
413
414
  targetLabel: target.label ?? null,
414
415
  metadata: target.metadata ?? null,
415
- ip: request.headers.get("x-forwarded-for") ?? null,
416
+ ip: clientAddressOf(request.headers) ?? null,
416
417
  };
417
418
  if (options.defer) this.recordLater(entry);
418
419
  else await this.record(entry);
@@ -8,6 +8,7 @@ import {
8
8
  OPENAPI,
9
9
  REQUEST_GUARDS,
10
10
  REQUEST_IDENTITY,
11
+ RESOLVED_SESSIONS,
11
12
  type AppPlugin,
12
13
  type PodokitModule,
13
14
  type ServiceKey,
@@ -158,7 +159,9 @@ export const authModule: PodokitModule = {
158
159
  setMailConfigStore(authConfigStore);
159
160
  const accessPolicy = services.resolve(ACCESS_POLICY);
160
161
  const settings = new SettingsService(database.sql, authConfigStore, publishConfigChanged);
161
- const authService = new AuthService(settings, accessPolicy);
162
+ const authService = new AuthService(settings, accessPolicy, {
163
+ resolvedSessions: services.resolve(RESOLVED_SESSIONS),
164
+ });
162
165
  services.register(SETTINGS, settings);
163
166
  services.register(AUTH_CONFIG, new AuthConfigService(database.sql, database.provider));
164
167
  services.register(AUTH, authService, () => authService.close());
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { AccessPolicy, type RequestIdentity } from "../core/services";
2
+ import { AccessPolicy, ResolvedSessions, type RequestIdentity, type ResolvedSession } from "../core/services";
3
3
  import type { SettingsService } from "../settings/settings.service";
4
4
  import {
5
5
  AuthService,
@@ -21,7 +21,7 @@ function session(
21
21
  }
22
22
 
23
23
  function identityFor(resolver: SessionResolver): RequestIdentity {
24
- return createRequestIdentity(new AuthService(settings(false), new AccessPolicy(), resolver));
24
+ return createRequestIdentity(new AuthService(settings(false), new AccessPolicy(), { resolveSession: resolver }));
25
25
  }
26
26
 
27
27
  describe("AuthService", () => {
@@ -33,12 +33,12 @@ describe("AuthService", () => {
33
33
  test("keeps explicitly public routes unauthenticated", async () => {
34
34
  const policy = new AccessPolicy();
35
35
  policy.register("GET", "/public", "public");
36
- const service = new AuthService(settings(false), policy, async () => null);
36
+ const service = new AuthService(settings(false), policy, { resolveSession: async () => null });
37
37
  await expect(service.guard(new Request("http://localhost/public"))).resolves.toBeUndefined();
38
38
  });
39
39
 
40
40
  test("requires a session by default", async () => {
41
- const service = new AuthService(settings(false), new AccessPolicy(), async () => null);
41
+ const service = new AuthService(settings(false), new AccessPolicy(), { resolveSession: async () => null });
42
42
  const result = service.guard(new Request("http://localhost/private"));
43
43
  await expect(result).rejects.toMatchObject({ code: "AUTH_REQUIRED", statusCode: 401 });
44
44
  });
@@ -47,7 +47,7 @@ describe("AuthService", () => {
47
47
  const service = new AuthService(
48
48
  settings(true),
49
49
  new AccessPolicy(),
50
- async () => session({ twoFactorEnabled: false }),
50
+ { resolveSession: async () => session({ twoFactorEnabled: false }) },
51
51
  );
52
52
  const result = service.guard(new Request("http://localhost/private"));
53
53
  await expect(result).rejects.toMatchObject({ code: "TWO_FACTOR_REQUIRED", statusCode: 403 });
@@ -57,13 +57,61 @@ describe("AuthService", () => {
57
57
  const service = new AuthService(
58
58
  settings(false),
59
59
  new AccessPolicy(),
60
- async () => session({ role: "user" }),
60
+ { resolveSession: async () => session({ role: "user" }) },
61
61
  );
62
62
  await expect(service.requireAdmin(new Request("http://localhost/account/settings")))
63
63
  .rejects.toMatchObject({ code: "ADMIN_REQUIRED", statusCode: 403 });
64
64
  });
65
65
  });
66
66
 
67
+ describe("AuthService resolved sessions", () => {
68
+ const expiresAt = new Date("2030-01-02T03:04:05.000Z");
69
+
70
+ function reporting(resolver: SessionResolver): { service: AuthService; reported: ResolvedSession[] } {
71
+ const resolvedSessions = new ResolvedSessions();
72
+ const reported: ResolvedSession[] = [];
73
+ resolvedSessions.listen((resolved) => reported.push(resolved));
74
+ return {
75
+ service: new AuthService(settings(false), new AccessPolicy(), { resolveSession: resolver, resolvedSessions }),
76
+ reported,
77
+ };
78
+ }
79
+
80
+ test("reports each resolved session once per request, before callers continue", async () => {
81
+ const resolver = mock(async () => session({}, { token: "session-token", expiresAt }));
82
+ const { service, reported } = reporting(resolver);
83
+ const request = new Request("http://localhost/private");
84
+
85
+ await service.requireSession(request);
86
+ expect(reported).toEqual([{ token: "session-token", userId: "user-1", expiresAt }]);
87
+ await service.session(request);
88
+ expect(resolver).toHaveBeenCalledTimes(1);
89
+ expect(reported).toHaveLength(1);
90
+
91
+ // A serialized expiry is accepted too.
92
+ const serialized = reporting(async () => session({}, { token: "t2", expiresAt: expiresAt.toISOString() }));
93
+ await serialized.service.session(new Request("http://localhost/private"));
94
+ expect(serialized.reported).toEqual([{ token: "t2", userId: "user-1", expiresAt }]);
95
+ });
96
+
97
+ test("reports nothing without a session, a token, or a valid expiry, and keeps lookup errors", async () => {
98
+ for (const resolver of [
99
+ async () => null,
100
+ async () => session({}, { expiresAt }),
101
+ async () => session({}, { token: "session-token", expiresAt: "soon" }),
102
+ ]) {
103
+ const { service, reported } = reporting(resolver);
104
+ await service.session(new Request("http://localhost/private"));
105
+ expect(reported).toEqual([]);
106
+ }
107
+ const failing = reporting(async () => {
108
+ throw new Error("database down");
109
+ });
110
+ await expect(failing.service.session(new Request("http://localhost/private"))).rejects.toThrow("database down");
111
+ expect(failing.reported).toEqual([]);
112
+ });
113
+ });
114
+
67
115
  describe("createRequestIdentity", () => {
68
116
  const request = (): Request => new Request("http://localhost/events");
69
117
 
@@ -1,5 +1,5 @@
1
1
  import { AppException } from "@podosoft/podokit-contracts";
2
- import type { AccessPolicy, RequestIdentity } from "../core/services";
2
+ import type { AccessPolicy, RequestIdentity, ResolvedSessions } from "../core/services";
3
3
  import type { SettingsService } from "../settings/settings.service";
4
4
  import { getAuth, refreshForRequest } from "./auth-provider";
5
5
  import { rethrowAuthError } from "./database-outage";
@@ -37,19 +37,34 @@ function activeOrganizationOf(session: AuthSession): string | undefined {
37
37
  return typeof value === "string" && value !== "" ? value : undefined;
38
38
  }
39
39
 
40
+ export interface AuthServiceOptions {
41
+ /** Looks a request's session up; Better Auth's getSession by default. */
42
+ resolveSession?: SessionResolver;
43
+ /** Receives every session this service resolves, such as for the rate limiter. */
44
+ resolvedSessions?: ResolvedSessions;
45
+ }
46
+
40
47
  export class AuthService {
41
48
  private readonly sessions = new WeakMap<Request, Promise<AuthSession | null>>();
49
+ private readonly resolveSession: SessionResolver;
50
+ private readonly resolvedSessions: ResolvedSessions | undefined;
42
51
 
43
52
  constructor(
44
53
  private readonly settings: SettingsService,
45
54
  private readonly accessPolicy: AccessPolicy,
46
- private readonly resolveSession: SessionResolver = defaultSessionResolver,
47
- ) {}
55
+ options: AuthServiceOptions = {},
56
+ ) {
57
+ this.resolveSession = options.resolveSession ?? defaultSessionResolver;
58
+ this.resolvedSessions = options.resolvedSessions;
59
+ }
48
60
 
49
61
  session(request: Request): Promise<AuthSession | null> {
50
62
  const cached = this.sessions.get(request);
51
63
  if (cached) return cached;
52
- const result = this.resolveSession(request.headers);
64
+ const result = this.resolveSession(request.headers).then((session) => {
65
+ if (session) this.report(session);
66
+ return session;
67
+ });
53
68
  this.sessions.set(request, result);
54
69
  return result;
55
70
  }
@@ -84,6 +99,13 @@ export class AuthService {
84
99
  async close(): Promise<void> {
85
100
  await closeAuthDatabase();
86
101
  }
102
+
103
+ private report(session: AuthSession): void {
104
+ const { token, expiresAt } = session.session;
105
+ const expires = expiresAt instanceof Date ? expiresAt : new Date(String(expiresAt));
106
+ if (!this.resolvedSessions || typeof token !== "string" || Number.isNaN(expires.getTime())) return;
107
+ this.resolvedSessions.report({ token, userId: session.user.id, expiresAt: expires });
108
+ }
87
109
  }
88
110
 
89
111
  /** Answers identity questions for other modules from the one session lookup memoized per request. */
@@ -15,6 +15,8 @@ import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-contracts";
15
15
  import { runUserDeletedHandlers } from "./user-delete-handlers";
16
16
  import { authBaseUrl, authSecret } from "./auth-environment";
17
17
  import { isMaintenanceCommand } from "./maintenance";
18
+ import { CLIENT_ADDRESS_HEADER } from "../core/client-address";
19
+ import { revokeEveryOtherSession } from "./revoke-other-sessions";
18
20
  // podokit:begin:auth-imports
19
21
  // podokit:end:auth-imports
20
22
 
@@ -55,6 +57,8 @@ export function buildAuth(config: AuthConfig) {
55
57
  // First, so a disabled feature answers 404 before another plugin's onRequest
56
58
  // (such as the OIDC provider's discovery metadata) can answer.
57
59
  featureGatePlugin(gateSettings),
60
+ // "Sign out other sessions" revokes every other session, not only the first 100.
61
+ revokeEveryOtherSession(),
58
62
  twoFactor(),
59
63
  magicLink({
60
64
  sendMagicLink: async ({ email, url }: { email: string; url: string; token: string }) => {
@@ -235,9 +239,10 @@ export function buildAuth(config: AuthConfig) {
235
239
  baseURL: authBaseUrl(),
236
240
  advanced: {
237
241
  ipAddress: {
238
- // The API sits behind the SvelteKit server proxy, which forwards the
239
- // resolved client IP as x-forwarded-for; trust it so sessions record an IP.
240
- ipAddressHeaders: ["x-forwarded-for"],
242
+ // The address the API resolved behind its trusted proxies, which it sets
243
+ // on every request, so sessions and Better Auth's own rate limits use the
244
+ // same client address as the rest of the API.
245
+ ipAddressHeaders: [CLIENT_ADDRESS_HEADER],
241
246
  },
242
247
  ...(isMaintenanceCommand() ? { database: { validateSchema: false } } : {}),
243
248
  },
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { betterAuth, type BetterAuthPlugin } from "better-auth";
3
+ import { memoryAdapter } from "better-auth/adapters/memory";
4
+ import { revokeEveryOtherSession } from "./revoke-other-sessions";
5
+
6
+ type Row = Record<string, unknown>;
7
+
8
+ // A user signed in once more than Better Auth's default findMany limit of 100
9
+ // sessions per list allows, plus the 50 extra the endpoint used to leave behind.
10
+ const EXTRA_SESSIONS = 150;
11
+
12
+ async function signedInWithManySessions(plugins: BetterAuthPlugin[]): Promise<{
13
+ revokeOthers: () => Promise<unknown>;
14
+ sessions: () => Row[];
15
+ currentToken: string;
16
+ }> {
17
+ const db: Record<string, Row[]> = { user: [], session: [], account: [], verification: [] };
18
+ const auth = betterAuth({
19
+ database: memoryAdapter(db),
20
+ secret: "revoke-other-sessions-test-secret-0123456789",
21
+ baseURL: "http://localhost:5002",
22
+ emailAndPassword: { enabled: true },
23
+ plugins,
24
+ });
25
+ const response = await auth.api.signUpEmail({
26
+ body: { email: "many@example.com", password: "Podokit3e-Str0ng!pw", name: "Many" },
27
+ asResponse: true,
28
+ });
29
+ const cookie = response.headers.getSetCookie().map((value) => value.split(";")[0]).join("; ");
30
+ const context = await auth.$context;
31
+ const userId = String(db.user[0]?.id);
32
+ for (let index = 0; index < EXTRA_SESSIONS; index += 1) {
33
+ await context.internalAdapter.createSession(userId);
34
+ }
35
+ const currentToken = String((await auth.api.getSession({ headers: new Headers({ cookie }) }))?.session.token);
36
+ return {
37
+ revokeOthers: () => auth.api.revokeOtherSessions({ headers: new Headers({ cookie }) }),
38
+ sessions: () => db.session ?? [],
39
+ currentToken,
40
+ };
41
+ }
42
+
43
+ describe("revokeEveryOtherSession", () => {
44
+ test("Better Auth alone revokes only the first 100 other sessions", async () => {
45
+ const user = await signedInWithManySessions([]);
46
+ expect(user.sessions()).toHaveLength(EXTRA_SESSIONS + 1);
47
+ await user.revokeOthers();
48
+ expect(user.sessions().length).toBeGreaterThan(1);
49
+ });
50
+
51
+ test("revokes every other session and keeps the current one", async () => {
52
+ const user = await signedInWithManySessions([revokeEveryOtherSession()]);
53
+ await user.revokeOthers();
54
+ expect(user.sessions().map((session) => session.token)).toEqual([user.currentToken]);
55
+ });
56
+ });
@@ -0,0 +1,45 @@
1
+ import type { BetterAuthPlugin } from "better-auth";
2
+ import { createAuthMiddleware } from "better-auth/api";
3
+
4
+ /** Sessions read and revoked per pass. */
5
+ const BATCH_SIZE = 500;
6
+
7
+ /**
8
+ * Makes `/revoke-other-sessions` revoke every other session of the user.
9
+ * Better Auth's endpoint lists the sessions through the adapter's default
10
+ * findMany limit of 100 and revokes only those, so a user with more sessions
11
+ * kept the rest after "sign out other sessions". This hook runs after the
12
+ * endpoint and revokes the remainder in batches through the internal adapter,
13
+ * which also clears their secondary-storage entries when one is configured.
14
+ */
15
+ export function revokeEveryOtherSession(): BetterAuthPlugin {
16
+ return {
17
+ id: "podokit-revoke-every-other-session",
18
+ hooks: {
19
+ after: [
20
+ {
21
+ matcher: (context) => context.path === "/revoke-other-sessions",
22
+ handler: createAuthMiddleware(async (ctx) => {
23
+ const current = ctx.context.session;
24
+ if (!current) return;
25
+ let first: string | undefined;
26
+ for (;;) {
27
+ const others = await ctx.context.adapter.findMany<{ token: string }>({
28
+ model: "session",
29
+ where: [
30
+ { field: "userId", value: current.user.id },
31
+ { field: "token", value: current.session.token, operator: "ne" },
32
+ ],
33
+ limit: BATCH_SIZE,
34
+ });
35
+ // Stop when nothing is left, or when a pass removed nothing.
36
+ if (others.length === 0 || others[0]?.token === first) return;
37
+ first = others[0]?.token;
38
+ await ctx.context.internalAdapter.deleteSessions(others.map((session) => session.token));
39
+ }
40
+ }),
41
+ },
42
+ ],
43
+ },
44
+ };
45
+ }
@@ -1,12 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import {
3
- clientAddressFromProxy,
4
- rateLimitConfig,
5
- sessionSigningSecret,
6
- } from "./rate-limit.config";
2
+ import { rateLimitConfig, sessionSigningSecret } from "./rate-limit.config";
7
3
 
8
4
  describe("rate limit configuration", () => {
9
- test("validates route limits and proxy settings", () => {
5
+ test("validates route limits", () => {
10
6
  expect(
11
7
  rateLimitConfig({
12
8
  RATE_LIMIT_KEY_PREFIX: "podokit:tenant-a:rate-limit",
@@ -15,8 +11,6 @@ describe("rate limit configuration", () => {
15
11
  RATE_LIMIT_AUTH_TTL: "45",
16
12
  RATE_LIMIT_AUTH_MAX: "15",
17
13
  RATE_LIMIT_RUNTIME_MAX: "900",
18
- RATE_LIMIT_TRUSTED_PROXY_HOPS: "2",
19
- RATE_LIMIT_PROXY_HEADER: "X-Real-IP",
20
14
  RATE_LIMIT_STORAGE_TIMEOUT_MS: "750",
21
15
  RATE_LIMIT_UNAVAILABLE_RETRY_AFTER: "3",
22
16
  }),
@@ -27,69 +21,18 @@ describe("rate limit configuration", () => {
27
21
  authTtlSeconds: 45,
28
22
  authLimit: 15,
29
23
  runtimeLimit: 900,
30
- trustedProxyHops: 2,
31
- proxyHeader: "x-real-ip",
32
24
  storageTimeoutMs: 750,
33
25
  unavailableRetryAfterSeconds: 3,
34
26
  });
35
27
  expect(() => rateLimitConfig({ RATE_LIMIT_MAX: "0" })).toThrow(
36
28
  "RATE_LIMIT_MAX must be a positive integer",
37
29
  );
38
- expect(() =>
39
- rateLimitConfig({ RATE_LIMIT_TRUSTED_PROXY_HOPS: "-1" }),
40
- ).toThrow("must be a non-negative integer");
41
- expect(() =>
42
- rateLimitConfig({ RATE_LIMIT_PROXY_HEADER: "invalid header" }),
43
- ).toThrow("must be a valid HTTP header name");
44
30
  expect(() => rateLimitConfig({ RATE_LIMIT_KEY_PREFIX: "invalid prefix!" })).toThrow(
45
31
  "RATE_LIMIT_KEY_PREFIX must be 1-128 characters",
46
32
  );
47
33
  expect(rateLimitConfig({}).keyPrefix).toBe("podokit:rate-limit");
48
34
  });
49
35
 
50
- test("selects only the address outside the configured trusted proxy depth", () => {
51
- const headers = {
52
- "x-forwarded-for": "203.0.113.7, 198.51.100.8",
53
- };
54
- expect(
55
- clientAddressFromProxy(headers, "10.0.0.9", {
56
- proxyHeader: "x-forwarded-for",
57
- trustedProxyHops: 2,
58
- }),
59
- ).toBe("203.0.113.7");
60
- expect(
61
- clientAddressFromProxy(headers, "10.0.0.9", {
62
- proxyHeader: "x-forwarded-for",
63
- trustedProxyHops: 1,
64
- }),
65
- ).toBe("198.51.100.8");
66
- expect(
67
- clientAddressFromProxy(headers, "10.0.0.9", {
68
- proxyHeader: "x-forwarded-for",
69
- trustedProxyHops: 0,
70
- }),
71
- ).toBe("10.0.0.9");
72
- });
73
-
74
- test("ignores malformed or incomplete forwarded chains", () => {
75
- expect(
76
- clientAddressFromProxy(
77
- { "x-forwarded-for": "attacker-controlled" },
78
- "::ffff:10.0.0.9",
79
- {
80
- proxyHeader: "x-forwarded-for",
81
- trustedProxyHops: 1,
82
- },
83
- ),
84
- ).toBe("10.0.0.9");
85
- expect(
86
- clientAddressFromProxy({}, "10.0.0.9", {
87
- proxyHeader: "x-forwarded-for",
88
- trustedProxyHops: 1,
89
- }),
90
- ).toBe("10.0.0.9");
91
- });
92
-
93
36
  test("reads the session signing secret the way the auth module does", () => {
94
37
  expect(
95
38
  sessionSigningSecret({ BETTER_AUTH_SECRET: " session-secret-with-at-least-32-chars " }),
@@ -1,5 +1,3 @@
1
- import { isIP } from "node:net";
2
-
3
1
  export interface RateLimitConfig {
4
2
  keyPrefix: string;
5
3
  ttlSeconds: number;
@@ -7,8 +5,6 @@ export interface RateLimitConfig {
7
5
  authTtlSeconds: number;
8
6
  authLimit: number;
9
7
  runtimeLimit: number;
10
- trustedProxyHops: number;
11
- proxyHeader: string;
12
8
  storageTimeoutMs: number;
13
9
  unavailableRetryAfterSeconds: number;
14
10
  }
@@ -32,23 +28,6 @@ function positiveInteger(name: string, value: string | undefined, fallback: numb
32
28
  return parsed;
33
29
  }
34
30
 
35
- function nonNegativeInteger(name: string, value: string | undefined, fallback: number): number {
36
- if (value === undefined || value === "") return fallback;
37
- const parsed = Number(value);
38
- if (!Number.isInteger(parsed) || parsed < 0) {
39
- throw new Error(`${name} must be a non-negative integer`);
40
- }
41
- return parsed;
42
- }
43
-
44
- function headerName(value: string | undefined): string {
45
- const normalized = (value || "x-forwarded-for").trim().toLowerCase();
46
- if (!/^[a-z0-9!#$%&'*+.^_`|~-]+$/.test(normalized)) {
47
- throw new Error("RATE_LIMIT_PROXY_HEADER must be a valid HTTP header name");
48
- }
49
- return normalized;
50
- }
51
-
52
31
  export function rateLimitConfig(env: NodeJS.ProcessEnv = process.env): RateLimitConfig {
53
32
  return {
54
33
  keyPrefix: keyPrefix(env.RATE_LIMIT_KEY_PREFIX),
@@ -65,12 +44,6 @@ export function rateLimitConfig(env: NodeJS.ProcessEnv = process.env): RateLimit
65
44
  env.RATE_LIMIT_RUNTIME_MAX,
66
45
  1000,
67
46
  ),
68
- trustedProxyHops: nonNegativeInteger(
69
- "RATE_LIMIT_TRUSTED_PROXY_HOPS",
70
- env.RATE_LIMIT_TRUSTED_PROXY_HOPS,
71
- 0,
72
- ),
73
- proxyHeader: headerName(env.RATE_LIMIT_PROXY_HEADER),
74
47
  storageTimeoutMs: positiveInteger(
75
48
  "RATE_LIMIT_STORAGE_TIMEOUT_MS",
76
49
  env.RATE_LIMIT_STORAGE_TIMEOUT_MS,
@@ -90,41 +63,3 @@ export function rateLimitConfig(env: NodeJS.ProcessEnv = process.env): RateLimit
90
63
  export function sessionSigningSecret(env: NodeJS.ProcessEnv = process.env): string | undefined {
91
64
  return env.BETTER_AUTH_SECRET?.trim() || undefined;
92
65
  }
93
-
94
- function normalizeIp(value: string | undefined): string | undefined {
95
- if (!value) return undefined;
96
- const trimmed = value.trim();
97
- const normalized = trimmed.startsWith("::ffff:")
98
- ? trimmed.slice("::ffff:".length)
99
- : trimmed;
100
- return isIP(normalized) === 0 ? undefined : normalized;
101
- }
102
-
103
- function headerValue(headers: unknown, name: string): string | undefined {
104
- if (headers instanceof Headers) return headers.get(name) ?? undefined;
105
- if (!headers || typeof headers !== "object") return undefined;
106
- const value = (headers as Record<string, unknown>)[name];
107
- if (typeof value === "string") return value;
108
- if (!Array.isArray(value)) return undefined;
109
- const strings = value.filter((item): item is string => typeof item === "string");
110
- return strings.length > 0 ? strings.join(",") : undefined;
111
- }
112
-
113
- export function clientAddressFromProxy(
114
- headers: unknown,
115
- remoteAddress: string | undefined,
116
- config: Pick<RateLimitConfig, "proxyHeader" | "trustedProxyHops">,
117
- ): string {
118
- const direct = normalizeIp(remoteAddress) ?? "unknown";
119
- if (config.trustedProxyHops === 0) return direct;
120
-
121
- const forwarded = headerValue(headers, config.proxyHeader)
122
- ?.split(",")
123
- .map((value) => value.trim())
124
- .filter(Boolean) ?? [];
125
- const chain = [...forwarded, direct];
126
- if (chain.length <= config.trustedProxyHops) return direct;
127
-
128
- const selected = chain[chain.length - config.trustedProxyHops - 1];
129
- return normalizeIp(selected) ?? direct;
130
- }