@objectstack/rest 13.0.0 → 14.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +652 -163
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +409 -3
- package/dist/index.d.ts +409 -3
- package/dist/index.js +643 -161
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
package/dist/index.js
CHANGED
|
@@ -250,7 +250,14 @@ function buildFieldMetaMap(schema) {
|
|
|
250
250
|
label: typeof f.label === "string" ? f.label : void 0,
|
|
251
251
|
options: Array.isArray(f.options) ? f.options : void 0,
|
|
252
252
|
reference: typeof f.reference === "string" ? f.reference : void 0,
|
|
253
|
-
displayField: typeof f.displayField === "string" ? f.displayField : void 0
|
|
253
|
+
displayField: typeof f.displayField === "string" ? f.displayField : void 0,
|
|
254
|
+
required: f.required === true,
|
|
255
|
+
system: f.system === true,
|
|
256
|
+
readonly: f.readonly === true,
|
|
257
|
+
// Mirror the engine's `applyFieldDefaults` gate (`f.defaultValue == null`
|
|
258
|
+
// ⇒ no default): any non-null default — literal, expression object, or the
|
|
259
|
+
// `current_user` token — counts as satisfying a required field.
|
|
260
|
+
hasDefault: f.defaultValue != null
|
|
254
261
|
});
|
|
255
262
|
}
|
|
256
263
|
return map;
|
|
@@ -288,6 +295,22 @@ function optionLabel(value, options) {
|
|
|
288
295
|
const hit = options.find((o) => o && o.value === value);
|
|
289
296
|
return hit?.label ?? value;
|
|
290
297
|
}
|
|
298
|
+
function toArgb(color) {
|
|
299
|
+
if (typeof color !== "string") return void 0;
|
|
300
|
+
const hex = color.trim().replace(/^#/, "");
|
|
301
|
+
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
|
|
302
|
+
const [r, g, b] = hex;
|
|
303
|
+
return `FF${r}${r}${g}${g}${b}${b}`.toUpperCase();
|
|
304
|
+
}
|
|
305
|
+
if (/^[0-9a-fA-F]{6}$/.test(hex)) return `FF${hex}`.toUpperCase();
|
|
306
|
+
return void 0;
|
|
307
|
+
}
|
|
308
|
+
function cellFontColor(value, meta) {
|
|
309
|
+
if (value === null || value === void 0) return void 0;
|
|
310
|
+
if (!meta || !meta.type || !OPTION_TYPES.has(meta.type) || !meta.options) return void 0;
|
|
311
|
+
const hit = meta.options.find((o) => o && o.value === value);
|
|
312
|
+
return toArgb(hit?.color);
|
|
313
|
+
}
|
|
291
314
|
function displayFromRecord(rec, displayField) {
|
|
292
315
|
if (displayField && rec[displayField] != null) return String(rec[displayField]);
|
|
293
316
|
for (const k of NAME_KEY_FALLBACKS) {
|
|
@@ -504,6 +527,27 @@ async function coerceFieldValue(raw, meta, ctx) {
|
|
|
504
527
|
}
|
|
505
528
|
return { value: trim && typeof raw === "string" ? raw.trim() : raw };
|
|
506
529
|
}
|
|
530
|
+
var REQUIRED_CHECK_SKIP = /* @__PURE__ */ new Set([
|
|
531
|
+
"id",
|
|
532
|
+
"created_at",
|
|
533
|
+
"created_by",
|
|
534
|
+
"updated_at",
|
|
535
|
+
"updated_by"
|
|
536
|
+
]);
|
|
537
|
+
function isBlankValue(v) {
|
|
538
|
+
return v === void 0 || v === null || typeof v === "string" && v.trim() === "";
|
|
539
|
+
}
|
|
540
|
+
function firstMissingRequiredField(data, metaMap) {
|
|
541
|
+
for (const meta of metaMap.values()) {
|
|
542
|
+
if (!meta.required) continue;
|
|
543
|
+
if (meta.system || meta.readonly) continue;
|
|
544
|
+
if (meta.hasDefault) continue;
|
|
545
|
+
if (meta.type === "autonumber") continue;
|
|
546
|
+
if (REQUIRED_CHECK_SKIP.has(meta.name)) continue;
|
|
547
|
+
if (isBlankValue(data[meta.name])) return meta.name;
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
507
551
|
async function coerceRow(rawRow, metaMap, ctx) {
|
|
508
552
|
const data = {};
|
|
509
553
|
const errors = [];
|
|
@@ -702,7 +746,11 @@ function runImport(opts) {
|
|
|
702
746
|
if (!handled) {
|
|
703
747
|
const willUpdate = existing && typeof existing === "object";
|
|
704
748
|
const willCreate = !willUpdate && (writeMode === "insert" || writeMode === "upsert");
|
|
705
|
-
|
|
749
|
+
const requiredMiss = willCreate && !runAutomations ? firstMissingRequiredField(data, metaMap) : null;
|
|
750
|
+
if (requiredMiss) {
|
|
751
|
+
errCount++;
|
|
752
|
+
results[i] = { row: rowNo, ok: false, action: "failed", field: requiredMiss, code: "required", error: `${requiredMiss} is required` };
|
|
753
|
+
} else if (!willUpdate && !willCreate) {
|
|
706
754
|
skipped++;
|
|
707
755
|
results[i] = { row: rowNo, ok: true, action: "skipped", code: "NO_MATCH" };
|
|
708
756
|
} else if (dryRun) {
|
|
@@ -874,154 +922,10 @@ function applyMappingToRows(rows, artifact) {
|
|
|
874
922
|
return { ok: true, rows: out };
|
|
875
923
|
}
|
|
876
924
|
|
|
877
|
-
// src/
|
|
878
|
-
var logError = (...args) => globalThis.console?.error(...args);
|
|
879
|
-
var TRANSLATABLE_META_TYPES = /* @__PURE__ */ new Set(["view", "action", "object", "app", "dashboard"]);
|
|
925
|
+
// src/import-prepare.ts
|
|
880
926
|
function isMetaEnvelope(value) {
|
|
881
927
|
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
928
|
}
|
|
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
929
|
function parseCsvToRows(csv, mapping = {}) {
|
|
1026
930
|
const text = csv.replace(/^\uFEFF/, "");
|
|
1027
931
|
const cells = [];
|
|
@@ -1240,6 +1144,162 @@ async function prepareImportRequest(body, opts) {
|
|
|
1240
1144
|
}
|
|
1241
1145
|
};
|
|
1242
1146
|
}
|
|
1147
|
+
|
|
1148
|
+
// src/rest-server.ts
|
|
1149
|
+
var logError = (...args) => globalThis.console?.error(...args);
|
|
1150
|
+
var TRANSLATABLE_META_TYPES = /* @__PURE__ */ new Set(["view", "action", "object", "app", "dashboard"]);
|
|
1151
|
+
function mapDataError(error, object) {
|
|
1152
|
+
if (error?.code === "DELETE_RESTRICTED") {
|
|
1153
|
+
return {
|
|
1154
|
+
status: 409,
|
|
1155
|
+
body: {
|
|
1156
|
+
error: error?.message ?? "Cannot delete: dependent records exist",
|
|
1157
|
+
code: "DELETE_RESTRICTED",
|
|
1158
|
+
...error?.dependentObject ? { dependentObject: error.dependentObject } : {},
|
|
1159
|
+
...typeof error?.dependentCount === "number" ? { dependentCount: error.dependentCount } : {},
|
|
1160
|
+
...object ? { object } : {}
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
if (error?.code === "CONCURRENT_UPDATE" || error?.name === "ConcurrentUpdateError") {
|
|
1165
|
+
return {
|
|
1166
|
+
status: 409,
|
|
1167
|
+
body: {
|
|
1168
|
+
error: error?.message ?? "Record was modified by another user",
|
|
1169
|
+
code: "CONCURRENT_UPDATE",
|
|
1170
|
+
...error?.currentVersion ? { currentVersion: error.currentVersion } : {},
|
|
1171
|
+
...error?.currentRecord ? { currentRecord: error.currentRecord } : {},
|
|
1172
|
+
...object ? { object } : {}
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
|
|
1177
|
+
return {
|
|
1178
|
+
status: 400,
|
|
1179
|
+
body: {
|
|
1180
|
+
error: error?.message ?? "Validation failed",
|
|
1181
|
+
code: "VALIDATION_FAILED",
|
|
1182
|
+
fields: Array.isArray(error?.fields) ? error.fields : [],
|
|
1183
|
+
...object ? { object } : {}
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
if (error?.code === "FEEDS_DISABLED" || error?.code === "FILES_DISABLED") {
|
|
1188
|
+
return {
|
|
1189
|
+
status: 403,
|
|
1190
|
+
body: {
|
|
1191
|
+
error: error?.message ?? "This capability is disabled for the target object",
|
|
1192
|
+
code: error.code,
|
|
1193
|
+
...error?.object || object ? { object: error?.object ?? object } : {}
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
|
|
1198
|
+
return {
|
|
1199
|
+
status: 403,
|
|
1200
|
+
body: {
|
|
1201
|
+
error: error?.message ?? "Permission denied",
|
|
1202
|
+
code: "PERMISSION_DENIED",
|
|
1203
|
+
...object ? { object } : {}
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
const raw = String(error?.message ?? error ?? "");
|
|
1208
|
+
const lower = raw.toLowerCase();
|
|
1209
|
+
if (raw.includes("[EnvironmentKernelFactory]") && (lower.includes("missing database_url") || lower.includes("not found"))) {
|
|
1210
|
+
const isProvisioning = lower.includes("status='provisioning'") || lower.includes("status='pending'");
|
|
1211
|
+
const isFailed = lower.includes("status='failed'");
|
|
1212
|
+
return {
|
|
1213
|
+
status: isProvisioning ? 503 : isFailed ? 502 : 404,
|
|
1214
|
+
body: {
|
|
1215
|
+
error: raw,
|
|
1216
|
+
code: isProvisioning ? "PROJECT_PROVISIONING" : isFailed ? "PROJECT_PROVISIONING_FAILED" : "PROJECT_NOT_FOUND"
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
if (error?.code === "RECORD_NOT_FOUND" || /^Record\s+\S+\s+not found in\s+\S+/i.test(raw)) {
|
|
1221
|
+
return {
|
|
1222
|
+
status: 404,
|
|
1223
|
+
body: {
|
|
1224
|
+
error: raw,
|
|
1225
|
+
code: "RECORD_NOT_FOUND",
|
|
1226
|
+
...object ? { object } : {}
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
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);
|
|
1231
|
+
if (unknownColumn) {
|
|
1232
|
+
const field = unknownColumn[1]?.split(".").pop();
|
|
1233
|
+
return {
|
|
1234
|
+
status: 400,
|
|
1235
|
+
body: {
|
|
1236
|
+
error: field ? `Unknown field '${field}'${object ? ` on object '${object}'` : ""}` : "Request references a field that does not exist",
|
|
1237
|
+
code: "INVALID_FIELD",
|
|
1238
|
+
...field ? { field } : {},
|
|
1239
|
+
...object ? { object } : {}
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
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);
|
|
1244
|
+
if (notNull) {
|
|
1245
|
+
const field = notNull[1];
|
|
1246
|
+
return {
|
|
1247
|
+
status: 400,
|
|
1248
|
+
body: {
|
|
1249
|
+
error: `${field} is required`,
|
|
1250
|
+
code: "VALIDATION_FAILED",
|
|
1251
|
+
fields: [{ field, code: "required", message: `${field} is required` }],
|
|
1252
|
+
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).`,
|
|
1253
|
+
...object ? { object } : {}
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
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");
|
|
1258
|
+
if (looksLikeUnknownObject) {
|
|
1259
|
+
return {
|
|
1260
|
+
status: 404,
|
|
1261
|
+
body: {
|
|
1262
|
+
error: object ? `Object '${object}' is not registered` : "Object not found",
|
|
1263
|
+
code: "object_not_found",
|
|
1264
|
+
object
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
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");
|
|
1269
|
+
if (looksLikeSqlLeak) {
|
|
1270
|
+
if (lower.includes("unique constraint") || lower.includes("unique violation")) {
|
|
1271
|
+
return {
|
|
1272
|
+
status: 409,
|
|
1273
|
+
body: {
|
|
1274
|
+
error: "A record with this value already exists",
|
|
1275
|
+
code: "UNIQUE_VIOLATION",
|
|
1276
|
+
...object ? { object } : {}
|
|
1277
|
+
}
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
return {
|
|
1281
|
+
status: 500,
|
|
1282
|
+
body: { error: "Internal data error", code: "DATABASE_ERROR" }
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
return { status: 400, body: { error: raw || "Bad request" } };
|
|
1286
|
+
}
|
|
1287
|
+
function sendError(res, error, object) {
|
|
1288
|
+
if (typeof error?.status === "number" && error.status >= 400 && error.status < 600) {
|
|
1289
|
+
const safeMsg = typeof error.message === "string" && error.message.length < 500 ? error.message : "Request failed";
|
|
1290
|
+
res.status(error.status).json({
|
|
1291
|
+
error: safeMsg,
|
|
1292
|
+
...error.code ? { code: error.code } : {},
|
|
1293
|
+
...Array.isArray(error.issues) ? { issues: error.issues } : {}
|
|
1294
|
+
});
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
const mapped = mapDataError(error, object);
|
|
1298
|
+
res.status(mapped.status).json(mapped.body);
|
|
1299
|
+
}
|
|
1300
|
+
function isExpectedDataStatus(status) {
|
|
1301
|
+
return status === 403 || status === 404 || status === 409 || status === 502 || status === 503;
|
|
1302
|
+
}
|
|
1243
1303
|
var IMPORT_JOB_OBJECT = "sys_import_job";
|
|
1244
1304
|
var IMPORT_JOB_MAX_ROWS = 5e4;
|
|
1245
1305
|
var IMPORT_JOB_RESULTS_CAP = 500;
|
|
@@ -1344,7 +1404,7 @@ function rowsToCsv(fields, rows, includeHeader, metaMap) {
|
|
|
1344
1404
|
}
|
|
1345
1405
|
return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
|
|
1346
1406
|
}
|
|
1347
|
-
async function createXlsxStream(res) {
|
|
1407
|
+
async function createXlsxStream(res, useStyles = false) {
|
|
1348
1408
|
const { PassThrough } = await import("stream");
|
|
1349
1409
|
const ExcelJS = (await import("exceljs")).default ?? await import("exceljs");
|
|
1350
1410
|
const passthrough = new PassThrough();
|
|
@@ -1361,7 +1421,7 @@ async function createXlsxStream(res) {
|
|
|
1361
1421
|
});
|
|
1362
1422
|
passthrough.on("error", reject);
|
|
1363
1423
|
});
|
|
1364
|
-
const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles
|
|
1424
|
+
const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles });
|
|
1365
1425
|
const ws = wb.addWorksheet("Export");
|
|
1366
1426
|
return {
|
|
1367
1427
|
ws,
|
|
@@ -1372,8 +1432,8 @@ async function createXlsxStream(res) {
|
|
|
1372
1432
|
}
|
|
1373
1433
|
};
|
|
1374
1434
|
}
|
|
1375
|
-
var RestServer = class {
|
|
1376
|
-
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider) {
|
|
1435
|
+
var RestServer = class _RestServer {
|
|
1436
|
+
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider) {
|
|
1377
1437
|
/**
|
|
1378
1438
|
* Short-TTL cache for `hostname → environmentId` (P1-4). `resolveByHostname`
|
|
1379
1439
|
* is a control-plane lookup (typically a DB query) that otherwise runs on
|
|
@@ -1426,6 +1486,7 @@ var RestServer = class {
|
|
|
1426
1486
|
this.analyticsServiceProvider = analyticsServiceProvider;
|
|
1427
1487
|
this.settingsServiceProvider = settingsServiceProvider;
|
|
1428
1488
|
this.serviceExistsProvider = serviceExistsProvider;
|
|
1489
|
+
this.securityServiceProvider = securityServiceProvider;
|
|
1429
1490
|
}
|
|
1430
1491
|
/**
|
|
1431
1492
|
* Resolve the protocol for a given request. When `environmentId` is present
|
|
@@ -1664,6 +1725,54 @@ var RestServer = class {
|
|
|
1664
1725
|
perReq.set(key, pending);
|
|
1665
1726
|
return pending;
|
|
1666
1727
|
}
|
|
1728
|
+
/**
|
|
1729
|
+
* [ADR-0046 §6.7] The audience-evaluation view of the caller for book/doc
|
|
1730
|
+
* gating. `permissionSets` resolves through the security service's
|
|
1731
|
+
* `resolvePermissionSetNames` — the SAME resolution as data-plane
|
|
1732
|
+
* enforcement (positions expanded, additive baseline), so the docs gate
|
|
1733
|
+
* can never drift from it. `permissionSets` stays undefined when the
|
|
1734
|
+
* service is absent or resolution fails; `audienceAllows` then DENIES
|
|
1735
|
+
* permission-set-gated audiences (fail closed, ADR-0049). Resolution is
|
|
1736
|
+
* skipped unless `needPermissionSets` — callers pass true only when a
|
|
1737
|
+
* `{ permissionSet }` audience is actually in play.
|
|
1738
|
+
*/
|
|
1739
|
+
async resolveAudienceCaller(environmentId, req, opts) {
|
|
1740
|
+
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => void 0);
|
|
1741
|
+
const authenticated = !!ctx?.userId;
|
|
1742
|
+
if (!authenticated || !opts.needPermissionSets || !this.securityServiceProvider) {
|
|
1743
|
+
return { authenticated };
|
|
1744
|
+
}
|
|
1745
|
+
try {
|
|
1746
|
+
const svc = await this.securityServiceProvider(environmentId);
|
|
1747
|
+
if (!svc || typeof svc.resolvePermissionSetNames !== "function") return { authenticated };
|
|
1748
|
+
const names = await svc.resolvePermissionSetNames(ctx);
|
|
1749
|
+
return { authenticated, permissionSets: Array.isArray(names) ? names : [] };
|
|
1750
|
+
} catch {
|
|
1751
|
+
return { authenticated };
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
/** Whether any of these books carries a `{ permissionSet }` audience. */
|
|
1755
|
+
static anyPermissionSetAudience(books) {
|
|
1756
|
+
return books.some(
|
|
1757
|
+
(b) => b && typeof b === "object" && b.audience && typeof b.audience === "object" && typeof b.audience.permissionSet === "string"
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
/** Coerce a getMetaItems result (array | {items}) into an array. */
|
|
1761
|
+
static metaItemsArray(raw) {
|
|
1762
|
+
if (Array.isArray(raw)) return raw;
|
|
1763
|
+
if (raw && typeof raw === "object" && Array.isArray(raw.items)) return raw.items;
|
|
1764
|
+
return [];
|
|
1765
|
+
}
|
|
1766
|
+
/** Fetch every book of the environment, shaped for the audience resolver. */
|
|
1767
|
+
async fetchAudienceBooks(p, environmentId) {
|
|
1768
|
+
const raw = await p.getMetaItems({
|
|
1769
|
+
type: "book",
|
|
1770
|
+
...environmentId ? { environmentId } : {}
|
|
1771
|
+
}).catch(() => []);
|
|
1772
|
+
return _RestServer.metaItemsArray(raw).map(
|
|
1773
|
+
(b) => b && typeof b === "object" ? { ...b, packageId: b._packageId } : b
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1667
1776
|
/** Heavy path behind `resolveExecCtx` — resolve identity + RBAC/RLS + localization. */
|
|
1668
1777
|
async computeExecCtx(environmentId, req) {
|
|
1669
1778
|
try {
|
|
@@ -2172,6 +2281,8 @@ var RestServer = class {
|
|
|
2172
2281
|
this.registerReportsEndpoints(bp);
|
|
2173
2282
|
this.registerApprovalsEndpoints(bp);
|
|
2174
2283
|
this.registerAnalyticsEndpoints(bp);
|
|
2284
|
+
this.registerSecurityEndpoints(bp);
|
|
2285
|
+
this.registerSecurityExplainEndpoints(bp);
|
|
2175
2286
|
this.registerDataActionEndpoints(bp);
|
|
2176
2287
|
if (this.config.api.enableCrud) {
|
|
2177
2288
|
this.registerCrudEndpoints(bp);
|
|
@@ -2384,9 +2495,49 @@ var RestServer = class {
|
|
|
2384
2495
|
}
|
|
2385
2496
|
}
|
|
2386
2497
|
/**
|
|
2387
|
-
* Register metadata
|
|
2498
|
+
* Register the metadata routes behind the SAME `requireAuth` gate the
|
|
2499
|
+
* `/data` routes use.
|
|
2500
|
+
*
|
|
2501
|
+
* `registerMetadataEndpoints` builds ~17 `/meta/*` routes but — unlike the
|
|
2502
|
+
* `/data` handlers — never calls {@link enforceAuth}: its handlers assumed
|
|
2503
|
+
* the `requireAuth` gate rejected anonymous callers "upstream", yet nothing
|
|
2504
|
+
* upstream covers `/meta`, so on a `requireAuth` deployment an anonymous
|
|
2505
|
+
* caller could read object / field schemas. On a tenant-less runtime host
|
|
2506
|
+
* those are SYSTEM-object schemas and the host is publicly reachable — a
|
|
2507
|
+
* real leak.
|
|
2508
|
+
*
|
|
2509
|
+
* Rather than add the gate to every handler (and have the next new route
|
|
2510
|
+
* forget it — the exact failure mode that caused this), wrap the route
|
|
2511
|
+
* registrar for the duration of registration so every meta route, present
|
|
2512
|
+
* and future, inherits it. The check is a no-op when `requireAuth` is off
|
|
2513
|
+
* (demo / single-tenant), so the previously-public metadata surface there
|
|
2514
|
+
* is unchanged; an authenticated user passes exactly as on `/data`.
|
|
2388
2515
|
*/
|
|
2389
2516
|
registerMetadataEndpoints(basePath) {
|
|
2517
|
+
const realRouteManager = this.routeManager;
|
|
2518
|
+
const guardedRouteManager = {
|
|
2519
|
+
register: (entry) => {
|
|
2520
|
+
const inner = entry.handler;
|
|
2521
|
+
if (typeof inner !== "function") return realRouteManager.register(entry);
|
|
2522
|
+
return realRouteManager.register({
|
|
2523
|
+
...entry,
|
|
2524
|
+
handler: async (req, res) => {
|
|
2525
|
+
const environmentId = req?.params?.environmentId;
|
|
2526
|
+
const context = await this.resolveExecCtx(environmentId, req).catch(() => void 0);
|
|
2527
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2528
|
+
return inner(req, res);
|
|
2529
|
+
}
|
|
2530
|
+
});
|
|
2531
|
+
}
|
|
2532
|
+
};
|
|
2533
|
+
this.routeManager = guardedRouteManager;
|
|
2534
|
+
try {
|
|
2535
|
+
this.registerMetadataEndpointsInner(basePath);
|
|
2536
|
+
} finally {
|
|
2537
|
+
this.routeManager = realRouteManager;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
registerMetadataEndpointsInner(basePath) {
|
|
2390
2541
|
const { metadata } = this.config;
|
|
2391
2542
|
const metaPath = `${basePath}${metadata.prefix}`;
|
|
2392
2543
|
const isScoped = basePath.includes("/environments/:environmentId");
|
|
@@ -2533,6 +2684,48 @@ var RestServer = class {
|
|
|
2533
2684
|
visible = Array.isArray(raw) ? filtered : { ...raw, items: filtered };
|
|
2534
2685
|
}
|
|
2535
2686
|
}
|
|
2687
|
+
if (req.params.type === "book") {
|
|
2688
|
+
const raw = visible;
|
|
2689
|
+
const list = _RestServer.metaItemsArray(raw);
|
|
2690
|
+
if (list.length > 0) {
|
|
2691
|
+
const { audienceAllows } = await import("@objectstack/spec/system");
|
|
2692
|
+
const caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2693
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience(list)
|
|
2694
|
+
});
|
|
2695
|
+
const filtered = list.filter((b) => b && typeof b === "object" && audienceAllows(b.audience, caller));
|
|
2696
|
+
visible = Array.isArray(raw) ? filtered : { ...raw, items: filtered };
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
if (req.params.type === "doc") {
|
|
2700
|
+
const raw = visible;
|
|
2701
|
+
const list = _RestServer.metaItemsArray(raw);
|
|
2702
|
+
if (list.length > 0) {
|
|
2703
|
+
const { audienceAllows, docAudienceAllows, resolveDocAudiences } = await import("@objectstack/spec/system");
|
|
2704
|
+
const books = await this.fetchAudienceBooks(p, environmentId);
|
|
2705
|
+
const caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2706
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience(books)
|
|
2707
|
+
});
|
|
2708
|
+
let filtered;
|
|
2709
|
+
if (caller.authenticated && !_RestServer.anyPermissionSetAudience(books)) {
|
|
2710
|
+
filtered = list;
|
|
2711
|
+
} else {
|
|
2712
|
+
const corpus = list.filter((d) => d && typeof d === "object").map((d) => ({
|
|
2713
|
+
name: d.name,
|
|
2714
|
+
group: d.group,
|
|
2715
|
+
tags: d.tags,
|
|
2716
|
+
order: d.order,
|
|
2717
|
+
packageId: d._packageId
|
|
2718
|
+
}));
|
|
2719
|
+
const audiences = resolveDocAudiences(books, corpus);
|
|
2720
|
+
filtered = list.filter((d) => {
|
|
2721
|
+
if (!d || typeof d !== "object") return false;
|
|
2722
|
+
const eff = audiences.get(d.name);
|
|
2723
|
+
return eff ? docAudienceAllows(eff, caller) : audienceAllows("org", caller);
|
|
2724
|
+
});
|
|
2725
|
+
}
|
|
2726
|
+
visible = Array.isArray(raw) ? filtered : { ...raw, items: filtered };
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2536
2729
|
if (req.params.type === "doc") {
|
|
2537
2730
|
const locale = this.extractLocale(req);
|
|
2538
2731
|
const { resolveDocLocale } = await import("@objectstack/spec/system");
|
|
@@ -2606,7 +2799,7 @@ var RestServer = class {
|
|
|
2606
2799
|
const prot = await this.resolveProtocol(environmentId, req);
|
|
2607
2800
|
const locale = this.extractLocale(req);
|
|
2608
2801
|
const packageId = req.query?.package || void 0;
|
|
2609
|
-
const { resolveBookTree, deriveImplicitPackageBook,
|
|
2802
|
+
const { resolveBookTree, deriveImplicitPackageBook, audienceAllows, resolveDocAudiences, docAudienceAllows, resolveDocLocale } = await import("@objectstack/spec/system");
|
|
2610
2803
|
const norm = (raw) => Array.isArray(raw) ? raw : raw && Array.isArray(raw.items) ? raw.items : [];
|
|
2611
2804
|
const books = norm(await prot.getMetaItems({
|
|
2612
2805
|
type: "book",
|
|
@@ -2617,9 +2810,16 @@ var RestServer = class {
|
|
|
2617
2810
|
if (!book) {
|
|
2618
2811
|
book = deriveImplicitPackageBook(req.params.name, req.params.name);
|
|
2619
2812
|
}
|
|
2620
|
-
const
|
|
2621
|
-
|
|
2622
|
-
|
|
2813
|
+
const audienceBooks = books.map((b) => b && typeof b === "object" ? { ...b, packageId: b._packageId } : b);
|
|
2814
|
+
const caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2815
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience([book, ...audienceBooks])
|
|
2816
|
+
});
|
|
2817
|
+
if (!audienceAllows(book.audience, caller)) {
|
|
2818
|
+
if (!caller.authenticated) {
|
|
2819
|
+
sendError(res, { code: "unauthorized", message: "This documentation requires sign-in", status: 401 });
|
|
2820
|
+
} else {
|
|
2821
|
+
sendError(res, { code: "PERMISSION_DENIED", message: "This documentation is limited to holders of a permission set you do not have", status: 403 });
|
|
2822
|
+
}
|
|
2623
2823
|
return;
|
|
2624
2824
|
}
|
|
2625
2825
|
const docs = norm(await prot.getMetaItems({
|
|
@@ -2636,6 +2836,14 @@ var RestServer = class {
|
|
|
2636
2836
|
packageId: d._packageId
|
|
2637
2837
|
}));
|
|
2638
2838
|
const tree = resolveBookTree(book, docs, book._packageId);
|
|
2839
|
+
const gatedTreePossible = !caller.authenticated || _RestServer.anyPermissionSetAudience(audienceBooks);
|
|
2840
|
+
if (gatedTreePossible) {
|
|
2841
|
+
const audiences = resolveDocAudiences(audienceBooks, docs);
|
|
2842
|
+
tree.groups = tree.groups.map((g) => ({
|
|
2843
|
+
...g,
|
|
2844
|
+
entries: g.entries.filter((e) => !e.doc || docAudienceAllows(audiences.get(e.doc), caller))
|
|
2845
|
+
})).filter((g) => g.entries.some((e) => e.doc || e.href));
|
|
2846
|
+
}
|
|
2639
2847
|
res.json(tree);
|
|
2640
2848
|
} catch (error) {
|
|
2641
2849
|
logError("[REST] Unhandled error:", error);
|
|
@@ -2670,7 +2878,7 @@ var RestServer = class {
|
|
|
2670
2878
|
const isDraftRead = typeof req.query?.state === "string" && req.query.state.toLowerCase() === "draft";
|
|
2671
2879
|
const previewDrafts = typeof req.query?.preview === "string" && req.query.preview.toLowerCase() === "draft";
|
|
2672
2880
|
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") {
|
|
2881
|
+
if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead && !previewDrafts && !packageScoped && req.params.type !== "doc" && req.params.type !== "book") {
|
|
2674
2882
|
const cacheRequest = {
|
|
2675
2883
|
ifNoneMatch: req.headers["if-none-match"],
|
|
2676
2884
|
ifModifiedSince: req.headers["if-modified-since"]
|
|
@@ -2739,6 +2947,47 @@ var RestServer = class {
|
|
|
2739
2947
|
const serviceGate = registered ? (n) => registered.has(n) : void 0;
|
|
2740
2948
|
if (serviceGate) visible = this.filterDashboardForUser(visible, serviceGate);
|
|
2741
2949
|
}
|
|
2950
|
+
if ((req.params.type === "book" || req.params.type === "doc") && visible) {
|
|
2951
|
+
const { audienceAllows, docAudienceAllows, resolveDocAudiences } = await import("@objectstack/spec/system");
|
|
2952
|
+
const target = isMetaEnvelope(visible) ? visible.item : visible;
|
|
2953
|
+
let caller;
|
|
2954
|
+
let allowed;
|
|
2955
|
+
if (req.params.type === "book") {
|
|
2956
|
+
caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2957
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience([target])
|
|
2958
|
+
});
|
|
2959
|
+
allowed = audienceAllows(target?.audience, caller);
|
|
2960
|
+
} else {
|
|
2961
|
+
const books = await this.fetchAudienceBooks(p, environmentId);
|
|
2962
|
+
caller = await this.resolveAudienceCaller(environmentId, req, {
|
|
2963
|
+
needPermissionSets: _RestServer.anyPermissionSetAudience(books)
|
|
2964
|
+
});
|
|
2965
|
+
if (caller.authenticated && !_RestServer.anyPermissionSetAudience(books)) {
|
|
2966
|
+
allowed = true;
|
|
2967
|
+
} else {
|
|
2968
|
+
const corpus = _RestServer.metaItemsArray(await p.getMetaItems({
|
|
2969
|
+
type: "doc",
|
|
2970
|
+
...environmentId ? { environmentId } : {}
|
|
2971
|
+
}).catch(() => [])).filter((d) => d && typeof d === "object").map((d) => ({
|
|
2972
|
+
name: d.name,
|
|
2973
|
+
group: d.group,
|
|
2974
|
+
tags: d.tags,
|
|
2975
|
+
order: d.order,
|
|
2976
|
+
packageId: d._packageId
|
|
2977
|
+
}));
|
|
2978
|
+
const audiences = resolveDocAudiences(books, corpus);
|
|
2979
|
+
allowed = docAudienceAllows(audiences.get(target?.name), caller);
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
if (!allowed) {
|
|
2983
|
+
if (!caller.authenticated) {
|
|
2984
|
+
sendError(res, { code: "unauthorized", message: "This documentation requires sign-in", status: 401 });
|
|
2985
|
+
} else {
|
|
2986
|
+
sendError(res, { code: "PERMISSION_DENIED", message: "This documentation is limited to holders of a permission set you do not have", status: 403 });
|
|
2987
|
+
}
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2742
2991
|
if (req.params.type === "doc" && visible) {
|
|
2743
2992
|
const locale = this.extractLocale(req);
|
|
2744
2993
|
const { resolveDocLocale } = await import("@objectstack/spec/system");
|
|
@@ -3739,9 +3988,11 @@ var RestServer = class {
|
|
|
3739
3988
|
const includeHeader = String(q.header ?? "true").toLowerCase() !== "false";
|
|
3740
3989
|
const HARD_CAP = 5e4;
|
|
3741
3990
|
const MAX_CHUNK = 5e3;
|
|
3991
|
+
const STYLE_ROW_CAP = 1e4;
|
|
3742
3992
|
const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 1e4;
|
|
3743
3993
|
const limit = Math.min(requestedLimit, HARD_CAP);
|
|
3744
3994
|
const chunkSize = Math.min(MAX_CHUNK, Math.max(50, q.page != null ? Number(q.page) || 500 : 500));
|
|
3995
|
+
const styled = format === "xlsx" && limit <= STYLE_ROW_CAP;
|
|
3745
3996
|
let filter = void 0;
|
|
3746
3997
|
if (typeof q.filter === "string" && q.filter.length > 0) {
|
|
3747
3998
|
try {
|
|
@@ -3808,12 +4059,13 @@ var RestServer = class {
|
|
|
3808
4059
|
}
|
|
3809
4060
|
res.header("X-Export-Format", format);
|
|
3810
4061
|
res.header("X-Export-Limit", String(limit));
|
|
4062
|
+
if (format === "xlsx") res.header("X-Export-Styles", styled ? "applied" : "dropped");
|
|
3811
4063
|
res.header("Cache-Control", "no-store");
|
|
3812
4064
|
let exported = 0;
|
|
3813
4065
|
let firstChunk = true;
|
|
3814
4066
|
let skip = 0;
|
|
3815
4067
|
if (format === "json") res.write("[");
|
|
3816
|
-
const xlsx = format === "xlsx" ? await createXlsxStream(res) : null;
|
|
4068
|
+
const xlsx = format === "xlsx" ? await createXlsxStream(res, styled) : null;
|
|
3817
4069
|
while (exported < limit) {
|
|
3818
4070
|
const take = Math.min(chunkSize, limit - exported);
|
|
3819
4071
|
const findArgs = {
|
|
@@ -3841,8 +4093,16 @@ var RestServer = class {
|
|
|
3841
4093
|
if (firstChunk && includeHeader) {
|
|
3842
4094
|
xlsx.ws.addRow((fields ?? []).map((f) => headerLabel(f, metaMap))).commit();
|
|
3843
4095
|
}
|
|
4096
|
+
const cols = fields ?? [];
|
|
3844
4097
|
for (const row of rows) {
|
|
3845
|
-
xlsx.ws.addRow(formatRowCells(row,
|
|
4098
|
+
const r = xlsx.ws.addRow(formatRowCells(row, cols, metaMap));
|
|
4099
|
+
if (styled) {
|
|
4100
|
+
cols.forEach((f, i) => {
|
|
4101
|
+
const argb = cellFontColor(row?.[f], metaMap.get(f));
|
|
4102
|
+
if (argb) r.getCell(i + 1).font = { color: { argb } };
|
|
4103
|
+
});
|
|
4104
|
+
}
|
|
4105
|
+
r.commit();
|
|
3846
4106
|
}
|
|
3847
4107
|
} else {
|
|
3848
4108
|
for (let i = 0; i < rows.length; i++) {
|
|
@@ -4471,6 +4731,100 @@ var RestServer = class {
|
|
|
4471
4731
|
metadata: { summary: "Run a semantic-layer dataset (preview/query)", tags: ["analytics"] }
|
|
4472
4732
|
});
|
|
4473
4733
|
}
|
|
4734
|
+
/**
|
|
4735
|
+
* [ADR-0090 D6] Access-explanation endpoint — the REST face of the
|
|
4736
|
+
* explain engine (framework#2696).
|
|
4737
|
+
*
|
|
4738
|
+
* GET {basePath}/security/explain?object=…&operation=…&userId=…
|
|
4739
|
+
* POST {basePath}/security/explain body: { object, operation, userId? }
|
|
4740
|
+
*
|
|
4741
|
+
* Delegates to the security service's `explain(request, callerContext)`
|
|
4742
|
+
* (`SecurityPlugin.explainAccessForCaller`) — the same code paths the
|
|
4743
|
+
* enforcement middleware runs, so the report is explained by
|
|
4744
|
+
* construction. Caller authorization lives in the SERVICE, not here:
|
|
4745
|
+
* explaining ANOTHER user requires `manage_users` or a delegated
|
|
4746
|
+
* `adminScope` covering that user (D12); the service's
|
|
4747
|
+
* `PermissionDeniedError` maps to 403. The route itself only insists on
|
|
4748
|
+
* an authenticated caller (an access report is sensitive even about
|
|
4749
|
+
* oneself, and the anonymous `guest` posture is not this endpoint's
|
|
4750
|
+
* business) and returns 501 when no security service exposing `explain`
|
|
4751
|
+
* is mounted (a deployment without `@objectstack/plugin-security`).
|
|
4752
|
+
*/
|
|
4753
|
+
registerSecurityExplainEndpoints(basePath) {
|
|
4754
|
+
const isScoped = basePath.includes("/environments/:environmentId");
|
|
4755
|
+
const resolveService = async (environmentId, req) => {
|
|
4756
|
+
try {
|
|
4757
|
+
const envId = await this.resolveRequestEnvironmentId(environmentId, req);
|
|
4758
|
+
if (envId && envId !== "platform" && this.kernelManager) {
|
|
4759
|
+
const kernel = await this.kernelManager.getOrCreate(envId);
|
|
4760
|
+
const svc = await kernel.getServiceAsync("security").catch(() => void 0);
|
|
4761
|
+
if (svc) return svc;
|
|
4762
|
+
}
|
|
4763
|
+
} catch {
|
|
4764
|
+
}
|
|
4765
|
+
if (!this.securityServiceProvider) return void 0;
|
|
4766
|
+
try {
|
|
4767
|
+
return await this.securityServiceProvider(environmentId);
|
|
4768
|
+
} catch {
|
|
4769
|
+
return void 0;
|
|
4770
|
+
}
|
|
4771
|
+
};
|
|
4772
|
+
const handler = async (req, res) => {
|
|
4773
|
+
try {
|
|
4774
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
4775
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
4776
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
4777
|
+
if (!context?.userId) {
|
|
4778
|
+
return res.status(401).json({
|
|
4779
|
+
code: "UNAUTHORIZED",
|
|
4780
|
+
message: "The access-explanation endpoint requires an authenticated caller."
|
|
4781
|
+
});
|
|
4782
|
+
}
|
|
4783
|
+
const svc = await resolveService(environmentId, req);
|
|
4784
|
+
if (!svc || typeof svc.explain !== "function") {
|
|
4785
|
+
return res.status(501).json({
|
|
4786
|
+
code: "NOT_IMPLEMENTED",
|
|
4787
|
+
message: "Access explanation is not available on this deployment (no security service with explain)."
|
|
4788
|
+
});
|
|
4789
|
+
}
|
|
4790
|
+
const src = req.method === "GET" ? req.query ?? {} : req.body ?? {};
|
|
4791
|
+
const { ExplainRequestSchema } = await import("@objectstack/spec/security");
|
|
4792
|
+
const parsed = ExplainRequestSchema.safeParse({
|
|
4793
|
+
object: src.object,
|
|
4794
|
+
operation: src.operation ?? "read",
|
|
4795
|
+
...src.userId != null && src.userId !== "" ? { userId: src.userId } : {}
|
|
4796
|
+
});
|
|
4797
|
+
if (!parsed.success) {
|
|
4798
|
+
return res.status(400).json({
|
|
4799
|
+
code: "VALIDATION_FAILED",
|
|
4800
|
+
message: "Invalid explain request \u2014 expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string }.",
|
|
4801
|
+
detail: String(parsed.error?.message ?? "").slice(0, 1e3)
|
|
4802
|
+
});
|
|
4803
|
+
}
|
|
4804
|
+
const decision = await svc.explain(parsed.data, context);
|
|
4805
|
+
res.json(decision);
|
|
4806
|
+
} catch (error) {
|
|
4807
|
+
const msg = String(error?.message ?? error ?? "");
|
|
4808
|
+
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || msg.startsWith("[Security] Access denied")) {
|
|
4809
|
+
return res.status(403).json({ code: "PERMISSION_DENIED", message: msg.slice(0, 1e3) });
|
|
4810
|
+
}
|
|
4811
|
+
logError("[REST] Security explain error:", error);
|
|
4812
|
+
res.status(500).json({ code: "EXPLAIN_FAILED", error: msg.slice(0, 500) });
|
|
4813
|
+
}
|
|
4814
|
+
};
|
|
4815
|
+
this.routeManager.register({
|
|
4816
|
+
method: "GET",
|
|
4817
|
+
path: `${basePath}/security/explain`,
|
|
4818
|
+
handler,
|
|
4819
|
+
metadata: { summary: "Explain why a principal can (or cannot) perform an operation on an object (ADR-0090 D6)", tags: ["security"] }
|
|
4820
|
+
});
|
|
4821
|
+
this.routeManager.register({
|
|
4822
|
+
method: "POST",
|
|
4823
|
+
path: `${basePath}/security/explain`,
|
|
4824
|
+
handler,
|
|
4825
|
+
metadata: { summary: "Explain why a principal can (or cannot) perform an operation on an object (ADR-0090 D6)", tags: ["security"] }
|
|
4826
|
+
});
|
|
4827
|
+
}
|
|
4474
4828
|
registerSharingEndpoints(basePath) {
|
|
4475
4829
|
const { crud } = this.config;
|
|
4476
4830
|
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
@@ -4713,6 +5067,106 @@ var RestServer = class {
|
|
|
4713
5067
|
metadata: { summary: "Re-evaluate a sharing rule and reconcile grants", tags: ["sharing"] }
|
|
4714
5068
|
});
|
|
4715
5069
|
}
|
|
5070
|
+
/**
|
|
5071
|
+
* Register the security admin endpoints (ADR-0090 D5/D9) — suggested
|
|
5072
|
+
* audience bindings. A package permission set declaring `isDefault: true`
|
|
5073
|
+
* is an install-time SUGGESTION to bind it to the `everyone` position;
|
|
5074
|
+
* these routes surface pending suggestions and let a tenant admin resolve
|
|
5075
|
+
* them. The `security` service (plugin-security) does the real gating:
|
|
5076
|
+
* tenant-admin pre-check on all three, and confirm writes the binding
|
|
5077
|
+
* with the caller's execution context so the audience-anchor and
|
|
5078
|
+
* delegated-admin gates enforce it — never auto-bound, never system.
|
|
5079
|
+
*
|
|
5080
|
+
* GET {basePath}/security/suggested-bindings?status=&packageId=
|
|
5081
|
+
* POST {basePath}/security/suggested-bindings/:id/confirm
|
|
5082
|
+
* POST {basePath}/security/suggested-bindings/:id/dismiss
|
|
5083
|
+
*
|
|
5084
|
+
* Routes return 501 when the `security` service is not registered
|
|
5085
|
+
* (deployment without plugin-security). Typed service errors carry their
|
|
5086
|
+
* HTTP status (403 permission / 404 not found / 409 state).
|
|
5087
|
+
*/
|
|
5088
|
+
registerSecurityEndpoints(basePath) {
|
|
5089
|
+
const dataPath = basePath;
|
|
5090
|
+
const isScoped = basePath.includes("/environments/:environmentId");
|
|
5091
|
+
const resolveService = async (environmentId) => {
|
|
5092
|
+
if (!this.securityServiceProvider) return void 0;
|
|
5093
|
+
try {
|
|
5094
|
+
const svc = await this.securityServiceProvider(environmentId);
|
|
5095
|
+
return svc && typeof svc.listAudienceBindingSuggestions === "function" ? svc : void 0;
|
|
5096
|
+
} catch {
|
|
5097
|
+
return void 0;
|
|
5098
|
+
}
|
|
5099
|
+
};
|
|
5100
|
+
const respond501 = (res) => res.status(501).json({
|
|
5101
|
+
code: "NOT_IMPLEMENTED",
|
|
5102
|
+
message: "Security service is not configured on this deployment"
|
|
5103
|
+
});
|
|
5104
|
+
const handleError = (err, res, defaultCode) => {
|
|
5105
|
+
const status = typeof err?.statusCode === "number" ? err.statusCode : 500;
|
|
5106
|
+
if (status !== 500) {
|
|
5107
|
+
return res.status(status).json({ code: err?.code ?? defaultCode, error: String(err?.message ?? err) });
|
|
5108
|
+
}
|
|
5109
|
+
logError(`[REST] suggested-bindings ${defaultCode}:`, err);
|
|
5110
|
+
return res.status(500).json({ code: defaultCode, error: String(err?.message ?? err).slice(0, 500) });
|
|
5111
|
+
};
|
|
5112
|
+
this.routeManager.register({
|
|
5113
|
+
method: "GET",
|
|
5114
|
+
path: `${dataPath}/security/suggested-bindings`,
|
|
5115
|
+
handler: async (req, res) => {
|
|
5116
|
+
try {
|
|
5117
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
5118
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
5119
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
5120
|
+
const svc = await resolveService(environmentId);
|
|
5121
|
+
if (!svc) return respond501(res);
|
|
5122
|
+
const result = await svc.listAudienceBindingSuggestions(context ?? {}, {
|
|
5123
|
+
status: req.query?.status ? String(req.query.status) : void 0,
|
|
5124
|
+
packageId: req.query?.packageId ? String(req.query.packageId) : void 0
|
|
5125
|
+
});
|
|
5126
|
+
res.json({ data: result });
|
|
5127
|
+
} catch (err) {
|
|
5128
|
+
handleError(err, res, "SUGGESTION_LIST_FAILED");
|
|
5129
|
+
}
|
|
5130
|
+
},
|
|
5131
|
+
metadata: { summary: "List suggested audience bindings (ADR-0090 D5/D9)", tags: ["security"] }
|
|
5132
|
+
});
|
|
5133
|
+
this.routeManager.register({
|
|
5134
|
+
method: "POST",
|
|
5135
|
+
path: `${dataPath}/security/suggested-bindings/:id/confirm`,
|
|
5136
|
+
handler: async (req, res) => {
|
|
5137
|
+
try {
|
|
5138
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
5139
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
5140
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
5141
|
+
const svc = await resolveService(environmentId);
|
|
5142
|
+
if (!svc) return respond501(res);
|
|
5143
|
+
const result = await svc.confirmAudienceBindingSuggestion(context ?? {}, String(req.params.id));
|
|
5144
|
+
res.json({ data: result });
|
|
5145
|
+
} catch (err) {
|
|
5146
|
+
handleError(err, res, "SUGGESTION_CONFIRM_FAILED");
|
|
5147
|
+
}
|
|
5148
|
+
},
|
|
5149
|
+
metadata: { summary: "Confirm a suggested audience binding (creates the everyone/guest binding)", tags: ["security"] }
|
|
5150
|
+
});
|
|
5151
|
+
this.routeManager.register({
|
|
5152
|
+
method: "POST",
|
|
5153
|
+
path: `${dataPath}/security/suggested-bindings/:id/dismiss`,
|
|
5154
|
+
handler: async (req, res) => {
|
|
5155
|
+
try {
|
|
5156
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
5157
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
5158
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
5159
|
+
const svc = await resolveService(environmentId);
|
|
5160
|
+
if (!svc) return respond501(res);
|
|
5161
|
+
const result = await svc.dismissAudienceBindingSuggestion(context ?? {}, String(req.params.id));
|
|
5162
|
+
res.json({ data: result });
|
|
5163
|
+
} catch (err) {
|
|
5164
|
+
handleError(err, res, "SUGGESTION_DISMISS_FAILED");
|
|
5165
|
+
}
|
|
5166
|
+
},
|
|
5167
|
+
metadata: { summary: "Dismiss a suggested audience binding", tags: ["security"] }
|
|
5168
|
+
});
|
|
5169
|
+
}
|
|
4716
5170
|
/**
|
|
4717
5171
|
* Register saved-report + scheduled-digest endpoints (M11.C16).
|
|
4718
5172
|
*
|
|
@@ -5510,6 +5964,20 @@ function registerPackageRoutes(server, packageService, basePath = "/api/v1", opt
|
|
|
5510
5964
|
try {
|
|
5511
5965
|
const packageId = req.params.id;
|
|
5512
5966
|
const version = req.query?.version;
|
|
5967
|
+
if (!version && typeof options.protocol?.deletePackage === "function") {
|
|
5968
|
+
const result2 = await options.protocol.deletePackage({ packageId });
|
|
5969
|
+
if (result2.failedCount === 0) {
|
|
5970
|
+
res.json({
|
|
5971
|
+
success: true,
|
|
5972
|
+
message: `Deleted ${packageId}`,
|
|
5973
|
+
deletedCount: result2.deletedCount,
|
|
5974
|
+
cleanups: result2.cleanups
|
|
5975
|
+
});
|
|
5976
|
+
return;
|
|
5977
|
+
}
|
|
5978
|
+
res.status(400).json({ success: false, failed: result2.failed, cleanups: result2.cleanups });
|
|
5979
|
+
return;
|
|
5980
|
+
}
|
|
5513
5981
|
const result = await packageService.delete(packageId, version);
|
|
5514
5982
|
if (result.success) {
|
|
5515
5983
|
res.json({
|
|
@@ -5709,6 +6177,13 @@ function createRestApiPlugin(config = {}) {
|
|
|
5709
6177
|
return void 0;
|
|
5710
6178
|
}
|
|
5711
6179
|
};
|
|
6180
|
+
const securityServiceProvider = async (_environmentId) => {
|
|
6181
|
+
try {
|
|
6182
|
+
return ctx.getService("security");
|
|
6183
|
+
} catch {
|
|
6184
|
+
return void 0;
|
|
6185
|
+
}
|
|
6186
|
+
};
|
|
5712
6187
|
if (!server) {
|
|
5713
6188
|
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
|
|
5714
6189
|
return;
|
|
@@ -5726,7 +6201,7 @@ function createRestApiPlugin(config = {}) {
|
|
|
5726
6201
|
}
|
|
5727
6202
|
};
|
|
5728
6203
|
try {
|
|
5729
|
-
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider);
|
|
6204
|
+
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider);
|
|
5730
6205
|
restServer.registerRoutes();
|
|
5731
6206
|
ctx.logger.info("REST API successfully registered");
|
|
5732
6207
|
if (config.api?.api?.requireAuth === false) {
|
|
@@ -5781,6 +6256,13 @@ export {
|
|
|
5781
6256
|
RestServer,
|
|
5782
6257
|
RouteGroupBuilder,
|
|
5783
6258
|
RouteManager,
|
|
5784
|
-
|
|
6259
|
+
buildFieldMetaMap,
|
|
6260
|
+
coerceRow,
|
|
6261
|
+
createRestApiPlugin,
|
|
6262
|
+
isMetaEnvelope,
|
|
6263
|
+
parseCsvToRows,
|
|
6264
|
+
parseXlsxToRows,
|
|
6265
|
+
prepareImportRequest,
|
|
6266
|
+
runImport
|
|
5785
6267
|
};
|
|
5786
6268
|
//# sourceMappingURL=index.js.map
|