Skip to content

Commit e46169c

Browse files
authored
fix(rest,client): show only the business message for sandboxed hook/action errors (#2893)
End users saw the sandbox debug wrapper and client branding in the console error toast: `[ObjectStack] hook 'pm_ref_base' threw: Error: 制作基地被…`. A hook's `throw new Error('…')` is a deliberate business rule — the toast must show only the author's message. - rest: mapDataError now unwraps SandboxError.innerMessage (and falls back to regex-stripping the `hook 'x' threw:` wrapper if the instance was lost), keeping non-default names like `TypeError:` as script-bug context. No `code` field on these branches — older bundled clients prepend it to the human message. - client: error.message is the server's human-readable message verbatim — no `[ObjectStack] CODE:` prefix; code/category/httpStatus stay on the error object programmatically. - dogfood: end-to-end golden test booting a real stack with a sandboxed QuickJS hook, asserting the REST body carries only the business message while the full debug wrapper still reaches server logs.
1 parent 84650c5 commit e46169c

6 files changed

Lines changed: 251 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@objectstack/rest': patch
3+
'@objectstack/client': patch
4+
---
5+
6+
面向最终用户的错误消息去掉调试噪音:REST 数据路由(`mapDataError`)对沙箱 hook/action 抛错解包 `SandboxError.innerMessage`(并对丢失实例的情况正则剥离 `hook 'x' threw: Error: ` 包装,保留 `TypeError:` 等非默认错误名);客户端 SDK 的 `error.message` 不再拼 `[ObjectStack] CODE:` 前缀(code 仍在 `error.code` 上可编程读取)。控制台报错 toast 从 `[ObjectStack] hook 'pm_ref_base' threw: Error: 制作基地被…` 变为只显示业务消息本身;完整调试包装仍写入服务端日志。

packages/client/src/client.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,3 +1143,39 @@ describe('Import-job namespace', () => {
11431143
expect(res.restored).toBe(2);
11441144
});
11451145
});
1146+
1147+
// ---------------------------------------------------------------------------
1148+
// HTTP error shaping — `.message` is shown to end users verbatim (console
1149+
// error toast), so it must carry only the server's human-readable message:
1150+
// no `[ObjectStack]` branding, no `CODE:` prefix. Codes stay programmatic.
1151+
// ---------------------------------------------------------------------------
1152+
describe('HTTP error shaping', () => {
1153+
it('surfaces the server error message verbatim, with code/status attached programmatically', async () => {
1154+
const businessMsg = '制作基地被「项目主计划批次」引用(3 条),删除被阻断,请先解除引用';
1155+
const { client } = createMockClient({ error: businessMsg, code: 'SOME_CODE' }, 400);
1156+
let caught: any;
1157+
try {
1158+
await client.data.delete('pm_base', 'rec_1');
1159+
} catch (e) {
1160+
caught = e;
1161+
}
1162+
expect(caught).toBeDefined();
1163+
expect(caught.message).toBe(businessMsg);
1164+
expect(caught.message).not.toMatch(/\[ObjectStack\]|SOME_CODE/);
1165+
expect(caught.code).toBe('SOME_CODE');
1166+
expect(caught.httpStatus).toBe(400);
1167+
});
1168+
1169+
it('falls back to statusText when the body has no message', async () => {
1170+
const { client } = createMockClient({}, 500);
1171+
let caught: any;
1172+
try {
1173+
await client.data.delete('pm_base', 'rec_1');
1174+
} catch (e) {
1175+
caught = e;
1176+
}
1177+
expect(caught).toBeDefined();
1178+
expect(caught.message).toBe('Error');
1179+
expect(caught.httpStatus).toBe(500);
1180+
});
1181+
});

