Skip to content
Merged
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
94 changes: 93 additions & 1 deletion packages/atxp-express/src/atxpExpress.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect} from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { atxpExpress } from './atxpExpress.js';
import { MemoryOAuthDb } from '@atxp/common';
import * as TH from '@atxp/server/serverTestHelpers';
Expand Down Expand Up @@ -163,4 +163,96 @@ describe('ATXP', () => {
scopes_supported: ['read', 'write'],
});
});

// The forwarding is one line at atxpExpress.ts (new ProtocolSettlement(...,
// { appName: config.appName })), but it's the glue most likely to silently
// break if someone refactors the settlement instantiation — a missing
// passthrough would still compile and pass unit tests. Close the loop by
// asserting the header actually reaches the outgoing fetch.
describe('X-ATXP-APP-NAME header forwarding', () => {
const mockFetch = vi.fn();

beforeEach(() => {
mockFetch.mockReset();
// Default: swallow the settle call so the middleware moves on to the
// handler. Tests assert on the headers recorded here.
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ txHash: '0xabc', settledAmount: '100' }),
text: async () => '',
});
// vi.stubGlobal + unstubAllGlobals is the idiomatic vitest pattern; plain
// reassignment of globalThis.fetch doesn't always propagate through the
// `fetch.bind(globalThis)` used in atxpExpress under all vitest configs.
vi.stubGlobal('fetch', mockFetch);
});

afterEach(() => {
vi.unstubAllGlobals();
});

const atxpCredential = JSON.stringify({
sourceAccountId: 'atxp_acct_test123',
sourceAccountToken: 'tok_abc',
});

const findSettleCall = () => mockFetch.mock.calls.find(
([url]) => typeof url === 'string' && url.includes('/settle/'),
);

// The middleware only runs the settle path on MCP requests — non-MCP
// requests bail out at parseMcpRequestsNode, which is why the older
// omniChallenge.test.ts tests observe that fetch isn't called.
const sendMcpToolCall = (app: express.Application) =>
request(app)
.post('/')
.set('Content-Type', 'application/json')
.set('Authorization', 'Bearer test-access-token')
.set('X-ATXP-PAYMENT', atxpCredential)
.send(TH.mcpToolRequest());

it('forwards config.appName into the X-ATXP-APP-NAME header on /settle/*', async () => {
const router = atxpExpress(TH.config({
appName: 'music-mcp',
oAuthClient: TH.oAuthClient({ introspectResult: TH.tokenData({ active: true, sub: 'test-user' }) }),
}));

const app = express();
app.use(express.json());
app.use(router);
app.post('/', (_req, res) => res.json({ ok: true }));

await sendMcpToolCall(app).expect(200);

const settleCall = findSettleCall();
expect(settleCall, 'atxpExpress should have called /settle/*').toBeDefined();
const headers = settleCall![1].headers as Record<string, string>;
expect(headers['X-ATXP-APP-NAME']).toBe('music-mcp');
});

it('omits the header when config.appName is unset and APP_NAME env is empty', async () => {
const savedAppName = process.env.APP_NAME;
delete process.env.APP_NAME;
try {
const router = atxpExpress(TH.config({
oAuthClient: TH.oAuthClient({ introspectResult: TH.tokenData({ active: true, sub: 'test-user' }) }),
}));

const app = express();
app.use(express.json());
app.use(router);
app.post('/', (_req, res) => res.json({ ok: true }));

await sendMcpToolCall(app).expect(200);

const settleCall = findSettleCall();
expect(settleCall, 'atxpExpress should have called /settle/*').toBeDefined();
const headers = settleCall![1].headers as Record<string, string>;
expect(headers).not.toHaveProperty('X-ATXP-APP-NAME');
} finally {
if (savedAppName === undefined) delete process.env.APP_NAME;
else process.env.APP_NAME = savedAppName;
}
});
});
});
1 change: 1 addition & 0 deletions packages/atxp-express/src/atxpExpress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
logger,
fetch.bind(globalThis),
destinationAccountId,
{ appName: config.appName },
);

// For X402: the credential's parsed payload contains `accepted` — the
Expand Down Expand Up @@ -223,11 +224,11 @@
res.writeHead = function writeHeadDeferred(this: Response, ...args: any[]): any {
deferredWriteHead = args;
return this;
} as any;

Check warning on line 227 in packages/atxp-express/src/atxpExpress.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type

function flushWriteHead(self: Response): void {
if (!deferredWriteHead) return;
(origWriteHead as any).apply(self, deferredWriteHead);

Check warning on line 231 in packages/atxp-express/src/atxpExpress.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
deferredWriteHead = null;
}

Expand All @@ -236,8 +237,8 @@
res.write = function writeWithPaymentRewrite(this: Response, ...args: any[]): any {
flushWriteHead(this);
args[0] = rewriteChunk(args[0]);
return (origWrite as any).apply(this, args);

Check warning on line 240 in packages/atxp-express/src/atxpExpress.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
} as any;

Check warning on line 241 in packages/atxp-express/src/atxpExpress.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type

