@podosoft/podokit 0.10.0 → 0.10.2

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.
Files changed (29) hide show
  1. package/dist/templates/fullstack-nest-svelte/README.md +8 -0
  2. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +12 -8
  3. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +2 -2
  4. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +12 -8
  5. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/api.ts +3 -1
  6. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +1 -1
  7. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +4 -0
  8. package/dist/templates/fullstack-nest-svelte/infra/k3s/configmap.yaml +2 -0
  9. package/dist/templates/fullstack-nest-svelte/package.json +1 -1
  10. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +8 -0
  11. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +6 -5
  12. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +33 -12
  13. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +2 -2
  14. package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +2 -2
  15. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +22 -0
  16. package/dist/templates/modules/auth/module.manifest.json +5 -1
  17. package/dist/templates/modules/bullmq/files/infra/docker/worker.compose.example.yml +2 -1
  18. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +42 -3
  19. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +16 -7
  20. package/dist/templates/modules/rate-limit/module.manifest.json +7 -3
  21. package/dist/templates/todo/apps/api/Dockerfile +12 -8
  22. package/dist/templates/todo/apps/api/src/health/health.controller.ts +2 -2
  23. package/dist/templates/todo/apps/web/Dockerfile +12 -8
  24. package/dist/templates/todo/apps/web/src/lib/server/api.ts +3 -1
  25. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +1 -1
  26. package/dist/templates/todo/compose.dev.yaml +4 -0
  27. package/dist/templates/todo/infra/k3s/configmap.yaml +2 -0
  28. package/dist/templates/todo/package.json +1 -1
  29. package/package.json +1 -1
@@ -39,3 +39,11 @@ Want a worked example? Generate the `todo` template instead:
39
39
 
40
40
  Docker Compose in `infra/docker`; example k3s manifests in `infra/k3s`
41
41
  (use `secret.example.yaml` as a template — never commit real secrets).
42
+
43
+ Build production images from the workspace root so Docker can use the committed
44
+ lockfile and all workspace package manifests:
45
+
46
+ ```bash
47
+ docker build -f apps/api/Dockerfile -t {{projectName}}-api .
48
+ docker build -f apps/web/Dockerfile -t {{projectName}}-web .
49
+ ```
@@ -1,22 +1,26 @@
1
1
  # syntax=docker/dockerfile:1
2
- FROM node:20-alpine AS deps
2
+ FROM node:22-alpine AS deps
3
3
  WORKDIR /app
4
- COPY package.json ./
5
- RUN npm install --omit=dev=false --no-audit --no-fund
4
+ COPY package.json package-lock.json ./
5
+ COPY apps/api/package.json ./apps/api/package.json
6
+ COPY apps/web/package.json ./apps/web/package.json
7
+ COPY tests/package.json ./tests/package.json
8
+ RUN npm ci --no-audit --no-fund
6
9
 
7
- FROM node:20-alpine AS build
10
+ FROM node:22-alpine AS build
8
11
  WORKDIR /app
9
12
  COPY --from=deps /app/node_modules ./node_modules
10
13
  COPY . .
11
- RUN npm run build
14
+ RUN npm run build --workspace=apps/api
12
15
 
13
- FROM node:20-alpine AS runtime
16
+ FROM node:22-alpine AS runtime
14
17
  WORKDIR /app
15
18
  ENV NODE_ENV=production
16
19
  RUN addgroup -S app && adduser -S app -G app
17
- COPY --from=build /app/dist ./dist
18
20
  COPY --from=build /app/node_modules ./node_modules
19
- COPY package.json ./
21
+ COPY --from=deps /app/apps/api ./apps/api
22
+ COPY --from=build /app/apps/api/dist ./apps/api/dist
23
+ WORKDIR /app/apps/api
20
24
  USER app
21
25
  EXPOSE 3000
22
26
  CMD ["node", "dist/main"]
@@ -1,4 +1,4 @@
1
- import { Controller, Get } from "@nestjs/common";
1
+ import { Controller, Get, ServiceUnavailableException } from "@nestjs/common";
2
2
  import { InjectDataSource } from "@nestjs/typeorm";
