diff --git a/.changeset/references-door-refusal-envelope-converged.md b/.changeset/references-door-refusal-envelope-converged.md new file mode 100644 index 0000000000..9c777959ec --- /dev/null +++ b/.changeset/references-door-refusal-envelope-converged.md @@ -0,0 +1,27 @@ +--- +"@objectstack/rest": patch +--- + +`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. + +The route can refuse in two ways, and the two answers agreed on neither the envelope nor the message: + +``` +A the 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":"protocol.findReferencesToMeta() is not available in this kernel"}} +``` + +A now answers in B's shape, carrying the producer's own sentence: + +``` +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."}} +``` + +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 `.` 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. + +Two consequences for a caller: + +- `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. +- 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. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index ac5d133bfe..cad79560f5 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -64,7 +64,7 @@ not on any flag. ## How the flag is set `isSystem` is **server-constructed and never client-supplied**. Inbound HTTP -cannot set it (`packages/rest/src/rest-server.ts:1651`, `:1680`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1739`, `:1768`), and neither can an action body (`packages/runtime/src/domains/actions.ts:414`). It is written by internal callers only, as an option on the engine call: @@ -103,7 +103,7 @@ that silently does not happen. | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:250` | | 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` | | 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` | -| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1683` | +| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1771` | ### 2. Write pipeline and data integrity @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 105 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5231`, `:6657`, `:6905`, `:7336`, `:7529` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5319`, `:6766`, `:7014`, `:7445`, `:7638` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:535`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | @@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs. | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1590` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | -| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1651`, `:1680`; `domains/actions.ts:414` | +| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1739`, `:1768`; `domains/actions.ts:414` | --- diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index e9824e268e..f5e6e1b370 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -397,8 +397,14 @@ function armSentence(error: any): unknown { * `statusCode` rather than blocking it, which is what makes better-auth's * `APIError` (`{ statusCode: 403, status: 'FORBIDDEN' }` — the status field is a * STRING there) resolve to the status it meant instead of to nothing. + * + * [#15685] Exported so the `/meta/:type/:name/references` door can ask THIS + * question — "did the producer declare a status, in either spelling" — instead + * of re-deriving it beside its own refusal arm. A read, not a policy: the + * export moves no wire byte, and `error-response.ts` is not part of + * `@objectstack/rest`'s package entry, so nothing published changes either. */ -function declaredHttpStatus(error: any): number | undefined { +export function declaredHttpStatus(error: any): number | undefined { const declared = (typeof error?.status === 'number' ? error.status : undefined) ?? (typeof error?.statusCode === 'number' ? error.statusCode : undefined); diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 6f13ea3044..a988bcef0e 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -392,8 +392,17 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // `enforceAuth` was measured NOT to be the repair). A mention count // that tracked the site count exactly would be measuring one thing // twice. + // + // [#15685] 77 → 77 sites / 99 → 100 mentions. The `/references` door's + // refusal arm added ONE prose mention and NO call site: its comment + // records that the arm's catch is scoped to the protocol call alone, so + // the `resolveExecCtx` seam above it cannot reach the arm whatever that + // seam declares. That is the safety claim the narrow catch rests on, so + // naming the seam is the point of the sentence — and the sentence + // moving only the mention count is this control working: a site was not + // added, and the number that tracks sites did not move. expect(SITES.length).toBe(77); - expect(SOURCE.split('resolveExecCtx').length - 1).toBe(99); + expect(SOURCE.split('resolveExecCtx').length - 1).toBe(100); }); it('the split is 24 locally caught / 53 bare — NOT 16 / 53, which does not add to 77', () => { diff --git a/packages/rest/src/rest-server-meta-read-org-scope.test.ts b/packages/rest/src/rest-server-meta-read-org-scope.test.ts index 679d990de6..69e2926c0b 100644 --- a/packages/rest/src/rest-server-meta-read-org-scope.test.ts +++ b/packages/rest/src/rest-server-meta-read-org-scope.test.ts @@ -1121,16 +1121,25 @@ describe('#13753 GET /meta/:type/:name/references states the org partition', () // reads as "the question was never asked", so a scope repair that // moved either would be moving the destructive-action clearance. // - // ⚠️ The code is read through BOTH refusal dialects on purpose. - // Measured on this boot, the two 501s this route can answer do not - // agree: the missing-method branch hand-builds the ADR-0112 NESTED - // `{ error: { code, message } }`, while the protocol-raised - // unanswerable-target refusal reaches the wire as the FLAT - // `{ error: 'Internal server error', code }` — the prescriptive - // "ask the owning object instead" message scrubbed. That is a - // finding of its own, filed as #15685; it is NOT this card's - // subject, and reading both keeps this pin measuring the thing it - // is about. + // ⚠️ The code is read through BOTH refusal dialects on purpose, + // and the reason CHANGED with #15685 — so the sentence is rewritten + // rather than left standing as a falsified one. + // + // It used to accommodate a real divergence: the missing-method + // branch hand-built the ADR-0112 NESTED `{ error: { code, message } }` + // while the protocol-raised unanswerable-target refusal reached the + // wire as the FLAT `{ error: 'Internal server error', code }`, its + // prescriptive "ask the owning object instead" message scrubbed. + // #15685 closed that: both exits now answer the nested envelope, and + // `body.error.code` reads the same way on each. + // + // The tolerant read STAYS, deliberately. The envelope and the + // message are pinned — positionally, and on both refusals at once — + // by `rest-server-meta-references-refusal-envelope.test.ts`, which + // is where a regression in either belongs. What THIS pin measures is + // that a SCOPE repair moves neither the code nor the status, and + // reading the code wherever it sits is what keeps it measuring that + // and not a second copy of the envelope contract. const refused = await b.references('field', 'account.owner'); const body = refused.body as any; const observed = refused.thrown diff --git a/packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts b/packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts new file mode 100644 index 0000000000..2561309a54 --- /dev/null +++ b/packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts @@ -0,0 +1,272 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #15685 — `GET /api/v1/meta/:type/:name/references` can refuse in two ways, +// and until this file the two answers agreed on neither the envelope nor the +// message. Measured on one boot, through the REAL route: +// +// A the protocol cannot answer for this TARGET type (#9327, `field`) +// 501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"} +// B the resolved kernel has no `findReferencesToMeta` at all (#9326) +// 501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}} +// +// ── What the divergence COST, which is why this is pinned at the wire ────── +// +// 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. ADR-0110 D3 (#8896) exists so that "the +// question was never asked" is never answered as "nothing depends on it", and +// A's message is the half that steers the operator away from the first reading +// — `findReferencesToMeta` says so in as many words ("The message is +// prescriptive per ADR-0110 D3: it names the answerable question"). It names +// the question that IS answerable: ask the owning OBJECT. On the wire that +// instruction had been replaced by "Internal server error". +// +// Second, `body.error.code` read on B and `undefined` on A — and the door's own +// comment on the B branch warns against exactly that dialect ("never the +// bare-string or sibling-`code` dialects, which make `body.error.code` read +// `undefined`"). One route was violating its own written rule at its other +// exit. +// +// ── Why this file exists rather than another assertion in the org-scope pin ─ +// +// `rest-server-meta-read-org-scope.test.ts` already drives this route's #9327 +// refusal — and reads the code through BOTH dialects on purpose, so it is GREEN +// under either shape. That is correct for what it measures (the refusal's code +// and status survive a SCOPE repair) and useless as a red/green criterion for +// this one. The two facts below are not that file's subject, and they need a +// boot WITHOUT `findReferencesToMeta` beside a boot with it, which its harness +// does not build. So: a file of its own, and the assertions are POSITIONAL — +// `body.error.code`, `body.error.message` — because position is half the +// finding. + +import { describe, it, expect } from 'vitest'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; + +const META = '/api/v1/meta'; + +/** The unanswerable target (#9327) and an answerable one, for the control. */ +const UNANSWERABLE_TARGET = 'field'; +const UNANSWERABLE_NAME = 'account.owner'; +const ANSWERABLE_TARGET = 'object'; + +function mockRes() { + const res: any = { statusCode: 200, _body: undefined }; + res.status = (c: number) => { res.statusCode = c; return res; }; + res.json = (b: any) => { res._body = b; return res; }; + res.send = (b: any) => { res._body = b; return res; }; + res.header = () => res; + res.setHeader = () => res; + res.end = () => res; + return res; +} + +/** + * The narrowest engine this route's reads bottom out on: every metadata lookup + * answers "no rows". Deliberately empty rather than seeded — nothing below + * asserts on reference CONTENT, and an empty store is what makes the + * answerable-target control's `{ references: [] }` unambiguous. + * + * ⛔ READ-ONLY on purpose: no `delete`, `update` or `insert` member exists, + * because nothing this file drives writes. A door that started writing here + * would fail on the missing member rather than silently exercise a write double + * nobody pinned — and this fixture therefore adds no new `delete()` double for + * `check:engine-double-contract` to police. + * + * The `registry` member is not optional decoration: `getMetaItems` reads + * `registry.listItems` on every source type the reference sweep walks, and an + * engine without it answers 500 — which would have read as "the door refuses + * answerable targets too", i.e. it would have silently voided the control this + * fixture exists to provide. + */ +function emptyEngine(): any { + return { + find: async () => [], + async findOne(table: string, opts: { where: Record }) { + // The `check:engine-double-contract` pin: a fake looser than + // `ObjectQL.findOne` is how a dead REST route once shipped with its + // suite green. This double answers "no rows" — but it answers it to + // the same dispatch predicate the real engine enforces. + assertEngineFindOnePredicate(table, opts); + return null; + }, + count: async () => 0, + aggregate: async () => [], + registry: { + listItems: () => [], + getItem: () => undefined, + getObject: () => undefined, + getPackage: () => undefined, + getArtifactItem: () => undefined, + isPackageDisabled: () => false, + }, + }; +} + +/** + * One boot on the REAL route table. `mutate` is applied to the real protocol + * before the routes are registered, which is how the two refusals and the + * fault control are reached: by changing what the PRODUCER does, never by + * stubbing the door. + */ +function boot(mutate: (protocol: any) => void = () => {}) { + const protocol: any = new ObjectStackProtocolImplementation(emptyEngine(), () => new Map()); + protocol.getDiscovery = async () => ({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }); + mutate(protocol); + + const rest = new RestServer( + { get() {}, post() {}, put() {}, patch() {}, delete() {}, use() {} } as any, + protocol as any, + { api: { requireAuth: false } } as any, + ); + (rest as any).resolveExecCtx = async () => ({ + userId: 'u1', systemPermissions: ['manage_metadata'], tenantId: 'org_alpha', + }); + rest.registerRoutes(); + + return async (type: string, name: string) => { + const route = (rest as any).getRoutes().find( + (r: any) => r.method === 'GET' && r.path === `${META}/:type/:name/references`, + ); + if (!route) throw new Error('route not registered: GET /meta/:type/:name/references'); + const res = mockRes(); + let thrown: any; + try { + await route.handler( + { method: 'GET', path: '', params: { type, name }, query: {}, headers: {}, body: {} } as any, + res, + ); + } catch (err) { thrown = err; } + return { status: res.statusCode, body: res._body as any, thrown }; + }; +} + +/** Refusal A: the real protocol, asked for a target it cannot answer for. */ +const refusalA = () => boot()(UNANSWERABLE_TARGET, UNANSWERABLE_NAME); + +/** Refusal B: a kernel whose resolved protocol has no such method at all. */ +const refusalB = () => boot((p) => { p.findReferencesToMeta = undefined; })( + ANSWERABLE_TARGET, 'account', +); + +describe('#15685 the /references door answers its two refusals in ONE envelope', () => { + // ── anti-vacuity ────────────────────────────────────────────────────── + // + // Every assertion below is about a REFUSAL, so a door that refused + // everything would satisfy them all. It does not: + it('control — an ANSWERABLE target on the same harness still answers 200', async () => { + const answered = await boot()(ANSWERABLE_TARGET, 'account'); + expect(answered.thrown, `the door threw: ${answered.thrown?.message}`).toBeUndefined(); + expect({ status: answered.status, body: answered.body }).toEqual({ + status: 200, body: { references: [] }, + }); + }); + + // ── ① the prescription reaches the caller ───────────────────────────── + describe('① refusal A keeps the prescriptive ADR-0110 D3 sentence', () => { + it('names the answerable question instead of "Internal server error"', async () => { + const refused = await refusalA(); + expect(refused.thrown, `the door threw: ${refused.thrown?.message}`).toBeUndefined(); + expect(refused.status).toBe(501); + + // `toEqual(stringContaining)` rather than `toContain`, deliberately: + // when the prose is missing this reads `undefined`, and `toContain` + // fails on an `undefined` subject with a matcher TYPE complaint + // rather than with the finding. The diff below names the fact. + const message = refused.body?.error?.message; + // The WHOLE point of the message: what to ask INSTEAD. Anchored on + // the URL it prescribes, derived from the composite key's owner — + // an operator can act on this sentence and on no other. + expect( + message, + 'refusal A carried no nested message — the prescription never reached the caller', + ).toEqual(expect.stringContaining( + 'Ask the owning object instead: GET /api/v1/meta/object/account/references', + )); + // And the fact the empty answer would have misreported, spelled out + // rather than left to be inferred from a 501. + expect(message).toEqual(expect.stringContaining('cannot be computed')); + + // The direct statement of the regression, not merely its absence: + // the generic fault text is what this used to be, everywhere in the + // body, and it is gone. + expect(JSON.stringify(refused.body)).not.toContain(INTERNAL_ERROR_MESSAGE); + }); + + it('control — the generic text really is what a withheld fault says', () => { + // Without this, the assertion above could be passing because + // `INTERNAL_ERROR_MESSAGE` is some string that never appears + // anywhere. It is the exact text refusal A used to ship. + expect(INTERNAL_ERROR_MESSAGE).toBe('Internal server error'); + }); + }); + + // ── ② the code reads the same way on BOTH refusals ──────────────────── + describe('② `body.error.code` reads the same way on both refusals', () => { + it('both are the ADR-0112 NESTED envelope, with the code in ONE place', async () => { + const [a, b] = await Promise.all([refusalA(), refusalB()]); + + expect([a.status, b.status]).toEqual([501, 501]); + // The positional claim, stated as ONE comparison so a repair that + // fixed one exit and not the other cannot read as green. + expect([a.body?.error?.code, b.body?.error?.code]) + .toEqual(['NOT_IMPLEMENTED', 'NOT_IMPLEMENTED']); + // …and the sibling-`code` dialect the door's own comment names is + // absent from BOTH, which is the other half of "one place". + expect([a.body?.code, b.body?.code]).toEqual([undefined, undefined]); + // Neither answers the bare-string dialect either. + expect([typeof a.body?.error, typeof b.body?.error]).toEqual(['object', 'object']); + }); + + it('and each still carries its OWN message — converged envelope, not converged prose', async () => { + const [a, b] = await Promise.all([refusalA(), refusalB()]); + expect(b.body?.error?.message).toBe( + 'protocol.findReferencesToMeta() is not available in this kernel', + ); + expect(a.body?.error?.message).not.toBe(b.body?.error?.message); + }); + }); + + // ── ③ the arm is a REFUSAL relay, not "5xx prose is public now" ─────── + describe('③ controls — a genuine fault is still withheld and still flat', () => { + it('a producer-declared 503 keeps the withheld generic answer', async () => { + // The `sys_metadata` outage class (#8896): `getMetaItems` raises it + // through this same call, and its message can carry driver + // internals. #5582/#11718 withhold it, and this repair must not + // have widened that by a byte. + const drive = boot((p) => { + p.findReferencesToMeta = async () => { + throw Object.assign( + new Error('pg: connection to 10.0.0.7:5432 refused (password=hunter2)'), + { status: 503, code: 'SERVICE_UNAVAILABLE' }, + ); + }; + }); + const refused = await drive(ANSWERABLE_TARGET, 'account'); + expect(refused.status).toBe(503); + expect(refused.body).toEqual({ error: INTERNAL_ERROR_MESSAGE, code: 'SERVICE_UNAVAILABLE' }); + expect(JSON.stringify(refused.body)).not.toContain('hunter2'); + }); + + it('a 501 declaring a code this door does not publish stays on the fault terminal', async () => { + // The stated boundary of the arm, pinned so it is a DECISION rather + // than an accident: the re-dress is keyed to the one refusal code + // this route publishes, which is also what keeps an unregistered + // producer spelling (#9232) off the nested exit by construction. + const drive = boot((p) => { + p.findReferencesToMeta = async () => { + throw Object.assign(new Error('some other 501'), { + status: 501, code: 'SOMETHING_ELSE', + }); + }; + }); + const refused = await drive(ANSWERABLE_TARGET, 'account'); + expect(refused.status).toBe(501); + expect(refused.body?.error).toBe(INTERNAL_ERROR_MESSAGE); + }); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index c9aa8227b8..67f490fe5b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -325,6 +325,7 @@ import { sandboxBusinessMessage, classifiedRefusalAnswer, boundedDeclaredUserMessage, + declaredHttpStatus, declaredServerFaultAnswer, sendThrownError, sendDeclaredFault, @@ -1211,6 +1212,93 @@ async function wiredEngineOrLoud( } } +/** + * [#15685] The `/meta/:type/:name/references` door's PROTOCOL-RAISED refusal, + * re-dressed in the ADR-0112 NESTED envelope that door's other refusal exit + * already publishes. Answers `undefined` for everything else, so the caller + * keeps `handleRouteError` for the rest. + * + * ## The defect this closes + * + * The door can refuse in two ways and, measured on one boot, the two answers + * agreed on neither the envelope nor the message: + * + * ``` + * A protocol-raised, unanswerable TARGET type (#9327) + * 501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"} + * B the resolved kernel has no `findReferencesToMeta` at all (#9326) + * 501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}} + * ``` + * + * Two facts are lost on A, and both of them are the operator's: + * + * 1. **The PRESCRIPTION.** A's message is not decoration — `findReferencesToMeta` + * says so in as many words ("The message is prescriptive per ADR-0110 D3: + * it names the answerable question"). It tells the operator what to ask + * INSTEAD: `Ask the owning object instead: GET /api/v1/meta/object//references`. + * That sentence is what keeps "the question was never asked" from being + * read as "nothing depends on this item" — the whole of ADR-0110 D3 + * (#8896), in front of an operator whose next click is a delete, because + * the admin "Used by" panel renders its empty case as "Nothing in the + * metadata graph points at this item. Safe to delete." On the wire the + * sentence was replaced by "Internal server error". + * 2. **The `code`'s POSITION.** `body.error.code` reads on B and `undefined` + * on A. This door's own comment on the B branch warns against precisely + * that dialect ("never the bare-string or sibling-`code` dialects, which + * make `body.error.code` read `undefined`") — so the route was violating + * its own written rule at its other exit, reached by a different path. + * + * ## Why the repair is HERE and not at the relay + * + * A reaches the wire through `handleRouteError` → {@link declaredServerFaultAnswer}, + * which keeps `status` and `code` and replaces the prose with + * `INTERNAL_ERROR_MESSAGE`. That arm is correct and argued for a server FAULT + * (#11718, #5582) — a fault message may carry driver internals, and withholding + * it is the point. What it cannot see is that a producer-declared 5xx might be + * a deliberate REFUSAL whose message is authored FOR the caller. Teaching it + * that distinction would change platform-wide behaviour for every + * producer-declared 5xx at every door; that is a maintainer's decision and is + * handed back as its own finding, not taken here. This arm is the bounded half: + * ONE door, re-dressing ONE refusal in the dialect it already publishes. + * + * ⛔ It is therefore NOT a general "5xx prose is relayed now" rule, and the + * three conditions below are what keep it from becoming one. + * + * ## The three conditions, and why each is exactly this narrow + * + * - **`501`**, read through {@link declaredHttpStatus} so both declaration + * spellings (`status` / `statusCode`, #7525) reach the same verdict rather + * than through a fourth local opinion about which field declares a status. + * - **`code === 'NOT_IMPLEMENTED'`**, the literal this route already publishes + * on its B exit. Matching the LITERAL rather than "any declared code" is + * also how the #9232 vocabulary narrowing is honoured by CONSTRUCTION: an + * unregistered producer spelling can never reach this exit, so no second + * copy of `thrownCodeFields`' demotion rule is needed and no new door ships + * an un-narrowed code. ⚠️ The cost is stated rather than hidden: a future + * SECOND refusal code on this route would fall back to the flat fault answer + * until whoever adds it comes here. That is a visible, one-line extension, + * not a silent gap. + * - **A non-empty message.** This arm exists to relay PROSE; with none + * declared there is nothing to relay, and inventing one is the half + * {@link declaredServerFaultAnswer} refuses to invent too. + * + * ⛔ And it does not re-derive `REFERENCE_SITES.unanswerableTargetTypes` to + * decide whether the target was answerable. That set, its canonical-type fold + * and its refusal all belong to `findReferencesToMeta`; a second copy at the + * transport is the tolerant-consumer direction, and it would drift the moment + * the set changed. The route reads what the protocol DECLARED, which is what a + * transport is for. + */ +function notImplementedRefusalAnswer( + error: any, +): { status: number; body: { error: { code: string; message: string } } } | undefined { + if (declaredHttpStatus(error) !== 501) return undefined; + if (error?.code !== 'NOT_IMPLEMENTED') return undefined; + const message = typeof error?.message === 'string' ? error.message : ''; + if (message.length === 0) return undefined; + return { status: 501, body: { error: { code: 'NOT_IMPLEMENTED', message } } }; +} + export class RestServer { private protocol: RestProtocol; private config: NormalizedRestServerConfig; @@ -5844,16 +5932,37 @@ export class RestServer { // laundering it into an org-unscoped 200. const referencesCtx = await this.resolveExecCtx(environmentId, req) .catch(rethrowAuthzStoreUnavailable); - const result = await (p as any).findReferencesToMeta({ - type: req.params.type, - name: req.params.name, - // SPREAD, never `organizationId: x ?? null` — the - // implementation declares `organizationId?: string` - // (optional plain string, not nullable), and it - // forwards on truthiness. - ...(referencesCtx?.tenantId ? { organizationId: referencesCtx.tenantId } : {}), - ...(environmentId ? { environmentId } : {}), - }); + // [#15685] The protocol's OWN refusal is re-answered in + // the nested envelope the branch above already uses — + // see {@link notImplementedRefusalAnswer} for why the + // repair is here and how narrow it is. The catch is + // scoped to the protocol call ALONE, so what this arm + // can re-dress is mechanically the set of things + // `findReferencesToMeta` raised: neither + // `resolveProtocol` nor the `resolveExecCtx` seam above + // can reach it, whatever they declare. + let result: unknown; + try { + result = await (p as any).findReferencesToMeta({ + type: req.params.type, + name: req.params.name, + // SPREAD, never `organizationId: x ?? null` — the + // implementation declares `organizationId?: string` + // (optional plain string, not nullable), and it + // forwards on truthiness. + ...(referencesCtx?.tenantId ? { organizationId: referencesCtx.tenantId } : {}), + ...(environmentId ? { environmentId } : {}), + }); + } catch (raised: any) { + const refusal = notImplementedRefusalAnswer(raised); + // Anything else is the outage it always was — the + // 503 `getMetaItems` raises for a `sys_metadata` + // failure (#8896) still propagates to the terminal + // below, message-withheld and logged, unchanged. + if (refusal === undefined) throw raised; + res.status(refusal.status).json(refusal.body); + return; + } res.json(result); } catch (error: any) { handleRouteError(res, error); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 81ef3511f3..5af6d82b6e 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3141,6 +3141,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/runtime/src/action-declarative-update.test.ts", "verb": "update",