@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 +184 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -8
- package/dist/index.d.ts +20 -8
- package/dist/index.js +184 -56
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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 =
|
|
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
|
|
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 ?? "
|
|
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
|
-
|
|
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(
|
|
1165
|
+
const parsed = parseConsoleQueryFilters(query);
|
|
1046
1166
|
if ("error" in parsed) {
|
|
1047
1167
|
return { status: 400, body: { error: parsed.error } };
|
|
1048
1168
|
}
|
|
1049
|
-
|
|
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 (
|
|
1091
|
-
const since = parseIsoDate(
|
|
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
|
|
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(
|
|
1275
|
+
if (hasLongQueryParam(query)) {
|
|
1128
1276
|
return { status: 400, body: { error: "Query parameter exceeds maximum length" } };
|
|
1129
1277
|
}
|
|
1130
|
-
const parsed = parseConsoleQueryFilters(
|
|
1278
|
+
const parsed = parseConsoleQueryFilters(query);
|
|
1131
1279
|
if ("error" in parsed) {
|
|
1132
1280
|
return { status: 400, body: { error: parsed.error } };
|
|
1133
1281
|
}
|
|
1134
|
-
const
|
|
1135
|
-
const data = await api.exportLogs(parsed.filters,
|
|
1136
|
-
|
|
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, "\\$&");
|