@podosoft/podokit 0.8.0 → 0.10.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/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/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/backend-proxy.ts +3 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+layout.svelte +2 -2
- package/dist/templates/fullstack-nest-svelte/dot-gitignore +6 -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/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 +3 -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 +90 -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)/+layout.server.ts +17 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +8 -680
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +10 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +30 -11
- 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/(auth)/login/+page.svelte +96 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +1 -0
- 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/apps/web/src/routes/setup-2fa/+page.server.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +122 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +106 -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-flows.ui.spec.ts +74 -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 +63 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +13 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +12 -8
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +35 -0
- 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 +63 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +23 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +137 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +17 -0
- 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/account/account.controller.ts +8 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/two-factor-required.guard.ts +68 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +5 -1
- package/dist/templates/modules/auth/module.manifest.json +10 -0
- 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/tests/api/rate-limit.api.spec.ts +35 -0
- 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/web/src/lib/components/site-runtime.svelte +7 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +3 -1
- package/dist/templates/todo/apps/web/src/routes/+layout.svelte +2 -2
- package/dist/templates/todo/dot-gitignore +6 -0
- package/dist/templates/todo/infra/k3s/ingress.yaml +10 -0
- package/dist/templates/todo/infra/traefik/dynamic.yml +4 -0
- 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,137 @@
|
|
|
1
|
+
import { expect, test, type Page } from "@playwright/test";
|
|
2
|
+
import { anonState } from "../helpers/accounts";
|
|
3
|
+
import { totpCode } from "../helpers/totp";
|
|
4
|
+
|
|
5
|
+
test.use({ storageState: anonState });
|
|
6
|
+
|
|
7
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
8
|
+
const origin = { origin: base };
|
|
9
|
+
|
|
10
|
+
async function openHydratedLogin(page: Page, redirect?: string): Promise<void> {
|
|
11
|
+
const query = redirect ? `?redirect=${encodeURIComponent(redirect)}` : "";
|
|
12
|
+
await page.goto(`/login${query}`);
|
|
13
|
+
// The form is server-rendered before Svelte attaches its submit handler. Wait
|
|
14
|
+
// for the client bundle so a fast test click cannot trigger a native reload.
|
|
15
|
+
await page.waitForLoadState("networkidle");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A disposable account (never the shared admin/user) so enabling 2FA here can't
|
|
19
|
+
// invalidate the seeded sessions the other auth specs rely on.
|
|
20
|
+
test("two-factor: sign in with a backup code from the login page @smoke", async ({ page, playwright }) => {
|
|
21
|
+
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
22
|
+
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
23
|
+
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
24
|
+
|
|
25
|
+
const email = `tf-ui-${Date.now()}@example.com`;
|
|
26
|
+
const pw = "Podokit3e-Str0ng!pw";
|
|
27
|
+
await api.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "UI" } });
|
|
28
|
+
const enable = await (await api.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
|
|
29
|
+
await api.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
|
|
30
|
+
const backupCode = (enable.backupCodes as string[])[0]!;
|
|
31
|
+
await api.dispose();
|
|
32
|
+
|
|
33
|
+
// Password sign-in hands off to the second-factor step (no session yet).
|
|
34
|
+
await openHydratedLogin(page, "/admin/account");
|
|
35
|
+
await page.getByLabel("Email").fill(email);
|
|
36
|
+
await page.getByLabel("Password").fill(pw);
|
|
37
|
+
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
38
|
+
|
|
39
|
+
const step = page.getByTestId("two-factor-step");
|
|
40
|
+
await expect(step).toBeVisible();
|
|
41
|
+
|
|
42
|
+
// Switch to the backup-code input.
|
|
43
|
+
await page.getByRole("button", { name: "Use a backup code instead" }).click();
|
|
44
|
+
|
|
45
|
+
// A wrong code surfaces a localized, mapped message — not better-auth's raw
|
|
46
|
+
// English string ("Invalid backup code").
|
|
47
|
+
await page.getByLabel("Backup code").fill("00000000");
|
|
48
|
+
await page.getByRole("button", { name: "Verify", exact: true }).click();
|
|
49
|
+
await expect(page.getByRole("alert")).toContainText("isn't valid");
|
|
50
|
+
await expect(page.getByRole("alert")).not.toContainText("Invalid backup code");
|
|
51
|
+
|
|
52
|
+
// The real code completes the login.
|
|
53
|
+
await page.getByLabel("Backup code").fill(backupCode);
|
|
54
|
+
await page.getByRole("button", { name: "Verify", exact: true }).click();
|
|
55
|
+
await expect(page).toHaveURL(/\/admin\/account/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("require-2fa: a new sign-up is forced through the enrolment page @smoke", async ({ page, playwright }) => {
|
|
59
|
+
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
60
|
+
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
61
|
+
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
62
|
+
await admin.post("/api/auth/sign-in/email", { data: { email: "admin@example.com", password: "Podokit3e-Str0ng!pw" } });
|
|
63
|
+
|
|
64
|
+
const email = `rq-ui-${Date.now()}@example.com`;
|
|
65
|
+
const pw = "Podokit3e-Str0ng!pw";
|
|
66
|
+
try {
|
|
67
|
+
await admin.put("/api/account/settings", { data: { require2fa: true } });
|
|
68
|
+
// A fresh un-enrolled user; poll (via API) until the guard cache picks up the policy.
|
|
69
|
+
const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
70
|
+
await probe.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQUI" } });
|
|
71
|
+
await expect(async () => {
|
|
72
|
+
expect((await probe.get("/api/account/me")).status()).toBe(403);
|
|
73
|
+
}).toPass({ timeout: 8000 });
|
|
74
|
+
await probe.dispose();
|
|
75
|
+
|
|
76
|
+
// Signing in through the UI lands on the mandatory enrolment page.
|
|
77
|
+
await openHydratedLogin(page);
|
|
78
|
+
await page.getByLabel("Email").fill(email);
|
|
79
|
+
await page.getByLabel("Password").fill(pw);
|
|
80
|
+
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
81
|
+
await expect(page).toHaveURL(/\/setup-2fa/);
|
|
82
|
+
|
|
83
|
+
// Complete enrolment entirely through the UI: password → QR + backup codes →
|
|
84
|
+
// authenticator code → activate. The code is computed from the on-page URI.
|
|
85
|
+
await page.getByLabel("Confirm your password").fill(pw);
|
|
86
|
+
await page.getByRole("button", { name: "Continue" }).click();
|
|
87
|
+
await expect(page.getByTestId("backup-codes")).toBeVisible();
|
|
88
|
+
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
|
|
89
|
+
const uri = (await page.locator("code").first().innerText()).trim();
|
|
90
|
+
await page.getByRole("checkbox").click();
|
|
91
|
+
await page.getByLabel("3. Enter the 6-digit code").fill(totpCode(uri));
|
|
92
|
+
await page.getByRole("button", { name: "Activate and continue" }).click();
|
|
93
|
+
|
|
94
|
+
// Enrolled → the gate lets them into the app (no longer bounced to /setup-2fa).
|
|
95
|
+
await expect(page).toHaveURL(/\/admin/);
|
|
96
|
+
} finally {
|
|
97
|
+
await admin.put("/api/account/settings", { data: { require2fa: false } });
|
|
98
|
+
await expect(async () => {
|
|
99
|
+
const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
100
|
+
await probe.post("/api/auth/sign-in/email", { data: { email: "user@example.com", password: "Podokit3e-Str0ng!pw" } });
|
|
101
|
+
const status = (await probe.get("/api/account/me")).status();
|
|
102
|
+
await probe.dispose();
|
|
103
|
+
expect(status).toBe(200);
|
|
104
|
+
}).toPass({ timeout: 8000 });
|
|
105
|
+
await admin.dispose();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("account: regenerate backup codes shows a fresh set @smoke", async ({ page, playwright }) => {
|
|
110
|
+
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
111
|
+
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
112
|
+
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
113
|
+
const email = `regen-ui-${Date.now()}@example.com`;
|
|
114
|
+
const pw = "Podokit3e-Str0ng!pw";
|
|
115
|
+
await api.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RG" } });
|
|
116
|
+
const enable = await (await api.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
|
|
117
|
+
await api.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
|
|
118
|
+
const backupCode = (enable.backupCodes as string[])[0]!;
|
|
119
|
+
await api.dispose();
|
|
120
|
+
|
|
121
|
+
// Sign in (backup-code path) straight to the account page.
|
|
122
|
+
await openHydratedLogin(page, "/admin/account");
|
|
123
|
+
await page.getByLabel("Email").fill(email);
|
|
124
|
+
await page.getByLabel("Password").fill(pw);
|
|
125
|
+
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
126
|
+
await page.getByRole("button", { name: "Use a backup code instead" }).click();
|
|
127
|
+
await page.getByLabel("Backup code").fill(backupCode);
|
|
128
|
+
await page.getByRole("button", { name: "Verify", exact: true }).click();
|
|
129
|
+
await expect(page).toHaveURL(/\/admin\/account/);
|
|
130
|
+
|
|
131
|
+
// Security → regenerate backup codes → a fresh set is shown.
|
|
132
|
+
await page.getByRole("button", { name: "Security" }).click();
|
|
133
|
+
await page.locator("#tf-off-pw").fill(pw);
|
|
134
|
+
await page.getByRole("button", { name: "Regenerate backup codes" }).click();
|
|
135
|
+
await expect(page.getByTestId("backup-codes")).toBeVisible();
|
|
136
|
+
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
|
|
137
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { anonState } from "../helpers/accounts";
|
|
3
|
+
import { ready } from "../helpers/hydration";
|
|
4
|
+
|
|
5
|
+
test.use({ storageState: anonState });
|
|
6
|
+
|
|
7
|
+
test("verify-email renders the resend form with the address prefilled @smoke", async ({ page }) => {
|
|
8
|
+
await ready(page, "/verify-email?email=someone%40example.com");
|
|
9
|
+
await expect(page.getByLabel("Email")).toHaveValue("someone@example.com");
|
|
10
|
+
await expect(page.getByRole("button", { name: "Resend verification email" })).toBeVisible();
|
|
11
|
+
await expect(page.getByRole("link", { name: /sign in/i })).toBeVisible();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("verify-email shows the link-error alert on a bad link", async ({ page }) => {
|
|
15
|
+
await ready(page, "/verify-email?error=invalid");
|
|
16
|
+
await expect(page.getByRole("alert")).toBeVisible();
|
|
17
|
+
});
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"Set ADMIN_EMAILS in .env (comma-separated). Those emails become admins on sign-up.",
|
|
60
60
|
"Re-run the auth migration to add the admin columns: npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts",
|
|
61
61
|
"Start the app (npm run dev), open /signup, register an ADMIN_EMAILS address, then sign in.",
|
|
62
|
-
"Admin pages: /admin/users (list, ban, set role, revoke sessions).
|
|
62
|
+
"Admin pages: /admin/users (list, ban, set role, revoke sessions). Signed-in users manage their profile and security at /account; /admin/account remains available in the admin shell. Admin, account, authentication, maintenance, and API responses include X-Robots-Tag: noindex, nofollow by default.",
|
|
63
63
|
"Password reset emails go through the mailer module (installed automatically with auth); in dev without SMTP they are logged to the API console. Configure SMTP in admin settings or via SMTP_* env for production."
|
|
64
64
|
]
|
|
65
65
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
// api-key auth is machine-to-machine: clients call the backend API directly, not
|
|
4
|
+
// through the browser web proxy (which strips custom headers by design). So this
|
|
5
|
+
// test targets the backend URL (`E2E_API_URL`) and skips when it isn't provided.
|
|
6
|
+
const apiBase = process.env.E2E_API_URL;
|
|
7
|
+
const apiOrigin = { origin: apiBase ?? "" };
|
|
8
|
+
|
|
9
|
+
// The static service key(s) come from the `API_KEYS` env var; the module ships
|
|
10
|
+
// `dev-key-please-change` as the default in .env.example.
|
|
11
|
+
const KEY = process.env.E2E_API_KEY ?? "dev-key-please-change";
|
|
12
|
+
|
|
13
|
+
test("api-key auth: a valid key passes, missing/invalid are 401 @smoke", async ({ request }) => {
|
|
14
|
+
test.skip(!apiBase, "E2E_API_URL (backend base) not set");
|
|
15
|
+
const ok = await request.get(`${apiBase}/machine/ping`, { headers: { ...apiOrigin, "x-api-key": KEY } });
|
|
16
|
+
test.skip(ok.status() === 404, "api-key-auth not installed");
|
|
17
|
+
expect(ok.status()).toBe(200);
|
|
18
|
+
expect(await ok.json()).toMatchObject({ ok: true, via: "api-key" });
|
|
19
|
+
|
|
20
|
+
const missing = await request.get(`${apiBase}/machine/ping`, { headers: apiOrigin });
|
|
21
|
+
expect(missing.status()).toBe(401);
|
|
22
|
+
|
|
23
|
+
const invalid = await request.get(`${apiBase}/machine/ping`, { headers: { ...apiOrigin, "x-api-key": "wrong-key" } });
|
|
24
|
+
expect(invalid.status()).toBe(401);
|
|
25
|
+
});
|
|
@@ -29,6 +29,14 @@ export class AccountController {
|
|
|
29
29
|
return session.user;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// The "require two-factor" policy, for the web enrolment gate. Kept out of the
|
|
33
|
+
// typed Capabilities (that lives in a published package); allow-listed in
|
|
34
|
+
// TwoFactorRequiredGuard so a not-yet-enrolled user can still read it.
|
|
35
|
+
@Get("require-2fa")
|
|
36
|
+
requireTwoFactor(): { require2fa: boolean } {
|
|
37
|
+
return { require2fa: this.settings.getBool("require2fa") };
|
|
38
|
+
}
|
|
39
|
+
|
|
32
40
|
// Which optional auth features are enabled, so the UI can show/hide sections.
|
|
33
41
|
// Public so the login page (unauthenticated) can offer available sign-in methods.
|
|
34
42
|
@Public()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type CanActivate, type ExecutionContext, Injectable } from "@nestjs/common";
|
|
2
|
+
import type { Request } from "express";
|
|
3
|
+
import { fromNodeHeaders } from "better-auth/node";
|
|
4
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
5
|
+
import { FLAG_DEFAULTS } from "../settings/flag-defaults";
|
|
6
|
+
import { pool } from "./db";
|
|
7
|
+
import { auth } from "./auth";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Enforces the "require two-factor" policy (admin Settings): while it is on, a
|
|
11
|
+
* signed-in user who has not enrolled in 2FA is blocked from every app route
|
|
12
|
+
* until they do — the security boundary behind the web enrolment gate (the web
|
|
13
|
+
* redirect is only UX).
|
|
14
|
+
*
|
|
15
|
+
* Registered after the better-auth AuthGuard. Requests with no user session
|
|
16
|
+
* (unauthenticated, or machine api-key/bearer calls) are not gated here — the
|
|
17
|
+
* auth guard owns authentication. A short allow-list keeps the endpoints needed
|
|
18
|
+
* to read the policy and enrol reachable; the 2FA enrol/verify endpoints live
|
|
19
|
+
* under /api/auth/two-factor (better-auth), never behind this guard.
|
|
20
|
+
*
|
|
21
|
+
* `require2fa` is read from app_setting through a small TTL cache (self-contained,
|
|
22
|
+
* so this global guard needs no DI), the same way auth/feature-gate.ts reads flags.
|
|
23
|
+
*/
|
|
24
|
+
@Injectable()
|
|
25
|
+
export class TwoFactorRequiredGuard implements CanActivate {
|
|
26
|
+
// Reachable while gated: read the policy/capabilities, and — break-glass — the
|
|
27
|
+
// admin-only flag toggle, so an admin who turned the policy on (and isn't yet
|
|
28
|
+
// enrolled) can still turn it back off instead of being hard-locked.
|
|
29
|
+
private static readonly ALLOW = [
|
|
30
|
+
"/account/capabilities",
|
|
31
|
+
"/account/require-2fa",
|
|
32
|
+
"/account/settings",
|
|
33
|
+
"/health",
|
|
34
|
+
];
|
|
35
|
+
private static readonly TTL_MS = 3_000;
|
|
36
|
+
private cached = FLAG_DEFAULTS.require2fa;
|
|
37
|
+
private fetchedAt = 0;
|
|
38
|
+
|
|
39
|
+
private async enforced(): Promise<boolean> {
|
|
40
|
+
if (Date.now() - this.fetchedAt < TwoFactorRequiredGuard.TTL_MS) return this.cached;
|
|
41
|
+
try {
|
|
42
|
+
const res = await pool.query<{ value: string }>(
|
|
43
|
+
'SELECT "value" FROM "app_setting" WHERE "key" = $1',
|
|
44
|
+
["require2fa"],
|
|
45
|
+
);
|
|
46
|
+
this.cached = res.rows[0]?.value === "true";
|
|
47
|
+
} catch {
|
|
48
|
+
this.cached = FLAG_DEFAULTS.require2fa;
|
|
49
|
+
}
|
|
50
|
+
this.fetchedAt = Date.now();
|
|
51
|
+
return this.cached;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
55
|
+
if (context.getType() !== "http") return true;
|
|
56
|
+
const req = context.switchToHttp().getRequest<Request>();
|
|
57
|
+
const path = req.path || req.url || "";
|
|
58
|
+
if (TwoFactorRequiredGuard.ALLOW.some((p) => path === p || path.startsWith(`${p}/`))) return true;
|
|
59
|
+
if (!(await this.enforced())) return true;
|
|
60
|
+
|
|
61
|
+
const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) });
|
|
62
|
+
const user = session?.user as { twoFactorEnabled?: boolean } | undefined;
|
|
63
|
+
if (!user) return true; // unauthenticated or non-session (machine) request
|
|
64
|
+
if (user.twoFactorEnabled) return true;
|
|
65
|
+
|
|
66
|
+
throw new AppException("TWO_FACTOR_REQUIRED", "Two-factor enrolment is required.", 403);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Must match the rows seeded by the InitAppSettings migration. phoneNumber is
|
|
8
8
|
* off by default because real delivery needs an SMS provider. */
|
|
9
|
-
export type FeatureFlag = "twoFactor" | "magicLink" | "emailOtp" | "username" | "multiSession" | "phoneNumber" | "apiKey" | "passkey" | "organization" | "oidcProvider";
|
|
9
|
+
export type FeatureFlag = "twoFactor" | "magicLink" | "emailOtp" | "username" | "multiSession" | "phoneNumber" | "apiKey" | "passkey" | "organization" | "oidcProvider" | "require2fa";
|
|
10
10
|
|
|
11
11
|
export const FLAG_DEFAULTS: Record<FeatureFlag, boolean> = {
|
|
12
12
|
twoFactor: true,
|
|
@@ -20,6 +20,10 @@ export const FLAG_DEFAULTS: Record<FeatureFlag, boolean> = {
|
|
|
20
20
|
organization: true,
|
|
21
21
|
// Acting as an OIDC identity provider is an enterprise opt-in; off by default.
|
|
22
22
|
oidcProvider: false,
|
|
23
|
+
// Policy (not a feature endpoint): when on, every signed-in user must enrol in
|
|
24
|
+
// two-factor before using the app. Enforced by TwoFactorRequiredGuard + the web
|
|
25
|
+
// enrolment gate — the feature gate ignores it (no endpoint prefix). Off by default.
|
|
26
|
+
require2fa: false,
|
|
23
27
|
};
|
|
24
28
|
|
|
25
29
|
export const FEATURE_FLAGS = Object.keys(FLAG_DEFAULTS) as FeatureFlag[];
|
|
@@ -67,6 +67,11 @@
|
|
|
67
67
|
"marker": "// podokit:end:imports",
|
|
68
68
|
"text": "import { AuthConfigModule } from \"./auth-config/auth-config.module\";"
|
|
69
69
|
},
|
|
70
|
+
{
|
|
71
|
+
"file": "apps/api/src/app.module.ts",
|
|
72
|
+
"marker": "// podokit:end:imports",
|
|
73
|
+
"text": "import { TwoFactorRequiredGuard } from \"./auth/two-factor-required.guard\";"
|
|
74
|
+
},
|
|
70
75
|
{
|
|
71
76
|
"file": "apps/api/src/app.module.ts",
|
|
72
77
|
"marker": "// podokit:end:module-imports",
|
|
@@ -87,6 +92,11 @@
|
|
|
87
92
|
"marker": "// podokit:end:providers",
|
|
88
93
|
"text": "{ provide: APP_GUARD, useClass: AuthGuard },"
|
|
89
94
|
},
|
|
95
|
+
{
|
|
96
|
+
"file": "apps/api/src/app.module.ts",
|
|
97
|
+
"marker": "// podokit:end:providers",
|
|
98
|
+
"text": "{ provide: APP_GUARD, useClass: TwoFactorRequiredGuard },"
|
|
99
|
+
},
|
|
90
100
|
{
|
|
91
101
|
"file": "AGENTS.md",
|
|
92
102
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
|
|
@@ -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
|