@podosoft/podokit 0.16.3 → 0.16.4

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.
@@ -30,6 +30,9 @@ export interface VerificationCheckProfile {
30
30
  expectedStatus: number;
31
31
  expectedJson?: Record<string, string | number | boolean | null>;
32
32
  }
33
+ export interface MigrationProfile {
34
+ command: string[];
35
+ }
33
36
  export interface DeployProfileV1 {
34
37
  schemaVersion: 1;
35
38
  driver: "kubernetes-helm";
@@ -67,6 +70,7 @@ export interface DeployProfileV1 {
67
70
  web: NamedSecretProfile | null;
68
71
  imagePull: string;
69
72
  };
73
+ migration?: MigrationProfile;
70
74
  runtimeConfig: Record<string, string>;
71
75
  verification: {
72
76
  baseUrl: string;
@@ -83,6 +83,20 @@ function parseRequiredKeys(value, field) {
83
83
  }
84
84
  return keys.sort();
85
85
  }
86
+ function parseCommand(value, field) {
87
+ if (!Array.isArray(value) || value.length === 0) {
88
+ throw new Error(`Deployment profile field "${field}" must be a non-empty string array.`);
89
+ }
90
+ return value.map((entry, index) => {
91
+ if (typeof entry !== "string" || entry.trim() === "") {
92
+ throw new Error(`Deployment profile field "${field}[${index}]" must be a non-empty string.`);
93
+ }
94
+ if (/[\u0000-\u001f\u007f]/.test(entry)) {
95
+ throw new Error(`Deployment profile field "${field}[${index}]" must not contain control characters.`);
96
+ }
97
+ return entry;
98
+ });
99
+ }
86
100
  function parseNamedSecret(value, field) {
87
101
  if (!isRecord(value)) {
88
102
  throw new Error(`Deployment profile field "${field}" must be an object.`);
@@ -221,6 +235,7 @@ function parseProfile(value) {
221
235
  "workloads",
222
236
  "dependencies",
223
237
  "secrets",
238
+ "migration",
224
239
  "runtimeConfig",
225
240
  "verification",
226
241
  ], "root");
@@ -236,6 +251,7 @@ function parseProfile(value) {
236
251
  const workloads = requiredRecord(value, "workloads");
237
252
  const dependencies = requiredRecord(value, "dependencies");
238
253
  const secrets = requiredRecord(value, "secrets");
254
+ const migration = value.migration === undefined ? undefined : requiredRecord(value, "migration");
239
255
  const verification = requiredRecord(value, "verification");
240
256
  const runtimeConfig = requiredRecord(value, "runtimeConfig");
241
257
  assertOnlyKeys(target, ["context", "clusterFingerprint", "namespace", "release", "createNamespace"], "target");
@@ -244,6 +260,8 @@ function parseProfile(value) {
244
260
  assertOnlyKeys(workloads, ["api", "web", "worker"], "workloads");
245
261
  assertOnlyKeys(dependencies, ["postgres", "redis", "objectStorage"], "dependencies");
246
262
  assertOnlyKeys(secrets, ["api", "web", "imagePull"], "secrets");
263
+ if (migration)
264
+ assertOnlyKeys(migration, ["command"], "migration");
247
265
  assertOnlyKeys(verification, ["baseUrl", "checks"], "verification");
248
266
  const context = requiredString(target, "context");
249
267
  const clusterFingerprint = requiredString(target, "clusterFingerprint");
@@ -402,6 +420,9 @@ function parseProfile(value) {
402
420
  : parseNamedSecret(secrets.web, "secrets.web"),
403
421
  imagePull: requiredString(secrets, "imagePull"),
404
422
  },
423
+ ...(migration
424
+ ? { migration: { command: parseCommand(migration.command, "migration.command") } }
425
+ : {}),
405
426
  runtimeConfig: parsedConfig,
406
427
  verification: { baseUrl, checks },
407
428
  };
@@ -8,6 +8,14 @@ const deploy_profile_1 = require("./deploy-profile");
8
8
  function quote(value) {
9
9
  return JSON.stringify(value);
10
10
  }
11
+ function yamlScalar(value) {
12
+ return /^(?!null$|true$|false$|~$)[A-Za-z_][A-Za-z0-9_./:-]*$/i.test(value)
13
+ ? value
14
+ : quote(value);
15
+ }
16
+ function yamlStringSequence(values) {
17
+ return `[${values.map((value) => yamlScalar(value)).join(", ")}]`;
18
+ }
11
19
  function digest(parts) {
12
20
  const hash = (0, node_crypto_1.createHash)("sha256");
13
21
  for (const part of parts)
@@ -571,6 +579,7 @@ function migrationRuntimeEnv(profile) {
571
579
  }
572
580
  function migrationManifest(profile, release, images) {
573
581
  const releaseSlug = release.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
582
+ const command = profile.migration?.command ?? ["npm", "run", "migrate:all"];
574
583
  return `apiVersion: batch/v1
575
584
  kind: Job
576
585
  metadata:
@@ -596,7 +605,7 @@ ${labels(`${profile.target.release}-migration`, 8)} spec:
596
605
  - name: migrate
597
606
  image: ${quote(images.api)}
598
607
  imagePullPolicy: IfNotPresent
599
- command: [npm, run, migrate:all]
608
+ command: ${yamlStringSequence(command)}
600
609
  env:
601
610
  ${migrationRuntimeEnv(profile)}
602
611
  envFrom:
@@ -8,7 +8,7 @@
8
8
  "build": "vite build",
9
9
  "preview": "vite preview",
10
10
  "lint": "svelte-check --tsconfig ./tsconfig.json",
11
- "test": "echo \"unit: add vitest; e2e: npm run test:e2e (see tests/)\" && exit 0"
11
+ "test": "vitest run"
12
12
  },
13
13
  "dependencies": {
14
14
  "@internationalized/date": "^3.12.2",
@@ -32,6 +32,7 @@
32
32
  "typesafe-i18n": "^5.26.2",
33
33
  "typescript": "^6.0.3",
34
34
  "vite": "^5.4.0",
35
+ "vitest": "^4.1.10",
35
36
  "tw-animate-css": "^1.4.0"
36
37
  }
37
38
  }
@@ -1,12 +1,12 @@
1
1
  import type { RequestEvent } from "@sveltejs/kit";
2
2
  import { createApiClient, type ApiClient } from "@podosoft/podokit-api-client";
3
- import { backendBaseUrl, normalizeClientIp } from "./backend-proxy";
3
+ import { backendBaseUrl, resolveClientIp } from "./backend-proxy";
4
4
 
5
5
  // Server-side client for load functions and hooks: points at the internal
6
6
  // backend URL and forwards the incoming request's cookies.
7
7
  export function serverApiClient(event: RequestEvent): ApiClient {
8
8
  const cookie = event.request.headers.get("cookie") ?? "";
9
- const clientIp = normalizeClientIp(event.getClientAddress());
9
+ const clientIp = resolveClientIp(event.getClientAddress);
10
10
  const fetchWithCookies: typeof fetch = (input, init) => {
11
11
  const headers = new Headers(init?.headers);
12
12
  if (cookie) headers.set("cookie", cookie);
@@ -0,0 +1,58 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { proxyRequest, resolveClientIp } from "./backend-proxy";
3
+
4
+ describe("backend proxy client address handling", () => {
5
+ afterEach(() => {
6
+ vi.unstubAllGlobals();
7
+ });
8
+
9
+ it("returns the normalized address from a trusted proxy chain", () => {
10
+ expect(resolveClientIp(() => "::ffff:203.0.113.9")).toBe("203.0.113.9");
11
+ });
12
+
13
+ it("omits the address when the proxy header is absent", () => {
14
+ expect(
15
+ resolveClientIp(() => {
16
+ throw new Error("address header is absent");
17
+ }),
18
+ ).toBeUndefined();
19
+ });
20
+
21
+ it("omits the address when the proxy chain is too short", () => {
22
+ expect(
23
+ resolveClientIp(() => {
24
+ throw new Error("configured depth exceeds address count");
25
+ }),
26
+ ).toBeUndefined();
27
+ });
28
+
29
+ it("does not trust an incoming forwarded header as a fallback", async () => {
30
+ let upstreamHeaders: Headers | undefined;
31
+ vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
32
+ upstreamHeaders = new Headers(init?.headers);
33
+ return new Response(null, { status: 204 });
34
+ });
35
+
36
+ const request = new Request("http://app.localhost/api/health", {
37
+ headers: { "x-forwarded-for": "198.51.100.25" },
38
+ });
39
+ const response = await proxyRequest(request, "http://api:5002/health");
40
+
41
+ expect(response.status).toBe(204);
42
+ expect(upstreamHeaders?.get("x-forwarded-for")).toBeNull();
43
+ });
44
+
45
+ it("forwards a resolved client address", async () => {
46
+ let upstreamHeaders: Headers | undefined;
47
+ vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
48
+ upstreamHeaders = new Headers(init?.headers);
49
+ return new Response(null, { status: 204 });
50
+ });
51
+
52
+ const request = new Request("http://app.localhost/api/health");
53
+ const response = await proxyRequest(request, "http://api:5002/health", "203.0.113.9");
54
+
55
+ expect(response.status).toBe(204);
56
+ expect(upstreamHeaders?.get("x-forwarded-for")).toBe("203.0.113.9");
57
+ });
58
+ });
@@ -19,6 +19,15 @@ export function normalizeClientIp(address: string | undefined): string | undefin
19
19
  return address;
20
20
  }
21
21
 
22
+ export function resolveClientIp(getClientAddress: () => string): string | undefined {
23
+ try {
24
+ return normalizeClientIp(getClientAddress());
25
+ } catch {
26
+ // An absent or shorter-than-configured proxy chain is not a trusted source.
27
+ return undefined;
28
+ }
29
+ }
30
+
22
31
  export async function proxyRequest(
23
32
  request: Request,
24
33
  targetUrl: string,
@@ -1,9 +1,13 @@
1
1
  import type { RequestHandler } from "@sveltejs/kit";
2
- import { backendBaseUrl, proxyRequest } from "$lib/server/backend-proxy";
2
+ import { backendBaseUrl, proxyRequest, resolveClientIp } from "$lib/server/backend-proxy";
3
3
 
4
4
  // Proxy the app's REST API, stripping the /api prefix (backend routes are at root).
5
5
  const handler: RequestHandler = ({ request, params, url, getClientAddress }) =>
6
- proxyRequest(request, `${backendBaseUrl()}/${params.path}${url.search}`, getClientAddress());
6
+ proxyRequest(
7
+ request,
8
+ `${backendBaseUrl()}/${params.path}${url.search}`,
9
+ resolveClientIp(getClientAddress),
10
+ );
7
11
 
8
12
  export const GET = handler;
9
13
  export const POST = handler;
@@ -1,9 +1,13 @@
1
1
  import type { RequestHandler } from "@sveltejs/kit";
2
- import { backendBaseUrl, proxyRequest } from "$lib/server/backend-proxy";
2
+ import { backendBaseUrl, proxyRequest, resolveClientIp } from "$lib/server/backend-proxy";
3
3
 
4
4
  // Proxy the better-auth handler, keeping the /api/auth prefix.
5
5
  const handler: RequestHandler = ({ request, params, url, getClientAddress }) =>
6
- proxyRequest(request, `${backendBaseUrl()}/api/auth/${params.all}${url.search}`, getClientAddress());
6
+ proxyRequest(
7
+ request,
8
+ `${backendBaseUrl()}/api/auth/${params.all}${url.search}`,
9
+ resolveClientIp(getClientAddress),
10
+ );
7
11
 
8
12
  export const GET = handler;
9
13
  export const POST = handler;
@@ -8,7 +8,7 @@
8
8
  "build": "vite build",
9
9
  "preview": "vite preview",
10
10
  "lint": "svelte-check --tsconfig ./tsconfig.json",
11
- "test": "echo \"unit: add vitest; e2e: npm run test:e2e (see tests/)\" && exit 0"
11
+ "test": "vitest run"
12
12
  },
13
13
  "dependencies": {
14
14
  "@internationalized/date": "^3.12.2",
@@ -32,6 +32,7 @@
32
32
  "typesafe-i18n": "^5.26.2",
33
33
  "typescript": "^6.0.3",
34
34
  "vite": "^5.4.0",
35
+ "vitest": "^4.1.10",
35
36
  "tw-animate-css": "^1.4.0"
36
37
  }
37
38
  }
@@ -1,12 +1,12 @@
1
1
  import type { RequestEvent } from "@sveltejs/kit";
2
2
  import { createApiClient, type ApiClient } from "@podosoft/podokit-api-client";
3
- import { backendBaseUrl, normalizeClientIp } from "./backend-proxy";
3
+ import { backendBaseUrl, resolveClientIp } from "./backend-proxy";
4
4
 
5
5
  // Server-side client for load functions and hooks: points at the internal
6
6
  // backend URL and forwards the incoming request's cookies.
7
7
  export function serverApiClient(event: RequestEvent): ApiClient {
8
8
  const cookie = event.request.headers.get("cookie") ?? "";
9
- const clientIp = normalizeClientIp(event.getClientAddress());
9
+ const clientIp = resolveClientIp(event.getClientAddress);
10
10
  const fetchWithCookies: typeof fetch = (input, init) => {
11
11
  const headers = new Headers(init?.headers);
12
12
  if (cookie) headers.set("cookie", cookie);
@@ -0,0 +1,58 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { proxyRequest, resolveClientIp } from "./backend-proxy";
3
+
4
+ describe("backend proxy client address handling", () => {
5
+ afterEach(() => {
6
+ vi.unstubAllGlobals();
7
+ });
8
+
9
+ it("returns the normalized address from a trusted proxy chain", () => {
10
+ expect(resolveClientIp(() => "::ffff:203.0.113.9")).toBe("203.0.113.9");
11
+ });
12
+
13
+ it("omits the address when the proxy header is absent", () => {
14
+ expect(
15
+ resolveClientIp(() => {
16
+ throw new Error("address header is absent");
17
+ }),
18
+ ).toBeUndefined();
19
+ });
20
+
21
+ it("omits the address when the proxy chain is too short", () => {
22
+ expect(
23
+ resolveClientIp(() => {
24
+ throw new Error("configured depth exceeds address count");
25
+ }),
26
+ ).toBeUndefined();
27
+ });
28
+
29
+ it("does not trust an incoming forwarded header as a fallback", async () => {
30
+ let upstreamHeaders: Headers | undefined;
31
+ vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
32
+ upstreamHeaders = new Headers(init?.headers);
33
+ return new Response(null, { status: 204 });
34
+ });
35
+
36
+ const request = new Request("http://app.localhost/api/health", {
37
+ headers: { "x-forwarded-for": "198.51.100.25" },
38
+ });
39
+ const response = await proxyRequest(request, "http://api:5002/health");
40
+
41
+ expect(response.status).toBe(204);
42
+ expect(upstreamHeaders?.get("x-forwarded-for")).toBeNull();
43
+ });
44
+
45
+ it("forwards a resolved client address", async () => {
46
+ let upstreamHeaders: Headers | undefined;
47
+ vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
48
+ upstreamHeaders = new Headers(init?.headers);
49
+ return new Response(null, { status: 204 });
50
+ });
51
+
52
+ const request = new Request("http://app.localhost/api/health");
53
+ const response = await proxyRequest(request, "http://api:5002/health", "203.0.113.9");
54
+
55
+ expect(response.status).toBe(204);
56
+ expect(upstreamHeaders?.get("x-forwarded-for")).toBe("203.0.113.9");
57
+ });
58
+ });
@@ -19,6 +19,15 @@ export function normalizeClientIp(address: string | undefined): string | undefin
19
19
  return address;
20
20
  }
21
21
 
22
+ export function resolveClientIp(getClientAddress: () => string): string | undefined {
23
+ try {
24
+ return normalizeClientIp(getClientAddress());
25
+ } catch {
26
+ // An absent or shorter-than-configured proxy chain is not a trusted source.
27
+ return undefined;
28
+ }
29
+ }
30
+
22
31
  export async function proxyRequest(
23
32
  request: Request,
24
33
  targetUrl: string,
@@ -1,9 +1,13 @@
1
1
  import type { RequestHandler } from "@sveltejs/kit";
2
- import { backendBaseUrl, proxyRequest } from "$lib/server/backend-proxy";
2
+ import { backendBaseUrl, proxyRequest, resolveClientIp } from "$lib/server/backend-proxy";
3
3
 
4
4
  // Proxy the app's REST API, stripping the /api prefix (backend routes are at root).
5
5
  const handler: RequestHandler = ({ request, params, url, getClientAddress }) =>
6
- proxyRequest(request, `${backendBaseUrl()}/${params.path}${url.search}`, getClientAddress());
6
+ proxyRequest(
7
+ request,
8
+ `${backendBaseUrl()}/${params.path}${url.search}`,
9
+ resolveClientIp(getClientAddress),
10
+ );
7
11
 
8
12
  export const GET = handler;
9
13
  export const POST = handler;
@@ -1,9 +1,13 @@
1
1
  import type { RequestHandler } from "@sveltejs/kit";
2
- import { backendBaseUrl, proxyRequest } from "$lib/server/backend-proxy";
2
+ import { backendBaseUrl, proxyRequest, resolveClientIp } from "$lib/server/backend-proxy";
3
3
 
4
4
  // Proxy the better-auth handler, keeping the /api/auth prefix.
5
5
  const handler: RequestHandler = ({ request, params, url, getClientAddress }) =>
6
- proxyRequest(request, `${backendBaseUrl()}/api/auth/${params.all}${url.search}`, getClientAddress());
6
+ proxyRequest(
7
+ request,
8
+ `${backendBaseUrl()}/api/auth/${params.all}${url.search}`,
9
+ resolveClientIp(getClientAddress),
10
+ );
7
11
 
8
12
  export const GET = handler;
9
13
  export const POST = handler;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "0.16.3",
3
+ "version": "0.16.4",
4
4
  "description": "An opinionated but extensible starter toolkit and CLI for full-stack TypeScript apps with NestJS, SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {