@usebetterdev/audit-core 0.4.0-beta.4 → 0.5.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.cjs CHANGED
@@ -25,6 +25,7 @@ __export(index_exports, {
25
25
  betterAudit: () => betterAudit,
26
26
  createAuditApi: () => createAuditApi,
27
27
  createAuditConsoleEndpoints: () => createAuditConsoleEndpoints,
28
+ createExportResponse: () => createExportResponse,
28
29
  fromBearerToken: () => fromBearerToken,
29
30
  fromCookie: () => fromCookie,
30
31
  fromHeader: () => fromHeader,
@@ -33,6 +34,7 @@ __export(index_exports, {
33
34
  mergeAuditContext: () => mergeAuditContext,
34
35
  normalizeInput: () => normalizeInput,
35
36
  parseDuration: () => parseDuration,
37
+ runExport: () => runExport,
36
38
  runWithAuditContext: () => runWithAuditContext
37
39
  });
38
40
  module.exports = __toCommonJS(index_exports);
@@ -559,38 +561,214 @@ var AuditQueryBuilder = class _AuditQueryBuilder {
559
561
  }
560
562
  };
561
563
 
562
- // src/audit-api.ts
563
- var CSV_HEADERS = [
564
+ // src/export.ts
565
+ var DEFAULT_BATCH_SIZE = 500;
566
+ var DEFAULT_CSV_DELIMITER = ",";
567
+ var CSV_COLUMNS = [
564
568
  "id",
565
569
  "timestamp",
566
570
  "tableName",
567
571
  "operation",
568
572
  "recordId",
569
573
  "actorId",
570
- "severity",
574
+ "beforeData",
575
+ "afterData",
576
+ "diff",
571
577
  "label",
572
- "description"
578
+ "description",
579
+ "severity",
580
+ "compliance",
581
+ "notify",
582
+ "reason",
583
+ "metadata",
584
+ "redactedFields"
573
585
  ];
574
- function escapeCsvField(value) {
575
- if (value.includes('"') || value.includes(",") || value.includes("\n") || value.includes("\r")) {
586
+ var JSON_FIELDS = /* @__PURE__ */ new Set([
587
+ "beforeData",
588
+ "afterData",
589
+ "diff",
590
+ "compliance",
591
+ "metadata",
592
+ "redactedFields"
593
+ ]);
594
+ function escapeCsvField(value, delimiter) {
595
+ if (value.includes('"') || value.includes(delimiter) || value.includes("\n") || value.includes("\r")) {
576
596
  return `"${value.replace(/"/g, '""')}"`;
577
597
  }
578
598
  return value;
579
599
  }
580
- function logToCsvRow(log) {
581
- const fields = [
582
- log.id,
583
- log.timestamp instanceof Date ? log.timestamp.toISOString() : String(log.timestamp),
584
- log.tableName,
585
- log.operation,
586
- log.recordId,
587
- log.actorId ?? "",
588
- log.severity ?? "",
589
- log.label ?? "",
590
- log.description ?? ""
591
- ];
592
- return fields.map(escapeCsvField).join(",");
600
+ function formatCsvValue(log, field, delimiter) {
601
+ const value = log[field];
602
+ if (value === void 0 || value === null) {
603
+ return "";
604
+ }
605
+ if (JSON_FIELDS.has(field)) {
606
+ return escapeCsvField(JSON.stringify(value), delimiter);
607
+ }
608
+ if (value instanceof Date) {
609
+ return escapeCsvField(value.toISOString(), delimiter);
610
+ }
611
+ if (typeof value === "boolean") {
612
+ return value ? "true" : "false";
613
+ }
614
+ return escapeCsvField(String(value), delimiter);
615
+ }
616
+ function createStringSink() {
617
+ const chunks = [];
618
+ return {
619
+ async write(chunk) {
620
+ chunks.push(chunk);
621
+ },
622
+ async finish() {
623
+ return chunks.join("");
624
+ },
625
+ async abort() {
626
+ }
627
+ };
593
628
  }
629
+ function createStreamSink(stream) {
630
+ const writer = stream.getWriter();
631
+ return {
632
+ async write(chunk) {
633
+ await writer.write(chunk);
634
+ },
635
+ async finish() {
636
+ await writer.close();
637
+ return void 0;
638
+ },
639
+ async abort(error) {
640
+ await writer.abort(error);
641
+ }
642
+ };
643
+ }
644
+ async function runExport(executor, options) {
645
+ const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
646
+ if (batchSize <= 0) {
647
+ throw new Error(`batchSize must be greater than 0, got ${batchSize}`);
648
+ }
649
+ const delimiter = options.csvDelimiter ?? DEFAULT_CSV_DELIMITER;
650
+ if (delimiter.length !== 1) {
651
+ throw new Error("csvDelimiter must be exactly one character");
652
+ }
653
+ const jsonStyle = options.jsonStyle ?? "ndjson";
654
+ const sink = options.output === "string" ? createStringSink() : createStreamSink(options.output);
655
+ const isStringSink = options.output === "string";
656
+ const baseSpec = options.query !== void 0 ? options.query.toSpec() : { filters: {} };
657
+ const totalLimit = baseSpec.limit;
658
+ const spec = { ...baseSpec, limit: batchSize };
659
+ let rowCount = 0;
660
+ try {
661
+ if (options.format === "csv") {
662
+ const header = CSV_COLUMNS.map((col) => escapeCsvField(col, delimiter)).join(delimiter);
663
+ await sink.write(header + "\n");
664
+ let cursor;
665
+ for (; ; ) {
666
+ const currentSpec = cursor !== void 0 ? { ...spec, cursor } : spec;
667
+ const result = await executor(currentSpec);
668
+ if (isStringSink) {
669
+ const lines = [];
670
+ for (const entry of result.entries) {
671
+ if (totalLimit !== void 0 && rowCount >= totalLimit) {
672
+ break;
673
+ }
674
+ const row = CSV_COLUMNS.map((col) => formatCsvValue(entry, col, delimiter)).join(delimiter);
675
+ lines.push(row + "\n");
676
+ rowCount++;
677
+ }
678
+ if (lines.length > 0) {
679
+ await sink.write(lines.join(""));
680
+ }
681
+ } else {
682
+ for (const entry of result.entries) {
683
+ if (totalLimit !== void 0 && rowCount >= totalLimit) {
684
+ break;
685
+ }
686
+ const row = CSV_COLUMNS.map((col) => formatCsvValue(entry, col, delimiter)).join(delimiter);
687
+ await sink.write(row + "\n");
688
+ rowCount++;
689
+ }
690
+ }
691
+ if (totalLimit !== void 0 && rowCount >= totalLimit) {
692
+ break;
693
+ }
694
+ if (result.nextCursor === void 0) {
695
+ break;
696
+ }
697
+ cursor = result.nextCursor;
698
+ }
699
+ } else {
700
+ if (jsonStyle === "array") {
701
+ let cursor;
702
+ const entries = [];
703
+ for (; ; ) {
704
+ const currentSpec = cursor !== void 0 ? { ...spec, cursor } : spec;
705
+ const result = await executor(currentSpec);
706
+ for (const entry of result.entries) {
707
+ if (totalLimit !== void 0 && rowCount >= totalLimit) {
708
+ break;
709
+ }
710
+ entries.push(entry);
711
+ rowCount++;
712
+ }
713
+ if (totalLimit !== void 0 && rowCount >= totalLimit) {
714
+ break;
715
+ }
716
+ if (result.nextCursor === void 0) {
717
+ break;
718
+ }
719
+ cursor = result.nextCursor;
720
+ }
721
+ await sink.write(JSON.stringify(entries, null, 2) + "\n");
722
+ } else {
723
+ let cursor;
724
+ for (; ; ) {
725
+ const currentSpec = cursor !== void 0 ? { ...spec, cursor } : spec;
726
+ const result = await executor(currentSpec);
727
+ if (isStringSink) {
728
+ const lines = [];
729
+ for (const entry of result.entries) {
730
+ if (totalLimit !== void 0 && rowCount >= totalLimit) {
731
+ break;
732
+ }
733
+ lines.push(JSON.stringify(entry) + "\n");
734
+ rowCount++;
735
+ }
736
+ if (lines.length > 0) {
737
+ await sink.write(lines.join(""));
738
+ }
739
+ } else {
740
+ for (const entry of result.entries) {
741
+ if (totalLimit !== void 0 && rowCount >= totalLimit) {
742
+ break;
743
+ }
744
+ await sink.write(JSON.stringify(entry) + "\n");
745
+ rowCount++;
746
+ }
747
+ }
748
+ if (totalLimit !== void 0 && rowCount >= totalLimit) {
749
+ break;
750
+ }
751
+ if (result.nextCursor === void 0) {
752
+ break;
753
+ }
754
+ cursor = result.nextCursor;
755
+ }
756
+ }
757
+ }
758
+ } catch (error) {
759
+ await sink.abort(error);
760
+ throw error;
761
+ }
762
+ const data = await sink.finish();
763
+ if (data !== void 0) {
764
+ return { rowCount, data };
765
+ }
766
+ return { rowCount };
767
+ }
768
+
769
+ // src/audit-api.ts
770
+ var VALID_SEVERITIES = /* @__PURE__ */ new Set(["low", "medium", "high", "critical"]);
771
+ var VALID_OPERATIONS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE"]);
594
772
  function toTimeFilter(date) {
595
773
  return { date };
596
774
  }
@@ -607,13 +785,24 @@ function buildQuerySpec(filters, effectiveLimit) {
607
785
  spec.filters.actorIds = [filters.actorId];
608
786
  }
609
787
  if (filters.severity !== void 0) {
788
+ if (!VALID_SEVERITIES.has(filters.severity)) {
789
+ throw new Error(
790
+ `Invalid severity "${filters.severity}". Must be one of: low, medium, high, critical`
791
+ );
792
+ }
610
793
  spec.filters.severities = [filters.severity];
611
794
  }
612
795
  if (filters.compliance !== void 0) {
613
796
  spec.filters.compliance = [filters.compliance];
614
797
  }
615
798
  if (filters.operation !== void 0) {
616
- spec.filters.operations = [filters.operation.toUpperCase()];
799
+ const normalized = filters.operation.toUpperCase();
800
+ if (!VALID_OPERATIONS.has(normalized)) {
801
+ throw new Error(
802
+ `Invalid operation "${filters.operation}". Must be one of: INSERT, UPDATE, DELETE`
803
+ );
804
+ }
805
+ spec.filters.operations = [normalized];
617
806
  }
618
807
  if (filters.since !== void 0) {
619
808
  spec.filters.since = toTimeFilter(filters.since);
@@ -714,17 +903,28 @@ function createAuditApi(adapter, registry, maxQueryLimit) {
714
903
  return summaries;
715
904
  }
716
905
  async function exportLogs(filters, format) {
906
+ const queryFn = requireQueryLogs();
717
907
  const exportFormat = format ?? "json";
718
- const exportFilters = { ...filters, limit: effectiveLimit };
719
- const result = await queryLogs(exportFilters);
720
- if (exportFormat === "json") {
721
- return JSON.stringify(result.entries);
722
- }
723
- const rows = [CSV_HEADERS.join(",")];
724
- for (const log of result.entries) {
725
- rows.push(logToCsvRow(log));
726
- }
727
- return rows.join("\n");
908
+ const spec = buildQuerySpec(filters ?? {}, effectiveLimit);
909
+ const queryBuilder = new AuditQueryBuilder(
910
+ (s) => queryFn(s),
911
+ spec.filters,
912
+ spec.limit,
913
+ void 0,
914
+ effectiveLimit,
915
+ spec.sortOrder
916
+ );
917
+ const exportOptions = {
918
+ format: exportFormat,
919
+ query: queryBuilder,
920
+ output: "string",
921
+ ...exportFormat === "json" && { jsonStyle: "array" }
922
+ };
923
+ const result = await runExport(
924
+ (s) => queryFn(s),
925
+ exportOptions
926
+ );
927
+ return result.data ?? "";
728
928
  }
729
929
  async function purgeLogs(options) {
730
930
  const purgeFn = requirePurgeLogs();
@@ -960,14 +1160,113 @@ function createAuditConsoleEndpoints(api) {
960
1160
  ];
961
1161
  }
962
1162
 
1163
+ // src/export-response.ts
1164
+ function contentTypeForFormat(format, jsonStyle) {
1165
+ if (format === "csv") {
1166
+ return "text/csv; charset=utf-8";
1167
+ }
1168
+ if (jsonStyle === "ndjson") {
1169
+ return "application/x-ndjson; charset=utf-8";
1170
+ }
1171
+ return "application/json; charset=utf-8";
1172
+ }
1173
+ function fileExtensionForFormat(format, jsonStyle) {
1174
+ if (format === "csv") {
1175
+ return ".csv";
1176
+ }
1177
+ if (jsonStyle === "ndjson") {
1178
+ return ".ndjson";
1179
+ }
1180
+ return ".json";
1181
+ }
1182
+ function formatDate(date) {
1183
+ const year = date.getFullYear();
1184
+ const month = String(date.getMonth() + 1).padStart(2, "0");
1185
+ const day = String(date.getDate()).padStart(2, "0");
1186
+ return `${year}-${month}-${day}`;
1187
+ }
1188
+ function sanitiseFilename(name) {
1189
+ return name.replace(/["\\\r\n\x00-\x1f]/g, "");
1190
+ }
1191
+ function createExportResponse(executor, options = {}) {
1192
+ const format = options.format ?? "csv";
1193
+ const jsonStyle = options.jsonStyle ?? "ndjson";
1194
+ const stem = options.filename ?? `audit-export-${formatDate(/* @__PURE__ */ new Date())}`;
1195
+ const extension = fileExtensionForFormat(format, jsonStyle);
1196
+ const fullFilename = sanitiseFilename(`${stem}${extension}`);
1197
+ const contentType = contentTypeForFormat(format, jsonStyle);
1198
+ const transform = new TransformStream();
1199
+ const encoder = new TextEncoderStream();
1200
+ const readable = transform.readable.pipeThrough(encoder);
1201
+ runExport(executor, {
1202
+ format,
1203
+ jsonStyle,
1204
+ output: transform.writable,
1205
+ ...options.batchSize !== void 0 && { batchSize: options.batchSize },
1206
+ ...options.csvDelimiter !== void 0 && {
1207
+ csvDelimiter: options.csvDelimiter
1208
+ },
1209
+ ...options.query !== void 0 && { query: options.query }
1210
+ }).catch(() => {
1211
+ });
1212
+ return new Response(readable, {
1213
+ status: 200,
1214
+ headers: {
1215
+ "Content-Type": contentType,
1216
+ "Content-Disposition": `attachment; filename="${fullFilename}"`,
1217
+ "Cache-Control": "no-cache"
1218
+ }
1219
+ });
1220
+ }
1221
+
1222
+ // src/retention.ts
1223
+ function validateRetentionPolicy(policy) {
1224
+ if (!Number.isInteger(policy.days) || !Number.isFinite(policy.days) || policy.days <= 0) {
1225
+ throw new Error(
1226
+ `retention: 'days' must be a positive integer, got ${String(policy.days)}`
1227
+ );
1228
+ }
1229
+ if (policy.tables !== void 0) {
1230
+ if (!Array.isArray(policy.tables) || policy.tables.length === 0 || policy.tables.some((t) => typeof t !== "string" || t === "")) {
1231
+ throw new Error(
1232
+ "retention: 'tables' must be a non-empty array of non-empty strings"
1233
+ );
1234
+ }
1235
+ }
1236
+ }
1237
+
963
1238
  // src/better-audit.ts
964
1239
  function withContext(context, fn) {
965
1240
  return runWithAuditContext(context, fn);
966
1241
  }
967
1242
  function betterAudit(config) {
1243
+ if (config.retention !== void 0) {
1244
+ validateRetentionPolicy(config.retention);
1245
+ }
968
1246
  const { database } = config;
969
1247
  const auditTables = new Set(config.auditTables);
1248
+ if (config.retention?.tables !== void 0) {
1249
+ const unknown = config.retention.tables.filter((t) => !auditTables.has(t));
1250
+ if (unknown.length > 0) {
1251
+ throw new Error(
1252
+ `retention: 'tables' contains table(s) not in auditTables: ${unknown.join(", ")}. Registered tables: ${[...auditTables].join(", ")}`
1253
+ );
1254
+ }
1255
+ }
970
1256
  const registry = new EnrichmentRegistry();
1257
+ const resolvedRetention = config.retention !== void 0 ? {
1258
+ ...config.retention,
1259
+ ...config.retention.tables !== void 0 && { tables: [...config.retention.tables] }
1260
+ } : void 0;
1261
+ function retentionPolicy() {
1262
+ if (resolvedRetention === void 0) {
1263
+ return void 0;
1264
+ }
1265
+ return {
1266
+ ...resolvedRetention,
1267
+ ...resolvedRetention.tables !== void 0 && { tables: [...resolvedRetention.tables] }
1268
+ };
1269
+ }
971
1270
  const beforeLogHooks = config.beforeLog !== void 0 ? [...config.beforeLog] : [];
972
1271
  const afterLogHooks = config.afterLog !== void 0 ? [...config.afterLog] : [];
973
1272
  function enrich(table, operation, enrichmentConfig) {
@@ -1075,6 +1374,24 @@ function betterAudit(config) {
1075
1374
  config.maxQueryLimit
1076
1375
  );
1077
1376
  }
1377
+ async function exportLogs(options) {
1378
+ if (database.queryLogs === void 0) {
1379
+ throw new Error(
1380
+ "audit.export() requires a database adapter that implements queryLogs(). Check that your ORM adapter supports querying."
1381
+ );
1382
+ }
1383
+ const queryLogs = database.queryLogs;
1384
+ return runExport((spec) => queryLogs(spec), options);
1385
+ }
1386
+ function exportResponse(options) {
1387
+ if (database.queryLogs === void 0) {
1388
+ throw new Error(
1389
+ "audit.exportResponse() requires a database adapter that implements queryLogs(). Check that your ORM adapter supports querying."
1390
+ );
1391
+ }
1392
+ const queryLogs = database.queryLogs;
1393
+ return createExportResponse((spec) => queryLogs(spec), options);
1394
+ }
1078
1395
  if (config.console) {
1079
1396
  const api = createAuditApi(database, registry, config.maxQueryLimit);
1080
1397
  const endpoints = createAuditConsoleEndpoints(api);
@@ -1084,7 +1401,7 @@ function betterAudit(config) {
1084
1401
  endpoints
1085
1402
  });
1086
1403
  }
1087
- return { captureLog, query, withContext, enrich, onBeforeLog, onAfterLog };
1404
+ return { captureLog, query, export: exportLogs, exportResponse, withContext, enrich, onBeforeLog, onAfterLog, retentionPolicy };
1088
1405
  }
1089
1406
 
1090
1407
  // src/audit-log-schema.ts
@@ -1293,6 +1610,7 @@ async function handleMiddleware(extractor, request, next, options = {}) {
1293
1610
  betterAudit,
1294
1611
  createAuditApi,
1295
1612
  createAuditConsoleEndpoints,
1613
+ createExportResponse,
1296
1614
  fromBearerToken,
1297
1615
  fromCookie,
1298
1616
  fromHeader,
@@ -1301,6 +1619,7 @@ async function handleMiddleware(extractor, request, next, options = {}) {
1301
1619
  mergeAuditContext,
1302
1620
  normalizeInput,
1303
1621
  parseDuration,
1622
+ runExport,
1304
1623
  runWithAuditContext
1305
1624
  });
1306
1625
  //# sourceMappingURL=index.cjs.map