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
38 changes: 37 additions & 1 deletion src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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/<slug>/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<string, unknown> {
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
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 18 additions & 1 deletion test/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, Record<string, { security?: unknown }>> };
assert.equal(doc.openapi, "3.1.0");
Expand Down Expand Up @@ -102,6 +111,14 @@ test("the routes: text, JSON, the download page, a redirect per platform, and no
assert.ok(pages.includes(`<loc>${SITE}/</loc>`));
assert.ok(pages.includes(`<loc>${SITE}/music</loc>`));

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");

Expand Down
Loading