@objectstack/rest 14.8.0 → 15.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +145 -55
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -4
- package/dist/index.d.ts +20 -4
- package/dist/index.js +153 -56
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
package/dist/index.d.cts
CHANGED
|
@@ -2,7 +2,7 @@ import { IHttpServer, RouteHandler, Plugin } from '@objectstack/core';
|
|
|
2
2
|
import * as System from '@objectstack/spec/system';
|
|
3
3
|
import * as Shared from '@objectstack/spec/shared';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import {
|
|
5
|
+
import { DataProtocol, MetadataProtocol, RestServerConfig } from '@objectstack/spec/api';
|
|
6
6
|
|
|
7
7
|
type RouteHandlerMetadata = System.RouteHandlerMetadata;
|
|
8
8
|
type HttpMethod = z.infer<typeof Shared.HttpMethod>;
|
|
@@ -155,6 +155,14 @@ declare class RouteGroupBuilder {
|
|
|
155
155
|
private resolvePath;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* The protocol slice the REST layer actually consumes (ADR-0076 D9 / #2462
|
|
160
|
+
* A1.5): wire-normalized data CRUD plus the metadata control plane — not the
|
|
161
|
+
* full `ObjectStackProtocol` union. Server-only extensions (drafts, history,
|
|
162
|
+
* diagnostics, clone, …) are feature-detected via runtime casts and so don't
|
|
163
|
+
* widen this contract.
|
|
164
|
+
*/
|
|
165
|
+
type RestProtocol = DataProtocol & MetadataProtocol;
|
|
158
166
|
/**
|
|
159
167
|
* Structural subset of `KernelManager` that RestServer needs in order to
|
|
160
168
|
* resolve a per-project protocol at request time. Typed locally to avoid
|
|
@@ -266,7 +274,7 @@ declare class RestServer {
|
|
|
266
274
|
* the durable source of truth a restarted/other node reads.
|
|
267
275
|
*/
|
|
268
276
|
private readonly cancelledImportJobs;
|
|
269
|
-
constructor(server: IHttpServer, protocol:
|
|
277
|
+
constructor(server: IHttpServer, protocol: RestProtocol, config?: RestServerConfig, kernelManager?: RestKernelManager, envRegistry?: RestEnvRegistry, defaultEnvironmentIdProvider?: () => string | undefined, authServiceProvider?: (environmentId?: string) => Promise<any | undefined>, objectQLProvider?: (environmentId?: string) => Promise<any | undefined>, emailServiceProvider?: (environmentId?: string) => Promise<any | undefined>, sharingServiceProvider?: (environmentId?: string) => Promise<any | undefined>, reportsServiceProvider?: (environmentId?: string) => Promise<any | undefined>, approvalsServiceProvider?: (environmentId?: string) => Promise<any | undefined>, sharingRulesServiceProvider?: (environmentId?: string) => Promise<any | undefined>, i18nServiceProvider?: (environmentId?: string) => Promise<any | undefined>, analyticsServiceProvider?: (environmentId?: string) => Promise<any | undefined>, settingsServiceProvider?: (environmentId?: string) => Promise<any | undefined>, serviceExistsProvider?: (name: string) => boolean, securityServiceProvider?: (environmentId?: string) => Promise<any | undefined>);
|
|
270
278
|
/**
|
|
271
279
|
* Resolve the protocol for a given request. When `environmentId` is present
|
|
272
280
|
* and a KernelManager is wired, fetch the per-project kernel's
|
|
@@ -755,7 +763,7 @@ interface RestApiPluginConfig {
|
|
|
755
763
|
*
|
|
756
764
|
* Responsibilities:
|
|
757
765
|
* 1. Consumes 'http.server' (or configured service)
|
|
758
|
-
* 2. Consumes 'protocol' (
|
|
766
|
+
* 2. Consumes 'protocol' (the `RestProtocol` slice — DataProtocol + MetadataProtocol, ADR-0076 D9)
|
|
759
767
|
* 3. Instantiates RestServer to auto-generate routes
|
|
760
768
|
*/
|
|
761
769
|
declare function createRestApiPlugin(config?: RestApiPluginConfig): Plugin;
|
|
@@ -787,6 +795,8 @@ interface ExportFieldMeta {
|
|
|
787
795
|
reference?: string;
|
|
788
796
|
/** Field on the referenced record to show as its label. */
|
|
789
797
|
displayField?: string;
|
|
798
|
+
/** Field holds multiple values (an array), e.g. a `multiple: true` lookup. */
|
|
799
|
+
multiple?: boolean;
|
|
790
800
|
/** Field is required — a value (or default) must exist on insert. */
|
|
791
801
|
required?: boolean;
|
|
792
802
|
/** Engine-owned column the client never supplies (never required of import). */
|
|
@@ -1027,6 +1037,12 @@ declare function runImport(opts: RunImportOptions): Promise<ImportRunSummary>;
|
|
|
1027
1037
|
* - select / radio → an option *value*
|
|
1028
1038
|
* - multiselect / checkboxes / tags → an array of option values
|
|
1029
1039
|
* - lookup / master_detail / user / reference → a record id (resolved async)
|
|
1040
|
+
* - file / image → a file id / url (as-is)
|
|
1041
|
+
*
|
|
1042
|
+
* Any of the last four whose field is flagged `multiple: true` (per the spec,
|
|
1043
|
+
* `multiple` applies to select / lookup / file / image; `radio`/`user` share
|
|
1044
|
+
* their branch) instead store an **array** — the cell is split on the export
|
|
1045
|
+
* separator and each token coerced individually. See `isMultiValueField`.
|
|
1030
1046
|
*
|
|
1031
1047
|
* Contract: when a field carries no usable metadata the value passes through
|
|
1032
1048
|
* untouched, so an import stays byte-identical to the pre-coercion behaviour.
|
|
@@ -1085,4 +1101,4 @@ declare function coerceRow(rawRow: Record<string, unknown>, metaMap: Map<string,
|
|
|
1085
1101
|
errors: FieldCoerceError[];
|
|
1086
1102
|
}>;
|
|
1087
1103
|
|
|
1088
|
-
export { type CoerceContext, type ExportFieldMeta, type ImportAction, type ImportProgress, type ImportProtocolLike, type ImportRowResult, type ImportRunSummary, type ImportUndoLog, type PrepareImportResult, type PreparedImport, type RefResolver, type RestApiPluginConfig, RestServer, type RouteEntry, RouteGroupBuilder, RouteManager, type RunImportOptions, buildFieldMetaMap, coerceRow, createRestApiPlugin, isMetaEnvelope, parseCsvToRows, parseXlsxToRows, prepareImportRequest, runImport };
|
|
1104
|
+
export { type CoerceContext, type ExportFieldMeta, type ImportAction, type ImportProgress, type ImportProtocolLike, type ImportRowResult, type ImportRunSummary, type ImportUndoLog, type PrepareImportResult, type PreparedImport, type RefResolver, type RestApiPluginConfig, type RestProtocol, RestServer, type RouteEntry, RouteGroupBuilder, RouteManager, type RunImportOptions, buildFieldMetaMap, coerceRow, createRestApiPlugin, isMetaEnvelope, parseCsvToRows, parseXlsxToRows, prepareImportRequest, runImport };
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { IHttpServer, RouteHandler, Plugin } from '@objectstack/core';
|
|
|
2
2
|
import * as System from '@objectstack/spec/system';
|
|
3
3
|
import * as Shared from '@objectstack/spec/shared';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import {
|
|
5
|
+
import { DataProtocol, MetadataProtocol, RestServerConfig } from '@objectstack/spec/api';
|
|
6
6
|
|
|
7
7
|
type RouteHandlerMetadata = System.RouteHandlerMetadata;
|
|
8
8
|
type HttpMethod = z.infer<typeof Shared.HttpMethod>;
|
|
@@ -155,6 +155,14 @@ declare class RouteGroupBuilder {
|
|
|
155
155
|
private resolvePath;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* The protocol slice the REST layer actually consumes (ADR-0076 D9 / #2462
|
|
160
|
+
* A1.5): wire-normalized data CRUD plus the metadata control plane — not the
|
|
161
|
+
* full `ObjectStackProtocol` union. Server-only extensions (drafts, history,
|
|
162
|
+
* diagnostics, clone, …) are feature-detected via runtime casts and so don't
|
|
163
|
+
* widen this contract.
|
|
164
|
+
*/
|
|
165
|
+
type RestProtocol = DataProtocol & MetadataProtocol;
|
|
158
166
|
/**
|
|
159
167
|
* Structural subset of `KernelManager` that RestServer needs in order to
|
|
160
168
|
* resolve a per-project protocol at request time. Typed locally to avoid
|
|
@@ -266,7 +274,7 @@ declare class RestServer {
|
|
|
266
274
|
* the durable source of truth a restarted/other node reads.
|
|
267
275
|
*/
|
|
268
276
|
private readonly cancelledImportJobs;
|
|
269
|
-
constructor(server: IHttpServer, protocol:
|
|
277
|
+
constructor(server: IHttpServer, protocol: RestProtocol, config?: RestServerConfig, kernelManager?: RestKernelManager, envRegistry?: RestEnvRegistry, defaultEnvironmentIdProvider?: () => string | undefined, authServiceProvider?: (environmentId?: string) => Promise<any | undefined>, objectQLProvider?: (environmentId?: string) => Promise<any | undefined>, emailServiceProvider?: (environmentId?: string) => Promise<any | undefined>, sharingServiceProvider?: (environmentId?: string) => Promise<any | undefined>, reportsServiceProvider?: (environmentId?: string) => Promise<any | undefined>, approvalsServiceProvider?: (environmentId?: string) => Promise<any | undefined>, sharingRulesServiceProvider?: (environmentId?: string) => Promise<any | undefined>, i18nServiceProvider?: (environmentId?: string) => Promise<any | undefined>, analyticsServiceProvider?: (environmentId?: string) => Promise<any | undefined>, settingsServiceProvider?: (environmentId?: string) => Promise<any | undefined>, serviceExistsProvider?: (name: string) => boolean, securityServiceProvider?: (environmentId?: string) => Promise<any | undefined>);
|
|
270
278
|
/**
|
|
271
279
|
* Resolve the protocol for a given request. When `environmentId` is present
|
|
272
280
|
* and a KernelManager is wired, fetch the per-project kernel's
|
|
@@ -755,7 +763,7 @@ interface RestApiPluginConfig {
|
|
|
755
763
|
*
|
|
756
764
|
* Responsibilities:
|
|
757
765
|
* 1. Consumes 'http.server' (or configured service)
|
|
758
|
-
* 2. Consumes 'protocol' (
|
|
766
|
+
* 2. Consumes 'protocol' (the `RestProtocol` slice — DataProtocol + MetadataProtocol, ADR-0076 D9)
|
|
759
767
|
* 3. Instantiates RestServer to auto-generate routes
|
|
760
768
|
*/
|
|
761
769
|
declare function createRestApiPlugin(config?: RestApiPluginConfig): Plugin;
|
|
@@ -787,6 +795,8 @@ interface ExportFieldMeta {
|
|
|
787
795
|
reference?: string;
|
|
788
796
|
/** Field on the referenced record to show as its label. */
|
|
789
797
|
displayField?: string;
|
|
798
|
+
/** Field holds multiple values (an array), e.g. a `multiple: true` lookup. */
|
|
799
|
+
multiple?: boolean;
|
|
790
800
|
/** Field is required — a value (or default) must exist on insert. */
|
|
791
801
|
required?: boolean;
|
|
792
802
|
/** Engine-owned column the client never supplies (never required of import). */
|
|
@@ -1027,6 +1037,12 @@ declare function runImport(opts: RunImportOptions): Promise<ImportRunSummary>;
|
|
|
1027
1037
|
* - select / radio → an option *value*
|
|
1028
1038
|
* - multiselect / checkboxes / tags → an array of option values
|
|
1029
1039
|
* - lookup / master_detail / user / reference → a record id (resolved async)
|
|
1040
|
+
* - file / image → a file id / url (as-is)
|
|
1041
|
+
*
|
|
1042
|
+
* Any of the last four whose field is flagged `multiple: true` (per the spec,
|
|
1043
|
+
* `multiple` applies to select / lookup / file / image; `radio`/`user` share
|
|
1044
|
+
* their branch) instead store an **array** — the cell is split on the export
|
|
1045
|
+
* separator and each token coerced individually. See `isMultiValueField`.
|
|
1030
1046
|
*
|
|
1031
1047
|
* Contract: when a field carries no usable metadata the value passes through
|
|
1032
1048
|
* untouched, so an import stays byte-identical to the pre-coercion behaviour.
|
|
@@ -1085,4 +1101,4 @@ declare function coerceRow(rawRow: Record<string, unknown>, metaMap: Map<string,
|
|
|
1085
1101
|
errors: FieldCoerceError[];
|
|
1086
1102
|
}>;
|
|
1087
1103
|
|
|
1088
|
-
export { type CoerceContext, type ExportFieldMeta, type ImportAction, type ImportProgress, type ImportProtocolLike, type ImportRowResult, type ImportRunSummary, type ImportUndoLog, type PrepareImportResult, type PreparedImport, type RefResolver, type RestApiPluginConfig, RestServer, type RouteEntry, RouteGroupBuilder, RouteManager, type RunImportOptions, buildFieldMetaMap, coerceRow, createRestApiPlugin, isMetaEnvelope, parseCsvToRows, parseXlsxToRows, prepareImportRequest, runImport };
|
|
1104
|
+
export { type CoerceContext, type ExportFieldMeta, type ImportAction, type ImportProgress, type ImportProtocolLike, type ImportRowResult, type ImportRunSummary, type ImportUndoLog, type PrepareImportResult, type PreparedImport, type RefResolver, type RestApiPluginConfig, type RestProtocol, RestServer, type RouteEntry, RouteGroupBuilder, RouteManager, type RunImportOptions, buildFieldMetaMap, coerceRow, createRestApiPlugin, isMetaEnvelope, parseCsvToRows, parseXlsxToRows, prepareImportRequest, runImport };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
// src/rest-server.ts
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
resolveAuthzContext,
|
|
4
|
+
resolveLocalizationContext,
|
|
5
|
+
isAuthGateAllowlisted,
|
|
6
|
+
shouldDenyAnonymous,
|
|
7
|
+
ANONYMOUS_DENY_BODY,
|
|
8
|
+
ANONYMOUS_DENY_STATUS
|
|
9
|
+
} from "@objectstack/core";
|
|
3
10
|
import { isMcpServerEnabled } from "@objectstack/types";
|
|
4
11
|
|
|
5
12
|
// src/route-manager.ts
|
|
@@ -212,6 +219,9 @@ var RouteGroupBuilder = class {
|
|
|
212
219
|
}
|
|
213
220
|
};
|
|
214
221
|
|
|
222
|
+
// src/rest-server.ts
|
|
223
|
+
import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from "@objectstack/spec/security";
|
|
224
|
+
|
|
215
225
|
// src/export-format.ts
|
|
216
226
|
function exportContentDisposition(objectName, label, ext, now = /* @__PURE__ */ new Date()) {
|
|
217
227
|
const pad = (n) => String(n).padStart(2, "0");
|
|
@@ -261,6 +271,7 @@ function buildFieldMetaMap(schema) {
|
|
|
261
271
|
options: Array.isArray(f.options) ? f.options : void 0,
|
|
262
272
|
reference: typeof f.reference === "string" ? f.reference : void 0,
|
|
263
273
|
displayField: typeof f.displayField === "string" ? f.displayField : void 0,
|
|
274
|
+
multiple: f.multiple === true,
|
|
264
275
|
required: f.required === true,
|
|
265
276
|
system: f.system === true,
|
|
266
277
|
readonly: f.readonly === true,
|
|
@@ -382,6 +393,14 @@ var OPTION_TYPES2 = /* @__PURE__ */ new Set(["select", "radio"]);
|
|
|
382
393
|
var MULTI_OPTION_TYPES2 = /* @__PURE__ */ new Set(["multiselect", "checkboxes", "tags"]);
|
|
383
394
|
var NUMBER_TYPES = /* @__PURE__ */ new Set(["number", "currency", "percent", "rating", "slider"]);
|
|
384
395
|
var BOOL_TYPES = /* @__PURE__ */ new Set(["boolean", "toggle"]);
|
|
396
|
+
var FILE_TYPES = /* @__PURE__ */ new Set(["file", "image"]);
|
|
397
|
+
var MULTI_CAPABLE_TYPES = /* @__PURE__ */ new Set(["select", "radio", "lookup", "user", "file", "image"]);
|
|
398
|
+
function isMultiValueField(meta) {
|
|
399
|
+
const t = meta?.type;
|
|
400
|
+
if (!t) return false;
|
|
401
|
+
if (MULTI_OPTION_TYPES2.has(t)) return true;
|
|
402
|
+
return MULTI_CAPABLE_TYPES.has(t) && meta?.multiple === true;
|
|
403
|
+
}
|
|
385
404
|
function normalizeRefMatch(result) {
|
|
386
405
|
if (result == null) return {};
|
|
387
406
|
if (typeof result === "string") return result ? { id: result } : {};
|
|
@@ -499,7 +518,23 @@ async function coerceFieldValue(raw, meta, ctx) {
|
|
|
499
518
|
if (d === void 0) return { error: { field, code: "invalid_date", message: `${field}: "${String(raw)}" is not a valid ${t}` } };
|
|
500
519
|
return { value: d };
|
|
501
520
|
}
|
|
502
|
-
if (OPTION_TYPES2.has(t)) {
|
|
521
|
+
if (OPTION_TYPES2.has(t) || MULTI_OPTION_TYPES2.has(t)) {
|
|
522
|
+
if (isMultiValueField(meta)) {
|
|
523
|
+
const parts = splitMulti(raw);
|
|
524
|
+
const out = [];
|
|
525
|
+
for (const part of parts) {
|
|
526
|
+
const v2 = matchOption(part, meta?.options);
|
|
527
|
+
if (v2 === void 0) {
|
|
528
|
+
if (ctx.createMissingOptions) {
|
|
529
|
+
out.push(part);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
return { error: { field, code: "invalid_option", message: `${field}: "${part}" is not a known option` } };
|
|
533
|
+
}
|
|
534
|
+
out.push(v2);
|
|
535
|
+
}
|
|
536
|
+
return { value: out };
|
|
537
|
+
}
|
|
503
538
|
const v = matchOption(raw, meta?.options);
|
|
504
539
|
if (v === void 0) {
|
|
505
540
|
if (ctx.createMissingOptions) return { value: String(raw).trim() };
|
|
@@ -507,23 +542,23 @@ async function coerceFieldValue(raw, meta, ctx) {
|
|
|
507
542
|
}
|
|
508
543
|
return { value: v };
|
|
509
544
|
}
|
|
510
|
-
if (
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
}
|
|
520
|
-
|
|
545
|
+
if (REFERENCE_TYPES2.has(t)) {
|
|
546
|
+
if (isMultiValueField(meta)) {
|
|
547
|
+
const tokens = splitMulti(raw);
|
|
548
|
+
if (!ctx.resolveRef || !meta.reference) return { value: tokens };
|
|
549
|
+
const out = [];
|
|
550
|
+
for (const token of tokens) {
|
|
551
|
+
const m = normalizeRefMatch(await ctx.resolveRef(meta.reference, token, meta));
|
|
552
|
+
if (m.ambiguous) {
|
|
553
|
+
return { error: { field, code: "reference_ambiguous", message: `${field}: "${token}" matches more than one ${meta.reference} \u2014 use a unique value or the record id` } };
|
|
554
|
+
}
|
|
555
|
+
if (m.id === void 0) {
|
|
556
|
+
return { error: { field, code: "reference_not_found", message: `${field}: no ${meta.reference} matches "${token}"` } };
|
|
557
|
+
}
|
|
558
|
+
out.push(m.id);
|
|
521
559
|
}
|
|
522
|
-
out
|
|
560
|
+
return { value: out };
|
|
523
561
|
}
|
|
524
|
-
return { value: out };
|
|
525
|
-
}
|
|
526
|
-
if (REFERENCE_TYPES2.has(t)) {
|
|
527
562
|
const display = String(raw).trim();
|
|
528
563
|
if (!ctx.resolveRef || !meta?.reference) return { value: display };
|
|
529
564
|
const match = normalizeRefMatch(await ctx.resolveRef(meta.reference, display, meta));
|
|
@@ -535,6 +570,9 @@ async function coerceFieldValue(raw, meta, ctx) {
|
|
|
535
570
|
}
|
|
536
571
|
return { value: match.id };
|
|
537
572
|
}
|
|
573
|
+
if (FILE_TYPES.has(t) && isMultiValueField(meta)) {
|
|
574
|
+
return { value: splitMulti(raw) };
|
|
575
|
+
}
|
|
538
576
|
return { value: trim && typeof raw === "string" ? raw.trim() : raw };
|
|
539
577
|
}
|
|
540
578
|
var REQUIRED_CHECK_SKIP = /* @__PURE__ */ new Set([
|
|
@@ -585,6 +623,10 @@ function toFailedResult(rowNo, err) {
|
|
|
585
623
|
return { row: rowNo, ok: false, action: "failed", error: message, code };
|
|
586
624
|
}
|
|
587
625
|
var MAX_CREATE_BATCH_SIZE = 200;
|
|
626
|
+
var yieldToEventLoop = () => new Promise((resolve) => {
|
|
627
|
+
if (typeof setImmediate === "function") setImmediate(resolve);
|
|
628
|
+
else setTimeout(resolve, 0);
|
|
629
|
+
});
|
|
588
630
|
function runImport(opts) {
|
|
589
631
|
const {
|
|
590
632
|
p,
|
|
@@ -803,8 +845,9 @@ function runImport(opts) {
|
|
|
803
845
|
await flushPendingCreates();
|
|
804
846
|
if (onProgress) await onProgress(snapshot(processed));
|
|
805
847
|
}
|
|
806
|
-
if (
|
|
807
|
-
|
|
848
|
+
if (processed < rows.length && processed % progressEvery === 0) {
|
|
849
|
+
await yieldToEventLoop();
|
|
850
|
+
if (shouldCancel && await shouldCancel()) {
|
|
808
851
|
cancelled = true;
|
|
809
852
|
break;
|
|
810
853
|
}
|
|
@@ -1068,7 +1111,7 @@ async function prepareImportRequest(body, opts) {
|
|
|
1068
1111
|
const dryRun = body?.dryRun === true;
|
|
1069
1112
|
let writeMode = body?.writeMode === "update" || body?.writeMode === "upsert" ? body.writeMode : "insert";
|
|
1070
1113
|
let matchFields = Array.isArray(body?.matchFields) ? body.matchFields.filter((f) => typeof f === "string" && f.length > 0) : [];
|
|
1071
|
-
const runAutomations = body?.runAutomations
|
|
1114
|
+
const runAutomations = body?.runAutomations !== false;
|
|
1072
1115
|
const trimWhitespace = body?.trimWhitespace !== false;
|
|
1073
1116
|
const nullValues = Array.isArray(body?.nullValues) ? body.nullValues.filter((v) => typeof v === "string") : void 0;
|
|
1074
1117
|
const createMissingOptions = body?.createMissingOptions === true;
|
|
@@ -1228,6 +1271,16 @@ function mapDataError(error, object) {
|
|
|
1228
1271
|
}
|
|
1229
1272
|
};
|
|
1230
1273
|
}
|
|
1274
|
+
if (error?.code === "ATTACHMENT_PARENT_ACCESS" || error?.code === "ATTACHMENT_DELETE_DENIED") {
|
|
1275
|
+
return {
|
|
1276
|
+
status: 403,
|
|
1277
|
+
body: {
|
|
1278
|
+
error: error?.message ?? "Attachment access denied",
|
|
1279
|
+
code: error.code,
|
|
1280
|
+
...error?.object || object ? { object: error?.object ?? object } : {}
|
|
1281
|
+
}
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1231
1284
|
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
|
|
1232
1285
|
return {
|
|
1233
1286
|
status: 403,
|
|
@@ -1247,6 +1300,17 @@ function mapDataError(error, object) {
|
|
|
1247
1300
|
}
|
|
1248
1301
|
};
|
|
1249
1302
|
}
|
|
1303
|
+
if (typeof error?.status === "number" && error.status >= 400 && error.status < 500) {
|
|
1304
|
+
const msg = typeof error?.message === "string" && error.message.length > 0 && error.message.length < 500 ? error.message : "Request failed";
|
|
1305
|
+
return {
|
|
1306
|
+
status: error.status,
|
|
1307
|
+
body: {
|
|
1308
|
+
error: msg,
|
|
1309
|
+
...typeof error?.code === "string" && error.code ? { code: error.code } : {},
|
|
1310
|
+
...object ? { object } : {}
|
|
1311
|
+
}
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1250
1314
|
const raw = String(error?.message ?? error ?? "");
|
|
1251
1315
|
const lower = raw.toLowerCase();
|
|
1252
1316
|
const sandboxWrapper = /^(?:hook|action) '[^']*' threw:\s*(.+)$/s.exec(raw);
|
|
@@ -1696,14 +1760,16 @@ var RestServer = class _RestServer {
|
|
|
1696
1760
|
res.status(403).json({ error: { code: gate.code, message: gate.message } });
|
|
1697
1761
|
return true;
|
|
1698
1762
|
}
|
|
1699
|
-
if (
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1763
|
+
if (shouldDenyAnonymous({
|
|
1764
|
+
requireAuth: this.config.api.requireAuth,
|
|
1765
|
+
userId: context?.userId,
|
|
1766
|
+
isSystem: context?.isSystem,
|
|
1767
|
+
method: req?.method
|
|
1768
|
+
})) {
|
|
1769
|
+
res.status(ANONYMOUS_DENY_STATUS).json(ANONYMOUS_DENY_BODY);
|
|
1770
|
+
return true;
|
|
1771
|
+
}
|
|
1772
|
+
return false;
|
|
1707
1773
|
}
|
|
1708
1774
|
/**
|
|
1709
1775
|
* Enforce object-level API exposure (ObjectSchema `enable.apiEnabled` /
|
|
@@ -1921,6 +1987,10 @@ var RestServer = class _RestServer {
|
|
|
1921
1987
|
permissions: authz.permissions,
|
|
1922
1988
|
systemPermissions: authz.systemPermissions,
|
|
1923
1989
|
...authz.tabPermissions ? { tabPermissions: authz.tabPermissions } : {},
|
|
1990
|
+
// [ADR-0095 D2 / #2947] Carry the derived posture rung so the
|
|
1991
|
+
// enforcement side reads the SAME value the resolver computed,
|
|
1992
|
+
// instead of dropping it here (the boundary this issue closes).
|
|
1993
|
+
...authz.posture ? { posture: authz.posture } : {},
|
|
1924
1994
|
isSystem: false,
|
|
1925
1995
|
org_user_ids: authz.org_user_ids,
|
|
1926
1996
|
...authGate ? { authGate } : {},
|
|
@@ -3450,7 +3520,7 @@ var RestServer = class _RestServer {
|
|
|
3450
3520
|
res.json(result);
|
|
3451
3521
|
} catch (error) {
|
|
3452
3522
|
const mapped = mapDataError(error, req.params?.object);
|
|
3453
|
-
if (mapped.status
|
|
3523
|
+
if (isExpectedDataStatus(mapped.status) || mapped.body?.code === "UNSUPPORTED_QUERY_PARAM") {
|
|
3454
3524
|
res.status(mapped.status).json(mapped.body);
|
|
3455
3525
|
} else {
|
|
3456
3526
|
logError("[REST] Unhandled error:", error);
|
|
@@ -3742,6 +3812,16 @@ var RestServer = class _RestServer {
|
|
|
3742
3812
|
tags: ["data", "import"]
|
|
3743
3813
|
}
|
|
3744
3814
|
});
|
|
3815
|
+
const loadImportJob = async (p, jobId, environmentId, context) => {
|
|
3816
|
+
const r = await p.findData({
|
|
3817
|
+
object: IMPORT_JOB_OBJECT,
|
|
3818
|
+
query: { $filter: { id: jobId }, $top: 1 },
|
|
3819
|
+
...environmentId ? { environmentId } : {},
|
|
3820
|
+
...context ? { context } : {}
|
|
3821
|
+
});
|
|
3822
|
+
const rows = Array.isArray(r?.records) ? r.records : Array.isArray(r?.data) ? r.data : Array.isArray(r?.rows) ? r.rows : Array.isArray(r) ? r : [];
|
|
3823
|
+
return rows[0];
|
|
3824
|
+
};
|
|
3745
3825
|
this.routeManager.register({
|
|
3746
3826
|
method: "POST",
|
|
3747
3827
|
path: `${dataPath}/:object/import/jobs`,
|
|
@@ -3807,6 +3887,11 @@ var RestServer = class _RestServer {
|
|
|
3807
3887
|
};
|
|
3808
3888
|
const captureUndo = !prepared.dryRun && prepared.rows.length <= IMPORT_JOB_UNDO_MAX_ROWS;
|
|
3809
3889
|
void (async () => {
|
|
3890
|
+
if (this.cancelledImportJobs.has(jobId)) {
|
|
3891
|
+
this.cancelledImportJobs.delete(jobId);
|
|
3892
|
+
await patch({ status: "cancelled", completed_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3893
|
+
return;
|
|
3894
|
+
}
|
|
3810
3895
|
await patch({ status: "running", started_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3811
3896
|
try {
|
|
3812
3897
|
const summary = await runImport({
|
|
@@ -3824,10 +3909,27 @@ var RestServer = class _RestServer {
|
|
|
3824
3909
|
skipped_count: pr.skipped,
|
|
3825
3910
|
error_count: pr.errors
|
|
3826
3911
|
}),
|
|
3827
|
-
shouldCancel: () =>
|
|
3912
|
+
shouldCancel: async () => {
|
|
3913
|
+
if (this.cancelledImportJobs.has(jobId)) return true;
|
|
3914
|
+
try {
|
|
3915
|
+
const row = await loadImportJob(p, jobId, environmentId, context);
|
|
3916
|
+
return String(row?.status ?? "") === "cancelled";
|
|
3917
|
+
} catch {
|
|
3918
|
+
return false;
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3828
3921
|
});
|
|
3922
|
+
let finalStatus = summary.cancelled ? "cancelled" : "succeeded";
|
|
3923
|
+
if (finalStatus === "succeeded" && this.cancelledImportJobs.has(jobId)) finalStatus = "cancelled";
|
|
3924
|
+
if (finalStatus === "succeeded") {
|
|
3925
|
+
try {
|
|
3926
|
+
const row = await loadImportJob(p, jobId, environmentId, context);
|
|
3927
|
+
if (String(row?.status ?? "") === "cancelled") finalStatus = "cancelled";
|
|
3928
|
+
} catch {
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3829
3931
|
await patch({
|
|
3830
|
-
status:
|
|
3932
|
+
status: finalStatus,
|
|
3831
3933
|
processed_rows: summary.processed,
|
|
3832
3934
|
created_count: summary.created,
|
|
3833
3935
|
updated_count: summary.updated,
|
|
@@ -3857,16 +3959,6 @@ var RestServer = class _RestServer {
|
|
|
3857
3959
|
tags: ["data", "import"]
|
|
3858
3960
|
}
|
|
3859
3961
|
});
|
|
3860
|
-
const loadImportJob = async (p, jobId, environmentId, context) => {
|
|
3861
|
-
const r = await p.findData({
|
|
3862
|
-
object: IMPORT_JOB_OBJECT,
|
|
3863
|
-
query: { $filter: { id: jobId }, $top: 1 },
|
|
3864
|
-
...environmentId ? { environmentId } : {},
|
|
3865
|
-
...context ? { context } : {}
|
|
3866
|
-
});
|
|
3867
|
-
const rows = Array.isArray(r?.records) ? r.records : Array.isArray(r?.data) ? r.data : Array.isArray(r?.rows) ? r.rows : Array.isArray(r) ? r : [];
|
|
3868
|
-
return rows[0];
|
|
3869
|
-
};
|
|
3870
3962
|
this.routeManager.register({
|
|
3871
3963
|
method: "POST",
|
|
3872
3964
|
path: `${dataPath}/import/jobs/:jobId/cancel`,
|
|
@@ -4450,6 +4542,7 @@ var RestServer = class _RestServer {
|
|
|
4450
4542
|
}
|
|
4451
4543
|
const fields = {};
|
|
4452
4544
|
for (const [name, def] of Object.entries(obj.fields)) {
|
|
4545
|
+
if (PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(name)) continue;
|
|
4453
4546
|
if (allowed.size === 0 || allowed.has(name)) {
|
|
4454
4547
|
fields[name] = def;
|
|
4455
4548
|
}
|
|
@@ -4474,6 +4567,7 @@ var RestServer = class _RestServer {
|
|
|
4474
4567
|
const safeForm = (() => {
|
|
4475
4568
|
if (!match.form || !Array.isArray(match.form.sections)) return match.form;
|
|
4476
4569
|
const allow = (name, cfg) => {
|
|
4570
|
+
if (PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(name)) return false;
|
|
4477
4571
|
const def = objectSchema?.fields?.[name];
|
|
4478
4572
|
const t = def?.type;
|
|
4479
4573
|
if (t !== "lookup" && t !== "master_detail" && t !== "user") return true;
|
|
@@ -4539,12 +4633,10 @@ var RestServer = class _RestServer {
|
|
|
4539
4633
|
}
|
|
4540
4634
|
const rawBody = req.body && typeof req.body === "object" ? req.body : {};
|
|
4541
4635
|
const filteredData = {};
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
} else {
|
|
4547
|
-
Object.assign(filteredData, rawBody);
|
|
4636
|
+
for (const [k, v] of Object.entries(rawBody)) {
|
|
4637
|
+
if (PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(k)) continue;
|
|
4638
|
+
if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
|
|
4639
|
+
if (allowedFields.size === 0 || allowedFields.has(k)) filteredData[k] = v;
|
|
4548
4640
|
}
|
|
4549
4641
|
const context = {
|
|
4550
4642
|
publicFormGrant: { object: match.object },
|
|
@@ -4593,15 +4685,17 @@ var RestServer = class _RestServer {
|
|
|
4593
4685
|
return;
|
|
4594
4686
|
}
|
|
4595
4687
|
let fieldCfg = null;
|
|
4596
|
-
|
|
4597
|
-
for (const
|
|
4598
|
-
const
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4688
|
+
if (!PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(fieldName)) {
|
|
4689
|
+
for (const sec of match.form?.sections ?? []) {
|
|
4690
|
+
for (const f of sec?.fields ?? []) {
|
|
4691
|
+
const name = typeof f === "string" ? f : f?.field;
|
|
4692
|
+
if (name === fieldName) {
|
|
4693
|
+
fieldCfg = typeof f === "string" ? {} : f;
|
|
4694
|
+
break;
|
|
4695
|
+
}
|
|
4602
4696
|
}
|
|
4697
|
+
if (fieldCfg) break;
|
|
4603
4698
|
}
|
|
4604
|
-
if (fieldCfg) break;
|
|
4605
4699
|
}
|
|
4606
4700
|
const picker = fieldCfg?.publicPicker;
|
|
4607
4701
|
if (!picker) {
|
|
@@ -4859,12 +4953,15 @@ var RestServer = class _RestServer {
|
|
|
4859
4953
|
const parsed = ExplainRequestSchema.safeParse({
|
|
4860
4954
|
object: src.object,
|
|
4861
4955
|
operation: src.operation ?? "read",
|
|
4862
|
-
...src.userId != null && src.userId !== "" ? { userId: src.userId } : {}
|
|
4956
|
+
...src.userId != null && src.userId !== "" ? { userId: src.userId } : {},
|
|
4957
|
+
// [C2 / ADR-0095] Optional record id — explains ONE concrete
|
|
4958
|
+
// row at record granularity; omitted stays object-level.
|
|
4959
|
+
...src.recordId != null && src.recordId !== "" ? { recordId: src.recordId } : {}
|
|
4863
4960
|
});
|
|
4864
4961
|
if (!parsed.success) {
|
|
4865
4962
|
return res.status(400).json({
|
|
4866
4963
|
code: "VALIDATION_FAILED",
|
|
4867
|
-
message: "Invalid explain request \u2014 expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string }.",
|
|
4964
|
+
message: "Invalid explain request \u2014 expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string, recordId?: string }.",
|
|
4868
4965
|
detail: String(parsed.error?.message ?? "").slice(0, 1e3)
|
|
4869
4966
|
});
|
|
4870
4967
|
}
|