From 7996dccc3f995648e00581533ab38e9930475c50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:53:09 +0000 Subject: [PATCH 1/2] fix(core): an import row for a sandboxed body the door answers as a fault reads as that fault A sandboxed hook body that crashed (`TypeError: boom`) and a sandboxed refusal that declared a 5xx status are answered by `POST /data/:object` and `/createMany` as server faults with their text withheld. The import row relayed both: the crash as the QuickJS debug wrapper through `sanitizeRowError`, the declared 5xx as the body's prose. `toFailedResult` now takes the create door's own sentence for a sandbox-origin error that `mapDataError` answers with a 5xx. The verdict and the sentence are `mapDataError`'s; the row adds no second withholding rule. The row's code is unchanged, and every non-sandbox error keeps its row text (driver text is still cleaned by `sanitizeRowError`). Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude --- .../import-runner-sandbox-refusal-row.test.ts | 39 +++++++++----- packages/core/src/utils/import-runner.ts | 29 ++++++++--- ...route-hook-refusal-sentence.ledger.test.ts | 51 ++++++++++++++----- 3 files changed, 86 insertions(+), 33 deletions(-) diff --git a/packages/core/src/utils/import-runner-sandbox-refusal-row.test.ts b/packages/core/src/utils/import-runner-sandbox-refusal-row.test.ts index 3fb13719915..ca2bfe299e0 100644 --- a/packages/core/src/utils/import-runner-sandbox-refusal-row.test.ts +++ b/packages/core/src/utils/import-runner-sandbox-refusal-row.test.ts @@ -29,10 +29,13 @@ * §3 pins door-to-row agreement directly: the row's sentence IS what * `mapDataError` (the create door) answers for the same error, including for a * sentence `sanitizeRowError` would have paraphrased. §4 holds the controls. + * §5 (#22718) holds the two sandboxed failures the door answers as a fault, a + * crashed body and a refusal that declared a 5xx: their row reads the door's + * sentence. */ import { describe, it, expect, vi } from 'vitest'; -import { mapDataError } from '@objectstack/types'; +import { mapDataError, INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { runImport, sanitizeRowError, type ImportProtocolLike } from './import-runner'; import type { ExportFieldMeta } from './import-field-meta.js'; @@ -176,17 +179,6 @@ describe('[#22694] §3 — the row answers what the create door answers', () => }); describe('[#22694] §4 — controls: what the read must NOT change', () => { - it('CONTROL — a hook body that CRASHED is not a refusal: its row is built from `.message` exactly as before', async () => { - const crash = sandboxCrash(); - const row = await rowFor(sandboxCrash); - - // `sandboxBusinessMessage` declines a crash, so the native error text is - // never presented as the author's sentence. - expect(row.error).not.toBe('TypeError: boom'); - expect(row.error).toBe(sanitizeRowError(crash.message)); - expect(row.code).toBe('IMPORT_ROW_FAILED'); - }); - it('CONTROL — a plain error whose own text looks like the wrapper is relayed as it always was', async () => { // No `innerMessage`: nothing about this error came from the sandbox, so a // pattern-strip would be the only thing that could change it. @@ -206,5 +198,28 @@ describe('[#22694] §4 — controls: what the read must NOT change', () => { const sql = "insert into `mz_locked` (`name`) values ('r1') - SQLITE_BUSY: database is locked"; const row = await rowFor(() => new Error(sql)); expect(row.error).toBe('SQLITE_BUSY: database is locked'); + // [#22718] The door answers this as a fault too, so §5's gate is the + // sandbox origin, never the door's 5xx alone. + expect(mapDataError(new Error(sql), OBJECT).status).toBeGreaterThanOrEqual(500); + }); +}); + +/** A refusal that DECLARED a server-band status: the doors keep the status and code and withhold the prose. */ +const sandboxDeclared5xx = () => sandboxRefusal('The ledger service is down.', { status: 503, code: 'SERVICE_UNAVAILABLE' }); + +describe('[#22718] §5 — a sandboxed body the door answers as a fault reads as that fault', () => { + it.each([ + ['a body that CRASHED', sandboxCrash, 'IMPORT_ROW_FAILED', /TypeError|boom|threw:/], + ['a refusal that declared a 5xx', sandboxDeclared5xx, 'SERVICE_UNAVAILABLE', /ledger|threw:/], + ] as const)('%s', async (_label, err, code, leak) => { + const door = mapDataError(err(), OBJECT); + // Anti-vacuity: the door answers a fault and withholds the text. + expect(door.status).toBeGreaterThanOrEqual(500); + expect(door.body.error).toBe(INTERNAL_ERROR_MESSAGE); + + const row = await rowFor(err); + // The door's sentence, never the native text or the prose; the code still reads as a fault. + expect(row).toEqual({ row: 1, ok: false, action: 'failed', error: door.body.error, code }); + expect(JSON.stringify(row)).not.toMatch(leak); }); }); diff --git a/packages/core/src/utils/import-runner.ts b/packages/core/src/utils/import-runner.ts index f5ce06c616f..8e511983ac2 100644 --- a/packages/core/src/utils/import-runner.ts +++ b/packages/core/src/utils/import-runner.ts @@ -8,7 +8,7 @@ import type { DroppedFieldsEvent } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { CreateDataRequest, FindDataRequest, UpdateDataRequest, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult } from './bulk-write.js'; -import { isUniqueViolationError, uniqueViolationColumn, isEngineDuplicateRecordEnvelope, mapDataError, sandboxBusinessMessage } from '@objectstack/types'; +import { isUniqueViolationError, uniqueViolationColumn, isEngineDuplicateRecordEnvelope, mapDataError, sandboxBusinessMessage, isSandboxOrigin } from '@objectstack/types'; /** * import-runner — the shared row-processing core for bulk import. @@ -459,22 +459,37 @@ export function sanitizeRowError(raw: unknown): string { * passed through {@link sanitizeRowError}. That function cleans DRIVER text, * and an author's sentence fed to it is paraphrased: one that opens with an * SQL verb ("Update the cost centre first.") reads as a leaked statement and - * comes back as the generic database sentence. A body that CRASHED is not a - * refusal: {@link sandboxBusinessMessage} declines it, so its row is built - * exactly as before, from `.message` through {@link sanitizeRowError}. A - * `code` the body declared still rides, read from the error as it always was. + * comes back as the generic database sentence. A `code` the body declared + * still rides, read from the error as it always was. + * + * ## A sandboxed body the door answers as a fault reads as that fault (#22718) + * + * Two sandboxed failures are not refusals, and the create door withholds + * their text: a body that CRASHED (`TypeError: boom`, which + * {@link sandboxBusinessMessage} declines) answers `500 INTERNAL_ERROR`, and a + * refusal that declared a 5xx status answers that status with its prose + * withheld. The row used to relay both: the crash as the wrapper through + * {@link sanitizeRowError}, the declared 5xx as the body's prose. So for a + * sandboxed error the door answers with a 5xx, the row's text is the door's + * own sentence. Which errors are faults and what their sentence is are both + * `mapDataError`'s verdict, ⛔ never a second withholding rule here. The row's + * `code` is unchanged, so it still reads as a failed row with a fault code. + * Every other error keeps its row text: driver text is still cleaned by + * {@link sanitizeRowError}, which is the import's own reading of it. */ function toFailedResult(rowNo: number, err: unknown, objectName: string): ImportRowResult { const e = err as { code?: unknown; message?: unknown; fields?: unknown; field?: unknown } | null | undefined; const first = firstFinding(e?.fields); + const door = mapDataError(err, objectName); if (first === undefined) { - const adopted = adoptDoorVerdict(rowNo, mapDataError(err, objectName).body); + const adopted = adoptDoorVerdict(rowNo, door.body); if (adopted !== undefined) return adopted; } const thrownCode = isEngineDuplicateRecordEnvelope(e) ? 'UNIQUE_VIOLATION' : e?.code; const code = first?.code ?? thrownCode ?? 'IMPORT_ROW_FAILED'; const field = first?.field != null && first.field !== '' ? first.field : e?.field; - const message = sandboxBusinessMessage(err) ?? sanitizeRowError(e?.message); + const sandboxFault = isSandboxOrigin(err) && door.status >= 500 ? String(door.body.error) : undefined; + const message = sandboxFault ?? sandboxBusinessMessage(err) ?? sanitizeRowError(e?.message); return { row: rowNo, ok: false, action: 'failed', error: message, code: String(code), ...(field != null && field !== '' ? { field: String(field) } : {}), diff --git a/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts b/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts index 8965163aa79..9bca79d50e6 100644 --- a/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts +++ b/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts @@ -42,7 +42,6 @@ */ import { describe, it, expect } from 'vitest'; -import { sanitizeRowError } from '@objectstack/core'; import { RestServer } from './rest-server.js'; import { mountAndRecordDirectRoutes } from './direct-mount-composition.js'; import { REST_ROUTE_LEDGER } from './rest-route-ledger.js'; @@ -391,19 +390,43 @@ describe('[#22694] §3 MEASURED, NOT REPAIRED: these doors answer the debug wrap } }); -describe('[#22694] §4 CONTROL: a hook body that CRASHED still answers as a fault on both import doors', () => { - const crash = sandboxCrash(); +/** A refusal that DECLARED a server-band status: the doors keep the status and withhold the prose. */ +function sandboxDeclared5xx(): Error { + const err = new Error("hook 'mz_guard' threw: Error: The ledger service is down."); + err.name = 'SandboxError'; + return Object.assign(err, { innerMessage: 'The ledger service is down.', status: 503, code: 'SERVICE_UNAVAILABLE' }); +} + +/** + * [#22718] A body that CRASHED and a refusal that declared a 5xx are answered + * as faults by the data doors, with their text withheld. Both import rows are + * held to that answer. §2 is the control: a refusal row keeps the hook's + * sentence on both import doors. + */ +describe('[#22718] §4 a sandboxed body the doors answer as a fault reads as that fault on both import doors', () => { it.each([ - ['POST /api/v1/data/:object/import', importRow, undefined], - ['POST /api/v1/data/:object/import/jobs', jobRow, IMPORT_JOB_PROTOCOL], - ] as const)('%s', async (route, answer, protocol) => { - const b = boot(sandboxCrash, protocol?.(sandboxCrash)); - const res = await drive(b, route, IMPORT_BODY); - - const row = await answer(res, b); - // The native error text is never presented as the author's sentence; - // the row is built from `.message` exactly as before this card. - expect(row).not.toBe('TypeError: boom'); - expect(row).toBe(sanitizeRowError(crash.message)); + ['a body that CRASHED', sandboxCrash, /TypeError|boom/], + ['a refusal that declared a 5xx', sandboxDeclared5xx, /ledger/], + ] as const)('%s', async (_label, error, leak) => { + const doors: unknown[] = []; + for (const [route, body] of [['POST /api/v1/data/:object', { name: 'x' }], ['POST /api/v1/data/:object/createMany', [{ name: 'x' }]]] as const) { + const res = await drive(boot(error), route, body); + expect(res.statusCode, route).toBeGreaterThanOrEqual(500); + doors.push(sentenceOf(res.body)); + } + expect(doors[1]).toBe(doors[0]); + expect(String(doors[0])).not.toMatch(leak); + + for (const [route, answer, protocol] of [ + ['POST /api/v1/data/:object/import', importRow, undefined], + ['POST /api/v1/data/:object/import/jobs', jobRow, IMPORT_JOB_PROTOCOL], + ] as const) { + const b = boot(error, protocol?.(error)); + const row = await answer(await drive(b, route, IMPORT_BODY), b); + + expect(b.calls, route).toContain('protocol.insertManyData'); + expect(row, route).toBe(doors[0]); + expect(String(row), route).not.toMatch(WRAPPER_RE); + } }, 60_000); }); From bb05e4686b8a7352085fb8954f806638ce4885dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:55:48 +0000 Subject: [PATCH 2/2] chore(changeset): core patch for the sandboxed fault import row Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude --- .changeset/22718-import-row-sandbox-fault.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/22718-import-row-sandbox-fault.md diff --git a/.changeset/22718-import-row-sandbox-fault.md b/.changeset/22718-import-row-sandbox-fault.md new file mode 100644 index 00000000000..967c142e591 --- /dev/null +++ b/.changeset/22718-import-row-sandbox-fault.md @@ -0,0 +1,9 @@ +--- +'@objectstack/core': patch +--- + +An import row for a sandboxed hook body that crashed, or that refused with a declared 5xx status, reads as a server fault instead of relaying the fault's text + +Clause-②: no + +When a hook body crashed during `POST /api/v1/data/:object/import` (or the async `/import/jobs` job), the row's `error` read `hook 'NAME' threw: TypeError: …`, and a hook refusal that declared a 5xx status relayed its own message. `POST /api/v1/data/:object` and `/createMany` answer both as server faults and withhold that text. The row now carries the same sentence those routes answer (`Internal server error`), and its `code` is unchanged: `IMPORT_ROW_FAILED` for a crash, the declared code for a declared 5xx. A hook refusal with no status or a 4xx status still reads in the hook's own words, and a database error's row text is unchanged.