@podosoft/podokit 0.9.0 → 0.10.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/dist/add.d.ts +20 -0
- package/dist/add.js +121 -8
- package/dist/assemble.d.ts +4 -1
- package/dist/assemble.js +35 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +20 -4
- package/dist/inspect.d.ts +5 -0
- package/dist/inspect.js +5 -0
- package/dist/lockfile.d.ts +20 -1
- package/dist/lockfile.js +48 -6
- package/dist/remove.js +3 -2
- 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/app.css +42 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/site-runtime.svelte +7 -0
- 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 +4 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+layout.svelte +2 -2
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +4 -0
- package/dist/templates/fullstack-nest-svelte/dot-gitignore +6 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/configmap.yaml +2 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +10 -0
- package/dist/templates/fullstack-nest-svelte/infra/traefik/dynamic.yml +4 -0
- package/dist/templates/fullstack-nest-svelte/package.json +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +60 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +11 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +42 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +46 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +730 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/appearance-card.svelte +371 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +39 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +12 -7
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +32 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/apply-theme.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/themes.ts +81 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +8 -717
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +17 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +0 -7
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +4 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +35 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +56 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +10 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +9 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +64 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +42 -13
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +5 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +14 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +12 -4
- package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
- package/dist/templates/modules/api-key-auth/files/tests/api/machine.api.spec.ts +25 -0
- 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/bullmq/files/tests/api/jobs.api.spec.ts +38 -0
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +31 -0
- package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +22 -0
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +28 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +38 -3
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +35 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -3
- package/dist/templates/modules/redis/files/tests/api/redis.api.spec.ts +30 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
- 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/components/site-runtime.svelte +7 -0
- 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 +4 -2
- package/dist/templates/todo/apps/web/src/routes/+layout.svelte +2 -2
- package/dist/templates/todo/compose.dev.yaml +4 -0
- package/dist/templates/todo/dot-gitignore +6 -0
- package/dist/templates/todo/infra/k3s/configmap.yaml +2 -0
- package/dist/templates/todo/infra/k3s/ingress.yaml +10 -0
- package/dist/templates/todo/infra/traefik/dynamic.yml +4 -0
- package/dist/templates/todo/package.json +1 -1
- package/dist/update.js +74 -8
- package/package.json +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +0 -61
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
|
+
const origin = { origin: base };
|
|
5
|
+
|
|
6
|
+
async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
|
|
7
|
+
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
8
|
+
const email = `jobs-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
|
9
|
+
await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "J" } }).catch(() => undefined);
|
|
10
|
+
return ctx;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test("bullmq: enqueue a job, read its status, 404 for unknown @smoke", async ({ playwright }) => {
|
|
14
|
+
const ctx = await session(playwright);
|
|
15
|
+
const res = await ctx.post("/api/jobs", { data: { text: "hello" } });
|
|
16
|
+
test.skip(res.status() >= 500, "redis not reachable");
|
|
17
|
+
expect(res.ok()).toBeTruthy();
|
|
18
|
+
const { id } = await res.json();
|
|
19
|
+
expect(typeof id).toBe("string");
|
|
20
|
+
|
|
21
|
+
const status = await (await ctx.get(`/api/jobs/${id}`)).json();
|
|
22
|
+
expect(["waiting", "active", "completed", "delayed", "prioritized", "waiting-children"]).toContain(status.state);
|
|
23
|
+
|
|
24
|
+
const missing = await ctx.get(`/api/jobs/does-not-exist-${Date.now()}`);
|
|
25
|
+
expect(missing.status()).toBe(404);
|
|
26
|
+
|
|
27
|
+
// When a worker is running, the demo job completes with an uppercased result;
|
|
28
|
+
// without a worker it stays waiting (this stays green either way).
|
|
29
|
+
let final = status.state;
|
|
30
|
+
const deadline = Date.now() + 6000;
|
|
31
|
+
while (Date.now() < deadline && final !== "completed") {
|
|
32
|
+
const s = await (await ctx.get(`/api/jobs/${id}`)).json();
|
|
33
|
+
final = s.state;
|
|
34
|
+
if (final === "completed") expect(s.result).toMatchObject({ upper: "HELLO" });
|
|
35
|
+
else await new Promise((r) => setTimeout(r, 400));
|
|
36
|
+
}
|
|
37
|
+
await ctx.dispose();
|
|
38
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
|
+
const origin = { origin: base };
|
|
5
|
+
|
|
6
|
+
async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
|
|
7
|
+
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
8
|
+
const email = `files-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
|
9
|
+
await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "F" } }).catch(() => undefined);
|
|
10
|
+
return ctx;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test("file upload: multipart upload returns a key and url @smoke", async ({ playwright }) => {
|
|
14
|
+
const ctx = await session(playwright);
|
|
15
|
+
const res = await ctx.post("/api/files", {
|
|
16
|
+
multipart: { file: { name: "hello.txt", mimeType: "text/plain", buffer: Buffer.from("hi there") } },
|
|
17
|
+
});
|
|
18
|
+
test.skip(res.status() >= 500, "object storage (MinIO/S3) not reachable");
|
|
19
|
+
expect(res.ok()).toBeTruthy();
|
|
20
|
+
const body = await res.json();
|
|
21
|
+
expect(typeof body.key).toBe("string");
|
|
22
|
+
expect(String(body.url)).toContain("http");
|
|
23
|
+
await ctx.dispose();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("file upload: a request with no file is a 400 @smoke", async ({ playwright }) => {
|
|
27
|
+
const ctx = await session(playwright);
|
|
28
|
+
const res = await ctx.post("/api/files", { multipart: { notfile: "x" } });
|
|
29
|
+
expect(res.status()).toBe(400);
|
|
30
|
+
await ctx.dispose();
|
|
31
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
|
+
const origin = { origin: base };
|
|
5
|
+
|
|
6
|
+
async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
|
|
7
|
+
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
8
|
+
const email = `progress-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
|
9
|
+
await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "P" } }).catch(() => undefined);
|
|
10
|
+
return ctx;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The end-to-end progress stream (worker → Redis pub/sub → SSE) needs the worker
|
|
14
|
+
// process running; this asserts the API contract (a job is enqueued).
|
|
15
|
+
test("job-progress: starting a job returns a job id @smoke", async ({ playwright }) => {
|
|
16
|
+
const ctx = await session(playwright);
|
|
17
|
+
const res = await ctx.post("/api/progress", { data: { steps: 3 } });
|
|
18
|
+
test.skip(res.status() >= 500, "redis not reachable");
|
|
19
|
+
expect(res.ok()).toBeTruthy();
|
|
20
|
+
expect(typeof (await res.json()).jobId).toBe("string");
|
|
21
|
+
await ctx.dispose();
|
|
22
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
|
+
const origin = { origin: base };
|
|
5
|
+
|
|
6
|
+
async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
|
|
7
|
+
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
8
|
+
const email = `storage-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
|
9
|
+
await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "S" } }).catch(() => undefined);
|
|
10
|
+
return ctx;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test("object storage: put, get, and presign @smoke", async ({ playwright }) => {
|
|
14
|
+
const ctx = await session(playwright);
|
|
15
|
+
const key = `obj-${Date.now()}`;
|
|
16
|
+
const put = await ctx.put(`/api/storage/${key}`, { data: { content: "hello world" } });
|
|
17
|
+
test.skip(put.status() >= 500, "object storage (MinIO/S3) not reachable");
|
|
18
|
+
expect(put.ok()).toBeTruthy();
|
|
19
|
+
expect(await put.json()).toMatchObject({ key });
|
|
20
|
+
|
|
21
|
+
const got = await (await ctx.get(`/api/storage/${key}`)).json();
|
|
22
|
+
expect(got).toMatchObject({ key, content: "hello world" });
|
|
23
|
+
|
|
24
|
+
const pre = await (await ctx.get(`/api/storage/${key}/presigned`)).json();
|
|
25
|
+
expect(String(pre.url)).toContain(key);
|
|
26
|
+
|
|
27
|
+
await ctx.dispose();
|
|
28
|
+
});
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts
CHANGED
|
@@ -1,11 +1,46 @@
|
|
|
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
|
+
|
|
15
|
+
function forwardedClient(headers: unknown): string | undefined {
|
|
16
|
+
if (!headers || typeof headers !== "object") return undefined;
|
|
17
|
+
const value = (headers as Record<string, unknown>)["x-forwarded-for"];
|
|
18
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
19
|
+
if (typeof first !== "string") return undefined;
|
|
20
|
+
return first.split(",")[0]?.trim() || undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// PodoKit exposes the API through its web proxy, which replaces x-forwarded-for
|
|
24
|
+
// with SvelteKit's resolved client address. Track that address instead of the
|
|
25
|
+
// web container so unrelated visitors do not share one global counter.
|
|
26
|
+
@Injectable()
|
|
27
|
+
class ProxyAwareThrottlerGuard extends ThrottlerGuard {
|
|
28
|
+
protected override handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
|
|
29
|
+
const { req } = this.getRequestResponse(requestProps.context);
|
|
30
|
+
const method = typeof req.method === "string" ? req.method : "";
|
|
31
|
+
const path = typeof req.path === "string" ? req.path : "";
|
|
32
|
+
if (method === "GET" && path === "/site/settings") {
|
|
33
|
+
return super.handleRequest({ ...requestProps, limit: runtimeLimit });
|
|
34
|
+
}
|
|
35
|
+
return super.handleRequest(requestProps);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
protected override getTracker(request: Record<string, unknown>): Promise<string> {
|
|
39
|
+
const forwarded = forwardedClient(request.headers);
|
|
40
|
+
const direct = typeof request.ip === "string" ? request.ip : "unknown";
|
|
41
|
+
return Promise.resolve(forwarded ?? direct);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
9
44
|
|
|
10
45
|
// Distributed rate limiting: the counter lives in Redis, so the limit holds
|
|
11
46
|
// across API replicas. Registers a global guard (429 when exceeded).
|
|
@@ -21,6 +56,6 @@ const limit = Number(process.env.RATE_LIMIT_MAX ?? 100);
|
|
|
21
56
|
),
|
|
22
57
|
}),
|
|
23
58
|
],
|
|
24
|
-
providers: [{ provide: APP_GUARD, useClass:
|
|
59
|
+
providers: [{ provide: APP_GUARD, useClass: ProxyAwareThrottlerGuard }],
|
|
25
60
|
})
|
|
26
61
|
export class RateLimitModule {}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
|
+
const origin = { origin: base };
|
|
5
|
+
|
|
6
|
+
// rate-limit installs a global throttler guard (default RATE_LIMIT_MAX requests
|
|
7
|
+
// per RATE_LIMIT_TTL seconds, per client). This module ships a global guard, so
|
|
8
|
+
// it never joins the shared Outer smoke app (it would throttle every other
|
|
9
|
+
// spec); this spec ships with the module and runs against a dedicated app.
|
|
10
|
+
//
|
|
11
|
+
// Honor RATE_LIMIT_MAX when the test runner has it (a dedicated app sets a small
|
|
12
|
+
// value so the window is crossed in a handful of requests); otherwise fall back
|
|
13
|
+
// to the module default and allow a little headroom.
|
|
14
|
+
const limit = Number(process.env.RATE_LIMIT_MAX ?? 100);
|
|
15
|
+
|
|
16
|
+
test("rate limit: exceeding the window returns 429 @smoke", async ({ playwright }) => {
|
|
17
|
+
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");
|
|
21
|
+
test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
|
|
22
|
+
|
|
23
|
+
let seen200 = probe.status() === 200;
|
|
24
|
+
let got429 = false;
|
|
25
|
+
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;
|
|
29
|
+
}
|
|
30
|
+
// A fresh window serves the first requests (200) and rejects once the limit is
|
|
31
|
+
// crossed (429).
|
|
32
|
+
expect(seen200).toBe(true);
|
|
33
|
+
expect(got429).toBe(true);
|
|
34
|
+
await ctx.dispose();
|
|
35
|
+
});
|
|
@@ -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,9 @@
|
|
|
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
|
+
"The limit is shared across API replicas because it is stored in Redis.",
|
|
26
|
+
"PodoKit's web proxy forwards the resolved client address, so visitors receive independent counters."
|
|
24
27
|
]
|
|
25
28
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
|
+
const origin = { origin: base };
|
|
5
|
+
|
|
6
|
+
// Self-contained: sign up + sign in a disposable account so the test works when
|
|
7
|
+
// `auth`'s global guard is installed (the common case). Harmless if the API is open.
|
|
8
|
+
async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
|
|
9
|
+
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
10
|
+
const email = `redis-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
|
11
|
+
await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "R" } }).catch(() => undefined);
|
|
12
|
+
return ctx;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test("redis cache: set, get, and miss @smoke", async ({ playwright }) => {
|
|
16
|
+
const ctx = await session(playwright);
|
|
17
|
+
const key = `k-${Date.now()}`;
|
|
18
|
+
const put = await ctx.put(`/api/cache/${key}`, { data: { value: "hello", ttl: 60 } });
|
|
19
|
+
test.skip(put.status() >= 500, "redis not reachable");
|
|
20
|
+
expect(put.ok()).toBeTruthy();
|
|
21
|
+
expect(await put.json()).toMatchObject({ key });
|
|
22
|
+
|
|
23
|
+
const got = await (await ctx.get(`/api/cache/${key}`)).json();
|
|
24
|
+
expect(got).toMatchObject({ key, value: "hello" });
|
|
25
|
+
|
|
26
|
+
const miss = await (await ctx.get(`/api/cache/missing-${Date.now()}`)).json();
|
|
27
|
+
expect(miss.value).toBeNull();
|
|
28
|
+
|
|
29
|
+
await ctx.dispose();
|
|
30
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
|
+
const origin = { origin: base };
|
|
5
|
+
|
|
6
|
+
async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
|
|
7
|
+
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
8
|
+
const email = `sse-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
|
9
|
+
await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "E" } }).catch(() => undefined);
|
|
10
|
+
return ctx;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test("sse: publish accepts a message @smoke", async ({ playwright }) => {
|
|
14
|
+
const ctx = await session(playwright);
|
|
15
|
+
const pub = await ctx.post("/api/events", { data: { message: `hi-${Date.now()}` } });
|
|
16
|
+
expect(pub.ok()).toBeTruthy();
|
|
17
|
+
expect(await pub.json()).toMatchObject({ ok: true });
|
|
18
|
+
await ctx.dispose();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("sse: a published message is delivered over the event stream @smoke", async ({ playwright }) => {
|
|
22
|
+
const ctx = await session(playwright);
|
|
23
|
+
// The stream is behind the auth guard — carry the session cookie on the raw fetch.
|
|
24
|
+
const cookies = (await ctx.storageState()).cookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
25
|
+
const ac = new AbortController();
|
|
26
|
+
const stream = await fetch(`${base}/api/events/stream`, { headers: { ...origin, cookie: cookies }, signal: ac.signal }).catch(() => null);
|
|
27
|
+
test.skip(!stream || !stream.ok || !stream.body, "sse stream not reachable");
|
|
28
|
+
expect(stream!.headers.get("content-type")).toContain("text/event-stream");
|
|
29
|
+
|
|
30
|
+
// Subscriber is attached now; publish, then read until the message arrives.
|
|
31
|
+
const msg = `sse-${Date.now()}`;
|
|
32
|
+
await ctx.post("/api/events", { data: { message: msg } });
|
|
33
|
+
|
|
34
|
+
const reader = stream!.body!.getReader();
|
|
35
|
+
const decoder = new TextDecoder();
|
|
36
|
+
let seen = false;
|
|
37
|
+
const deadline = Date.now() + 8000;
|
|
38
|
+
while (Date.now() < deadline && !seen) {
|
|
39
|
+
const { value, done } = await reader.read();
|
|
40
|
+
if (done) break;
|
|
41
|
+
if (decoder.decode(value).includes(msg)) seen = true;
|
|
42
|
+
}
|
|
43
|
+
ac.abort();
|
|
44
|
+
expect(seen).toBe(true);
|
|
45
|
+
await ctx.dispose();
|
|
46
|
+
});
|
|
@@ -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);
|
|
@@ -37,7 +37,9 @@ export async function proxyRequest(
|
|
|
37
37
|
const upstream = await fetch(targetUrl, {
|
|
38
38
|
method: request.method,
|
|
39
39
|
headers,
|
|
40
|
-
|
|
40
|
+
// Preserve multipart uploads and other binary bodies byte-for-byte. Reading
|
|
41
|
+
// them as text corrupts bytes that are not valid UTF-8 before forwarding.
|
|
42
|
+
body: hasBody ? await request.arrayBuffer() : undefined,
|
|
41
43
|
redirect: "manual",
|
|
42
44
|
});
|
|
43
45
|
|
|
@@ -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.
|
|
@@ -1,8 +1,18 @@
|
|
|
1
|
+
apiVersion: traefik.io/v1alpha1
|
|
2
|
+
kind: Middleware
|
|
3
|
+
metadata:
|
|
4
|
+
name: compression
|
|
5
|
+
namespace: podokit
|
|
6
|
+
spec:
|
|
7
|
+
compress: {}
|
|
8
|
+
---
|
|
1
9
|
apiVersion: networking.k8s.io/v1
|
|
2
10
|
kind: Ingress
|
|
3
11
|
metadata:
|
|
4
12
|
name: app
|
|
5
13
|
namespace: podokit
|
|
14
|
+
annotations:
|
|
15
|
+
traefik.ingress.kubernetes.io/router.middlewares: podokit-compression@kubernetescrd
|
|
6
16
|
spec:
|
|
7
17
|
rules:
|
|
8
18
|
- host: example.com
|