@objectstack/rest 10.3.0 → 11.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.cjs CHANGED
@@ -500,6 +500,19 @@ var RestServer = class {
500
500
  */
501
501
  this.hostnameCache = /* @__PURE__ */ new Map();
502
502
  this.hostnameCacheTtlMs = 3e4;
503
+ /**
504
+ * Request-scoped memoization for `resolveExecCtx`. A single HTTP request
505
+ * resolves the SAME execution context (identity + RBAC/RLS + localization)
506
+ * many times — the data operation itself, app-nav RBAC filtering, dashboard
507
+ * widget gating, the auth gate, etc. Each resolution is ~16 sequential
508
+ * queries (the `resolveAuthzContext` aggregation plus localization), so a
509
+ * request that resolves twice pays for duplicate authz and repeated
510
+ * localization. Keyed by the per-request `req` object (a `WeakMap`, so the
511
+ * entry is collected with the request — naturally request-scoped, no TTL,
512
+ * no cross-request leak) and the input `environmentId`. We cache the
513
+ * in-flight Promise so concurrent callers share one resolution.
514
+ */
515
+ this.execCtxMemo = /* @__PURE__ */ new WeakMap();
503
516
  /**
504
517
  * Lazily load the OpenAPI spec JSON shipped by @objectstack/spec.
505
518
  * Cached after first read. Resilient to missing files / parse errors
@@ -673,6 +686,11 @@ var RestServer = class {
673
686
  * requests (they're internal-only), so they cannot bypass this gate.
674
687
  */
675
688
  enforceAuth(req, res, context) {
689
+ const gate = context?.authGate;
690
+ if (gate && req?.method !== "OPTIONS" && !(0, import_core.isAuthGateAllowlisted)(req?.path)) {
691
+ res.status(403).json({ error: { code: gate.code, message: gate.message } });
692
+ return true;
693
+ }
676
694
  if (!this.config.api.requireAuth) return false;
677
695
  if (context?.userId) return false;
678
696
  if (req?.method === "OPTIONS") return false;
@@ -743,6 +761,21 @@ var RestServer = class {
743
761
  * to the protocol layer (the SecurityPlugin treats undefined as anon).
744
762
  */
745
763
  async resolveExecCtx(environmentId, req) {
764
+ if (!req || typeof req !== "object") return this.computeExecCtx(environmentId, req);
765
+ const key = environmentId ?? "\0default";
766
+ let perReq = this.execCtxMemo.get(req);
767
+ if (!perReq) {
768
+ perReq = /* @__PURE__ */ new Map();
769
+ this.execCtxMemo.set(req, perReq);
770
+ }
771
+ const cached = perReq.get(key);
772
+ if (cached) return cached;
773
+ const pending = this.computeExecCtx(environmentId, req);
774
+ perReq.set(key, pending);
775
+ return pending;
776
+ }
777
+ /** Heavy path behind `resolveExecCtx` — resolve identity + RBAC/RLS + localization. */
778
+ async computeExecCtx(environmentId, req) {
746
779
  try {
747
780
  if (!environmentId && req && this.envRegistry && this.kernelManager) {
748
781
  const host = this.extractHostname(req);
@@ -802,155 +835,45 @@ var RestServer = class {
802
835
  } else {
803
836
  return void 0;
804
837
  }
805
- const permissions = [];
806
- const systemPermissions = [];
807
- const roles = [];
808
- let identityQl;
809
- if (kernel) identityQl = await kernel.getServiceAsync("objectql").catch(() => void 0);
810
- if (!identityQl && this.objectQLProvider) {
811
- identityQl = await this.objectQLProvider(environmentId).catch(() => void 0);
812
- }
813
- let userId;
814
- let tenantId;
815
- let email;
816
- const keyPrincipal = await (0, import_core.resolveApiKeyPrincipal)(identityQl, headers).catch(() => void 0);
817
- if (keyPrincipal) {
818
- userId = keyPrincipal.userId;
819
- tenantId = keyPrincipal.tenantId;
820
- for (const s of keyPrincipal.scopes) if (!permissions.includes(s)) permissions.push(s);
821
- } else {
822
- const session = await api.getSession({ headers });
823
- if (!session?.user?.id) return void 0;
824
- userId = session.user.id;
825
- tenantId = session.session?.activeOrganizationId ?? void 0;
826
- if (session.user?.email) email = String(session.user.email);
827
- }
828
- if (!email && identityQl && typeof identityQl.find === "function") {
829
- const urows = await identityQl.find("sys_user", { where: { id: userId }, limit: 1, context: { isSystem: true } }).catch(() => []);
830
- if (urows?.[0]?.email) email = String(urows[0].email);
831
- }
832
- try {
833
- let ql;
834
- if (kernel) {
835
- ql = await kernel.getServiceAsync("objectql").catch(() => void 0);
836
- }
837
- if (!ql && this.objectQLProvider) {
838
- ql = await this.objectQLProvider(environmentId).catch(() => void 0);
839
- }
840
- if (ql && typeof ql.find === "function") {
841
- const sysOpts = { context: { isSystem: true } };
842
- const memberRows = await ql.find("sys_member", {
843
- where: tenantId ? { user_id: userId, organization_id: tenantId } : { user_id: userId },
844
- limit: 50,
845
- ...sysOpts
846
- }).catch(() => []);
847
- for (const m of memberRows ?? []) {
848
- if (typeof m.role === "string") {
849
- for (const r of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
850
- if (!roles.includes(r)) roles.push(r);
851
- }
852
- }
853
- }
854
- const upsRows = await ql.find("sys_user_permission_set", {
855
- where: { user_id: userId },
856
- limit: 100,
857
- ...sysOpts
858
- }).catch(() => []);
859
- const psIds = /* @__PURE__ */ new Set();
860
- for (const r of upsRows ?? []) {
861
- const orgScope = r.organization_id ?? null;
862
- if (!orgScope || tenantId && orgScope === tenantId) {
863
- const pid = r.permission_set_id ?? r.permissionSetId;
864
- if (pid) psIds.add(pid);
865
- }
866
- }
867
- if (psIds.size > 0) {
868
- const psRows = await ql.find("sys_permission_set", {
869
- where: { id: { $in: Array.from(psIds) } },
870
- limit: 500,
871
- ...sysOpts
872
- }).catch(() => []);
873
- for (const ps of psRows ?? []) {
874
- if (ps.name && !permissions.includes(ps.name)) permissions.push(ps.name);
875
- const rawSys = typeof ps.system_permissions === "string" ? (() => {
876
- try {
877
- return JSON.parse(ps.system_permissions);
878
- } catch {
879
- return [];
880
- }
881
- })() : ps.system_permissions ?? ps.systemPermissions;
882
- if (Array.isArray(rawSys)) {
883
- for (const sp of rawSys) {
884
- if (typeof sp === "string" && !systemPermissions.includes(sp)) {
885
- systemPermissions.push(sp);
886
- }
887
- }
888
- }
889
- }
890
- }
891
- }
892
- } catch {
893
- }
894
- let org_user_ids = [userId];
895
- if (tenantId) {
838
+ const ql = kernel ? await kernel.getServiceAsync("objectql").catch(() => void 0) : this.objectQLProvider ? await this.objectQLProvider(environmentId).catch(() => void 0) : void 0;
839
+ const getSession = async (h) => {
896
840
  try {
897
- let ql;
898
- if (kernel) {
899
- ql = await kernel.getServiceAsync("objectql").catch(() => void 0);
900
- }
901
- if (!ql && this.objectQLProvider) {
902
- ql = await this.objectQLProvider(environmentId).catch(() => void 0);
903
- }
904
- if (ql && typeof ql.find === "function") {
905
- const sysOpts = { context: { isSystem: true } };
906
- const memberRows = await ql.find("sys_member", {
907
- where: { organization_id: tenantId },
908
- limit: 1e3,
909
- ...sysOpts
910
- }).catch(() => []);
911
- const ids = /* @__PURE__ */ new Set([userId]);
912
- for (const m of memberRows ?? []) {
913
- const uid = m.user_id ?? m.userId;
914
- if (typeof uid === "string" && uid.length > 0) ids.add(uid);
915
- }
916
- org_user_ids = Array.from(ids);
917
- }
841
+ return await api.getSession({ headers: h });
918
842
  } catch {
843
+ return void 0;
919
844
  }
920
- }
921
- let timezone;
922
- let locale;
923
- let currency;
845
+ };
846
+ const authz = await (0, import_core.resolveAuthzContext)({ ql, headers, getSession });
847
+ if (!authz.userId) return void 0;
848
+ const settings = this.settingsServiceProvider ? await this.settingsServiceProvider(environmentId).catch(() => void 0) : void 0;
849
+ const localization = await (0, import_core.resolveLocalizationContext)({
850
+ ql,
851
+ settings,
852
+ tenantId: authz.tenantId,
853
+ userId: authz.userId
854
+ });
855
+ let authGate;
924
856
  try {
925
- const settings = this.settingsServiceProvider ? await this.settingsServiceProvider(environmentId).catch(() => void 0) : void 0;
926
- if (settings && typeof settings.get === "function") {
927
- const sctx = { tenantId, userId };
928
- const [tzRes, localeRes, currencyRes] = await Promise.all([
929
- settings.get("localization", "timezone", sctx).catch(() => void 0),
930
- settings.get("localization", "locale", sctx).catch(() => void 0),
931
- settings.get("localization", "currency", sctx).catch(() => void 0)
932
- ]);
933
- const tzVal = tzRes?.value;
934
- const localeVal = localeRes?.value;
935
- const currencyVal = currencyRes?.value;
936
- if (typeof tzVal === "string" && tzVal.trim()) timezone = tzVal.trim();
937
- if (typeof localeVal === "string" && localeVal.trim()) locale = localeVal.trim();
938
- if (typeof currencyVal === "string" && currencyVal.trim()) currency = currencyVal.trim().toUpperCase();
857
+ if (typeof authService.isAuthGateActive === "function" && authService.isAuthGateActive()) {
858
+ const gatedSession = await getSession(headers).catch(() => void 0);
859
+ if (gatedSession?.user?.authGate) authGate = gatedSession.user.authGate;
939
860
  }
940
861
  } catch {
941
862
  }
942
863
  return {
943
- userId,
944
- tenantId,
945
- email,
946
- roles,
947
- permissions,
948
- systemPermissions,
864
+ userId: authz.userId,
865
+ tenantId: authz.tenantId,
866
+ email: authz.email,
867
+ roles: authz.roles,
868
+ permissions: authz.permissions,
869
+ systemPermissions: authz.systemPermissions,
870
+ ...authz.tabPermissions ? { tabPermissions: authz.tabPermissions } : {},
949
871
  isSystem: false,
950
- org_user_ids,
951
- ...timezone ? { timezone } : {},
952
- ...locale ? { locale } : {},
953
- ...currency ? { currency } : {},
872
+ org_user_ids: authz.org_user_ids,
873
+ ...authGate ? { authGate } : {},
874
+ ...localization.timezone ? { timezone: localization.timezone } : {},
875
+ ...localization.locale ? { locale: localization.locale } : {},
876
+ ...localization.currency ? { currency: localization.currency } : {},
954
877
  // Internal: resolved kernel so the nav-serving path can probe
955
878
  // requiresService capability gates (ADR-0057 D10). NOT an
956
879
  // authorization input — never read by RLS/permission logic.
@@ -1010,6 +933,30 @@ var RestServer = class {
1010
933
  };
1011
934
  return { ...item, navigation: filterNav(nav) };
1012
935
  }
936
+ /**
937
+ * ADR-0057 D10 (dashboards): strip dashboard widgets whose `requiresService`
938
+ * capability gate names a kernel service that isn't registered — the same
939
+ * "server is the authoritative visibility gate" rule already applied to app
940
+ * nav entries (see {@link filterAppForUser}). Without this, a widget bound to
941
+ * an optional service renders a dead tile in deployments where the service is
942
+ * off (e.g. the Organizations KPI under multi-tenant `org-scoping`, which is
943
+ * absent in a single-tenant runtime while its nav entry is correctly hidden).
944
+ *
945
+ * Fail-open when the gate can't be probed (serviceGate undefined). Never
946
+ * mutates the original — returns a shallow copy only when a widget is dropped.
947
+ */
948
+ filterDashboardForUser(item, serviceGate) {
949
+ if (!item || typeof item !== "object" || !serviceGate) return item;
950
+ if (isMetaEnvelope(item)) {
951
+ const body = this.filterDashboardForUser(item.item, serviceGate);
952
+ return body === item.item ? item : { ...item, item: body };
953
+ }
954
+ if (!Array.isArray(item.widgets)) return item;
955
+ const widgets = item.widgets.filter(
956
+ (w) => !(w && typeof w.requiresService === "string" && serviceGate(w.requiresService) === false)
957
+ );
958
+ return widgets.length === item.widgets.length ? item : { ...item, widgets };
959
+ }
1013
960
  /**
1014
961
  * Probe which `requiresService` capability gates referenced anywhere in
1015
962
  * `items` are actually registered in the runtime kernel. Returns `null`
@@ -1046,7 +993,7 @@ var RestServer = class {
1046
993
  return;
1047
994
  }
1048
995
  if (typeof e.requiresService === "string") wanted.add(e.requiresService);
1049
- const kids = Array.isArray(e.navigation) ? e.navigation : Array.isArray(e.children) ? e.children : null;
996
+ const kids = Array.isArray(e.navigation) ? e.navigation : Array.isArray(e.children) ? e.children : Array.isArray(e.widgets) ? e.widgets : null;
1050
997
  if (kids) for (const k of kids) walk(k);
1051
998
  };
1052
999
  for (const it of items) walk(it);
@@ -1334,10 +1281,10 @@ var RestServer = class {
1334
1281
  this.registerReportsEndpoints(bp);
1335
1282
  this.registerApprovalsEndpoints(bp);
1336
1283
  this.registerAnalyticsEndpoints(bp);
1284
+ this.registerDataActionEndpoints(bp);
1337
1285
  if (this.config.api.enableCrud) {
1338
1286
  this.registerCrudEndpoints(bp);
1339
1287
  }
1340
- this.registerDataActionEndpoints(bp);
1341
1288
  if (this.config.api.enableBatch) {
1342
1289
  this.registerBatchEndpoints(bp);
1343
1290
  }
@@ -1674,6 +1621,19 @@ var RestServer = class {
1674
1621
  }
1675
1622
  }
1676
1623
  }
1624
+ if (req.params.type === "dashboard") {
1625
+ const raw = visible;
1626
+ const list = Array.isArray(raw) ? raw : raw && typeof raw === "object" && Array.isArray(raw.items) ? raw.items : null;
1627
+ if (list) {
1628
+ const ctx = await this.resolveExecCtx(environmentId, req).catch(() => void 0);
1629
+ const registered = await this.resolveRegisteredServices(ctx?.__kernel, list);
1630
+ const serviceGate = registered ? (n) => registered.has(n) : void 0;
1631
+ if (serviceGate) {
1632
+ const filtered = list.map((it) => this.filterDashboardForUser(it, serviceGate));
1633
+ visible = Array.isArray(raw) ? filtered : { ...raw, items: filtered };
1634
+ }
1635
+ }
1636
+ }
1677
1637
  if (req.params.type === "view" && req.query?.object) {
1678
1638
  const obj = String(req.query.object);
1679
1639
  const raw = visible;
@@ -1846,9 +1806,11 @@ var RestServer = class {
1846
1806
  res.header("Last-Modified", new Date(result.lastModified).toUTCString());
1847
1807
  }
1848
1808
  if (result.cacheControl) {
1849
- const directives = result.cacheControl.directives.join(", ");
1850
- const maxAge = result.cacheControl.maxAge ? `, max-age=${result.cacheControl.maxAge}` : "";
1851
- res.header("Cache-Control", directives + maxAge);
1809
+ const parts = result.cacheControl.directives.filter((d) => d !== "max-age");
1810
+ if (result.cacheControl.maxAge != null) {
1811
+ parts.push(`max-age=${result.cacheControl.maxAge}`);
1812
+ }
1813
+ res.header("Cache-Control", parts.join(", "));
1852
1814
  }
1853
1815
  res.header("Vary", "Accept-Language");
1854
1816
  res.json(await this.translateMetaItem(req, req.params.type, environmentId, result.data, cacheI18n));
@@ -1881,6 +1843,12 @@ var RestServer = class {
1881
1843
  }
1882
1844
  }
1883
1845
  }
1846
+ if (req.params.type === "dashboard" && visible) {
1847
+ const ctx = await this.resolveExecCtx(environmentId, req).catch(() => void 0);
1848
+ const registered = await this.resolveRegisteredServices(ctx?.__kernel, [visible]);
1849
+ const serviceGate = registered ? (n) => registered.has(n) : void 0;
1850
+ if (serviceGate) visible = this.filterDashboardForUser(visible, serviceGate);
1851
+ }
1884
1852
  if (req.params.type === "doc" && visible) {
1885
1853
  const locale = this.extractLocale(req);
1886
1854
  const { resolveDocLocale } = await import("@objectstack/spec/system");
@@ -3009,7 +2977,7 @@ var RestServer = class {
3009
2977
  const allow = (name, cfg) => {
3010
2978
  const def = objectSchema?.fields?.[name];
3011
2979
  const t = def?.type;
3012
- if (t !== "lookup" && t !== "master_detail") return true;
2980
+ if (t !== "lookup" && t !== "master_detail" && t !== "user") return true;
3013
2981
  return !!cfg?.publicPicker;
3014
2982
  };
3015
2983
  const sections = match.form.sections.map((sec) => {