@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.d.cts CHANGED
@@ -226,7 +226,8 @@ declare class RestServer {
226
226
  private reportsServiceProvider?;
227
227
  private approvalsServiceProvider?;
228
228
  private sharingRulesServiceProvider?;
229
- constructor(server: IHttpServer, protocol: ObjectStackProtocol, config?: RestServerConfig, kernelManager?: RestKernelManager, envRegistry?: RestEnvRegistry, defaultProjectIdProvider?: () => string | undefined, authServiceProvider?: (projectId?: string) => Promise<any | undefined>, objectQLProvider?: (projectId?: string) => Promise<any | undefined>, emailServiceProvider?: (projectId?: string) => Promise<any | undefined>, sharingServiceProvider?: (projectId?: string) => Promise<any | undefined>, reportsServiceProvider?: (projectId?: string) => Promise<any | undefined>, approvalsServiceProvider?: (projectId?: string) => Promise<any | undefined>, sharingRulesServiceProvider?: (projectId?: string) => Promise<any | undefined>);
229
+ private i18nServiceProvider?;
230
+ constructor(server: IHttpServer, protocol: ObjectStackProtocol, config?: RestServerConfig, kernelManager?: RestKernelManager, envRegistry?: RestEnvRegistry, defaultProjectIdProvider?: () => string | undefined, authServiceProvider?: (projectId?: string) => Promise<any | undefined>, objectQLProvider?: (projectId?: string) => Promise<any | undefined>, emailServiceProvider?: (projectId?: string) => Promise<any | undefined>, sharingServiceProvider?: (projectId?: string) => Promise<any | undefined>, reportsServiceProvider?: (projectId?: string) => Promise<any | undefined>, approvalsServiceProvider?: (projectId?: string) => Promise<any | undefined>, sharingRulesServiceProvider?: (projectId?: string) => Promise<any | undefined>, i18nServiceProvider?: (projectId?: string) => Promise<any | undefined>);
230
231
  /**
231
232
  * Resolve the protocol for a given request. When `projectId` is present
232
233
  * and a KernelManager is wired, fetch the per-project kernel's
@@ -388,6 +389,36 @@ declare class RestServer {
388
389
  * }
389
390
  */
390
391
  private registerEmailEndpoints;
392
+ /**
393
+ * Register public (anonymous) form endpoints.
394
+ *
395
+ * Public forms are opt-in: a `FormView` becomes accessible to anonymous
396
+ * visitors only when `sharing.allowAnonymous === true` AND a
397
+ * `sharing.publicLink` slug is configured. Two routes are registered:
398
+ *
399
+ * GET {basePath}/forms/:slug → resolved form spec
400
+ * POST {basePath}/forms/:slug/submit → INSERT record (no auth required)
401
+ *
402
+ * Both routes bypass `enforceAuth` even when `requireAuth=true` on the
403
+ * deployment (e.g. ObjectOS multi-tenant). Security is delegated to the
404
+ * `guest_portal` permission set carried on the execution context — the
405
+ * SecurityPlugin enforces INSERT-only access to the target object. If
406
+ * the deployment hasn't registered a `guest_portal` profile, the
407
+ * security middleware falls open with `permissions: []` (no userId),
408
+ * matching the existing anonymous-access semantics; deployers must
409
+ * keep `requireAuth=true` deployments paired with a `guest_portal`
410
+ * profile (the CRM example does this) to enforce the INSERT-only
411
+ * contract.
412
+ *
413
+ * The matched FormView's parent ViewSchema is found by scanning
414
+ * `protocol.getMetaItems({ type: 'view' })`. For each entry we inspect
415
+ * `form.sharing` and every entry in `formViews`; the first FormView
416
+ * whose `sharing.publicLink` matches `/forms/:slug` (or just `:slug`)
417
+ * wins. The response carries the matched form view under `form` and
418
+ * the inferred target object, matching what the frontend's
419
+ * `mapViewSpecToEmbeddableConfig` expects.
420
+ */
421
+ private registerFormEndpoints;
391
422
  /**
392
423
  * Register record-level sharing endpoints (M11.C17).
393
424
  *
package/dist/index.d.ts CHANGED
@@ -226,7 +226,8 @@ declare class RestServer {
226
226
  private reportsServiceProvider?;
227
227
  private approvalsServiceProvider?;
228
228
  private sharingRulesServiceProvider?;
229
- constructor(server: IHttpServer, protocol: ObjectStackProtocol, config?: RestServerConfig, kernelManager?: RestKernelManager, envRegistry?: RestEnvRegistry, defaultProjectIdProvider?: () => string | undefined, authServiceProvider?: (projectId?: string) => Promise<any | undefined>, objectQLProvider?: (projectId?: string) => Promise<any | undefined>, emailServiceProvider?: (projectId?: string) => Promise<any | undefined>, sharingServiceProvider?: (projectId?: string) => Promise<any | undefined>, reportsServiceProvider?: (projectId?: string) => Promise<any | undefined>, approvalsServiceProvider?: (projectId?: string) => Promise<any | undefined>, sharingRulesServiceProvider?: (projectId?: string) => Promise<any | undefined>);
229
+ private i18nServiceProvider?;
230
+ constructor(server: IHttpServer, protocol: ObjectStackProtocol, config?: RestServerConfig, kernelManager?: RestKernelManager, envRegistry?: RestEnvRegistry, defaultProjectIdProvider?: () => string | undefined, authServiceProvider?: (projectId?: string) => Promise<any | undefined>, objectQLProvider?: (projectId?: string) => Promise<any | undefined>, emailServiceProvider?: (projectId?: string) => Promise<any | undefined>, sharingServiceProvider?: (projectId?: string) => Promise<any | undefined>, reportsServiceProvider?: (projectId?: string) => Promise<any | undefined>, approvalsServiceProvider?: (projectId?: string) => Promise<any | undefined>, sharingRulesServiceProvider?: (projectId?: string) => Promise<any | undefined>, i18nServiceProvider?: (projectId?: string) => Promise<any | undefined>);
230
231
  /**
231
232
  * Resolve the protocol for a given request. When `projectId` is present
232
233
  * and a KernelManager is wired, fetch the per-project kernel's
@@ -388,6 +389,36 @@ declare class RestServer {
388
389
  * }
389
390
  */
390
391
  private registerEmailEndpoints;
392
+ /**
393
+ * Register public (anonymous) form endpoints.
394
+ *
395
+ * Public forms are opt-in: a `FormView` becomes accessible to anonymous
396
+ * visitors only when `sharing.allowAnonymous === true` AND a
397
+ * `sharing.publicLink` slug is configured. Two routes are registered:
398
+ *
399
+ * GET {basePath}/forms/:slug → resolved form spec
400
+ * POST {basePath}/forms/:slug/submit → INSERT record (no auth required)
401
+ *
402
+ * Both routes bypass `enforceAuth` even when `requireAuth=true` on the
403
+ * deployment (e.g. ObjectOS multi-tenant). Security is delegated to the
404
+ * `guest_portal` permission set carried on the execution context — the
405
+ * SecurityPlugin enforces INSERT-only access to the target object. If
406
+ * the deployment hasn't registered a `guest_portal` profile, the
407
+ * security middleware falls open with `permissions: []` (no userId),
408
+ * matching the existing anonymous-access semantics; deployers must
409
+ * keep `requireAuth=true` deployments paired with a `guest_portal`
410
+ * profile (the CRM example does this) to enforce the INSERT-only
411
+ * contract.
412
+ *
413
+ * The matched FormView's parent ViewSchema is found by scanning
414
+ * `protocol.getMetaItems({ type: 'view' })`. For each entry we inspect
415
+ * `form.sharing` and every entry in `formViews`; the first FormView
416
+ * whose `sharing.publicLink` matches `/forms/:slug` (or just `:slug`)
417
+ * wins. The response carries the matched form view under `form` and
418
+ * the inferred target object, matching what the frontend's
419
+ * `mapViewSpecToEmbeddableConfig` expects.
420
+ */
421
+ private registerFormEndpoints;
391
422
  /**
392
423
  * Register record-level sharing endpoints (M11.C17).
393
424
  *
package/dist/index.js CHANGED
@@ -391,7 +391,7 @@ function rowsToCsv(fields, rows, includeHeader) {
391
391
  return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
392
392
  }
393
393
  var RestServer = class {
394
- constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider) {
394
+ constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider) {
395
395
  this.protocol = protocol;
396
396
  this.config = this.normalizeConfig(config);
397
397
  this.routeManager = new RouteManager(server);
@@ -405,6 +405,7 @@ var RestServer = class {
405
405
  this.reportsServiceProvider = reportsServiceProvider;
406
406
  this.approvalsServiceProvider = approvalsServiceProvider;
407
407
  this.sharingRulesServiceProvider = sharingRulesServiceProvider;
408
+ this.i18nServiceProvider = i18nServiceProvider;
408
409
  }
409
410
  /**
410
411
  * Resolve the protocol for a given request. When `projectId` is present
@@ -472,14 +473,51 @@ var RestServer = class {
472
473
  * requests intentionally return `undefined` because the platform kernel
473
474
  * does not own per-app translation bundles.
474
475
  */
475
- async resolveI18nService(projectId) {
476
- if (!projectId || projectId === "platform" || !this.kernelManager) return void 0;
477
- try {
478
- const kernel = await this.kernelManager.getOrCreate(projectId);
479
- return await kernel.getServiceAsync("i18n");
480
- } catch {
481
- return void 0;
476
+ async resolveI18nService(projectId, req) {
477
+ if (projectId === "platform") return void 0;
478
+ if (!projectId && req && this.envRegistry && this.kernelManager) {
479
+ const host = this.extractHostname(req);
480
+ if (host) {
481
+ try {
482
+ const result = await this.envRegistry.resolveByHostname(host);
483
+ if (result?.projectId) projectId = result.projectId;
484
+ } catch {
485
+ }
486
+ }
487
+ if (!projectId && typeof this.envRegistry.resolveById === "function") {
488
+ const headerVal = this.extractProjectIdHeader(req);
489
+ if (headerVal) {
490
+ try {
491
+ const driver = await this.envRegistry.resolveById(headerVal);
492
+ if (driver) projectId = headerVal;
493
+ } catch {
494
+ }
495
+ }
496
+ }
497
+ }
498
+ if (!projectId && this.defaultProjectIdProvider) {
499
+ try {
500
+ const def = this.defaultProjectIdProvider();
501
+ if (def) projectId = def;
502
+ } catch {
503
+ }
504
+ }
505
+ if (projectId && this.kernelManager) {
506
+ try {
507
+ const kernel = await this.kernelManager.getOrCreate(projectId);
508
+ const svc = await kernel.getServiceAsync("i18n");
509
+ if (svc) return svc;
510
+ } catch {
511
+ }
512
+ }
513
+ if (this.i18nServiceProvider) {
514
+ try {
515
+ return await this.i18nServiceProvider(projectId);
516
+ } catch {
517
+ return void 0;
518
+ }
482
519
  }
520
+ return void 0;
483
521
  }
484
522
  /**
485
523
  * Reject anonymous requests with HTTP 401 when `api.requireAuth` is set.
@@ -681,8 +719,8 @@ var RestServer = class {
681
719
  */
682
720
  async translateMetaItem(req, type, projectId, item) {
683
721
  if (!item || typeof item !== "object") return item;
684
- if (type !== "view" && type !== "action") return item;
685
- const i18n = await this.resolveI18nService(projectId);
722
+ if (type !== "view" && type !== "action" && type !== "object") return item;
723
+ const i18n = await this.resolveI18nService(projectId, req);
686
724
  const bundle = this.buildTranslationBundle(i18n);
687
725
  if (!bundle) return item;
688
726
  const locale = this.extractLocale(req, i18n);
@@ -695,8 +733,8 @@ var RestServer = class {
695
733
  */
696
734
  async translateMetaItems(req, type, projectId, items) {
697
735
  if (!Array.isArray(items)) return items;
698
- if (type !== "view" && type !== "action") return items;
699
- const i18n = await this.resolveI18nService(projectId);
736
+ if (type !== "view" && type !== "action" && type !== "object") return items;
737
+ const i18n = await this.resolveI18nService(projectId, req);
700
738
  const bundle = this.buildTranslationBundle(i18n);
701
739
  if (!bundle) return items;
702
740
  const locale = this.extractLocale(req, i18n);
@@ -856,6 +894,7 @@ var RestServer = class {
856
894
  this.registerSearchEndpoints(bp);
857
895
  }
858
896
  this.registerEmailEndpoints(bp);
897
+ this.registerFormEndpoints(bp);
859
898
  this.registerSharingEndpoints(bp);
860
899
  this.registerSharingRuleEndpoints(bp);
861
900
  this.registerReportsEndpoints(bp);
@@ -1785,6 +1824,348 @@ var RestServer = class {
1785
1824
  }
1786
1825
  });
1787
1826
  }
1827
+ /**
1828
+ * Register public (anonymous) form endpoints.
1829
+ *
1830
+ * Public forms are opt-in: a `FormView` becomes accessible to anonymous
1831
+ * visitors only when `sharing.allowAnonymous === true` AND a
1832
+ * `sharing.publicLink` slug is configured. Two routes are registered:
1833
+ *
1834
+ * GET {basePath}/forms/:slug → resolved form spec
1835
+ * POST {basePath}/forms/:slug/submit → INSERT record (no auth required)
1836
+ *
1837
+ * Both routes bypass `enforceAuth` even when `requireAuth=true` on the
1838
+ * deployment (e.g. ObjectOS multi-tenant). Security is delegated to the
1839
+ * `guest_portal` permission set carried on the execution context — the
1840
+ * SecurityPlugin enforces INSERT-only access to the target object. If
1841
+ * the deployment hasn't registered a `guest_portal` profile, the
1842
+ * security middleware falls open with `permissions: []` (no userId),
1843
+ * matching the existing anonymous-access semantics; deployers must
1844
+ * keep `requireAuth=true` deployments paired with a `guest_portal`
1845
+ * profile (the CRM example does this) to enforce the INSERT-only
1846
+ * contract.
1847
+ *
1848
+ * The matched FormView's parent ViewSchema is found by scanning
1849
+ * `protocol.getMetaItems({ type: 'view' })`. For each entry we inspect
1850
+ * `form.sharing` and every entry in `formViews`; the first FormView
1851
+ * whose `sharing.publicLink` matches `/forms/:slug` (or just `:slug`)
1852
+ * wins. The response carries the matched form view under `form` and
1853
+ * the inferred target object, matching what the frontend's
1854
+ * `mapViewSpecToEmbeddableConfig` expects.
1855
+ */
1856
+ registerFormEndpoints(basePath) {
1857
+ const isScoped = basePath.includes("/projects/:projectId");
1858
+ const slugMatchesPublicLink = (publicLink, slug) => {
1859
+ if (!publicLink || typeof publicLink !== "string") return false;
1860
+ const normalized = publicLink.replace(/^\/+/, "").replace(/^forms\//, "");
1861
+ return normalized === slug;
1862
+ };
1863
+ const findPublicFormView = (views, slug) => {
1864
+ for (const view of views ?? []) {
1865
+ if (!view || typeof view !== "object") continue;
1866
+ const candidates = [];
1867
+ if (view.form && view.form.sharing) candidates.push({ form: view.form });
1868
+ const formViews = view.formViews;
1869
+ if (formViews && typeof formViews === "object") {
1870
+ for (const [key, fv] of Object.entries(formViews)) {
1871
+ if (fv && typeof fv === "object" && fv.sharing) {
1872
+ candidates.push({ form: fv, key });
1873
+ }
1874
+ }
1875
+ }
1876
+ for (const c of candidates) {
1877
+ const sharing = c.form?.sharing;
1878
+ if (!sharing || sharing.allowAnonymous !== true) continue;
1879
+ if (!slugMatchesPublicLink(sharing.publicLink, slug)) continue;
1880
+ const objectName = c.form?.data?.object ?? view?.list?.data?.object ?? view?.form?.data?.object ?? view?.object;
1881
+ if (!objectName) continue;
1882
+ return { view, form: c.form, object: objectName };
1883
+ }
1884
+ }
1885
+ return null;
1886
+ };
1887
+ const resolveFormBySlug = async (projectId, req, slug) => {
1888
+ const p = await this.resolveProtocol(projectId, req);
1889
+ if (typeof p.getMetaItems !== "function") return null;
1890
+ const result = await p.getMetaItems({
1891
+ type: "view",
1892
+ ...projectId ? { projectId } : {}
1893
+ });
1894
+ const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
1895
+ return findPublicFormView(items, slug);
1896
+ };
1897
+ this.routeManager.register({
1898
+ method: "GET",
1899
+ path: `${basePath}/forms/:slug`,
1900
+ handler: async (req, res) => {
1901
+ try {
1902
+ const projectId = isScoped ? req.params?.projectId : void 0;
1903
+ const slug = String(req.params?.slug ?? "").trim();
1904
+ if (!slug) {
1905
+ res.status(400).json({ code: "INVALID_REQUEST", error: "slug is required" });
1906
+ return;
1907
+ }
1908
+ const match = await resolveFormBySlug(projectId, req, slug);
1909
+ if (!match) {
1910
+ res.status(404).json({
1911
+ code: "FORM_NOT_FOUND",
1912
+ error: `No public form configured at /forms/${slug}`
1913
+ });
1914
+ return;
1915
+ }
1916
+ let objectSchema = null;
1917
+ try {
1918
+ const p = await this.resolveProtocol(projectId, req);
1919
+ if (typeof p.getMetaItems === "function") {
1920
+ const r = await p.getMetaItems({
1921
+ type: "object",
1922
+ ...projectId ? { projectId } : {}
1923
+ });
1924
+ const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
1925
+ const obj = items.find((o) => o?.name === match.object);
1926
+ if (obj && obj.fields && typeof obj.fields === "object") {
1927
+ const allowed = /* @__PURE__ */ new Set();
1928
+ for (const sec of match.form?.sections ?? []) {
1929
+ for (const f of sec?.fields ?? []) {
1930
+ if (typeof f === "string") allowed.add(f);
1931
+ else if (f?.field) allowed.add(f.field);
1932
+ }
1933
+ }
1934
+ const fields = {};
1935
+ for (const [name, def] of Object.entries(obj.fields)) {
1936
+ if (allowed.size === 0 || allowed.has(name)) {
1937
+ fields[name] = def;
1938
+ }
1939
+ }
1940
+ objectSchema = { name: obj.name, label: obj.label, fields };
1941
+ try {
1942
+ const i18n = await this.resolveI18nService(projectId, req);
1943
+ const bundle = this.buildTranslationBundle(i18n);
1944
+ const locale = this.extractLocale(req, i18n);
1945
+ if (bundle && locale) {
1946
+ const { translateMetadataDocument } = await import("@objectstack/spec/system");
1947
+ objectSchema = translateMetadataDocument("object", objectSchema, bundle, { locale });
1948
+ }
1949
+ } catch (e) {
1950
+ logError("[REST] Public form schema translation failed:", e);
1951
+ }
1952
+ }
1953
+ }
1954
+ } catch (e) {
1955
+ logError("[REST] Public form schema load failed:", e);
1956
+ }
1957
+ const safeForm = (() => {
1958
+ if (!match.form || !Array.isArray(match.form.sections)) return match.form;
1959
+ const allow = (name, cfg) => {
1960
+ const def = objectSchema?.fields?.[name];
1961
+ const t = def?.type;
1962
+ if (t !== "lookup" && t !== "master_detail") return true;
1963
+ return !!cfg?.publicPicker;
1964
+ };
1965
+ const sections = match.form.sections.map((sec) => {
1966
+ const fields = (sec?.fields ?? []).filter((f) => {
1967
+ const name = typeof f === "string" ? f : f?.field;
1968
+ if (!name) return false;
1969
+ const cfg = typeof f === "string" ? {} : f;
1970
+ return allow(name, cfg);
1971
+ });
1972
+ return { ...sec, fields };
1973
+ });
1974
+ return { ...match.form, sections };
1975
+ })();
1976
+ res.header("Vary", "Accept-Language");
1977
+ res.json({
1978
+ slug,
1979
+ object: match.object,
1980
+ label: match.view?.label ?? match.form?.label,
1981
+ form: safeForm,
1982
+ objectSchema
1983
+ });
1984
+ } catch (error) {
1985
+ logError("[REST] Public form resolve error:", error);
1986
+ res.status(500).json({
1987
+ code: "FORM_RESOLVE_FAILED",
1988
+ error: String(error?.message ?? error ?? "resolve failed").slice(0, 500)
1989
+ });
1990
+ }
1991
+ },
1992
+ metadata: {
1993
+ summary: "Resolve a public form spec by slug (anonymous)",
1994
+ tags: ["forms", "public"]
1995
+ }
1996
+ });
1997
+ this.routeManager.register({
1998
+ method: "POST",
1999
+ path: `${basePath}/forms/:slug/submit`,
2000
+ handler: async (req, res) => {
2001
+ try {
2002
+ const projectId = isScoped ? req.params?.projectId : void 0;
2003
+ const slug = String(req.params?.slug ?? "").trim();
2004
+ if (!slug) {
2005
+ res.status(400).json({ code: "INVALID_REQUEST", error: "slug is required" });
2006
+ return;
2007
+ }
2008
+ const match = await resolveFormBySlug(projectId, req, slug);
2009
+ if (!match) {
2010
+ res.status(404).json({
2011
+ code: "FORM_NOT_FOUND",
2012
+ error: `No public form configured at /forms/${slug}`
2013
+ });
2014
+ return;
2015
+ }
2016
+ const allowedFields = /* @__PURE__ */ new Set();
2017
+ for (const section of match.form?.sections ?? []) {
2018
+ for (const f of section?.fields ?? []) {
2019
+ if (typeof f === "string") allowedFields.add(f);
2020
+ else if (f?.field) allowedFields.add(f.field);
2021
+ }
2022
+ }
2023
+ const rawBody = req.body && typeof req.body === "object" ? req.body : {};
2024
+ const filteredData = {};
2025
+ if (allowedFields.size > 0) {
2026
+ for (const [k, v] of Object.entries(rawBody)) {
2027
+ if (allowedFields.has(k)) filteredData[k] = v;
2028
+ }
2029
+ } else {
2030
+ Object.assign(filteredData, rawBody);
2031
+ }
2032
+ const context = {
2033
+ permissions: ["guest_portal"],
2034
+ anonymous: true
2035
+ };
2036
+ const p = await this.resolveProtocol(projectId, req);
2037
+ const result = await p.createData({
2038
+ object: match.object,
2039
+ data: filteredData,
2040
+ ...projectId ? { projectId } : {},
2041
+ context
2042
+ });
2043
+ res.status(201).json(result);
2044
+ } catch (error) {
2045
+ const mapped = mapDataError(error);
2046
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") {
2047
+ logError("[REST] Public form submit error:", error);
2048
+ }
2049
+ res.status(mapped.status).json(mapped.body);
2050
+ }
2051
+ },
2052
+ metadata: {
2053
+ summary: "Submit an anonymous public form",
2054
+ tags: ["forms", "public"]
2055
+ }
2056
+ });
2057
+ this.routeManager.register({
2058
+ method: "GET",
2059
+ path: `${basePath}/forms/:slug/lookup/:field`,
2060
+ handler: async (req, res) => {
2061
+ try {
2062
+ const projectId = isScoped ? req.params?.projectId : void 0;
2063
+ const slug = String(req.params?.slug ?? "").trim();
2064
+ const fieldName = String(req.params?.field ?? "").trim();
2065
+ if (!slug || !fieldName) {
2066
+ res.status(400).json({ code: "INVALID_REQUEST", error: "slug and field are required" });
2067
+ return;
2068
+ }
2069
+ const match = await resolveFormBySlug(projectId, req, slug);
2070
+ if (!match) {
2071
+ res.status(404).json({
2072
+ code: "FORM_NOT_FOUND",
2073
+ error: `No public form configured at /forms/${slug}`
2074
+ });
2075
+ return;
2076
+ }
2077
+ let fieldCfg = null;
2078
+ for (const sec of match.form?.sections ?? []) {
2079
+ for (const f of sec?.fields ?? []) {
2080
+ const name = typeof f === "string" ? f : f?.field;
2081
+ if (name === fieldName) {
2082
+ fieldCfg = typeof f === "string" ? {} : f;
2083
+ break;
2084
+ }
2085
+ }
2086
+ if (fieldCfg) break;
2087
+ }
2088
+ const picker = fieldCfg?.publicPicker;
2089
+ if (!picker) {
2090
+ res.status(403).json({
2091
+ code: "LOOKUP_NOT_PUBLIC",
2092
+ error: `Field "${fieldName}" is not enabled for public lookup on this form`
2093
+ });
2094
+ return;
2095
+ }
2096
+ const p = await this.resolveProtocol(projectId, req);
2097
+ let referenceTo = picker.object;
2098
+ if (!referenceTo && typeof p.getMetaItems === "function") {
2099
+ try {
2100
+ const r = await p.getMetaItems({
2101
+ type: "object",
2102
+ ...projectId ? { projectId } : {}
2103
+ });
2104
+ const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
2105
+ const obj = items.find((o) => o?.name === match.object);
2106
+ const def = obj?.fields?.[fieldName];
2107
+ referenceTo = def?.referenceTo ?? def?.target ?? def?.options?.objectName;
2108
+ } catch {
2109
+ }
2110
+ }
2111
+ if (!referenceTo) {
2112
+ res.status(500).json({
2113
+ code: "LOOKUP_TARGET_MISSING",
2114
+ error: `Could not resolve referenced object for "${fieldName}"`
2115
+ });
2116
+ return;
2117
+ }
2118
+ const displayFields = Array.isArray(picker.displayFields) && picker.displayFields.length > 0 ? picker.displayFields.slice(0, 5) : ["name"];
2119
+ const hardCap = 50;
2120
+ const maxResults = Math.min(Math.max(1, Number(picker.maxResults) || 20), hardCap);
2121
+ const q = String(req.query?.q ?? "").trim().slice(0, 100);
2122
+ const filters = [];
2123
+ if (Array.isArray(picker.filter)) filters.push(...picker.filter);
2124
+ if (q) filters.push({ field: displayFields[0], operator: "contains", value: q });
2125
+ const context = {
2126
+ permissions: ["guest_portal"],
2127
+ anonymous: true
2128
+ };
2129
+ const result = await p.findData({
2130
+ object: referenceTo,
2131
+ query: {
2132
+ limit: maxResults,
2133
+ offset: 0,
2134
+ filters,
2135
+ select: ["id", ...displayFields],
2136
+ sort: picker.sort ?? [{ field: displayFields[0], order: "asc" }]
2137
+ },
2138
+ ...projectId ? { projectId } : {},
2139
+ context
2140
+ });
2141
+ const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.items) ? result.items : [];
2142
+ const projected = rows.slice(0, maxResults).map((row) => {
2143
+ const out = { id: row?.id };
2144
+ for (const f of displayFields) {
2145
+ if (row && Object.prototype.hasOwnProperty.call(row, f)) out[f] = row[f];
2146
+ }
2147
+ return out;
2148
+ });
2149
+ res.json({
2150
+ data: projected,
2151
+ total: projected.length,
2152
+ truncated: rows.length >= maxResults,
2153
+ displayFields
2154
+ });
2155
+ } catch (error) {
2156
+ const mapped = mapDataError(error);
2157
+ if (!isExpectedDataStatus(mapped.status)) {
2158
+ logError("[REST] Public form lookup error:", error);
2159
+ }
2160
+ res.status(mapped.status).json(mapped.body);
2161
+ }
2162
+ },
2163
+ metadata: {
2164
+ summary: "Scoped lookup picker for a public form field (anonymous)",
2165
+ tags: ["forms", "public"]
2166
+ }
2167
+ });
2168
+ }
1788
2169
  /**
1789
2170
  * Register record-level sharing endpoints (M11.C17).
1790
2171
  *
@@ -2898,6 +3279,13 @@ function createRestApiPlugin(config = {}) {
2898
3279
  return void 0;
2899
3280
  }
2900
3281
  };
3282
+ const i18nServiceProvider = async (_projectId) => {
3283
+ try {
3284
+ return ctx.getService("i18n");
3285
+ } catch {
3286
+ return void 0;
3287
+ }
3288
+ };
2901
3289
  if (!server) {
2902
3290
  ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
2903
3291
  return;
@@ -2908,7 +3296,7 @@ function createRestApiPlugin(config = {}) {
2908
3296
  }
2909
3297
  ctx.logger.info("Hydrating REST API from Protocol...");
2910
3298
  try {
2911
- const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider);
3299
+ const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider);
2912
3300
  restServer.registerRoutes();
2913
3301
  ctx.logger.info("REST API successfully registered");
2914
3302
  } catch (err) {