@objectstack/rest 13.0.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 +577 -160
- 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 +568 -158
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
package/dist/index.js
CHANGED
|
@@ -288,6 +288,22 @@ function optionLabel(value, options) {
|
|
|
288
288
|
const hit = options.find((o) => o && o.value === value);
|
|
289
289
|
return hit?.label ?? value;
|
|
290
290
|
}
|
|
291
|
+
function toArgb(color) {
|
|
292
|
+
if (typeof color !== "string") return void 0;
|
|
293
|
+
const hex = color.trim().replace(/^#/, "");
|
|
294
|
+
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
|
|
295
|
+
const [r, g, b] = hex;
|
|
296
|
+
return `FF${r}${r}${g}${g}${b}${b}`.toUpperCase();
|
|
297
|
+
}
|
|
298
|
+
if (/^[0-9a-fA-F]{6}$/.test(hex)) return `FF${hex}`.toUpperCase();
|
|
299
|
+
return void 0;
|
|
300
|
+
}
|
|
301
|
+
function cellFontColor(value, meta) {
|
|
302
|
+
if (value === null || value === void 0) return void 0;
|
|
303
|
+
if (!meta || !meta.type || !OPTION_TYPES.has(meta.type) || !meta.options) return void 0;
|
|
304
|
+
const hit = meta.options.find((o) => o && o.value === value);
|
|
305
|
+
return toArgb(hit?.color);
|
|
306
|
+
}
|
|
291
307
|
function displayFromRecord(rec, displayField) {
|
|
292
308
|
if (displayField && rec[displayField] != null) return String(rec[displayField]);
|
|
293
309
|
for (const k of NAME_KEY_FALLBACKS) {
|
|
@@ -874,154 +890,10 @@ function applyMappingToRows(rows, artifact) {
|
|
|
874
890
|
return { ok: true, rows: out };
|
|
875
891
|
}
|
|
876
892
|
|
|
877
|
-
// src/
|
|
878
|
-
var logError = (...args) => globalThis.console?.error(...args);
|
|
879
|
-
var TRANSLATABLE_META_TYPES = /* @__PURE__ */ new Set(["view", "action", "object", "app", "dashboard"]);
|
|
893
|
+
// src/import-prepare.ts
|
|
880
894
|
function isMetaEnvelope(value) {
|
|
881
895
|
return !!value && typeof value === "object" && typeof value.type === "string" && typeof value.name === "string" && value.item != null && typeof value.item === "object" && !Array.isArray(value.item);
|
|
882
896
|
}
|
|
883
|
-
function mapDataError(error, object) {
|
|
884
|
-
if (error?.code === "DELETE_RESTRICTED") {
|
|
885
|
-
return {
|
|
886
|
-
status: 409,
|
|
887
|
-
body: {
|
|
888
|
-
error: error?.message ?? "Cannot delete: dependent records exist",
|
|
889
|
-
code: "DELETE_RESTRICTED",
|
|
890
|
-
...error?.dependentObject ? { dependentObject: error.dependentObject } : {},
|
|
891
|
-
...typeof error?.dependentCount === "number" ? { dependentCount: error.dependentCount } : {},
|
|
892
|
-
...object ? { object } : {}
|
|
893
|
-
}
|
|
894
|
-
};
|
|
895
|
-
}
|
|
896
|
-
if (error?.code === "CONCURRENT_UPDATE" || error?.name === "ConcurrentUpdateError") {
|
|
897
|
-
return {
|
|
898
|
-
status: 409,
|
|
899
|
-
body: {
|
|
900
|
-
error: error?.message ?? "Record was modified by another user",
|
|
901
|
-
code: "CONCURRENT_UPDATE",
|
|
902
|
-
...error?.currentVersion ? { currentVersion: error.currentVersion } : {},
|
|
903
|
-
...error?.currentRecord ? { currentRecord: error.currentRecord } : {},
|
|
904
|
-
...object ? { object } : {}
|
|
905
|
-
}
|
|
906
|
-
};
|
|
907
|
-
}
|
|
908
|
-
if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
|
|
909
|
-
return {
|
|
910
|
-
status: 400,
|
|
911
|
-
body: {
|
|
912
|
-
error: error?.message ?? "Validation failed",
|
|
913
|
-
code: "VALIDATION_FAILED",
|
|
914
|
-
fields: Array.isArray(error?.fields) ? error.fields : [],
|
|
915
|
-
...object ? { object } : {}
|
|
916
|
-
}
|
|
917
|
-
};
|
|
918
|
-
}
|
|
919
|
-
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
|
|
920
|
-
return {
|
|
921
|
-
status: 403,
|
|
922
|
-
body: {
|
|
923
|
-
error: error?.message ?? "Permission denied",
|
|
924
|
-
code: "PERMISSION_DENIED",
|
|
925
|
-
...object ? { object } : {}
|
|
926
|
-
}
|
|
927
|
-
};
|
|
928
|
-
}
|
|
929
|
-
const raw = String(error?.message ?? error ?? "");
|
|
930
|
-
const lower = raw.toLowerCase();
|
|
931
|
-
if (raw.includes("[EnvironmentKernelFactory]") && (lower.includes("missing database_url") || lower.includes("not found"))) {
|
|
932
|
-
const isProvisioning = lower.includes("status='provisioning'") || lower.includes("status='pending'");
|
|
933
|
-
const isFailed = lower.includes("status='failed'");
|
|
934
|
-
return {
|
|
935
|
-
status: isProvisioning ? 503 : isFailed ? 502 : 404,
|
|
936
|
-
body: {
|
|
937
|
-
error: raw,
|
|
938
|
-
code: isProvisioning ? "PROJECT_PROVISIONING" : isFailed ? "PROJECT_PROVISIONING_FAILED" : "PROJECT_NOT_FOUND"
|
|
939
|
-
}
|
|
940
|
-
};
|
|
941
|
-
}
|
|
942
|
-
if (error?.code === "RECORD_NOT_FOUND" || /^Record\s+\S+\s+not found in\s+\S+/i.test(raw)) {
|
|
943
|
-
return {
|
|
944
|
-
status: 404,
|
|
945
|
-
body: {
|
|
946
|
-
error: raw,
|
|
947
|
-
code: "RECORD_NOT_FOUND",
|
|
948
|
-
...object ? { object } : {}
|
|
949
|
-
}
|
|
950
|
-
};
|
|
951
|
-
}
|
|
952
|
-
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);
|
|
953
|
-
if (unknownColumn) {
|
|
954
|
-
const field = unknownColumn[1]?.split(".").pop();
|
|
955
|
-
return {
|
|
956
|
-
status: 400,
|
|
957
|
-
body: {
|
|
958
|
-
error: field ? `Unknown field '${field}'${object ? ` on object '${object}'` : ""}` : "Request references a field that does not exist",
|
|
959
|
-
code: "INVALID_FIELD",
|
|
960
|
-
...field ? { field } : {},
|
|
961
|
-
...object ? { object } : {}
|
|
962
|
-
}
|
|
963
|
-
};
|
|
964
|
-
}
|
|
965
|
-
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);
|
|
966
|
-
if (notNull) {
|
|
967
|
-
const field = notNull[1];
|
|
968
|
-
return {
|
|
969
|
-
status: 400,
|
|
970
|
-
body: {
|
|
971
|
-
error: `${field} is required`,
|
|
972
|
-
code: "VALIDATION_FAILED",
|
|
973
|
-
fields: [{ field, code: "required", message: `${field} is required` }],
|
|
974
|
-
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).`,
|
|
975
|
-
...object ? { object } : {}
|
|
976
|
-
}
|
|
977
|
-
};
|
|
978
|
-
}
|
|
979
|
-
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");
|
|
980
|
-
if (looksLikeUnknownObject) {
|
|
981
|
-
return {
|
|
982
|
-
status: 404,
|
|
983
|
-
body: {
|
|
984
|
-
error: object ? `Object '${object}' is not registered` : "Object not found",
|
|
985
|
-
code: "object_not_found",
|
|
986
|
-
object
|
|
987
|
-
}
|
|
988
|
-
};
|
|
989
|
-
}
|
|
990
|
-
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");
|
|
991
|
-
if (looksLikeSqlLeak) {
|
|
992
|
-
if (lower.includes("unique constraint") || lower.includes("unique violation")) {
|
|
993
|
-
return {
|
|
994
|
-
status: 409,
|
|
995
|
-
body: {
|
|
996
|
-
error: "A record with this value already exists",
|
|
997
|
-
code: "UNIQUE_VIOLATION",
|
|
998
|
-
...object ? { object } : {}
|
|
999
|
-
}
|
|
1000
|
-
};
|
|
1001
|
-
}
|
|
1002
|
-
return {
|
|
1003
|
-
status: 500,
|
|
1004
|
-
body: { error: "Internal data error", code: "DATABASE_ERROR" }
|
|
1005
|
-
};
|
|
1006
|
-
}
|
|
1007
|
-
return { status: 400, body: { error: raw || "Bad request" } };
|
|
1008
|
-
}
|
|
1009
|
-
function sendError(res, error, object) {
|
|
1010
|
-
if (typeof error?.status === "number" && error.status >= 400 && error.status < 600) {
|
|
1011
|
-
const safeMsg = typeof error.message === "string" && error.message.length < 500 ? error.message : "Request failed";
|
|
1012
|
-
res.status(error.status).json({
|
|
1013
|
-
error: safeMsg,
|
|
1014
|
-
...error.code ? { code: error.code } : {},
|
|
1015
|
-
...Array.isArray(error.issues) ? { issues: error.issues } : {}
|
|
1016
|
-
});
|
|
1017
|
-
return;
|
|
1018
|
-
}
|
|
1019
|
-
const mapped = mapDataError(error, object);
|
|
1020
|
-
res.status(mapped.status).json(mapped.body);
|
|
1021
|
-
}
|
|
1022
|
-
function isExpectedDataStatus(status) {
|
|
1023
|
-
return status === 403 || status === 404 || status === 409 || status === 502 || status === 503;
|
|
1024
|
-
}
|
|
1025
897
|
function parseCsvToRows(csv, mapping = {}) {
|
|
1026
898
|
const text = csv.replace(/^\uFEFF/, "");
|
|
1027
899
|
const cells = [];
|
|
@@ -1240,6 +1112,162 @@ async function prepareImportRequest(body, opts) {
|
|
|
1240
1112
|
}
|
|
1241
1113
|
};
|
|
1242
1114
|
}
|
|
1115
|
+
|
|
1116
|
+
// src/rest-server.ts
|
|
1117
|
+
var logError = (...args) => globalThis.console?.error(...args);
|
|
1118
|
+
var TRANSLATABLE_META_TYPES = /* @__PURE__ */ new Set(["view", "action", "object", "app", "dashboard"]);
|
|
1119
|
+
function mapDataError(error, object) {
|
|
1120
|
+
if (error?.code === "DELETE_RESTRICTED") {
|
|
1121
|
+
return {
|
|
1122
|
+
status: 409,
|
|
1123
|
+
body: {
|
|
1124
|
+
error: error?.message ?? "Cannot delete: dependent records exist",
|
|
1125
|
+
code: "DELETE_RESTRICTED",
|
|
1126
|
+
...error?.dependentObject ? { dependentObject: error.dependentObject } : {},
|
|
1127
|
+
...typeof error?.dependentCount === "number" ? { dependentCount: error.dependentCount } : {},
|
|
1128
|
+
...object ? { object } : {}
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
if (error?.code === "CONCURRENT_UPDATE" || error?.name === "ConcurrentUpdateError") {
|
|
1133
|
+
return {
|
|
1134
|
+
status: 409,
|
|
1135
|
+
body: {
|
|
1136
|
+
error: error?.message ?? "Record was modified by another user",
|
|
1137
|
+
code: "CONCURRENT_UPDATE",
|
|
1138
|
+
...error?.currentVersion ? { currentVersion: error.currentVersion } : {},
|
|
1139
|
+
...error?.currentRecord ? { currentRecord: error.currentRecord } : {},
|
|
1140
|
+
...object ? { object } : {}
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
|
|
1145
|
+
return {
|
|
1146
|
+
status: 400,
|
|
1147
|
+
body: {
|
|
1148
|
+
error: error?.message ?? "Validation failed",
|
|
1149
|
+
code: "VALIDATION_FAILED",
|
|
1150
|
+
fields: Array.isArray(error?.fields) ? error.fields : [],
|
|
1151
|
+
...object ? { object } : {}
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
if (error?.code === "FEEDS_DISABLED" || error?.code === "FILES_DISABLED") {
|
|
1156
|
+
return {
|
|
1157
|
+
status: 403,
|
|
1158
|
+
body: {
|
|
1159
|
+
error: error?.message ?? "This capability is disabled for the target object",
|
|
1160
|
+
code: error.code,
|
|
1161
|
+
...error?.object || object ? { object: error?.object ?? object } : {}
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
|
|
1166
|
+
return {
|
|
1167
|
+
status: 403,
|
|
1168
|
+
body: {
|
|
1169
|
+
error: error?.message ?? "Permission denied",
|
|
1170
|
+
code: "PERMISSION_DENIED",
|
|
1171
|
+
...object ? { object } : {}
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
const raw = String(error?.message ?? error ?? "");
|
|
1176
|
+
const lower = raw.toLowerCase();
|
|
1177
|
+
if (raw.includes("[EnvironmentKernelFactory]") && (lower.includes("missing database_url") || lower.includes("not found"))) {
|
|
1178
|
+
const isProvisioning = lower.includes("status='provisioning'") || lower.includes("status='pending'");
|
|
1179
|
+
const isFailed = lower.includes("status='failed'");
|
|
1180
|
+
return {
|
|
1181
|
+
status: isProvisioning ? 503 : isFailed ? 502 : 404,
|
|
1182
|
+
body: {
|
|
1183
|
+
error: raw,
|
|
1184
|
+
code: isProvisioning ? "PROJECT_PROVISIONING" : isFailed ? "PROJECT_PROVISIONING_FAILED" : "PROJECT_NOT_FOUND"
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
if (error?.code === "RECORD_NOT_FOUND" || /^Record\s+\S+\s+not found in\s+\S+/i.test(raw)) {
|
|
1189
|
+
return {
|
|
1190
|
+
status: 404,
|
|
1191
|
+
body: {
|
|
1192
|
+
error: raw,
|
|
1193
|
+
code: "RECORD_NOT_FOUND",
|
|
1194
|
+
...object ? { object } : {}
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
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);
|
|
1199
|
+
if (unknownColumn) {
|
|
1200
|
+
const field = unknownColumn[1]?.split(".").pop();
|
|
1201
|
+
return {
|
|
1202
|
+
status: 400,
|
|
1203
|
+
body: {
|
|
1204
|
+
error: field ? `Unknown field '${field}'${object ? ` on object '${object}'` : ""}` : "Request references a field that does not exist",
|
|
1205
|
+
code: "INVALID_FIELD",
|
|
1206
|
+
...field ? { field } : {},
|
|
1207
|
+
...object ? { object } : {}
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
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);
|
|
1212
|
+
if (notNull) {
|
|
1213
|
+
const field = notNull[1];
|
|
1214
|
+
return {
|
|
1215
|
+
status: 400,
|
|
1216
|
+
body: {
|
|
1217
|
+
error: `${field} is required`,
|
|
1218
|
+
code: "VALIDATION_FAILED",
|
|
1219
|
+
fields: [{ field, code: "required", message: `${field} is required` }],
|
|
1220
|
+
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).`,
|
|
1221
|
+
...object ? { object } : {}
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
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");
|
|
1226
|
+
if (looksLikeUnknownObject) {
|
|
1227
|
+
return {
|
|
1228
|
+
status: 404,
|
|
1229
|
+
body: {
|
|
1230
|
+
error: object ? `Object '${object}' is not registered` : "Object not found",
|
|
1231
|
+
code: "object_not_found",
|
|
1232
|
+
object
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
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");
|
|
1237
|
+
if (looksLikeSqlLeak) {
|
|
1238
|
+
if (lower.includes("unique constraint") || lower.includes("unique violation")) {
|
|
1239
|
+
return {
|
|
1240
|
+
status: 409,
|
|
1241
|
+
body: {
|
|
1242
|
+
error: "A record with this value already exists",
|
|
1243
|
+
code: "UNIQUE_VIOLATION",
|
|
1244
|
+
...object ? { object } : {}
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
return {
|
|
1249
|
+
status: 500,
|
|
1250
|
+
body: { error: "Internal data error", code: "DATABASE_ERROR" }
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
return { status: 400, body: { error: raw || "Bad request" } };
|
|
1254
|
+
}
|
|
1255
|
+
function sendError(res, error, object) {
|
|
1256
|
+
if (typeof error?.status === "number" && error.status >= 400 && error.status < 600) {
|
|
1257
|
+
const safeMsg = typeof error.message === "string" && error.message.length < 500 ? error.message : "Request failed";
|
|
1258
|
+
res.status(error.status).json({
|
|
1259
|
+
error: safeMsg,
|
|
1260
|
+
...error.code ? { code: error.code } : {},
|
|
1261
|
+
...Array.isArray(error.issues) ? { issues: error.issues } : {}
|
|
1262
|
+
});
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const mapped = mapDataError(error, object);
|
|
1266
|
+
res.status(mapped.status).json(mapped.body);
|
|
1267
|
+
}
|
|
1268
|
+
function isExpectedDataStatus(status) {
|
|
1269
|
+
return status === 403 || status === 404 || status === 409 || status === 502 || status === 503;
|
|
1270
|
+
}
|
|
1243
1271
|
var IMPORT_JOB_OBJECT = "sys_import_job";
|
|
1244
1272
|
var IMPORT_JOB_MAX_ROWS = 5e4;
|
|
1245
1273
|
var IMPORT_JOB_RESULTS_CAP = 500;
|
|
@@ -1344,7 +1372,7 @@ function rowsToCsv(fields, rows, includeHeader, metaMap) {
|
|
|
1344
1372
|
}
|
|
1345
1373
|
return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
|
|
1346
1374
|
}
|
|
1347
|
-
async function createXlsxStream(res) {
|
|
1375
|
+
async function createXlsxStream(res, useStyles = false) {
|
|
1348
1376
|
const { PassThrough } = await import("stream");
|
|
1349
1377
|
const ExcelJS = (await import("exceljs")).default ?? await import("exceljs");
|
|
1350
1378
|
const passthrough = new PassThrough();
|
|
@@ -1361,7 +1389,7 @@ async function createXlsxStream(res) {
|
|
|
1361
1389
|
});
|
|
1362
1390
|
passthrough.on("error", reject);
|
|
1363
1391
|
});
|
|
1364
|
-
const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles
|
|
1392
|
+
const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles });
|
|
1365
1393
|
const ws = wb.addWorksheet("Export");
|
|
1366
1394
|
return {
|
|
1367
1395
|
ws,
|
|
@@ -1372,8 +1400,8 @@ async function createXlsxStream(res) {
|
|
|
1372
1400
|
}
|
|
1373
1401
|
};
|
|
1374
1402
|
}
|
|
1375
|
-
var RestServer = class {
|
|
1376
|
-
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider) {
|
|
1403
|
+
var RestServer = class _RestServer {
|
|
1404
|
+
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider) {
|
|
1377
1405
|
/**
|
|
1378
1406
|
* Short-TTL cache for `hostname → environmentId` (P1-4). `resolveByHostname`
|
|
1379
1407
|
* is a control-plane lookup (typically a DB query) that otherwise runs on
|
|
@@ -1426,6 +1454,7 @@ var RestServer = class {
|
|
|
1426
1454
|
this.analyticsServiceProvider = analyticsServiceProvider;
|
|
1427
1455
|
this.settingsServiceProvider = settingsServiceProvider;
|
|
1428
1456
|
this.serviceExistsProvider = serviceExistsProvider;
|
|
1457
|
+
this.securityServiceProvider = securityServiceProvider;
|
|
1429
1458
|
}
|
|
1430
1459
|
/**
|
|
1431
1460
|
* Resolve the protocol for a given request. When `environmentId` is present
|
|
@@ -1664,6 +1693,54 @@ var RestServer = class {
|
|
|
1664
1693
|
perReq.set(key, pending);
|
|
1665
1694
|
return pending;
|
|
1666
1695
|
}
|
|
1696
|
+
/**
|
|
1697
|
+
* [ADR-0046 §6.7] The audience-evaluation view of the caller for book/doc
|
|
1698
|
+
* gating. `permissionSets` resolves through the security service's
|
|
1699
|
+
* `resolvePermissionSetNames` — the SAME resolution as data-plane
|
|
1700
|
+
* enforcement (positions expanded, additive baseline), so the docs gate
|
|
1701
|
+
* can never drift from it. `permissionSets` stays undefined when the
|
|
1702
|
+
* service is absent or resolution fails; `audienceAllows` then DENIES
|
|
1703
|
+
* permission-set-gated audiences (fail closed, ADR-0049). Resolution is
|
|
1704
|
+
* skipped unless `needPermissionSets` — callers pass true only when a
|
|
1705
|
+
* `{ permissionSet }` audience is actually in play.
|
|
1706
|
+
*/
|
|
1707
|
+
async resolveAudienceCaller(environmentId, req, opts) {
|
|
1708
|
+
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => void 0);
|
|
1709
|
+
const authenticated = !!ctx?.userId;
|
|
1710
|
+
if (!authenticated || !opts.needPermissionSets || !this.securityServiceProvider) {
|
|
1711
|
+
return { authenticated };
|
|
1712
|
+
}
|
|
1713
|
+
try {
|
|
1714
|
+
const svc = await this.securityServiceProvider(environmentId);
|
|
1715
|
+
if (!svc || typeof svc.resolvePermissionSetNames !== "function") return { authenticated };
|
|
1716
|
+
const names = await svc.resolvePermissionSetNames(ctx);
|
|
1717
|
+
return { authenticated, permissionSets: Array.isArray(names) ? names : [] };
|
|
1718
|
+
} catch {
|
|
1719
|
+
return { authenticated };
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
/** Whether any of these books carries a `{ permissionSet }` audience. */
|
|
1723
|
+
static anyPermissionSetAudience(books) {
|
|
1724
|
+
return books.some(
|
|
1725
|
+
(b) => b && typeof b === "object" && b.audience && typeof b.audience === "object" && typeof b.audience.permissionSet === "string"
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
/** Coerce a getMetaItems result (array | {items}) into an array. */
|
|
1729
|
+
static metaItemsArray(raw) {
|
|
1730
|
+
if (Array.isArray(raw)) return raw;
|
|
1731
|
+
if (raw && typeof raw === "object" && Array.isArray(raw.items)) return raw.items;
|
|
1732
|
+
return [];
|
|
1733
|
+
}
|
|
1734
|
+
/** Fetch every book of the environment, shaped for the audience resolver. */
|
|
1735
|
+
async fetchAudienceBooks(p, environmentId) {
|
|
1736
|
+
const raw = await p.getMetaItems({
|
|
1737
|
+
type: "book",
|
|
1738
|
+
...environmentId ? { environmentId } : {}
|
|
1739
|
+
}).catch(() => []);
|
|
1740
|
+
return _RestServer.metaItemsArray(raw).map(
|
|
1741
|
+
(b) => b && typeof b === "object" ? { ...b, packageId: b._packageId } : b
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1667
1744
|
/** Heavy path behind `resolveExecCtx` — resolve identity + RBAC/RLS + localization. */
|
|
1668
1745
|
async computeExecCtx(environmentId, req) {
|
|
1669
1746
|
try {
|
|
@@ -2172,6 +2249,8 @@ var RestServer = class {
|
|
|
2172
2249
|
this.registerReportsEndpoints(bp);
|
|
2173
2250
|
this.registerApprovalsEndpoints(bp);
|
|
2174
2251
|
this.registerAnalyticsEndpoints(bp);
|
|
2252
|
+
this.registerSecurityEndpoints(bp);
|
|
2253
|
+
this.registerSecurityExplainEndpoints(bp);
|
|
2175
2254
|
this.registerDataActionEndpoints(bp);
|
|
2176
2255
|
if (this.config.api.enableCrud) {
|
|
2177
2256
|
this.registerCrudEndpoints(bp);
|
|
@@ -2533,6 +2612,48 @@ var RestServer = class {
|
|
|
2533
2612
|
visible = Array.isArray(raw) ? filtered : { ...raw, items: filtered };
|
|
2534
2613
|
}
|
|
2535
2614
|
}
|
|
2615
|
+
if (req.params.type === "book") {
|
|
2616
|
+
const raw = visible;
|
|
2617
|
+
const list = _RestServer.metaItemsArray(raw);
|
|
2618
|
+
if (list.length > 0) {
|
|
2619
|
+
const { audienceAllows } = await import("@objectstack/spec/system");
|
|
2620
|
+
const caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2621
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience(list)
|
|
2622
|
+
});
|
|
2623
|
+
const filtered = list.filter((b) => b && typeof b === "object" && audienceAllows(b.audience, caller));
|
|
2624
|
+
visible = Array.isArray(raw) ? filtered : { ...raw, items: filtered };
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
if (req.params.type === "doc") {
|
|
2628
|
+
const raw = visible;
|
|
2629
|
+
const list = _RestServer.metaItemsArray(raw);
|
|
2630
|
+
if (list.length > 0) {
|
|
2631
|
+
const { audienceAllows, docAudienceAllows, resolveDocAudiences } = await import("@objectstack/spec/system");
|
|
2632
|
+
const books = await this.fetchAudienceBooks(p, environmentId);
|
|
2633
|
+
const caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2634
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience(books)
|
|
2635
|
+
});
|
|
2636
|
+
let filtered;
|
|
2637
|
+
if (caller.authenticated && !_RestServer.anyPermissionSetAudience(books)) {
|
|
2638
|
+
filtered = list;
|
|
2639
|
+
} else {
|
|
2640
|
+
const corpus = list.filter((d) => d && typeof d === "object").map((d) => ({
|
|
2641
|
+
name: d.name,
|
|
2642
|
+
group: d.group,
|
|
2643
|
+
tags: d.tags,
|
|
2644
|
+
order: d.order,
|
|
2645
|
+
packageId: d._packageId
|
|
2646
|
+
}));
|
|
2647
|
+
const audiences = resolveDocAudiences(books, corpus);
|
|
2648
|
+
filtered = list.filter((d) => {
|
|
2649
|
+
if (!d || typeof d !== "object") return false;
|
|
2650
|
+
const eff = audiences.get(d.name);
|
|
2651
|
+
return eff ? docAudienceAllows(eff, caller) : audienceAllows("org", caller);
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
visible = Array.isArray(raw) ? filtered : { ...raw, items: filtered };
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2536
2657
|
if (req.params.type === "doc") {
|
|
2537
2658
|
const locale = this.extractLocale(req);
|
|
2538
2659
|
const { resolveDocLocale } = await import("@objectstack/spec/system");
|
|
@@ -2606,7 +2727,7 @@ var RestServer = class {
|
|
|
2606
2727
|
const prot = await this.resolveProtocol(environmentId, req);
|
|
2607
2728
|
const locale = this.extractLocale(req);
|
|
2608
2729
|
const packageId = req.query?.package || void 0;
|
|
2609
|
-
const { resolveBookTree, deriveImplicitPackageBook,
|
|
2730
|
+
const { resolveBookTree, deriveImplicitPackageBook, audienceAllows, resolveDocAudiences, docAudienceAllows, resolveDocLocale } = await import("@objectstack/spec/system");
|
|
2610
2731
|
const norm = (raw) => Array.isArray(raw) ? raw : raw && Array.isArray(raw.items) ? raw.items : [];
|
|
2611
2732
|
const books = norm(await prot.getMetaItems({
|
|
2612
2733
|
type: "book",
|
|
@@ -2617,9 +2738,16 @@ var RestServer = class {
|
|
|
2617
2738
|
if (!book) {
|
|
2618
2739
|
book = deriveImplicitPackageBook(req.params.name, req.params.name);
|
|
2619
2740
|
}
|
|
2620
|
-
const
|
|
2621
|
-
|
|
2622
|
-
|
|
2741
|
+
const audienceBooks = books.map((b) => b && typeof b === "object" ? { ...b, packageId: b._packageId } : b);
|
|
2742
|
+
const caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2743
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience([book, ...audienceBooks])
|
|
2744
|
+
});
|
|
2745
|
+
if (!audienceAllows(book.audience, caller)) {
|
|
2746
|
+
if (!caller.authenticated) {
|
|
2747
|
+
sendError(res, { code: "unauthorized", message: "This documentation requires sign-in", status: 401 });
|
|
2748
|
+
} else {
|
|
2749
|
+
sendError(res, { code: "PERMISSION_DENIED", message: "This documentation is limited to holders of a permission set you do not have", status: 403 });
|
|
2750
|
+
}
|
|
2623
2751
|
return;
|
|
2624
2752
|
}
|
|
2625
2753
|
const docs = norm(await prot.getMetaItems({
|
|
@@ -2636,6 +2764,14 @@ var RestServer = class {
|
|
|
2636
2764
|
packageId: d._packageId
|
|
2637
2765
|
}));
|
|
2638
2766
|
const tree = resolveBookTree(book, docs, book._packageId);
|
|
2767
|
+
const gatedTreePossible = !caller.authenticated || _RestServer.anyPermissionSetAudience(audienceBooks);
|
|
2768
|
+
if (gatedTreePossible) {
|
|
2769
|
+
const audiences = resolveDocAudiences(audienceBooks, docs);
|
|
2770
|
+
tree.groups = tree.groups.map((g) => ({
|
|
2771
|
+
...g,
|
|
2772
|
+
entries: g.entries.filter((e) => !e.doc || docAudienceAllows(audiences.get(e.doc), caller))
|
|
2773
|
+
})).filter((g) => g.entries.some((e) => e.doc || e.href));
|
|
2774
|
+
}
|
|
2639
2775
|
res.json(tree);
|
|
2640
2776
|
} catch (error) {
|
|
2641
2777
|
logError("[REST] Unhandled error:", error);
|
|
@@ -2670,7 +2806,7 @@ var RestServer = class {
|
|
|
2670
2806
|
const isDraftRead = typeof req.query?.state === "string" && req.query.state.toLowerCase() === "draft";
|
|
2671
2807
|
const previewDrafts = typeof req.query?.preview === "string" && req.query.preview.toLowerCase() === "draft";
|
|
2672
2808
|
const packageScoped = typeof req.query?.package === "string" && req.query.package.length > 0;
|
|
2673
|
-
if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead && !previewDrafts && !packageScoped && req.params.type !== "doc") {
|
|
2809
|
+
if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead && !previewDrafts && !packageScoped && req.params.type !== "doc" && req.params.type !== "book") {
|
|
2674
2810
|
const cacheRequest = {
|
|
2675
2811
|
ifNoneMatch: req.headers["if-none-match"],
|
|
2676
2812
|
ifModifiedSince: req.headers["if-modified-since"]
|
|
@@ -2739,6 +2875,47 @@ var RestServer = class {
|
|
|
2739
2875
|
const serviceGate = registered ? (n) => registered.has(n) : void 0;
|
|
2740
2876
|
if (serviceGate) visible = this.filterDashboardForUser(visible, serviceGate);
|
|
2741
2877
|
}
|
|
2878
|
+
if ((req.params.type === "book" || req.params.type === "doc") && visible) {
|
|
2879
|
+
const { audienceAllows, docAudienceAllows, resolveDocAudiences } = await import("@objectstack/spec/system");
|
|
2880
|
+
const target = isMetaEnvelope(visible) ? visible.item : visible;
|
|
2881
|
+
let caller;
|
|
2882
|
+
let allowed;
|
|
2883
|
+
if (req.params.type === "book") {
|
|
2884
|
+
caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2885
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience([target])
|
|
2886
|
+
});
|
|
2887
|
+
allowed = audienceAllows(target?.audience, caller);
|
|
2888
|
+
} else {
|
|
2889
|
+
const books = await this.fetchAudienceBooks(p, environmentId);
|
|
2890
|
+
caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2891
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience(books)
|
|
2892
|
+
});
|
|
2893
|
+
if (caller.authenticated && !_RestServer.anyPermissionSetAudience(books)) {
|
|
2894
|
+
allowed = true;
|
|
2895
|
+
} else {
|
|
2896
|
+
const corpus = _RestServer.metaItemsArray(await p.getMetaItems({
|
|
2897
|
+
type: "doc",
|
|
2898
|
+
...environmentId ? { environmentId } : {}
|
|
2899
|
+
}).catch(() => [])).filter((d) => d && typeof d === "object").map((d) => ({
|
|
2900
|
+
name: d.name,
|
|
2901
|
+
group: d.group,
|
|
2902
|
+
tags: d.tags,
|
|
2903
|
+
order: d.order,
|
|
2904
|
+
packageId: d._packageId
|
|
2905
|
+
}));
|
|
2906
|
+
const audiences = resolveDocAudiences(books, corpus);
|
|
2907
|
+
allowed = docAudienceAllows(audiences.get(target?.name), caller);
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
if (!allowed) {
|
|
2911
|
+
if (!caller.authenticated) {
|
|
2912
|
+
sendError(res, { code: "unauthorized", message: "This documentation requires sign-in", status: 401 });
|
|
2913
|
+
} else {
|
|
2914
|
+
sendError(res, { code: "PERMISSION_DENIED", message: "This documentation is limited to holders of a permission set you do not have", status: 403 });
|
|
2915
|
+
}
|
|
2916
|
+
return;
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2742
2919
|
if (req.params.type === "doc" && visible) {
|
|
2743
2920
|
const locale = this.extractLocale(req);
|
|
2744
2921
|
const { resolveDocLocale } = await import("@objectstack/spec/system");
|
|
@@ -3739,9 +3916,11 @@ var RestServer = class {
|
|
|
3739
3916
|
const includeHeader = String(q.header ?? "true").toLowerCase() !== "false";
|
|
3740
3917
|
const HARD_CAP = 5e4;
|
|
3741
3918
|
const MAX_CHUNK = 5e3;
|
|
3919
|
+
const STYLE_ROW_CAP = 1e4;
|
|
3742
3920
|
const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 1e4;
|
|
3743
3921
|
const limit = Math.min(requestedLimit, HARD_CAP);
|
|
3744
3922
|
const chunkSize = Math.min(MAX_CHUNK, Math.max(50, q.page != null ? Number(q.page) || 500 : 500));
|
|
3923
|
+
const styled = format === "xlsx" && limit <= STYLE_ROW_CAP;
|
|
3745
3924
|
let filter = void 0;
|
|
3746
3925
|
if (typeof q.filter === "string" && q.filter.length > 0) {
|
|
3747
3926
|
try {
|
|
@@ -3808,12 +3987,13 @@ var RestServer = class {
|
|
|
3808
3987
|
}
|
|
3809
3988
|
res.header("X-Export-Format", format);
|
|
3810
3989
|
res.header("X-Export-Limit", String(limit));
|
|
3990
|
+
if (format === "xlsx") res.header("X-Export-Styles", styled ? "applied" : "dropped");
|
|
3811
3991
|
res.header("Cache-Control", "no-store");
|
|
3812
3992
|
let exported = 0;
|
|
3813
3993
|
let firstChunk = true;
|
|
3814
3994
|
let skip = 0;
|
|
3815
3995
|
if (format === "json") res.write("[");
|
|
3816
|
-
const xlsx = format === "xlsx" ? await createXlsxStream(res) : null;
|
|
3996
|
+
const xlsx = format === "xlsx" ? await createXlsxStream(res, styled) : null;
|
|
3817
3997
|
while (exported < limit) {
|
|
3818
3998
|
const take = Math.min(chunkSize, limit - exported);
|
|
3819
3999
|
const findArgs = {
|
|
@@ -3841,8 +4021,16 @@ var RestServer = class {
|
|
|
3841
4021
|
if (firstChunk && includeHeader) {
|
|
3842
4022
|
xlsx.ws.addRow((fields ?? []).map((f) => headerLabel(f, metaMap))).commit();
|
|
3843
4023
|
}
|
|
4024
|
+
const cols = fields ?? [];
|
|
3844
4025
|
for (const row of rows) {
|
|
3845
|
-
xlsx.ws.addRow(formatRowCells(row,
|
|
4026
|
+
const r = xlsx.ws.addRow(formatRowCells(row, cols, metaMap));
|
|
4027
|
+
if (styled) {
|
|
4028
|
+
cols.forEach((f, i) => {
|
|
4029
|
+
const argb = cellFontColor(row?.[f], metaMap.get(f));
|
|
4030
|
+
if (argb) r.getCell(i + 1).font = { color: { argb } };
|
|
4031
|
+
});
|
|
4032
|
+
}
|
|
4033
|
+
r.commit();
|
|
3846
4034
|
}
|
|
3847
4035
|
} else {
|
|
3848
4036
|
for (let i = 0; i < rows.length; i++) {
|
|
@@ -4471,6 +4659,100 @@ var RestServer = class {
|
|
|
4471
4659
|
metadata: { summary: "Run a semantic-layer dataset (preview/query)", tags: ["analytics"] }
|
|
4472
4660
|
});
|
|
4473
4661
|
}
|
|
4662
|
+
/**
|
|
4663
|
+
* [ADR-0090 D6] Access-explanation endpoint — the REST face of the
|
|
4664
|
+
* explain engine (framework#2696).
|
|
4665
|
+
*
|
|
4666
|
+
* GET {basePath}/security/explain?object=…&operation=…&userId=…
|
|
4667
|
+
* POST {basePath}/security/explain body: { object, operation, userId? }
|
|
4668
|
+
*
|
|
4669
|
+
* Delegates to the security service's `explain(request, callerContext)`
|
|
4670
|
+
* (`SecurityPlugin.explainAccessForCaller`) — the same code paths the
|
|
4671
|
+
* enforcement middleware runs, so the report is explained by
|
|
4672
|
+
* construction. Caller authorization lives in the SERVICE, not here:
|
|
4673
|
+
* explaining ANOTHER user requires `manage_users` or a delegated
|
|
4674
|
+
* `adminScope` covering that user (D12); the service's
|
|
4675
|
+
* `PermissionDeniedError` maps to 403. The route itself only insists on
|
|
4676
|
+
* an authenticated caller (an access report is sensitive even about
|
|
4677
|
+
* oneself, and the anonymous `guest` posture is not this endpoint's
|
|
4678
|
+
* business) and returns 501 when no security service exposing `explain`
|
|
4679
|
+
* is mounted (a deployment without `@objectstack/plugin-security`).
|
|
4680
|
+
*/
|
|
4681
|
+
registerSecurityExplainEndpoints(basePath) {
|
|
4682
|
+
const isScoped = basePath.includes("/environments/:environmentId");
|
|
4683
|
+
const resolveService = async (environmentId, req) => {
|
|
4684
|
+
try {
|
|
4685
|
+
const envId = await this.resolveRequestEnvironmentId(environmentId, req);
|
|
4686
|
+
if (envId && envId !== "platform" && this.kernelManager) {
|
|
4687
|
+
const kernel = await this.kernelManager.getOrCreate(envId);
|
|
4688
|
+
const svc = await kernel.getServiceAsync("security").catch(() => void 0);
|
|
4689
|
+
if (svc) return svc;
|
|
4690
|
+
}
|
|
4691
|
+
} catch {
|
|
4692
|
+
}
|
|
4693
|
+
if (!this.securityServiceProvider) return void 0;
|
|
4694
|
+
try {
|
|
4695
|
+
return await this.securityServiceProvider(environmentId);
|
|
4696
|
+
} catch {
|
|
4697
|
+
return void 0;
|
|
4698
|
+
}
|
|
4699
|
+
};
|
|
4700
|
+
const handler = async (req, res) => {
|
|
4701
|
+
try {
|
|
4702
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
4703
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
4704
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
4705
|
+
if (!context?.userId) {
|
|
4706
|
+
return res.status(401).json({
|
|
4707
|
+
code: "UNAUTHORIZED",
|
|
4708
|
+
message: "The access-explanation endpoint requires an authenticated caller."
|
|
4709
|
+
});
|
|
4710
|
+
}
|
|
4711
|
+
const svc = await resolveService(environmentId, req);
|
|
4712
|
+
if (!svc || typeof svc.explain !== "function") {
|
|
4713
|
+
return res.status(501).json({
|
|
4714
|
+
code: "NOT_IMPLEMENTED",
|
|
4715
|
+
message: "Access explanation is not available on this deployment (no security service with explain)."
|
|
4716
|
+
});
|
|
4717
|
+
}
|
|
4718
|
+
const src = req.method === "GET" ? req.query ?? {} : req.body ?? {};
|
|
4719
|
+
const { ExplainRequestSchema } = await import("@objectstack/spec/security");
|
|
4720
|
+
const parsed = ExplainRequestSchema.safeParse({
|
|
4721
|
+
object: src.object,
|
|
4722
|
+
operation: src.operation ?? "read",
|
|
4723
|
+
...src.userId != null && src.userId !== "" ? { userId: src.userId } : {}
|
|
4724
|
+
});
|
|
4725
|
+
if (!parsed.success) {
|
|
4726
|
+
return res.status(400).json({
|
|
4727
|
+
code: "VALIDATION_FAILED",
|
|
4728
|
+
message: "Invalid explain request \u2014 expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string }.",
|
|
4729
|
+
detail: String(parsed.error?.message ?? "").slice(0, 1e3)
|
|
4730
|
+
});
|
|
4731
|
+
}
|
|
4732
|
+
const decision = await svc.explain(parsed.data, context);
|
|
4733
|
+
res.json(decision);
|
|
4734
|
+
} catch (error) {
|
|
4735
|
+
const msg = String(error?.message ?? error ?? "");
|
|
4736
|
+
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || msg.startsWith("[Security] Access denied")) {
|
|
4737
|
+
return res.status(403).json({ code: "PERMISSION_DENIED", message: msg.slice(0, 1e3) });
|
|
4738
|
+
}
|
|
4739
|
+
logError("[REST] Security explain error:", error);
|
|
4740
|
+
res.status(500).json({ code: "EXPLAIN_FAILED", error: msg.slice(0, 500) });
|
|
4741
|
+
}
|
|
4742
|
+
};
|
|
4743
|
+
this.routeManager.register({
|
|
4744
|
+
method: "GET",
|
|
4745
|
+
path: `${basePath}/security/explain`,
|
|
4746
|
+
handler,
|
|
4747
|
+
metadata: { summary: "Explain why a principal can (or cannot) perform an operation on an object (ADR-0090 D6)", tags: ["security"] }
|
|
4748
|
+
});
|
|
4749
|
+
this.routeManager.register({
|
|
4750
|
+
method: "POST",
|
|
4751
|
+
path: `${basePath}/security/explain`,
|
|
4752
|
+
handler,
|
|
4753
|
+
metadata: { summary: "Explain why a principal can (or cannot) perform an operation on an object (ADR-0090 D6)", tags: ["security"] }
|
|
4754
|
+
});
|
|
4755
|
+
}
|
|
4474
4756
|
registerSharingEndpoints(basePath) {
|
|
4475
4757
|
const { crud } = this.config;
|
|
4476
4758
|
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
@@ -4713,6 +4995,106 @@ var RestServer = class {
|
|
|
4713
4995
|
metadata: { summary: "Re-evaluate a sharing rule and reconcile grants", tags: ["sharing"] }
|
|
4714
4996
|
});
|
|
4715
4997
|
}
|
|
4998
|
+
/**
|
|
4999
|
+
* Register the security admin endpoints (ADR-0090 D5/D9) — suggested
|
|
5000
|
+
* audience bindings. A package permission set declaring `isDefault: true`
|
|
5001
|
+
* is an install-time SUGGESTION to bind it to the `everyone` position;
|
|
5002
|
+
* these routes surface pending suggestions and let a tenant admin resolve
|
|
5003
|
+
* them. The `security` service (plugin-security) does the real gating:
|
|
5004
|
+
* tenant-admin pre-check on all three, and confirm writes the binding
|
|
5005
|
+
* with the caller's execution context so the audience-anchor and
|
|
5006
|
+
* delegated-admin gates enforce it — never auto-bound, never system.
|
|
5007
|
+
*
|
|
5008
|
+
* GET {basePath}/security/suggested-bindings?status=&packageId=
|
|
5009
|
+
* POST {basePath}/security/suggested-bindings/:id/confirm
|
|
5010
|
+
* POST {basePath}/security/suggested-bindings/:id/dismiss
|
|
5011
|
+
*
|
|
5012
|
+
* Routes return 501 when the `security` service is not registered
|
|
5013
|
+
* (deployment without plugin-security). Typed service errors carry their
|
|
5014
|
+
* HTTP status (403 permission / 404 not found / 409 state).
|
|
5015
|
+
*/
|
|
5016
|
+
registerSecurityEndpoints(basePath) {
|
|
5017
|
+
const dataPath = basePath;
|
|
5018
|
+
const isScoped = basePath.includes("/environments/:environmentId");
|
|
5019
|
+
const resolveService = async (environmentId) => {
|
|
5020
|
+
if (!this.securityServiceProvider) return void 0;
|
|
5021
|
+
try {
|
|
5022
|
+
const svc = await this.securityServiceProvider(environmentId);
|
|
5023
|
+
return svc && typeof svc.listAudienceBindingSuggestions === "function" ? svc : void 0;
|
|
5024
|
+
} catch {
|
|
5025
|
+
return void 0;
|
|
5026
|
+
}
|
|
5027
|
+
};
|
|
5028
|
+
const respond501 = (res) => res.status(501).json({
|
|
5029
|
+
code: "NOT_IMPLEMENTED",
|
|
5030
|
+
message: "Security service is not configured on this deployment"
|
|
5031
|
+
});
|
|
5032
|
+
const handleError = (err, res, defaultCode) => {
|
|
5033
|
+
const status = typeof err?.statusCode === "number" ? err.statusCode : 500;
|
|
5034
|
+
if (status !== 500) {
|
|
5035
|
+
return res.status(status).json({ code: err?.code ?? defaultCode, error: String(err?.message ?? err) });
|
|
5036
|
+
}
|
|
5037
|
+
logError(`[REST] suggested-bindings ${defaultCode}:`, err);
|
|
5038
|
+
return res.status(500).json({ code: defaultCode, error: String(err?.message ?? err).slice(0, 500) });
|
|
5039
|
+
};
|
|
5040
|
+
this.routeManager.register({
|
|
5041
|
+
method: "GET",
|
|
5042
|
+
path: `${dataPath}/security/suggested-bindings`,
|
|
5043
|
+
handler: async (req, res) => {
|
|
5044
|
+
try {
|
|
5045
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
5046
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
5047
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
5048
|
+
const svc = await resolveService(environmentId);
|
|
5049
|
+
if (!svc) return respond501(res);
|
|
5050
|
+
const result = await svc.listAudienceBindingSuggestions(context ?? {}, {
|
|
5051
|
+
status: req.query?.status ? String(req.query.status) : void 0,
|
|
5052
|
+
packageId: req.query?.packageId ? String(req.query.packageId) : void 0
|
|
5053
|
+
});
|
|
5054
|
+
res.json({ data: result });
|
|
5055
|
+
} catch (err) {
|
|
5056
|
+
handleError(err, res, "SUGGESTION_LIST_FAILED");
|
|
5057
|
+
}
|
|
5058
|
+
},
|
|
5059
|
+
metadata: { summary: "List suggested audience bindings (ADR-0090 D5/D9)", tags: ["security"] }
|
|
5060
|
+
});
|
|
5061
|
+
this.routeManager.register({
|
|
5062
|
+
method: "POST",
|
|
5063
|
+
path: `${dataPath}/security/suggested-bindings/:id/confirm`,
|
|
5064
|
+
handler: async (req, res) => {
|
|
5065
|
+
try {
|
|
5066
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
5067
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
5068
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
5069
|
+
const svc = await resolveService(environmentId);
|
|
5070
|
+
if (!svc) return respond501(res);
|
|
5071
|
+
const result = await svc.confirmAudienceBindingSuggestion(context ?? {}, String(req.params.id));
|
|
5072
|
+
res.json({ data: result });
|
|
5073
|
+
} catch (err) {
|
|
5074
|
+
handleError(err, res, "SUGGESTION_CONFIRM_FAILED");
|
|
5075
|
+
}
|
|
5076
|
+
},
|
|
5077
|
+
metadata: { summary: "Confirm a suggested audience binding (creates the everyone/guest binding)", tags: ["security"] }
|
|
5078
|
+
});
|
|
5079
|
+
this.routeManager.register({
|
|
5080
|
+
method: "POST",
|
|
5081
|
+
path: `${dataPath}/security/suggested-bindings/:id/dismiss`,
|
|
5082
|
+
handler: async (req, res) => {
|
|
5083
|
+
try {
|
|
5084
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
5085
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
5086
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
5087
|
+
const svc = await resolveService(environmentId);
|
|
5088
|
+
if (!svc) return respond501(res);
|
|
5089
|
+
const result = await svc.dismissAudienceBindingSuggestion(context ?? {}, String(req.params.id));
|
|
5090
|
+
res.json({ data: result });
|
|
5091
|
+
} catch (err) {
|
|
5092
|
+
handleError(err, res, "SUGGESTION_DISMISS_FAILED");
|
|
5093
|
+
}
|
|
5094
|
+
},
|
|
5095
|
+
metadata: { summary: "Dismiss a suggested audience binding", tags: ["security"] }
|
|
5096
|
+
});
|
|
5097
|
+
}
|
|
4716
5098
|
/**
|
|
4717
5099
|
* Register saved-report + scheduled-digest endpoints (M11.C16).
|
|
4718
5100
|
*
|
|
@@ -5510,6 +5892,20 @@ function registerPackageRoutes(server, packageService, basePath = "/api/v1", opt
|
|
|
5510
5892
|
try {
|
|
5511
5893
|
const packageId = req.params.id;
|
|
5512
5894
|
const version = req.query?.version;
|
|
5895
|
+
if (!version && typeof options.protocol?.deletePackage === "function") {
|
|
5896
|
+
const result2 = await options.protocol.deletePackage({ packageId });
|
|
5897
|
+
if (result2.failedCount === 0) {
|
|
5898
|
+
res.json({
|
|
5899
|
+
success: true,
|
|
5900
|
+
message: `Deleted ${packageId}`,
|
|
5901
|
+
deletedCount: result2.deletedCount,
|
|
5902
|
+
cleanups: result2.cleanups
|
|
5903
|
+
});
|
|
5904
|
+
return;
|
|
5905
|
+
}
|
|
5906
|
+
res.status(400).json({ success: false, failed: result2.failed, cleanups: result2.cleanups });
|
|
5907
|
+
return;
|
|
5908
|
+
}
|
|
5513
5909
|
const result = await packageService.delete(packageId, version);
|
|
5514
5910
|
if (result.success) {
|
|
5515
5911
|
res.json({
|
|
@@ -5709,6 +6105,13 @@ function createRestApiPlugin(config = {}) {
|
|
|
5709
6105
|
return void 0;
|
|
5710
6106
|
}
|
|
5711
6107
|
};
|
|
6108
|
+
const securityServiceProvider = async (_environmentId) => {
|
|
6109
|
+
try {
|
|
6110
|
+
return ctx.getService("security");
|
|
6111
|
+
} catch {
|
|
6112
|
+
return void 0;
|
|
6113
|
+
}
|
|
6114
|
+
};
|
|
5712
6115
|
if (!server) {
|
|
5713
6116
|
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
|
|
5714
6117
|
return;
|
|
@@ -5726,7 +6129,7 @@ function createRestApiPlugin(config = {}) {
|
|
|
5726
6129
|
}
|
|
5727
6130
|
};
|
|
5728
6131
|
try {
|
|
5729
|
-
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider);
|
|
6132
|
+
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider);
|
|
5730
6133
|
restServer.registerRoutes();
|
|
5731
6134
|
ctx.logger.info("REST API successfully registered");
|
|
5732
6135
|
if (config.api?.api?.requireAuth === false) {
|
|
@@ -5781,6 +6184,13 @@ export {
|
|
|
5781
6184
|
RestServer,
|
|
5782
6185
|
RouteGroupBuilder,
|
|
5783
6186
|
RouteManager,
|
|
5784
|
-
|
|
6187
|
+
buildFieldMetaMap,
|
|
6188
|
+
coerceRow,
|
|
6189
|
+
createRestApiPlugin,
|
|
6190
|
+
isMetaEnvelope,
|
|
6191
|
+
parseCsvToRows,
|
|
6192
|
+
parseXlsxToRows,
|
|
6193
|
+
prepareImportRequest,
|
|
6194
|
+
runImport
|
|
5785
6195
|
};
|
|
5786
6196
|
//# sourceMappingURL=index.js.map
|