Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c579b7f
feat(web): add payment proof freshness badge for paid results
Osifowora Jun 29, 2026
e1f8cb3
fix(web): rebase freshness badge onto upstream-synced types shape
Osifowora Jun 29, 2026
33c5be0
Merge branch 'main' into payment-proof-freshness-badge
Osifowora Jun 29, 2026
2b0c4a4
fix(web): restore ProofKind export and capturedAt on PaymentEvidenceS…
Osifowora Jun 30, 2026
6ee8203
style: reformat upstream-synced files to match Prettier config
Osifowora Jun 30, 2026
fabb3bc
test(web): migrate wallet machine test to vitest so web test suite in…
Osifowora Jun 30, 2026
4ebe90e
fix(ci): resync package-lock.json so npm ci passes
Osifowora Jul 1, 2026
0bbf959
fix(ci): switch npm ci to npm install to heal npm 11.x resolution drift
Osifowora Jul 1, 2026
8343d45
fix(ci): pin npm version and use strict npm ci with --legacy-peer-deps
Osifowora Jul 1, 2026
de7ca8e
fix(ci): pin npm@11.9.0 via packageManager field (corepack) and smoke…
Osifowora Jul 1, 2026
207aec9
fix(ci): pin empirical npm-drift transitive deps via overrides + npm …
Osifowora Jul 1, 2026
c5526e7
fix(ci): pin npm version via packageManager + corepack, use npm ci, a…
Osifowora Jul 1, 2026
27ef6a9
fix(ci): regenerate package-lock.json with clean resolved entries
Osifowora Jul 1, 2026
24b8b12
Merge branch 'main' into payment-proof-freshness-badge
Osifowora Jul 1, 2026
5bef856
fix(ci): install npm@11.9.0 globally instead of corepack enable
Osifowora Jul 1, 2026
2e334c5
fix(ci): regenerate package-lock.json after merge with main
Osifowora Jul 1, 2026
2c23c58
style: apply prettier formatting to all files
Osifowora Jul 1, 2026
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
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@ jobs:
node-version-file: .nvmrc
cache: npm

- name: Enforce npm version
run: npm install -g npm@11.9.0

- name: Install dependencies
run: npm ci
run: npm ci --no-audit --no-fund

