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 services/appraisal-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ A `POST /appraise` body larger than 8192 bytes is rejected with **HTTP 413**
`{ "error": "payload_too_large", "limitBytes": 8192 }` before the bytes are
parsed as JSON, so the request never receives a 402 payment quote.

All appraisal quote challenges (`402`), paid appraisal responses (`200`), and
error responses include `Cache-Control: no-store`, `Pragma: no-cache`,
`Expires: 0`, and `X-Content-Type-Options: nosniff` so intermediary caches
never store or replay time-bound payment quotes or paid valuations.

## Schema fixtures

The canonical request/response shapes and the per-failure-mode payloads live as
Expand Down
6 changes: 3 additions & 3 deletions services/appraisal-api/src/appraisal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export interface AppraisalRequest {
}

export interface Appraisal {
model: typeof APPRAISALMODEL;
model: typeof APPRAISAL_MODEL;
itemRef: string;
inputsHash: string;
fairValue: number;
Expand Down Expand Up @@ -93,7 +93,7 @@ function canonical(value: unknown): string {
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k], v]) => `${JSON.stringify(k)}:${canonical(v)}`);
.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`);
return `{${entries.join(",")}}`;
}
return JSON.stringify(value ?? null);
Expand Down Expand Up @@ -275,7 +275,7 @@ export function appraise(req: AppraisalRequest): Appraisal {
const suggestedMaxBid = round2(fairValue * (0.8 + 0.15 * confidence));

const rationale = [
`base ${req.basePrice} USDC scaled by quality×${round2(qualityF)}, demand×ä{round2(demandF)}, scarcity×ä{round2(scarcityF)}, risk×ä{round2(riskF)}`,
`base ${req.basePrice} USDC scaled by quality×${round2(qualityF)}, demand×${round2(demandF)}, scarcity×${round2(scarcityF)}, risk×${round2(riskF)}`,
`category '${req.category ?? "none"}' multiplier ×${categoryF}`,
`${provided}/4 attributes supplied → confidence ${confidence}`,
`suggested max bid is fair value × ${round2(0.8 + 0.15 * confidence)} to preserve margin`,
Expand Down
76 changes: 75 additions & 1 deletion services/appraisal-api/src/body-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,19 @@ import { once } from "node:events";
import { PassThrough } from "node:stream";
import { describe, test } from "node:test";

import { Keypair, Networks } from "@stellar/stellar-sdk";
import { STELLAR_TESTNET_CAIP2, USDC_TESTNET_ADDRESS } from "@x402/stellar";

import {
APPRAISAL_BODY_TOO_LARGE,
takeAppraisalRequestBody,
} from "./body-limit.js";
import {
APPRAISAL_RESPONSE_SECURITY_HEADERS,
buildAppraisalServer,
withNonCacheableHeaders,
} from "./server.js";
import { validRequest } from "./fixtures/index.js";

function listen(server: http.Server): Promise<number> {
server.listen(0, "127.0.0.1");
Expand All @@ -24,7 +33,7 @@ function listen(server: http.Server): Promise<number> {

function fakeRequest(headers: http.IncomingHttpHeaders, body: Buffer): IncomingMessage {
const stream = new PassThrough();
const req = Object.assign(stream, { headers }) as IncomingMessage;
const req = Object.assign(stream, { headers }) as unknown as IncomingMessage;
queueMicrotask(() => {
stream.end(body);
});
Expand Down Expand Up @@ -75,6 +84,71 @@ describe("appraisal request body cap", () => {
});
assert.equal(allowed.status, 200);
} finally {
server.closeAllConnections?.();
server.close();
}
});
});

describe("appraisal response non-cacheable headers", () => {
test("withNonCacheableHeaders enforces Cache-Control: no-store and safe headers", () => {
const merged = withNonCacheableHeaders({
"Cache-Control": "public, max-age=3600",
"X-Payment-Response": "settled",
});
assert.equal(merged["cache-control"], "no-store");
assert.equal(merged["pragma"], "no-cache");
assert.equal(merged["expires"], "0");
assert.equal(merged["x-content-type-options"], "nosniff");
assert.equal(merged["x-payment-response"], "settled");
assert.deepEqual(APPRAISAL_RESPONSE_SECURITY_HEADERS, {
"cache-control": "no-store",
pragma: "no-cache",
expires: "0",
"x-content-type-options": "nosniff",
});
});

test("402 quote challenges and error responses carry Cache-Control: no-store and safe headers", async () => {
const facilitator = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 3));
const payee = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 4));
const server = await buildAppraisalServer(
{
facilitatorSecret: facilitator.secret(),
payTo: payee.publicKey(),
asset: USDC_TESTNET_ADDRESS,
price: 0.1,
network: STELLAR_TESTNET_CAIP2,
networkPassphrase: Networks.TESTNET,
rpcUrl: "https://soroban-testnet.stellar.org",
port: 0,
},
{ nowSeconds: () => 1_700_000_000 },
);
const port = await listen(server);
try {
const challenge = await fetch(`http://127.0.0.1:${port}/appraise`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(validRequest.value),
});
assert.equal(challenge.status, 402);
assert.equal(challenge.headers.get("cache-control"), "no-store");
assert.equal(challenge.headers.get("pragma"), "no-cache");
assert.equal(challenge.headers.get("expires"), "0");
assert.equal(challenge.headers.get("x-content-type-options"), "nosniff");

const emptyPost = await fetch(`http://127.0.0.1:${port}/appraise`, {
method: "POST",
headers: { "content-type": "application/json" },
body: "",
});
assert.equal(emptyPost.status, 400);
assert.equal(emptyPost.headers.get("cache-control"), "no-store");
assert.equal(emptyPost.headers.get("pragma"), "no-cache");
assert.equal(emptyPost.headers.get("x-content-type-options"), "nosniff");
} finally {
server.closeAllConnections?.();
server.close();
}
});
Expand Down
7 changes: 6 additions & 1 deletion services/appraisal-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ export {
type AppraisalAttributes,
} from "./appraisal.js";

