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
20 changes: 20 additions & 0 deletions .changeset/olive-crabs-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@object-ui/app-shell': patch
---

Every envelope reader on the package surfaces now renders a producer-marked
`error.userMessage`, and the Studio package list opens the failure body at all.

`fetchPackages` — the `/api/v1/packages` read behind the Studio switcher, the writability
courtesy gate, the namespace lookup and the builder landing page — answered a refusal with
`HTTP <status>` and never opened the body, so `message`, `code` and `userMessage` were
discarded together: a 403 whose envelope said `Reading packages requires the studio.access
or setup.access capability.` reached the author as four characters. `apiJson` on the
package admin page read `error.message` and never the mark, and rendered no code.
`duplicatePackage` read `error.message` alone.

All three now ask one shared rule (`readEnvelopeFailureText`): a producer-marked
`userMessage` outranks the diagnostic `message` at any status — presence of the field is
the producer's marking, and a consumer that sees it renders it verbatim — with `error.code`
appended to whichever prose won. An unmarked refusal is unchanged and still renders its
diagnostic; a body carrying no prose still falls back to each reader's own status text.
161 changes: 161 additions & 0 deletions packages/app-shell/src/utils/apiErrorEnvelope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7959 — the RULE, pinned where it is defined.
*
* Three readers on the package surfaces held three independent implementations
* of "read the ADR-0112 failure envelope", and they had already drifted into
* three different answers for the same body:
*
* - `fetchPackages` (`views/studio-design/packages-io.ts`) never opened the
* body at all — `message`, `code` and `userMessage` were dropped together;
* - `apiJson` (`views/metadata-admin/PackagesPage.tsx`) read `error.message`
* and never `error.userMessage`, and appended no code;
* - `fetchFullPackage` (`StudioDesignSurface.tsx`) read `message` + `code`,
* and was taught the mark separately in objectui#7938.
*
* This file pins the extracted rule itself, so a fourth consumer inherits a
* pinned rule rather than a fourth reading of the envelope. The CALL SITES are
* pinned separately — `packages-io.envelopeUserMessage.test.ts` and
* `PackagesPage.envelopeUserMessage.test.tsx` — because "the rule is right" and
* "this reader actually asks it" are two different claims, and only the second
* one is what the author sees.
*/

import { describe, expect, it } from 'vitest';
import { readEnvelopeFailureText } from './apiErrorEnvelope';

/** The generic sentence the 5xx prose withhold substitutes (`INTERNAL_ERROR_MESSAGE`). */
const GENERIC = 'Internal server error';
/** A producer's marked text: what `userMessage` carries, written for the person. */
const MARKED = 'Publishing is temporarily unavailable. Nothing was changed.';
/** The card's measured sample — a refusal that names the capability to grant. */
const CAPABILITY = 'Reading packages requires the `studio.access` or `setup.access` capability.';

/** A body exactly as `sendError` writes it: `{ success: false, error: { … } }`. */
const envelope = (error: Record<string, unknown>) => ({ success: false, error });

describe('readEnvelopeFailureText — the four combinations (#7959)', () => {
/**
* ⭐ The overwhelmingly common case, and the one this pin exists to protect:
* "prefer `userMessage`" must never be implemented as "read `userMessage`
* INSTEAD", which would blank every unmarked refusal the platform serves
* today. Byte-identical to the pre-extraction diagnostic read.
*/
it('§1 `message` only — the unmarked refusal renders the diagnostic', () => {
expect(readEnvelopeFailureText(envelope({ code: 'FORBIDDEN', message: CAPABILITY }))).toBe(
`${CAPABILITY} (FORBIDDEN)`,
);
});

it('§2 `userMessage` only — the mark is the only prose on the body', () => {
expect(readEnvelopeFailureText(envelope({ code: 'SERVICE_UNAVAILABLE', userMessage: MARKED }))).toBe(
`${MARKED} (SERVICE_UNAVAILABLE)`,
);
});

/**
* The live 5xx case: the door substituted the generic sentence into
* `message` and the mark rode through untouched. The mark DISPLACES the
* diagnostic — it is not appended to it.
*/
it('§3 both — the mark wins and the generic sentence does not also appear', () => {
const shown = readEnvelopeFailureText(
envelope({ code: 'INTERNAL_ERROR', message: GENERIC, userMessage: MARKED }),
);
expect(shown).toBe(`${MARKED} (INTERNAL_ERROR)`);
expect(shown).not.toContain(GENERIC);
});

it('§4 neither — no prose means no answer, and the caller states its own fallback', () => {
expect(readEnvelopeFailureText(envelope({ code: 'SERVICE_UNAVAILABLE' }))).toBeNull();
});
});

