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
5 changes: 5 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,9 @@ export interface Env {
/** The Counterparty API's `/v2` root. Unset reads the public node,
* https://api.counterparty.io:4000/v2. */
COUNTERPARTY_API_BASE?: string;
/** Per-caller guard on the public compatibility gateway. This is not the
* upstream quota: cache coalescing and the shared 429 cooldown protect
* Counterparty itself. It keeps one outside integration from turning our
* first-party fallback into its general-purpose API. */
NODE_PUBLIC_RATE_LIMITER: RateLimit;
}
125 changes: 103 additions & 22 deletions apps/api/src/read/node.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/** Read-only fallback for routine protocol reads that the first-party index cannot answer. Wallet
* composition, broadcasts and Electrs spend checks use the node directly. Never parse response JSON
* (quantities exceed 2^53), cache live state, or introduce automatic upstream retries here. */
* (quantities exceed 2^53) or introduce automatic upstream retries here. Counterparty's own public
* cache policy is preserved at our edge: stripping its max-age made every tab re-hit the upstream,
* which defeated the gateway's original purpose. */
import { Hono } from "hono";
import type { Env } from "#api/env";
import { retryAfterDeadline } from "#api/integrations/cooldown";
Expand All @@ -13,6 +15,8 @@ const HASH = /^[a-fA-F0-9]{64}$/;
const UTXO = /^[a-fA-F0-9]{64}:(0|[1-9][0-9]{0,9})$/;
const ASSET = /^[A-Za-z0-9_.!@-]{1,250}$/;
const EVENT = /^[A-Z][A-Z0-9_]{0,79}$/;
const FIRST_PARTY_ORIGINS = new Set(["https://xcp.fun", "https://www.xcp.fun"]);
const MAX_CACHE_SECONDS = 300;
const names = (value: string) => new Set(value.split(/\s+/).filter(Boolean));

