@podosoft/podokit 1.1.5 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/dist/deploy-render.js +5 -0
  2. package/dist/templates/fullstack/apps/api/package.json +2 -2
  3. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  4. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  5. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  6. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  7. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  8. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  9. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  10. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  11. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  12. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  13. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  14. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  15. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  16. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  17. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  18. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  19. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  20. package/dist/templates/fullstack/dot-env.docker +3 -0
  21. package/dist/templates/fullstack/dot-env.example +14 -0
  22. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
  30. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  31. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  32. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  66. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  67. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  68. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  69. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  72. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
  81. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  82. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  83. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  84. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
  85. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  86. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  87. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  88. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  90. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
  91. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  93. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  94. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +17 -9
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  104. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  106. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +13 -0
  107. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +5 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
  109. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -0
  112. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  113. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  114. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  115. package/dist/templates/modules/auth/module.manifest.json +2 -2
  116. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  117. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  118. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  119. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  120. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  121. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  122. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  123. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  124. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  125. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  126. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  127. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  128. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  129. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  130. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  131. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  132. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  133. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  134. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  135. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  136. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  137. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  138. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  139. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  140. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  141. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  142. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  143. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  144. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  145. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  146. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  147. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  148. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  149. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  150. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  151. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  152. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  153. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  154. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  155. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  156. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  157. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  158. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  159. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  160. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
  161. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
  162. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
  163. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
  164. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  165. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  166. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
  167. package/dist/templates/modules/sse/module.manifest.json +17 -4
  168. package/package.json +2 -2
@@ -1,4 +1,5 @@
1
- import { describe, expect, mock, test } from "bun:test";
1
+ import { createHmac } from "node:crypto";
2
+ import { describe, expect, mock, test, type Mock } from "bun:test";
2
3
  import { ApiKeyVerifier } from "../api-key/api-key-verifier";
