Skip to content

rest: the sandbox debug wrapper hook '<name>' threw: Error: … reaches the client on every write route that exits above mapDataError's unwrap (batch, createMany, updateMany, deleteMany, clone, analytics) #11588

Description

@os-zhuang

Filed unassigned while root-causing #10345 (the missing code on the same routes). Different half of the same envelope, different branch — recorded separately rather than fixed there, because the repair changes the message text on routes that card did not touch.

The rule this violates

classifyDataError's sandbox unwrap door exists precisely to keep the QuickJS debug wrapper off the wire. Its own docblock: a hook's throw new Error('删除被阻断…') is a deliberate business rule, "End users must see only the business message", and the <kind> '<name>' threw: <msg> prefix "belongs in server logs". packages/qa/dogfood/test/hook-error-format.dogfood.test.ts pins that end to end — but only for a single-row DELETE, which is the one route family that reaches the door.

Measured

In-process against the real route handlers (RestServer.registerRoutes(), mocked protocol, a SandboxError with .message = the wrapper, .innerMessage = the business text, .status = 409, .code = 'RECORD_LOCKED'), on main at bcfe215d:

routebody error / message
PATCH /api/v1/data/:obj/:idOpportunity is closed. — unwrapped, correct
POST /api/v1/data/:obj/batchhook 'guard' threw: Error: Opportunity is closed.
POST /api/v1/data/:obj/createManyhook 'guard' threw: Error: dup
POST /api/v1/data/:obj/updateManyhook 'guard' threw: Error: locked
POST /api/v1/data/:obj/deleteManyhook 'guard' threw: Error: restricted
POST /api/v1/data/:obj/:id/clonehook 'guard' threw: Error: locked
POST /api/v1/analytics/dataset/queryhook 'guard' threw: Error: locked

Why

Two distinct branches, both sitting above the unwrap:

  1. batch / bulk / clone exit through handleRouteErrorresolveErrorResponse, whose declared-status passthrough is checked before it delegates to mapDataError. So a refusal that declares its status as status (the spelling the QuickJS side-channel uses) is answered from error.message — the wrapper — and never reaches the unwrap. A refusal declaring statusCode falls through to mapDataError and is unwrapped, so the same hook produces two different message shapes on one route depending on which spelling its author picked.
  2. /analytics/dataset/query builds its own { code, message } envelope inline and reads error.message directly. It shares no branch with either of the above.

The single-row POST / PATCH / DELETE / query routes call mapDataError directly, which is why they are the only ones that unwrap.

Not established here

Reproduction lives in the harness shape of packages/rest/src/rest-hook-refusal-code-parity.test.ts (added by #10345's PR) — the same sandboxRefusal() fixture and BULK_ROUTES table, with the assertion moved from body.code to body.error.


Generated by Claude Code

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions