Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
25d5510
feat: add paid-route error taxonomy for analytics and CLI output (#83)
Micheal-Blessed Jun 29, 2026
70bfafb
feat(analytics): add price outlier flag for unusually expensive paid …
Seunfunmi-319509 Jun 30, 2026
d7a5265
docs: add demo-mode payment evidence documentation and fix build erro…
Emoji-dot Jun 30, 2026
9cef3ca
feat: add CLI summary table for paid-query evidence fields (#110)
baedboibidex-cmyk Jun 30, 2026
fa0241a
feat: add CI check that demo artifacts never include raw payment head…
Micheal-Blessed Jun 30, 2026
816d5c3
test(api): add x402 payment requirement snapshot for protected routes…
Osifowora Jun 30, 2026
b2ca89a
Add API response trace correlation to dashboard evidence view (#98)
Osifowora Jun 30, 2026
cf0e564
feat: add provider catalog sorting and payment header redaction tests…
annacodervibe Jun 30, 2026
0f7f44f
feat(demo): add transcript.ts artifact generator and CI demo-transcri…
Olajcodes Jun 30, 2026
ebb2aba
chore: fix CI pipeline - formatting, dependency resolution, and lint …
Micheal-Blessed Jun 30, 2026
2a2a46a
Merge branch 'main' into feature/paid-route-error-taxonomy-83
Micheal-Blessed Jun 30, 2026
ff1007c
Merge branch 'main' into feature/paid-route-error-taxonomy-83
Micheal-Blessed Jul 1, 2026
79f68ad
chore: regenerate package-lock.json to sync with updated deps (typesc…
Micheal-Blessed Jul 1, 2026
4f0cd90
fix: remove duplicate version 4 migration entry that caused 'duplicat…
Micheal-Blessed Jul 1, 2026
e884c33
ci: trigger re-run after migration fix
Micheal-Blessed Jul 1, 2026
da590bc
new fix
Micheal-Blessed Jul 1, 2026
15fab09
new fixxing
Micheal-Blessed Jul 1, 2026
0b8f624
fix: format code to pass Prettier formatting checks
Micheal-Blessed Jul 1, 2026
da3d738
fix: format code per Prettier config (printWidth: 100, trailingComma:…
Micheal-Blessed Jul 1, 2026
ecfa565
fix: correct Prettier formatting for multi-line constructs and line b…
Micheal-Blessed Jul 1, 2026
819e909
fix: apply Prettier auto-formatting
Micheal-Blessed 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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,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
5 changes: 5 additions & 0 deletions apps/agent-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ export async function runPaidQuery(input: {
}

const json = await response.json();
if (!response.ok || response.status === 402) {
if (json && typeof json === "object" && !json.errorCode) {
json.errorCode = response.status === 402 ? "payment_required" : "internal_error";
}
}

return {
endpoint,
Expand Down
112 changes: 61 additions & 51 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,
/Bearer\s+\S+/gi,
/x-payment:\s*\S+/gi,
/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"
]);

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,39 +212,46 @@ 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[] = [];

for (let i = 0; i < queries.length; i++) {
const q = queries[i];
const timestamp = new Date().toISOString();
const label = `3${String.fromCharCode(97 + i)}_demo_paid_${q.mode}`; // 3a_, 3b_, 3c_
const label = `3${String.fromCharCode(97 + i)}_demo_paid_${q.mode}`;

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,
// Never write the raw payment-response header value; record presence only
provider: q.provider,
endpoint: response.endpoint,
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 +263,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 +291,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 +317,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 +332,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 +357,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 @@ -389,12 +398,13 @@ async function main(): Promise<void> {
console.log(` JSON : ${json}`);
console.log(` TXT : ${txt}`);
console.log("");
console.log(" Label : DEMO_MODE (safe for SCF / Drips / investor notes)");
const note = " Label : DEMO_MODE (safe for SCF / Drips / investor notes)";
console.log(note);
console.log(" Secrets : all redacted");
console.log(" Payment : no real Stellar transaction");
}

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
Loading
Loading