describe('readEnvelopeFailureText — how `code` composes', () => {
it('is appended to whichever prose won — the mark', () => {
expect(readEnvelopeFailureText(envelope({ code: 'CONFLICT', message: GENERIC, userMessage: MARKED }))).toBe(
`${MARKED} (CONFLICT)`,
);
});

it('is appended to whichever prose won — the diagnostic', () => {
expect(readEnvelopeFailureText(envelope({ code: 'CONFLICT', message: CAPABILITY }))).toBe(
`${CAPABILITY} (CONFLICT)`,
);
});

it('a marked body with NO code renders the bare sentence', () => {
expect(readEnvelopeFailureText(envelope({ message: GENERIC, userMessage: MARKED }))).toBe(MARKED);
});

/**
* ⛔ A code NEVER rescues a prose-less body. A machine code is not a
* sentence to show a person, so this stays `null` and the caller falls back
* to naming the status.
*/
it('a code with no prose is still no prose', () => {
expect(readEnvelopeFailureText(envelope({ code: 'SERVICE_UNAVAILABLE' }))).toBeNull();
});

it('a non-string code is not a code — the prose renders bare', () => {
expect(readEnvelopeFailureText(envelope({ code: 500, message: CAPABILITY }))).toBe(CAPABILITY);
});
});

describe('readEnvelopeFailureText — what is not a mark', () => {
it('a non-string `userMessage` falls through to the diagnostic', () => {
expect(readEnvelopeFailureText(envelope({ code: 'INTERNAL_ERROR', message: GENERIC, userMessage: 42 }))).toBe(
`${GENERIC} (INTERNAL_ERROR)`,
);
});

it('an empty-string `userMessage` falls through to the diagnostic', () => {
// The producer's `declaredUserMessage` already applies the non-empty-string
// rule, so an empty mark should never ship — this reader does not depend on
// the producer having applied it.
expect(readEnvelopeFailureText(envelope({ code: 'INTERNAL_ERROR', message: GENERIC, userMessage: '' }))).toBe(
`${GENERIC} (INTERNAL_ERROR)`,
);
});

it('a non-string `message` is not prose either', () => {
expect(readEnvelopeFailureText(envelope({ code: 'INTERNAL_ERROR', message: { nested: 'x' } }))).toBeNull();
});

it('an empty-string mark AND an empty-string diagnostic leave nothing to show', () => {
expect(readEnvelopeFailureText(envelope({ code: 'INTERNAL_ERROR', message: '', userMessage: '' }))).toBeNull();
});
});

describe('readEnvelopeFailureText — bodies that are not this envelope', () => {
it.each([
['null (an unparseable body)', null],
['undefined', undefined],
['no `error` key at all', { success: false }],
['a bare-string `error` (an older runtime shape — the caller keeps that rung)', { error: 'boom' }],
['an array', [1, 2, 3]],
['a string', 'boom'],
['a number', 502],
])('%s → null', (_label, payload) => {
expect(readEnvelopeFailureText(payload)).toBeNull();
});
});

