@podosoft/podokit 1.4.2 → 1.6.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 +2 -1
- package/dist/app-proxy-timeouts.d.ts +27 -0
- package/dist/app-proxy-timeouts.js +180 -0
- package/dist/assemble.d.ts +5 -2
- package/dist/assemble.js +14 -4
- package/dist/deploy-checks.d.ts +44 -0
- package/dist/deploy-checks.js +267 -0
- package/dist/deploy-compose-profile.d.ts +6 -2
- package/dist/deploy-compose-profile.js +15 -6
- package/dist/deploy-compose-render.d.ts +12 -4
- package/dist/deploy-compose-render.js +47 -17
- package/dist/deploy-compose.d.ts +2 -5
- package/dist/deploy-compose.js +7 -2
- package/dist/deploy-profile.d.ts +6 -2
- package/dist/deploy-profile.js +11 -7
- package/dist/deploy-render.d.ts +4 -1
- package/dist/deploy-render.js +28 -20
- package/dist/deploy-schema.d.ts +61 -0
- package/dist/deploy-schema.js +157 -1
- package/dist/deploy.d.ts +2 -5
- package/dist/deploy.js +5 -2
- package/dist/dev.d.ts +21 -0
- package/dist/dev.js +78 -3
- package/dist/eject.js +2 -0
- package/dist/index.js +85 -5
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +24 -0
- package/dist/lockfile.js +77 -5
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +12 -4
- package/dist/remove.js +1 -1
- package/dist/template-files.d.ts +42 -0
- package/dist/template-files.js +102 -0
- package/dist/templates/fullstack/AGENTS.md +44 -7
- package/dist/templates/fullstack/README.md +8 -7
- package/dist/templates/fullstack/apps/api/package.json +4 -4
- package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
- package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +47 -28
- package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
- package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +46 -3
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +8 -2
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
- package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
- package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
- package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +43 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
- package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
- package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
- package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
- package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/app.css +3 -0
- package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +370 -36
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +250 -41
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +23 -0
- package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
- package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
- package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
- package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
- package/dist/templates/fullstack/compose.dev.yaml +7 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
- package/dist/templates/fullstack/dot-env.docker.example +7 -2
- package/dist/templates/fullstack/dot-env.example +21 -1
- package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +4 -0
- package/dist/templates/fullstack/tests/README.md +40 -1
- package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
- package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
- package/dist/templates/fullstack/tests/package.json +2 -2
- package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
- package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
- package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +73 -39
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +29 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +62 -59
- package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +33 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
- package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +33 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +20 -1
- package/dist/templates/modules/audit-log/module.manifest.json +15 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +6 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -8
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
- package/dist/templates/modules/auth/module.manifest.json +8 -2
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
- package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
- package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +63 -27
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
- package/dist/templates/todo/AGENTS.md +46 -6
- package/dist/templates/todo/README.md +6 -4
- package/dist/text-diff.d.ts +12 -0
- package/dist/text-diff.js +111 -0
- package/dist/update.d.ts +61 -3
- package/dist/update.js +152 -27
- package/package.json +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
|
@@ -24,12 +24,13 @@ services:
|
|
|
24
24
|
profiles: ["dev"]
|
|
25
25
|
depends_on:
|
|
26
26
|
- minio
|
|
27
|
+
# -e stops at the first failing command, so a bucket that cannot be
|
|
28
|
+
# created fails this service instead of leaving uploads to fail later.
|
|
27
29
|
entrypoint: >
|
|
28
|
-
/bin/sh -
|
|
30
|
+
/bin/sh -ec "
|
|
29
31
|
until mc alias set local http://minio:9000 ${S3_ACCESS_KEY_ID:-podokit} ${S3_SECRET_ACCESS_KEY:-podokitsecret}; do echo 'waiting for minio'; sleep 1; done;
|
|
30
|
-
mc mb -
|
|
31
|
-
echo 'bucket ready'
|
|
32
|
-
exit 0"
|
|
32
|
+
mc mb --ignore-existing local/${S3_BUCKET:-podokit};
|
|
33
|
+
echo 'bucket ready'"
|
|
33
34
|
|
|
34
35
|
volumes:
|
|
35
36
|
minio-data:
|
|
@@ -5,7 +5,7 @@ const origin = { origin: base };
|
|
|
5
5
|
// The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
|
|
6
6
|
// spec also compiles in apps that do not install that module.
|
|
7
7
|
const admin = {
|
|
8
|
-
email: "admin@example.com",
|
|
8
|
+
email: process.env.E2E_ADMIN_EMAIL || "admin@example.com",
|
|
9
9
|
password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
|
|
10
10
|
};
|
|
11
11
|
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
clientAddressFromProxy,
|
|
4
|
-
rateLimitConfig,
|
|
5
|
-
sessionSigningSecret,
|
|
6
|
-
} from "./rate-limit.config";
|
|
2
|
+
import { rateLimitConfig, sessionSigningSecret } from "./rate-limit.config";
|
|
7
3
|
|
|
8
4
|
describe("rate limit configuration", () => {
|
|
9
|
-
test("validates route limits
|
|
5
|
+
test("validates route limits", () => {
|
|
10
6
|
expect(
|
|
11
7
|
rateLimitConfig({
|
|
12
8
|
RATE_LIMIT_KEY_PREFIX: "podokit:tenant-a:rate-limit",
|
|
@@ -15,8 +11,6 @@ describe("rate limit configuration", () => {
|
|
|
15
11
|
RATE_LIMIT_AUTH_TTL: "45",
|
|
16
12
|
RATE_LIMIT_AUTH_MAX: "15",
|
|
17
13
|
RATE_LIMIT_RUNTIME_MAX: "900",
|
|
18
|
-
RATE_LIMIT_TRUSTED_PROXY_HOPS: "2",
|
|
19
|
-
RATE_LIMIT_PROXY_HEADER: "X-Real-IP",
|
|
20
14
|
RATE_LIMIT_STORAGE_TIMEOUT_MS: "750",
|
|
21
15
|
RATE_LIMIT_UNAVAILABLE_RETRY_AFTER: "3",
|
|
22
16
|
}),
|
|
@@ -27,69 +21,18 @@ describe("rate limit configuration", () => {
|
|
|
27
21
|
authTtlSeconds: 45,
|
|
28
22
|
authLimit: 15,
|
|
29
23
|
runtimeLimit: 900,
|
|
30
|
-
trustedProxyHops: 2,
|
|
31
|
-
proxyHeader: "x-real-ip",
|
|
32
24
|
storageTimeoutMs: 750,
|
|
33
25
|
unavailableRetryAfterSeconds: 3,
|
|
34
26
|
});
|
|
35
27
|
expect(() => rateLimitConfig({ RATE_LIMIT_MAX: "0" })).toThrow(
|
|
36
28
|
"RATE_LIMIT_MAX must be a positive integer",
|
|
37
29
|
);
|
|
38
|
-
expect(() =>
|
|
39
|
-
rateLimitConfig({ RATE_LIMIT_TRUSTED_PROXY_HOPS: "-1" }),
|
|
40
|
-
).toThrow("must be a non-negative integer");
|
|
41
|
-
expect(() =>
|
|
42
|
-
rateLimitConfig({ RATE_LIMIT_PROXY_HEADER: "invalid header" }),
|
|
43
|
-
).toThrow("must be a valid HTTP header name");
|
|
44
30
|
expect(() => rateLimitConfig({ RATE_LIMIT_KEY_PREFIX: "invalid prefix!" })).toThrow(
|
|
45
31
|
"RATE_LIMIT_KEY_PREFIX must be 1-128 characters",
|
|
46
32
|
);
|
|
47
33
|
expect(rateLimitConfig({}).keyPrefix).toBe("podokit:rate-limit");
|
|
48
34
|
});
|
|
49
35
|
|
|
50
|
-
test("selects only the address outside the configured trusted proxy depth", () => {
|
|
51
|
-
const headers = {
|
|
52
|
-
"x-forwarded-for": "203.0.113.7, 198.51.100.8",
|
|
53
|
-
};
|
|
54
|
-
expect(
|
|
55
|
-
clientAddressFromProxy(headers, "10.0.0.9", {
|
|
56
|
-
proxyHeader: "x-forwarded-for",
|
|
57
|
-
trustedProxyHops: 2,
|
|
58
|
-
}),
|
|
59
|
-
).toBe("203.0.113.7");
|
|
60
|
-
expect(
|
|
61
|
-
clientAddressFromProxy(headers, "10.0.0.9", {
|
|
62
|
-
proxyHeader: "x-forwarded-for",
|
|
63
|
-
trustedProxyHops: 1,
|
|
64
|
-
}),
|
|
65
|
-
).toBe("198.51.100.8");
|
|
66
|
-
expect(
|
|
67
|
-
clientAddressFromProxy(headers, "10.0.0.9", {
|
|
68
|
-
proxyHeader: "x-forwarded-for",
|
|
69
|
-
trustedProxyHops: 0,
|
|
70
|
-
}),
|
|
71
|
-
).toBe("10.0.0.9");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("ignores malformed or incomplete forwarded chains", () => {
|
|
75
|
-
expect(
|
|
76
|
-
clientAddressFromProxy(
|
|
77
|
-
{ "x-forwarded-for": "attacker-controlled" },
|
|
78
|
-
"::ffff:10.0.0.9",
|
|
79
|
-
{
|
|
80
|
-
proxyHeader: "x-forwarded-for",
|
|
81
|
-
trustedProxyHops: 1,
|
|
82
|
-
},
|
|
83
|
-
),
|
|
84
|
-
).toBe("10.0.0.9");
|
|
85
|
-
expect(
|
|
86
|
-
clientAddressFromProxy({}, "10.0.0.9", {
|
|
87
|
-
proxyHeader: "x-forwarded-for",
|
|
88
|
-
trustedProxyHops: 1,
|
|
89
|
-
}),
|
|
90
|
-
).toBe("10.0.0.9");
|
|
91
|
-
});
|
|
92
|
-
|
|
93
36
|
test("reads the session signing secret the way the auth module does", () => {
|
|
94
37
|
expect(
|
|
95
38
|
sessionSigningSecret({ BETTER_AUTH_SECRET: " session-secret-with-at-least-32-chars " }),
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { isIP } from "node:net";
|
|
2
|
-
|
|
3
1
|
export interface RateLimitConfig {
|
|
4
2
|
keyPrefix: string;
|
|
5
3
|
ttlSeconds: number;
|
|
@@ -7,8 +5,6 @@ export interface RateLimitConfig {
|
|
|
7
5
|
authTtlSeconds: number;
|
|
8
6
|
authLimit: number;
|
|
9
7
|
runtimeLimit: number;
|
|
10
|
-
trustedProxyHops: number;
|
|
11
|
-
proxyHeader: string;
|
|
12
8
|
storageTimeoutMs: number;
|
|
13
9
|
unavailableRetryAfterSeconds: number;
|
|
14
10
|
}
|
|
@@ -32,23 +28,6 @@ function positiveInteger(name: string, value: string | undefined, fallback: numb
|
|
|
32
28
|
return parsed;
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
function nonNegativeInteger(name: string, value: string | undefined, fallback: number): number {
|
|
36
|
-
if (value === undefined || value === "") return fallback;
|
|
37
|
-
const parsed = Number(value);
|
|
38
|
-
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
39
|
-
throw new Error(`${name} must be a non-negative integer`);
|
|
40
|
-
}
|
|
41
|
-
return parsed;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function headerName(value: string | undefined): string {
|
|
45
|
-
const normalized = (value || "x-forwarded-for").trim().toLowerCase();
|
|
46
|
-
if (!/^[a-z0-9!#$%&'*+.^_`|~-]+$/.test(normalized)) {
|
|
47
|
-
throw new Error("RATE_LIMIT_PROXY_HEADER must be a valid HTTP header name");
|
|
48
|
-
}
|
|
49
|
-
return normalized;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
31
|
export function rateLimitConfig(env: NodeJS.ProcessEnv = process.env): RateLimitConfig {
|
|
53
32
|
return {
|
|
54
33
|
keyPrefix: keyPrefix(env.RATE_LIMIT_KEY_PREFIX),
|
|
@@ -65,12 +44,6 @@ export function rateLimitConfig(env: NodeJS.ProcessEnv = process.env): RateLimit
|
|
|
65
44
|
env.RATE_LIMIT_RUNTIME_MAX,
|
|
66
45
|
1000,
|
|
67
46
|
),
|
|
68
|
-
trustedProxyHops: nonNegativeInteger(
|
|
69
|
-
"RATE_LIMIT_TRUSTED_PROXY_HOPS",
|
|
70
|
-
env.RATE_LIMIT_TRUSTED_PROXY_HOPS,
|
|
71
|
-
0,
|
|
72
|
-
),
|
|
73
|
-
proxyHeader: headerName(env.RATE_LIMIT_PROXY_HEADER),
|
|
74
47
|
storageTimeoutMs: positiveInteger(
|
|
75
48
|
"RATE_LIMIT_STORAGE_TIMEOUT_MS",
|
|
76
49
|
env.RATE_LIMIT_STORAGE_TIMEOUT_MS,
|
|
@@ -90,41 +63,3 @@ export function rateLimitConfig(env: NodeJS.ProcessEnv = process.env): RateLimit
|
|
|
90
63
|
export function sessionSigningSecret(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
91
64
|
return env.BETTER_AUTH_SECRET?.trim() || undefined;
|
|
92
65
|
}
|
|
93
|
-
|
|
94
|
-
function normalizeIp(value: string | undefined): string | undefined {
|
|
95
|
-
if (!value) return undefined;
|
|
96
|
-
const trimmed = value.trim();
|
|
97
|
-
const normalized = trimmed.startsWith("::ffff:")
|
|
98
|
-
? trimmed.slice("::ffff:".length)
|
|
99
|
-
: trimmed;
|
|
100
|
-
return isIP(normalized) === 0 ? undefined : normalized;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function headerValue(headers: unknown, name: string): string | undefined {
|
|
104
|
-
if (headers instanceof Headers) return headers.get(name) ?? undefined;
|
|
105
|
-
if (!headers || typeof headers !== "object") return undefined;
|
|
106
|
-
const value = (headers as Record<string, unknown>)[name];
|
|
107
|
-
if (typeof value === "string") return value;
|
|
108
|
-
if (!Array.isArray(value)) return undefined;
|
|
109
|
-
const strings = value.filter((item): item is string => typeof item === "string");
|
|
110
|
-
return strings.length > 0 ? strings.join(",") : undefined;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function clientAddressFromProxy(
|
|
114
|
-
headers: unknown,
|
|
115
|
-
remoteAddress: string | undefined,
|
|
116
|
-
config: Pick<RateLimitConfig, "proxyHeader" | "trustedProxyHops">,
|
|
117
|
-
): string {
|
|
118
|
-
const direct = normalizeIp(remoteAddress) ?? "unknown";
|
|
119
|
-
if (config.trustedProxyHops === 0) return direct;
|
|
120
|
-
|
|
121
|
-
const forwarded = headerValue(headers, config.proxyHeader)
|
|
122
|
-
?.split(",")
|
|
123
|
-
.map((value) => value.trim())
|
|
124
|
-
.filter(Boolean) ?? [];
|
|
125
|
-
const chain = [...forwarded, direct];
|
|
126
|
-
if (chain.length <= config.trustedProxyHops) return direct;
|
|
127
|
-
|
|
128
|
-
const selected = chain[chain.length - config.trustedProxyHops - 1];
|
|
129
|
-
return normalizeIp(selected) ?? direct;
|
|
130
|
-
}
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts
CHANGED
|
@@ -9,10 +9,12 @@ import {
|
|
|
9
9
|
RATE_LIMIT_IDENTITY_EXTENSION,
|
|
10
10
|
RateLimitIdentity,
|
|
11
11
|
RateLimitIdentityExtension,
|
|
12
|
+
SessionUsers,
|
|
12
13
|
verifiedSessionToken,
|
|
13
14
|
} from "./rate-limit.identity";
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
// The address the API resolved for the request (see core/client-address.ts).
|
|
17
|
+
const clientAddress = "203.0.113.7";
|
|
16
18
|
const secret = "rate-limit-test-secret-with-at-least-32-characters";
|
|
17
19
|
const sessionToken = "Sx0d8mFqY3KcT7wJpLr2NvBhZa9EgUi4";
|
|
18
20
|
|
|
@@ -63,8 +65,7 @@ describe("RateLimitIdentity", () => {
|
|
|
63
65
|
|
|
64
66
|
const value = await identity.resolve(
|
|
65
67
|
request({ cookie: sessionCookie(signed(sessionToken)) }),
|
|
66
|
-
|
|
67
|
-
proxyConfig,
|
|
68
|
+
clientAddress,
|
|
68
69
|
);
|
|
69
70
|
|
|
70
71
|
expect(value).toMatch(/^session:[a-f0-9]{64}$/);
|
|
@@ -76,25 +77,22 @@ describe("RateLimitIdentity", () => {
|
|
|
76
77
|
const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret);
|
|
77
78
|
const value = signed(sessionToken);
|
|
78
79
|
const resolved = await Promise.all([
|
|
79
|
-
identity.resolve(request({ cookie: sessionCookie(value) }),
|
|
80
|
+
identity.resolve(request({ cookie: sessionCookie(value) }), clientAddress),
|
|
80
81
|
identity.resolve(
|
|
81
82
|
request({ cookie: sessionCookie(value, "__Secure-better-auth.session_token") }),
|
|
82
|
-
|
|
83
|
-
proxyConfig,
|
|
83
|
+
clientAddress,
|
|
84
84
|
),
|
|
85
|
-
identity.resolve(request({ authorization: `Bearer ${value}` }),
|
|
85
|
+
identity.resolve(request({ authorization: `Bearer ${value}` }), clientAddress),
|
|
86
86
|
identity.resolve(
|
|
87
87
|
request({ authorization: `bearer ${encodeURIComponent(value)}` }),
|
|
88
|
-
|
|
89
|
-
proxyConfig,
|
|
88
|
+
clientAddress,
|
|
90
89
|
),
|
|
91
90
|
]);
|
|
92
91
|
expect(new Set(resolved).size).toBe(1);
|
|
93
92
|
|
|
94
93
|
const other = await identity.resolve(
|
|
95
94
|
request({ cookie: sessionCookie(signed("AnotherSessionToken0123456789abcd")) }),
|
|
96
|
-
|
|
97
|
-
proxyConfig,
|
|
95
|
+
clientAddress,
|
|
98
96
|
);
|
|
99
97
|
expect(other).toMatch(/^session:/);
|
|
100
98
|
expect(other).not.toBe(resolved[0]);
|
|
@@ -103,7 +101,7 @@ describe("RateLimitIdentity", () => {
|
|
|
103
101
|
test("counts forged, tampered, and unsigned session values by client address", async () => {
|
|
104
102
|
const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret);
|
|
105
103
|
const address = { "x-forwarded-for": "203.0.113.7" };
|
|
106
|
-
const anonymous = await identity.resolve(request(address),
|
|
104
|
+
const anonymous = await identity.resolve(request(address), clientAddress);
|
|
107
105
|
expect(anonymous).toMatch(/^ip:[a-f0-9]{64}$/);
|
|
108
106
|
|
|
109
107
|
const genuine = signed(sessionToken);
|
|
@@ -123,8 +121,7 @@ describe("RateLimitIdentity", () => {
|
|
|
123
121
|
for (const headers of variants) {
|
|
124
122
|
const value = await identity.resolve(
|
|
125
123
|
request({ ...address, ...headers }),
|
|
126
|
-
|
|
127
|
-
proxyConfig,
|
|
124
|
+
clientAddress,
|
|
128
125
|
);
|
|
129
126
|
expect(value).toBe(anonymous);
|
|
130
127
|
}
|
|
@@ -148,8 +145,7 @@ describe("RateLimitIdentity", () => {
|
|
|
148
145
|
for (const raw of ["configured-valid", "external-valid"]) {
|
|
149
146
|
const value = await identity.resolve(
|
|
150
147
|
request({ "x-api-key": raw, cookie }),
|
|
151
|
-
|
|
152
|
-
proxyConfig,
|
|
148
|
+
clientAddress,
|
|
153
149
|
);
|
|
154
150
|
expect(value).toMatch(/^api-key:[a-f0-9]{64}$/);
|
|
155
151
|
expect(value).not.toContain(raw);
|
|
@@ -157,8 +153,7 @@ describe("RateLimitIdentity", () => {
|
|
|
157
153
|
|
|
158
154
|
const invalidKey = await identity.resolve(
|
|
159
155
|
request({ "x-api-key": "invalid", cookie }),
|
|
160
|
-
|
|
161
|
-
proxyConfig,
|
|
156
|
+
clientAddress,
|
|
162
157
|
);
|
|
163
158
|
expect(invalidKey).toMatch(/^session:/);
|
|
164
159
|
});
|
|
@@ -169,17 +164,83 @@ describe("RateLimitIdentity", () => {
|
|
|
169
164
|
const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), undefined);
|
|
170
165
|
const value = await identity.resolve(
|
|
171
166
|
request({ "x-forwarded-for": "203.0.113.7", cookie }),
|
|
172
|
-
|
|
173
|
-
proxyConfig,
|
|
167
|
+
clientAddress,
|
|
174
168
|
);
|
|
175
169
|
expect(value).toMatch(/^ip:[a-f0-9]{64}$/);
|
|
176
170
|
expect(value).not.toContain("203.0.113.7");
|
|
177
171
|
expect(
|
|
178
|
-
await identity.resolve(request({ "x-forwarded-for": "203.0.113.7" }),
|
|
172
|
+
await identity.resolve(request({ "x-forwarded-for": "203.0.113.7" }), clientAddress),
|
|
179
173
|
).toBe(value);
|
|
180
174
|
});
|
|
181
175
|
});
|
|
182
176
|
|
|
177
|
+
describe("RateLimitIdentity per user", () => {
|
|
178
|
+
const otherToken = "AnotherSessionToken0123456789abcd";
|
|
179
|
+
const thirdToken = "ThirdSessionToken0123456789abcdef";
|
|
180
|
+
const resolveWith = (identity: RateLimitIdentity, headers: Record<string, string>): Promise<string> =>
|
|
181
|
+
identity.resolve(request({ "x-forwarded-for": "203.0.113.7", ...headers }), clientAddress);
|
|
182
|
+
|
|
183
|
+
test("counts every session of a user under one counter once the API resolved them", async () => {
|
|
184
|
+
let now = 1_000;
|
|
185
|
+
const users = new SessionUsers(100, () => now);
|
|
186
|
+
const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret, users);
|
|
187
|
+
const first = { cookie: sessionCookie(signed(sessionToken)) };
|
|
188
|
+
const second = { authorization: `Bearer ${signed(otherToken)}` };
|
|
189
|
+
|
|
190
|
+
// Before the API resolves a session, it keeps a counter of its own.
|
|
191
|
+
const unresolved = await resolveWith(identity, first);
|
|
192
|
+
expect(unresolved).toMatch(/^session:[a-f0-9]{64}$/);
|
|
193
|
+
|
|
194
|
+
users.remember(sessionToken, "user-1", new Date(now + 60_000));
|
|
195
|
+
users.remember(otherToken, "user-1", new Date(now + 60_000));
|
|
196
|
+
users.remember(thirdToken, "user-2", new Date(now + 60_000));
|
|
197
|
+
const userCounter = await resolveWith(identity, first);
|
|
198
|
+
expect(userCounter).toMatch(/^user:[a-f0-9]{64}$/);
|
|
199
|
+
expect(userCounter).not.toContain("user-1");
|
|
200
|
+
expect(await resolveWith(identity, second)).toBe(userCounter);
|
|
201
|
+
expect(await resolveWith(identity, { cookie: sessionCookie(signed(thirdToken)) })).not.toBe(userCounter);
|
|
202
|
+
|
|
203
|
+
// The entry lasts until the session expires.
|
|
204
|
+
now += 60_000;
|
|
205
|
+
expect(await resolveWith(identity, first)).toBe(unresolved);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("keeps API keys first and forged sessions on the client address", async () => {
|
|
209
|
+
const users = new SessionUsers();
|
|
210
|
+
users.remember(sessionToken, "user-1", new Date(Date.now() + 60_000));
|
|
211
|
+
const verifier = { isValid: mock((value: string) => value === "configured-valid") } as unknown as ApiKeyVerifier;
|
|
212
|
+
const identity = new RateLimitIdentity(verifier, new ServiceRegistry(), secret, users);
|
|
213
|
+
const cookie = sessionCookie(signed(sessionToken));
|
|
214
|
+
|
|
215
|
+
expect(await resolveWith(identity, { "x-api-key": "configured-valid", cookie })).toMatch(/^api-key:/);
|
|
216
|
+
expect(await resolveWith(identity, { cookie: sessionCookie(tamperedSignature(signed(sessionToken))) }))
|
|
217
|
+
.toBe(await resolveWith(identity, {}));
|
|
218
|
+
expect(await resolveWith(identity, {})).toMatch(/^ip:/);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("SessionUsers", () => {
|
|
223
|
+
test("ignores an expired session and forgets the oldest past its bound", () => {
|
|
224
|
+
let now = 1_000;
|
|
225
|
+
const users = new SessionUsers(2, () => now);
|
|
226
|
+
users.remember("expired", "user-0", new Date(now));
|
|
227
|
+
expect(users.userOf("expired")).toBeUndefined();
|
|
228
|
+
|
|
229
|
+
users.remember("a", "user-a", new Date(now + 10_000));
|
|
230
|
+
users.remember("b", "user-b", new Date(now + 10_000));
|
|
231
|
+
users.remember("a", "user-a", new Date(now + 20_000));
|
|
232
|
+
users.remember("c", "user-c", new Date(now + 10_000));
|
|
233
|
+
// "a" was refreshed, so "b" was the oldest.
|
|
234
|
+
expect(users.userOf("a")).toBe("user-a");
|
|
235
|
+
expect(users.userOf("b")).toBeUndefined();
|
|
236
|
+
expect(users.userOf("c")).toBe("user-c");
|
|
237
|
+
|
|
238
|
+
now += 10_000;
|
|
239
|
+
expect(users.userOf("c")).toBeUndefined();
|
|
240
|
+
expect(users.userOf("a")).toBe("user-a");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
183
244
|
describe("verifiedSessionToken", () => {
|
|
184
245
|
test("returns the token only for a matching signature", () => {
|
|
185
246
|
expect(verifiedSessionToken(signed(sessionToken), secret)).toBe(sessionToken);
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import type { ApiKeyVerifier } from "../api-key/api-key-verifier";
|
|
3
3
|
import type { ServiceKey, ServiceRegistry } from "../core/services";
|
|
4
|
-
import {
|
|
5
|
-
clientAddressFromProxy,
|
|
6
|
-
type RateLimitConfig,
|
|
7
|
-
} from "./rate-limit.config";
|
|
8
4
|
|
|
9
5
|
export abstract class RateLimitIdentityExtension {
|
|
10
6
|
abstract validatedApiKeyId(
|
|
@@ -28,7 +24,7 @@ const SESSION_COOKIE_NAMES = [
|
|
|
28
24
|
// accepts only the padded 44-character standard base64 signature.
|
|
29
25
|
const SIGNATURE = /^[A-Za-z0-9+/]{43}=$/;
|
|
30
26
|
|
|
31
|
-
type TrackerKind = "session" | "api-key" | "ip";
|
|
27
|
+
type TrackerKind = "user" | "session" | "api-key" | "ip";
|
|
32
28
|
|
|
33
29
|
function stableDigest(kind: TrackerKind, value: string): string {
|
|
34
30
|
return createHash("sha256")
|
|
@@ -85,18 +81,57 @@ export function verifiedSessionToken(
|
|
|
85
81
|
: undefined;
|
|
86
82
|
}
|
|
87
83
|
|
|
84
|
+
/** The most sessions one API process remembers; the oldest is forgotten first. */
|
|
85
|
+
const MAX_SESSION_USERS = 10_000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The users of the sessions this API process has resolved, so the limiter can
|
|
89
|
+
* count a signed-in caller per user without a session lookup of its own. Each
|
|
90
|
+
* entry lasts until its session expires, and is refreshed whenever the session
|
|
91
|
+
* is resolved again. A signed-out or revoked session keeps its entry: it only
|
|
92
|
+
* decides which counter its requests use, never whether they are allowed.
|
|
93
|
+
*/
|
|
94
|
+
export class SessionUsers {
|
|
95
|
+
private readonly users = new Map<string, { userId: string; expiresAt: number }>();
|
|
96
|
+
|
|
97
|
+
constructor(
|
|
98
|
+
private readonly maxEntries = MAX_SESSION_USERS,
|
|
99
|
+
private readonly now: () => number = Date.now,
|
|
100
|
+
) {}
|
|
101
|
+
|
|
102
|
+
remember(sessionToken: string, userId: string, expiresAt: Date): void {
|
|
103
|
+
const key = stableDigest("session", sessionToken);
|
|
104
|
+
this.users.delete(key);
|
|
105
|
+
if (!(expiresAt.getTime() > this.now())) return;
|
|
106
|
+
this.users.set(key, { userId, expiresAt: expiresAt.getTime() });
|
|
107
|
+
if (this.users.size > this.maxEntries) {
|
|
108
|
+
const oldest = this.users.keys().next().value;
|
|
109
|
+
if (oldest !== undefined) this.users.delete(oldest);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
userOf(sessionToken: string): string | undefined {
|
|
114
|
+
const key = stableDigest("session", sessionToken);
|
|
115
|
+
const entry = this.users.get(key);
|
|
116
|
+
if (!entry) return undefined;
|
|
117
|
+
if (entry.expiresAt <= this.now()) {
|
|
118
|
+
this.users.delete(key);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return entry.userId;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
88
125
|
export class RateLimitIdentity {
|
|
89
126
|
constructor(
|
|
90
127
|
private readonly configuredApiKeys: ApiKeyVerifier,
|
|
91
128
|
private readonly services: ServiceRegistry,
|
|
92
129
|
private readonly sessionSecret: string | undefined,
|
|
130
|
+
private readonly sessionUsers: SessionUsers = new SessionUsers(),
|
|
93
131
|
) {}
|
|
94
132
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
remoteAddress: string | undefined,
|
|
98
|
-
config: Pick<RateLimitConfig, "proxyHeader" | "trustedProxyHops">,
|
|
99
|
-
): Promise<string> {
|
|
133
|
+
/** clientAddress is the address the API resolved behind its trusted proxies. */
|
|
134
|
+
async resolve(request: Request, clientAddress: string | undefined): Promise<string> {
|
|
100
135
|
const rawApiKey = request.headers.get("x-api-key");
|
|
101
136
|
if (rawApiKey) {
|
|
102
137
|
if (this.configuredApiKeys.isValid(rawApiKey)) return tracker("api-key", rawApiKey);
|
|
@@ -106,15 +141,19 @@ export class RateLimitIdentity {
|
|
|
106
141
|
}
|
|
107
142
|
|
|
108
143
|
const sessionToken = this.sessionToken(request.headers);
|
|
109
|
-
if (sessionToken)
|
|
144
|
+
if (sessionToken) {
|
|
145
|
+
const userId = this.sessionUsers.userOf(sessionToken);
|
|
146
|
+
return userId === undefined ? tracker("session", sessionToken) : tracker("user", userId);
|
|
147
|
+
}
|
|
110
148
|
|
|
111
|
-
|
|
112
|
-
return tracker("ip", clientAddress);
|
|
149
|
+
return tracker("ip", clientAddress ?? "unknown");
|
|
113
150
|
}
|
|
114
151
|
|
|
115
152
|
// Only the signature is checked: resolving the session would cost a database
|
|
116
|
-
// read on every request.
|
|
117
|
-
//
|
|
153
|
+
// read on every request. Once the API has resolved the session for a request,
|
|
154
|
+
// SessionUsers maps it to its user's counter; until then, and for a revoked or
|
|
155
|
+
// expired token, it keeps a counter of its own. A value without a valid
|
|
156
|
+
// signature falls back to the client address.
|
|
118
157
|
private sessionToken(headers: Headers): string | undefined {
|
|
119
158
|
if (!this.sessionSecret) return undefined;
|
|
120
159
|
const cookies = headers.get("cookie");
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import
|
|
3
|
-
import type {
|
|
2
|
+
import { createHmac } from "node:crypto";
|
|
3
|
+
import type { ApiKeyVerifier } from "../api-key/api-key-verifier";
|
|
4
|
+
import { RESOLVED_SESSIONS, ResolvedSessions, ServiceRegistry, type RequestGuardContext } from "../core/services";
|
|
5
|
+
import { RateLimitIdentity, SessionUsers } from "./rate-limit.identity";
|
|
4
6
|
import { CacheRateLimitStorage, RateLimiter, type RateLimitStorage } from "./rate-limit.module";
|
|
5
7
|
import { MemoryCacheStore } from "@podosoft/podokit-runtime";
|
|
6
8
|
|
|
@@ -11,8 +13,6 @@ const config = {
|
|
|
11
13
|
authTtlSeconds: 60,
|
|
12
14
|
authLimit: 20,
|
|
13
15
|
runtimeLimit: 1000,
|
|
14
|
-
trustedProxyHops: 0,
|
|
15
|
-
proxyHeader: "x-forwarded-for",
|
|
16
16
|
storageTimeoutMs: 100,
|
|
17
17
|
unavailableRetryAfterSeconds: 1,
|
|
18
18
|
};
|
|
@@ -26,12 +26,50 @@ function context(path: string, method = "GET"): {
|
|
|
26
26
|
headers,
|
|
27
27
|
value: {
|
|
28
28
|
request: new Request(`http://localhost${path}`, { method }),
|
|
29
|
-
|
|
29
|
+
clientAddress: "127.0.0.1",
|
|
30
30
|
setHeader: (name, value) => { headers[name.toLowerCase()] = value; },
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
describe("RateLimiter per-user budgets", () => {
|
|
36
|
+
const secret = "rate-limit-test-secret-with-at-least-32-characters";
|
|
37
|
+
const signed = (token: string): string =>
|
|
38
|
+
`${token}.${createHmac("sha256", secret).update(token).digest("base64")}`;
|
|
39
|
+
|
|
40
|
+
test("draws a user's sessions from one budget and another user's from its own", async () => {
|
|
41
|
+
const services = new ServiceRegistry();
|
|
42
|
+
services.register(RESOLVED_SESSIONS, new ResolvedSessions());
|
|
43
|
+
const users = new SessionUsers();
|
|
44
|
+
services.resolve(RESOLVED_SESSIONS).listen(({ token, userId, expiresAt }) => users.remember(token, userId, expiresAt));
|
|
45
|
+
const limiter = new RateLimiter(
|
|
46
|
+
config,
|
|
47
|
+
new RateLimitIdentity({ isValid: () => false } as unknown as ApiKeyVerifier, services, secret, users),
|
|
48
|
+
new CacheRateLimitStorage(new MemoryCacheStore()),
|
|
49
|
+
);
|
|
50
|
+
const expiresAt = new Date(Date.now() + 60_000);
|
|
51
|
+
for (const [token, userId] of [["laptop", "user-1"], ["phone", "user-1"], ["other", "user-2"]] as const) {
|
|
52
|
+
services.resolve(RESOLVED_SESSIONS).report({ token, userId, expiresAt });
|
|
53
|
+
}
|
|
54
|
+
const remaining = async (token: string): Promise<string | undefined> => {
|
|
55
|
+
const headers: Record<string, string> = {};
|
|
56
|
+
await limiter.enforce({
|
|
57
|
+
request: new Request("http://localhost/ordinary", { headers: { authorization: `Bearer ${signed(token)}` } }),
|
|
58
|
+
clientAddress: "127.0.0.1",
|
|
59
|
+
setHeader: (name, value) => { headers[name.toLowerCase()] = value; },
|
|
60
|
+
});
|
|
61
|
+
return headers["ratelimit-remaining"];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
expect(await remaining("laptop")).toBe("299");
|
|
65
|
+
expect(await remaining("phone")).toBe("298");
|
|
66
|
+
expect(await remaining("laptop")).toBe("297");
|
|
67
|
+
expect(await remaining("other")).toBe("299");
|
|
68
|
+
// A session the API has not resolved yet keeps a counter of its own.
|
|
69
|
+
expect(await remaining("tablet")).toBe("299");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
35
73
|
describe("RateLimiter", () => {
|
|
36
74
|
test("uses the selected cache fixed-window primitive", async () => {
|
|
37
75
|
let now = 1_000;
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { AppException } from "../common/app-exception";
|
|
|
3
3
|
import { API_KEY_VERIFIER } from "../api-key/api-key.module";
|
|
4
4
|
import {
|
|
5
5
|
REQUEST_GUARDS,
|
|
6
|
+
RESOLVED_SESSIONS,
|
|
6
7
|
type PodokitModule,
|
|
7
8
|
type RequestGuardContext,
|
|
8
9
|
type ServiceKey,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
sessionSigningSecret,
|
|
13
14
|
type RateLimitConfig,
|
|
14
15
|
} from "./rate-limit.config";
|
|
15
|
-
import { RateLimitIdentity } from "./rate-limit.identity";
|
|
16
|
+
import { RateLimitIdentity, SessionUsers } from "./rate-limit.identity";
|
|
16
17
|
import { defaultRateLimitRoutes, generalProfile, type RateLimitRoutes } from "./rate-limit.routes";
|
|
17
18
|
|
|
18
19
|
export { RATE_LIMIT_ROUTES } from "@podosoft/podokit-runtime";
|
|
@@ -61,7 +62,7 @@ export class RateLimiter {
|
|
|
61
62
|
const profile = this.routes.resolve(request.method, path) ?? generalProfile(this.config);
|
|
62
63
|
if (profile === "exempt") return;
|
|
63
64
|
try {
|
|
64
|
-
const tracker = await this.identity.resolve(request, context.
|
|
65
|
+
const tracker = await this.identity.resolve(request, context.clientAddress);
|
|
65
66
|
await this.enforceCustom(profile.name, tracker, profile.ttlSeconds, profile.limit, context.setHeader);
|
|
66
67
|
} catch (error) {
|
|
67
68
|
if (error instanceof AppException) throw error;
|
|
@@ -125,12 +126,19 @@ export const RATE_LIMITER = Symbol("rate-limiter") as ServiceKey<RateLimiter>;
|
|
|
125
126
|
export const rateLimitModule: PodokitModule = {
|
|
126
127
|
name: "rate-limit",
|
|
127
128
|
configure: (_env, services): void => {
|
|
129
|
+
// Sessions the auth module resolves for any request teach the limiter their
|
|
130
|
+
// users, so a user's sessions share one counter without another lookup.
|
|
131
|
+
const sessionUsers = new SessionUsers();
|
|
132
|
+
services.resolve(RESOLVED_SESSIONS).listen(({ token, userId, expiresAt }) =>
|
|
133
|
+
sessionUsers.remember(token, userId, expiresAt),
|
|
134
|
+
);
|
|
128
135
|
const limiter = new RateLimiter(
|
|
129
136
|
rateLimitConfig(),
|
|
130
137
|
new RateLimitIdentity(
|
|
131
138
|
services.resolve(API_KEY_VERIFIER),
|
|
132
139
|
services,
|
|
133
140
|
sessionSigningSecret(),
|
|
141
|
+
sessionUsers,
|
|
134
142
|
),
|
|
135
143
|
new CacheRateLimitStorage(services.resolve(CACHE)),
|
|
136
144
|
);
|