@objectstack/rest 4.1.0 → 4.1.1

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
@@ -430,7 +430,7 @@ function rowsToCsv(fields, rows, includeHeader) {
430
430
  return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
431
431
  }
432
432
  var RestServer = class {
433
- constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider) {
433
+ constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider) {
434
434
  this.protocol = protocol;
435
435
  this.config = this.normalizeConfig(config);
436
436
  this.routeManager = new RouteManager(server);
@@ -444,6 +444,7 @@ var RestServer = class {
444
444
  this.reportsServiceProvider = reportsServiceProvider;
445
445
  this.approvalsServiceProvider = approvalsServiceProvider;
446
446
  this.sharingRulesServiceProvider = sharingRulesServiceProvider;
447
+ this.i18nServiceProvider = i18nServiceProvider;
447
448
  }
448
449
  /**
449
450
  * Resolve the protocol for a given request. When `projectId` is present
@@ -511,14 +512,51 @@ var RestServer = class {
511
512
  * requests intentionally return `undefined` because the platform kernel
512
513
  * does not own per-app translation bundles.
513
514
  */
514
- async resolveI18nService(projectId) {
515
- if (!projectId || projectId === "platform" || !this.kernelManager) return void 0;
516
- try {
517
- const kernel = await this.kernelManager.getOrCreate(projectId);
518
- return await kernel.getServiceAsync("i18n");
519
- } catch {
520
- return void 0;
515
+ async resolveI18nService(projectId, req) {
516
+ if (projectId === "platform") return void 0;
517
+ if (!projectId && req && this.envRegistry && this.kernelManager) {
518
+ const host = this.extractHostname(req);
519
+ if (host) {
520
+ try {
521
+ const result = await this.envRegistry.resolveByHostname(host);
522
+ if (result?.projectId) projectId = result.projectId;
523
+ } catch {
524
+ }
525
+ }
526
+ if (!projectId && typeof this.envRegistry.resolveById === "function") {
527
+ const headerVal = this.extractProjectIdHeader(req);
528
+ if (headerVal) {
529
+ try {
530
+ const driver = await this.envRegistry.resolveById(headerVal);
531
+ if (driver) projectId = headerVal;
532
+ } catch {
533
+ }
534
+ }
535
+ }
536
+ }
537
+ if (!projectId && this.defaultProjectIdProvider) {
538
+ try {
539
+ const def = this.defaultProjectIdProvider();
540
+ if (def) projectId = def;
541
+ } catch {
542
+ }
543
+ }
544
+ if (projectId && this.kernelManager) {
545
+ try {
546
+ const kernel = await this.kernelManager.getOrCreate(projectId);
547
+ const svc = await kernel.getServiceAsync("i18n");
548
+ if (svc) return svc;
549
+ } catch {
550
+ }
551
+ }
552
+ if (this.i18nServiceProvider) {
553
+ try {
554
+ return await this.i18nServiceProvider(projectId);
555
+ } catch {
556
+ return void 0;
557
+ }
521
558
  }
559
+ return void 0;
522
560
  }
523
561
  /**
524
562
  * Reject anonymous requests with HTTP 401 when `api.requireAuth` is set.
@@ -720,8 +758,8 @@ var RestServer = class {
720
758
  */
721
759
  async translateMetaItem(req, type, projectId, item) {
722
760
  if (!item || typeof item !== "object") return item;
723
- if (type !== "view" && type !== "action") return item;
724
- const i18n = await this.resolveI18nService(projectId);
761
+ if (type !== "view" && type !== "action" && type !== "object") return item;
762
+ const i18n = await this.resolveI18nService(projectId, req);
725
763
  const bundle = this.buildTranslationBundle(i18n);
726
764
  if (!bundle) return item;
727
765
  const locale = this.extractLocale(req, i18n);
@@ -734,8 +772,8 @@ var RestServer = class {
734
772
  */
735
773
  async translateMetaItems(req, type, projectId, items) {
736
774
  if (!Array.isArray(items)) return items;
737
- if (type !== "view" && type !== "action") return items;
738
- const i18n = await this.resolveI18nService(projectId);
775
+ if (type !== "view" && type !== "action" && type !== "object") return items;
776
+ const i18n = await this.resolveI18nService(projectId, req);
739
777
  const bundle = this.buildTranslationBundle(i18n);
740
778
  if (!bundle) return items;
741
779
  const locale = this.extractLocale(req, i18n);
@@ -895,6 +933,7 @@ var RestServer = class {
895
933
  this.registerSearchEndpoints(bp);
896
934
  }
897
935
  this.registerEmailEndpoints(bp);
936
+ this.registerFormEndpoints(bp);
898
937
  this.registerSharingEndpoints(bp);
899
938
  this.registerSharingRuleEndpoints(bp);
900
939
  this.registerReportsEndpoints(bp);
@@ -1824,6 +1863,348 @@ var RestServer = class {
1824
1863
  }
1825
1864
  });
1826
1865
  }
1866
+ /**
1867
+ * Register public (anonymous) form endpoints.
1868
+ *
1869
+ * Public forms are opt-in: a `FormView` becomes accessible to anonymous
1870
+ * visitors only when `sharing.allowAnonymous === true` AND a
1871
+ * `sharing.publicLink` slug is configured. Two routes are registered:
1872
+ *
1873
+ * GET {basePath}/forms/:slug → resolved form spec
1874
+ * POST {basePath}/forms/:slug/submit → INSERT record (no auth required)
1875
+ *
1876
+ * Both routes bypass `enforceAuth` even when `requireAuth=true` on the
1877
+ * deployment (e.g. ObjectOS multi-tenant). Security is delegated to the
1878
+ * `guest_portal` permission set carried on the execution context — the
1879
+ * SecurityPlugin enforces INSERT-only access to the target object. If
1880
+ * the deployment hasn't registered a `guest_portal` profile, the
1881
+ * security middleware falls open with `permissions: []` (no userId),
1882
+ * matching the existing anonymous-access semantics; deployers must
1883
+ * keep `requireAuth=true` deployments paired with a `guest_portal`
1884
+ * profile (the CRM example does this) to enforce the INSERT-only
1885
+ * contract.
1886
+ *
1887
+ * The matched FormView's parent ViewSchema is found by scanning
1888
+ * `protocol.getMetaItems({ type: 'view' })`. For each entry we inspect
1889
+ * `form.sharing` and every entry in `formViews`; the first FormView
1890
+ * whose `sharing.publicLink` matches `/forms/:slug` (or just `:slug`)
1891
+ * wins. The response carries the matched form view under `form` and
1892
+ * the inferred target object, matching what the frontend's
1893
+ * `mapViewSpecToEmbeddableConfig` expects.
1894
+ */
1895
+ registerFormEndpoints(basePath) {
1896
+ const isScoped = basePath.includes("/projects/:projectId");
1897
+ const slugMatchesPublicLink = (publicLink, slug) => {
1898
+ if (!publicLink || typeof publicLink !== "string") return false;
1899
+ const normalized = publicLink.replace(/^\/+/, "").replace(/^forms\//, "");
1900
+ return normalized === slug;
1901
+ };
1902
+ const findPublicFormView = (views, slug) => {
1903
+ for (const view of views ?? []) {
1904
+ if (!view || typeof view !== "object") continue;
1905
+ const candidates = [];
1906
+ if (view.form && view.form.sharing) candidates.push({ form: view.form });
1907
+ const formViews = view.formViews;
1908
+ if (formViews && typeof formViews === "object") {
1909
+ for (const [key, fv] of Object.entries(formViews)) {
1910
+ if (fv && typeof fv === "object" && fv.sharing) {
1911
+ candidates.push({ form: fv, key });
1912
+ }
1913
+ }
1914
+ }
1915
+ for (const c of candidates) {
1916
+ const sharing = c.form?.sharing;
1917
+ if (!sharing || sharing.allowAnonymous !== true) continue;
1918
+ if (!slugMatchesPublicLink(sharing.publicLink, slug)) continue;
1919
+ const objectName = c.form?.data?.object ?? view?.list?.data?.object ?? view?.form?.data?.object ?? view?.object;
1920
+ if (!objectName) continue;
1921
+ return { view, form: c.form, object: objectName };
1922
+ }
1923
+ }
1924
+ return null;
1925
+ };
1926
+ const resolveFormBySlug = async (projectId, req, slug) => {
1927
+ const p = await this.resolveProtocol(projectId, req);
1928
+ if (typeof p.getMetaItems !== "function") return null;
1929
+ const result = await p.getMetaItems({
1930
+ type: "view",
1931
+ ...projectId ? { projectId } : {}
1932
+ });
1933
+ const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
1934
+ return findPublicFormView(items, slug);
1935
+ };
1936
+ this.routeManager.register({
1937
+ method: "GET",
1938
+ path: `${basePath}/forms/:slug`,
1939
+ handler: async (req, res) => {
1940
+ try {
1941
+ const projectId = isScoped ? req.params?.projectId : void 0;
1942
+ const slug = String(req.params?.slug ?? "").trim();
1943
+ if (!slug) {
1944
+ res.status(400).json({ code: "INVALID_REQUEST", error: "slug is required" });
1945
+ return;
1946
+ }
1947
+ const match = await resolveFormBySlug(projectId, req, slug);
1948
+ if (!match) {
1949
+ res.status(404).json({
1950
+ code: "FORM_NOT_FOUND",
1951
+ error: `No public form configured at /forms/${slug}`
1952
+ });
1953
+ return;
1954
+ }
1955
+ let objectSchema = null;
1956
+ try {
1957
+ const p = await this.resolveProtocol(projectId, req);
1958
+ if (typeof p.getMetaItems === "function") {
1959
+ const r = await p.getMetaItems({
1960
+ type: "object",
1961
+ ...projectId ? { projectId } : {}
1962
+ });
1963
+ const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
1964
+ const obj = items.find((o) => o?.name === match.object);
1965
+ if (obj && obj.fields && typeof obj.fields === "object") {
1966
+ const allowed = /* @__PURE__ */ new Set();
1967
+ for (const sec of match.form?.sections ?? []) {
1968
+ for (const f of sec?.fields ?? []) {
1969
+ if (typeof f === "string") allowed.add(f);
1970
+ else if (f?.field) allowed.add(f.field);
1971
+ }
1972
+ }
1973
+ const fields = {};
1974
+ for (const [name, def] of Object.entries(obj.fields)) {
1975
+ if (allowed.size === 0 || allowed.has(name)) {
1976
+ fields[name] = def;
1977
+ }
1978
+ }
1979
+ objectSchema = { name: obj.name, label: obj.label, fields };
1980
+ try {
1981
+ const i18n = await this.resolveI18nService(projectId, req);
1982
+ const bundle = this.buildTranslationBundle(i18n);
1983
+ const locale = this.extractLocale(req, i18n);
1984
+ if (bundle && locale) {
1985
+ const { translateMetadataDocument } = await import("@objectstack/spec/system");
1986
+ objectSchema = translateMetadataDocument("object", objectSchema, bundle, { locale });
1987
+ }
1988
+ } catch (e) {
1989
+ logError("[REST] Public form schema translation failed:", e);
1990
+ }
1991
+ }
1992
+ }
1993
+ } catch (e) {
1994
+ logError("[REST] Public form schema load failed:", e);
1995
+ }
1996
+ const safeForm = (() => {
1997
+ if (!match.form || !Array.isArray(match.form.sections)) return match.form;
1998
+ const allow = (name, cfg) => {
1999
+ const def = objectSchema?.fields?.[name];
2000
+ const t = def?.type;
2001
+ if (t !== "lookup" && t !== "master_detail") return true;
2002
+ return !!cfg?.publicPicker;
2003
+ };
2004
+ const sections = match.form.sections.map((sec) => {
2005
+ const fields = (sec?.fields ?? []).filter((f) => {
2006
+ const name = typeof f === "string" ? f : f?.field;
2007
+ if (!name) return false;
2008
+ const cfg = typeof f === "string" ? {} : f;
2009
+ return allow(name, cfg);
2010
+ });
2011
+ return { ...sec, fields };
2012
+ });
2013
+ return { ...match.form, sections };
2014
+ })();
2015
+ res.header("Vary", "Accept-Language");
2016
+ res.json({
2017
+ slug,
2018
+ object: match.object,
2019
+ label: match.view?.label ?? match.form?.label,
2020
+ form: safeForm,
2021
+ objectSchema
2022
+ });
2023
+ } catch (error) {
2024
+ logError("[REST] Public form resolve error:", error);
2025
+ res.status(500).json({
2026
+ code: "FORM_RESOLVE_FAILED",
2027
+ error: String(error?.message ?? error ?? "resolve failed").slice(0, 500)
2028
+ });
2029
+ }
2030
+ },
2031
+ metadata: {
2032
+ summary: "Resolve a public form spec by slug (anonymous)",
2033
+ tags: ["forms", "public"]
2034
+ }
2035
+ });
2036
+ this.routeManager.register({
2037
+ method: "POST",
2038
+ path: `${basePath}/forms/:slug/submit`,
2039
+ handler: async (req, res) => {
2040
+ try {
2041
+ const projectId = isScoped ? req.params?.projectId : void 0;
2042
+ const slug = String(req.params?.slug ?? "").trim();
2043
+ if (!slug) {
2044
+ res.status(400).json({ code: "INVALID_REQUEST", error: "slug is required" });
2045
+ return;
2046
+ }
2047
+ const match = await resolveFormBySlug(projectId, req, slug);
2048
+ if (!match) {
2049
+ res.status(404).json({
2050
+ code: "FORM_NOT_FOUND",
2051
+ error: `No public form configured at /forms/${slug}`
2052
+ });
2053
+ return;
2054
+ }
2055
+ const allowedFields = /* @__PURE__ */ new Set();
2056
+ for (const section of match.form?.sections ?? []) {
2057
+ for (const f of section?.fields ?? []) {
2058
+ if (typeof f === "string") allowedFields.add(f);
2059
+ else if (f?.field) allowedFields.add(f.field);
2060
+ }
2061
+ }
2062
+ const rawBody = req.body && typeof req.body === "object" ? req.body : {};
2063
+ const filteredData = {};
2064
+ if (allowedFields.size > 0) {
2065
+ for (const [k, v] of Object.entries(rawBody)) {
2066
+ if (allowedFields.has(k)) filteredData[k] = v;
2067
+ }
2068
+ } else {
2069
+ Object.assign(filteredData, rawBody);
2070
+ }
2071
+ const context = {
2072
+ permissions: ["guest_portal"],
2073
+ anonymous: true
2074
+ };
2075
+ const p = await this.resolveProtocol(projectId, req);
2076
+ const result = await p.createData({
2077
+ object: match.object,
2078
+ data: filteredData,
2079
+ ...projectId ? { projectId } : {},
2080
+ context
2081
+ });
2082
+ res.status(201).json(result);
2083
+ } catch (error) {
2084
+ const mapped = mapDataError(error);
2085
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") {
2086
+ logError("[REST] Public form submit error:", error);
2087
+ }
2088
+ res.status(mapped.status).json(mapped.body);
2089
+ }
2090
+ },
2091
+ metadata: {
2092
+ summary: "Submit an anonymous public form",
2093
+ tags: ["forms", "public"]
2094
+ }
2095
+ });
2096
+ this.routeManager.register({
2097
+ method: "GET",
2098
+ path: `${basePath}/forms/:slug/lookup/:field`,
2099
+ handler: async (req, res) => {
2100
+ try {
2101
+ const projectId = isScoped ? req.params?.projectId : void 0;
2102
+ const slug = String(req.params?.slug ?? "").trim();
2103
+ const fieldName = String(req.params?.field ?? "").trim();
2104
+ if (!slug || !fieldName) {
2105
+ res.status(400).json({ code: "INVALID_REQUEST", error: "slug and field are required" });
2106
+ return;
2107
+ }
2108
+ const match = await resolveFormBySlug(projectId, req, slug);
2109
+ if (!match) {
2110
+ res.status(404).json({
2111
+ code: "FORM_NOT_FOUND",
2112
+ error: `No public form configured at /forms/${slug}`
2113
+ });
2114
+ return;
2115
+ }
2116
+ let fieldCfg = null;
2117
+ for (const sec of match.form?.sections ?? []) {
2118
+ for (const f of sec?.fields ?? []) {
2119
+ const name = typeof f === "string" ? f : f?.field;
2120
+ if (name === fieldName) {
2121
+ fieldCfg = typeof f === "string" ? {} : f;
2122
+ break;
2123
+ }
2124
+ }
2125
+ if (fieldCfg) break;
2126
+ }
2127
+ const picker = fieldCfg?.publicPicker;
2128
+ if (!picker) {
2129
+ res.status(403).json({
2130
+ code: "LOOKUP_NOT_PUBLIC",
2131
+ error: `Field "${fieldName}" is not enabled for public lookup on this form`
2132
+ });
2133
+ return;
2134
+ }
2135
+ const p = await this.resolveProtocol(projectId, req);
2136
+ let referenceTo = picker.object;
2137
+ if (!referenceTo && typeof p.getMetaItems === "function") {
2138
+ try {
2139
+ const r = await p.getMetaItems({
2140
+ type: "object",
2141
+ ...projectId ? { projectId } : {}
2142
+ });
2143
+ const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
2144
+ const obj = items.find((o) => o?.name === match.object);
2145
+ const def = obj?.fields?.[fieldName];
2146
+ referenceTo = def?.referenceTo ?? def?.target ?? def?.options?.objectName;
2147
+ } catch {
2148
+ }
2149
+ }
2150
+ if (!referenceTo) {
2151
+ res.status(500).json({
2152
+ code: "LOOKUP_TARGET_MISSING",
2153
+ error: `Could not resolve referenced object for "${fieldName}"`
2154
+ });
2155
+ return;
2156
+ }
2157
+ const displayFields = Array.isArray(picker.displayFields) && picker.displayFields.length > 0 ? picker.displayFields.slice(0, 5) : ["name"];
2158
+ const hardCap = 50;
2159
+ const maxResults = Math.min(Math.max(1, Number(picker.maxResults) || 20), hardCap);
2160
+ const q = String(req.query?.q ?? "").trim().slice(0, 100);
2161
+ const filters = [];
2162
+ if (Array.isArray(picker.filter)) filters.push(...picker.filter);
2163
+ if (q) filters.push({ field: displayFields[0], operator: "contains", value: q });
2164
+ const context = {
2165
+ permissions: ["guest_portal"],
2166
+ anonymous: true
2167
+ };
2168
+ const result = await p.findData({
2169
+ object: referenceTo,
2170
+ query: {
2171
+ limit: maxResults,
2172
+ offset: 0,
2173
+ filters,
2174
+ select: ["id", ...displayFields],
2175
+ sort: picker.sort ?? [{ field: displayFields[0], order: "asc" }]
2176
+ },
2177
+ ...projectId ? { projectId } : {},
2178
+ context
2179
+ });
2180
+ const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.items) ? result.items : [];
2181
+ const projected = rows.slice(0, maxResults).map((row) => {
2182
+ const out = { id: row?.id };
2183
+ for (const f of displayFields) {
2184
+ if (row && Object.prototype.hasOwnProperty.call(row, f)) out[f] = row[f];
2185
+ }
2186
+ return out;
2187
+ });
2188
+ res.json({
2189
+ data: projected,
2190
+ total: projected.length,
2191
+ truncated: rows.length >= maxResults,
2192
+ displayFields
2193
+ });
2194
+ } catch (error) {
2195
+ const mapped = mapDataError(error);
2196
+ if (!isExpectedDataStatus(mapped.status)) {
2197
+ logError("[REST] Public form lookup error:", error);
2198
+ }
2199
+ res.status(mapped.status).json(mapped.body);
2200
+ }
2201
+ },
2202
+ metadata: {
2203
+ summary: "Scoped lookup picker for a public form field (anonymous)",
2204
+ tags: ["forms", "public"]
2205
+ }
2206
+ });
2207
+ }
1827
2208
  /**
1828
2209
  * Register record-level sharing endpoints (M11.C17).
1829
2210
  *
@@ -2937,6 +3318,13 @@ function createRestApiPlugin(config = {}) {
2937
3318
  return void 0;
2938
3319
  }
2939
3320
  };
3321
+ const i18nServiceProvider = async (_projectId) => {
3322
+ try {
3323
+ return ctx.getService("i18n");
3324
+ } catch {
3325
+ return void 0;
3326
+ }
3327
+ };
2940
3328
  if (!server) {
2941
3329
  ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
2942
3330
  return;
@@ -2947,7 +3335,7 @@ function createRestApiPlugin(config = {}) {
2947
3335
  }
2948
3336
  ctx.logger.info("Hydrating REST API from Protocol...");
2949
3337
  try {
2950
- const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider);
3338
+ const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider);
2951
3339
  restServer.registerRoutes();
2952
3340
  ctx.logger.info("REST API successfully registered");
2953
3341
  } catch (err) {