@objectstack/rest 9.6.0 → 9.8.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
@@ -667,6 +667,60 @@ var RestServer = class {
667
667
  });
668
668
  return true;
669
669
  }
670
+ /**
671
+ * Enforce object-level API exposure (ObjectSchema `enable.apiEnabled` /
672
+ * `enable.apiMethods`) on the REST data surface — the *external* API boundary
673
+ * only. Internal callers (hooks, flows, raw objectql) are unaffected, which is
674
+ * the point: `apiEnabled` controls automatic API exposure, not data access.
675
+ *
676
+ * - `enable.apiEnabled === false` → object hidden from the API (404, so its
677
+ * existence isn't revealed).
678
+ * - `enable.apiMethods` (non-empty whitelist) → unlisted operations rejected (405).
679
+ *
680
+ * Default-allow: objects with no `enable` block (or `apiEnabled` unset/true and
681
+ * no `apiMethods` whitelist) behave exactly as before — no regression. Unknown
682
+ * objects fall through to the normal 404 path. A metadata-read failure does not
683
+ * block (the data call itself needs the same metadata and will surface the
684
+ * error). Returns `true` when the request was blocked (response already sent).
685
+ *
686
+ * See ADR-0049 (#1889): shipping a non-enforcing `apiEnabled` is false security.
687
+ */
688
+ async enforceApiAccess(req, res, p, environmentId, operation) {
689
+ const objectName = req?.params?.object;
690
+ if (!objectName) return false;
691
+ let enable;
692
+ try {
693
+ const r = await p.getMetaItems?.({
694
+ type: "object",
695
+ ...environmentId ? { environmentId } : {}
696
+ });
697
+ const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
698
+ const obj = items.find((o) => o?.name === objectName);
699
+ if (!obj) return false;
700
+ enable = obj.enable;
701
+ } catch {
702
+ return false;
703
+ }
704
+ if (!enable) return false;
705
+ if (enable.apiEnabled === false) {
706
+ res.status(404).json({
707
+ error: `Object '${objectName}' is not exposed via the API`,
708
+ code: "OBJECT_API_DISABLED",
709
+ object: objectName
710
+ });
711
+ return true;
712
+ }
713
+ if (Array.isArray(enable.apiMethods) && enable.apiMethods.length > 0 && !enable.apiMethods.includes(operation)) {
714
+ res.status(405).json({
715
+ error: `API operation '${operation}' is not allowed on object '${objectName}'`,
716
+ code: "OBJECT_API_METHOD_NOT_ALLOWED",
717
+ object: objectName,
718
+ allowed: enable.apiMethods
719
+ });
720
+ return true;
721
+ }
722
+ return false;
723
+ }
670
724
  /**
671
725
  * Resolve the request's execution context (RBAC/RLS/FLS) by looking up
672
726
  * the better-auth session via the project's `auth` service. Returns
@@ -2118,6 +2172,7 @@ var RestServer = class {
2118
2172
  const p = await this.resolveProtocol(environmentId, req);
2119
2173
  const context = await this.resolveExecCtx(environmentId, req);
2120
2174
  if (this.enforceAuth(req, res, context)) return;
2175
+ if (await this.enforceApiAccess(req, res, p, environmentId, "list")) return;
2121
2176
  const result = await p.findData({
2122
2177
  object: req.params.object,
2123
2178
  query: req.query,
@@ -2152,6 +2207,7 @@ var RestServer = class {
2152
2207
  const { select, expand } = req.query || {};
2153
2208
  const context = await this.resolveExecCtx(environmentId, req);
2154
2209
  if (this.enforceAuth(req, res, context)) return;
2210
+ if (await this.enforceApiAccess(req, res, p, environmentId, "get")) return;
2155
2211
  const result = await p.getData({
2156
2212
  object: req.params.object,
2157
2213
  id: req.params.id,
@@ -2183,6 +2239,7 @@ var RestServer = class {
2183
2239
  const p = await this.resolveProtocol(environmentId, req);
2184
2240
  const context = await this.resolveExecCtx(environmentId, req);
2185
2241
  if (this.enforceAuth(req, res, context)) return;
2242
+ if (await this.enforceApiAccess(req, res, p, environmentId, "create")) return;
2186
2243
  const result = await p.createData({
2187
2244
  object: req.params.object,
2188
2245
  data: req.body,
@@ -2212,6 +2269,7 @@ var RestServer = class {
2212
2269
  const p = await this.resolveProtocol(environmentId, req);
2213
2270
  const context = await this.resolveExecCtx(environmentId, req);
2214
2271
  if (this.enforceAuth(req, res, context)) return;
2272
+ if (await this.enforceApiAccess(req, res, p, environmentId, "list")) return;
2215
2273
  const result = await p.findData({
2216
2274
  object: req.params.object,
2217
2275
  query: req.body || {},
@@ -2249,6 +2307,7 @@ var RestServer = class {
2249
2307
  const { expectedVersion: _drop, ...rest } = data;
2250
2308
  data = rest;
2251
2309
  }
2310
+ if (await this.enforceApiAccess(req, res, p, environmentId, "update")) return;
2252
2311
  const result = await p.updateData({
2253
2312
  object: req.params.object,
2254
2313
  id: req.params.id,
@@ -2283,6 +2342,7 @@ var RestServer = class {
2283
2342
  const ifMatchHeader = req.headers?.["if-match"] ?? req.headers?.["If-Match"];
2284
2343
  const queryVersion = req.query && typeof req.query === "object" ? req.query.expectedVersion : void 0;
2285
2344
  const expectedVersion = queryVersion ?? ifMatchHeader;
2345
+ if (await this.enforceApiAccess(req, res, p, environmentId, "delete")) return;
2286
2346
  const result = await p.deleteData({
2287
2347
  object: req.params.object,
2288
2348
  id: req.params.id,
@@ -2351,6 +2411,42 @@ var RestServer = class {
2351
2411
  tags: ["data", "lead"]
2352
2412
  }
2353
2413
  });
2414
+ this.routeManager.register({
2415
+ method: "POST",
2416
+ path: `${dataPath}/:object/:id/clone`,
2417
+ handler: async (req, res) => {
2418
+ try {
2419
+ const environmentId = isScoped ? req.params?.environmentId : void 0;
2420
+ const p = await this.resolveProtocol(environmentId, req);
2421
+ const context = await this.resolveExecCtx(environmentId, req);
2422
+ if (this.enforceAuth(req, res, context)) return;
2423
+ if (await this.enforceApiAccess(req, res, p, environmentId, "create")) return;
2424
+ const cloneData = p.cloneData;
2425
+ if (typeof cloneData !== "function") {
2426
+ res.status(501).json({ code: "NOT_IMPLEMENTED", error: "Clone not supported by this protocol" });
2427
+ return;
2428
+ }
2429
+ const body = req.body ?? {};
2430
+ const overrides = body && typeof body === "object" && "overrides" in body ? body.overrides : body;
2431
+ const result = await cloneData.call(p, {
2432
+ object: req.params.object,
2433
+ id: req.params.id,
2434
+ ...overrides && typeof overrides === "object" ? { overrides } : {},
2435
+ ...environmentId ? { environmentId } : {},
2436
+ ...context ? { context } : {}
2437
+ });
2438
+ res.status(201).json(result);
2439
+ } catch (error) {
2440
+ const status = typeof error?.status === "number" ? error.status : mapDataError(error, req.params?.object).status;
2441
+ if (!isExpectedDataStatus(status) && error?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
2442
+ sendError(res, error, req.params?.object);
2443
+ }
2444
+ },
2445
+ metadata: {
2446
+ summary: "Clone a record (gated by enable.clone)",
2447
+ tags: ["data", "clone"]
2448
+ }
2449
+ });
2354
2450
  this.routeManager.register({
2355
2451
  method: "POST",
2356
2452
  path: `${dataPath}/:object/import`,
@@ -2365,6 +2461,7 @@ var RestServer = class {
2365
2461
  res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
2366
2462
  return;
2367
2463
  }
2464
+ if (await this.enforceApiAccess(req, res, p, environmentId, "import")) return;
2368
2465
  const body = req.body ?? {};
2369
2466
  const dryRun = body.dryRun === true;
2370
2467
  const mapping = body.mapping ?? {};
@@ -2448,6 +2545,7 @@ var RestServer = class {
2448
2545
  res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
2449
2546
  return;
2450
2547
  }
2548
+ if (await this.enforceApiAccess(req, res, p, environmentId, "export")) return;
2451
2549
  const q = req.query ?? {};
2452
2550
  const format = String(q.format ?? "csv").toLowerCase() === "json" ? "json" : "csv";
2453
2551
  const HARD_CAP = 5e4;
@@ -3969,6 +4067,7 @@ var RestServer = class {
3969
4067
  const p = await this.resolveProtocol(environmentId, req);
3970
4068
  const context = await this.resolveExecCtx(environmentId, req);
3971
4069
  if (this.enforceAuth(req, res, context)) return;
4070
+ if (await this.enforceApiAccess(req, res, p, environmentId, "bulk")) return;
3972
4071
  const result = await p.batchData({
3973
4072
  object: req.params.object,
3974
4073
  request: req.body,
@@ -3997,6 +4096,7 @@ var RestServer = class {
3997
4096
  const p = await this.resolveProtocol(environmentId, req);
3998
4097
  const context = await this.resolveExecCtx(environmentId, req);
3999
4098
  if (this.enforceAuth(req, res, context)) return;
4099
+ if (await this.enforceApiAccess(req, res, p, environmentId, "create")) return;
4000
4100
  const result = await p.createManyData({
4001
4101
  object: req.params.object,
4002
4102
  records: req.body || [],
@@ -4025,6 +4125,7 @@ var RestServer = class {
4025
4125
  const p = await this.resolveProtocol(environmentId, req);
4026
4126
  const context = await this.resolveExecCtx(environmentId, req);
4027
4127
  if (this.enforceAuth(req, res, context)) return;
4128
+ if (await this.enforceApiAccess(req, res, p, environmentId, "update")) return;
4028
4129
  const result = await p.updateManyData({
4029
4130
  object: req.params.object,
4030
4131
  ...req.body,
@@ -4053,6 +4154,7 @@ var RestServer = class {
4053
4154
  const p = await this.resolveProtocol(environmentId, req);
4054
4155
  const context = await this.resolveExecCtx(environmentId, req);
4055
4156
  if (this.enforceAuth(req, res, context)) return;
4157
+ if (await this.enforceApiAccess(req, res, p, environmentId, "delete")) return;
4056
4158
  const result = await p.deleteManyData({
4057
4159
  object: req.params.object,
4058
4160
  ...req.body,