@usebetterdev/audit-core 0.7.0 → 0.8.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
@@ -779,8 +779,41 @@ async function runExport(executor, options) {
779
779
  return { rowCount };
780
780
  }
781
781
 
782
+ // src/cursor.ts
783
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
784
+ function encodeCursor(timestamp, id) {
785
+ const payload = JSON.stringify({ t: timestamp.toISOString(), i: id });
786
+ return btoa(payload);
787
+ }
788
+ function decodeCursor(cursor) {
789
+ let parsed;
790
+ try {
791
+ parsed = JSON.parse(atob(cursor));
792
+ } catch {
793
+ throw new Error("Invalid cursor: failed to decode");
794
+ }
795
+ if (typeof parsed !== "object" || parsed === null || !("t" in parsed) || !("i" in parsed)) {
796
+ throw new Error("Invalid cursor: missing required fields");
797
+ }
798
+ const record = parsed;
799
+ const t = record["t"];
800
+ const i = record["i"];
801
+ if (typeof t !== "string" || typeof i !== "string") {
802
+ throw new Error("Invalid cursor: fields must be strings");
803
+ }
804
+ const timestamp = new Date(t);
805
+ if (isNaN(timestamp.getTime())) {
806
+ throw new Error("Invalid cursor: invalid timestamp");
807
+ }
808
+ if (!UUID_PATTERN.test(i)) {
809
+ throw new Error("Invalid cursor: id must be a valid UUID");
810
+ }
811
+ return { timestamp, id: i };
812
+ }
813
+
782
814
  // src/audit-api.ts
783
- var VALID_SEVERITIES = /* @__PURE__ */ new Set(["low", "medium", "high", "critical"]);
815
+ var SEVERITY_VALUES = ["low", "medium", "high", "critical"];
816
+ var VALID_SEVERITIES = new Set(SEVERITY_VALUES);
784
817
  var VALID_OPERATIONS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE"]);
