Skip to content
Closed
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
15 changes: 12 additions & 3 deletions examples/facilitator-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,16 @@ interface Facilitator {
};
}

export type AppModule = unknown;

export interface AppConfig {
facilitator: Facilitator;
tracking?: ResourceTrackingModule;
modules?: AppModule[];
}

export function createApp(config: AppConfig) {
const { facilitator, tracking } = config;
const { facilitator, tracking, modules = [] } = config;
const safeTrack = async (
fn: (module: ResourceTrackingModule) => Promise<void>,
label = "tracking"
Expand All @@ -131,7 +134,7 @@ export function createApp(config: AppConfig) {
}
};

const app = new Elysia({ adapter: node() })
const baseApp = new Elysia({ adapter: node() })
.use(
logger({
autoLogging: true,
Expand All @@ -143,7 +146,13 @@ export function createApp(config: AppConfig) {
serviceName: process.env.OTEL_SERVICE_NAME ?? "x402-facilitator",
spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())],
})
)
);

for (const module of modules) {
baseApp.use(module as any);
}

const app = baseApp
.get("/", () => file("./public/index.html"))
.use(staticPlugin())
.post("/verify", async ({ body, request, status }) => {
Expand Down
30 changes: 29 additions & 1 deletion examples/facilitator-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
* - CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET: For CDP signer
* - EVM_PRIVATE_KEY, SVM_PRIVATE_KEY: For private key signer (fallback)
* - EVM_RPC_URL_BASE, EVM_RPC_URL_BASE_SEPOLIA: RPC URLs
* - BEARER_TOKEN: Required bearer token for /verify and /settle
* - BEARER_TOKENS: Optional comma-separated bearer token list (overrides BEARER_TOKEN)
*/

import pg from "pg";
Expand All @@ -20,10 +22,26 @@ import { createFacilitator } from "@daydreamsai/facilitator";
import { createApp } from "./app.js";
import { createDrizzleAdapter, createTracking } from "./db.js";
import { runMigrations } from "./db-migrate.js";
import { createBearerTokenModule } from "./modules/bearer-token.js";
import * as trackingSchema from "./schema/tracking.js";

const PORT = parseInt(process.env.PORT || "8090", 10);
const DATABASE_URL = process.env.DATABASE_URL;
const BEARER_TOKEN = process.env.BEARER_TOKEN?.trim();
const BEARER_TOKENS = process.env.BEARER_TOKENS?.split(",")
.map((token) => token.trim())
.filter(Boolean);
const TOKENS = BEARER_TOKENS && BEARER_TOKENS.length > 0
? BEARER_TOKENS
: BEARER_TOKEN
? [BEARER_TOKEN]
: [];

if (TOKENS.length === 0) {
throw new Error(
"Set BEARER_TOKEN or BEARER_TOKENS to require bearer auth for facilitator startup."
);
}

// Database setup (optional)
let pool = DATABASE_URL
Expand Down Expand Up @@ -53,7 +71,17 @@ const tracking = createTracking(pgClient);

// Facilitator + App
const facilitator = createFacilitator({ ...defaultSigners });
const app = createApp({ facilitator, tracking });
const app = createApp({
facilitator,
tracking,
modules: [
createBearerTokenModule({
tokens: TOKENS,
protectedPaths: ["/verify", "/settle"],
realm: "facilitator",
}),
],
});

app.listen(PORT);
console.log(`x402 Facilitator listening on http://localhost:${PORT}`);
Expand Down
86 changes: 86 additions & 0 deletions examples/facilitator-server/src/modules/bearer-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Elysia } from "elysia";

export interface BearerTokenModuleConfig {
tokens: string[];
protectedPaths?: string[];
realm?: string;
}

const DEFAULT_PROTECTED_PATHS = ["/verify", "/settle"];
const DEFAULT_REALM = "facilitator";

function normalizePath(path: string): string {
if (!path || path === "/") return "/";
return path.endsWith("/") ? path.slice(0, -1) : path;
}

function parseRequestPath(request: Request, fallbackPath?: string): string {
const rawUrl = request.url || "";
try {
return normalizePath(new URL(rawUrl).pathname);
} catch {
try {
return normalizePath(new URL(rawUrl, "http://localhost").pathname);
} catch {
return normalizePath(fallbackPath ?? "/");
}
}
}

function parseBearerAuthorizationHeader(headerValue: string): string | undefined {
const match = /^Bearer\s+(.+)$/i.exec(headerValue.trim());
if (!match) return undefined;
const token = match[1]?.trim();
return token || undefined;
}

function matchesProtectedPath(path: string, protectedPaths: string[]): boolean {
return protectedPaths.some(
(protectedPath) =>
path === protectedPath || path.startsWith(`${protectedPath}/`)
);
}

function escapeRealm(realm: string): string {
return realm.replace(/"/g, '\\"');
}

export function createBearerTokenModule(
config: BearerTokenModuleConfig
): unknown {
const tokens = config.tokens.map((token) => token.trim()).filter(Boolean);
if (!tokens.length) {
throw new Error("Bearer auth requires at least one token.");
}

const realm = config.realm ?? DEFAULT_REALM;
const protectedPaths = (config.protectedPaths ?? DEFAULT_PROTECTED_PATHS).map(
normalizePath
);
const validTokens = new Set(tokens);
const challengeHeader = `Bearer realm="${escapeRealm(realm)}"`;

return (app: Elysia) =>
app.onBeforeHandle(({ request, path, set }) => {
const requestPath = parseRequestPath(request, path);
if (!matchesProtectedPath(requestPath, protectedPaths)) {
return;
}

const authorizationHeader = request.headers.get("authorization");
const token = authorizationHeader
? parseBearerAuthorizationHeader(authorizationHeader)
: undefined;

if (token && validTokens.has(token)) {
return;
}

set.status = 401;
set.headers["www-authenticate"] = challengeHeader;
return {
error: "Unauthorized",
message: "Valid Bearer token is required",
};
});
}
31 changes: 31 additions & 0 deletions examples/facilitator-server/tests/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "bun:test";
import { createApp } from "../src/app.js";
import { createTracking } from "../src/db.js";
import type { AppConfig } from "../src/app.js";
import { createBearerTokenModule } from "../src/modules/bearer-token.js";

let tracking: ReturnType<typeof createTracking>;
let app: ReturnType<typeof createApp>;
Expand Down Expand Up @@ -119,3 +120,33 @@ describe("/verify tracking with missing body", () => {
expect(record.paymentVerified).toBe(false);
});
});

