@objectstack/rest 6.9.0 → 7.1.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
@@ -280,6 +280,20 @@ declare class RestServer {
280
280
  * to the protocol layer (the SecurityPlugin treats undefined as anon).
281
281
  */
282
282
  private resolveExecCtx;
283
+ /**
284
+ * Filter an `App` metadata item by the current user's `systemPermissions`.
285
+ *
286
+ * - Drops the app entirely if its top-level `requiredPermissions` are not
287
+ * a subset of the user's system permissions.
288
+ * - Recursively strips child navigation entries (groups, items) whose
289
+ * `requiredPermissions` are not satisfied. Empty groups collapse so
290
+ * the sidebar doesn't render a label with no children.
291
+ *
292
+ * Returns `null` when the app should be hidden from the user. Returns a
293
+ * shallow copy with a filtered `navigation` tree otherwise — the original
294
+ * is never mutated so cached metadata stays clean.
295
+ */
296
+ private filterAppForUser;
283
297
  /**
284
298
  * Build a `TranslationBundle` (`Record<locale, TranslationData>`) from an
285
299
  * `II18nService` instance. Returns `undefined` when no locales are
@@ -304,6 +318,16 @@ declare class RestServer {
304
318
  * Translate a list of metadata documents using `translateMetaItem`.
305
319
  */
306
320
  private translateMetaItems;
321
+ /**
322
+ * Translate the `entries` payload returned by `getMetaTypes()` — applies
323
+ * the active locale to each entry's `label`, `description`, and the
324
+ * nested `form` layout (section labels, field labels, helpText,
325
+ * placeholders) via `metadataForms.<type>` translation namespace.
326
+ *
327
+ * No-ops when no i18n service / locale / matching bundle entry exists,
328
+ * so this is safe to call unconditionally from the `/meta` handler.
329
+ */
330
+ private translateMetaTypesResponse;
307
331
  /**
308
332
  * Pull the request hostname (without port) from a Node-style `req` or
309
333
  * a Fetch-style request wrapper. Returns undefined when no Host header
package/dist/index.d.ts CHANGED
@@ -280,6 +280,20 @@ declare class RestServer {
280
280
  * to the protocol layer (the SecurityPlugin treats undefined as anon).
281
281
  */
282
282
  private resolveExecCtx;
283
+ /**
284
+ * Filter an `App` metadata item by the current user's `systemPermissions`.
285
+ *
286
+ * - Drops the app entirely if its top-level `requiredPermissions` are not
287
+ * a subset of the user's system permissions.
288
+ * - Recursively strips child navigation entries (groups, items) whose
289
+ * `requiredPermissions` are not satisfied. Empty groups collapse so
290
+ * the sidebar doesn't render a label with no children.
291
+ *
292
+ * Returns `null` when the app should be hidden from the user. Returns a
293
+ * shallow copy with a filtered `navigation` tree otherwise — the original
294
+ * is never mutated so cached metadata stays clean.
295
+ */
296
+ private filterAppForUser;
283
297
  /**
284
298
  * Build a `TranslationBundle` (`Record<locale, TranslationData>`) from an
285
299
  * `II18nService` instance. Returns `undefined` when no locales are
@@ -304,6 +318,16 @@ declare class RestServer {
304
318
  * Translate a list of metadata documents using `translateMetaItem`.
305
319
  */
306
320
  private translateMetaItems;
321
+ /**
322
+ * Translate the `entries` payload returned by `getMetaTypes()` — applies
323
+ * the active locale to each entry's `label`, `description`, and the
324
+ * nested `form` layout (section labels, field labels, helpText,
325
+ * placeholders) via `metadataForms.<type>` translation namespace.
326
+ *
327
+ * No-ops when no i18n service / locale / matching bundle entry exists,
328
+ * so this is safe to call unconditionally from the `/meta` handler.
329
+ */
330
+ private translateMetaTypesResponse;
307
331
  /**
308
332
  * Pull the request hostname (without port) from a Node-style `req` or
309
333
  * a Fetch-style request wrapper. Returns undefined when no Host header
package/dist/index.js CHANGED
@@ -628,6 +628,7 @@ var RestServer = class {
628
628
  const userId = session.user.id;
629
629
  const tenantId = session.session?.activeOrganizationId ?? void 0;
630
630
  const permissions = [];
631
+ const systemPermissions = [];
631
632
  const roles = [];
632
633
  try {
633
634
  let ql;
@@ -672,6 +673,20 @@ var RestServer = class {
672
673
  }).catch(() => []);
673
674
  for (const ps of psRows ?? []) {
674
675
  if (ps.name && !permissions.includes(ps.name)) permissions.push(ps.name);
676
+ const rawSys = typeof ps.system_permissions === "string" ? (() => {
677
+ try {
678
+ return JSON.parse(ps.system_permissions);
679
+ } catch {
680
+ return [];
681
+ }
682
+ })() : ps.system_permissions ?? ps.systemPermissions;
683
+ if (Array.isArray(rawSys)) {
684
+ for (const sp of rawSys) {
685
+ if (typeof sp === "string" && !systemPermissions.includes(sp)) {
686
+ systemPermissions.push(sp);
687
+ }
688
+ }
689
+ }
675
690
  }
676
691
  }
677
692
  }
@@ -709,6 +724,7 @@ var RestServer = class {
709
724
  tenantId,
710
725
  roles,
711
726
  permissions,
727
+ systemPermissions,
712
728
  isSystem: false,
713
729
  org_user_ids
714
730
  };
@@ -716,6 +732,45 @@ var RestServer = class {
716
732
  return void 0;
717
733
  }
718
734
  }
735
+ /**
736
+ * Filter an `App` metadata item by the current user's `systemPermissions`.
737
+ *
738
+ * - Drops the app entirely if its top-level `requiredPermissions` are not
739
+ * a subset of the user's system permissions.
740
+ * - Recursively strips child navigation entries (groups, items) whose
741
+ * `requiredPermissions` are not satisfied. Empty groups collapse so
742
+ * the sidebar doesn't render a label with no children.
743
+ *
744
+ * Returns `null` when the app should be hidden from the user. Returns a
745
+ * shallow copy with a filtered `navigation` tree otherwise — the original
746
+ * is never mutated so cached metadata stays clean.
747
+ */
748
+ filterAppForUser(item, sysPerms) {
749
+ if (!item || typeof item !== "object") return item;
750
+ const reqApp = Array.isArray(item.requiredPermissions) ? item.requiredPermissions : [];
751
+ if (reqApp.length > 0 && !reqApp.every((p) => sysPerms.has(p))) {
752
+ return null;
753
+ }
754
+ const nav = Array.isArray(item.navigation) ? item.navigation : null;
755
+ if (!nav) return item;
756
+ const filterNav = (entries) => {
757
+ const out = [];
758
+ for (const e of entries) {
759
+ if (!e || typeof e !== "object") continue;
760
+ const req = Array.isArray(e.requiredPermissions) ? e.requiredPermissions : [];
761
+ if (req.length > 0 && !req.every((p) => sysPerms.has(p))) continue;
762
+ if (Array.isArray(e.children) && e.children.length > 0) {
763
+ const kids = filterNav(e.children);
764
+ if (e.type === "group" && kids.length === 0) continue;
765
+ out.push({ ...e, children: kids });
766
+ } else {
767
+ out.push(e);
768
+ }
769
+ }
770
+ return out;
771
+ };
772
+ return { ...item, navigation: filterNav(nav) };
773
+ }
719
774
  /**
720
775
  * Build a `TranslationBundle` (`Record<locale, TranslationData>`) from an
721
776
  * `II18nService` instance. Returns `undefined` when no locales are
@@ -789,6 +844,41 @@ var RestServer = class {
789
844
  const { translateMetadataDocument } = await import("@objectstack/spec/system");
790
845
  return items.map((item) => translateMetadataDocument(type, item, bundle, { locale }));
791
846
  }
847
+ /**
848
+ * Translate the `entries` payload returned by `getMetaTypes()` — applies
849
+ * the active locale to each entry's `label`, `description`, and the
850
+ * nested `form` layout (section labels, field labels, helpText,
851
+ * placeholders) via `metadataForms.<type>` translation namespace.
852
+ *
853
+ * No-ops when no i18n service / locale / matching bundle entry exists,
854
+ * so this is safe to call unconditionally from the `/meta` handler.
855
+ */
856
+ async translateMetaTypesResponse(req, environmentId, payload) {
857
+ if (!payload || typeof payload !== "object" || !Array.isArray(payload.entries)) return payload;
858
+ const i18n = await this.resolveI18nService(environmentId, req);
859
+ const bundle = this.buildTranslationBundle(i18n);
860
+ if (!bundle) return payload;
861
+ const locale = this.extractLocale(req, i18n);
862
+ if (!locale) return payload;
863
+ const {
864
+ resolveMetadataTypeLabel,
865
+ resolveMetadataTypeDescription,
866
+ resolveMetadataFormLabels
867
+ } = await import("@objectstack/spec/system");
868
+ const opts = { locale };
869
+ const entries = payload.entries.map((entry) => {
870
+ if (!entry || typeof entry !== "object" || typeof entry.type !== "string") return entry;
871
+ const next = { ...entry };
872
+ next.label = resolveMetadataTypeLabel(bundle, entry.type, entry.label ?? entry.type, opts);
873
+ const desc = resolveMetadataTypeDescription(bundle, entry.type, entry.description, opts);
874
+ if (desc !== void 0) next.description = desc;
875
+ if (entry.form) {
876
+ next.form = resolveMetadataFormLabels(entry.form, entry.type, bundle, opts);
877
+ }
878
+ return next;
879
+ });
880
+ return { ...payload, entries };
881
+ }
792
882
  /**
793
883
  * Pull the request hostname (without port) from a Node-style `req` or
794
884
  * a Fetch-style request wrapper. Returns undefined when no Host header
@@ -1171,7 +1261,9 @@ var RestServer = class {
1171
1261
  const environmentId = isScoped ? req.params?.environmentId : void 0;
1172
1262
  const p = await this.resolveProtocol(environmentId, req);
1173
1263
  const types = await p.getMetaTypes();
1174
- res.json(types);
1264
+ const translated = await this.translateMetaTypesResponse(req, environmentId, types);
1265
+ res.header("Vary", "Accept-Language");
1266
+ res.json(translated);
1175
1267
  } catch (error) {
1176
1268
  logError("[REST] Unhandled error:", error);
1177
1269
  sendError(res, error);
@@ -1183,6 +1275,40 @@ var RestServer = class {
1183
1275
  }
1184
1276
  });
1185
1277
  }
1278
+ if (metadata.endpoints.items !== false) {
1279
+ this.routeManager.register({
1280
+ method: "GET",
1281
+ path: `${metaPath}/diagnostics`,
1282
+ handler: async (req, res) => {
1283
+ try {
1284
+ const environmentId = isScoped ? req.params?.environmentId : void 0;
1285
+ const p = await this.resolveProtocol(environmentId, req);
1286
+ if (typeof p.getMetaDiagnostics !== "function") {
1287
+ res.status(501).json({
1288
+ error: "not_implemented",
1289
+ message: "protocol.getMetaDiagnostics() is not available in this kernel"
1290
+ });
1291
+ return;
1292
+ }
1293
+ const severityParam = req.query?.severity ?? "error";
1294
+ const severity = severityParam === "warning" ? "warning" : "error";
1295
+ const result = await p.getMetaDiagnostics({
1296
+ type: req.query?.type || void 0,
1297
+ severity,
1298
+ packageId: req.query?.package || void 0
1299
+ });
1300
+ res.json(result);
1301
+ } catch (error) {
1302
+ logError("[REST] Unhandled error:", error);
1303
+ sendError(res, error);
1304
+ }
1305
+ },
1306
+ metadata: {
1307
+ summary: "List metadata entries that fail spec validation",
1308
+ tags: ["metadata"]
1309
+ }
1310
+ });
1311
+ }
1186
1312
  if (metadata.endpoints.items !== false) {
1187
1313
  this.routeManager.register({
1188
1314
  method: "GET",
@@ -1197,7 +1323,22 @@ var RestServer = class {
1197
1323
  packageId,
1198
1324
  ...environmentId ? { environmentId } : {}
1199
1325
  });
1200
- const translated = await this.translateMetaItems(req, req.params.type, environmentId, items);
1326
+ let visible = items;
1327
+ if (req.params.type === "app") {
1328
+ const raw = items;
1329
+ const list = Array.isArray(raw) ? raw : raw && typeof raw === "object" && Array.isArray(raw.items) ? raw.items : null;
1330
+ if (list) {
1331
+ const ctx = await this.resolveExecCtx(environmentId, req).catch(() => void 0);
1332
+ if (ctx?.userId) {
1333
+ const sysPerms = new Set(
1334
+ Array.isArray(ctx.systemPermissions) ? ctx.systemPermissions : []
1335
+ );
1336
+ const filtered = list.map((it) => this.filterAppForUser(it, sysPerms)).filter((it) => it != null);
1337
+ visible = Array.isArray(raw) ? filtered : { ...raw, items: filtered };
1338
+ }
1339
+ }
1340
+ }
1341
+ const translated = await this.translateMetaItems(req, req.params.type, environmentId, visible);
1201
1342
  res.header("Vary", "Accept-Language");
1202
1343
  res.json(translated);
1203
1344
  } catch (error) {
@@ -1256,7 +1397,9 @@ var RestServer = class {
1256
1397
  res.json(layered);
1257
1398
  return;
1258
1399
  }
1259
- if (metadata.enableCache && p.getMetaItemCached) {
1400
+ const isAppType = req.params.type === "app";
1401
+ const isDraftRead = typeof req.query?.state === "string" && req.query.state.toLowerCase() === "draft";
1402
+ if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead) {
1260
1403
  const cacheRequest = {
1261
1404
  ifNoneMatch: req.headers["if-none-match"],
1262
1405
  ifModifiedSince: req.headers["if-modified-since"]
@@ -1287,13 +1430,32 @@ var RestServer = class {
1287
1430
  res.json(await this.translateMetaItem(req, req.params.type, environmentId, result.data));
1288
1431
  } else {
1289
1432
  const packageId = req.query?.package || void 0;
1433
+ const stateParam = typeof req.query?.state === "string" ? req.query.state.toLowerCase() : void 0;
1290
1434
  const item = await p.getMetaItem({
1291
1435
  type: req.params.type,
1292
1436
  name: req.params.name,
1293
- packageId
1437
+ packageId,
1438
+ ...stateParam === "draft" ? { state: "draft" } : {}
1294
1439
  });
1440
+ let visible = item;
1441
+ if (isAppType && item) {
1442
+ const ctx = await this.resolveExecCtx(environmentId, req).catch(() => void 0);
1443
+ if (ctx?.userId) {
1444
+ const sysPerms = new Set(
1445
+ Array.isArray(ctx.systemPermissions) ? ctx.systemPermissions : []
1446
+ );
1447
+ visible = this.filterAppForUser(item, sysPerms);
1448
+ if (visible == null) {
1449
+ res.status(404).json({
1450
+ error: "not_found",
1451
+ message: "Metadata item not found or access denied."
1452
+ });
1453
+ return;
1454
+ }
1455
+ }
1456
+ }
1295
1457
  res.header("Vary", "Accept-Language");
1296
- res.json(await this.translateMetaItem(req, req.params.type, environmentId, item));
1458
+ res.json(await this.translateMetaItem(req, req.params.type, environmentId, visible));
1297
1459
  }
1298
1460
  } catch (error) {
1299
1461
  logError("[REST] Unhandled error:", error);
@@ -1332,7 +1494,8 @@ var RestServer = class {
1332
1494
  ...environmentId ? { environmentId } : {},
1333
1495
  ...parentVersion !== void 0 ? { parentVersion } : {},
1334
1496
  ...actor ? { actor } : {},
1335
- ...force ? { force: true } : {}
1497
+ ...force ? { force: true } : {},
1498
+ ...typeof req.query?.mode === "string" && req.query.mode.toLowerCase() === "draft" ? { mode: "draft" } : {}
1336
1499
  });
1337
1500
  res.json(result);
1338
1501
  } catch (error) {
@@ -1362,12 +1525,14 @@ var RestServer = class {
1362
1525
  const parentVersion = typeof ifMatchHeader === "string" ? ifMatchHeader.replace(/^"|"$/g, "") : void 0;
1363
1526
  const actorHeader = req.headers?.["x-actor"] ?? req.headers?.["X-Actor"] ?? req.user?.id ?? req.userId;
1364
1527
  const actor = typeof actorHeader === "string" ? actorHeader : void 0;
1528
+ const stateParam = typeof req.query?.state === "string" && req.query.state.toLowerCase() === "draft" ? "draft" : void 0;
1365
1529
  const result = await p.deleteMetaItem({
1366
1530
  type: req.params.type,
1367
1531
  name: req.params.name,
1368
1532
  ...environmentId ? { environmentId } : {},
1369
1533
  ...parentVersion !== void 0 ? { parentVersion } : {},
1370
- ...actor ? { actor } : {}
1534
+ ...actor ? { actor } : {},
1535
+ ...stateParam ? { state: stateParam } : {}
1371
1536
  });
1372
1537
  res.json(result);
1373
1538
  } catch (error) {
@@ -1413,6 +1578,124 @@ var RestServer = class {
1413
1578
  tags: ["metadata"]
1414
1579
  }
1415
1580
  });
1581
+ this.routeManager.register({
1582
+ method: "POST",
1583
+ path: `${metaPath}/:type/:name/publish`,
1584
+ handler: async (req, res) => {
1585
+ try {
1586
+ const environmentId = isScoped ? req.params?.environmentId : void 0;
1587
+ const p = await this.resolveProtocol(environmentId, req);
1588
+ if (!p.publishMetaItem) {
1589
+ res.status(501).json({
1590
+ error: "Publish operation not supported by protocol implementation"
1591
+ });
1592
+ return;
1593
+ }
1594
+ const actorHeader = req.headers?.["x-actor"] ?? req.headers?.["X-Actor"] ?? req.user?.id ?? req.userId;
1595
+ const actor = typeof actorHeader === "string" ? actorHeader : void 0;
1596
+ const body = req.body && typeof req.body === "object" ? req.body : {};
1597
+ const message = typeof body.message === "string" ? body.message : void 0;
1598
+ const result = await p.publishMetaItem({
1599
+ type: req.params.type,
1600
+ name: req.params.name,
1601
+ ...environmentId ? { environmentId } : {},
1602
+ ...actor ? { actor } : {},
1603
+ ...message ? { message } : {}
1604
+ });
1605
+ res.json(result);
1606
+ } catch (error) {
1607
+ logError("[REST] Unhandled error:", error);
1608
+ sendError(res, error);
1609
+ }
1610
+ },
1611
+ metadata: {
1612
+ summary: "Publish the pending draft overlay (promotes draft \u2192 active)",
1613
+ tags: ["metadata"]
1614
+ }
1615
+ });
1616
+ this.routeManager.register({
1617
+ method: "POST",
1618
+ path: `${metaPath}/:type/:name/rollback`,
1619
+ handler: async (req, res) => {
1620
+ try {
1621
+ const environmentId = isScoped ? req.params?.environmentId : void 0;
1622
+ const p = await this.resolveProtocol(environmentId, req);
1623
+ if (!p.rollbackMetaItem) {
1624
+ res.status(501).json({
1625
+ error: "Rollback operation not supported by protocol implementation"
1626
+ });
1627
+ return;
1628
+ }
1629
+ const body = req.body && typeof req.body === "object" ? req.body : {};
1630
+ const toVersionRaw = body.toVersion ?? body.version ?? req.query?.toVersion;
1631
+ const toVersion = Number(toVersionRaw);
1632
+ if (!Number.isFinite(toVersion) || toVersion < 1) {
1633
+ res.status(400).json({
1634
+ error: `'toVersion' (positive integer) is required`,
1635
+ code: "invalid_request"
1636
+ });
1637
+ return;
1638
+ }
1639
+ const actorHeader = req.headers?.["x-actor"] ?? req.headers?.["X-Actor"] ?? req.user?.id ?? req.userId;
1640
+ const actor = typeof actorHeader === "string" ? actorHeader : void 0;
1641
+ const message = typeof body.message === "string" ? body.message : void 0;
1642
+ const result = await p.rollbackMetaItem({
1643
+ type: req.params.type,
1644
+ name: req.params.name,
1645
+ toVersion,
1646
+ ...environmentId ? { environmentId } : {},
1647
+ ...actor ? { actor } : {},
1648
+ ...message ? { message } : {}
1649
+ });
1650
+ res.json(result);
1651
+ } catch (error) {
1652
+ logError("[REST] Unhandled error:", error);
1653
+ sendError(res, error);
1654
+ }
1655
+ },
1656
+ metadata: {
1657
+ summary: "Restore the body at the given history version as the new live row",
1658
+ tags: ["metadata"]
1659
+ }
1660
+ });
1661
+ this.routeManager.register({
1662
+ method: "GET",
1663
+ path: `${metaPath}/:type/:name/diff`,
1664
+ handler: async (req, res) => {
1665
+ try {
1666
+ const environmentId = isScoped ? req.params?.environmentId : void 0;
1667
+ const p = await this.resolveProtocol(environmentId, req);
1668
+ if (!p.diffMetaItem) {
1669
+ res.status(501).json({
1670
+ error: "Diff operation not supported by protocol implementation"
1671
+ });
1672
+ return;
1673
+ }
1674
+ const parseV = (raw) => {
1675
+ if (raw === void 0 || raw === null || raw === "") return void 0;
1676
+ const n = Number(raw);
1677
+ return Number.isFinite(n) ? n : void 0;
1678
+ };
1679
+ const fromVersion = parseV(req.query?.from ?? req.query?.fromVersion);
1680
+ const toVersion = parseV(req.query?.to ?? req.query?.toVersion);
1681
+ const result = await p.diffMetaItem({
1682
+ type: req.params.type,
1683
+ name: req.params.name,
1684
+ ...environmentId ? { environmentId } : {},
1685
+ ...fromVersion !== void 0 ? { fromVersion } : {},
1686
+ ...toVersion !== void 0 ? { toVersion } : {}
1687
+ });
1688
+ res.json(result);
1689
+ } catch (error) {
1690
+ logError("[REST] Unhandled error:", error);
1691
+ sendError(res, error);
1692
+ }
1693
+ },
1694
+ metadata: {
1695
+ summary: "Diff two metadata versions (from/to query params; omit for previous-vs-current)",
1696
+ tags: ["metadata"]
1697
+ }
1698
+ });
1416
1699
  if (metadata.endpoints.item !== false) {
1417
1700
  this.routeManager.register({
1418
1701
  method: "GET",