@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.
- package/dist/templates/fullstack-nest-svelte/README.md +8 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +12 -8
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +12 -8
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/api.ts +3 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +1 -1
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +4 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/configmap.yaml +2 -0
- package/dist/templates/fullstack-nest-svelte/package.json +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +6 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +33 -12
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +22 -0
- package/dist/templates/modules/auth/module.manifest.json +5 -1
- package/dist/templates/modules/bullmq/files/infra/docker/worker.compose.example.yml +2 -1
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +42 -3
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +16 -7
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -3
- package/dist/templates/todo/apps/api/Dockerfile +12 -8
- package/dist/templates/todo/apps/api/src/health/health.controller.ts +2 -2
- package/dist/templates/todo/apps/web/Dockerfile +12 -8
- package/dist/templates/todo/apps/web/src/lib/server/api.ts +3 -1
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +1 -1
- package/dist/templates/todo/compose.dev.yaml +4 -0
- package/dist/templates/todo/infra/k3s/configmap.yaml +2 -0
- package/dist/templates/todo/package.json +1 -1
- 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:
|
|
2
|
+
FROM node:22-alpine AS deps
|
|
3
3
|
WORKDIR /app
|
|
4
|
-
COPY package.json ./
|
|
5
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
2
|
+
FROM node:22-alpine AS deps
|
|
3
3
|
WORKDIR /app
|
|
4
|
-
COPY package.json ./
|
|
5
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
//
|
|
60
|
-
await expect(page.
|
|
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
|
-
|
|
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-
|
|
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."
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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:
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
27
|
-
if (
|
|
28
|
-
if (
|
|
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
|
-
"
|
|
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:
|
|
2
|
+
FROM node:22-alpine AS deps
|
|
3
3
|
WORKDIR /app
|
|
4
|
-
COPY package.json ./
|
|
5
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
2
|
+
FROM node:22-alpine AS deps
|
|
3
3
|
WORKDIR /app
|
|
4
|
-
COPY package.json ./
|
|
5
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podosoft/podokit",
|
|
3
|
-
"version": "0.10.
|
|
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": {
|