@objectstack/rest 12.6.0 → 14.3.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 +580 -163
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +383 -2
- package/dist/index.d.ts +383 -2
- package/dist/index.js +571 -161
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
package/dist/index.cjs
CHANGED
|
@@ -33,12 +33,20 @@ __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
|
|
|
40
47
|
// src/rest-server.ts
|
|
41
48
|
var import_core2 = require("@objectstack/core");
|
|
49
|
+
var import_types = require("@objectstack/types");
|
|
42
50
|
|
|
43
51
|
// src/route-manager.ts
|
|
44
52
|
var RouteManager = class {
|
|
@@ -326,6 +334,22 @@ function optionLabel(value, options) {
|
|
|
326
334
|
const hit = options.find((o) => o && o.value === value);
|
|
327
335
|
return hit?.label ?? value;
|
|
328
336
|
}
|
|
337
|
+
function toArgb(color) {
|
|
338
|
+
if (typeof color !== "string") return void 0;
|
|
339
|
+
const hex = color.trim().replace(/^#/, "");
|
|
340
|
+
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
|
|
341
|
+
const [r, g, b] = hex;
|
|
342
|
+
return `FF${r}${r}${g}${g}${b}${b}`.toUpperCase();
|
|
343
|
+
}
|
|
344
|
+
if (/^[0-9a-fA-F]{6}$/.test(hex)) return `FF${hex}`.toUpperCase();
|
|
345
|
+
return void 0;
|
|
346
|
+
}
|
|
347
|
+
function cellFontColor(value, meta) {
|
|
348
|
+
if (value === null || value === void 0) return void 0;
|
|
349
|
+
if (!meta || !meta.type || !OPTION_TYPES.has(meta.type) || !meta.options) return void 0;
|
|
350
|
+
const hit = meta.options.find((o) => o && o.value === value);
|
|
351
|
+
return toArgb(hit?.color);
|
|
352
|
+
}
|
|
329
353
|
function displayFromRecord(rec, displayField) {
|
|
330
354
|
if (displayField && rec[displayField] != null) return String(rec[displayField]);
|
|
331
355
|
for (const k of NAME_KEY_FALLBACKS) {
|
|
@@ -912,155 +936,10 @@ function applyMappingToRows(rows, artifact) {
|
|
|
912
936
|
return { ok: true, rows: out };
|
|
913
937
|
}
|
|
914
938
|
|
|
915
|
-
// src/
|
|
916
|
-
var import_meta = {};
|
|
917
|
-
var logError = (...args) => globalThis.console?.error(...args);
|
|
918
|
-
var TRANSLATABLE_META_TYPES = /* @__PURE__ */ new Set(["view", "action", "object", "app", "dashboard"]);
|
|
939
|
+
// src/import-prepare.ts
|
|
919
940
|
function isMetaEnvelope(value) {
|
|
920
941
|
return !!value && typeof value === "object" && typeof value.type === "string" && typeof value.name === "string" && value.item != null && typeof value.item === "object" && !Array.isArray(value.item);
|
|
921
942
|
}
|
|
922
|
-
function mapDataError(error, object) {
|
|
923
|
-
if (error?.code === "DELETE_RESTRICTED") {
|
|
924
|
-
return {
|
|
925
|
-
status: 409,
|
|
926
|
-
body: {
|
|
927
|
-
error: error?.message ?? "Cannot delete: dependent records exist",
|
|
928
|
-
code: "DELETE_RESTRICTED",
|
|
929
|
-
...error?.dependentObject ? { dependentObject: error.dependentObject } : {},
|
|
930
|
-
...typeof error?.dependentCount === "number" ? { dependentCount: error.dependentCount } : {},
|
|
931
|
-
...object ? { object } : {}
|
|
932
|
-
}
|
|
933
|
-
};
|
|
934
|
-
}
|
|
935
|
-
if (error?.code === "CONCURRENT_UPDATE" || error?.name === "ConcurrentUpdateError") {
|
|
936
|
-
return {
|
|
937
|
-
status: 409,
|
|
938
|
-
body: {
|
|
939
|
-
error: error?.message ?? "Record was modified by another user",
|
|
940
|
-
code: "CONCURRENT_UPDATE",
|
|
941
|
-
...error?.currentVersion ? { currentVersion: error.currentVersion } : {},
|
|
942
|
-
...error?.currentRecord ? { currentRecord: error.currentRecord } : {},
|
|
943
|
-
...object ? { object } : {}
|
|
944
|
-
}
|
|
945
|
-
};
|
|
946
|
-
}
|
|
947
|
-
if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
|
|
948
|
-
return {
|
|
949
|
-
status: 400,
|
|
950
|
-
body: {
|
|
951
|
-
error: error?.message ?? "Validation failed",
|
|
952
|
-
code: "VALIDATION_FAILED",
|
|
953
|
-
fields: Array.isArray(error?.fields) ? error.fields : [],
|
|
954
|
-
...object ? { object } : {}
|
|
955
|
-
}
|
|
956
|
-
};
|
|
957
|
-
}
|
|
958
|
-
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
|
|
959
|
-
return {
|
|
960
|
-
status: 403,
|
|
961
|
-
body: {
|
|
962
|
-
error: error?.message ?? "Permission denied",
|
|
963
|
-
code: "PERMISSION_DENIED",
|
|
964
|
-
...object ? { object } : {}
|
|
965
|
-
}
|
|
966
|
-
};
|
|
967
|
-
}
|
|
968
|
-
const raw = String(error?.message ?? error ?? "");
|
|
969
|
-
const lower = raw.toLowerCase();
|
|
970
|
-
if (raw.includes("[EnvironmentKernelFactory]") && (lower.includes("missing database_url") || lower.includes("not found"))) {
|
|
971
|
-
const isProvisioning = lower.includes("status='provisioning'") || lower.includes("status='pending'");
|
|
972
|
-
const isFailed = lower.includes("status='failed'");
|
|
973
|
-
return {
|
|
974
|
-
status: isProvisioning ? 503 : isFailed ? 502 : 404,
|
|
975
|
-
body: {
|
|
976
|
-
error: raw,
|
|
977
|
-
code: isProvisioning ? "PROJECT_PROVISIONING" : isFailed ? "PROJECT_PROVISIONING_FAILED" : "PROJECT_NOT_FOUND"
|
|
978
|
-
}
|
|
979
|
-
};
|
|
980
|
-
}
|
|
981
|
-
if (error?.code === "RECORD_NOT_FOUND" || /^Record\s+\S+\s+not found in\s+\S+/i.test(raw)) {
|
|
982
|
-
return {
|
|
983
|
-
status: 404,
|
|
984
|
-
body: {
|
|
985
|
-
error: raw,
|
|
986
|
-
code: "RECORD_NOT_FOUND",
|
|
987
|
-
...object ? { object } : {}
|
|
988
|
-
}
|
|
989
|
-
};
|
|
990
|
-
}
|
|
991
|
-
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);
|
|
992
|
-
if (unknownColumn) {
|
|
993
|
-
const field = unknownColumn[1]?.split(".").pop();
|
|
994
|
-
return {
|
|
995
|
-
status: 400,
|
|
996
|
-
body: {
|
|
997
|
-
error: field ? `Unknown field '${field}'${object ? ` on object '${object}'` : ""}` : "Request references a field that does not exist",
|
|
998
|
-
code: "INVALID_FIELD",
|
|
999
|
-
...field ? { field } : {},
|
|
1000
|
-
...object ? { object } : {}
|
|
1001
|
-
}
|
|
1002
|
-
};
|
|
1003
|
-
}
|
|
1004
|
-
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);
|
|
1005
|
-
if (notNull) {
|
|
1006
|
-
const field = notNull[1];
|
|
1007
|
-
return {
|
|
1008
|
-
status: 400,
|
|
1009
|
-
body: {
|
|
1010
|
-
error: `${field} is required`,
|
|
1011
|
-
code: "VALIDATION_FAILED",
|
|
1012
|
-
fields: [{ field, code: "required", message: `${field} is required` }],
|
|
1013
|
-
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).`,
|
|
1014
|
-
...object ? { object } : {}
|
|
1015
|
-
}
|
|
1016
|
-
};
|
|
1017
|
-
}
|
|
1018
|
-
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");
|
|
1019
|
-
if (looksLikeUnknownObject) {
|
|
1020
|
-
return {
|
|
1021
|
-
status: 404,
|
|
1022
|
-
body: {
|
|
1023
|
-
error: object ? `Object '${object}' is not registered` : "Object not found",
|
|
1024
|
-
code: "object_not_found",
|
|
1025
|
-
object
|
|
1026
|
-
}
|
|
1027
|
-
};
|
|
1028
|
-
}
|
|
1029
|
-
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");
|
|
1030
|
-
if (looksLikeSqlLeak) {
|
|
1031
|
-
if (lower.includes("unique constraint") || lower.includes("unique violation")) {
|
|
1032
|
-
return {
|
|
1033
|
-
status: 409,
|
|
1034
|
-
body: {
|
|
1035
|
-
error: "A record with this value already exists",
|
|
1036
|
-
code: "UNIQUE_VIOLATION",
|
|
1037
|
-
...object ? { object } : {}
|
|
1038
|
-
}
|
|
1039
|
-
};
|
|
1040
|
-
}
|
|
1041
|
-
return {
|
|
1042
|
-
status: 500,
|
|
1043
|
-
body: { error: "Internal data error", code: "DATABASE_ERROR" }
|
|
1044
|
-
};
|
|
1045
|
-
}
|
|
1046
|
-
return { status: 400, body: { error: raw || "Bad request" } };
|
|
1047
|
-
}
|
|
1048
|
-
function sendError(res, error, object) {
|
|
1049
|
-
if (typeof error?.status === "number" && error.status >= 400 && error.status < 600) {
|
|
1050
|
-
const safeMsg = typeof error.message === "string" && error.message.length < 500 ? error.message : "Request failed";
|
|
1051
|
-
res.status(error.status).json({
|
|
1052
|
-
error: safeMsg,
|
|
1053
|
-
...error.code ? { code: error.code } : {},
|
|
1054
|
-
...Array.isArray(error.issues) ? { issues: error.issues } : {}
|
|
1055
|
-
});
|
|
1056
|
-
return;
|
|
1057
|
-
}
|
|
1058
|
-
const mapped = mapDataError(error, object);
|
|
1059
|
-
res.status(mapped.status).json(mapped.body);
|
|
1060
|
-
}
|
|
1061
|
-
function isExpectedDataStatus(status) {
|
|
1062
|
-
return status === 403 || status === 404 || status === 409 || status === 502 || status === 503;
|
|
1063
|
-
}
|
|
1064
943
|
function parseCsvToRows(csv, mapping = {}) {
|
|
1065
944
|
const text = csv.replace(/^\uFEFF/, "");
|
|
1066
945
|
const cells = [];
|
|
@@ -1279,6 +1158,163 @@ async function prepareImportRequest(body, opts) {
|
|
|
1279
1158
|
}
|
|
1280
1159
|
};
|
|
1281
1160
|
}
|
|
1161
|
+
|
|
1162
|
+
// src/rest-server.ts
|
|
1163
|
+
var import_meta = {};
|
|
1164
|
+
var logError = (...args) => globalThis.console?.error(...args);
|
|
1165
|
+
var TRANSLATABLE_META_TYPES = /* @__PURE__ */ new Set(["view", "action", "object", "app", "dashboard"]);
|
|
1166
|
+
function mapDataError(error, object) {
|
|
1167
|
+
if (error?.code === "DELETE_RESTRICTED") {
|
|
1168
|
+
return {
|
|
1169
|
+
status: 409,
|
|
1170
|
+
body: {
|
|
1171
|
+
error: error?.message ?? "Cannot delete: dependent records exist",
|
|
1172
|
+
code: "DELETE_RESTRICTED",
|
|
1173
|
+
...error?.dependentObject ? { dependentObject: error.dependentObject } : {},
|
|
1174
|
+
...typeof error?.dependentCount === "number" ? { dependentCount: error.dependentCount } : {},
|
|
1175
|
+
...object ? { object } : {}
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
if (error?.code === "CONCURRENT_UPDATE" || error?.name === "ConcurrentUpdateError") {
|
|
1180
|
+
return {
|
|
1181
|
+
status: 409,
|
|
1182
|
+
body: {
|
|
1183
|
+
error: error?.message ?? "Record was modified by another user",
|
|
1184
|
+
code: "CONCURRENT_UPDATE",
|
|
1185
|
+
...error?.currentVersion ? { currentVersion: error.currentVersion } : {},
|
|
1186
|
+
...error?.currentRecord ? { currentRecord: error.currentRecord } : {},
|
|
1187
|
+
...object ? { object } : {}
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
|
|
1192
|
+
return {
|
|
1193
|
+
status: 400,
|
|
1194
|
+
body: {
|
|
1195
|
+
error: error?.message ?? "Validation failed",
|
|
1196
|
+
code: "VALIDATION_FAILED",
|
|
1197
|
+
fields: Array.isArray(error?.fields) ? error.fields : [],
|
|
1198
|
+
...object ? { object } : {}
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
if (error?.code === "FEEDS_DISABLED" || error?.code === "FILES_DISABLED") {
|
|
1203
|
+
return {
|
|
1204
|
+
status: 403,
|
|
1205
|
+
body: {
|
|
1206
|
+
error: error?.message ?? "This capability is disabled for the target object",
|
|
1207
|
+
code: error.code,
|
|
1208
|
+
...error?.object || object ? { object: error?.object ?? object } : {}
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
|
|
1213
|
+
return {
|
|
1214
|
+
status: 403,
|
|
1215
|
+
body: {
|
|
1216
|
+
error: error?.message ?? "Permission denied",
|
|
1217
|
+
code: "PERMISSION_DENIED",
|
|
1218
|
+
...object ? { object } : {}
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
const raw = String(error?.message ?? error ?? "");
|
|
1223
|
+
const lower = raw.toLowerCase();
|
|
1224
|
+
if (raw.includes("[EnvironmentKernelFactory]") && (lower.includes("missing database_url") || lower.includes("not found"))) {
|
|
1225
|
+
const isProvisioning = lower.includes("status='provisioning'") || lower.includes("status='pending'");
|
|
1226
|
+
const isFailed = lower.includes("status='failed'");
|
|
1227
|
+
return {
|
|
1228
|
+
status: isProvisioning ? 503 : isFailed ? 502 : 404,
|
|
1229
|
+
body: {
|
|
1230
|
+
error: raw,
|
|
1231
|
+
code: isProvisioning ? "PROJECT_PROVISIONING" : isFailed ? "PROJECT_PROVISIONING_FAILED" : "PROJECT_NOT_FOUND"
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
if (error?.code === "RECORD_NOT_FOUND" || /^Record\s+\S+\s+not found in\s+\S+/i.test(raw)) {
|
|
1236
|
+
return {
|
|
1237
|
+
status: 404,
|
|
1238
|
+
body: {
|
|
1239
|
+
error: raw,
|
|
1240
|
+
code: "RECORD_NOT_FOUND",
|
|
1241
|
+
...object ? { object } : {}
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
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);
|
|
1246
|
+
if (unknownColumn) {
|
|
1247
|
+
const field = unknownColumn[1]?.split(".").pop();
|
|
1248
|
+
return {
|
|
1249
|
+
status: 400,
|
|
1250
|
+
body: {
|
|
1251
|
+
error: field ? `Unknown field '${field}'${object ? ` on object '${object}'` : ""}` : "Request references a field that does not exist",
|
|
1252
|
+
code: "INVALID_FIELD",
|
|
1253
|
+
...field ? { field } : {},
|
|
1254
|
+
...object ? { object } : {}
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
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);
|
|
1259
|
+
if (notNull) {
|
|
1260
|
+
const field = notNull[1];
|
|
1261
|
+
return {
|
|
1262
|
+
status: 400,
|
|
1263
|
+
body: {
|
|
1264
|
+
error: `${field} is required`,
|
|
1265
|
+
code: "VALIDATION_FAILED",
|
|
1266
|
+
fields: [{ field, code: "required", message: `${field} is required` }],
|
|
1267
|
+
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).`,
|
|
1268
|
+
...object ? { object } : {}
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
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");
|
|
1273
|
+
if (looksLikeUnknownObject) {
|
|
1274
|
+
return {
|
|
1275
|
+
status: 404,
|
|
1276
|
+
body: {
|
|
1277
|
+
error: object ? `Object '${object}' is not registered` : "Object not found",
|
|
1278
|
+
code: "object_not_found",
|
|
1279
|
+
object
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
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");
|
|
1284
|
+
if (looksLikeSqlLeak) {
|
|
1285
|
+
if (lower.includes("unique constraint") || lower.includes("unique violation")) {
|
|
1286
|
+
return {
|
|
1287
|
+
status: 409,
|
|
1288
|
+
body: {
|
|
1289
|
+
error: "A record with this value already exists",
|
|
1290
|
+
code: "UNIQUE_VIOLATION",
|
|
1291
|
+
...object ? { object } : {}
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
return {
|
|
1296
|
+
status: 500,
|
|
1297
|
+
body: { error: "Internal data error", code: "DATABASE_ERROR" }
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
return { status: 400, body: { error: raw || "Bad request" } };
|
|
1301
|
+
}
|
|
1302
|
+
function sendError(res, error, object) {
|
|
1303
|
+
if (typeof error?.status === "number" && error.status >= 400 && error.status < 600) {
|
|
1304
|
+
const safeMsg = typeof error.message === "string" && error.message.length < 500 ? error.message : "Request failed";
|
|
1305
|
+
res.status(error.status).json({
|
|
1306
|
+
error: safeMsg,
|
|
1307
|
+
...error.code ? { code: error.code } : {},
|
|
1308
|
+
...Array.isArray(error.issues) ? { issues: error.issues } : {}
|
|
1309
|
+
});
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
const mapped = mapDataError(error, object);
|
|
1313
|
+
res.status(mapped.status).json(mapped.body);
|
|
1314
|
+
}
|
|
1315
|
+
function isExpectedDataStatus(status) {
|
|
1316
|
+
return status === 403 || status === 404 || status === 409 || status === 502 || status === 503;
|
|
1317
|
+
}
|
|
1282
1318
|
var IMPORT_JOB_OBJECT = "sys_import_job";
|
|
1283
1319
|
var IMPORT_JOB_MAX_ROWS = 5e4;
|
|
1284
1320
|
var IMPORT_JOB_RESULTS_CAP = 500;
|
|
@@ -1383,7 +1419,7 @@ function rowsToCsv(fields, rows, includeHeader, metaMap) {
|
|
|
1383
1419
|
}
|
|
1384
1420
|
return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
|
|
1385
1421
|
}
|
|
1386
|
-
async function createXlsxStream(res) {
|
|
1422
|
+
async function createXlsxStream(res, useStyles = false) {
|
|
1387
1423
|
const { PassThrough } = await import("stream");
|
|
1388
1424
|
const ExcelJS = (await import("exceljs")).default ?? await import("exceljs");
|
|
1389
1425
|
const passthrough = new PassThrough();
|
|
@@ -1400,7 +1436,7 @@ async function createXlsxStream(res) {
|
|
|
1400
1436
|
});
|
|
1401
1437
|
passthrough.on("error", reject);
|
|
1402
1438
|
});
|
|
1403
|
-
const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles
|
|
1439
|
+
const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles });
|
|
1404
1440
|
const ws = wb.addWorksheet("Export");
|
|
1405
1441
|
return {
|
|
1406
1442
|
ws,
|
|
@@ -1411,8 +1447,8 @@ async function createXlsxStream(res) {
|
|
|
1411
1447
|
}
|
|
1412
1448
|
};
|
|
1413
1449
|
}
|
|
1414
|
-
var RestServer = class {
|
|
1415
|
-
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider) {
|
|
1450
|
+
var RestServer = class _RestServer {
|
|
1451
|
+
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider) {
|
|
1416
1452
|
/**
|
|
1417
1453
|
* Short-TTL cache for `hostname → environmentId` (P1-4). `resolveByHostname`
|
|
1418
1454
|
* is a control-plane lookup (typically a DB query) that otherwise runs on
|
|
@@ -1465,6 +1501,7 @@ var RestServer = class {
|
|
|
1465
1501
|
this.analyticsServiceProvider = analyticsServiceProvider;
|
|
1466
1502
|
this.settingsServiceProvider = settingsServiceProvider;
|
|
1467
1503
|
this.serviceExistsProvider = serviceExistsProvider;
|
|
1504
|
+
this.securityServiceProvider = securityServiceProvider;
|
|
1468
1505
|
}
|
|
1469
1506
|
/**
|
|
1470
1507
|
* Resolve the protocol for a given request. When `environmentId` is present
|
|
@@ -1703,6 +1740,54 @@ var RestServer = class {
|
|
|
1703
1740
|
perReq.set(key, pending);
|
|
1704
1741
|
return pending;
|
|
1705
1742
|
}
|
|
1743
|
+
/**
|
|
1744
|
+
* [ADR-0046 §6.7] The audience-evaluation view of the caller for book/doc
|
|
1745
|
+
* gating. `permissionSets` resolves through the security service's
|
|
1746
|
+
* `resolvePermissionSetNames` — the SAME resolution as data-plane
|
|
1747
|
+
* enforcement (positions expanded, additive baseline), so the docs gate
|
|
1748
|
+
* can never drift from it. `permissionSets` stays undefined when the
|
|
1749
|
+
* service is absent or resolution fails; `audienceAllows` then DENIES
|
|
1750
|
+
* permission-set-gated audiences (fail closed, ADR-0049). Resolution is
|
|
1751
|
+
* skipped unless `needPermissionSets` — callers pass true only when a
|
|
1752
|
+
* `{ permissionSet }` audience is actually in play.
|
|
1753
|
+
*/
|
|
1754
|
+
async resolveAudienceCaller(environmentId, req, opts) {
|
|
1755
|
+
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => void 0);
|
|
1756
|
+
const authenticated = !!ctx?.userId;
|
|
1757
|
+
if (!authenticated || !opts.needPermissionSets || !this.securityServiceProvider) {
|
|
1758
|
+
return { authenticated };
|
|
1759
|
+
}
|
|
1760
|
+
try {
|
|
1761
|
+
const svc = await this.securityServiceProvider(environmentId);
|
|
1762
|
+
if (!svc || typeof svc.resolvePermissionSetNames !== "function") return { authenticated };
|
|
1763
|
+
const names = await svc.resolvePermissionSetNames(ctx);
|
|
1764
|
+
return { authenticated, permissionSets: Array.isArray(names) ? names : [] };
|
|
1765
|
+
} catch {
|
|
1766
|
+
return { authenticated };
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
/** Whether any of these books carries a `{ permissionSet }` audience. */
|
|
1770
|
+
static anyPermissionSetAudience(books) {
|
|
1771
|
+
return books.some(
|
|
1772
|
+
(b) => b && typeof b === "object" && b.audience && typeof b.audience === "object" && typeof b.audience.permissionSet === "string"
|
|
1773
|
+
);
|
|
1774
|
+
}
|
|
1775
|
+
/** Coerce a getMetaItems result (array | {items}) into an array. */
|
|
1776
|
+
static metaItemsArray(raw) {
|
|
1777
|
+
if (Array.isArray(raw)) return raw;
|
|
1778
|
+
if (raw && typeof raw === "object" && Array.isArray(raw.items)) return raw.items;
|
|
1779
|
+
return [];
|
|
1780
|
+
}
|
|
1781
|
+
/** Fetch every book of the environment, shaped for the audience resolver. */
|
|
1782
|
+
async fetchAudienceBooks(p, environmentId) {
|
|
1783
|
+
const raw = await p.getMetaItems({
|
|
1784
|
+
type: "book",
|
|
1785
|
+
...environmentId ? { environmentId } : {}
|
|
1786
|
+
}).catch(() => []);
|
|
1787
|
+
return _RestServer.metaItemsArray(raw).map(
|
|
1788
|
+
(b) => b && typeof b === "object" ? { ...b, packageId: b._packageId } : b
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1706
1791
|
/** Heavy path behind `resolveExecCtx` — resolve identity + RBAC/RLS + localization. */
|
|
1707
1792
|
async computeExecCtx(environmentId, req) {
|
|
1708
1793
|
try {
|
|
@@ -1793,7 +1878,7 @@ var RestServer = class {
|
|
|
1793
1878
|
userId: authz.userId,
|
|
1794
1879
|
tenantId: authz.tenantId,
|
|
1795
1880
|
email: authz.email,
|
|
1796
|
-
|
|
1881
|
+
positions: authz.positions,
|
|
1797
1882
|
permissions: authz.permissions,
|
|
1798
1883
|
systemPermissions: authz.systemPermissions,
|
|
1799
1884
|
...authz.tabPermissions ? { tabPermissions: authz.tabPermissions } : {},
|
|
@@ -2211,6 +2296,8 @@ var RestServer = class {
|
|
|
2211
2296
|
this.registerReportsEndpoints(bp);
|
|
2212
2297
|
this.registerApprovalsEndpoints(bp);
|
|
2213
2298
|
this.registerAnalyticsEndpoints(bp);
|
|
2299
|
+
this.registerSecurityEndpoints(bp);
|
|
2300
|
+
this.registerSecurityExplainEndpoints(bp);
|
|
2214
2301
|
this.registerDataActionEndpoints(bp);
|
|
2215
2302
|
if (this.config.api.enableCrud) {
|
|
2216
2303
|
this.registerCrudEndpoints(bp);
|
|
@@ -2251,8 +2338,7 @@ var RestServer = class {
|
|
|
2251
2338
|
if (this.config.api.enableUi) {
|
|
2252
2339
|
discovery.routes.ui = `${realBase}/ui`;
|
|
2253
2340
|
}
|
|
2254
|
-
|
|
2255
|
-
if (mcpEnabled) {
|
|
2341
|
+
if ((0, import_types.isMcpServerEnabled)()) {
|
|
2256
2342
|
const unscopedBase = isScoped ? basePath.replace(/\/(environments|projects)\/:environmentId$/, "") : basePath;
|
|
2257
2343
|
discovery.routes.mcp = `${unscopedBase}/mcp`;
|
|
2258
2344
|
} else {
|
|
@@ -2573,6 +2659,48 @@ var RestServer = class {
|
|
|
2573
2659
|
visible = Array.isArray(raw) ? filtered : { ...raw, items: filtered };
|
|
2574
2660
|
}
|
|
2575
2661
|
}
|
|
2662
|
+
if (req.params.type === "book") {
|
|
2663
|
+
const raw = visible;
|
|
2664
|
+
const list = _RestServer.metaItemsArray(raw);
|
|
2665
|
+
if (list.length > 0) {
|
|
2666
|
+
const { audienceAllows } = await import("@objectstack/spec/system");
|
|
2667
|
+
const caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2668
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience(list)
|
|
2669
|
+
});
|
|
2670
|
+
const filtered = list.filter((b) => b && typeof b === "object" && audienceAllows(b.audience, caller));
|
|
2671
|
+
visible = Array.isArray(raw) ? filtered : { ...raw, items: filtered };
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
if (req.params.type === "doc") {
|
|
2675
|
+
const raw = visible;
|
|
2676
|
+
const list = _RestServer.metaItemsArray(raw);
|
|
2677
|
+
if (list.length > 0) {
|
|
2678
|
+
const { audienceAllows, docAudienceAllows, resolveDocAudiences } = await import("@objectstack/spec/system");
|
|
2679
|
+
const books = await this.fetchAudienceBooks(p, environmentId);
|
|
2680
|
+
const caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2681
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience(books)
|
|
2682
|
+
});
|
|
2683
|
+
let filtered;
|
|
2684
|
+
if (caller.authenticated && !_RestServer.anyPermissionSetAudience(books)) {
|
|
2685
|
+
filtered = list;
|
|
2686
|
+
} else {
|
|
2687
|
+
const corpus = list.filter((d) => d && typeof d === "object").map((d) => ({
|
|
2688
|
+
name: d.name,
|
|
2689
|
+
group: d.group,
|
|
2690
|
+
tags: d.tags,
|
|
2691
|
+
order: d.order,
|
|
2692
|
+
packageId: d._packageId
|
|
2693
|
+
}));
|
|
2694
|
+
const audiences = resolveDocAudiences(books, corpus);
|
|
2695
|
+
filtered = list.filter((d) => {
|
|
2696
|
+
if (!d || typeof d !== "object") return false;
|
|
2697
|
+
const eff = audiences.get(d.name);
|
|
2698
|
+
return eff ? docAudienceAllows(eff, caller) : audienceAllows("org", caller);
|
|
2699
|
+
});
|
|
2700
|
+
}
|
|
2701
|
+
visible = Array.isArray(raw) ? filtered : { ...raw, items: filtered };
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2576
2704
|
if (req.params.type === "doc") {
|
|
2577
2705
|
const locale = this.extractLocale(req);
|
|
2578
2706
|
const { resolveDocLocale } = await import("@objectstack/spec/system");
|
|
@@ -2646,7 +2774,7 @@ var RestServer = class {
|
|
|
2646
2774
|
const prot = await this.resolveProtocol(environmentId, req);
|
|
2647
2775
|
const locale = this.extractLocale(req);
|
|
2648
2776
|
const packageId = req.query?.package || void 0;
|
|
2649
|
-
const { resolveBookTree, deriveImplicitPackageBook,
|
|
2777
|
+
const { resolveBookTree, deriveImplicitPackageBook, audienceAllows, resolveDocAudiences, docAudienceAllows, resolveDocLocale } = await import("@objectstack/spec/system");
|
|
2650
2778
|
const norm = (raw) => Array.isArray(raw) ? raw : raw && Array.isArray(raw.items) ? raw.items : [];
|
|
2651
2779
|
const books = norm(await prot.getMetaItems({
|
|
2652
2780
|
type: "book",
|
|
@@ -2657,9 +2785,16 @@ var RestServer = class {
|
|
|
2657
2785
|
if (!book) {
|
|
2658
2786
|
book = deriveImplicitPackageBook(req.params.name, req.params.name);
|
|
2659
2787
|
}
|
|
2660
|
-
const
|
|
2661
|
-
|
|
2662
|
-
|
|
2788
|
+
const audienceBooks = books.map((b) => b && typeof b === "object" ? { ...b, packageId: b._packageId } : b);
|
|
2789
|
+
const caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2790
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience([book, ...audienceBooks])
|
|
2791
|
+
});
|
|
2792
|
+
if (!audienceAllows(book.audience, caller)) {
|
|
2793
|
+
if (!caller.authenticated) {
|
|
2794
|
+
sendError(res, { code: "unauthorized", message: "This documentation requires sign-in", status: 401 });
|
|
2795
|
+
} else {
|
|
2796
|
+
sendError(res, { code: "PERMISSION_DENIED", message: "This documentation is limited to holders of a permission set you do not have", status: 403 });
|
|
2797
|
+
}
|
|
2663
2798
|
return;
|
|
2664
2799
|
}
|
|
2665
2800
|
const docs = norm(await prot.getMetaItems({
|
|
@@ -2676,6 +2811,14 @@ var RestServer = class {
|
|
|
2676
2811
|
packageId: d._packageId
|
|
2677
2812
|
}));
|
|
2678
2813
|
const tree = resolveBookTree(book, docs, book._packageId);
|
|
2814
|
+
const gatedTreePossible = !caller.authenticated || _RestServer.anyPermissionSetAudience(audienceBooks);
|
|
2815
|
+
if (gatedTreePossible) {
|
|
2816
|
+
const audiences = resolveDocAudiences(audienceBooks, docs);
|
|
2817
|
+
tree.groups = tree.groups.map((g) => ({
|
|
2818
|
+
...g,
|
|
2819
|
+
entries: g.entries.filter((e) => !e.doc || docAudienceAllows(audiences.get(e.doc), caller))
|
|
2820
|
+
})).filter((g) => g.entries.some((e) => e.doc || e.href));
|
|
2821
|
+
}
|
|
2679
2822
|
res.json(tree);
|
|
2680
2823
|
} catch (error) {
|
|
2681
2824
|
logError("[REST] Unhandled error:", error);
|
|
@@ -2710,7 +2853,7 @@ var RestServer = class {
|
|
|
2710
2853
|
const isDraftRead = typeof req.query?.state === "string" && req.query.state.toLowerCase() === "draft";
|
|
2711
2854
|
const previewDrafts = typeof req.query?.preview === "string" && req.query.preview.toLowerCase() === "draft";
|
|
2712
2855
|
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") {
|
|
2856
|
+
if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead && !previewDrafts && !packageScoped && req.params.type !== "doc" && req.params.type !== "book") {
|
|
2714
2857
|
const cacheRequest = {
|
|
2715
2858
|
ifNoneMatch: req.headers["if-none-match"],
|
|
2716
2859
|
ifModifiedSince: req.headers["if-modified-since"]
|
|
@@ -2779,6 +2922,47 @@ var RestServer = class {
|
|
|
2779
2922
|
const serviceGate = registered ? (n) => registered.has(n) : void 0;
|
|
2780
2923
|
if (serviceGate) visible = this.filterDashboardForUser(visible, serviceGate);
|
|
2781
2924
|
}
|
|
2925
|
+
if ((req.params.type === "book" || req.params.type === "doc") && visible) {
|
|
2926
|
+
const { audienceAllows, docAudienceAllows, resolveDocAudiences } = await import("@objectstack/spec/system");
|
|
2927
|
+
const target = isMetaEnvelope(visible) ? visible.item : visible;
|
|
2928
|
+
let caller;
|
|
2929
|
+
let allowed;
|
|
2930
|
+
if (req.params.type === "book") {
|
|
2931
|
+
caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2932
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience([target])
|
|
2933
|
+
});
|
|
2934
|
+
allowed = audienceAllows(target?.audience, caller);
|
|
2935
|
+
} else {
|
|
2936
|
+
const books = await this.fetchAudienceBooks(p, environmentId);
|
|
2937
|
+
caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2938
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience(books)
|
|
2939
|
+
});
|
|
2940
|
+
if (caller.authenticated && !_RestServer.anyPermissionSetAudience(books)) {
|
|
2941
|
+
allowed = true;
|
|
2942
|
+
} else {
|
|
2943
|
+
const corpus = _RestServer.metaItemsArray(await p.getMetaItems({
|
|
2944
|
+
type: "doc",
|
|
2945
|
+
...environmentId ? { environmentId } : {}
|
|
2946
|
+
}).catch(() => [])).filter((d) => d && typeof d === "object").map((d) => ({
|
|
2947
|
+
name: d.name,
|
|
2948
|
+
group: d.group,
|
|
2949
|
+
tags: d.tags,
|
|
2950
|
+
order: d.order,
|
|
2951
|
+
packageId: d._packageId
|
|
2952
|
+
}));
|
|
2953
|
+
const audiences = resolveDocAudiences(books, corpus);
|
|
2954
|
+
allowed = docAudienceAllows(audiences.get(target?.name), caller);
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
if (!allowed) {
|
|
2958
|
+
if (!caller.authenticated) {
|
|
2959
|
+
sendError(res, { code: "unauthorized", message: "This documentation requires sign-in", status: 401 });
|
|
2960
|
+
} else {
|
|
2961
|
+
sendError(res, { code: "PERMISSION_DENIED", message: "This documentation is limited to holders of a permission set you do not have", status: 403 });
|
|
2962
|
+
}
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2782
2966
|
if (req.params.type === "doc" && visible) {
|
|
2783
2967
|
const locale = this.extractLocale(req);
|
|
2784
2968
|
const { resolveDocLocale } = await import("@objectstack/spec/system");
|
|
@@ -3779,9 +3963,11 @@ var RestServer = class {
|
|
|
3779
3963
|
const includeHeader = String(q.header ?? "true").toLowerCase() !== "false";
|
|
3780
3964
|
const HARD_CAP = 5e4;
|
|
3781
3965
|
const MAX_CHUNK = 5e3;
|
|
3966
|
+
const STYLE_ROW_CAP = 1e4;
|
|
3782
3967
|
const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 1e4;
|
|
3783
3968
|
const limit = Math.min(requestedLimit, HARD_CAP);
|
|
3784
3969
|
const chunkSize = Math.min(MAX_CHUNK, Math.max(50, q.page != null ? Number(q.page) || 500 : 500));
|
|
3970
|
+
const styled = format === "xlsx" && limit <= STYLE_ROW_CAP;
|
|
3785
3971
|
let filter = void 0;
|
|
3786
3972
|
if (typeof q.filter === "string" && q.filter.length > 0) {
|
|
3787
3973
|
try {
|
|
@@ -3848,12 +4034,13 @@ var RestServer = class {
|
|
|
3848
4034
|
}
|
|
3849
4035
|
res.header("X-Export-Format", format);
|
|
3850
4036
|
res.header("X-Export-Limit", String(limit));
|
|
4037
|
+
if (format === "xlsx") res.header("X-Export-Styles", styled ? "applied" : "dropped");
|
|
3851
4038
|
res.header("Cache-Control", "no-store");
|
|
3852
4039
|
let exported = 0;
|
|
3853
4040
|
let firstChunk = true;
|
|
3854
4041
|
let skip = 0;
|
|
3855
4042
|
if (format === "json") res.write("[");
|
|
3856
|
-
const xlsx = format === "xlsx" ? await createXlsxStream(res) : null;
|
|
4043
|
+
const xlsx = format === "xlsx" ? await createXlsxStream(res, styled) : null;
|
|
3857
4044
|
while (exported < limit) {
|
|
3858
4045
|
const take = Math.min(chunkSize, limit - exported);
|
|
3859
4046
|
const findArgs = {
|
|
@@ -3881,8 +4068,16 @@ var RestServer = class {
|
|
|
3881
4068
|
if (firstChunk && includeHeader) {
|
|
3882
4069
|
xlsx.ws.addRow((fields ?? []).map((f) => headerLabel(f, metaMap))).commit();
|
|
3883
4070
|
}
|
|
4071
|
+
const cols = fields ?? [];
|
|
3884
4072
|
for (const row of rows) {
|
|
3885
|
-
xlsx.ws.addRow(formatRowCells(row,
|
|
4073
|
+
const r = xlsx.ws.addRow(formatRowCells(row, cols, metaMap));
|
|
4074
|
+
if (styled) {
|
|
4075
|
+
cols.forEach((f, i) => {
|
|
4076
|
+
const argb = cellFontColor(row?.[f], metaMap.get(f));
|
|
4077
|
+
if (argb) r.getCell(i + 1).font = { color: { argb } };
|
|
4078
|
+
});
|
|
4079
|
+
}
|
|
4080
|
+
r.commit();
|
|
3886
4081
|
}
|
|
3887
4082
|
} else {
|
|
3888
4083
|
for (let i = 0; i < rows.length; i++) {
|
|
@@ -4511,6 +4706,100 @@ var RestServer = class {
|
|
|
4511
4706
|
metadata: { summary: "Run a semantic-layer dataset (preview/query)", tags: ["analytics"] }
|
|
4512
4707
|
});
|
|
4513
4708
|
}
|
|
4709
|
+
/**
|
|
4710
|
+
* [ADR-0090 D6] Access-explanation endpoint — the REST face of the
|
|
4711
|
+
* explain engine (framework#2696).
|
|
4712
|
+
*
|
|
4713
|
+
* GET {basePath}/security/explain?object=…&operation=…&userId=…
|
|
4714
|
+
* POST {basePath}/security/explain body: { object, operation, userId? }
|
|
4715
|
+
*
|
|
4716
|
+
* Delegates to the security service's `explain(request, callerContext)`
|
|
4717
|
+
* (`SecurityPlugin.explainAccessForCaller`) — the same code paths the
|
|
4718
|
+
* enforcement middleware runs, so the report is explained by
|
|
4719
|
+
* construction. Caller authorization lives in the SERVICE, not here:
|
|
4720
|
+
* explaining ANOTHER user requires `manage_users` or a delegated
|
|
4721
|
+
* `adminScope` covering that user (D12); the service's
|
|
4722
|
+
* `PermissionDeniedError` maps to 403. The route itself only insists on
|
|
4723
|
+
* an authenticated caller (an access report is sensitive even about
|
|
4724
|
+
* oneself, and the anonymous `guest` posture is not this endpoint's
|
|
4725
|
+
* business) and returns 501 when no security service exposing `explain`
|
|
4726
|
+
* is mounted (a deployment without `@objectstack/plugin-security`).
|
|
4727
|
+
*/
|
|
4728
|
+
registerSecurityExplainEndpoints(basePath) {
|
|
4729
|
+
const isScoped = basePath.includes("/environments/:environmentId");
|
|
4730
|
+
const resolveService = async (environmentId, req) => {
|
|
4731
|
+
try {
|
|
4732
|
+
const envId = await this.resolveRequestEnvironmentId(environmentId, req);
|
|
4733
|
+
if (envId && envId !== "platform" && this.kernelManager) {
|
|
4734
|
+
const kernel = await this.kernelManager.getOrCreate(envId);
|
|
4735
|
+
const svc = await kernel.getServiceAsync("security").catch(() => void 0);
|
|
4736
|
+
if (svc) return svc;
|
|
4737
|
+
}
|
|
4738
|
+
} catch {
|
|
4739
|
+
}
|
|
4740
|
+
if (!this.securityServiceProvider) return void 0;
|
|
4741
|
+
try {
|
|
4742
|
+
return await this.securityServiceProvider(environmentId);
|
|
4743
|
+
} catch {
|
|
4744
|
+
return void 0;
|
|
4745
|
+
}
|
|
4746
|
+
};
|
|
4747
|
+
const handler = async (req, res) => {
|
|
4748
|
+
try {
|
|
4749
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
4750
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
4751
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
4752
|
+
if (!context?.userId) {
|
|
4753
|
+
return res.status(401).json({
|
|
4754
|
+
code: "UNAUTHORIZED",
|
|
4755
|
+
message: "The access-explanation endpoint requires an authenticated caller."
|
|
4756
|
+
});
|
|
4757
|
+
}
|
|
4758
|
+
const svc = await resolveService(environmentId, req);
|
|
4759
|
+
if (!svc || typeof svc.explain !== "function") {
|
|
4760
|
+
return res.status(501).json({
|
|
4761
|
+
code: "NOT_IMPLEMENTED",
|
|
4762
|
+
message: "Access explanation is not available on this deployment (no security service with explain)."
|
|
4763
|
+
});
|
|
4764
|
+
}
|
|
4765
|
+
const src = req.method === "GET" ? req.query ?? {} : req.body ?? {};
|
|
4766
|
+
const { ExplainRequestSchema } = await import("@objectstack/spec/security");
|
|
4767
|
+
const parsed = ExplainRequestSchema.safeParse({
|
|
4768
|
+
object: src.object,
|
|
4769
|
+
operation: src.operation ?? "read",
|
|
4770
|
+
...src.userId != null && src.userId !== "" ? { userId: src.userId } : {}
|
|
4771
|
+
});
|
|
4772
|
+
if (!parsed.success) {
|
|
4773
|
+
return res.status(400).json({
|
|
4774
|
+
code: "VALIDATION_FAILED",
|
|
4775
|
+
message: "Invalid explain request \u2014 expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string }.",
|
|
4776
|
+
detail: String(parsed.error?.message ?? "").slice(0, 1e3)
|
|
4777
|
+
});
|
|
4778
|
+
}
|
|
4779
|
+
const decision = await svc.explain(parsed.data, context);
|
|
4780
|
+
res.json(decision);
|
|
4781
|
+
} catch (error) {
|
|
4782
|
+
const msg = String(error?.message ?? error ?? "");
|
|
4783
|
+
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || msg.startsWith("[Security] Access denied")) {
|
|
4784
|
+
return res.status(403).json({ code: "PERMISSION_DENIED", message: msg.slice(0, 1e3) });
|
|
4785
|
+
}
|
|
4786
|
+
logError("[REST] Security explain error:", error);
|
|
4787
|
+
res.status(500).json({ code: "EXPLAIN_FAILED", error: msg.slice(0, 500) });
|
|
4788
|
+
}
|
|
4789
|
+
};
|
|
4790
|
+
this.routeManager.register({
|
|
4791
|
+
method: "GET",
|
|
4792
|
+
path: `${basePath}/security/explain`,
|
|
4793
|
+
handler,
|
|
4794
|
+
metadata: { summary: "Explain why a principal can (or cannot) perform an operation on an object (ADR-0090 D6)", tags: ["security"] }
|
|
4795
|
+
});
|
|
4796
|
+
this.routeManager.register({
|
|
4797
|
+
method: "POST",
|
|
4798
|
+
path: `${basePath}/security/explain`,
|
|
4799
|
+
handler,
|
|
4800
|
+
metadata: { summary: "Explain why a principal can (or cannot) perform an operation on an object (ADR-0090 D6)", tags: ["security"] }
|
|
4801
|
+
});
|
|
4802
|
+
}
|
|
4514
4803
|
registerSharingEndpoints(basePath) {
|
|
4515
4804
|
const { crud } = this.config;
|
|
4516
4805
|
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
@@ -4753,6 +5042,106 @@ var RestServer = class {
|
|
|
4753
5042
|
metadata: { summary: "Re-evaluate a sharing rule and reconcile grants", tags: ["sharing"] }
|
|
4754
5043
|
});
|
|
4755
5044
|
}
|
|
5045
|
+
/**
|
|
5046
|
+
* Register the security admin endpoints (ADR-0090 D5/D9) — suggested
|
|
5047
|
+
* audience bindings. A package permission set declaring `isDefault: true`
|
|
5048
|
+
* is an install-time SUGGESTION to bind it to the `everyone` position;
|
|
5049
|
+
* these routes surface pending suggestions and let a tenant admin resolve
|
|
5050
|
+
* them. The `security` service (plugin-security) does the real gating:
|
|
5051
|
+
* tenant-admin pre-check on all three, and confirm writes the binding
|
|
5052
|
+
* with the caller's execution context so the audience-anchor and
|
|
5053
|
+
* delegated-admin gates enforce it — never auto-bound, never system.
|
|
5054
|
+
*
|
|
5055
|
+
* GET {basePath}/security/suggested-bindings?status=&packageId=
|
|
5056
|
+
* POST {basePath}/security/suggested-bindings/:id/confirm
|
|
5057
|
+
* POST {basePath}/security/suggested-bindings/:id/dismiss
|
|
5058
|
+
*
|
|
5059
|
+
* Routes return 501 when the `security` service is not registered
|
|
5060
|
+
* (deployment without plugin-security). Typed service errors carry their
|
|
5061
|
+
* HTTP status (403 permission / 404 not found / 409 state).
|
|
5062
|
+
*/
|
|
5063
|
+
registerSecurityEndpoints(basePath) {
|
|
5064
|
+
const dataPath = basePath;
|
|
5065
|
+
const isScoped = basePath.includes("/environments/:environmentId");
|
|
5066
|
+
const resolveService = async (environmentId) => {
|
|
5067
|
+
if (!this.securityServiceProvider) return void 0;
|
|
5068
|
+
try {
|
|
5069
|
+
const svc = await this.securityServiceProvider(environmentId);
|
|
5070
|
+
return svc && typeof svc.listAudienceBindingSuggestions === "function" ? svc : void 0;
|
|
5071
|
+
} catch {
|
|
5072
|
+
return void 0;
|
|
5073
|
+
}
|
|
5074
|
+
};
|
|
5075
|
+
const respond501 = (res) => res.status(501).json({
|
|
5076
|
+
code: "NOT_IMPLEMENTED",
|
|
5077
|
+
message: "Security service is not configured on this deployment"
|
|
5078
|
+
});
|
|
5079
|
+
const handleError = (err, res, defaultCode) => {
|
|
5080
|
+
const status = typeof err?.statusCode === "number" ? err.statusCode : 500;
|
|
5081
|
+
if (status !== 500) {
|
|
5082
|
+
return res.status(status).json({ code: err?.code ?? defaultCode, error: String(err?.message ?? err) });
|
|
5083
|
+
}
|
|
5084
|
+
logError(`[REST] suggested-bindings ${defaultCode}:`, err);
|
|
5085
|
+
return res.status(500).json({ code: defaultCode, error: String(err?.message ?? err).slice(0, 500) });
|
|
5086
|
+
};
|
|
5087
|
+
this.routeManager.register({
|
|
5088
|
+
method: "GET",
|
|
5089
|
+
path: `${dataPath}/security/suggested-bindings`,
|
|
5090
|
+
handler: async (req, res) => {
|
|
5091
|
+
try {
|
|
5092
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
5093
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
5094
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
5095
|
+
const svc = await resolveService(environmentId);
|
|
5096
|
+
if (!svc) return respond501(res);
|
|
5097
|
+
const result = await svc.listAudienceBindingSuggestions(context ?? {}, {
|
|
5098
|
+
status: req.query?.status ? String(req.query.status) : void 0,
|
|
5099
|
+
packageId: req.query?.packageId ? String(req.query.packageId) : void 0
|
|
5100
|
+
});
|
|
5101
|
+
res.json({ data: result });
|
|
5102
|
+
} catch (err) {
|
|
5103
|
+
handleError(err, res, "SUGGESTION_LIST_FAILED");
|
|
5104
|
+
}
|
|
5105
|
+
},
|
|
5106
|
+
metadata: { summary: "List suggested audience bindings (ADR-0090 D5/D9)", tags: ["security"] }
|
|
5107
|
+
});
|
|
5108
|
+
this.routeManager.register({
|
|
5109
|
+
method: "POST",
|
|
5110
|
+
path: `${dataPath}/security/suggested-bindings/:id/confirm`,
|
|
5111
|
+
handler: async (req, res) => {
|
|
5112
|
+
try {
|
|
5113
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
5114
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
5115
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
5116
|
+
const svc = await resolveService(environmentId);
|
|
5117
|
+
if (!svc) return respond501(res);
|
|
5118
|
+
const result = await svc.confirmAudienceBindingSuggestion(context ?? {}, String(req.params.id));
|
|
5119
|
+
res.json({ data: result });
|
|
5120
|
+
} catch (err) {
|
|
5121
|
+
handleError(err, res, "SUGGESTION_CONFIRM_FAILED");
|
|
5122
|
+
}
|
|
5123
|
+
},
|
|
5124
|
+
metadata: { summary: "Confirm a suggested audience binding (creates the everyone/guest binding)", tags: ["security"] }
|
|
5125
|
+
});
|
|
5126
|
+
this.routeManager.register({
|
|
5127
|
+
method: "POST",
|
|
5128
|
+
path: `${dataPath}/security/suggested-bindings/:id/dismiss`,
|
|
5129
|
+
handler: async (req, res) => {
|
|
5130
|
+
try {
|
|
5131
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
5132
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
5133
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
5134
|
+
const svc = await resolveService(environmentId);
|
|
5135
|
+
if (!svc) return respond501(res);
|
|
5136
|
+
const result = await svc.dismissAudienceBindingSuggestion(context ?? {}, String(req.params.id));
|
|
5137
|
+
res.json({ data: result });
|
|
5138
|
+
} catch (err) {
|
|
5139
|
+
handleError(err, res, "SUGGESTION_DISMISS_FAILED");
|
|
5140
|
+
}
|
|
5141
|
+
},
|
|
5142
|
+
metadata: { summary: "Dismiss a suggested audience binding", tags: ["security"] }
|
|
5143
|
+
});
|
|
5144
|
+
}
|
|
4756
5145
|
/**
|
|
4757
5146
|
* Register saved-report + scheduled-digest endpoints (M11.C16).
|
|
4758
5147
|
*
|
|
@@ -5550,6 +5939,20 @@ function registerPackageRoutes(server, packageService, basePath = "/api/v1", opt
|
|
|
5550
5939
|
try {
|
|
5551
5940
|
const packageId = req.params.id;
|
|
5552
5941
|
const version = req.query?.version;
|
|
5942
|
+
if (!version && typeof options.protocol?.deletePackage === "function") {
|
|
5943
|
+
const result2 = await options.protocol.deletePackage({ packageId });
|
|
5944
|
+
if (result2.failedCount === 0) {
|
|
5945
|
+
res.json({
|
|
5946
|
+
success: true,
|
|
5947
|
+
message: `Deleted ${packageId}`,
|
|
5948
|
+
deletedCount: result2.deletedCount,
|
|
5949
|
+
cleanups: result2.cleanups
|
|
5950
|
+
});
|
|
5951
|
+
return;
|
|
5952
|
+
}
|
|
5953
|
+
res.status(400).json({ success: false, failed: result2.failed, cleanups: result2.cleanups });
|
|
5954
|
+
return;
|
|
5955
|
+
}
|
|
5553
5956
|
const result = await packageService.delete(packageId, version);
|
|
5554
5957
|
if (result.success) {
|
|
5555
5958
|
res.json({
|
|
@@ -5749,6 +6152,13 @@ function createRestApiPlugin(config = {}) {
|
|
|
5749
6152
|
return void 0;
|
|
5750
6153
|
}
|
|
5751
6154
|
};
|
|
6155
|
+
const securityServiceProvider = async (_environmentId) => {
|
|
6156
|
+
try {
|
|
6157
|
+
return ctx.getService("security");
|
|
6158
|
+
} catch {
|
|
6159
|
+
return void 0;
|
|
6160
|
+
}
|
|
6161
|
+
};
|
|
5752
6162
|
if (!server) {
|
|
5753
6163
|
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
|
|
5754
6164
|
return;
|
|
@@ -5766,7 +6176,7 @@ function createRestApiPlugin(config = {}) {
|
|
|
5766
6176
|
}
|
|
5767
6177
|
};
|
|
5768
6178
|
try {
|
|
5769
|
-
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider);
|
|
6179
|
+
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider);
|
|
5770
6180
|
restServer.registerRoutes();
|
|
5771
6181
|
ctx.logger.info("REST API successfully registered");
|
|
5772
6182
|
if (config.api?.api?.requireAuth === false) {
|
|
@@ -5822,6 +6232,13 @@ function createRestApiPlugin(config = {}) {
|
|
|
5822
6232
|
RestServer,
|
|
5823
6233
|
RouteGroupBuilder,
|
|
5824
6234
|
RouteManager,
|
|
5825
|
-
|
|
6235
|
+
buildFieldMetaMap,
|
|
6236
|
+
coerceRow,
|
|
6237
|
+
createRestApiPlugin,
|
|
6238
|
+
isMetaEnvelope,
|
|
6239
|
+
parseCsvToRows,
|
|
6240
|
+
parseXlsxToRows,
|
|
6241
|
+
prepareImportRequest,
|
|
6242
|
+
runImport
|
|
5826
6243
|
});
|
|
5827
6244
|
//# sourceMappingURL=index.cjs.map
|