@podosoft/podokit 1.6.2 → 1.6.4

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 (28) hide show
  1. package/dist/deploy-checks.d.ts +20 -2
  2. package/dist/deploy-checks.js +83 -4
  3. package/dist/deploy-compose-render.js +15 -4
  4. package/dist/deploy-compose.js +211 -27
  5. package/dist/deploy.js +16 -0
  6. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
  7. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -2
  8. package/dist/templates/fullstack/tests/README.md +11 -0
  9. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +28 -2
  10. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +24 -0
  11. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
  12. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
  13. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +16 -4
  14. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
  15. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
  16. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
  17. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
  18. package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
  19. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +31 -8
  20. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +531 -29
  21. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +253 -60
  22. package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.spec.ts +232 -0
  23. package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.ts +68 -0
  24. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +53 -47
  25. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +1 -1
  26. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +9 -2
  27. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +60 -15
  28. package/package.json +1 -1
@@ -41,7 +41,7 @@ Review these fields before planning:
41
41
  - `runtimeConfig`: non-sensitive environment values only
42
42
  - `verification`: HTTPS origin and checks with exact statuses and optional JSON fields
43
43
 
44
- The cluster namespace and referenced Secrets must already exist. PodoKit asks `kubectl` to output only Secret key names and never prints or stores their values; the Kubernetes client still materializes the Secret while evaluating that output.
44
+ The cluster namespace and referenced Secrets must already exist. PodoKit asks `kubectl` to output only Secret key names and never prints or stores their values; the Kubernetes client still materializes the Secret while evaluating that output. The one exception is `ADMIN_EMAILS` of the API Secret, an allowlist of addresses rather than a credential: with the `auth` module installed, the doctor reads it and fails on a placeholder such as `admin@example.com`.
45
45
 
46
46
  Required keys:
47
47
 
@@ -94,7 +94,8 @@ The fields that differ from the Kubernetes driver:
94
94
 
95
95
  Credentials live in env files **on the target host**, never in the profile and never
96
96
  in the rendered Compose project. The doctor reads only the key names, in a throwaway
97
- container that mounts the file read-only. Create those files with the host's own
97
+ container that mounts the file read-only, plus the `ADMIN_EMAILS` line of the API env
98
+ file when the `auth` module is installed, to fail on a placeholder such as `admin@example.com`. Create those files with the host's own
98
99
  secret tooling before deploying; PodoKit does not write them.
99
100
 
100
101
  Required keys:
@@ -68,3 +68,14 @@ tests not run, and selecting an exclusive test runs every other test first. Use
68
68
 
69
69
  Application-owned projects in `playwright.projects.cjs` get twins too. A project
70
70
  that another project depends on is a setup project and keeps a single copy.
71
+
72
+ ## Email tests
73
+
74
+ The email specs that the `admin-dashboard` module adds read messages from the
75
+ Mailpit at `MAILPIT_URL` (default `http://localhost:8025`). Before the first one,
76
+ each worker has the app send a password reset email to a throwaway account and
77
+ waits up to five seconds for it there. When it does not arrive, because the app
78
+ has no SMTP or sends to a catcher other than the one on that port, the email
79
+ tests skip with that cause. Set `MAILPIT_URL` to the Mailpit that receives the
80
+ app's mail to run them, and `E2E_REQUIRE_MAIL=true` to make a missing probe
81
+ message fail them instead of skipping.
@@ -1,7 +1,7 @@
1
1
  import type { APIRequestContext } from "@playwright/test";
2
2
  import { expect, test } from "../helpers/disposable-users";
3
3
  import { ADMIN, USER } from "../helpers/accounts";
4
- import { mailpitReachable, waitForOtp } from "../helpers/mailpit";
4
+ import { skipUnlessMailReachesMailpit, waitForOtp } from "../helpers/mailpit";
5
5
  import { totpCode } from "../helpers/totp";
6
6
 
