@gatwech/auth 1.0.0-beta.24 → 1.0.0-beta.25

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 (90) hide show
  1. package/dist/adapters/index.d.mts +1 -1
  2. package/dist/api/index.d.mts +1 -1
  3. package/dist/{audit-log-DhRRCMrv.mjs → audit-log-CQnf8nrN.mjs} +442 -46
  4. package/dist/client/index.d.mts +1 -1
  5. package/dist/client/plugins/index.d.mts +46 -46
  6. package/dist/client/plugins/index.mjs +2 -2
  7. package/dist/client/react/index.d.mts +15 -15
  8. package/dist/{client-CMsC6-rl.mjs → client-BkpNva-h.mjs} +3 -2
  9. package/dist/{client-CGV_kj99.d.mts → client-g0OAVuPQ.d.mts} +1 -1
  10. package/dist/{client-CT-acJeN.d.mts → client-zkWmSxqy.d.mts} +7 -4
  11. package/dist/cookies/index.d.mts +2 -2
  12. package/dist/db/index.d.mts +1 -1
  13. package/dist/{index-Czr_eQFE.d.mts → index-B4HTudhm.d.mts} +6 -6
  14. package/dist/{index-DYqQWdEv.d.mts → index-BHiF_ZfZ.d.mts} +13 -13
  15. package/dist/{index-Dmjw6re9.d.mts → index-BX55rZO5.d.mts} +2 -2
  16. package/dist/{index-Clwezlxm.d.mts → index-BfwCJQfM.d.mts} +8 -8
  17. package/dist/{index-DGRGLg16.d.mts → index-BhbilPfs.d.mts} +2 -2
  18. package/dist/{index-JzSy-vFn.d.mts → index-Bi-vEjDm.d.mts} +166 -166
  19. package/dist/{index-Cl9ITXy1.d.mts → index-BifhxQ8O.d.mts} +10 -10
  20. package/dist/{index-DWGj7du8.d.mts → index-BiomZttP.d.mts} +19 -19
  21. package/dist/{index-Hoybqpnt.d.mts → index-BoHrBgTB.d.mts} +9 -9
  22. package/dist/{index-UDzaYKxq.d.mts → index-C5W_sTlp.d.mts} +9 -9
  23. package/dist/{index-CLhlur69.d.mts → index-CNyQFga7.d.mts} +6 -6
  24. package/dist/{index-CZtQ0rUe.d.mts → index-CTjXncwM.d.mts} +113 -113
  25. package/dist/{index-CGGitykk.d.mts → index-CUUaJp3R.d.mts} +7 -7
  26. package/dist/{index-D85KXOKz.d.mts → index-CcJjgkwe.d.mts} +3 -3
  27. package/dist/{index-B-i2ob-C.d.mts → index-Cdfh66xb.d.mts} +9 -9
  28. package/dist/{index-CHMYUshk.d.mts → index-DBtm_waB.d.mts} +13 -13
  29. package/dist/{index-DWSbFJWU.d.mts → index-DD_pxX-h.d.mts} +662 -354
  30. package/dist/{index-BYxt8qOP.d.mts → index-DRoa3BGG.d.mts} +33 -33
  31. package/dist/{index-D5cyFBEb.d.mts → index-M0IQ_DTL.d.mts} +4 -4
  32. package/dist/{index-rcUKPBBy.d.mts → index-gRLqvVoc.d.mts} +6 -6
  33. package/dist/{index-Dt6JhAV4.d.mts → index-i1L-uguJ.d.mts} +6 -6
  34. package/dist/{index-B6to9CaK.d.mts → index-kBEBsEUr.d.mts} +1 -1
  35. package/dist/{index-BRUQHEUF.d.mts → index-mDmrBYrk.d.mts} +5 -5
  36. package/dist/index.d.mts +1 -1
  37. package/dist/integrations/next-js.d.mts +4 -4
  38. package/dist/integrations/node.d.mts +1 -1
  39. package/dist/integrations/react-start.d.mts +2 -2
  40. package/dist/oauth2/index.d.mts +1 -1
  41. package/dist/plugins/admin/index.d.mts +2 -2
  42. package/dist/plugins/anonymous/index.d.mts +2 -2
  43. package/dist/plugins/audit-log/client.d.mts +23 -23
  44. package/dist/plugins/audit-log/index.d.mts +23 -23
  45. package/dist/plugins/audit-log/index.mjs +3 -3
  46. package/dist/plugins/bearer/index.d.mts +1 -1
  47. package/dist/plugins/captcha/index.d.mts +1 -1
  48. package/dist/plugins/custom-session/index.d.mts +2 -2
  49. package/dist/plugins/device-authorization/index.d.mts +1 -1
  50. package/dist/plugins/email-otp/index.d.mts +1 -1
  51. package/dist/plugins/generic-oauth/index.d.mts +2 -2
  52. package/dist/plugins/haveibeenpwned/index.d.mts +1 -1
  53. package/dist/plugins/index.d.mts +23 -23
  54. package/dist/plugins/index.mjs +5 -5
  55. package/dist/plugins/jwt/index.d.mts +2 -2
  56. package/dist/plugins/magic-link/index.d.mts +1 -1
  57. package/dist/plugins/multi-session/index.d.mts +1 -1
  58. package/dist/plugins/oauth-proxy/index.d.mts +1 -1
  59. package/dist/plugins/one-tap/index.d.mts +1 -1
  60. package/dist/plugins/one-time-token/index.d.mts +2 -2
  61. package/dist/plugins/one-time-token/index.mjs +4 -4
  62. package/dist/plugins/organization/index.d.mts +2 -2
  63. package/dist/plugins/passkey/index.d.mts +2 -2
  64. package/dist/plugins/phone-number/index.d.mts +2 -2
  65. package/dist/plugins/sentinel/client.d.mts +23 -23
  66. package/dist/plugins/sentinel/client.mjs +2 -2
  67. package/dist/plugins/sentinel/index.d.mts +22 -22
  68. package/dist/plugins/sentinel/index.mjs +3 -3
  69. package/dist/plugins/two-factor/index.d.mts +2 -2
  70. package/dist/plugins/username/index.d.mts +1 -1
  71. package/dist/{registry-GztLf62p.mjs → registry-BjYhRF79.mjs} +69 -2
  72. package/dist/{sentinel-b8XEetew.mjs → sentinel-BttZLWPD.mjs} +70 -14
  73. package/dist/src/plugins/audit-log/error-codes.d.ts +7 -0
  74. package/dist/src/plugins/audit-log/events.d.ts +13 -0
  75. package/dist/src/plugins/audit-log/index.d.ts +114 -8
  76. package/dist/src/plugins/audit-log/integrity.d.ts +20 -0
  77. package/dist/src/plugins/audit-log/redact.d.ts +18 -0
  78. package/dist/src/plugins/audit-log/remote.d.ts +1 -0
  79. package/dist/src/plugins/audit-log/schema.d.ts +25 -0
  80. package/dist/src/plugins/audit-log/types.d.ts +73 -13
  81. package/dist/src/plugins/sentinel/actions.d.ts +0 -6
  82. package/dist/src/plugins/sentinel/client.d.ts +3 -0
  83. package/dist/src/plugins/sentinel/error-codes.d.ts +6 -0
  84. package/dist/src/plugins/sentinel/index.d.ts +80 -3
  85. package/dist/src/plugins/sentinel/types.d.ts +1 -1
  86. package/dist/test-utils/index.d.mts +2 -2
  87. package/dist/test-utils/index.mjs +4 -4
  88. package/dist/types/index.d.mts +1 -1
  89. package/package.json +3 -3
  90. /package/dist/{pow-DvkUopsK.mjs → pow-Vna_j4hx.mjs} +0 -0
