@checodo/better-auth-audit-log 0.0.0-stage → 0.1.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/index.js ADDED
@@ -0,0 +1,1478 @@
1
+ import { t as AUDIT_LOG_ERROR_CODES } from "./error-codes-B9Frh9bu.js";
2
+ import { mergeSchema } from "better-auth/db";
3
+ import { getCurrentAdapter, queueAfterTransactionHook, runWithAdapter, tryGetCurrentAuthEndpointContext } from "@better-auth/core/context";
4
+ import { isAPIError } from "@better-auth/core/utils/is-api-error";
5
+ import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
6
+ import { getIP } from "@better-auth/core/utils/ip";
7
+ import * as z from "zod";
8
+ // package.json
9
+ var version = "0.1.0";
10
+ // src/utils/method.ts
11
+ /**
12
+ * How a user signed in or up, from the endpoint that did it:
13
+ * `/sign-in/email` → `email`, `/callback/github` → `github`,
14
+ * `/magic-link/verify` → `magic-link`, `/two-factor/verify-totp` → `two-factor`.
15
+ */
16
+ function signInMethod(path, params, body) {
17
+ const provider = params?.id ?? params?.providerId;
18
+ if (typeof provider === "string" && provider) return provider;
19
+ if (!path) return null;
20
+ if (path === "/sign-in/social" && typeof body?.provider === "string") return body.provider;
21
+ const [first, second] = path.split("/").filter(Boolean);
22
+ if ((first === "sign-in" || first === "sign-up") && second) return second;
23
+ return first ?? null;
24
+ }
25
+ // src/hooks/database.ts
26
+ /** Endpoints that verify an email address (not OAuth sign-ins, which rewrite the flag). */
27
+ const VERIFY_EMAIL_PATHS = /* @__PURE__ */ new Set(["/verify-email", "/email-otp/verify-email"]);
28
+ /**
29
+ * How long a new user's id is remembered, so the account created with the
30
+ * user (same request, milliseconds later) isn't recorded as a link.
31
+ */
32
+ const NEW_USER_TTL = 1e4;
33
+ /**
34
+ * The user an update is for, as far as the request tells: the target of
35
+ * the admin plugin's `updateUser`, otherwise the signed-in user (the
36
+ * two-factor plugin changes the user's own setting).
37
+ */
38
+ function updatedUserId(context) {
39
+ if (context.path === "/admin/update-user") {
40
+ const userId = context.body?.userId;
41
+ return typeof userId === "string" ? userId : null;
42
+ }
43
+ return context.context.session?.user.id ?? null;
44
+ }
45
+ /**
46
+ * Events about users and accounts, recorded however they happen (any
47
+ * endpoint, any plugin, server-side calls): sign-ups, deleted users,
48
+ * linked accounts, changed and verified emails, two-factor turned on or off.
49
+ */
50
+ function createDatabaseHooks(authContext, recorder) {
51
+ const contextFor = (context) => context?.context ?? authContext;
52
+ const headersOf = (context) => context?.request?.headers ?? context?.headers ?? null;
53
+ /** The signed-in user acting, e.g. an admin; `fallback` when nobody is signed in. */
54
+ const actor = (context, fallback) => context?.context.session?.user.id ?? fallback;
55
+ const newUsers = /* @__PURE__ */ new Map();
56
+ const rememberNewUser = (userId) => {
57
+ const now = Date.now();
58
+ for (const [id, at] of newUsers) if (now - at > NEW_USER_TTL) newUsers.delete(id);
59
+ newUsers.set(userId, now);
60
+ };
61
+ const wasJustCreated = (userId) => {
62
+ const at = newUsers.get(userId);
63
+ newUsers.delete(userId);
64
+ return at !== void 0 && Date.now() - at <= NEW_USER_TTL;
65
+ };
66
+ const pendingUpdates = /* @__PURE__ */ new WeakMap();
67
+ const record = (context, details) => recorder.record(contextFor(context), details, headersOf(context));
68
+ return {
69
+ user: {
70
+ create: { after: async (user, context) => {
71
+ rememberNewUser(user.id);
72
+ await record(context, {
73
+ type: "sign-up",
74
+ userId: user.id,
75
+ actorUserId: actor(context, user.id),
76
+ metadata: { method: signInMethod(context?.path, context?.params, context?.body) }
77
+ });
78
+ } },
79
+ update: {
80
+ before: async (data, context) => {
81
+ if (!context) return;
82
+ const flags = {
83
+ emailChanged: null,
84
+ emailVerified: false,
85
+ twoFactor: null,
86
+ twoFactorUserId: null
87
+ };
88
+ try {
89
+ if (typeof data.email === "string") {
90
+ const email = data.email.toLowerCase();
91
+ if (!await (await getCurrentAdapter(contextFor(context).adapter)).findOne({
92
+ model: "user",
93
+ where: [{
94
+ field: "email",
95
+ value: email
96
+ }]
97
+ })) flags.emailChanged = email;
98
+ }
99
+ flags.emailVerified = !flags.emailChanged && data.emailVerified === true && VERIFY_EMAIL_PATHS.has(context.path ?? "");
100
+ const twoFactorUserId = typeof data.twoFactorEnabled === "boolean" ? updatedUserId(context) : null;
101
+ if (twoFactorUserId) {
102
+ const stored = await (await getCurrentAdapter(contextFor(context).adapter)).findOne({
103
+ model: "user",
104
+ where: [{
105
+ field: "id",
106
+ value: twoFactorUserId
107
+ }]
108
+ });
109
+ if (stored && stored.twoFactorEnabled === true !== data.twoFactorEnabled) {
110
+ flags.twoFactor = data.twoFactorEnabled;
111
+ flags.twoFactorUserId = twoFactorUserId;
112
+ }
113
+ }
114
+ } catch (error) {
115
+ contextFor(context).logger.error("[audit-log] failed to inspect a user update", error);
116
+ }
117
+ const queue = pendingUpdates.get(context) ?? [];
118
+ queue.push(flags);
119
+ pendingUpdates.set(context, queue);
120
+ },
121
+ after: async (user, context) => {
122
+ if (!context || !user) return;
123
+ const flags = pendingUpdates.get(context)?.shift();
124
+ if (!flags) return;
125
+ if (flags.emailChanged && user.email?.toLowerCase() === flags.emailChanged) await record(context, {
126
+ type: "email-changed",
127
+ userId: user.id,
128
+ actorUserId: actor(context, user.id),
129
+ metadata: { email: flags.emailChanged }
130
+ });
131
+ else if (flags.emailVerified) await record(context, {
132
+ type: "email-verified",
133
+ userId: user.id,
134
+ actorUserId: user.id
135
+ });
136
+ const twoFactorEnabled = user.twoFactorEnabled;
137
+ if (flags.twoFactor !== null && user.id === flags.twoFactorUserId && twoFactorEnabled === flags.twoFactor) await record(context, {
138
+ type: flags.twoFactor ? "two-factor-enabled" : "two-factor-disabled",
139
+ userId: user.id,
140
+ actorUserId: actor(context, user.id)
141
+ });
142
+ }
143
+ },
144
+ delete: { after: async (user, context) => {
145
+ await record(context, {
146
+ type: "user-deleted",
147
+ userId: user.id,
148
+ actorUserId: actor(context, user.id)
149
+ });
150
+ } }
151
+ },
152
+ account: { create: { after: async (account, context) => {
153
+ if (wasJustCreated(account.userId)) return;
154
+ await record(context, {
155
+ type: "account-linked",
156
+ userId: account.userId,
157
+ actorUserId: actor(context, account.userId),
158
+ metadata: { providerId: account.providerId }
159
+ });
160
+ } } }
161
+ };
162
+ }
163
+ // src/hooks/paths.ts
164
+ /** Where the before-hook leaves what it looked up for the after-hook. */
165
+ const SUBJECT_KEY = "auditLogSubjectUserId";
166
+ /** Endpoints whose failures are failed sign-ins. Only these: they check a credential. */
167
+ const SIGN_IN_PATHS = /* @__PURE__ */ new Set([
168
+ "/sign-in/email",
169
+ "/sign-in/username",
170
+ "/sign-in/phone-number",
171
+ "/sign-in/email-otp",
172
+ "/two-factor/verify-totp",
173
+ "/two-factor/verify-otp",
174
+ "/two-factor/verify-backup-code"
175
+ ]);
176
+ /**
177
+ * Endpoints that set a new session cookie for a user who is already signed
178
+ * in (rotation, impersonation): not sign-ins. Most of them also load the
179
+ * session, which rules them out anyway; this list covers the rest.
180
+ */
181
+ const NOT_SIGN_IN_PATHS = /* @__PURE__ */ new Set([
182
+ "/change-password",
183
+ "/update-user",
184
+ "/update-session",
185
+ "/change-email",
186
+ "/two-factor/enable",
187
+ "/two-factor/disable",
188
+ "/admin/impersonate-user",
189
+ "/admin/stop-impersonating",
190
+ "/multi-session/set-active"
191
+ ]);
192
+ const target = (body) => body.userId;
193
+ const lookedUp = (_body, _returned, userId) => userId;
194
+ /** A field of the endpoint's result, if it succeeded (`returned` is an error otherwise). */
195
+ function field(returned, ...path) {
196
+ let value = isAPIError(returned) ? void 0 : returned;
197
+ for (const key of path) value = value && typeof value === "object" ? value[key] : void 0;
198
+ return value;
199
+ }
200
+ /** Endpoints whose subject is the user of a password reset, looked up before the reset. */
201
+ const PASSWORD_RESET_PATHS = /* @__PURE__ */ new Set([
202
+ "/reset-password",
203
+ "/email-otp/reset-password",
204
+ "/phone-number/reset-password"
205
+ ]);
206
+ const passwordReset = {
207
+ type: "password-reset",
208
+ subject: lookedUp
209
+ };
210
+ /** Endpoints recorded by path. Admin actions are recorded on success and on failure. */
211
+ const PATH_EVENTS = {
212
+ "/sign-out": { type: "sign-out" },
213
+ "/revoke-session": {
214
+ type: "session-revoked",
215
+ metadata: () => ({ scope: "session" })
216
+ },
217
+ "/revoke-sessions": {
218
+ type: "session-revoked",
219
+ metadata: () => ({ scope: "all" })
220
+ },
221
+ "/revoke-other-sessions": {
222
+ type: "session-revoked",
223
+ metadata: () => ({ scope: "others" })
224
+ },
225
+ "/change-password": {
226
+ type: "password-changed",
227
+ metadata: (body) => ({ revokeOtherSessions: body.revokeOtherSessions === true })
228
+ },
229
+ "/reset-password": passwordReset,
230
+ "/email-otp/reset-password": passwordReset,
231
+ "/phone-number/reset-password": passwordReset,
232
+ "/unlink-account": {
233
+ type: "account-unlinked",
234
+ metadata: (body) => ({ providerId: body.providerId })
235
+ },
236
+ "/admin/create-user": {
237
+ type: "admin.create-user",
238
+ subject: (_body, returned) => returned?.user?.id,
239
+ metadata: (body) => ({ role: body.role ?? null })
240
+ },
241
+ "/admin/update-user": {
242
+ type: "admin.update-user",
243
+ subject: target,
244
+ metadata: (body) => ({ fields: body.data && typeof body.data === "object" ? Object.keys(body.data) : [] })
245
+ },
246
+ "/admin/set-role": {
247
+ type: "admin.set-role",
248
+ subject: target,
249
+ metadata: (body) => ({ role: body.role })
250
+ },
251
+ "/admin/set-user-password": {
252
+ type: "admin.set-user-password",
253
+ subject: target
254
+ },
255
+ "/admin/ban-user": {
256
+ type: "admin.ban-user",
257
+ subject: target,
258
+ metadata: (body) => ({
259
+ reason: body.banReason ?? null,
260
+ expiresIn: body.banExpiresIn ?? null
261
+ })
262
+ },
263
+ "/admin/unban-user": {
264
+ type: "admin.unban-user",
265
+ subject: target
266
+ },
267
+ "/admin/impersonate-user": {
268
+ type: "admin.impersonate-user",
269
+ subject: target
270
+ },
271
+ "/admin/stop-impersonating": { type: "admin.stop-impersonating" },
272
+ "/admin/revoke-user-session": {
273
+ type: "admin.revoke-user-session",
274
+ subject: lookedUp
275
+ },
276
+ "/admin/revoke-user-sessions": {
277
+ type: "admin.revoke-user-sessions",
278
+ subject: target
279
+ },
280
+ "/admin/remove-user": {
281
+ type: "admin.remove-user",
282
+ subject: target
283
+ },
284
+ "/organization/create": {
285
+ type: "organization.created",
286
+ organization: (_body, returned) => field(returned, "id"),
287
+ metadata: (body) => ({
288
+ name: body.name ?? null,
289
+ slug: body.slug ?? null
290
+ })
291
+ },
292
+ "/organization/update": {
293
+ type: "organization.updated",
294
+ organization: (body, returned) => field(returned, "id") ?? body.organizationId,
295
+ metadata: (body) => ({ fields: body.data && typeof body.data === "object" ? Object.keys(body.data) : [] })
296
+ },
297
+ "/organization/delete": {
298
+ type: "organization.deleted",
299
+ organization: (body) => body.organizationId
300
+ },
301
+ "/organization/invite-member": {
302
+ type: "organization.invitation-created",
303
+ organization: (body, returned) => field(returned, "organizationId") ?? body.organizationId,
304
+ metadata: (body, returned) => ({
305
+ invitationId: field(returned, "id") ?? null,
306
+ email: typeof body.email === "string" ? body.email.toLowerCase() : null,
307
+ role: body.role ?? null
308
+ })
309
+ },
310
+ "/organization/accept-invitation": {
311
+ type: "organization.invitation-accepted",
312
+ organization: (_body, returned) => field(returned, "invitation", "organizationId"),
313
+ metadata: (body, returned) => ({
314
+ invitationId: body.invitationId ?? null,
315
+ memberId: field(returned, "member", "id") ?? null,
316
+ role: field(returned, "member", "role") ?? null
317
+ })
318
+ },
319
+ "/organization/reject-invitation": {
320
+ type: "organization.invitation-rejected",
321
+ organization: (_body, returned) => field(returned, "invitation", "organizationId"),
322
+ metadata: (body) => ({ invitationId: body.invitationId ?? null })
323
+ },
324
+ "/organization/cancel-invitation": {
325
+ type: "organization.invitation-canceled",
326
+ organization: (_body, returned) => field(returned, "organizationId"),
327
+ metadata: (body, returned) => ({
328
+ invitationId: body.invitationId ?? null,
329
+ email: field(returned, "email") ?? null
330
+ })
331
+ },
332
+ "/organization/remove-member": {
333
+ type: "organization.member-removed",
334
+ subject: (_body, returned) => field(returned, "member", "userId"),
335
+ organization: (body, returned) => field(returned, "member", "organizationId") ?? body.organizationId,
336
+ metadata: (_body, returned) => ({ memberId: field(returned, "member", "id") ?? null })
337
+ },
338
+ "/organization/update-member-role": {
339
+ type: "organization.member-role-changed",
340
+ subject: (_body, returned) => field(returned, "userId"),
341
+ organization: (body, returned) => field(returned, "organizationId") ?? body.organizationId,
342
+ metadata: (body) => ({
343
+ memberId: body.memberId ?? null,
344
+ role: body.role ?? null
345
+ })
346
+ },
347
+ "/organization/leave": {
348
+ type: "organization.member-left",
349
+ organization: (body) => body.organizationId
350
+ }
351
+ };
352
+ const asString = (value) => typeof value === "string" && value !== "" ? value : null;
353
+ /**
354
+ * Looks up, before the endpoint runs, what is gone afterwards: the
355
+ * session (sign-out, revoke), the user of a password reset (by token, or by
356
+ * the email or phone number of an OTP reset), or of a session an admin
357
+ * revokes.
358
+ */
359
+ async function lookUpSubject(ctx) {
360
+ const body = ctx.body ?? {};
361
+ if (ctx.path === "/reset-password") {
362
+ const token = asString(body.token) ?? asString(ctx.query?.token);
363
+ if (!token) return void 0;
364
+ return (await ctx.context.internalAdapter.findVerificationValue(`reset-password:${token}`))?.value;
365
+ }
366
+ if (ctx.path === "/email-otp/reset-password") {
367
+ const email = asString(body.email)?.trim().toLowerCase();
368
+ if (!email) return void 0;
369
+ return (await ctx.context.internalAdapter.findUserByEmail(email))?.user.id;
370
+ }
371
+ if (ctx.path === "/phone-number/reset-password") {
372
+ const phone = asString(body.phoneNumber)?.trim();
373
+ if (!phone) return void 0;
374
+ return (await ctx.context.adapter.findOne({
375
+ model: "user",
376
+ where: [{
377
+ field: "phoneNumber",
378
+ value: phone
379
+ }]
380
+ }).catch(() => null))?.id;
381
+ }
382
+ if (ctx.path === "/admin/revoke-user-session") {
383
+ const token = asString(body.sessionToken);
384
+ if (!token) return void 0;
385
+ return (await ctx.context.internalAdapter.findSession(token))?.user.id;
386
+ }
387
+ }
388
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
389
+ const PHONE = /^\+?[0-9 ()-]{5,20}$/;
390
+ /**
391
+ * Who a failed sign-in was for. People type their password into the
392
+ * email or username field, so the input is only kept when it is clearly
393
+ * an identifier: a well-formed email or phone number, or the username of
394
+ * an existing user.
395
+ */
396
+ async function failedSignInSubject(ctx, body) {
397
+ const email = asString(body.email)?.trim().toLowerCase();
398
+ if (email) {
399
+ if (!EMAIL.test(email) || email.length > 255) return {
400
+ userId: null,
401
+ identifier: null
402
+ };
403
+ return {
404
+ userId: (await ctx.context.internalAdapter.findUserByEmail(email))?.user.id ?? null,
405
+ identifier: email
406
+ };
407
+ }
408
+ const username = asString(body.username)?.trim().toLowerCase();
409
+ if (username) {
410
+ const user = await ctx.context.adapter.findOne({
411
+ model: "user",
412
+ where: [{
413
+ field: "username",
414
+ value: username
415
+ }]
416
+ }).catch(() => null);
417
+ return user ? {
418
+ userId: user.id,
419
+ identifier: username
420
+ } : {
421
+ userId: null,
422
+ identifier: null
423
+ };
424
+ }
425
+ const phone = asString(body.phoneNumber)?.trim();
426
+ if (phone && PHONE.test(phone)) return {
427
+ userId: (await ctx.context.adapter.findOne({
428
+ model: "user",
429
+ where: [{
430
+ field: "phoneNumber",
431
+ value: phone
432
+ }]
433
+ }).catch(() => null))?.id ?? null,
434
+ identifier: phone
435
+ };
436
+ return {
437
+ userId: null,
438
+ identifier: null
439
+ };
440
+ }
441
+ function failureOf(returned) {
442
+ if (!isAPIError(returned) || returned.statusCode < 400) return {
443
+ failed: false,
444
+ errorCode: null
445
+ };
446
+ const code = returned.body?.code;
447
+ return {
448
+ failed: true,
449
+ errorCode: typeof code === "string" ? code : returned.status.toString()
450
+ };
451
+ }
452
+ function sessionDetails(session) {
453
+ if (!session) return {};
454
+ const { id, activeOrganizationId } = session.session;
455
+ return {
456
+ sessionId: id,
457
+ organizationId: asString(activeOrganizationId)
458
+ };
459
+ }
460
+ function createPathHooks(options, recorder) {
461
+ const eventFor = (path) => {
462
+ if (!path) return void 0;
463
+ if (Object.hasOwn(options.paths, path)) return { type: options.paths[path] };
464
+ return Object.hasOwn(PATH_EVENTS, path) ? PATH_EVENTS[path] : void 0;
465
+ };
466
+ return {
467
+ before: [{
468
+ matcher: (context) => {
469
+ const event = eventFor(context.path);
470
+ return !!event && recorder.isRecorded(event.type);
471
+ },
472
+ handler: createAuthMiddleware(async (ctx) => {
473
+ try {
474
+ await getSessionFromCtx(ctx, { disableRefresh: true });
475
+ const subject = await lookUpSubject(ctx);
476
+ if (subject) ctx.context[SUBJECT_KEY] = subject;
477
+ } catch (error) {
478
+ ctx.context.logger.error("[audit-log] failed to prepare an event", error);
479
+ }
480
+ })
481
+ }],
482
+ after: [{
483
+ matcher: (context) => !!context.path,
484
+ handler: createAuthMiddleware(async (ctx) => {
485
+ try {
486
+ await afterEndpoint(ctx);
487
+ } catch (error) {
488
+ ctx.context.logger.error("[audit-log] failed to record an event", error);
489
+ }
490
+ })
491
+ }]
492
+ };
493
+ async function afterEndpoint(ctx) {
494
+ const { path } = ctx;
495
+ const body = ctx.body ?? {};
496
+ const headers = ctx.request?.headers ?? ctx.headers ?? null;
497
+ const { failed, errorCode } = failureOf(ctx.context.returned);
498
+ const session = ctx.context.session;
499
+ const created = ctx.context.newSession;
500
+ if (!failed && created && !session && !NOT_SIGN_IN_PATHS.has(path) && !created.session.impersonatedBy) await recorder.record(ctx.context, {
501
+ type: "sign-in",
502
+ userId: created.user.id,
503
+ actorUserId: created.user.id,
504
+ ...sessionDetails(created),
505
+ metadata: { method: signInMethod(path, ctx.params, body) }
506
+ }, headers);
507
+ if (failed && SIGN_IN_PATHS.has(path)) {
508
+ const { userId, identifier } = await failedSignInSubject(ctx, body);
509
+ await recorder.record(ctx.context, {
510
+ type: "sign-in",
511
+ status: "failure",
512
+ errorCode,
513
+ userId,
514
+ metadata: {
515
+ method: signInMethod(path),
516
+ ...identifier ? { identifier } : {}
517
+ }
518
+ }, headers);
519
+ return;
520
+ }
521
+ const event = eventFor(path);
522
+ if (!event) return;
523
+ const subject = ctx.context[SUBJECT_KEY];
524
+ let actorUserId = session?.user.id ?? null;
525
+ let userId = event.subject ? asString(event.subject(body, ctx.context.returned, subject)) : actorUserId;
526
+ if (path === "/admin/stop-impersonating" && session) {
527
+ actorUserId = asString(session.session.impersonatedBy) ?? session.user.id;
528
+ userId = session.user.id;
529
+ }
530
+ if (PASSWORD_RESET_PATHS.has(path)) actorUserId = userId;
531
+ if (!userId && !actorUserId) return;
532
+ const details = sessionDetails(session);
533
+ const organizationId = event.organization ? asString(event.organization(body, ctx.context.returned)) : null;
534
+ await recorder.record(ctx.context, {
535
+ type: event.type,
536
+ status: failed ? "failure" : "success",
537
+ errorCode,
538
+ userId,
539
+ actorUserId,
540
+ ...details,
541
+ organizationId: organizationId ?? details.organizationId ?? null,
542
+ metadata: event.metadata ? event.metadata(body, ctx.context.returned) : null
543
+ }, headers);
544
+ }
545
+ }
546
+ // ../plugin-utils/src/admin.ts
547
+ /** Applies the defaults of `AdminOptions`. */
548
+ function resolveAdminOptions(options) {
549
+ return {
550
+ adminRoles: options.adminRoles ?? ["admin"],
551
+ adminUserIds: options.adminUserIds ?? []
552
+ };
553
+ }
554
+ /**
555
+ * Whether `user` is listed in `adminUserIds`, or has one of `adminRoles`
556
+ * in the admin plugin's `role` field, which holds several roles
557
+ * comma-separated (`"user, admin"`).
558
+ */
559
+ function isAdmin(user, options) {
560
+ if (options.adminUserIds.includes(user.id)) return true;
561
+ if (typeof user.role !== "string") return false;
562
+ return user.role.split(",").some((role) => options.adminRoles.includes(role.trim()));
563
+ }
564
+ // ../plugin-utils/src/options.ts
565
+ /**
566
+ * Returns a function that throws an option error prefixed with the plugin
567
+ * id, `[referral] "codeLength" must be ...`, for validating options at
568
+ * plugin creation.
569
+ */
570
+ function createFail(pluginId) {
571
+ return (message) => {
572
+ throw new Error(`[${pluginId}] ${message}`);
573
+ };
574
+ }
575
+ const SEVERITIES = [
576
+ "low",
577
+ "medium",
578
+ "high",
579
+ "critical"
580
+ ];
581
+ const fail = createFail("audit-log");
582
+ function isValidEventType(type) {
583
+ return type.length <= 64 && /^[a-z0-9][a-z0-9._-]*$/i.test(type);
584
+ }
585
+ /**
586
+ * Compiles `events` patterns into a matcher: exact types, or `group.*`
587
+ * for every type starting with `group.`.
588
+ */
589
+ function compileEventFilter(patterns) {
590
+ const exact = /* @__PURE__ */ new Set();
591
+ const prefixes = [];
592
+ for (const pattern of patterns) if (pattern.endsWith(".*") && isValidEventType(pattern.slice(0, -2))) prefixes.push(pattern.slice(0, -1));
593
+ else if (isValidEventType(pattern)) exact.add(pattern);
594
+ else fail(`Invalid event pattern ${JSON.stringify(pattern)} in "events".`);
595
+ return (type) => exact.has(type) || prefixes.some((prefix) => type.startsWith(prefix));
596
+ }
597
+ /** Resolves defaults and validates the options once, at plugin creation. */
598
+ function resolveOptions(options) {
599
+ const paths = { ...options.paths };
600
+ for (const [path, type] of Object.entries(paths)) {
601
+ if (!path.startsWith("/")) fail(`Path ${JSON.stringify(path)} in "paths" must start with "/".`);
602
+ if (!isValidEventType(type)) fail(`Invalid event type ${JSON.stringify(type)} in "paths": use letters, digits, ".", "-", and "_", up to 64 characters.`);
603
+ }
604
+ const severity = { ...options.severity };
605
+ for (const [type, value] of Object.entries(severity)) if (!SEVERITIES.includes(value)) fail(`Invalid severity ${JSON.stringify(value)} for "${type}": use ${SEVERITIES.join(", ")}.`);
606
+ let retention;
607
+ if (options.retention) {
608
+ const { days, sweepInterval = 3600 } = options.retention;
609
+ if (!(typeof days === "number" && days > 0 && Number.isFinite(days))) fail(`"retention.days" must be a positive number, got ${days}.`);
610
+ if (!(typeof sweepInterval === "number" && sweepInterval > 0)) fail(`"retention.sweepInterval" must be a positive number of seconds, got ${sweepInterval}.`);
611
+ retention = {
612
+ days,
613
+ sweepInterval
614
+ };
615
+ }
616
+ let hashChain = null;
617
+ if (options.hashChain) {
618
+ if (options.storage) fail("\"hashChain\" needs the default database storage; remove \"storage\" or \"hashChain\".");
619
+ const secret = typeof options.hashChain === "object" ? options.hashChain.secret : void 0;
620
+ if (secret !== void 0 && secret.length < 32) fail("\"hashChain.secret\" must be at least 32 characters.");
621
+ hashChain = { secret };
622
+ }
623
+ return {
624
+ events: options.events ? compileEventFilter(options.events) : null,
625
+ paths,
626
+ severity,
627
+ captureIp: options.captureIp ?? true,
628
+ captureUserAgent: options.captureUserAgent ?? true,
629
+ beforeLog: options.beforeLog,
630
+ onEvent: options.onEvent,
631
+ storage: options.storage,
632
+ retention,
633
+ hashChain,
634
+ ...resolveAdminOptions(options)
635
+ };
636
+ }
637
+ // src/record-event.ts
638
+ /**
639
+ * Records an event for another plugin, through
640
+ * `ctx.context.getPlugin("audit-log")?.recordEvent(...)`, the way official
641
+ * plugins reach each other (`anonymous` calls `twoFactor` like this).
642
+ *
643
+ * Fills in what the request knows: the signed-in user as actor (unless
644
+ * `actorUserId` is given), the session, its active organization, the IP
645
+ * address, and the user agent. Never throws.
646
+ *
647
+ * Inside a database transaction (a database hook while a user is created),
648
+ * the event is written outside it once the transaction ends: an event
649
+ * about something that was refused, like a blocked sign-up, must survive
650
+ * the rollback.
651
+ */
652
+ function createRecordEvent(recorder) {
653
+ return async (details, endpoint = tryGetCurrentAuthEndpointContext()) => {
654
+ if (!endpoint || !recorder.isRecorded(details.type)) return;
655
+ const ctx = endpoint.context;
656
+ const session = ctx.session;
657
+ const event = {
658
+ actorUserId: session?.user.id ?? null,
659
+ sessionId: session?.session.id ?? null,
660
+ organizationId: (session?.session)?.activeOrganizationId ?? null,
661
+ ...details
662
+ };
663
+ const headers = endpoint.request?.headers ?? endpoint.headers ?? null;
664
+ try {
665
+ if (await getCurrentAdapter(ctx.adapter) === ctx.adapter) {
666
+ await recorder.record(ctx, event, headers);
667
+ return;
668
+ }
669
+ ctx.runInBackground(runWithAdapter(ctx.adapter, () => recorder.record(ctx, event, headers)));
670
+ } catch (error) {
671
+ ctx.logger.error(`[audit-log] failed to record a "${details.type}" event`, error);
672
+ }
673
+ };
674
+ }
675
+ // src/chain.ts
676
+ /** The `previousHash` of the first link. */
677
+ const GENESIS_HASH = "0".repeat(64);
678
+ /** How often a link is retried when another writer took the sequence number. */
679
+ const MAX_APPEND_ATTEMPTS = 20;
680
+ /** Links read per query while verifying. */
681
+ const VERIFY_PAGE_SIZE = 500;
682
+ const keys = /* @__PURE__ */ new Map();
683
+ function hmacKey(secret) {
684
+ let key = keys.get(secret);
685
+ if (!key) {
686
+ key = crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
687
+ name: "HMAC",
688
+ hash: "SHA-256"
689
+ }, false, ["sign"]);
690
+ keys.set(secret, key);
691
+ }
692
+ return key;
693
+ }
694
+ /**
695
+ * Everything the hash covers, in a fixed order. `createdAt` is in whole
696
+ * seconds, because some databases store dates with less precision than
697
+ * JavaScript, and the hash must match what is read back.
698
+ */
699
+ function canonical(event, sequence, previousHash) {
700
+ return JSON.stringify([
701
+ 1,
702
+ sequence,
703
+ previousHash,
704
+ event.id,
705
+ event.type,
706
+ event.status,
707
+ event.severity,
708
+ event.userId ?? null,
709
+ event.actorUserId ?? null,
710
+ event.sessionId ?? null,
711
+ event.organizationId ?? null,
712
+ event.ipAddress ?? null,
713
+ event.userAgent ?? null,
714
+ event.errorCode ?? null,
715
+ event.metadata ? JSON.stringify(event.metadata) : null,
716
+ Math.floor(new Date(event.createdAt).getTime() / 1e3)
717
+ ]);
718
+ }
719
+ async function linkHash(secret, event, sequence, previousHash) {
720
+ const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), new TextEncoder().encode(canonical(event, sequence, previousHash)));
721
+ return Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0")).join("");
722
+ }
723
+ async function latestLink(adapter) {
724
+ const [link] = await adapter.findMany({
725
+ model: "auditChain",
726
+ limit: 1,
727
+ sortBy: {
728
+ field: "sequence",
729
+ direction: "desc"
730
+ }
731
+ });
732
+ return link ?? null;
733
+ }
734
+ function createChain(secretOption) {
735
+ const secretFor = (ctx) => secretOption ?? ctx.secret;
736
+ /**
737
+ * Adds a link for `event`. Concurrent writers compete for the next
738
+ * sequence number through its unique index; the loser reads the new
739
+ * latest link and tries again.
740
+ */
741
+ async function append(ctx, event) {
742
+ const adapter = ctx.adapter;
743
+ for (let attempt = 0; attempt < MAX_APPEND_ATTEMPTS; attempt++) {
744
+ const previous = await latestLink(adapter);
745
+ const sequence = (previous?.sequence ?? 0) + 1;
746
+ const previousHash = previous?.hash ?? GENESIS_HASH;
747
+ const hash = await linkHash(secretFor(ctx), event, sequence, previousHash);
748
+ try {
749
+ await adapter.create({
750
+ model: "auditChain",
751
+ data: {
752
+ sequence,
753
+ eventId: event.id,
754
+ previousHash,
755
+ hash,
756
+ createdAt: event.createdAt
757
+ }
758
+ });
759
+ return;
760
+ } catch (error) {
761
+ if (!await adapter.findOne({
762
+ model: "auditChain",
763
+ where: [{
764
+ field: "sequence",
765
+ value: sequence
766
+ }]
767
+ })) throw error;
768
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 5 * (attempt + 1)));
769
+ }
770
+ }
771
+ throw new Error(`[audit-log] could not chain event ${event.id}: too many concurrent writers`);
772
+ }
773
+ /**
774
+ * `append`, but never inside a database transaction: a failed write
775
+ * there (a taken sequence number) would abort the transaction on some
776
+ * databases, and with it the sign-up being recorded. Inside one, the
777
+ * link is added after the commit, and dropped with a rollback like its
778
+ * event. Errors are logged: the event is stored, and verification
779
+ * reports it as unchained.
780
+ */
781
+ async function link(ctx, event) {
782
+ const run = async () => {
783
+ try {
784
+ await append(ctx, event);
785
+ } catch (error) {
786
+ ctx.logger.error("[audit-log] failed to chain an event", error);
787
+ }
788
+ };
789
+ if (await getCurrentAdapter(ctx.adapter) === ctx.adapter) await run();
790
+ else await queueAfterTransactionHook(run);
791
+ }
792
+ /** Deletes links of events created before `date` (retention). */
793
+ async function deleteOlderThan(ctx, date) {
794
+ return ctx.adapter.deleteMany({
795
+ model: "auditChain",
796
+ where: [{
797
+ field: "createdAt",
798
+ value: date,
799
+ operator: "lt"
800
+ }]
801
+ });
802
+ }
803
+ /** Re-computes every link, oldest first, against the stored events. */
804
+ async function verify(ctx, readEvents) {
805
+ const { adapter } = ctx;
806
+ const secret = secretFor(ctx);
807
+ const problems = [];
808
+ const report = (link, problem) => {
809
+ if (problems.length < 100) problems.push({
810
+ sequence: link.sequence,
811
+ eventId: link.eventId,
812
+ problem
813
+ });
814
+ };
815
+ let checked = 0;
816
+ let first = null;
817
+ let previous = null;
818
+ let problemCount = 0;
819
+ for (;;) {
820
+ const links = await adapter.findMany({
821
+ model: "auditChain",
822
+ where: previous ? [{
823
+ field: "sequence",
824
+ value: previous.sequence,
825
+ operator: "gt"
826
+ }] : [],
827
+ limit: VERIFY_PAGE_SIZE,
828
+ sortBy: {
829
+ field: "sequence",
830
+ direction: "asc"
831
+ }
832
+ });
833
+ if (links.length === 0) break;
834
+ const events = await readEvents(links.map((link) => link.eventId));
835
+ for (const link of links) {
836
+ checked++;
837
+ first ??= link;
838
+ if (previous) {
839
+ if (link.sequence !== previous.sequence + 1) {
840
+ report(link, "sequence-gap");
841
+ problemCount++;
842
+ } else if (link.previousHash !== previous.hash) {
843
+ report(link, "broken-link");
844
+ problemCount++;
845
+ }
846
+ }
847
+ const event = events.get(link.eventId);
848
+ if (!event) {
849
+ report(link, "missing-event");
850
+ problemCount++;
851
+ } else if (await linkHash(secret, event, link.sequence, link.previousHash) !== link.hash) {
852
+ report(link, "hash-mismatch");
853
+ problemCount++;
854
+ }
855
+ previous = link;
856
+ }
857
+ if (links.length < VERIFY_PAGE_SIZE) break;
858
+ }
859
+ let unchainedEvents = 0;
860
+ if (first && previous) {
861
+ const firstEvent = (await readEvents([first.eventId])).get(first.eventId);
862
+ if (firstEvent) {
863
+ const stored = await adapter.count({
864
+ model: "auditEvent",
865
+ where: [{
866
+ field: "createdAt",
867
+ value: firstEvent.createdAt,
868
+ operator: "gte"
869
+ }]
870
+ });
871
+ unchainedEvents = Math.max(0, stored - checked);
872
+ }
873
+ }
874
+ return {
875
+ valid: problemCount === 0 && unchainedEvents === 0,
876
+ checked,
877
+ firstSequence: first?.sequence ?? null,
878
+ lastSequence: previous?.sequence ?? null,
879
+ lastHash: previous?.hash ?? null,
880
+ problems,
881
+ unchainedEvents
882
+ };
883
+ }
884
+ return {
885
+ link,
886
+ verify,
887
+ deleteOlderThan
888
+ };
889
+ }
890
+ // src/store.ts
891
+ function toEvent(row) {
892
+ let metadata = null;
893
+ if (row.metadata) try {
894
+ metadata = JSON.parse(row.metadata);
895
+ } catch {
896
+ metadata = null;
897
+ }
898
+ return {
899
+ id: row.id,
900
+ type: row.type,
901
+ status: row.status,
902
+ severity: row.severity,
903
+ userId: row.userId ?? null,
904
+ actorUserId: row.actorUserId ?? null,
905
+ sessionId: row.sessionId ?? null,
906
+ organizationId: row.organizationId ?? null,
907
+ ipAddress: row.ipAddress ?? null,
908
+ userAgent: row.userAgent ?? null,
909
+ errorCode: row.errorCode ?? null,
910
+ metadata,
911
+ createdAt: new Date(row.createdAt)
912
+ };
913
+ }
914
+ function filters(query) {
915
+ const where = [];
916
+ if (query.userId) where.push({
917
+ field: "userId",
918
+ value: query.userId
919
+ });
920
+ if (query.actorUserId) where.push({
921
+ field: "actorUserId",
922
+ value: query.actorUserId
923
+ });
924
+ if (query.type) where.push({
925
+ field: "type",
926
+ value: query.type
927
+ });
928
+ if (query.status) where.push({
929
+ field: "status",
930
+ value: query.status
931
+ });
932
+ if (query.from) where.push({
933
+ field: "createdAt",
934
+ value: query.from,
935
+ operator: "gte"
936
+ });
937
+ if (query.to) where.push({
938
+ field: "createdAt",
939
+ value: query.to,
940
+ operator: "lt"
941
+ });
942
+ return where;
943
+ }
944
+ /**
945
+ * The default storage: the plugin's `auditEvent` table, through
946
+ * better-auth's adapter. Inside a transaction (database hooks), writes go
947
+ * through the transaction's adapter, so SQLite doesn't wait on itself.
948
+ */
949
+ function databaseStorage(baseAdapter) {
950
+ const adapter = () => getCurrentAdapter(baseAdapter);
951
+ return {
952
+ async write(event) {
953
+ return toEvent(await (await adapter()).create({
954
+ model: "auditEvent",
955
+ data: {
956
+ ...event,
957
+ metadata: event.metadata ? JSON.stringify(event.metadata) : null
958
+ }
959
+ }));
960
+ },
961
+ async read(query) {
962
+ const db = await adapter();
963
+ const where = filters(query);
964
+ const [rows, total] = await Promise.all([db.findMany({
965
+ model: "auditEvent",
966
+ where,
967
+ limit: query.limit,
968
+ offset: query.offset,
969
+ sortBy: {
970
+ field: "createdAt",
971
+ direction: "desc"
972
+ }
973
+ }), db.count({
974
+ model: "auditEvent",
975
+ where
976
+ })]);
977
+ return {
978
+ events: rows.map(toEvent),
979
+ total
980
+ };
981
+ },
982
+ async readByIds(ids) {
983
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
984
+ const rows = await (await adapter()).findMany({
985
+ model: "auditEvent",
986
+ where: [{
987
+ field: "id",
988
+ value: ids,
989
+ operator: "in"
990
+ }]
991
+ });
992
+ return new Map(rows.map((row) => [row.id, toEvent(row)]));
993
+ },
994
+ async deleteOlderThan(date) {
995
+ return (await adapter()).deleteMany({
996
+ model: "auditEvent",
997
+ where: [{
998
+ field: "createdAt",
999
+ value: date,
1000
+ operator: "lt"
1001
+ }]
1002
+ });
1003
+ }
1004
+ };
1005
+ }
1006
+ // src/utils/redact.ts
1007
+ /** Keys whose values never reach the log, whatever their nesting or case. */
1008
+ const SECRET_KEY = /password|passphrase|secret|token|^otp$|backup.?codes?|api.?key|authorization|cookie/i;
1009
+ const REDACTED = "[redacted]";
1010
+ const MAX_DEPTH = 5;
1011
+ /**
1012
+ * Copies metadata with secret-looking values replaced, so a careless
1013
+ * `recordAuditEvent` call can't put a password or token in the log.
1014
+ */
1015
+ function redact(value, depth = 0) {
1016
+ if (depth > MAX_DEPTH) return REDACTED;
1017
+ if (Array.isArray(value)) return value.map((item) => redact(item, depth + 1));
1018
+ if (value instanceof Date) return value.toISOString();
1019
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, SECRET_KEY.test(key) ? REDACTED : redact(item, depth + 1)]));
1020
+ return value;
1021
+ }
1022
+ // src/utils/severity.ts
1023
+ const BUILT_IN = {
1024
+ "sign-up": "low",
1025
+ "sign-in": "low",
1026
+ "sign-out": "low",
1027
+ "email-verified": "low",
1028
+ "session-revoked": "medium",
1029
+ "account-linked": "medium",
1030
+ "account-unlinked": "medium",
1031
+ "email-changed": "medium",
1032
+ "password-changed": "medium",
1033
+ "password-reset": "medium",
1034
+ "user-deleted": "high",
1035
+ "two-factor-enabled": "medium",
1036
+ "two-factor-disabled": "high",
1037
+ "organization.deleted": "high",
1038
+ "organization.member-removed": "medium",
1039
+ "organization.member-role-changed": "high",
1040
+ "admin.set-role": "critical",
1041
+ "admin.set-user-password": "critical",
1042
+ "admin.impersonate-user": "critical"
1043
+ };
1044
+ /**
1045
+ * The severity of an event: the `severity` option, then the built-in
1046
+ * table. Admin actions are `high` unless listed; failed sign-ins `medium`;
1047
+ * anything else `low`.
1048
+ */
1049
+ function severityOf(type, status, overrides) {
1050
+ const override = overrides[type];
1051
+ if (override) return override;
1052
+ if (type === "sign-in" && status === "failure") return "medium";
1053
+ return BUILT_IN[type] ?? (type.startsWith("admin.") ? "high" : "low");
1054
+ }
1055
+ const MAX_USER_AGENT_LENGTH = 512;
1056
+ function createRecorder(options) {
1057
+ let lastSweep = 0;
1058
+ const chain = options.hashChain ? createChain(options.hashChain.secret) : null;
1059
+ let lastCreatedAt = 0;
1060
+ /**
1061
+ * The current time, but never the same as or earlier than the previous
1062
+ * event's: events recorded in the same millisecond (a sign-up and its
1063
+ * sign-in) still list in the order they happened.
1064
+ */
1065
+ const nextTimestamp = () => {
1066
+ lastCreatedAt = Math.max(Date.now(), lastCreatedAt + 1);
1067
+ return new Date(lastCreatedAt);
1068
+ };
1069
+ const storageFor = (ctx) => options.storage ?? databaseStorage(ctx.adapter);
1070
+ const isRecorded = (type) => !options.events || options.events(type);
1071
+ function build(ctx, details, headers) {
1072
+ const status = details.status ?? "success";
1073
+ let metadata = details.metadata ? redact(details.metadata) : null;
1074
+ if (metadata && JSON.stringify(metadata).length > 4e3) metadata = { truncated: true };
1075
+ const ipAddress = details.ipAddress ?? (options.captureIp && headers ? getIP(headers, ctx.options) : null);
1076
+ const userAgent = details.userAgent ?? (options.captureUserAgent ? headers?.get("user-agent") ?? null : null);
1077
+ return {
1078
+ type: details.type,
1079
+ status,
1080
+ severity: details.severity ?? severityOf(details.type, status, options.severity),
1081
+ userId: details.userId ?? null,
1082
+ actorUserId: details.actorUserId ?? null,
1083
+ sessionId: details.sessionId ?? null,
1084
+ organizationId: details.organizationId ?? null,
1085
+ ipAddress,
1086
+ userAgent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
1087
+ errorCode: details.errorCode ?? null,
1088
+ metadata,
1089
+ createdAt: nextTimestamp()
1090
+ };
1091
+ }
1092
+ /** Deletes events created before `date`, and their links. */
1093
+ async function deleteOlderThan(ctx, date) {
1094
+ const deleted = await storageFor(ctx).deleteOlderThan(date);
1095
+ if (chain) await chain.deleteOlderThan(ctx, date);
1096
+ return deleted;
1097
+ }
1098
+ /** Starts a retention sweep in the background, at most once per `sweepInterval`. */
1099
+ function maybeSweep(ctx) {
1100
+ if (!options.retention) return;
1101
+ const now = Date.now();
1102
+ if (now - lastSweep < options.retention.sweepInterval * 1e3) return;
1103
+ lastSweep = now;
1104
+ const sweep = deleteOlderThan(ctx, /* @__PURE__ */ new Date(now - options.retention.days * 864e5)).catch((error) => ctx.logger.error("[audit-log] retention sweep failed", error));
1105
+ ctx.runInBackground(sweep);
1106
+ }
1107
+ /**
1108
+ * Builds, filters, and stores an event, then calls `onEvent`. Returns
1109
+ * `null` if the event isn't recorded (`events`, `beforeLog`). Storage
1110
+ * errors are thrown; `onEvent` errors are logged.
1111
+ */
1112
+ async function recordNow(ctx, details, headers) {
1113
+ if (!isRecorded(details.type)) return null;
1114
+ let event = build(ctx, details, headers);
1115
+ if (options.beforeLog) {
1116
+ try {
1117
+ event = await options.beforeLog(event);
1118
+ } catch (error) {
1119
+ ctx.logger.error("[audit-log] beforeLog threw an error; the event was dropped", error);
1120
+ return null;
1121
+ }
1122
+ if (!event) return null;
1123
+ }
1124
+ const stored = await storageFor(ctx).write(event);
1125
+ if (chain) await chain.link(ctx, stored);
1126
+ if (options.onEvent) try {
1127
+ await options.onEvent(stored);
1128
+ } catch (error) {
1129
+ ctx.logger.error("[audit-log] onEvent threw an error", error);
1130
+ }
1131
+ maybeSweep(ctx);
1132
+ return stored;
1133
+ }
1134
+ /**
1135
+ * `recordNow` for hooks: never throws, and runs in the background when
1136
+ * better-auth's `advanced.backgroundTasks` is configured.
1137
+ */
1138
+ async function record(ctx, details, headers) {
1139
+ if (!isRecorded(details.type)) return;
1140
+ const task = recordNow(ctx, details, headers).then(() => void 0, (error) => ctx.logger.error(`[audit-log] failed to record a "${details.type}" event`, error));
1141
+ await ctx.runInBackgroundOrAwait(task);
1142
+ }
1143
+ return {
1144
+ record,
1145
+ recordNow,
1146
+ storageFor,
1147
+ isRecorded,
1148
+ deleteOlderThan,
1149
+ chain
1150
+ };
1151
+ }
1152
+ // ../plugin-utils/src/pagination.ts
1153
+ /**
1154
+ * The `limit` and `offset` fields of a list endpoint's query, coerced from
1155
+ * query strings. Spread into the endpoint's `z.object({ ... })`.
1156
+ */
1157
+ function paginationQuery({ defaultLimit, maxLimit }) {
1158
+ return {
1159
+ limit: z.coerce.number().int().min(1).max(maxLimit).default(defaultLimit).meta({ description: `How many to return, at most ${maxLimit}. Defaults to ${defaultLimit}` }),
1160
+ offset: z.coerce.number().int().min(0).default(0).meta({ description: "How many to skip. Defaults to 0" })
1161
+ };
1162
+ }
1163
+ const statusSchema = z.enum(["success", "failure"]);
1164
+ const severitySchema = z.enum([
1165
+ "low",
1166
+ "medium",
1167
+ "high",
1168
+ "critical"
1169
+ ]);
1170
+ const typeSchema = z.string().max(64).refine(isValidEventType, { message: "Invalid event type" });
1171
+ const idSchema = z.string().min(1).max(255);
1172
+ function createEventEndpoints(options, recorder) {
1173
+ return {
1174
+ listAuditEvents: createAuthEndpoint("/audit-log/list-events", {
1175
+ method: "GET",
1176
+ use: [sessionMiddleware],
1177
+ query: z.optional(z.object({
1178
+ userId: idSchema.optional().meta({ description: "Events about this user. Admins only, except for yourself" }),
1179
+ actorUserId: idSchema.optional().meta({ description: "Events done by this user. Admins only" }),
1180
+ type: typeSchema.optional().meta({ description: "Only events of this type" }),
1181
+ status: statusSchema.optional().meta({ description: "Only events with this status" }),
1182
+ from: z.coerce.date().optional().meta({ description: "Events at or after this time" }),
1183
+ to: z.coerce.date().optional().meta({ description: "Events before this time" }),
1184
+ ...paginationQuery({
1185
+ defaultLimit: 20,
1186
+ maxLimit: 100
1187
+ })
1188
+ })),
1189
+ metadata: { openapi: { description: "List audit events, newest first: your own, or any user's for admins" } }
1190
+ }, async (ctx) => {
1191
+ const me = ctx.context.session.user;
1192
+ const query = ctx.query ?? {
1193
+ limit: 20,
1194
+ offset: 0
1195
+ };
1196
+ const admin = isAdmin(me, options);
1197
+ if (!admin && (query.userId && query.userId !== me.id || query.actorUserId)) throw APIError.from("FORBIDDEN", AUDIT_LOG_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_OTHER_USERS_EVENTS);
1198
+ const { events, total } = await recorder.storageFor(ctx.context).read({
1199
+ userId: admin ? query.userId : me.id,
1200
+ actorUserId: query.actorUserId,
1201
+ type: query.type,
1202
+ status: query.status,
1203
+ from: query.from,
1204
+ to: query.to,
1205
+ limit: query.limit ?? 20,
1206
+ offset: query.offset ?? 0
1207
+ });
1208
+ return ctx.json({
1209
+ events: admin ? events : events.map((event) => event.actorUserId === me.id ? event : {
1210
+ ...event,
1211
+ ipAddress: null,
1212
+ userAgent: null,
1213
+ sessionId: null
1214
+ }),
1215
+ total
1216
+ });
1217
+ }),
1218
+ /**
1219
+ * Server only (no path, so not reachable over HTTP): records an event
1220
+ * of your own, e.g. `auth.api.recordAuditEvent({ body: { type:
1221
+ * "billing.plan-changed", userId } })`.
1222
+ */
1223
+ recordAuditEvent: createAuthEndpoint({
1224
+ method: "POST",
1225
+ body: z.object({
1226
+ type: typeSchema.meta({ description: "The event type, e.g. `billing.plan-changed`" }),
1227
+ status: statusSchema.optional(),
1228
+ severity: severitySchema.optional(),
1229
+ userId: idSchema.optional().meta({ description: "The user the event is about" }),
1230
+ actorUserId: idSchema.optional().meta({ description: "The user who did it" }),
1231
+ organizationId: idSchema.optional(),
1232
+ errorCode: z.string().max(255).optional(),
1233
+ metadata: z.record(z.string(), z.unknown()).optional()
1234
+ }),
1235
+ metadata: { openapi: { description: "Record an audit event. Server only" } }
1236
+ }, async (ctx) => {
1237
+ const { metadata } = ctx.body;
1238
+ if (metadata && JSON.stringify(metadata).length > 4e3) throw APIError.from("BAD_REQUEST", AUDIT_LOG_ERROR_CODES.METADATA_TOO_LARGE);
1239
+ const event = await recorder.recordNow(ctx.context, ctx.body, ctx.request?.headers ?? ctx.headers);
1240
+ return ctx.json({ event });
1241
+ }),
1242
+ /**
1243
+ * Server only: deletes old events, for a cron job. Uses `retention.days`
1244
+ * unless `days` is passed.
1245
+ */
1246
+ deleteExpiredAuditEvents: createAuthEndpoint({
1247
+ method: "POST",
1248
+ body: z.optional(z.object({ days: z.number().positive().optional().meta({ description: "Delete events older than this" }) })),
1249
+ metadata: { openapi: { description: "Delete audit events older than the retention period. Server only" } }
1250
+ }, async (ctx) => {
1251
+ const days = ctx.body?.days ?? options.retention?.days;
1252
+ if (!days) throw APIError.from("BAD_REQUEST", AUDIT_LOG_ERROR_CODES.RETENTION_NOT_CONFIGURED);
1253
+ const deleted = await recorder.deleteOlderThan(ctx.context, /* @__PURE__ */ new Date(Date.now() - days * 864e5));
1254
+ return ctx.json({ deleted });
1255
+ }),
1256
+ /**
1257
+ * Server only: re-computes the hash chain and reports changed, deleted,
1258
+ * and unchained events. Reads every link, so run it from a job, not on
1259
+ * every request.
1260
+ */
1261
+ verifyAuditChain: createAuthEndpoint({
1262
+ method: "POST",
1263
+ metadata: { openapi: { description: "Verify the audit log's hash chain. Server only" } }
1264
+ }, async (ctx) => {
1265
+ const { chain } = recorder;
1266
+ if (!chain) throw APIError.from("BAD_REQUEST", AUDIT_LOG_ERROR_CODES.HASH_CHAIN_NOT_ENABLED);
1267
+ const storage = databaseStorage(ctx.context.adapter);
1268
+ return ctx.json(await chain.verify(ctx.context, (ids) => storage.readByIds(ids)));
1269
+ })
1270
+ };
1271
+ }
1272
+ // src/schema.ts
1273
+ const auditLogSchema = {
1274
+ /**
1275
+ * One row per event. User ids are plain columns, not foreign keys: the
1276
+ * log must outlive the users it mentions, `user-deleted` included.
1277
+ */
1278
+ auditEvent: { fields: {
1279
+ /** `sign-in`, `admin.ban-user`, or an app-defined type. */
1280
+ type: {
1281
+ type: "string",
1282
+ required: true,
1283
+ index: true
1284
+ },
1285
+ /** "success" | "failure" */
1286
+ status: {
1287
+ type: "string",
1288
+ required: true
1289
+ },
1290
+ /** "low" | "medium" | "high" | "critical" */
1291
+ severity: {
1292
+ type: "string",
1293
+ required: true
1294
+ },
1295
+ /** The user the event is about. */
1296
+ userId: {
1297
+ type: "string",
1298
+ required: false,
1299
+ index: true
1300
+ },
1301
+ /** The user who did it: the user themselves, or an admin. */
1302
+ actorUserId: {
1303
+ type: "string",
1304
+ required: false,
1305
+ index: true
1306
+ },
1307
+ sessionId: {
1308
+ type: "string",
1309
+ required: false
1310
+ },
1311
+ /** The session's active organization (organization plugin). */
1312
+ organizationId: {
1313
+ type: "string",
1314
+ required: false
1315
+ },
1316
+ ipAddress: {
1317
+ type: "string",
1318
+ required: false
1319
+ },
1320
+ userAgent: {
1321
+ type: "string",
1322
+ required: false
1323
+ },
1324
+ /** The error code of a failed request, e.g. `INVALID_EMAIL_OR_PASSWORD`. */
1325
+ errorCode: {
1326
+ type: "string",
1327
+ required: false
1328
+ },
1329
+ /** JSON-encoded. */
1330
+ metadata: {
1331
+ type: "string",
1332
+ required: false
1333
+ },
1334
+ createdAt: {
1335
+ type: "date",
1336
+ required: true,
1337
+ index: true
1338
+ }
1339
+ } } };
1340
+ /** Added with the `hashChain` option: one link per event, in order. */
1341
+ const auditChainSchema = { auditChain: {
1342
+ fields: {
1343
+ /** 1, 2, 3, ... Unique, so concurrent writers can't fork the chain. */
1344
+ sequence: {
1345
+ type: "number",
1346
+ required: true
1347
+ },
1348
+ /** The event's id. Not a foreign key: a deleted event must show as missing. */
1349
+ eventId: {
1350
+ type: "string",
1351
+ required: true
1352
+ },
1353
+ previousHash: {
1354
+ type: "string",
1355
+ required: true
1356
+ },
1357
+ /** HMAC-SHA256 of the event, the sequence number, and `previousHash`. */
1358
+ hash: {
1359
+ type: "string",
1360
+ required: true
1361
+ },
1362
+ /** The event's `createdAt`, for retention. */
1363
+ createdAt: {
1364
+ type: "date",
1365
+ required: true,
1366
+ index: true
1367
+ }
1368
+ },
1369
+ indexes: [{
1370
+ fields: ["sequence"],
1371
+ unique: true
1372
+ }]
1373
+ } };
1374
+ // src/memory.ts
1375
+ /**
1376
+ * Keeps events in memory, newest last. For tests and demos: events are
1377
+ * lost on restart and not shared between server instances.
1378
+ *
1379
+ * @example
1380
+ * ```ts
1381
+ * const storage = memoryStorage();
1382
+ * betterAuth({ plugins: [auditLog({ storage })] });
1383
+ * storage.events; // every stored event
1384
+ * ```
1385
+ */
1386
+ function memoryStorage() {
1387
+ const events = [];
1388
+ const matches = (event, query) => (!query.userId || event.userId === query.userId) && (!query.actorUserId || event.actorUserId === query.actorUserId) && (!query.type || event.type === query.type) && (!query.status || event.status === query.status) && (!query.from || event.createdAt >= query.from) && (!query.to || event.createdAt < query.to);
1389
+ return {
1390
+ events,
1391
+ async write(input) {
1392
+ const event = {
1393
+ ...input,
1394
+ id: crypto.randomUUID()
1395
+ };
1396
+ events.push(event);
1397
+ return event;
1398
+ },
1399
+ async read(query) {
1400
+ const found = events.filter((event) => matches(event, query)).reverse();
1401
+ return {
1402
+ events: found.slice(query.offset, query.offset + query.limit),
1403
+ total: found.length
1404
+ };
1405
+ },
1406
+ async deleteOlderThan(date) {
1407
+ const kept = events.filter((event) => event.createdAt >= date);
1408
+ const deleted = events.length - kept.length;
1409
+ events.splice(0, events.length, ...kept);
1410
+ return deleted;
1411
+ }
1412
+ };
1413
+ }
1414
+ // src/index.ts
1415
+ /** Plugins whose after-hooks clear `ctx.context.newSession` (a sign-in that isn't done yet). */
1416
+ const CLEARS_NEW_SESSION = /* @__PURE__ */ new Set(["two-factor"]);
1417
+ /**
1418
+ * Sign-ins are read from `ctx.context.newSession` after the endpoint ran,
1419
+ * which plugins such as `twoFactor` clear in their own after-hooks (the
1420
+ * sign-in isn't done until the second factor). Hooks run in plugin order,
1421
+ * so the audit log must come after them.
1422
+ */
1423
+ function warnAboutOrder(ctx) {
1424
+ const plugins = ctx.options.plugins ?? [];
1425
+ const index = plugins.findIndex((plugin) => plugin.id === "audit-log");
1426
+ const later = plugins.slice(index + 1).filter((plugin) => CLEARS_NEW_SESSION.has(plugin.id)).map((plugin) => plugin.id);
1427
+ if (later.length > 0) ctx.logger.warn(`[audit-log] Put auditLog() after ${later.join(", ")} in "plugins", so sign-ins are recorded once they are complete.`);
1428
+ }
1429
+ /**
1430
+ * Records security-relevant events: sign-ins (any method) and failed
1431
+ * sign-ins, sign-ups, sign-outs, revoked sessions, password and email
1432
+ * changes, linked accounts, deleted users, and the actions of better-auth's
1433
+ * `admin` plugin. Users can list their own events, admins everyone's.
1434
+ *
1435
+ * @example
1436
+ * ```ts
1437
+ * import { betterAuth } from "better-auth";
1438
+ * import { auditLog } from "@checodo/better-auth-audit-log";
1439
+ *
1440
+ * export const auth = betterAuth({
1441
+ * plugins: [
1442
+ * // ...other plugins, then:
1443
+ * auditLog({ retention: { days: 90 } }),
1444
+ * ],
1445
+ * });
1446
+ * ```
1447
+ */
1448
+ const auditLog = (options = {}) => {
1449
+ const resolved = resolveOptions(options);
1450
+ const recorder = createRecorder(resolved);
1451
+ const plugin = {
1452
+ id: "audit-log",
1453
+ version,
1454
+ init(ctx) {
1455
+ warnAboutOrder(ctx);
1456
+ return { options: { databaseHooks: createDatabaseHooks(ctx, recorder) } };
1457
+ },
1458
+ ...resolved.storage ? {} : { schema: mergeSchema(structuredClone({
1459
+ ...auditLogSchema,
1460
+ ...resolved.hashChain ? auditChainSchema : {}
1461
+ }), options.schema) },
1462
+ endpoints: createEventEndpoints(resolved, recorder),
1463
+ hooks: createPathHooks(resolved, recorder),
1464
+ $ERROR_CODES: AUDIT_LOG_ERROR_CODES,
1465
+ options
1466
+ };
1467
+ return Object.assign(plugin, {
1468
+ /**
1469
+ * Records an event from another plugin or your own server code, with
1470
+ * the current request's user, session, IP address, and user agent:
1471
+ * `ctx.context.getPlugin("audit-log")?.recordEvent({ type: "x.y", userId })`.
1472
+ * Never throws.
1473
+ */
1474
+ recordEvent: createRecordEvent(recorder) });
1475
+ };
1476
+ export { AUDIT_LOG_ERROR_CODES, auditLog, memoryStorage };
1477
+
1478
+ //# sourceMappingURL=index.js.map