diff --git a/README.md b/README.md index 5f1a2e0..95cbb19 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,30 @@ -# x402-directory -A curated, agent-maintained directory of x402 payment-enabled applications and endpoints. Browse, search, and discover services that accept x402 micropayments. Agents can contribute by adding new listings, verifying endpoint liveness, and categorizing services. +# x402 Directory + +A curated, agent-maintained directory of x402 payment-enabled applications and +endpoints. Browse, search, and discover services that accept x402 micropayments. +Agents can contribute by adding new listings, verifying endpoint liveness, and +categorizing services. + +## Category Taxonomy + +The directory ships with an initial taxonomy for x402 services: + +- AI/ML APIs +- Data feeds +- Compute +- Storage +- Content +- Developer tools +- Identity and auth + +Each listing belongs to one category. The app includes a categories index and +individual category pages with filtered listings. + +## Run Locally + +```bash +npm test +npm run dev +``` + +Then open `http://localhost:4173`. diff --git a/index.html b/index.html new file mode 100644 index 0000000..6b7bf03 --- /dev/null +++ b/index.html @@ -0,0 +1,22 @@ + + + + + + x402 Directory + + + + + +
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..1f67b3e --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "x402-directory", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node scripts/serve.mjs", + "test": "node --test tests/catalog.test.mjs" + } +} diff --git a/scripts/serve.mjs b/scripts/serve.mjs new file mode 100644 index 0000000..d9f522a --- /dev/null +++ b/scripts/serve.mjs @@ -0,0 +1,51 @@ +import { createReadStream, existsSync, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { extname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = normalize(fileURLToPath(new URL("..", import.meta.url))); +const port = Number.parseInt(process.env.PORT ?? "4173", 10); + +const mimeTypes = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml" +}; + +function resolvePath(requestUrl) { + const url = new URL(requestUrl, `http://localhost:${port}`); + const pathname = decodeURIComponent(url.pathname); + const target = normalize(join(root, pathname === "/" ? "index.html" : pathname)); + + if (!target.startsWith(root)) { + return null; + } + + if (!existsSync(target)) { + return join(root, "index.html"); + } + + return statSync(target).isDirectory() ? join(target, "index.html") : target; +} + +const server = createServer((request, response) => { + const filePath = resolvePath(request.url ?? "/"); + + if (!filePath || !existsSync(filePath)) { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not found"); + return; + } + + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Type": mimeTypes[extname(filePath)] ?? "application/octet-stream" + }); + createReadStream(filePath).pipe(response); +}); + +server.listen(port, () => { + console.log(`x402 Directory running at http://localhost:${port}`); +}); diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..452ab31 --- /dev/null +++ b/src/app.js @@ -0,0 +1,173 @@ +import { + categories, + getCategory, + getCategoryCounts, + getListingsByCategory, + listings +} from "./catalog.js"; + +const app = document.querySelector("#app"); + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function listingCard(listing) { + const category = getCategory(listing.category); + const status = listing.verified ? "Verified" : "Needs check"; + + return ` +
+
+

${escapeHtml(category?.name ?? "Uncategorized")}

+

${escapeHtml(listing.name)}

+

${escapeHtml(listing.description)}

+
+
+
+
Pricing
+
${escapeHtml(listing.pricing)}
+
+
+
Status
+
${status}
+
+
+
Last checked
+
${escapeHtml(listing.lastChecked)}
+
+
+ Try endpoint +
+ `; +} + +function categoryCard(category) { + return ` + + ${category.count} listing${category.count === 1 ? "" : "s"} +

${escapeHtml(category.name)}

+

${escapeHtml(category.description)}

+
+ `; +} + +function renderHome() { + app.innerHTML = ` +
+

Agent-maintained x402 services

+

Discover payment-enabled endpoints by category.

+

+ Browse an initial taxonomy for x402 services and jump into filtered + category pages for AI, data, compute, storage, content, developer tools, + and identity services. +

+
+ Browse categories + View all listings +
+
+
+
+

Categories

+

${categories.length} categories covering ${listings.length} starter listings.

+
+
+ ${getCategoryCounts().map(categoryCard).join("")} +
+
+ `; +} + +function renderCategories() { + app.innerHTML = ` +
+
+

Taxonomy

+

Browse by category

+

Each category page filters the directory to matching x402 services.

+
+
+ ${getCategoryCounts().map(categoryCard).join("")} +
+
+ `; +} + +function renderCategory(slug) { + const category = getCategory(slug); + + if (!category) { + renderNotFound(); + return; + } + + const categoryListings = getListingsByCategory(slug); + + app.innerHTML = ` +
+ Back to categories +
+

${categoryListings.length} listing${categoryListings.length === 1 ? "" : "s"}

+

${escapeHtml(category.name)}

+

${escapeHtml(category.description)}

+
+
+ ${categoryListings.length ? categoryListings.map(listingCard).join("") : "

No listings yet.

"} +
+
+ `; +} + +function renderListings() { + app.innerHTML = ` +
+
+

${listings.length} starter listings

+

All x402 listings

+

Use category labels to understand where each endpoint fits.

+
+
+ ${listings.map(listingCard).join("")} +
+
+ `; +} + +function renderNotFound() { + app.innerHTML = ` +
+

404

+

Category not found.

+

The requested category does not exist in the current taxonomy.

+ Browse categories +
+ `; +} + +function route() { + const hash = window.location.hash || "#/"; + const [, section, slug] = hash.split("/"); + + if (!section) { + renderHome(); + } else if (section === "categories" && slug) { + renderCategory(slug); + } else if (section === "categories") { + renderCategories(); + } else if (section === "listings") { + renderListings(); + } else { + renderNotFound(); + } + + app.focus({ preventScroll: true }); +} + +window.addEventListener("hashchange", route); +route(); diff --git a/src/catalog.js b/src/catalog.js new file mode 100644 index 0000000..a1777c7 --- /dev/null +++ b/src/catalog.js @@ -0,0 +1,139 @@ +export const categories = [ + { + slug: "ai-ml-apis", + name: "AI/ML APIs", + description: "Inference, embeddings, agents, and model-powered services." + }, + { + slug: "data-feeds", + name: "Data feeds", + description: "Paid access to live, historical, or specialized data streams." + }, + { + slug: "compute", + name: "Compute", + description: "On-demand execution, jobs, inference workers, and serverless tasks." + }, + { + slug: "storage", + name: "Storage", + description: "Files, blobs, retrieval endpoints, backups, and archival services." + }, + { + slug: "content", + name: "Content", + description: "Articles, media, reports, documents, and gated creative assets." + }, + { + slug: "developer-tools", + name: "Developer tools", + description: "APIs, SDKs, testing tools, monitoring, and automation utilities." + }, + { + slug: "identity-auth", + name: "Identity and auth", + description: "Verification, profiles, credentials, sessions, and access control." + } +]; + +export const listings = [ + { + id: "model-meter", + name: "Model Meter", + url: "https://example.com/model-meter", + category: "ai-ml-apis", + description: "x402-gated token usage estimates for common model providers.", + pricing: "$0.002 per request", + verified: true, + lastChecked: "2026-05-10" + }, + { + id: "chain-snapshot-feed", + name: "Chain Snapshot Feed", + url: "https://example.com/chain-snapshot", + category: "data-feeds", + description: "Fresh chain metadata snapshots for agent workflows.", + pricing: "$0.01 per snapshot", + verified: true, + lastChecked: "2026-05-12" + }, + { + id: "micro-render", + name: "Micro Render", + url: "https://example.com/micro-render", + category: "compute", + description: "Short-lived browser render jobs paid through x402.", + pricing: "$0.05 per render", + verified: false, + lastChecked: "2026-05-08" + }, + { + id: "agent-archive", + name: "Agent Archive", + url: "https://example.com/agent-archive", + category: "storage", + description: "Pay-per-upload object storage for agent-generated artifacts.", + pricing: "$0.001 per MB", + verified: true, + lastChecked: "2026-05-11" + }, + { + id: "research-briefs", + name: "Research Briefs", + url: "https://example.com/research-briefs", + category: "content", + description: "Gated market and technical briefs with x402 payment access.", + pricing: "$0.25 per brief", + verified: false, + lastChecked: "2026-05-05" + }, + { + id: "x402-probe", + name: "x402 Probe", + url: "https://example.com/x402-probe", + category: "developer-tools", + description: "Endpoint liveness checks and payment handshake diagnostics.", + pricing: "$0.003 per check", + verified: true, + lastChecked: "2026-05-13" + }, + { + id: "wallet-pass", + name: "Wallet Pass", + url: "https://example.com/wallet-pass", + category: "identity-auth", + description: "Wallet-bound access tokens for paid agent sessions.", + pricing: "$0.01 per session", + verified: false, + lastChecked: "2026-05-09" + } +]; + +export function getCategory(slug) { + return categories.find((category) => category.slug === slug); +} + +export function getListingsByCategory(slug) { + return listings.filter((listing) => listing.category === slug); +} + +export function getCategoryCounts() { + return categories.map((category) => ({ + ...category, + count: getListingsByCategory(category.slug).length + })); +} + +export function validateCatalog() { + const categorySlugs = new Set(categories.map((category) => category.slug)); + const duplicateSlugs = categories + .map((category) => category.slug) + .filter((slug, index, allSlugs) => allSlugs.indexOf(slug) !== index); + const invalidListings = listings.filter((listing) => !categorySlugs.has(listing.category)); + + return { + valid: duplicateSlugs.length === 0 && invalidListings.length === 0, + duplicateSlugs, + invalidListings + }; +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..c1c9c19 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,208 @@ +:root { + color: #17211d; + background: #f5f7f2; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; +} + +a { + color: inherit; +} + +.site-header { + align-items: center; + background: #ffffff; + border-bottom: 1px solid #dfe6dc; + display: flex; + justify-content: space-between; + min-height: 64px; + padding: 0 32px; + position: sticky; + top: 0; + z-index: 10; +} + +.brand { + font-size: 1.05rem; + font-weight: 800; + text-decoration: none; +} + +nav { + display: flex; + gap: 18px; +} + +nav a, +.back-link { + color: #406051; + font-weight: 700; + text-decoration: none; +} + +.app-shell { + margin: 0 auto; + max-width: 1120px; + padding: 48px 32px 72px; +} + +.app-shell:focus { + outline: none; +} + +.hero { + max-width: 780px; + padding: 40px 0 52px; +} + +.hero h1, +.section-heading h1 { + font-size: clamp(2rem, 6vw, 4.25rem); + line-height: 1; + margin: 0 0 18px; +} + +.hero p, +.section-heading p { + color: #55655d; + font-size: 1.05rem; + margin: 0; + max-width: 720px; +} + +.eyebrow { + color: #2f6f4e; + font-size: 0.76rem; + font-weight: 800; + letter-spacing: 0; + margin: 0 0 10px; + text-transform: uppercase; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 28px; +} + +.button { + align-items: center; + border: 1px solid #b8c8bd; + border-radius: 6px; + display: inline-flex; + font-weight: 800; + justify-content: center; + min-height: 42px; + padding: 0 16px; + text-decoration: none; +} + +.button.primary { + background: #173c2b; + border-color: #173c2b; + color: #ffffff; +} + +.section-heading { + margin-bottom: 24px; +} + +.section-heading h2 { + font-size: 2rem; + margin: 0 0 8px; +} + +.category-grid, +.listing-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.category-card, +.listing-card { + background: #ffffff; + border: 1px solid #dfe6dc; + border-radius: 8px; + box-shadow: 0 18px 40px rgba(24, 42, 31, 0.06); +} + +.category-card { + display: block; + min-height: 190px; + padding: 20px; + text-decoration: none; +} + +.category-card span { + color: #2f6f4e; + display: block; + font-size: 0.82rem; + font-weight: 800; + margin-bottom: 18px; +} + +.category-card h3, +.listing-card h3 { + font-size: 1.25rem; + margin: 0 0 10px; +} + +.category-card p, +.listing-card p { + color: #55655d; + margin: 0; +} + +.listing-card { + display: grid; + gap: 18px; + padding: 20px; +} + +.listing-card dl { + border-top: 1px solid #edf1eb; + display: grid; + gap: 8px; + margin: 0; + padding-top: 14px; +} + +.listing-card div { + display: grid; + gap: 2px; +} + +.listing-card dt { + color: #66746c; + font-size: 0.75rem; + font-weight: 800; + text-transform: uppercase; +} + +.listing-card dd { + margin: 0; +} + +@media (max-width: 640px) { + .site-header { + align-items: flex-start; + flex-direction: column; + gap: 10px; + padding: 16px 20px; + } + + .app-shell { + padding: 32px 20px 56px; + } +} diff --git a/tests/catalog.test.mjs b/tests/catalog.test.mjs new file mode 100644 index 0000000..9281972 --- /dev/null +++ b/tests/catalog.test.mjs @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + categories, + getCategory, + getCategoryCounts, + getListingsByCategory, + listings, + validateCatalog +} from "../src/catalog.js"; + +test("catalog defines the expected starter taxonomy", () => { + assert.equal(categories.length, 7); + assert.deepEqual( + categories.map((category) => category.slug), + [ + "ai-ml-apis", + "data-feeds", + "compute", + "storage", + "content", + "developer-tools", + "identity-auth" + ] + ); +}); + +test("every listing maps to a valid category", () => { + const result = validateCatalog(); + + assert.equal(result.valid, true); + assert.deepEqual(result.duplicateSlugs, []); + assert.deepEqual(result.invalidListings, []); +}); + +test("category pages can filter listings by slug", () => { + assert.equal(getCategory("compute").name, "Compute"); + assert.deepEqual( + getListingsByCategory("compute").map((listing) => listing.id), + ["micro-render"] + ); +}); + +test("category counts reflect the listing data", () => { + const totalFromCounts = getCategoryCounts().reduce((sum, category) => sum + category.count, 0); + + assert.equal(totalFromCounts, listings.length); +});