@@ -2,14 +2,131 @@ import { t as getIp } from "./get-request-ip-DSjrEpGo.mjs";
2
2
  import { t as generateRandomString } from "./random-32E3cU5o.mjs";
3
3
  import { n as mergeSchema } from "./date-CMr04reD.mjs";
4
4
  import { r as getSessionFromCtx, u as sessionMiddleware } from "./session-BB3oqApE.mjs";
5
- import { a as CREDENTIAL_SIGN_IN_PATHS, c as SIGN_IN_PATHS, i as AUDIT_EVENT_TYPES, l as VERIFICATION_PATHS, n as registerAuditRecorder, o as PATH_EVENTS, r as ALL_EVENT_TYPES, s as SECURITY_EVENT_TYPES, u as hasRole } from "./registry-GztLf62p.mjs";
6
- import { env } from "@gatwech/core/env";
5
+ import { a as CORE_EVENT_TYPES, c as PATH_EVENTS, d as VERIFICATION_PATHS, f as hasRole, i as AUDIT_EVENT_TYPES, l as SECURITY_EVENT_TYPES, n as registerAuditRecorder, o as CREDENTIAL_SIGN_IN_PATHS, r as ALL_EVENT_TYPES, s as EVENT_SEVERITIES, u as SIGN_IN_PATHS } from "./registry-BjYhRF79.mjs";
6
+ import { env, isProduction } from "@gatwech/core/env";
7
+ import { PayvoAuthError } from "@gatwech/core/error";
7
8
  import { defineErrorCodes } from "@gatwech/core/utils";
8
9
  import { APIError } from "better-call";
9
10
  import { createAuthEndpoint, createAuthMiddleware } from "@gatwech/core/api";
10
11
  import * as z$1 from "zod";
12
+ import { createHMAC } from "@gatwech/utils/hmac";
11
13
  import { betterFetch } from "@better-fetch/fetch";
12
14
 
