Skip to content

Commit 8bd1b3b

Browse files
committed
fix(rest): the /references door answers both 501 refusals in one ADR-0112 envelope
`GET /api/v1/meta/:type/:name/references` could refuse in two ways and the two answers agreed on neither the envelope nor the message: A protocol cannot answer for this TARGET type (a `field`) 501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"} B the resolved kernel has no `findReferencesToMeta` at all 501 {"error":{"code":"NOT_IMPLEMENTED","message":"…not available in this kernel"}} Two facts were lost on A, and both of them are the operator's. The PRESCRIPTION: A's message names the question that IS answerable ("Ask the owning object instead: GET /api/v1/meta/object/<owner>/references"), which is what keeps "the question was never asked" from being read as "nothing depends on it" in front of an operator whose next click is a delete — ADR-0110 D3, and `findReferencesToMeta` says the message is prescriptive for that reason in as many words. On the wire it was replaced by "Internal server error". And the `code`'s POSITION: `body.error.code` read on B and `undefined` on A, the very dialect this route's own comment on the B branch warns against. A reached the wire through `handleRouteError` -> `declaredServerFaultAnswer`, which is correct for a server FAULT (#11718, #5582) and cannot see that a producer-declared 5xx might be a deliberate REFUSAL whose message is authored for the caller. Teaching the relay that distinction would change behaviour for every producer-declared 5xx at every door; this takes the bounded half instead — one door re-dressing one refusal in the dialect it already publishes. The general question is handed back as its own finding. The arm is keyed to a declared 501 carrying this route's own published refusal code and a non-empty message, and the catch is scoped to the protocol call alone, so neither `resolveProtocol` nor the `resolveExecCtx` seam can reach it. A `sys_metadata` outage's 503 still propagates withheld, flat and logged. `rest-server-meta-references-refusal-envelope.test.ts` drives both refusals on one boot and pins the prescription and the `code` position together, with the withheld-503 and unpublished-501 controls that keep the arm from reading as "5xx prose is public now". The existing #9327 pin in `rest-server-meta-read-org-scope.test.ts` reads the code through both dialects on purpose and is green either way; its comment described the divergence as standing, so it is rewritten rather than left falsified. Fixes #15685 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
1 parent a4816a7 commit 8bd1b3b

5 files changed

Lines changed: 429 additions & 21 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
`GET /api/v1/meta/:type/:name/references`: both of the door's 501 refusals now answer the same ADR-0112 nested envelope, and the unanswerable-target refusal keeps the prescriptive message ADR-0110 D3 requires of it.
6+
7+
The route can refuse in two ways, and the two answers agreed on neither the envelope nor the message:
8+
9+
```
10+
A the protocol cannot answer for this TARGET type (a `field`)
11+
501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"}
12+
B the resolved kernel has no `findReferencesToMeta` at all
13+
501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}}
14+
```
15+
16+
A now answers in B's shape, carrying the producer's own sentence:
17+
18+
```
19+
501 {"error":{"code":"NOT_IMPLEMENTED","message":"[unanswerable_target] References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}}
20+
```
21+
22+
Why the message matters more than it looks. This door backs the admin "Used by" panel, whose empty case renders "Nothing in the metadata graph points at this item. Safe to delete." to an operator whose next click is a delete. A `field` target can never MATCH a reference site — fields are addressed by the composite `<object>.<field>` key while every property naming one holds the bare name — so the protocol refuses instead of answering an empty list, and its message names the question that IS answerable: ask the owning object. Relayed as "Internal server error", that instruction never reached the operator.
23+
24+
Two consequences for a caller:
25+
26+
- `body.error.code` now reads `NOT_IMPLEMENTED` on **both** refusals; the top-level sibling `body.code` this route used to answer on refusal A is gone. `@objectstack/client` reads either position, so `err.code` is unchanged for SDK callers; `err.message` improves from `Internal server error` to the prescriptive sentence. A raw HTTP caller branching on `body.code` for this route's 501 should read `body.error.code`, which is what the route's other refusal has always answered.
27+
- Nothing else on the door moves. A genuine server fault reaching this route — the 503 a `sys_metadata` outage raises — keeps its withheld generic message and its flat body, and 200 answers are untouched.

