@podosoft/podokit 0.15.0 → 0.16.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 (100) hide show
  1. package/README.md +17 -0
  2. package/dist/add.d.ts +4 -0
  3. package/dist/add.js +59 -1
  4. package/dist/create.js +8 -1
  5. package/dist/deploy-profile.d.ts +92 -0
  6. package/dist/deploy-profile.js +603 -0
  7. package/dist/deploy-render.d.ts +18 -0
  8. package/dist/deploy-render.js +651 -0
  9. package/dist/deploy.d.ts +65 -0
  10. package/dist/deploy.js +851 -0
  11. package/dist/dev.d.ts +1 -0
  12. package/dist/dev.js +29 -5
  13. package/dist/index.d.ts +8 -0
  14. package/dist/index.js +147 -1
  15. package/dist/lib.d.ts +6 -0
  16. package/dist/lib.js +18 -1
  17. package/dist/lockfile.js +1 -0
  18. package/dist/remove.js +7 -0
  19. package/dist/templates/fullstack-nest-svelte/AGENTS.md +1 -0
  20. package/dist/templates/fullstack-nest-svelte/README.md +19 -8
  21. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +2 -2
  22. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
  23. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.spec.ts +53 -0
  24. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.ts +69 -0
  25. package/dist/templates/fullstack-nest-svelte/apps/api/src/database/migrate.ts +16 -0
  26. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +19 -4
  27. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.module.ts +5 -1
  28. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.spec.ts +22 -0
  29. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.ts +33 -0
  30. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +2 -2
  31. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +3 -5
  32. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
  33. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
  34. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +37 -0
  35. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/rollback.md +18 -0
  36. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
  37. package/dist/templates/fullstack-nest-svelte/dot-env.example +6 -0
  38. package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +1 -1
  39. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +0 -4
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +6 -0
  41. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.spec.ts +36 -0
  42. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.ts +20 -0
  43. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.spec.ts +49 -0
  44. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.ts +3 -11
  45. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.module.ts +3 -1
  46. package/dist/templates/modules/api-key-auth/module.manifest.json +1 -1
  47. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +32 -0
  48. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +35 -0
  49. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -2
  50. package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +19 -4
  51. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +4 -5
  52. package/dist/templates/modules/bullmq/module.manifest.json +4 -1
  53. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +36 -0
  54. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +26 -5
  55. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.spec.ts +35 -0
  56. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +21 -2
  57. package/dist/templates/modules/object-storage-s3/module.manifest.json +3 -0
  58. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +85 -0
  59. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +110 -0
  60. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +132 -0
  61. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +100 -0
  62. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.integration.spec.ts +91 -0
  63. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +228 -0
  64. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +97 -38
  65. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +11 -6
  66. package/dist/templates/modules/rate-limit/module.manifest.json +23 -9
  67. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +28 -0
  68. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +52 -11
  69. package/dist/templates/modules/redis/module.manifest.json +16 -1
  70. package/dist/templates/modules/sse/files/apps/api/src/events/events.controller.ts +17 -3
  71. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +9 -2
  72. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +61 -0
  73. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +44 -4
  74. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.redis.spec.ts +42 -0
  75. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.spec.ts +97 -0
  76. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +165 -0
  77. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +41 -0
  78. package/dist/templates/modules/sse/module.manifest.json +16 -3
  79. package/dist/templates/todo/AGENTS.md +1 -0
  80. package/dist/templates/todo/README.md +12 -8
  81. package/dist/templates/todo/apps/api/Dockerfile +2 -2
  82. package/dist/templates/todo/apps/api/package.json +1 -0
  83. package/dist/templates/todo/apps/api/src/config/redis.connection.spec.ts +53 -0
  84. package/dist/templates/todo/apps/api/src/config/redis.connection.ts +69 -0
  85. package/dist/templates/todo/apps/api/src/database/migrate.ts +16 -0
  86. package/dist/templates/todo/apps/api/src/health/health.controller.ts +19 -4
  87. package/dist/templates/todo/apps/api/src/health/health.module.ts +5 -1
  88. package/dist/templates/todo/apps/api/src/health/readiness.service.spec.ts +22 -0
  89. package/dist/templates/todo/apps/api/src/health/readiness.service.ts +33 -0
  90. package/dist/templates/todo/apps/web/Dockerfile +2 -2
  91. package/dist/templates/todo/compose.dev.yaml +3 -5
  92. package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
  93. package/dist/templates/todo/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
  94. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +28 -0
  95. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/rollback.md +5 -0
  96. package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
  97. package/dist/templates/todo/dot-env.example +6 -0
  98. package/dist/templates/todo/infra/k3s/api-deployment.yaml +1 -1
  99. package/dist/templates/todo/infra/k3s/ingress.yaml +0 -4
  100. package/package.json +2 -2
