Repository: solid/community-server (ts)

Issue:
Assign codes to errors
### Problem

We want there to be nice error reporting toward users (#654).

An issue is that the **connection between the problem and possible solutions** is not always obvious.
This is because the component generating the error is not aware of its role in the bigger configuration.

---

### Example

For example, if run CSS behind a proxy but forget to set up the `Forwarded` header, then requests to the server will be made with the wrong hostname and/or protocol. This results in the error:

```
InternalServerError: http://wrong.host/ is not supported
    at SingleRootIdentifierStrategy.getParentContainer (/Users/ruben/Documents/UGent/Solid/solid-community-server/dist/util/identifiers/BaseIdentifierStrategy.js:14:19)
    at WebAclAuthorizer.getAclRecursive (/Users/ruben/Documents/UGent/Solid/solid-community-server/dist/authorization/WebAclAuthorizer.js:186:48)
    at processTicksAndRejections (internal/process/task_queues.js:93:5)
    at async WebAclAuthorizer.handle (/Users/ruben/Documents/UGent/Solid/solid-community-server/dist/authorization/WebAclAuthorizer.js:41:23)
    at async AuthenticatedLdpHandler.runHandlers (/Users/ruben/Documents/UGent/Solid/solid-community-server/dist/ldp/AuthenticatedLdpHandler.js:75:35)
    at async AuthenticatedLdpHandler.handle (/Users/ruben/Documents/UGent/Solid/solid-community-server/dist/ldp/AuthenticatedLdpHandler.js:46:61)
    at async SequenceHandler.handle (/Users/ruben/Documents/UGent/Solid/solid-community-server/dist/util/handlers/SequenceHandler.js:27:26)
    at async Server.<anonymous> (/Users/ruben/Documents/UGent/Solid/solid-community-server/dist/server/BaseHttpServerFactory.js:25:17)
```

For `SingleRootIdentifierStrategy`, it is impossible to know what the actual issue is, and `WebAclAuthorizer` does not have any clue either.

What we want (after #654), is an error page such as:

> ### Requests to `http://wrong.host/` are not supported
> The Community Solid Server received a request for `http://wrong.host/`, which is not configured.
> Here are some things you can try to fix this:
> - Have you started the server with the right hostname?
> - If you are running the server behind a reverse proxy, did you set up the `Forwarded` header correctly?

---


### Proposal

Errors should be assigned codes, something like `E1234`, such that we can easily recognize them later on.

We could do things like:
- have a file with a list of constants (enum?) that are error codes
  - something like `export const ERROR_UNSUPPORTED_IDENTIFIER = createErrorType('E1234');`
  - or `ErrorCodes.UnsupportedIdentifier` (perhaps simlar to the namespaces code we have)
- have a specific `Error` subtype that takes such an error code in its constructor
- or even have a custom error class for every error we emit; the class definition then has the error code
  - something like `export const UnsupportedIdentifierError = createErrorType('E1234');`

Pull request description:
Use error codes for extensive error descriptions
Closes #673.

I went back and forth several times deciding what the best place would be to do the conversion from code to text. This is what I ended up with.

How it works now is pretty similar to how we handled errors after the last change. The new thing is that the `ErrorToTemplateConverter` now also takes the error code into account. It looks in the `codeFolder` for a file matching the exact error code and uses that as a template for the error description if it finds a match. The reason I choose for a folder of templates instead of 1 JSON config with all descriptions in it is because JSON is bad for visualizing newlines.

The input parameters for the `ErrorToTemplateConverter` have changed to generate markdown now instead of html. This way the error templates can be written in markdown which seems nicer than having to write them in html. Markdown also seems more reusable if we want to return the error text in a non-html format in the future such as JSON. Html is now generated through the new `MarkdownToHtmlConverter`. This causes us to lose the `<title>` field with the error name but that seems acceptable.

Several errors also provide a value for the `cause` field in the error constructor now. Nothing is done with this yet but it might be useful in the future.

Patch:
diff --git a/.componentsignore b/.componentsignore
index 2e9c3c76b..0ac96dffd 100644
--- a/.componentsignore
+++ b/.componentsignore
@@ -4,6 +4,7 @@
   "Configuration",
   "Error",
   "EventEmitter",
+  "HttpErrorOptions",
   "LRUCache",
   "Provider",
   "ValuePreferencesArg"
diff --git a/config/util/representation-conversion/default.json b/config/util/representation-conversion/default.json
index 3265be779..1d8cf1ca2 100644
--- a/config/util/representation-conversion/default.json
+++ b/config/util/representation-conversion/default.json
@@ -28,8 +28,15 @@
             {
               "@type": "ErrorToTemplateConverter",
               "engine": { "@type": "HandlebarsTemplateEngine" },
-              "templatePath": "$PACKAGE_ROOT/templates/error/error.hbs",
-              "contentType": "text/html"
+              "templatePath": "$PACKAGE_ROOT/templates/error/main.md",
+              "descriptions": "$PACKAGE_ROOT/templates/error/descriptions/",
+              "contentType": "text/markdown",
+              "extension": ".md"
+            },
+            {
+              "@type": "MarkdownToHtmlConverter",
+              "engine": { "@type": "HandlebarsTemplateEngine" },
+              "templatePath": "$PACKAGE_ROOT/templates/main.html"
             }
           ]
         }
diff --git a/package-lock.json b/package-lock.json
index 11dac2a4e..e0c94d927 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4822,6 +4822,11 @@
       "resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz",
       "integrity": "sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w=="
     },
