Skip to content

Commit b1ea39e

Browse files
committed
Results layer: verifiable badge + shareable run links (no DB)
- lib/run-http.ts: shared SSRF-guarded chain runner (used by conform + badge). - api/badge.ts: live SVG badge (re-runs server-side, edge-cached) — can't be faked, always current. ?target= any verifier. - /?target=<url> shareable links auto-run on load; results panel shows a 'Share this run' link + 'Embed badge' markdown + live badge preview. - Stateless by design (no stored snapshots) — verifiable + zero infra.
1 parent 7061849 commit b1ea39e

6 files changed

Lines changed: 317 additions & 179 deletions

File tree

api/badge.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* GET /api/badge?target=<url> — a live, verifiable conformance badge (SVG).
3+
* It RE-RUNS the chain vectors against the target server-side, so the badge
4+
* can't be faked (it's computed, not self-asserted) and is always current.
5+
* Cached at the edge so repeated README loads don't re-run every time.
6+
*
7+
* ![AP2 conformance](https://ap2-conformance.vercel.app/api/badge?target=<your-url>)
8+
*/
9+
import { conform, type Report } from "../lib/run-http.js";
10+
11+
interface Req {
12+
query?: Record<string, string | string[] | undefined>;
13+
headers?: Record<string, string | string[] | undefined>;
14+
}
15+
interface Res {
16+
setHeader(k: string, v: string): void;
17+
status(code: number): Res;
18+
send(body: string): void;
19+
}
20+
21+
const one = (v: string | string[] | undefined): string | undefined => (Array.isArray(v) ? v[0] : v);
22+
const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c] as string));
23+
24+
function valueAndColor(r: Report): { value: string; color: string } {
25+
switch (r.status) {
26+
case "CONFORMANT":
27+
return { value: `${r.core.passed}/${r.core.total} ✓`, color: "#2ea043" };
28+
case "NON_CONFORMANT":
29+
return { value: `${r.core.passed}/${r.core.total} ✗`, color: "#d1242f" };
30+
case "BLOCKED":
31+
return { value: "blocked", color: "#8b949e" };
32+
default:
33+
return { value: "no contract", color: "#9a6700" };
34+
}
35+
}
36+
37+
function badgeSvg(label: string, value: string, color: string): string {
38+
const w = (t: string) => Math.round(t.length * 6.6 + 14);
39+
const lw = w(label);
40+
const vw = w(value);
41+
const total = lw + vw;
42+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="20" role="img" aria-label="${esc(label)}: ${esc(value)}">
43+
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
44+
<clipPath id="r"><rect width="${total}" height="20" rx="3" fill="#fff"/></clipPath>
45+
<g clip-path="url(#r)">
46+
<rect width="${lw}" height="20" fill="#33333b"/>
47+
<rect x="${lw}" width="${vw}" height="20" fill="${color}"/>
48+
<rect width="${total}" height="20" fill="url(#s)"/>
49+
</g>
50+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
51+
<text x="${lw / 2}" y="14">${esc(label)}</text>
52+
<text x="${lw + vw / 2}" y="14">${esc(value)}</text>
53+
</g>
54+
</svg>`;
55+
}
56+
57+
export default async function handler(req: Req, res: Res): Promise<void> {
58+
const proto = one(req.headers?.["x-forwarded-proto"]) ?? "https";
59+
const host = one(req.headers?.["host"]) ?? "localhost:3000";
60+
let report: Report;
61+
try {
62+
report = await conform(one(req.query?.["target"]), `${proto}://${host}`, host);
63+
} catch {
64+
report = { status: "BLOCKED" } as Report;
65+
}
66+
const { value, color } = valueAndColor(report);
67+
res.setHeader("Content-Type", "image/svg+xml; charset=utf-8");
68+
res.setHeader("Cache-Control", "public, max-age=0, s-maxage=600, stale-while-revalidate=86400");
69+
res.setHeader("Access-Control-Allow-Origin", "*");
70+
res.status(200).send(badgeSvg("AP2 conformance", value, color));
71+
}

api/conform.ts

Lines changed: 7 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,10 @@
11
/**
2-
* Conformance runner — fires every `chain` vector at a target verifier endpoint
3-
* (which implements CONTRACT.md), SERVER-SIDE, so it works against any URL
4-
* regardless of the target's CORS. Returns a live report the microsite renders.
5-
*
6-
* GET /api/conform?target=https://your-verifier.example/verify
7-
*
8-
* With no target it defaults to this deployment's own /api/verify-chain (the
9-
* reference), so the prefilled "Run" shows a live green result.
10-
*
11-
* Two hardenings vs the naive version:
12-
* - SSRF guard: external targets must be https and must not resolve to a
13-
* private/loopback/link-local address (the deployment's own host is exempt).
14-
* - Contract detection: distinguishes "this URL never spoke CONTRACT.md"
15-
* (not a verifier / errored) from "a verifier that answered wrong".
2+
* GET /api/conform?target=<url> — run the chain vectors against a target
3+
* verifier endpoint (CONTRACT.md), server-side, and return a JSON report.
4+
* Defaults to this deployment's own /api/verify-chain (the reference).
5+
* Core logic + SSRF guard + contract-detection live in ../lib/run-http.ts.
166
*/
17-
import chainVectors from "../vectors/chain.json" with { type: "json" };
18-
import { lookup } from "node:dns/promises";
19-
20-
const CANONICAL_TIME_UNIX = 1780000000;
21-
const PER_CALL_TIMEOUT_MS = 7000;
7+
import { conform } from "../lib/run-http.js";
228

