From 21cb72a4ae4eddc5de0e04e0897714451095241e Mon Sep 17 00:00:00 2001 From: Litant Ying Date: Wed, 26 Aug 2026 04:44:31 +0000 Subject: [PATCH 1/2] fix(types): let sendError's `extra` carry `userMessage` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ApiErrorSchema` declares `userMessage` — the #9934 producer-side opt-in for text addressed to the END USER (maintainer ruling 2026-08-19 on objectui#5210, option 1) — and two of the three doors already emit it: the flat `/data` door through `withDeclaredUserMessage`, the dispatcher door through `thrown.userMessage`. `sendError`'s `extra` was typed `Pick`, so a route answering the NESTED envelope could not put the field on the wire — a compile error to try, and an author's deliberate, localized refusal text was dropped on that door alone while a valid body shipped without it. Additive: `userMessage` joins the `Pick`. No call site changes and no wire byte moves for any body already emitted; the accept set is untouched, since the schema always declared the field. Unlike `declaredCode` this field carries no invariant for the caller to re-derive — `declaredUserMessage` already decided what counts as marked, so the caller passes `thrown.userMessage` straight through, exactly as the dispatcher door does. That difference is why `extra` stays an explicit `Pick` rather than being derived from `ApiError`'s optional fields. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --- packages/types/src/response-envelope.test.ts | 138 +++++++++++++++++++ packages/types/src/response-envelope.ts | 46 ++++++- 2 files changed, 182 insertions(+), 2 deletions(-) diff --git a/packages/types/src/response-envelope.test.ts b/packages/types/src/response-envelope.test.ts index e73a67ee66..606522cf41 100644 --- a/packages/types/src/response-envelope.test.ts +++ b/packages/types/src/response-envelope.test.ts @@ -245,3 +245,141 @@ describe('sendError — the `declaredCode` open channel', () => { .toEqual(['code', 'message']); }); }); + +/** + * The #9934 user-facing marking on the NESTED envelope (maintainer ruling + * 2026-08-19 on objectui#5210, option 1). + * + * `ApiErrorSchema` declares `userMessage` — the text a producer marked AT THROW + * TIME as addressed to the END USER — and two of the three doors already emit + * it (the flat `/data` door, the dispatcher door). `sendError`'s `extra` did + * not admit the field, so a route answering the nested envelope could not put + * it on the wire: a COMPILE ERROR to try, and the author's deliberate, + * localized refusal text was dropped on this door alone while a valid body + * shipped without it. + * + * Same discipline as the `declaredCode` block above: drive the REAL resolver + * and parse the emitted body with the REAL `ApiErrorSchema`, because a + * type-level assertion would pass against a schema that declares nothing, and + * `.success` alone would pass against one that merely STRIPS the field. + */ +describe('sendError — the `userMessage` user-facing channel', () => { + const USER_TEXT = '该任务已进入月末结账期,暂不能修改;请联系财务主管解锁。'; + const DIAGNOSTIC = 'close-period guard refused the write'; + + /** + * The producer shape the runtime actually delivers: a hook refusal that + * opted in at throw time. Host-side hooks write this literally; a metadata + * app's sandboxed body reaches the same shape via `e.userMessage` crossing + * the QuickJS boundary (`SANDBOX_ERROR_PASSTHROUGH`). + */ + const markedRefusal = () => Object.assign( + new Error(DIAGNOSTIC), + { statusCode: 403, userMessage: USER_TEXT }, + ); + + it('carries the producer text verbatim WITHOUT replacing the diagnostic message', () => { + const { res, seen } = capture(); + const thrown = resolveThrownHttpError(markedRefusal()); + + // No re-derivation here, unlike `declaredCode`: `declaredUserMessage` + // already decided what counts as marked, and the resolver applied it. + sendError(res, thrown.status, thrown.code, thrown.message, { + ...(thrown.userMessage !== undefined ? { userMessage: thrown.userMessage } : {}), + }); + + expect(seen.status).toBe(403); + expect(seen.body).toEqual({ + success: false, + error: { + code: 'PERMISSION_DENIED', + // The diagnostic channel keeps its own wording… + message: DIAGNOSTIC, + // …and the marked text rides beside it, byte for byte. + userMessage: USER_TEXT, + }, + }); + }); + + it('emits a body the REAL schemas accept, with the field still on it after the parse', () => { + const { res, seen } = capture(); + const thrown = resolveThrownHttpError(markedRefusal()); + sendError(res, thrown.status, thrown.code, thrown.message, { + userMessage: thrown.userMessage!, + }); + + const body = seen.body as { error: unknown }; + expect(BaseResponseSchema.safeParse(seen.body).success).toBe(true); + expect(envelopeViolations(seen.body)).toEqual([]); + + const parsed = ApiErrorSchema.safeParse(body.error); + expect(parsed.success).toBe(true); + expect((parsed as { data: { userMessage?: string } }).data.userMessage).toBe(USER_TEXT); + }); + + it('the survives-the-parse reading can say NO — an undeclared sibling beside it is stripped', () => { + // Paired control on a term that is NOT a substring of the one under + // test. `ApiErrorSchema` is a plain `z.object`, so it strips rather + // than rejects: "parsed clean" is worthless alone, and this is what + // makes the assertion above distinguish a DECLARED field from a merely + // tolerated one — same body, same parse, opposite outcomes. + const parsed = ApiErrorSchema.safeParse({ + code: 'PERMISSION_DENIED', + message: DIAGNOSTIC, + userMessage: USER_TEXT, + reason: 'closed-period', + }); + + expect(parsed.success).toBe(true); + const data = (parsed as { data: Record }).data; + expect(data.userMessage).toBe(USER_TEXT); + expect('reason' in data).toBe(false); + }); + + it('stays ABSENT when the producer marked nothing — #3821 preserved by construction', () => { + // Absence is the default and it is load-bearing: the consumer keeps its + // generic substitution for anything unmarked. A blank marking is NOT a + // declaration (`declaredUserMessage`'s non-empty-string rule), so the + // writer must not invent a marked message for a producer that wrote + // none — and the key must not appear as an empty string either. + const { res, seen } = capture(); + const thrown = resolveThrownHttpError( + Object.assign(new Error(DIAGNOSTIC), { statusCode: 403, userMessage: ' ' }), + ); + + expect(thrown.userMessage).toBeUndefined(); + + sendError(res, thrown.status, thrown.code, thrown.message, { + ...(thrown.userMessage !== undefined ? { userMessage: thrown.userMessage } : {}), + }); + + expect(Object.keys((seen.body as { error: object }).error)).toEqual(['code', 'message']); + }); + + it('rides BESIDE `declaredCode` — both open channels on one refusal', () => { + // The metadata-app case both cards were filed for: an app's own `.code` + // (unregistered, so demoted) and its own user-facing text, on the same + // throw. Admitting the second channel must not disturb the first. + const { res, seen } = capture(); + const thrown = resolveThrownHttpError(Object.assign( + new Error('crm quota guard refused the write'), + { code: 'crm.quota_exceeded', status: 403, userMessage: USER_TEXT }, + )); + const demoted = demotedDeclaredCode(thrown); + + sendError(res, thrown.status, thrown.code, thrown.message, { + ...(demoted !== undefined ? { declaredCode: demoted } : {}), + ...(thrown.userMessage !== undefined ? { userMessage: thrown.userMessage } : {}), + }); + + const body = seen.body as { error: unknown }; + const parsed = ApiErrorSchema.safeParse(body.error); + expect(parsed.success).toBe(true); + expect((parsed as { data: Record }).data).toEqual({ + code: 'PERMISSION_DENIED', + message: 'crm quota guard refused the write', + declaredCode: 'crm.quota_exceeded', + userMessage: USER_TEXT, + }); + }); +}); diff --git a/packages/types/src/response-envelope.ts b/packages/types/src/response-envelope.ts index 5d84c894dc..0302d9a0d0 100644 --- a/packages/types/src/response-envelope.ts +++ b/packages/types/src/response-envelope.ts @@ -111,7 +111,7 @@ export function sendOk(res: EnvelopeResponse, data: unknown, status = 200): void * * Merged into `error`, and typed as exactly what `ApiErrorSchema` declares * beside `code` and `message` — `details`, `category`, `requestId`, - * `httpStatus`, `declaredCode`. + * `httpStatus`, `declaredCode`, `userMessage`. * `details` is the slot for structured context: `package-routes` puts a partial * delete's per-item failures there, `settings-routes` the whole * `SettingsActionResult`. @@ -157,13 +157,55 @@ export function sendOk(res: EnvelopeResponse, data: unknown, status = 200): void * that, and no type here can catch it: vocabulary and position stay two * decisions (#9232), so the demotion rule stays with the resolver that owns * it rather than being restated in the envelope writer. + * + * ## `userMessage` — the second declared channel, and why this `Pick` stays explicit + * + * #9934's producer-side opt-in (maintainer ruling 2026-08-19 on objectui#5210, + * option 1) declares `ApiError.userMessage`: the text a producer marked, AT + * THROW TIME, as addressed to the END USER. Presence IS the marking — a + * consumer that sees the field renders it verbatim and keeps its generic + * substitution (#3821) for everything unmarked. + * + * The schema declared it and this writer barred it, with the same + * one-directional silence `declaredCode` had: the other two doors already emit + * it — the flat `/data` door through `withDeclaredUserMessage` + * (`rest/error-response.ts`) and the dispatcher door through + * `thrown.userMessage` (`runtime/http-dispatcher.ts`) — while a route + * answering the NESTED envelope could not, so an author's deliberate, + * localized refusal text was dropped on this door alone. Nothing invalid + * shipped; the text simply was not there. + * + * The channel is live on both ends, which is what makes admitting it a repair + * rather than a new declared-but-dead surface: a hook sets it at throw time — + * host-side, or a metadata app's sandboxed body whose `e.userMessage` crosses + * the QuickJS boundary through `SANDBOX_ERROR_PASSTHROUGH` + * (`runtime/sandbox/quickjs-runner.ts`) — and `resolveThrownHttpError` already + * carries it onto `ThrownHttpError` for every caller of the shared resolver. + * + * ⛔ Unlike `declaredCode`, this field carries NO invariant for the caller to + * re-derive. `declaredCode`'s presence MEANS demotion, so its caller passes + * `demotedDeclaredCode(thrown)` rather than the raw field; `userMessage`'s + * presence means only that the producer opted in, and `declaredUserMessage` + * has already decided that (a non-empty string, or nothing at all). The caller + * passes `thrown.userMessage` straight through, exactly as the dispatcher door + * does. + * + * That difference is why `extra` stays an explicit `Pick` rather than becoming + * "every optional field of `ApiError`". A derivation would admit each future + * optional on the day it lands, with nobody asked whether that channel should + * cross this door or what obligation it hands the caller — and the two fields + * above needed opposite answers to exactly that question. Recorded for the next + * reader, because it is the honest cost: with `userMessage` admitted the `Pick` + * now names ALL SIX of `ApiError`'s optional fields, so this gate has to date + * rejected none. What it has produced is a different caller obligation per + * field, which a derivation cannot produce at all. */ export function sendError( res: EnvelopeResponse, status: number, code: ErrorCode, message: string, - extra?: Pick, + extra?: Pick, ): void { res.status(status).json({ success: false, error: { code, message, ...extra } }); } From 899878a5fe9aaa0fc8e95d1f2c885baa65a152b7 Mon Sep 17 00:00:00 2001 From: Litant Ying Date: Wed, 26 Aug 2026 04:57:11 +0000 Subject: [PATCH 2/2] chore(changeset): sendError's `extra` carries `userMessage` Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --- .changeset/senderror-carries-usermessage.md | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .changeset/senderror-carries-usermessage.md diff --git a/.changeset/senderror-carries-usermessage.md b/.changeset/senderror-carries-usermessage.md new file mode 100644 index 0000000000..e69394585f --- /dev/null +++ b/.changeset/senderror-carries-usermessage.md @@ -0,0 +1,54 @@ +--- +"@objectstack/types": minor +--- + +fix(types): let `sendError`'s `extra` carry `userMessage`, so a nested-envelope route can emit the #9934 user-facing channel (#12404) + +`ApiErrorSchema` declares `userMessage` — the producer-side opt-in for "this +exact text is addressed to the END USER" (#9934; maintainer ruling 2026-08-19 on +objectui#5210, option 1), where **presence IS the marking** and a consumer that +sees the field renders it verbatim instead of substituting its generic string +(#3821 preserved by construction, for everything unmarked). + +Two of the three doors already emit it: the flat `/data` door through +`withDeclaredUserMessage` (`@objectstack/rest`) and the dispatcher door through +`thrown.userMessage` (`@objectstack/runtime`). The shared nested-envelope writer +could not — `sendError`'s `extra` was typed +`Pick`, +so passing the field was a **compile error** and every route answering the +nested envelope dropped it. Nothing invalid shipped, which is what made the loss +silent and one-directional: the author's deliberate, localized refusal text +gone, and a consumer told to read `userMessage` finding nothing there. +Declared-but-unemittable is a `declared = enforced` gap, closed here at the one +writer rather than per module. + +Additive: `userMessage` joins the `Pick`. No existing call site changes, no wire +byte moves for any body already being emitted, and the contract's accept set is +untouched — the schema has always declared the field. + +The channel is live on both ends, which is what makes this a repair rather than +a new declared-but-dead surface: a hook opts in at throw time (host-side, or a +metadata app's sandboxed body whose `e.userMessage` crosses the QuickJS boundary +through `SANDBOX_ERROR_PASSTHROUGH`), and `resolveThrownHttpError` already +carries it onto `ThrownHttpError` for every caller of the shared resolver. + +⛔ Unlike `declaredCode`, this field hands the caller **no invariant to +re-derive**. `declaredCode`'s presence means demotion, so its caller passes +`demotedDeclaredCode(thrown)`; `userMessage`'s presence means only that the +producer opted in, which `declaredUserMessage` has already decided (a non-empty +string, or nothing). The caller passes `thrown.userMessage` straight through, +exactly as the dispatcher door does. That difference is why `extra` stays an +explicit `Pick` rather than being derived from `ApiError`'s optional fields: a +derivation would admit every future optional on the day it lands, with nobody +asked what obligation the channel hands the caller — and these two fields needed +opposite answers to exactly that question. + +Pinned in `response-envelope.test.ts` by driving the real pipeline — a hook +refusal shaped like the one `hook-refusal-user-facing-marking.dogfood.test.ts` +drives, through `resolveThrownHttpError` — and by parsing the emitted body with +the real `ApiErrorSchema`, asserting the field is still on it *after* the parse, +paired with a control showing an undeclared sibling being stripped from the same +body. `ApiErrorSchema` is a plain `z.object` that strips undeclared keys, so a +`.success` assertion alone would have passed against a schema declaring nothing. +A blank marking is pinned ABSENT: the writer never invents a marked message for +a producer that wrote none.