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
3 changes: 3 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ export const verifications = pgTable(
checkedAt: timestamp("checked_at").defaultNow().notNull(),
},
(table) => ({
// /agent/status fetches the most recent verifications for its activity
// list, and aggregate queries filter/order by recency (#98).
checkedAtIdx: index("idx_verifications_checked_at").on(table.checkedAt),
// Verification lookups by resource (#107).
resourceIdIdx: index("idx_verifications_resource_id").on(table.resourceId),
}),
Expand Down
20 changes: 20 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ export function isAppError(value: unknown): value is AppError {
return value instanceof AppError;
}

/** Minimal response surface needed to write an error body (Express `Response` compatible). */
export interface ErrorResponseLike {
status(code: number): { json(body: ErrorResponseBody): unknown };
}

/**
* Writes any thrown value as the canonical error body. This is the single
* place middleware should use so every response carries `error`, `code` and
* `requestId`.
*/
export function sendError(
res: ErrorResponseLike,
error: unknown,
requestId?: string,
): ErrorResponseBody {
const body = serializeError(error, requestId);
res.status(getStatusCode(error)).json(body);
return body;
}

/** HTTP status for any thrown value (honours `status`/`statusCode` set by libs like body-parser). */
export function getStatusCode(error: unknown): number {
if (isAppError(error)) return error.status;
Expand Down
30 changes: 26 additions & 4 deletions src/middleware/dynamicPaywall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ vi.mock("../lib/x402.js", () => ({
sharedX402ResourceServer: {},
}));
vi.mock("../lib/logger.js", () => ({
getRequestId: () => undefined,
getRequestId: () => "test-request-id",
getLogger: () => ({
error: vi.fn(),
warn: vi.fn(),
Expand Down Expand Up @@ -110,7 +110,12 @@ describe("dynamicPaywall middleware", () => {
await dynamicPaywall(req, res, next);

expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: "Resource not found" });
expect(res.json).toHaveBeenCalledWith({
error: "Resource not found",
code: "RESOURCE_NOT_FOUND",
requestId: "test-request-id",
});
expect(res.json.mock.calls[0][0]).toMatchSnapshot();
expect(next).not.toHaveBeenCalled();
});

Expand All @@ -134,7 +139,12 @@ describe("dynamicPaywall middleware", () => {
await dynamicPaywall(req, res, next);

expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: "Resource not listed" });
expect(res.json).toHaveBeenCalledWith({
error: "Resource not listed",
code: "RESOURCE_NOT_FOUND",
requestId: "test-request-id",
});
expect(res.json.mock.calls[0][0]).toMatchSnapshot();
expect(next).not.toHaveBeenCalled();
});

Expand All @@ -158,9 +168,12 @@ describe("dynamicPaywall middleware", () => {
expect(res.status).toHaveBeenCalledWith(503);
expect(res.json).toHaveBeenCalledWith({
error: "chain_unavailable",
code: "CHAIN_UNAVAILABLE",
requestId: "test-request-id",
message: "Unable to verify resource price. Please try again later.",
resourceId: "r2",
});
expect(res.json.mock.calls[0][0]).toMatchSnapshot();
expect(next).not.toHaveBeenCalled();
});

Expand All @@ -182,8 +195,14 @@ describe("dynamicPaywall middleware", () => {

expect(res.status).toHaveBeenCalledWith(503);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: "chain_unavailable", resourceId: "r-missing" }),
expect.objectContaining({
error: "chain_unavailable",
code: "CHAIN_UNAVAILABLE",
requestId: "test-request-id",
resourceId: "r-missing",
}),
);
expect(res.json.mock.calls[0][0]).toMatchSnapshot();
expect(next).not.toHaveBeenCalled();
});

Expand All @@ -206,10 +225,13 @@ describe("dynamicPaywall middleware", () => {
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith({
error: "price_mismatch",
code: "PRICE_MISMATCH",
requestId: "test-request-id",
message:
"Resource price is temporarily unavailable due to a configuration issue. Please try again later.",
resourceId: "r3",
});
expect(res.json.mock.calls[0][0]).toMatchSnapshot();
expect(next).not.toHaveBeenCalled();
});

Expand Down
31 changes: 14 additions & 17 deletions src/middleware/dynamicPaywall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import { resources } from "../db/schema.js";
import type { RoutesConfig } from "@x402/core/server";
import { getLogger } from "../lib/logger.js";
import { network, sharedX402ResourceServer } from "../lib/x402.js";
import { getResource } from "../services/registryClient.js";
import { getOnChainPrice, normalizeUsdcPrice, stroopsToUsdc } from "../lib/stellarRegistry.js";
import { createTtlCache } from "../lib/ttlCache.js";
import { AppError } from "../lib/errors.js";