const READ_PARAMS = names(`limit cursor verbose status sort type addresses event_name
Expand Down Expand Up @@ -83,41 +87,107 @@ function validQuery(url: URL): boolean {
return true;
}

const responseHeaders = () => new Headers({
"cache-control": "no-store",
"access-control-allow-origin": "*",
"access-control-allow-headers": "Accept, Content-Type",
"access-control-expose-headers": "Retry-After",
});
const errorResponse = (error: string, status: number, extra?: Record<string, string>) => {
const headers = responseHeaders();
const responseHeaders = (origin: string | null, cacheControl = "no-store") => {
const headers = new Headers({
"cache-control": cacheControl,
// The gateway must execute on every request so CORS and the per-caller budget cannot be skipped.
// Browsers still honor Cache-Control, while our explicit Cache API copy absorbs upstream reads.
"cloudflare-cdn-cache-control": "no-store",
"access-control-allow-headers": "Accept, Content-Type",
"access-control-expose-headers": "Retry-After, X-XCP-Proxy-Cache",
"vary": "Origin",
"x-robots-tag": "noindex, nofollow",
});
if (origin && FIRST_PARTY_ORIGINS.has(origin)) headers.set("access-control-allow-origin", origin);
return headers;
};
const errorResponse = (error: string, status: number, origin: string | null, extra?: Record<string, string>) => {
const headers = responseHeaders(origin);
headers.set("content-type", "application/json");
for (const [key, value] of Object.entries(extra ?? {})) headers.set(key, value);
return new Response(JSON.stringify({ error }), { status, headers });
};

/** Use one canonical key across the custom domain, workers.dev and service bindings. Query parameter
* order is not semantically significant here (duplicates were rejected above), so sorting also stops
* callers from manufacturing cache misses by shuffling an otherwise identical query. */
const cacheKey = (url: URL) => {
const canonical = new URL(url);
canonical.protocol = "https:";
canonical.host = "api.xcp.fun";
canonical.searchParams.sort();
return new Request(canonical.toString(), { method: "GET" });
};

/** Counterparty owns the freshness decision. Admit only an explicit public max-age, clamp an
* unexpectedly large value, and keep mempool/no-store responses live. */
function cacheControl(upstream: Headers): string {
const value = upstream.get("cache-control") ?? "";
if (/\b(?:no-store|private)\b/i.test(value) || !/\bpublic\b/i.test(value)) return "no-store";
const match = /(?:^|,)\s*max-age\s*=\s*(\d+)/i.exec(value);
if (!match) return "no-store";
const seconds = Math.min(Number(match[1]), MAX_CACHE_SECONDS);
return Number.isSafeInteger(seconds) && seconds > 0 ? `public, max-age=${seconds}` : "no-store";
}

function clientResponse(response: Response, origin: string | null, cacheStatus: "HIT" | "MISS", control: string) {
const headers = responseHeaders(origin, control);
headers.set("x-xcp-proxy-cache", cacheStatus);
for (const key of ["content-type", "content-encoding", "retry-after"]) {
const value = response.headers.get(key);
if (value !== null) headers.set(key, value);
}
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}

// A refusal observed by browser traffic must not consume the indexer's permits or prevent a cron
// from progressing. This gate shares only a deadline in this isolate, not bodies,
// promises, a global quota, or user wallet state. Concurrent refusals can only extend it.
let cooldown = 0;
export const nodeRoute = new Hono<{ Bindings: Env }>();
nodeRoute.all("/node/*", async (c) => {
const url = new URL(c.req.url);
const origin = c.req.header("origin") ?? null;
// This is a useful compatibility layer for our own browser, not a public API product. An explicit
// outside browser origin is cheap to reject and removes the easiest form of third-party reuse.
// Non-browser clients do not send Origin, so the per-caller ceiling below remains the real backstop.
if (origin && !FIRST_PARTY_ORIGINS.has(origin)) {
return errorResponse("This first-party compatibility gateway is not available to external browser origins", 403, null);
}
const target = targetFor(url.pathname);
if (!target) return errorResponse("Unsupported node operation", 404);
if (!target) return errorResponse("Unsupported node operation", 404, origin);
if (c.req.method === "OPTIONS") {
const headers = responseHeaders();
const headers = responseHeaders(origin);
headers.set("access-control-allow-methods", "GET, OPTIONS");
return new Response(null, { status: 204, headers });
}
if (c.req.method !== "GET") return errorResponse("Method not allowed", 405, { allow: "GET, OPTIONS" });
if (!validQuery(url)) return errorResponse("Invalid node query", 400);
if (c.req.method !== "GET") return errorResponse("Method not allowed", 405, origin, { allow: "GET, OPTIONS" });
if (!validQuery(url)) return errorResponse("Invalid node query", 400, origin);
if (c.req.raw.body) {
void c.req.raw.body.cancel().catch(() => undefined);
return errorResponse("Request body is not supported", 400);
return errorResponse("Request body is not supported", 400, origin);
}

const actor = c.req.header("cf-connecting-ip") ?? "unattributed";
const admission = await c.env.NODE_PUBLIC_RATE_LIMITER.limit({ key: actor });
if (!admission.success) {
return errorResponse(
"First-party compatibility gateway limit reached; external integrations should use api.counterparty.io",
429,
origin,
{ "retry-after": "60" },
);
}

// A hit is safe even during an upstream cooldown: it performs no upstream request. The cache holds
// only successful responses for exactly the freshness window Counterparty itself advertised.
const key = cacheKey(url);
if (typeof caches !== "undefined") {
const hit = await caches.default.match(key);
if (hit) return clientResponse(hit, origin, "HIT", hit.headers.get("cache-control") ?? "no-store");
}
const remaining = cooldown - Date.now();
if (remaining > 0) return errorResponse("Upstream is rate limited; retry after the indicated delay", 429, {
if (remaining > 0) return errorResponse("Upstream is rate limited; retry after the indicated delay", 429, origin, {
"retry-after": String(Number.isFinite(remaining) ? Math.ceil(remaining / 1_000) : 31_536_000),
});
let upstream: Response;
Expand All @@ -133,7 +203,7 @@ nodeRoute.all("/node/*", async (c) => {
signal,
});
} catch (error) {
return errorResponse("Node temporarily unavailable", error instanceof Error && /^(TimeoutError|AbortError)$/.test(error.name) ? 504 : 502);
return errorResponse("Node temporarily unavailable", error instanceof Error && /^(TimeoutError|AbortError)$/.test(error.name) ? 504 : 502, origin);
}
if (upstream.status === 429) {
const now = Date.now();
Expand All @@ -144,12 +214,23 @@ nodeRoute.all("/node/*", async (c) => {
// including missing/pending transactions, 429 and 503, retain their raw protocol response.
if (upstream.status >= 300 && upstream.status < 400) {
void upstream.body?.cancel().catch(() => undefined);
return errorResponse("Unexpected node redirect", 502);
return errorResponse("Unexpected node redirect", 502, origin);
}
const headers = responseHeaders();
for (const key of ["content-type", "content-encoding", "retry-after"]) {
const value = upstream.headers.get(key);
if (value !== null) headers.set(key, value);

const control = cacheControl(upstream.headers);
const response = clientResponse(upstream, origin, "MISS", control);
if (upstream.ok && control !== "no-store" && typeof caches !== "undefined") {
const stored = response.clone();
// CORS is a property of this caller, not of the protocol answer. Add it after a hit so one origin
// can never populate another origin's access-control header.
stored.headers.delete("access-control-allow-origin");
stored.headers.delete("access-control-allow-headers");
stored.headers.delete("access-control-expose-headers");
stored.headers.delete("cloudflare-cdn-cache-control");
stored.headers.delete("vary");
stored.headers.delete("x-xcp-proxy-cache");
stored.headers.delete("x-robots-tag");
c.executionCtx.waitUntil(caches.default.put(key, stored).catch(() => undefined));
}
return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers });
return response;
});
13 changes: 13 additions & 0 deletions apps/api/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ CHAT_ENABLED = "true"
# Optional COUNTERPARTY_API_BASE is the Counterparty API's /v2 root; unset
# reads https://api.counterparty.io:4000/v2.

# The node compatibility gateway exists for xcp.fun, not as a hosted public
# Counterparty node. This approximate per-colo/per-caller ceiling is generous
# for a page (five reads a second sustained) but makes bulk reuse unattractive.
# Ordinary upstream responses are also cached under Counterparty's own TTL, so
# repeated first-party reads normally do not reach the node at all.
[[ratelimits]]
name = "NODE_PUBLIC_RATE_LIMITER"
namespace_id = "2026102901"

[ratelimits.simple]
limit = 300
period = 60

# Blocks land every ~10 minutes, and Counterparty's /fairminters list has no
# server-side filter beyond status — every tick re-lists the chain's ENTIRE
# fairminter universe (not just xcp.fun's own tracked set), so this job's
Expand Down
87 changes: 78 additions & 9 deletions tests/node-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,47 @@ afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(
async function fixture() {
const fetch = vi.fn(async () => new Response('{"result":[]}', { headers: { "content-type": "application/json" } }));
vi.stubGlobal("fetch", fetch);
const entries = new Map<string, Response>();
const cache = {
match: vi.fn(async (request: Request) => entries.get(request.url)?.clone()),
put: vi.fn(async (request: Request, response: Response) => { entries.set(request.url, response.clone()); }),
} as unknown as Cache;
vi.stubGlobal("caches", { default: cache });
const limiter = { limit: vi.fn(async () => ({ success: true })) } as unknown as RateLimit;
const pending: Promise<unknown>[] = [];
const executionCtx = {
waitUntil(promise: Promise<unknown>) { pending.push(promise); },
passThroughOnException() {},
} as ExecutionContext;
const { nodeRoute } = await import("#api/read/node");
const request = (path: string, init?: RequestInit) => nodeRoute.request(`https://api.xcp.fun/node${path}`, init);
return { fetch, request };
const request = (path: string, init?: RequestInit) => nodeRoute.request(
`https://api.xcp.fun/node${path}`,
init,
{ NODE_PUBLIC_RATE_LIMITER: limiter } as never,
executionCtx,
);
const flush = () => Promise.all(pending.splice(0));
return { cache, fetch, flush, limiter, request };
}