3
4
  import {
4
5
  REQUEST_IDENTITY,
@@ -8,36 +9,130 @@ import {
8
9
  RATE_LIMIT_IDENTITY_EXTENSION,
9
10
  RateLimitIdentity,
10
11
  RateLimitIdentityExtension,
12
+ verifiedSessionToken,
11
13
  } from "./rate-limit.identity";
12
14
 
13
15
  const proxyConfig = { proxyHeader: "x-forwarded-for", trustedProxyHops: 1 };
16
+ const secret = "rate-limit-test-secret-with-at-least-32-characters";
17
+ const sessionToken = "Sx0d8mFqY3KcT7wJpLr2NvBhZa9EgUi4";
14
18
 
15
19
  function request(headers: Record<string, string> = {}): Request {
16
20
  return new Request("http://localhost/ordinary", { headers });
17
21
  }
18
22
 
19
- function services(userId?: string): ServiceRegistry {
23
+ // The same format Better Auth issues: `${token}.${base64(HMAC-SHA256)}`.
24
+ function signed(token: string, key = secret): string {
25
+ return `${token}.${createHmac("sha256", key).update(token).digest("base64")}`;
26
+ }
27
+
28
+ function tamperedSignature(value: string): string {
29
+ const index = value.lastIndexOf(".") + 1;
30
+ const replacement = value[index] === "A" ? "B" : "A";
31
+ return `${value.slice(0, index)}${replacement}${value.slice(index + 1)}`;
32
+ }
33
+
34
+ function sessionCookie(value: string, name = "better-auth.session_token"): string {
35
+ return `theme=dark; ${name}=${encodeURIComponent(value)}`;
36
+ }
37
+
38
+ function rejectingVerifier(): ApiKeyVerifier {
39
+ return { isValid: mock(() => false) } as unknown as ApiKeyVerifier;
40
+ }
41
+
42
+ // Registers a request identity that would hit the session store, so tests can
43
+ // prove the limiter never asks for it.
44
+ function servicesWithSessionStore(): {
45
+ registry: ServiceRegistry;
46
+ userId: Mock<(request: Request) => Promise<string | undefined>>;
47
+ } {
20
48
  const registry = new ServiceRegistry();
21
- registry.register(REQUEST_IDENTITY, { userId: async () => userId });
22
- return registry;
49
+ const userId = mock(async (_request: Request): Promise<string | undefined> => "user-secret-id");
50
+ registry.register(REQUEST_IDENTITY, {
51
+ userId,
52
+ isAdmin: async () => false,
53
+ roles: async () => [],
54
+ activeOrganizationId: async () => undefined,
55
+ });
56
+ return { registry, userId };
23
57
  }
24
58
 
25
59
  describe("RateLimitIdentity", () => {
26
- test("uses an authenticated user without exposing the user id", async () => {
27
- const verifier = { isValid: mock(() => true) } as unknown as ApiKeyVerifier;
28
- const identity = new RateLimitIdentity(verifier, services("user-secret-id"));
60
+ test("uses a verified session cookie without resolving the session", async () => {
61
+ const { registry, userId } = servicesWithSessionStore();
62
+ const identity = new RateLimitIdentity(rejectingVerifier(), registry, secret);
63
+
29
64
  const value = await identity.resolve(
30
- request({ "x-api-key": "api-secret" }),
65
+ request({ cookie: sessionCookie(signed(sessionToken)) }),
66
+ "10.0.0.9",
67
+ proxyConfig,
68
+ );
69
+
70
+ expect(value).toMatch(/^session:[a-f0-9]{64}$/);
71
+ expect(value).not.toContain(sessionToken);
72
+ expect(userId).not.toHaveBeenCalled();
73
+ });
74
+
75
+ test("gives one session the same counter across cookie names and bearer forms", async () => {
76
+ const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret);
77
+ const value = signed(sessionToken);
78
+ const resolved = await Promise.all([
79
+ identity.resolve(request({ cookie: sessionCookie(value) }), "10.0.0.9", proxyConfig),
80
+ identity.resolve(
81
+ request({ cookie: sessionCookie(value, "__Secure-better-auth.session_token") }),
82
+ "10.0.0.9",
83
+ proxyConfig,
84
+ ),
85
+ identity.resolve(request({ authorization: `Bearer ${value}` }), "10.0.0.9", proxyConfig),
86
+ identity.resolve(
87
+ request({ authorization: `bearer ${encodeURIComponent(value)}` }),
88
+ "10.0.0.9",
89
+ proxyConfig,
90
+ ),
91
+ ]);
92
+ expect(new Set(resolved).size).toBe(1);
93
+
94
+ const other = await identity.resolve(
95
+ request({ cookie: sessionCookie(signed("AnotherSessionToken0123456789abcd")) }),
31
96
  "10.0.0.9",
32
97
  proxyConfig,
33
98
  );
34
- expect(value).toMatch(/^user:[a-f0-9]{64}$/);
35
- expect(value).not.toContain("user-secret-id");
36
- expect(verifier.isValid).not.toHaveBeenCalled();
99
+ expect(other).toMatch(/^session:/);
100
+ expect(other).not.toBe(resolved[0]);
101
+ });
102
+
103
+ test("counts forged, tampered, and unsigned session values by client address", async () => {
104
+ const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret);
105
+ const address = { "x-forwarded-for": "203.0.113.7" };
106
+ const anonymous = await identity.resolve(request(address), "10.0.0.9", proxyConfig);
107
+ expect(anonymous).toMatch(/^ip:[a-f0-9]{64}$/);
108
+
109
+ const genuine = signed(sessionToken);
110
+ const forgedValues = [
111
+ signed(sessionToken, "an-attacker-chosen-secret-of-32-characters"),
112
+ `${sessionToken}x${genuine.slice(sessionToken.length)}`,
113
+ tamperedSignature(genuine),
114
+ genuine.replace("=", ""),
115
+ sessionToken,
116
+ "",
117
+ ];
118
+ for (const forged of forgedValues) {
119
+ const variants: Array<Record<string, string>> = [
120
+ { cookie: sessionCookie(forged) },
121
+ { authorization: `Bearer ${forged}` },
122
+ ];
123
+ for (const headers of variants) {
124
+ const value = await identity.resolve(
125
+ request({ ...address, ...headers }),
126
+ "10.0.0.9",
127
+ proxyConfig,
128
+ );
129
+ expect(value).toBe(anonymous);
130
+ }
131
+ }
37
132
  });
38
133
 
39
- test("uses configured and extension API keys without storing raw keys", async () => {
40
- const registry = services();
134
+ test("checks API keys before sessions without storing raw keys", async () => {
135
+ const registry = new ServiceRegistry();
41
136
  class Extension extends RateLimitIdentityExtension {
42
137
  async validatedApiKeyId(_request: Request, rawApiKey: string): Promise<string | undefined> {
43
138
  return rawApiKey === "external-valid" ? "external-stable-id" : undefined;
@@ -47,26 +142,49 @@ describe("RateLimitIdentity", () => {
47
142
  const verifier = {
48
143
  isValid: mock((value: string) => value === "configured-valid"),
49
144
  } as unknown as ApiKeyVerifier;
50
- const identity = new RateLimitIdentity(verifier, registry);
145
+ const identity = new RateLimitIdentity(verifier, registry, secret);
146
+ const cookie = sessionCookie(signed(sessionToken));
51
147
 
52
148
  for (const raw of ["configured-valid", "external-valid"]) {
53
- const value = await identity.resolve(request({ "x-api-key": raw }), "10.0.0.9", proxyConfig);
149
+ const value = await identity.resolve(
150
+ request({ "x-api-key": raw, cookie }),
151
+ "10.0.0.9",
152
+ proxyConfig,
153
+ );
54
154
  expect(value).toMatch(/^api-key:[a-f0-9]{64}$/);
55
155
  expect(value).not.toContain(raw);
56
156
  }
57
- });
58
157
 
59
- test("falls back to the trusted client address", async () => {
60
- const identity = new RateLimitIdentity(
61
- { isValid: mock(() => false) } as unknown as ApiKeyVerifier,
62
- services(),
158
+ const invalidKey = await identity.resolve(
159
+ request({ "x-api-key": "invalid", cookie }),
160
+ "10.0.0.9",
161
+ proxyConfig,
63
162
  );
163
+ expect(invalidKey).toMatch(/^session:/);
164
+ });
165
+
166
+ test("falls back to the trusted client address without authentication", async () => {
167
+ const cookie = sessionCookie(signed(sessionToken));
168
+ // No session secret: the auth module is not installed or not configured.
169
+ const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), undefined);
64
170
  const value = await identity.resolve(
65
- request({ "x-forwarded-for": "203.0.113.7" }),
171
+ request({ "x-forwarded-for": "203.0.113.7", cookie }),
66
172
  "10.0.0.9",
67
173
  proxyConfig,
68
174
  );
69
175
  expect(value).toMatch(/^ip:[a-f0-9]{64}$/);
70
176
  expect(value).not.toContain("203.0.113.7");
177
+ expect(
178
+ await identity.resolve(request({ "x-forwarded-for": "203.0.113.7" }), "10.0.0.9", proxyConfig),
179
+ ).toBe(value);
180
+ });
181
+ });
182
+
183
+ describe("verifiedSessionToken", () => {
184
+ test("returns the token only for a matching signature", () => {
185
+ expect(verifiedSessionToken(signed(sessionToken), secret)).toBe(sessionToken);
186
+ expect(verifiedSessionToken(signed(sessionToken), `${secret}-rotated`)).toBeUndefined();
187
+ expect(verifiedSessionToken(".", secret)).toBeUndefined();
188
+ expect(verifiedSessionToken(`${sessionToken}.`, secret)).toBeUndefined();
71
189
  });
72
190
  });
@@ -1,10 +1,6 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
2
  import type { ApiKeyVerifier } from "../api-key/api-key-verifier";
3
- import {
4
- REQUEST_IDENTITY,
5
- type ServiceKey,
6
- type ServiceRegistry,
7
- } from "../core/services";
3
+ import type { ServiceKey, ServiceRegistry } from "../core/services";
8
4
  import {
9
5
  clientAddressFromProxy,
10
6
  type RateLimitConfig,
@@ -21,7 +17,20 @@ export const RATE_LIMIT_IDENTITY_EXTENSION = Symbol(
21
17
  "rate-limit-identity-extension",
22
18
  ) as ServiceKey<RateLimitIdentityExtension>;
23
19
 
24
- function stableDigest(kind: "user" | "api-key" | "ip", value: string): string {
20
+ // Better Auth's session cookie. It adds the __Secure- prefix when BETTER_AUTH_URL
21
+ // uses HTTPS; either name only counts when its signature verifies.
22
+ const SESSION_COOKIE_NAMES = [
23
+ "__Secure-better-auth.session_token",
24
+ "better-auth.session_token",
25
+ ] as const;
26
+
27
+ // better-call signs cookies as `${token}.${base64(HMAC-SHA256(secret, token))}` and
28
+ // accepts only the padded 44-character standard base64 signature.
29
+ const SIGNATURE = /^[A-Za-z0-9+/]{43}=$/;
30
+
31
+ type TrackerKind = "session" | "api-key" | "ip";
32
+
33
+ function stableDigest(kind: TrackerKind, value: string): string {
25
34
  return createHash("sha256")
26
35
  .update("podokit-rate-limit-v1", "utf8")
27
36
  .update("\0")
@@ -31,14 +40,56 @@ function stableDigest(kind: "user" | "api-key" | "ip", value: string): string {
31
40
  .digest("hex");
32
41
  }
33
42
 
34
- function tracker(kind: "user" | "api-key" | "ip", value: string): string {
43
+ function tracker(kind: TrackerKind, value: string): string {
35
44
  return `${kind}:${stableDigest(kind, value)}`;
36
45
  }
37
46
 
47
+ function decodeComponent(value: string): string {
48
+ try {
49
+ return decodeURIComponent(value);
50
+ } catch {
51
+ return value;
52
+ }
53
+ }
54
+
55
+ function cookieValue(header: string | null, name: string): string | undefined {
56
+ if (!header) return undefined;
57
+ for (const pair of header.split(";")) {
58
+ const separator = pair.indexOf("=");
59
+ if (separator === -1 || pair.slice(0, separator).trim() !== name) continue;
60
+ const value = pair.slice(separator + 1).trim();
61
+ return decodeComponent(value.startsWith('"') ? value.slice(1, -1) : value);
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ function bearerValue(header: string | null): string | undefined {
67
+ if (!header || header.slice(0, 7).toLowerCase() !== "bearer ") return undefined;
68
+ const value = header.slice(7).trim();
69
+ return value.includes("%") ? decodeComponent(value) : value;
70
+ }
71
+
72
+ export function verifiedSessionToken(
73
+ signedValue: string,
74
+ secret: string,
75
+ ): string | undefined {
76
+ const separator = signedValue.lastIndexOf(".");
77
+ if (separator < 1) return undefined;
78
+ const token = signedValue.slice(0, separator);
79
+ const signature = signedValue.slice(separator + 1);
80
+ if (!SIGNATURE.test(signature)) return undefined;
81
+ const expected = createHmac("sha256", secret).update(token, "utf8").digest();
82
+ const provided = Buffer.from(signature, "base64");
83
+ return provided.length === expected.length && timingSafeEqual(provided, expected)
84
+ ? token
85
+ : undefined;
86
+ }
87
+
38
88
  export class RateLimitIdentity {
39
89
  constructor(
40
90
  private readonly configuredApiKeys: ApiKeyVerifier,
41
91
  private readonly services: ServiceRegistry,
92
+ private readonly sessionSecret: string | undefined,
42
93
  ) {}
43
94
 
44
95
  async resolve(
@@ -46,9 +97,6 @@ export class RateLimitIdentity {
46
97
  remoteAddress: string | undefined,
47
98
  config: Pick<RateLimitConfig, "proxyHeader" | "trustedProxyHops">,
48
99
  ): Promise<string> {
49
- const userId = await this.services.resolve(REQUEST_IDENTITY).userId(request);
50
- if (userId) return tracker("user", userId);
51
-
52
100
  const rawApiKey = request.headers.get("x-api-key");
53
101
  if (rawApiKey) {
54
102
  if (this.configuredApiKeys.isValid(rawApiKey)) return tracker("api-key", rawApiKey);
@@ -57,7 +105,28 @@ export class RateLimitIdentity {
57
105
  if (additionalIdentity) return tracker("api-key", additionalIdentity);
58
106
  }
59
107
 
108
+ const sessionToken = this.sessionToken(request.headers);
109
+ if (sessionToken) return tracker("session", sessionToken);
110
+
60
111
  const clientAddress = clientAddressFromProxy(request.headers, remoteAddress, config);
61
112
  return tracker("ip", clientAddress);
62
113
  }
114
+
115
+ // 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.
118
+ private sessionToken(headers: Headers): string | undefined {
119
+ if (!this.sessionSecret) return undefined;
120
+ const cookies = headers.get("cookie");
121
+ const candidates = [
122
+ bearerValue(headers.get("authorization")),
123
+ ...SESSION_COOKIE_NAMES.map((name) => cookieValue(cookies, name)),
124
+ ];
125
+ for (const candidate of candidates) {
126
+ if (!candidate) continue;
127
+ const token = verifiedSessionToken(candidate, this.sessionSecret);
128
+ if (token) return token;
129
+ }
130
+ return undefined;
131
+ }
63
132
  }
@@ -49,25 +49,31 @@ describe("RateLimiter", () => {
49
49
  }));
50
50
  const limiter = new RateLimiter(
51
51
  config,
52
- { resolve: async () => "user:test" } as unknown as RateLimitIdentity,
52
+ { resolve: async () => "session:test" } as unknown as RateLimitIdentity,
53
53
  { increment } as RateLimitStorage,
54
54
  );
55
55
  await limiter.enforce(context("/ordinary").value);
56
56
  await limiter.enforce(context("/api/auth/sign-in", "POST").value);
57
57
  await limiter.enforce(context("/api/auth/get-session").value);
58
58
  await limiter.enforce(context("/site/settings").value);
59
+ await limiter.enforce(context("/api/auth/admin/list-users").value);
60
+ await limiter.enforce(context("/api/auth/admin/ban-user", "POST").value);
59
61
 
60
62
  expect(increment.mock.calls.map((call) => call.slice(1))).toEqual([
61
63
  [60],
62
64
  [60],
63
65
  [60],
64
66
  [60],
67
+ [60],
68
+ [60],
65
69
  ]);
66
70
  expect(increment.mock.calls.map((call) => call[0])).toEqual([
67
- "podokit:test:rate-limit:general:user:test",
68
- "podokit:test:rate-limit:auth:user:test",
69
- "podokit:test:rate-limit:runtime:user:test",
70
- "podokit:test:rate-limit:runtime:user:test",
71
+ "podokit:test:rate-limit:general:session:test",
72
+ "podokit:test:rate-limit:auth:session:test",
73
+ "podokit:test:rate-limit:runtime:session:test",
74
+ "podokit:test:rate-limit:runtime:session:test",
75
+ "podokit:test:rate-limit:general:session:test",
76
+ "podokit:test:rate-limit:auth:session:test",
71
77
  ]);
72
78
  });
73
79
 
@@ -131,7 +137,7 @@ describe("RateLimiter", () => {
131
137
  }));
132
138
  const limiter = new RateLimiter(
133
139
  config,
134
- { resolve: async () => "user:test" } as unknown as RateLimitIdentity,
140
+ { resolve: async () => "session:test" } as unknown as RateLimitIdentity,
135
141
  { increment },
136
142
  );
137
143
  const headers: Record<string, string> = {};
@@ -7,7 +7,11 @@ import {
7
7
  type RequestGuardContext,
8
8
  type ServiceKey,
9
9
  } from "../core/services";
10
- import { rateLimitConfig, type RateLimitConfig } from "./rate-limit.config";
10
+ import {
11
+ rateLimitConfig,
12
+ sessionSigningSecret,
13
+ type RateLimitConfig,
14
+ } from "./rate-limit.config";
11
15
  import { RateLimitIdentity } from "./rate-limit.identity";
12
16
 
13
17
  export interface RateLimitIncrement {
@@ -101,6 +105,11 @@ export class RateLimiter {
101
105
  ) {
102
106
  return { name: "runtime", ttlSeconds: this.config.ttlSeconds, limit: this.config.runtimeLimit };
103
107
  }
108
+ // Admin reads need an admin session and are not credential attempts; the
109
+ // server-paged admin tables issue one per page, sort, or search.
110
+ if (method === "GET" && path.startsWith("/api/auth/admin/")) {
111
+ return { name: "general", ttlSeconds: this.config.ttlSeconds, limit: this.config.limit };
112
+ }
104
113
  if (path === "/api/auth" || path.startsWith("/api/auth/")) {
105
114
  return { name: "auth", ttlSeconds: this.config.authTtlSeconds, limit: this.config.authLimit };
106
115
  }
@@ -132,7 +141,11 @@ export const rateLimitModule: PodokitModule = {
132
141
  configure: (_env, services): void => {
133
142
  const limiter = new RateLimiter(
134
143
  rateLimitConfig(),
135
- new RateLimitIdentity(services.resolve(API_KEY_VERIFIER), services),
144
+ new RateLimitIdentity(
145
+ services.resolve(API_KEY_VERIFIER),
146
+ services,
147
+ sessionSigningSecret(),
148
+ ),
136
149
  new CacheRateLimitStorage(services.resolve(CACHE)),
137
150
  );
138
151
  services.register(RATE_LIMITER, limiter);
@@ -65,6 +65,52 @@ test("rate limit: health probes stay available while ordinary routes return 429
65
65
  await ctx.dispose();
66
66
  });
67
67
 
68
+ test("rate limit: each signed session has its own counter and forged sessions do not", async ({
69
+ playwright,
70
+ }) => {
71
+ const email = `rate-limit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
72
+ const password = "Podokit3e-Str0ng!pw";
73
+ const first = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
74
+ expect((await first.post("/api/auth/sign-up/email", { data: { email, password, name: "Rate Limit" } })).ok()).toBeTruthy();
75
+ const probe = await first.get("/api/cache/rate-limit-probe");
76
+ test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
77
+ const remaining = (response: APIResponse): number => Number(response.headers()["ratelimit-remaining"]);
78
+ const ceiling = Number(probe.headers()["ratelimit-limit"]);
79
+ expect(remaining(probe)).toBe(ceiling - 1);
80
+
81
+ // The signed bearer form of the same session shares its counter.
82
+ const cookie = (await first.storageState()).cookies.find((c) => c.name.endsWith("session_token"));
83
+ expect(cookie, "sign-up set a session cookie").toBeTruthy();
84
+ const bearer = await playwright.request.newContext({
85
+ baseURL: base,
86
+ extraHTTPHeaders: { ...origin, authorization: `Bearer ${cookie?.value ?? ""}` },
87
+ });
88
+ expect(remaining(await bearer.get("/api/cache/rate-limit-probe"))).toBe(ceiling - 2);
89
+
90
+ // Counters follow the session, not the user: signing in again starts a new one.
91
+ const second = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
92
+ expect((await second.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
93
+ expect(remaining(await second.get("/api/cache/rate-limit-probe"))).toBe(ceiling - 1);
94
+
95
+ // A session cookie without a valid signature is counted with anonymous traffic
96
+ // from the same client address instead of receiving a fresh counter. The cache
97
+ // probe requires a session, so compare on a public route with the general limit.
98
+ const token = decodeURIComponent(cookie?.value ?? "").split(".")[0] ?? "";
99
+ const forged = await playwright.request.newContext({
100
+ baseURL: base,
101
+ extraHTTPHeaders: { ...origin, cookie: `${cookie?.name ?? ""}=${token}.${"A".repeat(43)}%3D` },
102
+ });
103
+ const anonymous = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
104
+ await expect(async () => {
105
+ const before = await anonymous.get("/api/account/capabilities");
106
+ const after = await forged.get("/api/account/capabilities");
107
+ expect(before.status()).toBe(200);
108
+ expect(remaining(after)).toBeLessThan(remaining(before));
109
+ }).toPass({ timeout: 10_000 });
110
+
111
+ for (const context of [first, bearer, second, forged, anonymous]) await context.dispose();
112
+ });
113
+
68
114
  test("rate limit: session reads do not consume the strict authentication budget", async ({
69
115
  playwright,
70
116
  }) => {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rate-limit",
3
3
  "manifestVersion": 3,
4
- "description": "Provider-neutral Elysia request limiting by authenticated user, validated API key, or trusted-proxy client address.",
4
+ "description": "Provider-neutral Elysia request limiting by validated API key, signed-in session, or trusted-proxy client address.",
5
5
  "requires": ["api-key-auth"],
6
6
  "requiresCapabilities": ["cache"],
7
7
  "targetApp": "api",
@@ -14,7 +14,7 @@
14
14
  "RATE_LIMIT_KEY_PREFIX=podokit:rate-limit",
15
15
  "RATE_LIMIT_TTL=60",
16
16
  "RATE_LIMIT_MAX=300",
17
- "# stricter authentication endpoint ceiling",
17
+ "# stricter ceiling for authentication writes (admin reads use RATE_LIMIT_MAX)",
18
18
  "RATE_LIMIT_AUTH_TTL=60",
19
19
  "RATE_LIMIT_AUTH_MAX=20",
20
20
  "# higher ceiling for SSR runtime settings and session reads",
@@ -42,7 +42,8 @@
42
42
  "Tune the general, authentication, and runtime limits in .env.",
43
43
  "Give each deployment that shares Redis a distinct RATE_LIMIT_KEY_PREFIX.",
44
44
  "Set RATE_LIMIT_TRUSTED_PROXY_HOPS to the exact number of trusted reverse proxies; keep 0 for direct API access.",
45
- "Authenticated users and validated X-API-Key clients receive independent opaque counters; other traffic uses the trusted client address.",
45
+ "Validated X-API-Key clients and signed-in sessions receive independent opaque counters; other traffic uses the trusted client address.",
46
+ "Sessions are recognized by verifying the Better Auth cookie or bearer signature with BETTER_AUTH_SECRET, without a database read, so limits apply per session rather than per user.",
46
47
  "Exceeded limits return RATE_LIMIT_EXCEEDED with HTTP 429 and Retry-After. Cache failures return RATE_LIMIT_UNAVAILABLE with HTTP 503.",
47
48
  "Register a RateLimitIdentityExtension under RATE_LIMIT_IDENTITY_EXTENSION in app.extensions.ts for another validated machine-key source.",
48
49
  "GET /health and GET /health/ready bypass request quotas so orchestrator probes remain reliable.",
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import {
3
+ FIXED_WINDOW_SCRIPT,
4
+ FIXED_WINDOW_SCRIPT_SHA,
5
+ RedisCacheStore,
6
+ } from "./redis-cache.store";
7
+ import { RedisService } from "./redis.service";
8
+
9
+ const redisUrl = process.env.REDIS_TEST_URL;
10
+ const integrationTest = redisUrl ? it : it.skip;
11
+
12
+ type Send = (command: string, args: string[]) => Promise<unknown>;
13
+ type SetValue = (key: string, value: string, ...options: Array<string | number>) => Promise<"OK">;
14
+
15
+ function storeWith(client: { send?: Send; set?: SetValue }): RedisCacheStore {
16
+ return new RedisCacheStore({ client } as unknown as RedisService);
17
+ }
18
+
19
+ function redisError(message: string): Error {
20
+ return Object.assign(new Error(message), { name: "RedisError", code: "ERR_REDIS_SERVER_ERROR" });
21
+ }
22
+
23
+ describe("RedisCacheStore fixed window", () => {
24
+ it("runs the cached script by SHA", async () => {
25
+ const send = mock<Send>(async () => [1, 60_000]);
26
+ const store = storeWith({ send });
27
+
28
+ await expect(
29
+ store.incrementFixedWindow("counter", { windowMs: 60_000, limit: 5 }),
30
+ ).resolves.toMatchObject({ allowed: true, count: 1, remaining: 4 });
31
+ expect(send.mock.calls).toEqual([
32
+ ["EVALSHA", [FIXED_WINDOW_SCRIPT_SHA, "1", "counter", "60000"]],
33
+ ]);
34
+ });
35
+
36
+ it("falls back to EVAL when Redis has not cached the script", async () => {
37
+ const send = mock<Send>(async (command) => {
38
+ if (command === "EVALSHA") throw redisError("NOSCRIPT No matching script. Please use EVAL.");
39
+ return [2, 500];
40
+ });
41
+ const store = storeWith({ send });
42
+
43
+ await expect(
44
+ store.incrementFixedWindow("counter", { windowMs: 1_000, limit: 1 }),
45
+ ).resolves.toMatchObject({ allowed: false, count: 2, remaining: 0 });
46
+ expect(send.mock.calls).toEqual([
47
+ ["EVALSHA", [FIXED_WINDOW_SCRIPT_SHA, "1", "counter", "1000"]],
48
+ ["EVAL", [FIXED_WINDOW_SCRIPT, "1", "counter", "1000"]],
49
+ ]);
50
+ });
51
+
52
+ it("propagates other Redis errors unchanged", async () => {
53
+ const failure = redisError("LOADING Redis is loading the dataset in memory");
54
+ const send = mock<Send>(async () => { throw failure; });
55
+ const store = storeWith({ send });
56
+
57
+ await expect(
58
+ store.incrementFixedWindow("counter", { windowMs: 1_000, limit: 1 }),
59
+ ).rejects.toBe(failure);
60
+ expect(send).toHaveBeenCalledTimes(1);
61
+ });
62
+ });
63
+
64
+ describe("RedisCacheStore set", () => {
65
+ it("writes the value and its TTL in one command", async () => {
66
+ const set = mock<SetValue>(async () => "OK");
67
+ const store = storeWith({ set });
68
+
69
+ await store.set("plain", "value");
70
+ await store.set("expiring", "value", { ttlMs: 1_500 });
71
+
72
+ expect(set.mock.calls).toEqual([
73
+ ["plain", "value"],
74
+ ["expiring", "value", "PX", 1_500],
75
+ ]);
76
+ });
77
+
78
+ it("rejects an invalid TTL before writing", async () => {
79
+ const set = mock<SetValue>(async () => "OK");
80
+ const store = storeWith({ set });
81
+
82
+ await expect(store.set("key", "value", { ttlMs: 0 })).rejects.toThrow("ttlMs");
83
+ expect(set).not.toHaveBeenCalled();
84
+ });
85
+ });
86
+
87
+ describe("RedisCacheStore integration", () => {
88
+ integrationTest("matches the Redis script SHA and counts atomically", async () => {
89
+ const previousUrl = process.env.REDIS_URL;
90
+ process.env.REDIS_URL = redisUrl;
91
+ const service = new RedisService();
92
+ const store = new RedisCacheStore(service);
93
+ const prefix = `podokit:test:${Date.now()}:${Math.random().toString(36).slice(2)}`;
94
+
95
+ try {
96
+ await service.connect();
97
+ await expect(
98
+ service.client.send("SCRIPT", ["LOAD", FIXED_WINDOW_SCRIPT]),
99
+ ).resolves.toBe(FIXED_WINDOW_SCRIPT_SHA);
100
+
101
+ const counter = `${prefix}:counter`;
102
+ const options = { windowMs: 60_000, limit: 1 };
103
+ await expect(store.incrementFixedWindow(counter, options)).resolves.toMatchObject({
104
+ allowed: true,
105
+ count: 1,
106
+ });
107
+ await expect(store.incrementFixedWindow(counter, options)).resolves.toMatchObject({
108
+ allowed: false,
109
+ count: 2,
110
+ });
111
+
112
+ const expiring = `${prefix}:expiring`;
113
+ await store.set(expiring, "value", { ttlMs: 60_000 });
114
+ await expect(store.get(expiring)).resolves.toBe("value");
115
+ expect(await service.client.pttl(expiring)).toBeGreaterThan(0);
116
+
117
+ await service.del(counter);
118
+ await service.del(expiring);
119
+ } finally {
120
+ service.close();
121
+ if (previousUrl === undefined) delete process.env.REDIS_URL;
122
+ else process.env.REDIS_URL = previousUrl;
123
+ }
124
+ });
125
+ });