diff --git a/.changeset/18401-meta-generic-branch-itemless-success.md b/.changeset/18401-meta-generic-branch-itemless-success.md new file mode 100644 index 00000000000..e9125b75364 --- /dev/null +++ b/.changeset/18401-meta-generic-branch-itemless-success.md @@ -0,0 +1,14 @@ +--- +"@objectstack/runtime": patch +--- + +The dispatcher's `/meta` domain answers `GET /meta/:type/:name` for a name with nothing behind it with `404 RESOURCE_NOT_FOUND` on its generic `:type/:name` branch, instead of announcing the miss as a `200` (#18401). + +**Clause-②: no** — no schema key moves, no accept set widens or narrows, no export changes, and no error code is minted: the refusal reuses the branch's own existing `deps.error('Not found', 404)`, whose code `standardErrorCodeForHttpStatus` already derives. + +`protocol.getMetaItem` answers a miss with the protection envelope wrapped around an absent item — `{ type, name, item: undefined, lock, editable, deletable, resettable }`, because `resolveLockState(undefined, false)` is unconditional — never with `undefined`. The generic branch returned that straight through, and `JSON.stringify` at the transport then dropped the `item` member, so a caller was handed a `200` whose body is the declared `GetMetaItemResponseSchema` envelope **minus its required member**. + +- **The branch disagreed with its own sibling.** The `object` branch of the same function already refused that exact shape and answered `404`, so one function answered "does absence mean success?" both ways, decided by which type you asked for. The generic branch now runs the same hit test. +- **A miss still falls through, it is not a hard refusal.** An item-less protocol answer hands the read on to the `MetadataService` resolver exactly as the object branch hands its own on to the ObjectQL registry; only a read that no resolver can satisfy reaches the `404`. +- **No new refusal dialect.** The fall-through ends at the branch's own pre-existing `404`, the ADR-0112 nested `{ success:false, error:{ code, message, httpStatus } }` this file already speaks — so the separate question of how this route spells its refusals is untouched. +- **What a caller observes**: a name with no item behind it. A request that was previously answered `200` with an item-less body is now answered `404`; a request that resolves to a real item is byte-identical to before, protection envelope included. diff --git a/packages/runtime/src/domains/meta-item-absent-404.test.ts b/packages/runtime/src/domains/meta-item-absent-404.test.ts new file mode 100644 index 00000000000..50536893c87 --- /dev/null +++ b/packages/runtime/src/domains/meta-item-absent-404.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18401] `GET /meta/:type/:name` on the dispatcher's `/meta` domain, for a + * name with NOTHING behind it — on the GENERIC `:type/:name` branch. + * + * The generic branch returned `protocol.getMetaItem`'s answer straight through + * with `deps.success(data)`. That producer answers a miss with the protection + * envelope wrapped around an absent item — `{ type, name, item: undefined, + * lock, editable, deletable, resettable }`, because `resolveLockState(undefined, + * false)` is unconditional — never with `undefined`. So the miss arrived at the + * caller as a `200`, and `JSON.stringify` at the transport dropped the `item` + * member on the way out: the declared envelope MINUS its required member, + * announced as a hit. + * + * ── Why this is execution and not a design question ───────────────────────── + * + * Three declarations already agreed with each other and against this one + * branch; only the branch was wrong. + * + * 1. The `object` branch of the SAME function refuses the identical shape + * ("only treat the lookup as a hit when `item` is really there") and 404s. + * §3 below pins the two branches answering one question one way. + * 2. `GetMetaItemResponseSchema` declares `item` a required member while every + * genuinely-optional key beside it is spelled `.optional()`. §2 asserts that + * against the wire body rather than restating it. + * 3. The REST twin of this door refuses the same shape (#18066). + * + * ── What this file deliberately does NOT do ───────────────────────────────── + * + * It adds no refusal DIALECT. The fall-through ends at the branch's own + * `deps.error('Not found', 404)` — the ADR-0112 nested `{ success:false, + * error:{ code, message, httpStatus } }` this file already speaks everywhere — + * so the three-dialect question #18402 raises about this route is neither + * answered nor pre-empted here. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { GetMetaItemResponseSchema } from '@objectstack/spec/api'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const AGENT = { name: 'triage_bot', label: 'Triage Bot', model: 'claude' }; +const CUSTOMER = { name: 'customer', label: 'Customer', fields: { id: { type: 'text' } } }; + +/** + * ⭐ THE FIXTURE THAT MATTERS: what `metadata-protocol`'s `getMetaItem` really + * resolves for a miss, key for key — the envelope with `item` present and + * holding `undefined`, NOT `undefined` itself. A double that answers + * `undefined` never reproduces this defect, because it never had an envelope to + * lose a member from. + */ +function absentItemEnvelope(type: string, name: string) { + return { + type, name, + item: undefined, + lock: 'none', editable: true, deletable: true, resettable: false, + }; +} + +/** Build a dispatcher whose kernel resolves exactly the named services. */ +function make(services: Record) { + const kernel = { + getServiceAsync: async (name: string) => services[name] ?? null, + getService: (name: string) => services[name] ?? null, + context: { getService: (name: string) => services[name] ?? null }, + } as any; + return new HttpDispatcher(kernel); +} + +const ctx = (): any => ({ + request: {}, + environmentId: 'platform', + executionContext: { userId: 'u1', systemPermissions: [] }, +}); + +/** + * A protocol double whose `getMetaItem` knows `corpus` and answers + * {@link absentItemEnvelope} — the live producer's miss — for anything else. + */ +function protocolDouble(corpus: Record = {}, extra: Record = {}) { + return { + getMetaItem: vi.fn(async ({ type, name }: any) => { + const hit = corpus[`${type}/${name}`]; + return hit === undefined + ? absentItemEnvelope(type, name) + : { type, name, item: hit, lock: 'none', editable: true, deletable: true, resettable: false }; + }), + ...extra, + }; +} + +/** The wire body, after the serialization that drops an `undefined` member. */ +const onWire = (body: any) => JSON.parse(JSON.stringify(body)); + +type Answered = NonNullable>['response']>; + +/** + * The dispatcher's answer, or a loud failure. The optional-chained + * `res.response?.` spelling is what the siblings in this directory use, but + * every NEGATIVE assertion below + * (`toBeUndefined()`, `not.toBe(200)`) passes vacuously against an unhandled + * result, which is the one outcome that must not read as a pass here. So this + * file refuses the optional rather than reaching through it. + */ +function answered(res: { response?: Answered }): Answered { + if (!res.response) throw new Error('the dispatcher did not handle the request - there is no answer to assert on'); + return res.response; +} + +describe('#18401 dispatcher /meta generic branch — an item-less envelope is a MISS, not a success', () => { + it('§1 refuses an absent name with 404 RESOURCE_NOT_FOUND instead of the item-less 200', async () => { + const protocol = protocolDouble(); + const res = await make({ protocol }).handleMetadata('/agent/no_such_agent_xyz', ctx(), 'GET'); + + // The refusal comes from the item-less guard, not from an absent + // protocol handle: the producer really was consulted. + expect(protocol.getMetaItem).toHaveBeenCalledWith( + expect.objectContaining({ type: 'agent', name: 'no_such_agent_xyz' }), + ); + const answer = answered(res); + expect(answer.status).toBe(404); + // ADR-0112 nested envelope: `code` and `status` are the minimal pin. + expect(answer.body.error.code).toBe('RESOURCE_NOT_FOUND'); + expect(answer.body.error.httpStatus).toBe(404); + expect(answer.body.success).toBe(false); + }); + + it('§2 the item-less envelope never reaches the wire as a 200 — it does not satisfy the response contract', async () => { + const res = await make({ protocol: protocolDouble() }) + .handleMetadata('/agent/no_such_agent_xyz', ctx(), 'GET'); + + // The shape the branch used to serve, measured against the schema the + // route declares. Asserted here so the pin states WHY 200 was wrong, + // not merely that the number changed. + const wouldHaveShipped = onWire(absentItemEnvelope('agent', 'no_such_agent_xyz')); + expect('item' in wouldHaveShipped).toBe(false); + expect(GetMetaItemResponseSchema.safeParse(wouldHaveShipped).success).toBe(false); + + // And it is not what the caller gets. + const answer = answered(res); + expect(answer.status).not.toBe(200); + expect(answer.body.data).toBeUndefined(); + }); + + it('§3 the `object` branch and the generic branch answer absence THE SAME WAY (the finding)', async () => { + // Same function, same question, entered through two different types. + // `getProjectId` puts the object branch on its scoped path — the one + // that consults the protocol first, exactly as the generic branch does. + const generic = await make({ protocol: protocolDouble() }) + .handleMetadata('/agent/nobody_home', ctx(), 'GET'); + const object = await make({ protocol: protocolDouble({}, { getProjectId: () => 'env_1' }) }) + .handleMetadata('/object/nobody_home', ctx(), 'GET'); + + const g = answered(generic); + const o = answered(object); + expect(g.status).toBe(o.status); + expect(g.body.error.code).toBe(o.body.error.code); + expect(g.status).toBe(404); + }); + + it('§4 an item-less protocol answer FALLS THROUGH to the MetadataService rather than terminating the read', async () => { + // The guard is a miss test, not a hard refusal: the later resolvers in + // the chain must still get their turn, the way the object branch's + // registry fallback does. + const protocol = protocolDouble(); + const getItem = vi.fn(async () => AGENT); + const res = await make({ protocol, metadata: { getItem } }) + .handleMetadata('/agents/triage_bot', ctx(), 'GET'); + + expect(protocol.getMetaItem).toHaveBeenCalled(); + expect(getItem).toHaveBeenCalled(); + const answer = answered(res); + expect(answer.status).toBe(200); + // The plural URL segment still resolves to the canonical singular. + expect(answer.body.data).toMatchObject({ type: 'agent', name: 'triage_bot' }); + expect(answer.body.data.item).toMatchObject({ label: 'Triage Bot', model: 'claude' }); + }); + + it('§5 a real hit is untouched — the whole protection envelope still passes through', async () => { + // The control: if the guard were reading the wrong member, or reading it + // too strictly, this is the assertion that fails. Every value read comes + // from INSIDE the answer, so a vacuous pass is not available. + const protocol = protocolDouble({ 'agent/triage_bot': AGENT }); + const res = await make({ protocol }).handleMetadata('/agent/triage_bot', ctx(), 'GET'); + + const answer = answered(res); + expect(answer.status).toBe(200); + expect(answer.body.data).toMatchObject({ + type: 'agent', name: 'triage_bot', lock: 'none', editable: true, deletable: true, + }); + expect(answer.body.data.item).toMatchObject({ label: 'Triage Bot', model: 'claude' }); + // And the answer this branch DOES serve satisfies the route's declared + // response contract on the wire, `item` member included. + expect(GetMetaItemResponseSchema.safeParse(onWire(answer.body.data)).success).toBe(true); + }); + + it('§6 the object branch still serves its own hit — the sibling is not collateral', async () => { + const protocol = protocolDouble({ 'object/customer': CUSTOMER }, { getProjectId: () => 'env_1' }); + const res = await make({ protocol }).handleMetadata('/object/customer', ctx(), 'GET'); + + const answer = answered(res); + expect(answer.status).toBe(200); + expect(answer.body.data.item).toMatchObject({ label: 'Customer' }); + }); +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index b2c9a06fb1a..4e5e86dde30 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -870,7 +870,35 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // Admin gating is layered on top in a follow-up (step 2). const previewDrafts = query?.preview === 'draft'; const data = await protocol.getMetaItem({ type: singularType, name, packageId, organizationId, previewDrafts }); - return { handled: true, response: deps.success(data) }; + // [#18401] The SAME hit test the `object` branch above runs, + // asked here for the same reason. `getMetaItem` answers a + // miss with the protection envelope around an absent item — + // `{ type, name, item: undefined, lock, editable, deletable, + // resettable }`, because `resolveLockState(undefined, false)` + // is unconditional — never with `undefined`. Returned + // straight through, `JSON.stringify` at the transport drops + // the `item` member and the caller is handed a 200 whose body + // is the declared envelope MINUS its required member: the + // route reports a hit for a name with nothing behind it. + // + // ⭐ What made this a defect rather than a rough edge is that + // this function already answered the same question the other + // way one branch up: `object` refuses the item-less envelope + // and 404s. One function, two opposite answers to "does + // absence mean success?", selected by which type you asked + // for. `GetMetaItemResponseSchema` declares `item` required, + // and the REST twin of this door refuses the identical shape + // (#18066) — three declarations agreeing against one branch. + // + // ⛔ This adds no new refusal dialect. The fall-through ends + // at this block's OWN `deps.error('Not found', 404)` below — + // the ADR-0112 nested envelope every other refusal in this + // file already speaks — so the dialect question #18402 raises + // about this route is untouched here, neither answered nor + // pre-empted. + if (data?.item != null) { + return { handled: true, response: deps.success(data) }; + } } catch (e: any) { // Protocol might throw if not found or not supported }