packages/client/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3337,7 +3337,12 @@ export class ObjectStackClient {
33373337
?? (typeof errorBody?.error === 'string' ? errorBody.error : undefined)
33383338
?? res.statusText;
33393339
const errorCode = errorBody?.code || errorBody?.error?.code;
3340-
const error = new Error(`[ObjectStack] ${errorCode ? `${errorCode}: ` : ''}${errorMessage}`) as any;
3340+
// `.message` is what UIs (e.g. the console's error toast) show to end
3341+
// users verbatim, so keep it to the server's human-readable message —
3342+
// no `[ObjectStack]` branding and no `CODE:` prefix. The code stays
3343+
// available programmatically via `error.code`, and the full response
3344+
// body was already logged above for debugging.
3345+
const error = new Error(errorMessage) as any;
33413346

33423347
// Attach error details for programmatic access
33433348
error.code = errorCode;
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Sandboxed-hook error message format, end-to-end through the real stack:
4+
// QuickJS sandbox (SandboxError + innerMessage) → ObjectQL triggerHooks →
5+
// REST mapDataError → HTTP error body.
6+
//
7+
// A hook author writing `throw new Error('业务规则说明')` is expressing a
8+
// deliberate business rule (e.g. referential-integrity "记录被引用,删除被
9+
// 阻断"). The console shows the REST body's `error` string verbatim in its
10+
// toast, so that string must be ONLY the author's message — not the sandbox
11+
// debug wrapper (`hook 'x' threw: Error: …`, which belongs in server logs)
12+
// and not a `code` field an older bundled @objectstack/client would prepend
13+
// as `[ObjectStack] CODE: …`.
14+
//
15+
// Non-default error names (`TypeError: …`) are deliberately KEPT: they mark
16+
// a genuine script bug rather than a thrown business rule.
17+
18+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
19+
import { bootStack, type VerifyStack } from '@objectstack/verify';
20+
import { defineStack } from '@objectstack/spec';
21+
import { ObjectSchema, Field } from '@objectstack/spec/data';
22+
23+
const BUSINESS_MSG = '制作基地被「项目主计划批次」引用(3 条),删除被阻断,请先解除引用';
24+
25+
const HefBase = ObjectSchema.create({
26+
name: 'hef_base',
27+
label: '制作基地',
28+
fields: {
29+
name: Field.text({ label: '名称', required: true }),
30+
},
31+
});
32+
33+
const hefStack = defineStack({
34+
manifest: {
35+
id: 'com.dogfood.hook_error_format',
36+
namespace: 'hef',
37+
version: '0.0.0',
38+
type: 'app',
39+
name: 'Hook Error Format Fixture',
40+
description: 'Sandboxed hooks throwing business-rule and script-bug errors.',
41+
},
42+
objects: [HefBase],
43+
hooks: [
44+
{
45+
// Mirrors the real-world referential-integrity guard (`pm_ref_base`)
46+
// that motivated the fix: a deliberate business rule thrown as a
47+
// default `Error`.
48+
name: 'hef_ref_guard',
49+
object: 'hef_base',
50+
events: ['beforeDelete'],
51+
body: {
52+
language: 'js',
53+
source: `throw new Error(${JSON.stringify(BUSINESS_MSG)});`,
54+
capabilities: [],
55+
},
56+
},
57+
{
58+
// A non-default error name signals a script bug, not a business rule —
59+
// the name must survive to the client as useful context.
60+
name: 'hef_buggy_guard',
61+
object: 'hef_base',
62+
events: ['beforeUpdate'],
63+
body: {
64+
language: 'js',
65+
source: `throw new TypeError('boom');`,
66+
capabilities: [],
67+
},
68+
},
69+
],
70+
});
71+
72+
describe('objectstack verify: sandboxed hook error message format (#hef)', () => {
73+
let stack: VerifyStack;
74+
let token: string;
75+
let baseId: string;
76+
77+
beforeAll(async () => {
78+
stack = await bootStack(hefStack);
79+
token = await stack.signIn();
80+
81+
const created = await stack.apiAs(token, 'POST', '/data/hef_base', { name: '华东制作基地' });
82+
expect(created.status, `create: ${created.status} ${await created.clone().text()}`).toBeLessThan(300);
83+
const body = (await created.json()) as any;
84+
baseId = body.record?.id ?? body.id;
85+
expect(baseId).toBeTruthy();
86+
}, 60_000);
87+
88+
afterAll(async () => {
89+
await stack?.stop();
90+
});
91+
92+
it('DELETE blocked by a sandboxed hook returns ONLY the business message', async () => {
93+
const r = await stack.apiAs(token, 'DELETE', `/data/hef_base/${baseId}`);
94+
expect(r.status).toBe(400);
95+
96+
const body = (await r.json()) as any;
97+
// The console toast renders this string verbatim — it must be exactly
98+
// what the hook author threw.
99+
expect(body.error).toBe(BUSINESS_MSG);
100+
// No sandbox debug wrapper, no branding, no code for old clients to prepend.
101+
expect(JSON.stringify(body)).not.toMatch(/threw:|hook '|\[ObjectStack\]/);
102+
expect(body.code).toBeUndefined();
103+
});
104+
105+
it('ground truth: the blocked delete did not remove the record', async () => {
106+
const r = await stack.apiAs(token, 'GET', `/data/hef_base/${baseId}`);
107+
expect(r.status).toBe(200);
108+
});
109+
110+
it('non-default error names (TypeError) survive as script-bug context', async () => {
111+
const r = await stack.apiAs(token, 'PATCH', `/data/hef_base/${baseId}`, { name: '改名' });
112+
expect(r.status).toBe(400);
113+
114+
const body = (await r.json()) as any;
115+
expect(body.error).toBe('TypeError: boom');
116+
expect(JSON.stringify(body)).not.toMatch(/threw:|hook '/);
117+
});
118+
});

packages/rest/src/rest-server.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,51 @@ export function mapDataError(error: any, object?: string): { status: number; bod
121121
},
122122
};
123123
}
124+
// Sandboxed hook/action bodies (QuickJS) throw SandboxError whose
125+
// `.message` carries a `<kind> '<name>' threw: <msg>` debug wrapper for
126+
// server logs, with the original business message preserved on
127+
// `.innerMessage` (see runtime/src/sandbox/quickjs-runner.ts). End users
128+
// must see only the business message — a hook's `throw new Error('删除被
129+
// 阻断…')` is a deliberate business rule, not a fault — the same unwrap
130+
// the custom-action route performs in http-dispatcher's handleAction.
131+
// The full wrapper still reaches server logs via the callers'
132+
// "[REST] Unhandled error" logging and the BodyRunner's own error log.
133+
// Deliberately NO `code` field: older @objectstack/client builds (still
134+
// bundled in deployed consoles) prepend any `code` to the human-readable
135+
// message, which would reintroduce the English noise this branch removes.
136+
if (typeof error?.innerMessage === 'string' && error.innerMessage) {
137+
return {
138+
status: 400,
139+
body: {
140+
error: error.innerMessage,
141+
...(object ? { object } : {}),
142+
},
143+
};
144+
}
145+
124146
const raw = String(error?.message ?? error ?? '');
125147
const lower = raw.toLowerCase();
126148

149+
// Fallback for the same sandbox wrapper when the SandboxError instance
150+
// (and its `innerMessage`) was lost crossing a rethrow/serialization
151+
// boundary: strip the debug wrapper from the raw message. A leading
152+
// default `Error: ` name is dropped; non-default names (`TypeError: …`)
153+
// are kept — they signal a genuine script bug rather than a deliberately
154+
// thrown business rule, which is useful context.
155+
const sandboxWrapper = /^(?:hook|action) '[^']*' threw:\s*(.+)$/s.exec(raw);
156+
if (sandboxWrapper) {
157+
const msg = sandboxWrapper[1].startsWith('Error: ')
158+
? sandboxWrapper[1].slice('Error: '.length)
159+
: sandboxWrapper[1];
160+
return {
161+
status: 400,
162+
body: {
163+
error: msg,
164+
...(object ? { object } : {}),
165+
},
166+
};
167+
}
168+
127169
// EnvironmentKernelFactory: project missing database_url/driver — typically
128170
// means provisioning is in flight or the project record was never
129171
// fully provisioned. 503 (with Retry-After implied) is more accurate

