Repository: solid/community-server (ts)

Issue:
Store metadata relevant for the server
#### Feature description:

There are certain values that we should store when starting a server that could help inform users about potential issues.

The **version** of the server can be used to inform the user if some migration is needed in case they upgrade to a newer version. We could automatically detect that value based on the package.json. If we create a migration tool it would then need to be able to read out that value so it would have to make use of the correct version of the storage classes to read it out as how we store data might also change between versions (see #1013).

If we store the **baseUrl** parameter we can warn them if at a later point in time they start the server with a different value (see https://github.com/solid/community-server/issues/1143#issuecomment-1032569011). I would immediately overwrite that value after the first warning though, not much more we can do after that.

Both of these can be added using a new Initializer.

Pull request:
Add metadata initializers
#### 📁 Related issues

Closes #1155 

#### ✍️ Description

Adds 2 initializers:

The `BaseUrlInitializer` stores the `baseUrl` the server was started with. If on the next boot this value differs, a warning will be logged.

The `VersionInitializer` stores the current version from the `package.json`. It doesn't actually do anything but this will be useful in the future if we need to do an internal data migration for v4.


Patch:
diff --git a/config/app/init/base/init.json b/config/app/init/base/init.json
index d794509f0..f3fad40d1 100644
--- a/config/app/init/base/init.json
+++ b/config/app/init/base/init.json
@@ -1,8 +1,10 @@
 {
   "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
   "import": [
+    "files-scs:config/app/init/initializers/base-url.json",
     "files-scs:config/app/init/initializers/logger.json",
-    "files-scs:config/app/init/initializers/server.json"
+    "files-scs:config/app/init/initializers/server.json",
+    "files-scs:config/app/init/initializers/version.json"
   ],
   "@graph": [
     {
@@ -11,8 +13,10 @@
       "@type": "SequenceHandler",
       "handlers": [
         { "@id": "urn:solid-server:default:LoggerInitializer" },
+        { "@id": "urn:solid-server:default:BaseUrlVerifier" },
         { "@id": "urn:solid-server:default:ParallelInitializer" },
-        { "@id": "urn:solid-server:default:ServerInitializer" }
+        { "@id": "urn:solid-server:default:ServerInitializer" },
+        { "@id": "urn:solid-server:default:ModuleVersionVerifier" }
       ]
     }
   ]
diff --git a/config/app/init/initializers/base-url.json b/config/app/init/initializers/base-url.json
new file mode 100644
index 000000000..0eb1176dd
--- /dev/null
+++ b/config/app/init/initializers/base-url.json
@@ -0,0 +1,13 @@
+{
+  "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
+  "@graph": [
+    {
+      "comment": "Logs a warning if the base URL changes.",
+      "@id": "urn:solid-server:default:BaseUrlVerifier",
+      "@type": "BaseUrlVerifier",
+      "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
+      "storageKey": "current-base-url",
+      "storage": { "@id": "urn:solid-server:default:SetupStorage" }
+    }
+  ]
+}
diff --git a/config/app/init/initializers/version.json b/config/app/init/initializers/version.json
new file mode 100644
index 000000000..ad06d3d1f
--- /dev/null
+++ b/config/app/init/initializers/version.json
@@ -0,0 +1,12 @@
+{
+  "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
+  "@graph": [
+    {
+      "comment": "Logs a warning if the base URL changes.",
+      "@id": "urn:solid-server:default:ModuleVersionVerifier",
+      "@type": "ModuleVersionVerifier",
+      "storageKey": "current-server-version",
+      "storage": { "@id": "urn:solid-server:default:SetupStorage" }
+    }
+  ]
+}
diff --git a/package-lock.json b/package-lock.json
index 34e7d8db0..7c54daae0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
         "@types/bcrypt": "^5.0.0",
         "@types/cors": "^2.8.12",
         "@types/end-of-stream": "^1.4.1",
+        "@types/fs-extra": "^9.0.13",
         "@types/lodash.orderby": "^4.6.6",
         "@types/marked": "^4.0.2",
         "@types/mime-types": "^2.1.1",
@@ -42,6 +43,7 @@
         "end-of-stream": "^1.4.4",
         "escape-string-regexp": "^4.0.0",
         "fetch-sparql-endpoint": "^2.4.0",
+        "fs-extra": "^10.0.0",
         "handlebars": "^4.7.7",
         "jose": "^4.4.0",
         "lodash.orderby": "^4.6.0",
@@ -76,7 +78,6 @@
         "@tsconfig/node12": "^1.0.9",
         "@types/cheerio": "^0.22.30",
         "@types/ejs": "^3.1.0",
-        "@types/fs-extra": "^9.0.13",
         "@types/jest": "^27.4.0",
         "@types/set-cookie-parser": "^2.4.2",
         "@types/supertest": "^2.0.11",
@@ -91,7 +92,6 @@
         "eslint-plugin-jest": "^26.0.0",
         "eslint-plugin-tsdoc": "^0.2.14",
         "eslint-plugin-unused-imports": "^2.0.0",
-        "fs-extra": "^10.0.0",
         "husky": "^4.3.8",
         "jest": "^27.4.7",
         "jest-rdf": "^1.7.0",
@@ -4430,7 +4430,6 @@
       "version": "9.0.13",
       "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
       "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==",
-      "dev": true,
       "dependencies": {
         "@types/node": "*"
       }
@@ -8310,7 +8309,6 @@
       "version": "10.0.0",
       "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.0.tgz",
       "integrity": "sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ==",
-      "dev": true,
       "dependencies": {
         "graceful-fs": "^4.2.0",
         "jsonfile": "^6.0.1",
@@ -8324,7 +8322,6 @@
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
       "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
-      "dev": true,
       "engines": {
         "node": ">= 10.0.0"
       }
@@ -8907,8 +8904,7 @@
     "node_modules/graceful-fs": {
       "version": "4.2.4",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz",
-      "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==",
-      "dev": true
+      "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw=="
     },
     "node_modules/graphql": {
       "version": "15.8.0",
@@ -10858,7 +10854,6 @@
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
       "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
-      "dev": true,
       "dependencies": {
         "universalify": "^2.0.0"
       },
@@ -10870,7 +10865,6 @@
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
       "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
-      "dev": true,
       "engines": {
         "node": ">= 10.0.0"
       }
@@ -18559,7 +18553,6 @@
       "version": "9.0.13",
       "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
       "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==",
-      "dev": true,
       "requires": {
         "@types/node": "*"
       }
@@ -21542,7 +21535,6 @@
       "version": "10.0.0",
       "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.0.tgz",
       "integrity": "sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ==",
-      "dev": true,
       "requires": {
         "graceful-fs": "^4.2.0",
         "jsonfile": "^6.0.1",
@@ -21552,8 +21544,7 @@
         "universalify": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
-          "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
-          "dev": true
+          "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ=="
         }
       }
     },
@@ -21986,8 +21977,7 @@
     "graceful-fs": {
       "version": "4.2.4",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz",
-      "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==",
-      "dev": true
+      "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw=="
     },
     "graphql": {
       "version": "15.8.0",
@@ -23445,7 +23435,6 @@
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
       "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
-      "dev": true,
       "requires": {
         "graceful-fs": "^4.1.6",
         "universalify": "^2.0.0"
@@ -23454,8 +23443,7 @@
         "universalify": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
-          "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
-          "dev": true
+          "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ=="
         }
       }
     },
diff --git a/package.json b/package.json
index 814bbc0ce..9f0cfa30c 100644
--- a/package.json
+++ b/package.json
@@ -82,6 +82,7 @@
     "@types/bcrypt": "^5.0.0",
     "@types/cors": "^2.8.12",
     "@types/end-of-stream": "^1.4.1",
+    "@types/fs-extra": "^9.0.13",
     "@types/lodash.orderby": "^4.6.6",
     "@types/marked": "^4.0.2",
     "@types/mime-types": "^2.1.1",
@@ -108,6 +109,7 @@
     "end-of-stream": "^1.4.4",
     "escape-string-regexp": "^4.0.0",
     "fetch-sparql-endpoint": "^2.4.0",
+    "fs-extra": "^10.0.0",
     "handlebars": "^4.7.7",
     "jose": "^4.4.0",
     "lodash.orderby": "^4.6.0",
@@ -139,7 +141,6 @@
     "@tsconfig/node12": "^1.0.9",
     "@types/cheerio": "^0.22.30",
     "@types/ejs": "^3.1.0",
-    "@types/fs-extra": "^9.0.13",
     "@types/jest": "^27.4.0",
     "@types/set-cookie-parser": "^2.4.2",
     "@types/supertest": "^2.0.11",
@@ -154,7 +155,6 @@
     "eslint-plugin-jest": "^26.0.0",
     "eslint-plugin-tsdoc": "^0.2.14",
     "eslint-plugin-unused-imports": "^2.0.0",
-    "fs-extra": "^10.0.0",
     "husky": "^4.3.8",
     "jest": "^27.4.7",
     "jest-rdf": "^1.7.0",
diff --git a/src/index.ts b/src/index.ts
index 47587d80e..86f2cf068 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -201,12 +201,14 @@ export * from './init/variables/SettingsResolver';
 // Init
 export * from './init/App';
 export * from './init/AppRunner';
+export * from './init/BaseUrlVerifier';
 export * from './init/CliResolver';
 export * from './init/ConfigPodInitializer';
 export * from './init/ContainerInitializer';
 export * from './init/Initializer';
 export * from './init/LoggerInitializer';
 export * from './init/ServerInitializer';
+export * from './init/ModuleVersionVerifier';
 
 // Logging
 export * from './logging/LazyLogger';
diff --git a/src/init/AppRunner.ts b/src/init/AppRunner.ts
index 39b4913a7..5e2d77677 100644
--- a/src/init/AppRunner.ts
+++ b/src/init/AppRunner.ts
@@ -6,12 +6,12 @@ import yargs from 'yargs';
 import { LOG_LEVELS } from '../logging/LogLevel';
 import { getLoggerFor } from '../logging/LogUtil';
 import { createErrorMessage, isError } from '../util/errors/ErrorUtil';
-import { modulePathPlaceholder, resolveAssetPath } from '../util/PathUtil';
+import { resolveModulePath, resolveAssetPath } from '../util/PathUtil';
 import type { App } from './App';
 import type { CliResolver } from './CliResolver';
 import type { CliArgv, VariableBindings } from './variables/Types';
 
-const DEFAULT_CONFIG = `${modulePathPlaceholder}config/default.json`;
+const DEFAULT_CONFIG = resolveModulePath('config/default.json');
 
 const DEFAULT_CLI_RESOLVER = 'urn:solid-server-app-setup:default:CliResolver';
 const DEFAULT_APP = 'urn:solid-server:default:App';
diff --git a/src/init/BaseUrlVerifier.ts b/src/init/BaseUrlVerifier.ts
new file mode 100644
index 000000000..4b2b39b4d
--- /dev/null
+++ b/src/init/BaseUrlVerifier.ts
@@ -0,0 +1,32 @@
+import { getLoggerFor } from '../logging/LogUtil';
+import type { KeyValueStorage } from '../storage/keyvalue/KeyValueStorage';
+import { Initializer } from './Initializer';
+
+/**
+ * Stores the `baseUrl` value that was used to start the server
+ * and warns the user in case it differs from the previous one.
+ */
+export class BaseUrlVerifier extends Initializer {
+  private readonly baseUrl: string;
+  private readonly storageKey: string;
+  private readonly storage: KeyValueStorage<string, string>;
+
+  private readonly logger = getLoggerFor(this);
+
+  public constructor(baseUrl: string, storageKey: string, storage: KeyValueStorage<string, string>) {
+    super();
+    this.baseUrl = baseUrl;
+    this.storageKey = storageKey;
+    this.storage = storage;
+  }
+
+  public async handle(): Promise<void> {
+    const previousValue = await this.storage.get(this.storageKey);
+    if (previousValue && this.baseUrl !== previousValue) {
+      this.logger.warn(`The server is being started with a base URL of ${this.baseUrl
+      } while it was previously started with ${previousValue
+      }. Resources generated with the previous server instance, such as a WebID, might no longer work correctly.`);
+    }
+    await this.storage.set(this.storageKey, this.baseUrl);
+  }
+}
diff --git a/src/init/ModuleVersionVerifier.ts b/src/init/ModuleVersionVerifier.ts
new file mode 100644
index 000000000..dd17922de
--- /dev/null
+++ b/src/init/ModuleVersionVerifier.ts
@@ -0,0 +1,28 @@
+import { readJson } from 'fs-extra';
+import type { KeyValueStorage } from '../storage/keyvalue/KeyValueStorage';
+import { resolveModulePath } from '../util/PathUtil';
+import { Initializer } from './Initializer';
+
+const PACKAGE_JSON_PATH = resolveModulePath('package.json');
+
+/**
+ * This initializer simply writes the version number of the server to the storage.
+ * This will be relevant in the future when we look into migration initializers.
+ *
+ * It automatically parses the version number from the `package.json`.
+ */
+export class ModuleVersionVerifier extends Initializer {
+  private readonly storageKey: string;
+  private readonly storage: KeyValueStorage<string, string>;
+
+  public constructor(storageKey: string, storage: KeyValueStorage<string, string>) {
+    super();
+    this.storageKey = storageKey;
+    this.storage = storage;
+  }
+
+  public async handle(): Promise<void> {
+    const pkg = await readJson(PACKAGE_JSON_PATH);
+    await this.storage.set(this.storageKey, pkg.version);
+  }
+}
diff --git a/src/storage/conversion/ErrorToTemplateConverter.ts b/src/storage/conversion/ErrorToTemplateConverter.ts
index 563668a54..48aa23357 100644
--- a/src/storage/conversion/ErrorToTemplateConverter.ts
+++ b/src/storage/conversion/ErrorToTemplateConverter.ts
@@ -3,7 +3,7 @@ import { BasicRepresentation } from '../../http/representation/BasicRepresentati
 import type { Representation } from '../../http/representation/Representation';
 import { INTERNAL_ERROR } from '../../util/ContentTypes';
 import { HttpError } from '../../util/errors/HttpError';
-import { modulePathPlaceholder } from '../../util/PathUtil';
+import { resolveModulePath } from '../../util/PathUtil';
 import { getSingleItem } from '../../util/StreamUtil';
 import type { TemplateEngine } from '../../util/templates/TemplateEngine';
 import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter';
@@ -18,8 +18,8 @@ export interface TemplateOptions {
 }
 
 const DEFAULT_TEMPLATE_OPTIONS: TemplateOptions = {
-  mainTemplatePath: `${modulePathPlaceholder}templates/error/main.md.hbs`,
-  codeTemplatesPath: `${modulePathPlaceholder}templates/error/descriptions/`,
+  mainTemplatePath: resolveModulePath('templates/error/main.md.hbs'),
+  codeTemplatesPath: resolveModulePath('templates/error/descriptions/'),
   extension: '.md.hbs',
   contentType: 'text/markdown',
 };
diff --git a/src/util/PathUtil.ts b/src/util/PathUtil.ts
index c5078e4eb..29a1520d8 100644
--- a/src/util/PathUtil.ts
+++ b/src/util/PathUtil.ts
@@ -197,18 +197,33 @@ export function getModuleRoot(): string {
 
 /**
  * A placeholder for the path to the `@solid/community-server` module root.
- * The resolveAssetPath function will replace this string with the actual path.
+ * The `resolveAssetPath` function will replace this string with the actual path.
  */
 export const modulePathPlaceholder = '@css:';
 
+/**
+ * Creates a path starting from the `@solid/community-server` module root,
+ * to be resolved by the `resolveAssetPath` function.
+ */
+export function modulePath(relativePath = ''): string {
+  return `${modulePathPlaceholder}${relativePath}`;
+}
+
+/**
+ * Creates an absolute path starting from the `@solid/community-server` module root.
+ */
+export function resolveModulePath(relativePath = ''): string {
+  return joinFilePath(getModuleRoot(), relativePath);
+}
+
 /**
  * Converts file path inputs into absolute paths.
  * Works similar to `absoluteFilePath` but paths that start with the `modulePathPlaceholder`
  * will be relative to the module directory instead of the cwd.
  */
-export function resolveAssetPath(path: string = modulePathPlaceholder): string {
+export function resolveAssetPath(path = modulePathPlaceholder): string {
   if (path.startsWith(modulePathPlaceholder)) {
-    return joinFilePath(getModuleRoot(), path.slice(modulePathPlaceholder.length));
+    return resolveModulePath(path.slice(modulePathPlaceholder.length));
   }
   return absoluteFilePath(path);
 }


Test patch:
diff --git a/test/unit/init/BaseUrlVerifier.test.ts b/test/unit/init/BaseUrlVerifier.test.ts
new file mode 100644
index 000000000..a669caf6a
--- /dev/null
+++ b/test/unit/init/BaseUrlVerifier.test.ts
@@ -0,0 +1,38 @@
+import { BaseUrlVerifier } from '../../../src/init/BaseUrlVerifier';
+import type { Logger } from '../../../src/logging/Logger';
+import { getLoggerFor } from '../../../src/logging/LogUtil';
+import type { KeyValueStorage } from '../../../src/storage/keyvalue/KeyValueStorage';
+
+jest.mock('../../../src/logging/LogUtil', (): any => {
+  const logger: Logger = { warn: jest.fn() } as any;
+  return { getLoggerFor: (): Logger => logger };
+});
+
+describe('A BaseUrlVerifier', (): void => {
+  const logger: jest.Mocked<Logger> = getLoggerFor(BaseUrlVerifier) as any;
+  const baseUrl1 = 'http://base1.example.com/';
+  const baseUrl2 = 'http://base2.example.com/';
+  const storageKey = 'uniqueKey';
+  let storage: KeyValueStorage<string, string>;
+
+  beforeEach(async(): Promise<void> => {
+    storage = new Map<string, string>() as any;
+    jest.clearAllMocks();
+  });
+
+  it('stores the value if no value was stored yet.', async(): Promise<void> => {
+    const initializer = new BaseUrlVerifier(baseUrl1, storageKey, storage);
+    await expect(initializer.handle()).resolves.toBeUndefined();
+    expect(logger.warn).toHaveBeenCalledTimes(0);
+  });
+
+  it('logs a warning in case the value changes.', async(): Promise<void> => {
+    let initializer = new BaseUrlVerifier(baseUrl1, storageKey, storage);
+    await expect(initializer.handle()).resolves.toBeUndefined();
+    expect(logger.warn).toHaveBeenCalledTimes(0);
+
+    initializer = new BaseUrlVerifier(baseUrl2, storageKey, storage);
+    await expect(initializer.handle()).resolves.toBeUndefined();
+    expect(logger.warn).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/test/unit/init/ModuleVersionVerifier.test.ts b/test/unit/init/ModuleVersionVerifier.test.ts
new file mode 100644
index 000000000..f524c0340
--- /dev/null
+++ b/test/unit/init/ModuleVersionVerifier.test.ts
@@ -0,0 +1,17 @@
+import { ModuleVersionVerifier } from '../../../src/init/ModuleVersionVerifier';
+
+describe('A ModuleVersionVerifier', (): void => {
+  const storageKey = 'uniqueVersionKey';
+  let storageMap: Map<string, string>;
+  let initializer: ModuleVersionVerifier;
+
+  beforeEach(async(): Promise<void> => {
+    storageMap = new Map<string, string>();
+    initializer = new ModuleVersionVerifier(storageKey, storageMap as any);
+  });
+
+  it('stores the latest version.', async(): Promise<void> => {
+    await expect(initializer.handle()).resolves.toBeUndefined();
+    expect(storageMap.get(storageKey)).toMatch(/^\d+\.\d+\.\d+(?:-.+)?/u);
+  });
+});
diff --git a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts
index 27f106859..31e190fcb 100644
--- a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts
+++ b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts
@@ -1,6 +1,7 @@
 import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
 import { ErrorToTemplateConverter } from '../../../../src/storage/conversion/ErrorToTemplateConverter';
 import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
+import { resolveModulePath } from '../../../../src/util/PathUtil';
 import { readableToString } from '../../../../src/util/StreamUtil';
 import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine';
 
@@ -155,9 +156,9 @@ describe('An ErrorToTemplateConverter', (): void => {
     expect(templateEngine.render).toHaveBeenCalledTimes(2);
     expect(templateEngine.render).toHaveBeenNthCalledWith(1,
       { key: 'val' },
-      { templatePath: '@css:templates/error/descriptions/', templateFile: 'E0001.md.hbs' });
+      { templatePath: resolveModulePath('templates/error/descriptions/'), templateFile: 'E0001.md.hbs' });
     expect(templateEngine.render).toHaveBeenNthCalledWith(2,
       { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' },
-      { templateFile: '@css:templates/error/main.md.hbs' });
+      { templateFile: resolveModulePath('templates/error/main.md.hbs') });
   });
 });
diff --git a/test/unit/util/PathUtil.test.ts b/test/unit/util/PathUtil.test.ts
index 0d9e3c7cc..d809d20b0 100644
--- a/test/unit/util/PathUtil.test.ts
+++ b/test/unit/util/PathUtil.test.ts
@@ -1,4 +1,4 @@
-import { existsSync } from 'fs';
+import { promises as fsPromises } from 'fs';
 import type { TargetExtractor } from '../../../src/http/input/identifier/TargetExtractor';
 import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier';
 import type { HttpRequest } from '../../../src/server/HttpRequest';
@@ -16,49 +16,51 @@ import {
   isContainerPath,
   joinFilePath,
   joinUrl,
+  modulePath,
   normalizeFilePath,
   resolveAssetPath,
+  resolveModulePath,
   toCanonicalUriPath,
   trimTrailingSlashes,
 } from '../../../src/util/PathUtil';
 
 describe('PathUtil', (): void => {
   describe('#normalizeFilePath', (): void => {
-    it('normalizes POSIX paths.', async(): Promise<void> => {
+    it('normalizes POSIX paths.', (): void => {
       expect(normalizeFilePath('/foo/bar/../baz')).toBe('/foo/baz');
     });
 
-    it('normalizes Windows paths.', async(): Promise<void> => {
+    it('normalizes Windows paths.', (): void => {
       expect(normalizeFilePath('c:\\foo\\bar\\..\\baz')).toBe('c:/foo/baz');
     });
   });
 
   describe('#joinFilePath', (): void => {
-    it('joins POSIX paths.', async(): Promise<void> => {
+    it('joins POSIX paths.', (): void => {
       expect(joinFilePath('/foo/bar/', '..', '/baz')).toBe('/foo/baz');
     });
 
-    it('joins Windows paths.', async(): Promise<void> => {
+    it('joins Windows paths.', (): void => {
       expect(joinFilePath('c:\\foo\\bar\\', '..', '/baz')).toBe(`c:/foo/baz`);
     });
   });
 
   describe('#absoluteFilePath', (): void => {
-    it('does not change absolute posix paths.', async(): Promise<void> => {
+    it('does not change absolute posix paths.', (): void => {
       expect(absoluteFilePath('/foo/bar/')).toBe('/foo/bar/');
     });
 
-    it('converts absolute win32 paths to posix paths.', async(): Promise<void> => {
+    it('converts absolute win32 paths to posix paths.', (): void => {
       expect(absoluteFilePath('C:\\foo\\bar')).toBe('C:/foo/bar');
     });
 
-    it('makes relative paths absolute.', async(): Promise<void> => {
+    it('makes relative paths absolute.', (): void => {
       expect(absoluteFilePath('foo/bar/')).toEqual(joinFilePath(process.cwd(), 'foo/bar/'));
     });
   });
 
   describe('#ensureTrailingSlash', (): void => {
-    it('makes sure there is always exactly 1 slash.', async(): Promise<void> => {
+    it('makes sure there is always exactly 1 slash.', (): void => {
       expect(ensureTrailingSlash('http://test.com')).toBe('http://test.com/');
       expect(ensureTrailingSlash('http://test.com/')).toBe('http://test.com/');
       expect(ensureTrailingSlash('http://test.com//')).toBe('http://test.com/');
@@ -67,7 +69,7 @@ describe('PathUtil', (): void => {
   });
 
   describe('#trimTrailingSlashes', (): void => {
-    it('removes all trailing slashes.', async(): Promise<void> => {
+    it('removes all trailing slashes.', (): void => {
       expect(trimTrailingSlashes('http://test.com')).toBe('http://test.com');
       expect(trimTrailingSlashes('http://test.com/')).toBe('http://test.com');
       expect(trimTrailingSlashes('http://test.com//')).toBe('http://test.com');
@@ -76,58 +78,58 @@ describe('PathUtil', (): void => {
   });
 
   describe('#getExtension', (): void => {
-    it('returns the extension of a path.', async(): Promise<void> => {
+    it('returns the extension of a path.', (): void => {
       expect(getExtension('/a/b.txt')).toBe('txt');
       expect(getExtension('/a/btxt')).toBe('');
     });
   });
 
   describe('#toCanonicalUriPath', (): void => {
-    it('encodes only the necessary parts.', async(): Promise<void> => {
+    it('encodes only the necessary parts.', (): void => {
       expect(toCanonicalUriPath('/a%20path&/name')).toBe('/a%20path%26/name');
     });
 
-    it('leaves the query string untouched.', async(): Promise<void> => {
+    it('leaves the query string untouched.', (): void => {
       expect(toCanonicalUriPath('/a%20path&/name?abc=def&xyz')).toBe('/a%20path%26/name?abc=def&xyz');
     });
   });
 
   describe('#decodeUriPathComponents', (): void => {
-    it('decodes all parts of a path.', async(): Promise<void> => {
+    it('decodes all parts of a path.', (): void => {
       expect(decodeUriPathComponents('/a%20path&/name')).toBe('/a path&/name');
     });
 
-    it('leaves the query string untouched.', async(): Promise<void> => {
+    it('leaves the query string untouched.', (): void => {
       expect(decodeUriPathComponents('/a%20path&/name?abc=def&xyz')).toBe('/a path&/name?abc=def&xyz');
     });
   });
 
   describe('#encodeUriPathComponents', (): void => {
-    it('encodes all parts of a path.', async(): Promise<void> => {
+    it('encodes all parts of a path.', (): void => {
       expect(encodeUriPathComponents('/a%20path&/name')).toBe('/a%2520path%26/name');
     });
 
-    it('leaves the query string untouched.', async(): Promise<void> => {
+    it('leaves the query string untouched.', (): void => {
       expect(encodeUriPathComponents('/a%20path&/name?abc=def&xyz')).toBe('/a%2520path%26/name?abc=def&xyz');
     });
   });
 
   describe('#isContainerPath', (): void => {
-    it('returns true if the path ends with a slash.', async(): Promise<void> => {
+    it('returns true if the path ends with a slash.', (): void => {
       expect(isContainerPath('/a/b')).toBe(false);
       expect(isContainerPath('/a/b/')).toBe(true);
     });
   });
 
   describe('#isContainerIdentifier', (): void => {
-    it('works af isContainerPath but for identifiers.', async(): Promise<void> => {
+    it('works af isContainerPath but for identifiers.', (): void => {
       expect(isContainerIdentifier({ path: '/a/b' })).toBe(false);
       expect(isContainerIdentifier({ path: '/a/b/' })).toBe(true);
     });
   });
 
   describe('#extractScheme', (): void => {
-    it('splits a URL.', async(): Promise<void> => {
+    it('splits a URL.', (): void => {
       expect(extractScheme('http://test.com/foo')).toEqual({ scheme: 'http://', rest: 'test.com/foo' });
     });
   });
@@ -155,7 +157,7 @@ describe('PathUtil', (): void => {
   });
 
   describe('#createSubdomainRegexp', (): void => {
-    it('creates a regex to match the URL and extract a subdomain.', async(): Promise<void> => {
+    it('creates a regex to match the URL and extract a subdomain.', (): void => {
       const regex = createSubdomainRegexp('http://test.com/foo/');
       expect(regex.exec('http://test.com/foo/')![1]).toBeUndefined();
       expect(regex.exec('http://test.com/foo/bar')![1]).toBeUndefined();
@@ -171,28 +173,48 @@ describe('PathUtil', (): void => {
       // Note that this test only makes sense as long as the dist folder is on the same level as the src folder
       const root = getModuleRoot();
       const packageJson = joinFilePath(root, 'package.json');
-      expect(existsSync(packageJson)).toBe(true);
+      expect(await fsPromises.access(packageJson)).toBeUndefined();
     });
   });
 
-  describe('#resolvePathInput', (): void => {
-    it('interprets paths relative to the module root when starting with @css:.', async(): Promise<void> => {
+  describe('#modulePath', (): void => {
+    it('transforms the empty input into "@css:".', (): void => {
+      expect(modulePath()).toBe('@css:');
+    });
+
+    it('prefixes a path with "@css".', (): void => {
+      expect(modulePath('foo/bar.json')).toBe('@css:foo/bar.json');
+    });
+  });
+
+  describe('#resolveModulePath', (): void => {
+    it('transforms the empty input into the module root path.', (): void => {
+      expect(resolveModulePath()).toBe(getModuleRoot());
+    });
+
+    it('prefixes a path with the module root path.', (): void => {
+      expect(resolveModulePath('foo/bar.json')).toBe(`${getModuleRoot()}foo/bar.json`);
+    });
+  });
+
+  describe('#resolveAssetPath', (): void => {
+    it('interprets paths relative to the module root when starting with "@css:".', (): void => {
       expect(resolveAssetPath('@css:foo/bar')).toBe(joinFilePath(getModuleRoot(), '/foo/bar'));
     });
 
-    it('handles ../ paths with @css:.', async(): Promise<void> => {
+    it('handles ../ paths with "@css":.', (): void => {
       expect(resolveAssetPath('@css:foo/bar/../baz')).toBe(joinFilePath(getModuleRoot(), '/foo/baz'));
     });
 
-    it('leaves absolute paths as they are.', async(): Promise<void> => {
+    it('leaves absolute paths as they are.', (): void => {
       expect(resolveAssetPath('/foo/bar/')).toBe('/foo/bar/');
     });
 
-    it('handles other paths relative to the cwd.', async(): Promise<void> => {
+    it('handles other paths relative to the cwd.', (): void => {
       expect(resolveAssetPath('foo/bar/')).toBe(joinFilePath(process.cwd(), 'foo/bar/'));
     });
 
-    it('handles other paths with ../.', async(): Promise<void> => {
+    it('handles other paths with ../.', (): void => {
       expect(resolveAssetPath('foo/bar/../baz')).toBe(joinFilePath(process.cwd(), 'foo/baz'));
     });
   });