@@ -0,0 +1,91 @@
1
+ import { Controller, Get, Global, Module } from "@nestjs/common";
2
+ import { Test } from "@nestjs/testing";
3
+ import { describe, it, jest } from "@jest/globals";
4
+ import request from "supertest";
5
+ import { AllExceptionsFilter } from "../common/all-exceptions.filter";
6
+ import { ReadinessService } from "../health/readiness.service";
7
+
8
+ jest.mock("../auth/auth-provider", () => ({
9
+ authRuntime: { api: { getSession: jest.fn() } },
10
+ }));
11
+ jest.mock("better-auth/node", () => ({
12
+ fromNodeHeaders: (headers: unknown) => headers,
13
+ }));
14
+
15
+ import { RateLimitModule } from "./rate-limit.module";
16
+
17
+ @Global()
18
+ @Module({
19
+ providers: [ReadinessService],
20
+ exports: [ReadinessService],
21
+ })
22
+ class TestReadinessModule {}
23
+
24
+ @Controller()
25
+ class TestController {
26
+ @Get("ordinary")
27
+ ordinary(): { ok: true } {
28
+ return { ok: true };
29
+ }
30
+
31
+ @Get("health")
32
+ health(): { ok: true } {
33
+ return { ok: true };
34
+ }
35
+ }
36
+
37
+ @Module({
38
+ imports: [TestReadinessModule, RateLimitModule],
39
+ controllers: [TestController],
40
+ })
41
+ class TestApplicationModule {}
42
+
43
+ const redisUrl = process.env.REDIS_TEST_URL;
44
+ const integrationTest = redisUrl ? it : it.skip;
45
+
46
+ describe("RateLimitModule integration", () => {
47
+ integrationTest("shares counters across replicas and bypasses health probes", async () => {
48
+ const previousRedisUrl = process.env.REDIS_URL;
49
+ process.env.REDIS_URL = redisUrl;
50
+ const firstModule = await Test.createTestingModule({
51
+ imports: [TestApplicationModule],
52
+ }).compile();
53
+ const secondModule = await Test.createTestingModule({
54
+ imports: [TestApplicationModule],
55
+ }).compile();
56
+ const first = firstModule.createNestApplication();
57
+ const second = secondModule.createNestApplication();
58
+
59
+ try {
60
+ first.useGlobalFilters(new AllExceptionsFilter());
61
+ second.useGlobalFilters(new AllExceptionsFilter());
62
+ await Promise.all([first.init(), second.init()]);
63
+ const clientAddress = `198.51.100.${Math.floor(Math.random() * 200) + 1}`;
64
+ await request(first.getHttpServer())
65
+ .get("/ordinary")
66
+ .set("x-forwarded-for", clientAddress)
67
+ .expect(200);
68
+ const limited = await request(second.getHttpServer())
69
+ .get("/ordinary")
70
+ .set("x-forwarded-for", clientAddress)
71
+ .expect(429);
72
+ if (
73
+ limited.body?.error?.code !== "RATE_LIMIT_EXCEEDED" ||
74
+ !limited.headers["retry-after"]
75
+ ) {
76
+ throw new Error("Shared rate-limit response contract is invalid");
77
+ }
78
+
79
+ for (let index = 0; index < 3; index += 1) {
80
+ await request(index % 2 === 0 ? first.getHttpServer() : second.getHttpServer())
81
+ .get("/health")
82
+ .set("x-forwarded-for", clientAddress)
83
+ .expect(200);
84
+ }
85
+ } finally {
86
+ await Promise.all([first.close(), second.close()]);
87
+ if (previousRedisUrl === undefined) delete process.env.REDIS_URL;
88
+ else process.env.REDIS_URL = previousRedisUrl;
89
+ }
90
+ });
91
+ });
@@ -0,0 +1,228 @@
1
+ import {
2
+ Controller,
3
+ Get,
4
+ type INestApplication,
5
+ Injectable,
6
+ Module,
7
+ Post,
8
+ } from "@nestjs/common";
9
+ import { APP_GUARD } from "@nestjs/core";
10
+ import { Test } from "@nestjs/testing";
11
+ import {
12
+ ThrottlerModule,
13
+ type ThrottlerStorage,
14
+ } from "@nestjs/throttler";
15
+ import {
16
+ afterAll,
17
+ beforeAll,
18
+ beforeEach,
19
+ describe,
20
+ expect,
21
+ it,
22
+ jest,
23
+ } from "@jest/globals";
24
+ import Redis from "ioredis";
25
+ import request from "supertest";
26
+ import { AllExceptionsFilter } from "../common/all-exceptions.filter";
27
+ import { RedisService } from "../redis/redis.service";
28
+
29
+ jest.mock("../auth/auth-provider", () => ({
30
+ authRuntime: { api: { getSession: jest.fn() } },
31
+ }));
32
+ jest.mock("better-auth/node", () => ({
33
+ fromNodeHeaders: (headers: unknown) => headers,
34
+ }));
35
+
36
+ import {
37
+ ProxyAwareThrottlerGuard,
38
+ RateLimitModule,
39
+ } from "./rate-limit.module";
40
+ import {
41
+ RateLimitIdentity,
42
+ RateLimitIdentityExtension,
43
+ type RateLimitRequest,
44
+ } from "./rate-limit.identity";
45
+
46
+ type StorageMode = "allow" | "blocked" | "error";
47
+ type IncrementCall = {
48
+ ttl: number;
49
+ limit: number;
50
+ blockDuration: number;
51
+ };
52
+
53
+ class ContractStorage implements ThrottlerStorage {
54
+ mode: StorageMode = "allow";
55
+ readonly calls: IncrementCall[] = [];
56
+
57
+ async increment(
58
+ _key: string,
59
+ ttl: number,
60
+ limit: number,
61
+ blockDuration: number,
62
+ _throttlerName: string,
63
+ ): Promise<{
64
+ totalHits: number;
65
+ timeToExpire: number;
66
+ isBlocked: boolean;
67
+ timeToBlockExpire: number;
68
+ }> {
69
+ this.calls.push({ ttl, limit, blockDuration });
70
+ if (this.mode === "error") throw new Error("Redis unavailable");
71
+ return {
72
+ totalHits: this.mode === "blocked" ? limit + 1 : 1,
73
+ timeToExpire: 7,
74
+ isBlocked: this.mode === "blocked",
75
+ timeToBlockExpire: 7,
76
+ };
77
+ }
78
+ }
79
+
80
+ const storage = new ContractStorage();
81
+
82
+ @Controller()
83
+ class ContractController {
84
+ @Get("ordinary")
85
+ ordinary(): { ok: true } {
86
+ return { ok: true };
87
+ }
88
+
89
+ @Post("api/auth/sign-in")
90
+ signIn(): { ok: true } {
91
+ return { ok: true };
92
+ }
93
+
94
+ @Get("site/settings")
95
+ runtime(): { ok: true } {
96
+ return { ok: true };
97
+ }
98
+
99
+ @Get("health")
100
+ health(): { ok: true } {
101
+ return { ok: true };
102
+ }
103
+
104
+ @Get("health/ready")
105
+ ready(): { ok: true } {
106
+ return { ok: true };
107
+ }
108
+ }
109
+
110
+ @Module({
111
+ imports: [
112
+ ThrottlerModule.forRoot({
113
+ throttlers: [{ ttl: 60_000, limit: 300 }],
114
+ storage,
115
+ }),
116
+ ],
117
+ controllers: [ContractController],
118
+ providers: [
119
+ {
120
+ provide: RateLimitIdentity,
121
+ useValue: { resolve: async () => "user:test-tracker" },
122
+ },
123
+ { provide: APP_GUARD, useClass: ProxyAwareThrottlerGuard },
124
+ ],
125
+ })
126
+ class ContractModule {}
127
+
128
+ describe("ProxyAwareThrottlerGuard contract", () => {
129
+ let app: INestApplication;
130
+
131
+ beforeAll(async () => {
132
+ const module = await Test.createTestingModule({
133
+ imports: [ContractModule],
134
+ }).compile();
135
+ app = module.createNestApplication();
136
+ app.useGlobalFilters(new AllExceptionsFilter());
137
+ await app.init();
138
+ });
139
+
140
+ beforeEach(() => {
141
+ storage.mode = "allow";
142
+ storage.calls.length = 0;
143
+ });
144
+
145
+ afterAll(async () => {
146
+ await app.close();
147
+ });
148
+
149
+ it("applies separate general, authentication, and runtime limits", async () => {
150
+ await request(app.getHttpServer()).get("/ordinary").expect(200);
151
+ await request(app.getHttpServer()).post("/api/auth/sign-in").expect(201);
152
+ await request(app.getHttpServer()).get("/site/settings").expect(200);
153
+
154
+ expect(storage.calls).toEqual([
155
+ { ttl: 60_000, limit: 300, blockDuration: 60_000 },
156
+ { ttl: 60_000, limit: 20, blockDuration: 60_000 },
157
+ { ttl: 60_000, limit: 1000, blockDuration: 60_000 },
158
+ ]);
159
+ });
160
+
161
+ it("keeps liveness and readiness outside request limiting", async () => {
162
+ await request(app.getHttpServer()).get("/health").expect(200);
163
+ await request(app.getHttpServer()).get("/health/ready").expect(200);
164
+ expect(storage.calls).toHaveLength(0);
165
+ });
166
+
167
+ it("returns stable error envelopes for blocked and unavailable storage", async () => {
168
+ storage.mode = "blocked";
169
+ const blocked = await request(app.getHttpServer()).get("/ordinary").expect(429);
170
+ expect(blocked.headers["retry-after"]).toBe("7");
171
+ expect(blocked.body).toMatchObject({
172
+ success: false,
173
+ error: { code: "RATE_LIMIT_EXCEEDED", statusCode: 429 },
174
+ });
175
+
176
+ storage.mode = "error";
177
+ const unavailable = await request(app.getHttpServer()).get("/ordinary").expect(503);
178
+ expect(unavailable.headers["retry-after"]).toBe("1");
179
+ expect(unavailable.body).toMatchObject({
180
+ success: false,
181
+ error: { code: "RATE_LIMIT_UNAVAILABLE", statusCode: 503 },
182
+ });
183
+ });
184
+
185
+ it("uses an application identity extension without a second rate-limit module", async () => {
186
+ @Injectable()
187
+ class ApplicationIdentityExtension extends RateLimitIdentityExtension {
188
+ override async validatedApiKeyId(
189
+ _request: RateLimitRequest,
190
+ rawApiKey: string,
191
+ ): Promise<string | undefined> {
192
+ return rawApiKey === "application-valid" ? "application-id" : undefined;
193
+ }
194
+ }
195
+ const redis = Object.create(Redis.prototype) as Redis;
196
+ const module = await Test.createTestingModule({
197
+ imports: [RateLimitModule],
198
+ providers: [
199
+ {
200
+ provide: RateLimitIdentityExtension,
201
+ useClass: ApplicationIdentityExtension,
202
+ },
203
+ ],
204
+ })
205
+ .overrideProvider(RedisService)
206
+ .useValue({ client: redis })
207
+ .compile();
208
+ try {
209
+ const identity = module.get(RateLimitIdentity);
210
+ const headers = { "x-api-key": "application-valid" };
211
+ const tracker = await identity.resolve(
212
+ {
213
+ headers,
214
+ header: (name: string) =>
215
+ headers[name.toLowerCase() as keyof typeof headers],
216
+ socket: { remoteAddress: "10.0.0.9" },
217
+ ip: "10.0.0.9",
218
+ session: null,
219
+ } as unknown as RateLimitRequest,
220
+ { proxyHeader: "x-forwarded-for", trustedProxyHops: 0 },
221
+ );
222
+ expect(tracker).toMatch(/^api-key:[a-f0-9]{64}$/);
223
+ expect(tracker).not.toContain("application-id");
224
+ } finally {
225
+ await module.close();
226
+ }
227
+ });
228
+ });
@@ -1,65 +1,124 @@
1
1
  import { Injectable, Module } from "@nestjs/common";
