From 81fa7844aacdcec8966245ae9001fef58a5db994 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Tue, 21 Jul 2026 10:51:04 -0400 Subject: [PATCH] Add link-cli inspect --- CLAUDE.md | 15 +- README.md | 14 + packages/cli/src/cli.tsx | 2 + .../inspect/__tests__/inspect.test.ts | 487 ++++++++++ packages/cli/src/commands/inspect/index.tsx | 45 + .../cli/src/commands/inspect/inspect-view.tsx | 84 ++ packages/cli/src/commands/inspect/inspect.ts | 899 ++++++++++++++++++ packages/cli/src/commands/inspect/schema.ts | 10 + 8 files changed, 1554 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/inspect/__tests__/inspect.test.ts create mode 100644 packages/cli/src/commands/inspect/index.tsx create mode 100644 packages/cli/src/commands/inspect/inspect-view.tsx create mode 100644 packages/cli/src/commands/inspect/inspect.ts create mode 100644 packages/cli/src/commands/inspect/schema.ts diff --git a/CLAUDE.md b/CLAUDE.md index 46d0bc6..f698309 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ Commands in `packages/cli/src/cli.tsx` (incur framework). Each has two output mo - **Interactive** (default): Ink/React components from `packages/cli/src/commands/` - **JSON** (`--format json`): JSON to stdout, errors as JSON with `code` and `message` fields with exit code 1 -Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `serve`. +Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `inspect `, `serve`. The CLI also runs as an MCP server (`--mcp`) and serves skill files via `skills` subcommand, both provided by incur. @@ -83,6 +83,16 @@ Key input field notes: - The SPT is one-time-use — a failed payment requires running `mpp pay` again (creates a new spend request). - Implemented in `packages/cli/src/commands/mpp/` — pay.tsx (logic), schema.ts (input/output schema), index.tsx (incur registration). +### inspect command + +- `inspect [--timeout ]` — no auth required. Probes a merchant site for supported agent payment strategies and recommends one. Implemented in `packages/cli/src/commands/inspect/` — inspect.ts (probing logic, pure/testable via injectable `fetchImpl`), inspect-view.tsx (interactive Ink view), index.tsx (incur registration), schema.ts. +- Checks, concurrently: `/.well-known/ucp` (UCP merchant profile), `/api/openapi.json` then `/openapi.json` (MPP OpenAPI spec), `/.well-known/x402.json` (x402 manifest, informational only — doesn't imply Stripe support), and the given page's HTML for a Link Pay Token AI-agent steering block (`AiAgentPaymentSteering`, "I am an AI agent" text, or a `link_pay_token` reference). +- **MPP detection is offer-aware, not just presence-based.** Per https://mpp.dev/advanced/discovery, each OpenAPI operation with `x-payment-info.offers[]` names payment rails via `method` (e.g. `tempo`, `stripe`, `evm`); `shared_payment_token` is only detected when an operation's offers include `"stripe"` — most MPP integrations only offer crypto rails. `probes.mpp_openapi[].operations` surfaces every payment-required operation (`path`, `method`, `operation_id`, `summary`/`description`, `request_body_schema`, `offers`), and `api_description`/`api_guidance` surface the spec's `info.description`/`info.guidance` — this is the context an agent needs to actually call the endpoint, not just know it exists. +- **UCP detection surfaces the full profile, not just presence.** `probes.ucp` parses the `.well-known/ucp` profile's top-level `ucp` node into `merchant`, `description`, `version`, `services` (transport/endpoint per named service, e.g. `dev.ucp.shopping` mcp/rest entries), `capabilities` (per named capability, e.g. `dev.ucp.shopping.checkout`), and `payment_handlers` (e.g. `com.stripe.payments`). +- **Live 402 fallback.** Some specs (e.g. climate.stripe.dev) declare only a coarse `x-payment-info.protocols` list with no per-method breakdown. When the static check can't confirm `"stripe"`, `inspect` sends a real request to the chosen payment operation (synthesizing a minimal JSON body from `request_body_schema` when required, since some endpoints validate the body before returning 402) and reads the `WWW-Authenticate` header via `decodeStripeChallenge()` (reused from `mpp/decode.ts`) — the same ground truth `mpp pay`/`mpp decode` use. Result is in `probes.live_challenge`. +- Returns `strategies` (sorted: detected first, then by priority `ucp` > `shared_payment_token` > `link_pay_token` > `card`) each with `detected` and `evidence`, plus a top-level `recommendation` with `strategy`, `credential_type` (the value for `spend-request create --credential-type`; `null` for `ucp`, which is a separate CLI/protocol), `reason`, `instruction`, and — for `shared_payment_token` — `operation` (`path`, `method`, `description`, `request_body_schema`), or — for `ucp` — `profile` (`profile_url`, `merchant`, `description`, `services`, `capabilities`, `payment_handlers`) telling the agent exactly what to call. Includes a literal `_next.command` only when one is unambiguously constructable (currently only for `ucp`, e.g. `ucp discover --business `). +- `card` is always included as the fallback recommendation when nothing else is detected. + ### demo command - `demo [--only-card] [--only-spt]` — Interactive demo of both payment flows. Always uses `--test` mode (no real charges). Shows a menu to choose: virtual card flow, SPT/machine payment flow, or both. `--only-card` and `--only-spt` skip the menu. Requires a TTY (no JSON output mode). @@ -115,8 +125,9 @@ Key input field notes: Server-returned strings can contain ANSI escape sequences or control characters that spoof the terminal approval UI. Sanitization is handled automatically via `sanitizeDeep()` from `packages/cli/src/utils/sanitize-text.ts`: -- **Commands using `useAsyncAction` hook** — sanitized automatically. The hook calls `sanitizeDeep()` on all returned data before it reaches components. +- **Commands using `useAsyncAction` hook** — sanitized automatically when the action calls an SDK resource, since `resource-factory.ts` wraps resource methods with `sanitizeDeep()`. Commands that call something other than an SDK resource (e.g. raw `fetch`) must sanitize their own return value before it reaches `useAsyncAction`. - **Commands with manual state management** (e.g. `create.tsx`, `retrieve.tsx`, `request-approval.tsx`, `mpp/pay.tsx`) — must call `sanitizeDeep()` on API responses before calling `setRequest()`/`setState()`. +- `inspect` fetches arbitrary third-party HTML/JSON directly (no SDK resource in the loop) — `runInspect()` in `inspect.ts` calls `sanitizeDeep()` on its own return value before either JSON or interactive output sees it. JSON output mode (`--format json`) is **not** affected — `JSON.stringify` encodes escape sequences as Unicode literals. ## Environment Variables diff --git a/README.md b/README.md index b9db7ae..dd24abe 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,20 @@ link-cli spend-request cancel lsrq_001 | Hourly creation rate | 50 per hour | | Rolling creation rate | 200 per 60 days | +### Inspect a merchant site + +Before creating a spend request, use `inspect` to check which agent payment strategies a merchant supports: + +```bash +link-cli inspect https://shop.example.com/checkout +``` + +It probes, in order of recommendation: a [UCP](https://ucp.dev) merchant profile at `/.well-known/ucp`, an [MPP](https://mpp.dev) OpenAPI spec at `/api/openapi.json` or `/openapi.json`, an x402 manifest at `/.well-known/x402.json` (informational only), and the given page's HTML for a Link Pay Token AI-agent steering block (`.AiAgentPaymentSteering` / `input[name="link_pay_token"]`). It returns a sorted list of detected strategies and a top recommendation — falling back to `card` when nothing else is detected. + +For MPP, it doesn't just check that an OpenAPI spec exists — per the [MPP discovery spec](https://mpp.dev/advanced/discovery), it inspects each operation's `x-payment-info.offers[]` and only recommends `shared_payment_token` when an operation actually offers the `"stripe"` method (most MPP integrations only offer crypto rails like `tempo`). When a spec doesn't break offers out by method, it falls back to a live probe of the endpoint and reads the real `WWW-Authenticate` challenge. The recommendation includes the exact `operation` (path, method, description, request body schema) an agent needs to call it. + +For UCP, it parses the full `.well-known/ucp` profile rather than just checking it responds — `merchant`, `description`, `services` (transport/endpoint per service), `capabilities`, and `payment_handlers` all come back in `probes.ucp` and, when recommended, in `recommendation.profile`. + ### MPP Use `mpp pay` to complete purchases on merchants that use the [Machine Payments Protocol](https://mpp.dev). The spend request must use `credential_type: "shared_payment_token"` and you must approve it before paying. The SPT is one-time-use — if payment fails, create a new spend request. diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 874b8a8..2b3100d 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -3,6 +3,7 @@ import { Cli } from 'incur'; import { createAuthCli } from './commands/auth'; import { createBalancesCli } from './commands/balances'; import { createDemoCli } from './commands/demo'; +import { createInspectCli } from './commands/inspect'; import { createMppCli } from './commands/mpp'; import { createOnboardCli } from './commands/onboard'; import { createPaymentMethodsCli } from './commands/payment-methods'; @@ -148,6 +149,7 @@ if (!hiddenCli) { envAccessToken, ), ); + cli.command(createInspectCli()); // cli.command( // createWebBotAuthCli(() => factory.createWebBotAuthResource(), authStorage), // ); diff --git a/packages/cli/src/commands/inspect/__tests__/inspect.test.ts b/packages/cli/src/commands/inspect/__tests__/inspect.test.ts new file mode 100644 index 0000000..58ef9e1 --- /dev/null +++ b/packages/cli/src/commands/inspect/__tests__/inspect.test.ts @@ -0,0 +1,487 @@ +import { describe, expect, it, vi } from 'vitest'; +import { runInspect } from '../inspect'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function htmlResponse(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { 'Content-Type': 'text/html' }, + }); +} + +function notFound(): Response { + return new Response('not found', { status: 404 }); +} + +function ucpProfile(): Record { + return { + signing_keys: [{ kty: 'EC', kid: 'key1' }], + ucp: { + version: '2026-04-08', + merchant: 'Prompt Shop', + description: 'Drop-in system prompts for AI agents.', + services: { + 'dev.ucp.shopping': [ + { + version: '2026-04-08', + transport: 'mcp', + endpoint: 'https://shop.example.com/api/ucp/mcp', + }, + { + version: '2026-04-08', + transport: 'rest', + endpoint: 'https://shop.example.com/api/ucp', + }, + ], + }, + capabilities: { + 'dev.ucp.shopping.catalog.search': [ + { + version: '2026-04-08', + endpoint: 'https://shop.example.com/api/ucp/catalog', + }, + ], + 'dev.ucp.shopping.checkout': [{ version: '2026-04-08' }], + }, + payment_handlers: { + 'com.stripe.payments': [ + { id: 'stripe_payments', version: '2026-06-25' }, + ], + }, + }, + }; +} + +function mppSpec(methods: string[]): Record { + return { + openapi: '3.1.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/api/thing': { + get: { + operationId: 'getThing', + 'x-payment-info': { + offers: methods.map((method) => ({ + method, + intent: 'charge', + amount: '100', + currency: 'usd', + })), + }, + responses: { 200: { description: 'ok' } }, + }, + }, + }, + }; +} + +// Mirrors climate.stripe.dev's shape: a `protocols`-only x-payment-info block +// with no per-method `offers` breakdown, so stripe support can only be +// confirmed by a live 402 probe of the operation. +function protocolsOnlySpec(): Record { + return { + openapi: '3.1.0', + info: { + title: 'Contribution API', + version: '1.0.0', + description: 'Contribute to fund carbon removal.', + guidance: "POST an 'amount' field (cents) to /api/contribute.", + }, + paths: { + '/api/contribute': { + post: { + operationId: 'contribute', + 'x-payment-info': { protocols: ['mpp', 'x402'] }, + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['amount'], + properties: { amount: { type: 'integer' } }, + }, + }, + }, + }, + responses: { 200: { description: 'ok' } }, + }, + }, + }, + }; +} + +function encodeStripeRequest(request: Record): string { + return Buffer.from(JSON.stringify(request)).toString('base64'); +} + +function stripeChallengeHeader(networkId: string): string { + return [ + 'Payment id="ch_001",', + 'realm="shop.example.com",', + 'method="stripe",', + 'intent="charge",', + `request="${encodeStripeRequest({ + amount: '100', + currency: 'usd', + methodDetails: { networkId, paymentMethodTypes: ['card'] }, + })}"`, + ].join(' '); +} + +describe('runInspect', () => { + it('recommends ucp when a UCP profile is present', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = input.toString(); + if (url.endsWith('/.well-known/ucp')) { + return jsonResponse(ucpProfile()); + } + if (url === 'https://shop.example.com/checkout') { + return htmlResponse('Pay here'); + } + return notFound(); + }); + + const result = await runInspect('https://shop.example.com/checkout', { + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result.hostname).toBe('shop.example.com'); + expect(result.recommendation.strategy).toBe('ucp'); + expect(result.recommendation.credential_type).toBeNull(); + expect(result.strategies.find((s) => s.name === 'ucp')?.detected).toBe( + true, + ); + expect(result.strategies[0].name).toBe('ucp'); + expect(result._next).toEqual({ + command: 'ucp discover --business https://shop.example.com', + description: 'Confirm UCP capabilities for this merchant', + }); + }); + + it('surfaces the UCP profile (merchant, services, capabilities, payment handlers) in the recommendation', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = input.toString(); + if (url.endsWith('/.well-known/ucp')) { + return jsonResponse(ucpProfile()); + } + if (url === 'https://shop.example.com/checkout') { + return htmlResponse('Pay here'); + } + return notFound(); + }); + + const result = await runInspect('https://shop.example.com/checkout', { + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result.probes.ucp.merchant).toBe('Prompt Shop'); + expect(result.probes.ucp.services).toEqual([ + { + service: 'dev.ucp.shopping', + version: '2026-04-08', + transport: 'mcp', + endpoint: 'https://shop.example.com/api/ucp/mcp', + }, + { + service: 'dev.ucp.shopping', + version: '2026-04-08', + transport: 'rest', + endpoint: 'https://shop.example.com/api/ucp', + }, + ]); + expect(result.probes.ucp.capabilities).toEqual([ + { + capability: 'dev.ucp.shopping.catalog.search', + version: '2026-04-08', + endpoint: 'https://shop.example.com/api/ucp/catalog', + }, + { + capability: 'dev.ucp.shopping.checkout', + version: '2026-04-08', + endpoint: undefined, + }, + ]); + expect(result.probes.ucp.payment_handlers).toEqual([ + { + handler: 'com.stripe.payments', + id: 'stripe_payments', + version: '2026-06-25', + }, + ]); + + const ucpStrategy = result.strategies.find((s) => s.name === 'ucp'); + expect(ucpStrategy?.evidence[0]).toMatch(/"Prompt Shop"/); + expect(ucpStrategy?.evidence[0]).toMatch(/2 services, 2 capabilities/); + + expect(result.recommendation.profile).toEqual({ + profile_url: 'https://shop.example.com/.well-known/ucp', + merchant: 'Prompt Shop', + description: 'Drop-in system prompts for AI agents.', + services: result.probes.ucp.services, + capabilities: result.probes.ucp.capabilities, + payment_handlers: result.probes.ucp.payment_handlers, + }); + }); + + it('recommends shared_payment_token when the MPP spec offers the "stripe" method', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = input.toString(); + if (url.endsWith('/api/openapi.json')) { + return jsonResponse(mppSpec(['tempo', 'stripe'])); + } + if (url === 'https://shop.example.com/checkout') { + return htmlResponse('Pay here'); + } + return notFound(); + }); + + const result = await runInspect('https://shop.example.com/checkout', { + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result.recommendation.strategy).toBe('shared_payment_token'); + expect(result.recommendation.credential_type).toBe('shared_payment_token'); + expect(result._next).toBeUndefined(); + const mppOpenapi = result.probes.mpp_openapi; + expect(mppOpenapi[0].url).toBe('https://shop.example.com/api/openapi.json'); + expect(mppOpenapi[0].found).toBe(true); + expect(mppOpenapi[0].offers_stripe).toBe(true); + expect(mppOpenapi[0].offered_methods).toEqual(['tempo', 'stripe']); + expect(mppOpenapi).toHaveLength(1); + }); + + it('falls back to /openapi.json when /api/openapi.json is missing', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = input.toString(); + if (url.endsWith('/api/openapi.json')) { + return notFound(); + } + if (url.endsWith('/openapi.json')) { + return jsonResponse(mppSpec(['stripe'])); + } + if (url === 'https://shop.example.com/checkout') { + return htmlResponse('Pay here'); + } + return notFound(); + }); + + const result = await runInspect('https://shop.example.com/checkout', { + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + const mppOpenapi = result.probes.mpp_openapi; + expect(mppOpenapi).toHaveLength(2); + expect(mppOpenapi[1].url).toBe('https://shop.example.com/openapi.json'); + expect(mppOpenapi[1].found).toBe(true); + expect(result.recommendation.strategy).toBe('shared_payment_token'); + }); + + it('does not recommend shared_payment_token when the MPP spec only offers crypto rails', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = input.toString(); + if (url.endsWith('/api/openapi.json')) { + return jsonResponse(mppSpec(['tempo', 'evm', 'solana'])); + } + if (url === 'https://shop.example.com/checkout') { + return htmlResponse('Pay here'); + } + return notFound(); + }); + + const result = await runInspect('https://shop.example.com/checkout', { + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + const mppOpenapi = result.probes.mpp_openapi; + expect(mppOpenapi[0].found).toBe(true); + expect(mppOpenapi[0].offers_stripe).toBe(false); + expect(mppOpenapi[0].offered_methods).toEqual(['tempo', 'evm', 'solana']); + + const sptStrategy = result.strategies.find( + (s) => s.name === 'shared_payment_token', + ); + expect(sptStrategy?.detected).toBe(false); + expect(sptStrategy?.evidence[0]).toMatch( + /does not explicitly declare the "stripe" payment method/, + ); + expect(result.recommendation.strategy).toBe('card'); + }); + + it('includes the operation to call when shared_payment_token is recommended', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = input.toString(); + if (url.endsWith('/api/openapi.json')) { + return jsonResponse(mppSpec(['tempo', 'stripe'])); + } + if (url === 'https://shop.example.com/checkout') { + return htmlResponse('Pay here'); + } + return notFound(); + }); + + const result = await runInspect('https://shop.example.com/checkout', { + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result.recommendation.operation).toEqual({ + path: '/api/thing', + method: 'GET', + description: undefined, + request_body_schema: undefined, + }); + }); + + it('falls back to a live 402 probe when the spec only declares coarse protocols (no per-method offers)', async () => { + const fetchImpl = vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.endsWith('/api/openapi.json')) { + return jsonResponse(protocolsOnlySpec()); + } + if (url === 'https://climate.stripe.dev/api/contribute') { + expect(init?.method).toBe('POST'); + expect(init?.body).toBe(JSON.stringify({ amount: 1 })); + return new Response('Payment required', { + status: 402, + headers: { + 'WWW-Authenticate': stripeChallengeHeader('network_abc'), + }, + }); + } + return notFound(); + }); + + const result = await runInspect( + 'https://climate.stripe.dev/api/contribute', + { + fetchImpl: fetchImpl as unknown as typeof fetch, + }, + ); + + expect(result.probes.mpp_openapi[0].offers_stripe).toBe(false); + expect(result.probes.live_challenge).toMatchObject({ + attempted: true, + method: 'POST', + status: 402, + found: true, + network_id: 'network_abc', + }); + + const sptStrategy = result.strategies.find( + (s) => s.name === 'shared_payment_token', + ); + expect(sptStrategy?.detected).toBe(true); + expect(sptStrategy?.evidence.some((e) => e.includes('network_abc'))).toBe( + true, + ); + expect(result.recommendation.strategy).toBe('shared_payment_token'); + expect(result.recommendation.operation).toMatchObject({ + path: '/api/contribute', + method: 'POST', + }); + }); + + it('does not attempt a live probe when the spec already declares a stripe offer', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = input.toString(); + if (url.endsWith('/api/openapi.json')) { + return jsonResponse(mppSpec(['stripe'])); + } + if (url === 'https://shop.example.com/checkout') { + return htmlResponse('Pay here'); + } + return notFound(); + }); + + const result = await runInspect('https://shop.example.com/checkout', { + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result.probes.live_challenge.attempted).toBe(false); + }); + + it('detects the Link Pay Token steering block in page HTML', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = input.toString(); + if (url === 'https://shop.example.com/checkout') { + return htmlResponse( + '', + ); + } + return notFound(); + }); + + const result = await runInspect('https://shop.example.com/checkout', { + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result.recommendation.strategy).toBe('link_pay_token'); + expect(result.recommendation.credential_type).toBe('card'); + expect(result.probes.link_pay_token.found).toBe(true); + expect(result.probes.link_pay_token.indicators).toContain( + 'Page HTML includes the "AiAgentPaymentSteering" component', + ); + }); + + it('falls back to card when nothing is detected', async () => { + const fetchImpl = vi.fn(async () => notFound()); + + const result = await runInspect('https://shop.example.com/checkout', { + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result.recommendation.strategy).toBe('card'); + expect(result.recommendation.credential_type).toBe('card'); + expect(result.strategies.find((s) => s.name === 'card')?.detected).toBe( + true, + ); + }); + + it('treats non-JSON responses at well-known endpoints as not found', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = input.toString(); + if (url.endsWith('/.well-known/x402.json')) { + return htmlResponse('not json'); + } + return notFound(); + }); + + const result = await runInspect('https://shop.example.com/checkout', { + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result.probes.x402.found).toBe(false); + expect(result.probes.x402.error).toMatch(/not valid JSON/); + }); + + it('sanitizes ANSI escape sequences found in remote content', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = input.toString(); + if (url === 'https://shop.example.com/checkout') { + return htmlResponse( + '
' + + '\x1b[31mmalicious\x1b[0m' + + '
', + ); + } + return notFound(); + }); + + const result = await runInspect('https://shop.example.com/checkout', { + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + const serialized = JSON.stringify(result); + expect(serialized).not.toContain('\x1b'); + }); + + it('rejects an invalid URL', async () => { + await expect(runInspect('not-a-url')).rejects.toThrow(/Invalid URL/); + }); +}); diff --git a/packages/cli/src/commands/inspect/index.tsx b/packages/cli/src/commands/inspect/index.tsx new file mode 100644 index 0000000..a5f2dcc --- /dev/null +++ b/packages/cli/src/commands/inspect/index.tsx @@ -0,0 +1,45 @@ +import { Cli, z } from 'incur'; +import React from 'react'; +import { renderInteractive } from '../../utils/render-interactive'; +import type { InspectResult } from './inspect'; +import { runInspect } from './inspect'; +import { InspectView } from './inspect-view'; +import { inspectOptions } from './schema'; + +export function createInspectCli() { + const cli = Cli.create('inspect', { + description: + 'Inspect a URL for supported agent payment strategies (UCP, MPP/x402, Link Pay Token) and recommend one', + args: z.object({ + url: z.string().describe('URL to inspect'), + }), + options: inspectOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + const { url } = c.args; + const timeoutMs = c.options.timeout; + + if (!c.agent && !c.formatExplicit) { + let capturedResult: InspectResult | null = null; + return renderInteractive( + { + capturedResult = result; + }} + />, + () => { + if (!capturedResult) + throw new Error('Component exited without producing a result'); + return capturedResult; + }, + ); + } + + return runInspect(url, { timeoutMs }); + }, + }); + + return cli; +} diff --git a/packages/cli/src/commands/inspect/inspect-view.tsx b/packages/cli/src/commands/inspect/inspect-view.tsx new file mode 100644 index 0000000..971c551 --- /dev/null +++ b/packages/cli/src/commands/inspect/inspect-view.tsx @@ -0,0 +1,84 @@ +import { Box, Text, useApp } from 'ink'; +import Spinner from 'ink-spinner'; +import type React from 'react'; +import { useCallback } from 'react'; +import { useAsyncAction } from '../../hooks/use-async-action'; +import type { InspectResult } from './inspect'; +import { runInspect } from './inspect'; + +interface InspectViewProps { + url: string; + timeoutMs?: number; + onComplete: (result: InspectResult | null) => void; +} + +export const InspectView: React.FC = ({ + url, + timeoutMs, + onComplete, +}) => { + const { exit } = useApp(); + const action = useCallback( + () => runInspect(url, { timeoutMs }), + [url, timeoutMs], + ); + const handleComplete = useCallback( + (result: InspectResult | null) => { + onComplete(result); + exit(); + }, + [onComplete, exit], + ); + const { status, data, error } = useAsyncAction(action, handleComplete); + + if (status === 'loading') { + return ( + + + Inspecting {url}... + + + ); + } + + if (status === 'error') { + return ( + + ✗ Inspection failed + {error} + + ); + } + + if (!data) return null; + + return ( + + + Payment strategies for {data.hostname}: + + + {data.strategies.map((strategy) => ( + + + {strategy.detected ? '✓' : '✗'} {strategy.label} + + {strategy.evidence.map((line, i) => ( + + {' '} + {line} + + ))} + + ))} + + + Recommendation: {data.recommendation.strategy} + + {data.recommendation.reason} + + {data.recommendation.instruction} + + + ); +}; diff --git a/packages/cli/src/commands/inspect/inspect.ts b/packages/cli/src/commands/inspect/inspect.ts new file mode 100644 index 0000000..3c81372 --- /dev/null +++ b/packages/cli/src/commands/inspect/inspect.ts @@ -0,0 +1,899 @@ +import { sanitizeDeep } from '../../utils/sanitize-text'; +import { decodeStripeChallenge } from '../mpp/decode'; + +const DEFAULT_TIMEOUT_MS = 5000; + +export type StrategyName = + | 'ucp' + | 'shared_payment_token' + | 'link_pay_token' + | 'card'; + +export type CredentialType = 'shared_payment_token' | 'card'; + +export interface EndpointProbe { + url: string; + found: boolean; + status?: number; + error?: string; +} + +export interface MppOffer { + method: string; + intent?: string; + amount?: string; + currency?: string; + description?: string; +} + +export interface MppOperation { + path: string; + method: string; + operation_id?: string; + summary?: string; + description?: string; + request_body_schema?: unknown; + offers: MppOffer[]; +} + +export interface MppOpenapiProbe extends EndpointProbe { + api_description?: string; + api_guidance?: string; + offered_methods?: string[]; + offers_stripe?: boolean; + operations?: MppOperation[]; +} + +export interface UcpServiceEntry { + service: string; + version?: string; + transport?: string; + endpoint?: string; +} + +export interface UcpCapabilityEntry { + capability: string; + version?: string; + endpoint?: string; +} + +export interface UcpPaymentHandlerEntry { + handler: string; + id?: string; + version?: string; +} + +export interface UcpProbe extends EndpointProbe { + merchant?: string; + description?: string; + version?: string; + services?: UcpServiceEntry[]; + capabilities?: UcpCapabilityEntry[]; + payment_handlers?: UcpPaymentHandlerEntry[]; +} + +export interface LiveChallengeProbe { + attempted: boolean; + url?: string; + method?: string; + status?: number; + found: boolean; + network_id?: string; + description?: string; + error?: string; +} + +export interface LinkPayTokenProbe { + url: string; + found: boolean; + indicators: string[]; + status?: number; + error?: string; +} + +export interface Strategy { + name: StrategyName; + label: string; + detected: boolean; + priority: number; + evidence: string[]; +} + +export interface RecommendedOperation { + path: string; + method: string; + description?: string; + request_body_schema?: unknown; +} + +export interface RecommendedUcpProfile { + profile_url: string; + merchant?: string; + description?: string; + services: UcpServiceEntry[]; + capabilities: UcpCapabilityEntry[]; + payment_handlers: UcpPaymentHandlerEntry[]; +} + +export interface InspectResult { + url: string; + hostname: string; + probes: { + mpp_openapi: MppOpenapiProbe[]; + x402: EndpointProbe; + ucp: UcpProbe; + link_pay_token: LinkPayTokenProbe; + live_challenge: LiveChallengeProbe; + }; + strategies: Strategy[]; + recommendation: { + strategy: StrategyName; + credential_type: CredentialType | null; + reason: string; + instruction: string; + operation?: RecommendedOperation; + profile?: RecommendedUcpProfile; + }; + _next?: { + command: string; + description: string; + }; +} + +type FetchLike = typeof fetch; + +async function fetchWithTimeout( + fetchImpl: FetchLike, + url: string, + timeoutMs: number, + init?: RequestInit, +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetchImpl(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +async function probeJsonEndpoint( + fetchImpl: FetchLike, + url: string, + timeoutMs: number, +): Promise { + try { + const response = await fetchWithTimeout(fetchImpl, url, timeoutMs); + if (!response.ok) { + return { url, found: false, status: response.status }; + } + const text = await response.text(); + try { + JSON.parse(text); + } catch { + return { + url, + found: false, + status: response.status, + error: 'response was not valid JSON', + }; + } + return { url, found: true, status: response.status }; + } catch (err) { + return { url, found: false, error: errorMessage(err) }; + } +} + +// UCP merchant profiles (https://ucp.dev) nest everything under a top-level +// `ucp` key: `merchant`/`description`/`version`, `services` (transport +// endpoints, e.g. the MCP/REST entry points), `capabilities` (individual +// operations like catalog.search or checkout), and `payment_handlers` (e.g. +// "com.stripe.payments"). Each is a map of name -> array of versioned +// entries, so we flatten them into name-tagged lists for easy reading. +function getUcpNode(spec: unknown): Record | undefined { + if (!spec || typeof spec !== 'object') return undefined; + const ucp = (spec as Record).ucp; + return ucp && typeof ucp === 'object' && !Array.isArray(ucp) + ? (ucp as Record) + : undefined; +} + +function extractUcpText( + ucpNode: Record | undefined, + field: string, +): string | undefined { + const value = ucpNode?.[field]; + return typeof value === 'string' ? value : undefined; +} + +function extractUcpServices( + ucpNode: Record | undefined, +): UcpServiceEntry[] { + const services = ucpNode?.services; + if (!services || typeof services !== 'object') return []; + const result: UcpServiceEntry[] = []; + for (const [service, entries] of Object.entries( + services as Record, + )) { + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + result.push({ + service, + version: typeof e.version === 'string' ? e.version : undefined, + transport: typeof e.transport === 'string' ? e.transport : undefined, + endpoint: typeof e.endpoint === 'string' ? e.endpoint : undefined, + }); + } + } + return result; +} + +function extractUcpCapabilities( + ucpNode: Record | undefined, +): UcpCapabilityEntry[] { + const capabilities = ucpNode?.capabilities; + if (!capabilities || typeof capabilities !== 'object') return []; + const result: UcpCapabilityEntry[] = []; + for (const [capability, entries] of Object.entries( + capabilities as Record, + )) { + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + result.push({ + capability, + version: typeof e.version === 'string' ? e.version : undefined, + endpoint: typeof e.endpoint === 'string' ? e.endpoint : undefined, + }); + } + } + return result; +} + +function extractUcpPaymentHandlers( + ucpNode: Record | undefined, +): UcpPaymentHandlerEntry[] { + const handlers = ucpNode?.payment_handlers; + if (!handlers || typeof handlers !== 'object') return []; + const result: UcpPaymentHandlerEntry[] = []; + for (const [handler, entries] of Object.entries( + handlers as Record, + )) { + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + result.push({ + handler, + id: typeof e.id === 'string' ? e.id : undefined, + version: typeof e.version === 'string' ? e.version : undefined, + }); + } + } + return result; +} + +async function probeUcpEndpoint( + fetchImpl: FetchLike, + url: string, + timeoutMs: number, +): Promise { + try { + const response = await fetchWithTimeout(fetchImpl, url, timeoutMs); + if (!response.ok) { + return { url, found: false, status: response.status }; + } + const text = await response.text(); + let spec: unknown; + try { + spec = JSON.parse(text); + } catch { + return { + url, + found: false, + status: response.status, + error: 'response was not valid JSON', + }; + } + const ucpNode = getUcpNode(spec); + return { + url, + found: true, + status: response.status, + merchant: extractUcpText(ucpNode, 'merchant'), + description: extractUcpText(ucpNode, 'description'), + version: extractUcpText(ucpNode, 'version'), + services: extractUcpServices(ucpNode), + capabilities: extractUcpCapabilities(ucpNode), + payment_handlers: extractUcpPaymentHandlers(ucpNode), + }; + } catch (err) { + return { url, found: false, error: errorMessage(err) }; + } +} + +const HTTP_METHODS = [ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', +]; + +function extractOffers(paymentInfo: unknown): MppOffer[] { + if (!paymentInfo || typeof paymentInfo !== 'object') return []; + const offers = (paymentInfo as Record).offers; + if (!Array.isArray(offers)) return []; + + const result: MppOffer[] = []; + for (const offer of offers) { + if (!offer || typeof offer !== 'object') continue; + const o = offer as Record; + if (typeof o.method !== 'string') continue; + result.push({ + method: o.method, + intent: typeof o.intent === 'string' ? o.intent : undefined, + amount: typeof o.amount === 'string' ? o.amount : undefined, + currency: typeof o.currency === 'string' ? o.currency : undefined, + description: + typeof o.description === 'string' ? o.description : undefined, + }); + } + return result; +} + +function extractRequestBodySchema(operation: Record): unknown { + const requestBody = operation.requestBody; + if (!requestBody || typeof requestBody !== 'object') return undefined; + const content = (requestBody as Record).content; + if (!content || typeof content !== 'object') return undefined; + const json = (content as Record)['application/json']; + if (!json || typeof json !== 'object') return undefined; + return (json as Record).schema; +} + +// Per https://mpp.dev/advanced/discovery, operations requiring payment carry +// `x-payment-info.offers[]`; each offer's `method` (e.g. "tempo", "stripe", +// "evm") names a payment rail. Link's shared_payment_token flow only works +// when "stripe" is among them -- most MPP integrations only offer crypto +// rails like "tempo". Some specs (e.g. climate.stripe.dev) instead declare a +// coarser `x-payment-info.protocols` list with no per-method breakdown; those +// operations are still extracted (with an empty `offers`) so a live 402 +// probe can confirm stripe support (see probeLiveChallenge). +function extractOperations(spec: unknown): MppOperation[] { + if (!spec || typeof spec !== 'object') return []; + const paths = (spec as Record).paths; + if (!paths || typeof paths !== 'object') return []; + + const operations: MppOperation[] = []; + for (const [path, pathItem] of Object.entries( + paths as Record, + )) { + if (!pathItem || typeof pathItem !== 'object') continue; + for (const method of HTTP_METHODS) { + const operation = (pathItem as Record)[method]; + if (!operation || typeof operation !== 'object') continue; + const op = operation as Record; + const paymentInfo = op['x-payment-info']; + if (!paymentInfo || typeof paymentInfo !== 'object') continue; + + operations.push({ + path, + method: method.toUpperCase(), + operation_id: + typeof op.operationId === 'string' ? op.operationId : undefined, + summary: typeof op.summary === 'string' ? op.summary : undefined, + description: + typeof op.description === 'string' ? op.description : undefined, + request_body_schema: extractRequestBodySchema(op), + offers: extractOffers(paymentInfo), + }); + } + } + return operations; +} + +function extractApiText(spec: unknown, field: string): string | undefined { + if (!spec || typeof spec !== 'object') return undefined; + const info = (spec as Record).info; + if (!info || typeof info !== 'object') return undefined; + const value = (info as Record)[field]; + return typeof value === 'string' ? value : undefined; +} + +async function probeMppOpenapiEndpoint( + fetchImpl: FetchLike, + url: string, + timeoutMs: number, +): Promise { + try { + const response = await fetchWithTimeout(fetchImpl, url, timeoutMs); + if (!response.ok) { + return { url, found: false, status: response.status }; + } + const text = await response.text(); + let spec: unknown; + try { + spec = JSON.parse(text); + } catch { + return { + url, + found: false, + status: response.status, + error: 'response was not valid JSON', + }; + } + const operations = extractOperations(spec); + const methods = new Set(); + for (const operation of operations) { + for (const offer of operation.offers) { + methods.add(offer.method); + } + } + const offeredMethods = Array.from(methods); + return { + url, + found: true, + status: response.status, + api_description: extractApiText(spec, 'description'), + api_guidance: extractApiText(spec, 'guidance'), + offered_methods: offeredMethods, + offers_stripe: offeredMethods.includes('stripe'), + operations, + }; + } catch (err) { + return { url, found: false, error: errorMessage(err) }; + } +} + +async function probeMppOpenapi( + fetchImpl: FetchLike, + origin: string, + timeoutMs: number, +): Promise { + const paths = ['/api/openapi.json', '/openapi.json']; + const results: MppOpenapiProbe[] = []; + for (const path of paths) { + const result = await probeMppOpenapiEndpoint( + fetchImpl, + `${origin}${path}`, + timeoutMs, + ); + results.push(result); + if (result.found) break; + } + return results; +} + +const LINK_PAY_TOKEN_INDICATORS: { pattern: RegExp; label: string }[] = [ + { + pattern: /AiAgentPaymentSteering/i, + label: 'Page HTML includes the "AiAgentPaymentSteering" component', + }, + { + pattern: /I am an AI agent/i, + label: 'Page HTML includes "I am an AI agent" checkbox text', + }, + { + pattern: /link_pay_token/i, + label: 'Page HTML references "link_pay_token"', + }, +]; + +async function probeLinkPayToken( + fetchImpl: FetchLike, + url: string, + timeoutMs: number, +): Promise { + try { + const response = await fetchWithTimeout(fetchImpl, url, timeoutMs); + if (!response.ok) { + return { + url, + found: false, + indicators: [], + status: response.status, + }; + } + const html = await response.text(); + const indicators = LINK_PAY_TOKEN_INDICATORS.filter(({ pattern }) => + pattern.test(html), + ).map(({ label }) => label); + return { + url, + found: indicators.length > 0, + indicators, + status: response.status, + }; + } catch (err) { + return { url, found: false, indicators: [], error: errorMessage(err) }; + } +} + +// Prefer an operation that already declares a "stripe" offer; otherwise fall +// back to the first payment-required operation, so the live probe below has +// something concrete to try even when the spec doesn't break offers out by +// method (e.g. climate.stripe.dev's `protocols`-only style). +function pickPaymentOperation( + operations: MppOperation[] | undefined, +): MppOperation | undefined { + if (!operations || operations.length === 0) return undefined; + return ( + operations.find((op) => op.offers.some((o) => o.method === 'stripe')) ?? + operations[0] + ); +} + +// Some MPP endpoints validate the request body before returning 402 (e.g. +// climate.stripe.dev rejects a body missing "amount" with 400 rather than +// challenging for payment first), so an empty `{}` isn't always enough to +// reach the payment gate. Synthesize a value satisfying each required +// property's JSON Schema so the probe body passes basic validation. +function minimalValueForSchema(schema: unknown): unknown { + if (!schema || typeof schema !== 'object') return null; + const s = schema as Record; + if ('example' in s) return s.example; + if ('default' in s) return s.default; + switch (s.type) { + case 'integer': + case 'number': + return typeof s.minimum === 'number' ? s.minimum : 1; + case 'string': + return typeof s.minLength === 'number' && s.minLength > 0 + ? 'x'.repeat(s.minLength) + : 'test'; + case 'boolean': + return true; + case 'array': + return s.items ? [minimalValueForSchema(s.items)] : []; + case 'object': + return buildMinimalObject(s); + default: + return null; + } +} + +function buildMinimalObject( + schema: Record, +): Record { + const properties = schema.properties; + if (!properties || typeof properties !== 'object') return {}; + const required = Array.isArray(schema.required) + ? (schema.required as string[]) + : Object.keys(properties); + + const result: Record = {}; + for (const key of required) { + const propSchema = (properties as Record)[key]; + result[key] = minimalValueForSchema(propSchema); + } + return result; +} + +function buildProbeBody(schema: unknown): string | undefined { + if (!schema || typeof schema !== 'object') return undefined; + if ((schema as Record).type !== 'object') return undefined; + return JSON.stringify(buildMinimalObject(schema as Record)); +} + +// Ground-truth check for "stripe" support: actually hit the endpoint and read +// the WWW-Authenticate header of the 402 response, the same way `mpp pay`/ +// `mpp decode` do. Used as a fallback when the openapi spec doesn't declare +// per-method offers explicitly (see extractOperations). +async function probeLiveChallenge( + fetchImpl: FetchLike, + origin: string, + targetUrl: string, + operation: MppOperation | undefined, + timeoutMs: number, +): Promise { + const url = operation + ? new URL(operation.path, origin).toString() + : targetUrl; + const method = operation?.method ?? 'GET'; + const body = + method === 'GET' || method === 'HEAD' + ? undefined + : buildProbeBody(operation?.request_body_schema); + const init: RequestInit = + body !== undefined + ? { method, body, headers: { 'Content-Type': 'application/json' } } + : { method }; + + try { + const response = await fetchWithTimeout(fetchImpl, url, timeoutMs, init); + if (response.status !== 402) { + return { + attempted: true, + url, + method, + status: response.status, + found: false, + }; + } + const header = response.headers.get('www-authenticate'); + if (!header) { + return { + attempted: true, + url, + method, + status: 402, + found: false, + error: 'missing WWW-Authenticate header', + }; + } + try { + const decoded = decodeStripeChallenge(header); + return { + attempted: true, + url, + method, + status: 402, + found: true, + network_id: decoded.network_id, + description: decoded.description, + }; + } catch (err) { + return { + attempted: true, + url, + method, + status: 402, + found: false, + error: errorMessage(err), + }; + } + } catch (err) { + return { + attempted: false, + url, + method, + found: false, + error: errorMessage(err), + }; + } +} + +const RECOMMENDATION_REASONS: Record = { + ucp: 'Merchant speaks the Universal Commerce Protocol (UCP) — use the ucp CLI for full catalog/cart/checkout support.', + shared_payment_token: + 'Merchant exposes a Machine Payment Protocol (MPP) endpoint that accepts the "stripe" payment method — create a spend request and use "link-cli mpp pay" to complete the 402 flow.', + link_pay_token: + 'Checkout page includes an AI-agent steering block — use the Link Pay Token flow (requires browser automation) to pay without exposing card numbers.', + card: "No agent-native payment protocol detected — use the default 'card' credential type and complete checkout on the page's own payment form.", +}; + +// Only 'card' and 'shared_payment_token' map to link-cli spend-request's +// --credential-type flag; 'ucp' is a fully separate protocol/CLI, and +// 'link_pay_token' rides on a 'card' spend request under the hood. +const RECOMMENDATION_CREDENTIAL_TYPES: Record< + StrategyName, + CredentialType | null +> = { + ucp: null, + shared_payment_token: 'shared_payment_token', + link_pay_token: 'card', + card: 'card', +}; + +const RECOMMENDATION_INSTRUCTIONS: Record = { + ucp: 'Run `ucp discover --business ` to confirm merchant capabilities, then use ucp cart/checkout commands against the endpoints in `recommendation.profile.services`/`capabilities` to complete the purchase.', + shared_payment_token: + 'Create a spend request with `--credential-type shared_payment_token`, get it approved, then run `link-cli mpp pay --spend-request-id ` against the operation in `recommendation.operation` (and `--data`/`--method` matching its `request_body_schema`) to complete the 402 flow.', + link_pay_token: + 'Create a spend request (default `card` credential type), get it approved, open the checkout page, and follow the Link Pay Token flow (check the "I am an AI agent" checkbox, inject the token from `spend-request retrieve --include link_pay_token`).', + card: 'Create a spend request with the default `card` credential type, get it approved, then run `spend-request retrieve --include card` and enter the returned card details into the checkout form.', +}; + +function buildNextAction( + strategy: StrategyName, + origin: string, +): InspectResult['_next'] { + if (strategy === 'ucp') { + return { + command: `ucp discover --business ${origin}`, + description: 'Confirm UCP capabilities for this merchant', + }; + } + return undefined; +} + +function buildUcpEvidence(ucpProbe: UcpProbe): string[] { + if (!ucpProbe.found) return []; + const merchant = ucpProbe.merchant ? ` for "${ucpProbe.merchant}"` : ''; + const serviceCount = ucpProbe.services?.length ?? 0; + const capabilityCount = ucpProbe.capabilities?.length ?? 0; + return [ + `${ucpProbe.url} responded with a UCP merchant profile${merchant} (${serviceCount} service${serviceCount === 1 ? '' : 's'}, ${capabilityCount} capabilit${capabilityCount === 1 ? 'y' : 'ies'})`, + ]; +} + +function buildMppEvidence( + mppMatch: MppOpenapiProbe | undefined, + liveChallenge: LiveChallengeProbe, +): string[] { + const evidence: string[] = []; + if (mppMatch) { + const methods = mppMatch.offered_methods ?? []; + if (mppMatch.offers_stripe) { + const others = methods.filter((m) => m !== 'stripe'); + evidence.push( + `${mppMatch.url} offers the "stripe" payment method${others.length ? ` (alongside ${others.join(', ')})` : ''}`, + ); + } else { + evidence.push( + `${mppMatch.url} responded with an MPP spec but does not explicitly declare the "stripe" payment method${methods.length ? ` (offers: ${methods.join(', ')})` : ' (no per-method offers declared)'}`, + ); + } + } + if (liveChallenge.found) { + evidence.push( + `Live ${liveChallenge.method} ${liveChallenge.url} returned HTTP 402 with a "stripe" payment challenge (network_id: ${liveChallenge.network_id})`, + ); + } else if (liveChallenge.attempted && !mppMatch?.offers_stripe) { + evidence.push( + `Live ${liveChallenge.method} ${liveChallenge.url} did not confirm stripe support (status: ${liveChallenge.status ?? 'error'}${liveChallenge.error ? `, ${liveChallenge.error}` : ''})`, + ); + } + return evidence; +} + +function buildStrategies( + probes: InspectResult['probes'], + mppMatch: MppOpenapiProbe | undefined, +): Strategy[] { + const mppOffersStripe = mppMatch?.offers_stripe ?? false; + const detected = mppOffersStripe || probes.live_challenge.found; + + const strategies: Strategy[] = [ + { + name: 'ucp', + label: 'Universal Commerce Protocol (UCP)', + detected: probes.ucp.found, + priority: 1, + evidence: buildUcpEvidence(probes.ucp), + }, + { + name: 'shared_payment_token', + label: 'Machine Payment Protocol (MPP) shared payment token', + detected, + priority: 2, + evidence: buildMppEvidence(mppMatch, probes.live_challenge), + }, + { + name: 'link_pay_token', + label: 'Link Pay Token (AI-agent steering block)', + detected: probes.link_pay_token.found, + priority: 3, + evidence: probes.link_pay_token.indicators, + }, + { + name: 'card', + label: 'Virtual card (default fallback)', + detected: true, + priority: 4, + evidence: [ + 'Always available via Link — no merchant-side support required', + ], + }, + ]; + + return strategies.sort((a, b) => { + if (a.detected !== b.detected) return a.detected ? -1 : 1; + return a.priority - b.priority; + }); +} + +function buildRecommendedOperation( + strategy: StrategyName, + operation: MppOperation | undefined, + liveChallenge: LiveChallengeProbe, +): RecommendedOperation | undefined { + if (strategy !== 'shared_payment_token') return undefined; + if (operation) { + return { + path: operation.path, + method: operation.method, + description: operation.description ?? operation.summary, + request_body_schema: operation.request_body_schema, + }; + } + if (liveChallenge.found && liveChallenge.url) { + return { + path: new URL(liveChallenge.url).pathname, + method: liveChallenge.method ?? 'GET', + }; + } + return undefined; +} + +function buildRecommendedProfile( + strategy: StrategyName, + ucpProbe: UcpProbe, +): RecommendedUcpProfile | undefined { + if (strategy !== 'ucp' || !ucpProbe.found) return undefined; + return { + profile_url: ucpProbe.url, + merchant: ucpProbe.merchant, + description: ucpProbe.description, + services: ucpProbe.services ?? [], + capabilities: ucpProbe.capabilities ?? [], + payment_handlers: ucpProbe.payment_handlers ?? [], + }; +} + +export async function runInspect( + url: string, + opts: { fetchImpl?: FetchLike; timeoutMs?: number } = {}, +): Promise { + const fetchImpl = opts.fetchImpl ?? fetch; + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid URL: ${url}`); + } + const origin = `${parsed.protocol}//${parsed.host}`; + + const [mppOpenapi, x402, ucp, linkPayToken] = await Promise.all([ + probeMppOpenapi(fetchImpl, origin, timeoutMs), + probeJsonEndpoint(fetchImpl, `${origin}/.well-known/x402.json`, timeoutMs), + probeUcpEndpoint(fetchImpl, `${origin}/.well-known/ucp`, timeoutMs), + probeLinkPayToken(fetchImpl, url, timeoutMs), + ]); + + const mppMatch = mppOpenapi.find((p) => p.found); + const operation = pickPaymentOperation(mppMatch?.operations); + + // Only fall back to a live 402 probe when the openapi spec didn't already + // give us a confident "stripe" answer -- e.g. climate.stripe.dev's spec + // declares `protocols: ["mpp","x402"]` with no per-method offers, so the + // static check alone can't tell whether "stripe" is actually accepted. + const liveChallenge = mppMatch?.offers_stripe + ? { + attempted: false as const, + found: false as const, + } + : await probeLiveChallenge(fetchImpl, origin, url, operation, timeoutMs); + + const probes: InspectResult['probes'] = { + mpp_openapi: mppOpenapi, + x402, + ucp, + link_pay_token: linkPayToken, + live_challenge: liveChallenge, + }; + + const strategies = buildStrategies(probes, mppMatch); + const top = strategies[0]; + + const result: InspectResult = { + url, + hostname: parsed.hostname, + probes, + strategies, + recommendation: { + strategy: top.name, + credential_type: RECOMMENDATION_CREDENTIAL_TYPES[top.name], + reason: RECOMMENDATION_REASONS[top.name], + instruction: RECOMMENDATION_INSTRUCTIONS[top.name], + operation: buildRecommendedOperation(top.name, operation, liveChallenge), + profile: buildRecommendedProfile(top.name, ucp), + }, + _next: buildNextAction(top.name, origin), + }; + + return sanitizeDeep(result); +} diff --git a/packages/cli/src/commands/inspect/schema.ts b/packages/cli/src/commands/inspect/schema.ts new file mode 100644 index 0000000..e8dfd64 --- /dev/null +++ b/packages/cli/src/commands/inspect/schema.ts @@ -0,0 +1,10 @@ +import { z } from 'incur'; + +export const inspectOptions = z.object({ + timeout: z + .number() + .int() + .positive() + .optional() + .describe('Timeout in milliseconds for each probe request (default: 5000)'), +});