@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
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { appBuildTargets } from "./build.extensions.mjs";
|
|
5
|
+
|
|
6
|
+
// Bun inlines process.env.NODE_ENV as "development" unless told otherwise, which
|
|
7
|
+
// would make every production check in the bundle, its dependencies included,
|
|
8
|
+
// take the development branch. Keep it a read of the runtime environment.
|
|
9
|
+
const define = { "process.env.NODE_ENV": "process.env.NODE_ENV" };
|
|
2
10
|
|
|
3
11
|
const targets = [
|
|
4
12
|
{ entrypoint: "src/main.ts", outdir: "dist" },
|
|
@@ -8,11 +16,20 @@ const targets = [
|
|
|
8
16
|
{ entrypoint: "src/bootstrap-admin-runtime.ts", outdir: "dist" },
|
|
9
17
|
].filter((target) => existsSync(target.entrypoint));
|
|
10
18
|
|
|
19
|
+
for (const target of appBuildTargets) {
|
|
20
|
+
if (!existsSync(target.entrypoint)) {
|
|
21
|
+
process.stderr.write(`Build failed: ${target.entrypoint} from build.extensions.mjs does not exist\n`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
targets.push(target);
|
|
25
|
+
}
|
|
26
|
+
|
|
11
27
|
for (const target of targets) {
|
|
12
28
|
const result = await Bun.build({
|
|
13
29
|
entrypoints: [target.entrypoint],
|
|
14
30
|
outdir: target.outdir,
|
|
15
31
|
target: "bun",
|
|
32
|
+
define,
|
|
16
33
|
});
|
|
17
34
|
if (!result.success) {
|
|
18
35
|
for (const log of result.logs) process.stderr.write(`${log}\n`);
|
|
@@ -20,20 +37,52 @@ for (const target of targets) {
|
|
|
20
37
|
}
|
|
21
38
|
}
|
|
22
39
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
function fail(message) {
|
|
41
|
+
process.stderr.write(`Build migrations failed: ${message}\n`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isMigration(value) {
|
|
46
|
+
return typeof value === "function" &&
|
|
47
|
+
typeof value.prototype?.up === "function" &&
|
|
48
|
+
typeof value.prototype?.down === "function";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Every source migration becomes dist/migrations/<file>.js, listed with its name
|
|
52
|
+
// in dist/migrations/migrations.json, which the migrators and the API read in
|
|
53
|
+
// production. A build that emits them anywhere else, or loses one, fails here
|
|
54
|
+
// instead of a deployment that runs no migrations.
|
|
55
|
+
const sources = existsSync("src/migrations")
|
|
56
|
+
? readdirSync("src/migrations").filter((file) => /^\d+-.+\.ts$/.test(file)).sort()
|
|
57
|
+
: [];
|
|
58
|
+
rmSync("dist/migrations", { recursive: true, force: true });
|
|
59
|
+
mkdirSync("dist/migrations", { recursive: true });
|
|
60
|
+
if (sources.length > 0) {
|
|
61
|
+
const result = await Bun.build({
|
|
62
|
+
entrypoints: sources.map((file) => `src/migrations/${file}`),
|
|
63
|
+
outdir: "dist/migrations",
|
|
64
|
+
root: "src/migrations",
|
|
65
|
+
target: "bun",
|
|
66
|
+
define,
|
|
67
|
+
external: ["typeorm"],
|
|
68
|
+
});
|
|
69
|
+
if (!result.success) {
|
|
70
|
+
for (const log of result.logs) process.stderr.write(`${log}\n`);
|
|
71
|
+
process.exit(1);
|
|
38
72
|
}
|
|
39
73
|
}
|
|
74
|
+
const files = sources.map((file) => file.replace(/\.ts$/, ".js"));
|
|
75
|
+
const built = readdirSync("dist/migrations", { recursive: true }).map(String).sort();
|
|
76
|
+
if (JSON.stringify(built) !== JSON.stringify(files)) {
|
|
77
|
+
fail(`expected ${JSON.stringify(files)} in dist/migrations, found ${JSON.stringify(built)}`);
|
|
78
|
+
}
|
|
79
|
+
const list = [];
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
const module = await import(pathToFileURL(resolve("dist/migrations", file)).href);
|
|
82
|
+
const classes = Object.values(module).filter(isMigration);
|
|
83
|
+
if (classes.length !== 1) fail(`${file} must export exactly one migration class`);
|
|
84
|
+
const [Migration] = classes;
|
|
85
|
+
list.push({ file, name: new Migration().name || Migration.name });
|
|
86
|
+
}
|
|
87
|
+
writeFileSync("dist/migrations/migrations.json", `${JSON.stringify(list, null, 2)}\n`);
|
|
88
|
+
process.stdout.write(`Built ${list.length} migrations\n`);
|
|
@@ -9,9 +9,13 @@ function entry(name) {
|
|
|
9
9
|
return existsSync(source) ? source : null;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
const args = process.argv.slice(2);
|
|
12
13
|
// The auth module adds src/migrate.ts, which applies the Better Auth schema
|
|
13
|
-
// before it runs the application migrations.
|
|
14
|
-
|
|
14
|
+
// before it runs the application migrations. --list needs neither the
|
|
15
|
+
// database nor auth, so it uses the application migrator.
|
|
16
|
+
const target = args.includes("--list")
|
|
17
|
+
? entry("database/migrate")
|
|
18
|
+
: entry("migrate") ?? entry("database/migrate");
|
|
15
19
|
if (!target) {
|
|
16
20
|
process.stderr.write("Run database migrations failed: no migration entry in src or dist\n");
|
|
17
21
|
process.exit(1);
|
|
@@ -19,7 +23,7 @@ if (!target) {
|
|
|
19
23
|
// Run in the API directory wherever this was started from: Bun reads decorator
|
|
20
24
|
// settings from the tsconfig.json of the working directory, and the data source
|
|
21
25
|
// finds .env and the migrations relative to it.
|
|
22
|
-
const subprocess = Bun.spawn([process.execPath, fileURLToPath(target)], {
|
|
26
|
+
const subprocess = Bun.spawn([process.execPath, fileURLToPath(target), ...args], {
|
|
23
27
|
cwd: fileURLToPath(new URL("..", import.meta.url)),
|
|
24
28
|
stdin: "inherit",
|
|
25
29
|
stdout: "inherit",
|
|
@@ -4,9 +4,11 @@ import { createApp } from "../src/app";
|
|
|
4
4
|
import { validateEnv } from "../src/config/env.validation";
|
|
5
5
|
import {
|
|
6
6
|
assertApiContract,
|
|
7
|
+
assertApplicationRoutes,
|
|
7
8
|
assertConsistentRouteParameters,
|
|
8
9
|
documentedApiRoutes,
|
|
9
10
|
} from "../src/core/api-contract";
|
|
11
|
+
import { APP_DOCUMENTED_ROUTES } from "../src/core/api-contract.extensions";
|
|
10
12
|
import { createCoreServices } from "../src/core/services";
|
|
11
13
|
|
|
12
14
|
interface ProjectManifest {
|
|
@@ -29,8 +31,10 @@ try {
|
|
|
29
31
|
const document: unknown = await response.json();
|
|
30
32
|
const modules = manifest.modules.map((module) => module.name);
|
|
31
33
|
assertApiContract(document, manifest.template, modules);
|
|
34
|
+
assertApplicationRoutes(document, app.routes, APP_DOCUMENTED_ROUTES);
|
|
32
35
|
process.stdout.write(
|
|
33
|
-
`Verified ${documentedApiRoutes(document).size} documented API operations for ${manifest.template}
|
|
36
|
+
`Verified ${documentedApiRoutes(document).size} documented API operations for ${manifest.template}, ` +
|
|
37
|
+
`including ${APP_DOCUMENTED_ROUTES.length} application operations.\n`,
|
|
34
38
|
);
|
|
35
39
|
} finally {
|
|
36
40
|
await services.close();
|
|
@@ -12,8 +12,11 @@ export const extensionModules: PodokitModule[] = [];
|
|
|
12
12
|
|
|
13
13
|
// Bun server options such as a larger request body limit for uploads, for
|
|
14
14
|
// example { maxRequestBodySize: 1024 ** 3, idleTimeout: 120 }. Values set here
|
|
15
|
-
// win over API_MAX_REQUEST_BODY_SIZE; a function receives
|
|
16
|
-
// environment instead: (env) => ({ maxRequestBodySize: ... }). A
|
|
17
|
-
// idleTimeout also needs
|
|
18
|
-
// waiting for response headers after 30 seconds by default
|
|
15
|
+
// win over API_MAX_REQUEST_BODY_SIZE and API_IDLE_TIMEOUT; a function receives
|
|
16
|
+
// the validated environment instead: (env) => ({ maxRequestBodySize: ... }). A
|
|
17
|
+
// longer idleTimeout also needs a longer proxy deadline on the web server, which
|
|
18
|
+
// stops waiting for response headers after 30 seconds by default:
|
|
19
|
+
// BACKEND_PROXY_TIMEOUT_MS, or one route's entry in APP_PROXY_TIMEOUTS
|
|
20
|
+
// (apps/web/src/lib/server/proxy-timeouts.ts), and the web server's
|
|
21
|
+
// CONNECTION_IDLE_TIMEOUT (35 seconds).
|
|
19
22
|
export const apiServerOptions: ApiServerOptionsSource = {};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it, mock, type Mock } from "bun:test";
|
|
2
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
3
|
import {
|
|
3
4
|
cachedOpenApiDocument,
|
|
5
|
+
renderError,
|
|
4
6
|
setRetryAfter,
|
|
5
7
|
UNAVAILABLE_RETRY_AFTER_SECONDS,
|
|
6
8
|
type OpenApiDocumentLoader,
|
|
@@ -98,3 +100,23 @@ describe("setRetryAfter", () => {
|
|
|
98
100
|
expect(headers).toEqual({ "Retry-After": "7" });
|
|
99
101
|
});
|
|
100
102
|
});
|
|
103
|
+
|
|
104
|
+
describe("renderError", () => {
|
|
105
|
+
it("puts an AppException's details in the envelope", () => {
|
|
106
|
+
const set = { headers: {} as Record<string, string | number>, status: undefined as number | string | undefined };
|
|
107
|
+
const details = { fields: [{ path: "email", code: "taken" }] };
|
|
108
|
+
const body = renderError("UNKNOWN", new AppException("SIGNUP_INVALID", "Invalid sign-up", 422, details), "/signup", set);
|
|
109
|
+
expect(set.status).toBe(422);
|
|
110
|
+
expect(body.error).toMatchObject({ code: "SIGNUP_INVALID", statusCode: 422, path: "/signup", details });
|
|
111
|
+
expect(renderError("UNKNOWN", new AppException("GONE", "Gone", 410), "/x", set).error).not.toHaveProperty("details");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("hides the message of an unexpected error", () => {
|
|
115
|
+
const set = { headers: {} as Record<string, string | number>, status: undefined as number | string | undefined };
|
|
116
|
+
expect(renderError("UNKNOWN", new Error("secret detail"), "/x", set).error).toMatchObject({
|
|
117
|
+
code: "INTERNAL_ERROR",
|
|
118
|
+
message: "Internal server error",
|
|
119
|
+
statusCode: 500,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -4,6 +4,9 @@ import { AppException, type ErrorEnvelope } from "@podosoft/podokit-contracts";
|
|
|
4
4
|
import { Elysia } from "elysia";
|
|
5
5
|
import { configureServices, extensionModules } from "./app.extensions";
|
|
6
6
|
import { validateEnv, type AppEnv } from "./config/env.validation";
|
|
7
|
+
import { assertSelectedProvidersInstalled } from "./config/provider-selection";
|
|
8
|
+
import { PROVIDERS } from "./config/providers";
|
|
9
|
+
import { stampClientAddress } from "./core/client-address";
|
|
7
10
|
import {
|
|
8
11
|
appContext,
|
|
9
12
|
createCoreServices,
|
|
@@ -124,6 +127,43 @@ function errorEnvelope(
|
|
|
124
127
|
};
|
|
125
128
|
}
|
|
126
129
|
|
|
130
|
+
export interface ErrorResponseState {
|
|
131
|
+
status?: number | string;
|
|
132
|
+
headers: Record<string, string | number>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Renders a failed request as the standard error envelope: an AppException with
|
|
137
|
+
* its code, status, and details; an unreachable database as 503; Elysia's
|
|
138
|
+
* validation and routing errors as 400 and 404; anything else as 500 without
|
|
139
|
+
* its message.
|
|
140
|
+
*/
|
|
141
|
+
export function renderError(code: unknown, error: unknown, path: string, set: ErrorResponseState): ErrorEnvelope {
|
|
142
|
+
if (error instanceof AppException) {
|
|
143
|
+
set.status = error.statusCode;
|
|
144
|
+
if (error.statusCode === 503) setRetryAfter(set.headers);
|
|
145
|
+
return errorEnvelope(error.code, error.message, error.statusCode, path, error.details);
|
|
146
|
+
}
|
|
147
|
+
if (isDatabaseUnavailable(error)) {
|
|
148
|
+
set.status = 503;
|
|
149
|
+
setRetryAfter(set.headers);
|
|
150
|
+
return errorEnvelope("DATABASE_UNAVAILABLE", "The database is temporarily unavailable", 503, path);
|
|
151
|
+
}
|
|
152
|
+
const statusCode = code === "NOT_FOUND" ? 404 : code === "VALIDATION" ? 400 : 500;
|
|
153
|
+
set.status = statusCode;
|
|
154
|
+
const publicCode = code === "NOT_FOUND"
|
|
155
|
+
? "NOT_FOUND"
|
|
156
|
+
: code === "VALIDATION"
|
|
157
|
+
? "VALIDATION_ERROR"
|
|
158
|
+
: "INTERNAL_ERROR";
|
|
159
|
+
const message = statusCode === 500
|
|
160
|
+
? "Internal server error"
|
|
161
|
+
: error instanceof Error
|
|
162
|
+
? error.message
|
|
163
|
+
: "Request failed";
|
|
164
|
+
return errorEnvelope(publicCode, message, statusCode, path);
|
|
165
|
+
}
|
|
166
|
+
|
|
127
167
|
const buildApp = (options: CreateAppOptions = {}) => {
|
|
128
168
|
const env = options.env ?? validateEnv(process.env);
|
|
129
169
|
const services = options.services ?? createCoreServices(env);
|
|
@@ -134,9 +174,11 @@ const buildApp = (options: CreateAppOptions = {}) => {
|
|
|
134
174
|
];
|
|
135
175
|
for (const module of modules) module.configure?.(env, services);
|
|
136
176
|
configureServices(services);
|
|
177
|
+
assertSelectedProvidersInstalled(services, PROVIDERS);
|
|
137
178
|
services.freeze();
|
|
138
179
|
const context = appContext(env, services);
|
|
139
180
|
const corsOrigin = env.CORS_ORIGIN?.split(",").map((origin) => origin.trim());
|
|
181
|
+
const clientAddressConfig = { trustedProxyHops: env.TRUSTED_PROXY_HOPS, header: env.TRUSTED_PROXY_HEADER };
|
|
140
182
|
|
|
141
183
|
const app = new Elysia({ name: "podokit" })
|
|
142
184
|
.use(cors({ origin: corsOrigin ?? true, credentials: true }))
|
|
@@ -157,11 +199,12 @@ const buildApp = (options: CreateAppOptions = {}) => {
|
|
|
157
199
|
.onRequest(async ({ request, server, set }) => {
|
|
158
200
|
const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
|
|
159
201
|
set.headers["x-request-id"] = requestId;
|
|
202
|
+
const remoteAddress = server?.requestIP(request)?.address;
|
|
203
|
+
const clientAddress = stampClientAddress(request, remoteAddress, clientAddressConfig);
|
|
160
204
|
await context.requestGuards.run({
|
|
161
205
|
request,
|
|
162
|
-
...(
|
|
163
|
-
|
|
164
|
-
: {}),
|
|
206
|
+
...(remoteAddress ? { remoteAddress } : {}),
|
|
207
|
+
...(clientAddress ? { clientAddress } : {}),
|
|
165
208
|
setHeader: (name, value) => { set.headers[name] = value; },
|
|
166
209
|
});
|
|
167
210
|
})
|
|
@@ -171,31 +214,7 @@ const buildApp = (options: CreateAppOptions = {}) => {
|
|
|
171
214
|
"Complete request",
|
|
172
215
|
);
|
|
173
216
|
})
|
|
174
|
-
.onError(({ code, error, path, set }) =>
|
|
175
|
-
if (error instanceof AppException) {
|
|
176
|
-
set.status = error.statusCode;
|
|
177
|
-
if (error.statusCode === 503) setRetryAfter(set.headers);
|
|
178
|
-
return errorEnvelope(error.code, error.message, error.statusCode, path);
|
|
179
|
-
}
|
|
180
|
-
if (isDatabaseUnavailable(error)) {
|
|
181
|
-
set.status = 503;
|
|
182
|
-
setRetryAfter(set.headers);
|
|
183
|
-
return errorEnvelope("DATABASE_UNAVAILABLE", "The database is temporarily unavailable", 503, path);
|
|
184
|
-
}
|
|
185
|
-
const statusCode = code === "NOT_FOUND" ? 404 : code === "VALIDATION" ? 400 : 500;
|
|
186
|
-
set.status = statusCode;
|
|
187
|
-
const publicCode = code === "NOT_FOUND"
|
|
188
|
-
? "NOT_FOUND"
|
|
189
|
-
: code === "VALIDATION"
|
|
190
|
-
? "VALIDATION_ERROR"
|
|
191
|
-
: "INTERNAL_ERROR";
|
|
192
|
-
const message = statusCode === 500
|
|
193
|
-
? "Internal server error"
|
|
194
|
-
: error instanceof Error
|
|
195
|
-
? error.message
|
|
196
|
-
: "Request failed";
|
|
197
|
-
return errorEnvelope(publicCode, message, statusCode, path);
|
|
198
|
-
});
|
|
217
|
+
.onError(({ code, error, path, set }) => renderError(code, error, path, set));
|
|
199
218
|
|
|
200
219
|
const plugins: AppPlugin[] = [
|
|
201
220
|
healthPlugin,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const BUILD = join(__dirname, "../scripts/build.mjs");
|
|
7
|
+
const EXTENSIONS = join(__dirname, "../scripts/build.extensions.mjs");
|
|
8
|
+
|
|
9
|
+
/** Builds an API layout made of `files` with the app's build script. */
|
|
10
|
+
async function build(files: Record<string, string>): Promise<{ root: string; code: number; stderr: string }> {
|
|
11
|
+
const root = mkdtempSync(join(tmpdir(), "api-build-"));
|
|
12
|
+
mkdirSync(join(root, "scripts"));
|
|
13
|
+
cpSync(BUILD, join(root, "scripts/build.mjs"));
|
|
14
|
+
cpSync(EXTENSIONS, join(root, "scripts/build.extensions.mjs"));
|
|
15
|
+
for (const [path, content] of Object.entries(files)) {
|
|
16
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
17
|
+
writeFileSync(join(root, path), content);
|
|
18
|
+
}
|
|
19
|
+
const child = Bun.spawn([process.execPath, "scripts/build.mjs"], { cwd: root, stdout: "pipe", stderr: "pipe" });
|
|
20
|
+
const [stderr, code] = await Promise.all([new Response(child.stderr).text(), child.exited]);
|
|
21
|
+
return { root, code, stderr };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function run(root: string, entry: string, env: Record<string, string>): Promise<string> {
|
|
25
|
+
const child = Bun.spawn([process.execPath, entry], { cwd: root, env: { ...process.env, ...env }, stdout: "pipe" });
|
|
26
|
+
const [stdout] = await Promise.all([new Response(child.stdout).text(), child.exited]);
|
|
27
|
+
return stdout.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const migration = (name: string, body = ""): string =>
|
|
31
|
+
`${body}export class ${name} { name = "${name}"; async up() {} async down() {} }\n`;
|
|
32
|
+
|
|
33
|
+
describe("API build", () => {
|
|
34
|
+
it("builds every migration flat into dist/migrations and lists it with its name", async () => {
|
|
35
|
+
const { root, code, stderr } = await build({
|
|
36
|
+
"src/config/table.ts": 'export const TABLE = "example";\n',
|
|
37
|
+
"src/migrations/1700000000002-AddIndex.ts": migration(
|
|
38
|
+
"AddIndex1700000000002",
|
|
39
|
+
'import { TABLE } from "../config/table";\nvoid TABLE;\n',
|
|
40
|
+
),
|
|
41
|
+
"src/migrations/1700000000001-CreateExample.ts": migration("CreateExample1700000000001"),
|
|
42
|
+
"dist/migrations/1690000000000-Removed.js": migration("Removed1690000000000"),
|
|
43
|
+
});
|
|
44
|
+
try {
|
|
45
|
+
expect({ code, stderr }).toEqual({ code: 0, stderr: "" });
|
|
46
|
+
expect(readdirSync(join(root, "dist/migrations"), { recursive: true }).map(String).sort()).toEqual([
|
|
47
|
+
"1700000000001-CreateExample.js",
|
|
48
|
+
"1700000000002-AddIndex.js",
|
|
49
|
+
"migrations.json",
|
|
50
|
+
]);
|
|
51
|
+
expect(JSON.parse(readFileSync(join(root, "dist/migrations/migrations.json"), "utf8"))).toEqual([
|
|
52
|
+
{ file: "1700000000001-CreateExample.js", name: "CreateExample1700000000001" },
|
|
53
|
+
{ file: "1700000000002-AddIndex.js", name: "AddIndex1700000000002" },
|
|
54
|
+
]);
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(root, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("writes an empty list for an app without migrations", async () => {
|
|
61
|
+
const { root, code } = await build({ "src/main.ts": "export {};\n" });
|
|
62
|
+
try {
|
|
63
|
+
expect(code).toBe(0);
|
|
64
|
+
expect(readFileSync(join(root, "dist/migrations/migrations.json"), "utf8")).toBe("[]\n");
|
|
65
|
+
} finally {
|
|
66
|
+
rmSync(root, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("fails on a migration file without exactly one migration class", async () => {
|
|
71
|
+
const { root, code, stderr } = await build({
|
|
72
|
+
"src/migrations/1700000000001-Two.ts": `${migration("A1700000000001")}${migration("B1700000000001")}`,
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
expect(code).toBe(1);
|
|
76
|
+
expect(stderr).toContain("1700000000001-Two.js must export exactly one migration class");
|
|
77
|
+
} finally {
|
|
78
|
+
rmSync(root, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("leaves NODE_ENV to the environment the bundle runs in", async () => {
|
|
83
|
+
const { root, code, stderr } = await build({ "src/main.ts": "console.log(process.env.NODE_ENV);\n" });
|
|
84
|
+
try {
|
|
85
|
+
expect({ code, stderr }).toEqual({ code: 0, stderr: "" });
|
|
86
|
+
expect(await run(root, "dist/main.js", { NODE_ENV: "production" })).toBe("production");
|
|
87
|
+
expect(await run(root, "dist/main.js", { NODE_ENV: "test" })).toBe("test");
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(root, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("bundles the application's entry points from build.extensions.mjs", async () => {
|
|
94
|
+
const { root, code, stderr } = await build({
|
|
95
|
+
"scripts/build.extensions.mjs":
|
|
96
|
+
'export const appBuildTargets = [{ entrypoint: "scripts/repair.ts", outdir: "dist/scripts" }];\n',
|
|
97
|
+
"scripts/repair.ts": 'console.log("repaired", process.env.NODE_ENV);\n',
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
expect({ code, stderr }).toEqual({ code: 0, stderr: "" });
|
|
101
|
+
expect(await run(root, "dist/scripts/repair.js", { NODE_ENV: "production" })).toBe("repaired production");
|
|
102
|
+
} finally {
|
|
103
|
+
rmSync(root, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("fails on an application entry point that does not exist", async () => {
|
|
108
|
+
const { root, code, stderr } = await build({
|
|
109
|
+
"scripts/build.extensions.mjs": 'export const appBuildTargets = [{ entrypoint: "scripts/gone.ts", outdir: "dist" }];\n',
|
|
110
|
+
});
|
|
111
|
+
try {
|
|
112
|
+
expect(code).toBe(1);
|
|
113
|
+
expect(stderr).toContain("scripts/gone.ts from build.extensions.mjs does not exist");
|
|
114
|
+
} finally {
|
|
115
|
+
rmSync(root, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { AppEnv } from "./env.validation";
|
|
3
|
+
|
|
4
|
+
// This owned file is never overwritten by `podo update`. Declare the
|
|
5
|
+
// application's environment variables here: they are validated with PodoKit's
|
|
6
|
+
// at startup, fail it with the same message when invalid, and are typed on
|
|
7
|
+
// `AppEnv`. A name PodoKit already defines is rejected. For example:
|
|
8
|
+
//
|
|
9
|
+
// import { z } from "zod";
|
|
10
|
+
// export const appEnvShape = {
|
|
11
|
+
// REPORTS_BUCKET: z.string().min(1),
|
|
12
|
+
// SYNC_INTERVAL_SECONDS: z.coerce.number().int().positive().default(60),
|
|
13
|
+
// } satisfies z.ZodRawShape;
|
|
14
|
+
export const appEnvShape = {} satisfies z.ZodRawShape;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Checks across variables, such as one that production requires. Report a
|
|
18
|
+
* problem with `context.addIssue({ code: "custom", path: ["NAME"], message })`.
|
|
19
|
+
*/
|
|
20
|
+
export function refineAppEnv(_env: AppEnv, _context: z.RefinementCtx): void {}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { validateEnv } from "./env.validation";
|
|
5
|
+
|
|
6
|
+
describe("environment validation", () => {
|
|
7
|
+
it("listens on every address and keeps SQLite under data/ by default", () => {
|
|
8
|
+
expect(validateEnv({})).toMatchObject({ HOST: "0.0.0.0", SQLITE_PATH: "./data/podokit.sqlite" });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("reads HOST and SQLITE_PATH", () => {
|
|
12
|
+
expect(validateEnv({ HOST: " 127.0.0.1 ", SQLITE_PATH: "/var/lib/app/app.sqlite" }))
|
|
13
|
+
.toMatchObject({ HOST: "127.0.0.1", SQLITE_PATH: "/var/lib/app/app.sqlite" });
|
|
14
|
+
expect(() => validateEnv({ HOST: " " })).toThrow("Invalid environment");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** Runs `script` next to a copy of env.validation.ts and an env.extensions.ts of the test's own. */
|
|
19
|
+
async function withExtensions(extensions: string, script: string): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
20
|
+
// Inside the API directory, so the copies resolve zod like the originals.
|
|
21
|
+
const root = mkdtempSync(join(__dirname, ".env-extensions-"));
|
|
22
|
+
try {
|
|
23
|
+
copyFileSync(join(__dirname, "env.validation.ts"), join(root, "env.validation.ts"));
|
|
24
|
+
writeFileSync(join(root, "env.extensions.ts"), extensions);
|
|
25
|
+
writeFileSync(join(root, "check.ts"), script);
|
|
26
|
+
const child = Bun.spawn([process.execPath, join(root, "check.ts")], { stdout: "pipe", stderr: "pipe" });
|
|
27
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
28
|
+
new Response(child.stdout).text(),
|
|
29
|
+
new Response(child.stderr).text(),
|
|
30
|
+
child.exited,
|
|
31
|
+
]);
|
|
32
|
+
return { code, stdout: stdout.trim(), stderr };
|
|
33
|
+
} finally {
|
|
34
|
+
rmSync(root, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("application environment", () => {
|
|
39
|
+
const extensions = [
|
|
40
|
+
'import { z } from "zod";',
|
|
41
|
+
'import type { AppEnv } from "./env.validation";',
|
|
42
|
+
"export const appEnvShape = {",
|
|
43
|
+
" REPORTS_BUCKET: z.string().min(1),",
|
|
44
|
+
" SYNC_INTERVAL_SECONDS: z.coerce.number().int().positive().default(60),",
|
|
45
|
+
"};",
|
|
46
|
+
"export function refineAppEnv(env: AppEnv, context: z.RefinementCtx): void {",
|
|
47
|
+
' if (env.NODE_ENV === "production" && env.REPORTS_BUCKET === "dev") {',
|
|
48
|
+
' context.addIssue({ code: "custom", path: ["REPORTS_BUCKET"], message: "use a production bucket" });',
|
|
49
|
+
" }",
|
|
50
|
+
"}",
|
|
51
|
+
"",
|
|
52
|
+
].join("\n");
|
|
53
|
+
|
|
54
|
+
it("validates and types the variables of env.extensions.ts with PodoKit's", async () => {
|
|
55
|
+
const result = await withExtensions(extensions, [
|
|
56
|
+
'import { validateEnv } from "./env.validation";',
|
|
57
|
+
'const env = validateEnv({ REPORTS_BUCKET: "reports" });',
|
|
58
|
+
"const interval: number = env.SYNC_INTERVAL_SECONDS;",
|
|
59
|
+
"console.log(JSON.stringify([env.REPORTS_BUCKET, interval, env.PORT]));",
|
|
60
|
+
"for (const input of [{}, { NODE_ENV: \"production\", REPORTS_BUCKET: \"dev\" }]) {",
|
|
61
|
+
" try { validateEnv(input); } catch (error) { console.log((error as Error).message.includes(\"REPORTS_BUCKET\")); }",
|
|
62
|
+
"}",
|
|
63
|
+
"",
|
|
64
|
+
].join("\n"));
|
|
65
|
+
expect(result).toMatchObject({ code: 0, stdout: '["reports",60,5002]\ntrue\ntrue' });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("rejects a variable PodoKit already defines", async () => {
|
|
69
|
+
const result = await withExtensions(
|
|
70
|
+
'import { z } from "zod";\nexport const appEnvShape = { PORT: z.string() };\nexport function refineAppEnv(): void {}\n',
|
|
71
|
+
'import "./env.validation";\n',
|
|
72
|
+
);
|
|
73
|
+
expect(result.code).toBe(1);
|
|
74
|
+
expect(result.stderr).toContain("env.extensions.ts redefines PORT");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { appEnvShape, refineAppEnv } from "./env.extensions";
|
|
2
3
|
|
|
3
4
|
// Schema-validated environment. Fails fast at boot if something is wrong.
|
|
4
|
-
|
|
5
|
+
// Application variables come from the owned env.extensions.ts.
|
|
6
|
+
const podokitShape = {
|
|
5
7
|
NODE_ENV: z.string().default("development"),
|
|
8
|
+
// The address the API listens on; 127.0.0.1 keeps it off the network.
|
|
9
|
+
HOST: z.string().trim().min(1).default("0.0.0.0"),
|
|
6
10
|
PORT: z.coerce.number().default(5002),
|
|
7
11
|
// Bytes one request body may hold; 128 MiB is Bun's default.
|
|
8
12
|
API_MAX_REQUEST_BODY_SIZE: z.coerce.number().int().positive().default(128 * 1024 * 1024),
|
|
13
|
+
// Seconds a connection may stay silent, 0 (never closed) to 255. Bun closes idle
|
|
14
|
+
// connections on a 4-second sweep, so 35 outlasts the web proxy's 30-second
|
|
15
|
+
// deadline. An empty value means unset rather than 0.
|
|
16
|
+
API_IDLE_TIMEOUT: z.preprocess(
|
|
17
|
+
(value) => (value === "" ? undefined : value),
|
|
18
|
+
z.coerce.number().int().min(0).max(255).default(35),
|
|
19
|
+
),
|
|
20
|
+
// Reverse proxies in front of the API whose forwarded header names the client;
|
|
21
|
+
// the generated web proxy is one. 0 uses the socket peer.
|
|
22
|
+
TRUSTED_PROXY_HOPS: z.coerce.number().int().nonnegative().default(0),
|
|
23
|
+
TRUSTED_PROXY_HEADER: z
|
|
24
|
+
.string()
|
|
25
|
+
.trim()
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.regex(/^[a-z0-9!#$%&'*+.^_`|~-]+$/, "must be an HTTP header name")
|
|
28
|
+
.default("x-forwarded-for"),
|
|
9
29
|
CORS_ORIGIN: z.string().optional(),
|
|
10
30
|
DATABASE_URL: z.string().optional(),
|
|
31
|
+
// The SQLite file when DATABASE_URL is unset, relative to the API directory.
|
|
32
|
+
SQLITE_PATH: z.string().trim().min(1).default("./data/podokit.sqlite"),
|
|
11
33
|
POSTGRES_HOST: z.string().default("localhost"),
|
|
12
34
|
POSTGRES_PORT: z.coerce.number().default(5432),
|
|
13
35
|
POSTGRES_USER: z.string().default("podokit"),
|
|
@@ -20,11 +42,32 @@ const schema = z.object({
|
|
|
20
42
|
DB_AUTH_POOL_MAX: z.coerce.number().int().positive().default(10),
|
|
21
43
|
READINESS_CHECK_TIMEOUT_MS: z.coerce.number().int().positive().default(800),
|
|
22
44
|
READINESS_CACHE_MS: z.coerce.number().int().nonnegative().default(2000),
|
|
23
|
-
}
|
|
45
|
+
} satisfies z.ZodRawShape;
|
|
24
46
|
|
|
25
|
-
|
|
47
|
+
const redefined = Object.keys(appEnvShape).filter((name) => name in podokitShape);
|
|
48
|
+
if (redefined.length > 0) {
|
|
49
|
+
throw new Error(`Invalid environment schema: env.extensions.ts redefines ${redefined.join(", ")}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const objectSchema = z.object({ ...podokitShape, ...appEnvShape });
|
|
53
|
+
|
|
54
|
+
export type AppEnv = z.infer<typeof objectSchema>;
|
|
55
|
+
|
|
56
|
+
const schema = objectSchema.superRefine((env, context) => refineAppEnv(env, context));
|
|
57
|
+
|
|
58
|
+
// Renamed settings fail loudly: falling back to the default would count and
|
|
59
|
+
// record every browser request under the web server's address.
|
|
60
|
+
const RENAMED: Record<string, string> = {
|
|
61
|
+
RATE_LIMIT_TRUSTED_PROXY_HOPS: "TRUSTED_PROXY_HOPS",
|
|
62
|
+
RATE_LIMIT_PROXY_HEADER: "TRUSTED_PROXY_HEADER",
|
|
63
|
+
};
|
|
26
64
|
|
|
27
65
|
export function validateEnv(config: Record<string, unknown>): AppEnv {
|
|
66
|
+
for (const [previous, current] of Object.entries(RENAMED)) {
|
|
67
|
+
if (config[previous] !== undefined) {
|
|
68
|
+
throw new Error(`Invalid environment: ${previous} was renamed to ${current}.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
28
71
|
const parsed = schema.safeParse(config);
|
|
29
72
|
if (!parsed.success) {
|
|
30
73
|
throw new Error(`Invalid environment:\n${parsed.error.toString()}`);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { CACHE, type ProviderSelections, type ServiceKey } from "@podosoft/podokit-runtime";
|
|
3
|
+
import {
|
|
4
|
+
assertSelectedProvidersInstalled,
|
|
5
|
+
providersFromEnvironment,
|
|
6
|
+
selectedFromEnvironment,
|
|
7
|
+
} from "./provider-selection";
|
|
8
|
+
|
|
9
|
+
const generated: ProviderSelections = {
|
|
10
|
+
database: "postgres",
|
|
11
|
+
cache: "redis",
|
|
12
|
+
"object-storage": "s3",
|
|
13
|
+
events: "redis",
|
|
14
|
+
jobs: "bullmq",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function registry(keys: symbol[]): { tryResolve<T>(key: ServiceKey<T>): T | undefined } {
|
|
18
|
+
return { tryResolve: <T>(key: ServiceKey<T>) => (keys.includes(key) ? ({} as T) : undefined) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("provider selection", () => {
|
|
22
|
+
it("keeps the generated providers without variables", () => {
|
|
23
|
+
expect(providersFromEnvironment(generated, {})).toEqual(generated);
|
|
24
|
+
expect(providersFromEnvironment(generated, { DATABASE_PROVIDER: " " })).toEqual(generated);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("replaces a capability whose variable is set", () => {
|
|
28
|
+
const env = { DATABASE_PROVIDER: "SQLite", CACHE_PROVIDER: "memory", JOBS_PROVIDER: "local" };
|
|
29
|
+
expect(providersFromEnvironment(generated, env)).toEqual({
|
|
30
|
+
...generated,
|
|
31
|
+
database: "sqlite",
|
|
32
|
+
cache: "memory",
|
|
33
|
+
jobs: "local",
|
|
34
|
+
});
|
|
35
|
+
expect(selectedFromEnvironment(env)).toEqual(["database", "cache", "jobs"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects an unknown provider name", () => {
|
|
39
|
+
expect(() => providersFromEnvironment(generated, { OBJECT_STORAGE_PROVIDER: "gcs" })).toThrow(
|
|
40
|
+
"OBJECT_STORAGE_PROVIDER must be one of s3, local",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("fails when a variable selects a provider without its module", () => {
|
|
45
|
+
const env = { CACHE_PROVIDER: "memory", DATABASE_PROVIDER: "sqlite" };
|
|
46
|
+
const providers = providersFromEnvironment(generated, env);
|
|
47
|
+
expect(() => assertSelectedProvidersInstalled(registry([]), providers, env)).toThrow(
|
|
48
|
+
"no installed module provides CACHE_PROVIDER=memory",
|
|
49
|
+
);
|
|
50
|
+
expect(() => assertSelectedProvidersInstalled(registry([CACHE]), providers, env)).not.toThrow();
|
|
51
|
+
expect(() => assertSelectedProvidersInstalled(registry([]), generated, {})).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
});
|