diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..210f1f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.env* diff --git a/README.md b/README.md index 5f1a2e0..bcf377b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,24 @@ # 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. + +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. + +## Listing Data Model + +This repository starts with a simple JSON-backed data model for v1 listings. + +- `schema/listing.schema.json` defines the JSON Schema contract. +- `src/schema.ts` exports TypeScript types and category/status constants. +- `data/listings.json` contains seed listings using the schema. +- `src/validate.ts` validates the seed data without external runtime dependencies. + +## Scripts + +```bash +npm install +npm run typecheck +npm run build +npm run validate +``` diff --git a/data/listings.json b/data/listings.json new file mode 100644 index 0000000..2df8bbc --- /dev/null +++ b/data/listings.json @@ -0,0 +1,36 @@ +[ + { + "id": "x402_weather_quote", + "name": "Weather Quote API", + "url": "https://weather-quote.example/x402", + "description": "Returns a paid current-weather summary for a requested city through an x402-protected HTTP endpoint.", + "category": "data-feeds", + "pricing": { + "amount": "0.001", + "currency": "USDC", + "unit": "request", + "network": "base", + "details": "Flat per-request price for JSON weather summaries." + }, + "status": "unverified", + "createdAt": "2026-05-11T00:00:00.000Z", + "updatedAt": "2026-05-11T00:00:00.000Z" + }, + { + "id": "x402_agent_icon", + "name": "Agent Icon Renderer", + "url": "https://agent-icon.example/render", + "description": "Generates a small paid PNG icon from a text prompt and returns the image URL after x402 payment.", + "category": "ai-ml-apis", + "pricing": { + "amount": "0.02", + "currency": "USDC", + "unit": "render", + "network": "base", + "details": "Per successful image render." + }, + "status": "unverified", + "createdAt": "2026-05-11T00:00:00.000Z", + "updatedAt": "2026-05-11T00:00:00.000Z" + } +] diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..aa89111 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,47 @@ +{ + "name": "x402-directory", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "x402-directory", + "version": "0.1.0", + "devDependencies": { + "@types/node": "22.15.17", + "typescript": "5.6.3" + } + }, + "node_modules/@types/node": { + "version": "22.15.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz", + "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4eafa35 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "x402-directory", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "validate": "npm run build && node dist/src/validate.js" + }, + "devDependencies": { + "@types/node": "22.15.17", + "typescript": "5.6.3" + } +} diff --git a/schema/listing.schema.json b/schema/listing.schema.json new file mode 100644 index 0000000..775386d --- /dev/null +++ b/schema/listing.schema.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/shipyard-projects/x402-directory/schema/listing.schema.json", + "title": "x402 Directory Listing", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "url", + "description", + "category", + "pricing", + "status", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^x402_[a-z0-9_]+$" + }, + "name": { + "type": "string", + "minLength": 2, + "maxLength": 80 + }, + "url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "minLength": 20, + "maxLength": 280 + }, + "category": { + "type": "string", + "enum": [ + "ai-ml-apis", + "data-feeds", + "compute", + "storage", + "content", + "developer-tools", + "identity", + "other" + ] + }, + "pricing": { + "type": "object", + "additionalProperties": false, + "required": ["amount", "currency", "unit", "network"], + "properties": { + "amount": { + "type": "string", + "pattern": "^[0-9]+(\\.[0-9]+)?$" + }, + "currency": { + "type": "string", + "minLength": 2, + "maxLength": 16 + }, + "unit": { + "type": "string", + "minLength": 3, + "maxLength": 64 + }, + "network": { + "type": "string", + "minLength": 2, + "maxLength": 32 + }, + "details": { + "type": "string", + "maxLength": 180 + } + } + }, + "status": { + "type": "string", + "enum": ["verified", "unverified"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "lastCheckedAt": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..cc3b38a --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,44 @@ +export const listingCategories = [ + "ai-ml-apis", + "data-feeds", + "compute", + "storage", + "content", + "developer-tools", + "identity", + "other" +] as const; + +export const listingStatuses = ["verified", "unverified"] as const; + +export type ListingCategory = (typeof listingCategories)[number]; +export type ListingStatus = (typeof listingStatuses)[number]; + +export type PricingInfo = { + amount: string; + currency: string; + unit: string; + network: string; + details?: string; +}; + +export type X402Listing = { + id: `x402_${string}`; + name: string; + url: string; + description: string; + category: ListingCategory; + pricing: PricingInfo; + status: ListingStatus; + createdAt: string; + updatedAt: string; + lastCheckedAt?: string; +}; + +export function isListingCategory(value: string): value is ListingCategory { + return listingCategories.includes(value as ListingCategory); +} + +export function isListingStatus(value: string): value is ListingStatus { + return listingStatuses.includes(value as ListingStatus); +} diff --git a/src/validate.ts b/src/validate.ts new file mode 100644 index 0000000..f7de769 --- /dev/null +++ b/src/validate.ts @@ -0,0 +1,128 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + isListingCategory, + isListingStatus, + type X402Listing +} from "./schema.js"; + +type ValidationError = { + id: string; + field: string; + message: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isIsoDate(value: unknown) { + return typeof value === "string" && !Number.isNaN(Date.parse(value)); +} + +function isHttpUrl(value: unknown) { + if (typeof value !== "string") { + return false; + } + + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:"; + } catch { + return false; + } +} + +function validateListing(value: unknown): ValidationError[] { + const errors: ValidationError[] = []; + + if (!isRecord(value)) { + return [{ id: "unknown", field: "listing", message: "Listing must be an object." }]; + } + + const listing = value as Partial; + const id = typeof listing.id === "string" ? listing.id : "unknown"; + + if (typeof listing.id !== "string" || !/^x402_[a-z0-9_]+$/.test(listing.id)) { + errors.push({ id, field: "id", message: "ID must match x402_[a-z0-9_]+." }); + } + + if (typeof listing.name !== "string" || listing.name.trim().length < 2) { + errors.push({ id, field: "name", message: "Name must be at least 2 characters." }); + } + + if (!isHttpUrl(listing.url)) { + errors.push({ id, field: "url", message: "URL must be a valid HTTP(S) URL." }); + } + + if ( + typeof listing.description !== "string" || + listing.description.trim().length < 20 + ) { + errors.push({ + id, + field: "description", + message: "Description must be at least 20 characters." + }); + } + + if (typeof listing.category !== "string" || !isListingCategory(listing.category)) { + errors.push({ id, field: "category", message: "Category is not supported." }); + } + + if (typeof listing.status !== "string" || !isListingStatus(listing.status)) { + errors.push({ id, field: "status", message: "Status must be verified or unverified." }); + } + + if (!isRecord(listing.pricing)) { + errors.push({ id, field: "pricing", message: "Pricing must be an object." }); + } else { + const amount = listing.pricing.amount; + if (typeof amount !== "string" || !/^[0-9]+(\.[0-9]+)?$/.test(amount)) { + errors.push({ id, field: "pricing.amount", message: "Amount must be numeric text." }); + } + + for (const field of ["currency", "unit", "network"] as const) { + if (typeof listing.pricing[field] !== "string" || !listing.pricing[field]) { + errors.push({ + id, + field: `pricing.${field}`, + message: `${field} is required.` + }); + } + } + } + + for (const field of ["createdAt", "updatedAt"] as const) { + if (!isIsoDate(listing[field])) { + errors.push({ id, field, message: `${field} must be an ISO date.` }); + } + } + + if (listing.lastCheckedAt !== undefined && !isIsoDate(listing.lastCheckedAt)) { + errors.push({ id, field: "lastCheckedAt", message: "lastCheckedAt must be an ISO date." }); + } + + return errors; +} + +const listingPath = join(process.cwd(), "data", "listings.json"); +const listingData = JSON.parse(readFileSync(listingPath, "utf8")) as unknown[]; +const errors = listingData.flatMap(validateListing); +const ids = new Set(); + +for (const listing of listingData) { + if (isRecord(listing) && typeof listing.id === "string") { + if (ids.has(listing.id)) { + errors.push({ id: listing.id, field: "id", message: "ID must be unique." }); + } + ids.add(listing.id); + } +} + +if (errors.length > 0) { + console.error(JSON.stringify(errors, null, 2)); + process.exit(1); +} + +console.log(`Validated ${listingData.length} x402 listings.`); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b6bf693 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": ".", + "declaration": true + }, + "include": ["src/**/*.ts", "data/**/*.json"], + "exclude": ["node_modules", "dist"] +}