Skip to content

Commit de0660d

Browse files
committed
fix(metadata-protocol): a batch row answers a sandboxed hook's refusal in the hook's words
The batch row sink withheld a sandboxed hook body's bare throw as a server fault (INTERNAL_ERROR, "the reason is in the server log"), while /data, /createMany and /import answer the body's sentence as a 400 refusal. The row now reads the sentence through sandboxBusinessMessage first, takes the 400 floor code the doors serve, and never quotes a sandboxed error's .message (the runner's debug wrapper). Crashes, the sandbox's own faults, declared-5xx refusals and non-sandbox faults stay withheld, with the warning. Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41c657b commit de0660d

1 file changed

Lines changed: 68 additions & 4 deletions

File tree

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
type AnonymousFlowDoorCandidate,
1313
} from '@objectstack/spec/api';
1414
import { 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
*/
36343649
function 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
*/
39734033
function 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

Comments
 (0)