describe("bearer token module integration", () => {
it("rejects /verify without authorization header when module is configured", async () => {
const guardedApp = createApp({
facilitator: mockFacilitator,
tracking,
modules: [
createBearerTokenModule({
tokens: ["DREAMS"],
}),
],
});

const response = await guardedApp.handle(
new Request("http://localhost/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
paymentPayload,
paymentRequirements,
}),
})
);

expect(response.status).toBe(401);
expect(response.headers.get("www-authenticate")).toBe(
'Bearer realm="facilitator"'
);
});
});
71 changes: 71 additions & 0 deletions examples/facilitator-server/tests/bearer-token-module.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from "bun:test";
import { Elysia } from "elysia";
import { createBearerTokenModule } from "../src/modules/bearer-token.js";

describe("createBearerTokenModule", () => {
it("allows a protected route with a valid bearer token", async () => {
const app = new Elysia()
.use(
createBearerTokenModule({
tokens: ["DREAMS"],
protectedPaths: ["/verify"],
})
)
.post("/verify", () => ({ ok: true }));

const response = await app.handle(
new Request("http://localhost/verify", {
method: "POST",
headers: {
Authorization: "Bearer DREAMS",
},
})
);

expect(response.status).toBe(200);
});

it("does not require auth for unprotected routes", async () => {
const app = new Elysia()
.use(
createBearerTokenModule({
tokens: ["DREAMS"],
protectedPaths: ["/verify"],
})
)
.get("/supported", () => ({ ok: true }));

const response = await app.handle(
new Request("http://localhost/supported", {
method: "GET",
})
);

expect(response.status).toBe(200);
});

it("rejects invalid bearer tokens for protected routes", async () => {
const app = new Elysia()
.use(
createBearerTokenModule({
tokens: ["DREAMS"],
protectedPaths: ["/settle"],
})
)
.post("/settle", () => ({ ok: true }));

const response = await app.handle(
new Request("http://localhost/settle", {
method: "POST",
headers: {
Authorization: "Bearer NOPE",
},
})
);

expect(response.status).toBe(401);
expect(response.headers.get("www-authenticate")).toBe(
'Bearer realm="facilitator"'
);
});
});
Loading