@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.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
|
+
}
|
|
@@ -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
|
+
});
|