@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
@@ -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(
@@ -8,14 +8,18 @@ import type { RateLimitConfig } from "./rate-limit.config";
8
8
  /** A budget with its window resolved, or "exempt". */
9
9
  export type ResolvedRateLimitProfile = Required<RateLimitProfile> | "exempt";
10
10
 
11
+ /** A literal path segment, or null for a `:name` parameter. */
12
+ type Segment = string | null;
13
+
11
14
  interface RouteEntry {
12
15
  method?: string;
13
- prefix: string;
16
+ segments: Segment[];
14
17
  profile: ResolvedRateLimitProfile;
15
18
  }
16
19
 
17
20
  const PROFILE_NAME = /^[A-Za-z0-9_-]{1,64}$/;
18
21
  const METHOD = /^[A-Z]+$/;
22
+ const PARAMETER = /^:[A-Za-z_][A-Za-z0-9_]*$/;
19
23
 
20
24
  function positiveInteger(value: number | undefined, label: string): void {
21
25
  if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) {
@@ -23,15 +27,47 @@ function positiveInteger(value: number | undefined, label: string): void {
23
27
  }
24
28
  }
25
29
 
26
- function normalizePrefix(prefix: string): string {
30
+ function parsePrefix(prefix: string): Segment[] {
27
31
  if (!prefix.startsWith("/") || /[?#\s]/.test(prefix) || prefix.includes("//")) {
28
32
  throw new Error(`Rate limit path prefix must be an absolute path: ${prefix}`);
29
33
  }
30
- return prefix.length > 1 && prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
34
+ if (prefix.includes("*")) {
35
+ throw new Error(`Rate limit path prefix takes ":name" parameters, not wildcards: ${prefix}`);
36
+ }
37
+ const base = prefix.length > 1 && prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
38
+ return base === "/"
39
+ ? []
40
+ : base.slice(1).split("/").map((segment) => {
41
+ if (segment === "." || segment === "..") {
42
+ throw new Error(`Rate limit path prefix must not contain "." or ".." segments: ${prefix}`);
43
+ }
44
+ if (!segment.startsWith(":")) return segment;
45
+ if (!PARAMETER.test(segment)) {
46
+ throw new Error(`Rate limit path parameter must be ":" and a name such as ":hostId": ${prefix}`);
47
+ }
48
+ return null;
49
+ });
50
+ }
51
+
52
+ /** Whole-segment prefix match; a parameter matches one non-empty segment. */
53
+ function covers(segments: Segment[], path: string[]): boolean {
54
+ return (
55
+ path.length >= segments.length &&
56
+ segments.every((segment, index) => (segment === null ? path[index] !== "" : segment === path[index]))
57
+ );
31
58
  }
32
59
 
33
- function covers(prefix: string, path: string): boolean {
34
- return prefix === "/" || path === prefix || path.startsWith(`${prefix}/`);
60
+ /**
61
+ * Below zero when `a` is more specific: more segments, then, from the left, a
62
+ * literal segment before a parameter.
63
+ */
64
+ function compareSpecificity(a: Segment[], b: Segment[]): number {
65
+ if (a.length !== b.length) return b.length - a.length;
66
+ for (let index = 0; index < a.length; index += 1) {
67
+ const literalA = a[index] !== null;
68
+ if (literalA !== (b[index] !== null)) return literalA ? -1 : 1;
69
+ }
70
+ return 0;
35
71
  }
36
72
 
37
73
  /**
@@ -50,24 +86,21 @@ export class RateLimitRoutes implements RateLimitRouteRegistry {
50
86
  if (method !== undefined && !METHOD.test(method)) {
51
87
  throw new Error(`Rate limit route method is invalid: ${route.method}`);
52
88
  }
53
- const prefix = normalizePrefix(route.pathPrefix);
89
+ const segments = parsePrefix(route.pathPrefix);
54
90
  const profile = route.profile === "exempt" ? "exempt" : this.budget(route.profile);
55
- this.entries.push({ ...(method ? { method } : {}), prefix, profile });
91
+ this.entries.push({ ...(method ? { method } : {}), segments, profile });
56
92
  }
57
93
 
58
94
  /** The most specific registered profile for a request, if any route matches. */
59
95
  resolve(method: string, path: string): ResolvedRateLimitProfile | undefined {
96
+ const pathSegments = path.split("/").slice(1);
60
97
  let best: RouteEntry | undefined;
61
98
  for (const entry of this.entries) {
62
99
  if (entry.method !== undefined && entry.method !== method) continue;
63
- if (!covers(entry.prefix, path)) continue;
64
- // The longer prefix wins, then a route with a method, then the later one.
65
- if (
66
- !best ||
67
- entry.prefix.length > best.prefix.length ||
68
- (entry.prefix.length === best.prefix.length &&
69
- (entry.method !== undefined || best.method === undefined))
70
- ) {
100
+ if (!covers(entry.segments, pathSegments)) continue;
101
+ // The more specific prefix wins, then a route with a method, then the later one.
102
+ const order = best ? compareSpecificity(entry.segments, best.segments) : -1;
103
+ if (order < 0 || (order === 0 && (entry.method !== undefined || best?.method === undefined))) {
71
104
  best = entry;
72
105
  }
73
106
  }