785
818
  function toTimeFilter(date) {
786
819
  return { date };
@@ -792,18 +825,23 @@ function buildQuerySpec(filters, effectiveLimit) {
792
825
  limit
793
826
  };
794
827
  if (filters.tableName !== void 0) {
795
- spec.filters.resource = { tableName: filters.tableName };
828
+ spec.filters.resource = filters.recordId !== void 0 ? { tableName: filters.tableName, recordId: filters.recordId } : { tableName: filters.tableName };
796
829
  }
797
830
  if (filters.actorId !== void 0) {
798
- spec.filters.actorIds = [filters.actorId];
831
+ const raw = filters.actorId.split(",").map((v) => v.trim()).filter((v) => v.length > 0);
832
+ spec.filters.actorIds = [...new Set(raw)];
799
833
  }
800
834
  if (filters.severity !== void 0) {
801
- if (!VALID_SEVERITIES.has(filters.severity)) {
802
- throw new Error(
803
- `Invalid severity "${filters.severity}". Must be one of: low, medium, high, critical`
804
- );
835
+ const raw = filters.severity.split(",").map((v) => v.trim()).filter((v) => v.length > 0);
836
+ const unique = [...new Set(raw)];
837
+ for (const value of unique) {
838
+ if (!VALID_SEVERITIES.has(value)) {
839
+ throw new Error(
840
+ `Invalid severity "${value}". Must be one of: low, medium, high, critical`
841
+ );
842
+ }
805
843
  }
806
- spec.filters.severities = [filters.severity];
844
+ spec.filters.severities = unique.filter((v) => VALID_SEVERITIES.has(v));
807
845
  }
808
846
  if (filters.compliance !== void 0) {
809
847
  spec.filters.compliance = [filters.compliance];
@@ -829,6 +867,12 @@ function buildQuerySpec(filters, effectiveLimit) {
829
867
  if (filters.cursor !== void 0) {
830
868
  spec.cursor = filters.cursor;
831
869
  }
870
+ if (filters.direction === "before") {
871
+ spec.sortOrder = "asc";
872
+ }
873
+ if (filters.order !== void 0) {
874
+ spec.sortOrder = filters.order;
875
+ }
832
876
  return spec;
833
877
  }
834
878
  function createAuditApi(adapter, registry, maxQueryLimit) {
@@ -867,12 +911,66 @@ function createAuditApi(adapter, registry, maxQueryLimit) {
867
911
  }
868
912
  async function queryLogs(filters) {
869
913
  const queryFn = requireQueryLogs();
870
- const spec = buildQuerySpec(filters ?? {}, effectiveLimit);
914
+ const resolved = filters ?? {};
915
+ const spec = buildQuerySpec(resolved, effectiveLimit);
871
916
  const result = await queryFn(spec);
917
+ if (resolved.direction === "before") {
918
+ const entries = [...result.entries].reverse();
919
+ const lastEntry = entries[entries.length - 1];
920
+ return {
921
+ entries,
922
+ // Adapter's "next" in ASC = newer entries = our prev
923
+ ...result.nextCursor !== void 0 && { prevCursor: result.nextCursor },
924
+ hasPrevPage: result.nextCursor !== void 0,
925
+ // Since we started from a cursor, there are older entries behind it
926
+ ...resolved.cursor !== void 0 && lastEntry !== void 0 && { nextCursor: encodeCursor(lastEntry.timestamp, lastEntry.id) },
927
+ hasNextPage: resolved.cursor !== void 0
928
+ };
929
+ }
930
+ const firstEntry = result.entries[0];
931
+ const prevCursor = resolved.cursor !== void 0 && firstEntry !== void 0 ? encodeCursor(firstEntry.timestamp, firstEntry.id) : void 0;
872
932
  return {
873
933
  entries: result.entries,
874
934
  ...result.nextCursor !== void 0 && { nextCursor: result.nextCursor },
875
- hasNextPage: result.nextCursor !== void 0
935
+ hasNextPage: result.nextCursor !== void 0,
936
+ hasPrevPage: resolved.cursor !== void 0,
937
+ ...prevCursor !== void 0 && { prevCursor }
938
+ };
939
+ }
940
+ async function queryLogsAround(anchorId, filters) {
941
+ const queryFn = requireQueryLogs();
942
+ const getLogFn = requireGetLogById();
943
+ const anchor = await getLogFn(anchorId);
944
+ if (anchor === null) {
945
+ return { entries: [], hasNextPage: false, hasPrevPage: false };
946
+ }
947
+ const resolved = filters ?? {};
948
+ const limit = Math.min(resolved.limit ?? effectiveLimit, effectiveLimit);
949
+ const remaining = Math.max(limit - 1, 0);
950
+ const olderCount = Math.ceil(remaining / 2);
951
+ const newerCount = Math.floor(remaining / 2);
952
+ const anchorCursor = encodeCursor(anchor.timestamp, anchor.id);
953
+ const { direction: _dir, ...baseFilters } = resolved;
954
+ const olderSpec = buildQuerySpec({ ...baseFilters, cursor: anchorCursor, limit: olderCount }, effectiveLimit);
955
+ olderSpec.sortOrder = "desc";
956
+ const newerSpec = buildQuerySpec({ ...baseFilters, cursor: anchorCursor, limit: newerCount }, effectiveLimit);
957
+ newerSpec.sortOrder = "asc";
958
+ const [olderResult, newerResult] = await Promise.all([
959
+ queryFn(olderSpec),
960
+ queryFn(newerSpec)
961
+ ]);
962
+ const newerEntries = [...newerResult.entries].reverse();
963
+ const entries = [...newerEntries, anchor, ...olderResult.entries];
964
+ const hasNextPage = olderResult.nextCursor !== void 0;
965
+ const hasPrevPage = newerResult.nextCursor !== void 0;
966
+ const oldest = entries[entries.length - 1];
967
+ const newest = entries[0];
968
+ return {
969
+ entries,
970
+ hasNextPage,
971
+ hasPrevPage,
972
+ ...hasNextPage && oldest !== void 0 && { nextCursor: encodeCursor(oldest.timestamp, oldest.id) },
973
+ ...hasPrevPage && newest !== void 0 && { prevCursor: encodeCursor(newest.timestamp, newest.id) }
876
974
  };
877
975
  }
878
976
  async function getLog(id) {
@@ -917,7 +1015,7 @@ function createAuditApi(adapter, registry, maxQueryLimit) {
917
1015
  }
918
1016
  async function exportLogs(filters, format) {
919
1017
  const queryFn = requireQueryLogs();
920
- const exportFormat = format ?? "json";
1018
+ const exportFormat = format ?? "csv";
921
1019
  const spec = buildQuerySpec(filters ?? {}, effectiveLimit);
922
1020
  const queryBuilder = new AuditQueryBuilder(
923
1021
  (s) => queryFn(s),
@@ -943,7 +1041,7 @@ function createAuditApi(adapter, registry, maxQueryLimit) {
943
1041
  const purgeFn = requirePurgeLogs();
944
1042
  return purgeFn(options);
945
1043
  }
946
- return { queryLogs, getLog, getStats, getEnrichments, exportLogs, purgeLogs };
1044
+ return { queryLogs, queryLogsAround, getLog, getStats, getEnrichments, exportLogs, purgeLogs };
947
1045
  }
948
1046
 
949
1047
  // ../../shared/console-utils/src/index.ts
@@ -977,7 +1075,7 @@ function exceedsMaxLength(value) {
977
1075
  return value !== void 0 && value.length > MAX_PARAM_LENGTH;
978
1076
  }
979
1077
  function hasLongQueryParam(query) {
980
- return exceedsMaxLength(query.tableName) || exceedsMaxLength(query.actorId) || exceedsMaxLength(query.cursor) || exceedsMaxLength(query.operation) || exceedsMaxLength(query.severity) || exceedsMaxLength(query.compliance) || exceedsMaxLength(query.search);
1078
+ return exceedsMaxLength(query.tableName) || exceedsMaxLength(query.recordId) || exceedsMaxLength(query.actorId) || exceedsMaxLength(query.cursor) || exceedsMaxLength(query.operation) || exceedsMaxLength(query.severity) || exceedsMaxLength(query.compliance) || exceedsMaxLength(query.search) || exceedsMaxLength(query.around) || exceedsMaxLength(query.direction) || exceedsMaxLength(query.order);
981
1079
  }
982
1080
  function parseConsoleQueryFilters(query) {
983
1081
  const filters = {};
@@ -991,6 +1089,12 @@ function parseConsoleQueryFilters(query) {
991
1089
  if (query.tableName !== void 0) {
992
1090
  filters.tableName = query.tableName;
993
1091
  }
1092
+ if (query.recordId !== void 0) {
1093
+ if (query.tableName === void 0) {
1094
+ return { error: "'recordId' requires 'tableName'" };
1095
+ }
1096
+ filters.recordId = query.recordId;
1097
+ }
994
1098
  if (query.operation !== void 0) {
995
1099
  filters.operation = query.operation;
996
1100
  }
@@ -1009,6 +1113,18 @@ function parseConsoleQueryFilters(query) {
1009
1113
  if (query.cursor !== void 0) {
1010
1114
  filters.cursor = query.cursor;
1011
1115
  }
1116
+ if (query.direction !== void 0) {
1117
+ if (query.direction !== "after" && query.direction !== "before") {
1118
+ return { error: "Invalid 'direction': must be 'after' or 'before'" };
1119
+ }
1120
+ filters.direction = query.direction;
1121
+ }
1122
+ if (query.order !== void 0) {
1123
+ if (query.order !== "asc" && query.order !== "desc") {
1124
+ return { error: "Invalid 'order': must be 'asc' or 'desc'" };
1125
+ }
1126
+ filters.order = query.order;
1127
+ }
1012
1128
  if (query.since !== void 0) {
1013
1129
  const since = parseIsoDate(query.since);
1014
1130
  if (since === void 0) {
@@ -1023,6 +1139,9 @@ function parseConsoleQueryFilters(query) {
1023
1139
  }
1024
1140
  filters.until = until;
1025
1141
  }
1142
+ if (filters.since !== void 0 && filters.until !== void 0 && filters.since >= filters.until) {
1143
+ return { error: "'since' must be before 'until'" };
1144
+ }
1026
1145
  return { filters };
1027
1146
  }
1028
1147
  function serializeLog(log) {
@@ -1039,14 +1158,22 @@ function createAuditConsoleEndpoints(api) {
1039
1158
  requiredPermission: "read",
1040
1159
  async handler(request) {
1041
1160
  try {
1042
- if (hasLongQueryParam(request.query)) {
1161
+ const query = request.query;
1162
+ if (hasLongQueryParam(query)) {
1043
1163
  return { status: 400, body: { error: "Query parameter exceeds maximum length" } };
1044
1164
  }
1045
- const parsed = parseConsoleQueryFilters(request.query);
1165
+ const parsed = parseConsoleQueryFilters(query);
1046
1166
  if ("error" in parsed) {
1047
1167
  return { status: 400, body: { error: parsed.error } };
1048
1168
  }
1049
- const result = await api.queryLogs(parsed.filters);
1169
+ if (parsed.filters.order !== void 0 && parsed.filters.direction !== void 0) {
1170
+ return { status: 400, body: { error: "'order' cannot be combined with 'direction'" } };
1171
+ }
1172
+ const around = query.around;
1173
+ if (around !== void 0 && (parsed.filters.cursor !== void 0 || parsed.filters.direction !== void 0 || parsed.filters.order !== void 0)) {
1174
+ return { status: 400, body: { error: "'around' cannot be combined with 'cursor', 'direction', or 'order'" } };
1175
+ }
1176
+ const result = around !== void 0 ? await api.queryLogsAround(around, parsed.filters) : await api.queryLogs(parsed.filters);
1050
1177
  const body = {
1051
1178
  entries: result.entries.map(serializeLog),
1052
1179
  hasNextPage: result.hasNextPage
@@ -1054,6 +1181,12 @@ function createAuditConsoleEndpoints(api) {
1054
1181
  if (result.nextCursor !== void 0) {
1055
1182
  body.nextCursor = result.nextCursor;
1056
1183
  }
1184
+ if (result.prevCursor !== void 0) {
1185
+ body.prevCursor = result.prevCursor;
1186
+ }
1187
+ if (result.hasPrevPage) {
1188
+ body.hasPrevPage = result.hasPrevPage;
1189
+ }
1057
1190
  return { status: 200, body };
1058
1191
  } catch {
1059
1192
  return { status: 500, body: { error: "Internal server error" } };
@@ -1086,14 +1219,28 @@ function createAuditConsoleEndpoints(api) {
1086
1219
  requiredPermission: "read",
1087
1220
  async handler(request) {
1088
1221
  try {
1222
+ const query = request.query;
1223
+ if (exceedsMaxLength(query.since) || exceedsMaxLength(query.until)) {
1224
+ return { status: 400, body: { error: "Query parameter exceeds maximum length" } };
1225
+ }
1089
1226
  const options = {};
1090
- if (request.query.since !== void 0) {
1091
- const since = parseIsoDate(request.query.since);
1227
+ if (query.since !== void 0) {
1228
+ const since = parseIsoDate(query.since);
1092
1229
  if (since === void 0) {
1093
1230
  return { status: 400, body: { error: "Invalid 'since': must be an ISO-8601 date" } };
1094
1231
  }
1095
1232
  options.since = since;
1096
1233
  }
1234
+ if (query.until !== void 0) {
1235
+ const until = parseIsoDate(query.until);
1236
+ if (until === void 0) {
1237
+ return { status: 400, body: { error: "Invalid 'until': must be an ISO-8601 date" } };
1238
+ }
1239
+ options.until = until;
1240
+ }
1241
+ if (options.since !== void 0 && options.until !== void 0 && options.since >= options.until) {
1242
+ return { status: 400, body: { error: "'since' must be before 'until'" } };
1243
+ }
1097
1244
  const stats = await api.getStats(options);
1098
1245
  return { status: 200, body: stats };
1099
1246
  } catch {
@@ -1120,20 +1267,33 @@ function createAuditConsoleEndpoints(api) {
1120
1267
  requiredPermission: "read",
1121
1268
  async handler(request) {
1122
1269
  try {
1123
- const format = request.query.format;
1270
+ const query = request.query;
1271
+ const format = query.format;
1124
1272
  if (format !== void 0 && format !== "csv" && format !== "json") {
1125
1273
  return { status: 400, body: { error: "Invalid format. Must be 'csv' or 'json'" } };
1126
1274
  }
1127
- if (hasLongQueryParam(request.query)) {
1275
+ if (hasLongQueryParam(query)) {
1128
1276
  return { status: 400, body: { error: "Query parameter exceeds maximum length" } };
1129
1277
  }
1130
- const parsed = parseConsoleQueryFilters(request.query);
1278
+ const parsed = parseConsoleQueryFilters(query);
1131
1279
  if ("error" in parsed) {
1132
1280
  return { status: 400, body: { error: parsed.error } };
1133
1281
  }
1134
- const exportFormat = format;
1135
- const data = await api.exportLogs(parsed.filters, exportFormat);
1136
- return { status: 200, body: data };
1282
+ const effectiveFormat = format ?? "csv";
1283
+ const data = await api.exportLogs(parsed.filters, effectiveFormat);
1284
+ const contentType = effectiveFormat === "csv" ? "text/csv; charset=utf-8" : "application/json; charset=utf-8";
1285
+ const ext = effectiveFormat === "csv" ? "csv" : "json";
1286
+ const dateStamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1287
+ const filename = `audit-export-${dateStamp}.${ext}`;
1288
+ return {
1289
+ status: 200,
1290
+ body: data,
1291
+ headers: {
1292
+ "content-type": contentType,
1293
+ "content-disposition": `attachment; filename="${filename}"`,
1294
+ "cache-control": "no-cache"
1295
+ }
1296
+ };
1137
1297
  } catch {
1138
1298
  return { status: 500, body: { error: "Internal server error" } };
1139
1299
  }
@@ -1522,38 +1682,6 @@ var AUDIT_LOG_SCHEMA = {
1522
1682
  }
1523
1683
  };
1524
1684
 
1525
- // src/cursor.ts
1526
- var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1527
- function encodeCursor(timestamp, id) {
1528
- const payload = JSON.stringify({ t: timestamp.toISOString(), i: id });
1529
- return btoa(payload);
1530
- }
1531
- function decodeCursor(cursor) {
1532
- let parsed;
1533
- try {
1534
- parsed = JSON.parse(atob(cursor));
1535
- } catch {
1536
- throw new Error("Invalid cursor: failed to decode");
1537
- }
1538
- if (typeof parsed !== "object" || parsed === null || !("t" in parsed) || !("i" in parsed)) {
1539
- throw new Error("Invalid cursor: missing required fields");
1540
- }
1541
- const record = parsed;
1542
- const t = record["t"];
1543
- const i = record["i"];
1544
- if (typeof t !== "string" || typeof i !== "string") {
1545
- throw new Error("Invalid cursor: fields must be strings");
1546
- }
1547
- const timestamp = new Date(t);
1548
- if (isNaN(timestamp.getTime())) {
1549
- throw new Error("Invalid cursor: invalid timestamp");
1550
- }
1551
- if (!UUID_PATTERN.test(i)) {
1552
- throw new Error("Invalid cursor: id must be a valid UUID");
1553
- }
1554
- return { timestamp, id: i };
1555
- }
1556
-
1557
1685
  // src/escape.ts
1558
1686
  function escapeLikePattern(input) {
1559
1687
  return input.replace(/[%_\\]/g, "\\$&");