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