239
interface Req {
2410
query?: Record<string, string | string[] | undefined>;
@@ -30,168 +16,12 @@ interface Res {
3016
json(body: unknown): void;
3117
}
3218

33-
interface ChainVector {
34-
name: string;
35-
chain: string;
36-
kind?: string;
37-
rootKey?: Record<string, unknown>;
38-
x5cTrustedRoots?: string[];
39-
expectedAud: string;
40-
expectedNonce: string;
41-
expect: "valid" | "reject";
42-
hardening?: boolean;
43-
expectedPayloads?: unknown;
44-
}
45-
46-
const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
4719
const one = (v: string | string[] | undefined): string | undefined => (Array.isArray(v) ? v[0] : v);
4820

49-
function deepEqual(a: unknown, b: unknown): boolean {
50-
if (a === b) return true;
51-
if (typeof a !== typeof b || a === null || b === null || typeof a !== "object") return false;
52-
if (Array.isArray(a) !== Array.isArray(b)) return false;
53-
const ka = Object.keys(a as object);
54-
const kb = Object.keys(b as object);
55-
if (ka.length !== kb.length) return false;
56-
return ka.every((k) => deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]));
57-
}
58-
59-
/** True if an IP literal is loopback / private / link-local (incl. cloud metadata). */
60-
function isPrivateIp(addr: string): boolean {
61-
const a = addr.toLowerCase().replace(/^\[|\]$/g, "");
62-
if (a === "::1" || a === "::") return true;
63-
if (a.startsWith("fe80") || a.startsWith("fc") || a.startsWith("fd")) return true; // v6 link-local / ULA
64-
const mapped = a.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
65-
const v4 = mapped ? mapped[1] : /^\d{1,3}(?:\.\d{1,3}){3}$/.test(a) ? a : null;
66-
if (!v4) return false;
67-
const p = v4.split(".").map(Number);
68-
if (p.length !== 4 || p.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true; // malformed → block
69-
const [x, y] = p;
70-
if (x === 0 || x === 10 || x === 127) return true;
71-
if (x === 169 && y === 254) return true; // link-local incl. 169.254.169.254 metadata
72-
if (x === 172 && y >= 16 && y <= 31) return true;
73-
if (x === 192 && y === 168) return true;
74-
if (x === 100 && y >= 64 && y <= 127) return true; // CGNAT
75-
return false;
76-
}
77-
78-
async function classifyTarget(target: string, selfHost: string): Promise<{ ok: boolean; reason?: string }> {
79-
let u: URL;
80-
try {
81-
u = new URL(target);
82-
} catch {
83-
return { ok: false, reason: "invalid URL" };
84-
}
85-
if (u.host === selfHost) return { ok: true }; // our own reference endpoint — always allowed
86-
if (u.protocol !== "https:") return { ok: false, reason: "target must use https" };
87-
const host = u.hostname.toLowerCase();
88-
if (host === "localhost" || host.endsWith(".local") || host.endsWith(".internal")) {
89-
return { ok: false, reason: "private host blocked" };
90-
}
91-
if (isPrivateIp(host)) return { ok: false, reason: "private/loopback IP blocked" };
92-
try {
93-
const addrs = await lookup(host, { all: true });
94-
if (addrs.some((a) => isPrivateIp(a.address))) return { ok: false, reason: "host resolves to a private IP" };
95-
} catch {
96-
return { ok: false, reason: "host does not resolve" };
97-
}
98-
return { ok: true };
99-
}
100-
10121
export default async function handler(req: Req, res: Res): Promise<void> {
10222
res.setHeader("Access-Control-Allow-Origin", "*");
103-
10423
const proto = one(req.headers?.["x-forwarded-proto"]) ?? "https";
10524
const host = one(req.headers?.["host"]) ?? "localhost:3000";
106-
const selfBase = `${proto}://${host}`;
107-
108-
let target = one(req.query?.["target"])?.trim();
109-
if (!target || target === "reference") target = `${selfBase}/api/verify-chain`;
110-
else if (target.startsWith("/")) target = `${selfBase}${target}`;
111-
112-
const guard = await classifyTarget(target, host);
113-
if (!guard.ok) {
114-
return res.status(400).json({
115-
target,
116-
status: "BLOCKED",
117-
error: `target rejected: ${guard.reason}`,
118-
endpointSpeaksContract: false,
119-
results: [],
120-
core: { passed: 0, total: 0 },
121-
hardening: { passed: 0, total: 0 },
122-
conformant: false,
123-
});
124-
}
125-
126-
const vectors = chainVectors as ChainVector[];
127-
const results = await Promise.all(
128-
vectors.map(async (v) => {
129-
const isX5c = v.kind === "x5c";
130-
const reqBody = {
131-
chain: v.chain,
132-
rootKey: isX5c ? null : v.rootKey,
133-
trustedRoots: isX5c ? v.x5cTrustedRoots ?? [] : null,
134-
currentTimeUnix: CANONICAL_TIME_UNIX,
135-
expectedAud: v.expectedAud,
136-
expectedNonce: v.expectedNonce,
137-
};
138-
const profile = v.hardening ? "hardening" : "core";
139-
let spoke = false;
140-
let passed = false;
141-
let accepted: boolean | null = null;
142-
let detail: string | undefined;
143-
try {
144-
const r = await fetch(target as string, {
145-
method: "POST",
146-
headers: { "content-type": "application/json" },
147-
body: JSON.stringify(reqBody),
148-
signal: AbortSignal.timeout(PER_CALL_TIMEOUT_MS),
149-
});
150-
let j: { ok?: unknown; payloads?: unknown; error?: string } | null = null;
151-
try {
152-
j = (await r.json()) as { ok?: unknown; payloads?: unknown; error?: string };
153-
} catch {
154-
j = null;
155-
}
156-
if (j && typeof j.ok === "boolean") {
157-
spoke = true;
158-
accepted = j.ok;
159-
if (v.expect === "valid") {
160-
passed = j.ok === true && deepEqual(j.payloads, v.expectedPayloads);
161-
if (!passed) detail = j.ok ? "accepted, but payloads differ from AP2" : `rejected a valid vector (${j.error ?? "no reason"})`;
162-
} else {
163-
passed = j.ok === false;
164-
if (!passed) detail = "accepted a vector that must be rejected";
165-
}
166-
} else {
167-
detail = r.ok ? "response was not the conformance shape { ok, payloads }" : `HTTP ${r.status} (not the conformance contract)`;
168-
}
169-
} catch (e) {
170-
detail = `request failed: ${errMsg(e)}`;
171-
}
172-
return { category: "chain", name: v.name, profile, spoke, passed, accepted, detail };
173-
}),
174-
);
175-
176-
const spokeCount = results.filter((r) => r.spoke).length;
177-
const endpointSpeaksContract = spokeCount > 0;
178-
const core = results.filter((r) => r.profile === "core");
179-
const hard = results.filter((r) => r.profile === "hardening");
180-
const conformant = endpointSpeaksContract && core.every((r) => r.passed);
181-
const status = !endpointSpeaksContract ? "DID_NOT_IMPLEMENT_CONTRACT" : conformant ? "CONFORMANT" : "NON_CONFORMANT";
182-
183-
res.status(200).json({
184-
target,
185-
status,
186-
endpointSpeaksContract,
187-
spokeCount,
188-
totalVectors: results.length,
189-
note: endpointSpeaksContract
190-
? "HTTP runner covers the chain category only; the full 67-check suite runs in-process via the adapter."
191-
: "This URL never responded with the conformance contract — it isn't an AP2 verifier speaking CONTRACT.md (or it errored / timed out).",
192-
results,
193-
core: { passed: core.filter((r) => r.passed).length, total: core.length },
194-
hardening: { passed: hard.filter((r) => r.passed).length, total: hard.length },
195-
conformant,
196-
});
25+
const report = await conform(one(req.query?.["target"]), `${proto}://${host}`, host);
26+
res.status(report.status === "BLOCKED" ? 400 : 200).json(report);
19727
}

0 commit comments

Comments
 (0)