describe('readEnvelopeFailureText — structurally cannot be scoped to a status band', () => {
/**
* ⛔ The producing door applies NO status condition to the marked channel —
* "a marked text is the producer's deliberate statement to the caller at any
* status" — so a consumer honouring the mark in one band only would
* re-create, on the reading end, the divergence that door refused to create
* on the writing end (ruled on objectui#7938).
*
* This asserts that as a property of the SIGNATURE rather than as a behaviour
* sampled at two statuses: the rule takes one parameter, the body, and the
* status is not among its inputs. A future "only in the 5xx band" variant
* cannot be written without changing the arity this line pins.
*/
it('takes the body and nothing else — the status is not an input to the rule', () => {
expect(readEnvelopeFailureText.length).toBe(1);
});
});
116 changes: 116 additions & 0 deletions packages/app-shell/src/utils/apiErrorEnvelope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ONE read of the platform's ADR-0112 failure envelope — the prose a person
* should be shown when a request was refused.
*
* ## The rule this implements, and whose rule it is
*
* The envelope writer (`sendError`, `@objectstack/types` `response-envelope.ts`)
* nests the refusal under `error`:
*
* res.status(status).json({ success: false, error: { code, message, ...extra } });
*
* and `extra` is where the producer's declared channels ride. Two of the fields
* under `error` are prose, and they are NOT two spellings of one thing:
*
* - `message` — the DIAGNOSTIC. Always present, written for whoever is
* debugging. In the 5xx band the door substitutes the generic
* `Internal server error` into it (see below), so it is the text that can be
* withheld.
* - `userMessage` — the text a producer marked, AT THROW TIME, as addressed to
* the END USER (#9934). Its presence IS the marking, and the envelope
* writer's own words are the rule this module implements: "a consumer that
* sees the field renders it verbatim and keeps its generic substitution
* (#3821) for everything unmarked".
*
* So the mark OUTRANKS the diagnostic. That order is the contract's, not a
* preference — a reader that ignores `userMessage` is not degrading gracefully,
* it is discarding the one sentence a producer deliberately wrote for the
* person now reading the screen.
*
* ## Measured, not assumed: both doors that serve these routes emit the field
*
* `GET /api/v1/packages` and its lifecycle siblings are served by two doors,
* and each spreads the marked channel onto the wire beside `details` /
* `declaredCode`:
*
* - `sendThrownError` (`@objectstack/rest` `package-routes.ts`):
* `...(thrown.userMessage !== undefined ? { userMessage: thrown.userMessage } : {})`
* - `errorFromThrown` (`@objectstack/runtime` `http-dispatcher.ts`), which that
* same door's note calls "byte for byte the dispatcher twin's expression …
* which serves this same path and has emitted the channel since #9934".
*
* The framework pins the pair wire-side in `package-door-user-message.test.ts`.
*
* ## Why the 5xx band is where the loss became visible
*
* The producing door withholds the producer's PROSE above:
*
* const message = thrown.status >= 500 && looksLikeInternalErrorLeak(thrown.message)
* ? INTERNAL_ERROR_MESSAGE
* : thrown.message;
*
* The withhold rewrites a LOCAL `message` const, and `looksLikeInternalErrorLeak`
* is only ever handed `thrown.message` — so `userMessage` is never an input to
* it and rides through a sanitised 500 untouched. A reader that only knew about
* `message` therefore showed the author the GENERIC sentence on exactly the
* bodies that were carrying a specific one. Nothing invalid was displayed,
* which is what made the loss quiet.
*
* ⛔ NOT scoped to 5xx, deliberately. The producing door applies no status
* condition to this channel — "a marked text is the producer's deliberate
* statement to the caller at any status" — so a consumer that honoured the mark
* in one band only would re-create, on the READING end, precisely the
* divergence that door refused to create on the WRITING end.
*
* ⛔ Not a tolerant alias ladder either. These are two DECLARED fields with
* different meanings. An unmarked refusal carries no `userMessage` at all (the
* producer's `declaredUserMessage` already applied its non-empty-string rule),
* so the overwhelmingly common case falls straight through to `message` with
* byte-identical output.
*
* ## Why this returns `null` instead of a fallback
*
* The three readers that share this rule do NOT share a fallback: the
* `packages-io` readers say `HTTP <status>` and `PackagesPage`'s `apiJson` says
* `Request failed (<status>)`, and `apiJson` additionally keeps two legacy rungs
* (a bare-string `error`, a top-level `message`) for the shapes older runtimes
* send. Folding a fallback in here would have forced a `fallback` parameter and
* a `legacyRungs` flag — three different things pressed into one signature,
* which is harder to read than the copies it replaces. So the shared part is
* exactly the part that IS shared: envelope in, the person's prose out, or
* `null` when this body carried no prose at all. Each caller keeps its own
* fallback, on its own line, where a reader can see it.
*
* @param payload The parsed response body, or `null` when it could not be
* parsed. Typed `unknown` because every caller obtains it differently
* (`res.json().catch(() => null)`, `res.text()` + `JSON.parse`) and none of
* them can promise a shape.
* @returns The prose to show, with `error.code` appended in parentheses when
* the envelope declared one; `null` when the body carried no prose, in which
* case the caller states its own fallback. A code NEVER rescues a
* prose-less body: a machine code alone is not a sentence to show a person.
*/
export function readEnvelopeFailureText(payload: unknown): string | null {
const error = (payload as { error?: unknown } | null | undefined)?.error;
// A bare-string `error` (an older runtime's shape) is not this envelope. It
// is left to the caller that still knows about it — see the note above.
if (!error || typeof error !== 'object') return null;
const { code, message, userMessage } = error as {
code?: unknown;
message?: unknown;
userMessage?: unknown;
};
// A typed `string` check, not a truthiness one, and it is load-bearing twice:
// a non-string mark is not a mark (it is a producer bug, and falling through
// to the diagnostic is the honest answer), and an empty-string mark is not
// one either — `declaredUserMessage` already applies that rule producer-side,
// and this reader does not depend on the producer having applied it.
const marked = typeof userMessage === 'string' ? userMessage : '';
const diagnostic = typeof message === 'string' ? message : '';
const prose = marked || diagnostic;
if (!prose) return null;
const declaredCode = typeof code === 'string' ? code : '';
return declaredCode ? `${prose} (${declaredCode})` : prose;
}
Loading
Loading