Skip to content

Commit 9ef203b

Browse files
committed
feat(connector-openapi): resolve providerConfig.spec from a package-relative file path (#3016)
ADR-0096 follow-up: complete the spec union (inline object | file path | remote URL) for the declarative openapi provider. - spec: ConnectorProviderContext gains an optional host-injected loadPackageFile capability (pure type) - service-automation: packageRoot option + createPackageFileLoader with a root-confinement guard (rejects absolute and ..-escaping paths; lazy node:fs/node:path imports); capability injected into every provider ctx; failures follow the reconcile policy (fatal at boot, soft on reload) - connector-openapi: non-URL string specs are read via ctx.loadPackageFile and parsed as OpenAPI JSON with clear errors - cli: serve/dev anchor packageRoot to the objectstack.config.ts directory - tests: file-path happy path, missing file (fatal at boot / skipped on reload), traversal rejection; ADR-0096 scope-boundary note updated Closes #3016 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016eY7byWABTUPtJG7R2AEFU
1 parent dd19298 commit 9ef203b

9 files changed

Lines changed: 322 additions & 24 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/connector-openapi': minor
5+
'@objectstack/cli': minor
6+
---
7+
8+
feat(connector-openapi): resolve `providerConfig.spec` from a package-relative file path (#3016, ADR-0096 follow-up)
9+
10+
ADR-0096's canonical example authors an OpenAPI-backed instance as
11+
`providerConfig: { spec: './billing-openapi.json' }`, but the landed `openapi`
12+
provider factory only accepted an inline document object or an http(s) URL.
13+
The spec union is now complete: **inline object | file path | remote URL**.
14+
15+
- **`@objectstack/spec`.** `ConnectorProviderContext` gains an optional
16+
host-injected `loadPackageFile(relativePath)` capability (pure type): reads a
17+
UTF-8 file resolved against the declaring stack/package root, confined to
18+
that root. `undefined` on hosts without a filesystem.
19+
20+
- **`@objectstack/service-automation`.** New `packageRoot` plugin option (the
21+
base for relative file refs; defaults to `process.cwd()`) and an exported
22+
`createPackageFileLoader(packageRoot)` that implements the confinement
23+
guard — absolute paths and `..`-escaping paths are rejected — with lazy
24+
`node:fs`/`node:path` imports so non-Node hosts only fail if a file ref is
25+
actually dereferenced. The materializer injects the capability into every
26+
provider factory's context. Failures follow the existing reconcile policy:
27+
**fatal at boot, entry skipped on reload**.
28+
29+
- **`@objectstack/connector-openapi`.** A string `providerConfig.spec` that is
30+
not an http(s) URL is now read via `ctx.loadPackageFile` and parsed as an
31+
OpenAPI JSON document (clear errors for missing/unreadable files, unparseable
32+
JSON, and hosts without package file access).
33+
34+
- **`@objectstack/cli`.** `serve`/`dev` pass the project folder (the
35+
`objectstack.config.ts` directory) as the automation service's `packageRoot`,
36+
mirroring how the standalone sqlite default is anchored.

docs/adr/0096-declarative-connector-instances.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,6 @@ keeps serving until its replacement materializes successfully. Boot keeps the
134134

135135
### Deliberate scope boundaries
136136

137-
- **`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.
137+
- **`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.
138138
- **MCP credentials** ride the transport (ADR-0024); for an http transport a resolved `auth` is folded into the request headers.
139139
- **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).

packages/cli/src/commands/serve.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1855,7 +1855,14 @@ export default class Serve extends Command {
18551855
}
18561856
// analytics needs cubes from config, others take no args
18571857
let arg: any;
1858-
if (spec.configKey === 'analyticsCubes') {
1858+
if (cap === 'automation') {
1859+
// #3016 — anchor declarative connector file refs (e.g. the openapi
1860+
// provider's `providerConfig.spec: './billing-openapi.json'`) to the
1861+
// project folder (next to objectstack.config.ts), mirroring how the
1862+
// standalone sqlite default is anchored above. Reads are confined to
1863+
// this root by the automation service's package file loader.
1864+
arg = { packageRoot: path.dirname(absolutePath) };
1865+
} else if (spec.configKey === 'analyticsCubes') {
18591866
const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? [];
18601867
arg = { cubes };
18611868
} else if (cap === 'email') {

packages/connectors/connector-openapi/src/openapi-provider.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,47 @@ describe('openapi provider factory (ADR-0096)', () => {
5252
await expect(factory(ctx({ providerConfig: {} }))).rejects.toThrow(/providerConfig\.spec/);
5353
});
5454

55-
it('rejects a bare file-path spec with a clear message', async () => {
55+
// ── File-path specs (#3016 — ADR-0096 follow-up) ────────────────────────
56+
57+
it('reads a file-path spec through the host loadPackageFile capability', async () => {
58+
const requested: string[] = [];
59+
const loadPackageFile = async (rel: string) => {
60+
requested.push(rel);
61+
return JSON.stringify(petstore);
62+
};
63+
const factory = createOpenApiProviderFactory();
64+
const { def, handlers } = await factory(
65+
ctx({ providerConfig: { spec: './specs/petstore.json' }, loadPackageFile }),
66+
);
67+
expect(requested).toEqual(['./specs/petstore.json']);
68+
expect(def.actions?.map((a) => a.key)).toEqual(['listPets']);
69+
expect(Object.keys(handlers)).toEqual(['listPets']);
70+
});
71+
72+
it('surfaces a loader failure (missing file / traversal rejection) with the connector name', async () => {
73+
const loadPackageFile = async (rel: string) => {
74+
throw new Error(`package file ref '${rel}' could not be read`);
75+
};
76+
const factory = createOpenApiProviderFactory();
77+
await expect(
78+
factory(ctx({ providerConfig: { spec: './missing.json' }, loadPackageFile })),
79+
).rejects.toThrow(/'pets' failed to read providerConfig\.spec '\.\/missing\.json'.*could not be read/s);
80+
});
81+
82+
it('rejects an unparseable file-path spec with a clear message', async () => {
83+
const factory = createOpenApiProviderFactory();
84+
await expect(
85+
factory(ctx({ providerConfig: { spec: './broken.json' }, loadPackageFile: async () => 'not-json{' })),
86+
).rejects.toThrow(/not a parseable.*OpenAPI JSON document/s);
87+
await expect(
88+
factory(ctx({ providerConfig: { spec: './array.json' }, loadPackageFile: async () => '[1,2]' })),
89+
).rejects.toThrow(/not a parseable.*OpenAPI JSON document/s);
90+
});
91+
92+
it('rejects a file-path spec with a clear message when the host has no package file access', async () => {
5693
const factory = createOpenApiProviderFactory();
5794
await expect(
5895
factory(ctx({ providerConfig: { spec: './petstore.json' } })),
59-
).rejects.toThrow(/not an http\(s\) URL/);
96+
).rejects.toThrow(/no package file access/);
6097
});
6198
});

packages/connectors/connector-openapi/src/openapi-provider.ts

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import type { ConnectorProviderFactory, ResolvedConnectorAuth } from '@objectstack/spec/integration';
3+
import type {
4+
ConnectorProviderContext,
5+
ConnectorProviderFactory,
6+
ResolvedConnectorAuth,
7+
} from '@objectstack/spec/integration';
48
import {
59
createOpenApiConnector,
610
type OpenApiDocument,
@@ -21,24 +25,32 @@ export interface OpenApiProviderDeps {
2125

2226
/** Shape of `providerConfig` for a `provider: 'openapi'` declarative instance. */
2327
interface OpenApiProviderConfig {
24-
/** The OpenAPI 3.x document: an inline object, or an http(s) URL to fetch at boot. */
28+
/**
29+
* The OpenAPI 3.x document: an inline object, an http(s) URL to fetch at
30+
* boot, or a file path resolved relative to the declaring stack/package root
31+
* (`'./billing-openapi.json'`, #3016).
32+
*/
2533
spec?: unknown;
2634
/** Override the base URL (else the document's `servers[0].url`). */
2735
baseUrl?: unknown;
2836
}
2937

3038
/**
31-
* Resolve `providerConfig.spec` into a parsed OpenAPI document. Accepts an inline
32-
* document object (the reliable, no-network-at-boot form used by the showcase) or
33-
* an http(s) URL fetched at materialization. A bare file path is rejected with a
34-
* clear message: resolving `./x.json` relative to the stack is the stack loader's
35-
* job, not the connector's — inline the document or serve it over HTTP.
39+
* Resolve `providerConfig.spec` into a parsed OpenAPI document (ADR-0096;
40+
* union per #3016): an inline document object (the reliable, no-I/O-at-boot
41+
* form used by the showcase), an http(s) URL fetched at materialization, or a
42+
* **file path** read through the host's `ctx.loadPackageFile` — which resolves
43+
* it relative to the declaring stack/package root and confines the read to
44+
* that root (absolute / `..`-escaping paths are rejected there). Every failure
45+
* throws, so the materializer's reconcile policy applies: fatal at boot, the
46+
* entry is skipped on reload.
3647
*/
3748
async function loadOpenApiDocument(
3849
spec: unknown,
3950
fetchImpl: typeof fetch | undefined,
40-
connectorName: string,
51+
ctx: ConnectorProviderContext,
4152
): Promise<OpenApiDocument> {
53+
const connectorName = ctx.name;
4254
if (spec && typeof spec === 'object' && !Array.isArray(spec)) {
4355
return spec as OpenApiDocument;
4456
}
@@ -53,13 +65,39 @@ async function loadOpenApiDocument(
5365
}
5466
return (await res.json()) as OpenApiDocument;
5567
}
56-
throw new Error(
57-
`connector-openapi provider: connector '${connectorName}' providerConfig.spec '${spec}' is not an http(s) URL. ` +
58-
`Provide an inline OpenAPI document object or an http(s) URL — file-path refs are resolved by the stack loader, not the connector.`,
59-
);
68+
// File path — dereferenced through the host capability so resolution stays
69+
// anchored to (and confined within) the declaring stack/package root.
70+
if (!ctx.loadPackageFile) {
71+
throw new Error(
72+
`connector-openapi provider: connector '${connectorName}' providerConfig.spec '${spec}' is a file path, ` +
73+
`but this host provides no package file access — inline the OpenAPI document or use an http(s) URL.`,
74+
);
75+
}
76+
let text: string;
77+
try {
78+
text = await ctx.loadPackageFile(spec);
79+
} catch (err) {
80+
throw new Error(
81+
`connector-openapi provider: connector '${connectorName}' failed to read providerConfig.spec '${spec}': ` +
82+
`${(err as Error).message}`,
83+
);
84+
}
85+
try {
86+
const parsed: unknown = JSON.parse(text);
87+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
88+
throw new Error('not a JSON object');
89+
}
90+
return parsed as OpenApiDocument;
91+
} catch (err) {
92+
throw new Error(
93+
`connector-openapi provider: connector '${connectorName}' providerConfig.spec '${spec}' is not a parseable ` +
94+
`OpenAPI JSON document: ${(err as Error).message}`,
95+
);
96+
}
6097
}
6198
throw new Error(
62-
`connector-openapi provider: connector '${connectorName}' requires providerConfig.spec — an inline OpenAPI 3.x document object or an http(s) URL.`,
99+
`connector-openapi provider: connector '${connectorName}' requires providerConfig.spec — an inline OpenAPI 3.x ` +
100+
`document object, an http(s) URL, or a package-relative file path.`,
63101
);
64102
}
65103

@@ -82,7 +120,7 @@ export function createOpenApiProviderFactory(deps: OpenApiProviderDeps = {}): Co
82120
`connector-openapi provider: connector '${ctx.name}' providerConfig.baseUrl must be a string when set.`,
83121
);
84122
}
85-
const document = await loadOpenApiDocument(cfg.spec, deps.fetchImpl, ctx.name);
123+
const document = await loadOpenApiDocument(cfg.spec, deps.fetchImpl, ctx);
86124
const auth = ctx.auth as ResolvedConnectorAuth | undefined as RestAuth | undefined;
87125
return createOpenApiConnector({
88126
name: ctx.name,

packages/services/service-automation/src/connector-materialization.test.ts

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@
99
// Boot fails loudly for unknown provider / unresolvable credentialRef / name
1010
// conflict / factory failure.
1111

12-
import { describe, it, expect } from 'vitest';
12+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
13+
import { tmpdir } from 'node:os';
14+
import * as path from 'node:path';
15+
import { describe, it, expect, afterAll } from 'vitest';
1316
import { LiteKernel } from '@objectstack/core';
1417
import type {
1518
ConnectorProviderContext,
1619
ConnectorProviderFactory,
1720
ConnectorInstanceAuth,
1821
} from '@objectstack/spec/integration';
19-
import { AutomationServicePlugin, type CredentialResolver } from './plugin.js';
22+
import { AutomationServicePlugin, createPackageFileLoader, type CredentialResolver } from './plugin.js';
2023
import type { AutomationEngine } from './engine.js';
2124

2225
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
@@ -81,6 +84,8 @@ interface BootOptions {
8184
/** Names registered as plugin connectors during init() (for the conflict rule). */
8285
registerLivePlugin?: string[];
8386
credentialResolver?: CredentialResolver;
87+
/** Stack/package root that relative file refs resolve against (#3016). */
88+
packageRoot?: string;
8489
}
8590

8691
/**
@@ -92,7 +97,7 @@ interface BootOptions {
9297
*/
9398
async function boot(declared: unknown[], opts: BootOptions = {}): Promise<LiteKernel> {
9499
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
95-
kernel.use(new AutomationServicePlugin({ credentialResolver: opts.credentialResolver }));
100+
kernel.use(new AutomationServicePlugin({ credentialResolver: opts.credentialResolver, packageRoot: opts.packageRoot }));
96101
const harness = {
97102
name: 'test.harness',
98103
type: 'standard' as const,
@@ -160,12 +165,12 @@ function makeClosableProvider() {
160165
*/
161166
async function bootReloadable(
162167
initial: unknown[],
163-
opts: { providerFactory: ConnectorProviderFactory; credentialResolver?: CredentialResolver },
168+
opts: { providerFactory: ConnectorProviderFactory; credentialResolver?: CredentialResolver; packageRoot?: string },
164169
) {
165170
const state = { declared: initial };
166171
let captured: any;
167172
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
168-
kernel.use(new AutomationServicePlugin({ credentialResolver: opts.credentialResolver }));
173+
kernel.use(new AutomationServicePlugin({ credentialResolver: opts.credentialResolver, packageRoot: opts.packageRoot }));
169174
const harness = {
170175
name: 'test.harness',
171176
type: 'standard' as const,
@@ -427,3 +432,105 @@ describe('ADR-0096 — runtime re-materialization on metadata:reloaded (F1)', ()
427432
await kernel.shutdown();
428433
});
429434
});
435+
436+
// ── #3016 — package file refs (loadPackageFile) ─────────────────────────────
437+
//
438+
// The `loadPackageFile` capability lets a provider factory dereference a
439+
// relative file ref (the openapi provider's `providerConfig.spec:
440+
// './billing-openapi.json'`) against the declaring stack/package root, with
441+
// reads CONFINED to that root. Failures follow the reconcile policy above:
442+
// fatal at boot, skipped on reload.
443+
444+
const fixtureRoot = mkdtempSync(path.join(tmpdir(), 'adr96-pkgfile-'));
445+
mkdirSync(path.join(fixtureRoot, 'specs'), { recursive: true });
446+
writeFileSync(path.join(fixtureRoot, 'specs', 'billing.json'), JSON.stringify({ openapi: '3.0.0' }));
447+
afterAll(() => rmSync(fixtureRoot, { recursive: true, force: true }));
448+
449+
/** A provider whose factory reads `providerConfig.spec` via ctx.loadPackageFile. */
450+
function makeFileReadingProvider() {
451+
const reads: string[] = [];
452+
const factory: ConnectorProviderFactory = async (ctx) => {
453+
const text = await ctx.loadPackageFile!(String(ctx.providerConfig.spec));
454+
reads.push(text);
455+
return {
456+
def: { name: ctx.name, label: ctx.label, type: 'api', authentication: { type: 'none' }, actions: [{ key: 'ping', label: 'Ping' }] },
457+
handlers: { ping: async () => ({ ok: true }) },
458+
};
459+
};
460+
return { factory, reads };
461+
}
462+
463+
describe('#3016 — package file loader (createPackageFileLoader)', () => {
464+
const load = createPackageFileLoader(fixtureRoot);
465+
466+
it('reads a root-relative file (happy path)', async () => {
467+
await expect(load('./specs/billing.json')).resolves.toBe(JSON.stringify({ openapi: '3.0.0' }));
468+
// Plain relative form (no leading ./) resolves identically.
469+
await expect(load('specs/billing.json')).resolves.toContain('openapi');
470+
});
471+
472+
it('rejects absolute paths (posix and windows-drive)', async () => {
473+
await expect(load(path.join(fixtureRoot, 'specs', 'billing.json'))).rejects.toThrow(/absolute/);
474+
await expect(load('C:\\evil\\creds.json')).rejects.toThrow(/absolute/);
475+
});
476+
477+
it('rejects paths that escape the package root after resolution', async () => {
478+
await expect(load('../outside.json')).rejects.toThrow(/escapes the stack\/package root/);
479+
await expect(load('specs/../../outside.json')).rejects.toThrow(/escapes the stack\/package root/);
480+
});
481+
482+
it('rejects empty refs and reports unreadable files with the resolved path', async () => {
483+
await expect(load('')).rejects.toThrow(/non-empty relative path/);
484+
await expect(load('./specs/missing.json')).rejects.toThrow(/could not be read/);
485+
});
486+
});
487+
488+
describe('#3016 — file-ref materialization policy (fatal at boot, soft on reload)', () => {
489+
it('hands every provider factory a working loadPackageFile anchored to packageRoot', async () => {
490+
const { factory, reads } = makeFileReadingProvider();
491+
const kernel = await boot(
492+
[providerConnector('billing', { providerConfig: { spec: './specs/billing.json' } })],
493+
{ providerFactory: factory, packageRoot: fixtureRoot },
494+
);
495+
expect(automationOf(kernel).getRegisteredConnectors()).toContain('billing');
496+
expect(reads).toEqual([JSON.stringify({ openapi: '3.0.0' })]);
497+
await kernel.shutdown();
498+
});
499+
500+
it('fails boot loudly when the file ref is missing', async () => {
501+
const { factory } = makeFileReadingProvider();
502+
await expect(
503+
boot([providerConnector('billing', { providerConfig: { spec: './specs/missing.json' } })], {
504+
providerFactory: factory,
505+
packageRoot: fixtureRoot,
506+
}),
507+
).rejects.toThrow(/failed to materialize connector instance 'billing'.*could not be read/s);
508+
});
509+
510+
it('fails boot loudly when the file ref escapes the package root', async () => {
511+
const { factory } = makeFileReadingProvider();
512+
await expect(
513+
boot([providerConnector('billing', { providerConfig: { spec: '../outside.json' } })], {
514+
providerFactory: factory,
515+
packageRoot: fixtureRoot,
516+
}),
517+
).rejects.toThrow(/escapes the stack\/package root/);
518+
});
519+
520+
it('reload is soft: a bad file ref skips the entry, keeps the old connector serving', async () => {
521+
const { factory } = makeFileReadingProvider();
522+
const { engine, reload, kernel } = await bootReloadable(
523+
[providerConnector('billing', { providerConfig: { spec: './specs/billing.json' } })],
524+
{ providerFactory: factory, packageRoot: fixtureRoot },
525+
);
526+
expect(engine.getRegisteredConnectors()).toContain('billing');
527+
528+
// A publish that points billing at a missing file must not crash the
529+
// server; the previously materialized connector keeps serving.
530+
await expect(
531+
reload([providerConnector('billing', { providerConfig: { spec: './specs/missing.json' } })]),
532+
).resolves.toBeUndefined();
533+
expect(engine.getRegisteredConnectors()).toContain('billing');
534+
await kernel.shutdown();
535+
});
536+
});

packages/services/service-automation/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export { SysAutomationRun } from './sys-automation-run.object.js';
4545

4646
// Kernel plugin — seeds all built-in nodes; this is the only plugin needed for
4747
// a fully-functional automation capability.
48-
export { AutomationServicePlugin } from './plugin.js';
48+
export { AutomationServicePlugin, createPackageFileLoader } from './plugin.js';
4949
export type { AutomationServicePluginOptions } from './plugin.js';
5050

5151
// Run identity (ADR-0049 / #1888). Maps a flow run's effective `runAs` to the

0 commit comments

Comments
 (0)