@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.
Files changed (97) hide show
  1. package/dist/deploy-compose-profile.js +2 -2
  2. package/dist/deploy-compose-render.js +3 -1
  3. package/dist/deploy-profile.js +2 -2
  4. package/dist/deploy-render.js +16 -8
  5. package/dist/deploy-schema.d.ts +7 -0
  6. package/dist/deploy-schema.js +40 -0
  7. package/dist/lockfile.d.ts +8 -1
  8. package/dist/lockfile.js +23 -4
  9. package/dist/provider.d.ts +1 -0
  10. package/dist/provider.js +5 -2
  11. package/dist/templates/fullstack/AGENTS.md +27 -2
  12. package/dist/templates/fullstack/README.md +1 -1
  13. package/dist/templates/fullstack/apps/api/package.json +1 -1
  14. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
  15. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -2
  16. package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
  17. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
  18. package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
  19. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  20. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  21. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  22. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  23. package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
  24. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +66 -0
  25. package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
  26. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
  27. package/dist/templates/fullstack/apps/web/Dockerfile +5 -1
  28. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
  29. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
  30. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +436 -6
  31. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +352 -38
  32. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  33. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
  34. package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
  35. package/dist/templates/fullstack/dot-env.docker.example +6 -0
  36. package/dist/templates/fullstack/dot-env.example +23 -0
  37. package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
  38. package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
  39. package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  45. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
  53. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  54. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
  55. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
  56. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
  57. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
  58. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
  59. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
  60. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
  61. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  63. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +54 -47
  64. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
  65. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
  66. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
  67. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
  68. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
  69. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
  70. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
  71. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
  72. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
  74. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  75. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
  76. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
  77. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
  78. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  79. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  80. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +19 -8
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  85. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  86. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  87. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  88. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  89. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  90. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  91. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  92. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  93. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
  94. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  95. package/dist/templates/todo/AGENTS.md +41 -5
  96. package/dist/templates/todo/README.md +1 -1
  97. package/package.json +2 -2
@@ -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
- }
@@ -9,10 +9,12 @@ import {
9
9
  RATE_LIMIT_IDENTITY_EXTENSION,
10
10
  RateLimitIdentity,
11
11
  RateLimitIdentityExtension,
12
+ SessionUsers,
12
13
  verifiedSessionToken,
13
14
  } from "./rate-limit.identity";
14
15
 
15
- const proxyConfig = { proxyHeader: "x-forwarded-for", trustedProxyHops: 1 };
16
+ // The address the API resolved for the request (see core/client-address.ts).
17
+ const clientAddress = "203.0.113.7";
16
18
  const secret = "rate-limit-test-secret-with-at-least-32-characters";
17
19
  const sessionToken = "Sx0d8mFqY3KcT7wJpLr2NvBhZa9EgUi4";
18
20
 
