diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml old mode 100755 new mode 100644 diff --git a/.vscode/launch.json b/.vscode/launch.json old mode 100755 new mode 100644 diff --git a/.vscode/settings.json b/.vscode/settings.json old mode 100755 new mode 100644 diff --git a/README.md b/README.md index 35fed03..584a230 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,14 @@ can override the default behavior by setting the `expose` property on the options argument. For all known HTTP error status codes, a `name` will be generated (e.g., -`NotFoundError` for 404). If the name is not known, it will default to -`UnknownClientError` or `UnknownServerError`. +`Not Found` for 404). If the name is not known, it will default to +`Unknown Client Error` or `Unknown Server Error`. ```ts import { HttpError } from "@udibo/http-error"; const error = new HttpError(404, "file not found"); -console.log(error.toString()); // NotFoundError: file not found +console.log(error.toString()); // Not Found: file not found console.log(error.status); // 404 console.log(error.expose); // true ``` @@ -141,8 +141,8 @@ console.log(httpErrorFromDetails.name); // ForbiddenAccess This method returns a plain JavaScript object representing the error in the RFC 9457 Problem Details format. This is useful for serializing the error to a JSON -response body. If `expose` is `false` (default for 5xx errors), the `detail` -(message) property will be omitted. +response body. The `detail` property is set to `exposedMessage`, which provides +a safe message for clients (see `exposedMessage` section below). ```ts import { HttpError } from "@udibo/http-error"; @@ -159,18 +159,20 @@ console.log(problemDetails); // { // field: "email", // status: 400, -// title: "BadRequestError", +// title: "Bad Request", // detail: "Invalid input", // type: "/errors/validation", // instance: "/form/user" // } -const serverError = new HttpError(500, "Internal details", { expose: false }); +// For server errors (expose=false), detail uses a safe default message +const serverError = new HttpError(500, "SQL syntax error near 'users'"); console.log(serverError.toJSON()); -// Outputs (detail omitted): +// Outputs: // { // status: 500, -// title: "InternalServerError" +// title: "Internal Server Error", +// detail: "The server encountered an unexpected condition." // } ``` @@ -193,6 +195,78 @@ console.log(response.headers.get("Content-Type")); // application/problem+json // response.body can be read to get the JSON string ``` +#### `exposedMessage` + +The `exposedMessage` property provides a safe, user-friendly message that can be +exposed to clients. This prevents internal error details (like SQL errors, file +paths, or stack traces) from leaking to users. + +**How it works:** + +- If `exposedMessage` is explicitly provided in options, that value is used +- If `expose` is `true` and `message` exists, `exposedMessage` defaults to + `message` +- Otherwise, `exposedMessage` defaults to a generic message for the status code + (e.g., "The server encountered an unexpected condition." for 500) + +```ts +import { HttpError } from "@udibo/http-error"; + +// Client error (expose=true by default) - message is used as exposedMessage +const clientError = new HttpError(400, "Invalid email format"); +console.log(clientError.exposedMessage); // "Invalid email format" + +// Server error (expose=false by default) - safe default is used +const serverError = new HttpError(500, "Database connection refused"); +console.log(serverError.exposedMessage); // "The server encountered an unexpected condition." +console.log(serverError.message); // "Database connection refused" (internal use only) + +// Custom exposedMessage - always takes priority +const customError = new HttpError(500, "SQL syntax error", { + exposedMessage: "An error occurred while processing your request.", +}); +console.log(customError.exposedMessage); // "An error occurred while processing your request." +``` + +### Server-Side Rendering Best Practices + +When rendering error messages in server-side templates or responses, always use +`error.exposedMessage` instead of `error.message` to prevent leaking internal +implementation details to users. + +**Why this matters:** + +- `error.message` may contain sensitive information (SQL errors, file paths, + stack traces) +- `error.exposedMessage` provides a safe, user-friendly message by default +- For server errors (5xx), the default `exposedMessage` is generic and safe +- For client errors (4xx), `exposedMessage` defaults to `message` since those + are typically user-facing + +**Example in a template:** + +```ts +// BAD - may expose internal details +
Error: ${error.message}
// "SQLSTATE[42S02]: Table 'users' doesn't exist" + +// GOOD - safe for users +Error: ${error.exposedMessage}
// "The server encountered an unexpected condition." +``` + +**Example in error handling middleware:** + +```ts +app.onError((cause, c) => { + const error = HttpError.from(cause); + + // Log the full internal message for debugging + console.error(`[${error.status}] ${error.message}`, error.cause); + + // Return safe message to client (via toJSON which uses exposedMessage) + return error.getResponse(); +}); +``` + ### `createHttpErrorClass()` This factory function allows you to create custom error classes that extend diff --git a/deno.json b/deno.json index b10fb4b..2d38943 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@udibo/http-error", - "version": "0.10.0", + "version": "0.11.0", "exports": { ".": "./mod.ts" }, @@ -14,13 +14,13 @@ }, "imports": { "@udibo/http-error": "./mod.ts", - "@std/assert": "jsr:@std/assert@1", - "@std/http": "jsr:@std/http@1", - "@std/testing": "jsr:@std/testing@1", - "@std/streams": "jsr:@std/streams@1", - "@std/path": "jsr:@std/path@1", - "@oak/oak": "jsr:@oak/oak@17", - "hono": "npm:hono@4" + "@std/assert": "jsr:@std/assert@^1.0.16", + "@std/http": "jsr:@std/http@^1.0.22", + "@std/testing": "jsr:@std/testing@^1.0.16", + "@std/streams": "jsr:@std/streams@^1.0.14", + "@std/path": "jsr:@std/path@^1.1.3", + "@oak/oak": "jsr:@oak/oak@^17.2.0", + "hono": "npm:hono@^4.10.7" }, "tasks": { "check": { diff --git a/deno.lock b/deno.lock index baa607a..50dd24f 100644 --- a/deno.lock +++ b/deno.lock @@ -1,95 +1,142 @@ { "version": "5", "specifiers": { - "jsr:@oak/commons@1": "1.0.0", - "jsr:@oak/oak@17": "17.1.4", - "jsr:@std/assert@1": "1.0.12", - "jsr:@std/assert@^1.0.12": "1.0.12", - "jsr:@std/bytes@1": "1.0.5", - "jsr:@std/bytes@^1.0.5": "1.0.5", - "jsr:@std/crypto@1": "1.0.4", - "jsr:@std/encoding@1": "1.0.8", - "jsr:@std/encoding@^1.0.7": "1.0.8", - "jsr:@std/http@1": "1.0.13", - "jsr:@std/internal@^1.0.6": "1.0.6", + "jsr:@oak/commons@1": "1.0.1", + "jsr:@oak/oak@^17.2.0": "17.2.0", + "jsr:@std/assert@1": "1.0.16", + "jsr:@std/assert@^1.0.15": "1.0.16", + "jsr:@std/assert@^1.0.16": "1.0.16", + "jsr:@std/bytes@1": "1.0.6", + "jsr:@std/bytes@^1.0.6": "1.0.6", + "jsr:@std/cli@^1.0.24": "1.0.24", + "jsr:@std/crypto@1": "1.0.5", + "jsr:@std/data-structures@^1.0.9": "1.0.9", + "jsr:@std/encoding@1": "1.0.10", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fmt@^1.0.8": "1.0.8", + "jsr:@std/fs@^1.0.19": "1.0.20", + "jsr:@std/fs@^1.0.20": "1.0.20", + "jsr:@std/html@^1.0.5": "1.0.5", + "jsr:@std/http@1": "1.0.22", + "jsr:@std/http@^1.0.22": "1.0.22", + "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/media-types@1": "1.1.0", - "jsr:@std/path@1": "1.0.9", - "jsr:@std/streams@1": "1.0.9", - "jsr:@std/testing@1": "1.0.10", - "npm:hono@4": "4.7.8", + "jsr:@std/media-types@^1.1.0": "1.1.0", + "jsr:@std/net@^1.0.6": "1.0.6", + "jsr:@std/path@1": "1.1.3", + "jsr:@std/path@^1.1.2": "1.1.3", + "jsr:@std/path@^1.1.3": "1.1.3", + "jsr:@std/streams@^1.0.14": "1.0.14", + "jsr:@std/testing@^1.0.16": "1.0.16", + "npm:hono@^4.10.7": "4.10.7", "npm:path-to-regexp@^6.3.0": "6.3.0" }, "jsr": { - "@oak/commons@1.0.0": { - "integrity": "49805b55603c3627a9d6235c0655aa2b6222d3036b3a13ff0380c16368f607ac", + "@oak/commons@1.0.1": { + "integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c", "dependencies": [ "jsr:@std/assert@1", "jsr:@std/bytes@1", "jsr:@std/crypto", "jsr:@std/encoding@1", - "jsr:@std/http", - "jsr:@std/media-types" + "jsr:@std/http@1", + "jsr:@std/media-types@1" ] }, - "@oak/oak@17.1.4": { - "integrity": "60530b582bf276ff741e39cc664026781aa08dd5f2bc5134d756cc427bf2c13e", + "@oak/oak@17.2.0": { + "integrity": "938537a92fc7922a46a9984696c65fb189c9baad164416ac3e336768a9ff0cd1", "dependencies": [ "jsr:@oak/commons", "jsr:@std/assert@1", "jsr:@std/bytes@1", - "jsr:@std/http", - "jsr:@std/media-types", - "jsr:@std/path", + "jsr:@std/http@1", + "jsr:@std/media-types@1", + "jsr:@std/path@1", "npm:path-to-regexp" ] }, - "@std/assert@1.0.12": { - "integrity": "08009f0926dda9cbd8bef3a35d3b6a4b964b0ab5c3e140a4e0351fbf34af5b9a", + "@std/assert@1.0.16": { + "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", "dependencies": [ "jsr:@std/internal" ] }, - "@std/bytes@1.0.5": { - "integrity": "4465dd739d7963d964c809202ebea6d5c6b8e3829ef25c6a224290fbb8a1021e" + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" }, - "@std/crypto@1.0.4": { - "integrity": "cee245c453bd5366207f4d8aa25ea3e9c86cecad2be3fefcaa6cb17203d79340" + "@std/cli@1.0.24": { + "integrity": "b655a5beb26aa94f98add6bc8889f5fb9bc3ee2cc3fc954e151201f4c4200a5e" }, - "@std/encoding@1.0.8": { - "integrity": "a6c8f3f933ab1bed66244f435d1dc0fd23a888e07195532122ddc3d5f8f0e6b4" + "@std/crypto@1.0.5": { + "integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40" }, - "@std/http@1.0.13": { - "integrity": "d29618b982f7ae44380111f7e5b43da59b15db64101198bb5f77100d44eb1e1e", + "@std/data-structures@1.0.9": { + "integrity": "033d6e17e64bf1f84a614e647c1b015fa2576ae3312305821e1a4cb20674bb4d" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fmt@1.0.8": { + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" + }, + "@std/fs@1.0.20": { + "integrity": "e953206aae48d46ee65e8783ded459f23bec7dd1f3879512911c35e5484ea187", + "dependencies": [ + "jsr:@std/path@^1.1.3" + ] + }, + "@std/html@1.0.5": { + "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" + }, + "@std/http@1.0.22": { + "integrity": "53f0bb70e23a2eec3e17c4240a85bb23d185b2e20635adb37ce0f03cc4ca012a", "dependencies": [ - "jsr:@std/encoding@^1.0.7" + "jsr:@std/cli", + "jsr:@std/encoding@^1.0.10", + "jsr:@std/fmt", + "jsr:@std/fs@^1.0.20", + "jsr:@std/html", + "jsr:@std/media-types@^1.1.0", + "jsr:@std/net", + "jsr:@std/path@^1.1.3", + "jsr:@std/streams" ] }, - "@std/internal@1.0.6": { - "integrity": "9533b128f230f73bd209408bb07a4b12f8d4255ab2a4d22a1fd6d87304aca9a4" + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, "@std/media-types@1.1.0": { "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" }, - "@std/path@1.0.9": { - "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e" + "@std/net@1.0.6": { + "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" }, - "@std/streams@1.0.9": { - "integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035", + "@std/path@1.1.3": { + "integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3", "dependencies": [ - "jsr:@std/bytes@^1.0.5" + "jsr:@std/internal" ] }, - "@std/testing@1.0.10": { - "integrity": "8997bd0b0df020b81bf5eae103c66622918adeff7e45e96291c92a29dbf82cc1", + "@std/streams@1.0.14": { + "integrity": "c0df6cdd73bd4bbcbe4baa89e323b88418c90ceb2d926f95aa99bdcdbfca2411", "dependencies": [ - "jsr:@std/assert@^1.0.12", - "jsr:@std/internal" + "jsr:@std/bytes@^1.0.6" + ] + }, + "@std/testing@1.0.16": { + "integrity": "a917ffdeb5924c9be436dc78bc32e511760e14d3a96e49c607fc5ecca86d0092", + "dependencies": [ + "jsr:@std/assert@^1.0.15", + "jsr:@std/data-structures", + "jsr:@std/fs@^1.0.19", + "jsr:@std/internal", + "jsr:@std/path@^1.1.2" ] } }, "npm": { - "hono@4.7.8": { - "integrity": "sha512-PCibtFdxa7/Ldud9yddl1G81GjYaeMYYTq4ywSaNsYbB1Lug4mwtOMJf2WXykL0pntYwmpRJeOI3NmoDgD+Jxw==" + "hono@4.10.7": { + "integrity": "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw==" }, "path-to-regexp@6.3.0": { "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" @@ -97,13 +144,13 @@ }, "workspace": { "dependencies": [ - "jsr:@oak/oak@17", - "jsr:@std/assert@1", - "jsr:@std/http@1", - "jsr:@std/path@1", - "jsr:@std/streams@1", - "jsr:@std/testing@1", - "npm:hono@4" + "jsr:@oak/oak@^17.2.0", + "jsr:@std/assert@^1.0.16", + "jsr:@std/http@^1.0.22", + "jsr:@std/path@^1.1.3", + "jsr:@std/streams@^1.0.14", + "jsr:@std/testing@^1.0.16", + "npm:hono@^4.10.7" ] } } diff --git a/examples/hono.test.ts b/examples/hono.test.ts index ce82c6f..589dcf7 100644 --- a/examples/hono.test.ts +++ b/examples/hono.test.ts @@ -22,12 +22,7 @@ describe("hono error handling", () => { .pipeThrough(new TextLineStream()); for await (const line of stdout.values({ preventCancel: true })) { - if (line.includes("Listening on")) { - const address = Deno.build.os === "windows" ? "localhost" : "0.0.0.0"; - assertEquals( - line, - `Listening on http://${address}:8000/ (http://localhost:8000/)`, - ); + if (line.includes("Listening on") && line.includes(":8000")) { break; } } @@ -44,7 +39,8 @@ describe("hono error handling", () => { assertEquals(res.headers.get("content-type"), "application/problem+json"); assertEquals(await res.json(), { status: 500, - title: "InternalServerError", + title: "Internal Server Error", + detail: "The server encountered an unexpected condition.", }); }); @@ -65,7 +61,7 @@ describe("hono error handling", () => { assertEquals(res.headers.get("content-type"), "application/problem+json"); assertEquals(await res.json(), { status: 400, - title: "BadRequestError", + title: "Bad Request", detail: "This is an example of an HttpError", type: "/errors/http-error", instance: "/errors/http-error/instance/123", diff --git a/examples/oak.test.ts b/examples/oak.test.ts index e25764f..c1f5160 100644 --- a/examples/oak.test.ts +++ b/examples/oak.test.ts @@ -22,8 +22,7 @@ describe("oak error handling", () => { .pipeThrough(new TextLineStream()); for await (const line of stdout.values({ preventCancel: true })) { - if (line.includes("Listening on")) { - assertEquals(line, "Listening on http://localhost:8000/"); + if (line.includes("Listening on") && line.includes(":8000")) { break; } } @@ -40,7 +39,8 @@ describe("oak error handling", () => { assertEquals(res.headers.get("content-type"), "application/problem+json"); assertEquals(await res.json(), { status: 500, - title: "InternalServerError", + title: "Internal Server Error", + detail: "The server encountered an unexpected condition.", }); }); @@ -50,7 +50,7 @@ describe("oak error handling", () => { assertEquals(res.headers.get("content-type"), "application/problem+json"); assertEquals(await res.json(), { status: 400, - title: "BadRequestError", + title: "Bad Request", detail: "This is an example of an error from oak", }); }); @@ -61,7 +61,7 @@ describe("oak error handling", () => { assertEquals(res.headers.get("content-type"), "application/problem+json"); assertEquals(await res.json(), { status: 400, - title: "BadRequestError", + title: "Bad Request", detail: "This is an example of an HttpError", type: "/errors/http-error", instance: "/errors/http-error/instance/123", diff --git a/mod.test.ts b/mod.test.ts index e54c036..dca7a26 100644 --- a/mod.test.ts +++ b/mod.test.ts @@ -1,4 +1,4 @@ -import { STATUS_CODE, type StatusCode } from "@std/http/status"; +import { STATUS_TEXT, type StatusCode } from "@std/http/status"; import { assert, assertEquals, @@ -17,12 +17,16 @@ const httpErrorTests = describe("HttpError"); it(httpErrorTests, "without args", () => { const error = new HttpError(); - assertEquals(error.toString(), "InternalServerError"); - assertEquals(error.name, "InternalServerError"); + assertEquals(error.toString(), "Internal Server Error"); + assertEquals(error.name, "Internal Server Error"); assertEquals(error.message, ""); assertEquals(error.status, 500); assertEquals(error.expose, false); assertEquals(error.cause, undefined); + assertEquals( + error.exposedMessage, + "The server encountered an unexpected condition.", + ); }); it(httpErrorTests, "with status", () => { @@ -37,8 +41,16 @@ it(httpErrorTests, "with status", () => { it(httpErrorTests, "with message", () => { function assertWithMessage(error: HttpError): void { - assertEquals(error.toString(), "InternalServerError: something went wrong"); + assertEquals( + error.toString(), + "Internal Server Error: something went wrong", + ); assertEquals(error.message, "something went wrong"); + // expose is false for 500 errors, so exposedMessage uses default + assertEquals( + error.exposedMessage, + "The server encountered an unexpected condition.", + ); } assertWithMessage(new HttpError("something went wrong")); assertWithMessage(new HttpError({ message: "something went wrong" })); @@ -52,7 +64,7 @@ it( httpErrorTests, "prefer status/message args over status/messagee options", () => { - const names = ["BadRequestError", "BadGatewayError"]; + const names = ["Bad Request", "Bad Gateway"]; const messages = ["something went wrong", "failed"]; const statuses = [400, 502]; const options = { message: messages[1], status: statuses[1] }; @@ -117,53 +129,11 @@ it(httpErrorTests, "invalid status", () => { ); }); -const DEFAULT_ERROR_NAMES = new Map