Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/hook-error-message-format.md
Original file line number Diff line number Diff line change
@@ -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: 制作基地被…` 变为只显示业务消息本身;完整调试包装仍写入服务端日志。
36 changes: 36 additions & 0 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
7 changes: 6 additions & 1 deletion packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
118 changes: 118 additions & 0 deletions packages/dogfood/test/hook-error-format.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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 '/);
});
});
42 changes: 42 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<kind> '<name>' threw: <msg>` 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
Expand Down
43 changes: 43 additions & 0 deletions packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<name>' 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();
});
});

// ---------------------------------------------------------------------------
Expand Down