@objectstack/rest 15.0.0 → 15.1.1

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 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 (MULTI_OPTION_TYPES2.has(t)) {
557
- const parts = splitMulti(raw);
558
- const out = [];
559
- for (const part of parts) {
560
- const v = matchOption(part, meta?.options);
561
- if (v === void 0) {
562
- if (ctx.createMissingOptions) {
563
- out.push(part);
564
- continue;
565
- }
566
- return { error: { field, code: "invalid_option", message: `${field}: "${part}" is not a known option` } };
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.push(v);
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 (shouldCancel && processed < rows.length && processed % progressEvery === 0) {
853
- if (await shouldCancel()) {
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
  }
@@ -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 (!this.config.api.requireAuth) return false;
1747
- if (context?.userId) return false;
1748
- if (req?.method === "OPTIONS") return false;
1749
- res.status(401).json({
1750
- error: "unauthenticated",
1751
- message: "Authentication is required to access this endpoint."
1752
- });
1753
- return true;
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 === 404 || mapped.status === 503 || mapped.status === 502) {
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: () => this.cancelledImportJobs.has(jobId)
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: summary.cancelled ? "cancelled" : "succeeded",
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
- if (allowedFields.size > 0) {
4590
- for (const [k, v] of Object.entries(rawBody)) {
4591
- if (allowedFields.has(k)) filteredData[k] = v;
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
- for (const sec of match.form?.sections ?? []) {
4644
- for (const f of sec?.fields ?? []) {
4645
- const name = typeof f === "string" ? f : f?.field;
4646
- if (name === fieldName) {
4647
- fieldCfg = typeof f === "string" ? {} : f;
4648
- break;
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) {