@@ -63,8 +65,7 @@ describe("RateLimitIdentity", () => {
63
65
 
64
66
  const value = await identity.resolve(
65
67
  request({ cookie: sessionCookie(signed(sessionToken)) }),
66
- "10.0.0.9",
67
- proxyConfig,
68
+ clientAddress,
68
69
  );
69
70
 
70
71
  expect(value).toMatch(/^session:[a-f0-9]{64}$/);
@@ -76,25 +77,22 @@ describe("RateLimitIdentity", () => {
76
77
  const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret);
77
78
  const value = signed(sessionToken);
78
79
  const resolved = await Promise.all([
79
- identity.resolve(request({ cookie: sessionCookie(value) }), "10.0.0.9", proxyConfig),
80
+ identity.resolve(request({ cookie: sessionCookie(value) }), clientAddress),
80
81
  identity.resolve(
81
82
  request({ cookie: sessionCookie(value, "__Secure-better-auth.session_token") }),
82
- "10.0.0.9",
83
- proxyConfig,
83
+ clientAddress,
84
84
  ),
85
- identity.resolve(request({ authorization: `Bearer ${value}` }), "10.0.0.9", proxyConfig),
85
+ identity.resolve(request({ authorization: `Bearer ${value}` }), clientAddress),
86
86
  identity.resolve(
87
87
  request({ authorization: `bearer ${encodeURIComponent(value)}` }),
88
- "10.0.0.9",
89
- proxyConfig,
88
+ clientAddress,
90
89
  ),
91
90
  ]);
92
91
  expect(new Set(resolved).size).toBe(1);
93
92
 
94
93
  const other = await identity.resolve(
95
94
  request({ cookie: sessionCookie(signed("AnotherSessionToken0123456789abcd")) }),
96
- "10.0.0.9",
97
- proxyConfig,
95
+ clientAddress,
98
96
  );
99
97
  expect(other).toMatch(/^session:/);
100
98
  expect(other).not.toBe(resolved[0]);
@@ -103,7 +101,7 @@ describe("RateLimitIdentity", () => {
103
101
  test("counts forged, tampered, and unsigned session values by client address", async () => {
104
102
  const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret);
105
103
  const address = { "x-forwarded-for": "203.0.113.7" };
106
- const anonymous = await identity.resolve(request(address), "10.0.0.9", proxyConfig);
104
+ const anonymous = await identity.resolve(request(address), clientAddress);
107
105
  expect(anonymous).toMatch(/^ip:[a-f0-9]{64}$/);
108
106
 
109
107
  const genuine = signed(sessionToken);
@@ -123,8 +121,7 @@ describe("RateLimitIdentity", () => {
123
121
  for (const headers of variants) {
124
122
  const value = await identity.resolve(
125
123
  request({ ...address, ...headers }),
126
- "10.0.0.9",
127
- proxyConfig,
124
+ clientAddress,
128
125
  );
129
126
  expect(value).toBe(anonymous);
130
127
  }
@@ -148,8 +145,7 @@ describe("RateLimitIdentity", () => {
148
145
  for (const raw of ["configured-valid", "external-valid"]) {
149
146
  const value = await identity.resolve(
150
147
  request({ "x-api-key": raw, cookie }),
151
- "10.0.0.9",
152
- proxyConfig,
148
+ clientAddress,
153
149
  );
154
150
  expect(value).toMatch(/^api-key:[a-f0-9]{64}$/);
155
151
  expect(value).not.toContain(raw);
@@ -157,8 +153,7 @@ describe("RateLimitIdentity", () => {
157
153
 
158
154
  const invalidKey = await identity.resolve(
159
155
  request({ "x-api-key": "invalid", cookie }),
160
- "10.0.0.9",
161
- proxyConfig,
156
+ clientAddress,
162
157
  );
163
158
  expect(invalidKey).toMatch(/^session:/);
164
159
  });
@@ -169,17 +164,83 @@ describe("RateLimitIdentity", () => {
169
164
  const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), undefined);
170
165
  const value = await identity.resolve(
171
166
  request({ "x-forwarded-for": "203.0.113.7", cookie }),
172
- "10.0.0.9",
173
- proxyConfig,
167
+ clientAddress,
174
168
  );
175
169
  expect(value).toMatch(/^ip:[a-f0-9]{64}$/);
176
170
  expect(value).not.toContain("203.0.113.7");
177
171
  expect(
178
- await identity.resolve(request({ "x-forwarded-for": "203.0.113.7" }), "10.0.0.9", proxyConfig),
172
+ await identity.resolve(request({ "x-forwarded-for": "203.0.113.7" }), clientAddress),
179
173
  ).toBe(value);
180
174
  });
181
175
  });
182
176
 
177
+ describe("RateLimitIdentity per user", () => {
178
+ const otherToken = "AnotherSessionToken0123456789abcd";
179
+ const thirdToken = "ThirdSessionToken0123456789abcdef";
180
+ const resolveWith = (identity: RateLimitIdentity, headers: Record<string, string>): Promise<string> =>
181
+ identity.resolve(request({ "x-forwarded-for": "203.0.113.7", ...headers }), clientAddress);
182
+
183
+ test("counts every session of a user under one counter once the API resolved them", async () => {
184
+ let now = 1_000;
185
+ const users = new SessionUsers(100, () => now);
186
+ const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret, users);
187
+ const first = { cookie: sessionCookie(signed(sessionToken)) };
188
+ const second = { authorization: `Bearer ${signed(otherToken)}` };
189
+
190
+ // Before the API resolves a session, it keeps a counter of its own.
191
+ const unresolved = await resolveWith(identity, first);
192
+ expect(unresolved).toMatch(/^session:[a-f0-9]{64}$/);
193
+
194
+ users.remember(sessionToken, "user-1", new Date(now + 60_000));
195
+ users.remember(otherToken, "user-1", new Date(now + 60_000));
196
+ users.remember(thirdToken, "user-2", new Date(now + 60_000));
197
+ const userCounter = await resolveWith(identity, first);
198
+ expect(userCounter).toMatch(/^user:[a-f0-9]{64}$/);
199
+ expect(userCounter).not.toContain("user-1");
200
+ expect(await resolveWith(identity, second)).toBe(userCounter);
201
+ expect(await resolveWith(identity, { cookie: sessionCookie(signed(thirdToken)) })).not.toBe(userCounter);
202
+
203
+ // The entry lasts until the session expires.
204
+ now += 60_000;
205
+ expect(await resolveWith(identity, first)).toBe(unresolved);
206
+ });
207
+
208
+ test("keeps API keys first and forged sessions on the client address", async () => {
209
+ const users = new SessionUsers();
210
+ users.remember(sessionToken, "user-1", new Date(Date.now() + 60_000));
211
+ const verifier = { isValid: mock((value: string) => value === "configured-valid") } as unknown as ApiKeyVerifier;
212
+ const identity = new RateLimitIdentity(verifier, new ServiceRegistry(), secret, users);
213
+ const cookie = sessionCookie(signed(sessionToken));
214
+
215
+ expect(await resolveWith(identity, { "x-api-key": "configured-valid", cookie })).toMatch(/^api-key:/);
216
+ expect(await resolveWith(identity, { cookie: sessionCookie(tamperedSignature(signed(sessionToken))) }))
217
+ .toBe(await resolveWith(identity, {}));
218
+ expect(await resolveWith(identity, {})).toMatch(/^ip:/);
219
+ });
220
+ });
221
+
222
+ describe("SessionUsers", () => {
223
+ test("ignores an expired session and forgets the oldest past its bound", () => {
224
+ let now = 1_000;
225
+ const users = new SessionUsers(2, () => now);
226
+ users.remember("expired", "user-0", new Date(now));
227
+ expect(users.userOf("expired")).toBeUndefined();
228
+
229
+ users.remember("a", "user-a", new Date(now + 10_000));
230
+ users.remember("b", "user-b", new Date(now + 10_000));
231
+ users.remember("a", "user-a", new Date(now + 20_000));
232
+ users.remember("c", "user-c", new Date(now + 10_000));
233
+ // "a" was refreshed, so "b" was the oldest.
234
+ expect(users.userOf("a")).toBe("user-a");
235
+ expect(users.userOf("b")).toBeUndefined();
236
+ expect(users.userOf("c")).toBe("user-c");
237
+
238
+ now += 10_000;
239
+ expect(users.userOf("c")).toBeUndefined();
240
+ expect(users.userOf("a")).toBe("user-a");
241
+ });
242
+ });
243
+
183
244
  describe("verifiedSessionToken", () => {
184
245
  test("returns the token only for a matching signature", () => {
185
246
  expect(verifiedSessionToken(signed(sessionToken), secret)).toBe(sessionToken);
@@ -1,10 +1,6 @@
1
1
  import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
2
  import type { ApiKeyVerifier } from "../api-key/api-key-verifier";
3
3
  import type { ServiceKey, ServiceRegistry } from "../core/services";
4
- import {
5
- clientAddressFromProxy,
6
- type RateLimitConfig,
7
- } from "./rate-limit.config";
8
4
 
9
5
  export abstract class RateLimitIdentityExtension {
10
6
  abstract validatedApiKeyId(
@@ -28,7 +24,7 @@ const SESSION_COOKIE_NAMES = [
28
24
  // accepts only the padded 44-character standard base64 signature.
29
25
  const SIGNATURE = /^[A-Za-z0-9+/]{43}=$/;
30
26
 
31
- type TrackerKind = "session" | "api-key" | "ip";
27
+ type TrackerKind = "user" | "session" | "api-key" | "ip";
32
28
 
33
29
  function stableDigest(kind: TrackerKind, value: string): string {
34
30
  return createHash("sha256")
@@ -85,18 +81,57 @@ export function verifiedSessionToken(
85
81
  : undefined;
86
82
  }
87
83
 
84
+ /** The most sessions one API process remembers; the oldest is forgotten first. */
85
+ const MAX_SESSION_USERS = 10_000;
86
+
87
+ /**
88
+ * The users of the sessions this API process has resolved, so the limiter can
89
+ * count a signed-in caller per user without a session lookup of its own. Each
90
+ * entry lasts until its session expires, and is refreshed whenever the session
91
+ * is resolved again. A signed-out or revoked session keeps its entry: it only
92
+ * decides which counter its requests use, never whether they are allowed.
93
+ */
94
+ export class SessionUsers {
95
+ private readonly users = new Map<string, { userId: string; expiresAt: number }>();
96
+
97
+ constructor(
98
+ private readonly maxEntries = MAX_SESSION_USERS,
99
+ private readonly now: () => number = Date.now,
100
+ ) {}
101
+
102
+ remember(sessionToken: string, userId: string, expiresAt: Date): void {
103
+ const key = stableDigest("session", sessionToken);
104
+ this.users.delete(key);
105
+ if (!(expiresAt.getTime() > this.now())) return;
106
+ this.users.set(key, { userId, expiresAt: expiresAt.getTime() });
107
+ if (this.users.size > this.maxEntries) {
108
+ const oldest = this.users.keys().next().value;
109
+ if (oldest !== undefined) this.users.delete(oldest);
110
+ }
111
+ }
112
+
113
+ userOf(sessionToken: string): string | undefined {
114
+ const key = stableDigest("session", sessionToken);
115
+ const entry = this.users.get(key);
116
+ if (!entry) return undefined;
117
+ if (entry.expiresAt <= this.now()) {
118
+ this.users.delete(key);
119
+ return undefined;
120
+ }
121
+ return entry.userId;
122
+ }
123
+ }
124
+
88
125
  export class RateLimitIdentity {
89
126
  constructor(
90
127
  private readonly configuredApiKeys: ApiKeyVerifier,
91
128
  private readonly services: ServiceRegistry,
92
129
  private readonly sessionSecret: string | undefined,
130
+ private readonly sessionUsers: SessionUsers = new SessionUsers(),
93
131
  ) {}
94
132
 
95
- async resolve(
96
- request: Request,
97
- remoteAddress: string | undefined,
98
- config: Pick<RateLimitConfig, "proxyHeader" | "trustedProxyHops">,
99
- ): Promise<string> {
133
+ /** clientAddress is the address the API resolved behind its trusted proxies. */
134
+ async resolve(request: Request, clientAddress: string | undefined): Promise<string> {
100
135
  const rawApiKey = request.headers.get("x-api-key");
101
136
  if (rawApiKey) {
102
137
  if (this.configuredApiKeys.isValid(rawApiKey)) return tracker("api-key", rawApiKey);
@@ -106,15 +141,19 @@ export class RateLimitIdentity {
106
141
  }
107
142
 
108
143
  const sessionToken = this.sessionToken(request.headers);
109
- if (sessionToken) return tracker("session", sessionToken);
144
+ if (sessionToken) {
145
+ const userId = this.sessionUsers.userOf(sessionToken);
146
+ return userId === undefined ? tracker("session", sessionToken) : tracker("user", userId);
147
+ }
110
148
 
111
- const clientAddress = clientAddressFromProxy(request.headers, remoteAddress, config);
112
- return tracker("ip", clientAddress);
149
+ return tracker("ip", clientAddress ?? "unknown");
113
150
  }
114
151
 
115
152
  // Only the signature is checked: resolving the session would cost a database
116
- // read on every request. A revoked or expired token still maps to its single
117
- // counter, and a value without a valid signature falls back to the client address.
153
+ // read on every request. Once the API has resolved the session for a request,
154
+ // SessionUsers maps it to its user's counter; until then, and for a revoked or
155
+ // expired token, it keeps a counter of its own. A value without a valid
156
+ // signature falls back to the client address.
118
157
  private sessionToken(headers: Headers): string | undefined {
119
158
  if (!this.sessionSecret) return undefined;
120
159
  const cookies = headers.get("cookie");
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import type { RequestGuardContext } from "../core/services";
3
- import type { RateLimitIdentity } from "./rate-limit.identity";
2
+ import { createHmac } from "node:crypto";
3
+ import type { ApiKeyVerifier } from "../api-key/api-key-verifier";
4
+ import { RESOLVED_SESSIONS, ResolvedSessions, ServiceRegistry, type RequestGuardContext } from "../core/services";
5
+ import { RateLimitIdentity, SessionUsers } from "./rate-limit.identity";
4
6
  import { CacheRateLimitStorage, RateLimiter, type RateLimitStorage } from "./rate-limit.module";
5
7
  import { MemoryCacheStore } from "@podosoft/podokit-runtime";
6
8
 
@@ -11,8 +13,6 @@ const config = {
11
13
  authTtlSeconds: 60,
12
14
  authLimit: 20,
13
15
  runtimeLimit: 1000,
14
- trustedProxyHops: 0,
15
- proxyHeader: "x-forwarded-for",
16
16
  storageTimeoutMs: 100,
17
17
  unavailableRetryAfterSeconds: 1,
18
18
  };
@@ -26,12 +26,50 @@ function context(path: string, method = "GET"): {
26
26
  headers,
27
27
  value: {
28
28
  request: new Request(`http://localhost${path}`, { method }),
29
- remoteAddress: "127.0.0.1",
29
+ clientAddress: "127.0.0.1",
30
30
  setHeader: (name, value) => { headers[name.toLowerCase()] = value; },
31
31
  },
32
32
  };
33
33
  }
34
34
 
35
+ describe("RateLimiter per-user budgets", () => {
36
+ const secret = "rate-limit-test-secret-with-at-least-32-characters";
37
+ const signed = (token: string): string =>
38
+ `${token}.${createHmac("sha256", secret).update(token).digest("base64")}`;
39
+
40
+ test("draws a user's sessions from one budget and another user's from its own", async () => {
41
+ const services = new ServiceRegistry();
42
+ services.register(RESOLVED_SESSIONS, new ResolvedSessions());
43
+ const users = new SessionUsers();
44
+ services.resolve(RESOLVED_SESSIONS).listen(({ token, userId, expiresAt }) => users.remember(token, userId, expiresAt));
45
+ const limiter = new RateLimiter(
46
+ config,
47
+ new RateLimitIdentity({ isValid: () => false } as unknown as ApiKeyVerifier, services, secret, users),
48
+ new CacheRateLimitStorage(new MemoryCacheStore()),
49
+ );
50
+ const expiresAt = new Date(Date.now() + 60_000);
51
+ for (const [token, userId] of [["laptop", "user-1"], ["phone", "user-1"], ["other", "user-2"]] as const) {
52
+ services.resolve(RESOLVED_SESSIONS).report({ token, userId, expiresAt });
53
+ }
54
+ const remaining = async (token: string): Promise<string | undefined> => {
55
+ const headers: Record<string, string> = {};
56
+ await limiter.enforce({
57
+ request: new Request("http://localhost/ordinary", { headers: { authorization: `Bearer ${signed(token)}` } }),
58
+ clientAddress: "127.0.0.1",
59
+ setHeader: (name, value) => { headers[name.toLowerCase()] = value; },
60
+ });
61
+ return headers["ratelimit-remaining"];
62
+ };
63
+
64
+ expect(await remaining("laptop")).toBe("299");
65
+ expect(await remaining("phone")).toBe("298");
66
+ expect(await remaining("laptop")).toBe("297");
67
+ expect(await remaining("other")).toBe("299");
68
+ // A session the API has not resolved yet keeps a counter of its own.
69
+ expect(await remaining("tablet")).toBe("299");
70
+ });
71
+ });
72
+
35
73
  describe("RateLimiter", () => {
36
74
  test("uses the selected cache fixed-window primitive", async () => {
37
75
  let now = 1_000;
@@ -3,6 +3,7 @@ import { AppException } from "../common/app-exception";
3
3
  import { API_KEY_VERIFIER } from "../api-key/api-key.module";
4
4
  import {
5
5
  REQUEST_GUARDS,
6
+ RESOLVED_SESSIONS,
6
7
  type PodokitModule,
7
8
  type RequestGuardContext,
8
9
  type ServiceKey,
@@ -12,7 +13,7 @@ import {
12
13
  sessionSigningSecret,
13
14
  type RateLimitConfig,
14
15
  } from "./rate-limit.config";
15
- import { RateLimitIdentity } from "./rate-limit.identity";
16
+ import { RateLimitIdentity, SessionUsers } from "./rate-limit.identity";
16
17
  import { defaultRateLimitRoutes, generalProfile, type RateLimitRoutes } from "./rate-limit.routes";
17
18
 
18
19
  export { RATE_LIMIT_ROUTES } from "@podosoft/podokit-runtime";
@@ -61,7 +62,7 @@ export class RateLimiter {
61
62
  const profile = this.routes.resolve(request.method, path) ?? generalProfile(this.config);
62
63
  if (profile === "exempt") return;
63
64
  try {
64
- const tracker = await this.identity.resolve(request, context.remoteAddress, this.config);
65
+ const tracker = await this.identity.resolve(request, context.clientAddress);
65
66
  await this.enforceCustom(profile.name, tracker, profile.ttlSeconds, profile.limit, context.setHeader);
66
67
  } catch (error) {
67
68
  if (error instanceof AppException) throw error;
@@ -125,12 +126,19 @@ export const RATE_LIMITER = Symbol("rate-limiter") as ServiceKey<RateLimiter>;
125
126
  export const rateLimitModule: PodokitModule = {
126
127
  name: "rate-limit",
127
128
  configure: (_env, services): void => {
129
+ // Sessions the auth module resolves for any request teach the limiter their
130
+ // users, so a user's sessions share one counter without another lookup.
131
+ const sessionUsers = new SessionUsers();
132
+ services.resolve(RESOLVED_SESSIONS).listen(({ token, userId, expiresAt }) =>
133
+ sessionUsers.remember(token, userId, expiresAt),
134
+ );
128
135
  const limiter = new RateLimiter(
129
136
  rateLimitConfig(),
130
137
  new RateLimitIdentity(
131
138
  services.resolve(API_KEY_VERIFIER),
132
139
  services,
133
140
  sessionSigningSecret(),
141
+ sessionUsers,
134
142
  ),
135
143
  new CacheRateLimitStorage(services.resolve(CACHE)),
136
144
  );
@@ -11,8 +11,6 @@ const config = {
11
11
  authTtlSeconds: 60,
12
12
  authLimit: 20,
13
13
  runtimeLimit: 1000,
14
- trustedProxyHops: 0,
15
- proxyHeader: "x-forwarded-for",
16
14
  storageTimeoutMs: 100,
17
15
  unavailableRetryAfterSeconds: 1,
18
16
  };
@@ -25,7 +23,7 @@ function context(path: string, method = "GET"): { value: RequestGuardContext; he
25
23
  headers,
26
24
  value: {
27
25
  request: new Request(`http://localhost${path}`, { method }),
28
- remoteAddress: "127.0.0.1",
26
+ clientAddress: "127.0.0.1",
29
27
  setHeader: (name, value) => { headers[name.toLowerCase()] = value; },
30
28
  },
31
29
  };
@@ -50,11 +48,49 @@ describe("RateLimitRoutes", () => {
50
48
  expect(routes.resolve("GET", "/other")).toBeUndefined();
51
49
  });
52
50
 
51
+ test("matches a :name parameter against exactly one non-empty segment", () => {
52
+ const routes = new RateLimitRoutes(60);
53
+ const transfers = { name: "file-transfers", limit: 600 };
54
+ routes.register({ pathPrefix: "/hosts/:hostId/file-transfers", profile: transfers });
55
+
56
+ expect(routes.resolve("PUT", "/hosts/h1/file-transfers/t1/chunks/3")).toMatchObject(transfers);
57
+ expect(routes.resolve("GET", "/hosts/h1/file-transfers")).toMatchObject(transfers);
58
+ expect(routes.resolve("GET", "/hosts/h1/file-transfers/")).toMatchObject(transfers);
59
+ expect(routes.resolve("GET", "/hosts/h1/file-transfers-old")).toBeUndefined();
60
+ expect(routes.resolve("GET", "/hosts/file-transfers")).toBeUndefined();
61
+ expect(routes.resolve("GET", "/hosts//file-transfers")).toBeUndefined();
62
+ expect(routes.resolve("GET", "/hosts/h1/h2/file-transfers")).toBeUndefined();
63
+ });
64
+
65
+ test("prefers more segments, then a literal segment over a parameter from the left", () => {
66
+ const routes = new RateLimitRoutes(60);
67
+ routes.register({ pathPrefix: "/hosts/:hostId/:section/:id", profile: { name: "deep", limit: 1 } });
68
+ routes.register({ pathPrefix: "/hosts/:hostId/files", profile: { name: "files", limit: 2 } });
69
+ routes.register({ pathPrefix: "/hosts/local/:section", profile: { name: "local", limit: 3 } });
70
+ routes.register({ pathPrefix: "/hosts", profile: { name: "hosts", limit: 4 } });
71
+
72
+ expect(routes.resolve("GET", "/hosts/local/files/1")).toMatchObject({ name: "deep" });
73
+ expect(routes.resolve("GET", "/hosts/local/files")).toMatchObject({ name: "local" });
74
+ expect(routes.resolve("GET", "/hosts/h2/files")).toMatchObject({ name: "files" });
75
+ expect(routes.resolve("GET", "/hosts/h2/logs")).toMatchObject({ name: "hosts" });
76
+
77
+ // Equal specificity falls back to the method, then the latest registration.
78
+ routes.register({ method: "GET", pathPrefix: "/hosts/:a/logs", profile: "exempt" });
79
+ routes.register({ pathPrefix: "/hosts/:b/logs", profile: { name: "logs", limit: 5 } });
80
+ expect(routes.resolve("GET", "/hosts/h2/logs")).toBe("exempt");
81
+ expect(routes.resolve("POST", "/hosts/h2/logs")).toMatchObject({ name: "logs" });
82
+ });
83
+
53
84
  test("rejects invalid routes and conflicting budgets under one name", () => {
54
85
  const routes = new RateLimitRoutes(60);
55
86
  expect(() => routes.register({ pathPrefix: "uploads", profile: "exempt" })).toThrow("absolute path");
56
87
  expect(() => routes.register({ pathPrefix: "/a?b", profile: "exempt" })).toThrow("absolute path");
57
88
  expect(() => routes.register({ pathPrefix: "/a//b", profile: "exempt" })).toThrow("absolute path");
89
+ expect(() => routes.register({ pathPrefix: "/a/../b", profile: "exempt" })).toThrow('".." segments');
90
+ expect(() => routes.register({ pathPrefix: "/a/*", profile: "exempt" })).toThrow("not wildcards");
91
+ for (const parameter of [":", ":1st", ":host-id", ":a:b"]) {
92
+ expect(() => routes.register({ pathPrefix: `/hosts/${parameter}/x`, profile: "exempt" })).toThrow("parameter");
93
+ }
58
94
  expect(() => routes.register({ method: "GET /", pathPrefix: "/a", profile: "exempt" })).toThrow("method");
59
95
  expect(() => routes.register({ pathPrefix: "/a", profile: { name: "a:b", limit: 1 } })).toThrow("name");
60
96
  expect(() => routes.register({ pathPrefix: "/a", profile: { name: "a", limit: 0 } })).toThrow("limit");
@@ -83,6 +119,28 @@ describe("RateLimitRoutes", () => {
83
119
  });
84
120
 
85
121
  describe("RateLimiter route profiles", () => {
122
+ test("advertises the built-in budget of each default route", async () => {
123
+ const limiter = new RateLimiter(
124
+ config,
125
+ { resolve: async () => "session:test" } as unknown as RateLimitIdentity,
126
+ { increment: async () => ({ count: 1, retryAfterSeconds: 60 }) },
127
+ );
128
+ const ceiling = async (path: string, method = "GET"): Promise<string | undefined> => {
129
+ const request = context(path, method);
130
+ await limiter.enforce(request.value);
131
+ return request.headers["ratelimit-limit"];
132
+ };
133
+
134
+ expect(await ceiling("/api/auth/get-session")).toBe("1000");
135
+ expect(await ceiling("/site/settings")).toBe("1000");
136
+ expect(await ceiling("/api/auth/admin/list-users")).toBe("300");
137
+ expect(await ceiling("/api/auth/admin/ban-user", "POST")).toBe("20");
138
+ expect(await ceiling("/api/auth/sign-out", "POST")).toBe("20");
139
+ expect(await ceiling("/api/auth/sign-in/email", "POST")).toBe("20");
140
+ expect(await ceiling("/site/settings", "PUT")).toBe("300");
141
+ expect(await ceiling("/account/capabilities")).toBe("300");
142
+ });
143
+
86
144
  test("counts a registered route in its own bucket and skips exempt routes", async () => {
87
145
  const increment = mock(async (_key: string, _ttlSeconds: number) => ({ count: 1, retryAfterSeconds: 30 }));
88
146
  const limiter = new RateLimiter(