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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .changeset/senderror-carries-usermessage.md
Original file line numberDiff line numberDiff line change
@@ -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<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId' | 'declaredCode'>`,
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.
138 changes: 138 additions & 0 deletions packages/types/src/response-envelope.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown> }).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<string, unknown> }).data).toEqual({
code: 'PERMISSION_DENIED',
message: 'crm quota guard refused the write',
declaredCode: 'crm.quota_exceeded',
userMessage: USER_TEXT,
});
});
});
46 changes: 44 additions & 2 deletions packages/types/src/response-envelope.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`.
Expand DownExpand Up@@ -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<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId' | 'declaredCode'>,
extra?: Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId' | 'declaredCode' | 'userMessage'>,
): void {
res.status(status).json({ success: false, error: { code, message, ...extra } });
}
Loading