@@ -145,6 +145,11 @@ describe('an unexpected FAULT is a 500', () => {
145145 // No `{success:true, data:{...}}` wrapper — this is the dispatcher's
146146 // error exit, so monitoring sees a 5xx.
147147 expect ( response . body . data ) . toBeUndefined ( ) ;
148+ // [#18540] …and the SENTENCE is withheld. This pin asserted the three
149+ // lines above and nothing about `message`, so the live leak sat green
150+ // underneath it — "still 500" is exactly what the defect looked like.
151+ // The disclosure half is pinned in full in its own section below.
152+ expect ( response . body . error . message ) . toBe ( 'Internal server error' ) ;
148153 } ) ;
149154
150155 it ( 'a ReferenceError from a buggy handler' , async ( ) => {
@@ -314,3 +319,101 @@ describe('[#17273] a sandboxed body that CRASHED is a fault, not a rejection', (
314319 expect ( response . body . error . message ) . toBe ( 'Import failed with a TypeError in row 4' ) ;
315320 } ) ;
316321} ) ;
322+
323+ /**
324+ * [#18540] A NON-sandboxed crash's native sentence is withheld — the face of
325+ * this question that needs no sandbox at all.
326+ *
327+ * #17273 put a crash terminal above every branch of this door that reads a
328+ * producer declaration as intent, but its predicate is keyed on the SANDBOX:
329+ * `isNativeErrorName` over the `innerMessage` the QuickJS runner fills. A plain
330+ * `TypeError` from an in-process registered handler never crosses a VM
331+ * boundary, so nothing sets `innerMessage`, that terminal never fires, and the
332+ * throw fell to `unexpectedFault` → `errorFromThrown`, which relays
333+ * `err.message`. Measured on the wire before this change:
334+ *
335+ * {"success":false,"error":{"code":"INTERNAL_ERROR",
336+ * "message":"Cannot read properties of undefined (reading 'id')","httpStatus":500}}
337+ *
338+ * The same crash through the `/data` door answered `"Internal server error"`
339+ * (#7543 / #15071). ⇒ the status was already right; what leaked was the
340+ * sentence.
341+ *
342+ * **The shape worth carrying: a predicate that classifies by HOW a crash
343+ * arrived is structurally blind to crashes that did not arrive that way —
344+ * while looking exhaustive.** Same family as a ratchet with no row for the
345+ * case, and as a slot whose third consumer nobody reached.
346+ *
347+ * The other guard misses it for a second, independent reason, and that is why
348+ * the fix is not a new phrasing: the dispatcher's 5xx withhold is gated on
349+ * `looksLikeInternalErrorLeak`, which recognises DRIVER DUMPS and reads FALSE
350+ * for stack-shaped prose. So the relay is DEFAULT-ALLOW, while `/data` is
351+ * default-DENY — `classifyDataError` ends in an unconditional
352+ * `UNCLASSIFIED_FAULT()`, and its `looksLikeInternalErrorLeak` limb only picks
353+ * `DATABASE_ERROR` over `INTERNAL_ERROR`. The fix answers this door's terminal
354+ * with the terminal's envelope; ⛔ it does not re-point the heuristic, which
355+ * guards a different question at every other boundary.
356+ *
357+ * The cases below are BOTH halves, because a disclosure pin that only asserts
358+ * the withheld case cannot tell a fix from a blanket sweep that ate the
359+ * refusal channel: two crashes whose text the heuristic does NOT recognise,
360+ * then the two controls one property away on either side. The `/data` parity
361+ * is deliberately asserted in prose rather than by importing
362+ * `@objectstack/rest` here — a cross-package import would move this file, and
363+ * the pins above it, into the `repo` vitest project.
364+ */
365+ describe ( '[#18540] a NON-sandboxed crash is answered with the sanitised sentence' , ( ) => {
366+ it ( "the card's exact repro — a bare TypeError, no sandbox anywhere in the path" , async ( ) => {
367+ const response = await invoke ( new TypeError ( "Cannot read properties of undefined (reading 'id')" ) ) ;
368+
369+ // Unmoved: the status and the code were already right, and this card is
370+ // fenced from touching them.
371+ expect ( response . status ) . toBe ( 500 ) ;
372+ expect ( response . body . error . code ) . toBe ( 'INTERNAL_ERROR' ) ;
373+ // The whole of the change: the sentence, matching what `/data` answers.
374+ expect ( response . body . error . message ) . toBe ( 'Internal server error' ) ;
375+ expect ( String ( response . body . error . message ) ) . not . toContain ( 'Cannot read properties' ) ;
376+ expect ( response . body . success ) . toBe ( false ) ;
377+ } ) ;
378+
379+ it ( 'a fault whose prose the leak heuristic does NOT recognise — a driver class naming a FILE PATH' , async ( ) => {
380+ // `looksLikeInternalErrorLeak` reads FALSE here: no SQL keyword, no
381+ // dialect template, nothing quoted. Before this change the tenant
382+ // received a server filesystem path. This case is why the fix cannot be
383+ // "teach the heuristic about TypeError" — the leaking population is not
384+ // a phrasing family, it is everything the terminal was relaying.
385+ const err : any = new Error ( 'database disk image is malformed at /srv/data/tenant_42.db' ) ;
386+ err . name = 'SqliteError' ;
387+ const response = await invoke ( err ) ;
388+
389+ expect ( response . status ) . toBe ( 500 ) ;
390+ expect ( response . body . error . message ) . toBe ( 'Internal server error' ) ;
391+ expect ( String ( response . body . error . message ) ) . not . toContain ( '/srv/data' ) ;
392+ } ) ;
393+
394+ it ( 'negative control: a deliberate rejection keeps its 400 AND its own sentence' , async ( ) => {
395+ // One `name` away from the first case. An implementation that withheld
396+ // every message at this catch — or that moved the fault terminal above
397+ // the rejection exit — would turn the cases above green while deleting
398+ // the channel a business rule speaks through.
399+ const response = await invoke ( new Error ( 'Lead is already converted' ) ) ;
400+
401+ expect ( response . status ) . toBe ( 400 ) ;
402+ expect ( response . body . error . message ) . toBe ( 'Lead is already converted' ) ;
403+ } ) ;
404+
405+ it ( 'negative control: a crash that DECLARED its own status keeps that status and that sentence' , async ( ) => {
406+ // The branch serving `.status` sits above `unexpectedFault`, so this
407+ // throw never reaches the terminal. It is the control for the fence on
408+ // this card: ⛔ no declared status moves, and a producer that composed
409+ // an answer still speaks.
410+ const err : any = new TypeError ( 'Not allowed' ) ;
411+ err . status = 403 ;
412+ err . code = 'FORBIDDEN' ;
413+ const response = await invoke ( err ) ;
414+
415+ expect ( response . status ) . toBe ( 403 ) ;
416+ expect ( response . body . error . code ) . toBe ( 'FORBIDDEN' ) ;
417+ expect ( response . body . error . message ) . toBe ( 'Not allowed' ) ;
418+ } ) ;
419+ } ) ;
0 commit comments