diff --git a/.changeset/data-door-strips-adr-0111-code-prefix.md b/.changeset/data-door-strips-adr-0111-code-prefix.md new file mode 100644 index 0000000000..e1217177ce --- /dev/null +++ b/.changeset/data-door-strips-adr-0111-code-prefix.md @@ -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. diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index bb8bde347f..25ed097978 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -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 @@ -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 diff --git a/packages/rest/src/rest-4xx-message-truncation.test.ts b/packages/rest/src/rest-4xx-message-truncation.test.ts index 0e647957ef..8f67e39550 100644 --- a/packages/rest/src/rest-4xx-message-truncation.test.ts +++ b/packages/rest/src/rest-4xx-message-truncation.test.ts @@ -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', () => { diff --git a/packages/rest/src/rest-5xx-status-passthrough.test.ts b/packages/rest/src/rest-5xx-status-passthrough.test.ts index ba865d8387..a6779264bc 100644 --- a/packages/rest/src/rest-5xx-status-passthrough.test.ts +++ b/packages/rest/src/rest-5xx-status-passthrough.test.ts @@ -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)', () => { diff --git a/packages/rest/src/rest-data-door-code-prefix.test.ts b/packages/rest/src/rest-data-door-code-prefix.test.ts new file mode 100644 index 0000000000..976d8b3741 --- /dev/null +++ b/packages/rest/src/rest-data-door-code-prefix.test.ts @@ -0,0 +1,400 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12975] The `/data` door's declared-4xx arm ships HUMAN LANGUAGE in `error`. + * + * --------------------------------------------------------------------------- + * The ruling + * --------------------------------------------------------------------------- + * Maintainer, 2026-08-29 (issue #12975, option 1): the `/data` door's + * declared-4xx arm strips the ADR-0111 `CODE:` prefix from the human-readable + * `error` string, converging with the share-route family — ONE envelope + * semantics: `error` = human language, `code` = the machine token (already + * carried separately via `thrownCodeFields`). + * + * What the user was reading before it, on a zh-CN deployment, through + * `PATCH /api/v1/data/:object/:id`: + * + * FORBIDDEN: 您无权修改或删除这条记录,如需修改请联系该记录的负责人或管理员。 + * + * The actionable half is localized; the machine token glued in front of it was + * the only non-human fragment left in the toast. + * + * --------------------------------------------------------------------------- + * Why every case asserts BOTH halves + * --------------------------------------------------------------------------- + * There are two ways to get this wrong and only one of them is the defect. + * Asserting `error` alone passes just as happily for the other one — dropping + * the machine token along with the prefix — so each case pins the token's new + * home (`code`, or the `declaredCode` sibling when the spelling is + * unregistered) beside the sentence. The token moves AXIS; it never leaves the + * body. + * + * --------------------------------------------------------------------------- + * Driven through the real routes + * --------------------------------------------------------------------------- + * Every case boots a real `RestServer`, registers the real routes and calls the + * registered handler, so what is asserted is the wire body a client receives — + * not a hand-built envelope agreeing with a hand-built expectation. + * + * --------------------------------------------------------------------------- + * Anti-vacuity — directions predicted BEFORE running, measured after + * --------------------------------------------------------------------------- + * Ablation leg: this file run with ONLY `error-response.ts` reverted to the + * pre-fix bytes (fix committed first; revert `git checkout -- `, restore `git checkout HEAD -- `, both under + * `trap … EXIT INT TERM`, the mutation proven on disk by grepping the removed + * text and comparing blob hashes, the restore proven by `git diff HEAD` empty). + * + * No rebuild between legs, and that is load-bearing rather than an omission: + * every symbol under test is reached by a RELATIVE import inside this package, + * which vitest transforms from source — no `dist/` sits between the mutation + * and the assertion. The `exports`-resolved workspace deps (`@objectstack/types`, + * `@objectstack/spec`) are untouched by the mutation. + * + * Measured on the revert, 9 red / 48 green across this file and the two moved + * pins, against predictions written first: + * + * §1 predicted RED 4 measured 4 red — as predicted. + * §2 predicted GREEN 8 measured green — as predicted, and these are the real + * controls: a fix that stripped by PATTERN instead of by + * the declared code reddens on the no-code and the + * non-matching-prefix cases and nowhere else. + * §3 predicted RED 1 measured 1 red — as predicted. + * §4 predicted RED 1 measured 1 red — as predicted. + * §5 predicted GREEN 3 measured 2 green, 1 RED. ⚠️ The prediction was WRONG + * and is recorded rather than re-fitted: `CONVERGENCE` + * asserts BOTH doors, so half of it reads the `/data` + * side the revert removes. Red is the correct answer + * for it. The other two are genuine controls and stayed + * green — the share family is untouched by this card, + * in the arm that already stripped and in the two exits + * measured still carrying the prefix. + * + * The two moved pins (`rest-4xx-message-truncation.test.ts`, + * `rest-5xx-status-passthrough.test.ts`) reddened one case each under the same + * revert and every OTHER case in both files stayed green — which is the check + * that exactly the two the ruling authorised moved, and nothing else was + * loosened to make room. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +// `.js` extension deliberately: this package resolves `nodenext`, so an +// extensionless relative import is a `tsc` error (TS2835). +import { RestServer } from './rest-server.js'; + +const ITEM = '/api/v1/data/:object/:id'; +const COLLECTION = '/api/v1/data/:object'; +const SHARES = '/api/v1/data/:object/:id/shares'; + +/** The sentence #12260 put on the wire, verbatim — the reason this card exists. */ +const ZH = '您无权修改或删除这条记录,如需修改请联系该记录的负责人或管理员。'; + +function mockServer() { + 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 mockRes() { + 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); + res.write = vi.fn(); + res.end = vi.fn(() => res); + res.send = vi.fn(() => res); + return res; +} + +/** + * @param protocolOverrides the data-protocol methods the route calls. + * @param sharingService what `sharingServiceProvider` resolves to; omit to + * leave the record-share routes unserved. + */ +function boot(protocolOverrides: Record = {}, sharingService?: any) { + 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_inquiry' }]), + getMetaItem: vi.fn().mockResolvedValue({}), + findData: vi.fn().mockResolvedValue([]), + createData: vi.fn().mockResolvedValue({}), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({}), + batchData: vi.fn().mockResolvedValue({}), + createManyData: vi.fn().mockResolvedValue({}), + updateManyData: vi.fn().mockResolvedValue({}), + deleteManyData: vi.fn().mockResolvedValue({}), + ...protocolOverrides, + }; + const rest = new RestServer( + mockServer() as any, protocol, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, + sharingService === undefined ? undefined : (async () => sharingService) as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); + rest.registerRoutes(); + return rest; +} + +async function call(rest: any, method: string, path: string, req: Record) { + const found = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + if (!found) throw new Error(`route not registered: ${method} ${path}`); + const res = mockRes(); + await found.handler({ method, query: {}, headers: {}, params: {}, body: {}, ...req }, res); + return { status: res.statusCode, body: res._body }; +} + +const thrown = (message: string, extra: Record = {}) => + Object.assign(new Error(message), extra); + +/** `sharing-plugin.ts`'s by-id write gate, verbatim: the card's live producer. */ +const sharingWriteRefusal = () => + thrown(`FORBIDDEN: ${ZH}`, { code: 'FORBIDDEN', status: 403 }); + +const patchWith = (error: unknown) => call( + boot({ updateData: vi.fn().mockRejectedValue(error) }), + 'PATCH', ITEM, { params: { object: 'showcase_inquiry', id: 'rec1' }, body: { name: 'x' } }, +); +const deleteWith = (error: unknown) => call( + boot({ deleteData: vi.fn().mockRejectedValue(error) }), + 'DELETE', ITEM, { params: { object: 'showcase_inquiry', id: 'rec1' } }, +); + +let errorSpy: ReturnType; +beforeEach(() => { errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); +afterEach(() => { errorSpy.mockRestore(); }); + +// --------------------------------------------------------------------------- +// §1 The ruled behaviour, on the two routes the report was filed from +// --------------------------------------------------------------------------- + +describe('[#12975] the declared-4xx arm hands the caller the human half only', () => { + it('PATCH /data/:object/:id — the localized sentence arrives with no machine prefix', async () => { + const answer = await patchWith(sharingWriteRefusal()); + + expect(answer.status).toBe(403); + // The half a user reads: human language, and ONLY human language. + expect(answer.body.error).toBe(ZH); + expect(String(answer.body.error).startsWith('FORBIDDEN')).toBe(false); + // The other half of the same fact: the machine token is still on the + // wire, on the axis that owns it. Without this assertion the case + // above would also pass for "the token was dropped entirely". + expect(answer.body.code).toBe('FORBIDDEN'); + }); + + it('DELETE /data/:object/:id — one key serves both write verbs', async () => { + const answer = await deleteWith(sharingWriteRefusal()); + + expect(answer.status).toBe(403); + expect(answer.body.error).toBe(ZH); + expect(answer.body.code).toBe('FORBIDDEN'); + }); + + it('the sentence a zh-CN user reads carries no Latin prose at all', async () => { + // The end-user-facing claim #12260 made, now true end to end: before + // this card the body opened with `FORBIDDEN:` and this assertion was + // false on the wire while being true inside the plugin. + const answer = await patchWith(sharingWriteRefusal()); + expect(String(answer.body.error)).not.toMatch(/[A-Za-z]/); + }); + + it('a producer-declared `userMessage` still rides the body, unchanged', async () => { + const answer = await patchWith( + thrown(`FORBIDDEN: ${ZH}`, { code: 'FORBIDDEN', status: 403, userMessage: '请联系管理员' }), + ); + expect(answer.body).toEqual({ + error: ZH, code: 'FORBIDDEN', object: 'showcase_inquiry', userMessage: '请联系管理员', + }); + }); +}); + +// --------------------------------------------------------------------------- +// §2 What must NOT move — measured byte-identical before and after +// --------------------------------------------------------------------------- + +describe('[#12975] every branch that is not the declared-4xx idiom is untouched', () => { + it('a declared 4xx whose message carries no prefix is unchanged', async () => { + const answer = await patchWith( + thrown('insufficient privileges', { code: 'FORBIDDEN', status: 403 }), + ); + expect(answer.body).toEqual({ + error: 'insufficient privileges', code: 'FORBIDDEN', object: 'showcase_inquiry', + }); + }); + + it('⭐ a declared 4xx with NO `code` KEEPS its prefix — the token is nowhere else', async () => { + // The control that rules out a blanket SCREAMING_SNAKE strip. + // `thrownCodeFields` answers `{}` for a producer that named no code + // (ADR-0112: nothing is invented for the half it did not declare), so + // stripping here would delete the only machine token in the response + // rather than move it to its axis. + const answer = await patchWith(thrown(`FORBIDDEN: ${ZH}`, { status: 403 })); + expect(answer.body).toEqual({ + error: `FORBIDDEN: ${ZH}`, object: 'showcase_inquiry', + }); + expect('code' in answer.body).toBe(false); + }); + + it('⭐ a prefix that does not name the declared code is left alone — driver prose stays', async () => { + // The second control: the strip is anchored to the producer's own + // `code`, so a message opening with some other capitalised word and a + // colon is not eaten by it. + const answer = await patchWith( + thrown('SQLITE_ERROR: no such table: showcase_inquiry', { code: 'FORBIDDEN', status: 400 }), + ); + expect(answer.body.error).toBe('SQLITE_ERROR: no such table: showcase_inquiry'); + }); + + it('a declared 5xx still withholds its prose entirely (#5437/#5582)', async () => { + const answer = await patchWith( + thrown('SERVICE_UNAVAILABLE: pool down', { code: 'SERVICE_UNAVAILABLE', status: 503 }), + ); + expect(answer.status).toBe(503); + expect(answer.body.code).toBe('SERVICE_UNAVAILABLE'); + expect(String(answer.body.error)).not.toContain('pool down'); + expect(String(answer.body.error)).not.toContain('SERVICE_UNAVAILABLE:'); + }); + + it('an undeclared error still reaches the sanitised 500 terminal', async () => { + const answer = await patchWith(thrown('FORBIDDEN: something')); + expect(answer.status).toBe(500); + expect(answer.body.code).toBe('INTERNAL_ERROR'); + }); + + it('a sandbox refusal still answers 400 with the author’s own sentence', async () => { + const answer = await patchWith(Object.assign( + thrown("hook 'guard' threw: Error: Opportunity is closed."), + { name: 'SandboxError', innerMessage: 'Opportunity is closed.' }, + )); + expect(answer.status).toBe(400); + expect(answer.body.error).toBe('Opportunity is closed.'); + }); + + it('DELETE_RESTRICTED answers from the arm ABOVE the passthrough, prefix and all', async () => { + const answer = await deleteWith(thrown('DELETE_RESTRICTED: dependents exist', { + code: 'DELETE_RESTRICTED', status: 409, dependentCount: 3, + })); + expect(answer.status).toBe(409); + expect(answer.body.error).toBe('DELETE_RESTRICTED: dependents exist'); + }); + + it('OBJECT_NOT_FOUND keeps its canonical 404 body', async () => { + // The arm ABOVE the passthrough, so the strip is never consulted. Its + // sentence is re-derived from the route's own object name rather than + // echoed from the throw, which is why the expectation names + // `showcase_inquiry` and not whatever the producer wrote. + const answer = await patchWith(thrown("Object 'zz' is not registered", { + code: 'OBJECT_NOT_FOUND', status: 404, + })); + expect(answer.status).toBe(404); + expect(answer.body.code).toBe('OBJECT_NOT_FOUND'); + expect(answer.body.error).toBe("Object 'showcase_inquiry' is not registered"); + }); +}); + +// --------------------------------------------------------------------------- +// §3 An UNREGISTERED spelling — the token lands on `declaredCode` +// --------------------------------------------------------------------------- + +describe('[#12975] a demoted spelling still carries the token beside the sentence', () => { + it('the prefix is read against the PRODUCER’s spelling, not the narrowed `code`', async () => { + // #9232 demotes an unregistered thrown spelling to a `declaredCode` + // sibling and fills `code` from the status. The prefix restates what + // the producer WROTE, so anchoring the strip on the narrowed value + // would miss exactly this shape — and the token is still on the wire, + // one field over. + const answer = await patchWith(thrown('RECORD_LOCKED_BY_APP: the row is checked out', { + code: 'RECORD_LOCKED_BY_APP', status: 423, + })); + expect(answer.status).toBe(423); + expect(answer.body.error).toBe('the row is checked out'); + expect(answer.body.declaredCode).toBe('RECORD_LOCKED_BY_APP'); + expect(typeof answer.body.code).toBe('string'); + }); +}); + +// --------------------------------------------------------------------------- +// §4 A message that is nothing BUT the prefix +// --------------------------------------------------------------------------- + +describe('[#12975] a message with no human half degrades rather than shipping a bare token', () => { + it('`CODE:` alone becomes the generic sentence, with the token still on `code`', async () => { + const answer = await patchWith(thrown('FORBIDDEN:', { code: 'FORBIDDEN', status: 403 })); + expect(answer.body.error).toBe('Request failed'); + expect(answer.body.code).toBe('FORBIDDEN'); + }); +}); + +// --------------------------------------------------------------------------- +// §5 The share-route family — what converged, and what measurably did NOT +// --------------------------------------------------------------------------- + +describe('[#12975] the share family: convergence, and the two exits still carrying the prefix', () => { + const throwingShareService = (error: unknown) => ({ + listShares: vi.fn().mockRejectedValue(error), + grant: vi.fn().mockRejectedValue(error), + revoke: vi.fn().mockRejectedValue(error), + }); + + it('the ADR-0111 prefix-idiom arm still strips — unchanged by this card', async () => { + const answer = await call( + boot({}, throwingShareService(thrown('NOT_FOUND: record showcase_inquiry/rec1 does not exist'))), + 'GET', SHARES, { params: { object: 'showcase_inquiry', id: 'rec1' } }, + ); + expect(answer.status).toBe(404); + expect(answer.body.error.code).toBe('NOT_FOUND'); + expect(answer.body.error.message).toBe('record showcase_inquiry/rec1 does not exist'); + }); + + it('CONVERGENCE — that arm and the `/data` door now answer one semantics', async () => { + // The same fact stated from both doors: neither puts the machine token + // inside the sentence, and both keep it on the code axis. This is the + // pin the ruling asked for; it reds if either door starts disagreeing + // again, whichever one moves. + const shareAnswer = await call( + boot({}, throwingShareService(thrown(`PERMISSION_DENIED: ${ZH}`))), + 'GET', SHARES, { params: { object: 'showcase_inquiry', id: 'rec1' } }, + ); + const dataAnswer = await patchWith(sharingWriteRefusal()); + + expect(shareAnswer.body.error.message).toBe(ZH); + expect(dataAnswer.body.error).toBe(ZH); + for (const sentence of [shareAnswer.body.error.message, dataAnswer.body.error]) { + expect(String(sentence)).not.toMatch(/^[A-Z][A-Z0-9_]*:/); + } + expect(shareAnswer.body.error.code).toBe('PERMISSION_DENIED'); + expect(dataAnswer.body.code).toBe('FORBIDDEN'); + }); + + it('⚠️ MEASURED, NOT REPAIRED HERE — two exits still ship the prefix', async () => { + // Recorded rather than fixed: the ruling moved ONE arm, and both exits + // below are reached through `resolveErrorResponse`'s own declared-4xx + // passthrough, which it did not name. Filed for the maintainer as + // #13095; this case is the evidence, and it REDS the day either exit is + // converged, which is the point — the follow-up moves it deliberately + // instead of discovering the divergence a third time. + // + // (a) the record-share family's CLASSIFIED arm — a producer that + // declared `{ code, status }` AND used the prefix idiom; + // (b) `/data`'s bulk exits (batch / createMany / updateMany / + // deleteMany / clone), which report through `handleRouteError`. + const classified = await call( + boot({}, throwingShareService(sharingWriteRefusal())), + 'GET', SHARES, { params: { object: 'showcase_inquiry', id: 'rec1' } }, + ); + expect(classified.body.error.message).toBe(`FORBIDDEN: ${ZH}`); + + const bulk = await call( + boot({ batchData: vi.fn().mockRejectedValue(sharingWriteRefusal()) }), + 'POST', `${COLLECTION}/batch`, + { params: { object: 'showcase_inquiry' }, body: { operation: 'update', records: [{ id: 'r1' }] } }, + ); + expect(bulk.body.error).toBe(`FORBIDDEN: ${ZH}`); + }); +});