+    "@types/marked": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/@types/marked/-/marked-2.0.3.tgz",
+      "integrity": "sha512-lbhSN1rht/tQ+dSWxawCzGgTfxe9DB31iLgiT1ZVT5lshpam/nyOA1m3tKHRoNPctB2ukSL22JZI5Fr+WI/zYg=="
+    },
     "@types/mime": {
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
@@ -11427,8 +11432,7 @@
     "marked": {
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/marked/-/marked-2.1.3.tgz",
-      "integrity": "sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA==",
-      "dev": true
+      "integrity": "sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA=="
     },
     "media-typer": {
       "version": "0.3.0",
diff --git a/package.json b/package.json
index 4f3e8a0f7..757050336 100644
--- a/package.json
+++ b/package.json
@@ -84,6 +84,7 @@
     "@types/bcrypt": "^5.0.0",
     "@types/cors": "^2.8.10",
     "@types/end-of-stream": "^1.4.0",
+    "@types/marked": "^2.0.3",
     "@types/mime-types": "^2.1.0",
     "@types/n3": "^1.10.0",
     "@types/node": "^15.12.5",
@@ -112,6 +113,7 @@
     "fetch-sparql-endpoint": "^2.0.1",
     "handlebars": "^4.7.7",
     "jose": "^3.11.6",
+    "marked": "^2.1.3",
     "mime-types": "^2.1.31",
     "n3": "^1.10.0",
     "nodemailer": "^6.6.2",
diff --git a/src/authentication/BearerWebIdExtractor.ts b/src/authentication/BearerWebIdExtractor.ts
index f68f2190e..167347e8f 100644
--- a/src/authentication/BearerWebIdExtractor.ts
+++ b/src/authentication/BearerWebIdExtractor.ts
@@ -36,7 +36,7 @@ export class BearerWebIdExtractor extends CredentialsExtractor {
     } catch (error: unknown) {
       const message = `Error verifying WebID via Bearer access token: ${(error as Error).message}`;
       this.logger.warn(message);
-      throw new BadRequestHttpError(message);
+      throw new BadRequestHttpError(message, { cause: error });
     }
   }
 }
diff --git a/src/authentication/DPoPWebIdExtractor.ts b/src/authentication/DPoPWebIdExtractor.ts
index 1b04942c1..5cdd69cd3 100644
--- a/src/authentication/DPoPWebIdExtractor.ts
+++ b/src/authentication/DPoPWebIdExtractor.ts
@@ -57,7 +57,7 @@ export class DPoPWebIdExtractor extends CredentialsExtractor {
     } catch (error: unknown) {
       const message = `Error verifying WebID via DPoP-bound access token: ${(error as Error).message}`;
       this.logger.warn(message);
-      throw new BadRequestHttpError(message);
+      throw new BadRequestHttpError(message, { cause: error });
     }
   }
 }
diff --git a/src/identity/interaction/email-password/EmailPasswordUtil.ts b/src/identity/interaction/email-password/EmailPasswordUtil.ts
index f4b555002..467374794 100644
--- a/src/identity/interaction/email-password/EmailPasswordUtil.ts
+++ b/src/identity/interaction/email-password/EmailPasswordUtil.ts
@@ -12,14 +12,15 @@ import { IdpInteractionError } from '../util/IdpInteractionError';
 export function throwIdpInteractionError(error: unknown, prefilled: Record<string, string> = {}): never {
   if (IdpInteractionError.isInstance(error)) {
     if (Object.keys(prefilled).length > 0) {
-      throw new IdpInteractionError(error.statusCode, error.message, { ...error.prefilled, ...prefilled });
+      const { statusCode, message } = error;
+      throw new IdpInteractionError(statusCode, message, { ...error.prefilled, ...prefilled }, { cause: error });
     } else {
       throw error;
     }
   } else if (HttpError.isInstance(error)) {
-    throw new IdpInteractionError(error.statusCode, error.message, prefilled);
+    throw new IdpInteractionError(error.statusCode, error.message, prefilled, { cause: error });
   } else {
-    throw new IdpInteractionError(500, createErrorMessage(error), prefilled);
+    throw new IdpInteractionError(500, createErrorMessage(error), prefilled, { cause: error });
   }
 }
 
