@podosoft/podokit 1.6.1 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/deploy-checks.d.ts +16 -5
  2. package/dist/deploy-checks.js +14 -6
  3. package/dist/deploy-render.js +28 -3
  4. package/dist/deploy.d.ts +9 -0
  5. package/dist/deploy.js +83 -17
  6. package/dist/index.js +9 -2
  7. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
  8. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +1 -1
  9. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -1
  10. package/dist/templates/fullstack/tests/README.md +11 -0
  11. package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
  12. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +96 -7
  13. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +11 -0
  14. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
  15. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
  16. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
  17. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
  18. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
  19. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
  20. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +3 -3
  21. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +24 -2
  22. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +609 -0
  23. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +178 -42
  24. package/dist/templates/modules/audit-log/module.manifest.json +2 -2
  25. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -1
  26. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +13 -1
  27. package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.spec.ts +67 -0
  28. package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.ts +54 -0
  29. package/dist/templates/modules/auth/module.manifest.json +1 -0
  30. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +208 -62
  31. package/dist/update.d.ts +25 -5
  32. package/dist/update.js +86 -10
  33. package/package.json +1 -1
@@ -1,11 +1,11 @@
1
1
  import { expect, test } from "@playwright/test";
2
- import { mailpitReachable, waitForLink, waitForOtp } from "../helpers/mailpit";
2
+ import { skipUnlessMailReachesMailpit, waitForLink, waitForOtp } from "../helpers/mailpit";
3
3
 
4
4
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
5
  const origin = { origin: base };
6
6
 