3
3
  import { DataSource } from "typeorm";
4
4
 
@@ -17,7 +17,7 @@ export class HealthController {
17
17
  await this.dataSource.query("SELECT 1");
18
18
  return { status: "ready", db: "up" };
19
19
  } catch {
20
- return { status: "degraded", db: "down" };
20
+ throw new ServiceUnavailableException({ status: "degraded", db: "down" });
21
21
  }
22
22
  }
23
23
  }
@@ -1,22 +1,26 @@
1
1
  # syntax=docker/dockerfile:1
2
- FROM node:20-alpine AS deps
2
+ FROM node:22-alpine AS deps
3
3
  WORKDIR /app
4
- COPY package.json ./
5
- RUN npm install --no-audit --no-fund
4
+ COPY package.json package-lock.json ./
5
+ COPY apps/api/package.json ./apps/api/package.json
6
+ COPY apps/web/package.json ./apps/web/package.json
7
+ COPY tests/package.json ./tests/package.json
8
+ RUN npm ci --no-audit --no-fund
6
9
 
7
- FROM node:20-alpine AS build
10
+ FROM node:22-alpine AS build
8
11
  WORKDIR /app
9
12
  COPY --from=deps /app/node_modules ./node_modules
10
13
  COPY . .
11
- RUN npm run build
14
+ RUN npm run build --workspace=apps/web
12
15
 
13
- FROM node:20-alpine AS runtime
16
+ FROM node:22-alpine AS runtime
14
17
  WORKDIR /app
15
18
  ENV NODE_ENV=production
16
19
  RUN addgroup -S app && adduser -S app -G app
17
- COPY --from=build /app/build ./build
18
20
  COPY --from=build /app/node_modules ./node_modules
19
- COPY package.json ./
21
+ COPY --from=deps /app/apps/web ./apps/web
22
+ COPY --from=build /app/apps/web/build ./apps/web/build
23
+ WORKDIR /app/apps/web
20
24
  USER app
21
25
  EXPOSE 3000
22
26
  CMD ["node", "build"]
@@ -1,14 +1,16 @@
1
1
  import type { RequestEvent } from "@sveltejs/kit";
2
2
  import { createApiClient, type ApiClient } from "@podosoft/podokit-api-client";
3
- import { backendBaseUrl } from "./backend-proxy";
3
+ import { backendBaseUrl, normalizeClientIp } 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
10
  const fetchWithCookies: typeof fetch = (input, init) => {
10
11
  const headers = new Headers(init?.headers);
11
12
  if (cookie) headers.set("cookie", cookie);
13
+ if (clientIp) headers.set("x-forwarded-for", clientIp);
12
14
  return event.fetch(input, { ...init, headers });
13
15
  };
