@podosoft/podokit 0.14.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.
- package/README.md +20 -0
- package/dist/add.d.ts +4 -0
- package/dist/add.js +59 -1
- package/dist/create.js +8 -1
- package/dist/deploy-profile.d.ts +92 -0
- package/dist/deploy-profile.js +603 -0
- package/dist/deploy-render.d.ts +18 -0
- package/dist/deploy-render.js +651 -0
- package/dist/deploy.d.ts +65 -0
- package/dist/deploy.js +851 -0
- package/dist/dev.d.ts +1 -0
- package/dist/dev.js +29 -5
- package/dist/index.d.ts +8 -0
- package/dist/index.js +147 -1
- package/dist/lib.d.ts +6 -0
- package/dist/lib.js +18 -1
- package/dist/lockfile.js +3 -0
- package/dist/remove.js +7 -0
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +1 -0
- package/dist/templates/fullstack-nest-svelte/README.md +19 -8
- package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.spec.ts +53 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.ts +69 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/database/migrate.ts +16 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.module.ts +5 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.spec.ts +22 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.ts +33 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/src/app.html +2 -0
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +3 -5
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +37 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/rollback.md +18 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.example +6 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +1 -1
- package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +0 -4
- package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +6 -4
- package/dist/templates/fullstack-nest-svelte/tests/playwright.extensions.ts +44 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +35 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +33 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +18 -10
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +11 -6
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +18 -8
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +15 -9
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +83 -0
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +13 -10
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +69 -3
- package/dist/templates/modules/admin-dashboard/files/tests/seed.teardown.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +13 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +3 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +34 -28
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +29 -39
- package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.spec.ts +36 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.ts +20 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.spec.ts +49 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.ts +3 -11
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.module.ts +3 -1
- package/dist/templates/modules/api-key-auth/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +32 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +35 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -2
- package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +4 -5
- package/dist/templates/modules/bullmq/module.manifest.json +4 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +36 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +26 -5
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.spec.ts +35 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +21 -2
- package/dist/templates/modules/object-storage-s3/module.manifest.json +3 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +85 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +110 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +132 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +100 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.integration.spec.ts +91 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +228 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +97 -38
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +11 -6
- package/dist/templates/modules/rate-limit/module.manifest.json +23 -9
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +28 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +52 -11
- package/dist/templates/modules/redis/module.manifest.json +16 -1
- package/dist/templates/modules/sse/files/apps/api/src/events/events.controller.ts +17 -3
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +9 -2
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +61 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +44 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.redis.spec.ts +42 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.spec.ts +97 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +165 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +41 -0
- package/dist/templates/modules/sse/module.manifest.json +16 -3
- package/dist/templates/todo/AGENTS.md +1 -0
- package/dist/templates/todo/README.md +12 -8
- package/dist/templates/todo/apps/api/Dockerfile +2 -2
- package/dist/templates/todo/apps/api/package.json +1 -0
- package/dist/templates/todo/apps/api/src/config/redis.connection.spec.ts +53 -0
- package/dist/templates/todo/apps/api/src/config/redis.connection.ts +69 -0
- package/dist/templates/todo/apps/api/src/database/migrate.ts +16 -0
- package/dist/templates/todo/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/todo/apps/api/src/health/health.module.ts +5 -1
- package/dist/templates/todo/apps/api/src/health/readiness.service.spec.ts +22 -0
- package/dist/templates/todo/apps/api/src/health/readiness.service.ts +33 -0
- package/dist/templates/todo/apps/web/Dockerfile +2 -2
- package/dist/templates/todo/compose.dev.yaml +3 -5
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +28 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/rollback.md +5 -0
- package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
- package/dist/templates/todo/dot-env.example +6 -0
- package/dist/templates/todo/infra/k3s/api-deployment.yaml +1 -1
- package/dist/templates/todo/infra/k3s/ingress.yaml +0 -4
- package/dist/templates/todo/tests/playwright.config.ts +6 -4
- package/dist/templates/todo/tests/playwright.extensions.ts +44 -0
- package/dist/update.js +17 -13
- package/package.json +2 -2
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts
ADDED
|
@@ -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
|
+
});
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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: [
|
|
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 ??
|
|
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
|
|
34
|
-
for (let i = 0; i < limit + 5 && !
|
|
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)
|
|
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(
|
|
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": "
|
|
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
|
|
15
|
+
"# rate limit (window in seconds, max requests per identity)",
|
|
12
16
|
"RATE_LIMIT_TTL=60",
|
|
13
|
-
"RATE_LIMIT_MAX=
|
|
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
|
|
24
|
-
"
|
|
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
|
+
});
|
|
@@ -1,14 +1,35 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
Optional,
|
|
4
|
+
type OnModuleDestroy,
|
|
5
|
+
type OnModuleInit,
|
|
6
|
+
} from "@nestjs/common";
|
|
2
7
|
import Redis from "ioredis";
|
|
8
|
+
import { redisConnectionOptions } from "../config/redis.connection";
|
|
9
|
+
import { ReadinessService } from "../health/readiness.service";
|
|
3
10
|
|
|
4
11
|
@Injectable()
|
|
5
|
-
export class RedisService implements OnModuleDestroy {
|
|
6
|
-
readonly client = new Redis({
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
export class RedisService implements OnModuleInit, OnModuleDestroy {
|
|
13
|
+
readonly client = new Redis(redisConnectionOptions(process.env, {
|
|
14
|
+
lazyConnect: true,
|
|
15
|
+
enableReadyCheck: true,
|
|
16
|
+
enableOfflineQueue: false,
|
|
17
|
+
maxRetriesPerRequest: 1,
|
|
18
|
+
connectTimeout: 5_000,
|
|
19
|
+
commandTimeout: 5_000,
|
|
20
|
+
}));
|
|
11
21
|
private readonly subscribers: Redis[] = [];
|
|
22
|
+
private unregisterReadiness?: () => void;
|
|
23
|
+
|
|
24
|
+
constructor(@Optional() private readonly readiness?: ReadinessService) {}
|
|
25
|
+
|
|
26
|
+
async onModuleInit(): Promise<void> {
|
|
27
|
+
await this.client.connect();
|
|
28
|
+
await this.client.ping();
|
|
29
|
+
this.unregisterReadiness = this.readiness?.register("redis", async () => {
|
|
30
|
+
await this.client.ping();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
12
33
|
|
|
13
34
|
get(key: string): Promise<string | null> {
|
|
14
35
|
return this.client.get(key);
|
|
@@ -27,17 +48,37 @@ export class RedisService implements OnModuleDestroy {
|
|
|
27
48
|
return this.client.publish(channel, message);
|
|
28
49
|
}
|
|
29
50
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
51
|
+
async subscribe(
|
|
52
|
+
channel: string,
|
|
53
|
+
handler: (message: string) => void,
|
|
54
|
+
): Promise<() => Promise<void>> {
|
|
55
|
+
const sub = new Redis(redisConnectionOptions(process.env, {
|
|
56
|
+
lazyConnect: true,
|
|
57
|
+
enableReadyCheck: true,
|
|
58
|
+
maxRetriesPerRequest: null,
|
|
59
|
+
connectTimeout: 5_000,
|
|
60
|
+
}));
|
|
61
|
+
try {
|
|
62
|
+
await sub.connect();
|
|
63
|
+
await sub.subscribe(channel);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
await sub.quit().catch(() => undefined);
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
33
68
|
this.subscribers.push(sub);
|
|
34
|
-
void sub.subscribe(channel);
|
|
35
69
|
sub.on("message", (ch, message) => {
|
|
36
70
|
if (ch === channel) handler(message);
|
|
37
71
|
});
|
|
72
|
+
return async () => {
|
|
73
|
+
const index = this.subscribers.indexOf(sub);
|
|
74
|
+
if (index >= 0) this.subscribers.splice(index, 1);
|
|
75
|
+
await sub.unsubscribe(channel);
|
|
76
|
+
await sub.quit();
|
|
77
|
+
};
|
|
38
78
|
}
|
|
39
79
|
|
|
40
80
|
async onModuleDestroy(): Promise<void> {
|
|
81
|
+
this.unregisterReadiness?.();
|
|
41
82
|
await Promise.allSettled([this.client.quit(), ...this.subscribers.map((s) => s.quit())]);
|
|
42
83
|
}
|
|
43
84
|
}
|
|
@@ -2,12 +2,26 @@
|
|
|
2
2
|
"name": "redis",
|
|
3
3
|
"description": "Redis client (ioredis) with get/set/del and publish/subscribe, plus demo cache endpoints.",
|
|
4
4
|
"requires": [],
|
|
5
|
+
"requiredProjectFiles": [
|
|
6
|
+
"apps/api/src/config/redis.connection.ts",
|
|
7
|
+
"apps/api/src/health/readiness.service.ts"
|
|
8
|
+
],
|
|
9
|
+
"requiredProjectContents": {
|
|
10
|
+
"apps/api/src/config/redis.connection.ts": [
|
|
11
|
+
"connectTimeout?: number;",
|
|
12
|
+
"commandTimeout?: number;"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"managedGlobs": [
|
|
16
|
+
"apps/api/src/redis/**",
|
|
17
|
+
"tests/api/redis.api.spec.ts"
|
|
18
|
+
],
|
|
5
19
|
"targetApp": "api",
|
|
6
20
|
"dependencies": {
|
|
7
21
|
"ioredis": "^5.4.1"
|
|
8
22
|
},
|
|
9
23
|
"env": [
|
|
10
|
-
"# redis
|
|
24
|
+
"# redis uses REDIS_URL or the REDIS_HOST / REDIS_PORT / REDIS_USERNAME / REDIS_PASSWORD / REDIS_DB settings defined above"
|
|
11
25
|
],
|
|
12
26
|
"inject": [
|
|
13
27
|
{ "file": "apps/api/src/app.module.ts", "marker": "// podokit:end:imports", "text": "import { RedisModule } from \"./redis/redis.module\";" },
|
|
@@ -15,6 +29,7 @@
|
|
|
15
29
|
],
|
|
16
30
|
"instructions": [
|
|
17
31
|
"Start Redis: docker compose -f infra/docker/docker-compose.yml up -d",
|
|
32
|
+
"For production, prefer REDIS_URL; authenticated host settings are also supported.",
|
|
18
33
|
"Set a value: curl -XPUT localhost:5002/cache/greeting -H 'content-type: application/json' -d '{\"value\":\"hi\",\"ttl\":60}'",
|
|
19
34
|
"Read it back: curl localhost:5002/cache/greeting"
|
|
20
35
|
]
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Controller,
|
|
4
|
+
Post,
|
|
5
|
+
ServiceUnavailableException,
|
|
6
|
+
Sse,
|
|
7
|
+
type MessageEvent,
|
|
8
|
+
} from "@nestjs/common";
|
|
2
9
|
import { ApiTags } from "@nestjs/swagger";
|
|
3
10
|
import { interval, map, merge, type Observable } from "rxjs";
|
|
4
11
|
import { EventsService } from "./events.service";
|
|
@@ -18,8 +25,15 @@ export class EventsController {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
@Post()
|
|
21
|
-
publish(@Body() dto: PublishEventDto): { ok: true } {
|
|
22
|
-
|
|
28
|
+
async publish(@Body() dto: PublishEventDto): Promise<{ ok: true }> {
|
|
29
|
+
try {
|
|
30
|
+
await this.events.publishAsync({ type: "message", message: dto.message });
|
|
31
|
+
} catch {
|
|
32
|
+
throw new ServiceUnavailableException({
|
|
33
|
+
code: "EVENTS_TRANSPORT_UNAVAILABLE",
|
|
34
|
+
message: "Event transport is unavailable",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
23
37
|
return { ok: true };
|
|
24
38
|
}
|
|
25
39
|
}
|