diff --git a/src/identity/interaction/util/IdpInteractionError.ts b/src/identity/interaction/util/IdpInteractionError.ts
index 0b85bf833..b87c364d5 100644
--- a/src/identity/interaction/util/IdpInteractionError.ts
+++ b/src/identity/interaction/util/IdpInteractionError.ts
@@ -1,3 +1,4 @@
+import type { HttpErrorOptions } from '../../../util/errors/HttpError';
 import { HttpError } from '../../../util/errors/HttpError';
 
 /**
@@ -7,8 +8,8 @@ import { HttpError } from '../../../util/errors/HttpError';
 export class IdpInteractionError extends HttpError {
   public readonly prefilled: Record<string, string>;
 
-  public constructor(status: number, message: string, prefilled: Record<string, string>) {
-    super(status, 'IdpInteractionError', message);
+  public constructor(status: number, message: string, prefilled: Record<string, string>, options?: HttpErrorOptions) {
+    super(status, 'IdpInteractionError', message, options);
     this.prefilled = prefilled;
   }
 
diff --git a/src/index.ts b/src/index.ts
index c59bc9bec..9e9a1e7fe 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -229,6 +229,7 @@ export * from './storage/conversion/ConversionUtil';
 export * from './storage/conversion/ErrorToTemplateConverter';
 export * from './storage/conversion/ErrorToQuadConverter';
 export * from './storage/conversion/IfNeededConverter';
+export * from './storage/conversion/MarkdownToHtmlConverter';
 export * from './storage/conversion/PassthroughConverter';
 export * from './storage/conversion/QuadToRdfConverter';
 export * from './storage/conversion/RdfToQuadConverter';
diff --git a/src/ldp/http/SparqlUpdateBodyParser.ts b/src/ldp/http/SparqlUpdateBodyParser.ts
index b9028a6ff..f0da06913 100644
--- a/src/ldp/http/SparqlUpdateBodyParser.ts
+++ b/src/ldp/http/SparqlUpdateBodyParser.ts
@@ -29,7 +29,7 @@ export class SparqlUpdateBodyParser extends BodyParser {
       algebra = translate(sparql, { quads: true, baseIRI: metadata.identifier.value });
     } catch (error: unknown) {
       this.logger.warn('Could not translate SPARQL query to SPARQL algebra', { error });
-      throw new BadRequestHttpError(createErrorMessage(error));
+      throw new BadRequestHttpError(createErrorMessage(error), { cause: error });
     }
 
     // Prevent body from being requested again
diff --git a/src/storage/DataAccessorBasedStore.ts b/src/storage/DataAccessorBasedStore.ts
index 0be878886..90da48364 100644
--- a/src/storage/DataAccessorBasedStore.ts
+++ b/src/storage/DataAccessorBasedStore.ts
@@ -364,7 +364,8 @@ export class DataAccessorBasedStore implements ResourceStore {
         quads = await parseQuads(representation.data, { format: contentType, baseIRI: identifier.value });
       }
     } catch (error: unknown) {
-      throw new BadRequestHttpError(`Can only create containers with RDF data. ${createErrorMessage(error)}`);
+      throw new BadRequestHttpError(`Can only create containers with RDF data. ${createErrorMessage(error)}`,
+        { cause: error });
     }
 
     // Solid, §5.3: "Servers MUST NOT allow HTTP POST, PUT and PATCH to update a container’s containment triples;
diff --git a/src/storage/RoutingResourceStore.ts b/src/storage/RoutingResourceStore.ts
index 83ae03887..65866e85a 100644
--- a/src/storage/RoutingResourceStore.ts
+++ b/src/storage/RoutingResourceStore.ts
@@ -60,7 +60,7 @@ export class RoutingResourceStore implements ResourceStore {
       return await this.rule.handleSafe({ identifier });
     } catch (error: unknown) {
       if (NotImplementedHttpError.isInstance(error)) {
-        throw new NotFoundHttpError();
+        throw new NotFoundHttpError('', { cause: error });
       }
       throw error;
     }
diff --git a/src/storage/accessors/FileDataAccessor.ts b/src/storage/accessors/FileDataAccessor.ts
index a82208ce2..39f786de5 100644
--- a/src/storage/accessors/FileDataAccessor.ts
+++ b/src/storage/accessors/FileDataAccessor.ts
@@ -155,7 +155,7 @@ export class FileDataAccessor implements DataAccessor {
       return await fsPromises.lstat(path);
     } catch (error: unknown) {
       if (isSystemError(error) && error.code === 'ENOENT') {
-        throw new NotFoundHttpError();
+        throw new NotFoundHttpError('', { cause: error });
       }
       throw error;
     }
diff --git a/src/storage/conversion/ErrorToTemplateConverter.ts b/src/storage/conversion/ErrorToTemplateConverter.ts
index d36e9649d..f296b5afb 100644
--- a/src/storage/conversion/ErrorToTemplateConverter.ts
+++ b/src/storage/conversion/ErrorToTemplateConverter.ts
@@ -4,25 +4,38 @@ import { BasicRepresentation } from '../../ldp/representation/BasicRepresentatio
 import type { Representation } from '../../ldp/representation/Representation';
 import type { TemplateEngine } from '../../pods/generate/TemplateEngine';
 import { INTERNAL_ERROR } from '../../util/ContentTypes';
+import { HttpError } from '../../util/errors/HttpError';
 import { InternalServerError } from '../../util/errors/InternalServerError';
-import { resolveAssetPath } from '../../util/PathUtil';
+import { joinFilePath, resolveAssetPath } from '../../util/PathUtil';
 import type { RepresentationConverterArgs } from './RepresentationConverter';
 import { TypedRepresentationConverter } from './TypedRepresentationConverter';
 
 /**
  * Serializes an Error by filling in the provided template.
  * Content-type is based on the constructor parameter.
+ *
+ * In case the input Error has an `options.errorCode` value,
+ * the converter will look in the `descriptions` for a file
+ * with the exact same name as that error code + `extension`.
+ * The templating engine will then be applied to that file.
+ * That result will be passed as an additional parameter to the main templating call,
+ * using the variable `codeMessage`.
  */
 export class ErrorToTemplateConverter extends TypedRepresentationConverter {
   private readonly engine: TemplateEngine;
   private readonly templatePath: string;
+  private readonly descriptions: string;
   private readonly contentType: string;
+  private readonly extension: string;
 
-  public constructor(engine: TemplateEngine, templatePath: string, contentType: string) {
+  public constructor(engine: TemplateEngine, templatePath: string, descriptions: string, contentType: string,
+    extension: string) {
     super(INTERNAL_ERROR, contentType);
     this.engine = engine;
     this.templatePath = resolveAssetPath(templatePath);
+    this.descriptions = resolveAssetPath(descriptions);
     this.contentType = contentType;
+    this.extension = extension;
   }
 
   public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
@@ -34,10 +47,26 @@ export class ErrorToTemplateConverter extends TypedRepresentationConverter {
 
     // Render the template
     const { name, message, stack } = error;
-    const variables = { name, message, stack };
+    const description = await this.getErrorCodeMessage(error);
+    const variables = { name, message, stack, description };
     const template = await fsPromises.readFile(this.templatePath, 'utf8');
-    const html = this.engine.apply(template, variables);
+    const rendered = this.engine.apply(template, variables);
 
-    return new BasicRepresentation(html, representation.metadata, this.contentType);
+    return new BasicRepresentation(rendered, representation.metadata, this.contentType);
+  }
+
+  private async getErrorCodeMessage(error: Error): Promise<string | undefined> {
+    if (HttpError.isInstance(error) && error.options.errorCode) {
+      const filePath = joinFilePath(this.descriptions, `${error.options.errorCode}${this.extension}`);
+      let template: string;
+      try {
+        template = await fsPromises.readFile(filePath, 'utf8');
+      } catch {
+        // In case no template is found we still want to convert
+        return;
+      }
+
+      return this.engine.apply(template, (error.options.details ?? {}) as NodeJS.Dict<string>);
+    }
   }
 }
diff --git a/src/storage/conversion/MarkdownToHtmlConverter.ts b/src/storage/conversion/MarkdownToHtmlConverter.ts
new file mode 100644
index 000000000..e17466fff
--- /dev/null
+++ b/src/storage/conversion/MarkdownToHtmlConverter.ts
@@ -0,0 +1,42 @@
+import { promises as fsPromises } from 'fs';
+import marked from 'marked';
+import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
+import type { Representation } from '../../ldp/representation/Representation';
+import type { TemplateEngine } from '../../pods/generate/TemplateEngine';
+import { TEXT_HTML, TEXT_MARKDOWN } from '../../util/ContentTypes';
+import { resolveAssetPath } from '../../util/PathUtil';
+import { readableToString } from '../../util/StreamUtil';
+import type { RepresentationConverterArgs } from './RepresentationConverter';
+import { TypedRepresentationConverter } from './TypedRepresentationConverter';
+
+/**
+ * Converts markdown data to HTML.
+ * The generated HTML will be injected into the given template using the parameter `htmlBody`.
+ * A standard markdown string will be converted to a <p> tag, so html and body tags should be part of the template.
+ * In case the markdown body starts with a header (#), that value will also be used as `title` parameter.
+ */
+export class MarkdownToHtmlConverter extends TypedRepresentationConverter {
+  private readonly engine: TemplateEngine;
+  private readonly templatePath: string;
+
+  public constructor(engine: TemplateEngine, templatePath: string) {
+    super(TEXT_MARKDOWN, TEXT_HTML);
+    this.engine = engine;
+    this.templatePath = resolveAssetPath(templatePath);
+  }
+
+  public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
+    const markdown = await readableToString(representation.data);
+
+    // See if there is a title we can use
+    const match = /^\s*#+\s*([^\n]+)\n/u.exec(markdown);
+    const title = match?.[1];
+
+    const htmlBody = marked(markdown);
+
+    const template = await fsPromises.readFile(this.templatePath, 'utf8');
+    const html = this.engine.apply(template, { htmlBody, title });
+
+    return new BasicRepresentation(html, representation.metadata, TEXT_HTML);
+  }
+}
diff --git a/src/util/ContentTypes.ts b/src/util/ContentTypes.ts
index 9163ab17d..1d0bce73d 100644
--- a/src/util/ContentTypes.ts
+++ b/src/util/ContentTypes.ts
@@ -4,6 +4,7 @@ export const APPLICATION_OCTET_STREAM = 'application/octet-stream';
 export const APPLICATION_SPARQL_UPDATE = 'application/sparql-update';
 export const APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded';
 export const TEXT_HTML = 'text/html';
