diff --git a/.changeset/quiet-lemons-read.md b/.changeset/quiet-lemons-read.md new file mode 100644 index 00000000..7a89a8e9 --- /dev/null +++ b/.changeset/quiet-lemons-read.md @@ -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. diff --git a/packages/cli/test/integration/cli-smoke.test.ts b/packages/cli/test/integration/cli-smoke.test.ts index 7ac2dc13..5bbec66a 100644 --- a/packages/cli/test/integration/cli-smoke.test.ts +++ b/packages/cli/test/integration/cli-smoke.test.ts @@ -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; + 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`, @@ -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 [ @@ -191,6 +201,16 @@ function closeServer(server: Server): Promise { } 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) => { @@ -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'])( diff --git a/packages/core/src/openapi/reader.ts b/packages/core/src/openapi/reader.ts index 647f984b..aaaa243c 100644 --- a/packages/core/src/openapi/reader.ts +++ b/packages/core/src/openapi/reader.ts @@ -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); @@ -101,8 +102,10 @@ export async function readOpenApi( throw new TypeError('OpenAPI operation tags must be an array of strings.'); operation.tags = [...new Set(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( @@ -188,6 +191,30 @@ class References { this.loaded.set(document.finalUrl, document); } private externalCount = 0; + private readonly schemaBudget = { remaining: 10_000 }; + + schemas(value: Record, base: string): Record { + 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, @@ -234,3 +261,54 @@ class References { return { value: result, url: resolved.url }; } } + +function localSchema( + schema: unknown, + document: unknown, + seen: Set, + 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; +} diff --git a/packages/core/test/unit/openapi-prepare.test.ts b/packages/core/test/unit/openapi-prepare.test.ts index 32bcd013..364635d1 100644 --- a/packages/core/test/unit/openapi-prepare.test.ts +++ b/packages/core/test/unit/openapi-prepare.test.ts @@ -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 = {}): OpenApiDescription { return { @@ -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'] } }, }, ), ), @@ -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 = {}, 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'); + } + }); +}); diff --git a/packaging/hosted/install.sh b/packaging/hosted/install.sh index 6f7a8b29..265313c2 100755 --- a/packaging/hosted/install.sh +++ b/packaging/hosted/install.sh @@ -94,8 +94,11 @@ install_linux() { elif command -v dnf >/dev/null 2>&1; then asset="inflow-${version}-1.${rpm_architecture}.rpm" package_manager='dnf' + elif command -v pacman >/dev/null 2>&1; then + asset="inflow-${version}-1-${rpm_architecture}.pkg.tar.zst" + package_manager='pacman' else - fail 'Installation requires a supported Debian/Ubuntu or Fedora/RHEL system with systemd.' + fail 'Installation requires a supported Debian/Ubuntu, Fedora/RHEL, or Arch Linux system with systemd.' fi say "Downloading InFlow $version for Linux $(uname -m)." @@ -122,8 +125,12 @@ install_linux() { if [ "$package_manager" = 'apt' ]; then privileged env DEBIAN_FRONTEND=noninteractive apt-get install --yes "$temporary_directory/$asset" - else + elif [ "$package_manager" = 'dnf' ]; then privileged dnf install --assumeyes "$temporary_directory/$asset" + privileged /opt/inflow/bin/inflow vault service start || + fail 'The package was installed, but the InFlow vault service could not start. Resolve the service error and rerun the installer.' + else + privileged pacman -U --noconfirm "$temporary_directory/$asset" fi command -v inflow >/dev/null 2>&1 || fail 'InFlow was installed but is not available on PATH.' say "Installed $(inflow --version)." @@ -200,8 +207,10 @@ uninstall_linux() { privileged env DEBIAN_FRONTEND=noninteractive apt-get remove --yes inflow elif command -v dnf >/dev/null 2>&1; then privileged dnf remove --assumeyes inflow + elif command -v pacman >/dev/null 2>&1; then + privileged pacman -R --noconfirm inflow else - fail 'Uninstall requires apt-get or dnf.' + fail 'Uninstall requires apt-get, dnf, or pacman.' fi say 'InFlow was uninstalled. Encrypted vault data was preserved.' } @@ -211,8 +220,10 @@ purge_linux() { privileged env DEBIAN_FRONTEND=noninteractive apt-get purge --yes inflow elif command -v dnf >/dev/null 2>&1; then privileged dnf remove --assumeyes inflow + elif command -v pacman >/dev/null 2>&1; then + privileged pacman -R --noconfirm inflow else - fail 'Purge requires apt-get or dnf.' + fail 'Purge requires apt-get, dnf, or pacman.' fi privileged rm -rf /var/lib/inflow /var/lib/inflow-broker /run/inflow say 'InFlow and its system vault data were removed.' diff --git a/packaging/linux/install.sh.template b/packaging/linux/install.sh.template index b88e1d6f..e805660d 100644 --- a/packaging/linux/install.sh.template +++ b/packaging/linux/install.sh.template @@ -157,6 +157,8 @@ if [ "$installer" = 'apt' ]; then privileged env DEBIAN_FRONTEND=noninteractive apt-get install --yes "$temporary_directory/$asset" elif [ "$installer" = 'dnf' ]; then privileged dnf install --assumeyes "$temporary_directory/$asset" + privileged /opt/inflow/bin/inflow vault service start || + fail 'The package was installed, but the InFlow vault service could not start. Resolve the service error and rerun the installer.' else privileged pacman -U --noconfirm "$temporary_directory/$asset" fi