@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
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts
CHANGED
|
@@ -11,8 +11,6 @@ const config = {
|
|
|
11
11
|
authTtlSeconds: 60,
|
|
12
12
|
authLimit: 20,
|
|
13
13
|
runtimeLimit: 1000,
|
|
14
|
-
trustedProxyHops: 0,
|
|
15
|
-
proxyHeader: "x-forwarded-for",
|
|
16
14
|
storageTimeoutMs: 100,
|
|
17
15
|
unavailableRetryAfterSeconds: 1,
|
|
18
16
|
};
|
|
@@ -25,7 +23,7 @@ function context(path: string, method = "GET"): { value: RequestGuardContext; he
|
|
|
25
23
|
headers,
|
|
26
24
|
value: {
|
|
27
25
|
request: new Request(`http://localhost${path}`, { method }),
|
|
28
|
-
|
|
26
|
+
clientAddress: "127.0.0.1",
|
|
29
27
|
setHeader: (name, value) => { headers[name.toLowerCase()] = value; },
|
|
30
28
|
},
|
|
31
29
|
};
|
|
@@ -50,11 +48,49 @@ describe("RateLimitRoutes", () => {
|
|
|
50
48
|
expect(routes.resolve("GET", "/other")).toBeUndefined();
|
|
51
49
|
});
|
|
52
50
|
|
|
51
|
+
test("matches a :name parameter against exactly one non-empty segment", () => {
|
|
52
|
+
const routes = new RateLimitRoutes(60);
|
|
53
|
+
const transfers = { name: "file-transfers", limit: 600 };
|
|
54
|
+
routes.register({ pathPrefix: "/hosts/:hostId/file-transfers", profile: transfers });
|
|
55
|
+
|
|
56
|
+
expect(routes.resolve("PUT", "/hosts/h1/file-transfers/t1/chunks/3")).toMatchObject(transfers);
|
|
57
|
+
expect(routes.resolve("GET", "/hosts/h1/file-transfers")).toMatchObject(transfers);
|
|
58
|
+
expect(routes.resolve("GET", "/hosts/h1/file-transfers/")).toMatchObject(transfers);
|
|
59
|
+
expect(routes.resolve("GET", "/hosts/h1/file-transfers-old")).toBeUndefined();
|
|
60
|
+
expect(routes.resolve("GET", "/hosts/file-transfers")).toBeUndefined();
|
|
61
|
+
expect(routes.resolve("GET", "/hosts//file-transfers")).toBeUndefined();
|
|
62
|
+
expect(routes.resolve("GET", "/hosts/h1/h2/file-transfers")).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("prefers more segments, then a literal segment over a parameter from the left", () => {
|
|
66
|
+
const routes = new RateLimitRoutes(60);
|
|
67
|
+
routes.register({ pathPrefix: "/hosts/:hostId/:section/:id", profile: { name: "deep", limit: 1 } });
|
|
68
|
+
routes.register({ pathPrefix: "/hosts/:hostId/files", profile: { name: "files", limit: 2 } });
|
|
69
|
+
routes.register({ pathPrefix: "/hosts/local/:section", profile: { name: "local", limit: 3 } });
|
|
70
|
+
routes.register({ pathPrefix: "/hosts", profile: { name: "hosts", limit: 4 } });
|
|
71
|
+
|
|
72
|
+
expect(routes.resolve("GET", "/hosts/local/files/1")).toMatchObject({ name: "deep" });
|
|
73
|
+
expect(routes.resolve("GET", "/hosts/local/files")).toMatchObject({ name: "local" });
|
|
74
|
+
expect(routes.resolve("GET", "/hosts/h2/files")).toMatchObject({ name: "files" });
|
|
75
|
+
expect(routes.resolve("GET", "/hosts/h2/logs")).toMatchObject({ name: "hosts" });
|
|
76
|
+
|
|
77
|
+
// Equal specificity falls back to the method, then the latest registration.
|
|
78
|
+
routes.register({ method: "GET", pathPrefix: "/hosts/:a/logs", profile: "exempt" });
|
|
79
|
+
routes.register({ pathPrefix: "/hosts/:b/logs", profile: { name: "logs", limit: 5 } });
|
|
80
|
+
expect(routes.resolve("GET", "/hosts/h2/logs")).toBe("exempt");
|
|
81
|
+
expect(routes.resolve("POST", "/hosts/h2/logs")).toMatchObject({ name: "logs" });
|
|
82
|
+
});
|
|
83
|
+
|
|
53
84
|
test("rejects invalid routes and conflicting budgets under one name", () => {
|
|
54
85
|
const routes = new RateLimitRoutes(60);
|
|
55
86
|
expect(() => routes.register({ pathPrefix: "uploads", profile: "exempt" })).toThrow("absolute path");
|
|
56
87
|
expect(() => routes.register({ pathPrefix: "/a?b", profile: "exempt" })).toThrow("absolute path");
|
|
57
88
|
expect(() => routes.register({ pathPrefix: "/a//b", profile: "exempt" })).toThrow("absolute path");
|
|
89
|
+
expect(() => routes.register({ pathPrefix: "/a/../b", profile: "exempt" })).toThrow('".." segments');
|
|
90
|
+
expect(() => routes.register({ pathPrefix: "/a/*", profile: "exempt" })).toThrow("not wildcards");
|
|
91
|
+
for (const parameter of [":", ":1st", ":host-id", ":a:b"]) {
|
|
92
|
+
expect(() => routes.register({ pathPrefix: `/hosts/${parameter}/x`, profile: "exempt" })).toThrow("parameter");
|
|
93
|
+
}
|
|
58
94
|
expect(() => routes.register({ method: "GET /", pathPrefix: "/a", profile: "exempt" })).toThrow("method");
|
|
59
95
|
expect(() => routes.register({ pathPrefix: "/a", profile: { name: "a:b", limit: 1 } })).toThrow("name");
|
|
60
96
|
expect(() => routes.register({ pathPrefix: "/a", profile: { name: "a", limit: 0 } })).toThrow("limit");
|
|
@@ -83,6 +119,28 @@ describe("RateLimitRoutes", () => {
|
|
|
83
119
|
});
|
|
84
120
|
|
|
85
121
|
describe("RateLimiter route profiles", () => {
|
|
122
|
+
test("advertises the built-in budget of each default route", async () => {
|
|
123
|
+
const limiter = new RateLimiter(
|
|
124
|
+
config,
|
|
125
|
+
{ resolve: async () => "session:test" } as unknown as RateLimitIdentity,
|
|
126
|
+
{ increment: async () => ({ count: 1, retryAfterSeconds: 60 }) },
|
|
127
|
+
);
|
|
128
|
+
const ceiling = async (path: string, method = "GET"): Promise<string | undefined> => {
|
|
129
|
+
const request = context(path, method);
|
|
130
|
+
await limiter.enforce(request.value);
|
|
131
|
+
return request.headers["ratelimit-limit"];
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
expect(await ceiling("/api/auth/get-session")).toBe("1000");
|
|
135
|
+
expect(await ceiling("/site/settings")).toBe("1000");
|
|
136
|
+
expect(await ceiling("/api/auth/admin/list-users")).toBe("300");
|
|
137
|
+
expect(await ceiling("/api/auth/admin/ban-user", "POST")).toBe("20");
|
|
138
|
+
expect(await ceiling("/api/auth/sign-out", "POST")).toBe("20");
|
|
139
|
+
expect(await ceiling("/api/auth/sign-in/email", "POST")).toBe("20");
|
|
140
|
+
expect(await ceiling("/site/settings", "PUT")).toBe("300");
|
|
141
|
+
expect(await ceiling("/account/capabilities")).toBe("300");
|
|
142
|
+
});
|
|
143
|
+
|
|
86
144
|
test("counts a registered route in its own bucket and skips exempt routes", async () => {
|
|
87
145
|
const increment = mock(async (_key: string, _ttlSeconds: number) => ({ count: 1, retryAfterSeconds: 30 }));
|
|
88
146
|
const limiter = new RateLimiter(
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts
CHANGED
|
@@ -8,14 +8,18 @@ import type { RateLimitConfig } from "./rate-limit.config";
|
|
|
8
8
|
/** A budget with its window resolved, or "exempt". */
|
|
9
9
|
export type ResolvedRateLimitProfile = Required<RateLimitProfile> | "exempt";
|
|
10
10
|
|
|
11
|
+
/** A literal path segment, or null for a `:name` parameter. */
|
|
12
|
+
type Segment = string | null;
|
|
13
|
+
|
|
11
14
|
interface RouteEntry {
|
|
12
15
|
method?: string;
|
|
13
|
-
|
|
16
|
+
segments: Segment[];
|
|
14
17
|
profile: ResolvedRateLimitProfile;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
const PROFILE_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
18
21
|
const METHOD = /^[A-Z]+$/;
|
|
22
|
+
const PARAMETER = /^:[A-Za-z_][A-Za-z0-9_]*$/;
|
|
19
23
|
|
|
20
24
|
function positiveInteger(value: number | undefined, label: string): void {
|
|
21
25
|
if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) {
|
|
@@ -23,15 +27,47 @@ function positiveInteger(value: number | undefined, label: string): void {
|
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
function
|
|
30
|
+
function parsePrefix(prefix: string): Segment[] {
|
|
27
31
|
if (!prefix.startsWith("/") || /[?#\s]/.test(prefix) || prefix.includes("//")) {
|
|
28
32
|
throw new Error(`Rate limit path prefix must be an absolute path: ${prefix}`);
|
|
29
33
|
}
|
|
30
|
-
|
|
34
|
+
if (prefix.includes("*")) {
|
|
35
|
+
throw new Error(`Rate limit path prefix takes ":name" parameters, not wildcards: ${prefix}`);
|
|
36
|
+
}
|
|
37
|
+
const base = prefix.length > 1 && prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
38
|
+
return base === "/"
|
|
39
|
+
? []
|
|
40
|
+
: base.slice(1).split("/").map((segment) => {
|
|
41
|
+
if (segment === "." || segment === "..") {
|
|
42
|
+
throw new Error(`Rate limit path prefix must not contain "." or ".." segments: ${prefix}`);
|
|
43
|
+
}
|
|
44
|
+
if (!segment.startsWith(":")) return segment;
|
|
45
|
+
if (!PARAMETER.test(segment)) {
|
|
46
|
+
throw new Error(`Rate limit path parameter must be ":" and a name such as ":hostId": ${prefix}`);
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whole-segment prefix match; a parameter matches one non-empty segment. */
|
|
53
|
+
function covers(segments: Segment[], path: string[]): boolean {
|
|
54
|
+
return (
|
|
55
|
+
path.length >= segments.length &&
|
|
56
|
+
segments.every((segment, index) => (segment === null ? path[index] !== "" : segment === path[index]))
|
|
57
|
+
);
|
|
31
58
|
}
|
|
32
59
|
|
|
33
|
-
|
|
34
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Below zero when `a` is more specific: more segments, then, from the left, a
|
|
62
|
+
* literal segment before a parameter.
|
|
63
|
+
*/
|
|
64
|
+
function compareSpecificity(a: Segment[], b: Segment[]): number {
|
|
65
|
+
if (a.length !== b.length) return b.length - a.length;
|
|
66
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
67
|
+
const literalA = a[index] !== null;
|
|
68
|
+
if (literalA !== (b[index] !== null)) return literalA ? -1 : 1;
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
35
71
|
}
|
|
36
72
|
|
|
37
73
|
/**
|
|
@@ -50,24 +86,21 @@ export class RateLimitRoutes implements RateLimitRouteRegistry {
|
|
|
50
86
|
if (method !== undefined && !METHOD.test(method)) {
|
|
51
87
|
throw new Error(`Rate limit route method is invalid: ${route.method}`);
|
|
52
88
|
}
|
|
53
|
-
const
|
|
89
|
+
const segments = parsePrefix(route.pathPrefix);
|
|
54
90
|
const profile = route.profile === "exempt" ? "exempt" : this.budget(route.profile);
|
|
55
|
-
this.entries.push({ ...(method ? { method } : {}),
|
|
91
|
+
this.entries.push({ ...(method ? { method } : {}), segments, profile });
|
|
56
92
|
}
|
|
57
93
|
|
|
58
94
|
/** The most specific registered profile for a request, if any route matches. */
|
|
59
95
|
resolve(method: string, path: string): ResolvedRateLimitProfile | undefined {
|
|
96
|
+
const pathSegments = path.split("/").slice(1);
|
|
60
97
|
let best: RouteEntry | undefined;
|
|
61
98
|
for (const entry of this.entries) {
|
|
62
99
|
if (entry.method !== undefined && entry.method !== method) continue;
|
|
63
|
-
if (!covers(entry.
|
|
64
|
-
// The
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
entry.prefix.length > best.prefix.length ||
|
|
68
|
-
(entry.prefix.length === best.prefix.length &&
|
|
69
|
-
(entry.method !== undefined || best.method === undefined))
|
|
70
|
-
) {
|
|
100
|
+
if (!covers(entry.segments, pathSegments)) continue;
|
|
101
|
+
// The more specific prefix wins, then a route with a method, then the later one.
|
|
102
|
+
const order = best ? compareSpecificity(entry.segments, best.segments) : -1;
|
|
103
|
+
if (order < 0 || (order === 0 && (entry.method !== undefined || best?.method === undefined))) {
|
|
71
104
|
best = entry;
|
|
72
105
|
}
|
|
73
106
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
expect,
|
|
3
|
+
test,
|
|
4
|
+
type APIRequestContext,
|
|
5
|
+
type APIResponse,
|
|
6
|
+
type PlaywrightWorkerArgs,
|
|
7
|
+
} from "@playwright/test";
|
|
2
8
|
|
|
3
9
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
10
|
const origin = { origin: base };
|
|
@@ -19,20 +25,42 @@ const limit = Number(process.env.RATE_LIMIT_MAX ?? 300);
|
|
|
19
25
|
const authLimit = Number(process.env.RATE_LIMIT_AUTH_MAX ?? 20);
|
|
20
26
|
const runtimeLimit = Number(process.env.RATE_LIMIT_RUNTIME_MAX ?? 1000);
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
type Playwright = PlaywrightWorkerArgs["playwright"];
|
|
29
|
+
const password = "Podokit3e-Str0ng!pw";
|
|
30
|
+
|
|
31
|
+
function uniqueEmail(): string {
|
|
32
|
+
return `rate-limit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Sign-ups and sign-ins are authentication writes. From an anonymous client the
|
|
36
|
+
// limiter counts them against the per-address budget that the setup project's
|
|
37
|
+
// and every other spec's sign-ins share. Only this worker's first one is
|
|
38
|
+
// anonymous: the later ones carry its session cookie, so the limiter counts them
|
|
39
|
+
// against that session instead.
|
|
40
|
+
let carrier: string | undefined;
|
|
41
|
+
|
|
42
|
+
/** Run one authentication write and return a context holding the session it set. */
|
|
43
|
+
async function authenticated(
|
|
44
|
+
playwright: Playwright,
|
|
45
|
+
path: "/api/auth/sign-up/email" | "/api/auth/sign-in/email",
|
|
46
|
+
data: Record<string, string>,
|
|
47
|
+
): Promise<APIRequestContext> {
|
|
48
|
+
const writer = await playwright.request.newContext({
|
|
49
|
+
baseURL: base,
|
|
50
|
+
extraHTTPHeaders: { ...origin, ...(carrier ? { cookie: carrier } : {}) },
|
|
33
51
|
});
|
|
34
|
-
|
|
35
|
-
|
|
52
|
+
const response = await writer.post(path, { data });
|
|
53
|
+
expect(response.ok(), `${path}: ${response.status()}`).toBeTruthy();
|
|
54
|
+
const storageState = await writer.storageState();
|
|
55
|
+
await writer.dispose();
|
|
56
|
+
const token = storageState.cookies.find((cookie) => cookie.name.endsWith("session_token"));
|
|
57
|
+
carrier ??= token ? `${token.name}=${token.value}` : undefined;
|
|
58
|
+
return playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin, storageState });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A new account, signed in. */
|
|
62
|
+
async function session(playwright: Playwright, email = uniqueEmail()): Promise<APIRequestContext> {
|
|
63
|
+
return authenticated(playwright, "/api/auth/sign-up/email", { email, password, name: "Rate Limit" });
|
|
36
64
|
}
|
|
37
65
|
|
|
38
66
|
test("rate limit: health probes stay available while ordinary routes return 429 @smoke", async ({
|
|
@@ -70,32 +98,39 @@ test("rate limit: health probes stay available while ordinary routes return 429
|
|
|
70
98
|
await ctx.dispose();
|
|
71
99
|
});
|
|
72
100
|
|
|
73
|
-
test("rate limit:
|
|
101
|
+
test("rate limit: a user's sessions share one counter and forged sessions do not get one", async ({
|
|
74
102
|
playwright,
|
|
75
103
|
}) => {
|
|
76
|
-
const email =
|
|
77
|
-
const
|
|
78
|
-
const first = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
79
|
-
expect((await first.post("/api/auth/sign-up/email", { data: { email, password, name: "Rate Limit" } })).ok()).toBeTruthy();
|
|
104
|
+
const email = uniqueEmail();
|
|
105
|
+
const first = await session(playwright, email);
|
|
80
106
|
const probe = await first.get("/api/cache/rate-limit-probe");
|
|
81
107
|
test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
|
|
82
108
|
const remaining = (response: APIResponse): number => Number(response.headers()["ratelimit-remaining"]);
|
|
83
109
|
const ceiling = Number(probe.headers()["ratelimit-limit"]);
|
|
84
|
-
expect(remaining(probe)).toBe(ceiling - 1);
|
|
85
110
|
|
|
86
|
-
//
|
|
111
|
+
// A second sign-in of the same user, and the signed bearer form of the first
|
|
112
|
+
// session. The probe requires a session, so each request below resolves it; a
|
|
113
|
+
// session counts under its user from the request after the API first resolves
|
|
114
|
+
// it (or from that request when authentication runs before the limiter).
|
|
115
|
+
const second = await authenticated(playwright, "/api/auth/sign-in/email", { email, password });
|
|
87
116
|
const cookie = (await first.storageState()).cookies.find((c) => c.name.endsWith("session_token"));
|
|
88
117
|
expect(cookie, "sign-up set a session cookie").toBeTruthy();
|
|
89
118
|
const bearer = await playwright.request.newContext({
|
|
90
119
|
baseURL: base,
|
|
91
120
|
extraHTTPHeaders: { ...origin, authorization: `Bearer ${cookie?.value ?? ""}` },
|
|
92
121
|
});
|
|
93
|
-
|
|
122
|
+
await second.get("/api/cache/rate-limit-probe");
|
|
123
|
+
|
|
124
|
+
// Both sessions now draw from one budget: each request lowers what the next
|
|
125
|
+
// one, from either session, has left.
|
|
126
|
+
const fromFirst = remaining(await first.get("/api/cache/rate-limit-probe"));
|
|
127
|
+
expect(remaining(await second.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 1);
|
|
128
|
+
expect(remaining(await bearer.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 2);
|
|
129
|
+
expect(remaining(await first.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 3);
|
|
94
130
|
|
|
95
|
-
//
|
|
96
|
-
const
|
|
97
|
-
expect((await
|
|
98
|
-
expect(remaining(await second.get("/api/cache/rate-limit-probe"))).toBe(ceiling - 1);
|
|
131
|
+
// Another user has a budget of their own.
|
|
132
|
+
const other = await session(playwright);
|
|
133
|
+
expect(remaining(await other.get("/api/cache/rate-limit-probe"))).toBe(ceiling - 1);
|
|
99
134
|
|
|
100
135
|
// A session cookie without a valid signature is counted with anonymous traffic
|
|
101
136
|
// from the same client address instead of receiving a fresh counter. The cache
|
|
@@ -113,12 +148,13 @@ test("rate limit: each signed session has its own counter and forged sessions do
|
|
|
113
148
|
expect(remaining(after)).toBeLessThan(remaining(before));
|
|
114
149
|
}).toPass({ timeout: 10_000 });
|
|
115
150
|
|
|
116
|
-
for (const context of [first, bearer,
|
|
151
|
+
for (const context of [first, second, bearer, other, forged, anonymous]) await context.dispose();
|
|
117
152
|
});
|
|
118
153
|
|
|
119
154
|
test("rate limit: session reads do not consume the strict authentication budget", async ({
|
|
120
155
|
playwright,
|
|
121
156
|
}) => {
|
|
157
|
+
test.skip(authLimit > 1000, "the authentication budget is too large to exhaust here");
|
|
122
158
|
const ctx = await session(playwright);
|
|
123
159
|
for (let i = 0; i < authLimit + 5; i++) {
|
|
124
160
|
const response = await ctx.get("/api/auth/get-session");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rate-limit",
|
|
3
3
|
"manifestVersion": 3,
|
|
4
|
-
"description": "Provider-neutral Elysia request limiting by validated API key, signed-in
|
|
4
|
+
"description": "Provider-neutral Elysia request limiting by validated API key, signed-in user, or trusted-proxy client address.",
|
|
5
5
|
"requires": ["api-key-auth"],
|
|
6
6
|
"requiresCapabilities": ["cache"],
|
|
7
7
|
"targetApp": "api",
|
|
@@ -19,9 +19,6 @@
|
|
|
19
19
|
"RATE_LIMIT_AUTH_MAX=20",
|
|
20
20
|
"# higher ceiling for SSR runtime settings and session reads",
|
|
21
21
|
"RATE_LIMIT_RUNTIME_MAX=1000",
|
|
22
|
-
"# trust no proxy hop by default; set the exact production proxy depth",
|
|
23
|
-
"RATE_LIMIT_TRUSTED_PROXY_HOPS=0",
|
|
24
|
-
"RATE_LIMIT_PROXY_HEADER=x-forwarded-for",
|
|
25
22
|
"RATE_LIMIT_STORAGE_TIMEOUT_MS=1000",
|
|
26
23
|
"RATE_LIMIT_UNAVAILABLE_RETRY_AFTER=1"
|
|
27
24
|
],
|
|
@@ -40,20 +37,20 @@
|
|
|
40
37
|
"file": "AGENTS.md",
|
|
41
38
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
42
39
|
"optional": true,
|
|
43
|
-
"text": "### rate-limit\n- Every request counts against a per-caller budget (general, `/api/auth`, or runtime reads); exceeded limits return 429 `RATE_LIMIT_EXCEEDED` with `Retry-After
|
|
40
|
+
"text": "### rate-limit\n- Every request counts against a per-caller budget (general, `/api/auth`, or runtime reads), keyed by API key, then signed-in user, then client address; exceeded limits return 429 `RATE_LIMIT_EXCEEDED` with `Retry-After`. A user's sessions share one budget once the API has resolved them for a request.\n- Give routes that legitimately send many requests, such as upload chunks, their own budget instead of raising `RATE_LIMIT_MAX`: `services.tryResolve(RATE_LIMIT_ROUTES)?.register({ method: \"PUT\", pathPrefix: \"/uploads\", profile: { name: \"uploads\", limit: 3000 } })` (`RATE_LIMIT_ROUTES` from `@podosoft/podokit-runtime`). Register from a module's `plugin` factory or `configureServices()` in `app.extensions.ts`; the most specific prefix wins (more segments, then a literal segment over a `:name` parameter), and `profile: \"exempt\"` skips limiting.\n- Prefixes are API paths (`/uploads`, not the web proxy's `/api/uploads`); Better Auth routes keep `/api/auth`. A `:name` segment matches one path segment, as in `/hosts/:hostId/file-transfers`.\n"
|
|
44
41
|
}
|
|
45
42
|
],
|
|
46
43
|
"instructions": [
|
|
47
44
|
"Configure the selected cache provider before enabling request limits.",
|
|
48
45
|
"Tune the general, authentication, and runtime limits in .env.",
|
|
49
46
|
"Give each deployment that shares Redis a distinct RATE_LIMIT_KEY_PREFIX.",
|
|
50
|
-
"
|
|
51
|
-
"Validated X-API-Key clients and signed-in
|
|
52
|
-
"Sessions are recognized by verifying the Better Auth cookie or bearer signature with BETTER_AUTH_SECRET, without a database read,
|
|
47
|
+
"Requests without a validated API key or session count by the client address the API resolves from TRUSTED_PROXY_HOPS and TRUSTED_PROXY_HEADER, which audit entries and Better Auth sessions use too.",
|
|
48
|
+
"Validated X-API-Key clients and signed-in users receive independent opaque counters; other traffic uses the resolved client address.",
|
|
49
|
+
"Sessions are recognized by verifying the Better Auth cookie or bearer signature with BETTER_AUTH_SECRET, without a database read, and count under their user once the API has resolved them for a request, so limits apply per user.",
|
|
53
50
|
"Exceeded limits return RATE_LIMIT_EXCEEDED with HTTP 429 and Retry-After. Cache failures return RATE_LIMIT_UNAVAILABLE with HTTP 503.",
|
|
54
51
|
"Register a RateLimitIdentityExtension under RATE_LIMIT_IDENTITY_EXTENSION in app.extensions.ts for another validated machine-key source.",
|
|
55
52
|
"GET /health and GET /health/ready bypass request quotas so orchestrator probes remain reliable.",
|
|
56
|
-
"Give a route its own budget or exempt it with services.tryResolve(RATE_LIMIT_ROUTES)?.register({ method, pathPrefix, profile }) from a module's plugin factory or configureServices() in app.extensions.ts; the most specific prefix wins.",
|
|
53
|
+
"Give a route its own budget or exempt it with services.tryResolve(RATE_LIMIT_ROUTES)?.register({ method, pathPrefix, profile }) from a module's plugin factory or configureServices() in app.extensions.ts; a :name segment matches one path segment, and the most specific prefix wins.",
|
|
57
54
|
"Redis counters are shared across replicas; memory counters require one API replica."
|
|
58
55
|
]
|
|
59
56
|
}
|
|
@@ -6,7 +6,7 @@ const origin = { origin: base };
|
|
|
6
6
|
// The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
|
|
7
7
|
// spec also compiles in apps that do not install that module.
|
|
8
8
|
const admin = {
|
|
9
|
-
email: "admin@example.com",
|
|
9
|
+
email: process.env.E2E_ADMIN_EMAIL || "admin@example.com",
|
|
10
10
|
password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
|
|
11
11
|
};
|
|
12
12
|
|
|
@@ -10,8 +10,9 @@ A Bun 1.4 full-stack TypeScript monorepo ({{packageManager}} workspaces):
|
|
|
10
10
|
|
|
11
11
|
- `apps/api` — **Elysia** backend (`{{projectName}}-api`), Bun.SQL + `{{databaseProvider}}`, merged OpenAPI at `/api-docs`.
|
|
12
12
|
- `apps/web` — **SvelteKit** frontend (`{{projectName}}-web`), Tailwind v4 + shadcn-svelte + typesafe-i18n.
|
|
13
|
-
- `infra/` — Docker Compose
|
|
14
|
-
- `.podokit/` — generation metadata plus application-owned development and
|
|
13
|
+
- `infra/` — provider-aware Docker Compose and example k3s manifests.
|
|
14
|
+
- `.podokit/` — PodoKit generation metadata plus application-owned development and
|
|
15
|
+
deployment profiles (see "PodoKit tooling" below).
|
|
15
16
|
- **Todo example**: `apps/api/src/todos/` (Elysia plugin, Bun.SQL repository, and migration) and the todo UI in `apps/web/src/routes/` show the end-to-end patterns to follow.
|
|
16
17
|
|
|
17
18
|
## Setup & commands
|
|
@@ -49,7 +50,32 @@ Merge added module settings from `.env.example` into an existing `.env`.
|
|
|
49
50
|
managed lists. Cap a route's request body in the app-owned
|
|
50
51
|
`apps/web/src/lib/server/body-limits.ts` (`APP_BODY_LIMITS`, 413
|
|
51
52
|
`PAYLOAD_TOO_LARGE`). `BODY_SIZE_LIMIT` is the ceiling for every web route, so
|
|
52
|
-
uploads larger than it must use a direct API path (`apiPaths`) instead.
|
|
53
|
+
uploads larger than it must use a direct API path (`apiPaths`) instead. Give a
|
|
54
|
+
route a longer, shorter, or no response deadline in the app-owned
|
|
55
|
+
`apps/web/src/lib/server/proxy-timeouts.ts` (`APP_PROXY_TIMEOUTS`, `null` for
|
|
56
|
+
none); never edit the managed deadline. Both lists take path prefixes with
|
|
57
|
+
`:name` parameters, such as `/api/hosts/:hostId/file-transfers`, and the most
|
|
58
|
+
specific prefix wins.
|
|
59
|
+
- Server-side API calls (`serverApiClient`) give up after `BACKEND_TIMEOUT_MS`
|
|
60
|
+
(2000), and the proxy answers 504 `BACKEND_TIMEOUT` when the API sends no
|
|
61
|
+
response headers within `BACKEND_PROXY_TIMEOUT_MS` (30000). Once the API's
|
|
62
|
+
`GET /health` stops answering too, calls throw `BackendUnavailableError` and the
|
|
63
|
+
proxy answers 503 `BACKEND_UNAVAILABLE` at once until it answers again. Handle
|
|
64
|
+
any non-`ApiError` failure in a load function as a temporary outage. The API
|
|
65
|
+
(`API_IDLE_TIMEOUT`) and the built web server (`CONNECTION_IDLE_TIMEOUT`) close
|
|
66
|
+
a connection that sends nothing for that many seconds (35 each): raise both
|
|
67
|
+
with any longer `BACKEND_PROXY_TIMEOUT_MS` or `APP_PROXY_TIMEOUTS` entry, more
|
|
68
|
+
than 4 above it, or set `0` when a route has no deadline. The proxy refuses to
|
|
69
|
+
load when `CONNECTION_IDLE_TIMEOUT` cannot cover a deadline.
|
|
70
|
+
- Read a caller's address in the API with `clientAddressOf(request.headers)` from
|
|
71
|
+
`apps/api/src/core/client-address.ts`, never from `X-Forwarded-For` directly. The
|
|
72
|
+
API resolves it once per request from `TRUSTED_PROXY_HOPS` (1: the web proxy) and
|
|
73
|
+
`TRUSTED_PROXY_HEADER`; Better Auth sessions, audit entries, and rate limits use
|
|
74
|
+
the same address.
|
|
75
|
+
- `GET /healthz` (`apps/web/src/routes/healthz/+server.ts`) is the web server's own
|
|
76
|
+
health route for the Kubernetes probes, the Compose healthcheck, and the image
|
|
77
|
+
`HEALTHCHECK`. Keep it free of API calls, so an API outage does not take the web
|
|
78
|
+
out of service.
|
|
53
79
|
- When the root layout or a server hook fails, such as with 503 while the API is
|
|
54
80
|
down, SvelteKit serves the app-owned `apps/web/src/error.html`. It is plain HTML
|
|
55
81
|
with inline styles: no components, app CSS, scripts, or assets, and only the
|
|
@@ -61,7 +87,11 @@ Merge added module settings from `.env.example` into an existing `.env`.
|
|
|
61
87
|
Better Auth schema before the application migrations. `{{apiRun}} migration:run` runs
|
|
62
88
|
only the application migrations and never creates Better Auth tables;
|
|
63
89
|
`{{apiRun}} migration:revert` reverts the latest application migration.
|
|
64
|
-
- e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`.
|
|
90
|
+
- e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`. Run the whole suite on more
|
|
91
|
+
workers with `E2E_WORKERS=2`, never `--workers`. Tag a test `{ tag: "@exclusive" }` when
|
|
92
|
+
it changes state other tests rely on (a server-wide setting, a seeded account's profile);
|
|
93
|
+
tagged tests run alone after the rest. Give each test its own mail and SMS recipients and
|
|
94
|
+
never empty the shared Mailpit inbox or SMS sink. See `tests/README.md`.
|
|
65
95
|
- Playwright officially runs on Node, so `bunx playwright` respects its Node
|
|
66
96
|
shebang. Node LTS is a test-tool dependency only; API, web, worker, migrations,
|
|
67
97
|
builds, and unit tests run on Bun.
|
|
@@ -96,15 +126,25 @@ changes need an e2e/unit test.
|
|
|
96
126
|
This project is managed by the `podo` CLI. Files have an ownership tier recorded
|
|
97
127
|
in `.podokit/files.lock` — respect it:
|
|
98
128
|
|
|
99
|
-
- **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. Add application-owned services or module descriptors through `app.extensions.ts` instead of changing toolkit registrations.
|
|
129
|
+
- **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. Add application-owned services or module descriptors through `app.extensions.ts` instead of changing toolkit registrations. Set Bun server options (`maxRequestBodySize`, `idleTimeout`) with its `apiServerOptions` export rather than editing `main.ts`; `API_MAX_REQUEST_BODY_SIZE` and `API_IDLE_TIMEOUT` apply when the export leaves them out.
|
|
100
130
|
- **assembled** (e.g. `apps/api/src/app.ts`, `auth.ts`) — contain `// podokit:begin:… / …:end` fenced regions that the toolkit recomputes. Edit outside the fences only; **never hand-edit inside the fences**.
|
|
101
131
|
- **managed** (bootstrap/config like `main.ts`) — the toolkit may update these; avoid gratuitous edits.
|
|
102
132
|
|
|
103
133
|
Useful commands:
|
|
104
134
|
|
|
105
|
-
- `podo
|
|
135
|
+
- `podo dev watch|up|ps|logs|exec|down` — run container development through the
|
|
136
|
+
shared loopback gateway. `.podokit/dev.json` owns the local/public hosts and the
|
|
137
|
+
exact API WebSocket allowlist; never use a prefix or wildcard for that allowlist.
|
|
138
|
+
`podo dev up` rebuilds the images from the current source so a recreated
|
|
139
|
+
container never runs code older than the files Compose Watch synced.
|
|
140
|
+
`podo dev down` also stops this project's running `podo dev watch`; do not
|
|
141
|
+
kill Compose processes by hand.
|
|
142
|
+
The containers read the untracked `.env.docker`; `podo dev` creates it from the
|
|
143
|
+
committed `.env.docker.example` with generated secrets when it is missing. Never
|
|
144
|
+
commit `.env.docker` or put secrets in the example.
|
|
106
145
|
- `podo add <module>` — add a feature (auth, admin-dashboard, redis, bullmq, …). It wires itself into the app; run `podo add` with no argument to list modules.
|
|
107
146
|
- `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
|
|
147
|
+
- `podo provider list` / `podo provider set <capability> <provider> [--apply]` — inspect or switch database, cache, object storage, events, and jobs. Set is dry-run by default and never migrates or deletes provider data. Local providers require one API process.
|
|
108
148
|
- `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
|
|
109
149
|
- `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
|
|
110
150
|
- `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Production API WebSocket paths belong in the deployment profile's exact `exposure.webSocketPaths` allowlist, and API routes that take bodies above the web server's `BODY_SIZE_LIMIT` (file uploads) in `exposure.apiPaths` (and `.podokit/dev.json` `apiPaths` for `podo dev`; `podo add` and `podo deploy init` fill in the paths modules declare), which reach the API without the web proxy, so the API must check their size, session, and `Origin` itself; Compose trusts forwarding headers only from `exposure.trustedProxyCidrs`. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
|
|
@@ -55,8 +55,9 @@ cp .env.example .env
|
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
Open the URL printed by the command; a new project defaults to
|
|
58
|
-
**http://{{
|
|
59
|
-
source of truth if you change the hostname
|
|
58
|
+
**http://{{devHostname}}**. The committed `.podokit/dev.json` is the
|
|
59
|
+
source of truth if you change the hostname: `podo dev` sets `CORS_ORIGIN` and
|
|
60
|
+
`BETTER_AUTH_URL` for the web and API containers from it. The first running PodoKit project starts
|
|
60
61
|
one user-level Traefik gateway on `127.0.0.1:80`; additional projects reuse it and
|
|
61
62
|
route by that hostname. Even a single app uses
|
|
62
63
|
the same topology, with one route and no project-specific host port.
|
|
@@ -164,5 +165,6 @@ PodoKit [deployment guide](https://github.com/podosoft-dev/podokit/blob/main/doc
|
|
|
164
165
|
For a Docker Compose deployment with API WebSockets, list their exact paths under
|
|
165
166
|
`exposure.webSocketPaths`. The driver keeps one published port, routes those paths
|
|
166
167
|
to API, and sends every other request to web. Large-upload routes listed under
|
|
167
|
-
`exposure.apiPaths` reach the API the same way, with `/api` removed.
|
|
168
|
-
|
|
168
|
+
`exposure.apiPaths` reach the API the same way, with `/api` removed. Set `exposure.edgeProxies` to the
|
|
169
|
+
number of reverse proxies in front of the deployment, and with the gateway list only their direct CIDR
|
|
170
|
+
blocks in `exposure.trustedProxyCidrs`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-based unified diff for `podo diff <path>`. It is dependency-free and
|
|
3
|
+
* meant for source files of a few thousand lines; above `MAX_CELLS` it reports
|
|
4
|
+
* the whole file as replaced instead of building the LCS table.
|
|
5
|
+
*/
|
|
6
|
+
export interface UnifiedDiffOptions {
|
|
7
|
+
fromLabel: string;
|
|
8
|
+
toLabel: string;
|
|
9
|
+
context?: number;
|
|
10
|
+
}
|
|
11
|
+
/** Unified diff from `from` to `to`, or an empty string when they are equal. */
|
|
12
|
+
export declare function unifiedDiff(from: string, to: string, options: UnifiedDiffOptions): string;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Line-based unified diff for `podo diff <path>`. It is dependency-free and
|
|
4
|
+
* meant for source files of a few thousand lines; above `MAX_CELLS` it reports
|
|
5
|
+
* the whole file as replaced instead of building the LCS table.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.unifiedDiff = unifiedDiff;
|
|
9
|
+
const MAX_CELLS = 25_000_000;
|
|
10
|
+
function splitLines(text) {
|
|
11
|
+
if (text === "")
|
|
12
|
+
return [];
|
|
13
|
+
const lines = text.split("\n");
|
|
14
|
+
if (lines[lines.length - 1] === "")
|
|
15
|
+
lines.pop();
|
|
16
|
+
return lines;
|
|
17
|
+
}
|
|
18
|
+
function operations(a, b) {
|
|
19
|
+
let start = 0;
|
|
20
|
+
while (start < a.length && start < b.length && a[start] === b[start])
|
|
21
|
+
start += 1;
|
|
22
|
+
let endA = a.length;
|
|
23
|
+
let endB = b.length;
|
|
24
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
25
|
+
endA -= 1;
|
|
26
|
+
endB -= 1;
|
|
27
|
+
}
|
|
28
|
+
const head = a.slice(0, start).map((line) => ({ kind: " ", line }));
|
|
29
|
+
const tail = a.slice(endA).map((line) => ({ kind: " ", line }));
|
|
30
|
+
const midA = a.slice(start, endA);
|
|
31
|
+
const midB = b.slice(start, endB);
|
|
32
|
+
const m = midA.length;
|
|
33
|
+
const n = midB.length;
|
|
34
|
+
const middle = [];
|
|
35
|
+
if ((m + 1) * (n + 1) > MAX_CELLS) {
|
|
36
|
+
middle.push(...midA.map((line) => ({ kind: "-", line })));
|
|
37
|
+
middle.push(...midB.map((line) => ({ kind: "+", line })));
|
|
38
|
+
return [...head, ...middle, ...tail];
|
|
39
|
+
}
|
|
40
|
+
const width = n + 1;
|
|
41
|
+
const table = new Uint32Array((m + 1) * width);
|
|
42
|
+
for (let i = m - 1; i >= 0; i -= 1) {
|
|
43
|
+
for (let j = n - 1; j >= 0; j -= 1) {
|
|
44
|
+
table[i * width + j] = midA[i] === midB[j]
|
|
45
|
+
? table[(i + 1) * width + j + 1] + 1
|
|
46
|
+
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
let i = 0;
|
|
50
|
+
let j = 0;
|
|
51
|
+
while (i < m || j < n) {
|
|
52
|
+
if (i < m && j < n && midA[i] === midB[j]) {
|
|
53
|
+
middle.push({ kind: " ", line: midA[i] });
|
|
54
|
+
i += 1;
|
|
55
|
+
j += 1;
|
|
56
|
+
}
|
|
57
|
+
else if (i < m && (j === n || table[(i + 1) * width + j] >= table[i * width + j + 1])) {
|
|
58
|
+
middle.push({ kind: "-", line: midA[i] });
|
|
59
|
+
i += 1;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
middle.push({ kind: "+", line: midB[j] });
|
|
63
|
+
j += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [...head, ...middle, ...tail];
|
|
67
|
+
}
|
|
68
|
+
function range(start, count) {
|
|
69
|
+
if (count === 0)
|
|
70
|
+
return `${start},0`;
|
|
71
|
+
return count === 1 ? `${start + 1}` : `${start + 1},${count}`;
|
|
72
|
+
}
|
|
73
|
+
/** Unified diff from `from` to `to`, or an empty string when they are equal. */
|
|
74
|
+
function unifiedDiff(from, to, options) {
|
|
75
|
+
if (from === to)
|
|
76
|
+
return "";
|
|
77
|
+
const context = options.context ?? 3;
|
|
78
|
+
const ops = operations(splitLines(from), splitLines(to));
|
|
79
|
+
const changed = ops.flatMap((op, index) => (op.kind === " " ? [] : [index]));
|
|
80
|
+
const out = [`--- ${options.fromLabel}`, `+++ ${options.toLabel}`];
|
|
81
|
+
if (!changed.length) {
|
|
82
|
+
// Only the trailing newline differs.
|
|
83
|
+
out.push(from.endsWith("\n") ? "\\ the template ends with a newline" : "\\ the template has no newline at end of file", to.endsWith("\n") ? "\\ the file ends with a newline" : "\\ the file has no newline at end of file");
|
|
84
|
+
return `${out.join("\n")}\n`;
|
|
85
|
+
}
|
|
86
|
+
let hunkStart = 0;
|
|
87
|
+
while (hunkStart < changed.length) {
|
|
88
|
+
let hunkEnd = hunkStart;
|
|
89
|
+
while (hunkEnd + 1 < changed.length &&
|
|
90
|
+
changed[hunkEnd + 1] - changed[hunkEnd] <= context * 2 + 1) {
|
|
91
|
+
hunkEnd += 1;
|
|
92
|
+
}
|
|
93
|
+
const first = Math.max(0, changed[hunkStart] - context);
|
|
94
|
+
const last = Math.min(ops.length - 1, changed[hunkEnd] + context);
|
|
95
|
+
let fromLine = 0;
|
|
96
|
+
let toLine = 0;
|
|
97
|
+
for (let index = 0; index < first; index += 1) {
|
|
98
|
+
if (ops[index].kind !== "+")
|
|
99
|
+
fromLine += 1;
|
|
100
|
+
if (ops[index].kind !== "-")
|
|
101
|
+
toLine += 1;
|
|
102
|
+
}
|
|
103
|
+
const body = ops.slice(first, last + 1);
|
|
104
|
+
const fromCount = body.filter((op) => op.kind !== "+").length;
|
|
105
|
+
const toCount = body.filter((op) => op.kind !== "-").length;
|
|
106
|
+
out.push(`@@ -${range(fromLine, fromCount)} +${range(toLine, toCount)} @@`);
|
|
107
|
+
out.push(...body.map((op) => `${op.kind}${op.line}`));
|
|
108
|
+
hunkStart = hunkEnd + 1;
|
|
109
|
+
}
|
|
110
|
+
return `${out.join("\n")}\n`;
|
|
111
|
+
}
|