7
7
  test("email one-time code signs a user in", async ({ playwright }) => {
8
- test.skip(!(await mailpitReachable()), "Mailpit not available");
8
+ await skipUnlessMailReachesMailpit();
9
9
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
10
10
  const caps = await (await ctx.get("/api/account/capabilities")).json();
11
11
  test.skip(!caps?.emailOtp, "email OTP not enabled");
@@ -49,7 +49,7 @@ test("organization invitation can be sent and accepted", async ({ playwright })
49
49
  });
50
50
 
51
51
  test("magic link signs a user in", async ({ playwright }) => {
52
- test.skip(!(await mailpitReachable()), "Mailpit not available");
52
+ await skipUnlessMailReachesMailpit();
53
53
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
54
54
  const caps = await (await ctx.get("/api/account/capabilities")).json();
55
55
  test.skip(!caps?.magicLink, "magic link not enabled");
@@ -74,7 +74,7 @@ function tokenFromRedirect(location: string): string | null {
74
74
  }
75
75
 
76
76
  test("password reset delivers an email and updates the password @smoke", async ({ playwright }) => {
77
- test.skip(!(await mailpitReachable()), "Mailpit not available");
77
+ await skipUnlessMailReachesMailpit();
78
78
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
79
79
  const email = `reset-${Date.now()}@example.com`;
80
80
  const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Reset" } });
@@ -99,7 +99,7 @@ test("password reset delivers an email and updates the password @smoke", async (
99
99
  });
100
100
 
101
101
  test("email verification blocks sign-in until the address is confirmed", async ({ playwright }) => {
102
- test.skip(!(await mailpitReachable()), "Mailpit not available");
102
+ await skipUnlessMailReachesMailpit();
103
103
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
104
104
  const email = `verify-${Date.now()}@example.com`;
105
105
  const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Verify", callbackURL: `${base}/admin` } });
@@ -1,33 +1,82 @@
1
- // Helpers for the local Mailpit catcher (docker-compose / CI service). Tests that
2
- // exercise email flows skip when it isn't reachable (see MAILPIT_URL). Each test
1
+ import { request, test } from "@playwright/test";
2
+
3
+ // Helpers for the local Mailpit catcher (docker-compose / CI service). Each test
3
4
  // mails an address of its own and reads the newest message to it; the inbox is
4
5
  // shared with tests on other workers, so no test empties it.
5
6
  const MAILPIT = process.env.MAILPIT_URL ?? "http://localhost:8025";
7
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
8
+ const PROBE_WAIT_MS = 5000;
9
+
10
+ type MailpitList = { messages: Array<{ ID: string; To: Array<{ Address: string }> }> };
11
+ type MailpitMessage = { Text?: string; HTML?: string };
6
12
 
7
- export async function mailpitReachable(): Promise<boolean> {
13
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
14
+
15
+ async function newestMessageTo(to: string): Promise<string | undefined> {
16
+ const list = (await (await fetch(`${MAILPIT}/api/v1/messages`)).json()) as MailpitList;
17
+ return list.messages?.find((m) => m.To?.some((t) => t.Address === to))?.ID;
18
+ }
19
+
20
+ // A Mailpit answering on MAILPIT_URL may belong to another stack while this app
21
+ // sends no mail or sends it elsewhere, so ask the app to mail an address of the
22
+ // probe's own and wait for that message. Returns why email tests cannot run, or
23
+ // null when the message arrives.
24
+ async function mailDeliveryProblem(): Promise<string | null> {
8
25
  try {
9
- return (await fetch(`${MAILPIT}/readyz`)).ok;
26
+ if (!(await fetch(`${MAILPIT}/readyz`)).ok) return `Mailpit at ${MAILPIT} is not ready`;
10
27
  } catch {
11
- return false;
28
+ return `no Mailpit answers at ${MAILPIT}`;
12
29
  }
30
+ const email = `mail-probe-${Date.now()}-${process.pid}@example.com`;
31
+ const app = await request.newContext({ baseURL: base, extraHTTPHeaders: { origin: base } });
32
+ try {
33
+ const signUp = await app.post("/api/auth/sign-up/email", {
34
+ data: { email, password: "Podokit3e-Str0ng!pw", name: "Mail probe" },
35
+ });
36
+ if (!signUp.ok()) return `the mail probe could not sign up (HTTP ${signUp.status()})`;
37
+ const reset = await app.post("/api/auth/request-password-reset", {
38
+ data: { email, redirectTo: `${base}/reset-password` },
39
+ });
40
+ if (!reset.ok()) return `the app could not send the probe email (HTTP ${reset.status()})`;
41
+ } finally {
42
+ await app.dispose();
43
+ }
44
+ const deadline = Date.now() + PROBE_WAIT_MS;
45
+ while (Date.now() < deadline) {
46
+ if (await newestMessageTo(email)) return null;
47
+ await sleep(250);
48
+ }
49
+ return `Mailpit at ${MAILPIT} does not receive this app's mail: a probe email to ${email} did not arrive within ${PROBE_WAIT_MS / 1000} s`;
13
50
  }
14
51
 
15
- type MailpitList = { messages: Array<{ ID: string; To: Array<{ Address: string }> }> };
16
- type MailpitMessage = { Text?: string; HTML?: string };
52
+ let delivery: Promise<string | null> | undefined;
53
+
54
+ /**
55
+ * Skip the current test unless the app under test delivers its mail to the
56
+ * Mailpit these helpers read. The probe runs once per worker. With
57
+ * E2E_REQUIRE_MAIL=true, which a harness that wires the app's SMTP to that
58
+ * Mailpit sets, a failed probe fails the test instead.
59
+ */
60
+ export async function skipUnlessMailReachesMailpit(): Promise<void> {
61
+ delivery ??= mailDeliveryProblem().catch((error: unknown) => `the mail probe failed: ${String(error)}`);
62
+ const problem = await delivery;
63
+ if (problem === null) return;
64
+ if (process.env.E2E_REQUIRE_MAIL === "true") throw new Error(`E2E_REQUIRE_MAIL is set, but ${problem}`);
65
+ test.skip(true, problem);
66
+ }
17
67
 
18
68
  // Poll for the newest message addressed to `to` and return the first match of
19
69
  // `pattern` in its text/HTML body.
20
70
  async function waitForMatch(to: string, pattern: RegExp, label: string, timeoutMs: number): Promise<string> {
21
71
  const deadline = Date.now() + timeoutMs;
22
72
  while (Date.now() < deadline) {
23
- const list = (await (await fetch(`${MAILPIT}/api/v1/messages`)).json()) as MailpitList;
24
- const msg = list.messages?.find((m) => m.To?.some((t) => t.Address === to));
25
- if (msg) {
26
- const full = (await (await fetch(`${MAILPIT}/api/v1/message/${msg.ID}`)).json()) as MailpitMessage;
73
+ const id = await newestMessageTo(to);
74
+ if (id) {
75
+ const full = (await (await fetch(`${MAILPIT}/api/v1/message/${id}`)).json()) as MailpitMessage;
27
76
  const match = `${full.Text ?? ""} ${full.HTML ?? ""}`.match(pattern);
28
77
  if (match) return match[0];
29
78
  }
30
- await new Promise((r) => setTimeout(r, 300));
79
+ await sleep(300);
31
80
  }
32
81
  throw new Error(`no email with ${label} to ${to} within ${timeoutMs}ms`);
33
82
  }
@@ -1,6 +1,6 @@
1
1
  import { expect, test } from "../helpers/disposable-users";
2
2
  import { ready } from "../helpers/hydration";
3
- import { mailpitReachable, waitForLink } from "../helpers/mailpit";
3
+ import { skipUnlessMailReachesMailpit, waitForLink } from "../helpers/mailpit";
4
4
 
5
5
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
6
6
 
@@ -17,7 +17,7 @@ test("verification badges stay hidden when the feature is off", async ({ page })
17
17
 
18
18
  // Admin can trigger a password-reset email for a user (not verification-gated).
19
19
  test("admin sends a password reset email to a user", async ({ page, disposableUsers }) => {
20
- test.skip(!(await mailpitReachable()), "Mailpit not available");
20
+ await skipUnlessMailReachesMailpit();
21
21
  await ready(page, "/admin/users");
22
22
  const email = `admin-reset-${Date.now()}@example.com`;
23
23
  await disposableUsers.create({ email, name: "AR" });
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { anonState } from "../helpers/accounts";
3
3
  import { ready } from "../helpers/hydration";
4
- import { mailpitReachable, waitForLink } from "../helpers/mailpit";
4
+ import { skipUnlessMailReachesMailpit, waitForLink } from "../helpers/mailpit";
5
5
 
6
6
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
7
7
  const origin = { origin: base };
@@ -10,7 +10,7 @@ const origin = { origin: base };
10
10
  test.use({ storageState: anonState });
11
11
 
12
12
  test("forgot password: request, open the emailed link, set a new password, sign in", async ({ page, playwright }) => {
13
- test.skip(!(await mailpitReachable()), "Mailpit not available");
13
+ await skipUnlessMailReachesMailpit();
14
14
  const email = `ui-reset-${Date.now()}@example.com`;
15
15
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
16
16
  const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "UIReset" } });
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { anonState } from "../helpers/accounts";
3
3
  import { ready } from "../helpers/hydration";
4
- import { mailpitReachable, waitForOtp } from "../helpers/mailpit";
4
+ import { skipUnlessMailReachesMailpit, waitForOtp } from "../helpers/mailpit";
5
5
 
6
6
  test.use({ storageState: anonState });
7
7
 
@@ -24,7 +24,7 @@ test("login: an emailed one-time code signs a user in", async ({ page, playwrigh
24
24
  const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
25
25
  const caps = await (await api.get("/api/account/capabilities")).json();
26
26
  test.skip(!caps?.emailOtp, "email otp disabled");
27
- test.skip(!(await mailpitReachable()), "mailpit unreachable");
27
+ await skipUnlessMailReachesMailpit();
28
28
 
29
29
  const email = `ui-otp-${Date.now()}@example.com`;
30
30
  await api.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "OTP" } });
@@ -57,8 +57,8 @@ test("settings: open and save the SMTP dialog", { tag: "@exclusive" }, async ({
57
57
  await page.getByRole("button", { name: "SMTP settings" }).click();
58
58
  const dialog = page.getByRole("dialog");
59
59
  try {
60
- // Point at the same Mailpit the env already uses, so saving can't re-route mail
61
- // for the other email specs.
60
+ // SMTP stays switched off, so saving these values can't re-route mail for the
61
+ // other email specs: the mailer keeps the environment's transport.
62
62
  await dialog.locator("#smtp-host").fill("localhost");
63
63
  await dialog.locator("#smtp-port").fill("1125");
64
64
  await dialog.getByRole("button", { name: "Save", exact: true }).click();
@@ -10,11 +10,11 @@ async function chooseTheme(page: Page, mode: "Light" | "Dark" | "System"): Promi
10
10
  // mode-watcher marks <html> with `.dark` or `.light`, so component libraries
11
11
  // that follow either class, and fall back to the OS setting otherwise, match the
12
12
  // theme the visitor chose rather than the system one. The managed ThemeToggle
13
- // renders on the page app-paths.ts names as the account menu page.
13
+ // renders on the page app-paths.ts names as themeToggle.
14
14
  test("the chosen theme is named on <html> in both modes", async ({ page }) => {
15
- test.skip(paths.accountMenu === null, "no page renders the theme toggle (tests/helpers/app-paths.ts)");
15
+ test.skip(paths.themeToggle === null, "no page renders the theme toggle (tests/helpers/app-paths.ts)");
16
16
  await page.emulateMedia({ colorScheme: "dark" });
17
- await ready(page, paths.accountMenu ?? "/");
17
+ await ready(page, paths.themeToggle ?? "/");
18
18
  const html = page.locator("html");
19
19
 
20
20
  await chooseTheme(page, "Light");
@@ -8,10 +8,26 @@ actor / action / target model. Every entry flows through one pipeline
8
8
 
9
9
  Nothing is recorded automatically except the built-in **auth/admin actions**
10
10
  (login, user create/update/delete, ban, role change, session revoke, ...),
11
- which the better-auth hook (`audit-hook.ts`) maps to semantic action codes like
11
+ which the Better Auth plugin in `audit-hook.ts` maps to semantic action codes like
12
12
  `user.create` and `auth.login`. Everything else is **opt-in** — you decide what
13
13
  matters.
14
14
 
15
+ The plugin records an action only when the request succeeded, so a wrong
16
+ password or a forbidden admin call leaves no entry. Every sign-in method records
17
+ `auth.login` with the method in `metadata.method` (`password`, `email-otp`,
18
+ `magic-link`, `passkey`, `two-factor-totp`, ...) and, for social sign-in, the
19
+ provider in `metadata.provider`. A sign-in through an endpoint of a plugin the
20
+ app adds, such as `@better-auth/electron`'s `/electron/token`, is recorded too,
21
+ with the endpoint's path as the method: any request that ends with a new session
22
+ it did not come with is a sign-in, except the endpoints in `NOT_SIGN_INS`
23
+ (sign-up, impersonation, switching between a browser's sessions, and requests
24
+ that replace the signed-in user's own session). Session reads and updates that
25
+ refresh the request's own session, including bearer and API key requests, are
26
+ not. A two-factor sign-in is recorded when the second factor completes it, not
27
+ at the password step. `auth.ts` pushes the plugin after every other plugin:
28
+ Better Auth runs after hooks in plugin order, and two-factor changes the outcome
29
+ of a password sign-in in its own after hook.
30
+
15
31
  ## Audit your own Elysia routes
16
32
 
17
33
  ```ts
@@ -86,7 +102,13 @@ survives renames/deletes), `targetType/targetId/targetLabel`, `ip`, `metadata`
86
102
 
87
103
  ## Customize
88
104
 
89
- - **Auth actions:** edit the `ACTIONS` map in `audit-hook.ts`.
105
+ - **Auth actions:** `ACTIONS` in `audit-hook.ts` maps Better Auth endpoints to
106
+ action codes, `SIGN_IN_METHODS` names the method of Better Auth's sign-in
107
+ endpoints, and `NOT_SIGN_INS` lists the endpoints that start or swap a session
108
+ without a sign-in. The `audit-hook.spec.ts` test fails when Better Auth or a
109
+ plugin PodoKit configures gains an endpoint that it has not classified; the
110
+ endpoints of plugins the app adds are recorded as sign-ins when they start a
111
+ session, without a change to these lists.
90
112
  - **Your routes:** call `AuditService.recordRequest()` after successful operations;
91
113
  pass `{ defer: true }` only for high-volume, non-security events.
92
114
  - **Schema:** add columns with a migration, and keep the