@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.d.cts CHANGED
@@ -299,6 +299,25 @@ declare class RestServer {
299
299
  * requests (they're internal-only), so they cannot bypass this gate.
300
300
  */
301
301
  private enforceAuth;
302
+ /**
303
+ * Enforce object-level API exposure (ObjectSchema `enable.apiEnabled` /
304
+ * `enable.apiMethods`) on the REST data surface — the *external* API boundary
305
+ * only. Internal callers (hooks, flows, raw objectql) are unaffected, which is
306
+ * the point: `apiEnabled` controls automatic API exposure, not data access.
307
+ *
308
+ * - `enable.apiEnabled === false` → object hidden from the API (404, so its
309
+ * existence isn't revealed).
310
+ * - `enable.apiMethods` (non-empty whitelist) → unlisted operations rejected (405).
311
+ *
312
+ * Default-allow: objects with no `enable` block (or `apiEnabled` unset/true and
313
+ * no `apiMethods` whitelist) behave exactly as before — no regression. Unknown
314
+ * objects fall through to the normal 404 path. A metadata-read failure does not
315
+ * block (the data call itself needs the same metadata and will surface the
316
+ * error). Returns `true` when the request was blocked (response already sent).
317
+ *
318
+ * See ADR-0049 (#1889): shipping a non-enforcing `apiEnabled` is false security.
319
+ */
320
+ private enforceApiAccess;
302
321
  /**
303
322
  * Resolve the request's execution context (RBAC/RLS/FLS) by looking up
304
323
  * the better-auth session via the project's `auth` service. Returns
@@ -432,11 +451,13 @@ declare class RestServer {
432
451
  private registerCrudEndpoints;
433
452
  /**
434
453
  * Register object-specific action endpoints that don't fit the
435
- * generic CRUD shape. These are domain operations (Salesforce
436
- * convertLead, etc.) where the protocol implementation does its own
437
- * multi-record orchestration and we just need a thin HTTP route.
454
+ * generic CRUD shape domain operations where the protocol does its
455
+ * own orchestration and we just need a thin HTTP route.
438
456
  *
439
- * POST {basePath}/data/lead/:id/convertM10.6 lead conversion.
457
+ * POST {basePath}/data/:object/:id/clonerecord clone (gated by
458
+ * `enable.clone`). This is object-agnostic by design: it works for any
459
+ * authored object regardless of namespace, unlike a hardcoded
460
+ * per-object route would.
440
461
  */
441
462
  private registerDataActionEndpoints;
442
463
  /**
package/dist/index.d.ts CHANGED
@@ -299,6 +299,25 @@ declare class RestServer {
299
299
  * requests (they're internal-only), so they cannot bypass this gate.
300
300
  */
301
301
  private enforceAuth;
302
+ /**
303
+ * Enforce object-level API exposure (ObjectSchema `enable.apiEnabled` /
304
+ * `enable.apiMethods`) on the REST data surface — the *external* API boundary
305
+ * only. Internal callers (hooks, flows, raw objectql) are unaffected, which is
306
+ * the point: `apiEnabled` controls automatic API exposure, not data access.
307
+ *
308
+ * - `enable.apiEnabled === false` → object hidden from the API (404, so its
309
+ * existence isn't revealed).
310
+ * - `enable.apiMethods` (non-empty whitelist) → unlisted operations rejected (405).
311
+ *
312
+ * Default-allow: objects with no `enable` block (or `apiEnabled` unset/true and
313
+ * no `apiMethods` whitelist) behave exactly as before — no regression. Unknown
314
+ * objects fall through to the normal 404 path. A metadata-read failure does not
315
+ * block (the data call itself needs the same metadata and will surface the
316
+ * error). Returns `true` when the request was blocked (response already sent).
317
+ *
318
+ * See ADR-0049 (#1889): shipping a non-enforcing `apiEnabled` is false security.
319
+ */
320
+ private enforceApiAccess;
302
321
  /**
303
322
  * Resolve the request's execution context (RBAC/RLS/FLS) by looking up
304
323
  * the better-auth session via the project's `auth` service. Returns
@@ -432,11 +451,13 @@ declare class RestServer {
432
451
  private registerCrudEndpoints;
433
452
  /**
434
453
  * Register object-specific action endpoints that don't fit the
435
- * generic CRUD shape. These are domain operations (Salesforce
436
- * convertLead, etc.) where the protocol implementation does its own
437
- * multi-record orchestration and we just need a thin HTTP route.
454
+ * generic CRUD shape domain operations where the protocol does its
455
+ * own orchestration and we just need a thin HTTP route.
438
456
  *
439
- * POST {basePath}/data/lead/:id/convertM10.6 lead conversion.
457
+ * POST {basePath}/data/:object/:id/clonerecord clone (gated by
458
+ * `enable.clone`). This is object-agnostic by design: it works for any
459
+ * authored object regardless of namespace, unlike a hardcoded
460
+ * per-object route would.
440
461
  */
441
462
  private registerDataActionEndpoints;
442
463
  /**
package/dist/index.js CHANGED
@@ -218,6 +218,18 @@ function isMetaEnvelope(value) {
218
218
  return !!value && typeof value === "object" && typeof value.type === "string" && typeof value.name === "string" && value.item != null && typeof value.item === "object" && !Array.isArray(value.item);
219
219
  }
220
220
  function mapDataError(error, object) {
221
+ if (error?.code === "DELETE_RESTRICTED") {
222
+ return {
223
+ status: 409,
224
+ body: {
225
+ error: error?.message ?? "Cannot delete: dependent records exist",
226
+ code: "DELETE_RESTRICTED",
227
+ ...error?.dependentObject ? { dependentObject: error.dependentObject } : {},
228
+ ...typeof error?.dependentCount === "number" ? { dependentCount: error.dependentCount } : {},
229
+ ...object ? { object } : {}
230
+ }
231
+ };
232
+ }
221
233
  if (error?.code === "CONCURRENT_UPDATE" || error?.name === "ConcurrentUpdateError") {
222
234
  return {
223
235
  status: 409,
@@ -627,6 +639,60 @@ var RestServer = class {
627
639
  });
628
640
  return true;
629
641
  }
642
+ /**
643
+ * Enforce object-level API exposure (ObjectSchema `enable.apiEnabled` /
644
+ * `enable.apiMethods`) on the REST data surface — the *external* API boundary
645
+ * only. Internal callers (hooks, flows, raw objectql) are unaffected, which is
646
+ * the point: `apiEnabled` controls automatic API exposure, not data access.
647
+ *
648
+ * - `enable.apiEnabled === false` → object hidden from the API (404, so its
649
+ * existence isn't revealed).
650
+ * - `enable.apiMethods` (non-empty whitelist) → unlisted operations rejected (405).
651
+ *
652
+ * Default-allow: objects with no `enable` block (or `apiEnabled` unset/true and
653
+ * no `apiMethods` whitelist) behave exactly as before — no regression. Unknown
654
+ * objects fall through to the normal 404 path. A metadata-read failure does not
655
+ * block (the data call itself needs the same metadata and will surface the
656
+ * error). Returns `true` when the request was blocked (response already sent).
657
+ *
658
+ * See ADR-0049 (#1889): shipping a non-enforcing `apiEnabled` is false security.
659
+ */
660
+ async enforceApiAccess(req, res, p, environmentId, operation) {
661
+ const objectName = req?.params?.object;
662
+ if (!objectName) return false;
663
+ let enable;
664
+ try {
665
+ const r = await p.getMetaItems?.({
666
+ type: "object",
667
+ ...environmentId ? { environmentId } : {}
668
+ });
669
+ const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
670
+ const obj = items.find((o) => o?.name === objectName);
671
+ if (!obj) return false;
672
+ enable = obj.enable;
673
+ } catch {
674
+ return false;
675
+ }
676
+ if (!enable) return false;
677
+ if (enable.apiEnabled === false) {
678
+ res.status(404).json({
679
+ error: `Object '${objectName}' is not exposed via the API`,
680
+ code: "OBJECT_API_DISABLED",
681
+ object: objectName
682
+ });
683
+ return true;
684
+ }
685
+ if (Array.isArray(enable.apiMethods) && enable.apiMethods.length > 0 && !enable.apiMethods.includes(operation)) {
686
+ res.status(405).json({
687
+ error: `API operation '${operation}' is not allowed on object '${objectName}'`,
688
+ code: "OBJECT_API_METHOD_NOT_ALLOWED",
689
+ object: objectName,
690
+ allowed: enable.apiMethods
691
+ });
692
+ return true;
693
+ }
694
+ return false;
695
+ }
630
696
  /**
631
697
  * Resolve the request's execution context (RBAC/RLS/FLS) by looking up
632
698
  * the better-auth session via the project's `auth` service. Returns
@@ -2078,6 +2144,7 @@ var RestServer = class {
2078
2144
  const p = await this.resolveProtocol(environmentId, req);
2079
2145
  const context = await this.resolveExecCtx(environmentId, req);
2080
2146
  if (this.enforceAuth(req, res, context)) return;
2147
+ if (await this.enforceApiAccess(req, res, p, environmentId, "list")) return;
2081
2148
  const result = await p.findData({
2082
2149
  object: req.params.object,
2083
2150
  query: req.query,
@@ -2112,6 +2179,7 @@ var RestServer = class {
2112
2179
  const { select, expand } = req.query || {};
2113
2180
  const context = await this.resolveExecCtx(environmentId, req);
2114
2181
  if (this.enforceAuth(req, res, context)) return;
2182
+ if (await this.enforceApiAccess(req, res, p, environmentId, "get")) return;
2115
2183
  const result = await p.getData({
2116
2184
  object: req.params.object,
2117
2185
  id: req.params.id,
@@ -2143,6 +2211,7 @@ var RestServer = class {
2143
2211
  const p = await this.resolveProtocol(environmentId, req);
2144
2212
  const context = await this.resolveExecCtx(environmentId, req);
2145
2213
  if (this.enforceAuth(req, res, context)) return;
2214
+ if (await this.enforceApiAccess(req, res, p, environmentId, "create")) return;
2146
2215
  const result = await p.createData({
2147
2216
  object: req.params.object,
2148
2217
  data: req.body,
@@ -2172,6 +2241,7 @@ var RestServer = class {
2172
2241
  const p = await this.resolveProtocol(environmentId, req);
2173
2242
  const context = await this.resolveExecCtx(environmentId, req);
2174
2243
  if (this.enforceAuth(req, res, context)) return;
2244
+ if (await this.enforceApiAccess(req, res, p, environmentId, "list")) return;
2175
2245
  const result = await p.findData({
2176
2246
  object: req.params.object,
2177
2247
  query: req.body || {},
@@ -2209,6 +2279,7 @@ var RestServer = class {
2209
2279
  const { expectedVersion: _drop, ...rest } = data;
2210
2280
  data = rest;
2211
2281
  }
2282
+ if (await this.enforceApiAccess(req, res, p, environmentId, "update")) return;
2212
2283
  const result = await p.updateData({
2213
2284
  object: req.params.object,
2214
2285
  id: req.params.id,
@@ -2243,6 +2314,7 @@ var RestServer = class {
2243
2314
  const ifMatchHeader = req.headers?.["if-match"] ?? req.headers?.["If-Match"];
2244
2315
  const queryVersion = req.query && typeof req.query === "object" ? req.query.expectedVersion : void 0;
2245
2316
  const expectedVersion = queryVersion ?? ifMatchHeader;
2317
+ if (await this.enforceApiAccess(req, res, p, environmentId, "delete")) return;
2246
2318
  const result = await p.deleteData({
2247
2319
  object: req.params.object,
2248
2320
  id: req.params.id,
@@ -2266,11 +2338,13 @@ var RestServer = class {
2266
2338
  }
2267
2339
  /**
2268
2340
  * Register object-specific action endpoints that don't fit the
2269
- * generic CRUD shape. These are domain operations (Salesforce
2270
- * convertLead, etc.) where the protocol implementation does its own
2271
- * multi-record orchestration and we just need a thin HTTP route.
2341
+ * generic CRUD shape domain operations where the protocol does its
2342
+ * own orchestration and we just need a thin HTTP route.
2272
2343
  *
2273
- * POST {basePath}/data/lead/:id/convertM10.6 lead conversion.
2344
+ * POST {basePath}/data/:object/:id/clonerecord clone (gated by
2345
+ * `enable.clone`). This is object-agnostic by design: it works for any
2346
+ * authored object regardless of namespace, unlike a hardcoded
2347
+ * per-object route would.
2274
2348
  */
2275
2349
  registerDataActionEndpoints(basePath) {
2276
2350
  const isScoped = basePath.includes("/environments/:environmentId");
@@ -2278,37 +2352,38 @@ var RestServer = class {
2278
2352
  const dataPath = `${basePath}${crud.dataPrefix}`;
2279
2353
  this.routeManager.register({
2280
2354
  method: "POST",
2281
- path: `${dataPath}/lead/:id/convert`,
2355
+ path: `${dataPath}/:object/:id/clone`,
2282
2356
  handler: async (req, res) => {
2283
2357
  try {
2284
2358
  const environmentId = isScoped ? req.params?.environmentId : void 0;
2285
2359
  const p = await this.resolveProtocol(environmentId, req);
2286
2360
  const context = await this.resolveExecCtx(environmentId, req);
2287
2361
  if (this.enforceAuth(req, res, context)) return;
2288
- const convertLead = p.convertLead;
2289
- if (typeof convertLead !== "function") {
2290
- res.status(501).json({ code: "NOT_IMPLEMENTED", error: "Lead convert not supported by this protocol" });
2362
+ if (await this.enforceApiAccess(req, res, p, environmentId, "create")) return;
2363
+ const cloneData = p.cloneData;
2364
+ if (typeof cloneData !== "function") {
2365
+ res.status(501).json({ code: "NOT_IMPLEMENTED", error: "Clone not supported by this protocol" });
2291
2366
  return;
2292
2367
  }
2293
2368
  const body = req.body ?? {};
2294
- const result = await convertLead.call(p, {
2295
- leadId: req.params.id,
2296
- accountId: body.accountId,
2297
- contactId: body.contactId,
2298
- createOpportunity: body.createOpportunity,
2299
- opportunity: body.opportunity,
2300
- convertedStatus: body.convertedStatus,
2369
+ const overrides = body && typeof body === "object" && "overrides" in body ? body.overrides : body;
2370
+ const result = await cloneData.call(p, {
2371
+ object: req.params.object,
2372
+ id: req.params.id,
2373
+ ...overrides && typeof overrides === "object" ? { overrides } : {},
2374
+ ...environmentId ? { environmentId } : {},
2301
2375
  ...context ? { context } : {}
2302
2376
  });
2303
- res.json(result);
2377
+ res.status(201).json(result);
2304
2378
  } catch (error) {
2305
- logError("[REST] Unhandled error:", error);
2306
- sendError(res, error, "lead");
2379
+ const status = typeof error?.status === "number" ? error.status : mapDataError(error, req.params?.object).status;
2380
+ if (!isExpectedDataStatus(status) && error?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
2381
+ sendError(res, error, req.params?.object);
2307
2382
  }
2308
2383
  },
2309
2384
  metadata: {
2310
- summary: "Convert a Lead into Account + Contact (+ optional Opportunity)",
2311
- tags: ["data", "lead"]
2385
+ summary: "Clone a record (gated by enable.clone)",
2386
+ tags: ["data", "clone"]
2312
2387
  }
2313
2388
  });
2314
2389
  this.routeManager.register({
@@ -2325,6 +2400,7 @@ var RestServer = class {
2325
2400
  res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
2326
2401
  return;
2327
2402
  }
2403
+ if (await this.enforceApiAccess(req, res, p, environmentId, "import")) return;
2328
2404
  const body = req.body ?? {};
2329
2405
  const dryRun = body.dryRun === true;
2330
2406
  const mapping = body.mapping ?? {};
@@ -2408,6 +2484,7 @@ var RestServer = class {
2408
2484
  res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
2409
2485
  return;
2410
2486
  }
2487
+ if (await this.enforceApiAccess(req, res, p, environmentId, "export")) return;
2411
2488
  const q = req.query ?? {};
2412
2489
  const format = String(q.format ?? "csv").toLowerCase() === "json" ? "json" : "csv";
2413
2490
  const HARD_CAP = 5e4;
@@ -2706,6 +2783,9 @@ var RestServer = class {
2706
2783
  }
2707
2784
  }
2708
2785
  }
2786
+ if (view.viewKind === "form" && view.config && typeof view.config === "object" && view.config.sharing) {
2787
+ candidates.push({ form: view.config, key: view.name });
2788
+ }
2709
2789
  for (const c of candidates) {
2710
2790
  const sharing = c.form?.sharing;
2711
2791
  if (!sharing || sharing.allowAnonymous !== true) continue;
@@ -3929,6 +4009,7 @@ var RestServer = class {
3929
4009
  const p = await this.resolveProtocol(environmentId, req);
3930
4010
  const context = await this.resolveExecCtx(environmentId, req);
3931
4011
  if (this.enforceAuth(req, res, context)) return;
4012
+ if (await this.enforceApiAccess(req, res, p, environmentId, "bulk")) return;
3932
4013
  const result = await p.batchData({
3933
4014
  object: req.params.object,
3934
4015
  request: req.body,
@@ -3957,6 +4038,7 @@ var RestServer = class {
3957
4038
  const p = await this.resolveProtocol(environmentId, req);
3958
4039
  const context = await this.resolveExecCtx(environmentId, req);
3959
4040
  if (this.enforceAuth(req, res, context)) return;
4041
+ if (await this.enforceApiAccess(req, res, p, environmentId, "create")) return;
3960
4042
  const result = await p.createManyData({
3961
4043
  object: req.params.object,
3962
4044
  records: req.body || [],
@@ -3985,6 +4067,7 @@ var RestServer = class {
3985
4067
  const p = await this.resolveProtocol(environmentId, req);
3986
4068
  const context = await this.resolveExecCtx(environmentId, req);
3987
4069
  if (this.enforceAuth(req, res, context)) return;
4070
+ if (await this.enforceApiAccess(req, res, p, environmentId, "update")) return;
3988
4071
  const result = await p.updateManyData({
3989
4072
  object: req.params.object,
3990
4073
  ...req.body,
@@ -4013,6 +4096,7 @@ var RestServer = class {
4013
4096
  const p = await this.resolveProtocol(environmentId, req);
4014
4097
  const context = await this.resolveExecCtx(environmentId, req);
4015
4098
  if (this.enforceAuth(req, res, context)) return;
4099
+ if (await this.enforceApiAccess(req, res, p, environmentId, "delete")) return;
4016
4100
  const result = await p.deleteManyData({
4017
4101
  object: req.params.object,
4018
4102
  ...req.body,