@objectstack/rest 9.7.0 → 9.9.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 CHANGED
@@ -258,6 +258,18 @@ function isMetaEnvelope(value) {
258
258
  return !!value && typeof value === "object" && typeof value.type === "string" && typeof value.name === "string" && value.item != null && typeof value.item === "object" && !Array.isArray(value.item);
259
259
  }
260
260
  function mapDataError(error, object) {
261
+ if (error?.code === "DELETE_RESTRICTED") {
262
+ return {
263
+ status: 409,
264
+ body: {
265
+ error: error?.message ?? "Cannot delete: dependent records exist",
266
+ code: "DELETE_RESTRICTED",
267
+ ...error?.dependentObject ? { dependentObject: error.dependentObject } : {},
268
+ ...typeof error?.dependentCount === "number" ? { dependentCount: error.dependentCount } : {},
269
+ ...object ? { object } : {}
270
+ }
271
+ };
272
+ }
261
273
  if (error?.code === "CONCURRENT_UPDATE" || error?.name === "ConcurrentUpdateError") {
262
274
  return {
263
275
  status: 409,
@@ -667,6 +679,60 @@ var RestServer = class {
667
679
  });
668
680
  return true;
669
681
  }
682
+ /**
683
+ * Enforce object-level API exposure (ObjectSchema `enable.apiEnabled` /
684
+ * `enable.apiMethods`) on the REST data surface — the *external* API boundary
685
+ * only. Internal callers (hooks, flows, raw objectql) are unaffected, which is
686
+ * the point: `apiEnabled` controls automatic API exposure, not data access.
687
+ *
688
+ * - `enable.apiEnabled === false` → object hidden from the API (404, so its
689
+ * existence isn't revealed).
690
+ * - `enable.apiMethods` (non-empty whitelist) → unlisted operations rejected (405).
691
+ *
692
+ * Default-allow: objects with no `enable` block (or `apiEnabled` unset/true and
693
+ * no `apiMethods` whitelist) behave exactly as before — no regression. Unknown
694
+ * objects fall through to the normal 404 path. A metadata-read failure does not
695
+ * block (the data call itself needs the same metadata and will surface the
696
+ * error). Returns `true` when the request was blocked (response already sent).
697
+ *
698
+ * See ADR-0049 (#1889): shipping a non-enforcing `apiEnabled` is false security.
699
+ */
700
+ async enforceApiAccess(req, res, p, environmentId, operation) {
701
+ const objectName = req?.params?.object;
702
+ if (!objectName) return false;
703
+ let enable;
704
+ try {
705
+ const r = await p.getMetaItems?.({
706
+ type: "object",
707
+ ...environmentId ? { environmentId } : {}
708
+ });
709
+ const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
710
+ const obj = items.find((o) => o?.name === objectName);
711
+ if (!obj) return false;
712
+ enable = obj.enable;
713
+ } catch {
714
+ return false;
715
+ }
716
+ if (!enable) return false;
717
+ if (enable.apiEnabled === false) {
718
+ res.status(404).json({
719
+ error: `Object '${objectName}' is not exposed via the API`,
720
+ code: "OBJECT_API_DISABLED",
721
+ object: objectName
722
+ });
723
+ return true;
724
+ }
725
+ if (Array.isArray(enable.apiMethods) && enable.apiMethods.length > 0 && !enable.apiMethods.includes(operation)) {
726
+ res.status(405).json({
727
+ error: `API operation '${operation}' is not allowed on object '${objectName}'`,
728
+ code: "OBJECT_API_METHOD_NOT_ALLOWED",
729
+ object: objectName,
730
+ allowed: enable.apiMethods
731
+ });
732
+ return true;
733
+ }
734
+ return false;
735
+ }
670
736
  /**
671
737
  * Resolve the request's execution context (RBAC/RLS/FLS) by looking up
672
738
  * the better-auth session via the project's `auth` service. Returns
@@ -2118,6 +2184,7 @@ var RestServer = class {
2118
2184
  const p = await this.resolveProtocol(environmentId, req);
2119
2185
  const context = await this.resolveExecCtx(environmentId, req);
2120
2186
  if (this.enforceAuth(req, res, context)) return;
2187
+ if (await this.enforceApiAccess(req, res, p, environmentId, "list")) return;
2121
2188
  const result = await p.findData({
2122
2189
  object: req.params.object,
2123
2190
  query: req.query,
@@ -2152,6 +2219,7 @@ var RestServer = class {
2152
2219
  const { select, expand } = req.query || {};
2153
2220
  const context = await this.resolveExecCtx(environmentId, req);
2154
2221
  if (this.enforceAuth(req, res, context)) return;
2222
+ if (await this.enforceApiAccess(req, res, p, environmentId, "get")) return;
2155
2223
  const result = await p.getData({
2156
2224
  object: req.params.object,
2157
2225
  id: req.params.id,
@@ -2183,6 +2251,7 @@ var RestServer = class {
2183
2251
  const p = await this.resolveProtocol(environmentId, req);
2184
2252
  const context = await this.resolveExecCtx(environmentId, req);
2185
2253
  if (this.enforceAuth(req, res, context)) return;
2254
+ if (await this.enforceApiAccess(req, res, p, environmentId, "create")) return;
2186
2255
  const result = await p.createData({
2187
2256
  object: req.params.object,
2188
2257
  data: req.body,
@@ -2212,6 +2281,7 @@ var RestServer = class {
2212
2281
  const p = await this.resolveProtocol(environmentId, req);
2213
2282
  const context = await this.resolveExecCtx(environmentId, req);
2214
2283
  if (this.enforceAuth(req, res, context)) return;
2284
+ if (await this.enforceApiAccess(req, res, p, environmentId, "list")) return;
2215
2285
  const result = await p.findData({
2216
2286
  object: req.params.object,
2217
2287
  query: req.body || {},
@@ -2249,6 +2319,7 @@ var RestServer = class {
2249
2319
  const { expectedVersion: _drop, ...rest } = data;
2250
2320
  data = rest;
2251
2321
  }
2322
+ if (await this.enforceApiAccess(req, res, p, environmentId, "update")) return;
2252
2323
  const result = await p.updateData({
2253
2324
  object: req.params.object,
2254
2325
  id: req.params.id,
@@ -2283,6 +2354,7 @@ var RestServer = class {
2283
2354
  const ifMatchHeader = req.headers?.["if-match"] ?? req.headers?.["If-Match"];
2284
2355
  const queryVersion = req.query && typeof req.query === "object" ? req.query.expectedVersion : void 0;
2285
2356
  const expectedVersion = queryVersion ?? ifMatchHeader;
2357
+ if (await this.enforceApiAccess(req, res, p, environmentId, "delete")) return;
2286
2358
  const result = await p.deleteData({
2287
2359
  object: req.params.object,
2288
2360
  id: req.params.id,
@@ -2306,11 +2378,13 @@ var RestServer = class {
2306
2378
  }
2307
2379
  /**
2308
2380
  * Register object-specific action endpoints that don't fit the
2309
- * generic CRUD shape. These are domain operations (Salesforce
2310
- * convertLead, etc.) where the protocol implementation does its own
2311
- * multi-record orchestration and we just need a thin HTTP route.
2381
+ * generic CRUD shape domain operations where the protocol does its
2382
+ * own orchestration and we just need a thin HTTP route.
2312
2383
  *
2313
- * POST {basePath}/data/lead/:id/convertM10.6 lead conversion.
2384
+ * POST {basePath}/data/:object/:id/clonerecord clone (gated by
2385
+ * `enable.clone`). This is object-agnostic by design: it works for any
2386
+ * authored object regardless of namespace, unlike a hardcoded
2387
+ * per-object route would.
2314
2388
  */
2315
2389
  registerDataActionEndpoints(basePath) {
2316
2390
  const isScoped = basePath.includes("/environments/:environmentId");
@@ -2318,37 +2392,38 @@ var RestServer = class {
2318
2392
  const dataPath = `${basePath}${crud.dataPrefix}`;
2319
2393
  this.routeManager.register({
2320
2394
  method: "POST",
2321
- path: `${dataPath}/lead/:id/convert`,
2395
+ path: `${dataPath}/:object/:id/clone`,
2322
2396
  handler: async (req, res) => {
2323
2397
  try {
2324
2398
  const environmentId = isScoped ? req.params?.environmentId : void 0;
2325
2399
  const p = await this.resolveProtocol(environmentId, req);
2326
2400
  const context = await this.resolveExecCtx(environmentId, req);
2327
2401
  if (this.enforceAuth(req, res, context)) return;
2328
- const convertLead = p.convertLead;
2329
- if (typeof convertLead !== "function") {
2330
- res.status(501).json({ code: "NOT_IMPLEMENTED", error: "Lead convert not supported by this protocol" });
2402
+ if (await this.enforceApiAccess(req, res, p, environmentId, "create")) return;
2403
+ const cloneData = p.cloneData;
2404
+ if (typeof cloneData !== "function") {
2405
+ res.status(501).json({ code: "NOT_IMPLEMENTED", error: "Clone not supported by this protocol" });
2331
2406
  return;
2332
2407
  }
2333
2408
  const body = req.body ?? {};
2334
- const result = await convertLead.call(p, {
2335
- leadId: req.params.id,
2336
- accountId: body.accountId,
2337
- contactId: body.contactId,
2338
- createOpportunity: body.createOpportunity,
2339
- opportunity: body.opportunity,
2340
- convertedStatus: body.convertedStatus,
2409
+ const overrides = body && typeof body === "object" && "overrides" in body ? body.overrides : body;
2410
+ const result = await cloneData.call(p, {
2411
+ object: req.params.object,
2412
+ id: req.params.id,
2413
+ ...overrides && typeof overrides === "object" ? { overrides } : {},
2414
+ ...environmentId ? { environmentId } : {},
2341
2415
  ...context ? { context } : {}
2342
2416
  });
2343
- res.json(result);
2417
+ res.status(201).json(result);
2344
2418
  } catch (error) {
2345
- logError("[REST] Unhandled error:", error);
2346
- sendError(res, error, "lead");
2419
+ const status = typeof error?.status === "number" ? error.status : mapDataError(error, req.params?.object).status;
2420
+ if (!isExpectedDataStatus(status) && error?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
2421
+ sendError(res, error, req.params?.object);
2347
2422
  }
2348
2423
  },
2349
2424
  metadata: {
2350
- summary: "Convert a Lead into Account + Contact (+ optional Opportunity)",
2351
- tags: ["data", "lead"]
2425
+ summary: "Clone a record (gated by enable.clone)",
2426
+ tags: ["data", "clone"]
2352
2427
  }
2353
2428
  });
2354
2429
  this.routeManager.register({
@@ -2365,6 +2440,7 @@ var RestServer = class {
2365
2440
  res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
2366
2441
  return;
2367
2442
  }
2443
+ if (await this.enforceApiAccess(req, res, p, environmentId, "import")) return;
2368
2444
  const body = req.body ?? {};
2369
2445
  const dryRun = body.dryRun === true;
2370
2446
  const mapping = body.mapping ?? {};
@@ -2448,6 +2524,7 @@ var RestServer = class {
2448
2524
  res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
2449
2525
  return;
2450
2526
  }
2527
+ if (await this.enforceApiAccess(req, res, p, environmentId, "export")) return;
2451
2528
  const q = req.query ?? {};
2452
2529
  const format = String(q.format ?? "csv").toLowerCase() === "json" ? "json" : "csv";
2453
2530
  const HARD_CAP = 5e4;
@@ -2746,6 +2823,9 @@ var RestServer = class {
2746
2823
  }
2747
2824
  }
2748
2825
  }
2826
+ if (view.viewKind === "form" && view.config && typeof view.config === "object" && view.config.sharing) {
2827
+ candidates.push({ form: view.config, key: view.name });
2828
+ }
2749
2829
  for (const c of candidates) {
2750
2830
  const sharing = c.form?.sharing;
2751
2831
  if (!sharing || sharing.allowAnonymous !== true) continue;
@@ -3969,6 +4049,7 @@ var RestServer = class {
3969
4049
  const p = await this.resolveProtocol(environmentId, req);
3970
4050
  const context = await this.resolveExecCtx(environmentId, req);
3971
4051
  if (this.enforceAuth(req, res, context)) return;
4052
+ if (await this.enforceApiAccess(req, res, p, environmentId, "bulk")) return;
3972
4053
  const result = await p.batchData({
3973
4054
  object: req.params.object,
3974
4055
  request: req.body,
@@ -3997,6 +4078,7 @@ var RestServer = class {
3997
4078
  const p = await this.resolveProtocol(environmentId, req);
3998
4079
  const context = await this.resolveExecCtx(environmentId, req);
3999
4080
  if (this.enforceAuth(req, res, context)) return;
4081
+ if (await this.enforceApiAccess(req, res, p, environmentId, "create")) return;
4000
4082
  const result = await p.createManyData({
4001
4083
  object: req.params.object,
4002
4084
  records: req.body || [],
@@ -4025,6 +4107,7 @@ var RestServer = class {
4025
4107
  const p = await this.resolveProtocol(environmentId, req);
4026
4108
  const context = await this.resolveExecCtx(environmentId, req);
4027
4109
  if (this.enforceAuth(req, res, context)) return;
4110
+ if (await this.enforceApiAccess(req, res, p, environmentId, "update")) return;
4028
4111
  const result = await p.updateManyData({
4029
4112
  object: req.params.object,
4030
4113
  ...req.body,
@@ -4053,6 +4136,7 @@ var RestServer = class {
4053
4136
  const p = await this.resolveProtocol(environmentId, req);
4054
4137
  const context = await this.resolveExecCtx(environmentId, req);
4055
4138
  if (this.enforceAuth(req, res, context)) return;
4139
+ if (await this.enforceApiAccess(req, res, p, environmentId, "delete")) return;
4056
4140
  const result = await p.deleteManyData({
4057
4141
  object: req.params.object,
4058
4142
  ...req.body,