packages/rest/src/rest.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2193,6 +2193,49 @@ describe('mapDataError — schema/constraint envelopes', () => {
21932193
expect(r.status).toBe(409);
21942194
expect(r.body.code).toBe('UNIQUE_VIOLATION');
21952195
});
2196+
2197+
// A sandboxed hook deliberately throwing a business rule (e.g. a
2198+
// referential-integrity guard blocking a delete) must surface only the
2199+
// business message to the client — never the sandbox's
2200+
// `hook '<name>' threw: Error: …` debug wrapper, which reads as English
2201+
// noise in the console's error toast. The wrapper stays in server logs.
2202+
it('unwraps SandboxError.innerMessage → 400 with only the business message', () => {
2203+
const err = Object.assign(
2204+
new Error("hook 'pm_ref_base' threw: Error: 制作基地被「项目主计划批次」引用(3 条),删除被阻断,请先解除引用"),
2205+
{ name: 'SandboxError', innerMessage: '制作基地被「项目主计划批次」引用(3 条),删除被阻断,请先解除引用' },
2206+
);
2207+
const r = mapDataError(err, 'pm_base');
2208+
expect(r.status).toBe(400);
2209+
expect(r.body.error).toBe('制作基地被「项目主计划批次」引用(3 条),删除被阻断,请先解除引用');
2210+
expect(r.body.object).toBe('pm_base');
2211+
// No `code`: older bundled clients prepend any code to the message,
2212+
// which would reintroduce the English noise this unwrap removes.
2213+
expect(r.body.code).toBeUndefined();
2214+
});
2215+
2216+
it('strips the sandbox debug wrapper from the raw message when innerMessage was lost', () => {
2217+
const r = mapDataError(new Error("hook 'pm_ref_base' threw: Error: 删除被阻断,请先解除引用"), 'pm_base');
2218+
expect(r.status).toBe(400);
2219+
expect(r.body.error).toBe('删除被阻断,请先解除引用');
2220+
expect(r.body.code).toBeUndefined();
2221+
});
2222+
2223+
it('keeps non-default error names when stripping the wrapper (genuine script bugs stay identifiable)', () => {
2224+
const r = mapDataError(new Error("hook 'pm_ref_base' threw: TypeError: cannot read properties of undefined"), 'pm_base');
2225+
expect(r.status).toBe(400);
2226+
expect(r.body.error).toBe('TypeError: cannot read properties of undefined');
2227+
});
2228+
2229+
it("unwraps an action body's wrapper the same way", () => {
2230+
const err = Object.assign(new Error("action 'approve' threw: Error: 线索信息不完整"), {
2231+
name: 'SandboxError',
2232+
innerMessage: '线索信息不完整',
2233+
});
2234+
const r = mapDataError(err);
2235+
expect(r.status).toBe(400);
2236+
expect(r.body.error).toBe('线索信息不完整');
2237+
expect(r.body.object).toBeUndefined();
2238+
});
21962239
});
21972240

21982241
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)