describe("first-party read-only protocol fallback", () => {
it("preserves raw integer digits, next_cursor and query encoding without touching a cache or forwarding credentials", async () => {
it("preserves raw integer digits, next_cursor and query encoding without forwarding credentials", async () => {
const { fetch, request } = await fixture();
const raw = '{"result":[{"quantity":10000000000000001}],"next_cursor":"a+b/c=="}';
fetch.mockResolvedValue(new Response(raw, { headers: {
"content-type": "application/json", "cache-control": "public,max-age=300", "set-cookie": "upstream-session=secret",
} }));
const path = `/v2/addresses/${ADDRESS}/balances?type=address&limit=1000&cursor=a%2Bb%2Fc%3D%3D`;
const response = await request(path, { headers: { authorization: "Bearer secret", cookie: "session=private", "x-admin-token": "secret" } });
const response = await request(path, { headers: {
origin: "https://xcp.fun", authorization: "Bearer secret", cookie: "session=private", "x-admin-token": "secret",
} });
expect(response.status).toBe(200);
expect(await response.text()).toBe(raw);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("cache-control")).toBe("public, max-age=300");
expect(response.headers.get("cloudflare-cdn-cache-control")).toBe("no-store");
expect(response.headers.get("set-cookie")).toBeNull();
expect(response.headers.get("access-control-allow-origin")).toBe("*");
expect(response.headers.get("access-control-allow-origin")).toBe("https://xcp.fun");
expect(response.headers.get("x-xcp-proxy-cache")).toBe("MISS");
expect(fetch).toHaveBeenCalledExactlyOnceWith(`${CP}${path.slice(3)}`, expect.objectContaining({
method: "GET", cache: "no-store", redirect: "manual", headers: { accept: "application/json" }, signal: expect.any(AbortSignal),
}));
Expand Down Expand Up @@ -123,7 +145,7 @@ describe("first-party read-only protocol fallback", () => {
const { fetch, request } = await fixture();
const response = await request(path, { method });
expect(response.status).toBe(expected);
expect(response.headers.get("access-control-allow-origin")).toBe("*");
expect(response.headers.get("access-control-allow-origin")).toBeNull();
expect(response.headers.get("cache-control")).toBe("no-store");
expect(fetch).not.toHaveBeenCalled();
});
Expand All @@ -137,13 +159,57 @@ describe("first-party read-only protocol fallback", () => {

it("answers preflight locally with only that operation's method", async () => {
const { fetch, request } = await fixture();
const response = await request("/v2/blocks/last", { method: "OPTIONS" });
const response = await request("/v2/blocks/last", { method: "OPTIONS", headers: { origin: "https://xcp.fun" } });
expect(response.status).toBe(204);
expect(response.headers.get("access-control-allow-methods")).toBe("GET, OPTIONS");
expect(response.headers.get("access-control-expose-headers")).toContain("Retry-After");
expect(response.headers.get("access-control-allow-origin")).toBe("https://xcp.fun");
expect(fetch).not.toHaveBeenCalled();
});

it("rejects an outside browser origin before consuming a rate-limit check or upstream request", async () => {
const { fetch, limiter, request } = await fixture();
const response = await request("/v2/blocks/last", { headers: { origin: "https://outside.example" } });
expect(response.status).toBe(403);
expect(response.headers.get("access-control-allow-origin")).toBeNull();
expect(limiter.limit).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});

it("caps a caller before either a cache read or an upstream request", async () => {
const { cache, fetch, limiter, request } = await fixture();
vi.mocked(limiter.limit).mockResolvedValue({ success: false });
const response = await request("/v2/blocks/last", { headers: { "cf-connecting-ip": "192.0.2.1" } });
expect(response.status).toBe(429);
expect(response.headers.get("retry-after")).toBe("60");
expect(limiter.limit).toHaveBeenCalledExactlyOnceWith({ key: "192.0.2.1" });
expect(cache.match).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});

it("collapses repeated cacheable reads while leaving no-store responses live", async () => {
const { fetch, flush, request } = await fixture();
fetch
.mockResolvedValueOnce(new Response('{"result":{"block_index":1}}', {
headers: { "cache-control": "public, max-age=60", "content-type": "application/json" },
}))
.mockResolvedValue(new Response('{"result":[]}', {
headers: { "cache-control": "no-store", "content-type": "application/json" },
}));

const first = await request("/v2/blocks/last", { headers: { origin: "https://xcp.fun" } });
expect(first.headers.get("x-xcp-proxy-cache")).toBe("MISS");
await flush();
const second = await request("/v2/blocks/last", { headers: { origin: "https://xcp.fun" } });
expect(second.headers.get("x-xcp-proxy-cache")).toBe("HIT");
expect(await second.text()).toBe('{"result":{"block_index":1}}');
expect(fetch).toHaveBeenCalledTimes(1);

expect((await request("/v2/mempool/events?limit=1")).headers.get("cache-control")).toBe("no-store");
expect((await request("/v2/mempool/events?limit=1")).headers.get("x-xcp-proxy-cache")).toBe("MISS");
expect(fetch).toHaveBeenCalledTimes(3);
});

it.each([400, 404, 500, 503])("keeps upstream status %d, body and Retry-After visible without retries or negative caching", async status => {
const { fetch, request } = await fixture();
fetch.mockImplementation(async () => new Response('{"error":"original upstream failure"}', { status, headers: { "retry-after": "120" } }));
Expand Down Expand Up @@ -184,6 +250,9 @@ describe("first-party read-only protocol fallback", () => {
fetch.mockImplementation(() => new Promise<Response>(r => { resolve.push(r); }));
const first = request("/v2/blocks/last");
const second = request(`/v2/transactions/${HASH}`);
await Promise.resolve(); // rate-limit binding
await Promise.resolve(); // edge-cache lookup
await Promise.resolve(); // upstream fetch
expect(resolve).toHaveLength(2);
resolve[0]!(new Response(null, { status: 429, headers: { "retry-after": "120" } }));
await first;
Expand Down Expand Up @@ -216,7 +285,7 @@ describe("first-party read-only protocol fallback", () => {
upstreamSignal.addEventListener("abort", () => reject(upstreamSignal!.reason), { once: true });
}));
const pending = request("/v2/blocks/last", { signal: caller.signal });
expect(fetch).toHaveBeenCalledOnce();
await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
if (cancelledBy === "caller") caller.abort();
else deadline.abort(new DOMException("Read deadline expired", "TimeoutError"));
expect((await pending).status).toBe(504);
Expand Down
Loading