diff --git a/.changeset/adr-0096-openapi-spec-file-path.md b/.changeset/adr-0096-openapi-spec-file-path.md new file mode 100644 index 0000000000..fcc5faa43c --- /dev/null +++ b/.changeset/adr-0096-openapi-spec-file-path.md @@ -0,0 +1,36 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +'@objectstack/connector-openapi': minor +'@objectstack/cli': minor +--- + +feat(connector-openapi): resolve `providerConfig.spec` from a package-relative file path (#3016, ADR-0096 follow-up) + +ADR-0096's canonical example authors an OpenAPI-backed instance as +`providerConfig: { spec: './billing-openapi.json' }`, but the landed `openapi` +provider factory only accepted an inline document object or an http(s) URL. +The spec union is now complete: **inline object | file path | remote URL**. + +- **`@objectstack/spec`.** `ConnectorProviderContext` gains an optional + host-injected `loadPackageFile(relativePath)` capability (pure type): reads a + UTF-8 file resolved against the declaring stack/package root, confined to + that root. `undefined` on hosts without a filesystem. + +- **`@objectstack/service-automation`.** New `packageRoot` plugin option (the + base for relative file refs; defaults to `process.cwd()`) and an exported + `createPackageFileLoader(packageRoot)` that implements the confinement + guard — absolute paths and `..`-escaping paths are rejected — with lazy + `node:fs`/`node:path` imports so non-Node hosts only fail if a file ref is + actually dereferenced. The materializer injects the capability into every + provider factory's context. Failures follow the existing reconcile policy: + **fatal at boot, entry skipped on reload**. + +- **`@objectstack/connector-openapi`.** A string `providerConfig.spec` that is + not an http(s) URL is now read via `ctx.loadPackageFile` and parsed as an + OpenAPI JSON document (clear errors for missing/unreadable files, unparseable + JSON, and hosts without package file access). + +- **`@objectstack/cli`.** `serve`/`dev` pass the project folder (the + `objectstack.config.ts` directory) as the automation service's `packageRoot`, + mirroring how the standalone sqlite default is anchored. diff --git a/docs/adr/0096-declarative-connector-instances.md b/docs/adr/0096-declarative-connector-instances.md index 5df3a7cf2b..d86c99d61a 100644 --- a/docs/adr/0096-declarative-connector-instances.md +++ b/docs/adr/0096-declarative-connector-instances.md @@ -134,6 +134,6 @@ keeps serving until its replacement materializes successfully. Boot keeps the ### Deliberate scope boundaries -- **`providerConfig.spec` (openapi)** accepts an inline document or an http(s) URL; resolving a `./file.json` ref relative to the stack is the stack loader's job, not the connector's. +- **`providerConfig.spec` (openapi)** accepts an inline document, an http(s) URL, **or a package-relative file path** (#3016 follow-up). The connector still owns no filesystem access: the automation service injects a `loadPackageFile` capability into `ConnectorProviderContext` that resolves the ref against the declaring stack/package root (`packageRoot`, CLI default: the `objectstack.config.ts` directory) and **confines reads to that root** — absolute and `..`-escaping paths are rejected. Read/parse failures follow the reconcile policy above: fatal at boot, skipped on reload. - **MCP credentials** ride the transport (ADR-0024); for an http transport a resolved `auth` is folded into the request headers. - **MCP at boot** connects during materialization, so an unreachable server fails boot; a fail-soft "optional" marker for boot-time materialization is a possible future refinement (runtime reloads are already soft). diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index d16a585714..1c56291e29 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { defineStack } from '@objectstack/spec'; +import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import { ConnectorSlackPlugin } from '@objectstack/connector-slack'; import { @@ -102,12 +103,17 @@ export default defineStack({ // Concrete connectors for the `connector_action` node. The baseline engine // ships the dispatch node + an empty registry; these plugins populate it. - // • rest → points at the running server itself, so the REST connector - // flow's call + response are observable on the flow run with no - // external dependency. Override the target with SHOWCASE_SELF_URL. - // • slack → registered so TaskCompletedSlackFlow resolves its connector; - // live posting needs a real bot token (set SLACK_BOT_TOKEN). + // • rest → points at the running server itself, so the REST connector + // flow's call + response are observable on the flow run with no + // external dependency. Override the target with SHOWCASE_SELF_URL. + // • slack → registered so TaskCompletedSlackFlow resolves its connector; + // live posting needs a real bot token (set SLACK_BOT_TOKEN). + // • openapi → option-less: contributes only the `openapi` provider factory + // (ADR-0096), which materializes the StatusOpenApiConnector + // declarative instance below — its OpenAPI document is a + // package-relative FILE PATH read at boot (#3016). plugins: [ + new ConnectorOpenApiPlugin(), new ConnectorRestPlugin({ name: 'rest', baseUrl: process.env.SHOWCASE_SELF_URL ?? 'http://127.0.0.1:3000', @@ -191,8 +197,11 @@ export default defineStack({ // Declarative REST endpoints (object_operation + flow) — the metadata // counterpart of the code-mounted recalc endpoint (see src/system/apis/). apis: allApis, - // Declarative connector CATALOG DESCRIPTORS (#2612) — metadata-only, never - // runtime-dispatchable; the live connectors are the plugins above. See + // Declarative `connectors:` — both kinds (ADR-0096): provider-bound + // INSTANCES (StatusApiConnector via `rest`; StatusOpenApiConnector via + // `openapi` with a package-relative file-path spec, #3016) materialized into + // live, dispatchable connectors at boot, plus a CATALOG DESCRIPTOR + // (ErpCatalogConnector, #2612) that stays metadata-only. See // src/system/connectors/ for the full contract. connectors: allConnectors, hooks: allHooks, diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 82efed03f6..e9abd25251 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@objectstack/cloud-connection": "workspace:*", + "@objectstack/connector-openapi": "workspace:*", "@objectstack/connector-rest": "workspace:*", "@objectstack/connector-slack": "workspace:*", "@objectstack/driver-sql": "workspace:*", diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 07c9bd907a..ce6aecce98 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -179,7 +179,7 @@ export const STACK_COLLECTION_COVERAGE: Record = { status: 'demonstrated', files: ['src/system/connectors/index.ts', 'src/automation/flows/index.ts'], notes: - 'Both connector kinds are demonstrated. (1) Provider-bound INSTANCE (ADR-0096 / #2977): StatusApiConnector declares `provider: rest` and is materialized into a live, dispatchable connector at boot by ConnectorRestPlugin\'s provider factory — ShowcaseDeclarativeConnectorPingFlow calls it via connector_action and it appears in GET /connectors. (2) Catalog DESCRIPTOR (#2612): ErpCatalogConnector has no provider, so it stays inert metadata; enabled:false marks the deliberate catalog entry and silences the boot audit. Plugin-registered connectors (ConnectorRestPlugin/ConnectorSlackPlugin in objectstack.config.ts) are also exercised by the connector flows.', + 'Both connector kinds are demonstrated. (1) Provider-bound INSTANCES (ADR-0096 / #2977): StatusApiConnector declares `provider: rest` (inline config) and StatusOpenApiConnector declares `provider: openapi` with its OpenAPI document referenced as a package-relative FILE PATH (#3016, read at boot and confined to the package root) — both are materialized into live, dispatchable connectors at boot; ShowcaseDeclarativeConnectorPingFlow calls the rest instance via connector_action and both appear in GET /connectors. (2) Catalog DESCRIPTOR (#2612): ErpCatalogConnector has no provider, so it stays inert metadata; enabled:false marks the deliberate catalog entry and silences the boot audit. Plugin-registered connectors (ConnectorRestPlugin/ConnectorSlackPlugin in objectstack.config.ts) are also exercised by the connector flows.', }, }; diff --git a/examples/app-showcase/src/system/connectors/index.ts b/examples/app-showcase/src/system/connectors/index.ts index f09240b257..1c3515072e 100644 --- a/examples/app-showcase/src/system/connectors/index.ts +++ b/examples/app-showcase/src/system/connectors/index.ts @@ -58,6 +58,39 @@ export const StatusApiConnector = defineConnector({ }, auth: { type: 'none' }, }); +/** + * ADR-0096 provider-bound instance, **file-path spec** form (#3016): the OpenAPI + * document lives next to this file (`status-openapi.json`) and is referenced by + * a path resolved relative to THIS package's root at materialization — reads + * are confined to the package root (absolute / `..`-escaping paths are + * rejected), and a missing or unparseable document fails boot loudly. The + * `openapi` provider factory (ConnectorOpenApiPlugin in objectstack.config.ts) + * turns the document's one operation (`getHealth` → `GET /api/v1/health`) into + * a dispatchable action against the running server itself, so the materialized + * connector is observable with no external dependency. Complements + * {@link StatusApiConnector}, which demos the same materialization from the + * `rest` provider's inline config. + */ +export const StatusOpenApiConnector = defineConnector({ + name: 'showcase_status_openapi', + label: 'Status API (Declarative OpenAPI Instance, File-Path Spec)', + type: 'api', + description: + 'Provider-bound declarative connector instance (ADR-0096) whose OpenAPI document is referenced as a ' + + 'package-relative file path (#3016) and read at boot, confined to the package root. Materialized into a live ' + + '`openapi` connector — getHealth dispatches GET /api/v1/health against the running server.', + provider: 'openapi', + providerConfig: { + // Package-relative file ref (#3016) — resolved against the directory that + // holds objectstack.config.ts (the CLI passes it as the automation + // service's packageRoot). Inline documents and http(s) URLs stay valid. + spec: './src/system/connectors/status-openapi.json', + // Same self-pointing literal rationale as StatusApiConnector above. + baseUrl: 'http://127.0.0.1:3000', + }, + auth: { type: 'none' }, +}); + export const ErpCatalogConnector = defineConnector({ name: 'showcase_erp_catalog', label: 'ERP Integration (Catalog Descriptor)', @@ -110,4 +143,4 @@ export const ErpCatalogConnector = defineConnector({ enabled: false, }); -export const allConnectors: Connector[] = [StatusApiConnector, ErpCatalogConnector]; +export const allConnectors: Connector[] = [StatusApiConnector, StatusOpenApiConnector, ErpCatalogConnector]; diff --git a/examples/app-showcase/src/system/connectors/status-openapi.json b/examples/app-showcase/src/system/connectors/status-openapi.json new file mode 100644 index 0000000000..2e8d11600a --- /dev/null +++ b/examples/app-showcase/src/system/connectors/status-openapi.json @@ -0,0 +1,21 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Showcase Status API", + "version": "1.0.0", + "description": "Minimal OpenAPI document for the showcase's own health probe. Referenced by the StatusOpenApiConnector declarative instance (src/system/connectors/index.ts) as a package-relative file path — the #3016 / ADR-0096 spec form resolved and confined to this package's root at boot." + }, + "servers": [{ "url": "http://127.0.0.1:3000" }], + "paths": { + "/api/v1/health": { + "get": { + "operationId": "getHealth", + "summary": "Server health probe", + "description": "Returns the running server's health status.", + "responses": { + "200": { "description": "Server is healthy" } + } + } + } + } +} diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 7912f809f4..f0947c431a 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1855,7 +1855,14 @@ export default class Serve extends Command { } // analytics needs cubes from config, others take no args let arg: any; - if (spec.configKey === 'analyticsCubes') { + if (cap === 'automation') { + // #3016 — anchor declarative connector file refs (e.g. the openapi + // provider's `providerConfig.spec: './billing-openapi.json'`) to the + // project folder (next to objectstack.config.ts), mirroring how the + // standalone sqlite default is anchored above. Reads are confined to + // this root by the automation service's package file loader. + arg = { packageRoot: path.dirname(absolutePath) }; + } else if (spec.configKey === 'analyticsCubes') { const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }; } else if (cap === 'email') { diff --git a/packages/connectors/connector-openapi/src/openapi-provider.test.ts b/packages/connectors/connector-openapi/src/openapi-provider.test.ts index d2b095bbd6..8958eda744 100644 --- a/packages/connectors/connector-openapi/src/openapi-provider.test.ts +++ b/packages/connectors/connector-openapi/src/openapi-provider.test.ts @@ -52,10 +52,47 @@ describe('openapi provider factory (ADR-0096)', () => { await expect(factory(ctx({ providerConfig: {} }))).rejects.toThrow(/providerConfig\.spec/); }); - it('rejects a bare file-path spec with a clear message', async () => { + // ── File-path specs (#3016 — ADR-0096 follow-up) ──────────────────────── + + it('reads a file-path spec through the host loadPackageFile capability', async () => { + const requested: string[] = []; + const loadPackageFile = async (rel: string) => { + requested.push(rel); + return JSON.stringify(petstore); + }; + const factory = createOpenApiProviderFactory(); + const { def, handlers } = await factory( + ctx({ providerConfig: { spec: './specs/petstore.json' }, loadPackageFile }), + ); + expect(requested).toEqual(['./specs/petstore.json']); + expect(def.actions?.map((a) => a.key)).toEqual(['listPets']); + expect(Object.keys(handlers)).toEqual(['listPets']); + }); + + it('surfaces a loader failure (missing file / traversal rejection) with the connector name', async () => { + const loadPackageFile = async (rel: string) => { + throw new Error(`package file ref '${rel}' could not be read`); + }; + const factory = createOpenApiProviderFactory(); + await expect( + factory(ctx({ providerConfig: { spec: './missing.json' }, loadPackageFile })), + ).rejects.toThrow(/'pets' failed to read providerConfig\.spec '\.\/missing\.json'.*could not be read/s); + }); + + it('rejects an unparseable file-path spec with a clear message', async () => { + const factory = createOpenApiProviderFactory(); + await expect( + factory(ctx({ providerConfig: { spec: './broken.json' }, loadPackageFile: async () => 'not-json{' })), + ).rejects.toThrow(/not a parseable.*OpenAPI JSON document/s); + await expect( + factory(ctx({ providerConfig: { spec: './array.json' }, loadPackageFile: async () => '[1,2]' })), + ).rejects.toThrow(/not a parseable.*OpenAPI JSON document/s); + }); + + it('rejects a file-path spec with a clear message when the host has no package file access', async () => { const factory = createOpenApiProviderFactory(); await expect( factory(ctx({ providerConfig: { spec: './petstore.json' } })), - ).rejects.toThrow(/not an http\(s\) URL/); + ).rejects.toThrow(/no package file access/); }); }); diff --git a/packages/connectors/connector-openapi/src/openapi-provider.ts b/packages/connectors/connector-openapi/src/openapi-provider.ts index d81adde3af..4fcab127ca 100644 --- a/packages/connectors/connector-openapi/src/openapi-provider.ts +++ b/packages/connectors/connector-openapi/src/openapi-provider.ts @@ -1,6 +1,10 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { ConnectorProviderFactory, ResolvedConnectorAuth } from '@objectstack/spec/integration'; +import type { + ConnectorProviderContext, + ConnectorProviderFactory, + ResolvedConnectorAuth, +} from '@objectstack/spec/integration'; import { createOpenApiConnector, type OpenApiDocument, @@ -21,24 +25,32 @@ export interface OpenApiProviderDeps { /** Shape of `providerConfig` for a `provider: 'openapi'` declarative instance. */ interface OpenApiProviderConfig { - /** The OpenAPI 3.x document: an inline object, or an http(s) URL to fetch at boot. */ + /** + * The OpenAPI 3.x document: an inline object, an http(s) URL to fetch at + * boot, or a file path resolved relative to the declaring stack/package root + * (`'./billing-openapi.json'`, #3016). + */ spec?: unknown; /** Override the base URL (else the document's `servers[0].url`). */ baseUrl?: unknown; } /** - * Resolve `providerConfig.spec` into a parsed OpenAPI document. Accepts an inline - * document object (the reliable, no-network-at-boot form used by the showcase) or - * an http(s) URL fetched at materialization. A bare file path is rejected with a - * clear message: resolving `./x.json` relative to the stack is the stack loader's - * job, not the connector's — inline the document or serve it over HTTP. + * Resolve `providerConfig.spec` into a parsed OpenAPI document (ADR-0096; + * union per #3016): an inline document object (the reliable, no-I/O-at-boot + * form used by the showcase), an http(s) URL fetched at materialization, or a + * **file path** read through the host's `ctx.loadPackageFile` — which resolves + * it relative to the declaring stack/package root and confines the read to + * that root (absolute / `..`-escaping paths are rejected there). Every failure + * throws, so the materializer's reconcile policy applies: fatal at boot, the + * entry is skipped on reload. */ async function loadOpenApiDocument( spec: unknown, fetchImpl: typeof fetch | undefined, - connectorName: string, + ctx: ConnectorProviderContext, ): Promise { + const connectorName = ctx.name; if (spec && typeof spec === 'object' && !Array.isArray(spec)) { return spec as OpenApiDocument; } @@ -53,13 +65,39 @@ async function loadOpenApiDocument( } return (await res.json()) as OpenApiDocument; } - throw new Error( - `connector-openapi provider: connector '${connectorName}' providerConfig.spec '${spec}' is not an http(s) URL. ` + - `Provide an inline OpenAPI document object or an http(s) URL — file-path refs are resolved by the stack loader, not the connector.`, - ); + // File path — dereferenced through the host capability so resolution stays + // anchored to (and confined within) the declaring stack/package root. + if (!ctx.loadPackageFile) { + throw new Error( + `connector-openapi provider: connector '${connectorName}' providerConfig.spec '${spec}' is a file path, ` + + `but this host provides no package file access — inline the OpenAPI document or use an http(s) URL.`, + ); + } + let text: string; + try { + text = await ctx.loadPackageFile(spec); + } catch (err) { + throw new Error( + `connector-openapi provider: connector '${connectorName}' failed to read providerConfig.spec '${spec}': ` + + `${(err as Error).message}`, + ); + } + try { + const parsed: unknown = JSON.parse(text); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('not a JSON object'); + } + return parsed as OpenApiDocument; + } catch (err) { + throw new Error( + `connector-openapi provider: connector '${connectorName}' providerConfig.spec '${spec}' is not a parseable ` + + `OpenAPI JSON document: ${(err as Error).message}`, + ); + } } throw new Error( - `connector-openapi provider: connector '${connectorName}' requires providerConfig.spec — an inline OpenAPI 3.x document object or an http(s) URL.`, + `connector-openapi provider: connector '${connectorName}' requires providerConfig.spec — an inline OpenAPI 3.x ` + + `document object, an http(s) URL, or a package-relative file path.`, ); } @@ -82,7 +120,7 @@ export function createOpenApiProviderFactory(deps: OpenApiProviderDeps = {}): Co `connector-openapi provider: connector '${ctx.name}' providerConfig.baseUrl must be a string when set.`, ); } - const document = await loadOpenApiDocument(cfg.spec, deps.fetchImpl, ctx.name); + const document = await loadOpenApiDocument(cfg.spec, deps.fetchImpl, ctx); const auth = ctx.auth as ResolvedConnectorAuth | undefined as RestAuth | undefined; return createOpenApiConnector({ name: ctx.name, diff --git a/packages/services/service-automation/src/connector-materialization.test.ts b/packages/services/service-automation/src/connector-materialization.test.ts index 23e7f86ac0..6e439f4464 100644 --- a/packages/services/service-automation/src/connector-materialization.test.ts +++ b/packages/services/service-automation/src/connector-materialization.test.ts @@ -9,14 +9,17 @@ // Boot fails loudly for unknown provider / unresolvable credentialRef / name // conflict / factory failure. -import { describe, it, expect } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { describe, it, expect, afterAll } from 'vitest'; import { LiteKernel } from '@objectstack/core'; import type { ConnectorProviderContext, ConnectorProviderFactory, ConnectorInstanceAuth, } from '@objectstack/spec/integration'; -import { AutomationServicePlugin, type CredentialResolver } from './plugin.js'; +import { AutomationServicePlugin, createPackageFileLoader, type CredentialResolver } from './plugin.js'; import type { AutomationEngine } from './engine.js'; const flush = () => new Promise((r) => setTimeout(r, 0)); @@ -81,6 +84,8 @@ interface BootOptions { /** Names registered as plugin connectors during init() (for the conflict rule). */ registerLivePlugin?: string[]; credentialResolver?: CredentialResolver; + /** Stack/package root that relative file refs resolve against (#3016). */ + packageRoot?: string; } /** @@ -92,7 +97,7 @@ interface BootOptions { */ async function boot(declared: unknown[], opts: BootOptions = {}): Promise { const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); - kernel.use(new AutomationServicePlugin({ credentialResolver: opts.credentialResolver })); + kernel.use(new AutomationServicePlugin({ credentialResolver: opts.credentialResolver, packageRoot: opts.packageRoot })); const harness = { name: 'test.harness', type: 'standard' as const, @@ -160,12 +165,12 @@ function makeClosableProvider() { */ async function bootReloadable( initial: unknown[], - opts: { providerFactory: ConnectorProviderFactory; credentialResolver?: CredentialResolver }, + opts: { providerFactory: ConnectorProviderFactory; credentialResolver?: CredentialResolver; packageRoot?: string }, ) { const state = { declared: initial }; let captured: any; const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); - kernel.use(new AutomationServicePlugin({ credentialResolver: opts.credentialResolver })); + kernel.use(new AutomationServicePlugin({ credentialResolver: opts.credentialResolver, packageRoot: opts.packageRoot })); const harness = { name: 'test.harness', type: 'standard' as const, @@ -427,3 +432,105 @@ describe('ADR-0096 — runtime re-materialization on metadata:reloaded (F1)', () await kernel.shutdown(); }); }); + +// ── #3016 — package file refs (loadPackageFile) ───────────────────────────── +// +// The `loadPackageFile` capability lets a provider factory dereference a +// relative file ref (the openapi provider's `providerConfig.spec: +// './billing-openapi.json'`) against the declaring stack/package root, with +// reads CONFINED to that root. Failures follow the reconcile policy above: +// fatal at boot, skipped on reload. + +const fixtureRoot = mkdtempSync(path.join(tmpdir(), 'adr96-pkgfile-')); +mkdirSync(path.join(fixtureRoot, 'specs'), { recursive: true }); +writeFileSync(path.join(fixtureRoot, 'specs', 'billing.json'), JSON.stringify({ openapi: '3.0.0' })); +afterAll(() => rmSync(fixtureRoot, { recursive: true, force: true })); + +/** A provider whose factory reads `providerConfig.spec` via ctx.loadPackageFile. */ +function makeFileReadingProvider() { + const reads: string[] = []; + const factory: ConnectorProviderFactory = async (ctx) => { + const text = await ctx.loadPackageFile!(String(ctx.providerConfig.spec)); + reads.push(text); + return { + def: { name: ctx.name, label: ctx.label, type: 'api', authentication: { type: 'none' }, actions: [{ key: 'ping', label: 'Ping' }] }, + handlers: { ping: async () => ({ ok: true }) }, + }; + }; + return { factory, reads }; +} + +describe('#3016 — package file loader (createPackageFileLoader)', () => { + const load = createPackageFileLoader(fixtureRoot); + + it('reads a root-relative file (happy path)', async () => { + await expect(load('./specs/billing.json')).resolves.toBe(JSON.stringify({ openapi: '3.0.0' })); + // Plain relative form (no leading ./) resolves identically. + await expect(load('specs/billing.json')).resolves.toContain('openapi'); + }); + + it('rejects absolute paths (posix and windows-drive)', async () => { + await expect(load(path.join(fixtureRoot, 'specs', 'billing.json'))).rejects.toThrow(/absolute/); + await expect(load('C:\\evil\\creds.json')).rejects.toThrow(/absolute/); + }); + + it('rejects paths that escape the package root after resolution', async () => { + await expect(load('../outside.json')).rejects.toThrow(/escapes the stack\/package root/); + await expect(load('specs/../../outside.json')).rejects.toThrow(/escapes the stack\/package root/); + }); + + it('rejects empty refs and reports unreadable files with the resolved path', async () => { + await expect(load('')).rejects.toThrow(/non-empty relative path/); + await expect(load('./specs/missing.json')).rejects.toThrow(/could not be read/); + }); +}); + +describe('#3016 — file-ref materialization policy (fatal at boot, soft on reload)', () => { + it('hands every provider factory a working loadPackageFile anchored to packageRoot', async () => { + const { factory, reads } = makeFileReadingProvider(); + const kernel = await boot( + [providerConnector('billing', { providerConfig: { spec: './specs/billing.json' } })], + { providerFactory: factory, packageRoot: fixtureRoot }, + ); + expect(automationOf(kernel).getRegisteredConnectors()).toContain('billing'); + expect(reads).toEqual([JSON.stringify({ openapi: '3.0.0' })]); + await kernel.shutdown(); + }); + + it('fails boot loudly when the file ref is missing', async () => { + const { factory } = makeFileReadingProvider(); + await expect( + boot([providerConnector('billing', { providerConfig: { spec: './specs/missing.json' } })], { + providerFactory: factory, + packageRoot: fixtureRoot, + }), + ).rejects.toThrow(/failed to materialize connector instance 'billing'.*could not be read/s); + }); + + it('fails boot loudly when the file ref escapes the package root', async () => { + const { factory } = makeFileReadingProvider(); + await expect( + boot([providerConnector('billing', { providerConfig: { spec: '../outside.json' } })], { + providerFactory: factory, + packageRoot: fixtureRoot, + }), + ).rejects.toThrow(/escapes the stack\/package root/); + }); + + it('reload is soft: a bad file ref skips the entry, keeps the old connector serving', async () => { + const { factory } = makeFileReadingProvider(); + const { engine, reload, kernel } = await bootReloadable( + [providerConnector('billing', { providerConfig: { spec: './specs/billing.json' } })], + { providerFactory: factory, packageRoot: fixtureRoot }, + ); + expect(engine.getRegisteredConnectors()).toContain('billing'); + + // A publish that points billing at a missing file must not crash the + // server; the previously materialized connector keeps serving. + await expect( + reload([providerConnector('billing', { providerConfig: { spec: './specs/missing.json' } })]), + ).resolves.toBeUndefined(); + expect(engine.getRegisteredConnectors()).toContain('billing'); + await kernel.shutdown(); + }); +}); diff --git a/packages/services/service-automation/src/index.ts b/packages/services/service-automation/src/index.ts index ece7fddb2d..dfd44ce2ca 100644 --- a/packages/services/service-automation/src/index.ts +++ b/packages/services/service-automation/src/index.ts @@ -45,7 +45,7 @@ export { SysAutomationRun } from './sys-automation-run.object.js'; // Kernel plugin — seeds all built-in nodes; this is the only plugin needed for // a fully-functional automation capability. -export { AutomationServicePlugin } from './plugin.js'; +export { AutomationServicePlugin, createPackageFileLoader } from './plugin.js'; export type { AutomationServicePluginOptions } from './plugin.js'; // Run identity (ADR-0049 / #1888). Maps a flow run's effective `runAs` to the diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 620d4dcac0..911d47cacf 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -63,6 +63,62 @@ export interface AutomationServicePluginOptions { * hard boot error (an app must not silently run with a dead connector). */ credentialResolver?: CredentialResolver; + /** + * Root directory of the stack/package whose metadata this kernel serves — + * the base that relative file refs in declarative connector entries resolve + * against (#3016, e.g. `providerConfig.spec: './billing-openapi.json'` for + * `provider: 'openapi'`). The CLI passes the directory containing + * `objectstack.config.ts`; embedders pass their stack root. Defaults to + * `process.cwd()`. Reads are confined to this root (see + * {@link createPackageFileLoader}). + */ + packageRoot?: string; +} + +/** + * Build the `loadPackageFile` capability handed to provider factories via + * {@link ConnectorProviderContext} (#3016): read a UTF-8 text file resolved + * against `packageRoot`, **confined to that root**. Rejects absolute paths and + * any path that escapes the root after resolution (`../…`, `a/../../…`), so a + * declarative entry can never read outside the stack/package that declared it. + * A missing/unreadable file throws — the materializer's reconcile policy makes + * that fatal at boot and a skipped entry on reload, like every other ADR-0096 + * materialization failure. + * + * Node builtins are imported lazily inside the returned closure so merely + * constructing the capability never touches `node:fs`/`node:path` — hosts + * without a filesystem only fail if a factory actually dereferences a file ref. + */ +export function createPackageFileLoader(packageRoot?: string): (relativePath: string) => Promise { + return async (relativePath: string) => { + if (typeof relativePath !== 'string' || relativePath.trim().length === 0) { + throw new Error('package file ref must be a non-empty relative path.'); + } + const path = await import('node:path'); + // Windows drive-letter absolutes ('C:\…') are not `isAbsolute` on posix — + // reject them explicitly so the guard is platform-independent. + if (path.isAbsolute(relativePath) || /^[a-zA-Z]:[\\/]/.test(relativePath)) { + throw new Error( + `package file ref '${relativePath}' is absolute — file refs must be relative to the declaring stack/package root.`, + ); + } + const root = path.resolve(packageRoot ?? process.cwd()); + const resolved = path.resolve(root, relativePath); + const rel = path.relative(root, resolved); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error( + `package file ref '${relativePath}' escapes the stack/package root — reads are confined to '${root}'.`, + ); + } + const { readFile } = await import('node:fs/promises'); + try { + return await readFile(resolved, 'utf8'); + } catch (err) { + throw new Error( + `package file ref '${relativePath}' could not be read (resolved to '${resolved}'): ${(err as Error).message}`, + ); + } + }; } /** @@ -624,6 +680,10 @@ export class AutomationServicePlugin implements Plugin { type: typeof entry.type === 'string' ? entry.type : 'api', providerConfig: entry.providerConfig ?? {}, auth, + // #3016 — lets a factory dereference relative file refs (e.g. + // openapi's `providerConfig.spec: './billing-openapi.json'`), + // confined to the stack/package root. + loadPackageFile: createPackageFileLoader(this.options.packageRoot), }; let materialization; diff --git a/packages/spec/src/integration/connector-provider.ts b/packages/spec/src/integration/connector-provider.ts index 4521e15f6a..97f5f0e4ee 100644 --- a/packages/spec/src/integration/connector-provider.ts +++ b/packages/spec/src/integration/connector-provider.ts @@ -62,6 +62,19 @@ export interface ConnectorProviderContext { readonly type: string; readonly providerConfig: Record; readonly auth?: ResolvedConnectorAuth; + /** + * Host-injected package file reader (#3016, ADR-0096 follow-up). Resolves a + * **relative** path against the root of the stack/package that declared the + * entry and returns the file's UTF-8 text, so a factory can support file-path + * refs like `providerConfig.spec: './billing-openapi.json'` without owning + * filesystem access itself. The host confines reads to the package root: + * absolute paths and `..`-escaping paths are rejected (throws). Missing / + * unreadable files also throw, which the materializer's reconcile policy + * turns into a hard boot error (fatal) or a skipped entry (reload). + * `undefined` on hosts without a filesystem (edge/browser kernels) — a + * factory needing a file must then fail with a clear message. + */ + readonly loadPackageFile?: (relativePath: string) => Promise; } /** diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 0efebbfee3..40837b9a83 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -605,13 +605,18 @@ export const ConnectorSchema = lazySchema(() => z.object({ /** * ADR-0096 — provider-specific configuration, **validated by the provider * factory** (not by this schema): the OpenAPI provider expects `{ spec, - * baseUrl? }`, the MCP provider a `{ transport }`, the REST provider a - * `{ baseUrl }`. Deliberately untyped here — re-modelling each provider's - * inputs in the stack schema (an OpenAPI document, an MCP transport) is exactly - * what ADR-0023 rejected. Ignored unless `provider` is set. + * baseUrl? }` — where `spec` is an inline OpenAPI document object, a file + * path resolved relative to the declaring stack/package root (reads are + * confined to that root; #3016), or an http(s) URL — the MCP provider a + * `{ transport }`, the REST provider a `{ baseUrl }`. Deliberately untyped + * here — re-modelling each provider's inputs in the stack schema (an OpenAPI + * document, an MCP transport) is exactly what ADR-0023 rejected. Ignored + * unless `provider` is set. */ providerConfig: z.record(z.string(), z.unknown()).optional().describe( - 'Provider-specific config validated by the provider factory at boot (e.g. { spec, baseUrl } for openapi). Requires `provider`.', + 'Provider-specific config validated by the provider factory at boot (e.g. { spec, baseUrl } for openapi, ' + + "where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). " + + 'Requires `provider`.', ), /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20c745d678..53fcc84b5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: '@objectstack/cloud-connection': specifier: workspace:* version: link:../../packages/cloud-connection + '@objectstack/connector-openapi': + specifier: workspace:* + version: link:../../packages/connectors/connector-openapi '@objectstack/connector-rest': specifier: workspace:* version: link:../../packages/connectors/connector-rest