@usebetterdev/audit-core 0.7.0 → 0.8.1

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.d.cts CHANGED
@@ -53,6 +53,8 @@ interface AuditQueryResult {
53
53
  entries: AuditLog[];
54
54
  /** If present, more results are available. Pass to `.after()` for the next page. */
55
55
  nextCursor?: string;
56
+ /** If present, more results are available in the reverse direction. */
57
+ prevCursor?: string;
56
58
  }
57
59
 
58
60
  /** Callback that executes a query spec against the adapter. */
@@ -170,12 +172,6 @@ interface AuditContext {
170
172
  /** Arbitrary key-value metadata. Redaction rules do not apply to metadata. */
171
173
  metadata?: Record<string, unknown>;
172
174
  }
173
- /**
174
- * Database adapter provided by ORM packages (e.g. drizzle, prisma).
175
- * Passed as the `database` field in `BetterAuditConfig`.
176
- *
177
- * `writeLog` is required. `queryLogs` is optional — only needed when `query()` is used.
178
- */
179
175
  /** Aggregated statistics returned by `AuditApi.getStats()`. */
180
176
  interface AuditStats {
181
177
  totalLogs: number;
@@ -195,12 +191,19 @@ interface AuditStats {
195
191
  operationBreakdown: Record<string, number>;
196
192
  severityBreakdown: Record<string, number>;
197
193
  }
194
+ /**
195
+ * Database adapter provided by ORM packages (e.g. drizzle, prisma).
196
+ * Passed as the `database` field in `BetterAuditConfig`.
197
+ *
198
+ * `writeLog` is required. `queryLogs` is optional — only needed when `query()` is used.
199
+ */
198
200
  interface AuditDatabaseAdapter {
199
201
  writeLog(log: AuditLog): Promise<void>;
200
202
  queryLogs?(spec: AuditQuerySpec): Promise<AuditQueryResult>;
201
203
  getLogById?(id: string): Promise<AuditLog | null>;
202
204
  getStats?(options?: {
203
205
  since?: Date;
206
+ until?: Date;
204
207
  }): Promise<AuditStats>;
205
208
  purgeLogs?(options: {
206
209
  before: Date;
@@ -414,7 +417,7 @@ declare function normalizeInput(operation: AuditOperation, before: Record<string
414
417
  * ORM adapters translate this into their own migration format.
415
418
  * Core never runs SQL — this is a declarative data structure only.
416
419
  */
417
- type ColumnType = "uuid" | "timestamptz" | "text" | "jsonb" | "boolean";
420
+ type ColumnType = "uuid" | "timestamptz" | "text" | "jsonb" | "boolean" | "integer";
418
421
  interface ColumnDefinition {
419
422
  type: ColumnType;
420
423
  nullable: boolean;
@@ -596,6 +599,7 @@ declare class EnrichmentRegistry {
596
599
  /** Flat console-friendly query filters. Single-value fields translated to multi-value internal filters. */
597
600
  interface ConsoleQueryFilters {
598
601
  tableName?: string;
602
+ recordId?: string;
599
603
  operation?: string;
600
604
  actorId?: string;
601
605
  severity?: string;
@@ -605,6 +609,10 @@ interface ConsoleQueryFilters {
605
609
  search?: string;
606
610
  limit?: number;
607
611
  cursor?: string;
612
+ /** Pagination direction: "after" fetches older entries (default), "before" fetches newer entries. */
613
+ direction?: "after" | "before";
614
+ /** Sort order: "asc" (oldest first) or "desc" (newest first). Overrides direction-based sort when both present. */
615
+ order?: "asc" | "desc";
608
616
  }
609
617
  /** Serializable summary of an enrichment config (function fields stripped). */
610
618
  interface EnrichmentSummary {
@@ -617,9 +625,10 @@ interface EnrichmentSummary {
617
625
  redact?: string[];
618
626
  include?: string[];
619
627
  }
620
- /** Query result extended with a convenience `hasNextPage` flag. */
628
+ /** Query result extended with convenience pagination flags. */
621
629
  interface ConsoleQueryResult extends AuditQueryResult {
622
630
  hasNextPage: boolean;
631
+ hasPrevPage: boolean;
623
632
  }
624
633
  /**
625
634
  * High-level API consumed by console endpoints.
@@ -630,11 +639,14 @@ interface ConsoleQueryResult extends AuditQueryResult {
630
639
  interface AuditApi {
631
640
  /** Query audit log entries with optional flat filters and cursor-based pagination. */
632
641
  queryLogs(filters?: ConsoleQueryFilters): Promise<ConsoleQueryResult>;
642
+ /** Query audit log entries centered around a specific log ID. */
643
+ queryLogsAround(anchorId: string, filters?: ConsoleQueryFilters): Promise<ConsoleQueryResult>;
633
644
  /** Retrieve a single audit log entry by its ID. Returns `null` when not found. */
634
645
  getLog(id: string): Promise<AuditLog | null>;
635
646
  /** Get aggregated audit statistics. Requires adapter.getStats. */
636
647
  getStats(options?: {
637
648
  since?: Date;
649
+ until?: Date;
638
650
  }): Promise<AuditStats>;
639
651
  /** Get serializable summaries of all registered enrichments. */
640
652
  getEnrichments(): EnrichmentSummary[];
package/dist/index.d.ts CHANGED
@@ -53,6 +53,8 @@ interface AuditQueryResult {
53
53
  entries: AuditLog[];
54
54
  /** If present, more results are available. Pass to `.after()` for the next page. */
55
55
  nextCursor?: string;
56
+ /** If present, more results are available in the reverse direction. */
57
+ prevCursor?: string;
56
58
  }
57
59
 
58
60
  /** Callback that executes a query spec against the adapter. */
@@ -170,12 +172,6 @@ interface AuditContext {
170
172
  /** Arbitrary key-value metadata. Redaction rules do not apply to metadata. */
171
173
  metadata?: Record<string, unknown>;
172
174
  }
173
- /**
174
- * Database adapter provided by ORM packages (e.g. drizzle, prisma).
175
- * Passed as the `database` field in `BetterAuditConfig`.
176
- *
177
- * `writeLog` is required. `queryLogs` is optional — only needed when `query()` is used.
178
- */
179
175
  /** Aggregated statistics returned by `AuditApi.getStats()`. */
180
176
  interface AuditStats {
181
177
  totalLogs: number;
@@ -195,12 +191,19 @@ interface AuditStats {
195
191
  operationBreakdown: Record<string, number>;
196
192
  severityBreakdown: Record<string, number>;
197
193
  }
194
+ /**
195
+ * Database adapter provided by ORM packages (e.g. drizzle, prisma).
196
+ * Passed as the `database` field in `BetterAuditConfig`.
197
+ *
198
+ * `writeLog` is required. `queryLogs` is optional — only needed when `query()` is used.
199
+ */
198
200
  interface AuditDatabaseAdapter {
199
201
  writeLog(log: AuditLog): Promise<void>;
200
202
  queryLogs?(spec: AuditQuerySpec): Promise<AuditQueryResult>;
201
203
  getLogById?(id: string): Promise<AuditLog | null>;
202
204
  getStats?(options?: {
203
205
  since?: Date;
206
+ until?: Date;
204
207
  }): Promise<AuditStats>;
205
208
  purgeLogs?(options: {
206
209
  before: Date;
@@ -414,7 +417,7 @@ declare function normalizeInput(operation: AuditOperation, before: Record<string
414
417
  * ORM adapters translate this into their own migration format.
415
418
  * Core never runs SQL — this is a declarative data structure only.
416
419
  */
417
- type ColumnType = "uuid" | "timestamptz" | "text" | "jsonb" | "boolean";
420
+ type ColumnType = "uuid" | "timestamptz" | "text" | "jsonb" | "boolean" | "integer";
418
421
  interface ColumnDefinition {
419
422
  type: ColumnType;
420
423
  nullable: boolean;
@@ -596,6 +599,7 @@ declare class EnrichmentRegistry {
596
599
  /** Flat console-friendly query filters. Single-value fields translated to multi-value internal filters. */
597
600
  interface ConsoleQueryFilters {
598
601
  tableName?: string;
602
+ recordId?: string;
599
603
  operation?: string;
600
604
  actorId?: string;
601
605
  severity?: string;
@@ -605,6 +609,10 @@ interface ConsoleQueryFilters {
605
609
  search?: string;
606
610
  limit?: number;
607
611
  cursor?: string;
612
+ /** Pagination direction: "after" fetches older entries (default), "before" fetches newer entries. */
613
+ direction?: "after" | "before";
614
+ /** Sort order: "asc" (oldest first) or "desc" (newest first). Overrides direction-based sort when both present. */
615
+ order?: "asc" | "desc";
608
616
  }
609
617
  /** Serializable summary of an enrichment config (function fields stripped). */
610
618
  interface EnrichmentSummary {
@@ -617,9 +625,10 @@ interface EnrichmentSummary {
617
625
  redact?: string[];
618
626
  include?: string[];
619
627
  }
620
- /** Query result extended with a convenience `hasNextPage` flag. */
628
+ /** Query result extended with convenience pagination flags. */
621
629
  interface ConsoleQueryResult extends AuditQueryResult {
622
630
  hasNextPage: boolean;
631
+ hasPrevPage: boolean;
623
632
  }
624
633
  /**
625
634
  * High-level API consumed by console endpoints.
@@ -630,11 +639,14 @@ interface ConsoleQueryResult extends AuditQueryResult {
630
639
  interface AuditApi {
631
640
  /** Query audit log entries with optional flat filters and cursor-based pagination. */
632
641
  queryLogs(filters?: ConsoleQueryFilters): Promise<ConsoleQueryResult>;
642
+ /** Query audit log entries centered around a specific log ID. */
643
+ queryLogsAround(anchorId: string, filters?: ConsoleQueryFilters): Promise<ConsoleQueryResult>;
633
644
  /** Retrieve a single audit log entry by its ID. Returns `null` when not found. */
634
645
  getLog(id: string): Promise<AuditLog | null>;
635
646
  /** Get aggregated audit statistics. Requires adapter.getStats. */
636
647
  getStats(options?: {
637
648
  since?: Date;
649
+ until?: Date;
638
650
  }): Promise<AuditStats>;
639
651
  /** Get serializable summaries of all registered enrichments. */
640
652
  getEnrichments(): EnrichmentSummary[];
package/dist/index.js CHANGED
@@ -725,8 +725,41 @@ async function runExport(executor, options) {
725
725
  return { rowCount };
726
726
  }
727
727
 
728
+ // src/cursor.ts
729
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
730
+ function encodeCursor(timestamp, id) {
731
+ const payload = JSON.stringify({ t: timestamp.toISOString(), i: id });
732
+ return btoa(payload);
733
+ }
734
+ function decodeCursor(cursor) {
735
+ let parsed;
736
+ try {
737
+ parsed = JSON.parse(atob(cursor));
738
+ } catch {
739
+ throw new Error("Invalid cursor: failed to decode");
740
+ }
741
+ if (typeof parsed !== "object" || parsed === null || !("t" in parsed) || !("i" in parsed)) {
742
+ throw new Error("Invalid cursor: missing required fields");
743
+ }
744
+ const record = parsed;
745
+ const t = record["t"];
746
+ const i = record["i"];
747
+ if (typeof t !== "string" || typeof i !== "string") {
748
+ throw new Error("Invalid cursor: fields must be strings");
749
+ }
750
+ const timestamp = new Date(t);
751
+ if (isNaN(timestamp.getTime())) {
752
+ throw new Error("Invalid cursor: invalid timestamp");
753
+ }
754
+ if (!UUID_PATTERN.test(i)) {
755
+ throw new Error("Invalid cursor: id must be a valid UUID");
756
+ }
757
+ return { timestamp, id: i };
758
+ }
759
+
728
760
  // src/audit-api.ts
729
- var VALID_SEVERITIES = /* @__PURE__ */ new Set(["low", "medium", "high", "critical"]);
761
+ var SEVERITY_VALUES = ["low", "medium", "high", "critical"];
762
+ var VALID_SEVERITIES = new Set(SEVERITY_VALUES);
730
763
  var VALID_OPERATIONS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE"]);
731
764
  function toTimeFilter(date) {
732
765
  return { date };
@@ -738,18 +771,23 @@ function buildQuerySpec(filters, effectiveLimit) {
738
771
  limit
739
772
  };
740
773
  if (filters.tableName !== void 0) {
741
- spec.filters.resource = { tableName: filters.tableName };
774
+ spec.filters.resource = filters.recordId !== void 0 ? { tableName: filters.tableName, recordId: filters.recordId } : { tableName: filters.tableName };
742
775
  }
743
776
  if (filters.actorId !== void 0) {
744
- spec.filters.actorIds = [filters.actorId];
777
+ const raw = filters.actorId.split(",").map((v) => v.trim()).filter((v) => v.length > 0);
778
+ spec.filters.actorIds = [...new Set(raw)];
745
779
  }
746
780
  if (filters.severity !== void 0) {
747
- if (!VALID_SEVERITIES.has(filters.severity)) {
748
- throw new Error(
749
- `Invalid severity "${filters.severity}". Must be one of: low, medium, high, critical`
750
- );
781
+ const raw = filters.severity.split(",").map((v) => v.trim()).filter((v) => v.length > 0);
782
+ const unique = [...new Set(raw)];
783
+ for (const value of unique) {
784
+ if (!VALID_SEVERITIES.has(value)) {
785
+ throw new Error(
786
+ `Invalid severity "${value}". Must be one of: low, medium, high, critical`
787
+ );
788
+ }
751
789
  }
752
- spec.filters.severities = [filters.severity];
790
+ spec.filters.severities = unique.filter((v) => VALID_SEVERITIES.has(v));
753
791
  }
754
792
  if (filters.compliance !== void 0) {
755
793
  spec.filters.compliance = [filters.compliance];
@@ -775,6 +813,12 @@ function buildQuerySpec(filters, effectiveLimit) {
775
813
  if (filters.cursor !== void 0) {
776
814
  spec.cursor = filters.cursor;
777
815
  }
816
+ if (filters.direction === "before") {
817
+ spec.sortOrder = "asc";
818
+ }
819
+ if (filters.order !== void 0) {
820
+ spec.sortOrder = filters.order;
821
+ }
778
822
  return spec;
779
823
  }
780
824
  function createAuditApi(adapter, registry, maxQueryLimit) {
@@ -813,12 +857,66 @@ function createAuditApi(adapter, registry, maxQueryLimit) {
813
857
  }
814
858
  async function queryLogs(filters) {
815
859
  const queryFn = requireQueryLogs();
816
- const spec = buildQuerySpec(filters ?? {}, effectiveLimit);
860
+ const resolved = filters ?? {};
861
+ const spec = buildQuerySpec(resolved, effectiveLimit);
817
862
  const result = await queryFn(spec);
863
+ if (resolved.direction === "before") {
864
+ const entries = [...result.entries].reverse();
865
+ const lastEntry = entries[entries.length - 1];
866
+ return {
867
+ entries,
868
+ // Adapter's "next" in ASC = newer entries = our prev
869
+ ...result.nextCursor !== void 0 && { prevCursor: result.nextCursor },
870
+ hasPrevPage: result.nextCursor !== void 0,
871
+ // Since we started from a cursor, there are older entries behind it
872
+ ...resolved.cursor !== void 0 && lastEntry !== void 0 && { nextCursor: encodeCursor(lastEntry.timestamp, lastEntry.id) },
873
+ hasNextPage: resolved.cursor !== void 0
874
+ };
875
+ }
876
+ const firstEntry = result.entries[0];
877
+ const prevCursor = resolved.cursor !== void 0 && firstEntry !== void 0 ? encodeCursor(firstEntry.timestamp, firstEntry.id) : void 0;
818
878
  return {
819
879
  entries: result.entries,
820
880
  ...result.nextCursor !== void 0 && { nextCursor: result.nextCursor },
821
- hasNextPage: result.nextCursor !== void 0
881
+ hasNextPage: result.nextCursor !== void 0,
882
+ hasPrevPage: resolved.cursor !== void 0,
883
+ ...prevCursor !== void 0 && { prevCursor }
884
+ };
885
+ }
886
+ async function queryLogsAround(anchorId, filters) {
887
+ const queryFn = requireQueryLogs();
888
+ const getLogFn = requireGetLogById();
889
+ const anchor = await getLogFn(anchorId);
890
+ if (anchor === null) {
891
+ return { entries: [], hasNextPage: false, hasPrevPage: false };
892
+ }
893
+ const resolved = filters ?? {};
894
+ const limit = Math.min(resolved.limit ?? effectiveLimit, effectiveLimit);
895
+ const remaining = Math.max(limit - 1, 0);
896
+ const olderCount = Math.ceil(remaining / 2);
897
+ const newerCount = Math.floor(remaining / 2);
898
+ const anchorCursor = encodeCursor(anchor.timestamp, anchor.id);
899
+ const { direction: _dir, ...baseFilters } = resolved;
900
+ const olderSpec = buildQuerySpec({ ...baseFilters, cursor: anchorCursor, limit: olderCount }, effectiveLimit);
901
+ olderSpec.sortOrder = "desc";
902
+ const newerSpec = buildQuerySpec({ ...baseFilters, cursor: anchorCursor, limit: newerCount }, effectiveLimit);
903
+ newerSpec.sortOrder = "asc";
904
+ const [olderResult, newerResult] = await Promise.all([
905
+ queryFn(olderSpec),
906
+ queryFn(newerSpec)
907
+ ]);
908
+ const newerEntries = [...newerResult.entries].reverse();
909
+ const entries = [...newerEntries, anchor, ...olderResult.entries];
910
+ const hasNextPage = olderResult.nextCursor !== void 0;
911
+ const hasPrevPage = newerResult.nextCursor !== void 0;
912
+ const oldest = entries[entries.length - 1];
913
+ const newest = entries[0];
914
+ return {
915
+ entries,
916
+ hasNextPage,
917
+ hasPrevPage,
918
+ ...hasNextPage && oldest !== void 0 && { nextCursor: encodeCursor(oldest.timestamp, oldest.id) },
919
+ ...hasPrevPage && newest !== void 0 && { prevCursor: encodeCursor(newest.timestamp, newest.id) }
822
920
  };
823
921
  }
824
922
  async function getLog(id) {
@@ -863,7 +961,7 @@ function createAuditApi(adapter, registry, maxQueryLimit) {
863
961
  }
864
962
  async function exportLogs(filters, format) {
865
963
  const queryFn = requireQueryLogs();
866
- const exportFormat = format ?? "json";
964
+ const exportFormat = format ?? "csv";
867
965
  const spec = buildQuerySpec(filters ?? {}, effectiveLimit);
868
966
  const queryBuilder = new AuditQueryBuilder(
869
967
  (s) => queryFn(s),
@@ -889,7 +987,7 @@ function createAuditApi(adapter, registry, maxQueryLimit) {
889
987
  const purgeFn = requirePurgeLogs();
890
988
  return purgeFn(options);
891
989
  }
892
- return { queryLogs, getLog, getStats, getEnrichments, exportLogs, purgeLogs };
990
+ return { queryLogs, queryLogsAround, getLog, getStats, getEnrichments, exportLogs, purgeLogs };
893
991
  }
894
992
 
895
993
  // ../../shared/console-utils/src/index.ts
@@ -923,7 +1021,7 @@ function exceedsMaxLength(value) {
923
1021
  return value !== void 0 && value.length > MAX_PARAM_LENGTH;
924
1022
  }
925
1023
  function hasLongQueryParam(query) {
926
- return exceedsMaxLength(query.tableName) || exceedsMaxLength(query.actorId) || exceedsMaxLength(query.cursor) || exceedsMaxLength(query.operation) || exceedsMaxLength(query.severity) || exceedsMaxLength(query.compliance) || exceedsMaxLength(query.search);
1024
+ 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);
927
1025
  }
928
1026
  function parseConsoleQueryFilters(query) {
929
1027
  const filters = {};
@@ -937,6 +1035,12 @@ function parseConsoleQueryFilters(query) {
937
1035
  if (query.tableName !== void 0) {
938
1036
  filters.tableName = query.tableName;
939
1037
  }
1038
+ if (query.recordId !== void 0) {
1039
+ if (query.tableName === void 0) {
1040
+ return { error: "'recordId' requires 'tableName'" };
1041
+ }
1042
+ filters.recordId = query.recordId;
1043
+ }
940
1044
  if (query.operation !== void 0) {
941
1045
  filters.operation = query.operation;
942
1046
  }
@@ -955,6 +1059,18 @@ function parseConsoleQueryFilters(query) {
955
1059
  if (query.cursor !== void 0) {
956
1060
  filters.cursor = query.cursor;
957
1061
  }
1062
+ if (query.direction !== void 0) {
1063
+ if (query.direction !== "after" && query.direction !== "before") {
1064
+ return { error: "Invalid 'direction': must be 'after' or 'before'" };
1065
+ }
1066
+ filters.direction = query.direction;
1067
+ }
1068
+ if (query.order !== void 0) {
1069
+ if (query.order !== "asc" && query.order !== "desc") {
1070
+ return { error: "Invalid 'order': must be 'asc' or 'desc'" };
1071
+ }
1072
+ filters.order = query.order;
1073
+ }
958
1074
  if (query.since !== void 0) {
959
1075
  const since = parseIsoDate(query.since);
960
1076
  if (since === void 0) {
@@ -969,6 +1085,9 @@ function parseConsoleQueryFilters(query) {
969
1085
  }
970
1086
  filters.until = until;
971
1087
  }
1088
+ if (filters.since !== void 0 && filters.until !== void 0 && filters.since >= filters.until) {
1089
+ return { error: "'since' must be before 'until'" };
1090
+ }
972
1091
  return { filters };
973
1092
  }
974
1093
  function serializeLog(log) {
@@ -985,14 +1104,22 @@ function createAuditConsoleEndpoints(api) {
985
1104
  requiredPermission: "read",
986
1105
  async handler(request) {
987
1106
  try {
988
- if (hasLongQueryParam(request.query)) {
1107
+ const query = request.query;
1108
+ if (hasLongQueryParam(query)) {
989
1109
  return { status: 400, body: { error: "Query parameter exceeds maximum length" } };
990
1110
  }
991
- const parsed = parseConsoleQueryFilters(request.query);
1111
+ const parsed = parseConsoleQueryFilters(query);
992
1112
  if ("error" in parsed) {
993
1113
  return { status: 400, body: { error: parsed.error } };
994
1114
  }
995
- const result = await api.queryLogs(parsed.filters);
1115
+ if (parsed.filters.order !== void 0 && parsed.filters.direction !== void 0) {
1116
+ return { status: 400, body: { error: "'order' cannot be combined with 'direction'" } };
1117
+ }
1118
+ const around = query.around;
1119
+ if (around !== void 0 && (parsed.filters.cursor !== void 0 || parsed.filters.direction !== void 0 || parsed.filters.order !== void 0)) {
1120
+ return { status: 400, body: { error: "'around' cannot be combined with 'cursor', 'direction', or 'order'" } };
1121
+ }
1122
+ const result = around !== void 0 ? await api.queryLogsAround(around, parsed.filters) : await api.queryLogs(parsed.filters);
996
1123
  const body = {
997
1124
  entries: result.entries.map(serializeLog),
998
1125
  hasNextPage: result.hasNextPage
@@ -1000,6 +1127,12 @@ function createAuditConsoleEndpoints(api) {
1000
1127
  if (result.nextCursor !== void 0) {
1001
1128
  body.nextCursor = result.nextCursor;
1002
1129
  }
1130
+ if (result.prevCursor !== void 0) {
1131
+ body.prevCursor = result.prevCursor;
1132
+ }
1133
+ if (result.hasPrevPage) {
1134
+ body.hasPrevPage = result.hasPrevPage;
1135
+ }
1003
1136
  return { status: 200, body };
1004
1137
  } catch {
1005
1138
  return { status: 500, body: { error: "Internal server error" } };
@@ -1032,14 +1165,28 @@ function createAuditConsoleEndpoints(api) {
1032
1165
  requiredPermission: "read",
1033
1166
  async handler(request) {
1034
1167
  try {
1168
+ const query = request.query;
1169
+ if (exceedsMaxLength(query.since) || exceedsMaxLength(query.until)) {
1170
+ return { status: 400, body: { error: "Query parameter exceeds maximum length" } };
1171
+ }
1035
1172
  const options = {};
1036
- if (request.query.since !== void 0) {
1037
- const since = parseIsoDate(request.query.since);
1173
+ if (query.since !== void 0) {
1174
+ const since = parseIsoDate(query.since);
1038
1175
  if (since === void 0) {
1039
1176
  return { status: 400, body: { error: "Invalid 'since': must be an ISO-8601 date" } };
1040
1177
  }
1041
1178
  options.since = since;
1042
1179
  }
1180
+ if (query.until !== void 0) {
1181
+ const until = parseIsoDate(query.until);
1182
+ if (until === void 0) {
1183
+ return { status: 400, body: { error: "Invalid 'until': must be an ISO-8601 date" } };
1184
+ }
1185
+ options.until = until;
1186
+ }
1187
+ if (options.since !== void 0 && options.until !== void 0 && options.since >= options.until) {
1188
+ return { status: 400, body: { error: "'since' must be before 'until'" } };
1189
+ }
1043
1190
  const stats = await api.getStats(options);
1044
1191
  return { status: 200, body: stats };
1045
1192
  } catch {
@@ -1066,20 +1213,33 @@ function createAuditConsoleEndpoints(api) {
1066
1213
  requiredPermission: "read",
1067
1214
  async handler(request) {
1068
1215
  try {
1069
- const format = request.query.format;
1216
+ const query = request.query;
1217
+ const format = query.format;
1070
1218
  if (format !== void 0 && format !== "csv" && format !== "json") {
1071
1219
  return { status: 400, body: { error: "Invalid format. Must be 'csv' or 'json'" } };
1072
1220
  }
1073
- if (hasLongQueryParam(request.query)) {
1221
+ if (hasLongQueryParam(query)) {
1074
1222
  return { status: 400, body: { error: "Query parameter exceeds maximum length" } };
1075
1223
  }
1076
- const parsed = parseConsoleQueryFilters(request.query);
1224
+ const parsed = parseConsoleQueryFilters(query);
1077
1225
  if ("error" in parsed) {
1078
1226
  return { status: 400, body: { error: parsed.error } };
1079
1227
  }
1080
- const exportFormat = format;
1081
- const data = await api.exportLogs(parsed.filters, exportFormat);
1082
- return { status: 200, body: data };
1228
+ const effectiveFormat = format ?? "csv";
1229
+ const data = await api.exportLogs(parsed.filters, effectiveFormat);
1230
+ const contentType = effectiveFormat === "csv" ? "text/csv; charset=utf-8" : "application/json; charset=utf-8";
1231
+ const ext = effectiveFormat === "csv" ? "csv" : "json";
1232
+ const dateStamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1233
+ const filename = `audit-export-${dateStamp}.${ext}`;
1234
+ return {
1235
+ status: 200,
1236
+ body: data,
1237
+ headers: {
1238
+ "content-type": contentType,
1239
+ "content-disposition": `attachment; filename="${filename}"`,
1240
+ "cache-control": "no-cache"
1241
+ }
1242
+ };
1083
1243
  } catch {
1084
1244
  return { status: 500, body: { error: "Internal server error" } };
1085
1245
  }
@@ -1468,38 +1628,6 @@ var AUDIT_LOG_SCHEMA = {
1468
1628
  }
1469
1629
  };
1470
1630
 
1471
- // src/cursor.ts
1472
- var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1473
- function encodeCursor(timestamp, id) {
1474
- const payload = JSON.stringify({ t: timestamp.toISOString(), i: id });
1475
- return btoa(payload);
1476
- }
1477
- function decodeCursor(cursor) {
1478
- let parsed;
1479
- try {
1480
- parsed = JSON.parse(atob(cursor));
1481
- } catch {
1482
- throw new Error("Invalid cursor: failed to decode");
1483
- }
1484
- if (typeof parsed !== "object" || parsed === null || !("t" in parsed) || !("i" in parsed)) {
1485
- throw new Error("Invalid cursor: missing required fields");
1486
- }
1487
- const record = parsed;
1488
- const t = record["t"];
1489
- const i = record["i"];
1490
- if (typeof t !== "string" || typeof i !== "string") {
1491
- throw new Error("Invalid cursor: fields must be strings");
1492
- }
1493
- const timestamp = new Date(t);
1494
- if (isNaN(timestamp.getTime())) {
1495
- throw new Error("Invalid cursor: invalid timestamp");
1496
- }
1497
- if (!UUID_PATTERN.test(i)) {
1498
- throw new Error("Invalid cursor: id must be a valid UUID");
1499
- }
1500
- return { timestamp, id: i };
1501
- }
1502
-
1503
1631
  // src/escape.ts
1504
1632
  function escapeLikePattern(input) {
1505
1633
  return input.replace(/[%_\\]/g, "\\$&");