@objectstack/rest 14.8.0 → 15.1.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 +145 -55
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -4
- package/dist/index.d.ts +20 -4
- package/dist/index.js +153 -56
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
package/dist/index.cjs
CHANGED
|
@@ -258,6 +258,9 @@ var RouteGroupBuilder = class {
|
|
|
258
258
|
}
|
|
259
259
|
};
|
|
260
260
|
|
|
261
|
+
// src/rest-server.ts
|
|
262
|
+
var import_security = require("@objectstack/spec/security");
|
|
263
|
+
|
|
261
264
|
// src/export-format.ts
|
|
262
265
|
function exportContentDisposition(objectName, label, ext, now = /* @__PURE__ */ new Date()) {
|
|
263
266
|
const pad = (n) => String(n).padStart(2, "0");
|
|
@@ -307,6 +310,7 @@ function buildFieldMetaMap(schema) {
|
|
|
307
310
|
options: Array.isArray(f.options) ? f.options : void 0,
|
|
308
311
|
reference: typeof f.reference === "string" ? f.reference : void 0,
|
|
309
312
|
displayField: typeof f.displayField === "string" ? f.displayField : void 0,
|
|
313
|
+
multiple: f.multiple === true,
|
|
310
314
|
required: f.required === true,
|
|
311
315
|
system: f.system === true,
|
|
312
316
|
readonly: f.readonly === true,
|
|
@@ -428,6 +432,14 @@ var OPTION_TYPES2 = /* @__PURE__ */ new Set(["select", "radio"]);
|
|
|
428
432
|
var MULTI_OPTION_TYPES2 = /* @__PURE__ */ new Set(["multiselect", "checkboxes", "tags"]);
|
|
429
433
|
var NUMBER_TYPES = /* @__PURE__ */ new Set(["number", "currency", "percent", "rating", "slider"]);
|
|
430
434
|
var BOOL_TYPES = /* @__PURE__ */ new Set(["boolean", "toggle"]);
|
|
435
|
+
var FILE_TYPES = /* @__PURE__ */ new Set(["file", "image"]);
|
|
436
|
+
var MULTI_CAPABLE_TYPES = /* @__PURE__ */ new Set(["select", "radio", "lookup", "user", "file", "image"]);
|
|
437
|
+
function isMultiValueField(meta) {
|
|
438
|
+
const t = meta?.type;
|
|
439
|
+
if (!t) return false;
|
|
440
|
+
if (MULTI_OPTION_TYPES2.has(t)) return true;
|
|
441
|
+
return MULTI_CAPABLE_TYPES.has(t) && meta?.multiple === true;
|
|
442
|
+
}
|
|
431
443
|
function normalizeRefMatch(result) {
|
|
432
444
|
if (result == null) return {};
|
|
433
445
|
if (typeof result === "string") return result ? { id: result } : {};
|
|
@@ -545,7 +557,23 @@ async function coerceFieldValue(raw, meta, ctx) {
|
|
|
545
557
|
if (d === void 0) return { error: { field, code: "invalid_date", message: `${field}: "${String(raw)}" is not a valid ${t}` } };
|
|
546
558
|
return { value: d };
|
|
547
559
|
}
|
|
548
|
-
if (OPTION_TYPES2.has(t)) {
|
|
560
|
+
if (OPTION_TYPES2.has(t) || MULTI_OPTION_TYPES2.has(t)) {
|
|
561
|
+
if (isMultiValueField(meta)) {
|
|
562
|
+
const parts = splitMulti(raw);
|
|
563
|
+
const out = [];
|
|
564
|
+
for (const part of parts) {
|
|
565
|
+
const v2 = matchOption(part, meta?.options);
|
|
566
|
+
if (v2 === void 0) {
|
|
567
|
+
if (ctx.createMissingOptions) {
|
|
568
|
+
out.push(part);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
return { error: { field, code: "invalid_option", message: `${field}: "${part}" is not a known option` } };
|
|
572
|
+
}
|
|
573
|
+
out.push(v2);
|
|
574
|
+
}
|
|
575
|
+
return { value: out };
|
|
576
|
+
}
|
|
549
577
|
const v = matchOption(raw, meta?.options);
|
|
550
578
|
if (v === void 0) {
|
|
551
579
|
if (ctx.createMissingOptions) return { value: String(raw).trim() };
|
|
@@ -553,23 +581,23 @@ async function coerceFieldValue(raw, meta, ctx) {
|
|
|
553
581
|
}
|
|
554
582
|
return { value: v };
|
|
555
583
|
}
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
}
|
|
566
|
-
|
|
584
|
+
if (REFERENCE_TYPES2.has(t)) {
|
|
585
|
+
if (isMultiValueField(meta)) {
|
|
586
|
+
const tokens = splitMulti(raw);
|
|
587
|
+
if (!ctx.resolveRef || !meta.reference) return { value: tokens };
|
|
588
|
+
const out = [];
|
|
589
|
+
for (const token of tokens) {
|
|
590
|
+
const m = normalizeRefMatch(await ctx.resolveRef(meta.reference, token, meta));
|
|
591
|
+
if (m.ambiguous) {
|
|
592
|
+
return { error: { field, code: "reference_ambiguous", message: `${field}: "${token}" matches more than one ${meta.reference} \u2014 use a unique value or the record id` } };
|
|
593
|
+
}
|
|
594
|
+
if (m.id === void 0) {
|
|
595
|
+
return { error: { field, code: "reference_not_found", message: `${field}: no ${meta.reference} matches "${token}"` } };
|
|
596
|
+
}
|
|
597
|
+
out.push(m.id);
|
|
567
598
|
}
|
|
568
|
-
out
|
|
599
|
+
return { value: out };
|
|
569
600
|
}
|
|
570
|
-
return { value: out };
|
|
571
|
-
}
|
|
572
|
-
if (REFERENCE_TYPES2.has(t)) {
|
|
573
601
|
const display = String(raw).trim();
|
|
574
602
|
if (!ctx.resolveRef || !meta?.reference) return { value: display };
|
|
575
603
|
const match = normalizeRefMatch(await ctx.resolveRef(meta.reference, display, meta));
|
|
@@ -581,6 +609,9 @@ async function coerceFieldValue(raw, meta, ctx) {
|
|
|
581
609
|
}
|
|
582
610
|
return { value: match.id };
|
|
583
611
|
}
|
|
612
|
+
if (FILE_TYPES.has(t) && isMultiValueField(meta)) {
|
|
613
|
+
return { value: splitMulti(raw) };
|
|
614
|
+
}
|
|
584
615
|
return { value: trim && typeof raw === "string" ? raw.trim() : raw };
|
|
585
616
|
}
|
|
586
617
|
var REQUIRED_CHECK_SKIP = /* @__PURE__ */ new Set([
|
|
@@ -631,6 +662,10 @@ function toFailedResult(rowNo, err) {
|
|
|
631
662
|
return { row: rowNo, ok: false, action: "failed", error: message, code };
|
|
632
663
|
}
|
|
633
664
|
var MAX_CREATE_BATCH_SIZE = 200;
|
|
665
|
+
var yieldToEventLoop = () => new Promise((resolve) => {
|
|
666
|
+
if (typeof setImmediate === "function") setImmediate(resolve);
|
|
667
|
+
else setTimeout(resolve, 0);
|
|
668
|
+
});
|
|
634
669
|
function runImport(opts) {
|
|
635
670
|
const {
|
|
636
671
|
p,
|
|
@@ -849,8 +884,9 @@ function runImport(opts) {
|
|
|
849
884
|
await flushPendingCreates();
|
|
850
885
|
if (onProgress) await onProgress(snapshot(processed));
|
|
851
886
|
}
|
|
852
|
-
if (
|
|
853
|
-
|
|
887
|
+
if (processed < rows.length && processed % progressEvery === 0) {
|
|
888
|
+
await yieldToEventLoop();
|
|
889
|
+
if (shouldCancel && await shouldCancel()) {
|
|
854
890
|
cancelled = true;
|
|
855
891
|
break;
|
|
856
892
|
}
|
|
@@ -1114,7 +1150,7 @@ async function prepareImportRequest(body, opts) {
|
|
|
1114
1150
|
const dryRun = body?.dryRun === true;
|
|
1115
1151
|
let writeMode = body?.writeMode === "update" || body?.writeMode === "upsert" ? body.writeMode : "insert";
|
|
1116
1152
|
let matchFields = Array.isArray(body?.matchFields) ? body.matchFields.filter((f) => typeof f === "string" && f.length > 0) : [];
|
|
1117
|
-
const runAutomations = body?.runAutomations
|
|
1153
|
+
const runAutomations = body?.runAutomations !== false;
|
|
1118
1154
|
const trimWhitespace = body?.trimWhitespace !== false;
|
|
1119
1155
|
const nullValues = Array.isArray(body?.nullValues) ? body.nullValues.filter((v) => typeof v === "string") : void 0;
|
|
1120
1156
|
const createMissingOptions = body?.createMissingOptions === true;
|
|
@@ -1275,6 +1311,16 @@ function mapDataError(error, object) {
|
|
|
1275
1311
|
}
|
|
1276
1312
|
};
|
|
1277
1313
|
}
|
|
1314
|
+
if (error?.code === "ATTACHMENT_PARENT_ACCESS" || error?.code === "ATTACHMENT_DELETE_DENIED") {
|
|
1315
|
+
return {
|
|
1316
|
+
status: 403,
|
|
1317
|
+
body: {
|
|
1318
|
+
error: error?.message ?? "Attachment access denied",
|
|
1319
|
+
code: error.code,
|
|
1320
|
+
...error?.object || object ? { object: error?.object ?? object } : {}
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1278
1324
|
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
|
|
1279
1325
|
return {
|
|
1280
1326
|
status: 403,
|
|
@@ -1294,6 +1340,17 @@ function mapDataError(error, object) {
|
|
|
1294
1340
|
}
|
|
1295
1341
|
};
|
|
1296
1342
|
}
|
|
1343
|
+
if (typeof error?.status === "number" && error.status >= 400 && error.status < 500) {
|
|
1344
|
+
const msg = typeof error?.message === "string" && error.message.length > 0 && error.message.length < 500 ? error.message : "Request failed";
|
|
1345
|
+
return {
|
|
1346
|
+
status: error.status,
|
|
1347
|
+
body: {
|
|
1348
|
+
error: msg,
|
|
1349
|
+
...typeof error?.code === "string" && error.code ? { code: error.code } : {},
|
|
1350
|
+
...object ? { object } : {}
|
|
1351
|
+
}
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1297
1354
|
const raw = String(error?.message ?? error ?? "");
|
|
1298
1355
|
const lower = raw.toLowerCase();
|
|
1299
1356
|
const sandboxWrapper = /^(?:hook|action) '[^']*' threw:\s*(.+)$/s.exec(raw);
|
|
@@ -1743,14 +1800,16 @@ var RestServer = class _RestServer {
|
|
|
1743
1800
|
res.status(403).json({ error: { code: gate.code, message: gate.message } });
|
|
1744
1801
|
return true;
|
|
1745
1802
|
}
|
|
1746
|
-
if (
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1803
|
+
if ((0, import_core2.shouldDenyAnonymous)({
|
|
1804
|
+
requireAuth: this.config.api.requireAuth,
|
|
1805
|
+
userId: context?.userId,
|
|
1806
|
+
isSystem: context?.isSystem,
|
|
1807
|
+
method: req?.method
|
|
1808
|
+
})) {
|
|
1809
|
+
res.status(import_core2.ANONYMOUS_DENY_STATUS).json(import_core2.ANONYMOUS_DENY_BODY);
|
|
1810
|
+
return true;
|
|
1811
|
+
}
|
|
1812
|
+
return false;
|
|
1754
1813
|
}
|
|
1755
1814
|
/**
|
|
1756
1815
|
* Enforce object-level API exposure (ObjectSchema `enable.apiEnabled` /
|
|
@@ -1968,6 +2027,10 @@ var RestServer = class _RestServer {
|
|
|
1968
2027
|
permissions: authz.permissions,
|
|
1969
2028
|
systemPermissions: authz.systemPermissions,
|
|
1970
2029
|
...authz.tabPermissions ? { tabPermissions: authz.tabPermissions } : {},
|
|
2030
|
+
// [ADR-0095 D2 / #2947] Carry the derived posture rung so the
|
|
2031
|
+
// enforcement side reads the SAME value the resolver computed,
|
|
2032
|
+
// instead of dropping it here (the boundary this issue closes).
|
|
2033
|
+
...authz.posture ? { posture: authz.posture } : {},
|
|
1971
2034
|
isSystem: false,
|
|
1972
2035
|
org_user_ids: authz.org_user_ids,
|
|
1973
2036
|
...authGate ? { authGate } : {},
|
|
@@ -3497,7 +3560,7 @@ var RestServer = class _RestServer {
|
|
|
3497
3560
|
res.json(result);
|
|
3498
3561
|
} catch (error) {
|
|
3499
3562
|
const mapped = mapDataError(error, req.params?.object);
|
|
3500
|
-
if (mapped.status
|
|
3563
|
+
if (isExpectedDataStatus(mapped.status) || mapped.body?.code === "UNSUPPORTED_QUERY_PARAM") {
|
|
3501
3564
|
res.status(mapped.status).json(mapped.body);
|
|
3502
3565
|
} else {
|
|
3503
3566
|
logError("[REST] Unhandled error:", error);
|
|
@@ -3789,6 +3852,16 @@ var RestServer = class _RestServer {
|
|
|
3789
3852
|
tags: ["data", "import"]
|
|
3790
3853
|
}
|
|
3791
3854
|
});
|
|
3855
|
+
const loadImportJob = async (p, jobId, environmentId, context) => {
|
|
3856
|
+
const r = await p.findData({
|
|
3857
|
+
object: IMPORT_JOB_OBJECT,
|
|
3858
|
+
query: { $filter: { id: jobId }, $top: 1 },
|
|
3859
|
+
...environmentId ? { environmentId } : {},
|
|
3860
|
+
...context ? { context } : {}
|
|
3861
|
+
});
|
|
3862
|
+
const rows = Array.isArray(r?.records) ? r.records : Array.isArray(r?.data) ? r.data : Array.isArray(r?.rows) ? r.rows : Array.isArray(r) ? r : [];
|
|
3863
|
+
return rows[0];
|
|
3864
|
+
};
|
|
3792
3865
|
this.routeManager.register({
|
|
3793
3866
|
method: "POST",
|
|
3794
3867
|
path: `${dataPath}/:object/import/jobs`,
|
|
@@ -3854,6 +3927,11 @@ var RestServer = class _RestServer {
|
|
|
3854
3927
|
};
|
|
3855
3928
|
const captureUndo = !prepared.dryRun && prepared.rows.length <= IMPORT_JOB_UNDO_MAX_ROWS;
|
|
3856
3929
|
void (async () => {
|
|
3930
|
+
if (this.cancelledImportJobs.has(jobId)) {
|
|
3931
|
+
this.cancelledImportJobs.delete(jobId);
|
|
3932
|
+
await patch({ status: "cancelled", completed_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3933
|
+
return;
|
|
3934
|
+
}
|
|
3857
3935
|
await patch({ status: "running", started_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3858
3936
|
try {
|
|
3859
3937
|
const summary = await runImport({
|
|
@@ -3871,10 +3949,27 @@ var RestServer = class _RestServer {
|
|
|
3871
3949
|
skipped_count: pr.skipped,
|
|
3872
3950
|
error_count: pr.errors
|
|
3873
3951
|
}),
|
|
3874
|
-
shouldCancel: () =>
|
|
3952
|
+
shouldCancel: async () => {
|
|
3953
|
+
if (this.cancelledImportJobs.has(jobId)) return true;
|
|
3954
|
+
try {
|
|
3955
|
+
const row = await loadImportJob(p, jobId, environmentId, context);
|
|
3956
|
+
return String(row?.status ?? "") === "cancelled";
|
|
3957
|
+
} catch {
|
|
3958
|
+
return false;
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3875
3961
|
});
|
|
3962
|
+
let finalStatus = summary.cancelled ? "cancelled" : "succeeded";
|
|
3963
|
+
if (finalStatus === "succeeded" && this.cancelledImportJobs.has(jobId)) finalStatus = "cancelled";
|
|
3964
|
+
if (finalStatus === "succeeded") {
|
|
3965
|
+
try {
|
|
3966
|
+
const row = await loadImportJob(p, jobId, environmentId, context);
|
|
3967
|
+
if (String(row?.status ?? "") === "cancelled") finalStatus = "cancelled";
|
|
3968
|
+
} catch {
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3876
3971
|
await patch({
|
|
3877
|
-
status:
|
|
3972
|
+
status: finalStatus,
|
|
3878
3973
|
processed_rows: summary.processed,
|
|
3879
3974
|
created_count: summary.created,
|
|
3880
3975
|
updated_count: summary.updated,
|
|
@@ -3904,16 +3999,6 @@ var RestServer = class _RestServer {
|
|
|
3904
3999
|
tags: ["data", "import"]
|
|
3905
4000
|
}
|
|
3906
4001
|
});
|
|
3907
|
-
const loadImportJob = async (p, jobId, environmentId, context) => {
|
|
3908
|
-
const r = await p.findData({
|
|
3909
|
-
object: IMPORT_JOB_OBJECT,
|
|
3910
|
-
query: { $filter: { id: jobId }, $top: 1 },
|
|
3911
|
-
...environmentId ? { environmentId } : {},
|
|
3912
|
-
...context ? { context } : {}
|
|
3913
|
-
});
|
|
3914
|
-
const rows = Array.isArray(r?.records) ? r.records : Array.isArray(r?.data) ? r.data : Array.isArray(r?.rows) ? r.rows : Array.isArray(r) ? r : [];
|
|
3915
|
-
return rows[0];
|
|
3916
|
-
};
|
|
3917
4002
|
this.routeManager.register({
|
|
3918
4003
|
method: "POST",
|
|
3919
4004
|
path: `${dataPath}/import/jobs/:jobId/cancel`,
|
|
@@ -4497,6 +4582,7 @@ var RestServer = class _RestServer {
|
|
|
4497
4582
|
}
|
|
4498
4583
|
const fields = {};
|
|
4499
4584
|
for (const [name, def] of Object.entries(obj.fields)) {
|
|
4585
|
+
if (import_security.PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(name)) continue;
|
|
4500
4586
|
if (allowed.size === 0 || allowed.has(name)) {
|
|
4501
4587
|
fields[name] = def;
|
|
4502
4588
|
}
|
|
@@ -4521,6 +4607,7 @@ var RestServer = class _RestServer {
|
|
|
4521
4607
|
const safeForm = (() => {
|
|
4522
4608
|
if (!match.form || !Array.isArray(match.form.sections)) return match.form;
|
|
4523
4609
|
const allow = (name, cfg) => {
|
|
4610
|
+
if (import_security.PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(name)) return false;
|
|
4524
4611
|
const def = objectSchema?.fields?.[name];
|
|
4525
4612
|
const t = def?.type;
|
|
4526
4613
|
if (t !== "lookup" && t !== "master_detail" && t !== "user") return true;
|
|
@@ -4586,12 +4673,10 @@ var RestServer = class _RestServer {
|
|
|
4586
4673
|
}
|
|
4587
4674
|
const rawBody = req.body && typeof req.body === "object" ? req.body : {};
|
|
4588
4675
|
const filteredData = {};
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
} else {
|
|
4594
|
-
Object.assign(filteredData, rawBody);
|
|
4676
|
+
for (const [k, v] of Object.entries(rawBody)) {
|
|
4677
|
+
if (import_security.PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(k)) continue;
|
|
4678
|
+
if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
|
|
4679
|
+
if (allowedFields.size === 0 || allowedFields.has(k)) filteredData[k] = v;
|
|
4595
4680
|
}
|
|
4596
4681
|
const context = {
|
|
4597
4682
|
publicFormGrant: { object: match.object },
|
|
@@ -4640,15 +4725,17 @@ var RestServer = class _RestServer {
|
|
|
4640
4725
|
return;
|
|
4641
4726
|
}
|
|
4642
4727
|
let fieldCfg = null;
|
|
4643
|
-
|
|
4644
|
-
for (const
|
|
4645
|
-
const
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4728
|
+
if (!import_security.PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(fieldName)) {
|
|
4729
|
+
for (const sec of match.form?.sections ?? []) {
|
|
4730
|
+
for (const f of sec?.fields ?? []) {
|
|
4731
|
+
const name = typeof f === "string" ? f : f?.field;
|
|
4732
|
+
if (name === fieldName) {
|
|
4733
|
+
fieldCfg = typeof f === "string" ? {} : f;
|
|
4734
|
+
break;
|
|
4735
|
+
}
|
|
4649
4736
|
}
|
|
4737
|
+
if (fieldCfg) break;
|
|
4650
4738
|
}
|
|
4651
|
-
if (fieldCfg) break;
|
|
4652
4739
|
}
|
|
4653
4740
|
const picker = fieldCfg?.publicPicker;
|
|
4654
4741
|
if (!picker) {
|
|
@@ -4906,12 +4993,15 @@ var RestServer = class _RestServer {
|
|
|
4906
4993
|
const parsed = ExplainRequestSchema.safeParse({
|
|
4907
4994
|
object: src.object,
|
|
4908
4995
|
operation: src.operation ?? "read",
|
|
4909
|
-
...src.userId != null && src.userId !== "" ? { userId: src.userId } : {}
|
|
4996
|
+
...src.userId != null && src.userId !== "" ? { userId: src.userId } : {},
|
|
4997
|
+
// [C2 / ADR-0095] Optional record id — explains ONE concrete
|
|
4998
|
+
// row at record granularity; omitted stays object-level.
|
|
4999
|
+
...src.recordId != null && src.recordId !== "" ? { recordId: src.recordId } : {}
|
|
4910
5000
|
});
|
|
4911
5001
|
if (!parsed.success) {
|
|
4912
5002
|
return res.status(400).json({
|
|
4913
5003
|
code: "VALIDATION_FAILED",
|
|
4914
|
-
message: "Invalid explain request \u2014 expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string }.",
|
|
5004
|
+
message: "Invalid explain request \u2014 expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string, recordId?: string }.",
|
|
4915
5005
|
detail: String(parsed.error?.message ?? "").slice(0, 1e3)
|
|
4916
5006
|
});
|
|
4917
5007
|
}
|