@@ -12,7 +12,7 @@ import {
1212 type AnonymousFlowDoorCandidate,
1313} from '@objectstack/spec/api';
1414import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError, relationConditionSites } from '@objectstack/core';
15- import { declaredUserMessage, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types';
15+ import { declaredHttpStatus, declaredUserMessage, isSandboxOrigin, isSandboxOwnFault, resolveTenancyPosture, resolveThrownHttpError, sandboxBusinessMessage } from '@objectstack/types';
1616// [#6285] ADR-0105 D1's authority on "does this deployment wall organizations?".
1717// `resolveMultiOrgEnabled()` is DEMOTED and its own doc comment says answering
1818// this question with it is a bug (cloud#1020, #5233) — so the posture, and only
@@ -3630,6 +3630,21 @@ type BatchDataRowResult = BatchOperationResult;
36303630 * boundary. And a producer that merely SPEAKS `DUPLICATE_RECORD` — a hook
36313631 * throwing the registered code without being the engine's class — keeps its
36323632 * own code on the row, the same discrimination the whole-request arm makes.
3633+ *
3634+ * ## [#24561] A sandboxed body's refusal is not a server fault in row form
3635+ *
3636+ * The `INTERNAL_ERROR` default assumes an error that declared nothing is a
3637+ * fault. A sandboxed hook body's bare `throw new Error('…')` declares no status
3638+ * or code either, yet every data door (`/data`, `/createMany`, `/import`)
3639+ * serves it as a `400` refusal in the body's words, so a batch row said
3640+ * `INTERNAL_ERROR` beside the doors' `VALIDATION_ERROR` for the same refusal.
3641+ * {@link sandboxRefusalSentence} is the one test both limbs ask: when it
3642+ * answers, the `code` limb derives from the `400` the doors serve (the
3643+ * `declared ?? 400` of `classifyDataError`, `@objectstack/types`). A code or
3644+ * status the body DID declare still wins, as for every other producer.
3645+ *
3646+ * ⛔ `httpStatus` is NOT stamped for it. That limb carries only a status the
3647+ * producer declared (above), and this body declared none.
36333648 */
36343649function toRowApiError(err: any, fallback: string): ApiError {
36353650 const thrown = isEngineDuplicateRecordEnvelope(err)
@@ -3638,8 +3653,9 @@ function toRowApiError(err: any, fallback: string): ApiError {
36383653 ? (err.code as ApiError['code'])
36393654 : undefined;
36403655 const { declaredStatus } = resolveThrownHttpError(err);
3656+ const servedStatus = declaredStatus ?? (sandboxRefusalSentence(err) !== undefined ? 400 : undefined);
36413657 return {
3642- code: thrown ?? (declaredStatus !== undefined ? standardErrorCodeForHttpStatus(declaredStatus ) : 'INTERNAL_ERROR'),
3658+ code: thrown ?? (servedStatus !== undefined ? standardErrorCodeForHttpStatus(servedStatus ) : 'INTERNAL_ERROR'),
36433659 message: clientFacingRowFailureText(err, fallback),
36443660 ...(declaredStatus !== undefined ? { httpStatus: declaredStatus } : {}),
36453661 };
@@ -3868,6 +3884,29 @@ export function clientFacingFailureText(err: unknown, fallback: string): string
38683884 return fallback;
38693885}
38703886
3887+ /**
3888+ * [#24561] The sentence a sandboxed hook body's business refusal says on a
3889+ * batch row, or `undefined` when this error is not one.
3890+ *
3891+ * The read is `sandboxBusinessMessage` (`@objectstack/types`), the one every
3892+ * sibling door makes (the import runner, `/data`, `/createMany`), never a
3893+ * pattern-strip of the wrapper off `.message`. It already declines a body
3894+ * that CRASHED and the sandbox's own fault. The one condition added here is
3895+ * the doors' own: a refusal that declared a server-band status is answered as
3896+ * a fault with its text withheld (`classifyDataError`'s `declared < 500`, read
3897+ * through the same `declaredHttpStatus`), so it is not a sentence for a row
3898+ * either.
3899+ *
3900+ * Both limbs of {@link toRowApiError} ask this, so the row's `code` and
3901+ * `message` cannot disagree about whether a refusal was disclosed.
3902+ */
3903+ function sandboxRefusalSentence(err: unknown): string | undefined {
3904+ const sentence = sandboxBusinessMessage(err);
3905+ if (sentence === undefined) return undefined;
3906+ const declared = declaredHttpStatus(err);
3907+ return declared === undefined || declared < 500 ? sentence : undefined;
3908+ }
3909+
38713910/**
38723911 * [#8502] The per-row `errors[].message` a bulk data write puts on its
38733912 * response — the producer's own refusal when it declared one, otherwise a
@@ -3969,18 +4008,43 @@ export function clientFacingFailureText(err: unknown, fallback: string): string
39694008 * this function owes is one-directional and is pinned in BOTH directions by
39704009 * that integration file: it logs when it WITHHOLDS, and is silent when it
39714010 * does not.
4011+ *
4012+ * ## [#24561] A sandboxed body's refusal is read the way every sibling door reads it
4013+ *
4014+ * The undeclared hook above is an IN-PROCESS hook. A sandboxed (QuickJS) hook
4015+ * body is a different producer: its runner puts the body's own sentence on
4016+ * `SandboxError.innerMessage` for the caller and keeps the
4017+ * `hook '<name>' threw: …` debug wrapper on `.message` for the log. `/data`,
4018+ * `/createMany` and `/import` answer that sentence; this sink withheld it as a
4019+ * fault, so `POST /data/:object/batch` alone said "the reason is in the server
4020+ * log" for a refusal the hook wrote for the caller. The sentence is now read
4021+ * through {@link sandboxRefusalSentence} FIRST, which is
4022+ * `sandboxBusinessMessage` (`@objectstack/types`), the import runner's read.
4023+ *
4024+ * And a sandboxed error's `.message` is never quoted, whatever it declared.
4025+ * It is the wrapper, written for the log: a refusal that declared a 4xx used
4026+ * to be quoted from there, wrapper and all, and so did a body that CRASHED
4027+ * with a declared 4xx, which every data door answers `500` with its text
4028+ * withheld. "Sandboxed" is the import runner's test (a sandbox origin or the
4029+ * sandbox's own fault). What stays withheld is what the doors withhold: a
4030+ * crash, the runner's own fault, a refusal that declared a 5xx, and every
4031+ * non-sandbox error that declared no 4xx.
39724032 */
39734033function clientFacingRowFailureText(err: unknown, fallback: string): string {
4034+ const sentence = sandboxRefusalSentence(err);
4035+ if (sentence !== undefined) return sentence;
39744036 // 500 as the fallback status: an error that declared nothing is a server
39754037 // fault in row form, exactly what `toRowApiError`'s own code limb assumes.
39764038 const { status } = resolveThrownHttpError(err, 500);
3977- if (status >= 400 && status < 500) {
4039+ const sandboxed = isSandboxOrigin(err) || isSandboxOwnFault(err);
4040+ if (status >= 400 && status < 500 && !sandboxed) {
39784041 const declared = (err as { message?: unknown } | null | undefined)?.message;
39794042 if (typeof declared === 'string' && declared.length > 0) return declared;
39804043 }
39814044 console.warn(
39824045 '[Protocol] Withheld a caught error\'s text from a batch row: the producer declared no '
3983- + 'client-facing refusal (no 4xx status or statusCode, and not the VALIDATION_FAILED shape), so its '
4046+ + 'client-facing refusal (no 4xx status or statusCode, not the VALIDATION_FAILED shape, and not a '
4047+ + 'sandboxed body\'s business refusal), so its '
39844048 + 'sentence must not be quoted back on response data. The row says: '
39854049 + `"${fallback}" — cause (withheld from the response):`,
39864050 err,
0 commit comments