2
- import { APP_GUARD } from "@nestjs/core";
2
+ import { APP_GUARD, Reflector } from "@nestjs/core";
3
3
  import {
4
+ InjectThrottlerOptions,
5
+ InjectThrottlerStorage,
4
6
  ThrottlerGuard,
5
7
  ThrottlerModule,
8
+ type ThrottlerLimitDetail,
9
+ type ThrottlerModuleOptions,
6
10
  type ThrottlerRequest,
11
+ type ThrottlerStorage,
7
12
  } from "@nestjs/throttler";
8
13
  import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
9
- import Redis from "ioredis";
14
+ import type { Request } from "express";
15
+ import { ApiKeyVerifier } from "../api-key/api-key-verifier";
16
+ import { AppException } from "../common/app-exception";
17
+ import { RedisModule } from "../redis/redis.module";
18
+ import { RedisService } from "../redis/redis.service";
19
+ import { rateLimitConfig } from "./rate-limit.config";
20
+ import { RateLimitIdentity } from "./rate-limit.identity";
10
21
 
11
- const ttlSeconds = Number(process.env.RATE_LIMIT_TTL ?? 60);
12
- const limit = Number(process.env.RATE_LIMIT_MAX ?? 100);
13
- const runtimeLimit = Number(process.env.RATE_LIMIT_RUNTIME_MAX ?? 1000);
22
+ const config = rateLimitConfig();
14
23
  const unthrottledHealthPaths = new Set(["/health", "/health/ready"]);
