@podosoft/podokit 1.5.0 → 1.6.1
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 -18
- 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 +27 -21
- package/dist/deploy-schema.d.ts +60 -2
- package/dist/deploy-schema.js +141 -14
- 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 +74 -4
- package/dist/provider.js +10 -5
- 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 +30 -4
- 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 +7 -2
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +41 -25
- package/dist/templates/fullstack/apps/api/src/build.spec.ts +121 -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 +18 -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 +7 -3
- 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 +72 -4
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +78 -6
- package/dist/templates/fullstack/apps/api/src/core/services.ts +13 -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/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 +79 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +55 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +2 -1
- 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 +13 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
- package/dist/templates/fullstack/dot-env.docker.example +5 -2
- package/dist/templates/fullstack/dot-env.example +4 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +1 -0
- package/dist/templates/fullstack/tests/README.md +50 -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/helpers/seeded-admin.ts +15 -0
- package/dist/templates/fullstack/tests/package.json +3 -3
- package/dist/templates/fullstack/tests/playwright.config.ts +10 -4
- package/dist/templates/fullstack/tests/playwright.extensions.ts +149 -1
- package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
- package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
- 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/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 +53 -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 +11 -1
- 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/theme-toggle.svelte +1 -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/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 +3 -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 +41 -11
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
- 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 +14 -6
- 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.ui.spec.ts +55 -55
- 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 +8 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +35 -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.module.ts +5 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
- 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 +2 -0
- 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.ts +7 -5
- 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 +107 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +113 -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 +174 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +150 -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 +3 -6
- 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 +3 -6
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
- package/dist/templates/todo/AGENTS.md +5 -1
- 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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import type { MigrationInterface } from "typeorm";
|
|
6
|
+
import {
|
|
7
|
+
assertMigrationsLoaded,
|
|
8
|
+
loadMigrations,
|
|
9
|
+
MIGRATION_LIST,
|
|
10
|
+
migrationPaths,
|
|
11
|
+
migrationSet,
|
|
12
|
+
} from "./migration-files";
|
|
13
|
+
|
|
14
|
+
const roots: string[] = [];
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function apiRoot(files: Record<string, string>): string {
|
|
21
|
+
const root = mkdtempSync(join(tmpdir(), "migration-files-"));
|
|
22
|
+
roots.push(root);
|
|
23
|
+
for (const [path, content] of Object.entries(files)) {
|
|
24
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
25
|
+
writeFileSync(join(root, path), content);
|
|
26
|
+
}
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const migrationSource = (name: string): string =>
|
|
31
|
+
`export class ${name} { name = "${name}"; async up() {} async down() {} }\n`;
|
|
32
|
+
|
|
33
|
+
const loaded = (...names: string[]): MigrationInterface[] =>
|
|
34
|
+
names.map((name) => ({ name, up: async () => undefined, down: async () => undefined }));
|
|
35
|
+
|
|
36
|
+
describe("migration files", () => {
|
|
37
|
+
it("lists source migrations in timestamp order outside production", () => {
|
|
38
|
+
const root = apiRoot({
|
|
39
|
+
"src/migrations/1700000000002-Second.ts": migrationSource("Second1700000000002"),
|
|
40
|
+
"src/migrations/1700000000001-First.ts": migrationSource("First1700000000001"),
|
|
41
|
+
"src/migrations/helpers.ts": "export {};\n",
|
|
42
|
+
"dist/migrations/1690000000000-Stale.js": migrationSource("Stale1690000000000"),
|
|
43
|
+
});
|
|
44
|
+
const set = migrationSet({ root, production: false });
|
|
45
|
+
expect(set).toEqual({
|
|
46
|
+
directory: join(root, "src/migrations"),
|
|
47
|
+
compiled: false,
|
|
48
|
+
files: [{ file: "1700000000001-First.ts" }, { file: "1700000000002-Second.ts" }],
|
|
49
|
+
});
|
|
50
|
+
expect(migrationPaths(set)).toEqual([
|
|
51
|
+
join(root, "src/migrations/1700000000001-First.ts"),
|
|
52
|
+
join(root, "src/migrations/1700000000002-Second.ts"),
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("reads the build's list in production", () => {
|
|
57
|
+
const list = [{ file: "1700000000001-First.js", name: "First1700000000001" }];
|
|
58
|
+
const root = apiRoot({
|
|
59
|
+
[`dist/migrations/${MIGRATION_LIST}`]: JSON.stringify(list),
|
|
60
|
+
"dist/migrations/1700000000001-First.js": migrationSource("First1700000000001"),
|
|
61
|
+
});
|
|
62
|
+
expect(migrationSet({ root, production: true })).toEqual({
|
|
63
|
+
directory: join(root, "dist/migrations"),
|
|
64
|
+
compiled: true,
|
|
65
|
+
files: list,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("refuses a build output without its list instead of running no migrations", () => {
|
|
70
|
+
const root = apiRoot({ "dist/main.js": "", "dist/migrations/src/migrations/1700000000001-First.js": "" });
|
|
71
|
+
expect(() => migrationSet({ root, production: true })).toThrow(`${MIGRATION_LIST} is missing`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("refuses a list whose files are missing", () => {
|
|
75
|
+
const root = apiRoot({
|
|
76
|
+
[`dist/migrations/${MIGRATION_LIST}`]: JSON.stringify([{ file: "1700000000001-First.js", name: "First1700000000001" }]),
|
|
77
|
+
});
|
|
78
|
+
expect(() => migrationSet({ root, production: true })).toThrow("Compiled migrations are missing");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("runs source migrations when production code runs without a build", () => {
|
|
82
|
+
const root = apiRoot({ "src/migrations/1700000000001-First.ts": migrationSource("First1700000000001") });
|
|
83
|
+
expect(migrationSet({ root, production: true })).toMatchObject({ compiled: false, files: [{ file: "1700000000001-First.ts" }] });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("fails when a runner loaded fewer migrations than the set holds", () => {
|
|
87
|
+
const root = apiRoot({
|
|
88
|
+
"src/migrations/1700000000001-First.ts": migrationSource("First1700000000001"),
|
|
89
|
+
"src/migrations/1700000000002-Second.ts": migrationSource("Second1700000000002"),
|
|
90
|
+
});
|
|
91
|
+
const set = migrationSet({ root, production: false });
|
|
92
|
+
expect(() => assertMigrationsLoaded([], set)).toThrow("Loaded 0 of the 2 migrations");
|
|
93
|
+
expect(() => assertMigrationsLoaded(loaded("First1700000000001", "Second1700000000002"), set)).not.toThrow();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("fails when the build's listed names did not load", () => {
|
|
97
|
+
const set = {
|
|
98
|
+
directory: "dist/migrations",
|
|
99
|
+
compiled: true,
|
|
100
|
+
files: [{ file: "1700000000001-First.js", name: "First1700000000001" }],
|
|
101
|
+
};
|
|
102
|
+
expect(() => assertMigrationsLoaded(loaded("Other1700000000001"), set)).toThrow("did not load: First1700000000001");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("loads one migration class from each file", async () => {
|
|
106
|
+
const root = apiRoot({
|
|
107
|
+
"src/migrations/1700000000001-First.ts": migrationSource("First1700000000001"),
|
|
108
|
+
"src/migrations/1700000000002-Two.ts": `${migrationSource("A1700000000002")}${migrationSource("B1700000000002")}`,
|
|
109
|
+
});
|
|
110
|
+
const set = migrationSet({ root, production: false });
|
|
111
|
+
await expect(loadMigrations({ ...set, files: set.files.slice(0, 1) })).resolves.toMatchObject([
|
|
112
|
+
{ file: "1700000000001-First.ts", timestamp: 1700000000001, migration: { name: "First1700000000001" } },
|
|
113
|
+
]);
|
|
114
|
+
await expect(loadMigrations(set)).rejects.toThrow("must export exactly one migration class");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import type { MigrationInterface } from "typeorm";
|
|
5
|
+
|
|
6
|
+
/** The list `scripts/build.mjs` writes next to the compiled migrations. */
|
|
7
|
+
export const MIGRATION_LIST = "migrations.json";
|
|
8
|
+
|
|
9
|
+
const SOURCE_FILE = /^\d+-.+\.ts$/;
|
|
10
|
+
|
|
11
|
+
export interface MigrationFile {
|
|
12
|
+
file: string;
|
|
13
|
+
/** The migration's name, recorded by the build; unknown for source files until loaded. */
|
|
14
|
+
name?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MigrationSet {
|
|
18
|
+
directory: string;
|
|
19
|
+
/** True for the build output, which lists its migrations in `migrations.json`. */
|
|
20
|
+
compiled: boolean;
|
|
21
|
+
/** In timestamp order. */
|
|
22
|
+
files: MigrationFile[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MigrationSetOptions {
|
|
26
|
+
/** The API directory. Defaults to the working directory. */
|
|
27
|
+
root?: string;
|
|
28
|
+
/** Reads the build output. Defaults to NODE_ENV=production. */
|
|
29
|
+
production?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readList(path: string): MigrationFile[] {
|
|
33
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
34
|
+
if (
|
|
35
|
+
!Array.isArray(parsed) ||
|
|
36
|
+
!parsed.every((entry: unknown) =>
|
|
37
|
+
typeof entry === "object" && entry !== null &&
|
|
38
|
+
typeof (entry as Record<string, unknown>).file === "string" &&
|
|
39
|
+
typeof (entry as Record<string, unknown>).name === "string"
|
|
40
|
+
)
|
|
41
|
+
) {
|
|
42
|
+
throw new Error(`${path} must list { file, name } entries; rebuild the API`);
|
|
43
|
+
}
|
|
44
|
+
return parsed as MigrationFile[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The migrations this process runs. In production that is the build output's
|
|
49
|
+
* list; a build output without its list fails instead of running none.
|
|
50
|
+
* Otherwise it is every `<timestamp>-<name>.ts` file in `src/migrations`.
|
|
51
|
+
*/
|
|
52
|
+
export function migrationSet(options: MigrationSetOptions = {}): MigrationSet {
|
|
53
|
+
const root = options.root ?? process.cwd();
|
|
54
|
+
const production = options.production ?? process.env.NODE_ENV === "production";
|
|
55
|
+
const compiled = join(root, "dist", "migrations");
|
|
56
|
+
const source = join(root, "src", "migrations");
|
|
57
|
+
if (production && existsSync(join(root, "dist"))) {
|
|
58
|
+
const list = join(compiled, MIGRATION_LIST);
|
|
59
|
+
if (!existsSync(list)) {
|
|
60
|
+
throw new Error(`${list} is missing; build the API with scripts/build.mjs before running it`);
|
|
61
|
+
}
|
|
62
|
+
const files = readList(list);
|
|
63
|
+
const missing = files.filter((entry) => !existsSync(join(compiled, entry.file)));
|
|
64
|
+
if (missing.length > 0) {
|
|
65
|
+
throw new Error(`Compiled migrations are missing from ${compiled}: ${missing.map((entry) => entry.file).join(", ")}`);
|
|
66
|
+
}
|
|
67
|
+
return { directory: compiled, compiled: true, files };
|
|
68
|
+
}
|
|
69
|
+
const files = existsSync(source)
|
|
70
|
+
? readdirSync(source).filter((file) => SOURCE_FILE.test(file)).sort().map((file) => ({ file }))
|
|
71
|
+
: [];
|
|
72
|
+
return { directory: source, compiled: false, files };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function migrationPaths(set: MigrationSet): string[] {
|
|
76
|
+
return set.files.map((entry) => join(set.directory, entry.file));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type MigrationConstructor = new () => MigrationInterface;
|
|
80
|
+
|
|
81
|
+
function isMigration(value: unknown): value is MigrationConstructor {
|
|
82
|
+
if (typeof value !== "function") return false;
|
|
83
|
+
const prototype = (value as { prototype?: unknown }).prototype;
|
|
84
|
+
if (!prototype || typeof prototype !== "object") return false;
|
|
85
|
+
const record = prototype as Record<string, unknown>;
|
|
86
|
+
return typeof record.up === "function" && typeof record.down === "function";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** TypeORM's name for a migration: its `name` property, else its class name. */
|
|
90
|
+
export function migrationName(migration: MigrationInterface): string {
|
|
91
|
+
return migration.name || migration.constructor.name;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface LoadedMigration {
|
|
95
|
+
file: string;
|
|
96
|
+
timestamp: number;
|
|
97
|
+
migration: MigrationInterface;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Imports every file of the set; each must export exactly one migration class. */
|
|
101
|
+
export async function loadMigrations(set: MigrationSet): Promise<LoadedMigration[]> {
|
|
102
|
+
const loaded: LoadedMigration[] = [];
|
|
103
|
+
for (const entry of set.files) {
|
|
104
|
+
const module = await import(pathToFileURL(join(set.directory, entry.file)).href) as Record<string, unknown>;
|
|
105
|
+
const constructors = Object.values(module).filter(isMigration);
|
|
106
|
+
const Constructor = constructors[0];
|
|
107
|
+
if (constructors.length !== 1 || !Constructor) {
|
|
108
|
+
throw new Error(`Migration ${entry.file} must export exactly one migration class`);
|
|
109
|
+
}
|
|
110
|
+
const timestamp = Number(entry.file.slice(0, entry.file.indexOf("-")));
|
|
111
|
+
if (!Number.isSafeInteger(timestamp)) throw new Error(`Migration ${entry.file} has an invalid timestamp`);
|
|
112
|
+
loaded.push({ file: entry.file, timestamp, migration: new Constructor() });
|
|
113
|
+
}
|
|
114
|
+
return loaded;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Fails unless the migrations a runner loaded are the set: TypeORM loads
|
|
119
|
+
* nothing, without an error, from a path that matches no file.
|
|
120
|
+
*/
|
|
121
|
+
export function assertMigrationsLoaded(loaded: readonly MigrationInterface[], set: MigrationSet): void {
|
|
122
|
+
if (loaded.length !== set.files.length) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Loaded ${loaded.length} of the ${set.files.length} migrations in ${set.directory}; ` +
|
|
125
|
+
"each migration file must export exactly one migration class",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (!set.compiled) return;
|
|
129
|
+
const names = new Set(loaded.map(migrationName));
|
|
130
|
+
const absent = set.files.filter((entry) => entry.name && !names.has(entry.name));
|
|
131
|
+
if (absent.length > 0) {
|
|
132
|
+
throw new Error(`Migrations listed in ${MIGRATION_LIST} did not load: ${absent.map((entry) => entry.name).join(", ")}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import { pendingMigrationsCheck } from "./migration-readiness";
|
|
4
|
+
|
|
5
|
+
const set = {
|
|
6
|
+
directory: "dist/migrations",
|
|
7
|
+
compiled: true,
|
|
8
|
+
files: [
|
|
9
|
+
{ file: "1700000000001-First.js", name: "First1700000000001" },
|
|
10
|
+
{ file: "1700000000002-Second.js", name: "Second1700000000002" },
|
|
11
|
+
],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("pending migrations readiness", () => {
|
|
15
|
+
it("is down until every listed migration is applied, then stops querying", async () => {
|
|
16
|
+
const sql = new SQL(":memory:");
|
|
17
|
+
try {
|
|
18
|
+
const check = pendingMigrationsCheck(() => sql, set);
|
|
19
|
+
await expect(check()).rejects.toThrow();
|
|
20
|
+
await sql`CREATE TABLE "migrations" ("id" integer PRIMARY KEY, "timestamp" bigint, "name" text)`;
|
|
21
|
+
await sql`INSERT INTO "migrations" ("timestamp", "name") VALUES (1700000000001, 'First1700000000001')`;
|
|
22
|
+
await expect(check()).rejects.toThrow("1 migrations are pending; run migrate:all: Second1700000000002");
|
|
23
|
+
await sql`INSERT INTO "migrations" ("timestamp", "name") VALUES (1700000000002, 'Second1700000000002')`;
|
|
24
|
+
await expect(check()).resolves.toBeUndefined();
|
|
25
|
+
await sql`DROP TABLE "migrations"`;
|
|
26
|
+
await expect(check()).resolves.toBeUndefined();
|
|
27
|
+
} finally {
|
|
28
|
+
await sql.close();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("is up without a query when the build has no migrations", async () => {
|
|
33
|
+
const check = pendingMigrationsCheck(() => { throw new Error("no database"); }, { ...set, files: [] });
|
|
34
|
+
await expect(check()).resolves.toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { ReadinessCheck } from "../health/readiness.service";
|
|
3
|
+
import type { MigrationSet } from "./migration-files";
|
|
4
|
+
|
|
5
|
+
/** Reported down while the database lacks a migration of this build. */
|
|
6
|
+
export const MIGRATIONS_READINESS_CHECK = "migrations";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compares the migrations table with the build's list. Once every migration is
|
|
10
|
+
* applied the check stays up without a query: this process never runs one.
|
|
11
|
+
*/
|
|
12
|
+
export function pendingMigrationsCheck(sql: () => SQL, set: MigrationSet): ReadinessCheck {
|
|
13
|
+
const expected = set.files.flatMap((entry) => entry.name ? [entry.name] : []);
|
|
14
|
+
let applied = expected.length === 0;
|
|
15
|
+
return async (): Promise<void> => {
|
|
16
|
+
if (applied) return;
|
|
17
|
+
const rows = await sql()<Array<{ name: string }>>`SELECT "name" FROM "migrations"`;
|
|
18
|
+
const done = new Set(rows.map((row) => row.name));
|
|
19
|
+
const pending = expected.filter((name) => !done.has(name));
|
|
20
|
+
if (pending.length > 0) {
|
|
21
|
+
throw new Error(`${pending.length} migrations are pending; run migrate:all: ${pending.join(", ")}`);
|
|
22
|
+
}
|
|
23
|
+
applied = true;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -15,6 +15,8 @@ interface LaunchOptions {
|
|
|
15
15
|
/** Starts the launcher from the directory above the API, like a repository root. */
|
|
16
16
|
fromParent?: boolean;
|
|
17
17
|
files?: Record<string, string>;
|
|
18
|
+
/** Arguments after the launcher, such as --list. */
|
|
19
|
+
args?: string[];
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
/** Runs the migrate:all launcher in an API layout whose entries print their own path. */
|
|
@@ -32,7 +34,8 @@ async function launch(entries: string[], nodeEnv: string, body = "", options: La
|
|
|
32
34
|
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
33
35
|
writeFileSync(join(root, path), content);
|
|
34
36
|
}
|
|
35
|
-
const
|
|
37
|
+
const launcher = options.fromParent ? "api/scripts/run-migrations.mjs" : "scripts/run-migrations.mjs";
|
|
38
|
+
const child = Bun.spawn([process.execPath, launcher, ...(options.args ?? [])], {
|
|
36
39
|
cwd: options.fromParent ? parent : root,
|
|
37
40
|
env: { ...process.env, NODE_ENV: nodeEnv },
|
|
38
41
|
stdout: "pipe",
|
|
@@ -68,6 +71,14 @@ describe("migrate:all launcher", () => {
|
|
|
68
71
|
.toMatchObject({ code: 0, stdout: "dist/migrate.js" });
|
|
69
72
|
});
|
|
70
73
|
|
|
74
|
+
it("lists migrations through the application migrator, which needs no auth", async () => {
|
|
75
|
+
const body = "console.log(process.argv.slice(2).join(' '));\n";
|
|
76
|
+
expect(await launch(["src/migrate.ts", "src/database/migrate.ts"], "development", body, { args: ["--list"] }))
|
|
77
|
+
.toMatchObject({ code: 0, stdout: "src/database/migrate.ts\n--list" });
|
|
78
|
+
expect(await launch(["dist/migrate.js", "dist/database/migrate.js"], "production", body, { args: ["--list"] }))
|
|
79
|
+
.toMatchObject({ code: 0, stdout: "dist/database/migrate.js\n--list" });
|
|
80
|
+
});
|
|
81
|
+
|
|
71
82
|
it("exits with the migration's status", async () => {
|
|
72
83
|
const result = await launch(["src/database/migrate.ts"], "development", "process.exit(3);\n");
|
|
73
84
|
expect(result.code).toBe(3);
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { Database as SqliteDatabase } from "bun:sqlite";
|
|
2
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
5
2
|
import type { MigrationInterface, QueryRunner } from "typeorm";
|
|
3
|
+
import { loadMigrations, migrationSet } from "./migration-files";
|
|
6
4
|
|
|
7
5
|
export interface SqliteMigrationDefinition {
|
|
8
6
|
timestamp: number;
|
|
@@ -15,40 +13,8 @@ interface AppliedMigration {
|
|
|
15
13
|
name: string;
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
type MigrationConstructor = new () => MigrationInterface;
|
|
19
|
-
|
|
20
|
-
function isMigration(value: unknown): value is MigrationConstructor {
|
|
21
|
-
if (typeof value !== "function") return false;
|
|
22
|
-
const prototype = (value as { prototype?: unknown }).prototype;
|
|
23
|
-
if (!prototype || typeof prototype !== "object") return false;
|
|
24
|
-
const record = prototype as Record<string, unknown>;
|
|
25
|
-
return typeof record.up === "function" && typeof record.down === "function";
|
|
26
|
-
}
|
|
27
|
-
|
|
28
16
|
async function discoverMigrations(): Promise<SqliteMigrationDefinition[]> {
|
|
29
|
-
|
|
30
|
-
const source = join(process.cwd(), "src", "migrations");
|
|
31
|
-
const directory = process.env.NODE_ENV === "production" && existsSync(compiled)
|
|
32
|
-
? compiled
|
|
33
|
-
: source;
|
|
34
|
-
if (!existsSync(directory)) return [];
|
|
35
|
-
const files = readdirSync(directory)
|
|
36
|
-
.filter((file) => /^\d+-.+\.(?:js|ts)$/.test(file))
|
|
37
|
-
.sort();
|
|
38
|
-
const definitions: SqliteMigrationDefinition[] = [];
|
|
39
|
-
for (const file of files) {
|
|
40
|
-
const loaded = await import(pathToFileURL(join(directory, file)).href) as Record<string, unknown>;
|
|
41
|
-
const constructors = Object.values(loaded).filter(isMigration);
|
|
42
|
-
if (constructors.length !== 1) {
|
|
43
|
-
throw new Error(`SQLite migration ${file} must export exactly one migration class`);
|
|
44
|
-
}
|
|
45
|
-
const timestamp = Number(file.slice(0, file.indexOf("-")));
|
|
46
|
-
if (!Number.isSafeInteger(timestamp)) throw new Error(`SQLite migration ${file} has an invalid timestamp`);
|
|
47
|
-
const Constructor = constructors[0];
|
|
48
|
-
if (!Constructor) throw new Error(`SQLite migration ${file} has no migration class`);
|
|
49
|
-
definitions.push({ timestamp, migration: new Constructor() });
|
|
50
|
-
}
|
|
51
|
-
return definitions;
|
|
17
|
+
return loadMigrations(migrationSet());
|
|
52
18
|
}
|
|
53
19
|
|
|
54
20
|
function queryRunner(database: SqliteDatabase): QueryRunner {
|
|
@@ -10,8 +10,8 @@ const app = createApp({ env, services });
|
|
|
10
10
|
async function bootstrap(): Promise<void> {
|
|
11
11
|
try {
|
|
12
12
|
await services.start();
|
|
13
|
-
app.listen({ ...apiServerOptions(env), hostname:
|
|
14
|
-
process.stdout.write(`API listening on http
|
|
13
|
+
app.listen({ ...apiServerOptions(env), hostname: env.HOST, port: env.PORT });
|
|
14
|
+
process.stdout.write(`API listening on http://${env.HOST}:${env.PORT}\n`);
|
|
15
15
|
} catch (error) {
|
|
16
16
|
await services.close();
|
|
17
17
|
throw error;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Application-owned styles. The managed app.css imports this file last, so rules
|
|
3
|
+
* here come after PodoKit's theme variables and base styles. Add `@source` for
|
|
4
|
+
* packages whose built components use Tailwind classes, override variables in
|
|
5
|
+
* `:root` and `.dark`, or add `@theme` entries and base styles, for example:
|
|
6
|
+
*
|
|
7
|
+
* @source "../../../node_modules/@example/ui/dist";
|
|
8
|
+
* :root { --radius: 0.5rem; }
|
|
9
|
+
*
|
|
10
|
+
* PodoKit creates this file once and never overwrites it.
|
|
11
|
+
*/
|
|
@@ -4,4 +4,6 @@
|
|
|
4
4
|
import { ModeWatcher } from "mode-watcher";
|
|
5
5
|
</script>
|
|
6
6
|
|
|
7
|
-
<
|
|
7
|
+
<!-- `.light` on <html> in light mode lets component libraries that follow a `.dark`
|
|
8
|
+
or `.light` class, and fall back to the OS setting otherwise, match the theme. -->
|
|
9
|
+
<ModeWatcher lightClassNames={["light"]} />
|
|
@@ -9,6 +9,7 @@ type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
|
9
9
|
function requestEvent(fetch: Fetch): RequestEvent {
|
|
10
10
|
return {
|
|
11
11
|
request: new Request("http://app.localhost/services", { headers: { cookie: "session=abc" } }),
|
|
12
|
+
url: new URL("http://app.localhost/services"),
|
|
12
13
|
getClientAddress: () => "203.0.113.9",
|
|
13
14
|
fetch,
|
|
14
15
|
} as unknown as RequestEvent;
|
|
@@ -50,6 +51,24 @@ describe("serverApiClient", () => {
|
|
|
50
51
|
expect(headers?.get("x-forwarded-for")).toBe("203.0.113.9");
|
|
51
52
|
});
|
|
52
53
|
|
|
54
|
+
it("forwards the public origin only when CORS_ORIGIN lists it", async () => {
|
|
55
|
+
let headers: Headers | undefined;
|
|
56
|
+
const api = serverApiClient(requestEvent(async (_input, init) => {
|
|
57
|
+
headers = new Headers(init?.headers);
|
|
58
|
+
return Response.json({ ok: true });
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
vi.stubEnv("CORS_ORIGIN", "https://example.com, http://app.localhost");
|
|
62
|
+
await api.get("/site/settings");
|
|
63
|
+
expect(headers?.get("x-forwarded-host")).toBe("app.localhost");
|
|
64
|
+
expect(headers?.get("x-forwarded-proto")).toBe("http");
|
|
65
|
+
|
|
66
|
+
vi.stubEnv("CORS_ORIGIN", "https://example.com");
|
|
67
|
+
await api.get("/site/settings");
|
|
68
|
+
expect(headers?.has("x-forwarded-host")).toBe(false);
|
|
69
|
+
expect(headers?.has("x-forwarded-proto")).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
53
72
|
it("gives up on a call that outlasts BACKEND_TIMEOUT_MS", async () => {
|
|
54
73
|
vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
|
|
55
74
|
stubHealth(async () => new Response("ok"));
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
reportBackendAnswer,
|
|
9
9
|
reportBackendFailure,
|
|
10
10
|
resolveClientIp,
|
|
11
|
+
setForwardedOrigin,
|
|
11
12
|
} from "./backend-proxy";
|
|
12
13
|
|
|
13
14
|
/** Thrown without contacting the API while it is known to be unreachable. */
|
|
@@ -19,7 +20,8 @@ export class BackendUnavailableError extends Error {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
// Server-side client for load functions and hooks: points at the internal
|
|
22
|
-
// backend URL and forwards the incoming request's cookies
|
|
23
|
+
// backend URL and forwards the incoming request's cookies, client address, and
|
|
24
|
+
// public origin as the /api proxy does. Each call is bounded
|
|
23
25
|
// by BACKEND_TIMEOUT_MS, so a page renders its fallbacks instead of waiting for
|
|
24
26
|
// an API that does not answer.
|
|
25
27
|
export function serverApiClient(event: RequestEvent): ApiClient {
|
|
@@ -31,6 +33,7 @@ export function serverApiClient(event: RequestEvent): ApiClient {
|
|
|
31
33
|
const headers = new Headers(init?.headers);
|
|
32
34
|
if (cookie) headers.set("cookie", cookie);
|
|
33
35
|
if (clientIp) headers.set("x-forwarded-for", clientIp);
|
|
36
|
+
setForwardedOrigin(headers, event.url);
|
|
34
37
|
const outage = backendOutageSignal();
|
|
35
38
|
const signals = [AbortSignal.timeout(backendTimeoutMs()), outage];
|
|
36
39
|
if (init?.signal) signals.push(init.signal);
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
bodyLimitFor,
|
|
8
8
|
checkBackend,
|
|
9
9
|
checkIdleTimeout,
|
|
10
|
+
configuredPublicOrigins,
|
|
10
11
|
envMilliseconds,
|
|
11
12
|
IN_MEMORY_RESPONSE_BYTES,
|
|
12
13
|
proxyRequest,
|
|
@@ -16,12 +17,14 @@ import {
|
|
|
16
17
|
resolveClientIp,
|
|
17
18
|
sortedBodyLimits,
|
|
18
19
|
sortedProxyTimeouts,
|
|
20
|
+
upstreamFetchOptions,
|
|
19
21
|
withAppHeaders,
|
|
20
22
|
} from "./backend-proxy";
|
|
21
23
|
|
|
22
24
|
describe("backend proxy client address handling", () => {
|
|
23
25
|
afterEach(() => {
|
|
24
26
|
vi.unstubAllGlobals();
|
|
27
|
+
vi.unstubAllEnvs();
|
|
25
28
|
});
|
|
26
29
|
|
|
27
30
|
it("returns the normalized address from a trusted proxy chain", () => {
|
|
@@ -74,6 +77,48 @@ describe("backend proxy client address handling", () => {
|
|
|
74
77
|
expect(upstreamHeaders?.get("x-forwarded-for")).toBe("203.0.113.9");
|
|
75
78
|
});
|
|
76
79
|
|
|
80
|
+
it("forwards the public origin a request came in on when CORS_ORIGIN lists it", async () => {
|
|
81
|
+
let upstreamHeaders: Headers | undefined;
|
|
82
|
+
vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
83
|
+
upstreamHeaders = new Headers(init?.headers);
|
|
84
|
+
return new Response(null, { status: 204 });
|
|
85
|
+
});
|
|
86
|
+
vi.stubEnv("CORS_ORIGIN", "https://example.com,https://mirror.example.com");
|
|
87
|
+
|
|
88
|
+
// The adapter builds the request URL from the gateway's forwarded host and
|
|
89
|
+
// protocol; a caller's own forwarded headers never reach the API.
|
|
90
|
+
const request = new Request("https://mirror.example.com/api/mcp/info", {
|
|
91
|
+
headers: { "x-forwarded-host": "attacker.example", "x-forwarded-proto": "http" },
|
|
92
|
+
});
|
|
93
|
+
await proxyRequest(request, "http://api:5002/mcp/info");
|
|
94
|
+
|
|
95
|
+
expect(upstreamHeaders?.get("x-forwarded-host")).toBe("mirror.example.com");
|
|
96
|
+
expect(upstreamHeaders?.get("x-forwarded-proto")).toBe("https");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("forwards no origin that CORS_ORIGIN does not list", async () => {
|
|
100
|
+
let upstreamHeaders: Headers | undefined;
|
|
101
|
+
vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
102
|
+
upstreamHeaders = new Headers(init?.headers);
|
|
103
|
+
return new Response(null, { status: 204 });
|
|
104
|
+
});
|
|
105
|
+
vi.stubEnv("CORS_ORIGIN", "https://example.com");
|
|
106
|
+
|
|
107
|
+
const request = new Request("http://attacker.example/api/mcp/info", {
|
|
108
|
+
headers: { "x-forwarded-host": "example.com", "x-forwarded-proto": "https" },
|
|
109
|
+
});
|
|
110
|
+
await proxyRequest(request, "http://api:5002/mcp/info");
|
|
111
|
+
|
|
112
|
+
expect(upstreamHeaders?.has("x-forwarded-host")).toBe(false);
|
|
113
|
+
expect(upstreamHeaders?.has("x-forwarded-proto")).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("reads absolute CORS_ORIGIN entries as origins and skips patterns", () => {
|
|
117
|
+
expect([...configuredPublicOrigins(" https://example.com/ , *.example.com, not a url, http://localhost:5001")])
|
|
118
|
+
.toEqual(["https://example.com", "http://localhost:5001"]);
|
|
119
|
+
expect(configuredPublicOrigins("").size).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
77
122
|
// Not forwarding this does not leave the header unset: fetch substitutes its own
|
|
78
123
|
// default, so the API records "node" for every session and the account page shows
|
|
79
124
|
// that as the device for all of them.
|
|
@@ -886,6 +931,40 @@ describe("backend proxy application timeouts", () => {
|
|
|
886
931
|
expect(checks).toBeGreaterThanOrEqual(3);
|
|
887
932
|
});
|
|
888
933
|
|
|
934
|
+
it("turns off Bun's fetch idle timeout on a route without a deadline only", async () => {
|
|
935
|
+
const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/hosts/:hostId/file-transfers", timeoutMs: null }]);
|
|
936
|
+
const fetch = stubApi(async () => new Response("ok"), async () => Response.json({ done: true }));
|
|
937
|
+
|
|
938
|
+
await proxy(
|
|
939
|
+
new Request("http://app.localhost/api/hosts/h1/file-transfers/t1/commit", { method: "POST", body: "{}" }),
|
|
940
|
+
"http://api:5002/hosts/h1/file-transfers/t1/commit",
|
|
941
|
+
);
|
|
942
|
+
await proxy(new Request("http://app.localhost/api/hosts/h1/notes"), "http://api:5002/hosts/h1/notes");
|
|
943
|
+
|
|
944
|
+
const upstream = fetch.mock.calls.filter(([input]) => !String(input).endsWith("/health"));
|
|
945
|
+
const [commit, notes] = upstream.map(([, init]) => init as RequestInit & { timeout?: unknown });
|
|
946
|
+
expect(upstream).toHaveLength(2);
|
|
947
|
+
expect(commit?.timeout).toBe(false);
|
|
948
|
+
expect(notes).not.toHaveProperty("timeout");
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it("turns off Bun's fetch idle timeout for a deadline it would cut first", async () => {
|
|
952
|
+
expect(upstreamFetchOptions(null)).toEqual({ timeout: false });
|
|
953
|
+
expect(upstreamFetchOptions(600_000)).toEqual({ timeout: false });
|
|
954
|
+
expect(upstreamFetchOptions(300_000)).toEqual({ timeout: false });
|
|
955
|
+
expect(upstreamFetchOptions(299_999)).toEqual({});
|
|
956
|
+
expect(upstreamFetchOptions(30_000)).toEqual({});
|
|
957
|
+
|
|
958
|
+
vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "300000");
|
|
959
|
+
vi.stubEnv("CONNECTION_IDLE_TIMEOUT", "0");
|
|
960
|
+
const proxy = await proxyWithTimeouts([]);
|
|
961
|
+
const fetch = stubApi(async () => new Response("ok"), async () => Response.json({ done: true }));
|
|
962
|
+
|
|
963
|
+
await proxy(new Request("http://app.localhost/api/report"), "http://api:5002/report");
|
|
964
|
+
|
|
965
|
+
expect(fetch.mock.calls[0]?.[1]).toHaveProperty("timeout", false);
|
|
966
|
+
});
|
|
967
|
+
|
|
889
968
|
it("waits without a deadline on a route with a path parameter", async () => {
|
|
890
969
|
vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
|
|
891
970
|
const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/hosts/:hostId/file-transfers", timeoutMs: null }]);
|