diff --git a/src/discovery.ts b/src/discovery.ts index 0766f76..9810bb8 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -24,7 +24,7 @@ export const DESKTOP_TARGETS: ReadonlyArray<{ target: string; label: string; suf { target: "deb-arm64", label: "Debian/Ubuntu .deb (arm64)", suffix: "-linux-arm64.deb" }, ]; -export const DISCOVERY_PATHS = new Set(["/llms.txt", "/openapi.json", "/api/v1/health", "/download", "/robots.txt", "/sitemap.xml", "/sitemaps/static.xml"]); +export const DISCOVERY_PATHS = new Set(["/.well-known/x402", "/.well-known/x402.json", "/llms.txt", "/openapi.json", "/api/v1/health", "/download", "/robots.txt", "/sitemap.xml", "/sitemaps/static.xml"]); export const isDiscoveryPath = (path: string): boolean => DISCOVERY_PATHS.has(path) || /^\/download\/[a-z0-9-]{1,20}$/.test(path); export interface ReleaseAsset { name: string; browser_download_url: string } @@ -107,6 +107,34 @@ export function latestRelease(fetcher: typeof fetch = fetch, now: () => number = }; } +/** + * /.well-known/x402: what a machine buyer can pay for here, by convention + * (`version` + `resources`, the shape x402 directories crawl). The crawl day + * pass is the one fixed resource; a ticketed live show sells at its own + * /live//tickets, so those are described rather than listed. An unpaid + * request to any resource answers 402 with the offer, which is the real price. + */ +export function x402Document(site: string): Record { + return { + version: 1, + name: "nixamp", + description: "Open source live radio, TV and film. Machine buyers pay over x402 v2 (USDC on Base, Polygon or Ethereum).", + resources: [`${site}/crawl`], + items: [ + { resource: `${site}/crawl`, method: "GET", description: "Crawl day pass: 24 hours of crawl access to nixamp.com for $1. People, verified search crawlers and AI chat fetchers are never charged." }, + { resource: `${site}/live/{slug}/tickets`, method: "GET", description: "A ticket to a ticketed live show; the show's price and minutes come back in the 402." }, + ], + mcp: { + public: `${site}/mcp/public`, + authenticated: `${site}/mcp`, + note: "/mcp/public answers initialize with no account. /mcp answers 401 with a WWW-Authenticate pointing at the OAuth resource metadata, by design: that is how an MCP client learns to sign in.", + }, + oauth: `${site}/.well-known/oauth-protected-resource`, + llms: `${site}/llms.txt`, + openapi: `${site}/openapi.json`, + }; +} + export function llmsTxt(site: string): string { const desktop = DESKTOP_TARGETS.map((one) => `- ${one.label}: ${site}/download/${one.target}`).join("\n"); return `# nixamp @@ -127,6 +155,10 @@ export function llmsTxt(site: string): string { - Signed in (watch parties, rooms, transcripts, music, your stations): ${site}/mcp , a nixamp token as the bearer (\`nixamp token create\`) or OAuth - Local over stdio: npx -y nixamp mcp +## x402 +- What a machine can pay for: ${site}/.well-known/x402 +- Crawl day pass ($1, 24 h): ${site}/crawl + ## API - OpenAPI: ${site}/openapi.json - Base: ${site}/api/v1 @@ -238,6 +270,10 @@ export async function handleDiscovery(request: IncomingMessage, response: Server send(response, 200, "text/plain; charset=utf-8", llmsTxt(site), method); return true; } + if (path === "/.well-known/x402" || path === "/.well-known/x402.json") { + send(response, 200, "application/json; charset=utf-8", JSON.stringify(x402Document(site), null, 2), method); + return true; + } if (path === "/robots.txt") { send(response, 200, "text/plain; charset=utf-8", robotsTxt(site), method); return true; diff --git a/test/discovery.test.ts b/test/discovery.test.ts index 68fbb3a..084aab8 100644 --- a/test/discovery.test.ts +++ b/test/discovery.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import { - DESKTOP_TARGETS, RELEASES, desktopAsset, handleDiscovery, isDiscoveryPath, latestRelease, llmsTxt, openapiDocument, type Release, + DESKTOP_TARGETS, RELEASES, desktopAsset, handleDiscovery, isDiscoveryPath, latestRelease, llmsTxt, openapiDocument, x402Document, type Release, } from "../src/discovery.ts"; const SITE = "https://nixamp.com"; @@ -40,6 +40,15 @@ test("llms.txt names the public MCP, the stdio MCP, the API and a Desktop sectio for (const one of DESKTOP_TARGETS) assert.ok(desktop.includes(`- ${one.label}: ${SITE}/download/${one.target}`), one.target); }); +test("/.well-known/x402 lists the crawl pass as a resource and points at both MCP endpoints (issue #409)", () => { + const doc = x402Document(SITE) as { version: number; resources: string[]; mcp: { public: string; authenticated: string } }; + assert.equal(doc.version, 1); + assert.deepEqual(doc.resources, [`${SITE}/crawl`]); + assert.equal(doc.mcp.public, `${SITE}/mcp/public`); + assert.equal(doc.mcp.authenticated, `${SITE}/mcp`); + assert.match(llmsTxt(SITE), /\/\.well-known\/x402$/m); +}); + test("the OpenAPI document is 3.1, points at the site, and marks the signed-in routes", () => { const doc = openapiDocument(SITE, "9.9.9") as { openapi: string; info: { version: string }; servers: { url: string }[]; paths: Record> }; assert.equal(doc.openapi, "3.1.0"); @@ -102,6 +111,14 @@ test("the routes: text, JSON, the download page, a redirect per platform, and no assert.ok(pages.includes(`${SITE}/`)); assert.ok(pages.includes(`${SITE}/music`)); + for (const wellKnown of ["/.well-known/x402", "/.well-known/x402.json"]) { + const x402 = await fetch(`${base}${wellKnown}`); + assert.equal(x402.status, 200); + assert.match(x402.headers.get("content-type") ?? "", /^application\/json/); + assert.equal(x402.headers.get("access-control-allow-origin"), "*"); + assert.deepEqual((await x402.json() as { resources: string[] }).resources, [`${SITE}/crawl`]); + } + const spec = await fetch(`${base}/openapi.json`); assert.equal((await spec.json() as { info: { version: string } }).info.version, "1.2.3");