@objectstack/rest 13.0.0 → 14.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 +652 -163
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +409 -3
- package/dist/index.d.ts +409 -3
- package/dist/index.js +643 -161
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
package/dist/index.cjs
CHANGED
|
@@ -33,7 +33,14 @@ __export(index_exports, {
|
|
|
33
33
|
RestServer: () => RestServer,
|
|
34
34
|
RouteGroupBuilder: () => RouteGroupBuilder,
|
|
35
35
|
RouteManager: () => RouteManager,
|
|
36
|
-
|
|
36
|
+
buildFieldMetaMap: () => buildFieldMetaMap,
|
|
37
|
+
coerceRow: () => coerceRow,
|
|
38
|
+
createRestApiPlugin: () => createRestApiPlugin,
|
|
39
|
+
isMetaEnvelope: () => isMetaEnvelope,
|
|
40
|
+
parseCsvToRows: () => parseCsvToRows,
|
|
41
|
+
parseXlsxToRows: () => parseXlsxToRows,
|
|
42
|
+
prepareImportRequest: () => prepareImportRequest,
|
|
43
|
+
runImport: () => runImport
|
|
37
44
|
});
|
|
38
45
|
module.exports = __toCommonJS(index_exports);
|
|
39
46
|
|
|
@@ -289,7 +296,14 @@ function buildFieldMetaMap(schema) {
|
|
|
289
296
|
label: typeof f.label === "string" ? f.label : void 0,
|
|
290
297
|
options: Array.isArray(f.options) ? f.options : void 0,
|
|
291
298
|
reference: typeof f.reference === "string" ? f.reference : void 0,
|
|
292
|
-
displayField: typeof f.displayField === "string" ? f.displayField : void 0
|
|
299
|
+
displayField: typeof f.displayField === "string" ? f.displayField : void 0,
|
|
300
|
+
required: f.required === true,
|
|
301
|
+
system: f.system === true,
|
|
302
|
+
readonly: f.readonly === true,
|
|
303
|
+
// Mirror the engine's `applyFieldDefaults` gate (`f.defaultValue == null`
|
|
304
|
+
// ⇒ no default): any non-null default — literal, expression object, or the
|
|
305
|
+
// `current_user` token — counts as satisfying a required field.
|
|
306
|
+
hasDefault: f.defaultValue != null
|
|
293
307
|
});
|
|
294
308
|
}
|
|
295
309
|
return map;
|
|
@@ -327,6 +341,22 @@ function optionLabel(value, options) {
|
|
|
327
341
|
const hit = options.find((o) => o && o.value === value);
|
|
328
342
|
return hit?.label ?? value;
|
|
329
343
|
}
|
|
344
|
+
function toArgb(color) {
|
|
345
|
+
if (typeof color !== "string") return void 0;
|
|
346
|
+
const hex = color.trim().replace(/^#/, "");
|
|
347
|
+
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
|
|
348
|
+
const [r, g, b] = hex;
|
|
349
|
+
return `FF${r}${r}${g}${g}${b}${b}`.toUpperCase();
|
|
350
|
+
}
|
|
351
|
+
if (/^[0-9a-fA-F]{6}$/.test(hex)) return `FF${hex}`.toUpperCase();
|
|
352
|
+
return void 0;
|
|
353
|
+
}
|
|
354
|
+
function cellFontColor(value, meta) {
|
|
355
|
+
if (value === null || value === void 0) return void 0;
|
|
356
|
+
if (!meta || !meta.type || !OPTION_TYPES.has(meta.type) || !meta.options) return void 0;
|
|
357
|
+
const hit = meta.options.find((o) => o && o.value === value);
|
|
358
|
+
return toArgb(hit?.color);
|
|
359
|
+
}
|
|
330
360
|
function displayFromRecord(rec, displayField) {
|
|
331
361
|
if (displayField && rec[displayField] != null) return String(rec[displayField]);
|
|
332
362
|
for (const k of NAME_KEY_FALLBACKS) {
|
|
@@ -543,6 +573,27 @@ async function coerceFieldValue(raw, meta, ctx) {
|
|
|
543
573
|
}
|
|
544
574
|
return { value: trim && typeof raw === "string" ? raw.trim() : raw };
|
|
545
575
|
}
|
|
576
|
+
var REQUIRED_CHECK_SKIP = /* @__PURE__ */ new Set([
|
|
577
|
+
"id",
|
|
578
|
+
"created_at",
|
|
579
|
+
"created_by",
|
|
580
|
+
"updated_at",
|
|
581
|
+
"updated_by"
|
|
582
|
+
]);
|
|
583
|
+
function isBlankValue(v) {
|
|
584
|
+
return v === void 0 || v === null || typeof v === "string" && v.trim() === "";
|
|
585
|
+
}
|
|
586
|
+
function firstMissingRequiredField(data, metaMap) {
|
|
587
|
+
for (const meta of metaMap.values()) {
|
|
588
|
+
if (!meta.required) continue;
|
|
589
|
+
if (meta.system || meta.readonly) continue;
|
|
590
|
+
if (meta.hasDefault) continue;
|
|
591
|
+
if (meta.type === "autonumber") continue;
|
|
592
|
+
if (REQUIRED_CHECK_SKIP.has(meta.name)) continue;
|
|
593
|
+
if (isBlankValue(data[meta.name])) return meta.name;
|
|
594
|
+
}
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
546
597
|
async function coerceRow(rawRow, metaMap, ctx) {
|
|
547
598
|
const data = {};
|
|
548
599
|
const errors = [];
|
|
@@ -741,7 +792,11 @@ function runImport(opts) {
|
|
|
741
792
|
if (!handled) {
|
|
742
793
|
const willUpdate = existing && typeof existing === "object";
|
|
743
794
|
const willCreate = !willUpdate && (writeMode === "insert" || writeMode === "upsert");
|
|
744
|
-
|
|
795
|
+
const requiredMiss = willCreate && !runAutomations ? firstMissingRequiredField(data, metaMap) : null;
|
|
796
|
+
if (requiredMiss) {
|
|
797
|
+
errCount++;
|
|
798
|
+
results[i] = { row: rowNo, ok: false, action: "failed", field: requiredMiss, code: "required", error: `${requiredMiss} is required` };
|
|
799
|
+
} else if (!willUpdate && !willCreate) {
|
|
745
800
|
skipped++;
|
|
746
801
|
results[i] = { row: rowNo, ok: true, action: "skipped", code: "NO_MATCH" };
|
|
747
802
|
} else if (dryRun) {
|
|
@@ -913,155 +968,10 @@ function applyMappingToRows(rows, artifact) {
|
|
|
913
968
|
return { ok: true, rows: out };
|
|
914
969
|
}
|
|
915
970
|
|
|
916
|
-
// src/
|
|
917
|
-
var import_meta = {};
|
|
918
|
-
var logError = (...args) => globalThis.console?.error(...args);
|
|
919
|
-
var TRANSLATABLE_META_TYPES = /* @__PURE__ */ new Set(["view", "action", "object", "app", "dashboard"]);
|
|
971
|
+
// src/import-prepare.ts
|
|
920
972
|
function isMetaEnvelope(value) {
|
|
921
973
|
return !!value && typeof value === "object" && typeof value.type === "string" && typeof value.name === "string" && value.item != null && typeof value.item === "object" && !Array.isArray(value.item);
|
|
922
974
|
}
|
|
923
|
-
function mapDataError(error, object) {
|
|
924
|
-
if (error?.code === "DELETE_RESTRICTED") {
|
|
925
|
-
return {
|
|
926
|
-
status: 409,
|
|
927
|
-
body: {
|
|
928
|
-
error: error?.message ?? "Cannot delete: dependent records exist",
|
|
929
|
-
code: "DELETE_RESTRICTED",
|
|
930
|
-
...error?.dependentObject ? { dependentObject: error.dependentObject } : {},
|
|
931
|
-
...typeof error?.dependentCount === "number" ? { dependentCount: error.dependentCount } : {},
|
|
932
|
-
...object ? { object } : {}
|
|
933
|
-
}
|
|
934
|
-
};
|
|
935
|
-
}
|
|
936
|
-
if (error?.code === "CONCURRENT_UPDATE" || error?.name === "ConcurrentUpdateError") {
|
|
937
|
-
return {
|
|
938
|
-
status: 409,
|
|
939
|
-
body: {
|
|
940
|
-
error: error?.message ?? "Record was modified by another user",
|
|
941
|
-
code: "CONCURRENT_UPDATE",
|
|
942
|
-
...error?.currentVersion ? { currentVersion: error.currentVersion } : {},
|
|
943
|
-
...error?.currentRecord ? { currentRecord: error.currentRecord } : {},
|
|
944
|
-
...object ? { object } : {}
|
|
945
|
-
}
|
|
946
|
-
};
|
|
947
|
-
}
|
|
948
|
-
if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
|
|
949
|
-
return {
|
|
950
|
-
status: 400,
|
|
951
|
-
body: {
|
|
952
|
-
error: error?.message ?? "Validation failed",
|
|
953
|
-
code: "VALIDATION_FAILED",
|
|
954
|
-
fields: Array.isArray(error?.fields) ? error.fields : [],
|
|
955
|
-
...object ? { object } : {}
|
|
956
|
-
}
|
|
957
|
-
};
|
|
958
|
-
}
|
|
959
|
-
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
|
|
960
|
-
return {
|
|
961
|
-
status: 403,
|
|
962
|
-
body: {
|
|
963
|
-
error: error?.message ?? "Permission denied",
|
|
964
|
-
code: "PERMISSION_DENIED",
|
|
965
|
-
...object ? { object } : {}
|
|
966
|
-
}
|
|
967
|
-
};
|
|
968
|
-
}
|
|
969
|
-
const raw = String(error?.message ?? error ?? "");
|
|
970
|
-
const lower = raw.toLowerCase();
|
|
971
|
-
if (raw.includes("[EnvironmentKernelFactory]") && (lower.includes("missing database_url") || lower.includes("not found"))) {
|
|
972
|
-
const isProvisioning = lower.includes("status='provisioning'") || lower.includes("status='pending'");
|
|
973
|
-
const isFailed = lower.includes("status='failed'");
|
|
974
|
-
return {
|
|
975
|
-
status: isProvisioning ? 503 : isFailed ? 502 : 404,
|
|
976
|
-
body: {
|
|
977
|
-
error: raw,
|
|
978
|
-
code: isProvisioning ? "PROJECT_PROVISIONING" : isFailed ? "PROJECT_PROVISIONING_FAILED" : "PROJECT_NOT_FOUND"
|
|
979
|
-
}
|
|
980
|
-
};
|
|
981
|
-
}
|
|
982
|
-
if (error?.code === "RECORD_NOT_FOUND" || /^Record\s+\S+\s+not found in\s+\S+/i.test(raw)) {
|
|
983
|
-
return {
|
|
984
|
-
status: 404,
|
|
985
|
-
body: {
|
|
986
|
-
error: raw,
|
|
987
|
-
code: "RECORD_NOT_FOUND",
|
|
988
|
-
...object ? { object } : {}
|
|
989
|
-
}
|
|
990
|
-
};
|
|
991
|
-
}
|
|
992
|
-
const unknownColumn = /has no column named\s+["'`]?([a-z0-9_]+)/i.exec(raw) || /no such column:\s*["'`]?([a-z0-9_.]+)/i.exec(raw) || /unknown column\s+["'`]([a-z0-9_]+)["'`]/i.exec(raw) || /column\s+["'`]([a-z0-9_]+)["'`]\s+of relation\s+\S+\s+does not exist/i.exec(raw);
|
|
993
|
-
if (unknownColumn) {
|
|
994
|
-
const field = unknownColumn[1]?.split(".").pop();
|
|
995
|
-
return {
|
|
996
|
-
status: 400,
|
|
997
|
-
body: {
|
|
998
|
-
error: field ? `Unknown field '${field}'${object ? ` on object '${object}'` : ""}` : "Request references a field that does not exist",
|
|
999
|
-
code: "INVALID_FIELD",
|
|
1000
|
-
...field ? { field } : {},
|
|
1001
|
-
...object ? { object } : {}
|
|
1002
|
-
}
|
|
1003
|
-
};
|
|
1004
|
-
}
|
|
1005
|
-
const notNull = /not null constraint failed:\s*\S*?\.([a-z0-9_]+)/i.exec(raw) || /null value in column\s+["'`]([a-z0-9_]+)["'`]/i.exec(raw) || /column\s+["'`]([a-z0-9_]+)["'`]\s+cannot be null/i.exec(raw);
|
|
1006
|
-
if (notNull) {
|
|
1007
|
-
const field = notNull[1];
|
|
1008
|
-
return {
|
|
1009
|
-
status: 400,
|
|
1010
|
-
body: {
|
|
1011
|
-
error: `${field} is required`,
|
|
1012
|
-
code: "VALIDATION_FAILED",
|
|
1013
|
-
fields: [{ field, code: "required", message: `${field} is required` }],
|
|
1014
|
-
hint: `If '${field}' is optional in your object metadata, the database column is still NOT NULL \u2014 the physical schema has drifted from metadata. Run 'os migrate' to reconcile (or reset the dev database).`,
|
|
1015
|
-
...object ? { object } : {}
|
|
1016
|
-
}
|
|
1017
|
-
};
|
|
1018
|
-
}
|
|
1019
|
-
const looksLikeUnknownObject = lower.includes("no such table") || lower.includes("relation") && lower.includes("does not exist") || lower.includes("table not found") || lower.includes("unknown object") || lower.includes("object not found") || lower.includes("no driver available") || object !== void 0 && lower.includes(`'${object.toLowerCase()}'`) && lower.includes("not");
|
|
1020
|
-
if (looksLikeUnknownObject) {
|
|
1021
|
-
return {
|
|
1022
|
-
status: 404,
|
|
1023
|
-
body: {
|
|
1024
|
-
error: object ? `Object '${object}' is not registered` : "Object not found",
|
|
1025
|
-
code: "object_not_found",
|
|
1026
|
-
object
|
|
1027
|
-
}
|
|
1028
|
-
};
|
|
1029
|
-
}
|
|
1030
|
-
const looksLikeSqlLeak = lower.includes("sqlite_") || lower.includes("sqlstate") || lower.startsWith("insert into ") || lower.startsWith("update ") || lower.startsWith("select ") || lower.startsWith("delete from ") || lower.includes("constraint failed") || lower.includes("unique constraint") || lower.includes("foreign key");
|
|
1031
|
-
if (looksLikeSqlLeak) {
|
|
1032
|
-
if (lower.includes("unique constraint") || lower.includes("unique violation")) {
|
|
1033
|
-
return {
|
|
1034
|
-
status: 409,
|
|
1035
|
-
body: {
|
|
1036
|
-
error: "A record with this value already exists",
|
|
1037
|
-
code: "UNIQUE_VIOLATION",
|
|
1038
|
-
...object ? { object } : {}
|
|
1039
|
-
}
|
|
1040
|
-
};
|
|
1041
|
-
}
|
|
1042
|
-
return {
|
|
1043
|
-
status: 500,
|
|
1044
|
-
body: { error: "Internal data error", code: "DATABASE_ERROR" }
|
|
1045
|
-
};
|
|
1046
|
-
}
|
|
1047
|
-
return { status: 400, body: { error: raw || "Bad request" } };
|
|
1048
|
-
}
|
|
1049
|
-
function sendError(res, error, object) {
|
|
1050
|
-
if (typeof error?.status === "number" && error.status >= 400 && error.status < 600) {
|
|
1051
|
-
const safeMsg = typeof error.message === "string" && error.message.length < 500 ? error.message : "Request failed";
|
|
1052
|
-
res.status(error.status).json({
|
|
1053
|
-
error: safeMsg,
|
|
1054
|
-
...error.code ? { code: error.code } : {},
|
|
1055
|
-
...Array.isArray(error.issues) ? { issues: error.issues } : {}
|
|
1056
|
-
});
|
|
1057
|
-
return;
|
|
1058
|
-
}
|
|
1059
|
-
const mapped = mapDataError(error, object);
|
|
1060
|
-
res.status(mapped.status).json(mapped.body);
|
|
1061
|
-
}
|
|
1062
|
-
function isExpectedDataStatus(status) {
|
|
1063
|
-
return status === 403 || status === 404 || status === 409 || status === 502 || status === 503;
|
|
1064
|
-
}
|
|
1065
975
|
function parseCsvToRows(csv, mapping = {}) {
|
|
1066
976
|
const text = csv.replace(/^\uFEFF/, "");
|
|
1067
977
|
const cells = [];
|
|
@@ -1280,6 +1190,163 @@ async function prepareImportRequest(body, opts) {
|
|
|
1280
1190
|
}
|
|
1281
1191
|
};
|
|
1282
1192
|
}
|
|
1193
|
+
|
|
1194
|
+
// src/rest-server.ts
|
|
1195
|
+
var import_meta = {};
|
|
1196
|
+
var logError = (...args) => globalThis.console?.error(...args);
|
|
1197
|
+
var TRANSLATABLE_META_TYPES = /* @__PURE__ */ new Set(["view", "action", "object", "app", "dashboard"]);
|
|
1198
|
+
function mapDataError(error, object) {
|
|
1199
|
+
if (error?.code === "DELETE_RESTRICTED") {
|
|
1200
|
+
return {
|
|
1201
|
+
status: 409,
|
|
1202
|
+
body: {
|
|
1203
|
+
error: error?.message ?? "Cannot delete: dependent records exist",
|
|
1204
|
+
code: "DELETE_RESTRICTED",
|
|
1205
|
+
...error?.dependentObject ? { dependentObject: error.dependentObject } : {},
|
|
1206
|
+
...typeof error?.dependentCount === "number" ? { dependentCount: error.dependentCount } : {},
|
|
1207
|
+
...object ? { object } : {}
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
if (error?.code === "CONCURRENT_UPDATE" || error?.name === "ConcurrentUpdateError") {
|
|
1212
|
+
return {
|
|
1213
|
+
status: 409,
|
|
1214
|
+
body: {
|
|
1215
|
+
error: error?.message ?? "Record was modified by another user",
|
|
1216
|
+
code: "CONCURRENT_UPDATE",
|
|
1217
|
+
...error?.currentVersion ? { currentVersion: error.currentVersion } : {},
|
|
1218
|
+
...error?.currentRecord ? { currentRecord: error.currentRecord } : {},
|
|
1219
|
+
...object ? { object } : {}
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
|
|
1224
|
+
return {
|
|
1225
|
+
status: 400,
|
|
1226
|
+
body: {
|
|
1227
|
+
error: error?.message ?? "Validation failed",
|
|
1228
|
+
code: "VALIDATION_FAILED",
|
|
1229
|
+
fields: Array.isArray(error?.fields) ? error.fields : [],
|
|
1230
|
+
...object ? { object } : {}
|
|
1231
|
+
}
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
if (error?.code === "FEEDS_DISABLED" || error?.code === "FILES_DISABLED") {
|
|
1235
|
+
return {
|
|
1236
|
+
status: 403,
|
|
1237
|
+
body: {
|
|
1238
|
+
error: error?.message ?? "This capability is disabled for the target object",
|
|
1239
|
+
code: error.code,
|
|
1240
|
+
...error?.object || object ? { object: error?.object ?? object } : {}
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
|
|
1245
|
+
return {
|
|
1246
|
+
status: 403,
|
|
1247
|
+
body: {
|
|
1248
|
+
error: error?.message ?? "Permission denied",
|
|
1249
|
+
code: "PERMISSION_DENIED",
|
|
1250
|
+
...object ? { object } : {}
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
const raw = String(error?.message ?? error ?? "");
|
|
1255
|
+
const lower = raw.toLowerCase();
|
|
1256
|
+
if (raw.includes("[EnvironmentKernelFactory]") && (lower.includes("missing database_url") || lower.includes("not found"))) {
|
|
1257
|
+
const isProvisioning = lower.includes("status='provisioning'") || lower.includes("status='pending'");
|
|
1258
|
+
const isFailed = lower.includes("status='failed'");
|
|
1259
|
+
return {
|
|
1260
|
+
status: isProvisioning ? 503 : isFailed ? 502 : 404,
|
|
1261
|
+
body: {
|
|
1262
|
+
error: raw,
|
|
1263
|
+
code: isProvisioning ? "PROJECT_PROVISIONING" : isFailed ? "PROJECT_PROVISIONING_FAILED" : "PROJECT_NOT_FOUND"
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
if (error?.code === "RECORD_NOT_FOUND" || /^Record\s+\S+\s+not found in\s+\S+/i.test(raw)) {
|
|
1268
|
+
return {
|
|
1269
|
+
status: 404,
|
|
1270
|
+
body: {
|
|
1271
|
+
error: raw,
|
|
1272
|
+
code: "RECORD_NOT_FOUND",
|
|
1273
|
+
...object ? { object } : {}
|
|
1274
|
+
}
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
const unknownColumn = /has no column named\s+["'`]?([a-z0-9_]+)/i.exec(raw) || /no such column:\s*["'`]?([a-z0-9_.]+)/i.exec(raw) || /unknown column\s+["'`]([a-z0-9_]+)["'`]/i.exec(raw) || /column\s+["'`]([a-z0-9_]+)["'`]\s+of relation\s+\S+\s+does not exist/i.exec(raw);
|
|
1278
|
+
if (unknownColumn) {
|
|
1279
|
+
const field = unknownColumn[1]?.split(".").pop();
|
|
1280
|
+
return {
|
|
1281
|
+
status: 400,
|
|
1282
|
+
body: {
|
|
1283
|
+
error: field ? `Unknown field '${field}'${object ? ` on object '${object}'` : ""}` : "Request references a field that does not exist",
|
|
1284
|
+
code: "INVALID_FIELD",
|
|
1285
|
+
...field ? { field } : {},
|
|
1286
|
+
...object ? { object } : {}
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
const notNull = /not null constraint failed:\s*\S*?\.([a-z0-9_]+)/i.exec(raw) || /null value in column\s+["'`]([a-z0-9_]+)["'`]/i.exec(raw) || /column\s+["'`]([a-z0-9_]+)["'`]\s+cannot be null/i.exec(raw);
|
|
1291
|
+
if (notNull) {
|
|
1292
|
+
const field = notNull[1];
|
|
1293
|
+
return {
|
|
1294
|
+
status: 400,
|
|
1295
|
+
body: {
|
|
1296
|
+
error: `${field} is required`,
|
|
1297
|
+
code: "VALIDATION_FAILED",
|
|
1298
|
+
fields: [{ field, code: "required", message: `${field} is required` }],
|
|
1299
|
+
hint: `If '${field}' is optional in your object metadata, the database column is still NOT NULL \u2014 the physical schema has drifted from metadata. Run 'os migrate' to reconcile (or reset the dev database).`,
|
|
1300
|
+
...object ? { object } : {}
|
|
1301
|
+
}
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
const looksLikeUnknownObject = lower.includes("no such table") || lower.includes("relation") && lower.includes("does not exist") || lower.includes("table not found") || lower.includes("unknown object") || lower.includes("object not found") || lower.includes("no driver available") || object !== void 0 && lower.includes(`'${object.toLowerCase()}'`) && lower.includes("not");
|
|
1305
|
+
if (looksLikeUnknownObject) {
|
|
1306
|
+
return {
|
|
1307
|
+
status: 404,
|
|
1308
|
+
body: {
|
|
1309
|
+
error: object ? `Object '${object}' is not registered` : "Object not found",
|
|
1310
|
+
code: "object_not_found",
|
|
1311
|
+
object
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
const looksLikeSqlLeak = lower.includes("sqlite_") || lower.includes("sqlstate") || lower.startsWith("insert into ") || lower.startsWith("update ") || lower.startsWith("select ") || lower.startsWith("delete from ") || lower.includes("constraint failed") || lower.includes("unique constraint") || lower.includes("foreign key");
|
|
1316
|
+
if (looksLikeSqlLeak) {
|
|
1317
|
+
if (lower.includes("unique constraint") || lower.includes("unique violation")) {
|
|
1318
|
+
return {
|
|
1319
|
+
status: 409,
|
|
1320
|
+
body: {
|
|
1321
|
+
error: "A record with this value already exists",
|
|
1322
|
+
code: "UNIQUE_VIOLATION",
|
|
1323
|
+
...object ? { object } : {}
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
return {
|
|
1328
|
+
status: 500,
|
|
1329
|
+
body: { error: "Internal data error", code: "DATABASE_ERROR" }
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
return { status: 400, body: { error: raw || "Bad request" } };
|
|
1333
|
+
}
|
|
1334
|
+
function sendError(res, error, object) {
|
|
1335
|
+
if (typeof error?.status === "number" && error.status >= 400 && error.status < 600) {
|
|
1336
|
+
const safeMsg = typeof error.message === "string" && error.message.length < 500 ? error.message : "Request failed";
|
|
1337
|
+
res.status(error.status).json({
|
|
1338
|
+
error: safeMsg,
|
|
1339
|
+
...error.code ? { code: error.code } : {},
|
|
1340
|
+
...Array.isArray(error.issues) ? { issues: error.issues } : {}
|
|
1341
|
+
});
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
const mapped = mapDataError(error, object);
|
|
1345
|
+
res.status(mapped.status).json(mapped.body);
|
|
1346
|
+
}
|
|
1347
|
+
function isExpectedDataStatus(status) {
|
|
1348
|
+
return status === 403 || status === 404 || status === 409 || status === 502 || status === 503;
|
|
1349
|
+
}
|
|
1283
1350
|
var IMPORT_JOB_OBJECT = "sys_import_job";
|
|
1284
1351
|
var IMPORT_JOB_MAX_ROWS = 5e4;
|
|
1285
1352
|
var IMPORT_JOB_RESULTS_CAP = 500;
|
|
@@ -1384,7 +1451,7 @@ function rowsToCsv(fields, rows, includeHeader, metaMap) {
|
|
|
1384
1451
|
}
|
|
1385
1452
|
return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
|
|
1386
1453
|
}
|
|
1387
|
-
async function createXlsxStream(res) {
|
|
1454
|
+
async function createXlsxStream(res, useStyles = false) {
|
|
1388
1455
|
const { PassThrough } = await import("stream");
|
|
1389
1456
|
const ExcelJS = (await import("exceljs")).default ?? await import("exceljs");
|
|
1390
1457
|
const passthrough = new PassThrough();
|
|
@@ -1401,7 +1468,7 @@ async function createXlsxStream(res) {
|
|
|
1401
1468
|
});
|
|
1402
1469
|
passthrough.on("error", reject);
|
|
1403
1470
|
});
|
|
1404
|
-
const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles
|
|
1471
|
+
const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles });
|
|
1405
1472
|
const ws = wb.addWorksheet("Export");
|
|
1406
1473
|
return {
|
|
1407
1474
|
ws,
|
|
@@ -1412,8 +1479,8 @@ async function createXlsxStream(res) {
|
|
|
1412
1479
|
}
|
|
1413
1480
|
};
|
|
1414
1481
|
}
|
|
1415
|
-
var RestServer = class {
|
|
1416
|
-
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider) {
|
|
1482
|
+
var RestServer = class _RestServer {
|
|
1483
|
+
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider) {
|
|
1417
1484
|
/**
|
|
1418
1485
|
* Short-TTL cache for `hostname → environmentId` (P1-4). `resolveByHostname`
|
|
1419
1486
|
* is a control-plane lookup (typically a DB query) that otherwise runs on
|
|
@@ -1466,6 +1533,7 @@ var RestServer = class {
|
|
|
1466
1533
|
this.analyticsServiceProvider = analyticsServiceProvider;
|
|
1467
1534
|
this.settingsServiceProvider = settingsServiceProvider;
|
|
1468
1535
|
this.serviceExistsProvider = serviceExistsProvider;
|
|
1536
|
+
this.securityServiceProvider = securityServiceProvider;
|
|
1469
1537
|
}
|
|
1470
1538
|
/**
|
|
1471
1539
|
* Resolve the protocol for a given request. When `environmentId` is present
|
|
@@ -1704,6 +1772,54 @@ var RestServer = class {
|
|
|
1704
1772
|
perReq.set(key, pending);
|
|
1705
1773
|
return pending;
|
|
1706
1774
|
}
|
|
1775
|
+
/**
|
|
1776
|
+
* [ADR-0046 §6.7] The audience-evaluation view of the caller for book/doc
|
|
1777
|
+
* gating. `permissionSets` resolves through the security service's
|
|
1778
|
+
* `resolvePermissionSetNames` — the SAME resolution as data-plane
|
|
1779
|
+
* enforcement (positions expanded, additive baseline), so the docs gate
|
|
1780
|
+
* can never drift from it. `permissionSets` stays undefined when the
|
|
1781
|
+
* service is absent or resolution fails; `audienceAllows` then DENIES
|
|
1782
|
+
* permission-set-gated audiences (fail closed, ADR-0049). Resolution is
|
|
1783
|
+
* skipped unless `needPermissionSets` — callers pass true only when a
|
|
1784
|
+
* `{ permissionSet }` audience is actually in play.
|
|
1785
|
+
*/
|
|
1786
|
+
async resolveAudienceCaller(environmentId, req, opts) {
|
|
1787
|
+
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => void 0);
|
|
1788
|
+
const authenticated = !!ctx?.userId;
|
|
1789
|
+
if (!authenticated || !opts.needPermissionSets || !this.securityServiceProvider) {
|
|
1790
|
+
return { authenticated };
|
|
1791
|
+
}
|
|
1792
|
+
try {
|
|
1793
|
+
const svc = await this.securityServiceProvider(environmentId);
|
|
1794
|
+
if (!svc || typeof svc.resolvePermissionSetNames !== "function") return { authenticated };
|
|
1795
|
+
const names = await svc.resolvePermissionSetNames(ctx);
|
|
1796
|
+
return { authenticated, permissionSets: Array.isArray(names) ? names : [] };
|
|
1797
|
+
} catch {
|
|
1798
|
+
return { authenticated };
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
/** Whether any of these books carries a `{ permissionSet }` audience. */
|
|
1802
|
+
static anyPermissionSetAudience(books) {
|
|
1803
|
+
return books.some(
|
|
1804
|
+
(b) => b && typeof b === "object" && b.audience && typeof b.audience === "object" && typeof b.audience.permissionSet === "string"
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1807
|
+
/** Coerce a getMetaItems result (array | {items}) into an array. */
|
|
1808
|
+
static metaItemsArray(raw) {
|
|
1809
|
+
if (Array.isArray(raw)) return raw;
|
|
1810
|
+
if (raw && typeof raw === "object" && Array.isArray(raw.items)) return raw.items;
|
|
1811
|
+
return [];
|
|
1812
|
+
}
|
|
1813
|
+
/** Fetch every book of the environment, shaped for the audience resolver. */
|
|
1814
|
+
async fetchAudienceBooks(p, environmentId) {
|
|
1815
|
+
const raw = await p.getMetaItems({
|
|
1816
|
+
type: "book",
|
|
1817
|
+
...environmentId ? { environmentId } : {}
|
|
1818
|
+
}).catch(() => []);
|
|
1819
|
+
return _RestServer.metaItemsArray(raw).map(
|
|
1820
|
+
(b) => b && typeof b === "object" ? { ...b, packageId: b._packageId } : b
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1707
1823
|
/** Heavy path behind `resolveExecCtx` — resolve identity + RBAC/RLS + localization. */
|
|
1708
1824
|
async computeExecCtx(environmentId, req) {
|
|
1709
1825
|
try {
|
|
@@ -2212,6 +2328,8 @@ var RestServer = class {
|
|
|
2212
2328
|
this.registerReportsEndpoints(bp);
|
|
2213
2329
|
this.registerApprovalsEndpoints(bp);
|
|
2214
2330
|
this.registerAnalyticsEndpoints(bp);
|
|
2331
|
+
this.registerSecurityEndpoints(bp);
|
|
2332
|
+
this.registerSecurityExplainEndpoints(bp);
|
|
2215
2333
|
this.registerDataActionEndpoints(bp);
|
|
2216
2334
|
if (this.config.api.enableCrud) {
|
|
2217
2335
|
this.registerCrudEndpoints(bp);
|
|
@@ -2424,9 +2542,49 @@ var RestServer = class {
|
|
|
2424
2542
|
}
|
|
2425
2543
|
}
|
|
2426
2544
|
/**
|
|
2427
|
-
* Register metadata
|
|
2545
|
+
* Register the metadata routes behind the SAME `requireAuth` gate the
|
|
2546
|
+
* `/data` routes use.
|
|
2547
|
+
*
|
|
2548
|
+
* `registerMetadataEndpoints` builds ~17 `/meta/*` routes but — unlike the
|
|
2549
|
+
* `/data` handlers — never calls {@link enforceAuth}: its handlers assumed
|
|
2550
|
+
* the `requireAuth` gate rejected anonymous callers "upstream", yet nothing
|
|
2551
|
+
* upstream covers `/meta`, so on a `requireAuth` deployment an anonymous
|
|
2552
|
+
* caller could read object / field schemas. On a tenant-less runtime host
|
|
2553
|
+
* those are SYSTEM-object schemas and the host is publicly reachable — a
|
|
2554
|
+
* real leak.
|
|
2555
|
+
*
|
|
2556
|
+
* Rather than add the gate to every handler (and have the next new route
|
|
2557
|
+
* forget it — the exact failure mode that caused this), wrap the route
|
|
2558
|
+
* registrar for the duration of registration so every meta route, present
|
|
2559
|
+
* and future, inherits it. The check is a no-op when `requireAuth` is off
|
|
2560
|
+
* (demo / single-tenant), so the previously-public metadata surface there
|
|
2561
|
+
* is unchanged; an authenticated user passes exactly as on `/data`.
|
|
2428
2562
|
*/
|
|
2429
2563
|
registerMetadataEndpoints(basePath) {
|
|
2564
|
+
const realRouteManager = this.routeManager;
|
|
2565
|
+
const guardedRouteManager = {
|
|
2566
|
+
register: (entry) => {
|
|
2567
|
+
const inner = entry.handler;
|
|
2568
|
+
if (typeof inner !== "function") return realRouteManager.register(entry);
|
|
2569
|
+
return realRouteManager.register({
|
|
2570
|
+
...entry,
|
|
2571
|
+
handler: async (req, res) => {
|
|
2572
|
+
const environmentId = req?.params?.environmentId;
|
|
2573
|
+
const context = await this.resolveExecCtx(environmentId, req).catch(() => void 0);
|
|
2574
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2575
|
+
return inner(req, res);
|
|
2576
|
+
}
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
};
|
|
2580
|
+
this.routeManager = guardedRouteManager;
|
|
2581
|
+
try {
|
|
2582
|
+
this.registerMetadataEndpointsInner(basePath);
|
|
2583
|
+
} finally {
|
|
2584
|
+
this.routeManager = realRouteManager;
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
registerMetadataEndpointsInner(basePath) {
|
|
2430
2588
|
const { metadata } = this.config;
|
|
2431
2589
|
const metaPath = `${basePath}${metadata.prefix}`;
|
|
2432
2590
|
const isScoped = basePath.includes("/environments/:environmentId");
|
|
@@ -2573,6 +2731,48 @@ var RestServer = class {
|
|
|
2573
2731
|
visible = Array.isArray(raw) ? filtered : { ...raw, items: filtered };
|
|
2574
2732
|
}
|
|
2575
2733
|
}
|
|
2734
|
+
if (req.params.type === "book") {
|
|
2735
|
+
const raw = visible;
|
|
2736
|
+
const list = _RestServer.metaItemsArray(raw);
|
|
2737
|
+
if (list.length > 0) {
|
|
2738
|
+
const { audienceAllows } = await import("@objectstack/spec/system");
|
|
2739
|
+
const caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2740
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience(list)
|
|
2741
|
+
});
|
|
2742
|
+
const filtered = list.filter((b) => b && typeof b === "object" && audienceAllows(b.audience, caller));
|
|
2743
|
+
visible = Array.isArray(raw) ? filtered : { ...raw, items: filtered };
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
if (req.params.type === "doc") {
|
|
2747
|
+
const raw = visible;
|
|
2748
|
+
const list = _RestServer.metaItemsArray(raw);
|
|
2749
|
+
if (list.length > 0) {
|
|
2750
|
+
const { audienceAllows, docAudienceAllows, resolveDocAudiences } = await import("@objectstack/spec/system");
|
|
2751
|
+
const books = await this.fetchAudienceBooks(p, environmentId);
|
|
2752
|
+
const caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2753
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience(books)
|
|
2754
|
+
});
|
|
2755
|
+
let filtered;
|
|
2756
|
+
if (caller.authenticated && !_RestServer.anyPermissionSetAudience(books)) {
|
|
2757
|
+
filtered = list;
|
|
2758
|
+
} else {
|
|
2759
|
+
const corpus = list.filter((d) => d && typeof d === "object").map((d) => ({
|
|
2760
|
+
name: d.name,
|
|
2761
|
+
group: d.group,
|
|
2762
|
+
tags: d.tags,
|
|
2763
|
+
order: d.order,
|
|
2764
|
+
packageId: d._packageId
|
|
2765
|
+
}));
|
|
2766
|
+
const audiences = resolveDocAudiences(books, corpus);
|
|
2767
|
+
filtered = list.filter((d) => {
|
|
2768
|
+
if (!d || typeof d !== "object") return false;
|
|
2769
|
+
const eff = audiences.get(d.name);
|
|
2770
|
+
return eff ? docAudienceAllows(eff, caller) : audienceAllows("org", caller);
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
visible = Array.isArray(raw) ? filtered : { ...raw, items: filtered };
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2576
2776
|
if (req.params.type === "doc") {
|
|
2577
2777
|
const locale = this.extractLocale(req);
|
|
2578
2778
|
const { resolveDocLocale } = await import("@objectstack/spec/system");
|
|
@@ -2646,7 +2846,7 @@ var RestServer = class {
|
|
|
2646
2846
|
const prot = await this.resolveProtocol(environmentId, req);
|
|
2647
2847
|
const locale = this.extractLocale(req);
|
|
2648
2848
|
const packageId = req.query?.package || void 0;
|
|
2649
|
-
const { resolveBookTree, deriveImplicitPackageBook,
|
|
2849
|
+
const { resolveBookTree, deriveImplicitPackageBook, audienceAllows, resolveDocAudiences, docAudienceAllows, resolveDocLocale } = await import("@objectstack/spec/system");
|
|
2650
2850
|
const norm = (raw) => Array.isArray(raw) ? raw : raw && Array.isArray(raw.items) ? raw.items : [];
|
|
2651
2851
|
const books = norm(await prot.getMetaItems({
|
|
2652
2852
|
type: "book",
|
|
@@ -2657,9 +2857,16 @@ var RestServer = class {
|
|
|
2657
2857
|
if (!book) {
|
|
2658
2858
|
book = deriveImplicitPackageBook(req.params.name, req.params.name);
|
|
2659
2859
|
}
|
|
2660
|
-
const
|
|
2661
|
-
|
|
2662
|
-
|
|
2860
|
+
const audienceBooks = books.map((b) => b && typeof b === "object" ? { ...b, packageId: b._packageId } : b);
|
|
2861
|
+
const caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2862
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience([book, ...audienceBooks])
|
|
2863
|
+
});
|
|
2864
|
+
if (!audienceAllows(book.audience, caller)) {
|
|
2865
|
+
if (!caller.authenticated) {
|
|
2866
|
+
sendError(res, { code: "unauthorized", message: "This documentation requires sign-in", status: 401 });
|
|
2867
|
+
} else {
|
|
2868
|
+
sendError(res, { code: "PERMISSION_DENIED", message: "This documentation is limited to holders of a permission set you do not have", status: 403 });
|
|
2869
|
+
}
|
|
2663
2870
|
return;
|
|
2664
2871
|
}
|
|
2665
2872
|
const docs = norm(await prot.getMetaItems({
|
|
@@ -2676,6 +2883,14 @@ var RestServer = class {
|
|
|
2676
2883
|
packageId: d._packageId
|
|
2677
2884
|
}));
|
|
2678
2885
|
const tree = resolveBookTree(book, docs, book._packageId);
|
|
2886
|
+
const gatedTreePossible = !caller.authenticated || _RestServer.anyPermissionSetAudience(audienceBooks);
|
|
2887
|
+
if (gatedTreePossible) {
|
|
2888
|
+
const audiences = resolveDocAudiences(audienceBooks, docs);
|
|
2889
|
+
tree.groups = tree.groups.map((g) => ({
|
|
2890
|
+
...g,
|
|
2891
|
+
entries: g.entries.filter((e) => !e.doc || docAudienceAllows(audiences.get(e.doc), caller))
|
|
2892
|
+
})).filter((g) => g.entries.some((e) => e.doc || e.href));
|
|
2893
|
+
}
|
|
2679
2894
|
res.json(tree);
|
|
2680
2895
|
} catch (error) {
|
|
2681
2896
|
logError("[REST] Unhandled error:", error);
|
|
@@ -2710,7 +2925,7 @@ var RestServer = class {
|
|
|
2710
2925
|
const isDraftRead = typeof req.query?.state === "string" && req.query.state.toLowerCase() === "draft";
|
|
2711
2926
|
const previewDrafts = typeof req.query?.preview === "string" && req.query.preview.toLowerCase() === "draft";
|
|
2712
2927
|
const packageScoped = typeof req.query?.package === "string" && req.query.package.length > 0;
|
|
2713
|
-
if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead && !previewDrafts && !packageScoped && req.params.type !== "doc") {
|
|
2928
|
+
if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead && !previewDrafts && !packageScoped && req.params.type !== "doc" && req.params.type !== "book") {
|
|
2714
2929
|
const cacheRequest = {
|
|
2715
2930
|
ifNoneMatch: req.headers["if-none-match"],
|
|
2716
2931
|
ifModifiedSince: req.headers["if-modified-since"]
|
|
@@ -2779,6 +2994,47 @@ var RestServer = class {
|
|
|
2779
2994
|
const serviceGate = registered ? (n) => registered.has(n) : void 0;
|
|
2780
2995
|
if (serviceGate) visible = this.filterDashboardForUser(visible, serviceGate);
|
|
2781
2996
|
}
|
|
2997
|
+
if ((req.params.type === "book" || req.params.type === "doc") && visible) {
|
|
2998
|
+
const { audienceAllows, docAudienceAllows, resolveDocAudiences } = await import("@objectstack/spec/system");
|
|
2999
|
+
const target = isMetaEnvelope(visible) ? visible.item : visible;
|
|
3000
|
+
let caller;
|
|
3001
|
+
let allowed;
|
|
3002
|
+
if (req.params.type === "book") {
|
|
3003
|
+
caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
3004
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience([target])
|
|
3005
|
+
});
|
|
3006
|
+
allowed = audienceAllows(target?.audience, caller);
|
|
3007
|
+
} else {
|
|
3008
|
+
const books = await this.fetchAudienceBooks(p, environmentId);
|
|
3009
|
+
caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
3010
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience(books)
|
|
3011
|
+
});
|
|
3012
|
+
if (caller.authenticated && !_RestServer.anyPermissionSetAudience(books)) {
|
|
3013
|
+
allowed = true;
|
|
3014
|
+
} else {
|
|
3015
|
+
const corpus = _RestServer.metaItemsArray(await p.getMetaItems({
|
|
3016
|
+
type: "doc",
|
|
3017
|
+
...environmentId ? { environmentId } : {}
|
|
3018
|
+
}).catch(() => [])).filter((d) => d && typeof d === "object").map((d) => ({
|
|
3019
|
+
name: d.name,
|
|
3020
|
+
group: d.group,
|
|
3021
|
+
tags: d.tags,
|
|
3022
|
+
order: d.order,
|
|
3023
|
+
packageId: d._packageId
|
|
3024
|
+
}));
|
|
3025
|
+
const audiences = resolveDocAudiences(books, corpus);
|
|
3026
|
+
allowed = docAudienceAllows(audiences.get(target?.name), caller);
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
if (!allowed) {
|
|
3030
|
+
if (!caller.authenticated) {
|
|
3031
|
+
sendError(res, { code: "unauthorized", message: "This documentation requires sign-in", status: 401 });
|
|
3032
|
+
} else {
|
|
3033
|
+
sendError(res, { code: "PERMISSION_DENIED", message: "This documentation is limited to holders of a permission set you do not have", status: 403 });
|
|
3034
|
+
}
|
|
3035
|
+
return;
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
2782
3038
|
if (req.params.type === "doc" && visible) {
|
|
2783
3039
|
const locale = this.extractLocale(req);
|
|
2784
3040
|
const { resolveDocLocale } = await import("@objectstack/spec/system");
|
|
@@ -3779,9 +4035,11 @@ var RestServer = class {
|
|
|
3779
4035
|
const includeHeader = String(q.header ?? "true").toLowerCase() !== "false";
|
|
3780
4036
|
const HARD_CAP = 5e4;
|
|
3781
4037
|
const MAX_CHUNK = 5e3;
|
|
4038
|
+
const STYLE_ROW_CAP = 1e4;
|
|
3782
4039
|
const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 1e4;
|
|
3783
4040
|
const limit = Math.min(requestedLimit, HARD_CAP);
|
|
3784
4041
|
const chunkSize = Math.min(MAX_CHUNK, Math.max(50, q.page != null ? Number(q.page) || 500 : 500));
|
|
4042
|
+
const styled = format === "xlsx" && limit <= STYLE_ROW_CAP;
|
|
3785
4043
|
let filter = void 0;
|
|
3786
4044
|
if (typeof q.filter === "string" && q.filter.length > 0) {
|
|
3787
4045
|
try {
|
|
@@ -3848,12 +4106,13 @@ var RestServer = class {
|
|
|
3848
4106
|
}
|
|
3849
4107
|
res.header("X-Export-Format", format);
|
|
3850
4108
|
res.header("X-Export-Limit", String(limit));
|
|
4109
|
+
if (format === "xlsx") res.header("X-Export-Styles", styled ? "applied" : "dropped");
|
|
3851
4110
|
res.header("Cache-Control", "no-store");
|
|
3852
4111
|
let exported = 0;
|
|
3853
4112
|
let firstChunk = true;
|
|
3854
4113
|
let skip = 0;
|
|
3855
4114
|
if (format === "json") res.write("[");
|
|
3856
|
-
const xlsx = format === "xlsx" ? await createXlsxStream(res) : null;
|
|
4115
|
+
const xlsx = format === "xlsx" ? await createXlsxStream(res, styled) : null;
|
|
3857
4116
|
while (exported < limit) {
|
|
3858
4117
|
const take = Math.min(chunkSize, limit - exported);
|
|
3859
4118
|
const findArgs = {
|
|
@@ -3881,8 +4140,16 @@ var RestServer = class {
|
|
|
3881
4140
|
if (firstChunk && includeHeader) {
|
|
3882
4141
|
xlsx.ws.addRow((fields ?? []).map((f) => headerLabel(f, metaMap))).commit();
|
|
3883
4142
|
}
|
|
4143
|
+
const cols = fields ?? [];
|
|
3884
4144
|
for (const row of rows) {
|
|
3885
|
-
xlsx.ws.addRow(formatRowCells(row,
|
|
4145
|
+
const r = xlsx.ws.addRow(formatRowCells(row, cols, metaMap));
|
|
4146
|
+
if (styled) {
|
|
4147
|
+
cols.forEach((f, i) => {
|
|
4148
|
+
const argb = cellFontColor(row?.[f], metaMap.get(f));
|
|
4149
|
+
if (argb) r.getCell(i + 1).font = { color: { argb } };
|
|
4150
|
+
});
|
|
4151
|
+
}
|
|
4152
|
+
r.commit();
|
|
3886
4153
|
}
|
|
3887
4154
|
} else {
|
|
3888
4155
|
for (let i = 0; i < rows.length; i++) {
|
|
@@ -4511,6 +4778,100 @@ var RestServer = class {
|
|
|
4511
4778
|
metadata: { summary: "Run a semantic-layer dataset (preview/query)", tags: ["analytics"] }
|
|
4512
4779
|
});
|
|
4513
4780
|
}
|
|
4781
|
+
/**
|
|
4782
|
+
* [ADR-0090 D6] Access-explanation endpoint — the REST face of the
|
|
4783
|
+
* explain engine (framework#2696).
|
|
4784
|
+
*
|
|
4785
|
+
* GET {basePath}/security/explain?object=…&operation=…&userId=…
|
|
4786
|
+
* POST {basePath}/security/explain body: { object, operation, userId? }
|
|
4787
|
+
*
|
|
4788
|
+
* Delegates to the security service's `explain(request, callerContext)`
|
|
4789
|
+
* (`SecurityPlugin.explainAccessForCaller`) — the same code paths the
|
|
4790
|
+
* enforcement middleware runs, so the report is explained by
|
|
4791
|
+
* construction. Caller authorization lives in the SERVICE, not here:
|
|
4792
|
+
* explaining ANOTHER user requires `manage_users` or a delegated
|
|
4793
|
+
* `adminScope` covering that user (D12); the service's
|
|
4794
|
+
* `PermissionDeniedError` maps to 403. The route itself only insists on
|
|
4795
|
+
* an authenticated caller (an access report is sensitive even about
|
|
4796
|
+
* oneself, and the anonymous `guest` posture is not this endpoint's
|
|
4797
|
+
* business) and returns 501 when no security service exposing `explain`
|
|
4798
|
+
* is mounted (a deployment without `@objectstack/plugin-security`).
|
|
4799
|
+
*/
|
|
4800
|
+
registerSecurityExplainEndpoints(basePath) {
|
|
4801
|
+
const isScoped = basePath.includes("/environments/:environmentId");
|
|
4802
|
+
const resolveService = async (environmentId, req) => {
|
|
4803
|
+
try {
|
|
4804
|
+
const envId = await this.resolveRequestEnvironmentId(environmentId, req);
|
|
4805
|
+
if (envId && envId !== "platform" && this.kernelManager) {
|
|
4806
|
+
const kernel = await this.kernelManager.getOrCreate(envId);
|
|
4807
|
+
const svc = await kernel.getServiceAsync("security").catch(() => void 0);
|
|
4808
|
+
if (svc) return svc;
|
|
4809
|
+
}
|
|
4810
|
+
} catch {
|
|
4811
|
+
}
|
|
4812
|
+
if (!this.securityServiceProvider) return void 0;
|
|
4813
|
+
try {
|
|
4814
|
+
return await this.securityServiceProvider(environmentId);
|
|
4815
|
+
} catch {
|
|
4816
|
+
return void 0;
|
|
4817
|
+
}
|
|
4818
|
+
};
|
|
4819
|
+
const handler = async (req, res) => {
|
|
4820
|
+
try {
|
|
4821
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
4822
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
4823
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
4824
|
+
if (!context?.userId) {
|
|
4825
|
+
return res.status(401).json({
|
|
4826
|
+
code: "UNAUTHORIZED",
|
|
4827
|
+
message: "The access-explanation endpoint requires an authenticated caller."
|
|
4828
|
+
});
|
|
4829
|
+
}
|
|
4830
|
+
const svc = await resolveService(environmentId, req);
|
|
4831
|
+
if (!svc || typeof svc.explain !== "function") {
|
|
4832
|
+
return res.status(501).json({
|
|
4833
|
+
code: "NOT_IMPLEMENTED",
|
|
4834
|
+
message: "Access explanation is not available on this deployment (no security service with explain)."
|
|
4835
|
+
});
|
|
4836
|
+
}
|
|
4837
|
+
const src = req.method === "GET" ? req.query ?? {} : req.body ?? {};
|
|
4838
|
+
const { ExplainRequestSchema } = await import("@objectstack/spec/security");
|
|
4839
|
+
const parsed = ExplainRequestSchema.safeParse({
|
|
4840
|
+
object: src.object,
|
|
4841
|
+
operation: src.operation ?? "read",
|
|
4842
|
+
...src.userId != null && src.userId !== "" ? { userId: src.userId } : {}
|
|
4843
|
+
});
|
|
4844
|
+
if (!parsed.success) {
|
|
4845
|
+
return res.status(400).json({
|
|
4846
|
+
code: "VALIDATION_FAILED",
|
|
4847
|
+
message: "Invalid explain request \u2014 expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string }.",
|
|
4848
|
+
detail: String(parsed.error?.message ?? "").slice(0, 1e3)
|
|
4849
|
+
});
|
|
4850
|
+
}
|
|
4851
|
+
const decision = await svc.explain(parsed.data, context);
|
|
4852
|
+
res.json(decision);
|
|
4853
|
+
} catch (error) {
|
|
4854
|
+
const msg = String(error?.message ?? error ?? "");
|
|
4855
|
+
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || msg.startsWith("[Security] Access denied")) {
|
|
4856
|
+
return res.status(403).json({ code: "PERMISSION_DENIED", message: msg.slice(0, 1e3) });
|
|
4857
|
+
}
|
|
4858
|
+
logError("[REST] Security explain error:", error);
|
|
4859
|
+
res.status(500).json({ code: "EXPLAIN_FAILED", error: msg.slice(0, 500) });
|
|
4860
|
+
}
|
|
4861
|
+
};
|
|
4862
|
+
this.routeManager.register({
|
|
4863
|
+
method: "GET",
|
|
4864
|
+
path: `${basePath}/security/explain`,
|
|
4865
|
+
handler,
|
|
4866
|
+
metadata: { summary: "Explain why a principal can (or cannot) perform an operation on an object (ADR-0090 D6)", tags: ["security"] }
|
|
4867
|
+
});
|
|
4868
|
+
this.routeManager.register({
|
|
4869
|
+
method: "POST",
|
|
4870
|
+
path: `${basePath}/security/explain`,
|
|
4871
|
+
handler,
|
|
4872
|
+
metadata: { summary: "Explain why a principal can (or cannot) perform an operation on an object (ADR-0090 D6)", tags: ["security"] }
|
|
4873
|
+
});
|
|
4874
|
+
}
|
|
4514
4875
|
registerSharingEndpoints(basePath) {
|
|
4515
4876
|
const { crud } = this.config;
|
|
4516
4877
|
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
@@ -4753,6 +5114,106 @@ var RestServer = class {
|
|
|
4753
5114
|
metadata: { summary: "Re-evaluate a sharing rule and reconcile grants", tags: ["sharing"] }
|
|
4754
5115
|
});
|
|
4755
5116
|
}
|
|
5117
|
+
/**
|
|
5118
|
+
* Register the security admin endpoints (ADR-0090 D5/D9) — suggested
|
|
5119
|
+
* audience bindings. A package permission set declaring `isDefault: true`
|
|
5120
|
+
* is an install-time SUGGESTION to bind it to the `everyone` position;
|
|
5121
|
+
* these routes surface pending suggestions and let a tenant admin resolve
|
|
5122
|
+
* them. The `security` service (plugin-security) does the real gating:
|
|
5123
|
+
* tenant-admin pre-check on all three, and confirm writes the binding
|
|
5124
|
+
* with the caller's execution context so the audience-anchor and
|
|
5125
|
+
* delegated-admin gates enforce it — never auto-bound, never system.
|
|
5126
|
+
*
|
|
5127
|
+
* GET {basePath}/security/suggested-bindings?status=&packageId=
|
|
5128
|
+
* POST {basePath}/security/suggested-bindings/:id/confirm
|
|
5129
|
+
* POST {basePath}/security/suggested-bindings/:id/dismiss
|
|
5130
|
+
*
|
|
5131
|
+
* Routes return 501 when the `security` service is not registered
|
|
5132
|
+
* (deployment without plugin-security). Typed service errors carry their
|
|
5133
|
+
* HTTP status (403 permission / 404 not found / 409 state).
|
|
5134
|
+
*/
|
|
5135
|
+
registerSecurityEndpoints(basePath) {
|
|
5136
|
+
const dataPath = basePath;
|
|
5137
|
+
const isScoped = basePath.includes("/environments/:environmentId");
|
|
5138
|
+
const resolveService = async (environmentId) => {
|
|
5139
|
+
if (!this.securityServiceProvider) return void 0;
|
|
5140
|
+
try {
|
|
5141
|
+
const svc = await this.securityServiceProvider(environmentId);
|
|
5142
|
+
return svc && typeof svc.listAudienceBindingSuggestions === "function" ? svc : void 0;
|
|
5143
|
+
} catch {
|
|
5144
|
+
return void 0;
|
|
5145
|
+
}
|
|
5146
|
+
};
|
|
5147
|
+
const respond501 = (res) => res.status(501).json({
|
|
5148
|
+
code: "NOT_IMPLEMENTED",
|
|
5149
|
+
message: "Security service is not configured on this deployment"
|
|
5150
|
+
});
|
|
5151
|
+
const handleError = (err, res, defaultCode) => {
|
|
5152
|
+
const status = typeof err?.statusCode === "number" ? err.statusCode : 500;
|
|
5153
|
+
if (status !== 500) {
|
|
5154
|
+
return res.status(status).json({ code: err?.code ?? defaultCode, error: String(err?.message ?? err) });
|
|
5155
|
+
}
|
|
5156
|
+
logError(`[REST] suggested-bindings ${defaultCode}:`, err);
|
|
5157
|
+
return res.status(500).json({ code: defaultCode, error: String(err?.message ?? err).slice(0, 500) });
|
|
5158
|
+
};
|
|
5159
|
+
this.routeManager.register({
|
|
5160
|
+
method: "GET",
|
|
5161
|
+
path: `${dataPath}/security/suggested-bindings`,
|
|
5162
|
+
handler: async (req, res) => {
|
|
5163
|
+
try {
|
|
5164
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
5165
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
5166
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
5167
|
+
const svc = await resolveService(environmentId);
|
|
5168
|
+
if (!svc) return respond501(res);
|
|
5169
|
+
const result = await svc.listAudienceBindingSuggestions(context ?? {}, {
|
|
5170
|
+
status: req.query?.status ? String(req.query.status) : void 0,
|
|
5171
|
+
packageId: req.query?.packageId ? String(req.query.packageId) : void 0
|
|
5172
|
+
});
|
|
5173
|
+
res.json({ data: result });
|
|
5174
|
+
} catch (err) {
|
|
5175
|
+
handleError(err, res, "SUGGESTION_LIST_FAILED");
|
|
5176
|
+
}
|
|
5177
|
+
},
|
|
5178
|
+
metadata: { summary: "List suggested audience bindings (ADR-0090 D5/D9)", tags: ["security"] }
|
|
5179
|
+
});
|
|
5180
|
+
this.routeManager.register({
|
|
5181
|
+
method: "POST",
|
|
5182
|
+
path: `${dataPath}/security/suggested-bindings/:id/confirm`,
|
|
5183
|
+
handler: async (req, res) => {
|
|
5184
|
+
try {
|
|
5185
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
5186
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
5187
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
5188
|
+
const svc = await resolveService(environmentId);
|
|
5189
|
+
if (!svc) return respond501(res);
|
|
5190
|
+
const result = await svc.confirmAudienceBindingSuggestion(context ?? {}, String(req.params.id));
|
|
5191
|
+
res.json({ data: result });
|
|
5192
|
+
} catch (err) {
|
|
5193
|
+
handleError(err, res, "SUGGESTION_CONFIRM_FAILED");
|
|
5194
|
+
}
|
|
5195
|
+
},
|
|
5196
|
+
metadata: { summary: "Confirm a suggested audience binding (creates the everyone/guest binding)", tags: ["security"] }
|
|
5197
|
+
});
|
|
5198
|
+
this.routeManager.register({
|
|
5199
|
+
method: "POST",
|
|
5200
|
+
path: `${dataPath}/security/suggested-bindings/:id/dismiss`,
|
|
5201
|
+
handler: async (req, res) => {
|
|
5202
|
+
try {
|
|
5203
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
5204
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
5205
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
5206
|
+
const svc = await resolveService(environmentId);
|
|
5207
|
+
if (!svc) return respond501(res);
|
|
5208
|
+
const result = await svc.dismissAudienceBindingSuggestion(context ?? {}, String(req.params.id));
|
|
5209
|
+
res.json({ data: result });
|
|
5210
|
+
} catch (err) {
|
|
5211
|
+
handleError(err, res, "SUGGESTION_DISMISS_FAILED");
|
|
5212
|
+
}
|
|
5213
|
+
},
|
|
5214
|
+
metadata: { summary: "Dismiss a suggested audience binding", tags: ["security"] }
|
|
5215
|
+
});
|
|
5216
|
+
}
|
|
4756
5217
|
/**
|
|
4757
5218
|
* Register saved-report + scheduled-digest endpoints (M11.C16).
|
|
4758
5219
|
*
|
|
@@ -5550,6 +6011,20 @@ function registerPackageRoutes(server, packageService, basePath = "/api/v1", opt
|
|
|
5550
6011
|
try {
|
|
5551
6012
|
const packageId = req.params.id;
|
|
5552
6013
|
const version = req.query?.version;
|
|
6014
|
+
if (!version && typeof options.protocol?.deletePackage === "function") {
|
|
6015
|
+
const result2 = await options.protocol.deletePackage({ packageId });
|
|
6016
|
+
if (result2.failedCount === 0) {
|
|
6017
|
+
res.json({
|
|
6018
|
+
success: true,
|
|
6019
|
+
message: `Deleted ${packageId}`,
|
|
6020
|
+
deletedCount: result2.deletedCount,
|
|
6021
|
+
cleanups: result2.cleanups
|
|
6022
|
+
});
|
|
6023
|
+
return;
|
|
6024
|
+
}
|
|
6025
|
+
res.status(400).json({ success: false, failed: result2.failed, cleanups: result2.cleanups });
|
|
6026
|
+
return;
|
|
6027
|
+
}
|
|
5553
6028
|
const result = await packageService.delete(packageId, version);
|
|
5554
6029
|
if (result.success) {
|
|
5555
6030
|
res.json({
|
|
@@ -5749,6 +6224,13 @@ function createRestApiPlugin(config = {}) {
|
|
|
5749
6224
|
return void 0;
|
|
5750
6225
|
}
|
|
5751
6226
|
};
|
|
6227
|
+
const securityServiceProvider = async (_environmentId) => {
|
|
6228
|
+
try {
|
|
6229
|
+
return ctx.getService("security");
|
|
6230
|
+
} catch {
|
|
6231
|
+
return void 0;
|
|
6232
|
+
}
|
|
6233
|
+
};
|
|
5752
6234
|
if (!server) {
|
|
5753
6235
|
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
|
|
5754
6236
|
return;
|
|
@@ -5766,7 +6248,7 @@ function createRestApiPlugin(config = {}) {
|
|
|
5766
6248
|
}
|
|
5767
6249
|
};
|
|
5768
6250
|
try {
|
|
5769
|
-
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider);
|
|
6251
|
+
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider);
|
|
5770
6252
|
restServer.registerRoutes();
|
|
5771
6253
|
ctx.logger.info("REST API successfully registered");
|
|
5772
6254
|
if (config.api?.api?.requireAuth === false) {
|
|
@@ -5822,6 +6304,13 @@ function createRestApiPlugin(config = {}) {
|
|
|
5822
6304
|
RestServer,
|
|
5823
6305
|
RouteGroupBuilder,
|
|
5824
6306
|
RouteManager,
|
|
5825
|
-
|
|
6307
|
+
buildFieldMetaMap,
|
|
6308
|
+
coerceRow,
|
|
6309
|
+
createRestApiPlugin,
|
|
6310
|
+
isMetaEnvelope,
|
|
6311
|
+
parseCsvToRows,
|
|
6312
|
+
parseXlsxToRows,
|
|
6313
|
+
prepareImportRequest,
|
|
6314
|
+
runImport
|
|
5826
6315
|
});
|
|
5827
6316
|
//# sourceMappingURL=index.cjs.map
|