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
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,66 @@ jobs:
- name: Run tests with coverage
run: cd packages/core && bun test --coverage

facilitator_e2e:
name: Facilitator Server E2E
runs-on: ubuntu-latest
timeout-minutes: 15
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: facilitator
POSTGRES_USER: facilitator
POSTGRES_PASSWORD: facilitator
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U facilitator -d facilitator"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"

- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Build core package
run: cd packages/core && bun run build

- name: Build facilitator-server
run: cd examples/facilitator-server && bun run build

- name: Run facilitator-server e2e
env:
DATABASE_URL: postgresql://facilitator:facilitator@localhost:5432/facilitator
PORT: "18090"
EVM_PRIVATE_KEY: "0x0000000000000000000000000000000000000000000000000000000000000001"
EVM_NETWORKS: base-sepolia
BEARER_TOKEN: e2e-test-token
TRACKING_ALLOW_IN_MEMORY_FALLBACK: "false"
OTEL_SDK_DISABLED: "true"
run: cd examples/facilitator-server && bun run test:e2e

docker_build:
name: Docker Build Test
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions examples/facilitator-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "bun test",
"test:e2e": "bun run tests/e2e-ci.ts",
"db:generate": "bunx drizzle-kit generate",
"db:migrate": "bun run src/migrate.ts",
"db:push": "bunx drizzle-kit push",
Expand Down
33 changes: 24 additions & 9 deletions examples/facilitator-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* Environment variables:
* - PORT: Server port (default: 8090)
* - DATABASE_URL: PostgreSQL connection string (optional, enables Drizzle tracking)
* - TRACKING_ALLOW_IN_MEMORY_FALLBACK: set to "true" to continue startup when DB init fails
* - 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
Expand All @@ -16,17 +17,17 @@
*/

