Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
26813d8
fix(sdk): route the tenant key through one shared credentialFetch, en…
antoine-berger Oct 16, 2026
e528cc8
fix(sdk): normalize Headers in credentialFetch, match split header wr…
antoine-berger Oct 16, 2026
2f45c39
docs(sdk): shorten module comments on the credential path [SAP-4106]
antoine-berger Oct 16, 2026
8f394f6
fix(sdk): keep the TypeError name on a refused credential channel [SA…
antoine-berger Oct 16, 2026
2ddfaec
chore: restore the electron node-gyp integrity hash in the lockfile […
antoine-berger Oct 16, 2026
5ae9446
fix(analytics-core): strip SRI digest padding without a quadratic reg…
antoine-berger Oct 16, 2026
d8ac7d9
fix(analytics-core): follow plain http redirects when no key is sent …
antoine-berger Oct 17, 2026
cbe7b79
docs(core, mcp): document the plain-http opt-in for the API key [SAP-…
antoine-berger Oct 17, 2026
46996a5
fix(analytics-core): never downgrade a keyless request from https to …
antoine-berger Oct 17, 2026
035841b
fix(analytics-core): refuse any TLS downgrade on a keyless redirect c…
antoine-berger Oct 17, 2026
7b8eaeb
revert(analytics-core): one channel rule for every redirect hop, keye…
antoine-berger Oct 17, 2026
7687dff
docs(changeset): flag the behavior changes and core's new runtime dep…
antoine-berger Oct 22, 2026
8bee2ae
Merge remote-tracking branch 'origin/main' into antoine-berger/sap-41…
antoine-berger Oct 22, 2026
11602d4
ci(credential-header): allowlist the browser-pay relay, tracked in SA…
antoine-berger Oct 22, 2026
19ef213
fix(browser-pay): send the relay's tenant key through credentialFetch…
antoine-berger Oct 22, 2026
02852fe
docs(browser-pay): document the relay's channel rule and opt-in [SAP-…
antoine-berger Oct 22, 2026
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
38 changes: 38 additions & 0 deletions .changeset/sdk-shared-credential-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@sapiom/analytics-core": minor
"@sapiom/core": minor
"@sapiom/agent-core": minor
"@sapiom/browser-pay": 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.

Behavior changes, shipped as 0.x minor bumps: a plain `http://` URL to a
non-loopback host is now refused, and the key headers are stripped on a
cross-origin redirect. `@sapiom/core` and `@sapiom/browser-pay` gain their
first runtime dependency, `@sapiom/analytics-core`.

- `@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/browser-pay`: the payment relay uses the shared rule, so a
`*.localhost` base URL and `SAPIOM_ALLOW_INSECURE_HTTP=1` are now accepted, and
a refused base URL throws a `CredentialChannelError`. Redirects are still
refused.
- `@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