Skip to content

Commit d952358

Browse files
fix(rest): the hand-built write routes answer a sandboxed crash and a declared-5xx refusal as the doors do (#22764) (#22844)
Fixes #22764 Clause-②: no The hand-built REST write routes now answer two sandboxed-hook failures the way the `/data` routes do: a hook body that **crashed**, and a hook refusal that **declared a server-band status**. Both are faults, and the text is withheld. A hook's refusal still keeps its sentence on every row. ## What was wrong (re-measured before any edit) Measured at `origin/main` `efcbac73c`, at the route handler, through the pin harness of `packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts`. The two shapes were a crashed body (`.innerMessage` = `TypeError: boom`) and a sandboxed refusal declaring `status: 503, code: 'SERVICE_UNAVAILABLE'`: - **The reference.** `POST /data/:object` and `/createMany` answer a crash `500 INTERNAL_ERROR` `Internal server error`. They answer the declared 503 as `503 SERVICE_UNAVAILABLE` `Internal server error`. - **The card's rows, now 19.** The overlay-discard row retired with its route after the card was filed. - The 14 sharing-rule, suggested-binding and approval rows answered a crash with `500` + the family code (`RULE_*_FAILED`, `SUGGESTION_*_FAILED`, `APPROVAL_*_FAILED`) and `hook 'mz_guard' threw: TypeError: boom`. They answered the declared 503 with the same `500` + family code and the wrapper. - `POST /packages/publish` answered a crash `500 INTERNAL_ERROR` with the wrapper, and the declared 503 as `503 SERVICE_UNAVAILABLE` with the hook's sentence. - The 4 external-datasource rows answered a crash `400 EXTERNAL_DATASOURCE_ERROR` / `400 EXTERNAL_IMPORT_ERROR` with the wrapper, and the declared 503 at the same `400` with the sentence. - **Two more ledgered write rows, same defect** (measured in the same pass over every driven row): - `POST /analytics/dataset/query` answered a crash `500 ANALYTICS_QUERY_FAILED` with the wrapper. Its declared-503 answer already matched the reference. - `POST /security/explain` answered both shapes `500 EXPLAIN_FAILED` with the wrapper. - **Every other driven write row already matched the reference.** The only exception is the record-share pair, which withholds the text but keeps its own `500 SHARE_*_FAILED` (see Acceptance notes). ## The repair: one shared answer, re-dressed per family `error-response.ts` gains `sandboxFaultAnswer(error)`. It is the classification door's own answer (`resolveErrorResponse`, the same one `sendThrownError` / `handleRouteError` send) for a sandbox-origin error that crashed or declared a 5xx, and it answers `undefined` for anything else. It is not a second fault implementation. It is the counterpart of `classifiedRefusalAnswer`, which answers the refusal half and declines every 5xx. Each family asks it after its refusal arm and before its own 500 terminal. It re-dresses the answer in the family's own envelope: `nestedRefusalEnvelope` for the nested families, the flat body for analytics. It logs the original error first, because the operator is now the only reader of the native text. The publish and external-datasource doors gained that log line. The approvals helper logs inside itself, because answering skips the outer arm's log. | rows | crash, after | declared 503, after | | --- | --- | --- | | all 19 card rows, `POST /security/explain` | `500 INTERNAL_ERROR`, `Internal server error` | `503 SERVICE_UNAVAILABLE`, `Internal server error` | | `POST /analytics/dataset/query` | `500 INTERNAL_ERROR`, `Internal server error` (its flat `{ code, error }`) | unchanged (already the reference) | Envelopes: sharing-rule, approvals, explain, publish and external use the shared nested `{ success: false, error: { code, message } }`. Suggested bindings use their family's `{ error: { code, message } }`. A `userMessage` and a demoted `declaredCode` ride the answer, as at the doors. The same arms also serve `GET /sharing/rules`, `GET /sharing/rules/:idOrName`, `GET /security/suggested-bindings` and `GET /datasources/:name/external/tables`, so those reads answer the two shapes the same way. **Codes.** PM assumption 3 asked whether a family keeps its own code on a fault. Following the card's direction ("as the data doors do"), these two shapes now answer the doors' code. Every family code stays live for every other fault, and §5 of the pin still holds the 19 rows' plain-fault terminal at `RULE_*_FAILED` / `APPROVAL_*_FAILED` / `SUGGESTION_*_FAILED` / `INTERNAL_ERROR` / `EXTERNAL_*`. So the ledger entries (`content/docs/references/api/error-code-ledger.mdx`, auto-generated) keep their emitters and nothing in `packages/spec` moves. No test outside this pin asserted a family code on either sandbox shape: the full rest suite is green with no edit to any other test. ## Scope - **The two extra rows** (`POST /analytics/dataset/query` crash arm, `POST /security/explain` 500 arm) are repaired here under the bounded in-place exemption. All four conditions hold: - ① The defect class is the same: a crashed sandbox body's native text, or a declared 5xx unwithheld, on a ledgered REST write row. - ② The repair is mechanical, and §6 pins its shape: the same helper and the doors' answer. - ③ The file is `rest-server.ts`, which this card's claim already holds. The claim's census records no open PR or other claim on `packages/rest`. - ④ The gate family is the same, with no new verification surface: §6 of the same pin. **File-surface supplement to the claim:** the analytics dataset-query fault arm and the security-explain fault arm, both in `rest-server.ts`. Without them, §6 could not enumerate the ledger's write rows by construction, which the triage grade's pin asks for. - **Fence held (the declared-4xx question, options A/B/C).** Nothing here moves a declared-4xx answer. The helper is sandbox-scoped and answers only a crash or a declared 5xx. On the four external rows a sandboxed refusal declaring a 4xx, and a non-sandbox declared 4xx, still answer `400` with the family's code: §5's declared-409 cases pass unchanged. The metadata door's `409 DESTRUCTIVE_CHANGE` that `external-import-destructive-remedy.dogfood.test.ts` pins at `400 EXTERNAL_IMPORT_ERROR` is a non-sandbox throw and does not reach the helper. That is option A, the status quo. - **Not widened to non-sandbox declared 5xx.** A service's own declared 5xx keeps each route's terminal. On publish, that terminal's prose withhold is byte for byte its dispatcher twin's heuristic. A census of `plugin-approvals`, `plugin-sharing` and `plugin-security` source finds no declared 5xx. `service-package`'s one (`packageSeamUnreadableError`, 503) is a seat refusal answered by that twin rule, so it is left alone. ## Tests - **The pin, §6** (new): every write row §2 drives, enumerated from `REST_ROUTE_LEDGER` through §1, with both shapes, 88 cases (43 rows x 2 + the pinned reference + a map-membership check). Each case asserts: - the named downstream was called; - the answer's sentence equals the reference door's; - neither the native text (`TypeError|boom`, the withheld `ledger` sentence) nor the wrapper appears anywhere in the body; - `status` and `code` equal the reference's (`500 INTERNAL_ERROR` / `503 SERVICE_UNAVAILABLE`, pinned literally on the reference case), except the record-share pair, which is named with its reason. The two import rows answer per row inside a `200` / `201` and are held to the reference sentence. §2 remains the control: a refusal keeps its sentence on the same rows. - **Ablation at `92aee9407`** (fix and pin committed first). `git restore --source=efcbac73c` restored the four source files in the tree only: the `sandboxFaultAnswer` counts went 2/6/3/3 to 0/0/0/0, and `git status` showed four ` M`. The pin then ran **41 failed / 148 passed (189)**. The 41 are exactly 19 rows x 2 shapes + the analytics crash + the explain crash and declared 503, and no other section went red. The restore used `git checkout HEAD --`: all four blob hashes equal `HEAD` and `git diff HEAD` is empty, run under an `EXIT INT TERM` trap. There is no dist leg: every subject is a relative import inside `packages/rest/src`, so it resolves from source. - `pnpm --filter @objectstack/rest test` passed (**272 files, 5333 passed, 326 skipped**, lock `VERDICT command-exit 0`), at `1b89f1c70`. It passed again at `aeba43296`, after merging `origin/main` and rebuilding the two touched closure members (`lint`, `plugin-security`). - `pnpm --filter @objectstack/rest typecheck` passed at `aeba43296`: `tsc --noEmit` plus `check:test-typecheck: OK`. The pin file is in the test program (`tsc -p tsconfig.test.json --listFilesOnly`: 1 hit, 277 test files). - `pnpm --filter @objectstack/rest test:repo` passed at `aeba43296`: 5 files, 209 passed, 1 skipped. Three of those are the `error-response-*` door-parity pins. ## Gates (at `aeba43296`) - 65 derived families (the 54 dispatch-time ones, plus 11 changeset families added by this diff's `.changeset` file), each run with its exit captured before any pipe: **65 exit 0**. `dispatch-gates --ran` reconciles: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN". - `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (no full build in this worktree). After a cached full `turbo run build` (72 of 73 cached), it reported "107 published require entry point(s) across 66 package(s) load". - **`pnpm lint`: proven narrowing, not the full run.** - ① The population is read from `eslint.config.mjs`: `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus the build dirs. All five `.ts` files of this diff are in it, since none reported "File ignored". - ② `eslint --no-inline-config --format json` on those five files gave 5 files, 0 errors and 0 warnings. - ③ Invariance: the config never enables type-aware linting (no `parserOptions.project` or `projectService`, no typed rules). Its local rules are per-file AST rules, and its only disk reads are two baseline JSONs at config load, which this diff does not touch. So the diff cannot move any untouched file's verdict. The full `pnpm lint` run is CI's. ## Acceptance notes - **Record-share pair** (`POST /data/:object/:id/shares`, `DELETE /data/:object/:id/shares/:shareId`). For both sandbox fault shapes it answers `500 SHARE_*_FAILED` with `Internal server error`: the text is withheld (`sharingFaultMessage`), but the status and code are the family's own. Its comments record the fold of a declared 5xx into that 500 as deliberate (#11683, #12693). It is not one of the card's rows, so §6 names it in `OWN_FAULT_TERMINAL` with that reason instead of converging it. It is noted, not filed. - **Plain (non-sandbox) faults** on these families still relay the thrown message, capped, at the family's `500` (`400` on external). §5 pins this, as does `sharing-envelope.test.ts` for the record-share family, and the arms' comments record it as deliberate. The data doors withhold an unclassified fault's text instead. This card's direction names the two sandbox shapes only, so nothing moved. It is noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0253192 commit d952358

6 files changed

Lines changed: 282 additions & 12 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): the hand-built REST write routes answer a sandboxed hook that crashed, or that declared a server error, as the data routes do (#22764)
6+
7+
Clause-②: no
8+
9+
**What was wrong.** Two kinds of sandboxed hook failure reach a route as faults, not refusals. One is a hook body that crashed (`ctx.input.title.trim()` on a number): the route receives the QuickJS debug wrapper on `.message`, for example `hook 'guard' threw: TypeError: boom`. The other is a hook that declared a server-band status (`status: 503`). The `/data` routes answer both as faults and withhold the text: a crash answers `500 INTERNAL_ERROR` with `Internal server error`, and a declared `503` answers `503` with its own code and `Internal server error`. The routes below build their error answer by hand, and they sent the wrapper and the native error text to the caller. The four external-datasource routes also answered a crash as `400`, the caller's mistake.
10+
11+
**What changes on the wire, for those two failures only.**
12+
13+
| routes | before | after |
14+
| --- | --- | --- |
15+
| `POST /sharing/rules`, `DELETE /sharing/rules/:idOrName`, `POST /sharing/rules/:idOrName/evaluate` | `500 RULE_*_FAILED`, flat `{ code, error }`, the wrapper | the `/data` answer, nested `{ success: false, error: { code, message } }` |
16+
| `POST /security/suggested-bindings/:id/confirm` and `/dismiss` | `500 SUGGESTION_*_FAILED`, the wrapper | the `/data` answer, `{ error: { code, message } }` |
17+
| the nine `POST /approvals/requests/:id/*` writes (`approve`, `reject`, `recall`, `revise`, `resubmit`, `reassign`, `remind`, `request-info`, `comment`) | `500 APPROVAL_*_FAILED`, flat `{ code, error }`, the wrapper | the `/data` answer, nested |
18+
| `POST /packages/publish` | a crash: `500 INTERNAL_ERROR` with the wrapper; a declared `503`: `503` with the hook's sentence | the `/data` answer |
19+
| `POST /datasources/:name/external/tables/:remote/draft` and `/import`, `/external/refresh-catalog`, `/external/validate` | `400 EXTERNAL_DATASOURCE_ERROR` / `400 EXTERNAL_IMPORT_ERROR`, the wrapper for a crash, the sentence for a declared `503` | the `/data` answer, nested |
20+
| `POST /security/explain` | `500 EXPLAIN_FAILED`, the wrapper | the `/data` answer, nested |
21+
| `POST /analytics/dataset/query`, a crash only (a declared 5xx already answered as `/data` does) | `500 ANALYTICS_QUERY_FAILED`, the wrapper | the `/data` answer, in this route's flat `{ code, error }` |
22+
23+
"The `/data` answer" is `500 INTERNAL_ERROR` with `Internal server error` for a crash, whatever status it declared. For a refusal that declared a 5xx, it is that status, the declared code and `Internal server error`. The prose is released only when the hook marked the declaration a refusal, and a `userMessage` the hook set rides the answer. The withheld text goes to the server log. The answer is computed by the same classification the `/data` routes use, so it cannot drift from theirs.
24+
25+
The same arms serve the read routes of the sharing-rule, suggested-binding and external-datasource families (`GET /sharing/rules`, `GET /sharing/rules/:idOrName`, `GET /security/suggested-bindings`, `GET /datasources/:name/external/tables`), so they answer these two failures the same way.
26+
27+
**Unchanged.** A hook's refusal keeps its sentence and status on every route. A refusal that declared a 4xx on the external-datasource routes still answers `400` with that family's code. A fault that is not from a sandboxed hook keeps each route's own answer and code. A service-declared `503` on `POST /packages/publish` is one such fault. The per-record share routes already withheld the text at their own `500 SHARE_*_FAILED` and are unchanged.
28+
29+
**Upgrading.** Nothing to change. A client that branched on `RULE_*_FAILED`, `APPROVAL_*_FAILED`, `SUGGESTION_*_FAILED`, `EXPLAIN_FAILED`, `ANALYTICS_QUERY_FAILED` or a `400` from an external-datasource route to detect a broken hook reads `INTERNAL_ERROR` (or the declared 5xx code) instead. Those codes still answer every other fault.

‎packages/rest/src/error-response.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,61 @@ export function classifiedRefusalAnswer(
678678
return resolved.status < 500 ? resolved : undefined;
679679
}
680680

681+
/**
682+
* [#22764] The `/data` door's answer for a SANDBOXED body it answers as a
683+
* FAULT, or `undefined` for every other throw, which stays the catching
684+
* route's own.
685+
*
686+
* ## Why it exists
687+
*
688+
* {@link classifiedRefusalAnswer} answers a sandbox's REFUSAL and declines
689+
* every 5xx, so a hand-built family that asks it still owns the two sandbox
690+
* shapes the doors answer as faults, and each family's own terminal relayed
691+
* `.message` for them:
692+
*
693+
* - a body that CRASHED ({@link isSandboxCrash}, #7543). `.message` is the
694+
* QuickJS wrapper around the runtime's native text, so the approvals,
695+
* sharing-rule, suggested-binding, publish and external-datasource doors
696+
* answered `hook '<name>' threw: TypeError: …`, and the four external ones
697+
* at `400`, a fault answered as the caller's mistake;
698+
* - a refusal that DECLARED a server-band status. The doors keep the status
699+
* and withhold the prose (#5437) unless the producer declared it a refusal
700+
* (#16146); the families folded it into their own `500` with the wrapper,
701+
* or relayed the sentence at the declared status.
702+
*
703+
* ## One answer, not a second fault terminal
704+
*
705+
* The answer IS {@link resolveErrorResponse}'s — the classification door
706+
* `sendThrownError` and `handleRouteError` send for the same throw — so its
707+
* status, `code`, withheld sentence and declared channels (`declaredCode`,
708+
* `userMessage`) cannot drift from the doors'. A crash takes that function's
709+
* crash terminal ahead of any declared status (#17273), exactly as there.
710+
* The caller re-dresses it in its own envelope, as it does a classified
711+
* refusal ({@link nestedRefusalEnvelope} for the nested families), and logs
712+
* the original error first: the client no longer reads the words, so the
713+
* operator must.
714+
*
715+
* ## What it deliberately leaves alone
716+
*
717+
* - A throw that is not a sandbox's. A producer-declared 5xx from a service
718+
* keeps the catching route's terminal: the publish door's prose withhold is
719+
* byte for byte its dispatcher twin's heuristic (#8086), and widening it
720+
* at one door would mint the divergence that note closes. No measured
721+
* producer behind these families declares one in any case.
722+
* - A sandboxed refusal declaring a 4xx. That is the refusal half's, and the
723+
* four external routes answer it at their own `400`, unchanged.
724+
*/
725+
export function sandboxFaultAnswer(
726+
error: any,
727+
): { status: number; body: Record<string, unknown> } | undefined {
728+
if (!isSandboxOrigin(error)) return undefined;
729+
if (!isSandboxCrash(error)) {
730+
const declared = declaredHttpStatus(error);
731+
if (declared === undefined || declared < 500) return undefined;
732+
}
733+
return resolveErrorResponse(error);
734+
}
735+
681736
/**
682737
* [#22719] {@link classifiedRefusalAnswer}'s answer, re-dressed for a door that
683738
* publishes the NESTED ADR-0112 D5 envelope (`{ error: { code, message } }`)
@@ -713,6 +768,10 @@ export function classifiedRefusalAnswer(
713768
* decision, not a rename. `extra` is absent when there is nothing to carry,
714769
* so an emitter writes exactly `{ code, message }` for a refusal that declared
715770
* nothing.
771+
*
772+
* [#22764] The same families re-dress {@link sandboxFaultAnswer}'s answer
773+
* through it too: that answer is phrased in the same flat classification, so
774+
* the derivation above holds unchanged for a fault.
716775
*/
717776
export function nestedRefusalEnvelope(refusal: { status: number; body: Record<string, unknown> }): {
718777
status: number;

‎packages/rest/src/external-datasource-routes.ts‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import type {
1818
// The declared envelope is written in ONE place for the whole platform (#3973).
1919
// [#22719] …and so is the read of a sandboxed hook's refusal sentence.
2020
import { sendOk, sendError, sandboxBusinessMessage } from '@objectstack/types';
21+
// [#22764] …and so is the `/data` door's answer for a sandboxed body it
22+
// answers as a fault.
23+
import { nestedRefusalEnvelope, sandboxFaultAnswer } from './error-response.js';
2124
import { mountDirectRoutes, type DirectMountedRoute } from './direct-mount.js';
25+
import { logError } from './log.js';
2226

2327
/**
2428
* External Datasource Federation REST routes (ADR-0015 §6.2).
@@ -396,23 +400,51 @@ export function registerExternalDatasourceRoutes(
396400
* `.message`. It answers `undefined` for anything that is not a sandboxed
397401
* refusal, so every other message here is byte-identical.
398402
*
399-
* ⛔ Only the SENTENCE moves. Both arms keep their status and their code:
400-
* `EXTERNAL_IMPORT_ERROR` is what the import route answers for a refusal it
401-
* relays from the metadata door (a declared `409 DESTRUCTIVE_CHANGE`
402-
* included, which the import dogfood pins at `400`), so this family's status
403-
* is its own decision and the shared classification is not asked here.
403+
* ⛔ For a refusal only the SENTENCE moves. Both arms keep their status and
404+
* their code: `EXTERNAL_IMPORT_ERROR` is what the import route answers for a
405+
* refusal it relays from the metadata door (a declared `409
406+
* DESTRUCTIVE_CHANGE` included, which the import dogfood pins at `400`), so
407+
* this family's refusal status is its own decision and the shared refusal
408+
* classification is not asked here. A FAULT is the other question, answered
409+
* by {@link answerThrown} below.
404410
*/
405411
const refusalSentence = (err: unknown): string =>
406412
sandboxBusinessMessage(err) ?? (err instanceof Error ? err.message : String(err));
407413

414+
/**
415+
* [#22764] Both catch arms' answer. A sandboxed body the doors answer as a
416+
* FAULT — one that CRASHED, or a refusal that DECLARED a 5xx — answers as the
417+
* doors do ({@link sandboxFaultAnswer}), logged first because the caller no
418+
* longer reads its words. Before, both reached this family's `400`: a crash
419+
* as the caller's mistake, with the QuickJS wrapper around the native error
420+
* text, and a declared `503` as a client error carrying its sentence.
421+
*
422+
* ⛔ Every other throw keeps this family's own `400` and code, a declared 4xx
423+
* included: a sandboxed refusal declaring one, and the metadata door's
424+
* `409 DESTRUCTIVE_CHANGE` the import dogfood pins at `400`. That status is
425+
* a separate decision this does not take.
426+
*/
427+
const answerThrown = (
428+
res: any,
429+
err: unknown,
430+
code: 'EXTERNAL_DATASOURCE_ERROR' | 'EXTERNAL_IMPORT_ERROR',
431+
) => {
432+
const fault = sandboxFaultAnswer(err);
433+
if (fault) {
434+
logError('[REST] external-datasource error:', err);
435+
const nested = nestedRefusalEnvelope(fault);
436+
return sendError(res, nested.status, nested.code, nested.message, nested.extra);
437+
}
438+
return sendError(res, 400, code, refusalSentence(err));
439+
};
440+
408441
/**
409442
* A refusal from the external-datasource service — same code as the
410443
* admin-routes path (#4249). Named after the service, not one operation:
411444
* since #4264 every route here except the import (which has its own
412445
* registered code) answers its catch with this.
413446
*/
414-
const refused = (res: any, err: unknown) =>
415-
sendError(res, 400, 'EXTERNAL_DATASOURCE_ERROR', refusalSentence(err));
447+
const refused = (res: any, err: unknown) => answerThrown(res, err, 'EXTERNAL_DATASOURCE_ERROR');
416448

417449
/**
418450
* ONE declaration of this registrar's surface (#5822): the array below is
@@ -489,7 +521,7 @@ export function registerExternalDatasourceRoutes(
489521
);
490522
sendOk(res, { object: result }, 201);
491523
} catch (err) {
492-
sendError(res, 400, 'EXTERNAL_IMPORT_ERROR', refusalSentence(err));
524+
answerThrown(res, err, 'EXTERNAL_IMPORT_ERROR');
493525
}
494526
},
495527
},

‎packages/rest/src/package-routes.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ import {
1717
sandboxBusinessMessage,
1818
INTERNAL_ERROR_MESSAGE,
1919
} from '@objectstack/types';
20-
import { classifiedRefusalAnswer } from './error-response.js';
20+
import { classifiedRefusalAnswer, nestedRefusalEnvelope, sandboxFaultAnswer } from './error-response.js';
2121
import { mountDirectRoutes, type DirectMountedRoute } from './direct-mount.js';
22+
import { logError } from './log.js';
2223

2324
/**
2425
* [#7033 / #7023] The authorization gate for the REST package transport.
@@ -202,8 +203,27 @@ async function refusePackageRequest(
202203
* mapping, which is why the classification's body is not re-dressed here the
203204
* way the hand-built families in `rest-server.ts` re-dress it: their 500 arm
204205
* read nothing, this door already reads everything but the sandbox.
206+
*
207+
* ## [#22764] …and a sandboxed body the doors answer as a fault reads as one
208+
*
209+
* The two reads above leave the sandbox's FAULT shapes to this door's own
210+
* mapping, and both shipped words the doors withhold: a body that CRASHED
211+
* answered `500 INTERNAL_ERROR` with the QuickJS wrapper around the native
212+
* error text (the business read declines a crash, so the raw message stood),
213+
* and a refusal that DECLARED `503` answered at its status with its sentence.
214+
* {@link sandboxFaultAnswer} is the doors' answer for exactly those two, so it
215+
* is asked first and re-dressed whole, the way the nested families re-dress
216+
* it. The original error is logged first, the one place its words now reach.
217+
* Every other throw is answered below exactly as before.
205218
*/
206219
function sendThrownError(res: any, error: unknown): void {
220+
const fault = sandboxFaultAnswer(error);
221+
if (fault) {
222+
logError('[REST] package publish error:', error);
223+
const nested = nestedRefusalEnvelope(fault);
224+
sendError(res, nested.status, nested.code, nested.message, nested.extra);
225+
return;
226+
}
207227
const thrown = resolveThrownHttpError(error, classifiedRefusalAnswer(error)?.status ?? 500);
208228
// The dispatcher twin's withhold, byte for byte — one rule, two doors. Its
209229
// other limb is the shared sandbox read (#22719), not the raw message.

‎packages/rest/src/rest-server.ts‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,7 @@ import {
414414
sandboxBusinessMessage,
415415
classifiedRefusalAnswer,
416416
nestedRefusalEnvelope,
417+
sandboxFaultAnswer,
417418
boundedDeclaredUserMessage,
418419
boundedDeclaredRefusalMessage,
419420
declaredHttpStatus,
@@ -11242,7 +11243,17 @@ export class RestServer {
1124211243
// now carry is the one they declare and the one the sibling
1124311244
// face has always shipped. `ANALYTICS_QUERY_FAILED` remains
1124411245
// this route's answer for an UNDECLARED fault, below.
11245-
const declaredFault = declaredServerFaultAnswer(error);
11246+
//
11247+
// [#22764] A sandboxed body the doors answer as a FAULT is
11248+
// asked first, for the order the doors ask it in: a body
11249+
// that CRASHED is a fault whatever status it declared
11250+
// (#17273), so it must not reach the declared-5xx relay, and
11251+
// it never reaches ③b, whose `clientMsg` is the QuickJS
11252+
// wrapper around the native error text for a crash (the
11253+
// business read declines one). A sandboxed refusal that
11254+
// declared a 5xx answers here too, the same bytes ③a gives
11255+
// it: one classification, `/data`'s.
11256+
const declaredFault = sandboxFaultAnswer(error) ?? declaredServerFaultAnswer(error);
1124611257
if (declaredFault) {
1124711258
return res.status(declaredFault.status).json({ ...declaredFault.body, ...markExtra });
1124811259
}
@@ -11498,6 +11509,15 @@ export class RestServer {
1149811509
return respondError(res, refusal.status, code, String(refusal.body.error ?? ''));
1149911510
}
1150011511
logError('[REST] Security explain error:', error);
11512+
// [#22764] A sandboxed body the doors answer as a fault (a
11513+
// crash, or a refusal declaring a 5xx) answers as the doors do,
11514+
// instead of the QuickJS wrapper relayed below. Status, code
11515+
// and message only, the refusal arm's scope above.
11516+
const fault = sandboxFaultAnswer(error);
11517+
if (fault) {
11518+
const nested = nestedRefusalEnvelope(fault);
11519+
return respondError(res, nested.status, nested.code, nested.message);
11520+
}
1150111521
// The 500 arm keeps its 500-char cap: an unexpected fault's
1150211522
// message is not a contract, and truncating it stays a
1150311523
// sanitization step — only the position of the words moves.
@@ -12117,6 +12137,16 @@ export class RestServer {
1211712137
return sendEnvelopeError(res, nested.status, nested.code, nested.message, nested.extra);
1211812138
}
1211912139
logError(`[REST] sharing-rule ${defaultCode}:`, err);
12140+
// [#22764] …and a sandboxed body the doors answer as a FAULT — one
12141+
// that crashed, or a refusal that declared a 5xx — answers as the
12142+
// doors do, logged above and withheld from the caller. The 500
12143+
// below relayed the QuickJS wrapper around the native error text.
12144+
// Nested, for the classified arm's reason.
12145+
const fault = sandboxFaultAnswer(err);
12146+
if (fault) {
12147+
const nested = nestedRefusalEnvelope(fault);
12148+
return sendEnvelopeError(res, nested.status, nested.code, nested.message, nested.extra);
12149+
}
1212012150
return res.status(500).json({ code: defaultCode, error: msg.slice(0, 500) });
1212112151
};
1212212152

@@ -12324,6 +12354,14 @@ export class RestServer {
1232412354
return respondError(res, nested.status, nested.code, nested.message, nested.extra);
1232512355
}
1232612356
logError(`[REST] suggested-bindings ${defaultCode}:`, err);
12357+
// [#22764] A sandboxed body the doors answer as a fault (a crash, or
12358+
// a refusal declaring a 5xx) answers as the doors do, through this
12359+
// family's one emitter, instead of the wrapper relayed below.
12360+
const fault = sandboxFaultAnswer(err);
12361+
if (fault) {
12362+
const nested = nestedRefusalEnvelope(fault);
12363+
return respondError(res, nested.status, nested.code, nested.message, nested.extra);
12364+
}
1232712365
// The 500 arm keeps its 500-char cap: an unexpected fault's message
1232812366
// is not a contract, and truncating it stays a sanitization step.
1232912367
return respondError(res, 500, defaultCode, String(err?.message ?? err).slice(0, 500));
@@ -12535,6 +12573,19 @@ export class RestServer {
1253512573
sendEnvelopeError(res, nested.status, nested.code, nested.message, nested.extra);
1253612574
return true;
1253712575
}
12576+
// [#22764] A sandboxed body the doors answer as a FAULT — one that
12577+
// crashed, or a refusal that declared a 5xx — answers as the doors
12578+
// do rather than reaching the caller's outer `500
12579+
// APPROVAL_*_FAILED`, which relayed the QuickJS wrapper around the
12580+
// native error text. Logged here because answering it skips the
12581+
// outer arm's log line, and the operator is now the only reader.
12582+
const fault = sandboxFaultAnswer(err);
12583+
if (fault) {
12584+
logError('[REST] approval error:', err);
12585+
const nested = nestedRefusalEnvelope(fault);
12586+
sendEnvelopeError(res, nested.status, nested.code, nested.message, nested.extra);
12587+
return true;
12588+
}
1253812589
return false;
1253912590
};
1254012591

0 commit comments

Comments
 (0)