@objectstack/rest 4.1.0 → 4.2.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 +427 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +32 -1
- package/dist/index.d.ts +32 -1
- package/dist/index.js +427 -14
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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
|
-
|
|
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
|
-
|
|
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
|
@@ -211,6 +211,18 @@ var RouteGroupBuilder = class {
|
|
|
211
211
|
// src/rest-server.ts
|
|
212
212
|
var logError = (...args) => globalThis.console?.error(...args);
|
|
213
213
|
function mapDataError(error, object) {
|
|
214
|
+
if (error?.code === "CONCURRENT_UPDATE" || error?.name === "ConcurrentUpdateError") {
|
|
215
|
+
return {
|
|
216
|
+
status: 409,
|
|
217
|
+
body: {
|
|
218
|
+
error: error?.message ?? "Record was modified by another user",
|
|
219
|
+
code: "CONCURRENT_UPDATE",
|
|
220
|
+
...error?.currentVersion ? { currentVersion: error.currentVersion } : {},
|
|
221
|
+
...error?.currentRecord ? { currentRecord: error.currentRecord } : {},
|
|
222
|
+
...object ? { object } : {}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
214
226
|
if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
|
|
215
227
|
return {
|
|
216
228
|
status: 400,
|
|
@@ -391,7 +403,7 @@ function rowsToCsv(fields, rows, includeHeader) {
|
|
|
391
403
|
return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
|
|
392
404
|
}
|
|
393
405
|
var RestServer = class {
|
|
394
|
-
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider) {
|
|
406
|
+
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider) {
|
|
395
407
|
this.protocol = protocol;
|
|
396
408
|
this.config = this.normalizeConfig(config);
|
|
397
409
|
this.routeManager = new RouteManager(server);
|
|
@@ -405,6 +417,7 @@ var RestServer = class {
|
|
|
405
417
|
this.reportsServiceProvider = reportsServiceProvider;
|
|
406
418
|
this.approvalsServiceProvider = approvalsServiceProvider;
|
|
407
419
|
this.sharingRulesServiceProvider = sharingRulesServiceProvider;
|
|
420
|
+
this.i18nServiceProvider = i18nServiceProvider;
|
|
408
421
|
}
|
|
409
422
|
/**
|
|
410
423
|
* Resolve the protocol for a given request. When `projectId` is present
|
|
@@ -472,14 +485,51 @@ var RestServer = class {
|
|
|
472
485
|
* requests intentionally return `undefined` because the platform kernel
|
|
473
486
|
* does not own per-app translation bundles.
|
|
474
487
|
*/
|
|
475
|
-
async resolveI18nService(projectId) {
|
|
476
|
-
if (
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
488
|
+
async resolveI18nService(projectId, req) {
|
|
489
|
+
if (projectId === "platform") return void 0;
|
|
490
|
+
if (!projectId && req && this.envRegistry && this.kernelManager) {
|
|
491
|
+
const host = this.extractHostname(req);
|
|
492
|
+
if (host) {
|
|
493
|
+
try {
|
|
494
|
+
const result = await this.envRegistry.resolveByHostname(host);
|
|
495
|
+
if (result?.projectId) projectId = result.projectId;
|
|
496
|
+
} catch {
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (!projectId && typeof this.envRegistry.resolveById === "function") {
|
|
500
|
+
const headerVal = this.extractProjectIdHeader(req);
|
|
501
|
+
if (headerVal) {
|
|
502
|
+
try {
|
|
503
|
+
const driver = await this.envRegistry.resolveById(headerVal);
|
|
504
|
+
if (driver) projectId = headerVal;
|
|
505
|
+
} catch {
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
482
509
|
}
|
|
510
|
+
if (!projectId && this.defaultProjectIdProvider) {
|
|
511
|
+
try {
|
|
512
|
+
const def = this.defaultProjectIdProvider();
|
|
513
|
+
if (def) projectId = def;
|
|
514
|
+
} catch {
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (projectId && this.kernelManager) {
|
|
518
|
+
try {
|
|
519
|
+
const kernel = await this.kernelManager.getOrCreate(projectId);
|
|
520
|
+
const svc = await kernel.getServiceAsync("i18n");
|
|
521
|
+
if (svc) return svc;
|
|
522
|
+
} catch {
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (this.i18nServiceProvider) {
|
|
526
|
+
try {
|
|
527
|
+
return await this.i18nServiceProvider(projectId);
|
|
528
|
+
} catch {
|
|
529
|
+
return void 0;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return void 0;
|
|
483
533
|
}
|
|
484
534
|
/**
|
|
485
535
|
* Reject anonymous requests with HTTP 401 when `api.requireAuth` is set.
|
|
@@ -681,8 +731,8 @@ var RestServer = class {
|
|
|
681
731
|
*/
|
|
682
732
|
async translateMetaItem(req, type, projectId, item) {
|
|
683
733
|
if (!item || typeof item !== "object") return item;
|
|
684
|
-
if (type !== "view" && type !== "action") return item;
|
|
685
|
-
const i18n = await this.resolveI18nService(projectId);
|
|
734
|
+
if (type !== "view" && type !== "action" && type !== "object") return item;
|
|
735
|
+
const i18n = await this.resolveI18nService(projectId, req);
|
|
686
736
|
const bundle = this.buildTranslationBundle(i18n);
|
|
687
737
|
if (!bundle) return item;
|
|
688
738
|
const locale = this.extractLocale(req, i18n);
|
|
@@ -695,8 +745,8 @@ var RestServer = class {
|
|
|
695
745
|
*/
|
|
696
746
|
async translateMetaItems(req, type, projectId, items) {
|
|
697
747
|
if (!Array.isArray(items)) return items;
|
|
698
|
-
if (type !== "view" && type !== "action") return items;
|
|
699
|
-
const i18n = await this.resolveI18nService(projectId);
|
|
748
|
+
if (type !== "view" && type !== "action" && type !== "object") return items;
|
|
749
|
+
const i18n = await this.resolveI18nService(projectId, req);
|
|
700
750
|
const bundle = this.buildTranslationBundle(i18n);
|
|
701
751
|
if (!bundle) return items;
|
|
702
752
|
const locale = this.extractLocale(req, i18n);
|
|
@@ -856,6 +906,7 @@ var RestServer = class {
|
|
|
856
906
|
this.registerSearchEndpoints(bp);
|
|
857
907
|
}
|
|
858
908
|
this.registerEmailEndpoints(bp);
|
|
909
|
+
this.registerFormEndpoints(bp);
|
|
859
910
|
this.registerSharingEndpoints(bp);
|
|
860
911
|
this.registerSharingRuleEndpoints(bp);
|
|
861
912
|
this.registerReportsEndpoints(bp);
|
|
@@ -1342,10 +1393,19 @@ var RestServer = class {
|
|
|
1342
1393
|
const p = await this.resolveProtocol(projectId, req);
|
|
1343
1394
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1344
1395
|
if (this.enforceAuth(req, res, context)) return;
|
|
1396
|
+
const ifMatchHeader = req.headers?.["if-match"] ?? req.headers?.["If-Match"];
|
|
1397
|
+
const bodyVersion = req.body && typeof req.body === "object" ? req.body.expectedVersion : void 0;
|
|
1398
|
+
const expectedVersion = bodyVersion ?? ifMatchHeader;
|
|
1399
|
+
let data = req.body;
|
|
1400
|
+
if (data && typeof data === "object" && "expectedVersion" in data) {
|
|
1401
|
+
const { expectedVersion: _drop, ...rest } = data;
|
|
1402
|
+
data = rest;
|
|
1403
|
+
}
|
|
1345
1404
|
const result = await p.updateData({
|
|
1346
1405
|
object: req.params.object,
|
|
1347
1406
|
id: req.params.id,
|
|
1348
|
-
data
|
|
1407
|
+
data,
|
|
1408
|
+
...expectedVersion ? { expectedVersion: String(expectedVersion) } : {},
|
|
1349
1409
|
...projectId ? { projectId } : {},
|
|
1350
1410
|
...context ? { context } : {}
|
|
1351
1411
|
});
|
|
@@ -1372,9 +1432,13 @@ var RestServer = class {
|
|
|
1372
1432
|
const p = await this.resolveProtocol(projectId, req);
|
|
1373
1433
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1374
1434
|
if (this.enforceAuth(req, res, context)) return;
|
|
1435
|
+
const ifMatchHeader = req.headers?.["if-match"] ?? req.headers?.["If-Match"];
|
|
1436
|
+
const queryVersion = req.query && typeof req.query === "object" ? req.query.expectedVersion : void 0;
|
|
1437
|
+
const expectedVersion = queryVersion ?? ifMatchHeader;
|
|
1375
1438
|
const result = await p.deleteData({
|
|
1376
1439
|
object: req.params.object,
|
|
1377
1440
|
id: req.params.id,
|
|
1441
|
+
...expectedVersion ? { expectedVersion: String(expectedVersion) } : {},
|
|
1378
1442
|
...projectId ? { projectId } : {},
|
|
1379
1443
|
...context ? { context } : {}
|
|
1380
1444
|
});
|
|
@@ -1785,6 +1849,348 @@ var RestServer = class {
|
|
|
1785
1849
|
}
|
|
1786
1850
|
});
|
|
1787
1851
|
}
|
|
1852
|
+
/**
|
|
1853
|
+
* Register public (anonymous) form endpoints.
|
|
1854
|
+
*
|
|
1855
|
+
* Public forms are opt-in: a `FormView` becomes accessible to anonymous
|
|
1856
|
+
* visitors only when `sharing.allowAnonymous === true` AND a
|
|
1857
|
+
* `sharing.publicLink` slug is configured. Two routes are registered:
|
|
1858
|
+
*
|
|
1859
|
+
* GET {basePath}/forms/:slug → resolved form spec
|
|
1860
|
+
* POST {basePath}/forms/:slug/submit → INSERT record (no auth required)
|
|
1861
|
+
*
|
|
1862
|
+
* Both routes bypass `enforceAuth` even when `requireAuth=true` on the
|
|
1863
|
+
* deployment (e.g. ObjectOS multi-tenant). Security is delegated to the
|
|
1864
|
+
* `guest_portal` permission set carried on the execution context — the
|
|
1865
|
+
* SecurityPlugin enforces INSERT-only access to the target object. If
|
|
1866
|
+
* the deployment hasn't registered a `guest_portal` profile, the
|
|
1867
|
+
* security middleware falls open with `permissions: []` (no userId),
|
|
1868
|
+
* matching the existing anonymous-access semantics; deployers must
|
|
1869
|
+
* keep `requireAuth=true` deployments paired with a `guest_portal`
|
|
1870
|
+
* profile (the CRM example does this) to enforce the INSERT-only
|
|
1871
|
+
* contract.
|
|
1872
|
+
*
|
|
1873
|
+
* The matched FormView's parent ViewSchema is found by scanning
|
|
1874
|
+
* `protocol.getMetaItems({ type: 'view' })`. For each entry we inspect
|
|
1875
|
+
* `form.sharing` and every entry in `formViews`; the first FormView
|
|
1876
|
+
* whose `sharing.publicLink` matches `/forms/:slug` (or just `:slug`)
|
|
1877
|
+
* wins. The response carries the matched form view under `form` and
|
|
1878
|
+
* the inferred target object, matching what the frontend's
|
|
1879
|
+
* `mapViewSpecToEmbeddableConfig` expects.
|
|
1880
|
+
*/
|
|
1881
|
+
registerFormEndpoints(basePath) {
|
|
1882
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
1883
|
+
const slugMatchesPublicLink = (publicLink, slug) => {
|
|
1884
|
+
if (!publicLink || typeof publicLink !== "string") return false;
|
|
1885
|
+
const normalized = publicLink.replace(/^\/+/, "").replace(/^forms\//, "");
|
|
1886
|
+
return normalized === slug;
|
|
1887
|
+
};
|
|
1888
|
+
const findPublicFormView = (views, slug) => {
|
|
1889
|
+
for (const view of views ?? []) {
|
|
1890
|
+
if (!view || typeof view !== "object") continue;
|
|
1891
|
+
const candidates = [];
|
|
1892
|
+
if (view.form && view.form.sharing) candidates.push({ form: view.form });
|
|
1893
|
+
const formViews = view.formViews;
|
|
1894
|
+
if (formViews && typeof formViews === "object") {
|
|
1895
|
+
for (const [key, fv] of Object.entries(formViews)) {
|
|
1896
|
+
if (fv && typeof fv === "object" && fv.sharing) {
|
|
1897
|
+
candidates.push({ form: fv, key });
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
for (const c of candidates) {
|
|
1902
|
+
const sharing = c.form?.sharing;
|
|
1903
|
+
if (!sharing || sharing.allowAnonymous !== true) continue;
|
|
1904
|
+
if (!slugMatchesPublicLink(sharing.publicLink, slug)) continue;
|
|
1905
|
+
const objectName = c.form?.data?.object ?? view?.list?.data?.object ?? view?.form?.data?.object ?? view?.object;
|
|
1906
|
+
if (!objectName) continue;
|
|
1907
|
+
return { view, form: c.form, object: objectName };
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
return null;
|
|
1911
|
+
};
|
|
1912
|
+
const resolveFormBySlug = async (projectId, req, slug) => {
|
|
1913
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1914
|
+
if (typeof p.getMetaItems !== "function") return null;
|
|
1915
|
+
const result = await p.getMetaItems({
|
|
1916
|
+
type: "view",
|
|
1917
|
+
...projectId ? { projectId } : {}
|
|
1918
|
+
});
|
|
1919
|
+
const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
|
|
1920
|
+
return findPublicFormView(items, slug);
|
|
1921
|
+
};
|
|
1922
|
+
this.routeManager.register({
|
|
1923
|
+
method: "GET",
|
|
1924
|
+
path: `${basePath}/forms/:slug`,
|
|
1925
|
+
handler: async (req, res) => {
|
|
1926
|
+
try {
|
|
1927
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1928
|
+
const slug = String(req.params?.slug ?? "").trim();
|
|
1929
|
+
if (!slug) {
|
|
1930
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "slug is required" });
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
const match = await resolveFormBySlug(projectId, req, slug);
|
|
1934
|
+
if (!match) {
|
|
1935
|
+
res.status(404).json({
|
|
1936
|
+
code: "FORM_NOT_FOUND",
|
|
1937
|
+
error: `No public form configured at /forms/${slug}`
|
|
1938
|
+
});
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
let objectSchema = null;
|
|
1942
|
+
try {
|
|
1943
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1944
|
+
if (typeof p.getMetaItems === "function") {
|
|
1945
|
+
const r = await p.getMetaItems({
|
|
1946
|
+
type: "object",
|
|
1947
|
+
...projectId ? { projectId } : {}
|
|
1948
|
+
});
|
|
1949
|
+
const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
|
|
1950
|
+
const obj = items.find((o) => o?.name === match.object);
|
|
1951
|
+
if (obj && obj.fields && typeof obj.fields === "object") {
|
|
1952
|
+
const allowed = /* @__PURE__ */ new Set();
|
|
1953
|
+
for (const sec of match.form?.sections ?? []) {
|
|
1954
|
+
for (const f of sec?.fields ?? []) {
|
|
1955
|
+
if (typeof f === "string") allowed.add(f);
|
|
1956
|
+
else if (f?.field) allowed.add(f.field);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
const fields = {};
|
|
1960
|
+
for (const [name, def] of Object.entries(obj.fields)) {
|
|
1961
|
+
if (allowed.size === 0 || allowed.has(name)) {
|
|
1962
|
+
fields[name] = def;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
objectSchema = { name: obj.name, label: obj.label, fields };
|
|
1966
|
+
try {
|
|
1967
|
+
const i18n = await this.resolveI18nService(projectId, req);
|
|
1968
|
+
const bundle = this.buildTranslationBundle(i18n);
|
|
1969
|
+
const locale = this.extractLocale(req, i18n);
|
|
1970
|
+
if (bundle && locale) {
|
|
1971
|
+
const { translateMetadataDocument } = await import("@objectstack/spec/system");
|
|
1972
|
+
objectSchema = translateMetadataDocument("object", objectSchema, bundle, { locale });
|
|
1973
|
+
}
|
|
1974
|
+
} catch (e) {
|
|
1975
|
+
logError("[REST] Public form schema translation failed:", e);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
} catch (e) {
|
|
1980
|
+
logError("[REST] Public form schema load failed:", e);
|
|
1981
|
+
}
|
|
1982
|
+
const safeForm = (() => {
|
|
1983
|
+
if (!match.form || !Array.isArray(match.form.sections)) return match.form;
|
|
1984
|
+
const allow = (name, cfg) => {
|
|
1985
|
+
const def = objectSchema?.fields?.[name];
|
|
1986
|
+
const t = def?.type;
|
|
1987
|
+
if (t !== "lookup" && t !== "master_detail") return true;
|
|
1988
|
+
return !!cfg?.publicPicker;
|
|
1989
|
+
};
|
|
1990
|
+
const sections = match.form.sections.map((sec) => {
|
|
1991
|
+
const fields = (sec?.fields ?? []).filter((f) => {
|
|
1992
|
+
const name = typeof f === "string" ? f : f?.field;
|
|
1993
|
+
if (!name) return false;
|
|
1994
|
+
const cfg = typeof f === "string" ? {} : f;
|
|
1995
|
+
return allow(name, cfg);
|
|
1996
|
+
});
|
|
1997
|
+
return { ...sec, fields };
|
|
1998
|
+
});
|
|
1999
|
+
return { ...match.form, sections };
|
|
2000
|
+
})();
|
|
2001
|
+
res.header("Vary", "Accept-Language");
|
|
2002
|
+
res.json({
|
|
2003
|
+
slug,
|
|
2004
|
+
object: match.object,
|
|
2005
|
+
label: match.view?.label ?? match.form?.label,
|
|
2006
|
+
form: safeForm,
|
|
2007
|
+
objectSchema
|
|
2008
|
+
});
|
|
2009
|
+
} catch (error) {
|
|
2010
|
+
logError("[REST] Public form resolve error:", error);
|
|
2011
|
+
res.status(500).json({
|
|
2012
|
+
code: "FORM_RESOLVE_FAILED",
|
|
2013
|
+
error: String(error?.message ?? error ?? "resolve failed").slice(0, 500)
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
},
|
|
2017
|
+
metadata: {
|
|
2018
|
+
summary: "Resolve a public form spec by slug (anonymous)",
|
|
2019
|
+
tags: ["forms", "public"]
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
2022
|
+
this.routeManager.register({
|
|
2023
|
+
method: "POST",
|
|
2024
|
+
path: `${basePath}/forms/:slug/submit`,
|
|
2025
|
+
handler: async (req, res) => {
|
|
2026
|
+
try {
|
|
2027
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2028
|
+
const slug = String(req.params?.slug ?? "").trim();
|
|
2029
|
+
if (!slug) {
|
|
2030
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "slug is required" });
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
const match = await resolveFormBySlug(projectId, req, slug);
|
|
2034
|
+
if (!match) {
|
|
2035
|
+
res.status(404).json({
|
|
2036
|
+
code: "FORM_NOT_FOUND",
|
|
2037
|
+
error: `No public form configured at /forms/${slug}`
|
|
2038
|
+
});
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
const allowedFields = /* @__PURE__ */ new Set();
|
|
2042
|
+
for (const section of match.form?.sections ?? []) {
|
|
2043
|
+
for (const f of section?.fields ?? []) {
|
|
2044
|
+
if (typeof f === "string") allowedFields.add(f);
|
|
2045
|
+
else if (f?.field) allowedFields.add(f.field);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
const rawBody = req.body && typeof req.body === "object" ? req.body : {};
|
|
2049
|
+
const filteredData = {};
|
|
2050
|
+
if (allowedFields.size > 0) {
|
|
2051
|
+
for (const [k, v] of Object.entries(rawBody)) {
|
|
2052
|
+
if (allowedFields.has(k)) filteredData[k] = v;
|
|
2053
|
+
}
|
|
2054
|
+
} else {
|
|
2055
|
+
Object.assign(filteredData, rawBody);
|
|
2056
|
+
}
|
|
2057
|
+
const context = {
|
|
2058
|
+
permissions: ["guest_portal"],
|
|
2059
|
+
anonymous: true
|
|
2060
|
+
};
|
|
2061
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
2062
|
+
const result = await p.createData({
|
|
2063
|
+
object: match.object,
|
|
2064
|
+
data: filteredData,
|
|
2065
|
+
...projectId ? { projectId } : {},
|
|
2066
|
+
context
|
|
2067
|
+
});
|
|
2068
|
+
res.status(201).json(result);
|
|
2069
|
+
} catch (error) {
|
|
2070
|
+
const mapped = mapDataError(error);
|
|
2071
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") {
|
|
2072
|
+
logError("[REST] Public form submit error:", error);
|
|
2073
|
+
}
|
|
2074
|
+
res.status(mapped.status).json(mapped.body);
|
|
2075
|
+
}
|
|
2076
|
+
},
|
|
2077
|
+
metadata: {
|
|
2078
|
+
summary: "Submit an anonymous public form",
|
|
2079
|
+
tags: ["forms", "public"]
|
|
2080
|
+
}
|
|
2081
|
+
});
|
|
2082
|
+
this.routeManager.register({
|
|
2083
|
+
method: "GET",
|
|
2084
|
+
path: `${basePath}/forms/:slug/lookup/:field`,
|
|
2085
|
+
handler: async (req, res) => {
|
|
2086
|
+
try {
|
|
2087
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2088
|
+
const slug = String(req.params?.slug ?? "").trim();
|
|
2089
|
+
const fieldName = String(req.params?.field ?? "").trim();
|
|
2090
|
+
if (!slug || !fieldName) {
|
|
2091
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "slug and field are required" });
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
const match = await resolveFormBySlug(projectId, req, slug);
|
|
2095
|
+
if (!match) {
|
|
2096
|
+
res.status(404).json({
|
|
2097
|
+
code: "FORM_NOT_FOUND",
|
|
2098
|
+
error: `No public form configured at /forms/${slug}`
|
|
2099
|
+
});
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
let fieldCfg = null;
|
|
2103
|
+
for (const sec of match.form?.sections ?? []) {
|
|
2104
|
+
for (const f of sec?.fields ?? []) {
|
|
2105
|
+
const name = typeof f === "string" ? f : f?.field;
|
|
2106
|
+
if (name === fieldName) {
|
|
2107
|
+
fieldCfg = typeof f === "string" ? {} : f;
|
|
2108
|
+
break;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
if (fieldCfg) break;
|
|
2112
|
+
}
|
|
2113
|
+
const picker = fieldCfg?.publicPicker;
|
|
2114
|
+
if (!picker) {
|
|
2115
|
+
res.status(403).json({
|
|
2116
|
+
code: "LOOKUP_NOT_PUBLIC",
|
|
2117
|
+
error: `Field "${fieldName}" is not enabled for public lookup on this form`
|
|
2118
|
+
});
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
2122
|
+
let referenceTo = picker.object;
|
|
2123
|
+
if (!referenceTo && typeof p.getMetaItems === "function") {
|
|
2124
|
+
try {
|
|
2125
|
+
const r = await p.getMetaItems({
|
|
2126
|
+
type: "object",
|
|
2127
|
+
...projectId ? { projectId } : {}
|
|
2128
|
+
});
|
|
2129
|
+
const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
|
|
2130
|
+
const obj = items.find((o) => o?.name === match.object);
|
|
2131
|
+
const def = obj?.fields?.[fieldName];
|
|
2132
|
+
referenceTo = def?.referenceTo ?? def?.target ?? def?.options?.objectName;
|
|
2133
|
+
} catch {
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
if (!referenceTo) {
|
|
2137
|
+
res.status(500).json({
|
|
2138
|
+
code: "LOOKUP_TARGET_MISSING",
|
|
2139
|
+
error: `Could not resolve referenced object for "${fieldName}"`
|
|
2140
|
+
});
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
const displayFields = Array.isArray(picker.displayFields) && picker.displayFields.length > 0 ? picker.displayFields.slice(0, 5) : ["name"];
|
|
2144
|
+
const hardCap = 50;
|
|
2145
|
+
const maxResults = Math.min(Math.max(1, Number(picker.maxResults) || 20), hardCap);
|
|
2146
|
+
const q = String(req.query?.q ?? "").trim().slice(0, 100);
|
|
2147
|
+
const filters = [];
|
|
2148
|
+
if (Array.isArray(picker.filter)) filters.push(...picker.filter);
|
|
2149
|
+
if (q) filters.push({ field: displayFields[0], operator: "contains", value: q });
|
|
2150
|
+
const context = {
|
|
2151
|
+
permissions: ["guest_portal"],
|
|
2152
|
+
anonymous: true
|
|
2153
|
+
};
|
|
2154
|
+
const result = await p.findData({
|
|
2155
|
+
object: referenceTo,
|
|
2156
|
+
query: {
|
|
2157
|
+
limit: maxResults,
|
|
2158
|
+
offset: 0,
|
|
2159
|
+
filters,
|
|
2160
|
+
select: ["id", ...displayFields],
|
|
2161
|
+
sort: picker.sort ?? [{ field: displayFields[0], order: "asc" }]
|
|
2162
|
+
},
|
|
2163
|
+
...projectId ? { projectId } : {},
|
|
2164
|
+
context
|
|
2165
|
+
});
|
|
2166
|
+
const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.items) ? result.items : [];
|
|
2167
|
+
const projected = rows.slice(0, maxResults).map((row) => {
|
|
2168
|
+
const out = { id: row?.id };
|
|
2169
|
+
for (const f of displayFields) {
|
|
2170
|
+
if (row && Object.prototype.hasOwnProperty.call(row, f)) out[f] = row[f];
|
|
2171
|
+
}
|
|
2172
|
+
return out;
|
|
2173
|
+
});
|
|
2174
|
+
res.json({
|
|
2175
|
+
data: projected,
|
|
2176
|
+
total: projected.length,
|
|
2177
|
+
truncated: rows.length >= maxResults,
|
|
2178
|
+
displayFields
|
|
2179
|
+
});
|
|
2180
|
+
} catch (error) {
|
|
2181
|
+
const mapped = mapDataError(error);
|
|
2182
|
+
if (!isExpectedDataStatus(mapped.status)) {
|
|
2183
|
+
logError("[REST] Public form lookup error:", error);
|
|
2184
|
+
}
|
|
2185
|
+
res.status(mapped.status).json(mapped.body);
|
|
2186
|
+
}
|
|
2187
|
+
},
|
|
2188
|
+
metadata: {
|
|
2189
|
+
summary: "Scoped lookup picker for a public form field (anonymous)",
|
|
2190
|
+
tags: ["forms", "public"]
|
|
2191
|
+
}
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
1788
2194
|
/**
|
|
1789
2195
|
* Register record-level sharing endpoints (M11.C17).
|
|
1790
2196
|
*
|
|
@@ -2898,6 +3304,13 @@ function createRestApiPlugin(config = {}) {
|
|
|
2898
3304
|
return void 0;
|
|
2899
3305
|
}
|
|
2900
3306
|
};
|
|
3307
|
+
const i18nServiceProvider = async (_projectId) => {
|
|
3308
|
+
try {
|
|
3309
|
+
return ctx.getService("i18n");
|
|
3310
|
+
} catch {
|
|
3311
|
+
return void 0;
|
|
3312
|
+
}
|
|
3313
|
+
};
|
|
2901
3314
|
if (!server) {
|
|
2902
3315
|
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
|
|
2903
3316
|
return;
|
|
@@ -2908,7 +3321,7 @@ function createRestApiPlugin(config = {}) {
|
|
|
2908
3321
|
}
|
|
2909
3322
|
ctx.logger.info("Hydrating REST API from Protocol...");
|
|
2910
3323
|
try {
|
|
2911
|
-
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider);
|
|
3324
|
+
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider);
|
|
2912
3325
|
restServer.registerRoutes();
|
|
2913
3326
|
ctx.logger.info("REST API successfully registered");
|
|
2914
3327
|
} catch (err) {
|