diff --git a/.changeset/hook-refusal-user-facing-marking.md b/.changeset/hook-refusal-user-facing-marking.md new file mode 100644 index 0000000000..760f17ddb9 --- /dev/null +++ b/.changeset/hook-refusal-user-facing-marking.md @@ -0,0 +1,50 @@ +--- +"@objectstack/spec": minor +"@objectstack/types": minor +"@objectstack/runtime": patch +"@objectstack/rest": patch +"@objectstack/client": patch +--- + +feat(contract): a hook refusal can mark its message user-facing — `userMessage`, the producer-side opt-in channel (#9934, producer half of objectui#5210) + + + +The console form deliberately discards the server `message` on 403 and +substitutes a generic string — the recorded #3821 fix for platform diagnostics +leaking to end users. That substitution also suppressed every deliberate, +localized refusal an application hook author wrote (11 real hook guards in the +objectui#5210 report), and incentivized misusing 400 for permission refusals. +The maintainer-accepted ruling (2026-08-19, option 1): give the AUTHOR a +producer-side way to mark a refusal message user-facing, once, at the contract +level — status-agnostic, with #3821 preserved by construction for everything +unmarked. + +**The marking**: set `userMessage` (non-empty string) on the thrown error at +throw time. It is a text-carrying field, not a boolean beside `message` — the +mark and the marked text are one value, so no boundary that rewraps or +substitutes `message` can promote platform prose into the marked channel, and +platform/driver code never sets it. + +- `@objectstack/spec`: `ApiErrorSchema.userMessage` and + `EnhancedApiErrorSchema.userMessage` (optional, additive). +- `@objectstack/types`: `declaredUserMessage(error)` — the ONE "is this + marked?" read (non-empty string, nothing invented) — and + `ThrownHttpError.userMessage` on `resolveThrownHttpError`. +- `@objectstack/rest`: `mapDataError` / `resolveErrorResponse` ride a declared + marking onto whatever envelope classification chose (flat body top-level + `userMessage`, truncated at the same #5423 bound as the 4xx message). +- `@objectstack/runtime`: the QuickJS side-channel carries `userMessage` + across the sandbox boundary (both directions, joining `code`/`fields`/ + `status`), and the dispatcher door emits it as a declared sibling in the + nested envelope. +- `@objectstack/client`: the SDK attaches `err.userMessage` from both wire + dialects, so a UI renders it verbatim when present and keeps its generic + substitution when absent. + +The consumer half — the console form rendering a marked message instead of the +generic `form.noPermissionToSave` — is objectui#5210. diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 48403c5243..691acb57bb 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -44,7 +44,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; title?: string; measures: object[]; dimensions: object[] }[]` | ✅ | Available cubes, each as the `CubeMeta` discovery projection — the cube name, its title, and the measures/dimensions a client may name in a query. A bare array: there is no `cubes` wrapper object, and no cube `sql` is published. | @@ -79,7 +79,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ rows: Record[]; fields: object[]; sql?: string }` | ✅ | | @@ -93,7 +93,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sql: string; params: any[] }` | ✅ | | diff --git a/content/docs/references/api/auth.mdx b/content/docs/references/api/auth.mdx index f41ac7b78f..07465d365c 100644 --- a/content/docs/references/api/auth.mdx +++ b/content/docs/references/api/auth.mdx @@ -117,7 +117,7 @@ const result = AuthProvider.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ session: object; user: object; token?: string }` | ✅ | | @@ -153,7 +153,7 @@ const result = AuthProvider.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; email: string; emailVerified: boolean; name: string; … }` | ✅ | | diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index f222fc7a23..1a1bdfbaa7 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -119,7 +119,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The created flow definition | @@ -144,7 +144,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; deleted: boolean }` | ✅ | | @@ -187,7 +187,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | Full flow definition | @@ -213,7 +213,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; flowName: string; flowVersion?: integer; status: Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| …>; … }` | ✅ | Full execution log with step details | @@ -241,7 +241,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ flows: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -269,7 +269,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ runs: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -295,7 +295,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; enabled: boolean }` | ✅ | | @@ -325,7 +325,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; output?: any; error?: string; durationMs?: number }` | ✅ | | @@ -351,7 +351,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The updated flow definition | diff --git a/content/docs/references/api/batch.mdx b/content/docs/references/api/batch.mdx index dd0dbf32e0..2c3b3445f2 100644 --- a/content/docs/references/api/batch.mdx +++ b/content/docs/references/api/batch.mdx @@ -55,7 +55,7 @@ const result = BatchConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if operation succeeded | | **success** | `boolean` | ✅ | Whether this record was processed successfully | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | @@ -122,7 +122,7 @@ const result = BatchConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index b75be3005e..8a04e65c8d 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -30,6 +30,7 @@ const result = ApiErrorSchema.parse(data); | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +283 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | | **message** | `string` | ✅ | Readable error message | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -338,7 +339,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | @@ -377,7 +378,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id?: string; success: boolean; errors?: object[]; index?: number; … }[]` | ✅ | Results for each item in the batch | @@ -419,7 +420,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **id** | `string` | ✅ | ID of the deleted record | @@ -471,7 +472,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record[]` | ✅ | Array of matching records | | **pagination** | `{ total?: number; limit?: number; offset?: number; cursor?: string; … }` | ✅ | Pagination info | @@ -487,7 +488,7 @@ const result = ApiErrorSchema.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if processed | | **success** | `boolean` | ✅ | | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }[]` | optional | | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | | | **index** | `number` | optional | Index in original request | | **data** | `any` | optional | Result data (e.g. created record) | @@ -526,7 +527,7 @@ Key-value map of record data | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record` | ✅ | The requested or modified record | diff --git a/content/docs/references/api/errors.mdx b/content/docs/references/api/errors.mdx index 51ef3baaa9..cccfa78b9e 100644 --- a/content/docs/references/api/errors.mdx +++ b/content/docs/references/api/errors.mdx @@ -42,6 +42,7 @@ const result = EnhancedApiErrorSchema.parse(data); | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +43 more>` | ✅ | Machine-readable error code | | **message** | `string` | ✅ | Human-readable error message | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934) — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution (#3821). | | **category** | `Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'server' \| 'external' \| 'maintenance'>` | optional | Error category | | **httpStatus** | `number` | optional | HTTP status code | | **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | @@ -136,7 +137,7 @@ const result = EnhancedApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `false` | ✅ | Always false for error responses | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; message: string; category?: Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| …>; httpStatus?: number; … }` | ✅ | Error details | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; message: string; userMessage?: string; category?: Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| …>; … }` | ✅ | Error details | | **meta** | `{ timestamp?: string; requestId?: string; traceId?: string }` | optional | Response metadata | diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index 6b3ebca831..fc622f44e3 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -57,7 +57,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; estimatedRecords?: integer; createdAt: string }` | ✅ | | @@ -157,7 +157,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; format: Enum<'csv' \| 'json' \| 'jsonl' \| 'xlsx' \| 'parquet'>; totalRecords?: integer; … }` | ✅ | | @@ -231,7 +231,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; downloadUrl: string; fileName: string; fileSize: integer; … }` | ✅ | | @@ -449,7 +449,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ totalRecords: integer; validRecords: integer; invalidRecords: integer; duplicateRecords: integer; … }` | ✅ | | @@ -488,7 +488,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobs: object[]; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -546,7 +546,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; name: string; enabled: boolean; nextRunAt?: string; … }` | ✅ | | diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 809b87b9f8..afc7c119c3 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -51,7 +51,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }` | ✅ | Full App Configuration | @@ -65,7 +65,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; icon?: string; description?: string }[]` | ✅ | List of available concepts (Objects, Apps, Flows) | @@ -92,7 +92,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ total: integer; succeeded: integer; failed: integer; errors?: object[] }` | ✅ | Bulk operation result | @@ -117,7 +117,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; name: string }` | ✅ | | @@ -131,7 +131,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sourceType: string; sourceName: string; targetType: string; targetName: string; … }[]` | ✅ | Items this item depends on | @@ -145,7 +145,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sourceType: string; sourceName: string; targetType: string; targetName: string; … }[]` | ✅ | Items that depend on this item | @@ -159,7 +159,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record` | optional | Effective metadata with all overlays applied | @@ -173,7 +173,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ exists: boolean }` | ✅ | | @@ -200,7 +200,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `any` | ✅ | Exported metadata bundle | @@ -228,7 +228,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ total: integer; imported: integer; skipped: integer; failed: integer; … }` | ✅ | Import result | @@ -242,7 +242,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; name: string; definition: Record }` | ✅ | Metadata item | @@ -256,7 +256,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record[]` | ✅ | Array of metadata definitions | @@ -270,7 +270,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `string[]` | ✅ | Array of metadata item names | @@ -284,7 +284,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; baseType: string; baseName: string; packageId?: string; … }` | optional | Overlay definition, undefined if none | @@ -348,7 +348,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ items: object[]; total: integer; page: integer; pageSize: integer }` | ✅ | Paginated query result | @@ -406,7 +406,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; label: string; description?: string; filePatterns: string[]; … }` | optional | Type info | @@ -420,7 +420,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `string[]` | ✅ | Registered metadata type identifiers | @@ -446,7 +446,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ valid: boolean; errors?: object[]; warnings?: object[] }` | ✅ | Validation result | @@ -460,7 +460,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }` | ✅ | Full Object Schema | diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index 28e3e571d6..cf977b7923 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -57,7 +57,7 @@ Get installed package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … }` | ✅ | Installed package details | @@ -89,7 +89,7 @@ List installed packages response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ packages: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -143,7 +143,7 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ package: object; dependencyResolution?: object; namespaceConflicts?: object[]; message?: string }` | ✅ | | @@ -185,7 +185,7 @@ Rollback package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; restoredVersion?: string; message?: string }` | ✅ | | @@ -220,7 +220,7 @@ Upgrade package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; phase: string; plan?: object; snapshotId?: string; … }` | ✅ | | @@ -250,7 +250,7 @@ Resolve dependencies response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ dependencies: object[]; canProceed: boolean; requiredActions: object[]; installOrder: string[]; … }` | ✅ | Dependency resolution result with topological sort | @@ -277,7 +277,7 @@ Uninstall package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ packageId: string; success: boolean; message?: string }` | ✅ | | @@ -309,7 +309,7 @@ Upload artifact response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; artifactRef?: object; submissionId?: string; message?: string }` | ✅ | | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 5474e5faa0..7c0a138f09 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -280,7 +280,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | @@ -428,7 +428,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | @@ -1571,7 +1571,7 @@ Uninstall package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | diff --git a/content/docs/references/api/storage.mdx b/content/docs/references/api/storage.mdx index a33eec3a8f..050269d40b 100644 --- a/content/docs/references/api/storage.mdx +++ b/content/docs/references/api/storage.mdx @@ -46,7 +46,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ fileId: string; key: string; size: integer; mimeType: string; … }` | ✅ | | @@ -72,7 +72,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ url: string }` | ✅ | | @@ -101,7 +101,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ path: string; name: string; size: integer; mimeType: string; … }` | ✅ | Uploaded file metadata | @@ -147,7 +147,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadId: string; resumeToken: string; fileId: string; totalChunks: integer; … }` | ✅ | | @@ -161,7 +161,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadUrl: string; downloadUrl?: string; fileId: string; method: Enum<'PUT' \| 'POST'>; … }` | ✅ | | @@ -175,7 +175,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ key: string }` | ✅ | | @@ -202,7 +202,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ chunkIndex: integer; eTag: string; bytesReceived: integer }` | ✅ | | @@ -216,7 +216,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; category?: string; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadId: string; fileId: string; filename: string; totalSize: integer; … }` | ✅ | | diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index e344a14363..e5692a9a6e 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -23,6 +23,67 @@ function createMockClient(body: any, status = 200) { return { client, fetchMock }; } +// [#9934] The producer-marked user-facing refusal text (`userMessage`) — the +// SDK surfaces it from BOTH live envelopes' declared spots, the same +// two-dialect rule as `code`/`fields`, so the console can render a marked hook +// refusal and keep its generic #3821 substitution for everything unmarked. +describe('[#9934] err.userMessage — the user-facing marking, both dialects', () => { + const USER_TEXT = '该记录已进入结账期,暂不能修改。'; + + it('reads the FLAT envelope (top-level userMessage)', async () => { + const { client } = createMockClient({ + error: 'close-period guard refused the write', + userMessage: USER_TEXT, + object: 'ufm_task', + }, 403); + + const err: any = await client.data.update('ufm_task', 'rec1', { name: 'x' }) + .then(() => { throw new Error('expected the update to reject'); }, (e) => e); + + expect(err.httpStatus).toBe(403); + expect(err.userMessage).toBe(USER_TEXT); + expect(err.message).toBe('close-period guard refused the write'); + }); + + it('reads the WRAPPED envelope (error.userMessage)', async () => { + const { client } = createMockClient({ + success: false, + error: { + code: 'PERMISSION_DENIED', + message: 'close-period guard refused the write', + httpStatus: 403, + userMessage: USER_TEXT, + }, + }, 403); + + const err: any = await client.data.update('ufm_task', 'rec1', { name: 'x' }) + .then(() => { throw new Error('expected the update to reject'); }, (e) => e); + + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.userMessage).toBe(USER_TEXT); + }); + + it('an unmarked refusal attaches NO userMessage — nothing is promoted from message', async () => { + const { client } = createMockClient({ + error: 'close-period guard refused the write', + code: 'PERMISSION_DENIED', + }, 403); + + const err: any = await client.data.update('ufm_task', 'rec1', { name: 'x' }) + .then(() => { throw new Error('expected the update to reject'); }, (e) => e); + + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.userMessage).toBeUndefined(); + }); + + it('a blank or non-string marking is ignored', async () => { + const { client } = createMockClient({ error: 'refused', userMessage: ' ' }, 403); + const err: any = await client.data.update('ufm_task', 'rec1', { name: 'x' }) + .then(() => { throw new Error('expected the update to reject'); }, (e) => e); + expect(err.userMessage).toBeUndefined(); + }); +}); + describe('ObjectStackClient', () => { it('should initialize with correct configuration', () => { const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000' }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 6c28d59434..06a8982739 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -4990,7 +4990,20 @@ export class ObjectStackClient { // actually carries. error.details = errorBody?.details ?? errorBody?.error?.details ?? errorBody; if (fieldErrors) error.fields = fieldErrors; - + // [#9934] The producer-marked user-facing refusal text + // (`ApiErrorSchema.userMessage`) — read from both live envelopes' + // declared spots, same two-dialect rule as `code`/`fields` above: the + // flat body carries it at the top level, the wrapped one inside + // `error`. Presence means the PRODUCER opted in at throw time; a UI + // renders `err.userMessage` verbatim and keeps its generic + // substitution (#3821) whenever it is absent — never promote + // `err.message` into the marked channel client-side. + const asUserMessage = (v: unknown) => (typeof v === 'string' && v.trim() ? v : undefined); + const userMessage = + asUserMessage(errorBody?.userMessage) + ?? asUserMessage(errorBody?.error?.userMessage); + if (userMessage) error.userMessage = userMessage; + throw error; } diff --git a/packages/qa/dogfood/test/hook-refusal-user-facing-marking.dogfood.test.ts b/packages/qa/dogfood/test/hook-refusal-user-facing-marking.dogfood.test.ts new file mode 100644 index 0000000000..63f1e532ea --- /dev/null +++ b/packages/qa/dogfood/test/hook-refusal-user-facing-marking.dogfood.test.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#9934] The producer-side user-facing marking, end-to-end through the real +// stack — the producer half of the objectui#5210 ruling (maintainer, +// 2026-08-19, option 1: producer-side opt-in). +// +// The problem the marking solves: the console form deliberately discards the +// server `message` on 403 and substitutes a generic string — the recorded +// #3821 fix for platform diagnostics leaking to end users — so a hook GUARD's +// deliberate, localized refusal never reached users, and authors were +// incentivized to misuse 400. The contract now gives the author a way to say, +// AT THROW TIME, "this exact text is addressed to the end user": set +// `userMessage` on the thrown error. +// +// This file pins the card's executable criterion, producer side, through the +// real runtime hook path (real kernel, real hook dispatch, real REST door): +// +// • a hook refusal WITH the marking carries its EXACT text to the wire error +// payload (`body.userMessage`), at the status the hook declared; +// • the SAME throw WITHOUT the marking carries no user-facing marking at all +// — which is what preserves #3821 by construction (unmarked stays generic +// at the console). +// +// The console render half — showing `userMessage` instead of the generic +// `form.noPermissionToSave` — is objectui#5210's card, keyed on these bodies. + +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 USER_TEXT = '该任务已进入月末结账期,暂不能修改;请联系财务主管解锁。'; +const BODY_USER_TEXT = '该任务被 3 条子任务引用,删除被阻断;请先处理子任务。'; + +const UfmTask = ObjectSchema.create({ + name: 'ufm_task', + label: '结账任务', + fields: { + name: Field.text({ label: '名称', required: true }), + }, +}); + +const UfmPlain = ObjectSchema.create({ + name: 'ufm_plain', + label: '普通任务', + fields: { + name: Field.text({ label: '名称', required: true }), + }, +}); + +const ufmStack = defineStack({ + manifest: { + id: 'com.dogfood.user_facing_marking', + namespace: 'ufm', + version: '0.0.0', + type: 'app', + name: 'User-Facing Refusal Marking Fixture', + description: 'Hooks refusing writes with and without the #9934 user-facing marking.', + }, + objects: [UfmTask, UfmPlain], + hooks: [ + { + // The ruling's example shape: a beforeUpdate guard refusing with a 403. + // MARKED — the author opts the message in for end users at throw time. + name: 'ufm_close_period_guard', + object: 'ufm_task', + events: ['beforeUpdate'], + handler: async () => { + throw Object.assign(new Error('close-period guard refused the write'), { + statusCode: 403, + userMessage: USER_TEXT, + }); + }, + }, + { + // The SAME throw, UNMARKED — the #3821-preservation half of the pin. + name: 'ufm_plain_guard', + object: 'ufm_plain', + events: ['beforeUpdate'], + handler: async () => { + throw Object.assign(new Error('close-period guard refused the write'), { + statusCode: 403, + }); + }, + }, + { + // A sandboxed L2 BODY hook — the metadata-app authoring surface the + // marking exists for. Its marking must survive the QuickJS boundary + // (the #9934 side-channel) and ride the sandbox-unwrap envelope. + name: 'ufm_ref_guard', + object: 'ufm_task', + events: ['beforeDelete'], + body: { + language: 'js', + source: `var e = new Error('删除被阻断:存在未完成的子任务'); + e.userMessage = ${JSON.stringify(BODY_USER_TEXT)}; + throw e;`, + capabilities: [], + }, + }, + ], +}); + +describe('objectstack verify: hook refusal user-facing marking (#9934)', () => { + let stack: VerifyStack; + let token: string; + let taskId: string; + let plainId: string; + + beforeAll(async () => { + stack = await bootStack(ufmStack); + token = await stack.signIn(); + + const t = await stack.apiAs(token, 'POST', '/data/ufm_task', { name: '十二月对账' }); + expect(t.status, `create: ${t.status} ${await t.clone().text()}`).toBeLessThan(300); + const tBody = (await t.json()) as any; + taskId = tBody.record?.id ?? tBody.id; + expect(taskId).toBeTruthy(); + + const p = await stack.apiAs(token, 'POST', '/data/ufm_plain', { name: '普通记录' }); + expect(p.status).toBeLessThan(300); + const pBody = (await p.json()) as any; + plainId = pBody.record?.id ?? pBody.id; + expect(plainId).toBeTruthy(); + }, 60_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('MARKED: the 403 refusal carries the exact author text in `userMessage`', async () => { + const r = await stack.apiAs(token, 'PATCH', `/data/ufm_task/${taskId}`, { name: '改名' }); + expect(r.status).toBe(403); + + const body = (await r.json()) as any; + // The executable criterion, first half: exact text, on the wire payload. + expect(body.userMessage).toBe(USER_TEXT); + // The diagnostic channel is untouched — the marking never replaces it. + expect(body.error).toBe('close-period guard refused the write'); + }); + + it('UNMARKED: the same throw carries NO user-facing marking — #3821 preserved', async () => { + const r = await stack.apiAs(token, 'PATCH', `/data/ufm_plain/${plainId}`, { name: '改名' }); + expect(r.status).toBe(403); + + const body = (await r.json()) as any; + // Second half: nothing marks this message user-facing, so the console + // keeps its generic substitution for it. + expect('userMessage' in body).toBe(false); + }); + + it('a sandboxed BODY hook keeps its marking across the QuickJS boundary', async () => { + const r = await stack.apiAs(token, 'DELETE', `/data/ufm_task/${taskId}`); + // The sandbox-unwrap envelope: a body's deliberate throw answers 400 with + // the business message verbatim (see hook-error-format.dogfood.test.ts); + // the marking rides that envelope — status-agnostic by design. + expect(r.status).toBe(400); + + const body = (await r.json()) as any; + expect(body.error).toBe('删除被阻断:存在未完成的子任务'); + expect(body.userMessage).toBe(BODY_USER_TEXT); + }); + + it('ground truth: both refusals actually aborted their writes', async () => { + const r = await stack.apiAs(token, 'GET', `/data/ufm_task/${taskId}`); + expect(r.status).toBe(200); + const body = (await r.json()) as any; + expect((body.record ?? body).name).toBe('十二月对账'); + + const p = await stack.apiAs(token, 'GET', `/data/ufm_plain/${plainId}`); + expect(p.status).toBe(200); + const pBody = (await p.json()) as any; + expect((pBody.record ?? pBody).name).toBe('普通记录'); + }); +}); diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index d0ee49a6d5..18ea2d94c3 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -54,6 +54,7 @@ import { declaresServerFault, resolveThrownHttpError, demotedDeclaredCode, + declaredUserMessage, INTERNAL_ERROR_MESSAGE, } from '@objectstack/types'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; @@ -412,8 +413,54 @@ function missingRelationIsObject(raw: string, object: string | undefined): boole * "[Security] Access denied: operation 'insert' on object 'sys_user' is * not permitted …" — trips the `'' … not` substring check and * returns a misleading 404. + * + * [#9934] The exported face is a WRAPPER: classification happens in + * {@link classifyDataError} below (this docblock's subject, byte-for-byte the + * old `mapDataError`), and the wrapper then rides the producer's declared + * `userMessage` onto whatever body classification chose — see + * {@link withDeclaredUserMessage} for the rule and its one deliberate + * asymmetry. */ export function mapDataError(error: any, object?: string): { status: number; body: Record } { + return withDeclaredUserMessage(error, classifyDataError(error, object)); +} + +/** + * [#9934] Carry a producer-marked user-facing refusal text onto a classified + * wire body — the REST door's half of the objectui#5210 ruling (producer-side + * opt-in; the console render half is objectui's). + * + * The rule is deliberately BRANCH-AGNOSTIC: whatever envelope classification + * chose — the structured-code 403s, the declared-status passthrough (the + * measured exit for an engine hook's refusal, #7525), the sandbox unwrap's + * 400, even the sanitised fault terminals — a `userMessage` the producer + * declared on the thrown error reaches the body verbatim (truncated at the + * same {@link CLIENT_MESSAGE_MAX} bound as the 4xx `error` text, by the same + * argument: #5423's truncate-never-replace). One sentence, no branch table, + * status-agnostic by construction, which is what the ruling asked the marking + * to be. + * + * Why riding it across the FAULT terminals is safe rather than a #5437/#7543 + * regression: those disciplines withhold text the producer never addressed to + * the caller — driver prose, a crash's `TypeError: …`. `userMessage` is the + * opposite by construction: it exists on an error only because an author + * deliberately wrote user-facing text onto it (`declaredUserMessage` answers + * `undefined` for everything else — platform and driver code never sets the + * field), so carrying it discloses nothing that was not authored for exactly + * this audience. A genuine crash carries no marking and its envelope is + * byte-identical to before. The one thing the marking never does is move the + * STATUS or the `code` — a marked crash is still the sanitised 500. + */ +function withDeclaredUserMessage( + error: any, + mapped: { status: number; body: Record }, +): { status: number; body: Record } { + const userMessage = declaredUserMessage(error); + if (userMessage === undefined) return mapped; + return { status: mapped.status, body: { ...mapped.body, userMessage: truncateClientMessage(userMessage) } }; +} + +function classifyDataError(error: any, object?: string): { status: number; body: Record } { // Referential-integrity restrict on delete → 409 with the dependent count. // Surfaced FIRST so the structured fields survive the generic catch-alls. if (error?.code === 'DELETE_RESTRICTED') { @@ -1352,14 +1399,19 @@ function resolveErrorResponse(error: any, object?: string): { status: number; bo // {@link thrownCodeFields}. This arm's old gate was bare truthiness, so // it also admitted a non-string `code`; that limb is gone with the // narrowing, and the four flat arms now ask one question. + // [#9934] Both passthrough arms ride a producer-declared `userMessage` + // onto the body, the same rule as the exported `mapDataError` wrapper — + // see {@link withDeclaredUserMessage}. On the 5xx arm the PROSE is + // still withheld (#5437); the marked channel is authored user text, not + // the message being withheld, so carrying it is not a re-opening. if (error.status >= 500) { - return { + return withDeclaredUserMessage(error, { status: error.status, body: { error: INTERNAL_ERROR_MESSAGE, ...thrownCodeFields(error, error.status), }, - }; + }); } // [#5423] 4xx keeps the bound as a TRUNCATION, not a replacement: a 4xx // message is addressed TO the caller and is the remedy. Unchanged by @@ -1368,14 +1420,14 @@ function resolveErrorResponse(error: any, object?: string): { status: number; bo ? 'Request failed' : truncateClientMessage(error.message); // [#9232] Narrowed, same as the three arms above. - return { + return withDeclaredUserMessage(error, { status: error.status, body: { error: safeMsg, ...thrownCodeFields(error, error.status), ...(Array.isArray(error.issues) ? { issues: error.issues } : {}), }, - }; + }); } return mapDataError(error, object); } diff --git a/packages/rest/src/rest-user-facing-refusal-marking.test.ts b/packages/rest/src/rest-user-facing-refusal-marking.test.ts new file mode 100644 index 0000000000..cfd6828658 --- /dev/null +++ b/packages/rest/src/rest-user-facing-refusal-marking.test.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#9934] The producer-side user-facing marking, at the REST door — the +// producer half of the objectui#5210 ruling (maintainer, 2026-08-19, option 1). +// +// A hook refusal that declares `userMessage` on the thrown error carries that +// EXACT text to the wire body's `userMessage` channel; the same throw without +// the marking carries NO user-facing marking at all. Both halves are pinned: +// the second half is what preserves #3821 by construction — the console's +// generic 403 substitution stays the answer for everything unmarked, so +// platform diagnostics can never reach an end user by default. (The console +// render half — showing a marked message instead of the generic string — is +// objectui#5210's card, not this file's subject.) +// +// The marking is STATUS-AGNOSTIC and BRANCH-AGNOSTIC (`withDeclaredUserMessage` +// in `error-response.ts`): whatever envelope classification chooses, a declared +// `userMessage` rides it, and it never moves the status or the `code`. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; +import { mapDataError, RestServer } from './rest-server.js'; +import { handleRouteError } from './error-response.js'; + +const USER_TEXT = '该记录已进入月末结账期,暂不能修改;请联系财务主管解锁。'; + +/** The authoring shape the ruling targets: a hook guard's deliberate 403. */ +function markedRefusal(status = 403, extra: Record = {}) { + return Object.assign(new Error(`close-period guard refused the write`), { + statusCode: status, + userMessage: USER_TEXT, + ...extra, + }); +} + +function unmarkedRefusal(status = 403) { + return Object.assign(new Error(`close-period guard refused the write`), { + statusCode: status, + }); +} + +// --------------------------------------------------------------------------- +// §1 The executable criterion, on the classification door +// --------------------------------------------------------------------------- + +describe('[#9934] mapDataError: the marking carries the exact text; unmarked carries nothing', () => { + it('WITH the marking: the exact text reaches the wire body', () => { + const r = mapDataError(markedRefusal(), 'showcase_task'); + + expect(r.status).toBe(403); + expect(r.body.userMessage).toBe(USER_TEXT); + // The diagnostic channel is untouched — the marking never replaces it. + expect(r.body.error).toBe('close-period guard refused the write'); + }); + + it('WITHOUT the marking: no user-facing marking on the body at all', () => { + const r = mapDataError(unmarkedRefusal(), 'showcase_task'); + + expect(r.status).toBe(403); + // The load-bearing negative: absence is what lets the console keep the + // #3821 generic substitution for everything unmarked. + expect('userMessage' in r.body).toBe(false); + }); + + it('the marking moves NOTHING else — marked and unmarked bodies differ by exactly one key', () => { + const marked = mapDataError(markedRefusal(), 'showcase_task'); + const unmarked = mapDataError(unmarkedRefusal(), 'showcase_task'); + + expect(marked.status).toBe(unmarked.status); + const { userMessage, ...rest } = marked.body; + expect(userMessage).toBe(USER_TEXT); + expect(rest).toEqual(unmarked.body); + }); + + it('status-agnostic: not a 403 special case', () => { + for (const status of [400, 403, 404, 409, 423, 451]) { + const r = mapDataError(markedRefusal(status), 'showcase_task'); + expect(r.status).toBe(status); + expect(r.body.userMessage).toBe(USER_TEXT); + } + }); + + it('rides the structured-code branches too — a hook throwing the catalog PERMISSION_DENIED', () => { + // The PERMISSION_DENIED branch sits ABOVE the declared-status + // passthrough; a producer that reaches it with a marking keeps it. + const err = Object.assign(new Error('denied by guard'), { + code: 'PERMISSION_DENIED', + userMessage: USER_TEXT, + }); + const r = mapDataError(err, 'showcase_task'); + expect(r.status).toBe(403); + expect(r.body.code).toBe('PERMISSION_DENIED'); + expect(r.body.userMessage).toBe(USER_TEXT); + }); + + it('rides the sandbox unwrap — a body hook refusal keeps its marking at 400', () => { + // The shape `quickjs-runner` produces for a body's deliberate throw: + // `innerMessage` set (business message), plus the #9934 side-channel. + const err = Object.assign(new Error("hook 'close_guard' threw: Error: 删除被阻断"), { + innerMessage: '删除被阻断', + userMessage: USER_TEXT, + }); + const r = mapDataError(err, 'showcase_task'); + expect(r.status).toBe(400); + expect(r.body.error).toBe('删除被阻断'); + expect(r.body.userMessage).toBe(USER_TEXT); + }); + + it('a declared 5xx: the prose is still withheld, the marked channel still arrives', () => { + // #5437's withhold is about text the producer never addressed to the + // caller; the marked channel is authored FOR the caller and rides. + const err = Object.assign(new Error('maintenance window: pool drained on 10.0.0.5'), { + statusCode: 503, + code: 'SERVICE_UNAVAILABLE', + userMessage: '系统维护中(每日 02:00–02:30),请稍后重试。', + }); + const r = mapDataError(err, 'showcase_task'); + expect(r.status).toBe(503); + expect(r.body.error).toBe(INTERNAL_ERROR_MESSAGE); + expect(JSON.stringify(r.body)).not.toContain('10.0.0.5'); + expect(r.body.userMessage).toBe('系统维护中(每日 02:00–02:30),请稍后重试。'); + }); + + it('a genuine crash is byte-identical to before — no marking, no change', () => { + const err = Object.assign(new Error("hook 'buggy' threw: TypeError: not a function"), { + innerMessage: 'TypeError: not a function', + }); + const r = mapDataError(err, 'showcase_task'); + expect(r).toEqual({ + status: 500, + body: { error: INTERNAL_ERROR_MESSAGE, code: 'INTERNAL_ERROR' }, + }); + }); + + it('a marked throw the crash heuristic misjudges still gets its marked text out', () => { + // The accepted cost recorded on `isScriptFaultMessage` — a business rule + // authored as `throw new RangeError(…)` — is softened by the marking: + // the envelope stays the sanitised 500, but the author's opted-in text + // is not lost. The status and code do NOT move. + const err = Object.assign(new Error("hook 'range_rule' threw: RangeError: 数量超出范围"), { + innerMessage: 'RangeError: 数量超出范围', + userMessage: '数量超出可售余额,请调小数量后重试。', + }); + const r = mapDataError(err, 'showcase_task'); + expect(r.status).toBe(500); + expect(r.body.code).toBe('INTERNAL_ERROR'); + expect(r.body.error).toBe(INTERNAL_ERROR_MESSAGE); + expect(r.body.userMessage).toBe('数量超出可售余额,请调小数量后重试。'); + }); + + it('what is NOT a declaration: blank, non-string, whitespace — nothing is invented', () => { + for (const bad of ['', ' ', 42, true, {}, [], null, undefined]) { + const err = Object.assign(new Error('refused'), { + statusCode: 403, + userMessage: bad, + }); + const r = mapDataError(err, 'showcase_task'); + expect('userMessage' in r.body, `userMessage=${JSON.stringify(bad)}`).toBe(false); + } + }); + + it('over-long marked text is TRUNCATED, never replaced — the #5423 rule, same bound', () => { + const long = 'A'.repeat(600); + const r = mapDataError(markedRefusal(403, { userMessage: long }), 'showcase_task'); + const um = r.body.userMessage as string; + expect(um.length).toBe(500); + expect(um.endsWith('…')).toBe(true); + expect(um.startsWith('AAAA')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// §2 The OTHER rest door — `resolveErrorResponse`'s status passthrough +// (metadata/UI/discovery routes exit there, bypassing `mapDataError`) +// --------------------------------------------------------------------------- + +describe('[#9934] the handleRouteError passthrough carries the marking too', () => { + function makeRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + return res; + } + + it('a `.status`-spelled 4xx refusal keeps its marking', () => { + const res = makeRes(); + handleRouteError(res, Object.assign(new Error('refused'), { status: 403, userMessage: USER_TEXT })); + expect(res.statusCode).toBe(403); + expect(res.body.userMessage).toBe(USER_TEXT); + }); + + it('a `.status`-spelled 4xx without the marking carries none', () => { + const res = makeRes(); + handleRouteError(res, Object.assign(new Error('refused'), { status: 403 })); + expect(res.statusCode).toBe(403); + expect('userMessage' in res.body).toBe(false); + }); + + it('a `.status`-spelled 5xx: prose withheld, marking carried', () => { + const res = makeRes(); + handleRouteError(res, Object.assign(new Error('internal detail'), { + status: 503, code: 'SERVICE_UNAVAILABLE', userMessage: USER_TEXT, + })); + expect(res.statusCode).toBe(503); + expect(res.body.error).toBe(INTERNAL_ERROR_MESSAGE); + expect(res.body.userMessage).toBe(USER_TEXT); + }); +}); + +// --------------------------------------------------------------------------- +// §3 The wire, on the real CRUD data route — the report's request shape +// --------------------------------------------------------------------------- + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeWireRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(() => res); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); + return res; +} + +function setup(protocolOverrides: Record = {}) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([{ name: 'showcase_task' }]), + getMetaItem: vi.fn().mockResolvedValue({}), + findData: vi.fn().mockResolvedValue([]), + createData: vi.fn().mockResolvedValue({}), + updateData: vi.fn().mockResolvedValue({}), + ...protocolOverrides, + }; + const rest = new RestServer( + createMockServer() as any, + protocol, + { api: { requireAuth: false } } as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); + rest.registerRoutes(); + return rest; +} + +let errorSpy: ReturnType; +beforeEach(() => { errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); +afterEach(() => { errorSpy.mockRestore(); }); + +describe('[#9934] PATCH /api/v1/data — a beforeUpdate guard refusal on the wire', () => { + async function patchTask(rest: any) { + const res = makeWireRes(); + const route = rest.getRoutes().find((r: any) => r.method === 'PATCH' && r.path === '/api/v1/data/:object/:id'); + if (!route) throw new Error('PATCH data route not registered'); + await route.handler( + { method: 'PATCH', params: { object: 'showcase_task', id: 'rec1' }, query: {}, headers: {}, body: { name: 'edited' } }, + res, + ); + return res; + } + + it('marked: 403 with the exact author text in `userMessage`', async () => { + const rest = setup({ updateData: vi.fn().mockRejectedValue(markedRefusal()) }); + const res = await patchTask(rest); + expect(res.statusCode).toBe(403); + expect(res.body.userMessage).toBe(USER_TEXT); + }, 60_000); + + it('unmarked: 403 with no user-facing marking — #3821 preserved by construction', async () => { + const rest = setup({ updateData: vi.fn().mockRejectedValue(unmarkedRefusal()) }); + const res = await patchTask(rest); + expect(res.statusCode).toBe(403); + expect('userMessage' in res.body).toBe(false); + }, 60_000); +}); diff --git a/packages/runtime/src/error-envelope.conformance.test.ts b/packages/runtime/src/error-envelope.conformance.test.ts index 5bee012d2c..f63a0460fd 100644 --- a/packages/runtime/src/error-envelope.conformance.test.ts +++ b/packages/runtime/src/error-envelope.conformance.test.ts @@ -208,6 +208,32 @@ describe('#3842 — every dispatcher error exit answers in the declared envelope expect(error.code).toBe('CONNECTOR_UPSTREAM_UNAVAILABLE'); }); + it('[#9934] carries a producer-marked `userMessage` as a declared sibling — and the body stays conformant', async () => { + // The producer-side user-facing marking (objectui#5210 ruling): a hook + // guard's marked refusal reaches the nested envelope's declared + // `ApiErrorSchema.userMessage` channel, verbatim, at whatever status it + // declared. `expectConformantError` parses the body against the real + // schema, so this also proves a marked body IS the declared envelope. + const marked = Object.assign(new Error('close-period guard refused'), { + code: 'PERMISSION_DENIED', + status: 403, + userMessage: '该记录已进入结账期,暂不能修改。', + }); + const error = expectConformantError((makeDispatcher() as any).errorFromThrown(marked)); + expect(error.code).toBe('PERMISSION_DENIED'); + expect(error.userMessage).toBe('该记录已进入结账期,暂不能修改。'); + expect(error.message).toBe('close-period guard refused'); + + // The other half of the pin — unmarked carries NO user-facing marking, + // which is what preserves the console's #3821 generic substitution. + const unmarked = Object.assign(new Error('close-period guard refused'), { + code: 'PERMISSION_DENIED', + status: 403, + }); + const bare = expectConformantError((makeDispatcher() as any).errorFromThrown(unmarked)); + expect('userMessage' in bare).toBe(false); + }); + it('keeps the `Allow` header on the MCP 405 while sharing the body builder', async () => { const result = await makeDispatcher().handleMcpSkill('POST', { request: {} } as any); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 780812b9c4..f228316a25 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -783,9 +783,20 @@ export class HttpDispatcher { // and parses the body against `ApiErrorSchema`, which now PASSES for // every body this door emits — the "parse every body it emits" half. const declaredCode = demotedDeclaredCode(thrown); + // [#9934] The producer-side user-facing marking rides as a declared + // sibling of `code`/`message` (`ApiErrorSchema.userMessage`), exactly + // like `declaredCode` — the shared resolver already answered whether + // the throw declared one (`declaredUserMessage`'s non-empty-string + // rule), so this door and the REST door agree by construction. Note + // `this.error`'s 5xx leak-withholding touches only `message`; the + // marked channel is authored user text, never the withheld prose. + const extra = { + ...(declaredCode !== undefined ? { declaredCode } : {}), + ...(thrown.userMessage !== undefined ? { userMessage: thrown.userMessage } : {}), + }; return this.error( thrown.message, thrown.status, thrown.details, thrown.code, - declaredCode !== undefined ? { declaredCode } : undefined, + Object.keys(extra).length > 0 ? extra : undefined, ); } diff --git a/packages/runtime/src/sandbox/error-passthrough.test.ts b/packages/runtime/src/sandbox/error-passthrough.test.ts index c3ffea21d0..d37925095c 100644 --- a/packages/runtime/src/sandbox/error-passthrough.test.ts +++ b/packages/runtime/src/sandbox/error-passthrough.test.ts @@ -237,3 +237,69 @@ describe('[#7867] an error that names its own HTTP status keeps it across the bo expect(err.status).toBeUndefined(); }); }); + +/* ──────────────────────────────────────────────────────────────────────────── + * [#9934] `userMessage` — the fourth allowlisted property: the producer-side + * user-facing marking (objectui#5210 ruling). A sandboxed BODY is the authoring + * surface the marking exists for, so the author's opt-in must survive the VM + * flattening the throw to a string — in both directions, like the other three. + * ──────────────────────────────────────────────────────────────────────────── */ + +describe('[#9934] a body-authored user-facing marking crosses the boundary', () => { + const USER_TEXT = '该记录已进入结账期,暂不能修改。'; + + it('carries `userMessage` onto the thrown SandboxError — the marking the HTTP doors read', async () => { + const err = await run( + `var e = new Error('close-period guard refused'); + e.status = 403; + e.userMessage = ${JSON.stringify(USER_TEXT)}; + throw e;`, + new Error('unused'), + ).catch((e) => e); + + expect(err).toBeInstanceOf(SandboxError); + expect(err.status).toBe(403); + expect(err.userMessage).toBe(USER_TEXT); + // The message channels are untouched — the marking rides ALONGSIDE. + expect(err.innerMessage).toBe('close-period guard refused'); + }); + + it('an unmarked throw carries NO userMessage — the sandbox never invents one', async () => { + const err = await run( + `var e = new Error('refused'); e.status = 403; throw e;`, + new Error('unused'), + ).catch((e) => e); + + expect(err).toBeInstanceOf(SandboxError); + expect(err.userMessage).toBeUndefined(); + }); + + it('a blank marking is not a declaration', async () => { + const err = await run( + `var e = new Error('refused'); e.userMessage = ' '; throw e;`, + new Error('unused'), + ).catch((e) => e); + + expect(err.userMessage).toBeUndefined(); + }); + + it('a body can read `userMessage` off a rejected host write, and a re-throw keeps it', async () => { + // Host → VM → host, the round trip the other three properties already + // guarantee: a body that catches, inspects and re-throws a marked host + // refusal must not strip the marking. + const marked = Object.assign(new Error('guard refused'), { + code: 'PERMISSION_DENIED', + status: 403, + userMessage: USER_TEXT, + }); + const err = await run( + `try { await ctx.api.object('invoice').update({ id: 1 }); } + catch (e) { if (e.userMessage !== ${JSON.stringify(USER_TEXT)}) throw new Error('lost'); throw e; }`, + marked, + ).catch((e) => e); + + expect(err).toBeInstanceOf(SandboxError); + expect(err.userMessage).toBe(USER_TEXT); + expect(err.code).toBe('PERMISSION_DENIED'); + }); +}); diff --git a/packages/runtime/src/sandbox/quickjs-runner.ts b/packages/runtime/src/sandbox/quickjs-runner.ts index 7edf5986f2..37f4a74043 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.ts @@ -284,8 +284,8 @@ export class QuickJSScriptRunner implements ScriptRunner { `function(e){ globalThis.__error = (e && e.message) ? (e.name + ': ' + e.message) : String(e); try { - globalThis.__errorInfo = (e && (e.code || e.fields || e.status || e['${SANDBOX_FAULT_PROP}'])) - ? JSON.stringify({ code: e.code, fields: e.fields, status: e.status, sandboxFault: e['${SANDBOX_FAULT_PROP}'] === true }) + globalThis.__errorInfo = (e && (e.code || e.fields || e.status || e.userMessage || e['${SANDBOX_FAULT_PROP}'])) + ? JSON.stringify({ code: e.code, fields: e.fields, status: e.status, userMessage: e.userMessage, sandboxFault: e['${SANDBOX_FAULT_PROP}'] === true }) : undefined; } catch (_) { globalThis.__errorInfo = undefined; } }`; @@ -970,8 +970,20 @@ function safeJsonStringify(v: unknown): string { * error that NAMES its own HTTP status is asking to be served with it" — so * nothing downstream needed teaching; the number simply never arrived. A * number, like `code`, carries no host state. + * + * [#9934] `userMessage` is the fourth member — the producer-side user-facing + * marking (see `declaredUserMessage` in `@objectstack/types`). A hook or + * action BODY is the authoring surface the marking exists for: an app author + * writes `const e = new Error(msg); e.userMessage = msg; throw e`, and the + * text must survive the VM flattening the throw to a string, or the marking + * dies exactly where its primary producers live. Crossing INTO the VM is safe + * for the same reason `code` is: the value is author-written user-facing text + * by construction (platform and driver code never sets the field), so it + * carries no host state a sandboxed body could exfiltrate — and it keeps the + * established property that a body which catches, inspects and re-throws a + * host error does not lose the structured payload. */ -const SANDBOX_ERROR_PASSTHROUGH = ['code', 'fields', 'status'] as const; +const SANDBOX_ERROR_PASSTHROUGH = ['code', 'fields', 'status', 'userMessage'] as const; /** * Marshal a HOST error into the VM as a rejectable QuickJS error handle, @@ -980,7 +992,7 @@ const SANDBOX_ERROR_PASSTHROUGH = ['code', 'fields', 'status'] as const; * The caller owns the returned handle and must dispose it. */ function hostErrorToVm(vm: QuickJSContext, err: unknown): QuickJSHandle { - const e = err as { name?: string; message?: string; code?: unknown; fields?: unknown; status?: unknown }; + const e = err as { name?: string; message?: string; code?: unknown; fields?: unknown; status?: unknown; userMessage?: unknown }; const errH = err instanceof Error ? vm.newError({ name: e.name || 'Error', message: e.message ?? '' }) : vm.newError({ name: 'Error', message: String(err) }); @@ -1005,6 +1017,14 @@ function hostErrorToVm(vm: QuickJSContext, err: unknown): QuickJSHandle { vm.setProp(errH, 'status', h); h.dispose(); } + // [#9934] Non-empty strings only, same one-read rule as every other + // boundary (`declaredUserMessage`): a blank or non-string value is not a + // declaration and must not become one by crossing the VM. + if (typeof e?.userMessage === 'string' && e.userMessage.trim().length > 0) { + const h = vm.newString(e.userMessage); + vm.setProp(errH, 'userMessage', h); + h.dispose(); + } // [#4431] Mark the sandbox's OWN faults so the pump loop can tell them // apart from a user throw after the VM has flattened both to a string. if (err instanceof SandboxError) { @@ -1237,6 +1257,16 @@ export class SandboxError extends Error { * against a nonexistent record was answered `RECORD_NOT_FOUND` at status 400. */ readonly status?: number; + /** + * [#9934] The user-facing refusal text the error that crossed OUT of the VM + * was marked with — the producer-side opt-in of the objectui#5210 ruling. A + * body that throws `e.userMessage = '…'` is saying that exact text is + * addressed to the END USER; the HTTP boundaries carry it to the wire's + * `userMessage` channel and consumers render it verbatim, keeping the + * generic #3821 substitution for everything unmarked. Absent unless the + * body's own throw declared it — the sandbox never invents one. + */ + readonly userMessage?: string; constructor(message: string, innerMessage?: string, info?: SandboxErrorInfo) { super(message); this.name = 'SandboxError'; @@ -1244,6 +1274,7 @@ export class SandboxError extends Error { if (info?.code) this.code = info.code; if (info?.fields) this.fields = info.fields; if (typeof info?.status === 'number') this.status = info.status; + if (info?.userMessage) this.userMessage = info.userMessage; } } @@ -1253,6 +1284,8 @@ export interface SandboxErrorInfo { fields?: unknown[]; /** [#7867] See {@link SandboxError.status}. */ status?: number; + /** [#9934] See {@link SandboxError.userMessage}. */ + userMessage?: string; /** * [#4431] The error that crossed `__error` was the SANDBOX's own fault — a * denied capability, an unavailable `ctx.api`, a marshalling failure — not @@ -1284,7 +1317,7 @@ function readErrorInfo(vm: QuickJSContext): SandboxErrorInfo | undefined { } catch { return undefined; } - const p = parsed as { code?: unknown; fields?: unknown; status?: unknown; sandboxFault?: unknown }; + const p = parsed as { code?: unknown; fields?: unknown; status?: unknown; userMessage?: unknown; sandboxFault?: unknown }; const info: SandboxErrorInfo = {}; if (typeof p?.code === 'string' && p.code) info.code = p.code; if (Array.isArray(p?.fields)) info.fields = p.fields; @@ -1292,8 +1325,13 @@ function readErrorInfo(vm: QuickJSContext): SandboxErrorInfo | undefined { // number JSON-round-trips to `null`, and `NaN` would satisfy `typeof` while // making `errorFromThrown` emit a nonsense status line. if (typeof p?.status === 'number' && Number.isFinite(p.status)) info.status = p.status; + // [#9934] Non-empty strings only — the same "what counts as marked" rule as + // `declaredUserMessage` (`@objectstack/types`), applied at this boundary too. + if (typeof p?.userMessage === 'string' && p.userMessage.trim().length > 0) info.userMessage = p.userMessage; if (p?.sandboxFault === true) info.sandboxFault = true; - return info.code || info.fields || info.status !== undefined || info.sandboxFault ? info : undefined; + return info.code || info.fields || info.status !== undefined || info.userMessage !== undefined || info.sandboxFault + ? info + : undefined; } /** diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 0893c4ab96..faf3c4257e 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -136,6 +136,7 @@ "api/ApiError:httpStatus", "api/ApiError:message", "api/ApiError:requestId", + "api/ApiError:userMessage", "api/ApiMapping:source", "api/ApiMapping:target", "api/ApiMapping:transform", @@ -550,6 +551,7 @@ "api/EnhancedApiError:retryable", "api/EnhancedApiError:timestamp", "api/EnhancedApiError:traceId", + "api/EnhancedApiError:userMessage", "api/ErrorHandlingConfig:customErrorMessages", "api/ErrorHandlingConfig:documentationBaseUrl", "api/ErrorHandlingConfig:enabled", diff --git a/packages/spec/src/api/contract.test.ts b/packages/spec/src/api/contract.test.ts index 7e2888a20a..e95edb9325 100644 --- a/packages/spec/src/api/contract.test.ts +++ b/packages/spec/src/api/contract.test.ts @@ -44,6 +44,38 @@ describe('ApiErrorSchema', () => { expect(error.details).toBeDefined(); }); + + // [#9934] The producer-side user-facing marking (objectui#5210 ruling): + // presence is the producer's opt-in, absence keeps the consumer's generic + // substitution (#3821 preserved by construction). + it('carries a producer-marked `userMessage` verbatim', () => { + const error = ApiErrorSchema.parse({ + code: 'PERMISSION_DENIED', + message: 'close-period guard refused the write', + userMessage: '该记录已进入结账期,暂不能修改。', + httpStatus: 403, + }); + + expect(error.userMessage).toBe('该记录已进入结账期,暂不能修改。'); + // The marking rides ALONGSIDE `message`, never instead of it. + expect(error.message).toBe('close-period guard refused the write'); + }); + + it('an unmarked error parses with the key ABSENT — the default is unmarked', () => { + const error = ApiErrorSchema.parse({ + code: 'PERMISSION_DENIED', + message: 'refused', + }); + expect('userMessage' in error).toBe(false); + }); + + it('rejects a non-string marking — the field is the text, not a flag', () => { + expect(ApiErrorSchema.safeParse({ + code: 'PERMISSION_DENIED', + message: 'refused', + userMessage: true, + }).success).toBe(false); + }); }); describe('BaseResponseSchema', () => { diff --git a/packages/spec/src/api/contract.zod.ts b/packages/spec/src/api/contract.zod.ts index 3eda860fa8..058590424c 100644 --- a/packages/spec/src/api/contract.zod.ts +++ b/packages/spec/src/api/contract.zod.ts @@ -46,6 +46,49 @@ export const ApiErrorSchema = lazySchema(() => z.object({ */ declaredCode: z.string().optional().describe('The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106)'), message: z.string().describe('Readable error message'), + /** + * The producer's user-facing refusal text, verbatim — the producer-side + * opt-in channel for "this exact text is addressed to the END USER" (#9934; + * maintainer ruling 2026-08-19 on objectui#5210, option 1). + * + * ## The problem it solves + * + * The platform gave an application hook no way to distinguish author-written + * user guidance from platform diagnostics, so the console form deliberately + * discards the server `message` on 403 and substitutes a generic string — + * the recorded #3821 fix for raw platform diagnostics leaking to end users. + * That substitution also suppressed every deliberate, localized refusal a + * hook author wrote, and incentivized misusing 400 (whose messages render) + * for permission refusals. This field is the distinction, declared ONCE at + * the contract level. + * + * ## Semantics + * + * - **Producer-side opt-in, at throw time.** A hook (or any producer) sets + * `userMessage` on the thrown error; the boundaries carry it to the wire + * (`declaredUserMessage` in `@objectstack/types` is the one read). + * - **Presence IS the marking.** A consumer that sees this field renders it + * verbatim in user-facing surfaces; when it is absent the consumer keeps + * its generic substitution — the default is unmarked, so #3821's + * protection is preserved by construction. + * - **Status-agnostic.** Not a 403 special case: any refusal status may + * carry it. + * - **A field carrying the text, not a boolean beside `message`.** The mark + * and the marked text are one value, so a boundary that rewraps or + * substitutes `message` (sanitisation, truncation, the sandbox debug + * wrapper) can never accidentally promote platform prose into the marked + * channel. Platform/driver code never sets it. Same audience-split + * precedent as `developerMessage` on the DELETE_RESTRICTED envelope + * (#7307), pointed the other way. + * + * It never replaces `message` — the diagnostic channel keeps its own wording + * for logs and developers. + */ + userMessage: z.string().optional().describe( + 'Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the ' + + 'producer opted in at throw time; consumers render it to end users and keep their ' + + 'generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`.', + ), category: z.string().optional().describe('Error category (e.g. validation, authorization)'), /** * The numeric HTTP status, when a producer chooses to mirror it into the body. diff --git a/packages/spec/src/api/errors.test.ts b/packages/spec/src/api/errors.test.ts index abd81a042b..ff07875149 100644 --- a/packages/spec/src/api/errors.test.ts +++ b/packages/spec/src/api/errors.test.ts @@ -143,6 +143,24 @@ describe('EnhancedApiErrorSchema', () => { expect(error.documentation).toContain('objectstack.dev'); }); + // [#9934] Same field, same semantics as `ApiErrorSchema.userMessage` — the + // producer-side user-facing marking of the objectui#5210 ruling. + it('carries a producer-marked `userMessage` verbatim, and stays absent when unmarked', () => { + const marked = EnhancedApiErrorSchema.parse({ + code: 'PERMISSION_DENIED', + message: 'close-period guard refused the write', + userMessage: '该记录已进入结账期,暂不能修改。', + }); + expect(marked.userMessage).toBe('该记录已进入结账期,暂不能修改。'); + expect(marked.message).toBe('close-period guard refused the write'); + + const unmarked = EnhancedApiErrorSchema.parse({ + code: 'PERMISSION_DENIED', + message: 'refused', + }); + expect('userMessage' in unmarked).toBe(false); + }); + it('should accept rate limit error with retry info', () => { const error = EnhancedApiErrorSchema.parse({ code: 'RATE_LIMIT_EXCEEDED', diff --git a/packages/spec/src/api/errors.zod.ts b/packages/spec/src/api/errors.zod.ts index 130ab6d963..a935e32fc2 100644 --- a/packages/spec/src/api/errors.zod.ts +++ b/packages/spec/src/api/errors.zod.ts @@ -365,6 +365,21 @@ export type FieldError = z.input; export const EnhancedApiErrorSchema = lazySchema(() => z.object({ code: StandardErrorCode.describe('Machine-readable error code'), message: z.string().describe('Human-readable error message'), + /** + * The producer's user-facing refusal text, verbatim — the same field, with + * the same semantics, as `ApiErrorSchema.userMessage` (`contract.zod.ts`, + * which carries the full rationale): the producer-side opt-in that marks a + * refusal message as addressed to the END USER (#9934, maintainer ruling + * 2026-08-19 on objectui#5210). Present exactly when the producer opted in + * at throw time; absent means consumers keep their generic substitution + * (#3821 preserved by construction). Status-agnostic; never replaces + * `message`. + */ + userMessage: z.string().optional().describe( + 'Producer-marked user-facing refusal text, verbatim (#9934) — see ApiErrorSchema.userMessage. ' + + 'Present only when the producer opted in at throw time; unmarked errors keep the generic ' + + 'consumer substitution (#3821).', + ), category: ErrorCategory.optional().describe('Error category'), httpStatus: z.number().optional().describe('HTTP status code'), retryable: z.boolean().default(false).describe('Whether the request can be retried'), diff --git a/packages/types/src/thrown-http-error.test.ts b/packages/types/src/thrown-http-error.test.ts new file mode 100644 index 0000000000..dde2b17dda --- /dev/null +++ b/packages/types/src/thrown-http-error.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#9934] `declaredUserMessage` — the ONE read for "did the producer mark this +// refusal's message user-facing?", and the resolver limb that carries it. +// +// The marking is the producer-side opt-in the objectui#5210 ruling asked for: +// a hook author sets `userMessage` on the thrown error at throw time, every +// boundary carries it to the wire verbatim, and consumers render it while +// keeping the generic #3821 substitution for everything unmarked. What this +// file pins is the DECLARATION rule itself, so it cannot fork per door the way +// the `status`/`statusCode` spelling once did (#7525): the same probe answers +// at the REST classification door, the dispatcher door and the sandbox +// side-channel, because all three call this function. + +import { describe, it, expect } from 'vitest'; +import { declaredUserMessage, resolveThrownHttpError } from './thrown-http-error.js'; + +const USER_TEXT = '该记录已锁定,请联系管理员。'; + +describe('[#9934] declaredUserMessage', () => { + it('a non-empty string userMessage is a declaration, returned verbatim', () => { + const err = Object.assign(new Error('diagnostic text'), { userMessage: USER_TEXT }); + expect(declaredUserMessage(err)).toBe(USER_TEXT); + }); + + it('is never invented from `message` — an unmarked error declares nothing', () => { + expect(declaredUserMessage(new Error('a perfectly readable message'))).toBeUndefined(); + }); + + it('blank and non-string values are NOT declarations', () => { + for (const bad of ['', ' ', '\n\t', 0, 42, true, false, {}, [], null, undefined]) { + const err = Object.assign(new Error('x'), { userMessage: bad }); + expect(declaredUserMessage(err), `userMessage=${JSON.stringify(bad)}`).toBeUndefined(); + } + }); + + it('tolerates non-object throws', () => { + expect(declaredUserMessage('a bare string')).toBeUndefined(); + expect(declaredUserMessage(null)).toBeUndefined(); + expect(declaredUserMessage(undefined)).toBeUndefined(); + }); + + it('preserves the text exactly — no trimming of the returned value', () => { + // `.trim()` decides WHETHER it is a declaration; the declared VALUE is + // the producer's bytes, untouched. + const err = Object.assign(new Error('x'), { userMessage: ` ${USER_TEXT} ` }); + expect(declaredUserMessage(err)).toBe(` ${USER_TEXT} `); + }); +}); + +describe('[#9934] resolveThrownHttpError carries the marking', () => { + it('a marked refusal resolves with `userMessage` beside its status and code', () => { + const thrown = resolveThrownHttpError( + Object.assign(new Error('guard refused'), { + statusCode: 403, + code: 'PERMISSION_DENIED', + userMessage: USER_TEXT, + }), + ); + expect(thrown.status).toBe(403); + expect(thrown.code).toBe('PERMISSION_DENIED'); + expect(thrown.userMessage).toBe(USER_TEXT); + // The diagnostic channel is untouched. + expect(thrown.message).toBe('guard refused'); + }); + + it('an unmarked throw resolves with the key ABSENT — not undefined-present', () => { + const thrown = resolveThrownHttpError(Object.assign(new Error('x'), { statusCode: 403 })); + expect('userMessage' in thrown).toBe(false); + }); + + it('status-agnostic: a marked 503 carries it the same way', () => { + const thrown = resolveThrownHttpError( + Object.assign(new Error('pool drained'), { status: 503, userMessage: USER_TEXT }), + ); + expect(thrown.status).toBe(503); + expect(thrown.userMessage).toBe(USER_TEXT); + }); +}); diff --git a/packages/types/src/thrown-http-error.ts b/packages/types/src/thrown-http-error.ts index a555aa13a3..be905ae8a7 100644 --- a/packages/types/src/thrown-http-error.ts +++ b/packages/types/src/thrown-http-error.ts @@ -135,6 +135,31 @@ export interface ThrownHttpError { declaredCode?: string; /** The thrown message, UNSANITISED — see the module note on disclosure. */ message: string; + /** + * The producer's user-facing refusal text, verbatim — present exactly when + * the throw carried a non-empty string `userMessage` (#9934). + * + * This is the producer-side opt-in the objectui#5210 ruling asked for + * (maintainer, 2026-08-19, option 1): an application hook's refusal has no + * way to distinguish author-written user guidance from platform diagnostics, + * so the console substitutes a generic string on 403 (the recorded #3821 + * fix) and every author-written remedy is suppressed with the diagnostics. + * A producer that sets `userMessage` on the thrown error is saying, at throw + * time, "this exact text is addressed to the END USER" — a consumer renders + * it verbatim and keeps the generic substitution for everything unmarked. + * + * Deliberately a FIELD carrying the text, not a boolean beside `message`: + * the mark and the marked text are one value, so a boundary that rewraps or + * substitutes `message` (sanitisation, truncation, the sandbox debug + * wrapper) can never accidentally promote platform prose into the marked + * channel — the #3821 protection holds by construction. Read through + * {@link declaredUserMessage}, never with an inline `typeof` probe. + * + * Status-agnostic on purpose (the ruling's second constraint): a 400, 403, + * 409 or 503 refusal may all carry it. It never REPLACES `message` — the + * diagnostic channel keeps its wording for logs and developers. + */ + userMessage?: string; /** * Structured context: spec-validation `issues[]`, record-validation * `fields[]`. Absent rather than `{}` when the throw carried none, so an @@ -156,6 +181,7 @@ export interface ThrownHttpError { * | code | `VALIDATION_FAILED` if it is one → a REGISTERED `.code` → derived from the status | * | declaredCode | `VALIDATION_FAILED` if it is one → any non-empty string `.code` → absent | * | message | `.message` when it is a string → `String(error)` | + * | userMessage | a non-empty string `.userMessage` → absent (see {@link declaredUserMessage}) | * * Both status spellings are read because both are produced in this repo: * `plugin-approvals`' lifecycle hooks and `metadata-protocol` throw @@ -200,16 +226,39 @@ export function resolveThrownHttpError(error: unknown, fallbackStatus = 500): Th ...(validation ? { fields: validation.fields } : {}), }; + const userMessage = declaredUserMessage(error); + return { status, ...(declaredStatus !== undefined ? { declaredStatus } : {}), code, ...(declaredCode !== undefined ? { declaredCode } : {}), message: typeof e?.message === 'string' ? e.message : String(error), + ...(userMessage !== undefined ? { userMessage } : {}), ...(Object.keys(details).length > 0 ? { details } : {}), }; } +/** + * The user-facing refusal text a thrown error DECLARED, or `undefined` when it + * declared none (#9934). See {@link ThrownHttpError.userMessage} for what the + * declaration means and why it is a text-carrying field rather than a flag. + * + * The ONE read every boundary applies — the REST classification door, the + * dispatcher door, and the sandbox side-channel all call this rather than + * probing `error.userMessage` themselves, so "what counts as marked" cannot + * fork per door the way the `status`/`statusCode` spelling once did (#7525). + * + * A non-string or blank `userMessage` is NOT a declaration: `undefined`, a + * number, `''` and whitespace-only all answer `undefined`, so nothing invents + * a marked message for a producer that never wrote one — absent means the + * consumer keeps its generic substitution (#3821 preserved by construction). + */ +export function declaredUserMessage(error: unknown): string | undefined { + const declared = (error as { userMessage?: unknown } | null | undefined)?.userMessage; + return typeof declared === 'string' && declared.trim().length > 0 ? declared : undefined; +} + /** * The producer's spelling a boundary should surface as the wire's * `declaredCode` beside the closed `code` — or `undefined` when there is