7
7
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
@@ -12,6 +12,7 @@ type Entry = {
12
12
  id: string;
13
13
  action: string;
14
14
  actorEmail: string | null;
15
+ targetId: string | null;
15
16
  targetLabel: string | null;
16
17
  metadata: Record<string, unknown> | null;
17
18
  createdAt: string;
@@ -61,6 +62,31 @@ test("audit log records auth/admin actions", async ({ playwright, disposableUser
61
62
  await admin.dispose();
62
63
  });
63
64
 
65
+ test("a sign-up and a sign-out are audited with the user", async ({ playwright, disposableUsers }) => {
66
+ const admin = await signedIn(playwright, ADMIN);
67
+ test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
68
+ const email = `audit-signup-${Date.now()}@example.com`;
69
+ const visitor = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
70
+ const signup = await visitor.post("/api/auth/sign-up/email", { data: { email, password: PASSWORD, name: "Signup" } });
71
+ test.skip(signup.status() === 403, "public sign-up is closed");
72
+ const userId = await disposableUsers.trackResponse(signup);
73
+ const entries = async (action: string): Promise<Entry[]> =>
74
+ (await (await admin.get(auditQuery({ action, target: email }))).json()) as Entry[];
75
+
76
+ expect(await entries("auth.signup")).toEqual([
77
+ expect.objectContaining({ actorEmail: email, targetId: userId, metadata: { method: "password" } }),
78
+ ]);
79
+ if (!(await visitor.get("/api/auth/get-session").then((response) => response.json()))?.session) {
80
+ expect((await visitor.post("/api/auth/sign-in/email", { data: { email, password: PASSWORD } })).ok()).toBeTruthy();
81
+ }
82
+ expect((await visitor.post("/api/auth/sign-out", { data: {} })).ok()).toBeTruthy();
83
+ expect(await entries("auth.logout"), "the sign-out names the user whose session ended").toEqual([
84
+ expect.objectContaining({ actorEmail: email, targetId: userId }),
85
+ ]);
86
+ await visitor.dispose();
87
+ await admin.dispose();
88
+ });
89
+
64
90
  test("the audit log is admin-only", async ({ playwright }) => {
65
91
  const admin = await signedIn(playwright, ADMIN);
66
92
  test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
@@ -171,7 +197,7 @@ test("a wrong password is not audited as a sign-in", async ({ playwright, dispos
171
197
  });
172
198
 
173
199
  test("an email code sign-in is audited once", async ({ playwright, disposableUsers }) => {
174
- test.skip(!(await mailpitReachable()), "Mailpit not available");
200
+ await skipUnlessMailReachesMailpit();
175
201
  const admin = await signedIn(playwright, ADMIN);
176
202
  test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
177
203
  const caps = await (await admin.get("/api/account/capabilities")).json();
@@ -470,6 +470,30 @@ test("ADMIN_EMAILS account has the admin role @smoke", async ({ playwright }) =>
470
470
  await ctx.dispose();
471
471
  });
472
472
 
473
+ // An ADMIN_EMAILS address with no account yet, such as a placeholder that was
474
+ // never replaced: a sign-up with it must not create an account, let alone an
475
+ // approved administrator.
476
+ test("a sign-up cannot claim an ADMIN_EMAILS address", async ({ playwright }) => {
477
+ const email = process.env.E2E_UNCLAIMED_ADMIN_EMAIL;
478
+ test.skip(!email, "set E2E_UNCLAIMED_ADMIN_EMAIL to an ADMIN_EMAILS address that has no account");
479
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
480
+ const password = "Podokit3e-Str0ng!pw";
481
+ const signUp = await ctx.post("/api/auth/sign-up/email", { data: { email, password, name: "Claim" } });
482
+ // Refused, or answered like any sign-up while approval or email verification
483
+ // hides whether an address can register.
484
+ expect([200, 403], await signUp.text()).toContain(signUp.status());
485
+ expect((await ctx.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBe(false);
486
+ expect((await (await ctx.get("/api/auth/get-session")).json())?.user ?? null).toBeNull();
487
+ await ctx.dispose();
488
+
489
+ const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
490
+ await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
491
+ const found = await admin.get(`/api/auth/admin/list-users?searchField=email&searchOperator=contains&searchValue=${encodeURIComponent(email ?? "")}`);
492
+ expect(found.ok()).toBeTruthy();
493
+ expect(((await found.json()) as { users: unknown[] }).users).toEqual([]);
494
+ await admin.dispose();
495
+ });
496
+
473
497
  test("admin can list users, a normal user cannot (403)", async ({ playwright }) => {
474
498
  const adminCtx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
475
499
  await adminCtx.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
@@ -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
  }
@@ -41,17 +41,29 @@ function withTestLocale(state: StorageState): StorageState {
41
41
  // cookies as storageState for the browser `ui` project to reuse. Pin the suite
42
42
  // locale because UI locators use the generated English catalog and must not
43
43
  // inherit an application's configured default language.
44
+ //
45
+ // The user signs up here. The administrator cannot: the API refuses a sign-up
46
+ // with an ADMIN_EMAILS address that has not proven it, so the account comes
47
+ // from admin:bootstrap before the suite runs.
44
48
  async function seedSession(
45
49
  playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"],
46
50
  account: Account,
47
51
  path: string,
52
+ { signUp }: { signUp: boolean },
48
53
  ): Promise<void> {
49
54
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: { origin: base } });
50
- await ctx.post("/api/auth/sign-up/email", { data: account }).catch(() => undefined); // idempotent
55
+ if (signUp) await ctx.post("/api/auth/sign-up/email", { data: account }).catch(() => undefined); // idempotent
51
56
  const res = await ctx.post("/api/auth/sign-in/email", {
52
57
  data: { email: account.email, password: account.password },
53
58
  });
54
- expect(res.ok(), `sign-in ${account.email}`).toBeTruthy();
59
+ expect(
60
+ res.ok(),
61
+ signUp
62
+ ? `sign-in ${account.email}`
63
+ : `sign-in ${account.email}: create the administrator first with ` +
64
+ `ADMIN_BOOTSTRAP_EMAIL=${account.email} ADMIN_BOOTSTRAP_PASSWORD=<its password> ADMIN_BOOTSTRAP_NAME="${account.name}" {{apiRun}} admin:bootstrap{{apiWorkspaceArg}}, ` +
65
+ "or name an existing administrator as tests/helpers/accounts.ts describes",
66
+ ).toBeTruthy();
55
67
  // Each run signs in again, so keep a long-lived local database from piling up
56
68
  // this account's sessions: the seeded one is the only one left.
57
69
  expect((await ctx.post("/api/auth/revoke-other-sessions")).ok(), `clear old sessions for ${account.email}`).toBeTruthy();
@@ -60,7 +72,7 @@ async function seedSession(
60
72
  }
61
73
 
62
74
  setup("seed admin session", async ({ playwright }) => {
63
- await seedSession(playwright, ADMIN, adminState);
75
+ await seedSession(playwright, ADMIN, adminState, { signUp: false });
64
76
  });
65
77
 
66
78
  // Feature flags are DB-backed (seeded by the app_setting migration). phoneNumber
@@ -75,7 +87,7 @@ setup("enable optional features", async ({ playwright }) => {
75
87
  });
76
88
 
77
89
  setup("seed user session", async ({ playwright }) => {
78
- await seedSession(playwright, USER, userState);
90
+ await seedSession(playwright, USER, userState, { signUp: true });
79
91
  });
80
92
 
81
93
  setup("capture the user cleanup baseline", async ({ playwright }) => {
@@ -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();
@@ -51,7 +51,9 @@
51
51
  }
52
52
  },
53
53
  "env": [
54
- "# admin-dashboard: emails auto-promoted to the admin role on sign-up (comma-separated)",
54
+ "# admin-dashboard: administrator emails (comma-separated). An account with one becomes an",
55
+ "# admin when admin:bootstrap creates it or a sign-in verifies the address; an email and",
56
+ "# password sign-up with one is refused. A production API refuses placeholders (example.com).",
55
57
  "ADMIN_EMAILS=admin@example.com"
56
58
  ],
57
59
  "migrations": [
@@ -140,7 +142,7 @@
140
142
  "instructions": [
141
143
  "Set ADMIN_EMAILS in .env (comma-separated). After building and migrating, inject ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD for one command only, then run {{apiRun}} admin:bootstrap{{apiWorkspaceArg}}. The command creates a verified, approved admin or safely verifies an existing one.",
142
144
  "Run all migrations to add the admin and sign-up approval columns: bun run --cwd apps/api migrate:all",
143
- "Remove ADMIN_BOOTSTRAP_PASSWORD from the environment after bootstrap. Keep ADMIN_EMAILS so matching future sign-ups are promoted consistently.",
145
+ "Remove ADMIN_BOOTSTRAP_PASSWORD from the environment after bootstrap. Keep ADMIN_EMAILS with the administrators' real addresses: a later account with one becomes an administrator only when a sign-in verifies the address as it creates the account, an email and password sign-up with one is refused, and a production API refuses to start with a placeholder such as an example.com address.",
144
146
  "Admin pages: /admin/users (list, approve pending registrations, ban, set role, revoke sessions). Signed-in users manage their profile, profile image, and security at /account; /admin/account remains available in the admin shell. Profile images accept PNG, JPEG, and WebP up to 2 MB and 2048 by 2048 pixels. Admin, account, authentication, maintenance, and API responses include X-Robots-Tag: noindex, nofollow by default.",
145
147
  "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."
146
148
  ]
@@ -13,13 +13,33 @@ which the Better Auth plugin in `audit-hook.ts` maps to semantic action codes li
13
13
  matters.
14
14
 
15
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
16
+ password or a forbidden admin call leaves no entry. The exception is a new
17
+ account: `auth.signup` is recorded when Better Auth stores the user, whichever
18
+ endpoint creates it (email sign-up, a social provider's callback, a magic link,
19
+ an email code, or a plugin the app adds), and even when the request then fails,
20
+ as a social sign-up that waits for approval does when it is refused a session.
21
+ Its `metadata.method` names the endpoint the way `auth.login` does, with the
22
+ provider for a social sign-up, for example
23
+ `{ "method": "social", "provider": "google" }`. A user an administrator creates
24
+ is recorded as `user.create` only. Entries name the user even when the request
25
+ carries no session: `auth.logout` names each account whose session the sign-out
26
+ ended (a sign-out without a session records nothing), a password reset by link
27
+ or code and an email verification name the account they changed,
28
+ `user.session_revoke` names the owner of the revoked session, and
29
+ `user.stop_impersonate` names the administrator. Every sign-in method records
17
30
  `auth.login` with the method in `metadata.method` (`password`, `email-otp`,
18
31
  `magic-link`, `passkey`, `two-factor-totp`, ...) and, for social sign-in, the
19
- provider in `metadata.provider`. A two-factor sign-in is recorded when the second
20
- factor completes it, not at the password step. `auth.ts` pushes the plugin after
21
- every other plugin: Better Auth runs after hooks in plugin order, and two-factor
22
- changes the outcome of a password sign-in in its own after hook.
32
+ provider in `metadata.provider`. A sign-in through an endpoint of a plugin the
33
+ app adds, such as `@better-auth/electron`'s `/electron/token`, is recorded too,
34
+ with the endpoint's path as the method: any request that ends with a new session
35
+ it did not come with is a sign-in, except the endpoints in `NOT_SIGN_INS`
36
+ (sign-up, impersonation, switching between a browser's sessions, and requests
37
+ that replace the signed-in user's own session). Session reads and updates that
38
+ refresh the request's own session, including bearer and API key requests, are
39
+ not. A two-factor sign-in is recorded when the second factor completes it, not
40
+ at the password step. `auth.ts` pushes the plugin after every other plugin:
41
+ Better Auth runs after hooks in plugin order, and two-factor changes the outcome
42
+ of a password sign-in in its own after hook.
23
43
 
24
44
  ## Audit your own Elysia routes
25
45
 
@@ -96,9 +116,12 @@ survives renames/deletes), `targetType/targetId/targetLabel`, `ip`, `metadata`
96
116
  ## Customize
97
117
 
98
118
  - **Auth actions:** `ACTIONS` in `audit-hook.ts` maps Better Auth endpoints to
99
- action codes, and `SIGN_IN_METHODS` lists the endpoints that sign a user in.
100
- The `audit-hook.spec.ts` test fails when the app's Better Auth gains a sign-in
101
- endpoint that neither list classifies.
119
+ action codes, `SIGN_IN_METHODS` names the method of Better Auth's sign-in
120
+ endpoints, and `NOT_SIGN_INS` lists the endpoints that start or swap a session
121
+ without a sign-in. The `audit-hook.spec.ts` test fails when Better Auth or a
122
+ plugin PodoKit configures gains an endpoint that it has not classified; the
123
+ endpoints of plugins the app adds are recorded as sign-ins when they start a
124
+ session, without a change to these lists.
102
125
  - **Your routes:** call `AuditService.recordRequest()` after successful operations;
103
126
  pass `{ defer: true }` only for high-volume, non-security events.
104
127
  - **Schema:** add columns with a migration, and keep the