@podosoft/podokit 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/add.d.ts +4 -0
- package/dist/add.js +59 -1
- package/dist/create.js +8 -1
- package/dist/deploy-profile.d.ts +92 -0
- package/dist/deploy-profile.js +603 -0
- package/dist/deploy-render.d.ts +18 -0
- package/dist/deploy-render.js +651 -0
- package/dist/deploy.d.ts +65 -0
- package/dist/deploy.js +851 -0
- package/dist/dev.d.ts +1 -0
- package/dist/dev.js +29 -5
- package/dist/index.d.ts +8 -0
- package/dist/index.js +147 -1
- package/dist/lib.d.ts +6 -0
- package/dist/lib.js +18 -1
- package/dist/lockfile.js +3 -0
- package/dist/remove.js +7 -0
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +1 -0
- package/dist/templates/fullstack-nest-svelte/README.md +19 -8
- package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.spec.ts +53 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.ts +69 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/database/migrate.ts +16 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.module.ts +5 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.spec.ts +22 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.ts +33 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/src/app.html +2 -0
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +3 -5
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +37 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/rollback.md +18 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.example +6 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +1 -1
- package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +0 -4
- package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +6 -4
- package/dist/templates/fullstack-nest-svelte/tests/playwright.extensions.ts +44 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +35 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +33 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +18 -10
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +11 -6
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +18 -8
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +15 -9
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +83 -0
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +13 -10
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +69 -3
- package/dist/templates/modules/admin-dashboard/files/tests/seed.teardown.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +13 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +3 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +34 -28
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +29 -39
- package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.spec.ts +36 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.ts +20 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.spec.ts +49 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.ts +3 -11
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.module.ts +3 -1
- package/dist/templates/modules/api-key-auth/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +32 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +35 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -2
- package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +4 -5
- package/dist/templates/modules/bullmq/module.manifest.json +4 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +36 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +26 -5
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.spec.ts +35 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +21 -2
- package/dist/templates/modules/object-storage-s3/module.manifest.json +3 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +85 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +110 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +132 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +100 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.integration.spec.ts +91 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +228 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +97 -38
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +11 -6
- package/dist/templates/modules/rate-limit/module.manifest.json +23 -9
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +28 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +52 -11
- package/dist/templates/modules/redis/module.manifest.json +16 -1
- package/dist/templates/modules/sse/files/apps/api/src/events/events.controller.ts +17 -3
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +9 -2
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +61 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +44 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.redis.spec.ts +42 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.spec.ts +97 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +165 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +41 -0
- package/dist/templates/modules/sse/module.manifest.json +16 -3
- package/dist/templates/todo/AGENTS.md +1 -0
- package/dist/templates/todo/README.md +12 -8
- package/dist/templates/todo/apps/api/Dockerfile +2 -2
- package/dist/templates/todo/apps/api/package.json +1 -0
- package/dist/templates/todo/apps/api/src/config/redis.connection.spec.ts +53 -0
- package/dist/templates/todo/apps/api/src/config/redis.connection.ts +69 -0
- package/dist/templates/todo/apps/api/src/database/migrate.ts +16 -0
- package/dist/templates/todo/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/todo/apps/api/src/health/health.module.ts +5 -1
- package/dist/templates/todo/apps/api/src/health/readiness.service.spec.ts +22 -0
- package/dist/templates/todo/apps/api/src/health/readiness.service.ts +33 -0
- package/dist/templates/todo/apps/web/Dockerfile +2 -2
- package/dist/templates/todo/compose.dev.yaml +3 -5
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +28 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/rollback.md +5 -0
- package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
- package/dist/templates/todo/dot-env.example +6 -0
- package/dist/templates/todo/infra/k3s/api-deployment.yaml +1 -1
- package/dist/templates/todo/infra/k3s/ingress.yaml +0 -4
- package/dist/templates/todo/tests/playwright.config.ts +6 -4
- package/dist/templates/todo/tests/playwright.extensions.ts +44 -0
- package/dist/update.js +17 -13
- package/package.json +2 -2
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { expect, test } from "
|
|
1
|
+
import { expect, test } from "../helpers/disposable-users";
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
3
|
|
|
4
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
5
|
|
|
6
|
-
test("admin can create a user with a custom role", async ({ page }) => {
|
|
6
|
+
test("admin can create a user with a custom role", async ({ page, disposableUsers }) => {
|
|
7
7
|
await ready(page, "/admin/users");
|
|
8
8
|
await page.getByRole("button", { name: "Add user" }).click();
|
|
9
9
|
const role = page.locator("#c-role");
|
|
@@ -16,7 +16,13 @@ test("admin can create a user with a custom role", async ({ page }) => {
|
|
|
16
16
|
// pick the Moderator role from the select
|
|
17
17
|
await role.click();
|
|
18
18
|
await page.getByRole("option", { name: "Moderator" }).click();
|
|
19
|
+
const created = page.waitForResponse(
|
|
20
|
+
(response) =>
|
|
21
|
+
response.url().endsWith("/api/auth/admin/create-user") &&
|
|
22
|
+
response.request().method() === "POST",
|
|
23
|
+
);
|
|
19
24
|
await page.getByRole("button", { name: "Create", exact: true }).click();
|
|
25
|
+
await disposableUsers.trackResponse(await created);
|
|
20
26
|
await expect(page.getByText("User created")).toBeVisible();
|
|
21
27
|
// it appears in the list, filterable by the Moderator role
|
|
22
28
|
await page.locator("#toolbar-search").fill(email);
|
|
@@ -52,43 +58,27 @@ test("row menu exposes admin actions", async ({ page }) => {
|
|
|
52
58
|
await expect(page.getByRole("menuitem", { name: "Impersonate" })).toBeVisible();
|
|
53
59
|
});
|
|
54
60
|
|
|
55
|
-
test("admin can approve a pending registration from the user list", async ({ page }) => {
|
|
61
|
+
test("admin can approve a pending registration from the user list", async ({ page, disposableUsers }) => {
|
|
56
62
|
const email = `pending-ui-${Date.now()}@example.com`;
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
const userId = await disposableUsers.create({ email, name: "Pending UI" });
|
|
64
|
+
expect(
|
|
65
|
+
(await page.request.post("/api/auth/admin/update-user", {
|
|
66
|
+
headers: { origin: base },
|
|
67
|
+
data: { userId, data: { signupApproved: false } },
|
|
68
|
+
})).ok(),
|
|
69
|
+
).toBeTruthy();
|
|
63
70
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
).
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
await expect(row.getByText("Pending approval")).toBeVisible();
|
|
78
|
-
await row.getByRole("button").click();
|
|
79
|
-
const response = page.waitForResponse(
|
|
80
|
-
(res) => res.url().endsWith("/api/auth/admin/update-user") && res.request().method() === "POST",
|
|
81
|
-
);
|
|
82
|
-
await page.getByRole("menuitem", { name: "Approve sign-up" }).click();
|
|
83
|
-
expect((await response).ok()).toBeTruthy();
|
|
84
|
-
await expect(page.getByText("Sign-up approved")).toBeVisible();
|
|
85
|
-
await expect(row.getByText("Active")).toBeVisible();
|
|
86
|
-
} finally {
|
|
87
|
-
if (userId) {
|
|
88
|
-
await page.request.post("/api/auth/admin/remove-user", {
|
|
89
|
-
headers: { origin: base },
|
|
90
|
-
data: { userId },
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
}
|
|
71
|
+
await ready(page, "/admin/users");
|
|
72
|
+
await page.locator("#toolbar-search").fill(email);
|
|
73
|
+
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
74
|
+
const row = page.getByRole("row", { name: new RegExp(email) });
|
|
75
|
+
await expect(row.getByText("Pending approval")).toBeVisible();
|
|
76
|
+
await row.getByRole("button").click();
|
|
77
|
+
const response = page.waitForResponse(
|
|
78
|
+
(res) => res.url().endsWith("/api/auth/admin/update-user") && res.request().method() === "POST",
|
|
79
|
+
);
|
|
80
|
+
await page.getByRole("menuitem", { name: "Approve sign-up" }).click();
|
|
81
|
+
expect((await response).ok()).toBeTruthy();
|
|
82
|
+
await expect(page.getByText("Sign-up approved")).toBeVisible();
|
|
83
|
+
await expect(row.getByText("Active")).toBeVisible();
|
|
94
84
|
});
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
"file": "AGENTS.md",
|
|
120
120
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
121
121
|
"optional": true,
|
|
122
|
-
"text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `npm run admin:bootstrap -w <app>-api`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `$lib/server/guards` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- **Every list/table MUST use the shared `$lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `$lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand.\n"
|
|
122
|
+
"text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `npm run admin:bootstrap -w <app>-api`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `$lib/server/guards` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- **Every list/table MUST use the shared `$lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `$lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
|
|
123
123
|
},
|
|
124
124
|
{
|
|
125
125
|
"file": "apps/api/src/app.module.ts",
|
package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.spec.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { ApiKeyVerifier } from "./api-key-verifier";
|
|
3
|
+
|
|
4
|
+
const originalApiKeys = process.env.API_KEYS;
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
if (originalApiKeys === undefined) {
|
|
8
|
+
delete process.env.API_KEYS;
|
|
9
|
+
} else {
|
|
10
|
+
process.env.API_KEYS = originalApiKeys;
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("ApiKeyVerifier", () => {
|
|
15
|
+
it("accepts trimmed configured keys and rejects other values", () => {
|
|
16
|
+
process.env.API_KEYS = " first-service-key, second-service-key , ";
|
|
17
|
+
const verifier = new ApiKeyVerifier();
|
|
18
|
+
|
|
19
|
+
expect(verifier.isValid("first-service-key")).toBe(true);
|
|
20
|
+
expect(verifier.isValid("second-service-key")).toBe(true);
|
|
21
|
+
expect(verifier.isValid("different-service-key")).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects keys when the allowlist is empty", () => {
|
|
25
|
+
delete process.env.API_KEYS;
|
|
26
|
+
|
|
27
|
+
expect(new ApiKeyVerifier().isValid("any-service-key")).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("compares fixed-length digests for differently sized input", () => {
|
|
31
|
+
process.env.API_KEYS = "short";
|
|
32
|
+
const verifier = new ApiKeyVerifier();
|
|
33
|
+
|
|
34
|
+
expect(verifier.isValid("a-much-longer-unconfigured-service-key")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common";
|
|
2
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
function digest(value: string): Buffer {
|
|
5
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
@Injectable()
|
|
9
|
+
export class ApiKeyVerifier {
|
|
10
|
+
private readonly keyDigests = (process.env.API_KEYS ?? "")
|
|
11
|
+
.split(",")
|
|
12
|
+
.map((key) => key.trim())
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.map(digest);
|
|
15
|
+
|
|
16
|
+
isValid(provided: string): boolean {
|
|
17
|
+
const supplied = digest(provided);
|
|
18
|
+
return this.keyDigests.some((expected) => timingSafeEqual(expected, supplied));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from "@jest/globals";
|
|
2
|
+
import { type ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
|
3
|
+
import type { ApiKeyVerifier } from "./api-key-verifier";
|
|
4
|
+
import { ApiKeyGuard } from "./api-key.guard";
|
|
5
|
+
|
|
6
|
+
function requestContext(apiKey?: string): ExecutionContext {
|
|
7
|
+
return {
|
|
8
|
+
switchToHttp: () => ({
|
|
9
|
+
getRequest: () => ({
|
|
10
|
+
header: (name: string) => (name === "x-api-key" ? apiKey : undefined),
|
|
11
|
+
}),
|
|
12
|
+
}),
|
|
13
|
+
} as unknown as ExecutionContext;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("ApiKeyGuard", () => {
|
|
17
|
+
it("delegates a supplied key to the shared verifier", () => {
|
|
18
|
+
const verifier = {
|
|
19
|
+
isValid: jest.fn<(provided: string) => boolean>(() => true),
|
|
20
|
+
};
|
|
21
|
+
const guard = new ApiKeyGuard(verifier as unknown as ApiKeyVerifier);
|
|
22
|
+
|
|
23
|
+
expect(guard.canActivate(requestContext("service-key"))).toBe(true);
|
|
24
|
+
expect(verifier.isValid).toHaveBeenCalledWith("service-key");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects a missing key without invoking the verifier", () => {
|
|
28
|
+
const verifier = {
|
|
29
|
+
isValid: jest.fn<(provided: string) => boolean>(() => true),
|
|
30
|
+
};
|
|
31
|
+
const guard = new ApiKeyGuard(verifier as unknown as ApiKeyVerifier);
|
|
32
|
+
|
|
33
|
+
expect(() => guard.canActivate(requestContext())).toThrow(
|
|
34
|
+
new UnauthorizedException("Missing X-API-Key"),
|
|
35
|
+
);
|
|
36
|
+
expect(verifier.isValid).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects a key that the verifier does not recognize", () => {
|
|
40
|
+
const verifier = {
|
|
41
|
+
isValid: jest.fn<(provided: string) => boolean>(() => false),
|
|
42
|
+
};
|
|
43
|
+
const guard = new ApiKeyGuard(verifier as unknown as ApiKeyVerifier);
|
|
44
|
+
|
|
45
|
+
expect(() => guard.canActivate(requestContext("invalid-key"))).toThrow(
|
|
46
|
+
new UnauthorizedException("Invalid API key"),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { type CanActivate, type ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
|
|
2
|
-
import { timingSafeEqual } from "node:crypto";
|
|
3
2
|
import type { Request } from "express";
|
|
3
|
+
import { ApiKeyVerifier } from "./api-key-verifier";
|
|
4
4
|
|
|
5
5
|
// Validates the X-API-Key header against the API_KEYS allowlist.
|
|
6
6
|
@Injectable()
|
|
7
7
|
export class ApiKeyGuard implements CanActivate {
|
|
8
|
-
private readonly
|
|
9
|
-
.split(",")
|
|
10
|
-
.map((key) => key.trim())
|
|
11
|
-
.filter(Boolean);
|
|
8
|
+
constructor(private readonly verifier: ApiKeyVerifier) {}
|
|
12
9
|
|
|
13
10
|
canActivate(context: ExecutionContext): boolean {
|
|
14
11
|
const request = context.switchToHttp().getRequest<Request>();
|
|
@@ -16,12 +13,7 @@ export class ApiKeyGuard implements CanActivate {
|
|
|
16
13
|
if (!provided) {
|
|
17
14
|
throw new UnauthorizedException("Missing X-API-Key");
|
|
18
15
|
}
|
|
19
|
-
|
|
20
|
-
const valid = this.keys.some((key) => {
|
|
21
|
-
const expected = Buffer.from(key);
|
|
22
|
-
return expected.length === supplied.length && timingSafeEqual(expected, supplied);
|
|
23
|
-
});
|
|
24
|
-
if (!valid) {
|
|
16
|
+
if (!this.verifier.isValid(provided)) {
|
|
25
17
|
throw new UnauthorizedException("Invalid API key");
|
|
26
18
|
}
|
|
27
19
|
return true;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Module } from "@nestjs/common";
|
|
2
2
|
import { MachineController } from "../machine/machine.controller";
|
|
3
3
|
import { ApiKeyGuard } from "./api-key.guard";
|
|
4
|
+
import { ApiKeyVerifier } from "./api-key-verifier";
|
|
4
5
|
|
|
5
6
|
@Module({
|
|
6
7
|
controllers: [MachineController],
|
|
7
|
-
providers: [ApiKeyGuard],
|
|
8
|
+
providers: [ApiKeyGuard, ApiKeyVerifier],
|
|
9
|
+
exports: [ApiKeyVerifier],
|
|
8
10
|
})
|
|
9
11
|
export class ApiKeyModule {}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "api-key-auth",
|
|
3
|
-
"description": "API-key auth for machine/service clients (X-API-Key), separate from user sessions. Provides an ApiKeyGuard and
|
|
3
|
+
"description": "API-key auth for machine/service clients (X-API-Key), separate from user sessions. Provides an ApiKeyGuard and shared ApiKeyVerifier that compare SHA-256 key digests in constant time. Requires auth.",
|
|
4
4
|
"requires": ["auth"],
|
|
5
5
|
"targetApp": "api",
|
|
6
6
|
"env": [
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function authSecret(): string {
|
|
2
|
+
const value = process.env.BETTER_AUTH_SECRET?.trim();
|
|
3
|
+
if (
|
|
4
|
+
process.env.NODE_ENV === "production" &&
|
|
5
|
+
(!value || value.startsWith("change-me-"))
|
|
6
|
+
) {
|
|
7
|
+
throw new Error("BETTER_AUTH_SECRET must be set to a non-placeholder value in production");
|
|
8
|
+
}
|
|
9
|
+
const resolved = value || "development-only-auth-secret-min-32-characters";
|
|
10
|
+
if (resolved.length < 32) {
|
|
11
|
+
throw new Error("BETTER_AUTH_SECRET must contain at least 32 characters");
|
|
12
|
+
}
|
|
13
|
+
return resolved;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function authBaseUrl(): string {
|
|
17
|
+
const value = process.env.BETTER_AUTH_URL?.trim();
|
|
18
|
+
if (process.env.NODE_ENV === "production" && !value) {
|
|
19
|
+
throw new Error("BETTER_AUTH_URL must be set in production");
|
|
20
|
+
}
|
|
21
|
+
const resolved = value || "http://localhost:5002";
|
|
22
|
+
let url: URL;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(resolved);
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Error("BETTER_AUTH_URL must be a valid URL");
|
|
27
|
+
}
|
|
28
|
+
if (process.env.NODE_ENV === "production" && url.protocol !== "https:") {
|
|
29
|
+
throw new Error("BETTER_AUTH_URL must use HTTPS in production");
|
|
30
|
+
}
|
|
31
|
+
return resolved;
|
|
32
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { authBaseUrl, authSecret } from "./auth-environment";
|
|
3
|
+
|
|
4
|
+
const originalEnvironment = {
|
|
5
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
6
|
+
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
|
|
7
|
+
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
for (const [key, value] of Object.entries(originalEnvironment)) {
|
|
12
|
+
if (value === undefined) delete process.env[key];
|
|
13
|
+
else process.env[key] = value;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("authentication production environment", () => {
|
|
18
|
+
it("rejects missing and placeholder production secrets", () => {
|
|
19
|
+
process.env.NODE_ENV = "production";
|
|
20
|
+
delete process.env.BETTER_AUTH_SECRET;
|
|
21
|
+
expect(() => authSecret()).toThrow("must be set");
|
|
22
|
+
process.env.BETTER_AUTH_SECRET = "change-me-in-production-min-32-characters";
|
|
23
|
+
expect(() => authSecret()).toThrow("non-placeholder");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("requires an HTTPS production origin", () => {
|
|
27
|
+
process.env.NODE_ENV = "production";
|
|
28
|
+
delete process.env.BETTER_AUTH_URL;
|
|
29
|
+
expect(() => authBaseUrl()).toThrow("must be set");
|
|
30
|
+
process.env.BETTER_AUTH_URL = "http://app.example.com";
|
|
31
|
+
expect(() => authBaseUrl()).toThrow("must use HTTPS");
|
|
32
|
+
process.env.BETTER_AUTH_URL = "https://app.example.com";
|
|
33
|
+
expect(authBaseUrl()).toBe("https://app.example.com");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -12,6 +12,7 @@ import { passkey } from "@better-auth/passkey";
|
|
|
12
12
|
import { oauthProvider } from "@better-auth/oauth-provider";
|
|
13
13
|
import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-contracts";
|
|
14
14
|
import { runUserDeletedHandlers } from "./user-delete-handlers";
|
|
15
|
+
import { authBaseUrl, authSecret } from "./auth-environment";
|
|
15
16
|
// podokit:begin:auth-imports
|
|
16
17
|
// podokit:end:auth-imports
|
|
17
18
|
|
|
@@ -214,8 +215,8 @@ export function buildAuth(config: AuthConfig) {
|
|
|
214
215
|
...(session ? { session } : {}),
|
|
215
216
|
plugins,
|
|
216
217
|
hooks,
|
|
217
|
-
secret:
|
|
218
|
-
baseURL:
|
|
218
|
+
secret: authSecret(),
|
|
219
|
+
baseURL: authBaseUrl(),
|
|
219
220
|
advanced: {
|
|
220
221
|
ipAddress: {
|
|
221
222
|
// The API sits behind the SvelteKit server proxy, which forwards the
|
|
@@ -2,12 +2,22 @@ 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";
|
|
5
|
+
import { ReadinessService, type ReadinessStatus } from "./readiness.service";
|
|
6
|
+
|
|
7
|
+
interface ReadinessResponse {
|
|
8
|
+
status: "ready";
|
|
9
|
+
db: "up";
|
|
10
|
+
checks: Record<string, ReadinessStatus>;
|
|
11
|
+
}
|
|
5
12
|
|
|
6
13
|
// Health checks stay public under the global auth guard.
|
|
7
14
|
@Public()
|
|
8
15
|
@Controller("health")
|
|
9
16
|
export class HealthController {
|
|
10
|
-
constructor(
|
|
17
|
+
constructor(
|
|
18
|
+
@InjectDataSource() private readonly dataSource: DataSource,
|
|
19
|
+
private readonly readinessService: ReadinessService,
|
|
20
|
+
) {}
|
|
11
21
|
|
|
12
22
|
@Get()
|
|
13
23
|
liveness(): { status: string; uptime: number; timestamp: string } {
|
|
@@ -15,12 +25,17 @@ export class HealthController {
|
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
@Get("ready")
|
|
18
|
-
async readiness(): Promise<
|
|
28
|
+
async readiness(): Promise<ReadinessResponse> {
|
|
29
|
+
let db: ReadinessStatus = "up";
|
|
19
30
|
try {
|
|
20
31
|
await this.dataSource.query("SELECT 1");
|
|
21
|
-
return { status: "ready", db: "up" };
|
|
22
32
|
} catch {
|
|
23
|
-
|
|
33
|
+
db = "down";
|
|
34
|
+
}
|
|
35
|
+
const checks = await this.readinessService.run();
|
|
36
|
+
if (db === "down" || Object.values(checks).includes("down")) {
|
|
37
|
+
throw new ServiceUnavailableException({ status: "degraded", db, checks });
|
|
24
38
|
}
|
|
39
|
+
return { status: "ready", db: "up", checks };
|
|
25
40
|
}
|
|
26
41
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
+
import { redisConnectionOptions, type RedisConnectionOptions } from "../config/redis.connection";
|
|
2
|
+
|
|
1
3
|
export const DEMO_QUEUE = "demo";
|
|
2
4
|
|
|
3
|
-
export function redisConnection():
|
|
4
|
-
return {
|
|
5
|
-
host: process.env.REDIS_HOST ?? "localhost",
|
|
6
|
-
port: Number(process.env.REDIS_PORT ?? 6379),
|
|
7
|
-
};
|
|
5
|
+
export function redisConnection(): RedisConnectionOptions {
|
|
6
|
+
return redisConnectionOptions(process.env, { maxRetriesPerRequest: null });
|
|
8
7
|
}
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
"name": "bullmq",
|
|
3
3
|
"description": "Background jobs with BullMQ: a demo queue, a separate worker process, and enqueue/status endpoints (needs Redis).",
|
|
4
4
|
"requires": [],
|
|
5
|
+
"requiredProjectFiles": [
|
|
6
|
+
"apps/api/src/config/redis.connection.ts"
|
|
7
|
+
],
|
|
5
8
|
"targetApp": "api",
|
|
6
9
|
"dependencies": {
|
|
7
10
|
"@nestjs/bullmq": "^11.0.4",
|
|
@@ -12,7 +15,7 @@
|
|
|
12
15
|
"dev:worker": "nest start --watch --builder swc --entryFile main-worker"
|
|
13
16
|
},
|
|
14
17
|
"env": [
|
|
15
|
-
"# bullmq uses
|
|
18
|
+
"# bullmq uses REDIS_URL or the individual Redis settings defined above"
|
|
16
19
|
],
|
|
17
20
|
"inject": [
|
|
18
21
|
{
|
package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from "@jest/globals";
|
|
2
|
+
import type { EventsService } from "../events/events.service";
|
|
3
|
+
import type { RedisService } from "../redis/redis.service";
|
|
4
|
+
import { ProgressBridge } from "./progress.bridge";
|
|
5
|
+
import { PROGRESS_CHANNEL } from "./progress.processor";
|
|
6
|
+
|
|
7
|
+
describe("ProgressBridge", () => {
|
|
8
|
+
it("delivers worker fan-out locally without republishing it", async () => {
|
|
9
|
+
let handler: ((message: string) => void) | undefined;
|
|
10
|
+
const unsubscribe = jest.fn<() => Promise<void>>(() => Promise.resolve());
|
|
11
|
+
const subscribe = jest.fn(
|
|
12
|
+
(channel: string, listener: (message: string) => void): Promise<() => Promise<void>> => {
|
|
13
|
+
expect(channel).toBe(PROGRESS_CHANNEL);
|
|
14
|
+
handler = listener;
|
|
15
|
+
return Promise.resolve(unsubscribe);
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
const publishLocal = jest.fn<(data: unknown) => void>();
|
|
19
|
+
const redis = { subscribe } as unknown as RedisService;
|
|
20
|
+
const events = { publishLocal } as unknown as EventsService;
|
|
21
|
+
const bridge = new ProgressBridge(redis, events);
|
|
22
|
+
|
|
23
|
+
await bridge.onModuleInit();
|
|
24
|
+
handler?.('{"jobId":"1","progress":50}');
|
|
25
|
+
handler?.("invalid");
|
|
26
|
+
|
|
27
|
+
expect(publishLocal).toHaveBeenCalledTimes(1);
|
|
28
|
+
expect(publishLocal).toHaveBeenCalledWith({
|
|
29
|
+
type: "job-progress",
|
|
30
|
+
jobId: "1",
|
|
31
|
+
progress: 50,
|
|
32
|
+
});
|
|
33
|
+
await bridge.onModuleDestroy();
|
|
34
|
+
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -1,19 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
Logger,
|
|
4
|
+
type OnModuleDestroy,
|
|
5
|
+
type OnModuleInit,
|
|
6
|
+
} from "@nestjs/common";
|
|
2
7
|
import { RedisService } from "../redis/redis.service";
|
|
3
8
|
import { EventsService } from "../events/events.service";
|
|
4
9
|
import { PROGRESS_CHANNEL } from "./progress.processor";
|
|
5
10
|
|
|
6
11
|
// Runs in the API process: relays worker progress (Redis pub/sub) to SSE.
|
|
7
12
|
@Injectable()
|
|
8
|
-
export class ProgressBridge implements OnModuleInit {
|
|
13
|
+
export class ProgressBridge implements OnModuleInit, OnModuleDestroy {
|
|
14
|
+
private readonly logger = new Logger(ProgressBridge.name);
|
|
15
|
+
private unsubscribe?: () => Promise<void>;
|
|
16
|
+
|
|
9
17
|
constructor(
|
|
10
18
|
private readonly redis: RedisService,
|
|
11
19
|
private readonly events: EventsService,
|
|
12
20
|
) {}
|
|
13
21
|
|
|
14
|
-
onModuleInit(): void {
|
|
15
|
-
this.redis.subscribe(PROGRESS_CHANNEL, (message) => {
|
|
16
|
-
|
|
22
|
+
async onModuleInit(): Promise<void> {
|
|
23
|
+
this.unsubscribe = await this.redis.subscribe(PROGRESS_CHANNEL, (message) => {
|
|
24
|
+
try {
|
|
25
|
+
const parsed: unknown = JSON.parse(message);
|
|
26
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
27
|
+
this.logger.warn("Discarded invalid job progress event");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
this.events.publishLocal({ type: "job-progress", ...parsed });
|
|
31
|
+
} catch {
|
|
32
|
+
this.logger.warn("Discarded malformed job progress event");
|
|
33
|
+
}
|
|
17
34
|
});
|
|
18
35
|
}
|
|
36
|
+
|
|
37
|
+
async onModuleDestroy(): Promise<void> {
|
|
38
|
+
await this.unsubscribe?.();
|
|
39
|
+
}
|
|
19
40
|
}
|
package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.spec.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { HeadBucketCommand } from "@aws-sdk/client-s3";
|
|
2
|
+
import { describe, expect, it, jest } from "@jest/globals";
|
|
3
|
+
import type { ReadinessService } from "../health/readiness.service";
|
|
4
|
+
import { StorageService } from "./storage.service";
|
|
5
|
+
|
|
6
|
+
describe("StorageService readiness", () => {
|
|
7
|
+
it("registers a bucket probe and releases resources on shutdown", async () => {
|
|
8
|
+
const unregister = jest.fn();
|
|
9
|
+
const register = jest.fn<
|
|
10
|
+
(name: string, check: () => Promise<void>) => () => void
|
|
11
|
+
>(() => unregister);
|
|
12
|
+
const service = new StorageService({ register } as unknown as ReadinessService);
|
|
13
|
+
const send = jest
|
|
14
|
+
.fn<(...arguments_: unknown[]) => Promise<unknown>>()
|
|
15
|
+
.mockResolvedValue({});
|
|
16
|
+
const destroy = jest.fn();
|
|
17
|
+
Object.defineProperty(service, "client", {
|
|
18
|
+
value: { send, destroy },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
service.onModuleInit();
|
|
22
|
+
expect(register).toHaveBeenCalledWith("object-storage", expect.any(Function));
|
|
23
|
+
const probe = register.mock.calls[0]?.[1];
|
|
24
|
+
expect(probe).toBeDefined();
|
|
25
|
+
await probe?.();
|
|
26
|
+
expect(send).toHaveBeenCalledWith(
|
|
27
|
+
expect.any(HeadBucketCommand),
|
|
28
|
+
expect.objectContaining({ abortSignal: expect.any(AbortSignal) }),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
service.onModuleDestroy();
|
|
32
|
+
expect(unregister).toHaveBeenCalledTimes(1);
|
|
33
|
+
expect(destroy).toHaveBeenCalledTimes(1);
|
|
34
|
+
});
|
|
35
|
+
});
|
package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
|
-
import { Injectable } from "@nestjs/common";
|
|
1
|
+
import { Injectable, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
|
2
2
|
import {
|
|
3
3
|
DeleteObjectCommand,
|
|
4
4
|
GetObjectCommand,
|
|
5
|
+
HeadBucketCommand,
|
|
5
6
|
PutObjectCommand,
|
|
6
7
|
S3Client,
|
|
7
8
|
} from "@aws-sdk/client-s3";
|
|
8
9
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
10
|
+
import { ReadinessService } from "../health/readiness.service";
|
|
9
11
|
import { storageClientConfig, storageSettings } from "./storage.config";
|
|
10
12
|
|
|
11
13
|
@Injectable()
|
|
12
|
-
export class StorageService {
|
|
14
|
+
export class StorageService implements OnModuleInit, OnModuleDestroy {
|
|
13
15
|
private readonly client = new S3Client(storageClientConfig());
|
|
14
16
|
private readonly bucket = storageSettings().bucket;
|
|
17
|
+
private unregisterReadiness?: () => void;
|
|
18
|
+
|
|
19
|
+
constructor(private readonly readiness: ReadinessService) {}
|
|
20
|
+
|
|
21
|
+
onModuleInit(): void {
|
|
22
|
+
this.unregisterReadiness = this.readiness.register("object-storage", async () => {
|
|
23
|
+
await this.client.send(
|
|
24
|
+
new HeadBucketCommand({ Bucket: this.bucket }),
|
|
25
|
+
{ abortSignal: AbortSignal.timeout(3_000) },
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
15
29
|
|
|
16
30
|
async put(key: string, body: Buffer | string, contentType?: string): Promise<void> {
|
|
17
31
|
await this.client.send(
|
|
@@ -34,4 +48,9 @@ export class StorageService {
|
|
|
34
48
|
async delete(key: string): Promise<void> {
|
|
35
49
|
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
36
50
|
}
|
|
51
|
+
|
|
52
|
+
onModuleDestroy(): void {
|
|
53
|
+
this.unregisterReadiness?.();
|
|
54
|
+
this.client.destroy();
|
|
55
|
+
}
|
|
37
56
|
}
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
"name": "object-storage-s3",
|
|
3
3
|
"description": "S3-compatible object storage (AWS S3 or MinIO, chosen by STORAGE_PROVIDER) with a StorageService and presigned URLs.",
|
|
4
4
|
"requires": [],
|
|
5
|
+
"requiredProjectFiles": [
|
|
6
|
+
"apps/api/src/health/readiness.service.ts"
|
|
7
|
+
],
|
|
5
8
|
"targetApp": "api",
|
|
6
9
|
"dependencies": {
|
|
7
10
|
"@aws-sdk/client-s3": "^3.700.0",
|