// Hook res.end for non-SSE (enableJsonResponse) responses.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -265,12 +266,12 @@
}
}
}
(origWriteHead as any).apply(this, deferredWriteHead);

Check warning on line 269 in packages/atxp-express/src/atxpExpress.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
deferredWriteHead = null;
}

return (origEnd as any).apply(this, args);

Check warning on line 273 in packages/atxp-express/src/atxpExpress.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
} as any;

Check warning on line 274 in packages/atxp-express/src/atxpExpress.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/atxp-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export {
detectProtocol,
parseCredentialBase64,
ProtocolSettlement,
type ProtocolSettlementOptions,
} from './protocol.js';

// Omni-challenge builders
Expand Down
125 changes: 124 additions & 1 deletion packages/atxp-server/src/protocol.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { detectProtocol, ProtocolSettlement } from './protocol.js';

describe('detectProtocol', () => {
Expand Down Expand Up @@ -415,4 +415,127 @@ describe('ProtocolSettlement', () => {
});
});
});

describe('X-ATXP-APP-NAME header', () => {
// Auth reads this header and attaches it to settle observability events
// so dashboards can slice by calling service. See auth#254.
const savedAppName = process.env.APP_NAME;
afterEach(() => {
if (savedAppName === undefined) delete process.env.APP_NAME;
else process.env.APP_NAME = savedAppName;
});

const okResponse = () => ({ ok: true, json: async () => ({ txHash: '0xabc', settledAmount: '1' }) });
const credential = Buffer.from(JSON.stringify({ signature: '0xabc' })).toString('base64');

const headersFromFetch = (fetch: ReturnType<typeof vi.fn>) =>
fetch.mock.calls[0][1].headers as Record<string, string>;

it('sends X-ATXP-APP-NAME when the explicit appName option is set', async () => {
mockFetch.mockResolvedValue(okResponse());
const s = new ProtocolSettlement(
'https://auth.atxp.ai' as any,
mockLogger,
mockFetch,
undefined,
{ appName: 'llm' },
);

await s.settle('x402', credential, { paymentRequirements: { network: 'base' } });

expect(headersFromFetch(mockFetch)['X-ATXP-APP-NAME']).toBe('llm');
});

it('falls back to process.env.APP_NAME when appName option is omitted', async () => {
process.env.APP_NAME = 'music-mcp';
mockFetch.mockResolvedValue(okResponse());
const s = new ProtocolSettlement(
'https://auth.atxp.ai' as any,
mockLogger,
mockFetch,
);

await s.settle('x402', credential, { paymentRequirements: { network: 'base' } });

expect(headersFromFetch(mockFetch)['X-ATXP-APP-NAME']).toBe('music-mcp');
});

it('explicit appName option overrides process.env.APP_NAME', async () => {
process.env.APP_NAME = 'from-env';
mockFetch.mockResolvedValue(okResponse());
const s = new ProtocolSettlement(
'https://auth.atxp.ai' as any,
mockLogger,
mockFetch,
undefined,
{ appName: 'from-option' },
);

await s.settle('x402', credential, { paymentRequirements: { network: 'base' } });

expect(headersFromFetch(mockFetch)['X-ATXP-APP-NAME']).toBe('from-option');
});

it('explicit empty string disables env fallback (header omitted)', async () => {
// Empty-string override lets tests and oddball configs opt out of the
// env fallback without mutating process.env.
process.env.APP_NAME = 'would-have-used-this';
mockFetch.mockResolvedValue(okResponse());
const s = new ProtocolSettlement(
'https://auth.atxp.ai' as any,
mockLogger,
mockFetch,
undefined,
{ appName: '' },
);

await s.settle('x402', credential, { paymentRequirements: { network: 'base' } });

expect(headersFromFetch(mockFetch)).not.toHaveProperty('X-ATXP-APP-NAME');
});

it('omits the header when neither option nor env is set', async () => {
delete process.env.APP_NAME;
mockFetch.mockResolvedValue(okResponse());
const s = new ProtocolSettlement(
'https://auth.atxp.ai' as any,
mockLogger,
mockFetch,
);

await s.settle('x402', credential, { paymentRequirements: { network: 'base' } });

expect(headersFromFetch(mockFetch)).not.toHaveProperty('X-ATXP-APP-NAME');
});

it('trims whitespace-only values to undefined (header omitted)', async () => {
mockFetch.mockResolvedValue(okResponse());
const s = new ProtocolSettlement(
'https://auth.atxp.ai' as any,
mockLogger,
mockFetch,
undefined,
{ appName: ' ' },
);

await s.settle('x402', credential, { paymentRequirements: { network: 'base' } });

expect(headersFromFetch(mockFetch)).not.toHaveProperty('X-ATXP-APP-NAME');
});

it('sets the header on verify() as well as settle()', async () => {
mockFetch.mockResolvedValue({ ok: true, json: async () => ({ valid: true }) });
const s = new ProtocolSettlement(
'https://auth.atxp.ai' as any,
mockLogger,
mockFetch,
undefined,
{ appName: 'llm' },
);

await s.verify('x402', credential, { paymentRequirements: { network: 'base' } });

expect(headersFromFetch(mockFetch)['X-ATXP-APP-NAME']).toBe('llm');
});
});
});
66 changes: 63 additions & 3 deletions packages/atxp-server/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,18 +171,78 @@ export function parseCredentialBase64(credential: string): Record<string, unknow
}
}

