@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 +104 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -4
- package/dist/index.d.ts +25 -4
- package/dist/index.js +104 -20
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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
|
|
2310
|
-
*
|
|
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
|
|
2384
|
+
* POST {basePath}/data/:object/:id/clone — record 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}
|
|
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
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
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
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
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
|
-
|
|
2346
|
-
|
|
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: "
|
|
2351
|
-
tags: ["data", "
|
|
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,
|