diff --git a/packages/atxp-express/src/atxpExpress.test.ts b/packages/atxp-express/src/atxpExpress.test.ts index 002d2cf..227c77c 100644 --- a/packages/atxp-express/src/atxpExpress.test.ts +++ b/packages/atxp-express/src/atxpExpress.test.ts @@ -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'; @@ -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; + 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; + expect(headers).not.toHaveProperty('X-ATXP-APP-NAME'); + } finally { + if (savedAppName === undefined) delete process.env.APP_NAME; + else process.env.APP_NAME = savedAppName; + } + }); + }); }); \ No newline at end of file diff --git a/packages/atxp-express/src/atxpExpress.ts b/packages/atxp-express/src/atxpExpress.ts index 06091ab..7259b91 100644 --- a/packages/atxp-express/src/atxpExpress.ts +++ b/packages/atxp-express/src/atxpExpress.ts @@ -126,6 +126,7 @@ export function atxpExpress(args: ATXPArgs): Router { logger, fetch.bind(globalThis), destinationAccountId, + { appName: config.appName }, ); // For X402: the credential's parsed payload contains `accepted` — the diff --git a/packages/atxp-server/src/index.ts b/packages/atxp-server/src/index.ts index 91b65d8..0a7cdd2 100644 --- a/packages/atxp-server/src/index.ts +++ b/packages/atxp-server/src/index.ts @@ -96,6 +96,7 @@ export { detectProtocol, parseCredentialBase64, ProtocolSettlement, + type ProtocolSettlementOptions, } from './protocol.js'; // Omni-challenge builders diff --git a/packages/atxp-server/src/protocol.test.ts b/packages/atxp-server/src/protocol.test.ts index 10c62cb..37ef919 100644 --- a/packages/atxp-server/src/protocol.test.ts +++ b/packages/atxp-server/src/protocol.test.ts @@ -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', () => { @@ -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) => + fetch.mock.calls[0][1].headers as Record; + + 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'); + }); + }); }); diff --git a/packages/atxp-server/src/protocol.ts b/packages/atxp-server/src/protocol.ts index ae6a469..c7928ed 100644 --- a/packages/atxp-server/src/protocol.ts +++ b/packages/atxp-server/src/protocol.ts @@ -171,18 +171,78 @@ export function parseCredentialBase64(credential: string): Record { + const headers: Record = { 'Content-Type': 'application/json' }; + if (this.appName) headers[APP_NAME_HEADER] = this.appName; + return headers; + } /** * Verify a payment credential at request start. @@ -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), }); @@ -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), }); diff --git a/packages/atxp-server/src/serverConfig.ts b/packages/atxp-server/src/serverConfig.ts index 79e0397..041a90b 100644 --- a/packages/atxp-server/src/serverConfig.ts +++ b/packages/atxp-server/src/serverConfig.ts @@ -6,7 +6,7 @@ type RequiredATXPConfigFields = 'destination'; type RequiredATXPConfig = Pick; type OptionalATXPConfig = Omit; export type ATXPArgs = RequiredATXPConfig & Partial; -type BuildableATXPConfigFields = 'oAuthDb' | 'oAuthClient' | 'paymentServer' | 'logger' | 'minimumPayment'; +type BuildableATXPConfigFields = 'oAuthDb' | 'oAuthClient' | 'paymentServer' | 'logger' | 'minimumPayment' | 'appName'; export const DEFAULT_CONFIG: Required> = { mountPath: '/', diff --git a/packages/atxp-server/src/types.ts b/packages/atxp-server/src/types.ts index 11d2135..6369641 100644 --- a/packages/atxp-server/src/types.ts +++ b/packages/atxp-server/src/types.ts @@ -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; }