const CACHE_TTL_MS = 60_000; // 1 minute
const MIDDLEWARE_CACHE_MAX_SIZE = 1000;
Expand All @@ -29,13 +31,11 @@ export async function dynamicPaywall(req: Request, res: Response, next: NextFunc
.then((rows) => rows[0] ?? null);

if (!resource) {
res.status(404).json({ error: "Resource not found" });
return;
throw new AppError("NOT_FOUND", "Resource not found", { resourceId });
}

if (!resource.listed) {
res.status(404).json({ error: "Resource not listed" });
return;
throw new AppError("NOT_FOUND", "Resource not listed", { resourceId });
}

// Validate the DB price against the on-chain registry before serving a 402.
Expand All @@ -59,12 +59,11 @@ export async function dynamicPaywall(req: Request, res: Response, next: NextFunc
},
"on-chain price lookup failed",
);
res.status(503).json({
error: "chain_unavailable",
message: "Unable to verify resource price. Please try again later.",
resourceId,
});
return;
throw new AppError(
"SERVICE_UNAVAILABLE",
"Unable to verify resource price. Please try again later.",
{ resourceId },
);
}

const dbPriceNormalized = normalizeUsdcPrice(resource.price);
Expand All @@ -80,13 +79,11 @@ export async function dynamicPaywall(req: Request, res: Response, next: NextFunc
},
"price mismatch between database and on-chain registry",
);
res.status(409).json({
error: "price_mismatch",
message:
"Resource price is temporarily unavailable due to a configuration issue. Please try again later.",
resourceId,
});
return;
throw new AppError(
"CONFLICT",
"Resource price is temporarily unavailable due to a configuration issue. Please try again later.",
{ resourceId },
);
}

// Attach resource to request for the delivery handler
Expand Down
16 changes: 16 additions & 0 deletions src/middleware/errorHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,20 @@ describe("errorHandler", () => {
expect(res.status).toBe(404);
expect(res.body).toMatchObject({ code: "NOT_FOUND", error: "Route GET /nope not found" });
});

it("snapshots the not-found response body", async () => {
const res = await request(buildApp(() => null)).get("/nope");
expect(res.status).toBe(404);
expect(res.body).toMatchSnapshot();
expect(res.body).toEqual(expect.objectContaining({ error: expect.any(String), code: "NOT_FOUND", requestId: "req-123" }));
});

it("snapshots the generic app error response body", async () => {
const res = await request(buildApp(() => new Error("db password leaked"))).get("/boom");
expect(res.status).toBe(500);
expect(res.body).toMatchSnapshot();
expect(res.body).toEqual(
expect.objectContaining({ error: expect.any(String), code: "INTERNAL_ERROR", requestId: "req-123" }),
);
});
});
11 changes: 4 additions & 7 deletions src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { NextFunction, Request, Response } from "express";
import { getStatusCode, serializeError } from "../lib/errors.js";
import { AppError, getStatusCode, serializeError } from "../lib/errors.js";
import { getLogger, getRequestId } from "../lib/logger.js";
import { getRequestLogContext } from "../lib/requestLogContext.js";
import { captureServerException } from "../lib/sentry.js";
Expand Down Expand Up @@ -30,9 +30,6 @@ export function errorHandler(err: unknown, req: Request, res: Response, next: Ne

/** Fallback for unmatched routes. */
export function notFoundHandler(req: Request, res: Response): void {
res.status(404).json({
error: `Route ${req.method} ${req.path} not found`,
code: "NOT_FOUND",
...(getRequestId() ? { requestId: getRequestId() } : {}),
});
}
const err = new AppError("NOT_FOUND", `Route ${req.method} ${req.path} not found`);
res.status(404).json(serializeError(err, getRequestId()));
}
36 changes: 36 additions & 0 deletions src/middleware/uploadGuards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import {
__getActiveUploadCount,
} from "./uploadGuards.js";

vi.mock("../lib/requestContext.js", () => ({
getRequestId: () => "test-request-id",
}));

const PDF = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37]);
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);

Expand Down Expand Up @@ -50,6 +54,12 @@ describe("upload guards middleware", () => {
contentType: "application/zip",
});
expect(res.status).toBe(415);
expect(res.body).toMatchSnapshot();
expect(res.body).toMatchObject({
error: expect.any(String),
code: expect.any(String),
requestId: "test-request-id",
});
});

