@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.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
|
|
436
|
-
*
|
|
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
|
|
457
|
+
* POST {basePath}/data/:object/:id/clone — record 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
|
|
436
|
-
*
|
|
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
|
|
457
|
+
* POST {basePath}/data/:object/:id/clone — record 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
|
|
2270
|
-
*
|
|
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
|
|
2344
|
+
* POST {basePath}/data/:object/:id/clone — record 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}
|
|
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
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
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
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
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
|
-
|
|
2306
|
-
|
|
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: "
|
|
2311
|
-
tags: ["data", "
|
|
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,
|