Skip to content
Open
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
28 changes: 28 additions & 0 deletions .changeset/sdk-shared-credential-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@sapiom/analytics-core": minor
"@sapiom/core": minor
"@sapiom/agent-core": minor
"@sapiom/mcp": patch
"@sapiom/tools": patch
---

The tenant API key now only travels over HTTPS, or plain HTTP to a loopback
host, from every SDK client, and never follows a redirect to another origin.
Comment thread
antoine-berger marked this conversation as resolved.
One function, `credentialFetch` from the new `@sapiom/analytics-core/credential`
subpath, is the only code that sets the key header.

- `@sapiom/core`, `@sapiom/agent-core` and `@sapiom/mcp`: a plain `http://`
URL to a non-loopback host (for example a Docker service name or a LAN IP)
now fails before the request is sent. On a trusted private network, opt in
with `new SapiomClient({ allowInsecureHttp: true })` (core),
`createClient({ allowInsecureHttp: true })` (agent-core) or
`SAPIOM_ALLOW_INSECURE_HTTP=1` (all clients). `localhost`, `*.localhost`,
`127.0.0.1` and `[::1]` work as before. Core does not retry a refused channel.
- `@sapiom/analytics-core`: on such an endpoint the batch is still sent, without
the key. A redirect to a refused channel drops the batch.
- Every client follows redirects itself: the first hop to another origin drops
`x-api-key`, `x-sapiom-api-key` and every `x-sapiom-*` header.
- `@sapiom/core`: `getHttpClient().defaults.headers` no longer carries the key;
read `defaults.credential` instead.
- `@sapiom/tools`: no behavior change. A refused call now throws a
`CredentialChannelError`, a `TypeError` subclass that keeps `name: "TypeError"`.
5 changes: 5 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ jobs:
- name: Check Agent Studio visible terminology
run: pnpm terminology:check

# Keep literal tenant-key header writes in the shared owner, except for
# the exact-path exceptions in scripts/credential-header-allowlist.json.
- name: Check tenant credential headers
run: pnpm credential-header:check

# Fixture tests for the gate itself: a typo'd declaration field must fail
# rather than be silently stripped downstream. This glob also runs the
# terminology guard's forbidden-copy, compatibility, and stale-allowlist
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"examples:check:test": "node --test scripts/*.test.mjs",
"examples:test": "node scripts/examples-test.mjs",
"terminology:check": "node scripts/agent-studio-terminology-check.mjs",
"credential-header:check": "node scripts/credential-header-check.mjs",
"terminology:check:test": "node --test scripts/agent-studio-terminology-check.test.mjs",
"provider-copy:check": "node scripts/provider-neutral-copy-check.mjs",
"provider-copy:check:test": "node --test scripts/provider-neutral-copy-check.test.mjs",
Expand Down
84 changes: 84 additions & 0 deletions packages/agent-core/src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,90 @@ describe('signal', () => {
});
});

describe('GatewayClient credential channel', () => {
const ENV = 'SAPIOM_ALLOW_INSECURE_HTTP';
const ORIGINAL = process.env[ENV];
beforeEach(() => {
delete process.env[ENV];
});
afterAll(() => {
if (ORIGINAL === undefined) delete process.env[ENV];
else process.env[ENV] = ORIGINAL;
});

const sentKey = (spy: jest.SpyInstance, call: number) =>
(spy.mock.calls[call][1] as RequestInit & { headers: Record<string, string> }).headers[
'x-api-key'
];

it.each([
['get', (c: GatewayClient) => c.get('/foo')],
['post', (c: GatewayClient) => c.post('/foo', { a: 1 })],
['postAtHostRoot', (c: GatewayClient) => c.postAtHostRoot('/v1/studio-feedback', {})],
['openStream', (c: GatewayClient) => c.openStream('/executions/e1/stream')],
])('%s refuses plaintext to a non-loopback host as NETWORK, before sending', async (_m, call) => {
const spy = mockFetch([{ status: 200, body: {} }]);
const client = createClient({ host: 'http://gw.internal', apiKey: 'sk_live' });
await expect(call(client)).rejects.toMatchObject({
code: 'NETWORK',
hint: expect.stringContaining('@sapiom/agent-core: refusing plaintext HTTP to http://gw.internal'),
});
expect(spy).not.toHaveBeenCalled();
});

it('names its own opt-in in the refusal', async () => {
mockFetch([{ status: 200, body: {} }]);
const client = createClient({ host: 'http://gw.internal', apiKey: 'sk_live' });
await expect(client.get('/foo')).rejects.toMatchObject({
hint: expect.stringContaining(
'createClient({ allowInsecureHttp: true }) or SAPIOM_ALLOW_INSECURE_HTTP=1',
),
});
});

it.each([
['a loopback host', { host: 'http://localhost:3000' }, undefined],
['the client option', { host: 'http://gw.internal', allowInsecureHttp: true }, undefined],
['the env var', { host: 'http://gw.internal' }, '1'],
])('sends over plain http to %s', async (_label, options, env) => {
if (env) process.env[ENV] = env;
const spy = mockFetch([{ status: 200, body: {} }]);
await createClient({ apiKey: 'sk_live', ...options }).get('/foo');
expect(sentKey(spy, 0)).toBe('sk_live');
});

it('drops x-api-key on a redirect to another origin (get)', async () => {
const spy = jest
.spyOn(global, 'fetch' as any)
.mockResolvedValueOnce(
new Response(null, { status: 307, headers: { location: 'https://other.example/foo' } }),
)
.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 }));
const client = createClient({ host: 'https://example.com', apiKey: 'sk_live' });
await expect(client.get('/foo')).resolves.toEqual({ ok: true });
expect(spy.mock.calls.map((c) => c[0])).toEqual([
'https://example.com/v1/workflows/foo',
'https://other.example/foo',
]);
expect([sentKey(spy, 0), sentKey(spy, 1)]).toEqual(['sk_live', undefined]);
});