it("rejects when declared type does not match detected bytes (415)", async () => {
Expand All @@ -58,6 +68,12 @@ describe("upload guards middleware", () => {
.attach("file", PNG, { filename: "fake.pdf", contentType: "application/pdf" });
expect(res.status).toBe(415);
expect(res.body.error).toMatch(/does not match/i);
expect(res.body).toMatchSnapshot();
expect(res.body).toMatchObject({
error: expect.any(String),
code: expect.any(String),
requestId: "test-request-id",
});
});

it("rejects oversized uploads with 413", async () => {
Expand All @@ -66,6 +82,26 @@ describe("upload guards middleware", () => {
.post("/upload")
.attach("file", big, { filename: "big.txt", contentType: "text/plain" });
expect(res.status).toBe(413);
expect(res.body).toMatchSnapshot();
expect(res.body).toMatchObject({
error: expect.any(String),
code: expect.any(String),
requestId: "test-request-id",
});
});

it("rejects malformed multipart bodies with 400", async () => {
const res = await request(app)
.post("/upload")
.set("Content-Type", "multipart/form-data; boundary=----broken")
.send("not a valid multipart body");
expect(res.status).toBe(400);
expect(res.body).toMatchSnapshot();
expect(res.body).toMatchObject({
error: expect.any(String),
code: expect.any(String),
requestId: "test-request-id",
});
});

it("passes through requests with no file", async () => {
Expand Down
15 changes: 10 additions & 5 deletions src/middleware/uploadGuards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Request, Response, NextFunction, RequestHandler } from "express";
import { promises as fs } from "node:fs";
import os from "node:os";
import { config } from "../config.js";
import { AppError } from "../lib/AppError.js";
import { parseAllowedMimeTypes, validateUpload } from "../lib/uploadValidation.js";

const allowedMimeTypes = parseAllowedMimeTypes(config.ALLOWED_UPLOAD_MIME_TYPES);
Expand Down Expand Up @@ -46,12 +47,16 @@ export function singleFileUpload(field = "file"): RequestHandler {
if (err instanceof MulterError) {
release();
if (err.code === "LIMIT_FILE_SIZE") {
res.status(413).json({
error: `File exceeds the maximum size of ${config.MAX_FILE_SIZE_MB}MB`,
});
next(
new AppError(
`File exceeds the maximum size of ${config.MAX_FILE_SIZE_MB}MB`,
413,
"PAYLOAD_TOO_LARGE",
),
);
return;
}
res.status(400).json({ error: `Upload error: ${err.message}` });
next(new AppError(`Upload error: ${err.message}`, 400, "VALIDATION_ERROR"));
return;
}
if (err) {
Expand Down Expand Up @@ -81,7 +86,7 @@ export function validateUploadContentType(req: Request, res: Response, next: Nex
const result = validateUpload(buffer, file.mimetype, allowedMimeTypes);
if (!result.ok) {
return fs.unlink(file.path).catch(() => undefined).then(() => {
res.status(result.status).json({ error: result.error });
next(new AppError(result.error, result.status, "VALIDATION_ERROR"));
});
}
next();
Expand Down
26 changes: 22 additions & 4 deletions src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ export const openApiSpec = {
schemas: {
Error: {
type: "object",
description: "Standard error body returned by every endpoint.",
description:
"Standard error body returned by every endpoint, including the paywall, upload guards, " +
"not-found handler and unexpected application errors.",
properties: {
error: { type: "string", description: "Human-readable message.", example: "Resource not found" },
code: {
Expand All @@ -93,6 +95,8 @@ export const openApiSpec = {
"NOT_FOUND",
"CONFLICT",
"PAYLOAD_TOO_LARGE",
"UNSUPPORTED_MEDIA_TYPE",
"PAYMENT_REQUIRED",
"RATE_LIMITED",
"INTERNAL_ERROR",
"UPSTREAM_ERROR",
Expand All @@ -111,7 +115,7 @@ export const openApiSpec = {
example: "req_01HZX8Y2K3M4N5P6Q7R8S9T0V1",
},
},
required: ["error", "code"],
required: ["error", "code", "requestId"],
},
RateLimitError: {
type: "object",
Expand Down Expand Up @@ -1092,6 +1096,14 @@ export const openApiSpec = {
description: "Unauthorized",
content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
},
"413": {
description: "Uploaded file exceeds the maximum allowed size",
content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
},
"415": {
description: "Unsupported upload media type",
content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
},
"429": {
description: "Rate limit exceeded",
content: {
Expand Down Expand Up @@ -1171,7 +1183,10 @@ export const openApiSpec = {
security: [{ X402Payment: [] }],
responses: {
"200": { description: "Resource content or link URL delivered" },
"402": { description: "Payment required (x402)" },
"402": {
description: "Payment required (x402)",
content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
},
"404": {
description: "Not found or not listed",
content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
Expand Down Expand Up @@ -1575,7 +1590,10 @@ export const openApiSpec = {
description: "`resourceId` given without a valid `x-api-key`",
content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
},
"402": { description: "Payment required (x402)" },
"402": {
description: "Payment required (x402)",
content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
},
"403": {
description:
"The caller does not own the resource, or the resource was delisted by an administrator",
Expand Down
Loading