@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.
- package/README.md +17 -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 +1 -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/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/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +6 -0
- 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/package.json +2 -2
package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from "@jest/globals";
|
|
2
|
+
import type { EventsService } from "../events/events.service";
|
|
3
|
+
import type { RedisService } from "../redis/redis.service";
|
|
4
|
+
import { ProgressBridge } from "./progress.bridge";
|
|
5
|
+
import { PROGRESS_CHANNEL } from "./progress.processor";
|
|
6
|
+
|
|
7
|
+
describe("ProgressBridge", () => {
|
|
8
|
+
it("delivers worker fan-out locally without republishing it", async () => {
|
|
9
|
+
let handler: ((message: string) => void) | undefined;
|
|
10
|
+
const unsubscribe = jest.fn<() => Promise<void>>(() => Promise.resolve());
|
|
11
|
+
const subscribe = jest.fn(
|
|
12
|
+
(channel: string, listener: (message: string) => void): Promise<() => Promise<void>> => {
|
|
13
|
+
expect(channel).toBe(PROGRESS_CHANNEL);
|
|
14
|
+
handler = listener;
|
|
15
|
+
return Promise.resolve(unsubscribe);
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
const publishLocal = jest.fn<(data: unknown) => void>();
|
|
19
|
+
const redis = { subscribe } as unknown as RedisService;
|
|
20
|
+
const events = { publishLocal } as unknown as EventsService;
|
|
21
|
+
const bridge = new ProgressBridge(redis, events);
|
|
22
|
+
|
|
23
|
+
await bridge.onModuleInit();
|
|
24
|
+
handler?.('{"jobId":"1","progress":50}');
|
|
25
|
+
handler?.("invalid");
|
|
26
|
+
|
|
27
|
+
expect(publishLocal).toHaveBeenCalledTimes(1);
|
|
28
|
+
expect(publishLocal).toHaveBeenCalledWith({
|
|
29
|
+
type: "job-progress",
|
|
30
|
+
jobId: "1",
|
|
31
|
+
progress: 50,
|
|
32
|
+
});
|
|
33
|
+
await bridge.onModuleDestroy();
|
|
34
|
+
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -1,19 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
Logger,
|
|
4
|
+
type OnModuleDestroy,
|
|
5
|
+
type OnModuleInit,
|
|
6
|
+
} from "@nestjs/common";
|
|
2
7
|
import { RedisService } from "../redis/redis.service";
|
|
3
8
|
import { EventsService } from "../events/events.service";
|
|
4
9
|
import { PROGRESS_CHANNEL } from "./progress.processor";
|
|
5
10
|
|
|
6
11
|
// Runs in the API process: relays worker progress (Redis pub/sub) to SSE.
|
|
7
12
|
@Injectable()
|
|
8
|
-
export class ProgressBridge implements OnModuleInit {
|
|
13
|
+
export class ProgressBridge implements OnModuleInit, OnModuleDestroy {
|
|
14
|
+
private readonly logger = new Logger(ProgressBridge.name);
|
|
15
|
+
private unsubscribe?: () => Promise<void>;
|
|
16
|
+
|
|
9
17
|
constructor(
|
|
10
18
|
private readonly redis: RedisService,
|
|
11
19
|
private readonly events: EventsService,
|
|
12
20
|
) {}
|
|
13
21
|
|
|
14
|
-
onModuleInit(): void {
|
|
15
|
-
this.redis.subscribe(PROGRESS_CHANNEL, (message) => {
|
|
16
|
-
|
|
22
|
+
async onModuleInit(): Promise<void> {
|
|
23
|
+
this.unsubscribe = await this.redis.subscribe(PROGRESS_CHANNEL, (message) => {
|
|
24
|
+
try {
|
|
25
|
+
const parsed: unknown = JSON.parse(message);
|
|
26
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
27
|
+
this.logger.warn("Discarded invalid job progress event");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
this.events.publishLocal({ type: "job-progress", ...parsed });
|
|
31
|
+
} catch {
|
|
32
|
+
this.logger.warn("Discarded malformed job progress event");
|
|
33
|
+
}
|
|
17
34
|
});
|
|
18
35
|
}
|
|
36
|
+
|
|
37
|
+
async onModuleDestroy(): Promise<void> {
|
|
38
|
+
await this.unsubscribe?.();
|
|
39
|
+
}
|
|
19
40
|
}
|
package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.spec.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { HeadBucketCommand } from "@aws-sdk/client-s3";
|
|
2
|
+
import { describe, expect, it, jest } from "@jest/globals";
|
|
3
|
+
import type { ReadinessService } from "../health/readiness.service";
|
|
4
|
+
import { StorageService } from "./storage.service";
|
|
5
|
+
|
|
6
|
+
describe("StorageService readiness", () => {
|
|
7
|
+
it("registers a bucket probe and releases resources on shutdown", async () => {
|
|
8
|
+
const unregister = jest.fn();
|
|
9
|
+
const register = jest.fn<
|
|
10
|
+
(name: string, check: () => Promise<void>) => () => void
|
|
11
|
+
>(() => unregister);
|
|
12
|
+
const service = new StorageService({ register } as unknown as ReadinessService);
|
|
13
|
+
const send = jest
|
|
14
|
+
.fn<(...arguments_: unknown[]) => Promise<unknown>>()
|
|
15
|
+
.mockResolvedValue({});
|
|
16
|
+
const destroy = jest.fn();
|
|
17
|
+
Object.defineProperty(service, "client", {
|
|
18
|
+
value: { send, destroy },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
service.onModuleInit();
|
|
22
|
+
expect(register).toHaveBeenCalledWith("object-storage", expect.any(Function));
|
|
23
|
+
const probe = register.mock.calls[0]?.[1];
|
|
24
|
+
expect(probe).toBeDefined();
|
|
25
|
+
await probe?.();
|
|
26
|
+
expect(send).toHaveBeenCalledWith(
|
|
27
|
+
expect.any(HeadBucketCommand),
|
|
28
|
+
expect.objectContaining({ abortSignal: expect.any(AbortSignal) }),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
service.onModuleDestroy();
|
|
32
|
+
expect(unregister).toHaveBeenCalledTimes(1);
|
|
33
|
+
expect(destroy).toHaveBeenCalledTimes(1);
|
|
34
|
+
});
|
|
35
|
+
});
|
package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
|
-
import { Injectable } from "@nestjs/common";
|
|
1
|
+
import { Injectable, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
|
2
2
|
import {
|
|
3
3
|
DeleteObjectCommand,
|
|
4
4
|
GetObjectCommand,
|
|
5
|
+
HeadBucketCommand,
|
|
5
6
|
PutObjectCommand,
|
|
6
7
|
S3Client,
|
|
7
8
|
} from "@aws-sdk/client-s3";
|
|
8
9
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
10
|
+
import { ReadinessService } from "../health/readiness.service";
|
|
9
11
|
import { storageClientConfig, storageSettings } from "./storage.config";
|
|
10
12
|
|
|
11
13
|
@Injectable()
|
|
12
|
-
export class StorageService {
|
|
14
|
+
export class StorageService implements OnModuleInit, OnModuleDestroy {
|
|
13
15
|
private readonly client = new S3Client(storageClientConfig());
|
|
14
16
|
private readonly bucket = storageSettings().bucket;
|
|
17
|
+
private unregisterReadiness?: () => void;
|
|
18
|
+
|
|
19
|
+
constructor(private readonly readiness: ReadinessService) {}
|
|
20
|
+
|
|
21
|
+
onModuleInit(): void {
|
|
22
|
+
this.unregisterReadiness = this.readiness.register("object-storage", async () => {
|
|
23
|
+
await this.client.send(
|
|
24
|
+
new HeadBucketCommand({ Bucket: this.bucket }),
|
|
25
|
+
{ abortSignal: AbortSignal.timeout(3_000) },
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
15
29
|
|
|
16
30
|
async put(key: string, body: Buffer | string, contentType?: string): Promise<void> {
|
|
17
31
|
await this.client.send(
|
|
@@ -34,4 +48,9 @@ export class StorageService {
|
|
|
34
48
|
async delete(key: string): Promise<void> {
|
|
35
49
|
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
36
50
|
}
|
|
51
|
+
|
|
52
|
+
onModuleDestroy(): void {
|
|
53
|
+
this.unregisterReadiness?.();
|
|
54
|
+
this.client.destroy();
|
|
55
|
+
}
|
|
37
56
|
}
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
"name": "object-storage-s3",
|
|
3
3
|
"description": "S3-compatible object storage (AWS S3 or MinIO, chosen by STORAGE_PROVIDER) with a StorageService and presigned URLs.",
|
|
4
4
|
"requires": [],
|
|
5
|
+
"requiredProjectFiles": [
|
|
6
|
+
"apps/api/src/health/readiness.service.ts"
|
|
7
|
+
],
|
|
5
8
|
"targetApp": "api",
|
|
6
9
|
"dependencies": {
|
|
7
10
|
"@aws-sdk/client-s3": "^3.700.0",
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import {
|
|
3
|
+
clientAddressFromProxy,
|
|
4
|
+
rateLimitConfig,
|
|
5
|
+
} from "./rate-limit.config";
|
|
6
|
+
|
|
7
|
+
describe("rate limit configuration", () => {
|
|
8
|
+
it("validates route limits and proxy settings", () => {
|
|
9
|
+
expect(
|
|
10
|
+
rateLimitConfig({
|
|
11
|
+
RATE_LIMIT_TTL: "30",
|
|
12
|
+
RATE_LIMIT_MAX: "250",
|
|
13
|
+
RATE_LIMIT_AUTH_TTL: "45",
|
|
14
|
+
RATE_LIMIT_AUTH_MAX: "15",
|
|
15
|
+
RATE_LIMIT_RUNTIME_MAX: "900",
|
|
16
|
+
RATE_LIMIT_TRUSTED_PROXY_HOPS: "2",
|
|
17
|
+
RATE_LIMIT_PROXY_HEADER: "X-Real-IP",
|
|
18
|
+
RATE_LIMIT_STORAGE_TIMEOUT_MS: "750",
|
|
19
|
+
RATE_LIMIT_UNAVAILABLE_RETRY_AFTER: "3",
|
|
20
|
+
}),
|
|
21
|
+
).toEqual({
|
|
22
|
+
ttlSeconds: 30,
|
|
23
|
+
limit: 250,
|
|
24
|
+
authTtlSeconds: 45,
|
|
25
|
+
authLimit: 15,
|
|
26
|
+
runtimeLimit: 900,
|
|
27
|
+
trustedProxyHops: 2,
|
|
28
|
+
proxyHeader: "x-real-ip",
|
|
29
|
+
storageTimeoutMs: 750,
|
|
30
|
+
unavailableRetryAfterSeconds: 3,
|
|
31
|
+
});
|
|
32
|
+
expect(() => rateLimitConfig({ RATE_LIMIT_MAX: "0" })).toThrow(
|
|
33
|
+
"RATE_LIMIT_MAX must be a positive integer",
|
|
34
|
+
);
|
|
35
|
+
expect(() =>
|
|
36
|
+
rateLimitConfig({ RATE_LIMIT_TRUSTED_PROXY_HOPS: "-1" }),
|
|
37
|
+
).toThrow("must be a non-negative integer");
|
|
38
|
+
expect(() =>
|
|
39
|
+
rateLimitConfig({ RATE_LIMIT_PROXY_HEADER: "invalid header" }),
|
|
40
|
+
).toThrow("must be a valid HTTP header name");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("selects only the address outside the configured trusted proxy depth", () => {
|
|
44
|
+
const headers = {
|
|
45
|
+
"x-forwarded-for": "203.0.113.7, 198.51.100.8",
|
|
46
|
+
};
|
|
47
|
+
expect(
|
|
48
|
+
clientAddressFromProxy(headers, "10.0.0.9", {
|
|
49
|
+
proxyHeader: "x-forwarded-for",
|
|
50
|
+
trustedProxyHops: 2,
|
|
51
|
+
}),
|
|
52
|
+
).toBe("203.0.113.7");
|
|
53
|
+
expect(
|
|
54
|
+
clientAddressFromProxy(headers, "10.0.0.9", {
|
|
55
|
+
proxyHeader: "x-forwarded-for",
|
|
56
|
+
trustedProxyHops: 1,
|
|
57
|
+
}),
|
|
58
|
+
).toBe("198.51.100.8");
|
|
59
|
+
expect(
|
|
60
|
+
clientAddressFromProxy(headers, "10.0.0.9", {
|
|
61
|
+
proxyHeader: "x-forwarded-for",
|
|
62
|
+
trustedProxyHops: 0,
|
|
63
|
+
}),
|
|
64
|
+
).toBe("10.0.0.9");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("ignores malformed or incomplete forwarded chains", () => {
|
|
68
|
+
expect(
|
|
69
|
+
clientAddressFromProxy(
|
|
70
|
+
{ "x-forwarded-for": "attacker-controlled" },
|
|
71
|
+
"::ffff:10.0.0.9",
|
|
72
|
+
{
|
|
73
|
+
proxyHeader: "x-forwarded-for",
|
|
74
|
+
trustedProxyHops: 1,
|
|
75
|
+
},
|
|
76
|
+
),
|
|
77
|
+
).toBe("10.0.0.9");
|
|
78
|
+
expect(
|
|
79
|
+
clientAddressFromProxy({}, "10.0.0.9", {
|
|
80
|
+
proxyHeader: "x-forwarded-for",
|
|
81
|
+
trustedProxyHops: 1,
|
|
82
|
+
}),
|
|
83
|
+
).toBe("10.0.0.9");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
|
|
3
|
+
export interface RateLimitConfig {
|
|
4
|
+
ttlSeconds: number;
|
|
5
|
+
limit: number;
|
|
6
|
+
authTtlSeconds: number;
|
|
7
|
+
authLimit: number;
|
|
8
|
+
runtimeLimit: number;
|
|
9
|
+
trustedProxyHops: number;
|
|
10
|
+
proxyHeader: string;
|
|
11
|
+
storageTimeoutMs: number;
|
|
12
|
+
unavailableRetryAfterSeconds: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function positiveInteger(name: string, value: string | undefined, fallback: number): number {
|
|
16
|
+
if (value === undefined || value === "") return fallback;
|
|
17
|
+
const parsed = Number(value);
|
|
18
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
19
|
+
throw new Error(`${name} must be a positive integer`);
|
|
20
|
+
}
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function nonNegativeInteger(name: string, value: string | undefined, fallback: number): number {
|
|
25
|
+
if (value === undefined || value === "") return fallback;
|
|
26
|
+
const parsed = Number(value);
|
|
27
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
28
|
+
throw new Error(`${name} must be a non-negative integer`);
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function headerName(value: string | undefined): string {
|
|
34
|
+
const normalized = (value || "x-forwarded-for").trim().toLowerCase();
|
|
35
|
+
if (!/^[a-z0-9!#$%&'*+.^_`|~-]+$/.test(normalized)) {
|
|
36
|
+
throw new Error("RATE_LIMIT_PROXY_HEADER must be a valid HTTP header name");
|
|
37
|
+
}
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function rateLimitConfig(env: NodeJS.ProcessEnv = process.env): RateLimitConfig {
|
|
42
|
+
return {
|
|
43
|
+
ttlSeconds: positiveInteger("RATE_LIMIT_TTL", env.RATE_LIMIT_TTL, 60),
|
|
44
|
+
limit: positiveInteger("RATE_LIMIT_MAX", env.RATE_LIMIT_MAX, 300),
|
|
45
|
+
authTtlSeconds: positiveInteger(
|
|
46
|
+
"RATE_LIMIT_AUTH_TTL",
|
|
47
|
+
env.RATE_LIMIT_AUTH_TTL,
|
|
48
|
+
60,
|
|
49
|
+
),
|
|
50
|
+
authLimit: positiveInteger("RATE_LIMIT_AUTH_MAX", env.RATE_LIMIT_AUTH_MAX, 20),
|
|
51
|
+
runtimeLimit: positiveInteger(
|
|
52
|
+
"RATE_LIMIT_RUNTIME_MAX",
|
|
53
|
+
env.RATE_LIMIT_RUNTIME_MAX,
|
|
54
|
+
1000,
|
|
55
|
+
),
|
|
56
|
+
trustedProxyHops: nonNegativeInteger(
|
|
57
|
+
"RATE_LIMIT_TRUSTED_PROXY_HOPS",
|
|
58
|
+
env.RATE_LIMIT_TRUSTED_PROXY_HOPS,
|
|
59
|
+
0,
|
|
60
|
+
),
|
|
61
|
+
proxyHeader: headerName(env.RATE_LIMIT_PROXY_HEADER),
|
|
62
|
+
storageTimeoutMs: positiveInteger(
|
|
63
|
+
"RATE_LIMIT_STORAGE_TIMEOUT_MS",
|
|
64
|
+
env.RATE_LIMIT_STORAGE_TIMEOUT_MS,
|
|
65
|
+
1000,
|
|
66
|
+
),
|
|
67
|
+
unavailableRetryAfterSeconds: positiveInteger(
|
|
68
|
+
"RATE_LIMIT_UNAVAILABLE_RETRY_AFTER",
|
|
69
|
+
env.RATE_LIMIT_UNAVAILABLE_RETRY_AFTER,
|
|
70
|
+
1,
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeIp(value: string | undefined): string | undefined {
|
|
76
|
+
if (!value) return undefined;
|
|
77
|
+
const trimmed = value.trim();
|
|
78
|
+
const normalized = trimmed.startsWith("::ffff:")
|
|
79
|
+
? trimmed.slice("::ffff:".length)
|
|
80
|
+
: trimmed;
|
|
81
|
+
return isIP(normalized) === 0 ? undefined : normalized;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function headerValue(headers: unknown, name: string): string | undefined {
|
|
85
|
+
if (!headers || typeof headers !== "object") return undefined;
|
|
86
|
+
const value = (headers as Record<string, unknown>)[name];
|
|
87
|
+
if (typeof value === "string") return value;
|
|
88
|
+
if (!Array.isArray(value)) return undefined;
|
|
89
|
+
const strings = value.filter((item): item is string => typeof item === "string");
|
|
90
|
+
return strings.length > 0 ? strings.join(",") : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function clientAddressFromProxy(
|
|
94
|
+
headers: unknown,
|
|
95
|
+
remoteAddress: string | undefined,
|
|
96
|
+
config: Pick<RateLimitConfig, "proxyHeader" | "trustedProxyHops">,
|
|
97
|
+
): string {
|
|
98
|
+
const direct = normalizeIp(remoteAddress) ?? "unknown";
|
|
99
|
+
if (config.trustedProxyHops === 0) return direct;
|
|
100
|
+
|
|
101
|
+
const forwarded = headerValue(headers, config.proxyHeader)
|
|
102
|
+
?.split(",")
|
|
103
|
+
.map((value) => value.trim())
|
|
104
|
+
.filter(Boolean) ?? [];
|
|
105
|
+
const chain = [...forwarded, direct];
|
|
106
|
+
if (chain.length <= config.trustedProxyHops) return direct;
|
|
107
|
+
|
|
108
|
+
const selected = chain[chain.length - config.trustedProxyHops - 1];
|
|
109
|
+
return normalizeIp(selected) ?? direct;
|
|
110
|
+
}
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from "@jest/globals";
|
|
2
|
+
import type { Request } from "express";
|
|
3
|
+
import type { ModuleRef } from "@nestjs/core";
|
|
4
|
+
import type { ApiKeyVerifier } from "../api-key/api-key-verifier";
|
|
5
|
+
|
|
6
|
+
jest.mock("../auth/auth-provider", () => ({
|
|
7
|
+
authRuntime: { api: { getSession: jest.fn() } },
|
|
8
|
+
}));
|
|
9
|
+
jest.mock("better-auth/node", () => ({
|
|
10
|
+
fromNodeHeaders: (headers: unknown) => headers,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { authRuntime } from "../auth/auth-provider";
|
|
14
|
+
import {
|
|
15
|
+
RateLimitIdentity,
|
|
16
|
+
RateLimitIdentityExtension,
|
|
17
|
+
type RateLimitRequest,
|
|
18
|
+
} from "./rate-limit.identity";
|
|
19
|
+
|
|
20
|
+
function testRequest(input?: {
|
|
21
|
+
session?: RateLimitRequest["session"];
|
|
22
|
+
apiKey?: string;
|
|
23
|
+
forwarded?: string;
|
|
24
|
+
remoteAddress?: string;
|
|
25
|
+
}): RateLimitRequest {
|
|
26
|
+
const headers: Record<string, string> = {};
|
|
27
|
+
if (input?.apiKey) headers["x-api-key"] = input.apiKey;
|
|
28
|
+
if (input?.forwarded) headers["x-forwarded-for"] = input.forwarded;
|
|
29
|
+
return {
|
|
30
|
+
headers,
|
|
31
|
+
header: (name: string) => headers[name.toLowerCase()],
|
|
32
|
+
socket: { remoteAddress: input?.remoteAddress ?? "10.0.0.9" },
|
|
33
|
+
ip: input?.remoteAddress ?? "10.0.0.9",
|
|
34
|
+
...(input && "session" in input ? { session: input.session } : {}),
|
|
35
|
+
} as unknown as Request & RateLimitRequest;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const proxyConfig = {
|
|
39
|
+
proxyHeader: "x-forwarded-for",
|
|
40
|
+
trustedProxyHops: 1,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
class ApplicationIdentityExtension extends RateLimitIdentityExtension {
|
|
44
|
+
override async validatedApiKeyId(
|
|
45
|
+
_request: RateLimitRequest,
|
|
46
|
+
rawApiKey: string,
|
|
47
|
+
): Promise<string | undefined> {
|
|
48
|
+
return rawApiKey === "external-valid" ? "external-stable-id" : undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("RateLimitIdentity", () => {
|
|
53
|
+
it("uses an attached user before API key or IP without exposing the user id", async () => {
|
|
54
|
+
const verifier = { isValid: jest.fn(() => true) };
|
|
55
|
+
const identity = new RateLimitIdentity(verifier as unknown as ApiKeyVerifier);
|
|
56
|
+
const request = testRequest({
|
|
57
|
+
session: { user: { id: "user-secret-id" } },
|
|
58
|
+
apiKey: "api-secret",
|
|
59
|
+
forwarded: "203.0.113.7",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const first = await identity.resolve(request, proxyConfig);
|
|
63
|
+
const second = await identity.resolve(request, proxyConfig);
|
|
64
|
+
|
|
65
|
+
expect(first).toBe(second);
|
|
66
|
+
expect(first).toMatch(/^user:[a-f0-9]{64}$/);
|
|
67
|
+
expect(first).not.toContain("user-secret-id");
|
|
68
|
+
expect(verifier.isValid).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("resolves a session when a preceding auth guard has not attached one", async () => {
|
|
72
|
+
const session = {
|
|
73
|
+
user: { id: "resolved-user", email: "user@example.com", name: "User" },
|
|
74
|
+
session: { id: "session-id", userId: "resolved-user" },
|
|
75
|
+
};
|
|
76
|
+
const getSession = jest
|
|
77
|
+
.spyOn(authRuntime.api, "getSession")
|
|
78
|
+
.mockResolvedValue(
|
|
79
|
+
session as Awaited<ReturnType<typeof authRuntime.api.getSession>>,
|
|
80
|
+
);
|
|
81
|
+
const identity = new RateLimitIdentity(
|
|
82
|
+
{ isValid: jest.fn(() => false) } as unknown as ApiKeyVerifier,
|
|
83
|
+
);
|
|
84
|
+
const request = testRequest();
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const value = await identity.resolve(request, proxyConfig);
|
|
88
|
+
expect(value).toMatch(/^user:[a-f0-9]{64}$/);
|
|
89
|
+
expect(request.session?.user?.id).toBe("resolved-user");
|
|
90
|
+
expect(getSession).toHaveBeenCalledTimes(1);
|
|
91
|
+
} finally {
|
|
92
|
+
getSession.mockRestore();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("uses validated configured and extension API keys without storing raw keys", async () => {
|
|
97
|
+
const verifier = {
|
|
98
|
+
isValid: jest.fn((value: string) => value === "configured-valid"),
|
|
99
|
+
} as unknown as ApiKeyVerifier;
|
|
100
|
+
const extension = new ApplicationIdentityExtension();
|
|
101
|
+
const moduleRef = {
|
|
102
|
+
get: () => extension,
|
|
103
|
+
} as unknown as ModuleRef;
|
|
104
|
+
const identity = new RateLimitIdentity(verifier, moduleRef);
|
|
105
|
+
|
|
106
|
+
for (const raw of ["configured-valid", "external-valid"]) {
|
|
107
|
+
const value = await identity.resolve(
|
|
108
|
+
testRequest({ session: null, apiKey: raw }),
|
|
109
|
+
proxyConfig,
|
|
110
|
+
);
|
|
111
|
+
expect(value).toMatch(/^api-key:[a-f0-9]{64}$/);
|
|
112
|
+
expect(value).not.toContain(raw);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("falls back to the trusted proxy client for an invalid API key", async () => {
|
|
117
|
+
const identity = new RateLimitIdentity(
|
|
118
|
+
{ isValid: jest.fn(() => false) } as unknown as ApiKeyVerifier,
|
|
119
|
+
);
|
|
120
|
+
const request = testRequest({
|
|
121
|
+
session: null,
|
|
122
|
+
apiKey: "invalid-and-rotating",
|
|
123
|
+
forwarded: "203.0.113.7",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const value = await identity.resolve(request, proxyConfig);
|
|
127
|
+
|
|
128
|
+
expect(value).toMatch(/^ip:[a-f0-9]{64}$/);
|
|
129
|
+
expect(value).not.toContain("invalid-and-rotating");
|
|
130
|
+
expect(value).not.toContain("203.0.113.7");
|
|
131
|
+
});
|
|
132
|
+
});
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Injectable, Optional } from "@nestjs/common";
|
|
2
|
+
import { ModuleRef } from "@nestjs/core";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { fromNodeHeaders } from "better-auth/node";
|
|
5
|
+
import type { Request } from "express";
|
|
6
|
+
import { ApiKeyVerifier } from "../api-key/api-key-verifier";
|
|
7
|
+
import { authRuntime } from "../auth/auth-provider";
|
|
8
|
+
import {
|
|
9
|
+
clientAddressFromProxy,
|
|
10
|
+
type RateLimitConfig,
|
|
11
|
+
} from "./rate-limit.config";
|
|
12
|
+
|
|
13
|
+
type SessionLike = { user?: { id?: string | null } | null } | null;
|
|
14
|
+
export type RateLimitRequest = Request & {
|
|
15
|
+
session?: SessionLike;
|
|
16
|
+
user?: unknown;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export abstract class RateLimitIdentityExtension {
|
|
20
|
+
abstract validatedApiKeyId(
|
|
21
|
+
request: RateLimitRequest,
|
|
22
|
+
rawApiKey: string,
|
|
23
|
+
): Promise<string | undefined>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stableDigest(kind: "user" | "api-key" | "ip", value: string): string {
|
|
27
|
+
return createHash("sha256")
|
|
28
|
+
.update("podokit-rate-limit-v1", "utf8")
|
|
29
|
+
.update("\0")
|
|
30
|
+
.update(kind, "utf8")
|
|
31
|
+
.update("\0")
|
|
32
|
+
.update(value, "utf8")
|
|
33
|
+
.digest("hex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function tracker(kind: "user" | "api-key" | "ip", value: string): string {
|
|
37
|
+
return `${kind}:${stableDigest(kind, value)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Injectable()
|
|
41
|
+
export class RateLimitIdentity {
|
|
42
|
+
constructor(
|
|
43
|
+
private readonly configuredApiKeys: ApiKeyVerifier,
|
|
44
|
+
@Optional() private readonly moduleRef?: ModuleRef,
|
|
45
|
+
) {}
|
|
46
|
+
|
|
47
|
+
async resolve(
|
|
48
|
+
request: RateLimitRequest,
|
|
49
|
+
config: Pick<RateLimitConfig, "proxyHeader" | "trustedProxyHops">,
|
|
50
|
+
): Promise<string> {
|
|
51
|
+
const userId = await this.userId(request);
|
|
52
|
+
if (userId) return tracker("user", userId);
|
|
53
|
+
|
|
54
|
+
const rawApiKey = request.header("x-api-key");
|
|
55
|
+
if (rawApiKey) {
|
|
56
|
+
if (this.configuredApiKeys.isValid(rawApiKey)) {
|
|
57
|
+
return tracker("api-key", rawApiKey);
|
|
58
|
+
}
|
|
59
|
+
const additionalIdentity = await this.additionalApiKeyIdentity(request, rawApiKey);
|
|
60
|
+
if (additionalIdentity) return tracker("api-key", additionalIdentity);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const remoteAddress = request.socket?.remoteAddress ?? request.ip;
|
|
64
|
+
const clientAddress = clientAddressFromProxy(request.headers, remoteAddress, config);
|
|
65
|
+
return tracker("ip", clientAddress);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async additionalApiKeyIdentity(
|
|
69
|
+
request: RateLimitRequest,
|
|
70
|
+
rawApiKey: string,
|
|
71
|
+
): Promise<string | undefined> {
|
|
72
|
+
if (!this.moduleRef) return undefined;
|
|
73
|
+
let extension: RateLimitIdentityExtension;
|
|
74
|
+
try {
|
|
75
|
+
extension = this.moduleRef.get(RateLimitIdentityExtension, {
|
|
76
|
+
strict: false,
|
|
77
|
+
});
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
return extension.validatedApiKeyId(request, rawApiKey);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async userId(request: RateLimitRequest): Promise<string | undefined> {
|
|
85
|
+
if (request.session === undefined) {
|
|
86
|
+
try {
|
|
87
|
+
const session = await authRuntime.api.getSession({
|
|
88
|
+
headers: fromNodeHeaders(request.headers),
|
|
89
|
+
});
|
|
90
|
+
request.session = session;
|
|
91
|
+
request.user = session?.user ?? null;
|
|
92
|
+
} catch {
|
|
93
|
+
request.session = null;
|
|
94
|
+
request.user = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const id = request.session?.user?.id;
|
|
98
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|