- name: Typecheck
run: npm run typecheck
Expand Down Expand Up @@ -132,4 +135,4 @@ jobs:
- name: Stop API
if: always()
run: |
[[ -n "${API_PID:-}" ]] && kill "$API_PID" 2>/dev/null || true
[[ -n "${API_PID:-}" ]] && kill "$API_PID" 2>/dev/null || true
2 changes: 1 addition & 1 deletion apps/agent-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@
"typescript": "^5.8.3",
"vitest": "^3.2.4"
}
}
}
2 changes: 1 addition & 1 deletion apps/agent-client/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe("formatSummary", () => {
asset: "USDC",
traceId: "trace-abc-123",
evidenceId: "ev-xyz-789",
latencyMs: 342,
latencyMs: 342
};

it("includes mode and provider", () => {
Expand Down
26 changes: 13 additions & 13 deletions apps/agent-client/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,20 @@ export interface SummaryInput {
/** Formats the post-query summary table. Pure function — safe to unit-test directly. */
export function formatSummary(input: SummaryInput): string {
const rows: [string, string][] = [
["Mode", input.mode],
["Provider", input.provider],
["Status", String(input.status)],
["Client", input.isDemoMode ? "demo" : "real"],
["Mode", input.mode],
["Provider", input.provider],
["Status", String(input.status)],
["Client", input.isDemoMode ? "demo" : "real"],
["Price (USD)", input.priceUsd != null ? String(input.priceUsd) : "n/a"],
["Asset", input.asset ?? "n/a"],
["Trace ID", input.traceId ?? "unavailable"],
["Evidence ID", input.evidenceId ?? "unavailable"],
["Asset", input.asset ?? "n/a"],
["Trace ID", input.traceId ?? "unavailable"],
["Evidence ID", input.evidenceId ?? "unavailable"]
];
if (input.latencyMs != null) {
rows.push(["Latency", `${input.latencyMs}ms`]);
}
const labelWidth = Math.max(...rows.map(([label]) => label.length));
const body = rows
.map(([label, value]) => ` ${label.padEnd(labelWidth)} ${value}`)
.join("\n");
const body = rows.map(([label, value]) => ` ${label.padEnd(labelWidth)} ${value}`).join("\n");
const divider = "=".repeat(labelWidth + 4 + 20);
return `\n=== Query402 Paid Query Summary ===\n${body}\n${divider}`;
}
Expand Down Expand Up @@ -88,8 +86,10 @@ async function main() {
const latencyMs = Date.now() - start;

const payload = result.body as Record<string, unknown>;
const resultBlock = (payload?.result ?? (payload?.body as Record<string, unknown>)?.result) as Record<string, unknown> | undefined;
const evidenceBlock = (payload?.payment as Record<string, unknown>)?.evidence as Record<string, unknown> | undefined;
const resultBlock = (payload?.result ?? (payload?.body as Record<string, unknown>)?.result) as
Record<string, unknown> | undefined;
const evidenceBlock = (payload?.payment as Record<string, unknown>)?.evidence as
Record<string, unknown> | undefined;

console.log(
formatSummary({
Expand All @@ -101,7 +101,7 @@ async function main() {
asset: (evidenceBlock?.proofLinks as Record<string, string> | undefined)?.asset,
traceId: resultBlock?.traceId as string | undefined,
evidenceId: (evidenceBlock?.id ?? evidenceBlock?.evidenceId) as string | undefined,
latencyMs,
latencyMs
})
);
}
Expand Down
98 changes: 50 additions & 48 deletions apps/agent-client/src/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,7 @@ const OUT_DIR = path.resolve(__dirname, "../../../transcript");
// Guard: refuse to run outside DEMO_MODE
// ---------------------------------------------------------------------------
if (config.DEMO_MODE !== "true") {
console.error(
"ERROR: Set DEMO_MODE=true to generate a transcript without live credentials."
);
console.error("ERROR: Set DEMO_MODE=true to generate a transcript without live credentials.");
process.exit(1);
}

Expand All @@ -49,10 +47,10 @@ const API_BASE = config.API_BASE_URL.replace(/\/$/, "");
// Secret redaction
// ---------------------------------------------------------------------------
const SECRET_PATTERNS: RegExp[] = [
/S[A-Z0-9]{55}/g, // Stellar secret key (starts with S, 56 chars)
/Bearer\s+\S+/gi, // Bearer tokens
/x-payment:\s*\S+/gi, // raw payment header value
/X402-Payment:\s*\S+/gi,
/S[A-Z0-9]{55}/g, // Stellar secret key (starts with S, 56 chars)
/Bearer\s+\S+/gi, // Bearer tokens
/x-payment:\s*\S+/gi, // raw payment header value
/X402-Payment:\s*\S+/gi
];
const REDACTED = "[REDACTED]";

Expand All @@ -61,7 +59,7 @@ const SENSITIVE_HEADER_KEYS = new Set([
"x402-payment",
"authorization",
"x-api-key",
"payment-response", // raw tx ID; replaced with presence flag below
"payment-response" // raw tx ID; replaced with presence flag below
]);

const SENSITIVE_OBJ_KEYS = new Set([
Expand All @@ -71,7 +69,7 @@ const SENSITIVE_OBJ_KEYS = new Set([
"private_key",
"privatekey",
"api_key",
"apikey",
"apikey"
]);

function redact(value: unknown): unknown {
Expand Down Expand Up @@ -117,15 +115,21 @@ interface RawResult {

function httpGet(url: string): Promise<RawResult> {
return new Promise((resolve, reject) => {
http.get(url, (res) => {
let raw = "";
res.on("data", (chunk: Buffer) => (raw += chunk.toString()));
res.on("end", () => {
let body: unknown;
try { body = JSON.parse(raw); } catch { body = raw; }
resolve({ status: res.statusCode ?? 0, headers: res.headers, body });
});
}).on("error", reject);
http
.get(url, (res) => {
let raw = "";
res.on("data", (chunk: Buffer) => (raw += chunk.toString()));
res.on("end", () => {
let body: unknown;
try {
body = JSON.parse(raw);
} catch {
body = raw;
}
resolve({ status: res.statusCode ?? 0, headers: res.headers, body });
});
})
.on("error", reject);
});
}

Expand Down Expand Up @@ -167,17 +171,15 @@ async function step1Health(): Promise<Step> {
responseHeaders: safeHeaders(r.headers),
body: redact(r.body),
note:
r.status === 200
? "API is healthy and ready."
: "API responded but may not be fully ready.",
r.status === 200 ? "API is healthy and ready." : "API responded but may not be fully ready."
};
} catch (err) {
return {
step: "1_health_check",
timestamp,
status: "n/a",
error: `Could not reach ${API_BASE}/health — is the API running? (${err})`,
note: "Transcript is still written for CI evidence purposes.",
note: "Transcript is still written for CI evidence purposes."
};
}
}
Expand All @@ -192,14 +194,14 @@ async function step2Catalog(): Promise<Step> {
status: r.status,
responseHeaders: safeHeaders(r.headers),
body: redact(r.body),
note: "Available search/news/scrape providers with per-request pricing.",
note: "Available search/news/scrape providers with per-request pricing."
};
} catch (err) {
return {
step: "2_provider_catalog",
timestamp,
status: "n/a",
error: String(err),
error: String(err)
};
}
}
Expand All @@ -210,9 +212,9 @@ async function step2Catalog(): Promise<Step> {
*/
async function step3PaidQueries(): Promise<Step[]> {
const queries: Array<Parameters<typeof runPaidQuery>[0]> = [
{ mode: "search", provider: "search.pro", query: "latest stellar x402 updates" },
{ mode: "news", provider: "news.deep", query: "stablecoin micropayments" },
{ mode: "scrape", provider: "scrape.extract", url: "https://developers.stellar.org" },
{ mode: "search", provider: "search.pro", query: "latest stellar x402 updates" },
{ mode: "news", provider: "news.deep", query: "stablecoin micropayments" },
{ mode: "scrape", provider: "scrape.extract", url: "https://developers.stellar.org" }
];

const steps: Step[] = [];
Expand All @@ -225,24 +227,24 @@ async function step3PaidQueries(): Promise<Step[]> {
try {
const response = await runPaidQuery(q);
const payload = response.body as Record<string, unknown> | undefined;
const result = payload?.result as Record<string, unknown> | undefined;
const result = payload?.result as Record<string, unknown> | undefined;

steps.push({
step: label,
timestamp,
status: response.status,
body: redact({
provider: q.provider,
endpoint: response.endpoint,
provider: q.provider,
endpoint: response.endpoint,
// Never write the raw payment-response header value; record presence only
payment_response_present: Boolean(response.paymentResponse),
price_usd: result?.priceUsd ?? "n/a",
items_returned: Array.isArray(result?.items) ? result.items.length : 0,
result_body: payload,
price_usd: result?.priceUsd ?? "n/a",
items_returned: Array.isArray(result?.items) ? result.items.length : 0,
result_body: payload
}),
note:
"DEMO_MODE=true — payment header contains a placeholder tx ID; " +
"no real Stellar transaction is submitted or settled.",
"no real Stellar transaction is submitted or settled."
});
} catch (err) {
steps.push({ step: label, timestamp, status: "n/a", error: String(err) });
Expand All @@ -254,21 +256,21 @@ async function step3PaidQueries(): Promise<Step[]> {

function step4Metadata(paidSteps: Step[]): Step {
const first = paidSteps.find((s) => s.status !== "n/a");
const body = first?.body as Record<string, unknown> | undefined;
const body = first?.body as Record<string, unknown> | undefined;
return {
step: "4_response_metadata",
timestamp: new Date().toISOString(),
status: "n/a",
body: {
provider: body?.provider ?? "search.pro",
price_usd: body?.price_usd ?? "n/a",
provider: body?.provider ?? "search.pro",
price_usd: body?.price_usd ?? "n/a",
settlement_network: "stellar:testnet",
payment_status: "DEMO — no real Stellar settlement",
payment_status: "DEMO — no real Stellar settlement",
note:
"In production this section contains the facilitator-signed " +
"payment-response header. Here it is intentionally omitted.",
"payment-response header. Here it is intentionally omitted."
},
note: "Synthesised from paid-query responses. No secrets included.",
note: "Synthesised from paid-query responses. No secrets included."
};
}

Expand All @@ -282,14 +284,14 @@ async function step5Analytics(): Promise<Step> {
status: r.status,
responseHeaders: safeHeaders(r.headers),
body: redact(r.body),
note: "Total spend + per-category breakdown stored in SQLite.",
note: "Total spend + per-category breakdown stored in SQLite."
};
} catch (err) {
return {
step: "5_analytics_summary",
timestamp,
status: "n/a",
error: String(err),
error: String(err)
};
}
}
Expand All @@ -308,7 +310,7 @@ function assemble(steps: Step[]): Transcript {
"Generated in DEMO_MODE. No real Stellar credentials or live payments " +
"were used. All secret fields are redacted. " +
"Safe to attach to Drips/SCF updates and investor notes.",
steps,
steps
};
}

Expand All @@ -323,13 +325,13 @@ function toText(t: Transcript): string {
` Network : ${t.settlement_network}`,
` ⚠ ${t.warning}`,
bar,
"",
""
];
for (const s of t.steps) {
lines.push(`▶ ${s.step}`);
lines.push(` Timestamp : ${s.timestamp}`);
lines.push(` Status : ${s.status}`);
if (s.note) lines.push(` Note : ${s.note}`);
if (s.note) lines.push(` Note : ${s.note}`);
if (s.error) lines.push(` ERROR : ${s.error}`);
if (s.body) {
lines.push(" Body:");
Expand All @@ -348,10 +350,10 @@ function writeArtifact(t: Transcript): { json: string; txt: string } {

const slug = t.generated_at.replace(/[:.]/g, "-").replace("T", "_");
const jsonPath = path.join(OUT_DIR, `demo-transcript-${slug}.json`);
const txtPath = path.join(OUT_DIR, `demo-transcript-${slug}.txt`);
const txtPath = path.join(OUT_DIR, `demo-transcript-${slug}.txt`);

fs.writeFileSync(jsonPath, JSON.stringify(t, null, 2), "utf8");
fs.writeFileSync(txtPath, toText(t), "utf8");
fs.writeFileSync(txtPath, toText(t), "utf8");
return { json: jsonPath, txt: txtPath };
}

Expand Down Expand Up @@ -397,4 +399,4 @@ async function main(): Promise<void> {
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});
});
6 changes: 3 additions & 3 deletions apps/agent-client/src/validate-real.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ describe("safeFacilitatorUrl", () => {

it("strips auth credentials from URL", async () => {
const { safeFacilitatorUrl } = await import("./validate-real.js");
expect(
safeFacilitatorUrl("https://user:password@channels.openzeppelin.com/x402/testnet")
).toBe("channels.openzeppelin.com");
expect(safeFacilitatorUrl("https://user:password@channels.openzeppelin.com/x402/testnet")).toBe(
"channels.openzeppelin.com"
);
});

it("preserves port in host", async () => {
Expand Down
18 changes: 12 additions & 6 deletions apps/agent-client/src/validate-real.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,25 @@ async function main() {
console.log("\n--- Facilitator Health ---");
if (!config.X402_FACILITATOR_URL) {
console.log(`Host: not configured`);
console.log(`Warning: X402_FACILITATOR_URL is not set. Set it in your .env file for real payment validation.`);
console.log(
`Warning: X402_FACILITATOR_URL is not set. Set it in your .env file for real payment validation.`
);
console.log(`Probe attempted: no`);
console.log(`Network: ${network}`);
console.log(`Timestamp: ${probeTimestamp}`);
throw new Error("X402_FACILITATOR_URL is not configured. Real payment validation requires a facilitator URL.");
throw new Error(
"X402_FACILITATOR_URL is not configured. Real payment validation requires a facilitator URL."
);
} else {
console.log(`Host: ${facilitatorHost ?? "unparseable"}`);
console.log(`Network: ${network}`);
console.log("[probe] Checking facilitator /supported...");
const healthResult = await checkFacilitator(config.X402_FACILITATOR_URL);
const reasonText = healthResult.ok ? undefined : typeof healthResult.body === "string" ? healthResult.body : JSON.stringify(healthResult.body);
const reasonText = healthResult.ok
? undefined
: typeof healthResult.body === "string"
? healthResult.body
: JSON.stringify(healthResult.body);
console.log(`Probe attempted: yes`);
console.log(`Status: ${healthResult.status === 0 ? "N/A" : healthResult.status}`);
if (!healthResult.ok && reasonText) {
Expand All @@ -105,9 +113,7 @@ async function main() {
console.log(`Timestamp: ${probeTimestamp}`);

if (!healthResult.ok) {
throw new Error(
`Facilitator check failed (${healthResult.status}): ${reasonText}`
);
throw new Error(`Facilitator check failed (${healthResult.status}): ${reasonText}`);
}
console.log("[ok] Facilitator reachable and returned /supported response.");
}
Expand Down
Loading
Loading