Skip to content

Commit 8f266f1

Browse files
Elon Muskclaude
andauthored
rest: serve a sandboxed body's declared HTTP status on /api/v1/data (#9967) (#10007)
The sandbox-unwrap branch of mapDataError answered 400 unconditionally, so a hook body's deliberate `e.status = 403; throw e` — carried out of the QuickJS VM onto SandboxError.status since #7867, and honoured by the custom-action door — was dead on arrival at the data routes. The branch now reads declaredHttpStatus: a declared 4xx is served with the unwrapped innerMessage as body text (still deliberately no `code`); a declared 5xx falls through to the declared-status passthrough's sanitised 5xx arm (#5582). Crash classification (isScriptFaultMessage) stays first, so a TypeError with a stray status stays the sanitised 500. Undeclared body throws keep the verbatim-message 400. Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r Co-authored-by: Claude <noreply@anthropic.com>
1 parent f2920e1 commit 8f266f1

3 files changed

Lines changed: 292 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
A sandboxed hook/action body that declares its own HTTP status (`e.status = 403; throw e`) is now served with that status on the `/api/v1/data` routes, instead of an unconditional 400 — the same #7867 declared-status rule the custom-action route already applies. The unwrapped business message stays the body text for a declared 4xx; a declared 5xx keeps the status and takes the standard sanitised server-fault envelope. Undeclared body throws (verbatim-message 400) and body crashes (sanitised 500) are unchanged.

packages/rest/src/error-response.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -620,15 +620,41 @@ function classifyDataError(error: any, object?: string): { status: number; body:
620620
// [#7543] …but only when the body REPORTED something. A body that
621621
// CRASHED arrives here too, and its `TypeError: not a function` is an
622622
// internal fault, not a business message — see
623-
// {@link isScriptFaultMessage}.
623+
// {@link isScriptFaultMessage}. Deliberately FIRST: a crash outranks
624+
// everything else about the error, including a stray declared
625+
// `status` — a `TypeError` carrying one stays the sanitised 500.
624626
if (isScriptFaultMessage(error.innerMessage)) return UNCLASSIFIED_FAULT();
625-
return {
626-
status: 400,
627-
body: {
628-
error: error.innerMessage,
629-
...(object ? { object } : {}),
630-
},
631-
};
627+
// [#9967] A body that NAMES its own HTTP status is asking to be served
628+
// with it — the same #7867 rule `domains/actions.ts` applies on the
629+
// custom-action route. The QuickJS side-channel carries a body-thrown
630+
// error's declared `status` out of the VM onto `SandboxError.status`,
631+
// and this branch used to answer 400 unconditionally, so a deliberate
632+
// `e.status = 403` was dead on arrival at this door while the actions
633+
// door honoured it — the #7525/#8016 door-disagreement shape, one
634+
// branch earlier. The read is {@link declaredHttpStatus}: the same
635+
// both-spellings 400-599 band as the passthrough below, so a nonsense
636+
// or out-of-band status is not a declaration and the undeclared
637+
// default stays exactly the 400-with-verbatim-message the dogfood
638+
// pins (`hook-error-format.dogfood.test.ts`) require.
639+
const declared = declaredHttpStatus(error);
640+
if (declared === undefined || declared < 500) {
641+
return {
642+
status: declared ?? 400,
643+
body: {
644+
error: error.innerMessage,
645+
...(object ? { object } : {}),
646+
},
647+
};
648+
}
649+
// A declared SERVER-band status is not a business refusal addressed to
650+
// the caller in its own words — it is a producer-declared server
651+
// fault, and this file already has exactly one arm for that: the
652+
// declared-status passthrough below, whose 5xx half keeps the status
653+
// and withholds the prose unconditionally (#5582). Fall through to it
654+
// rather than duplicating the arm here — one condition, one wire
655+
// answer. (The structured `code` branches in between keep outranking
656+
// the passthrough for this producer exactly as they do for every
657+
// other — the #7525 §5 pins.)
632658
}
633659
// [#3770] Object does not exist — thrown by the protocol's registry gate
634660
// (`assertObjectRegistered`, which covers every data entry point) and by
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#9967] A sandboxed hook body that DECLARES its own HTTP status is served
4+
// with it on `/api/v1/data` — the sandbox unwrap no longer outranks the
5+
// declared-status read.
6+
//
7+
// ---------------------------------------------------------------------------
8+
// The asymmetry this file closes: since #7867 the QuickJS side-channel carries
9+
// a body-thrown error's declared `status` out of the VM onto
10+
// `SandboxError.status`, and `domains/actions.ts` honours it ("an error that
11+
// NAMES its own HTTP status is asking to be served with it"). On the CRUD data
12+
// routes the sandbox-unwrap branch of `mapDataError` sat ABOVE the
13+
// `declaredHttpStatus` passthrough and answered `{ status: 400 }`
14+
// unconditionally, so a hook body's deliberate
15+
//
16+
// var e = new Error('close-period lock'); e.status = 403; throw e;
17+
//
18+
// crossed the VM fine and was then answered 400 — a permission refusal
19+
// presented as a client-input error, the #7525/#8016 door-disagreement shape
20+
// one branch earlier.
21+
//
22+
// What is deliberately UNCHANGED, pinned in §3 here and (independently) by
23+
// `hook-error-format.dogfood.test.ts` and
24+
// `rest-hook-refusal-status-passthrough.test.ts` §3:
25+
// - a body throw that declares NO status keeps the verbatim-message 400;
26+
// - a body that CRASHES (`isScriptFaultMessage`) stays the sanitised 500 —
27+
// even when the crash object carries a stray `status`;
28+
// - the envelope still carries NO `code` field (old @objectstack/client
29+
// builds prepend `code` to the human-readable message).
30+
//
31+
// Reverse verification (measured against this branch with ONLY
32+
// `error-response.ts` reverted to the pre-fix `origin/main` copy — the fix
33+
// committed first, the revert via `git checkout origin/main -- <path>`, the
34+
// restore via `git checkout <branch> -- <path>`): predicted §1 + §2 + §4 red,
35+
// §3 green by construction. The measured result is recorded in the PR body
36+
// rather than here so a wrong prediction cannot be rewritten to fit.
37+
// ---------------------------------------------------------------------------
38+
39+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
40+
import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
41+
import { mapDataError, RestServer } from './rest-server.js';
42+
43+
const DATA_ITEM = '/api/v1/data/:object/:id';
44+
45+
// ---------------------------------------------------------------------------
46+
// Fixtures — the shape `quickjs-runner.ts` actually produces: `.message` is
47+
// the `<kind> '<name>' threw: <msg>` debug wrapper, `.innerMessage` the
48+
// business text, `.status` the #7867 side-channel value. Reproduced here so
49+
// `@objectstack/rest` does not depend on `@objectstack/runtime` to run its
50+
// own tests.
51+
// ---------------------------------------------------------------------------
52+
53+
/** The issue's own repro: a body refusal that NAMES its status. */
54+
function sandboxRefusal(overrides: Record<string, unknown> = {}) {
55+
const err: any = new Error("hook 'close_period_guard' threw: Error: close-period lock");
56+
err.name = 'SandboxError';
57+
err.innerMessage = 'close-period lock';
58+
return Object.assign(err, overrides);
59+
}
60+
61+
// ---------------------------------------------------------------------------
62+
// §1 The mapping itself — declared 4xx
63+
// ---------------------------------------------------------------------------
64+
65+
describe('[#9967] mapDataError: a sandboxed body that declares a 4xx status keeps it', () => {
66+
it('the reported shape: `e.status = 403` answers 403, not 400', () => {
67+
const r = mapDataError(sandboxRefusal({ status: 403 }), 'showcase_task');
68+
69+
expect(r.status).toBe(403);
70+
// The measured defect, pinned as a NEGATIVE so a partial fix cannot pass.
71+
expect(r.status).not.toBe(400);
72+
// "Keeping the unwrapped `innerMessage` as the body text": the business
73+
// message verbatim, never the debug wrapper.
74+
expect(r.body.error).toBe('close-period lock');
75+
expect(JSON.stringify(r.body)).not.toMatch(/threw:|hook '/);
76+
expect(r.body.object).toBe('showcase_task');
77+
});
78+
79+
it('the body is byte-identical to the undeclared 400 envelope — only the status moves', () => {
80+
// The unwrap branch's own contract (deliberately NO `code`, `object`
81+
// rides) is unchanged by the fix; compared output-to-output so a field
82+
// later added to BOTH envelopes (e.g. #9934's marking) keeps this green.
83+
const declared = mapDataError(sandboxRefusal({ status: 403 }), 'showcase_task');
84+
const undeclared = mapDataError(sandboxRefusal(), 'showcase_task');
85+
86+
expect(declared.body).toEqual(undeclared.body);
87+
expect(declared.body.code).toBeUndefined();
88+
});
89+
90+
it('the whole client band is served, off either spelling — same read as every other exit', () => {
91+
for (const status of [401, 403, 404, 409, 423, 451]) {
92+
expect(mapDataError(sandboxRefusal({ status }), 'showcase_task').status).toBe(status);
93+
expect(mapDataError(sandboxRefusal({ statusCode: status }), 'showcase_task').status).toBe(status);
94+
}
95+
});
96+
97+
it('a numeric `status` wins over `statusCode` — precedence matches the passthrough', () => {
98+
const r = mapDataError(sandboxRefusal({ status: 409, statusCode: 403 }), 'showcase_task');
99+
expect(r.status).toBe(409);
100+
});
101+
102+
it('an out-of-band status is not a declaration — the 400 default holds', () => {
103+
for (const status of [0, 200, 302, 399, 600, 999]) {
104+
const r = mapDataError(sandboxRefusal({ status }), 'showcase_task');
105+
expect(r.status).toBe(400);
106+
expect(r.body.error).toBe('close-period lock');
107+
}
108+
});
109+
});
110+
111+
// ---------------------------------------------------------------------------
112+
// §2 Declared SERVER band — status kept, prose withheld (#5582's rule applies
113+
// to this producer exactly as to every other)
114+
// ---------------------------------------------------------------------------
115+
116+
describe('[#9967] a sandboxed body that declares a 5xx takes the sanitised 5xx arm', () => {
117+
it('`e.status = 503` keeps the 503 and withholds the words', () => {
118+
const r = mapDataError(sandboxRefusal({ status: 503 }), 'showcase_task');
119+
120+
expect(r.status).toBe(503);
121+
expect(r.body.error).toBe(INTERNAL_ERROR_MESSAGE);
122+
expect(JSON.stringify(r.body)).not.toContain('close-period lock');
123+
// No code was declared; none is invented (ADR-0112: the producer names
124+
// the condition).
125+
expect(r.body.code).toBeUndefined();
126+
});
127+
128+
it('a declared 5xx WITH a registered code ships both — same answer the passthrough gives', () => {
129+
const r = mapDataError(
130+
sandboxRefusal({ status: 503, code: 'SERVICE_UNAVAILABLE' }),
131+
'showcase_task',
132+
);
133+
expect(r.status).toBe(503);
134+
expect(r.body.code).toBe('SERVICE_UNAVAILABLE');
135+
expect(r.body.error).toBe(INTERNAL_ERROR_MESSAGE);
136+
});
137+
});
138+
139+
// ---------------------------------------------------------------------------
140+
// §3 What deliberately did NOT move — green by construction under the fix's
141+
// reverse verification
142+
// ---------------------------------------------------------------------------
143+
144+
describe('[#9967] the pinned defaults are untouched', () => {
145+
it('an UNDECLARED body throw keeps the verbatim-message 400 with no code', () => {
146+
const r = mapDataError(sandboxRefusal(), 'showcase_task');
147+
148+
expect(r.status).toBe(400);
149+
expect(r.body.error).toBe('close-period lock');
150+
expect(r.body.code).toBeUndefined();
151+
});
152+
153+
it('a body CRASH stays the sanitised 500 even when it carries a stray `status`', () => {
154+
// Crash classification outranks the declaration: a `TypeError` that
155+
// happens to have a `status` property is still a script fault, never
156+
// an author-declared refusal.
157+
const err = sandboxRefusal({ status: 403 });
158+
err.innerMessage = 'TypeError: boom';
159+
const r = mapDataError(err, 'showcase_task');
160+
161+
expect(r.status).toBe(500);
162+
expect(r.body.code).toBe('INTERNAL_ERROR');
163+
expect(JSON.stringify(r.body)).not.toMatch(/TypeError|boom/);
164+
});
165+
});
166+
167+
// ---------------------------------------------------------------------------
168+
// §4 The wire — the reported request walked on the real CRUD data route
169+
// (mirrors `rest-hook-refusal-status-passthrough.test.ts` §2's harness)
170+
// ---------------------------------------------------------------------------
171+
172+
function createMockServer() {
173+
return {
174+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(),
175+
listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
176+
};
177+
}
178+
179+
function makeRes() {
180+
const res: any = { statusCode: 200, body: undefined };
181+
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
182+
res.json = vi.fn((b: any) => { res.body = b; return res; });
183+
res.header = vi.fn(() => res);
184+
res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn();
185+
return res;
186+
}
187+
188+
function setup(protocolOverrides: Record<string, unknown> = {}) {
189+
const protocol: any = {
190+
getDiscovery: vi.fn().mockResolvedValue({
191+
version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' },
192+
}),
193+
getMetaTypes: vi.fn().mockResolvedValue([]),
194+
getMetaItems: vi.fn().mockResolvedValue([{ name: 'showcase_task' }]),
195+
getMetaItem: vi.fn().mockResolvedValue({}),
196+
findData: vi.fn().mockResolvedValue([]),
197+
createData: vi.fn().mockResolvedValue({}),
198+
updateData: vi.fn().mockResolvedValue({}),
199+
...protocolOverrides,
200+
};
201+
const rest = new RestServer(
202+
createMockServer() as any,
203+
protocol,
204+
{ api: { requireAuth: false } } as any,
205+
);
206+
(rest as any).resolveExecCtx = async () => ({ userId: 'u1' });
207+
rest.registerRoutes();
208+
return rest;
209+
}
210+
211+
async function callPatch(rest: any, object: string, id: string, body: Record<string, unknown>) {
212+
const route = rest.getRoutes().find((r: any) => r.method === 'PATCH' && r.path === DATA_ITEM);
213+
if (!route) throw new Error('PATCH data route not registered');
214+
const res = makeRes();
215+
await route.handler({ method: 'PATCH', params: { object, id }, query: {}, headers: {}, body }, res);
216+
return res;
217+
}
218+
219+
let errorSpy: ReturnType<typeof vi.spyOn>;
220+
beforeEach(() => { errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
221+
afterEach(() => { errorSpy.mockRestore(); });
222+
223+
describe('[#9967] the reported request on the real data route', () => {
224+
it('PATCH refused by a status-declaring body → 403 with the business message', async () => {
225+
const rest = setup({ updateData: vi.fn().mockRejectedValue(sandboxRefusal({ status: 403 })) });
226+
227+
const res = await callPatch(rest, 'showcase_task', 'rec1', { name: 'edited' });
228+
229+
expect(res.statusCode).toBe(403);
230+
expect(res.body.error).toBe('close-period lock');
231+
expect(res.body.code).toBeUndefined();
232+
}, 60_000);
233+
234+
it('the declared refusal is not logged as an unhandled fault — 403 is an expected outcome', async () => {
235+
const rest = setup({ updateData: vi.fn().mockRejectedValue(sandboxRefusal({ status: 403 })) });
236+
237+
await callPatch(rest, 'showcase_task', 'rec1', { name: 'edited' });
238+
239+
const logged = errorSpy.mock.calls.some(
240+
(call: unknown[]) => JSON.stringify(call.map(String)).includes('Unhandled error'),
241+
);
242+
expect(logged).toBe(false);
243+
}, 60_000);
244+
245+
it('an UNDECLARED body refusal on the wire is still the verbatim 400', async () => {
246+
const rest = setup({ updateData: vi.fn().mockRejectedValue(sandboxRefusal()) });
247+
248+
const res = await callPatch(rest, 'showcase_task', 'rec1', { name: 'edited' });
249+
250+
expect(res.statusCode).toBe(400);
251+
expect(res.body.error).toBe('close-period lock');
252+
}, 60_000);
253+
});

0 commit comments

Comments
 (0)