@podosoft/podokit 1.6.0 → 1.6.2

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 (50) hide show
  1. package/dist/deploy-checks.d.ts +16 -5
  2. package/dist/deploy-checks.js +17 -9
  3. package/dist/deploy-render.js +28 -3
  4. package/dist/deploy-schema.d.ts +7 -3
  5. package/dist/deploy-schema.js +16 -5
  6. package/dist/deploy.d.ts +9 -0
  7. package/dist/deploy.js +83 -17
  8. package/dist/index.js +9 -2
  9. package/dist/templates/fullstack/AGENTS.md +3 -1
  10. package/dist/templates/fullstack/apps/api/package.json +1 -1
  11. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +2 -1
  12. package/dist/templates/fullstack/apps/api/src/build.spec.ts +5 -2
  13. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +43 -4
  14. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -4
  15. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +1 -1
  16. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +5 -2
  17. package/dist/templates/fullstack/tests/README.md +20 -10
  18. package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
  19. package/dist/templates/fullstack/tests/helpers/seeded-admin.ts +15 -0
  20. package/dist/templates/fullstack/tests/package.json +3 -3
  21. package/dist/templates/fullstack/tests/playwright.config.ts +8 -6
  22. package/dist/templates/fullstack/tests/playwright.extensions.ts +73 -12
  23. package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +17 -3
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +1 -1
  26. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +96 -7
  27. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +11 -0
  28. package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
  29. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +8 -6
  30. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +16 -10
  31. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +14 -2
  32. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +385 -0
  33. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +132 -43
  34. package/dist/templates/modules/audit-log/module.manifest.json +2 -2
  35. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -1
  36. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +13 -1
  37. package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.spec.ts +67 -0
  38. package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.ts +54 -0
  39. package/dist/templates/modules/auth/module.manifest.json +1 -0
  40. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +50 -0
  41. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +23 -3
  42. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -0
  43. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +60 -3
  44. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +3 -6
  45. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +3 -6
  46. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +163 -62
  47. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
  48. package/dist/update.d.ts +25 -5
  49. package/dist/update.js +86 -10
  50. package/package.json +2 -2
@@ -8,10 +8,19 @@ 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 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.
23
+
15
24
  ## Audit your own Elysia routes
16
25
 
17
26
  ```ts
@@ -86,7 +95,10 @@ survives renames/deletes), `targetType/targetId/targetLabel`, `ip`, `metadata`
86
95
 
87
96
  ## Customize
88
97
 
89
- - **Auth actions:** edit the `ACTIONS` map in `audit-hook.ts`.
98
+ - **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.
90
102
  - **Your routes:** call `AuditService.recordRequest()` after successful operations;
91
103
  pass `{ defer: true }` only for high-volume, non-security events.
92
104
  - **Schema:** add columns with a migration, and keep the
@@ -0,0 +1,385 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { createHash, createHmac } from "node:crypto";
3
+ import { oauthProvider } from "@better-auth/oauth-provider";
4
+ import { envAuthConfig } from "@podosoft/podokit-auth";
5
+ import { betterAuth } from "better-auth";
6
+ import { memoryAdapter } from "better-auth/adapters/memory";
7
+ import { getAuthTables } from "better-auth/db";
8
+ import { admin, emailOTP, jwt, magicLink, twoFactor } from "better-auth/plugins";
9
+ import { buildAuth } from "../auth/auth";
10
+ import { setAuditRecorder, type AuditEntry } from "./audit-events";
11
+ import { auditPlugin, SIGN_IN_METHODS } from "./audit-hook";
12
+
13
+ const BASE_URL = "http://localhost:5002";
14
+ const PASSWORD = "Podokit3e-Str0ng!pw";
15
+
16
+ // Endpoints that look like a sign-in but never start a session of their own.
17
+ const STARTS_NO_SESSION = new Set([
18
+ "/sign-in/magic-link", // emails the link that /magic-link/verify signs in with
19
+ "/passkey/verify-registration", // adds a passkey for the signed-in user
20
+ "/verify-password", // checks the signed-in user's password again
21
+ "/delete-user/callback", // confirms an account deletion
22
+ "/email-otp/check-verification-otp", // checks a code without using it
23
+ ]);
24
+
25
+ interface Mailbox {
26
+ magicLink: string;
27
+ otp: string;
28
+ verification: string;
29
+ }
30
+
31
+ interface AuthHandler {
32
+ handler: (request: Request) => Promise<Response>;
33
+ }
34
+
35
+ let entries: AuditEntry[] = [];
36
+ let restoreRecorder = (): void => undefined;
37
+
38
+ beforeEach(() => {
39
+ entries = [];
40
+ restoreRecorder = setAuditRecorder({
41
+ record: async (entry) => {
42
+ entries.push(entry);
43
+ },
44
+ recordLater: (entry) => {
45
+ entries.push(entry);
46
+ },
47
+ });
48
+ });
49
+
50
+ afterEach(() => restoreRecorder());
51
+
52
+ const logins = (): AuditEntry[] => entries.filter((entry) => entry.action === "auth.login");
53
+ const actions = (action: string): AuditEntry[] => entries.filter((entry) => entry.action === action);
54
+
55
+ // The sign-in methods PodoKit's auth.ts enables, on an in-memory database, with
56
+ // the audit plugin pushed last as the audit-log module does.
57
+ function startAuth(enabled: () => Promise<boolean> = async () => true) {
58
+ const mail: Mailbox = { magicLink: "", otp: "", verification: "" };
59
+ const db: Record<string, Record<string, unknown>[]> = {};
60
+ const options = {
61
+ baseURL: BASE_URL,
62
+ secret: "audit-hook-test-secret-0123456789abcdef",
63
+ database: memoryAdapter(db),
64
+ emailAndPassword: { enabled: true },
65
+ emailVerification: {
66
+ autoSignInAfterVerification: true,
67
+ sendVerificationEmail: async ({ url }: { url: string }) => {
68
+ mail.verification = url;
69
+ },
70
+ },
71
+ socialProviders: {
72
+ google: {
73
+ clientId: "client",
74
+ clientSecret: "secret",
75
+ getUserInfo: async () => ({
76
+ user: { email: "social@example.com", name: "Social", emailVerified: true },
77
+ data: {
78
+ sub: "google-1",
79
+ email: "social@example.com",
80
+ email_verified: true,
81
+ name: "Social",
82
+ given_name: "Social",
83
+ family_name: "User",
84
+ picture: "https://example.com/social.png",
85
+ aud: "client",
86
+ azp: "client",
87
+ iss: "https://accounts.google.com",
88
+ iat: 0,
89
+ exp: 0,
90
+ },
91
+ }),
92
+ },
93
+ },
94
+ plugins: [
95
+ twoFactor({ issuer: "podokit" }),
96
+ magicLink({
97
+ sendMagicLink: async ({ url }) => {
98
+ mail.magicLink = url;
99
+ },
100
+ }),
101
+ emailOTP({
102
+ sendVerificationOTP: async ({ otp }) => {
103
+ mail.otp = otp;
104
+ },
105
+ }),
106
+ jwt({ disableSettingJwtHeader: true }),
107
+ oauthProvider({ loginPage: "/login", consentPage: "/oauth2/consent" }),
108
+ admin(),
109
+ auditPlugin({ enabled }),
110
+ ],
111
+ };
112
+ for (const table of Object.values(getAuthTables(options))) db[table.modelName] = [];
113
+ return { auth: betterAuth(options), mail };
114
+ }
115
+
116
+ // A cookie jar in front of the Better Auth router, like a browser's.
117
+ class Browser {
118
+ private readonly auth: AuthHandler;
119
+ private readonly cookies = new Map<string, string>();
120
+
121
+ constructor(auth: AuthHandler) {
122
+ this.auth = auth;
123
+ }
124
+
125
+ /** Request headers that carry this browser's cookies, for a server-side auth.api call. */
126
+ headers(): Headers {
127
+ return new Headers({ cookie: [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; ") });
128
+ }
129
+
130
+ get(path: string): Promise<Response> {
131
+ return this.send("GET", path);
132
+ }
133
+
134
+ post(path: string, body: unknown): Promise<Response> {
135
+ return this.send("POST", path, body);
136
+ }
137
+
138
+ private async send(method: string, path: string, body?: unknown): Promise<Response> {
139
+ const headers = this.headers();
140
+ headers.set("origin", BASE_URL);
141
+ if (body !== undefined) headers.set("content-type", "application/json");
142
+ const url = path.startsWith("http") ? path : `${BASE_URL}/api/auth${path}`;
143
+ const response = await this.auth.handler(
144
+ new Request(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: "manual" }),
145
+ );
146
+ for (const cookie of response.headers.getSetCookie()) {
147
+ const [pair = ""] = cookie.split(";");
148
+ const name = pair.slice(0, pair.indexOf("="));
149
+ const value = pair.slice(pair.indexOf("=") + 1);
150
+ if (/max-age=0/i.test(cookie) || value === "") this.cookies.delete(name);
151
+ else this.cookies.set(name, value);
152
+ }
153
+ return response;
154
+ }
155
+ }
156
+
157
+ async function signUp(auth: AuthHandler, email: string): Promise<Browser> {
158
+ const browser = new Browser(auth);
159
+ expect((await browser.post("/sign-up/email", { email, password: PASSWORD, name: "Audit" })).status).toBe(200);
160
+ return browser;
161
+ }
162
+
163
+ // The current code for an otpauth:// URI, as an authenticator app computes it.
164
+ function totp(uri: string): string {
165
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
166
+ const secret = (new URL(uri).searchParams.get("secret") ?? "").replace(/=+$/, "").toUpperCase();
167
+ const bits = [...secret].map((char) => alphabet.indexOf(char).toString(2).padStart(5, "0")).join("");
168
+ const key = Buffer.from((bits.match(/.{8}/g) ?? []).map((byte) => Number.parseInt(byte, 2)));
169
+ const counter = Buffer.alloc(8);
170
+ counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30_000)));
171
+ const digest = createHmac("sha1", key).update(counter).digest();
172
+ const offset = (digest[digest.length - 1] ?? 0) & 0x0f;
173
+ return String((digest.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).padStart(6, "0");
174
+ }
175
+
176
+ const otherCode = (code: string): string => String((Number(code) + 1) % 1_000_000).padStart(6, "0");
177
+
178
+ describe("audit plugin", () => {
179
+ test("records a password sign-in once and a wrong password not at all", async () => {
180
+ const { auth } = startAuth();
181
+ await signUp(auth, "password@example.com");
182
+
183
+ const wrong = await new Browser(auth).post("/sign-in/email", { email: "password@example.com", password: "Wrong-password-1" });
184
+ expect(wrong.status).toBe(401);
185
+ expect(logins()).toEqual([]);
186
+
187
+ const right = await new Browser(auth).post("/sign-in/email", { email: "password@example.com", password: PASSWORD });
188
+ expect(right.status).toBe(200);
189
+ expect(logins()).toEqual([
190
+ expect.objectContaining({
191
+ actorEmail: "password@example.com",
192
+ targetLabel: "password@example.com",
193
+ metadata: { method: "password" },
194
+ }),
195
+ ]);
196
+ });
197
+
198
+ test("records a Google sign-in with its provider and not a callback with a forged state", async () => {
199
+ const { auth } = startAuth();
200
+ const realFetch = globalThis.fetch;
201
+ const tokenEndpoint = spyOn(globalThis, "fetch").mockImplementation(((input: RequestInfo | URL, init?: RequestInit) => {
202
+ const url = input instanceof Request ? input.url : String(input);
203
+ if (url.startsWith("https://oauth2.googleapis.com/token")) {
204
+ return Promise.resolve(Response.json({ access_token: "access", expires_in: 3600, token_type: "Bearer" }));
205
+ }
206
+ return realFetch(input, init);
207
+ }) as typeof fetch);
208
+ try {
209
+ const browser = new Browser(auth);
210
+ const started = await browser.post("/sign-in/social", { provider: "google", callbackURL: `${BASE_URL}/` });
211
+ const state = new URL(((await started.json()) as { url: string }).url).searchParams.get("state") ?? "";
212
+ expect(logins()).toEqual([]);
213
+
214
+ const forged = await new Browser(auth).get("/callback/google?code=code&state=forged");
215
+ expect(forged.status).toBe(302);
216
+ expect(logins()).toEqual([]);
217
+
218
+ const callback = await browser.get(`/callback/google?code=code&state=${encodeURIComponent(state)}`);
219
+ expect(callback.status).toBe(302);
220
+ expect(logins()).toEqual([
221
+ expect.objectContaining({ actorEmail: "social@example.com", metadata: { method: "social", provider: "google" } }),
222
+ ]);
223
+ } finally {
224
+ tokenEndpoint.mockRestore();
225
+ }
226
+ });
227
+
228
+ test("records magic link and email code sign-ins, and not a used link or a wrong code", async () => {
229
+ const { auth, mail } = startAuth();
230
+ const browser = new Browser(auth);
231
+ await browser.post("/sign-in/magic-link", { email: "magic@example.com", callbackURL: `${BASE_URL}/` });
232
+ expect(logins()).toEqual([]);
233
+ expect((await browser.get(mail.magicLink)).status).toBe(302);
234
+ expect((await new Browser(auth).get(mail.magicLink)).status).toBe(302);
235
+ expect(logins()).toEqual([expect.objectContaining({ actorEmail: "magic@example.com", metadata: { method: "magic-link" } })]);
236
+
237
+ entries = [];
238
+ const coded = new Browser(auth);
239
+ await coded.post("/email-otp/send-verification-otp", { email: "code@example.com", type: "sign-in" });
240
+ const wrong = await coded.post("/sign-in/email-otp", { email: "code@example.com", otp: otherCode(mail.otp) });
241
+ expect(wrong.status).toBeGreaterThanOrEqual(400);
242
+ expect(logins()).toEqual([]);
243
+ expect((await coded.post("/sign-in/email-otp", { email: "code@example.com", otp: mail.otp })).status).toBe(200);
244
+ expect(logins()).toEqual([expect.objectContaining({ actorEmail: "code@example.com", metadata: { method: "email-otp" } })]);
245
+ });
246
+
247
+ test("records a two-factor sign-in when the second factor completes it", async () => {
248
+ const { auth } = startAuth();
249
+ const email = "two-factor@example.com";
250
+ const owner = await signUp(auth, email);
251
+ const enabled = (await (await owner.post("/two-factor/enable", { password: PASSWORD })).json()) as {
252
+ totpURI: string;
253
+ backupCodes: string[];
254
+ };
255
+ // Confirming the authenticator replaces the owner's session; nobody signs in.
256
+ expect((await owner.post("/two-factor/verify-totp", { code: totp(enabled.totpURI) })).status).toBe(200);
257
+ expect(actions("2fa.enable")).toHaveLength(1);
258
+ expect(logins()).toEqual([]);
259
+
260
+ const challenged = new Browser(auth);
261
+ const pending = await challenged.post("/sign-in/email", { email, password: PASSWORD });
262
+ expect(await pending.json()).toMatchObject({ twoFactorRedirect: true });
263
+ expect(logins()).toEqual([]);
264
+ const code = totp(enabled.totpURI);
265
+ expect((await challenged.post("/two-factor/verify-totp", { code: otherCode(code) })).status).toBe(401);
266
+ expect(logins()).toEqual([]);
267
+ expect((await challenged.post("/two-factor/verify-totp", { code })).status).toBe(200);
268
+ expect(logins()).toEqual([expect.objectContaining({ actorEmail: email, metadata: { method: "two-factor-totp" } })]);
269
+
270
+ const backup = new Browser(auth);
271
+ await backup.post("/sign-in/email", { email, password: PASSWORD });
272
+ expect((await backup.post("/two-factor/verify-backup-code", { code: enabled.backupCodes[0] })).status).toBe(200);
273
+ expect(logins().map((entry) => entry.metadata)).toEqual([
274
+ { method: "two-factor-totp" },
275
+ { method: "two-factor-backup-code" },
276
+ ]);
277
+ });
278
+
279
+ test("records an email verification link as a sign-in only when it starts a session", async () => {
280
+ const { auth, mail } = startAuth();
281
+ const owner = await signUp(auth, "verified@example.com");
282
+ await owner.post("/send-verification-email", { email: "verified@example.com", callbackURL: `${BASE_URL}/` });
283
+ expect((await owner.get(mail.verification)).status).toBe(302);
284
+ expect(actions("auth.email_verified")).toHaveLength(1);
285
+ expect(logins()).toEqual([]);
286
+
287
+ const sender = await signUp(auth, "elsewhere@example.com");
288
+ await sender.post("/send-verification-email", { email: "elsewhere@example.com", callbackURL: `${BASE_URL}/` });
289
+ expect((await new Browser(auth).get(mail.verification)).status).toBe(302);
290
+ expect(actions("auth.email_verified")).toHaveLength(2);
291
+ expect(logins()).toEqual([
292
+ expect.objectContaining({ actorEmail: "elsewhere@example.com", metadata: { method: "email-verification" } }),
293
+ ]);
294
+
295
+ const forged = await new Browser(auth).get(`/verify-email?token=forged&callbackURL=${encodeURIComponent(`${BASE_URL}/`)}`);
296
+ expect(forged.status).toBe(302);
297
+ expect(new URL(forged.headers.get("location") ?? "", BASE_URL).searchParams.get("error")).toBeTruthy();
298
+ expect(actions("auth.email_verified")).toHaveLength(2);
299
+ expect(logins()).toHaveLength(1);
300
+ });
301
+
302
+ test("records a sign-in that resumes an OAuth authorization request", async () => {
303
+ const { auth } = startAuth();
304
+ const owner = await signUp(auth, "oauth@example.com");
305
+ const client = await auth.api.adminCreateOAuthClient({
306
+ headers: owner.headers(),
307
+ body: { redirect_uris: ["https://client.example.com/callback"], skip_consent: true },
308
+ });
309
+ const verifier = "audit-hook-code-verifier-0123456789abcdefghijklmnop";
310
+ const query = new URLSearchParams({
311
+ response_type: "code",
312
+ client_id: client.client_id,
313
+ redirect_uri: "https://client.example.com/callback",
314
+ scope: "openid",
315
+ state: "state",
316
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"),
317
+ code_challenge_method: "S256",
318
+ });
319
+
320
+ const browser = new Browser(auth);
321
+ const authorize = await browser.get(`/oauth2/authorize?${query}`);
322
+ expect(authorize.status).toBe(302);
323
+ const login = new URL(authorize.headers.get("location") ?? "", BASE_URL);
324
+ const signedIn = await browser.post("/sign-in/email", {
325
+ email: "oauth@example.com",
326
+ password: PASSWORD,
327
+ oauth_query: login.search.slice(1),
328
+ });
329
+ expect(signedIn.status).toBe(200);
330
+ expect(await signedIn.json()).toMatchObject({ redirect: true, url: expect.stringContaining("code=") });
331
+ expect(logins()).toEqual([expect.objectContaining({ actorEmail: "oauth@example.com", metadata: { method: "password" } })]);
332
+ });
333
+
334
+ test("does not record failed account and admin requests", async () => {
335
+ const { auth } = startAuth();
336
+ const target = await signUp(auth, "target@example.com");
337
+ const targetId = ((await (await target.get("/get-session")).json()) as { user: { id: string } }).user.id;
338
+ const member = await signUp(auth, "member@example.com");
339
+
340
+ expect((await member.post("/admin/ban-user", { userId: targetId })).status).toBe(403);
341
+ expect(actions("user.ban")).toEqual([]);
342
+
343
+ const wrong = await member.post("/change-password", { currentPassword: "Wrong-password-1", newPassword: "N3w-Str0ng!password" });
344
+ expect(wrong.status).toBe(400);
345
+ expect(actions("auth.password_change")).toEqual([]);
346
+
347
+ const changed = await member.post("/change-password", {
348
+ currentPassword: PASSWORD,
349
+ newPassword: "N3w-Str0ng!password",
350
+ revokeOtherSessions: true,
351
+ });
352
+ expect(changed.status).toBe(200);
353
+ expect(actions("auth.password_change")).toEqual([expect.objectContaining({ actorEmail: "member@example.com" })]);
354
+ expect(logins()).toEqual([]);
355
+ });
356
+
357
+ test("records nothing while auditing is off", async () => {
358
+ const { auth } = startAuth(async () => false);
359
+ await signUp(auth, "off@example.com");
360
+ expect((await new Browser(auth).post("/sign-in/email", { email: "off@example.com", password: PASSWORD })).status).toBe(200);
361
+ expect(entries).toEqual([]);
362
+ });
363
+ });
364
+
365
+ describe("the app's Better Auth", () => {
366
+ test("runs the audit hook after every plugin's after hooks", () => {
367
+ const instance = buildAuth(envAuthConfig());
368
+ const plugins = instance.options.plugins as Array<{ id: string }>;
369
+ expect(plugins.at(-1)?.id).toBe("podokit-audit");
370
+ expect(instance.options.hooks?.after).toBeUndefined();
371
+ });
372
+
373
+ test("classifies every endpoint that can sign a user in", () => {
374
+ const instance = buildAuth(envAuthConfig());
375
+ const paths = new Set(Object.values(instance.api as Record<string, { path?: string }>).map((endpoint) => endpoint.path ?? ""));
376
+ expect(Object.keys(SIGN_IN_METHODS).filter((path) => !paths.has(path))).toEqual([]);
377
+ const unclassified = [...paths].filter(
378
+ (path) =>
379
+ (path.startsWith("/sign-in/") || /callback|verify/.test(path)) &&
380
+ SIGN_IN_METHODS[path] === undefined &&
381
+ !STARTS_NO_SESSION.has(path),
382
+ );
383
+ expect(unclassified).toEqual([]);
384
+ });
385
+ });
@@ -1,13 +1,31 @@
1
- import { createAuthMiddleware } from "better-auth/api";
1
+ import type { BetterAuthPlugin } from "better-auth";
2
+ import { createAuthMiddleware, isAPIError } from "better-auth/api";
2
3
  import { clientAddressOf } from "../core/client-address";
3
4
  import { recordAudit } from "./audit-events";
4
5
  import { auditEnabled } from "./audit-enabled";
5
6
 
6
- // Map mutating auth/admin endpoints to semantic action codes. better-auth is
7
- // mounted directly in Elysia, so these security events are recorded through the
8
- // same application audit pipeline.
9
- const ACTIONS: Record<string, string> = {
10
- "/sign-in/email": "auth.login",
7
+ // Endpoints that sign a user in, with the method their auth.login entry records.
8
+ // ctx.path is the route pattern an endpoint was registered with, not the request
9
+ // URL, so every social provider's callback arrives as "/callback/:id".
10
+ export const SIGN_IN_METHODS: Readonly<Record<string, string>> = {
11
+ "/sign-in/email": "password",
12
+ "/sign-in/username": "username",
13
+ "/sign-in/phone-number": "phone-number",
14
+ "/sign-in/email-otp": "email-otp",
15
+ "/sign-in/social": "social",
16
+ "/callback/:id": "social",
17
+ "/magic-link/verify": "magic-link",
18
+ "/passkey/verify-authentication": "passkey",
19
+ "/phone-number/verify": "phone-number-otp",
20
+ "/two-factor/verify-totp": "two-factor-totp",
21
+ "/two-factor/verify-otp": "two-factor-otp",
22
+ "/two-factor/verify-backup-code": "two-factor-backup-code",
23
+ "/verify-email": "email-verification",
24
+ "/email-otp/verify-email": "email-otp-verification",
25
+ };
26
+
27
+ // Other auth and admin endpoints, with the action each records once it succeeds.
28
+ const ACTIONS: Readonly<Record<string, string>> = {
11
29
  "/sign-up/email": "auth.signup",
12
30
  "/sign-out": "auth.logout",
13
31
  "/change-password": "auth.password_change",
@@ -35,43 +53,114 @@ const ACTIONS: Record<string, string> = {
35
53
  "/admin/stop-impersonating": "user.stop_impersonate",
36
54
  };
37
55
 
38
- // The top-level `hooks.after` is a single middleware; it runs only on success,
39
- // so it records completed actions.
40
- export const auditAfterHook = createAuthMiddleware(async (ctx) => {
41
- const action = ACTIONS[ctx.path];
42
- if (!action) return;
43
- // AuditService checks the toggle again on write; checking here as well skips
44
- // the target lookup below when auditing is off.
45
- if (!(await auditEnabled())) return;
46
- const context = ctx.context as {
47
- session?: { user?: { id?: string; name?: string; email?: string } };
48
- newSession?: { user?: { id?: string; name?: string; email?: string } };
49
- };
50
- const actor = context.session?.user ?? context.newSession?.user;
51
- const body = (ctx.body ?? {}) as { email?: string; userId?: string };
56
+ interface SessionRecord {
57
+ session: { token: string };
58
+ user: { id: string; name?: string | null; email?: string | null };
59
+ }
52
60
 
53
- // Prefer a human-readable target: the email in the body, otherwise resolve the
54
- // target user's email by id (falls back to the id for already-deleted users).
55
- const targetId = body.userId ?? null;
56
- let targetLabel = body.email ?? null;
57
- if (!targetLabel && targetId) {
58
- try {
59
- const adapter = (ctx.context as { internalAdapter?: { findUserById?: (id: string) => Promise<{ email?: string } | null> } })
60
- .internalAdapter;
61
- const target = await adapter?.findUserById?.(targetId);
62
- targetLabel = target?.email ?? targetId;
63
- } catch {
64
- targetLabel = targetId;
65
- }
61
+ // After hooks run for failed requests too, with the APIError the endpoint threw
62
+ // in ctx.context.returned. Redirects are APIErrors as well: a 3xx status, and an
63
+ // `error` query parameter on the location when the redirect reports a failure.
64
+ function failed(returned: unknown): boolean {
65
+ if (!isAPIError(returned)) return false;
66
+ if (returned.statusCode >= 400) return true;
67
+ const location = new Headers(returned.headers).get("location");
68
+ if (!location) return false;
69
+ try {
70
+ return new URL(location, "http://localhost").searchParams.has("error");
71
+ } catch {
72
+ return false;
66
73
  }
74
+ }
75
+
76
+ // A sign-in ends with a session the request did not come with. Two requests end
77
+ // with a new session without signing anyone in: confirming two-factor replaces
78
+ // the signed-in user's session, which two-factor loaded into ctx.context.session,
79
+ // and verifying the signed-in user's email sets the cookie of the session the
80
+ // request came with again. oauthProvider() also puts the new session into
81
+ // ctx.context.session when a sign-in resumes an authorization request.
82
+ function signedIn(
83
+ created: SessionRecord | null | undefined,
84
+ current: SessionRecord | null | undefined,
85
+ presentedToken: string | null | false,
86
+ ): created is SessionRecord {
87
+ if (!created || created.session.token === presentedToken) return false;
88
+ return !(current && current.user.id === created.user.id && current.session.token !== created.session.token);
89
+ }
90
+
91
+ export interface AuditPluginOptions {
92
+ /** Whether auditing is on. Defaults to the admin Settings toggle. */
93
+ enabled?: () => Promise<boolean>;
94
+ }
67
95
 
68
- await recordAudit({
69
- action,
70
- actorId: actor?.id ?? null,
71
- actorName: actor?.name ?? null,
72
- actorEmail: actor?.email ?? null,
73
- targetId,
74
- targetLabel,
75
- ip: clientAddressOf(ctx.headers) ?? null,
76
- });
77
- });
96
+ // Records auth and admin actions through the application audit pipeline. Pushed
97
+ // after every other plugin, because after hooks run in plugin order and earlier
98
+ // ones change the outcome: two-factor turns a password sign-in into a pending
99
+ // challenge by clearing ctx.context.newSession.
100
+ export function auditPlugin(options: AuditPluginOptions = {}): BetterAuthPlugin & { id: "podokit-audit" } {
101
+ const enabled = options.enabled ?? auditEnabled;
102
+ return {
103
+ id: "podokit-audit",
104
+ hooks: {
105
+ after: [
106
+ {
107
+ matcher: (context) => Boolean(context.path && (SIGN_IN_METHODS[context.path] || ACTIONS[context.path])),
108
+ handler: createAuthMiddleware(async (ctx) => {
109
+ if (failed(ctx.context.returned)) return;
110
+ // AuditService checks the toggle again on write; checking here as well
111
+ // skips the lookups below when auditing is off.
112
+ if (!(await enabled())) return;
113
+ const ip = clientAddressOf(ctx.headers) ?? null;
114
+
115
+ const action = ACTIONS[ctx.path];
116
+ if (action) {
117
+ const actor = ctx.context.session?.user ?? ctx.context.newSession?.user;
118
+ const body = (ctx.body ?? {}) as { email?: string; userId?: string };
119
+
120
+ // Prefer a human-readable target: the email in the body, otherwise
121
+ // the target user's email by id (the id for already-deleted users).
122
+ const targetId = body.userId ?? null;
123
+ let targetLabel = body.email ?? null;
124
+ if (!targetLabel && targetId) {
125
+ try {
126
+ const target = await ctx.context.internalAdapter.findUserById(targetId);
127
+ targetLabel = target?.email ?? targetId;
128
+ } catch {
129
+ targetLabel = targetId;
130
+ }
131
+ }
132
+ await recordAudit({
133
+ action,
134
+ actorId: actor?.id ?? null,
135
+ actorName: actor?.name ?? null,
136
+ actorEmail: actor?.email ?? null,
137
+ targetId,
138
+ targetLabel,
139
+ ip,
140
+ });
141
+ }
142
+
143
+ const method = SIGN_IN_METHODS[ctx.path];
144
+ if (!method) return;
145
+ const created = ctx.context.newSession;
146
+ const presented = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
147
+ if (!signedIn(created, ctx.context.session, presented)) return;
148
+ const params = (ctx.params ?? {}) as { id?: string };
149
+ const body = (ctx.body ?? {}) as { provider?: string };
150
+ const provider = method === "social" ? (params.id ?? body.provider) : undefined;
151
+ await recordAudit({
152
+ action: "auth.login",
153
+ actorId: created.user.id,
154
+ actorName: created.user.name ?? null,
155
+ actorEmail: created.user.email ?? null,
156
+ targetId: created.user.id,
157
+ targetLabel: created.user.email ?? null,
158
+ ip,
159
+ metadata: provider ? { method, provider } : { method },
160
+ });
161
+ }),
162
+ },
163
+ ],
164
+ },
165
+ };
166
+ }
@@ -26,12 +26,12 @@
26
26
  {
27
27
  "file": "apps/api/src/auth/auth.ts",
28
28
  "marker": "// podokit:end:auth-imports",
29
- "text": "import { auditAfterHook } from \"../audit/audit-hook\";"
29
+ "text": "import { auditPlugin } from \"../audit/audit-hook\";"
30
30
  },
31
31
  {
32
32
  "file": "apps/api/src/auth/auth.ts",
33
33
  "marker": "// podokit:end:auth-hooks",
34
- "text": "hooks.after = auditAfterHook;"
34
+ "text": "plugins.push(auditPlugin());"
35
35
  },
36
36
  {
37
37
  "file": "infra/k3s/configmap.yaml",
@@ -192,7 +192,9 @@ export function buildAuth(config: AuthConfig) {
192
192
  // Request-time hooks. The feature gate turns the admin Settings toggles into a
193
193
  // real server boundary (disabled features 404) for endpoint calls, including
194
194
  // in-process auth.api calls the plugin above never sees. Other PodoKit modules
195
- // add their own hooks below the marker.
195
+ // add their own hooks below the marker. The top-level `hooks.after` runs before
196
+ // every plugin's after hooks, so a hook that needs a request's final outcome is
197
+ // pushed below the marker as a plugin, after all the plugins above.
196
198
  const hooks: NonNullable<BetterAuthOptions["hooks"]> = {
197
199
  before: createFeatureGate(gateSettings),
198
200
  };