diff --git a/.changeset/hook-error-message-format.md b/.changeset/hook-error-message-format.md new file mode 100644 index 0000000000..edbf14f068 --- /dev/null +++ b/.changeset/hook-error-message-format.md @@ -0,0 +1,6 @@ +--- +'@objectstack/rest': patch +'@objectstack/client': patch +--- + +面向最终用户的错误消息去掉调试噪音: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: 制作基地被…` 变为只显示业务消息本身;完整调试包装仍写入服务端日志。 diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index e5b85d8981..4afecfd4e7 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -1143,3 +1143,39 @@ describe('Import-job namespace', () => { expect(res.restored).toBe(2); }); }); + +// --------------------------------------------------------------------------- +// HTTP error shaping — `.message` is shown to end users verbatim (console +// error toast), so it must carry only the server's human-readable message: +// no `[ObjectStack]` branding, no `CODE:` prefix. Codes stay programmatic. +// --------------------------------------------------------------------------- +describe('HTTP error shaping', () => { + it('surfaces the server error message verbatim, with code/status attached programmatically', async () => { + const businessMsg = '制作基地被「项目主计划批次」引用(3 条),删除被阻断,请先解除引用'; + const { client } = createMockClient({ error: businessMsg, code: 'SOME_CODE' }, 400); + let caught: any; + try { + await client.data.delete('pm_base', 'rec_1'); + } catch (e) { + caught = e; + } + expect(caught).toBeDefined(); + expect(caught.message).toBe(businessMsg); + expect(caught.message).not.toMatch(/\[ObjectStack\]|SOME_CODE/); + expect(caught.code).toBe('SOME_CODE'); + expect(caught.httpStatus).toBe(400); + }); + + it('falls back to statusText when the body has no message', async () => { + const { client } = createMockClient({}, 500); + let caught: any; + try { + await client.data.delete('pm_base', 'rec_1'); + } catch (e) { + caught = e; + } + expect(caught).toBeDefined(); + expect(caught.message).toBe('Error'); + expect(caught.httpStatus).toBe(500); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 387a9d344f..f0549fd426 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -3337,7 +3337,12 @@ export class ObjectStackClient { ?? (typeof errorBody?.error === 'string' ? errorBody.error : undefined) ?? res.statusText; const errorCode = errorBody?.code || errorBody?.error?.code; - const error = new Error(`[ObjectStack] ${errorCode ? `${errorCode}: ` : ''}${errorMessage}`) as any; + // `.message` is what UIs (e.g. the console's error toast) show to end + // users verbatim, so keep it to the server's human-readable message — + // no `[ObjectStack]` branding and no `CODE:` prefix. The code stays + // available programmatically via `error.code`, and the full response + // body was already logged above for debugging. + const error = new Error(errorMessage) as any; // Attach error details for programmatic access error.code = errorCode; diff --git a/packages/dogfood/test/hook-error-format.dogfood.test.ts b/packages/dogfood/test/hook-error-format.dogfood.test.ts new file mode 100644 index 0000000000..4365a35e06 --- /dev/null +++ b/packages/dogfood/test/hook-error-format.dogfood.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Sandboxed-hook error message format, end-to-end through the real stack: +// QuickJS sandbox (SandboxError + innerMessage) → ObjectQL triggerHooks → +// REST mapDataError → HTTP error body. +// +// A hook author writing `throw new Error('业务规则说明')` is expressing a +// deliberate business rule (e.g. referential-integrity "记录被引用,删除被 +// 阻断"). The console shows the REST body's `error` string verbatim in its +// toast, so that string must be ONLY the author's message — not the sandbox +// debug wrapper (`hook 'x' threw: Error: …`, which belongs in server logs) +// and not a `code` field an older bundled @objectstack/client would prepend +// as `[ObjectStack] CODE: …`. +// +// Non-default error names (`TypeError: …`) are deliberately KEPT: they mark +// a genuine script bug rather than a thrown business rule. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +const BUSINESS_MSG = '制作基地被「项目主计划批次」引用(3 条),删除被阻断,请先解除引用'; + +const HefBase = ObjectSchema.create({ + name: 'hef_base', + label: '制作基地', + fields: { + name: Field.text({ label: '名称', required: true }), + }, +}); + +const hefStack = defineStack({ + manifest: { + id: 'com.dogfood.hook_error_format', + namespace: 'hef', + version: '0.0.0', + type: 'app', + name: 'Hook Error Format Fixture', + description: 'Sandboxed hooks throwing business-rule and script-bug errors.', + }, + objects: [HefBase], + hooks: [ + { + // Mirrors the real-world referential-integrity guard (`pm_ref_base`) + // that motivated the fix: a deliberate business rule thrown as a + // default `Error`. + name: 'hef_ref_guard', + object: 'hef_base', + events: ['beforeDelete'], + body: { + language: 'js', + source: `throw new Error(${JSON.stringify(BUSINESS_MSG)});`, + capabilities: [], + }, + }, + { + // A non-default error name signals a script bug, not a business rule — + // the name must survive to the client as useful context. + name: 'hef_buggy_guard', + object: 'hef_base', + events: ['beforeUpdate'], + body: { + language: 'js', + source: `throw new TypeError('boom');`, + capabilities: [], + }, + }, + ], +}); + +describe('objectstack verify: sandboxed hook error message format (#hef)', () => { + let stack: VerifyStack; + let token: string; + let baseId: string; + + beforeAll(async () => { + stack = await bootStack(hefStack); + token = await stack.signIn(); + + const created = await stack.apiAs(token, 'POST', '/data/hef_base', { name: '华东制作基地' }); + expect(created.status, `create: ${created.status} ${await created.clone().text()}`).toBeLessThan(300); + const body = (await created.json()) as any; + baseId = body.record?.id ?? body.id; + expect(baseId).toBeTruthy(); + }, 60_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('DELETE blocked by a sandboxed hook returns ONLY the business message', async () => { + const r = await stack.apiAs(token, 'DELETE', `/data/hef_base/${baseId}`); + expect(r.status).toBe(400); + + const body = (await r.json()) as any; + // The console toast renders this string verbatim — it must be exactly + // what the hook author threw. + expect(body.error).toBe(BUSINESS_MSG); + // No sandbox debug wrapper, no branding, no code for old clients to prepend. + expect(JSON.stringify(body)).not.toMatch(/threw:|hook '|\[ObjectStack\]/); + expect(body.code).toBeUndefined(); + }); + + it('ground truth: the blocked delete did not remove the record', async () => { + const r = await stack.apiAs(token, 'GET', `/data/hef_base/${baseId}`); + expect(r.status).toBe(200); + }); + + it('non-default error names (TypeError) survive as script-bug context', async () => { + const r = await stack.apiAs(token, 'PATCH', `/data/hef_base/${baseId}`, { name: '改名' }); + expect(r.status).toBe(400); + + const body = (await r.json()) as any; + expect(body.error).toBe('TypeError: boom'); + expect(JSON.stringify(body)).not.toMatch(/threw:|hook '/); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 73a08c6ce0..b2f5fc33d7 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -121,9 +121,51 @@ export function mapDataError(error: any, object?: string): { status: number; bod }, }; } + // Sandboxed hook/action bodies (QuickJS) throw SandboxError whose + // `.message` carries a ` '' threw: ` debug wrapper for + // server logs, with the original business message preserved on + // `.innerMessage` (see runtime/src/sandbox/quickjs-runner.ts). End users + // must see only the business message — a hook's `throw new Error('删除被 + // 阻断…')` is a deliberate business rule, not a fault — the same unwrap + // the custom-action route performs in http-dispatcher's handleAction. + // The full wrapper still reaches server logs via the callers' + // "[REST] Unhandled error" logging and the BodyRunner's own error log. + // Deliberately NO `code` field: older @objectstack/client builds (still + // bundled in deployed consoles) prepend any `code` to the human-readable + // message, which would reintroduce the English noise this branch removes. + if (typeof error?.innerMessage === 'string' && error.innerMessage) { + return { + status: 400, + body: { + error: error.innerMessage, + ...(object ? { object } : {}), + }, + }; + } + const raw = String(error?.message ?? error ?? ''); const lower = raw.toLowerCase(); + // Fallback for the same sandbox wrapper when the SandboxError instance + // (and its `innerMessage`) was lost crossing a rethrow/serialization + // boundary: strip the debug wrapper from the raw message. A leading + // default `Error: ` name is dropped; non-default names (`TypeError: …`) + // are kept — they signal a genuine script bug rather than a deliberately + // thrown business rule, which is useful context. + const sandboxWrapper = /^(?:hook|action) '[^']*' threw:\s*(.+)$/s.exec(raw); + if (sandboxWrapper) { + const msg = sandboxWrapper[1].startsWith('Error: ') + ? sandboxWrapper[1].slice('Error: '.length) + : sandboxWrapper[1]; + return { + status: 400, + body: { + error: msg, + ...(object ? { object } : {}), + }, + }; + } + // EnvironmentKernelFactory: project missing database_url/driver — typically // means provisioning is in flight or the project record was never // fully provisioned. 503 (with Retry-After implied) is more accurate diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index cc50af992b..3579f2655a 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -2193,6 +2193,49 @@ describe('mapDataError — schema/constraint envelopes', () => { expect(r.status).toBe(409); expect(r.body.code).toBe('UNIQUE_VIOLATION'); }); + + // A sandboxed hook deliberately throwing a business rule (e.g. a + // referential-integrity guard blocking a delete) must surface only the + // business message to the client — never the sandbox's + // `hook '' threw: Error: …` debug wrapper, which reads as English + // noise in the console's error toast. The wrapper stays in server logs. + it('unwraps SandboxError.innerMessage → 400 with only the business message', () => { + const err = Object.assign( + new Error("hook 'pm_ref_base' threw: Error: 制作基地被「项目主计划批次」引用(3 条),删除被阻断,请先解除引用"), + { name: 'SandboxError', innerMessage: '制作基地被「项目主计划批次」引用(3 条),删除被阻断,请先解除引用' }, + ); + const r = mapDataError(err, 'pm_base'); + expect(r.status).toBe(400); + expect(r.body.error).toBe('制作基地被「项目主计划批次」引用(3 条),删除被阻断,请先解除引用'); + expect(r.body.object).toBe('pm_base'); + // No `code`: older bundled clients prepend any code to the message, + // which would reintroduce the English noise this unwrap removes. + expect(r.body.code).toBeUndefined(); + }); + + it('strips the sandbox debug wrapper from the raw message when innerMessage was lost', () => { + const r = mapDataError(new Error("hook 'pm_ref_base' threw: Error: 删除被阻断,请先解除引用"), 'pm_base'); + expect(r.status).toBe(400); + expect(r.body.error).toBe('删除被阻断,请先解除引用'); + expect(r.body.code).toBeUndefined(); + }); + + it('keeps non-default error names when stripping the wrapper (genuine script bugs stay identifiable)', () => { + const r = mapDataError(new Error("hook 'pm_ref_base' threw: TypeError: cannot read properties of undefined"), 'pm_base'); + expect(r.status).toBe(400); + expect(r.body.error).toBe('TypeError: cannot read properties of undefined'); + }); + + it("unwraps an action body's wrapper the same way", () => { + const err = Object.assign(new Error("action 'approve' threw: Error: 线索信息不完整"), { + name: 'SandboxError', + innerMessage: '线索信息不完整', + }); + const r = mapDataError(err); + expect(r.status).toBe(400); + expect(r.body.error).toBe('线索信息不完整'); + expect(r.body.object).toBeUndefined(); + }); }); // ---------------------------------------------------------------------------