/**
* Constructor options for `ProtocolSettlement`. Kept as a trailing options
* bag so new knobs can be added without shifting positional arguments.
*/
export interface ProtocolSettlementOptions {
/**
* Identifier for the calling service (e.g. `"llm"`, `"music-mcp"`). Sent
* as the `X-ATXP-APP-NAME` header on every /settle/* and /verify/* request
* so auth can attribute observability events to the originating app.
*
* Resolution order:
* 1. this option, if set to a non-empty string
* 2. `process.env.APP_NAME`, if set to a non-empty string
* 3. header omitted
*
* An explicit empty string disables the env fallback for this instance.
*
* Expected format (enforced on the auth side): 1–64 characters,
* `[a-zA-Z0-9._-]+`. Values outside this range are accepted by the SDK
* but silently dropped by auth's `readAppNameHeader` validator — the
* span attribute will be missing rather than the settle failing.
*/
appName?: string;
}

const APP_NAME_HEADER = 'X-ATXP-APP-NAME';

/**
* Client for calling auth service verify/settle endpoints.
* Routes to the appropriate protocol-specific endpoint.
*/
export class ProtocolSettlement {
private readonly appName: string | undefined;

constructor(
private readonly authServer: AuthorizationServerUrl,
private readonly logger: Logger,
private readonly fetchFn: FetchLike = fetch.bind(globalThis),
/** Destination account ID for ATXP settle (the server/LLM's own account) */
private readonly destinationAccountId?: string,
) {}
options?: ProtocolSettlementOptions,
) {
// Resolve appName once per instance. Long-lived callers (LLM constructs
// once at startup) get a stable value; short-lived callers that rebuild
// the instance — notably @atxp/express, which instantiates per-request
// at atxpExpress.ts:~124 — re-read process.env.APP_NAME each time, which
// is fine because APP_NAME is a deployment-time constant, not runtime
// config.
//
// An explicit empty string (options.appName === '') opts out of the env
// fallback — useful when a single process hosts two services and wants
// to suppress cross-attribution, or in tests asserting header-omitted
// behavior.
const explicit = options?.appName;
if (explicit !== undefined) {
const trimmed = explicit.trim();
this.appName = trimmed || undefined;
} else {
const envValue = (typeof process !== 'undefined' ? process.env.APP_NAME : undefined)?.trim();
this.appName = envValue || undefined;
}
}

/**
* Build the headers sent to auth on every /settle/* and /verify/* request.
* Always JSON; adds X-ATXP-APP-NAME when a non-empty app name is configured.
*/
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.appName) headers[APP_NAME_HEADER] = this.appName;
return headers;
}

/**
* Verify a payment credential at request start.
Expand All @@ -199,7 +259,7 @@ export class ProtocolSettlement {

const response = await this.fetchFn(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: this.buildHeaders(),
body: JSON.stringify(body),
});

Expand All @@ -225,7 +285,7 @@ export class ProtocolSettlement {

const response = await this.fetchFn(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: this.buildHeaders(),
body: JSON.stringify(body),
});

Expand Down
2 changes: 1 addition & 1 deletion packages/atxp-server/src/serverConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type RequiredATXPConfigFields = 'destination';
type RequiredATXPConfig = Pick<ATXPConfig, RequiredATXPConfigFields>;
type OptionalATXPConfig = Omit<ATXPConfig, RequiredATXPConfigFields>;
export type ATXPArgs = RequiredATXPConfig & Partial<OptionalATXPConfig>;
type BuildableATXPConfigFields = 'oAuthDb' | 'oAuthClient' | 'paymentServer' | 'logger' | 'minimumPayment';
type BuildableATXPConfigFields = 'oAuthDb' | 'oAuthClient' | 'paymentServer' | 'logger' | 'minimumPayment' | 'appName';

export const DEFAULT_CONFIG: Required<Omit<OptionalATXPConfig, BuildableATXPConfigFields>> = {
mountPath: '/',
Expand Down
17 changes: 17 additions & 0 deletions packages/atxp-server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ export type ATXPConfig = {
oAuthClient: OAuthResourceClient;
paymentServer: PaymentServer;
minimumPayment?: BigNumber;
/**
* Identifier for the calling service (e.g. `"llm"`, `"music-mcp"`). Sent to
* auth as the `X-ATXP-APP-NAME` request header on /settle/* and /verify/*
* calls so auth can attribute observability events to the originating app.
*
* When omitted, `ProtocolSettlement` falls back to `process.env.APP_NAME`.
* Explicit value takes precedence; set to empty string to disable the env
* fallback for this instance.
*
* Expected format (enforced on the auth side — values outside this format
* are silently dropped by the receiver, producing a missing span attribute
* rather than a failed settle): 1–64 characters, `[a-zA-Z0-9._-]+`.
*
* Observability metadata only — auth treats this as untrusted. Do not use
* for authorization or billing attribution.
*/
appName?: string;
}


Expand Down
Loading