import pg from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
import { defaultSigners } from "./setup.js";
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 TRACKING_ALLOW_IN_MEMORY_FALLBACK =
process.env.TRACKING_ALLOW_IN_MEMORY_FALLBACK === "true";
const BEARER_TOKEN = process.env.BEARER_TOKEN?.trim();
const BEARER_TOKENS = process.env.BEARER_TOKENS?.split(",")
.map((token) => token.trim())
Expand All @@ -47,27 +48,37 @@ if (TOKENS.length === 0) {
let pool = DATABASE_URL
? new pg.Pool({ connectionString: DATABASE_URL })
: undefined;
let db = pool
? drizzle(pool, { schema: trackingSchema })
: undefined;
let pgClient = pool ? createDrizzleAdapter(pool) : undefined;

// Run migrations if database is configured
if (pool) {
try {
await runMigrations(pool);
} catch (err) {
console.error(`❌ Database migration failed - falling back to in-memory tracking`);
console.error("❌ Database migration failed");
console.error(err instanceof Error ? err.message : err);

if (!TRACKING_ALLOW_IN_MEMORY_FALLBACK) {
console.error(
"Set TRACKING_ALLOW_IN_MEMORY_FALLBACK=true to continue with in-memory tracking."
);
await pool.end().catch(() => {});
process.exit(1);
}

console.error("⚠️ Continuing with in-memory tracking.");
await pool.end().catch(() => {});
pool = undefined;
db = undefined;
pgClient = undefined;
}
}

// Resource tracking (falls back to in-memory if no DATABASE_URL)
const tracking = createTracking(pgClient);
const tracking = createTracking(pgClient, {
onTrackingError: (err, id) => {
console.error(`[tracking:${id}]`, err);
},
});

// Facilitator + App
const facilitator = createFacilitator({ ...defaultSigners });
Expand All @@ -85,8 +96,12 @@ const app = createApp({

app.listen(PORT);
console.log(`x402 Facilitator listening on http://localhost:${PORT}`);
if (DATABASE_URL) {
if (pgClient) {
console.log(`Resource tracking: PostgreSQL (Drizzle)`);
} else if (DATABASE_URL) {
console.log(
`Resource tracking: In-memory (DB init failed; set TRACKING_ALLOW_IN_MEMORY_FALLBACK=true to allow this explicitly)`
);
} else {
console.log(`Resource tracking: In-memory (set DATABASE_URL for persistence)`);
}
Comment on lines +99 to 107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misleading log message when in-memory fallback is active.

When execution reaches Line 73, TRACKING_ALLOW_IN_MEMORY_FALLBACK is necessarily true (otherwise the process would have exited at Line 48). The message telling the user to "set TRACKING_ALLOW_IN_MEMORY_FALLBACK=true to allow this explicitly" is confusing because they already did.

Proposed fix
 } else if (DATABASE_URL) {
   console.log(
-    `Resource tracking: In-memory (DB init failed; set TRACKING_ALLOW_IN_MEMORY_FALLBACK=true to allow this explicitly)`
+    `Resource tracking: In-memory (DB init failed; running with in-memory fallback)`
   );
 } else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (pgClient) {
console.log(`Resource tracking: PostgreSQL (Drizzle)`);
} else if (DATABASE_URL) {
console.log(
`Resource tracking: In-memory (DB init failed; set TRACKING_ALLOW_IN_MEMORY_FALLBACK=true to allow this explicitly)`
);
} else {
console.log(`Resource tracking: In-memory (set DATABASE_URL for persistence)`);
}
if (pgClient) {
console.log(`Resource tracking: PostgreSQL (Drizzle)`);
} else if (DATABASE_URL) {
console.log(
`Resource tracking: In-memory (DB init failed; running with in-memory fallback)`
);
} else {
console.log(`Resource tracking: In-memory (set DATABASE_URL for persistence)`);
}
🤖 Prompt for AI Agents
In `@examples/facilitator-server/src/index.ts` around lines 71 - 79, The current
log under the condition where pgClient is falsy but DATABASE_URL is set emits a
confusing message about setting TRACKING_ALLOW_IN_MEMORY_FALLBACK=true; update
that console.log to clearly state that in-memory tracking is active because
TRACKING_ALLOW_IN_MEMORY_FALLBACK is enabled (i.e., the explicit fallback was
allowed). Locate the block checking pgClient and DATABASE_URL and change the
message for the case where DATABASE_URL is truthy and pgClient is falsy to
something like "Resource tracking: In-memory (DB init failed;
TRACKING_ALLOW_IN_MEMORY_FALLBACK=true enabled fallback)" so it accurately
reflects that TRACKING_ALLOW_IN_MEMORY_FALLBACK is already true.

Expand Down
147 changes: 147 additions & 0 deletions examples/facilitator-server/tests/e2e-ci.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { strict as assert } from "node:assert";
import { Pool } from "pg";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveE2ePrivateKey } from "./e2e-env.js";

const __filename = fileURLToPath(import.meta.url);
const testsDir = resolve(__filename, "..");
const serverDir = resolve(testsDir, "..");

const DATABASE_URL = process.env.DATABASE_URL;
const PORT = Number(process.env.PORT ?? "18090");
const BEARER_TOKEN = process.env.BEARER_TOKEN ?? "e2e-test-token";

if (!DATABASE_URL) {
throw new Error("DATABASE_URL is required for e2e test");
}

const sleep = (ms: number): Promise<void> =>
new Promise((resolveSleep) => setTimeout(resolveSleep, ms));

async function waitForServer(url: string, timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs;

while (Date.now() < deadline) {
try {
const response = await fetch(`${url}/supported`);
if (response.ok) return;
} catch {
// Server not ready yet.
}
await sleep(500);
}

throw new Error("Timed out waiting for facilitator server to start");
}

async function waitForDatabase(
pool: Pool,
timeoutMs = 20_000
): Promise<void> {
const deadline = Date.now() + timeoutMs;

while (Date.now() < deadline) {
try {
await pool.query("SELECT 1");
return;
} catch {
// Database not ready yet.
}
await sleep(500);
}

throw new Error("Timed out waiting for Postgres to become ready");
}

async function waitForRecord(pool: Pool, timeoutMs = 15_000): Promise<void> {
const deadline = Date.now() + timeoutMs;

while (Date.now() < deadline) {
const result = await pool.query<{ count: string }>(
`SELECT COUNT(*) AS count
FROM resource_call_records
WHERE path = '/verify' AND response_status = 400`
);

if (Number(result.rows[0]?.count ?? "0") > 0) {
return;
}

await sleep(300);
}

throw new Error("No /verify tracking row found in Postgres");
}

async function run(): Promise<void> {
const baseUrl = `http://127.0.0.1:${PORT}`;
const pool = new Pool({ connectionString: DATABASE_URL });
let failed = false;
const privateKey = resolveE2ePrivateKey(process.env.EVM_PRIVATE_KEY);
if (process.env.EVM_PRIVATE_KEY && privateKey !== process.env.EVM_PRIVATE_KEY) {
console.warn(
"EVM_PRIVATE_KEY for e2e was malformed; using normalized fallback key."
);
}

const server = Bun.spawn({
cmd: ["node", "dist/index.js"],
cwd: serverDir,
env: {
...process.env,
PORT: String(PORT),
DATABASE_URL,
TRACKING_ALLOW_IN_MEMORY_FALLBACK: "false",
OTEL_SDK_DISABLED: "true",
BEARER_TOKEN,
EVM_PRIVATE_KEY: privateKey,
EVM_NETWORKS: process.env.EVM_NETWORKS ?? "base-sepolia",
},
stdout: "pipe",
stderr: "pipe",
});
const stdoutTextPromise = new Response(server.stdout).text();
const stderrTextPromise = new Response(server.stderr).text();

try {
await waitForDatabase(pool);
await waitForServer(baseUrl);
await pool.query("TRUNCATE TABLE resource_call_records");

const verifyResponse = await fetch(`${baseUrl}/verify`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${BEARER_TOKEN}`,
},
body: JSON.stringify({}),
});

assert.equal(verifyResponse.status, 400, "Expected /verify to return 400");
await waitForRecord(pool);
} catch (error) {
failed = true;
throw error;
} finally {
server.kill();
await server.exited;
await pool.end();

const stdoutText = await stdoutTextPromise.catch(() => "");
const stderrText = await stderrTextPromise.catch(() => "");
if (failed && stdoutText.trim()) {
console.log("=== facilitator stdout ===");
console.log(stdoutText);
}
if (failed && stderrText.trim()) {
console.log("=== facilitator stderr ===");
console.log(stderrText);
}
}
}

run().catch(async (error) => {
console.error(error);
process.exit(1);
});
23 changes: 23 additions & 0 deletions examples/facilitator-server/tests/e2e-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import { resolveE2ePrivateKey } from "./e2e-env.js";

describe("resolveE2ePrivateKey", () => {
test("keeps a valid 0x-prefixed private key", () => {
const key =
"0x0000000000000000000000000000000000000000000000000000000000000001";
expect(resolveE2ePrivateKey(key)).toBe(key);
});

test("prefixes a valid 64-char hex key", () => {
const raw =
"0000000000000000000000000000000000000000000000000000000000000001";
expect(resolveE2ePrivateKey(raw)).toBe(`0x${raw}`);
});

test("falls back for malformed values", () => {
const fallback =
"0x0000000000000000000000000000000000000000000000000000000000000001";
expect(resolveE2ePrivateKey("1")).toBe(fallback);
expect(resolveE2ePrivateKey(undefined)).toBe(fallback);
});
});
18 changes: 18 additions & 0 deletions examples/facilitator-server/tests/e2e-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const DEFAULT_E2E_PRIVATE_KEY =
"0x0000000000000000000000000000000000000000000000000000000000000001";

const HEX_64 = /^[0-9a-fA-F]{64}$/;
const HEX_0X_64 = /^0x[0-9a-fA-F]{64}$/;

/**
* Returns a valid hex private key for e2e tests.
* Falls back to a known public test key when input is missing or malformed.
*/
export function resolveE2ePrivateKey(value?: string): string {
if (!value) return DEFAULT_E2E_PRIVATE_KEY;
if (HEX_0X_64.test(value)) return value;
if (HEX_64.test(value)) return `0x${value}`;
return DEFAULT_E2E_PRIVATE_KEY;
}

export { DEFAULT_E2E_PRIVATE_KEY };