+export const TEXT_MARKDOWN = 'text/markdown';
 export const TEXT_TURTLE = 'text/turtle';
 
 // Internal content types (not exposed over HTTP)
diff --git a/src/util/errors/BadRequestHttpError.ts b/src/util/errors/BadRequestHttpError.ts
index 7bc5acd15..a2d93dfd7 100644
--- a/src/util/errors/BadRequestHttpError.ts
+++ b/src/util/errors/BadRequestHttpError.ts
@@ -1,3 +1,4 @@
+import type { HttpErrorOptions } from './HttpError';
 import { HttpError } from './HttpError';
 
 /**
@@ -8,9 +9,13 @@ export class BadRequestHttpError extends HttpError {
   /**
    * Default message is 'The given input is not supported by the server configuration.'.
    * @param message - Optional, more specific, message.
+   * @param options - Optional error options.
    */
-  public constructor(message?: string) {
-    super(400, 'BadRequestHttpError', message ?? 'The given input is not supported by the server configuration.');
+  public constructor(message?: string, options?: HttpErrorOptions) {
+    super(400,
+      'BadRequestHttpError',
+      message ?? 'The given input is not supported by the server configuration.',
+      options);
   }
 
   public static isInstance(error: any): error is BadRequestHttpError {
diff --git a/src/util/errors/ConflictHttpError.ts b/src/util/errors/ConflictHttpError.ts
index efb887b06..bd0916b62 100644
--- a/src/util/errors/ConflictHttpError.ts
+++ b/src/util/errors/ConflictHttpError.ts
@@ -1,10 +1,11 @@
+import type { HttpErrorOptions } from './HttpError';
 import { HttpError } from './HttpError';
 /**
  * An error thrown when a request conflict with current state of the server.
  */
 export class ConflictHttpError extends HttpError {
-  public constructor(message?: string) {
-    super(409, 'ConflictHttpError', message);
+  public constructor(message?: string, options?: HttpErrorOptions) {
+    super(409, 'ConflictHttpError', message, options);
   }
 
   public static isInstance(error: any): error is ConflictHttpError {
diff --git a/src/util/errors/ForbiddenHttpError.ts b/src/util/errors/ForbiddenHttpError.ts
index 8539490dc..2758cd15e 100644
--- a/src/util/errors/ForbiddenHttpError.ts
+++ b/src/util/errors/ForbiddenHttpError.ts
@@ -1,11 +1,12 @@
+import type { HttpErrorOptions } from './HttpError';
 import { HttpError } from './HttpError';
 
 /**
  * An error thrown when an agent is not allowed to access data.
  */
 export class ForbiddenHttpError extends HttpError {
-  public constructor(message?: string) {
-    super(403, 'ForbiddenHttpError', message);
+  public constructor(message?: string, options?: HttpErrorOptions) {
+    super(403, 'ForbiddenHttpError', message, options);
   }
 
   public static isInstance(error: any): error is ForbiddenHttpError {
diff --git a/src/util/errors/HttpError.ts b/src/util/errors/HttpError.ts
index b5b206aa0..05dc3c6e6 100644
--- a/src/util/errors/HttpError.ts
+++ b/src/util/errors/HttpError.ts
@@ -1,5 +1,11 @@
 import { isError } from './ErrorUtil';
 
+export interface HttpErrorOptions {
+  cause?: unknown;
+  errorCode?: string;
+  details?: NodeJS.Dict<unknown>;
+}
+
 /**
  * A class for all errors that could be thrown by Solid.
  * All errors inheriting from this should fix the status code thereby hiding the HTTP internals from other components.
@@ -7,17 +13,20 @@ import { isError } from './ErrorUtil';
 export class HttpError extends Error {
   protected static readonly statusCode: number;
   public readonly statusCode: number;
+  public readonly options: HttpErrorOptions;
 
   /**
    * Creates a new HTTP error. Subclasses should call this with their fixed status code.
    * @param statusCode - HTTP status code needed for the HTTP response.
    * @param name - Error name. Useful for logging and stack tracing.
-   * @param message - Message to be thrown.
+   * @param message - Error message.
+   * @param options - Optional options.
    */
-  public constructor(statusCode: number, name: string, message?: string) {
+  public constructor(statusCode: number, name: string, message?: string, options: HttpErrorOptions = {}) {
     super(message);
     this.statusCode = statusCode;
     this.name = name;
+    this.options = options;
   }
 
   public static isInstance(error: any): error is HttpError {
diff --git a/src/util/errors/InternalServerError.ts b/src/util/errors/InternalServerError.ts
index 1619d5861..c1418c9ab 100644
--- a/src/util/errors/InternalServerError.ts
+++ b/src/util/errors/InternalServerError.ts
@@ -1,10 +1,11 @@
+import type { HttpErrorOptions } from './HttpError';
 import { HttpError } from './HttpError';
 /**
  * A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.
  */
 export class InternalServerError extends HttpError {
-  public constructor(message?: string) {
-    super(500, 'InternalServerError', message);
+  public constructor(message?: string, options?: HttpErrorOptions) {
+    super(500, 'InternalServerError', message, options);
   }
 
   public static isInstance(error: any): error is InternalServerError {
diff --git a/src/util/errors/MethodNotAllowedHttpError.ts b/src/util/errors/MethodNotAllowedHttpError.ts
index f2163374c..5df8cb692 100644
--- a/src/util/errors/MethodNotAllowedHttpError.ts
+++ b/src/util/errors/MethodNotAllowedHttpError.ts
@@ -1,10 +1,11 @@
+import type { HttpErrorOptions } from './HttpError';
 import { HttpError } from './HttpError';
 /**
  * An error thrown when data was found for the requested identifier, but is not supported by the target resource.
  */
 export class MethodNotAllowedHttpError extends HttpError {
-  public constructor(message?: string) {
-    super(405, 'MethodNotAllowedHttpError', message);
+  public constructor(message?: string, options?: HttpErrorOptions) {
+    super(405, 'MethodNotAllowedHttpError', message, options);
   }
 
   public static isInstance(error: any): error is MethodNotAllowedHttpError {
diff --git a/src/util/errors/NotFoundHttpError.ts b/src/util/errors/NotFoundHttpError.ts
index 7ce6d0624..df2dbb159 100644
--- a/src/util/errors/NotFoundHttpError.ts
+++ b/src/util/errors/NotFoundHttpError.ts
@@ -1,10 +1,11 @@
+import type { HttpErrorOptions } from './HttpError';
 import { HttpError } from './HttpError';
 /**
  * An error thrown when no data was found for the requested identifier.
  */
 export class NotFoundHttpError extends HttpError {
-  public constructor(message?: string) {
-    super(404, 'NotFoundHttpError', message);
+  public constructor(message?: string, options?: HttpErrorOptions) {
+    super(404, 'NotFoundHttpError', message, options);
   }
 
   public static isInstance(error: any): error is NotFoundHttpError {
diff --git a/src/util/errors/NotImplementedHttpError.ts b/src/util/errors/NotImplementedHttpError.ts
index 16df618a5..059e6dab0 100644
--- a/src/util/errors/NotImplementedHttpError.ts
+++ b/src/util/errors/NotImplementedHttpError.ts
@@ -1,11 +1,13 @@
+import type { HttpErrorOptions } from './HttpError';
 import { HttpError } from './HttpError';
+
 /**
  * The server either does not recognize the request method, or it lacks the ability to fulfil the request.
  * Usually this implies future availability (e.g., a new feature of a web-service API).
  */
 export class NotImplementedHttpError extends HttpError {
-  public constructor(message?: string) {
-    super(501, 'NotImplementedHttpError', message);
+  public constructor(message?: string, options?: HttpErrorOptions) {
+    super(501, 'NotImplementedHttpError', message, options);
   }
 
   public static isInstance(error: any): error is NotImplementedHttpError {
diff --git a/src/util/errors/UnauthorizedHttpError.ts b/src/util/errors/UnauthorizedHttpError.ts
index f68c69f44..3a3302c71 100644
--- a/src/util/errors/UnauthorizedHttpError.ts
+++ b/src/util/errors/UnauthorizedHttpError.ts
@@ -1,11 +1,12 @@
+import type { HttpErrorOptions } from './HttpError';
 import { HttpError } from './HttpError';
 
 /**
  * An error thrown when an agent is not authorized.
  */
 export class UnauthorizedHttpError extends HttpError {
-  public constructor(message?: string) {
-    super(401, 'UnauthorizedHttpError', message);
+  public constructor(message?: string, options?: HttpErrorOptions) {
+    super(401, 'UnauthorizedHttpError', message, options);
   }
 
   public static isInstance(error: any): error is UnauthorizedHttpError {
diff --git a/src/util/errors/UnsupportedMediaTypeHttpError.ts b/src/util/errors/UnsupportedMediaTypeHttpError.ts
index 0ca1542db..7f86fe51e 100644
--- a/src/util/errors/UnsupportedMediaTypeHttpError.ts
+++ b/src/util/errors/UnsupportedMediaTypeHttpError.ts
@@ -1,11 +1,12 @@
+import type { HttpErrorOptions } from './HttpError';
 import { HttpError } from './HttpError';
 
 /**
  * An error thrown when the media type of incoming data is not supported by a parser.
  */
 export class UnsupportedMediaTypeHttpError extends HttpError {
-  public constructor(message?: string) {
-    super(415, 'UnsupportedMediaTypeHttpError', message);
+  public constructor(message?: string, options?: HttpErrorOptions) {
+    super(415, 'UnsupportedMediaTypeHttpError', message, options);
   }
 
   public static isInstance(error: any): error is UnsupportedMediaTypeHttpError {
diff --git a/src/util/handlers/WaterfallHandler.ts b/src/util/handlers/WaterfallHandler.ts
index d87a718fb..de1ac5cd0 100644
--- a/src/util/handlers/WaterfallHandler.ts
+++ b/src/util/handlers/WaterfallHandler.ts
@@ -46,9 +46,9 @@ export class WaterfallHandler<TIn, TOut> implements AsyncHandler<TIn, TOut> {
 
     try {
       handler = await this.findHandler(input);
-    } catch {
+    } catch (error: unknown) {
       this.logger.warn('All handlers failed. This might be the consequence of calling handle before canHandle.');
-      throw new InternalServerError('All handlers failed');
+      throw new InternalServerError('All handlers failed', { cause: error });
     }
 
     return handler.handle(input);
diff --git a/src/util/identifiers/BaseIdentifierStrategy.ts b/src/util/identifiers/BaseIdentifierStrategy.ts
index 529a7fae4..93bb3b7bb 100644
--- a/src/util/identifiers/BaseIdentifierStrategy.ts
+++ b/src/util/identifiers/BaseIdentifierStrategy.ts
@@ -13,7 +13,8 @@ export abstract class BaseIdentifierStrategy implements IdentifierStrategy {
 
   public getParentContainer(identifier: ResourceIdentifier): ResourceIdentifier {
     if (!this.supportsIdentifier(identifier)) {
-      throw new InternalServerError(`The identifier ${identifier.path} is outside the configured identifier space.`);
+      throw new InternalServerError(`The identifier ${identifier.path} is outside the configured identifier space.`,
+        { errorCode: 'E0001', details: { path: identifier.path }});
     }
     if (this.isRootContainer(identifier)) {
       throw new InternalServerError(`Cannot obtain the parent of ${identifier.path} because it is a root container.`);
diff --git a/src/util/locking/RedisResourceLocker.ts b/src/util/locking/RedisResourceLocker.ts
index c34b48e37..cb91e1ef6 100644
--- a/src/util/locking/RedisResourceLocker.ts
+++ b/src/util/locking/RedisResourceLocker.ts
@@ -94,7 +94,7 @@ export class RedisResourceLocker implements ResourceLocker, Finalizable {
         { ...defaultRedlockConfig, ...redlockOptions },
       );
     } catch (error: unknown) {
-      throw new InternalServerError(`Error initializing Redlock: ${error}`);
+      throw new InternalServerError(`Error initializing Redlock: ${error}`, { cause: error });
     }
   }
 
@@ -118,7 +118,7 @@ export class RedisResourceLocker implements ResourceLocker, Finalizable {
       assert(lock);
     } catch (error: unknown) {
       this.logger.debug(`Unable to acquire lock for ${resource}`);
-      throw new InternalServerError(`Unable to acquire lock for ${resource} (${error})`);
+      throw new InternalServerError(`Unable to acquire lock for ${resource} (${error})`, { cause: error });
     }
     if (this.lockMap.get(resource)) {
       throw new InternalServerError(`Acquired duplicate lock on ${resource}`);
@@ -145,7 +145,7 @@ export class RedisResourceLocker implements ResourceLocker, Finalizable {
       this.logger.debug(`Released lock for ${resource}, ${this.getLockCount()} active locks remaining!`);
     } catch (error: unknown) {
       this.logger.error(`Error releasing lock for ${resource} (${error})`);
-      throw new InternalServerError(`Unable to release lock for: ${resource}, ${error}`);
+      throw new InternalServerError(`Unable to release lock for: ${resource}, ${error}`, { cause: error });
     }
   }
 
diff --git a/templates/error/descriptions/E0001 b/templates/error/descriptions/E0001
new file mode 100644
index 000000000..6831798bc
--- /dev/null
+++ b/templates/error/descriptions/E0001
@@ -0,0 +1,5 @@
+### Requests to `{{ path }}` are not supported
+The Community Solid Server received a request for `{{ path }}`, which is not configured.
+Here are some things you can try to fix this:
+ - Have you started the server with the right hostname?
+ - If you are running the server behind a reverse proxy, did you set up the `Forwarded` header correctly?
diff --git a/templates/error/error.hbs b/templates/error/error.hbs
deleted file mode 100644
index 26dcb9f23..000000000
--- a/templates/error/error.hbs
+++ /dev/null
@@ -1,15 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="utf-8"/>
-    <meta name="viewport" content="width=device-width, initial-scale=1"/>
-    <title>{{ name }}</title>
-  </head>
-  <body>
-    <h1>{{ name }}</h1>
-    <p>{{ message }}</p>
-    {{#if stack}}
-      <pre><code>{{ stack }}</code></pre>
-    {{/if}}
-  </body>
-</html>
diff --git a/templates/error/main.md b/templates/error/main.md
new file mode 100644
index 000000000..3e30f35f3
--- /dev/null
+++ b/templates/error/main.md
@@ -0,0 +1,14 @@
+# {{ name }}
+
+{{#if description}}
+{{{ description }}}
+{{/if}}
+
+## Technical details
+{{ message }}
+
+{{#if stack}}
+```  
+{{ stack }}
+```
+{{/if}}
diff --git a/templates/main.html b/templates/main.html
new file mode 100644
index 000000000..f4f52090f
--- /dev/null
+++ b/templates/main.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="utf-8"/>
+  <meta name="viewport" content="width=device-width, initial-scale=1"/>
+  {{#if title}}
+  <title>{{ title }}</title>
+  {{/if}}
+</head>
+<body>
+{{! Triple braces to prevent HTML escaping }}
+{{{ htmlBody }}}
+</body>
+</html>


Test patch:
diff --git a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts
index 4785c52d6..7fc33ca50 100644
--- a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts
+++ b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts
@@ -4,29 +4,32 @@ import { ErrorToTemplateConverter } from '../../../../src/storage/conversion/Err
 import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
 import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
 import { readableToString } from '../../../../src/util/StreamUtil';
+import { mockFs } from '../../../util/Util';
 
-const mockRead = jest.fn().mockResolvedValue('{{ template }}');
-jest.mock('fs', (): any => ({
-  promises: { readFile: (...args: any[]): any => mockRead(...args) },
-}));
+jest.mock('fs');
 
 describe('An ErrorToTemplateConverter', (): void => {
+  let cache: { data: any };
   const identifier = { path: 'http://test.com/error' };
+  const templatePath = '/templates/error.template';
+  const descriptions = '/templates/codes';
+  const errorCode = 'E0001';
   let engine: TemplateEngine;
-  const path = '/template/error.template';
   let converter: ErrorToTemplateConverter;
   const preferences = {};
 
   beforeEach(async(): Promise<void> => {
-    mockRead.mockClear();
+    cache = mockFs('/templates');
+    cache.data['error.template'] = '{{ template }}';
+    cache.data.codes = { [`${errorCode}.html`]: '{{{ errorText }}}' };
     engine = {
       apply: jest.fn().mockReturnValue('<html>'),
     };
 
-    converter = new ErrorToTemplateConverter(engine, path, 'text/html');
+    converter = new ErrorToTemplateConverter(engine, templatePath, descriptions, 'text/html', '.html');
   });
 
-  it('supports going from errors to quads.', async(): Promise<void> => {
+  it('supports going from errors to the given content type.', async(): Promise<void> => {
     await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 });
     await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 });
   });
@@ -47,8 +50,6 @@ describe('An ErrorToTemplateConverter', (): void => {
     expect(result.binary).toBe(true);
     expect(result.metadata.contentType).toBe('text/html');
     await expect(readableToString(result.data)).resolves.toBe('<html>');
-    expect(mockRead).toHaveBeenCalledTimes(1);
-    expect(mockRead).toHaveBeenLastCalledWith(path, 'utf8');
     expect(engine.apply).toHaveBeenCalledTimes(1);
     expect(engine.apply).toHaveBeenLastCalledWith(
       '{{ template }}', { name: 'BadRequestHttpError', message: 'error text', stack: error.stack },
@@ -65,11 +66,63 @@ describe('An ErrorToTemplateConverter', (): void => {
     expect(result.binary).toBe(true);
     expect(result.metadata.contentType).toBe('text/html');
     await expect(readableToString(result.data)).resolves.toBe('<html>');
-    expect(mockRead).toHaveBeenCalledTimes(1);
-    expect(mockRead).toHaveBeenLastCalledWith(path, 'utf8');
     expect(engine.apply).toHaveBeenCalledTimes(1);
     expect(engine.apply).toHaveBeenLastCalledWith(
       '{{ template }}', { name: 'BadRequestHttpError', message: 'error text' },
     );
   });
+
+  it('adds additional information if an error code is found.', async(): Promise<void> => {
+    const error = new BadRequestHttpError('error text', { errorCode, details: { key: 'val' }});
+    const representation = new BasicRepresentation([ error ], 'internal/error', false);
+    const prom = converter.handle({ identifier, representation, preferences });
+    await expect(prom).resolves.toBeDefined();
+    const result = await prom;
+    expect(result.binary).toBe(true);
+    expect(result.metadata.contentType).toBe('text/html');
+    await expect(readableToString(result.data)).resolves.toBe('<html>');
+    expect(engine.apply).toHaveBeenCalledTimes(2);
+    expect(engine.apply).toHaveBeenCalledWith(
+      '{{{ errorText }}}', { key: 'val' },
+    );
+    expect(engine.apply).toHaveBeenLastCalledWith(
+      '{{ template }}',
+      { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' },
+    );
+  });
+
+  it('sends an empty object for additional error code parameters if none are defined.', async(): Promise<void> => {
+    const error = new BadRequestHttpError('error text', { errorCode });
+    const representation = new BasicRepresentation([ error ], 'internal/error', false);
+    const prom = converter.handle({ identifier, representation, preferences });
+    await expect(prom).resolves.toBeDefined();
+    const result = await prom;
+    expect(result.binary).toBe(true);
+    expect(result.metadata.contentType).toBe('text/html');
+    await expect(readableToString(result.data)).resolves.toBe('<html>');
+    expect(engine.apply).toHaveBeenCalledTimes(2);
+    expect(engine.apply).toHaveBeenCalledWith(
+      '{{{ errorText }}}', { },
+    );
+    expect(engine.apply).toHaveBeenLastCalledWith(
+      '{{ template }}',
+      { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' },
+    );
+  });
+
+  it('converts errors with a code as usual if no corresponding template is found.', async(): Promise<void> => {
+    const error = new BadRequestHttpError('error text', { errorCode: 'invalid' });
+    const representation = new BasicRepresentation([ error ], 'internal/error', false);
+    const prom = converter.handle({ identifier, representation, preferences });
+    await expect(prom).resolves.toBeDefined();
+    const result = await prom;
+    expect(result.binary).toBe(true);
+    expect(result.metadata.contentType).toBe('text/html');
+    await expect(readableToString(result.data)).resolves.toBe('<html>');
+    expect(engine.apply).toHaveBeenCalledTimes(1);
+    expect(engine.apply).toHaveBeenLastCalledWith(
+      '{{ template }}',
+      { name: 'BadRequestHttpError', message: 'error text', stack: error.stack },
+    );
+  });
 });
diff --git a/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts b/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts
new file mode 100644
index 000000000..6eea42c3f
--- /dev/null
+++ b/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts
@@ -0,0 +1,61 @@
+import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
+import type { TemplateEngine } from '../../../../src/pods/generate/TemplateEngine';
+import { MarkdownToHtmlConverter } from '../../../../src/storage/conversion/MarkdownToHtmlConverter';
+import { readableToString } from '../../../../src/util/StreamUtil';
+import { mockFs } from '../../../util/Util';
+
+jest.mock('fs');
+
+describe('A MarkdownToHtmlConverter', (): void => {
+  let cache: { data: any };
+  const identifier = { path: 'http://test.com/text' };
+  const templatePath = '/templates/error.template';
+  const preferences = {};
+  let engine: TemplateEngine;
+  let converter: MarkdownToHtmlConverter;
+
+  beforeEach(async(): Promise<void> => {
+    cache = mockFs('/templates');
+    cache.data['error.template'] = '{{ template }}';
+    engine = {
+      apply: jest.fn().mockReturnValue('<html>'),
+    };
+
+    converter = new MarkdownToHtmlConverter(engine, templatePath);
+  });
+
+  it('supports going from markdown to html.', async(): Promise<void> => {
+    await expect(converter.getInputTypes()).resolves.toEqual({ 'text/markdown': 1 });
+    await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 });
+  });
+
+  it('converts markdown and inserts it in the template.', async(): Promise<void> => {
+    const markdown = 'Text `code` more text.';
+    const representation = new BasicRepresentation(markdown, 'text/markdown', true);
+    const prom = converter.handle({ identifier, representation, preferences });
+    await expect(prom).resolves.toBeDefined();
+    const result = await prom;
+    expect(result.binary).toBe(true);
+    expect(result.metadata.contentType).toBe('text/html');
+    await expect(readableToString(result.data)).resolves.toBe('<html>');
+    expect(engine.apply).toHaveBeenCalledTimes(1);
+    expect(engine.apply).toHaveBeenLastCalledWith(
+      '{{ template }}', { htmlBody: '<p>Text <code>code</code> more text.</p>\n' },
+    );
+  });
+
+  it('uses the main markdown header as title if there is one.', async(): Promise<void> => {
+    const markdown = '# title text\nmore text';
+    const representation = new BasicRepresentation(markdown, 'text/markdown', true);
+    const prom = converter.handle({ identifier, representation, preferences });
+    await expect(prom).resolves.toBeDefined();
+    const result = await prom;
+    expect(result.binary).toBe(true);
+    expect(result.metadata.contentType).toBe('text/html');
+    await expect(readableToString(result.data)).resolves.toBe('<html>');
+    expect(engine.apply).toHaveBeenCalledTimes(1);
+    expect(engine.apply).toHaveBeenLastCalledWith(
+      '{{ template }}', { htmlBody: '<h1 id="title-text">title text</h1>\n<p>more text</p>\n', title: 'title text' },
+    );
+  });
+});
diff --git a/test/unit/util/identifiers/BaseIdentifierStrategy.test.ts b/test/unit/util/identifiers/BaseIdentifierStrategy.test.ts
index 5686bf39d..a4675f2c7 100644
--- a/test/unit/util/identifiers/BaseIdentifierStrategy.test.ts
+++ b/test/unit/util/identifiers/BaseIdentifierStrategy.test.ts
@@ -22,6 +22,8 @@ describe('A BaseIdentifierStrategy', (): void => {
   it('errors when attempting to get the parent of an unsupported identifier.', async(): Promise<void> => {
     expect((): any => strategy.getParentContainer({ path: '/unsupported' }))
       .toThrow('The identifier /unsupported is outside the configured identifier space.');
+    expect((): any => strategy.getParentContainer({ path: '/unsupported' }))
+      .toThrow(expect.objectContaining({ options: { errorCode: 'E0001', details: { path: '/unsupported' }}}));
   });
 
   it('errors when attempting to get the parent of a root container.', async(): Promise<void> => {