Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/quiet-lemons-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@inflowpayai/inflow': patch
---

Resolve local OpenAPI schema references when preparing operation parameters and JSON request bodies. Unsupported
references remain explicit preparation errors without additional network requests.

Support Arch Linux in the hosted installer and report failed vault startup after RPM installation instead of reporting
installation success.
26 changes: 23 additions & 3 deletions packages/cli/test/integration/cli-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,17 +115,27 @@ function callMcp(
let errors = '';
let response: string | undefined;
let initialized = false;
const timer = setTimeout(() => child.kill('SIGTERM'), 10_000);
let phase = 'initialization';
let timer: ReturnType<typeof setTimeout>;
const waitFor = (nextPhase: string, timeout: number) => {
phase = nextPhase;
clearTimeout(timer);
timer = setTimeout(() => child.kill('SIGTERM'), timeout);
};
waitFor('initialization', 10_000);
child.stdout.on('data', (chunk: Buffer) => {
output += chunk.toString();
for (const line of output.split('\n').slice(0, -1)) {
const message = JSON.parse(line) as { id?: number };
if (message.id === 1 && !initialized) {
initialized = true;
waitFor('test setup', 30_000);
void Promise.resolve()
.then(afterInitialize)
.then(
() => {
if (child.killed) return;
waitFor('tools/call', 10_000);
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`);
child.stdin.write(
`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name, arguments: arguments_ } })}\n`,
Expand All @@ -149,7 +159,7 @@ function callMcp(
child.on('error', reject);
child.on('close', () => {
clearTimeout(timer);
if (response === undefined) reject(new Error(`MCP did not respond: ${errors}`));
if (response === undefined) reject(new Error(`MCP ${name} did not respond during ${phase}: ${errors}`));
else resolveResult(response);
});
for (const message of [
Expand Down Expand Up @@ -191,6 +201,16 @@ function closeServer(server: Server): Promise<void> {
}

describe('cli smoke', () => {
it('does not count test setup against the MCP response deadline', async () => {
const response = await callMcp(
'mpp_decode',
{ value: '@@@not-decodable@@@' },
{ HOME: authDir, XDG_DATA_HOME: join(authDir, 'data'), INFLOW_API_KEY: '' },
() => new Promise((resolve) => setTimeout(resolve, 11_000)),
);
expect(JSON.parse(response)).toMatchObject({ id: 2, result: { isError: true } });
}, 25_000);

it.each([200, 402])('inspect returns schema-valid structured MCP output for HTTP %i', async (status) => {
await withSeller(
(req, res) => {
Expand Down Expand Up @@ -848,7 +868,7 @@ describe('cli smoke', () => {
rmSync(root, { recursive: true, force: true });
}
},
30_000,
60_000,
);

it.skipIf(process.platform !== 'darwin' || packagedExecutable !== undefined).each(['replace', 'lock'])(
Expand Down
84 changes: 81 additions & 3 deletions packages/core/src/openapi/reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ export async function readOpenApi(
if (group === undefined) continue;
if (!Array.isArray(group)) throw new TypeError('OpenAPI parameters must be an array.');
for (const parameter of group) {
const resolved = (await references.resolve(parameter, item.url)).value;
const parameterReference = await references.resolve(parameter, item.url);
const resolved = references.schemas(parameterReference.value, parameterReference.url);
if (typeof resolved['name'] !== 'string' || typeof resolved['in'] !== 'string')
throw new TypeError('OpenAPI parameters require name and in.');
parameters.set(`${resolved['in']}:${resolved['name']}`, resolved);
Expand All @@ -101,8 +102,10 @@ export async function readOpenApi(
throw new TypeError('OpenAPI operation tags must be an array of strings.');
operation.tags = [...new Set<string>(raw['tags'])];
}
if (raw['requestBody'] !== undefined)
operation.requestBody = (await references.resolve(raw['requestBody'], item.url)).value;
if (raw['requestBody'] !== undefined) {
const bodyReference = await references.resolve(raw['requestBody'], item.url);
operation.requestBody = references.schemas(bodyReference.value, bodyReference.url);
}
for (const parameter of operation.parameters) {
if (!['path', 'query', 'header'].includes(String(parameter['in'])))
operation.limitations.push(
Expand Down Expand Up @@ -188,6 +191,30 @@ class References {
this.loaded.set(document.finalUrl, document);
}
private externalCount = 0;
private readonly schemaBudget = { remaining: 10_000 };

schemas(value: Record<string, unknown>, base: string): Record<string, unknown> {
const document = this.loaded.get(base);
if (document === undefined) return value;
if (
isRecord(document.value) &&
document.value['jsonSchemaDialect'] !== undefined &&
document.value['jsonSchemaDialect'] !== 'https://spec.openapis.org/oas/3.1/dialect/base'
)
return value;
const resolve = (schema: unknown) => localSchema(schema, document.value, new Set(), this.schemaBudget);
const result = { ...value };
if (value['schema'] !== undefined) result['schema'] = resolve(value['schema']);
if (isRecord(value['content'])) {
result['content'] = Object.fromEntries(
Object.entries(value['content']).map(([type, media]) => [
type,
isRecord(media) && media['schema'] !== undefined ? { ...media, schema: resolve(media['schema']) } : media,
]),
);
}
return result;
}

async resolve(
value: unknown,
Expand Down Expand Up @@ -234,3 +261,54 @@ class References {
return { value: result, url: resolved.url };
}
}

function localSchema(
schema: unknown,
document: unknown,
seen: Set<unknown>,
budget: { remaining: number },
depth = 0,
): unknown {
if (!isRecord(schema) || --budget.remaining < 0 || depth > 32 || seen.has(schema)) return schema;
if (schema['$id'] !== undefined || schema['$schema'] !== undefined) return schema;
const next = new Set(seen).add(schema);
const resolve = (value: unknown) => localSchema(value, document, next, budget, depth + 1);
if (schema['$ref'] !== undefined) {
// Schema reference siblings have version-dependent semantics; keep them unsupported rather than discard constraints.
if (Object.keys(schema).length !== 1 || typeof schema['$ref'] !== 'string' || !schema['$ref'].startsWith('#/'))
return schema;
let fragment: string;
try {
fragment = decodeURIComponent(schema['$ref'].slice(2));
} catch {
return schema;
}
let target = document;
for (const part of fragment.split('/')) {
if (/~(?:[^01]|$)/.test(part)) return schema;
const key = part.replace(/~1/g, '/').replace(/~0/g, '~');
if (
(!isRecord(target) && !Array.isArray(target)) ||
(isRecord(target) && (target['$id'] !== undefined || target['$schema'] !== undefined)) ||
!Object.hasOwn(target, key)
)
return schema;
target = Reflect.get(target, key) as unknown;
}
if ((!isRecord(target) && typeof target !== 'boolean') || next.has(target)) return schema;
if (isRecord(target) && (target['$id'] !== undefined || target['$schema'] !== undefined)) return schema;
return resolve(target);
}
const result = { ...schema };
if (isRecord(schema['properties']))
result['properties'] = Object.fromEntries(
Object.entries(schema['properties']).map(([name, property]) => [name, resolve(property)]),
);
for (const key of ['items', 'additionalProperties']) {
if (schema[key] !== undefined) result[key] = resolve(schema[key]);
}
for (const key of ['allOf', 'anyOf', 'oneOf']) {
if (Array.isArray(schema[key])) result[key] = schema[key].map(resolve);
}
return result;
}
150 changes: 147 additions & 3 deletions packages/core/test/unit/openapi-prepare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
previewOpenApiRequest,
type OpenApiPreparationInput,
} from '../../src/openapi/prepare.js';
import type { OpenApiDescription, OpenApiOperation } from '../../src/openapi/reader.js';
import { readOpenApi, type OpenApiDescription, type OpenApiOperation } from '../../src/openapi/reader.js';

function document(overrides: Partial<OpenApiOperation> = {}): OpenApiDescription {
return {
Expand Down Expand Up @@ -450,11 +450,12 @@ describe('OpenAPI request preparation', () => {
},
}
: {
parameter: { name: 'id', in: 'path', required: true, schema: { type: 'integer' } },
parameter: { name: 'id', in: 'path', required: true, schema: { $ref: '#/definitions/Id' } },
body: {
required: true,
content: { 'application/json': { schema: { type: 'object', required: ['query'] } } },
content: { 'application/json': { schema: { $ref: '#/definitions/Body' } } },
},
definitions: { Id: { type: 'integer' }, Body: { type: 'object', required: ['query'] } },
},
),
),
Expand All @@ -472,3 +473,146 @@ describe('OpenAPI request preparation', () => {
expect(fetch.mock.calls.map(([url]) => url.pathname)).toEqual(['/openapi.json', '/defs.json']);
});
});

describe('local OpenAPI schema references', () => {
async function read(schema: unknown, definitions: Record<string, unknown> = {}, overrides = {}) {
const raw = {
openapi: '3.1.0',
info: { title: 'Example' },
components: { schemas: { Id: { type: 'string', enum: ['one'] }, ...definitions } },
paths: {
'/items/{id}': {
post: {
operationId: 'create',
parameters: [{ name: 'id', in: 'path', required: true, schema }],
requestBody: { content: { 'application/json': { schema } } },
},
},
},
...overrides,
};
const original = structuredClone(raw);
const fetch = vi.fn(() => Promise.reject(new Error('Unexpected reference retrieval')));
const cache: PublicSourceCache = { get: () => undefined, set: () => undefined, delete: () => undefined };
const result = await readOpenApi(
{
sourceUrl: 'https://example.com/openapi.json',
finalUrl: 'https://example.com/openapi.json',
value: raw,
status: 200,
headers: {},
cacheable: false,
expiresAt: 0,
},
new PublicSourceDocuments(cache, fetch),
{},
);
expect(raw).toEqual(original);
expect(fetch).not.toHaveBeenCalled();
return result;
}

it.each(['3.0.3', '3.1.0'])(
'resolves a local parameter reference without weakening type or enum checks (%s)',
async (version) => {
const doc = await read({ $ref: '#/components/schemas/Id' }, {}, { openapi: version });
expect(prepareOpenApiRequest(doc, input).request.url).toBe('https://example.com/items/one');
expect(() => prepareOpenApiRequest(doc, { ...input, parameters: { path: { id: 1 } } })).toThrow('declared type');
expect(() => prepareOpenApiRequest(doc, { ...input, parameters: { path: { id: 'two' } } })).toThrow('enum');
},
);

it('resolves chains, escaped pointer names and boolean schemas', async () => {
const doc = await read(
{ $ref: '#/components/schemas/Alias' },
{
Alias: { $ref: '#/components/schemas/a~1b~0c%20d' },
'a/b~c d': false,
},
);
expect(() => prepareOpenApiRequest(doc, input)).toThrow('not permitted');
expect(
prepareOpenApiRequest(await read({ $ref: '#/components/schemas/Yes' }, { Yes: true }), input).request.url,
).toBe('https://example.com/items/one');
});

it('resolves nested body schemas without interpreting examples or defaults as schemas', async () => {
const schema = {
type: 'object',
required: ['ids'],
properties: { ids: { type: 'array', items: { $ref: '#/components/schemas/Id' } } },
additionalProperties: { $ref: '#/components/schemas/No' },
example: { $ref: '#/missing-example' },
default: { $ref: '#/missing-default' },
};
const doc = await read(schema, { No: false });
const operation = doc.operations[0];
if (operation === undefined) throw new Error('Missing fixture operation');
operation.parameters = [{ name: 'id', in: 'path', schema: { type: 'string' } }];
expect(prepareOpenApiRequest(doc, { ...input, data: '{"ids":["one"]}' }).request.body).toBe('{"ids":["one"]}');
expect(() => prepareOpenApiRequest(doc, { ...input, data: '{"ids":[2]}' })).toThrow('declared type');
expect(() => prepareOpenApiRequest(doc, { ...input, data: '{"ids":["one"],"extra":true}' })).toThrow(
'not permitted',
);
expect(operation.requestBody?.['content']).toMatchObject({
'application/json': { schema: { example: schema.example, default: schema.default } },
});
});

it.each(['allOf', 'anyOf', 'oneOf'])('resolves references inside %s', async (keyword) => {
const doc = await read({ [keyword]: [{ $ref: '#/components/schemas/Id' }] });
expect(prepareOpenApiRequest(doc, input).request.url).toContain('/items/one');
expect(() => prepareOpenApiRequest(doc, { ...input, parameters: { path: { id: 'two' } } })).toThrow();
});

it.each([
{ $ref: '#/components/schemas/Missing' },
{ $ref: '#/components/schemas/~9' },
{ $ref: '#/%E0%A4' },
{ $ref: '#named-anchor' },
{ $ref: 'https://other.example/schema.json' },
{ $ref: 1 },
{ $ref: '#/components/schemas/Id', type: 'integer' },
{ $id: 'https://other.example/schema', $ref: '#/components/schemas/Id' },
{ $schema: 'https://other.example/dialect', $ref: '#/components/schemas/Id' },
])('keeps unsupported references explicit: %j', async (schema) => {
const awaited = await read(schema);
expect(() => prepareOpenApiRequest(awaited, input)).toThrow('unsupported schema keyword $ref');
});

it.each([
null,
'invalid',
{ $id: 'https://other.example/schema', type: 'string' },
{ $schema: 'https://other.example/dialect', type: 'string' },
])('does not substitute invalid or differently scoped targets: %j', async (target) => {
const doc = await read({ $ref: '#/components/schemas/Target' }, { Target: target });
expect(() => prepareOpenApiRequest(doc, input)).toThrow('unsupported schema keyword $ref');
});

it('bounds cycles and long reference chains while keeping inspection available', async () => {
const cyclic = await read(
{ $ref: '#/components/schemas/A' },
{
A: { $ref: '#/components/schemas/B' },
B: { $ref: '#/components/schemas/A' },
},
);
expect(() => prepareOpenApiRequest(cyclic, input)).toThrow('unsupported schema keyword $ref');
const long = await read(
{ $ref: '#/components/schemas/0' },
Object.fromEntries(Array.from({ length: 40 }, (_, i) => [String(i), { $ref: `#/components/schemas/${i + 1}` }])),
);
expect(() => prepareOpenApiRequest(long, input)).toThrow('unsupported schema keyword $ref');
});

it('does not interpret references using an unsupported root dialect or a nested resource base', async () => {
for (const overrides of [
{ jsonSchemaDialect: 'https://other.example/dialect' },
{ components: { $id: 'https://other.example/resource', schemas: { Id: { type: 'string' } } } },
]) {
const doc = await read({ $ref: '#/components/schemas/Id' }, {}, overrides);
expect(() => prepareOpenApiRequest(doc, input)).toThrow('unsupported schema keyword $ref');
}
});
});
Loading
Loading