15
24
 
16
- function forwardedClient(headers: unknown): string | undefined {
17
- if (!headers || typeof headers !== "object") return undefined;
18
- const value = (headers as Record<string, unknown>)["x-forwarded-for"];
19
- const first = Array.isArray(value) ? value[0] : value;
20
- if (typeof first !== "string") return undefined;
21
- return first.split(",")[0]?.trim() || undefined;
22
- }
23
-
24
- // PodoKit exposes the API through its web proxy, which replaces x-forwarded-for
25
- // with SvelteKit's resolved client address. Track that address instead of the
26
- // web container so unrelated visitors do not share one global counter.
27
25
  @Injectable()
28
- class ProxyAwareThrottlerGuard extends ThrottlerGuard {
29
- protected override handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
30
- const { req } = this.getRequestResponse(requestProps.context);
26
+ export class ProxyAwareThrottlerGuard extends ThrottlerGuard {
27
+ constructor(
28
+ @InjectThrottlerOptions() options: ThrottlerModuleOptions,
29
+ @InjectThrottlerStorage() storage: ThrottlerStorage,
30
+ reflector: Reflector,
31
+ private readonly identity: RateLimitIdentity,
32
+ ) {
33
+ super(options, storage, reflector);
34
+ }
35
+
36
+ protected override async handleRequest(
37
+ requestProps: ThrottlerRequest,
38
+ ): Promise<boolean> {
39
+ const { req, res } = this.getRequestResponse(requestProps.context);
31
40
  const method = typeof req.method === "string" ? req.method : "";
32
41
  const path = typeof req.path === "string" ? req.path : "";
33
- if (method === "GET" && unthrottledHealthPaths.has(path)) {
34
- return Promise.resolve(true);
42
+ if (method === "GET" && unthrottledHealthPaths.has(path)) return true;
43
+
44
+ let effective = requestProps;
45
+ if (path === "/api/auth" || path.startsWith("/api/auth/")) {
46
+ effective = {
47
+ ...requestProps,
48
+ limit: config.authLimit,
49
+ ttl: config.authTtlSeconds * 1000,
50
+ blockDuration: config.authTtlSeconds * 1000,
51
+ };
52
+ } else if (method === "GET" && path === "/site/settings") {
53
+ effective = { ...requestProps, limit: config.runtimeLimit };
35
54
  }
36
- if (method === "GET" && path === "/site/settings") {
37
- return super.handleRequest({ ...requestProps, limit: runtimeLimit });
55
+
56
+ try {
57
+ return await this.withStorageTimeout(super.handleRequest(effective));
58
+ } catch (error) {
59
+ if (error instanceof AppException) throw error;
60
+ if (typeof res.header === "function") {
61
+ res.header("Retry-After", config.unavailableRetryAfterSeconds);
62
+ }
63
+ throw new AppException(
64
+ "RATE_LIMIT_UNAVAILABLE",
65
+ "Request limiting is temporarily unavailable.",
66
+ 503,
67
+ );
38
68
  }
39
- return super.handleRequest(requestProps);
40
69
  }
41
70
 
42
71
  protected override getTracker(request: Record<string, unknown>): Promise<string> {
43
- const forwarded = forwardedClient(request.headers);
44
- const direct = typeof request.ip === "string" ? request.ip : "unknown";
45
- return Promise.resolve(forwarded ?? direct);
72
+ return this.identity.resolve(request as unknown as Request, config);
73
+ }
74
+
75
+ protected override async throwThrottlingException(
76
+ _context: Parameters<ThrottlerGuard["canActivate"]>[0],
77
+ _detail: ThrottlerLimitDetail,
78
+ ): Promise<void> {
79
+ throw new AppException(
80
+ "RATE_LIMIT_EXCEEDED",
81
+ "Too many requests.",
82
+ 429,
83
+ );
84
+ }
85
+
86
+ private async withStorageTimeout(operation: Promise<boolean>): Promise<boolean> {
87
+ let timeout: NodeJS.Timeout | undefined;
88
+ try {
89
+ return await Promise.race([
90
+ operation,
91
+ new Promise<never>((_resolve, reject) => {
92
+ timeout = setTimeout(
93
+ () => reject(new Error("Rate limit storage timeout")),
94
+ config.storageTimeoutMs,
95
+ );
96
+ }),
97
+ ]);
98
+ } finally {
99
+ if (timeout) clearTimeout(timeout);
100
+ }
46
101
  }
47
102
  }
48
103
 
49
- // Distributed rate limiting: the counter lives in Redis, so the limit holds
50
- // across API replicas. Registers a global guard (429 when exceeded).
51
104
  @Module({
52
105
  imports: [
53
- ThrottlerModule.forRoot({
54
- throttlers: [{ ttl: ttlSeconds * 1000, limit }],
55
- storage: new ThrottlerStorageRedisService(
56
- new Redis({
57
- host: process.env.REDIS_HOST ?? "localhost",
58
- port: Number(process.env.REDIS_PORT ?? 6379),
59
- }),
60
- ),
106
+ RedisModule,
107
+ ThrottlerModule.forRootAsync({
108
+ imports: [RedisModule],
109
+ inject: [RedisService],
110
+ useFactory: (redis: RedisService) => ({
111
+ throttlers: [{ ttl: config.ttlSeconds * 1000, limit: config.limit }],
112
+ storage: new ThrottlerStorageRedisService(redis.client),
113
+ }),
61
114
  }),
62
115
  ],
63
- providers: [{ provide: APP_GUARD, useClass: ProxyAwareThrottlerGuard }],
116
+ providers: [
117
+ ApiKeyVerifier,
118
+ RateLimitIdentity,
119
+ ProxyAwareThrottlerGuard,
120
+ { provide: APP_GUARD, useExisting: ProxyAwareThrottlerGuard },
121
+ ],
122
+ exports: [RateLimitIdentity],
64
123
  })
65
124
  export class RateLimitModule {}
@@ -1,4 +1,4 @@
1
- import { expect, test } from "@playwright/test";
1
+ import { expect, test, type APIResponse } from "@playwright/test";
2
2
 
3
3
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
4
4
  const origin = { origin: base };
@@ -11,7 +11,7 @@ const origin = { origin: base };
11
11
  // Honor RATE_LIMIT_MAX when the test runner has it (a dedicated app sets a small
12
12
  // value so the window is crossed in a handful of requests); otherwise fall back
13
13
  // to the module default and allow a little headroom.
14
- const limit = Number(process.env.RATE_LIMIT_MAX ?? 100);
14
+ const limit = Number(process.env.RATE_LIMIT_MAX ?? 300);
15
15
 
16
16
  test("rate limit: health probes stay available while ordinary routes return 429 @smoke", async ({
17
17
  playwright,
@@ -30,15 +30,20 @@ test("rate limit: health probes stay available while ordinary routes return 429
30
30
  test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
31
31
 
32
32
  let seen200 = probe.status() === 200;
33
- let got429 = false;
34
- for (let i = 0; i < limit + 5 && !got429; i++) {
33
+ let limitedResponse: APIResponse | undefined;
34
+ for (let i = 0; i < limit + 5 && !limitedResponse; i++) {
35
35
  const response = await ctx.get("/api/cache/rate-limit-probe");
36
36
  if (response.status() === 200) seen200 = true;
37
- if (response.status() === 429) got429 = true;
37
+ if (response.status() === 429) limitedResponse = response;
38
38
  }
39
39
  // A fresh window serves the first requests (200) and rejects once the limit is
40
40
  // crossed (429).
41
41
  expect(seen200).toBe(true);
42
- expect(got429).toBe(true);
42
+ expect(limitedResponse?.status()).toBe(429);
43
+ expect(limitedResponse?.headers()["retry-after"]).toBeTruthy();
44
+ expect(await limitedResponse?.json()).toMatchObject({
45
+ success: false,
46
+ error: { code: "RATE_LIMIT_EXCEEDED", statusCode: 429 },
47
+ });
43
48
  await ctx.dispose();
44
49
  });
@@ -1,18 +1,30 @@
1
1
  {
2
2
  "name": "rate-limit",
3
- "description": "Rate limiting with @nestjs/throttler backed by Redis (correct across API replicas). Installs a global throttler guard; configurable via env.",
4
- "requires": ["redis"],
3
+ "description": "Distributed request limiting by authenticated user, validated API key, or trusted-proxy client address. Uses Redis across API replicas and returns stable 429/503 errors.",
4
+ "requires": ["redis", "api-key-auth"],
5
5
  "targetApp": "api",
6
+ "managedGlobs": [
7
+ "apps/api/src/rate-limit/**",
8
+ "tests/api/rate-limit.api.spec.ts"
9
+ ],
6
10
  "dependencies": {
7
11
  "@nestjs/throttler": "^6.5.0",
8
12
  "@nest-lab/throttler-storage-redis": "^1.2.0"
9
13
  },
10
14
  "env": [
11
- "# rate limit (window in seconds, max requests per window per client)",
15
+ "# rate limit (window in seconds, max requests per identity)",
12
16
  "RATE_LIMIT_TTL=60",
13
- "RATE_LIMIT_MAX=100",
17
+ "RATE_LIMIT_MAX=300",
18
+ "# stricter authentication endpoint ceiling",
19
+ "RATE_LIMIT_AUTH_TTL=60",
20
+ "RATE_LIMIT_AUTH_MAX=20",
14
21
  "# higher ceiling for SSR runtime settings reads (maintenance, sign-up, branding)",
15
- "RATE_LIMIT_RUNTIME_MAX=1000"
22
+ "RATE_LIMIT_RUNTIME_MAX=1000",
23
+ "# trust no proxy hop by default; set the exact production proxy depth",
24
+ "RATE_LIMIT_TRUSTED_PROXY_HOPS=0",
25
+ "RATE_LIMIT_PROXY_HEADER=x-forwarded-for",
26
+ "RATE_LIMIT_STORAGE_TIMEOUT_MS=1000",
27
+ "RATE_LIMIT_UNAVAILABLE_RETRY_AFTER=1"
16
28
  ],
17
29
  "inject": [
18
30
  { "file": "apps/api/src/app.module.ts", "marker": "// podokit:end:imports", "text": "import { RateLimitModule } from \"./rate-limit/rate-limit.module\";" },
@@ -20,10 +32,12 @@
20
32
  ],
21
33
  "instructions": [
22
34
  "Ensure Redis is running: docker compose -f infra/docker/docker-compose.yml up -d",
23
- "Tune RATE_LIMIT_TTL / RATE_LIMIT_MAX / RATE_LIMIT_RUNTIME_MAX in .env.",
24
- "Exceeding the limit returns HTTP 429. Skip a route with @SkipThrottle() or tune it with @Throttle().",
35
+ "Tune the general, authentication, and runtime limits in .env.",
36
+ "Set RATE_LIMIT_TRUSTED_PROXY_HOPS to the exact number of trusted reverse proxies; keep 0 for direct API access.",
37
+ "Authenticated users and validated X-API-Key clients receive independent opaque counters; other traffic uses the trusted client address.",
38
+ "Exceeded limits return RATE_LIMIT_EXCEEDED with HTTP 429 and Retry-After. Redis failures return RATE_LIMIT_UNAVAILABLE with HTTP 503.",
39
+ "Provide RateLimitIdentityExtension from app.extensions.ts when an application has another validated machine-key source; keep the single managed RateLimitModule import.",
25
40
  "GET /health and GET /health/ready bypass request quotas so orchestrator probes remain reliable.",
26
- "The limit is shared across API replicas because it is stored in Redis.",
27
- "PodoKit's web proxy forwards the resolved client address, so visitors receive independent counters."
41
+ "The limit is shared across API replicas because it is stored in Redis."
28
42
  ]
29
43
  }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import { ReadinessService } from "../health/readiness.service";
3
+ import { RedisService } from "./redis.service";
4
+
5
+ const redisUrl = process.env.REDIS_TEST_URL;
6
+ const integrationTest = redisUrl ? it : it.skip;
7
+
8
+ describe("RedisService integration", () => {
9
+ integrationTest("uses authenticated settings and registers readiness", async () => {
10
+ const previousUrl = process.env.REDIS_URL;
11
+ process.env.REDIS_URL = redisUrl;
12
+ const readiness = new ReadinessService();
13
+ const service = new RedisService(readiness);
14
+ const key = `podokit:test:${Date.now()}:${Math.random().toString(36).slice(2)}`;
15
+
16
+ try {
17
+ await service.onModuleInit();
18
+ await service.set(key, "value", 10);
19
+ await expect(service.get(key)).resolves.toBe("value");
20
+ await expect(readiness.run()).resolves.toEqual({ redis: "up" });
21
+ await service.del(key);
22
+ } finally {
23
+ await service.onModuleDestroy();
24
+ if (previousUrl === undefined) delete process.env.REDIS_URL;
25
+ else process.env.REDIS_URL = previousUrl;
26
+ }
27
+ });
28
+ });