14
16
  return createApiClient({
@@ -12,7 +12,7 @@ export function backendBaseUrl(): string {
12
12
  // IPv4 as-is but anonymizes IPv6 (masks the host bits), so an IPv6 loopback would
13
13
  // be recorded as "::" — map loopback to 127.0.0.1 for a clean local value, and
14
14
  // unwrap IPv4-mapped addresses.
15
- function normalizeClientIp(address: string | undefined): string | undefined {
15
+ export function normalizeClientIp(address: string | undefined): string | undefined {
16
16
  if (!address) return undefined;
17
17
  if (address === "::1" || address === "::") return "127.0.0.1";
18
18
  if (address.startsWith("::ffff:")) return address.slice("::ffff:".length);
@@ -48,6 +48,10 @@ services:
48
48
  environment:
49
49
  - VITE_DOCKER=1
50
50
  - BACKEND_INTERNAL_URL=http://api:5002
51
+ # One trusted Traefik proxy sits in front of SvelteKit. This keeps
52
+ # getClientAddress() tied to the visitor instead of the proxy container.
53
+ - ADDRESS_HEADER=x-forwarded-for
54
+ - XFF_DEPTH=1
51
55
  # Match Traefik's published port so HMR's WebSocket targets the right port
52
56
  # (default 80). Without this, HMR connects to :80 and a non-80 stack falls
53
57
  # back to a full page reload on every edit.
@@ -8,3 +8,5 @@ data:
8
8
  PORT: "3000"
9
9
  CORS_ORIGIN: "https://example.com"
10
10
  BACKEND_INTERNAL_URL: "http://api:3000"
11
+ ADDRESS_HEADER: "x-forwarded-for"
12
+ XFF_DEPTH: "1"
@@ -3,7 +3,7 @@
3
3
  "version": "0.0.0",
4
4
  "private": true,
5
5
  "engines": {
6
- "node": ">=20"
6
+ "node": ">=22.22.1"
7
7
  },
8
8
  "workspaces": [
9
9
  "apps/*",
@@ -5,6 +5,14 @@ import { applySearchIndexingHeaders } from "$lib/server/search-indexing";
5
5
  import type { SiteSettings } from "$lib/site.svelte";
6
6
 
7
7
  export const handle: Handle = async ({ event, resolve }) => {
8
+ // Static assets and API proxy requests do not render application layouts.
9
+ // Avoid multiplying settings/session API calls for every asset and proxied
10
+ // request; page and data routes still receive the complete runtime context.
11
+ if (event.route.id === null || event.url.pathname.startsWith("/api/")) {
12
+ const response = await resolve(event);
13
+ return applySearchIndexingHeaders(response, event.url.pathname);
14
+ }
15
+
8
16
  // Public site settings (branding, locale/timezone defaults, maintenance, …),
9
17
  // loaded once here and reused by the layout load. A visitor with no locale
10
18
  // cookie falls back to the admin-configured site locale, then the app default.
@@ -5,14 +5,15 @@ import { ready } from "../helpers/hydration";
5
5
  // own "Appearance" settings tab.
6
6
  async function openAppearance(page: import("@playwright/test").Page): Promise<void> {
7
7
  await ready(page, "/admin/settings");
8
- await page.getByRole("tab", { name: "Appearance" }).click();
8
+ const tab = page.getByRole("tab", { name: "Appearance" });
9
+ await expect(async () => {
10
+ await tab.click();
11
+ await expect(tab).toHaveAttribute("data-state", "active");
12
+ }).toPass();
9
13
  }
10
14
 
11
15
  test("appearance: has its own tab with theme controls @smoke", async ({ page }) => {
12
- await ready(page, "/admin/settings");
13
- await expect(page.getByRole("tab", { name: "Appearance" })).toBeVisible();
14
- await page.getByRole("tab", { name: "Appearance" }).click();
15
- await expect(page.getByRole("heading", { name: "Appearance" })).toBeVisible();
16
+ await openAppearance(page);
16
17
  await expect(page.getByRole("heading", { name: "Featured themes" })).toBeVisible();
17
18
  await expect(page.getByRole("button", { name: "Default", exact: true })).toBeVisible();
18
19
  await expect(page.getByRole("button", { name: "Violet" })).toBeVisible();
@@ -8,12 +8,23 @@ import { ready } from "../helpers/hydration";
8
8
  // toggle-then-restore pattern safe for the rest of the suite; restores run in a
9
9
  // `finally` so a mid-test failure still leaves the shared site settings clean.
10
10
 
11
- async function saveGeneral(page: Page): Promise<void> {
12
- const saved = page.waitForResponse(
13
- (response) => response.url().endsWith("/api/site/settings") && response.request().method() === "PUT",
14
- );
15
- await page.getByRole("button", { name: "Save changes" }).click();
16
- expect((await saved).ok()).toBeTruthy();
11
+ async function saveGeneral(
12
+ page: Page,
13
+ prepare?: () => Promise<void>,
14
+ expected?: Record<string, string>,
15
+ ): Promise<void> {
16
+ const button = page.getByRole("button", { name: "Save changes" });
17
+ await expect(async () => {
18
+ await prepare?.();
19
+ const saved = page.waitForResponse(
20
+ (response) => response.url().endsWith("/api/site/settings") && response.request().method() === "PUT",
21
+ { timeout: 3_000 },
22
+ );
23
+ await button.click();
24
+ const response = await saved;
25
+ expect(response.ok()).toBeTruthy();
26
+ if (expected) expect(await response.json()).toMatchObject(expected);
27
+ }).toPass({ timeout: 10_000 });
17
28
  await expect(page.getByText("General settings saved.").last()).toBeVisible();
18
29
  }
19
30
 
@@ -24,9 +35,14 @@ test("general: footer text and links render on public pages @smoke", async ({ pa
24
35
  const support = page.getByLabel("Support email");
25
36
  const origFooter = await footer.inputValue();
26
37
  const origSupport = await support.inputValue();
27
- await footer.fill("© 2026 PodoKit Test");
28
- await support.fill("help@example.com");
29
- await saveGeneral(page);
38
+ await saveGeneral(
39
+ page,
40
+ async () => {
41
+ await footer.fill("© 2026 PodoKit Test");
42
+ await support.fill("help@example.com");
43
+ },
44
+ { footerText: "© 2026 PodoKit Test", supportEmail: "help@example.com" },
45
+ );
30
46
  try {
31
47
  const ctx = await browser.newContext({ storageState: anonState });
32
48
  const anon = await ctx.newPage();
@@ -36,9 +52,14 @@ test("general: footer text and links render on public pages @smoke", async ({ pa
36
52
  await ctx.close();
37
53
  } finally {
38
54
  await ready(page, "/admin/settings");
39
- await page.getByLabel("Footer text").fill(origFooter);
40
- await page.getByLabel("Support email").fill(origSupport);
41
- await saveGeneral(page);
55
+ await saveGeneral(
56
+ page,
57
+ async () => {
58
+ await page.getByLabel("Footer text").fill(origFooter);
59
+ await page.getByLabel("Support email").fill(origSupport);
60
+ },
61
+ { footerText: origFooter, supportEmail: origSupport },
62
+ );
42
63
  }
43
64
  });
44
65
 
@@ -56,8 +56,8 @@ test("sidebar: back-to-home link returns to the landing page @smoke", async ({ p
56
56
  // the home link lives in the sidebar footer, above the user menu
57
57
  await page.getByRole("link", { name: "Back to home" }).click();
58
58
  await expect(page).toHaveURL(/\/$/);
59
- // The starter landing route is app-owned and remains intact when the module is added.
60
- await expect(page.getByRole("heading", { name: "app" })).toBeVisible();
59
+ // Landing routes are app-owned, so only verify the shared page shell.
60
+ await expect(page.locator("main").first()).toBeVisible();
61
61
  });
62
62
 
63
63
  test("settings is reachable from the sidebar @smoke", async ({ page }) => {
@@ -1,4 +1,4 @@
1
- import { Controller, Get } from "@nestjs/common";
1
+ import { Controller, Get, ServiceUnavailableException } from "@nestjs/common";
2
2
  import { InjectDataSource } from "@nestjs/typeorm";
3
3
  import { DataSource } from "typeorm";
4
4
  import { Public } from "@thallesp/nestjs-better-auth";
@@ -20,7 +20,7 @@ export class HealthController {
20
20
  await this.dataSource.query("SELECT 1");
21
21
  return { status: "ready", db: "up" };
22
22
  } catch {
23
- return { status: "degraded", db: "down" };
23
+ throw new ServiceUnavailableException({ status: "degraded", db: "down" });
24
24
  }
25
25
  }
26
26
  }
@@ -0,0 +1,22 @@
1
+ import { getMigrations } from "better-auth/db/migration";
2
+ import { auth } from "./auth/auth";
3
+ import { pool } from "./auth/db";
4
+ import dataSource from "./database/data-source";
5
+
6
+ async function runMigrations(): Promise<void> {
7
+ const authMigrations = await getMigrations(auth.options);
8
+ await authMigrations.runMigrations();
9
+
10
+ await dataSource.initialize();
11
+ await dataSource.runMigrations();
12
+ }
13
+
14
+ runMigrations()
15
+ .catch((error: unknown) => {
16
+ console.error("Run database migrations failed", error);
17
+ process.exitCode = 1;
18
+ })
19
+ .finally(async (): Promise<void> => {
20
+ if (dataSource.isInitialized) await dataSource.destroy();
21
+ await pool.end();
22
+ });
@@ -16,6 +16,9 @@
16
16
  "devDependencies": {
17
17
  "@types/pg": "^8.11.10"
18
18
  },
19
+ "scripts": {
20
+ "migrate:all": "node dist/migrate"
21
+ },
19
22
  "env": [
20
23
  "# \u2500\u2500 Auth (better-auth) \u2014 the only required secrets \u2500\u2500",
21
24
  "# BETTER_AUTH_SECRET also derives the key that encrypts config secrets stored in the",
@@ -101,13 +104,14 @@
101
104
  "file": "AGENTS.md",
102
105
  "marker": "<!-- podokit:end:agents-modules -->",
103
106
  "optional": true,
104
- "text": "### auth (better-auth)\n- The API is **secure by default**: every route needs a session except `/health` and `/api/auth/*`. Opt a route out with `@Public()`; read the user with `@Session()`.\n- Configure OAuth providers, SMTP, and the server toggles on the admin **Settings** page (DB-backed, applied live \u2014 not via env). Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required env vars.\n- Create the auth tables: `npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts`.\n"
107
+ "text": "### auth (better-auth)\n- The API is **secure by default**: every route needs a session except `/health` and `/api/auth/*`. Opt a route out with `@Public()`; read the user with `@Session()`.\n- Configure OAuth providers, SMTP, and the server toggles on the admin **Settings** page (DB-backed, applied live \u2014 not via env). Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required env vars.\n- Development setup: create auth tables with `npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts`, then run TypeORM migrations. Production images can run both from compiled output with `npm run migrate:all`.\n"
105
108
  }
106
109
  ],
107
110
  "instructions": [
108
111
  "Set BETTER_AUTH_SECRET in .env (>= 32 chars). This also derives the key that encrypts config secrets stored in the DB, so keep it stable and secret.",
109
112
  "Create the auth tables: npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts",
110
113
  "Create the app tables (auth_config, app_setting): npm run migration:run",
114
+ "After building a production image, run all Better Auth and TypeORM migrations: npm run migrate:all",
111
115
  "Sign up: curl -c cookies.txt -XPOST localhost:5002/api/auth/sign-up/email -H 'content-type: application/json' -d '{\"email\":\"a@example.com\",\"password\":\"password123\",\"name\":\"A\"}'",
112
116
  "Call a protected route with the session: curl -b cookies.txt localhost:5002/account/me",
113
117
  "Everything is protected by default; mark public routes with @Public(). Configure OAuth (Google/GitHub), SMTP, and server-enforced toggles on the admin Settings page \u2014 stored encrypted in the DB and applied live (no restart). Environment variables (GOOGLE_CLIENT_*, SMTP_*, AUTH_*) still work as an optional fallback/override."
@@ -8,7 +8,8 @@
8
8
  services:
9
9
  worker:
10
10
  build:
11
- context: ../../apps/api
11
+ context: ../..
12
+ dockerfile: apps/api/Dockerfile
12
13
  command: ["node", "dist/main-worker"]
13
14
  environment:
14
15
  REDIS_HOST: redis
@@ -1,11 +1,50 @@
1
- import { Module } from "@nestjs/common";
1
+ import { Injectable, Module } from "@nestjs/common";
2
2
  import { APP_GUARD } from "@nestjs/core";
3
- import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
3
+ import {
4
+ ThrottlerGuard,
5
+ ThrottlerModule,
6
+ type ThrottlerRequest,
7
+ } from "@nestjs/throttler";
4
8
  import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
5
9
  import Redis from "ioredis";
6
10
 
7
11
  const ttlSeconds = Number(process.env.RATE_LIMIT_TTL ?? 60);
8
12
  const limit = Number(process.env.RATE_LIMIT_MAX ?? 100);
13
+ const runtimeLimit = Number(process.env.RATE_LIMIT_RUNTIME_MAX ?? 1000);
14
+ const unthrottledHealthPaths = new Set(["/health", "/health/ready"]);
15
+
16
+ function forwardedClient(headers: unknown): string | undefined {
17
+ if (!headers || typeof headers !== "object") return undefined;
18
+ const value = (headers as Record<string, unknown>)["x-forwarded-for"];
19
+ const first = Array.isArray(value) ? value[0] : value;
20
+ if (typeof first !== "string") return undefined;
21
+ return first.split(",")[0]?.trim() || undefined;
22
+ }
23
+
24
+ // PodoKit exposes the API through its web proxy, which replaces x-forwarded-for
25
+ // with SvelteKit's resolved client address. Track that address instead of the
26
+ // web container so unrelated visitors do not share one global counter.
27
+ @Injectable()
28
+ class ProxyAwareThrottlerGuard extends ThrottlerGuard {
29
+ protected override handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
30
+ const { req } = this.getRequestResponse(requestProps.context);
31
+ const method = typeof req.method === "string" ? req.method : "";
32
+ const path = typeof req.path === "string" ? req.path : "";
33
+ if (method === "GET" && unthrottledHealthPaths.has(path)) {
34
+ return Promise.resolve(true);
35
+ }
36
+ if (method === "GET" && path === "/site/settings") {
37
+ return super.handleRequest({ ...requestProps, limit: runtimeLimit });
38
+ }
39
+ return super.handleRequest(requestProps);
40
+ }
41
+
42
+ protected override getTracker(request: Record<string, unknown>): Promise<string> {
43
+ const forwarded = forwardedClient(request.headers);
44
+ const direct = typeof request.ip === "string" ? request.ip : "unknown";
45
+ return Promise.resolve(forwarded ?? direct);
46
+ }
47
+ }
9
48
 
10
49
  // Distributed rate limiting: the counter lives in Redis, so the limit holds
11
50
  // across API replicas. Registers a global guard (429 when exceeded).
@@ -21,6 +60,6 @@ const limit = Number(process.env.RATE_LIMIT_MAX ?? 100);
21
60
  ),
22
61
  }),
23
62
  ],
24
- providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
63
+ providers: [{ provide: APP_GUARD, useClass: ProxyAwareThrottlerGuard }],
25
64
  })
26
65
  export class RateLimitModule {}
@@ -13,19 +13,28 @@ const origin = { origin: base };
13
13
  // to the module default and allow a little headroom.
14
14
  const limit = Number(process.env.RATE_LIMIT_MAX ?? 100);
15
15
 
16
- test("rate limit: exceeding the window returns 429 @smoke", async ({ playwright }) => {
16
+ test("rate limit: health probes stay available while ordinary routes return 429 @smoke", async ({
17
+ playwright,
18
+ }) => {
17
19
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
18
- // /health is public — no session needed. If the first hit is a 5xx the
19
- // throttler's Redis backing isn't reachable; nothing to assert.
20
- const probe = await ctx.get("/api/health");
20
+ for (const path of ["/api/health", "/api/health/ready"]) {
21
+ for (let i = 0; i < limit + 5; i++) {
22
+ const response = await ctx.get(path);
23
+ expect(response.status(), `${path} request ${i + 1}`).toBe(200);
24
+ }
25
+ }
26
+
27
+ // The redis module is added automatically and exposes a public demo cache
28
+ // route, so it provides a stable ordinary endpoint for the global limit.
29
+ const probe = await ctx.get("/api/cache/rate-limit-probe");
21
30
  test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
22
31
 
23
32
  let seen200 = probe.status() === 200;
24
33
  let got429 = false;
25
34
  for (let i = 0; i < limit + 5 && !got429; i++) {
26
- const r = await ctx.get("/api/health");
27
- if (r.status() === 200) seen200 = true;
28
- if (r.status() === 429) got429 = true;
35
+ const response = await ctx.get("/api/cache/rate-limit-probe");
36
+ if (response.status() === 200) seen200 = true;
37
+ if (response.status() === 429) got429 = true;
29
38
  }
30
39
  // A fresh window serves the first requests (200) and rejects once the limit is
31
40
  // crossed (429).
@@ -10,7 +10,9 @@
10
10
  "env": [
11
11
  "# rate limit (window in seconds, max requests per window per client)",
12
12
  "RATE_LIMIT_TTL=60",
13
- "RATE_LIMIT_MAX=100"
13
+ "RATE_LIMIT_MAX=100",
14
+ "# higher ceiling for SSR runtime settings reads (maintenance, sign-up, branding)",
15
+ "RATE_LIMIT_RUNTIME_MAX=1000"
14
16
  ],
15
17
  "inject": [
16
18
  { "file": "apps/api/src/app.module.ts", "marker": "// podokit:end:imports", "text": "import { RateLimitModule } from \"./rate-limit/rate-limit.module\";" },
@@ -18,8 +20,10 @@
18
20
  ],
19
21
  "instructions": [
20
22
  "Ensure Redis is running: docker compose -f infra/docker/docker-compose.yml up -d",
21
- "Tune RATE_LIMIT_TTL / RATE_LIMIT_MAX in .env.",
23
+ "Tune RATE_LIMIT_TTL / RATE_LIMIT_MAX / RATE_LIMIT_RUNTIME_MAX in .env.",
22
24
  "Exceeding the limit returns HTTP 429. Skip a route with @SkipThrottle() or tune it with @Throttle().",
23
- "The limit is shared across API replicas because it is stored in Redis."
25
+ "GET /health and GET /health/ready bypass request quotas so orchestrator probes remain reliable.",
26
+ "The limit is shared across API replicas because it is stored in Redis.",
27
+ "PodoKit's web proxy forwards the resolved client address, so visitors receive independent counters."
24
28
  ]
25
29
  }
@@ -1,22 +1,26 @@
1
1
  # syntax=docker/dockerfile:1
2
- FROM node:20-alpine AS deps
2
+ FROM node:22-alpine AS deps
3
3
  WORKDIR /app
4
- COPY package.json ./
5
- RUN npm install --omit=dev=false --no-audit --no-fund
4
+ COPY package.json package-lock.json ./
5
+ COPY apps/api/package.json ./apps/api/package.json
6
+ COPY apps/web/package.json ./apps/web/package.json
7
+ COPY tests/package.json ./tests/package.json
8
+ RUN npm ci --no-audit --no-fund
6
9
 
7
- FROM node:20-alpine AS build
10
+ FROM node:22-alpine AS build
8
11
  WORKDIR /app
9
12
  COPY --from=deps /app/node_modules ./node_modules
10
13
  COPY . .
11
- RUN npm run build
14
+ RUN npm run build --workspace=apps/api
12
15
 
13
- FROM node:20-alpine AS runtime
16
+ FROM node:22-alpine AS runtime
14
17
  WORKDIR /app
15
18
  ENV NODE_ENV=production
16
19
  RUN addgroup -S app && adduser -S app -G app
17
- COPY --from=build /app/dist ./dist
18
20
  COPY --from=build /app/node_modules ./node_modules
19
- COPY package.json ./
21
+ COPY --from=deps /app/apps/api ./apps/api
22
+ COPY --from=build /app/apps/api/dist ./apps/api/dist
23
+ WORKDIR /app/apps/api
20
24
  USER app
21
25
  EXPOSE 3000
22
26
  CMD ["node", "dist/main"]
@@ -1,4 +1,4 @@
1
- import { Controller, Get } from "@nestjs/common";
1
+ import { Controller, Get, ServiceUnavailableException } from "@nestjs/common";
2
2
  import { InjectDataSource } from "@nestjs/typeorm";
3
3
  import { DataSource } from "typeorm";
4
4
 
@@ -17,7 +17,7 @@ export class HealthController {
17
17
  await this.dataSource.query("SELECT 1");
18
18
  return { status: "ready", db: "up" };
19
19
  } catch {
20
- return { status: "degraded", db: "down" };
20
+ throw new ServiceUnavailableException({ status: "degraded", db: "down" });
21
21
  }
22
22
  }
23
23
  }
@@ -1,22 +1,26 @@
1
1
  # syntax=docker/dockerfile:1
2
- FROM node:20-alpine AS deps
2
+ FROM node:22-alpine AS deps
3
3
  WORKDIR /app
4
- COPY package.json ./
5
- RUN npm install --no-audit --no-fund
4
+ COPY package.json package-lock.json ./
5
+ COPY apps/api/package.json ./apps/api/package.json
6
+ COPY apps/web/package.json ./apps/web/package.json
7
+ COPY tests/package.json ./tests/package.json
8
+ RUN npm ci --no-audit --no-fund
6
9
 
7
- FROM node:20-alpine AS build
10
+ FROM node:22-alpine AS build
8
11
  WORKDIR /app
9
12
  COPY --from=deps /app/node_modules ./node_modules
10
13
  COPY . .
11
- RUN npm run build
14
+ RUN npm run build --workspace=apps/web
12
15
 
13
- FROM node:20-alpine AS runtime
16
+ FROM node:22-alpine AS runtime
14
17
  WORKDIR /app
15
18
  ENV NODE_ENV=production
16
19
  RUN addgroup -S app && adduser -S app -G app
17
- COPY --from=build /app/build ./build
18
20
  COPY --from=build /app/node_modules ./node_modules
19
- COPY package.json ./
21
+ COPY --from=deps /app/apps/web ./apps/web
22
+ COPY --from=build /app/apps/web/build ./apps/web/build
23
+ WORKDIR /app/apps/web
20
24
  USER app
21
25
  EXPOSE 3000
22
26
  CMD ["node", "build"]
@@ -1,14 +1,16 @@
1
1
  import type { RequestEvent } from "@sveltejs/kit";
2
2
  import { createApiClient, type ApiClient } from "@podosoft/podokit-api-client";
3
- import { backendBaseUrl } from "./backend-proxy";
3
+ import { backendBaseUrl, normalizeClientIp } 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
10
  const fetchWithCookies: typeof fetch = (input, init) => {
10
11
  const headers = new Headers(init?.headers);
11
12
  if (cookie) headers.set("cookie", cookie);
13
+ if (clientIp) headers.set("x-forwarded-for", clientIp);
12
14
  return event.fetch(input, { ...init, headers });
13
15
  };
14
16
  return createApiClient({
@@ -12,7 +12,7 @@ export function backendBaseUrl(): string {
12
12
  // IPv4 as-is but anonymizes IPv6 (masks the host bits), so an IPv6 loopback would
13
13
  // be recorded as "::" — map loopback to 127.0.0.1 for a clean local value, and
14
14
  // unwrap IPv4-mapped addresses.
15
- function normalizeClientIp(address: string | undefined): string | undefined {
15
+ export function normalizeClientIp(address: string | undefined): string | undefined {
16
16
  if (!address) return undefined;
17
17
  if (address === "::1" || address === "::") return "127.0.0.1";
18
18
  if (address.startsWith("::ffff:")) return address.slice("::ffff:".length);
@@ -48,6 +48,10 @@ services:
48
48
  environment:
49
49
  - VITE_DOCKER=1
50
50
  - BACKEND_INTERNAL_URL=http://api:5002
51
+ # One trusted Traefik proxy sits in front of SvelteKit. This keeps
52
+ # getClientAddress() tied to the visitor instead of the proxy container.
53
+ - ADDRESS_HEADER=x-forwarded-for
54
+ - XFF_DEPTH=1
51
55
  # Match Traefik's published port so HMR's WebSocket targets the right port
52
56
  # (default 80). Without this, HMR connects to :80 and a non-80 stack falls
53
57
  # back to a full page reload on every edit.
@@ -8,3 +8,5 @@ data:
8
8
  PORT: "3000"
9
9
  CORS_ORIGIN: "https://example.com"
10
10
  BACKEND_INTERNAL_URL: "http://api:3000"
11
+ ADDRESS_HEADER: "x-forwarded-for"
12
+ XFF_DEPTH: "1"
@@ -3,7 +3,7 @@
3
3
  "version": "0.0.0",
4
4
  "private": true,
5
5
  "engines": {
6
- "node": ">=20"
6
+ "node": ">=22.22.1"
7
7
  },
8
8
  "workspaces": [
9
9
  "apps/*",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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": {