packages/rest/src/error-response.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,8 +397,14 @@ function armSentence(error: any): unknown {
397397
* `statusCode` rather than blocking it, which is what makes better-auth's
398398
* `APIError` (`{ statusCode: 403, status: 'FORBIDDEN' }` — the status field is a
399399
* STRING there) resolve to the status it meant instead of to nothing.
400+
*
401+
* [#15685] Exported so the `/meta/:type/:name/references` door can ask THIS
402+
* question — "did the producer declare a status, in either spelling" — instead
403+
* of re-deriving it beside its own refusal arm. A read, not a policy: the
404+
* export moves no wire byte, and `error-response.ts` is not part of
405+
* `@objectstack/rest`'s package entry, so nothing published changes either.
400406
*/
401-
function declaredHttpStatus(error: any): number | undefined {
407+
export function declaredHttpStatus(error: any): number | undefined {
402408
const declared =
403409
(typeof error?.status === 'number' ? error.status : undefined) ??
404410
(typeof error?.statusCode === 'number' ? error.statusCode : undefined);

packages/rest/src/rest-server-meta-read-org-scope.test.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,16 +1121,25 @@ describe('#13753 GET /meta/:type/:name/references states the org partition', ()
11211121
// reads as "the question was never asked", so a scope repair that
11221122
// moved either would be moving the destructive-action clearance.
11231123
//
1124-
// ⚠️ The code is read through BOTH refusal dialects on purpose.
1125-
// Measured on this boot, the two 501s this route can answer do not
1126-
// agree: the missing-method branch hand-builds the ADR-0112 NESTED
1127-
// `{ error: { code, message } }`, while the protocol-raised
1128-
// unanswerable-target refusal reaches the wire as the FLAT
1129-
// `{ error: 'Internal server error', code }` — the prescriptive
1130-
// "ask the owning object instead" message scrubbed. That is a
1131-
// finding of its own, filed as #15685; it is NOT this card's
1132-
// subject, and reading both keeps this pin measuring the thing it
1133-
// is about.
1124+
// ⚠️ The code is read through BOTH refusal dialects on purpose,
1125+
// and the reason CHANGED with #15685 — so the sentence is rewritten
1126+
// rather than left standing as a falsified one.
1127+
//
1128+
// It used to accommodate a real divergence: the missing-method
1129+
// branch hand-built the ADR-0112 NESTED `{ error: { code, message } }`
1130+
// while the protocol-raised unanswerable-target refusal reached the
1131+
// wire as the FLAT `{ error: 'Internal server error', code }`, its
1132+
// prescriptive "ask the owning object instead" message scrubbed.
1133+
// #15685 closed that: both exits now answer the nested envelope, and
1134+
// `body.error.code` reads the same way on each.
1135+
//
1136+
// The tolerant read STAYS, deliberately. The envelope and the
1137+
// message are pinned — positionally, and on both refusals at once —
1138+
// by `rest-server-meta-references-refusal-envelope.test.ts`, which
1139+
// is where a regression in either belongs. What THIS pin measures is
1140+
// that a SCOPE repair moves neither the code nor the status, and
1141+
// reading the code wherever it sits is what keeps it measuring that
1142+
// and not a second copy of the envelope contract.
11341143
const refused = await b.references('field', 'account.owner');
11351144
const body = refused.body as any;
11361145
const observed = refused.thrown
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #15685 — `GET /api/v1/meta/:type/:name/references` can refuse in two ways,
4+
// and until this file the two answers agreed on neither the envelope nor the
5+
// message. Measured on one boot, through the REAL route:
6+
//
7+
// A the protocol cannot answer for this TARGET type (#9327, `field`)
8+
// 501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"}
9+
// B the resolved kernel has no `findReferencesToMeta` at all (#9326)
10+
// 501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}}
11+
//
12+
// ── What the divergence COST, which is why this is pinned at the wire ──────
13+
//
14+
// This door backs the admin "Used by" panel, whose empty case renders "Nothing
15+
// in the metadata graph points at this item. Safe to delete." to an operator
16+
// whose next click is a delete. ADR-0110 D3 (#8896) exists so that "the
17+
// question was never asked" is never answered as "nothing depends on it", and
18+
// A's message is the half that steers the operator away from the first reading
19+
// — `findReferencesToMeta` says so in as many words ("The message is
20+
// prescriptive per ADR-0110 D3: it names the answerable question"). It names
21+
// the question that IS answerable: ask the owning OBJECT. On the wire that
22+
// instruction had been replaced by "Internal server error".
23+
//
24+
// Second, `body.error.code` read on B and `undefined` on A — and the door's own
25+
// comment on the B branch warns against exactly that dialect ("never the
26+
// bare-string or sibling-`code` dialects, which make `body.error.code` read
27+
// `undefined`"). One route was violating its own written rule at its other
28+
// exit.
29+
//
30+
// ── Why this file exists rather than another assertion in the org-scope pin ─
31+
//
32+
// `rest-server-meta-read-org-scope.test.ts` already drives this route's #9327
33+
// refusal — and reads the code through BOTH dialects on purpose, so it is GREEN
34+
// under either shape. That is correct for what it measures (the refusal's code
35+
// and status survive a SCOPE repair) and useless as a red/green criterion for
36+
// this one. The two facts below are not that file's subject, and they need a
37+
// boot WITHOUT `findReferencesToMeta` beside a boot with it, which its harness
38+
// does not build. So: a file of its own, and the assertions are POSITIONAL —
39+
// `body.error.code`, `body.error.message` — because position is half the
40+
// finding.
41+
42+
import { describe, it, expect } from 'vitest';
43+
import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
44+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
45+
import { RestServer } from './rest-server.js';
46+
47+
const META = '/api/v1/meta';
48+
49+
/** The unanswerable target (#9327) and an answerable one, for the control. */
50+
const UNANSWERABLE_TARGET = 'field';
51+
const UNANSWERABLE_NAME = 'account.owner';
52+
const ANSWERABLE_TARGET = 'object';
53+
54+
function mockRes() {
55+
const res: any = { statusCode: 200, _body: undefined };
56+
res.status = (c: number) => { res.statusCode = c; return res; };
57+
res.json = (b: any) => { res._body = b; return res; };
58+
res.send = (b: any) => { res._body = b; return res; };
59+
res.header = () => res;
60+
res.setHeader = () => res;
61+
res.end = () => res;
62+
return res;
63+
}
64+
65+
/**
66+
* The narrowest engine this route's reads bottom out on: every metadata lookup
67+
* answers "no rows". Deliberately empty rather than seeded — nothing below
68+
* asserts on reference CONTENT, and an empty store is what makes the
69+
* answerable-target control's `{ references: [] }` unambiguous.
70+
*
71+
* ⛔ READ-ONLY on purpose: no `delete`, `update` or `insert` member exists,
72+
* because nothing this file drives writes. A door that started writing here
73+
* would fail on the missing member rather than silently exercise a write double
74+
* nobody pinned — and this fixture therefore adds no new `delete()` double for
75+
* `check:engine-double-contract` to police.
76+
*
77+
* The `registry` member is not optional decoration: `getMetaItems` reads
78+
* `registry.listItems` on every source type the reference sweep walks, and an
79+
* engine without it answers 500 — which would have read as "the door refuses
80+
* answerable targets too", i.e. it would have silently voided the control this
81+
* fixture exists to provide.
82+
*/
83+
function emptyEngine(): any {
84+
return {
85+
find: async () => [],
86+
findOne: async () => null,
87+
count: async () => 0,
88+
aggregate: async () => [],
89+
registry: {
90+
listItems: () => [],
91+
getItem: () => undefined,
92+
getObject: () => undefined,
93+
getPackage: () => undefined,
94+
getArtifactItem: () => undefined,
95+
isPackageDisabled: () => false,
96+
},
97+
};
98+
}
99+
100+
/**
101+
* One boot on the REAL route table. `mutate` is applied to the real protocol
102+
* before the routes are registered, which is how the two refusals and the
103+
* fault control are reached: by changing what the PRODUCER does, never by
104+
* stubbing the door.
105+
*/
106+
function boot(mutate: (protocol: any) => void = () => {}) {
107+
const protocol: any = new ObjectStackProtocolImplementation(emptyEngine(), () => new Map());
108+
protocol.getDiscovery = async () => ({
109+
version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' },
110+
});
111+
mutate(protocol);
112+
113+
const rest = new RestServer(
114+
{ get() {}, post() {}, put() {}, patch() {}, delete() {}, use() {} } as any,
115+
protocol as any,
116+
{ api: { requireAuth: false } } as any,
117+
);
118+
(rest as any).resolveExecCtx = async () => ({
119+
userId: 'u1', systemPermissions: ['manage_metadata'], tenantId: 'org_alpha',
120+
});
121+
rest.registerRoutes();
122+
123+
return async (type: string, name: string) => {
124+
const route = (rest as any).getRoutes().find(
125+
(r: any) => r.method === 'GET' && r.path === `${META}/:type/:name/references`,
126+
);
127+
if (!route) throw new Error('route not registered: GET /meta/:type/:name/references');
128+
const res = mockRes();
129+
let thrown: any;
130+
try {
131+
await route.handler(
132+
{ method: 'GET', path: '', params: { type, name }, query: {}, headers: {}, body: {} } as any,
133+
res,
134+
);
135+
} catch (err) { thrown = err; }
136+
return { status: res.statusCode, body: res._body as any, thrown };
137+
};
138+
}
139+
140+
/** Refusal A: the real protocol, asked for a target it cannot answer for. */
141+
const refusalA = () => boot()(UNANSWERABLE_TARGET, UNANSWERABLE_NAME);
142+
143+
/** Refusal B: a kernel whose resolved protocol has no such method at all. */
144+
const refusalB = () => boot((p) => { p.findReferencesToMeta = undefined; })(
145+
ANSWERABLE_TARGET, 'account',
146+
);
147+
148+
describe('#15685 the /references door answers its two refusals in ONE envelope', () => {
149+
// ── anti-vacuity ──────────────────────────────────────────────────────
150+
//
151+
// Every assertion below is about a REFUSAL, so a door that refused
152+
// everything would satisfy them all. It does not:
153+
it('control — an ANSWERABLE target on the same harness still answers 200', async () => {
154+
const answered = await boot()(ANSWERABLE_TARGET, 'account');
155+
expect(answered.thrown, `the door threw: ${answered.thrown?.message}`).toBeUndefined();
156+
expect({ status: answered.status, body: answered.body }).toEqual({
157+
status: 200, body: { references: [] },
158+
});
159+
});
160+
161+
// ── ① the prescription reaches the caller ─────────────────────────────
162+
describe('① refusal A keeps the prescriptive ADR-0110 D3 sentence', () => {
163+
it('names the answerable question instead of "Internal server error"', async () => {
164+
const refused = await refusalA();
165+
expect(refused.thrown, `the door threw: ${refused.thrown?.message}`).toBeUndefined();
166+
expect(refused.status).toBe(501);
167+
168+
const message = refused.body?.error?.message;
169+
// The WHOLE point of the message: what to ask INSTEAD. Anchored on
170+
// the URL it prescribes, derived from the composite key's owner —
171+
// an operator can act on this sentence and on no other.
172+
expect(message).toContain(
173+
`Ask the owning object instead: GET /api/v1/meta/object/account/references`,
174+
);
175+
// And the fact the empty answer would have misreported, spelled out
176+
// rather than left to be inferred from a 501.
177+
expect(message).toContain('cannot be computed');
178+
179+
// The direct statement of the regression, not merely its absence:
180+
// the generic fault text is what this used to be, everywhere in the
181+
// body, and it is gone.
182+
expect(JSON.stringify(refused.body)).not.toContain(INTERNAL_ERROR_MESSAGE);
183+
});
184+
185+
it('control — the generic text really is what a withheld fault says', () => {
186+
// Without this, the assertion above could be passing because
187+
// `INTERNAL_ERROR_MESSAGE` is some string that never appears
188+
// anywhere. It is the exact text refusal A used to ship.
189+
expect(INTERNAL_ERROR_MESSAGE).toBe('Internal server error');
190+
});
191+
});
192+
193+
// ── ② the code reads the same way on BOTH refusals ────────────────────
194+
describe('② `body.error.code` reads the same way on both refusals', () => {
195+
it('both are the ADR-0112 NESTED envelope, with the code in ONE place', async () => {
196+
const [a, b] = await Promise.all([refusalA(), refusalB()]);
197+
198+
expect([a.status, b.status]).toEqual([501, 501]);
199+
// The positional claim, stated as ONE comparison so a repair that
200+
// fixed one exit and not the other cannot read as green.
201+
expect([a.body?.error?.code, b.body?.error?.code])
202+
.toEqual(['NOT_IMPLEMENTED', 'NOT_IMPLEMENTED']);
203+
// …and the sibling-`code` dialect the door's own comment names is
204+
// absent from BOTH, which is the other half of "one place".
205+
expect([a.body?.code, b.body?.code]).toEqual([undefined, undefined]);
206+
// Neither answers the bare-string dialect either.
207+
expect([typeof a.body?.error, typeof b.body?.error]).toEqual(['object', 'object']);
208+
});
209+
210+
it('and each still carries its OWN message — converged envelope, not converged prose', async () => {
211+
const [a, b] = await Promise.all([refusalA(), refusalB()]);
212+
expect(b.body?.error?.message).toBe(
213+
'protocol.findReferencesToMeta() is not available in this kernel',
214+
);
215+
expect(a.body?.error?.message).not.toBe(b.body?.error?.message);
216+
});
217+
});
218+
219+
// ── ③ the arm is a REFUSAL relay, not "5xx prose is public now" ───────
220+
describe('③ controls — a genuine fault is still withheld and still flat', () => {
221+
it('a producer-declared 503 keeps the withheld generic answer', async () => {
222+
// The `sys_metadata` outage class (#8896): `getMetaItems` raises it
223+
// through this same call, and its message can carry driver
224+
// internals. #5582/#11718 withhold it, and this repair must not
225+
// have widened that by a byte.
226+
const drive = boot((p) => {
227+
p.findReferencesToMeta = async () => {
228+
throw Object.assign(
229+
new Error('pg: connection to 10.0.0.7:5432 refused (password=hunter2)'),
230+
{ status: 503, code: 'SERVICE_UNAVAILABLE' },
231+
);
232+
};
233+
});
234+
const refused = await drive(ANSWERABLE_TARGET, 'account');
235+
expect(refused.status).toBe(503);
236+
expect(refused.body).toEqual({ error: INTERNAL_ERROR_MESSAGE, code: 'SERVICE_UNAVAILABLE' });
237+
expect(JSON.stringify(refused.body)).not.toContain('hunter2');
238+
});
239+
240+
it('a 501 declaring a code this door does not publish stays on the fault terminal', async () => {
241+
// The stated boundary of the arm, pinned so it is a DECISION rather
242+
// than an accident: the re-dress is keyed to the one refusal code
243+
// this route publishes, which is also what keeps an unregistered
244+
// producer spelling (#9232) off the nested exit by construction.
245+
const drive = boot((p) => {
246+
p.findReferencesToMeta = async () => {
247+
throw Object.assign(new Error('some other 501'), {
248+
status: 501, code: 'SOMETHING_ELSE',
249+
});
250+
};
251+
});
252+
const refused = await drive(ANSWERABLE_TARGET, 'account');
253+
expect(refused.status).toBe(501);
254+
expect(refused.body?.error).toBe(INTERNAL_ERROR_MESSAGE);
255+
});
256+
});
257+
});

0 commit comments

Comments
 (0)