Skip to content

Commit d693848

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19939-p4-s2-screen-trio
2 parents 528f5ca + c4e7fa5 commit d693848

9 files changed

Lines changed: 348 additions & 46 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
feat(core): export `relationConditionSites`, the one walk that finds a filter's nested-relation conditions and each one's target object (#22777)
7+
8+
`relationConditionSites(fields, filter)` returns every `{ <lookup>: { <field of the related object>: … } }` condition a filter holds, at the top level and under `$and` / `$or` / `$not`, with the reference field, the target object (resolved by the spec's `referenceTargetOf`) and the condition. An id comparand, an operator bag and a non-reference field are not conditions.
9+
10+
It moved unchanged from `@objectstack/metadata-protocol`, where it was module-private, so that the MCP stdio data bridge (`@objectstack/mcp`) and the generic data door ask each condition's target its exposure over the same sites. `@objectstack/metadata-protocol` now imports it; its behaviour does not change.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/mcp': minor
3+
---
4+
5+
fix(mcp)!: on the stdio transport, a nested-relation filter condition asks the related object its declared exposure, and is refused when that object does not serve `list` (#22777)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is one runtime read position on one transport: the MCP stdio data bridge stops evaluating a filter condition on a lookup's target object whose existing `enable` declaration already refuses a query of it on every data door, and the remedy is a filter the caller can already write. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
A nested-relation filter condition, `{ <lookup>: { <field of the related object>: … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one.
14+
15+
**FROM.** On the MCP stdio transport (`OS_MCP_STDIO_API_KEY`), the `query_records` and `aggregate_records` tools judged only the object they address against its `enable` block, so such a condition in `where` was evaluated whatever the related object's `enable` block declared — including a related object those same tools refuse when it is addressed.
16+
17+
**TO.** The stdio data bridge asks each condition's related object the same exposure gate it applies to an addressed object, for `list` (the operation a query of that object is judged as), before the engine is asked. A related object the gate does not serve refuses the call with the bridge's existing exposure refusal, the one an addressed object gets:
18+
19+
- `enable.apiEnabled: false` → `OBJECT_API_DISABLED` (404).
20+
- an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `OBJECT_API_METHOD_NOT_ALLOWED` (405), with the effective operation set.
21+
22+
The tool's error text names the related object and the condition's position (`where.<lookup>`), with the remedy. It applies at the top level and under `$and` / `$or` / `$not`. The generic data door has given the same answer for the same condition since #22737.
23+
24+
**Migration.** Filter on the lookup's stored id (`{ <lookup>: '<id>' }` or `{ <lookup>: { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`.
25+
26+
**Bindings.**
27+
28+
- **Refused, not withheld.** Dropping a condition would widen the result.
29+
- **One decision, one walk.** The gate is the one the bridge already applies to an addressed object, and the conditions are found by `relationConditionSites` from `@objectstack/core`, the walk the data door uses.
30+
- **The engine is untouched.** A flow's or a hook's own query calls the engine directly and never passes this bridge.
31+
- **No new error code.**

‎packages/core/src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,12 @@ export * from './utils/record-not-found.js';
180180
// resolve with it (one name space for both doors).
181181
export * from './utils/object-not-found.js';
182182

183+
// [#22777] The one walk that finds a filter's nested-relation conditions and
184+
// their target objects: the data door (`@objectstack/metadata-protocol`) and
185+
// the MCP stdio data bridge (`@objectstack/mcp`) ask each target its exposure
186+
// before the engine evaluates the condition, and share no other package.
187+
export * from './utils/relation-condition-sites.js';
188+
183189
// Export in-memory fallbacks for core-criticality services
184190
export * from './fallbacks/index.js';
185191

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,14 @@
4141
* for `aggregate` over `queryObjects`); and the reads whose target is a FIXED
4242
* platform object rather than an authored one (a file field's `sys_file`
4343
* hydration, the `sys_user` display names the approvals and audit surfaces
44-
* resolve). They are named here, not held.
44+
* resolve). They are named here, not held. Nor does it see a door that reaches a
45+
* classified read through the shared condition walk rather than calling the
46+
* arbiter itself: the MCP stdio data bridge hands a caller's filter to the
47+
* engine's relation lowering (the `relation-filter-lowering.ts` row), and its
48+
* targets are resolved inside `relationConditionSites` (`@objectstack/core`),
49+
* which is the caller classified below. The bridge asks each target the
50+
* decision for `list` (`enforceRelationTargetExposure`, #22777), held by its
51+
* own package's pin rather than by a row here.
4552
*
4653
* The scan surface and its prefilter follow `row-serving-door-exposure.pin.test.ts`
4754
* in this directory: git's authored-file list, never a directory crawl, and `.ts`
@@ -161,6 +168,13 @@ const CALLERS: Record<string, Classification> = {
161168
wiring: { file: 'packages/plugins/plugin-audit/src/audit-writers.ts', call: 'servesSummaryTitleTarget(objectName' },
162169
pin: 'packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts',
163170
},
171+
'packages/core/src/utils/relation-condition-sites.ts': {
172+
kind: 'not-a-served-read',
173+
reason:
174+
"finds a filter's nested-relation conditions and each one's target; reads no record. Both doors that call it ask " +
175+
"each target the decision before the engine reads it: the data door's `refuseUnservedRelationTarget` and the MCP " +
176+
"stdio data bridge's `enforceRelationTargetExposure`",
177+
},
164178
'packages/lint/src/object-graph.ts': {
165179
kind: 'not-a-served-read',
166180
reason: 'authoring-time static analysis of metadata; reads no record',
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { relationConditionSites } from './relation-condition-sites.js';
5+
6+
const FIELDS = {
7+
name: { type: 'text' },
8+
account: { type: 'lookup', reference: 'rcs_account' },
9+
owner: { type: 'user' },
10+
tags: { type: 'lookup', reference: 'rcs_tag', multiple: true },
11+
};
12+
13+
describe('[#22777] relationConditionSites — the one walk both exposure-asking doors share', () => {
14+
it('finds a condition on a related object, at the top level and under every combinator, with its target', () => {
15+
const where = {
16+
account: { name: 'a' },
17+
$or: [{ owner: { email: 'e' } }, { $not: { tags: { label: 't' } } }],
18+
$and: [{ name: 'n' }],
19+
};
20+
const sites = relationConditionSites(FIELDS, where).map((s) => [s.field, s.target, s.condition]);
21+
expect(sites.sort()).toEqual([
22+
['account', 'rcs_account', { name: 'a' }],
23+
['owner', 'sys_user', { email: 'e' }],
24+
['tags', 'rcs_tag', { label: 't' }],
25+
]);
26+
});
27+
28+
it('CONTROL finds no site in an id comparand, an operator bag, a non-reference field or without a field map', () => {
29+
expect(relationConditionSites(FIELDS, { account: 'a1', owner: { $in: ['u1'] }, name: { first: 'x' } })).toEqual([]);
30+
expect(relationConditionSites(undefined, { account: { name: 'a' } })).toEqual([]);
31+
});
32+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { REFERENCE_VALUE_TYPES, referenceTargetOf } from '@objectstack/spec/data';
4+
5+
/** One nested-relation condition a filter holds, with the object it is evaluated on. */
6+
export interface RelationConditionSite {
7+
/** The reference field of the filtered object — the filter key. */
8+
field: string;
9+
/** The related object the field points at, by the spec's one arbiter (`referenceTargetOf`). */
10+
target: string;
11+
/** The condition on the related object: the plain record beneath the field. */
12+
condition: Record<string, unknown>;
13+
}
14+
15+
/**
16+
* [#22646] The NESTED-RELATION conditions a filter holds — a key naming a
17+
* reference field of the object (`REFERENCE_VALUE_TYPES`) whose value is a
18+
* plain record carrying at least one non-`$` key, i.e. a condition on the
19+
* related object rather than an operator bag — with each site's target object.
20+
* Logical combinators (and any other `$` key) are walked as conditions, so a
21+
* site under `$or` / `$not` is found. The engine's own relation lowering
22+
* (`ObjectQL.lowerRelationConditions`) is the authority on whether a site RUNS;
23+
* this only finds what a filter would evaluate on the related object.
24+
*
25+
* [#22777] The ONE walk for every door that asks each site's target the spec's
26+
* exposure decision before handing the filter to the engine: the data door
27+
* (`@objectstack/metadata-protocol`, where it also feeds the internal-field
28+
* refusal) and the MCP stdio data bridge (`@objectstack/mcp`). It lives here
29+
* because the two doors share no other package, and a second walk is how two
30+
* doors come to disagree about which conditions a filter holds.
31+
*
32+
* `fields` is the filtered object's declared field map; with none, there is
33+
* nothing to resolve a target with and no site is found.
34+
*/
35+
export function relationConditionSites(
36+
fields: Record<string, any> | undefined,
37+
root: unknown,
38+
): RelationConditionSite[] {
39+
const sites: RelationConditionSite[] = [];
40+
if (!fields || typeof fields !== 'object') return sites;
41+
const seen = new WeakSet<object>();
42+
const pending: unknown[] = [root];
43+
while (pending.length > 0) {
44+
const node = pending.pop();
45+
if (node === null || typeof node !== 'object' || seen.has(node)) continue;
46+
seen.add(node);
47+
if (Array.isArray(node)) {
48+
for (const member of node) pending.push(member);
49+
continue;
50+
}
51+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
52+
if (key.startsWith('$')) {
53+
pending.push(value);
54+
continue;
55+
}
56+
const def = fields[key];
57+
if (def == null || !REFERENCE_VALUE_TYPES.has(def.type) || !isPlainRecord(value)) continue;
58+
if (!Object.keys(value).some((k) => !k.startsWith('$'))) continue;
59+
const target = referenceTargetOf(def);
60+
if (target) sites.push({ field: key, target, condition: value });
61+
}
62+
}
63+
return sites;
64+
}
65+
66+
/** A JSON object — not an array, not `null`. */
67+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
68+
return typeof value === 'object' && value !== null && !Array.isArray(value);
69+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22777] A nested-relation condition in a stdio caller's filter is evaluated
5+
* on its TARGET object (the engine lowers it by reading the target as the
6+
* caller), so the stdio data bridge asks each condition's target the spec's one
7+
* exposure decision for `list` — the same gate an addressed object gets — and
8+
* REFUSES an unserved target before the engine is asked. The data door asks
9+
* the same of the same sites (`@objectstack/metadata-protocol`). Fixture objects
10+
* only; refusals are asserted by envelope (`code` + `status`), never by a bare
11+
* throw, and every refusal asserts the engine was never reached.
12+
*/
13+
14+
import { describe, it, expect, vi } from 'vitest';
15+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
16+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
17+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
18+
import { effectiveOperationsArray, resolveEffectiveApiMethods } from '@objectstack/spec/data';
19+
import type { ExecutionContext } from '@objectstack/spec/kernel';
20+
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
21+
import { createStdioDataBridge, type McpExposureError } from './stdio-data-bridge.js';
22+
import { registerObjectTools } from './mcp-http-tools.js';
23+
24+
const lookup = (reference: string) => ({ type: 'lookup', reference });
25+
/** Each source lookup → its target's `enable` block (`undefined`: none declared). */
26+
const TARGETS: Record<string, Record<string, unknown> | undefined> = {
27+
hidden: { apiEnabled: false },
28+
closed: { apiMethods: ['create'] },
29+
getonly: { apiMethods: ['get'] },
30+
none: { apiMethods: [] },
31+
listonly: { apiMethods: ['list'] },
32+
open: undefined,
33+
};
34+
const DEFS: Record<string, unknown> = {
35+
rcx_source: {
36+
name: 'rcx_source',
37+
fields: { name: { type: 'text' }, ...Object.fromEntries(Object.keys(TARGETS).map((rel) => [rel, lookup(`rcx_${rel}`)])) },
38+
},
39+
...Object.fromEntries(Object.entries(TARGETS).map(([rel, enable]) => [
40+
`rcx_${rel}`, { name: `rcx_${rel}`, fields: { name: { type: 'text' } }, ...(enable ? { enable } : {}) },
41+
])),
42+
};
43+
const REFUSED = [
44+
['hidden', 'OBJECT_API_DISABLED', 404],
45+
['closed', 'OBJECT_API_METHOD_NOT_ALLOWED', 405],
46+
['getonly', 'OBJECT_API_METHOD_NOT_ALLOWED', 405],
47+
['none', 'OBJECT_API_METHOD_NOT_ALLOWED', 405],
48+
] as const;
49+
50+
function makeBridge() {
51+
const engine = {
52+
find: vi.fn(async (_object: string, _query?: { where?: unknown }) => [{ id: 's1' }]),
53+
aggregate: vi.fn(async (_object: string, _query?: { where?: unknown }) => [{ n: 1 }]),
54+
};
55+
const metadataService = { getObject: vi.fn(async (name: string) => DEFS[name]) };
56+
const bridge = createStdioDataBridge({
57+
engine: engine as unknown as IDataEngine,
58+
metadataService: metadataService as unknown as IMetadataService,
59+
resolvePrincipal: async () => ({ userId: 'u1', isSystem: false }) as unknown as ExecutionContext,
60+
});
61+
return { bridge, engine };
62+
}
63+
64+
const VERBS = {
65+
query: (b: ReturnType<typeof makeBridge>['bridge'], where: Record<string, unknown>) => b.query('rcx_source', { where }),
66+
aggregate: (b: ReturnType<typeof makeBridge>['bridge'], where: Record<string, unknown>) =>
67+
b.aggregate!('rcx_source', { where, aggregations: [{ function: 'count', alias: 'n' }] }),
68+
};
69+
70+
describe('[#22777] the stdio bridge refuses a condition on a target the decision does not serve for list', () => {
71+
for (const [verb, run] of Object.entries(VERBS)) {
72+
for (const [rel, code, status] of REFUSED) {
73+
for (const [shape, where] of [
74+
['direct', { [rel]: { name: 'x' } }],
75+
['under $or', { $or: [{ [rel]: { name: 'x' } }, { name: 'y' }] }],
76+
['under $not', { $not: { [rel]: { name: 'x' } } }],
77+
] as const) {
78+
it(`${verb}: ${rel} (${shape}) answers ${status} and never reaches the engine`, async () => {
79+
const { bridge, engine } = makeBridge();
80+
const err = (await run(bridge, where).then(() => null, (e: unknown) => e)) as McpExposureError | null;
81+
expect(err, 'the call resolved: no refusal was raised').toBeTruthy();
82+
expect(err!.code).toBe(code);
83+
expect(err!.status).toBe(status);
84+
if (status === 405) {
85+
expect(err!.allowedOperations).toEqual(effectiveOperationsArray(resolveEffectiveApiMethods(TARGETS[rel]!)));
86+
}
87+
expect(err!.message).toContain(`'rcx_${rel}'`);
88+
expect(err!.message).toContain(`'where.${rel}'`);
89+
expect(engine.find).not.toHaveBeenCalled();
90+
expect(engine.aggregate).not.toHaveBeenCalled();
91+
});
92+
}
93+
}
94+
95+
it(`${verb}: CONTROL a served target, an id comparand and an operator bag reach the engine unchanged`, async () => {
96+
for (const where of [
97+
{ listonly: { name: 'x' } },
98+
{ $or: [{ open: { name: 'x' } }] },
99+
{ hidden: 'h1' },
100+
{ hidden: { $in: ['h1'] } },
101+
]) {
102+
const { bridge, engine } = makeBridge();
103+
await run(bridge, where);
104+
const call = (verb === 'query' ? engine.find : engine.aggregate).mock.calls[0]?.[1];
105+
expect(call?.where, JSON.stringify(where)).toBe(where);
106+
}
107+
});
108+
}
109+
110+
it('query_records answers the refusal naming the target and the position; CONTROL a served target is answered', async () => {
111+
const { bridge, engine } = makeBridge();
112+
const server = new McpServer({ name: 'pin', version: '0' });
113+
registerObjectTools(server, bridge);
114+
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
115+
await server.connect(serverSide);
116+
const client = new Client({ name: 'pin', version: '0' });
117+
await client.connect(clientSide);
118+
try {
119+
const call = (where: Record<string, unknown>) =>
120+
client.callTool({ name: 'query_records', arguments: { objectName: 'rcx_source', where } }) as Promise<{
121+
isError?: boolean;
122+
content: Array<{ text: string }>;
123+
}>;
124+
const refused = await call({ hidden: { name: 'x' } });
125+
expect(refused.isError).toBe(true);
126+
expect(refused.content[0]!.text).toContain("Object 'rcx_hidden' is not exposed via the API");
127+
expect(refused.content[0]!.text).toContain("'where.hidden'");
128+
expect(engine.find).not.toHaveBeenCalled();
129+
const served = await call({ listonly: { name: 'x' } });
130+
expect(served.isError).toBeFalsy();
131+
expect(engine.find).toHaveBeenCalledTimes(1);
132+
} finally {
133+
await client.close();
134+
}
135+
});
136+
});

0 commit comments

Comments
 (0)