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
46 changes: 46 additions & 0 deletions .changeset/data-door-strips-adr-0111-code-prefix.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/rest": patch
---

fix(rest): the `/data` door's declared-4xx body carries human language in `error`, not the ADR-0111 `CODE:` prefix (#12975)

A producer that refuses with the ADR-0111 `CODE: message` idiom *and* declares
`{ code, status }` — `plugin-sharing`'s by-id write gate is the live in-repo
example — reached `/data` clients with the machine token glued to the front of
the human sentence. Since the sharing denial's copy moved onto the Operation
Message Catalog, a zh-CN user read this in a toast:

```text
FROM {"error":"FORBIDDEN: 您无权修改或删除这条记录,如需修改请联系该记录的负责人或管理员。","code":"FORBIDDEN","object":"showcase_inquiry"}
TO {"error":"您无权修改或删除这条记录,如需修改请联系该记录的负责人或管理员。","code":"FORBIDDEN","object":"showcase_inquiry"}
```

Maintainer ruling, 2026-08-29: one envelope semantics — `error` is human
language, `code` is the machine token. The token is unchanged and still on the
wire; only its duplicate inside the sentence is gone, so a client keying on
`code` (or on the `declaredCode` sibling for an unregistered spelling) reads
exactly what it read before.

**Scope — the strip is anchored to the producer's own declared `code`**, not to
a SCREAMING_SNAKE-then-colon pattern. Three consequences, each pinned:

- a declared 4xx carrying **no** `code` keeps its prefix, because the token
rides nowhere else on that body and dropping it would be a loss rather than a
move;
- a message opening with some **other** capitalised word and a colon is left
alone — driver prose such as a SQLite "no such table" line is untouched;
- a message that is **nothing but** the prefix degrades to `Request failed`,
the same generic sentence an absent or empty message already produced.

Every other branch of the door is byte-identical: declared 5xx (prose still
withheld whole), undeclared errors, the sandbox-refusal unwrap,
`DELETE_RESTRICTED`, `OBJECT_NOT_FOUND`, and any 4xx whose message never
carried the idiom.