it('drops x-api-key on a redirect to another origin (openStream), keeping the body open', async () => {
const spy = jest
.spyOn(global, 'fetch' as any)
.mockResolvedValueOnce(
new Response(null, { status: 307, headers: { location: 'https://other.example/s' } }),
)
.mockResolvedValueOnce(new Response('data: 1\n\n', { status: 200 }));
const client = createClient({ host: 'https://example.com', apiKey: 'sk_live' });
const res = await client.openStream('/executions/e1/stream');
expect(spy.mock.calls[1][0]).toBe('https://other.example/s');
expect([sentKey(spy, 0), sentKey(spy, 1)]).toEqual(['sk_live', undefined]);
expect(res.bodyUsed).toBe(false);
expect(await res.text()).toBe('data: 1\n\n');
});
});

// ── emitEvent ─────────────────────────────────────────────────────────────────

describe('emitEvent', () => {
Expand Down
57 changes: 39 additions & 18 deletions packages/agent-core/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/**
* Configurable HTTP client for the Sapiom workflows backend API. All inputs are
* passed explicitly (base URL + API key) — no process.env reads, no global
* state — so the client is usable from a CLI, an MCP tool, or a test harness.
*/
/** Explicit credentials keep this client reusable by CLI, MCP, and test callers.
* The shared channel policy also reads SAPIOM_ALLOW_INSECURE_HTTP. */
import {
credentialFetch,
resolveCredentialPolicy,
type Credential,
type CredentialPolicy,
} from '@sapiom/analytics-core/credential';
import { AgentOperationError } from './errors.js';

/**
Expand All @@ -17,6 +20,9 @@ export interface ClientOptions {
host?: string;
/** API key sent as `x-api-key`. Must start with `sk_`. */
apiKey: string;
/** Permit plaintext credentials only for a trusted private network.
* Explicit configuration overrides SAPIOM_ALLOW_INSECURE_HTTP. */
allowInsecureHttp?: boolean;
}

/** Result shape from a failed gateway request. */
Expand All @@ -42,12 +48,17 @@ export class GatewayClient {
private readonly host: string;
/** `${host}/v1/workflows` — what get/post/request/openStream paths are relative to. */
private readonly base: string;
private readonly apiKey: string;
private readonly credential: Credential;
private readonly policy: CredentialPolicy;

constructor(opts: ClientOptions) {
this.host = stripTrailingSlashes(opts.host ?? DEFAULT_WORKFLOWS_HOST);
this.base = `${this.host}/v1/workflows`;
this.apiKey = opts.apiKey;
this.credential = { header: 'x-api-key', value: opts.apiKey };
this.policy = resolveCredentialPolicy(opts.allowInsecureHttp, {
label: '@sapiom/agent-core',
optIn: 'createClient({ allowInsecureHttp: true })',
});
}

/** `path` is relative to `/v1/workflows`. */
Expand Down Expand Up @@ -110,12 +121,19 @@ export class GatewayClient {

let res: Response;
try {
res = await fetch(url, {
method,
headers: { 'x-api-key': this.apiKey, 'content-type': 'application/json' },
body: payload,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
// Preserve the NETWORK boundary so callers can surface channel refusals through hint.
res = await credentialFetch(
fetch,
url,
{
method,
headers: { 'content-type': 'application/json' },
body: payload,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
},
this.credential,
this.policy,
);
} catch (err) {
throw networkError(url, err);
}
Expand Down Expand Up @@ -160,18 +178,21 @@ export class GatewayClient {
path: string,
opts: { signal?: AbortSignal; lastEventId?: string } = {},
): Promise<Response> {
const headers: Record<string, string> = {
'x-api-key': this.apiKey,
accept: 'text/event-stream',
};
const headers: Record<string, string> = { accept: 'text/event-stream' };
if (opts.lastEventId) {
headers['last-event-id'] = opts.lastEventId;
}

const url = `${this.base}${path}`;
let res: Response;
try {
res = await fetch(url, { method: 'GET', headers, signal: opts.signal });
res = await credentialFetch(
fetch,
url,
{ method: 'GET', headers, signal: opts.signal },
this.credential,
this.policy,
);
} catch (err) {
throw networkError(url, err);
}
Expand Down
4 changes: 4 additions & 0 deletions packages/analytics-core/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ Authentication is optional and does exactly one thing: a **valid**
to the events (see [Server-stamped fields](#server-stamped-fields-never-client-trusted)).
An invalid or missing key never causes a rejection.

The key is only sent over https or to a loopback host (or with
`SAPIOM_ALLOW_INSECURE_HTTP=1`); on any other endpoint the batch goes out
without it. A redirect to another origin drops the key.

This URL is exported by `@sapiom/analytics-core` as the constant
`SAPIOM_COLLECTOR_ENDPOINT` and is the emitter's **default endpoint**: an
emitter with no explicit `endpoint` configured delivers here, unless the
Expand Down
8 changes: 8 additions & 0 deletions packages/analytics-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,20 @@
"types": "./dist/cjs/testing/index.d.ts",
"import": "./dist/esm/testing/index.js",
"require": "./dist/cjs/testing/index.js"
},
"./credential": {
"types": "./dist/cjs/credential.d.ts",
"import": "./dist/esm/credential.js",
"require": "./dist/cjs/credential.js"
}
},
"typesVersions": {
"*": {
"testing": [
"./dist/cjs/testing/index.d.ts"
],
"credential": [
"./dist/cjs/credential.d.ts"
]
}
},
Expand Down
Loading
Loading