Skip to content
Open
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
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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`.
22 changes: 22 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>x402 Directory</title>
<link rel="stylesheet" href="./src/styles.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="#/">x402 Directory</a>
<nav aria-label="Primary">
<a href="#/categories">Categories</a>
<a href="#/listings">Listings</a>
</nav>
</header>

<main id="app" class="app-shell" tabindex="-1"></main>

<script type="module" src="./src/app.js"></script>
</body>
</html>
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
51 changes: 51 additions & 0 deletions scripts/serve.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
});
173 changes: 173 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}

function listingCard(listing) {
const category = getCategory(listing.category);
const status = listing.verified ? "Verified" : "Needs check";

return `
<article class="listing-card">
<div>
<p class="eyebrow">${escapeHtml(category?.name ?? "Uncategorized")}</p>
<h3>${escapeHtml(listing.name)}</h3>
<p>${escapeHtml(listing.description)}</p>
</div>
<dl>
<div>
<dt>Pricing</dt>
<dd>${escapeHtml(listing.pricing)}</dd>
</div>
<div>
<dt>Status</dt>
<dd>${status}</dd>
</div>
<div>
<dt>Last checked</dt>
<dd>${escapeHtml(listing.lastChecked)}</dd>
</div>
</dl>
<a class="button" href="${escapeHtml(listing.url)}" rel="noreferrer">Try endpoint</a>
</article>
`;
}

function categoryCard(category) {
return `
<a class="category-card" href="#/categories/${category.slug}">
<span>${category.count} listing${category.count === 1 ? "" : "s"}</span>
<h3>${escapeHtml(category.name)}</h3>
<p>${escapeHtml(category.description)}</p>
</a>
`;
}

function renderHome() {
app.innerHTML = `
<section class="hero">
<p class="eyebrow">Agent-maintained x402 services</p>
<h1>Discover payment-enabled endpoints by category.</h1>
<p>
Browse an initial taxonomy for x402 services and jump into filtered
category pages for AI, data, compute, storage, content, developer tools,
and identity services.
</p>
<div class="actions">
<a class="button primary" href="#/categories">Browse categories</a>
<a class="button" href="#/listings">View all listings</a>
</div>
</section>
<section>
<div class="section-heading">
<h2>Categories</h2>
<p>${categories.length} categories covering ${listings.length} starter listings.</p>
</div>
<div class="category-grid">
${getCategoryCounts().map(categoryCard).join("")}
</div>
</section>
`;
}

function renderCategories() {
app.innerHTML = `
<section>
<div class="section-heading">
<p class="eyebrow">Taxonomy</p>
<h1>Browse by category</h1>
<p>Each category page filters the directory to matching x402 services.</p>
</div>
<div class="category-grid">
${getCategoryCounts().map(categoryCard).join("")}
</div>
</section>
`;
}

function renderCategory(slug) {
const category = getCategory(slug);

if (!category) {
renderNotFound();
return;
}

const categoryListings = getListingsByCategory(slug);

app.innerHTML = `
<section>
<a class="back-link" href="#/categories">Back to categories</a>
<div class="section-heading">
<p class="eyebrow">${categoryListings.length} listing${categoryListings.length === 1 ? "" : "s"}</p>
<h1>${escapeHtml(category.name)}</h1>
<p>${escapeHtml(category.description)}</p>
</div>
<div class="listing-grid">
${categoryListings.length ? categoryListings.map(listingCard).join("") : "<p>No listings yet.</p>"}
</div>
</section>
`;
}

function renderListings() {
app.innerHTML = `
<section>
<div class="section-heading">
<p class="eyebrow">${listings.length} starter listings</p>
<h1>All x402 listings</h1>
<p>Use category labels to understand where each endpoint fits.</p>
</div>
<div class="listing-grid">
${listings.map(listingCard).join("")}
</div>
</section>
`;
}

function renderNotFound() {
app.innerHTML = `
<section class="hero">
<p class="eyebrow">404</p>
<h1>Category not found.</h1>
<p>The requested category does not exist in the current taxonomy.</p>
<a class="button primary" href="#/categories">Browse categories</a>
</section>
`;
}

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();
Loading