**Consumer census** (recorded per the ruling's precondition): nothing parses
meaning out of the prefix. The only readers that touch it on the wire are three
display-side strippers in `objectui` (`packages/react/src/utils/error-message.ts`,
`plugin-detail`'s `InlineEditSaveBar.tsx` and `DetailView.tsx`), which delete it
for rendering and become no-ops. The prefix readers inside this repo all run
**in-process**, upstream of the wire — the `rest-server.ts` route mappings and
one `plugin-email` check on an error it threw itself — and are untouched.
76 changes: 74 additions & 2 deletions packages/rest/src/error-response.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -429,6 +429,54 @@ function thrownCodeFields(error: any, status: number): { code?: string; declared
return { code: thrown.code, ...(demoted !== undefined ? { declaredCode: demoted } : {}) };
}

/**
* [#12975] The caller-facing half of an ADR-0111 `CODE: message` throw — the
* message with the leading restatement of the producer's OWN declared `code`
* removed, or the message unchanged when it carries no such restatement.
*
* ## The rule, and why it is anchored to the declared code
*
* Maintainer ruling, 2026-08-29, on the `/data` door shipping `FORBIDDEN:` in
* front of a localized refusal: ONE envelope semantics — `error` is HUMAN
* LANGUAGE and `code` is the MACHINE TOKEN, and the token is already carried
* separately by {@link thrownCodeFields} above. The prefix is therefore removed
* *because* the same fact rides the `code` axis, and that is exactly the
* condition this function tests: the message opens with the producer's own
* `code`, followed by a colon.
*
* ⛔ NOT a blanket SCREAMING_SNAKE-then-colon strip, and the difference is the
* whole safety argument. The broader shape removes a token the wire may carry
* NOWHERE else — a 4xx that declared a `status` but no `code` gets `{}` from
* {@link thrownCodeFields} (ADR-0112's own rule: nothing is invented for the
* half the producer did not name), so a blind strip would delete the token
* outright rather than move it to its axis. It also eats any sentence that
* merely opens with a capitalised word and a colon — driver prose such as a
* SQLite "no such table" line included. Anchored to the declared code, the
* strip can only ever remove a DUPLICATE of something already on the wire.
*
* ⚠️ The anchor is the PRODUCER's spelling (`error.code`), not the narrowed
* wire `code`. An unregistered spelling is demoted to a `declaredCode` sibling
* by {@link thrownCodeFields} (#9232) while the prefix restates the spelling
* the producer actually wrote, so comparing against the narrowed value would
* miss precisely the idiom this reads. Either way the token still reaches the
* wire — as `code`, or as the `declaredCode` beside it.
*
* This is the shape `respondSharingError`'s ADR-0111 prefix arm already applies
* in `rest-server.ts` (it strips the prefix naming the code it just answered),
* rather than a third local rule: strip the prefix that names the code being
* answered, never an arbitrary one.
*/
function withoutDeclaredCodePrefix(message: string, error: any): string {
const declared = typeof error?.code === 'string' && error.code.length > 0
? error.code
: undefined;
if (declared === undefined || !message.startsWith(declared)) return message;
const separator = /^:\s*/.exec(message.slice(declared.length));
return separator === null
? message
: message.slice(declared.length + separator[0].length);
}

/**
* [#11718] The DECLARED-SERVER-FAULT relay, as one definition instead of a
* shape each door re-derives: a producer that declared a 5xx keeps its
Expand DownExpand Up@@ -1016,8 +1064,32 @@ function classifyDataError(error: any, object?: string): { status: number; body:
// An over-long message is TRUNCATED, not swapped for generic text
// (#5423) — see {@link truncateClientMessage}. A missing or empty one
// still degrades to `'Request failed'`: there is nothing to truncate.
const msg = typeof error?.message === 'string' && error.message.length > 0
? truncateClientMessage(error.message)
//
// [#12975] …and the sentence it keeps is the HUMAN half only. A
// producer using the ADR-0111 `CODE: message` idiom restates on the
// MESSAGE axis a token this body already carries on the `code` axis
// ({@link thrownCodeFields}, three lines down), and that restatement
// was reaching Console's toast in front of a localized sentence —
// `FORBIDDEN: 您无权修改或删除这条记录…` — where it was the only
// non-human fragment left in the user's face. Maintainer ruling,
// 2026-08-29: one envelope semantics, `error` = human language,
// `code` = the machine token. {@link withoutDeclaredCodePrefix} carries
// why the strip is anchored to the producer's declared code rather than
// to a SCREAMING_SNAKE shape.
//
// ⛔ The strip runs BEFORE the bound, not after: #5423's budget belongs
// to the text addressed to the caller and the prefix is not that text,
// so truncating first would spend part of the caller's 500 characters
// on a token they must not read.
//
// A message that is NOTHING BUT the prefix degrades to 'Request failed'
// through the same limb an absent or empty one takes — there is no
// human half to ship, and the token rides `code` regardless.
const authored = typeof error?.message === 'string'
? withoutDeclaredCodePrefix(error.message, error)
: '';
const msg = authored.length > 0
? truncateClientMessage(authored)
: 'Request failed';
// [#9232] Same narrowing as the 5xx arm above. The gate this replaces
// (`typeof error?.code === 'string' && error.code`) is exactly the
Expand Down
16 changes: 15 additions & 1 deletion packages/rest/src/rest-4xx-message-truncation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -120,11 +120,25 @@ describe('mapDataError: 4xx passthrough truncates an over-long message (#5423)',

describe('mapDataError: short 4xx messages are byte-for-byte unchanged (#5423)', () => {
it('a normal-length message passes through with no ellipsis and no slicing', () => {
// [#12975] PIN MOVED, with the ruled behaviour change in the same PR
// (maintainer, 2026-08-29). This case pins the BOUND — no ellipsis, no
// slicing — and its fixture happens to use the ADR-0111 `CODE: message`
// idiom, so the declared-4xx arm now hands the caller the HUMAN half
// alone. The subject of the case is unchanged: what comes back is the
// authored sentence entire, not a truncation of it.
//
// ⛔ Both halves are asserted deliberately. Reading only `error` would
// pass just as well for the OTHER way of getting this wrong — dropping
// the machine token along with the prefix — so `code` is pinned beside
// it. The token moves axis; it does not leave the body.
const msg = 'FORBIDDEN: insufficient privileges to update showcase_inquiry rec1';
const human = 'insufficient privileges to update showcase_inquiry rec1';
const r = mapDataError(Object.assign(new Error(msg), { code: 'FORBIDDEN', status: 403 }));

expect(r.status).toBe(403);
expect(r.body.error).toBe(msg);
expect(r.body.error).toBe(human);
expect(r.body.code).toBe('FORBIDDEN');
expect(String(r.body.error).endsWith('…')).toBe(false);
});

it('exactly 499 characters is still verbatim; exactly 500 is the first truncated length', () => {
Expand Down
18 changes: 16 additions & 2 deletions packages/rest/src/rest-5xx-status-passthrough.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -297,11 +297,25 @@ describe('[#5582] nothing of a 5xx message reaches the client', () => {
// ---------------------------------------------------------------------------

describe('[#5582] the 4xx half and the structured branches are untouched', () => {
it('a short 4xx is still byte-for-byte verbatim, with its object', () => {
it('a short 4xx keeps its authored sentence — nothing withheld — with its object', () => {
// [#12975] PIN MOVED, with the ruled behaviour change in the same PR
// (maintainer, 2026-08-29). §4's subject is the 5xx WITHHOLD not
// reaching the 4xx half, and that is unchanged: the caller still gets
// the producer's sentence rather than `INTERNAL_ERROR_MESSAGE`. What
// moved is that the ADR-0111 `CODE:` prefix this fixture carries is no
// longer part of that sentence — `error` is human language, `code` is
// the machine token, and the whole body is asserted here so losing the
// token with the prefix would red rather than pass.
//
// The title lost the words "byte-for-byte verbatim" for the same
// reason: the AUTHORED half is verbatim, the restatement of `code` in
// front of it is not part of what was authored for the caller.
const msg = 'FORBIDDEN: insufficient privileges to update showcase_inquiry rec1';
const human = 'insufficient privileges to update showcase_inquiry rec1';
const r = mapDataError(Object.assign(new Error(msg), { code: 'FORBIDDEN', status: 403 }), 'showcase_inquiry');
expect(r.status).toBe(403);
expect(r.body).toEqual({ error: msg, code: 'FORBIDDEN', object: 'showcase_inquiry' });
expect(r.body).toEqual({ error: human, code: 'FORBIDDEN', object: 'showcase_inquiry' });
expect(r.body.error).not.toBe(INTERNAL_ERROR_MESSAGE);
});

it('a long 4xx is still TRUNCATED rather than withheld (#5423)', () => {
Expand Down
Loading
Loading