export { buildAppraisalServer } from "./server.js";
export {
APPRAISAL_RESPONSE_SECURITY_HEADERS,
buildAppraisalServer,
withNonCacheableHeaders,
type AppraisalServerOptions,
} from "./server.js";
export {
AppraisalConfigError,
configFromEnv,
Expand Down
82 changes: 63 additions & 19 deletions services/appraisal-api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,39 @@ import type { AppraisalServerConfig } from "./config.js";

const APPRAISE_ROUTE = "POST /appraise";

/** Non-cacheable security headers applied to appraisal quotes and paid responses. */
export const APPRAISAL_RESPONSE_SECURITY_HEADERS: Readonly<Record<string, string>> =
Object.freeze({
"cache-control": "no-store",
pragma: "no-cache",
expires: "0",
"x-content-type-options": "nosniff",
});

/**
* Merge response headers with mandatory non-cacheable security headers.
* Case-insensitively prevents upstream headers from re-enabling response caching.
*/
export function withNonCacheableHeaders(
headers: Record<string, string> = {},
): Record<string, string> {
const normalized: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
normalized[key.toLowerCase()] = value;
}
return {
...normalized,
...APPRAISAL_RESPONSE_SECURITY_HEADERS,
};
}

export interface AppraisalServerOptions {
/** Optional custom facilitator client (used by deterministic unit tests). */
facilitatorClient?: FacilitatorClient;
/** Optional wall-clock override returning Unix seconds. */
nowSeconds?: () => number;
}

/** Minimal HTTPAdapter over a Node request + already-read body. */
function makeAdapter(
req: http.IncomingMessage,
Expand Down Expand Up @@ -73,7 +106,10 @@ function send(
) {
const payload =
typeof body === "string" ? body : JSON.stringify(body ?? {}, null, 2);
res.writeHead(status, { "content-type": "application/json", ...headers });
res.writeHead(status, {
"content-type": "application/json",
...withNonCacheableHeaders(headers),
});
res.end(payload);
}

Expand All @@ -84,32 +120,37 @@ function writeInstructions(res: http.ServerResponse, i: HTTPResponseInstructions
: JSON.stringify(i.body ?? {}, null, 2);
res.writeHead(i.status, {
"content-type": i.isHtml ? "text/html" : "application/json",
...i.headers,
...withNonCacheableHeaders(i.headers),
});
res.end(body ?? "");
}

/** Build (and initialize) the x402-gated appraisal HTTP server. */
export async function buildAppraisalServer(
config: AppraisalServerConfig,
options: AppraisalServerOptions = {},
): Promise<http.Server> {
const rpcConfig = config.rpcUrl ? { url: config.rpcUrl } : undefined;

// In-process facilitator: verifies + settles on-chain (sponsors fees, submits).
const facilitatorSigner = createEd25519Signer(config.facilitatorSecret, config.network);
const facilitator = new x402Facilitator().register(
config.network,
new FacilitatorStellarScheme([facilitatorSigner], { rpcConfig }),
);
// x402Facilitator already exposes verify/settle/getSupported; adapt the
// synchronous getSupported() to the async FacilitatorClient contract so we can
// wire it straight into the resource server with no HTTP hop.
const facilitatorClient: FacilitatorClient = {
verify: (payload, requirements) => facilitator.verify(payload, requirements),
settle: (payload, requirements) => facilitator.settle(payload, requirements),
getSupported: async (): Promise<SupportedResponse> =>
facilitator.getSupported() as unknown as SupportedResponse,
};
const facilitatorClient: FacilitatorClient =
options.facilitatorClient ??
(() => {
// In-process facilitator: verifies + settles on-chain (sponsors fees, submits).
const facilitatorSigner = createEd25519Signer(config.facilitatorSecret, config.network);
const facilitator = new x402Facilitator().register(
config.network,
new FacilitatorStellarScheme([facilitatorSigner], { rpcConfig }),
);
// x402Facilitator already exposes verify/settle/getSupported; adapt the
// synchronous getSupported() to the async FacilitatorClient contract so we can
// wire it straight into the resource server with no HTTP hop.
return {
verify: (payload, requirements) => facilitator.verify(payload, requirements),
settle: (payload, requirements) => facilitator.settle(payload, requirements),
getSupported: async (): Promise<SupportedResponse> =>
facilitator.getSupported() as unknown as SupportedResponse,
};
})();

// Resource server: builds payment requirements and orchestrates verify/settle.
const serverScheme = new ServerStellarScheme().registerMoneyParser(async (amount) => ({
Expand Down Expand Up @@ -199,13 +240,16 @@ export async function buildAppraisalServer(
// malformed body never costs the caller.
let body: unknown;
try {
const nowSeconds = options.nowSeconds
? options.nowSeconds()
: Math.floor(globalThis.Date.now() / 1000);
body = {
appraisal: appraise(parseAppraisalRequest(parsedBody)),
quote: buildAppraisalQuote({
asset: config.asset,
price: config.price,
destination: config.payTo,
nowSeconds: Math.floor(Date.now() / 1000),
nowSeconds,
}),
};
} catch (e) {
Expand All @@ -220,7 +264,7 @@ export async function buildAppraisalServer(
result.paymentPayload as PaymentPayload,
result.paymentRequirements as PaymentRequirements,
result.declaredExtensions,
{ request: ctx, responseBody, responseHeaders: {} },
{ request: ctx, responseBody, responseHeaders: withNonCacheableHeaders() },
);

if (!settle.success) {
Expand Down