@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/LICENSE +202 -93
- package/README.md +1 -1
- package/dist/index.cjs +114 -146
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +115 -147
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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
|
|
806
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
...
|
|
952
|
-
...
|
|
953
|
-
...
|
|
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
|
|
1850
|
-
|
|
1851
|
-
|
|
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) => {
|