15
+ //#region src/plugins/audit-log/error-codes.ts
16
+ const AUDIT_LOG_ERROR_CODES = defineErrorCodes({
17
+ AUDIT_LOG_ACCESS_DENIED: "You don't have access to these audit logs",
18
+ AUDIT_CANNOT_BE_DISABLED: "Core audit events and the local audit store can't be turned off",
19
+ AUDIT_LOG_UNAVAILABLE: "Audit logs are temporarily unavailable",
20
+ AUDIT_RETENTION_TOO_SHORT: "Audit events can't be deleted before the retention minimum has passed",
21
+ AUDIT_SIGNING_SECRET_MISSING: "Audit events must be signed in production: set integrity.secret or PAYVO_AUDIT_SIGNING_SECRET"
22
+ });
23
+
24
+ //#endregion
25
+ //#region src/plugins/audit-log/integrity.ts
26
+ const hmac = createHMAC("SHA-256", "base64url");
27
+ /** The version a signature is made with, and what `secret` is for */
28
+ const CURRENT_VERSION = "v1";
29
+ /**
30
+ * The value as it is stored: the same as `JSON.stringify` makes of it, with
31
+ * sorted keys at every depth, dates as ISO strings and bigints as strings.
32
+ * A property that is `undefined`, a function or a symbol is left out, and
33
+ * such an array element, a number that isn't finite and an invalid date are
34
+ * `null`, so a signed event verifies once the database has stored it.
35
+ */
36
+ function sorted(value) {
37
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString();
38
+ switch (typeof value) {
39
+ case "string":
40
+ case "boolean": return value;
41
+ case "number": return Number.isFinite(value) ? value : null;
42
+ case "bigint": return value.toString();
43
+ case "object":
44
+ if (value === null) return null;
45
+ if (Array.isArray(value)) return value.map(sorted);
46
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => !omittedFromJson(entry)).sort(([first], [second]) => first < second ? -1 : 1).map(([key, entry]) => [key, sorted(entry)]));
47
+ default: return null;
48
+ }
49
+ }
50
+ /** A property `JSON.stringify` leaves out */
51
+ const omittedFromJson = (value) => value === void 0 || typeof value === "function" || typeof value === "symbol";
52
+ /**
53
+ * A value as the JSON that is signed: sorted keys at every depth, dates as ISO
54
+ * strings, bigints as strings and no whitespace.
55
+ *
56
+ * It is the encoding payvo-core signs its own audit entries with.
57
+ */
58
+ function canonicalize(value) {
59
+ return JSON.stringify(sorted(value));
60
+ }
61
+ /**
62
+ * Signs a string with HMAC-SHA256 and returns `v1:<signature>`, where the
63
+ * signature is base64url encoded.
64
+ */
65
+ async function signAuditPayload(payload, secret) {
66
+ return `${CURRENT_VERSION}:${await hmac.sign(secret, payload)}`;
67
+ }
68
+ /**
69
+ * The string that is signed for an event: its fields as canonical JSON, with
70
+ * sorted keys at every depth, dates as ISO strings, bigints as strings and
71
+ * no whitespace. `signature` isn't part of it, and a field an event doesn't
72
+ * have is `null`.
73
+ */
74
+ function canonicalPayload(event) {
75
+ return canonicalize({
76
+ id: event.id,
77
+ eventType: event.eventType,
78
+ status: event.status,
79
+ userId: event.userId ?? null,
80
+ identifier: event.identifier ?? null,
81
+ organizationId: event.organizationId ?? null,
82
+ path: event.path ?? null,
83
+ ipAddress: event.ipAddress ?? null,
84
+ userAgent: event.userAgent ?? null,
85
+ visitorId: event.visitorId ?? null,
86
+ eventData: event.eventData ?? null,
87
+ traceId: event.traceId ?? null,
88
+ requestId: event.requestId ?? null,
89
+ actorType: event.actorType,
90
+ severity: event.severity,
91
+ createdAt: event.createdAt
92
+ });
93
+ }
94
+ /** Signs an event's fields, as `signAuditPayload` does the string */
95
+ function signAuditEvent(event, secret) {
96
+ return signAuditPayload(canonicalPayload(event), secret);
97
+ }
98
+ /**
99
+ * Whether an event's signature matches its fields, so a row that was changed
100
+ * after it was recorded doesn't verify.
101
+ *
102
+ * `integrity.secret` verifies signatures made with the current key, and
103
+ * `integrity.secrets` maps an older version to its key, so keys can be
104
+ * rotated without signing old rows again. An event without a signature, or
105
+ * with a version that has no key, doesn't verify.
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * const events = await auth.api.getAllAuditLogs({ headers });
110
+ * events.events.every((event) => event.verified);
111
+ * await verifyAuditEvent(event, { secret: process.env.PAYVO_AUDIT_SIGNING_SECRET });
112
+ * ```
113
+ */
114
+ async function verifyAuditEvent(event, integrity) {
115
+ if (!event.signature) return false;
116
+ const separator = event.signature.indexOf(":");
117
+ if (separator < 1) return false;
118
+ const version = event.signature.slice(0, separator);
119
+ const secret = integrity.secrets?.[version] ?? (version === CURRENT_VERSION ? integrity.secret : void 0);
120
+ if (!secret) return false;
121
+ const { signature: _,...fields } = event;
122
+ try {
123
+ return await hmac.verify(secret, canonicalPayload(fields), event.signature.slice(separator + 1));
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ //#endregion
13
130
  //#region src/plugins/audit-log/remote.ts
14
131
  /** Sends to the backend: the first attempt and up to 2 retries */
15
132
  const SEND_ATTEMPTS = 3;
@@ -83,6 +200,67 @@ function createRemoteSink(options) {
83
200
  };
84
201
  }
85
202
 
203
+ //#endregion
204
+ //#region src/plugins/audit-log/redact.ts
205
+ /**
206
+ * Keys that are always redacted, compared as in `redactAuditData`: in lower
207
+ * case, without `-` or `_`.
208
+ */
209
+ const ALWAYS_REDACTED_KEYS = new Set([
210
+ "password",
211
+ "newpassword",
212
+ "currentpassword",
213
+ "token",
214
+ "accesstoken",
215
+ "refreshtoken",
216
+ "idtoken",
217
+ "secret",
218
+ "clientsecret",
219
+ "otp",
220
+ "otpcode",
221
+ "verificationcode",
222
+ "backupcodes",
223
+ "authorization",
224
+ "cookie",
225
+ "cardnumber",
226
+ "cvv",
227
+ "pin"
228
+ ]);
229
+ const REDACTED = "[REDACTED]";
230
+ /** A key as compared: lower case, without `-` or `_` */
231
+ const normalizeKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
232
+ /**
233
+ * Replaces the value of every sensitive key with `"[REDACTED]"`, at any depth
234
+ * and through arrays. The key stays, so the shape of the data is still
235
+ * visible.
236
+ *
237
+ * A key is sensitive when its name, in lower case and without `-` or `_`,
238
+ * is in the built-in list (passwords, tokens, OTPs, secrets, authorization
239
+ * and cookie headers, card data) or in `sensitiveKeys`. The input isn't
240
+ * changed. Only plain objects and arrays are walked; other values are
241
+ * returned as they are.
242
+ *
243
+ * @example
244
+ * ```ts
245
+ * redactAuditData({ user: { id: "1", Access_Token: "abc" } }, [])
246
+ * // { user: { id: "1", Access_Token: "[REDACTED]" } }
247
+ * ```
248
+ */
249
+ function redactAuditData(value, sensitiveKeys = []) {
250
+ const sensitive = new Set([...ALWAYS_REDACTED_KEYS, ...sensitiveKeys.map(normalizeKey)]);
251
+ const redact = (current) => {
252
+ if (Array.isArray(current)) return current.map(redact);
253
+ if (!isPlainObject(current)) return current;
254
+ return Object.fromEntries(Object.entries(current).map(([key, entry]) => [key, sensitive.has(normalizeKey(key)) ? REDACTED : redact(entry)]));
255
+ };
256
+ return redact(value);
257
+ }
258
+ function isPlainObject(value) {
259
+ if (typeof value !== "object" || value === null) return false;
260
+ const prototype = Object.getPrototypeOf(value);
261
+ return prototype === Object.prototype || prototype === null;
262
+ }
263
+
86
264
  //#endregion
87
265
  //#region src/plugins/audit-log/schema.ts
88
266
  const schema = { auditLog: { fields: {
@@ -126,6 +304,26 @@ const schema = { auditLog: { fields: {
126
304
  type: "json",
127
305
  required: false
128
306
  },
307
+ traceId: {
308
+ type: "string",
309
+ required: false
310
+ },
311
+ requestId: {
312
+ type: "string",
313
+ required: false
314
+ },
315
+ actorType: {
316
+ type: "string",
317
+ required: false
318
+ },
319
+ severity: {
320
+ type: "string",
321
+ required: false
322
+ },
323
+ signature: {
324
+ type: "string",
325
+ required: false
326
+ },
129
327
  createdAt: {
130
328
  type: "date",
131
329
  required: true
@@ -134,10 +332,6 @@ const schema = { auditLog: { fields: {
134
332
 
135
333
  //#endregion
136
334
  //#region src/plugins/audit-log/index.ts
137
- const AUDIT_LOG_ERROR_CODES = defineErrorCodes({
138
- AUDIT_LOG_ACCESS_DENIED: "You don't have access to these audit logs",
139
- AUDIT_LOG_UNAVAILABLE: "Audit logs are temporarily unavailable"
140
- });
141
335
  /** User fields that change as a side effect and aren't profile edits */
142
336
  const INTERNAL_USER_FIELDS = new Set([
143
337
  "updatedAt",
@@ -156,11 +350,23 @@ const auditLogQuery = {
156
350
  offset: z$1.string().meta({ description: "The offset to start from" }).or(z$1.number()).optional(),
157
351
  eventType: z$1.string().meta({ description: "Filter by event type. Eg: \"user_signed_in\"" }).optional(),
158
352
  identifier: z$1.string().meta({ description: "Filter by the actor's identifier. Eg: \"user@example.com\"" }).optional(),
353
+ traceId: z$1.string().meta({ description: "Filter by the request chain the events belong to" }).optional(),
159
354
  organizationId: z$1.string().meta({ description: "Filter by organization" }).optional(),
160
355
  userId: z$1.string().meta({ description: "Filter by user" }).optional(),
161
356
  startDate: z$1.string().refine(isDate, "Invalid date").meta({ description: "Only return events created at or after this date. Eg: \"2026-01-01\"" }).optional(),
162
357
  endDate: z$1.string().refine(isDate, "Invalid date").meta({ description: "Only return events created at or before this date. Eg: \"2026-01-31\"" }).optional()
163
358
  };
359
+ /** `00-<trace id>-<parent id>-<flags>` */
360
+ const TRACEPARENT = /^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$/i;
361
+ function newId() {
362
+ return crypto.randomUUID().replaceAll("-", "");
363
+ }
364
+ /** The catalog's severity, raised to `warning` for a failed or refused event */
365
+ function severityOf(eventType, status) {
366
+ const severity = EVENT_SEVERITIES[eventType];
367
+ if (severity) return severity;
368
+ return status === "success" ? "info" : "warning";
369
+ }
164
370
  function toPagination(query) {
165
371
  const limit = Number(query.limit) || 50;
166
372
  return {
@@ -184,13 +390,19 @@ const ACCOUNT_ATTEMPT_LIMIT = 50;
184
390
  const auditLog = (options) => {
185
391
  const opts = {
186
392
  ...options,
187
- database: options?.database ?? true,
188
- trackFailedSignIns: options?.trackFailedSignIns ?? true,
189
393
  adminRoles: options?.adminRoles ?? ["admin"],
190
394
  organizationAdminRoles: options?.organizationAdminRoles ?? ["owner", "admin"],
191
395
  showAttemptsOnOwnAccount: options?.showAttemptsOnOwnAccount ?? true
192
396
  };
397
+ const minimumRetentionDays = options?.retention?.minimumDays ?? 365;
398
+ if (!Number.isInteger(minimumRetentionDays) || minimumRetentionDays < 1) throw new PayvoAuthError("audit-log: retention.minimumDays must be a whole number of at least 1");
399
+ const integrity = {
400
+ secret: options?.integrity?.secret ?? env.PAYVO_AUDIT_SIGNING_SECRET,
401
+ secrets: options?.integrity?.secrets
402
+ };
193
403
  const excluded = new Set(opts.exclude ?? []);
404
+ const excludedCoreEvent = CORE_EVENT_TYPES.find((eventType) => excluded.has(eventType));
405
+ const disabledOption = ["database", "trackFailedSignIns"].find((name) => options?.[name] === false);
194
406
  const { sink: remote, misconfigured } = createRemoteSink(opts.remote);
195
407
  const mergedSchema = mergeSchema(schema, opts.schema);
196
408
  let authContext;
@@ -205,8 +417,8 @@ const auditLog = (options) => {
205
417
  /** The user an admin endpoint acted on, when the body doesn't name them */
206
418
  const targets = /* @__PURE__ */ new WeakMap();
207
419
  /** The organization an organization endpoint acted on */
208
- async function resolveOrganizationId(auth, path, body, result, activeOrganizationId) {
209
- const direct = body.organizationId ?? result.organizationId ?? result.member?.organizationId ?? result.invitation?.organizationId ?? (path === "/organization/create" || path === "/organization/update" ? result.id : void 0);
420
+ async function resolveOrganizationId(auth, path, body, returnedBody, activeOrganizationId) {
421
+ const direct = body.organizationId ?? returnedBody.organizationId ?? returnedBody.member?.organizationId ?? returnedBody.invitation?.organizationId ?? (path === "/organization/create" || path === "/organization/update" ? returnedBody.id : void 0);
210
422
  if (typeof direct === "string") return direct;
211
423
  if (!path.startsWith("/organization/")) return null;
212
424
  if (typeof body.teamId === "string") {
@@ -230,6 +442,29 @@ const auditLog = (options) => {
230
442
  visitorId: headers?.get("x-visitor-id") ?? null
231
443
  };
232
444
  }
445
+ /** Each request's trace and request ids, so all its events share them */
446
+ const correlations = /* @__PURE__ */ new WeakMap();
447
+ /**
448
+ * The request's trace id, from `Trace-Id` or a W3C `traceparent`, and its
449
+ * request id, from `Request-Id`. Either is minted when the request has
450
+ * none. Null outside a request.
451
+ */
452
+ function correlationOf(ctx) {
453
+ const headers = ctx?.headers ?? ctx?.request?.headers;
454
+ if (!headers) return {
455
+ traceId: null,
456
+ requestId: null
457
+ };
458
+ const known = correlations.get(headers);
459
+ if (known) return known;
460
+ const traceparent = TRACEPARENT.exec(headers.get("traceparent") ?? "");
461
+ const correlation = {
462
+ traceId: headers.get("trace-id") || headers.get("x-trace-id") || traceparent?.[1] || newId(),
463
+ requestId: headers.get("request-id") || headers.get("x-request-id") || newId()
464
+ };
465
+ correlations.set(headers, correlation);
466
+ return correlation;
467
+ }
233
468
  /**
234
469
  * The signed-in user making a request, and the admin impersonating them,
235
470
  * when known
@@ -261,39 +496,78 @@ const auditLog = (options) => {
261
496
  ...actor.id && actor.id !== event.userId ? { actorId: actor.id } : {},
262
497
  ...actor.impersonatedBy ? { impersonatedBy: actor.impersonatedBy } : {}
263
498
  };
264
- const eventData = Object.keys(attribution).length ? {
499
+ const eventData = redactAuditData(Object.keys(attribution).length ? {
265
500
  ...event.eventData,
266
501
  ...attribution
267
- } : event.eventData ?? null;
502
+ } : event.eventData ?? null, opts.sensitiveKeys);
503
+ const status = event.status ?? "success";
504
+ const inRequest = Boolean(ctx?.headers ?? ctx?.request?.headers);
505
+ const actorType = event.actorType ?? (!inRequest ? "system" : actor.impersonatedBy || attribution.actorId ? "admin" : "user");
268
506
  const { id: _,...data } = {
269
507
  id: "",
270
- status: "success",
271
508
  userId: null,
272
509
  identifier: null,
273
510
  organizationId: null,
274
511
  ...meta,
512
+ ...correlationOf(ctx),
275
513
  ...event,
514
+ status,
515
+ actorType,
516
+ severity: event.severity ?? severityOf(event.eventType, status),
276
517
  eventData,
277
518
  createdAt: /* @__PURE__ */ new Date()
278
519
  };
279
520
  let id = auth.generateId({ model: "auditLog" }) || "";
280
- if (opts.database) {
281
- const row = id ? {
282
- ...data,
521
+ const unsigned = {
522
+ ...data,
523
+ signature: null
524
+ };
525
+ let signature = integrity.secret && id ? await signAuditEvent({
526
+ ...unsigned,
527
+ id
528
+ }, integrity.secret) : null;
529
+ const row = id ? {
530
+ ...unsigned,
531
+ signature,
532
+ id
533
+ } : {
534
+ ...unsigned,
535
+ signature
536
+ };
537
+ const created = await auth.adapter.create({
538
+ model: "auditLog",
539
+ data: row,
540
+ forceAllowId: Boolean(id)
541
+ }).catch((e) => {
542
+ auth.logger.error("[audit-log] Failed to write audit log", e);
543
+ return null;
544
+ });
545
+ id = created?.id ?? id;
546
+ if (integrity.secret && !signature && created) {
547
+ signature = await signAuditEvent({
548
+ ...unsigned,
283
549
  id
284
- } : data;
285
- id = (await auth.adapter.create({
550
+ }, integrity.secret);
551
+ await auth.adapter.update({
286
552
  model: "auditLog",
287
- data: row,
288
- forceAllowId: Boolean(id)
553
+ where: [{
554
+ field: "id",
555
+ value: id
556
+ }],
557
+ update: { signature }
289
558
  }).catch((e) => {
290
- auth.logger.error("[audit-log] Failed to write audit log", e);
291
- return null;
292
- }))?.id ?? id;
559
+ auth.logger.error("[audit-log] Failed to sign audit log", e);
560
+ });
293
561
  }
562
+ id ||= generateRandomString(32);
563
+ if (integrity.secret && !signature) signature = await signAuditEvent({
564
+ ...unsigned,
565
+ id
566
+ }, integrity.secret);
294
567
  const full = {
295
- ...data,
296
- id: id || generateRandomString(32)
568
+ ...unsigned,
569
+ signature,
570
+ id
297
571
  };
298
572
  remote?.push(full, auth);
299
573
  const onEvent = opts.onEvent;
@@ -336,6 +610,7 @@ const auditLog = (options) => {
336
610
  for (const field of [
337
611
  "eventType",
338
612
  "identifier",
613
+ "traceId",
339
614
  "userId",
340
615
  "organizationId"
341
616
  ]) {
@@ -436,7 +711,8 @@ const auditLog = (options) => {
436
711
  ipAddress: null,
437
712
  userAgent: null,
438
713
  visitorId: null,
439
- eventData: null
714
+ eventData: null,
715
+ signature: null
440
716
  }));
441
717
  }
442
718
  /** Organization memberships of a user, empty without the organization plugin */
@@ -455,7 +731,15 @@ const auditLog = (options) => {
455
731
  }
456
732
  async function runQuery(auth, query) {
457
733
  try {
458
- return await queryEvents(auth, query);
734
+ const page = await queryEvents(auth, query);
735
+ if (!integrity.secret) return page;
736
+ return {
737
+ ...page,
738
+ events: await Promise.all(page.events.map(async (event) => ({
739
+ ...event,
740
+ verified: await verifyAuditEvent(event, integrity)
741
+ })))
742
+ };
459
743
  } catch (e) {
460
744
  auth.logger.error("[audit-log] Failed to query audit logs", e);
461
745
  throw new APIError("SERVICE_UNAVAILABLE", { message: AUDIT_LOG_ERROR_CODES.AUDIT_LOG_UNAVAILABLE });
@@ -464,6 +748,8 @@ const auditLog = (options) => {
464
748
  return {
465
749
  id: "audit-log",
466
750
  init(ctx) {
751
+ if (excludedCoreEvent || disabledOption) throw new PayvoAuthError(`${AUDIT_LOG_ERROR_CODES.AUDIT_CANNOT_BE_DISABLED}: ${excludedCoreEvent ?? disabledOption}`);
752
+ if (isProduction && !integrity.secret) throw new PayvoAuthError(AUDIT_LOG_ERROR_CODES.AUDIT_SIGNING_SECRET_MISSING);
467
753
  authContext = ctx;
468
754
  registerAuditRecorder(ctx, record);
469
755
  if (misconfigured) ctx.logger.warn("[audit-log] Missing PAYVO_INFRA_API_URL or PAYVO_INFRA_API_KEY. Remote audit logging is disabled.");
@@ -564,7 +850,7 @@ const auditLog = (options) => {
564
850
  return;
565
851
  }
566
852
  const isCredentialCheck = CREDENTIAL_SIGN_IN_PATHS.includes(ctx.path);
567
- if (returned instanceof APIError && failed && opts.trackFailedSignIns && (isCredentialCheck || VERIFICATION_PATHS.includes(ctx.path))) {
853
+ if (returned instanceof APIError && failed && (isCredentialCheck || VERIFICATION_PATHS.includes(ctx.path))) {
568
854
  await record(ctx, {
569
855
  eventType: isCredentialCheck ? AUDIT_EVENT_TYPES.USER_SIGN_IN_FAILED : AUDIT_EVENT_TYPES.VERIFICATION_FAILED,
570
856
  status: "failure",
@@ -588,10 +874,10 @@ const auditLog = (options) => {
588
874
  const eventType = PATH_EVENTS[ctx.path];
589
875
  if (!eventType || failed) return;
590
876
  if (ctx.path === "/email-otp/send-verification-otp" && body.type !== "email-verification") return;
591
- const result = returned ?? {};
877
+ const returnedBody = returned ?? {};
592
878
  const actor = ctx.headers ? actors.get(ctx.headers) : void 0;
593
879
  const target = (ctx.headers && targets.get(ctx.headers)) ?? (ctx.path.startsWith("/admin/") && typeof body.userId === "string" ? body.userId : null);
594
- const organizationId = await resolveOrganizationId(ctx.context, ctx.path, body, result, actor?.activeOrganizationId ?? null);
880
+ const organizationId = await resolveOrganizationId(ctx.context, ctx.path, body, returnedBody, actor?.activeOrganizationId ?? null);
595
881
  const eventData = {};
596
882
  if (ctx.path === "/unlink-account") eventData.providerId = body.providerId;
597
883
  if (ctx.path === "/organization/invite-member") {
@@ -605,13 +891,13 @@ const auditLog = (options) => {
605
891
  if (ctx.path.startsWith("/organization/") && ctx.path.endsWith("-role")) eventData.role = body.role ?? body.roleName ?? body.roleId;
606
892
  let owner = null;
607
893
  if (ctx.path.startsWith("/api-key/")) {
608
- eventData.keyId = body.keyId ?? result.id;
609
- owner = result.userId ?? null;
894
+ eventData.keyId = body.keyId ?? returnedBody.id;
895
+ owner = returnedBody.userId ?? null;
610
896
  }
611
- if (ctx.path.startsWith("/passkey/")) eventData.passkeyId = body.id ?? result.id;
897
+ if (ctx.path.startsWith("/passkey/")) eventData.passkeyId = body.id ?? returnedBody.id;
612
898
  await record(ctx, {
613
899
  eventType,
614
- userId: target ?? actor?.id ?? result.user?.id ?? owner,
900
+ userId: target ?? actor?.id ?? returnedBody.user?.id ?? owner,
615
901
  identifier: actor?.email ?? identifier,
616
902
  organizationId,
617
903
  eventData: Object.keys(eventData).length ? eventData : null
@@ -673,6 +959,38 @@ const auditLog = (options) => {
673
959
  type: "object",
674
960
  nullable: true
675
961
  },
962
+ traceId: {
963
+ type: "string",
964
+ nullable: true
965
+ },
966
+ requestId: {
967
+ type: "string",
968
+ nullable: true
969
+ },
970
+ actorType: {
971
+ type: "string",
972
+ enum: [
973
+ "user",
974
+ "admin",
975
+ "system"
976
+ ]
977
+ },
978
+ severity: {
979
+ type: "string",
980
+ enum: [
981
+ "info",
982
+ "warning",
983
+ "critical"
984
+ ]
985
+ },
986
+ signature: {
987
+ type: "string",
988
+ nullable: true
989
+ },
990
+ verified: {
991
+ type: "boolean",
992
+ description: "Whether the signature matches the event. Only present when signing is configured."
993
+ },
676
994
  createdAt: {
677
995
  type: "string",
678
996
  format: "date-time"
@@ -724,7 +1042,7 @@ const auditLog = (options) => {
724
1042
  metadata: { openapi: {
725
1043
  operationId: "getAllAuditLogs",
726
1044
  summary: "Get all audit logs",
727
- description: "Get audit events across organizations the user owns or administers, or every event for admins",
1045
+ description: "Get audit events across organizations the user owns or administers, or every event for admins. Each read is recorded as an audit_read event, which is not part of the page it returns.",
728
1046
  responses: { 200: {
729
1047
  description: "Paginated audit events",
730
1048
  content: { "application/json": { schema: {
@@ -770,6 +1088,38 @@ const auditLog = (options) => {
770
1088
  type: "object",
771
1089
  nullable: true
772
1090
  },
1091
+ traceId: {
1092
+ type: "string",
1093
+ nullable: true
1094
+ },
1095
+ requestId: {
1096
+ type: "string",
1097
+ nullable: true
1098
+ },
1099
+ actorType: {
1100
+ type: "string",
1101
+ enum: [
1102
+ "user",
1103
+ "admin",
1104
+ "system"
1105
+ ]
1106
+ },
1107
+ severity: {
1108
+ type: "string",
1109
+ enum: [
1110
+ "info",
1111
+ "warning",
1112
+ "critical"
1113
+ ]
1114
+ },
1115
+ signature: {
1116
+ type: "string",
1117
+ nullable: true
1118
+ },
1119
+ verified: {
1120
+ type: "boolean",
1121
+ description: "Whether the signature matches the event. Only present when signing is configured."
1122
+ },
773
1123
  createdAt: {
774
1124
  type: "string",
775
1125
  format: "date-time"
@@ -794,17 +1144,36 @@ const auditLog = (options) => {
794
1144
  const { user } = ctx.context.session;
795
1145
  const { limit: _limit, offset: _offset,...filters } = ctx.query ?? {};
796
1146
  const pagination = toPagination(ctx.query ?? {});
797
- if (hasRole(user.role, opts.adminRoles)) return ctx.json(await runQuery(ctx.context, {
798
- ...filters,
799
- ...pagination
800
- }));
1147
+ /** Records the read once the page is built, so it isn't in the page */
1148
+ const recordRead = (page$1, organizationId) => record(ctx, {
1149
+ eventType: AUDIT_EVENT_TYPES.AUDIT_READ,
1150
+ userId: user.id,
1151
+ identifier: user.email,
1152
+ organizationId,
1153
+ actorType: "admin",
1154
+ eventData: {
1155
+ filters,
1156
+ returned: page$1.events.length,
1157
+ total: page$1.total
1158
+ }
1159
+ });
1160
+ if (hasRole(user.role, opts.adminRoles)) {
1161
+ const page$1 = await runQuery(ctx.context, {
1162
+ ...filters,
1163
+ ...pagination
1164
+ });
1165
+ await recordRead(page$1, filters.organizationId ?? null);
1166
+ return ctx.json(page$1);
1167
+ }
801
1168
  const administered = (await findMemberships(ctx.context, user.id)).filter((m) => hasRole(m.role, opts.organizationAdminRoles)).map((m) => m.organizationId);
802
1169
  if (!administered.length || filters.organizationId && !administered.includes(filters.organizationId)) throw new APIError("FORBIDDEN", { message: AUDIT_LOG_ERROR_CODES.AUDIT_LOG_ACCESS_DENIED });
803
- return ctx.json(await runQuery(ctx.context, {
1170
+ const page = await runQuery(ctx.context, {
804
1171
  ...filters,
805
1172
  ...pagination,
806
1173
  organizationIds: filters.organizationId ? void 0 : administered
807
- }));
1174
+ });
1175
+ await recordRead(page, filters.organizationId ?? (administered.length === 1 ? administered[0] : null));
1176
+ return ctx.json(page);
808
1177
  }),
809
1178
  getEventTypes: createAuthEndpoint("/audit-log/get-event-types", {
810
1179
  method: "GET",
@@ -828,21 +1197,48 @@ const auditLog = (options) => {
828
1197
  }, async (ctx) => ctx.json({ types: ALL_EVENT_TYPES })),
829
1198
  deleteOldEvents: createAuthEndpoint("/audit-log/delete-old-events", {
830
1199
  method: "POST",
831
- body: z$1.object({ olderThanDays: z$1.number().int().min(1).meta({ description: "Delete events recorded more than this many days ago. There is no default: set it to your retention period." }) }),
1200
+ body: z$1.object({ olderThanDays: z$1.number().int().min(1).meta({ description: "Delete events recorded more than this many days ago. There is no default, and a period shorter than retention.minimumDays is refused." }) }),
832
1201
  metadata: {
833
1202
  SERVER_ONLY: true,
834
- client: false
1203
+ client: false,
1204
+ openapi: {
1205
+ operationId: "deleteOldAuditEvents",
1206
+ summary: "Delete old audit events",
1207
+ description: "Delete audit events recorded more than olderThanDays ago. A period shorter than retention.minimumDays is refused, and every deletion is recorded as an audit_events_deleted event.",
1208
+ responses: { 200: {
1209
+ description: "How many events were deleted",
1210
+ content: { "application/json": { schema: {
1211
+ type: "object",
1212
+ properties: { deleted: { type: "number" } },
1213
+ required: ["deleted"]
1214
+ } } }
1215
+ } }
1216
+ }
835
1217
  }
836
1218
  }, async (ctx) => {
837
- if (!opts.database) return ctx.json({ deleted: 0 });
1219
+ const { olderThanDays } = ctx.body;
1220
+ if (olderThanDays < minimumRetentionDays) throw new APIError("BAD_REQUEST", { message: AUDIT_LOG_ERROR_CODES.AUDIT_RETENTION_TOO_SHORT });
1221
+ const before = /* @__PURE__ */ new Date(Date.now() - olderThanDays * 864e5);
838
1222
  const deleted = await ctx.context.adapter.deleteMany({
839
1223
  model: "auditLog",
840
1224
  where: [{
841
1225
  field: "createdAt",
842
1226
  operator: "lt",
843
- value: /* @__PURE__ */ new Date(Date.now() - ctx.body.olderThanDays * 864e5)
1227
+ value: before
844
1228
  }]
845
1229
  });
1230
+ const session = await getSessionFromCtx(ctx).catch(() => null);
1231
+ await record(ctx, {
1232
+ eventType: AUDIT_EVENT_TYPES.AUDIT_EVENTS_DELETED,
1233
+ userId: session?.user.id ?? null,
1234
+ identifier: session?.user.email ?? null,
1235
+ actorType: session ? "admin" : "system",
1236
+ eventData: {
1237
+ olderThanDays,
1238
+ before: before.toISOString(),
1239
+ deleted
1240
+ }
1241
+ });
846
1242
  return ctx.json({ deleted });
847
1243
  })
848
1244
  },
@@ -853,4 +1249,4 @@ const auditLog = (options) => {
853
1249
  };
854
1250
 
855
1251
  //#endregion
856
- export { auditLog as n, AUDIT_LOG_ERROR_CODES as t };
1252
+ export { verifyAuditEvent as n, AUDIT_LOG_ERROR_CODES as r, auditLog as t };