Reorganize server files and address TODO comments

Changes:
- Remove unneeded dependencies from package.json
- Remove unneeded async build() methods from services
    - Use constructor as often as possible
- Rename and move storage services for clarity
    - creds.ts -> accounts.ts, and creds.json -> accounts.json
    - admin-config.ts -> admin-account.ts
    - vault.ts -> signing-keys.ts
- Rename ws.ts to websocket-server.ts for clarity and consistency
- Make WebSocketServer initialize using constructor and bind server upgrade to WebSocketServer.upgrade
- Remove unused send-account-message endpoint from account-router.ts
- Set issuer and audience claims for JWT
- Create new utils/jwt.ts file to remove code duplication for JWT signing and verifying
- Delete utils.ts and merge it with jami-swig.ts
- Handle potentially undefined types in jami-swig.ts
- Replace hard to read one-liners with functions in jami-swig.ts
- Rename types in jami-swig.ts for consistency with daemon
- Remove handled/answered TODO comments
- Remove TODO comment about using .env for jamid.node as it does not work for require()

GitLab: #87
Change-Id: I1e5216ffa79ea34dd7e9b61540fb7e37d1f66c9f
diff --git a/server/.gitignore b/server/.gitignore
index 12fee05..bb6d966 100644
--- a/server/.gitignore
+++ b/server/.gitignore
@@ -1,2 +1,2 @@
-creds.json
-admin.json
\ No newline at end of file
+accounts.json
+admin.json
diff --git a/server/package.json b/server/package.json
index bfcca37..d4ac1be 100644
--- a/server/package.json
+++ b/server/package.json
@@ -5,7 +5,7 @@
     "start": "nodemon --watch src src/index.ts",
     "start:prod": "env NODE_ENV=production node dist/index.js",
     "build": "tsc",
-    "clean": "rm -Rf dist tsconfig.tsbuildinfo",
+    "clean": "rm -rf dist tsconfig.tsbuildinfo",
     "lint": "eslint src",
     "lint:fix": "eslint --fix src",
     "format": "prettier --write src",
@@ -20,10 +20,8 @@
     "@types/ws": "^8.5.3",
     "dotenv-cli": "^6.0.0",
     "nodemon": "^2.0.20",
-    "npm-check-updates": "^16.3.3",
     "ts-node": "^10.9.1",
-    "typescript": "^4.8.4",
-    "wscat": "^5.2.0"
+    "typescript": "^4.8.4"
   },
   "dependencies": {
     "argon2": "^0.29.1",
diff --git a/server/src/app.ts b/server/src/app.ts
index f6086cd..27e6e99 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -22,7 +22,6 @@
 import log from 'loglevel';
 import { Service } from 'typedi';
 
-import { bindWebRTCCallbacks } from './handlers/webrtc-handler.js';
 import { checkAdminSetup } from './middleware/setup.js';
 import { accountRouter } from './routers/account-router.js';
 import { authRouter } from './routers/auth-router.js';
@@ -32,44 +31,43 @@
 import { defaultModeratorsRouter } from './routers/default-moderators-router.js';
 import { nameserverRouter } from './routers/nameserver-router.js';
 import { setupRouter } from './routers/setup-router.js';
+import { bindWebRTCCallbacks } from './websocket/webrtc-handler.js';
 
 @Service()
 export class App {
-  async build() {
-    const app = express();
+  app = express();
 
+  constructor() {
     // Setup middleware
-    app.use(helmet());
-    app.use(cors());
-    app.use(json());
+    this.app.use(helmet());
+    this.app.use(cors());
+    this.app.use(json());
 
     // Enforce admin setup
-    app.use('/setup', setupRouter);
-    app.use(checkAdminSetup);
+    this.app.use('/setup', setupRouter);
+    this.app.use(checkAdminSetup);
 
     // Setup routing
-    app.use('/auth', authRouter);
-    app.use('/account', accountRouter);
-    app.use('/contacts', contactsRouter);
-    app.use('/default-moderators', defaultModeratorsRouter);
-    app.use('/conversations', conversationRouter);
-    app.use('/calls', callRouter);
-    app.use('/ns', nameserverRouter);
+    this.app.use('/auth', authRouter);
+    this.app.use('/account', accountRouter);
+    this.app.use('/contacts', contactsRouter);
+    this.app.use('/default-moderators', defaultModeratorsRouter);
+    this.app.use('/conversations', conversationRouter);
+    this.app.use('/calls', callRouter);
+    this.app.use('/ns', nameserverRouter);
 
     // Setup WebSocket callbacks
     bindWebRTCCallbacks();
 
     // Setup 404 error handling
-    app.use((_req, res) => {
+    this.app.use((_req, res) => {
       res.sendStatus(HttpStatusCode.NotFound);
     });
 
     // Setup internal error handling
-    app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
+    this.app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
       log.error(err);
       res.status(HttpStatusCode.InternalServerError).send(err.message);
     });
-
-    return app;
   }
 }
diff --git a/server/src/creds.ts b/server/src/creds.ts
deleted file mode 100644
index 9c43dc5..0000000
--- a/server/src/creds.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright (C) 2022 Savoir-faire Linux Inc.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this program.  If not, see
- * <https://www.gnu.org/licenses/>.
- */
-import { readFile, writeFile } from 'node:fs/promises';
-
-import { Service } from 'typedi';
-
-@Service()
-export class Creds {
-  readonly file = 'creds.json';
-  db: Record<string, string>;
-
-  constructor() {
-    this.db = {};
-  }
-
-  async build() {
-    const buffer = await readFile(this.file).catch(() => Buffer.from('{}'));
-    this.db = JSON.parse(buffer.toString());
-    return this;
-  }
-
-  get(username: string) {
-    return this.db[username];
-  }
-
-  set(username: string, password: string) {
-    this.db[username] = password;
-  }
-
-  async save() {
-    await writeFile(this.file, JSON.stringify(this.db) + '\n');
-  }
-}
diff --git a/server/src/index.ts b/server/src/index.ts
index d00f78b..43c3e45 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -26,25 +26,23 @@
 import { Container } from 'typedi';
 
 import { App } from './app.js';
-import { Creds } from './creds.js';
 import { Jamid } from './jamid/jamid.js';
-import { Vault } from './vault.js';
-import { Ws } from './ws.js';
+import { SigningKeys } from './storage/signing-keys.js';
+import { WebSocketServer } from './websocket/websocket-server.js';
 
 log.setLevel(process.env.NODE_ENV === 'production' ? 'error' : 'trace');
 
 const port: string | number = 5000;
 
-await Container.get(Creds).build();
-await Container.get(Vault).build();
+await Container.get(SigningKeys).build();
 const jamid = Container.get(Jamid);
-const app = await Container.get(App).build();
-const wss = await Container.get(Ws).build();
+const app = Container.get(App);
+const webSocketServer = Container.get(WebSocketServer);
 
 const server = createServer();
 
-server.on('request', app);
-server.on('upgrade', wss);
+server.on('request', app.app);
+server.on('upgrade', webSocketServer.upgrade.bind(webSocketServer));
 
 server.listen(port);
 server.on('error', onError);
diff --git a/server/src/interfaces.ts b/server/src/interfaces/constructable.ts
similarity index 100%
rename from server/src/interfaces.ts
rename to server/src/interfaces/constructable.ts
diff --git a/server/src/jamid/jami-swig.ts b/server/src/jamid/jami-swig.ts
index 7182270..3b4ddd8 100644
--- a/server/src/jamid/jami-swig.ts
+++ b/server/src/jamid/jami-swig.ts
@@ -15,36 +15,66 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { Constructable } from '../interfaces.js';
-import { itMap, itRange, itToArr, itToRecord } from './utils.js';
+import { Constructable } from '../interfaces/constructable.js';
 
-interface SwigVec<T> {
+interface SwigVect<T> {
   size(): number;
-  get(i: number): T; // TODO: | undefined;
+  get(index: number): T | undefined;
 }
 
 interface SwigMap<T, U> {
-  keys(): SwigVec<T>;
-  get(k: T): U; // TODO: | undefined;
-  set(k: T, v: U): void;
+  keys(): SwigVect<T>;
+  get(key: T): U | undefined;
+  set(key: T, value: U): void;
 }
 
-// TODO: Review these conversion functions
-const swigVecToIt = <T>(v: SwigVec<T>) => itMap(itRange(0, v.size()), (i) => v.get(i));
-const swigMapToIt = <T, U>(m: SwigMap<T, U>) => itMap(swigVecToIt(m.keys()), (k): [T, U] => [k, m.get(k)]);
-
 // export type IntVect = SwigVec<number>;
 // export type UintVect = SwigVec<number>;
 // export type FloatVect = SwigVec<number>;
-export type StringVect = SwigVec<string>;
+export type StringVect = SwigVect<string>;
 // export type IntegerMap = SwigMap<string, number>;
 export type StringMap = SwigMap<string, string>;
-export type VectMap = SwigVec<StringMap>;
+export type VectMap = SwigVect<StringMap>;
 // export type Blob = SwigVec<number>;
 
-export const stringVectToArray = (sv: StringVect) => itToArr(swigVecToIt(sv));
-export const stringMapToRecord = (sm: StringMap) => itToRecord(swigMapToIt(sm));
-export const vectMapToRecordArray = (vm: VectMap) => itToArr(itMap(swigVecToIt(vm), stringMapToRecord));
+function* swigVectToIt<T>(swigVect: SwigVect<T>) {
+  const size = swigVect.size();
+  for (let i = 0; i < size; i++) {
+    yield swigVect.get(i)!;
+  }
+}
+
+function* swigMapToIt<T, U>(swigMap: SwigMap<T, U>) {
+  const keys = swigVectToIt(swigMap.keys());
+  for (const key of keys) {
+    const value = swigMap.get(key)!;
+    yield [key, value];
+  }
+}
+
+export function stringVectToArray(stringVect: StringVect): string[] {
+  const elements = swigVectToIt(stringVect);
+  return Array.from(elements);
+}
+
+export function stringMapToRecord(stringMap: StringMap): Record<string, string> {
+  const keyValuePairs = swigMapToIt(stringMap);
+  const record: Record<string, string> = {};
+  for (const [key, value] of keyValuePairs) {
+    record[key] = value;
+  }
+  return record;
+}
+
+export function vectMapToRecordArray(vectMap: VectMap): Record<string, string>[] {
+  const stringMaps = swigVectToIt(vectMap);
+  const records = [];
+  for (const stringMap of stringMaps) {
+    const record = stringMapToRecord(stringMap);
+    records.push(record);
+  }
+  return records;
+}
 
 /**
  * Non-exhaustive list of properties for JamiSwig.
diff --git a/server/src/jamid/jamid.ts b/server/src/jamid/jamid.ts
index 0c54adf..773b298 100644
--- a/server/src/jamid/jamid.ts
+++ b/server/src/jamid/jamid.ts
@@ -15,6 +15,8 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
+import { createRequire } from 'node:module';
+
 import {
   AccountDetails,
   AccountTextMessage,
@@ -28,7 +30,7 @@
 import { filter, firstValueFrom, map, Subject } from 'rxjs';
 import { Service } from 'typedi';
 
-import { Ws } from '../ws.js';
+import { WebSocketServer } from '../websocket/websocket-server.js';
 import { JamiSignal } from './jami-signal.js';
 import {
   AccountDetailsChanged,
@@ -47,22 +49,19 @@
   VolatileDetailsChanged,
 } from './jami-signal-interfaces.js';
 import { JamiSwig, StringMap, stringMapToRecord, stringVectToArray, vectMapToRecordArray } from './jami-swig.js';
-import { require } from './utils.js';
 
-// TODO: Mechanism to map account IDs to a list of WebSockets
+const require = createRequire(import.meta.url);
+
 // TODO: Convert Records to interfaces and replace them in common/ (e.g. Contact)
 
 @Service()
 export class Jamid {
-  private readonly jamiSwig: JamiSwig;
-  private readonly usernamesToAccountIds: Map<string, string>;
+  private jamiSwig: JamiSwig;
+  private usernamesToAccountIds = new Map<string, string>();
   private readonly events;
 
-  constructor(private ws: Ws) {
-    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
-    this.jamiSwig = require('../../jamid.node') as JamiSwig; // TODO: we should put the path in the .env
-
-    this.usernamesToAccountIds = new Map<string, string>();
+  constructor(private webSocketServer: WebSocketServer) {
+    this.jamiSwig = require('../../jamid.node') as JamiSwig;
 
     // Setup signal handlers
     const handlers: Record<string, unknown> = {};
@@ -380,12 +379,17 @@
       const message: WebSocketMessage<any> = JSON.parse(signal.payload['application/json']);
 
       if (message === undefined) {
-        log.debug('Undefined account message');
+        log.warn('Undefined account message');
+        return;
+      }
+
+      if (message.type === undefined || message.data === undefined) {
+        log.warn('Account message is not a valid WebSocketMessage (missing type or data fields)');
         return;
       }
 
       if (!Object.values(WebSocketMessageType).includes(message.type)) {
-        log.warn(`Unhandled account message type: ${message.type}`);
+        log.warn(`Invalid WebSocket message type: ${message.type}`);
         return;
       }
 
@@ -394,10 +398,8 @@
         to: signal.accountId,
         message: message.data.message,
       };
-      message.data = data;
 
-      log.info(`Sending ${JSON.stringify(message)} to ${signal.accountId}`);
-      this.ws.send(signal.accountId, message.type, data);
+      this.webSocketServer.send(signal.accountId, message.type, data);
     });
 
     this.events.onAccountMessageStatusChanged.subscribe((signal) => {
@@ -433,7 +435,7 @@
         conversationId: signal.conversationId,
         message: signal.message,
       };
-      this.ws.send(signal.accountId, WebSocketMessageType.ConversationMessage, data);
+      this.webSocketServer.send(signal.accountId, WebSocketMessageType.ConversationMessage, data);
     });
   }
 }
diff --git a/server/src/jamid/utils.ts b/server/src/jamid/utils.ts
deleted file mode 100644
index f692578..0000000
--- a/server/src/jamid/utils.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright (C) 2022 Savoir-faire Linux Inc.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this program.  If not, see
- * <https://www.gnu.org/licenses/>.
- */
-import { createRequire } from 'node:module';
-
-// TODO: Move these functions to jami-swig.ts
-
-export function* itRange(lo: number, hi: number) {
-  for (let i = lo; i < hi; ++i) {
-    yield i;
-  }
-}
-
-export function* itMap<T, U>(it: Iterable<T>, cb: (value: T, index: number) => U) {
-  let i = 0;
-  for (const item of it) {
-    yield cb(item, i++);
-  }
-}
-
-export function* itFilter<T>(it: Iterable<T>, cb: (value: T, index: number) => boolean) {
-  let i = 0;
-  for (const item of it) {
-    if (cb(item, i++)) {
-      yield item;
-    }
-  }
-}
-
-export const itToArr = <T>(it: Iterable<T>) => Array.from(it);
-
-export const itToRecord = <T>(it: Iterable<[string, T]>) => {
-  const r: Record<string, T> = {};
-  for (const [k, v] of it) {
-    r[k] = v;
-  }
-  return r;
-};
-
-export const require = createRequire(import.meta.url);
diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts
index 4f06992..7bbd20c 100644
--- a/server/src/middleware/auth.ts
+++ b/server/src/middleware/auth.ts
@@ -17,15 +17,11 @@
  */
 import { NextFunction, Request, Response } from 'express';
 import { HttpStatusCode } from 'jami-web-common';
-import { jwtVerify } from 'jose';
-import { Container } from 'typedi';
 
-import { Vault } from '../vault.js';
+import { verifyJwt } from '../utils/jwt.js';
 
 function createAuthenticationMiddleware(isAuthenticationRequired: boolean) {
   return async (req: Request, res: Response, next: NextFunction) => {
-    const publicKey = Container.get(Vault).publicKey;
-
     const authorizationHeader = req.headers.authorization;
     if (!authorizationHeader) {
       if (isAuthenticationRequired) {
@@ -45,11 +41,8 @@
     }
 
     try {
-      const { payload } = await jwtVerify(token, publicKey, {
-        issuer: 'urn:example:issuer',
-        audience: 'urn:example:audience',
-      });
-      res.locals.accountId = payload.id;
+      const { payload } = await verifyJwt(token);
+      res.locals.accountId = payload.accountId;
       next();
     } catch (e) {
       res.status(HttpStatusCode.Unauthorized).send('Invalid access token');
diff --git a/server/src/middleware/setup.ts b/server/src/middleware/setup.ts
index a697b5a..d9fc3e7 100644
--- a/server/src/middleware/setup.ts
+++ b/server/src/middleware/setup.ts
@@ -19,12 +19,12 @@
 import { HttpStatusCode } from 'jami-web-common';
 import { Container } from 'typedi';
 
-import { AdminConfig } from '../admin-config.js';
+import { AdminAccount } from '../storage/admin-account.js';
 
-const adminConfig = Container.get(AdminConfig);
+const adminAccount = Container.get(AdminAccount);
 
 export async function checkAdminSetup(_req: Request, res: Response, next: NextFunction) {
-  const isSetupComplete = adminConfig.get() !== undefined;
+  const isSetupComplete = adminAccount.get() !== undefined;
 
   if (!isSetupComplete) {
     res.status(HttpStatusCode.Forbidden).send('Setup not complete');
diff --git a/server/src/routers/account-router.ts b/server/src/routers/account-router.ts
index 9b72667..505d1d9 100644
--- a/server/src/routers/account-router.ts
+++ b/server/src/routers/account-router.ts
@@ -15,10 +15,9 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { Request, Router } from 'express';
+import { Router } from 'express';
 import asyncHandler from 'express-async-handler';
-import { ParamsDictionary } from 'express-serve-static-core';
-import { AccountDetails, AccountTextMessage, HttpStatusCode } from 'jami-web-common';
+import { AccountDetails, HttpStatusCode } from 'jami-web-common';
 import { Container } from 'typedi';
 
 import { Jamid } from '../jamid/jamid.js';
@@ -67,15 +66,3 @@
 
   res.sendStatus(HttpStatusCode.NoContent);
 });
-
-// TODO: Should this endpoint be removed?
-accountRouter.post('/send-account-message', (req: Request<ParamsDictionary, any, AccountTextMessage<unknown>>, res) => {
-  const { from, to, message } = req.body;
-  if (from === undefined || to === undefined || message === undefined) {
-    res.status(HttpStatusCode.BadRequest).send('Missing from, to, or message in body');
-    return;
-  }
-
-  jamid.sendAccountTextMessage(from, to, JSON.stringify(message));
-  res.sendStatus(HttpStatusCode.NoContent);
-});
diff --git a/server/src/routers/auth-router.ts b/server/src/routers/auth-router.ts
index f04529e..4255655 100644
--- a/server/src/routers/auth-router.ts
+++ b/server/src/routers/auth-router.ts
@@ -20,27 +20,25 @@
 import asyncHandler from 'express-async-handler';
 import { ParamsDictionary, Request } from 'express-serve-static-core';
 import { HttpStatusCode } from 'jami-web-common';
-import { SignJWT } from 'jose';
 import { Container } from 'typedi';
 
-import { Creds } from '../creds.js';
 import { Jamid } from '../jamid/jamid.js';
-import { Vault } from '../vault.js';
+import { Accounts } from '../storage/accounts.js';
+import { signJwt } from '../utils/jwt.js';
 
 interface Credentials {
-  username?: string;
-  password?: string;
+  username: string;
+  password: string;
 }
 
 const jamid = Container.get(Jamid);
-const creds = Container.get(Creds);
-const vault = Container.get(Vault);
+const accounts = Container.get(Accounts);
 
 export const authRouter = Router();
 
 authRouter.post(
   '/new-account',
-  asyncHandler(async (req: Request<ParamsDictionary, string, Credentials>, res, _next) => {
+  asyncHandler(async (req: Request<ParamsDictionary, string, Partial<Credentials>>, res, _next) => {
     const { username, password } = req.body;
     if (username === undefined || password === undefined) {
       res.status(HttpStatusCode.BadRequest).send('Missing username or password in body');
@@ -57,12 +55,8 @@
     // TODO: add JAMS support
     // managerUri: 'https://jams.savoirfairelinux.com',
     // managerUsername: data.username,
-    // TODO: find a way to store the password directly in Jami
-    // Maybe by using the "password" field? But as I tested, it's not
-    // returned when getting user infos.
     const accountId = await jamid.addAccount(new Map());
 
-    // TODO: understand why the password arg in this call must be empty
     const state = await jamid.registerUsername(accountId, username, '');
     if (state !== 0) {
       jamid.removeAccount(accountId);
@@ -76,8 +70,8 @@
       return;
     }
 
-    creds.set(username, hashedPassword);
-    await creds.save();
+    accounts.set(username, hashedPassword);
+    await accounts.save();
 
     res.sendStatus(HttpStatusCode.Created);
   })
@@ -85,45 +79,37 @@
 
 authRouter.post(
   '/login',
-  asyncHandler(async (req: Request<ParamsDictionary, { accessToken: string } | string, Credentials>, res, _next) => {
-    const { username, password } = req.body;
-    if (username === undefined || password === undefined) {
-      res.status(HttpStatusCode.BadRequest).send('Missing username or password in body');
-      return;
-    }
+  asyncHandler(
+    async (req: Request<ParamsDictionary, { accessToken: string } | string, Partial<Credentials>>, res, _next) => {
+      const { username, password } = req.body;
+      if (username === undefined || password === undefined) {
+        res.status(HttpStatusCode.BadRequest).send('Missing username or password in body');
+        return;
+      }
 
-    // The account may either be:
-    // 1. not found
-    // 2. found but not on this instance (but I'm not sure about this)
-    const accountId = jamid.getAccountIdFromUsername(username);
-    if (accountId === undefined) {
-      res.status(HttpStatusCode.NotFound).send('Username not found');
-      return;
-    }
+      // Check if the account is stored stored on this daemon instance
+      const accountId = jamid.getAccountIdFromUsername(username);
+      if (accountId === undefined) {
+        res.status(HttpStatusCode.NotFound).send('Username not found');
+        return;
+      }
 
-    // TODO: load the password from Jami
-    const hashedPassword = creds.get(username);
-    if (!hashedPassword) {
-      res
-        .status(HttpStatusCode.NotFound)
-        .send('Password not found (the account does not have a password set on the server)');
-      return;
-    }
+      const hashedPassword = accounts.get(username);
+      if (hashedPassword === undefined) {
+        res
+          .status(HttpStatusCode.NotFound)
+          .send('Password not found (the account does not have a password set on the server)');
+        return;
+      }
 
-    const isPasswordVerified = await argon2.verify(hashedPassword, password);
-    if (!isPasswordVerified) {
-      res.status(HttpStatusCode.Unauthorized).send('Incorrect password');
-      return;
-    }
+      const isPasswordVerified = await argon2.verify(hashedPassword, password);
+      if (!isPasswordVerified) {
+        res.status(HttpStatusCode.Unauthorized).send('Incorrect password');
+        return;
+      }
 
-    const jwt = await new SignJWT({ id: accountId })
-      .setProtectedHeader({ alg: 'EdDSA' })
-      .setIssuedAt()
-      // TODO: use valid issuer and audience
-      .setIssuer('urn:example:issuer')
-      .setAudience('urn:example:audience')
-      .setExpirationTime('2h')
-      .sign(vault.privateKey);
-    res.send({ accessToken: jwt });
-  })
+      const jwt = await signJwt(accountId);
+      res.send({ accessToken: jwt });
+    }
+  )
 );
diff --git a/server/src/routers/setup-router.ts b/server/src/routers/setup-router.ts
index 7dfa24f..3bc3088 100644
--- a/server/src/routers/setup-router.ts
+++ b/server/src/routers/setup-router.ts
@@ -20,20 +20,18 @@
 import asyncHandler from 'express-async-handler';
 import { ParamsDictionary, Request } from 'express-serve-static-core';
 import { HttpStatusCode } from 'jami-web-common';
-import { SignJWT } from 'jose';
 import { Container } from 'typedi';
 
-import { AdminConfig } from '../admin-config.js';
 import { checkAdminSetup } from '../middleware/setup.js';
-import { Vault } from '../vault.js';
+import { AdminAccount } from '../storage/admin-account.js';
+import { signJwt } from '../utils/jwt.js';
+
+const adminAccount = Container.get(AdminAccount);
 
 export const setupRouter = Router();
 
-const vault = Container.get(Vault);
-const adminConfig = Container.get(AdminConfig);
-
 setupRouter.get('/check', (_req, res, _next) => {
-  const isSetupComplete = adminConfig.get() !== undefined;
+  const isSetupComplete = adminAccount.get() !== undefined;
   res.send({ isSetupComplete });
 });
 
@@ -51,7 +49,7 @@
       return;
     }
 
-    const isAdminCreated = adminConfig.get() !== undefined;
+    const isAdminCreated = adminAccount.get() !== undefined;
     if (isAdminCreated) {
       res.status(HttpStatusCode.Conflict).send('Admin already exists');
       return;
@@ -59,8 +57,8 @@
 
     const hashedPassword = await argon2.hash(password, { type: argon2.argon2id });
 
-    adminConfig.set(hashedPassword);
-    await adminConfig.save();
+    adminAccount.set(hashedPassword);
+    await adminAccount.save();
 
     res.sendStatus(HttpStatusCode.Created);
   })
@@ -68,7 +66,7 @@
 
 // Every request handler after this line will be submitted to this middleware
 // in order to ensure that the admin account is set up before proceeding with
-// setup related requests
+// setup-related requests
 setupRouter.use(checkAdminSetup);
 
 setupRouter.post(
@@ -81,7 +79,11 @@
         return;
       }
 
-      const hashedPassword = adminConfig.get();
+      const hashedPassword = adminAccount.get();
+      if (hashedPassword === undefined) {
+        res.status(HttpStatusCode.InternalServerError).send('Admin password not found');
+        return;
+      }
 
       const isPasswordVerified = await argon2.verify(hashedPassword, password);
       if (!isPasswordVerified) {
@@ -89,14 +91,7 @@
         return;
       }
 
-      const jwt = await new SignJWT({ id: 'admin' })
-        .setProtectedHeader({ alg: 'EdDSA' })
-        .setIssuedAt()
-        // TODO: use valid issuer and audience
-        .setIssuer('urn:example:issuer')
-        .setAudience('urn:example:audience')
-        .setExpirationTime('2h')
-        .sign(vault.privateKey);
+      const jwt = await signJwt('admin');
       res.send({ accessToken: jwt });
     }
   )
diff --git a/server/src/admin-config.ts b/server/src/storage/accounts.ts
similarity index 66%
copy from server/src/admin-config.ts
copy to server/src/storage/accounts.ts
index 0f745b9..77a8d9d 100644
--- a/server/src/admin-config.ts
+++ b/server/src/storage/accounts.ts
@@ -21,32 +21,31 @@
 import { Service } from 'typedi';
 
 @Service()
-export class AdminConfig {
-  private readonly file = 'admin.json';
-  private account: { admin: string };
+export class Accounts {
+  private readonly filename = 'accounts.json';
+  private accounts: Record<string, string>;
 
   constructor() {
     let buffer: Buffer;
 
     try {
-      buffer = readFileSync(this.file);
+      buffer = readFileSync(this.filename);
     } catch (e) {
-      console.error(e);
       buffer = Buffer.from('{}');
     }
 
-    this.account = JSON.parse(buffer.toString());
+    this.accounts = JSON.parse(buffer.toString());
   }
 
-  get() {
-    return this.account.admin;
+  get(username: string): string | undefined {
+    return this.accounts[username];
   }
 
-  set(password: string) {
-    this.account.admin = password;
+  set(username: string, password: string): void {
+    this.accounts[username] = password;
   }
 
-  async save() {
-    await writeFile(this.file, JSON.stringify(this.account) + '\n');
+  async save(): Promise<void> {
+    await writeFile(this.filename, JSON.stringify(this.accounts, null, 2) + '\n');
   }
 }
diff --git a/server/src/admin-config.ts b/server/src/storage/admin-account.ts
similarity index 79%
rename from server/src/admin-config.ts
rename to server/src/storage/admin-account.ts
index 0f745b9..b207cfd 100644
--- a/server/src/admin-config.ts
+++ b/server/src/storage/admin-account.ts
@@ -21,32 +21,31 @@
 import { Service } from 'typedi';
 
 @Service()
-export class AdminConfig {
-  private readonly file = 'admin.json';
+export class AdminAccount {
+  private readonly filename = 'admin.json';
   private account: { admin: string };
 
   constructor() {
     let buffer: Buffer;
 
     try {
-      buffer = readFileSync(this.file);
+      buffer = readFileSync(this.filename);
     } catch (e) {
-      console.error(e);
       buffer = Buffer.from('{}');
     }
 
     this.account = JSON.parse(buffer.toString());
   }
 
-  get() {
+  get(): string | undefined {
     return this.account.admin;
   }
 
-  set(password: string) {
+  set(password: string): void {
     this.account.admin = password;
   }
 
-  async save() {
-    await writeFile(this.file, JSON.stringify(this.account) + '\n');
+  async save(): Promise<void> {
+    await writeFile(this.filename, JSON.stringify(this.account, null, 2) + '\n');
   }
 }
diff --git a/server/src/vault.ts b/server/src/storage/signing-keys.ts
similarity index 92%
rename from server/src/vault.ts
rename to server/src/storage/signing-keys.ts
index 07e2914..8e48c94 100644
--- a/server/src/vault.ts
+++ b/server/src/storage/signing-keys.ts
@@ -19,7 +19,7 @@
 import { Service } from 'typedi';
 
 @Service()
-export class Vault {
+export class SigningKeys {
   privateKey!: KeyLike;
   publicKey!: KeyLike;
 
@@ -27,7 +27,7 @@
     const privatekey = process.env.PRIVATE_KEY;
     const publicKey = process.env.PUBLIC_KEY;
 
-    if (!privatekey || !publicKey) {
+    if (privatekey === undefined || publicKey === undefined) {
       throw new Error('Missing private or public key environment variables. Try running "npm run genkeys"');
     }
 
diff --git a/server/src/utils/jwt.ts b/server/src/utils/jwt.ts
new file mode 100644
index 0000000..672c5c3
--- /dev/null
+++ b/server/src/utils/jwt.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { jwtVerify, JWTVerifyResult, SignJWT } from 'jose';
+import { Container } from 'typedi';
+
+import { SigningKeys } from '../storage/signing-keys.js';
+
+const jwtIssuer = 'https://jami.net/';
+const jwtAudience = 'https://jami.net/';
+
+const signingKeys = Container.get(SigningKeys);
+
+export async function signJwt(accountId: string): Promise<string> {
+  return new SignJWT({ accountId })
+    .setProtectedHeader({ alg: 'EdDSA' })
+    .setIssuedAt()
+    .setIssuer(jwtIssuer)
+    .setAudience(jwtAudience)
+    .setExpirationTime('2h')
+    .sign(signingKeys.privateKey);
+}
+
+export async function verifyJwt(token: string): Promise<JWTVerifyResult> {
+  return jwtVerify(token, signingKeys.publicKey, {
+    issuer: jwtIssuer,
+    audience: jwtAudience,
+  });
+}
diff --git a/server/src/handlers/webrtc-handler.ts b/server/src/websocket/webrtc-handler.ts
similarity index 68%
rename from server/src/handlers/webrtc-handler.ts
rename to server/src/websocket/webrtc-handler.ts
index 409a030..e6ebcbc 100644
--- a/server/src/handlers/webrtc-handler.ts
+++ b/server/src/websocket/webrtc-handler.ts
@@ -20,21 +20,22 @@
 import { Container } from 'typedi';
 
 import { Jamid } from '../jamid/jamid.js';
-import { Ws } from '../ws.js';
+import { WebSocketServer } from './websocket-server.js';
 
 const jamid = Container.get(Jamid);
-const ws = Container.get(Ws);
+const webSocketServer = Container.get(WebSocketServer);
 
 function sendWebRTCData<T>(data: Partial<AccountTextMessage<T>>) {
-  if (!data.from || !data.to || !data.message) {
-    log.warn('Incorrect format for AccountTextMessage (require from, to and message):', data);
+  if (data.from === undefined || data.to === undefined || data.message === undefined) {
+    log.warn('Message is not a valid AccountTextMessage (missing from, to, or message fields)');
     return;
   }
+
   jamid.sendAccountTextMessage(data.from, data.to, JSON.stringify(data.message));
 }
 
 export function bindWebRTCCallbacks() {
-  ws.bind(WebSocketMessageType.WebRTCOffer, sendWebRTCData);
-  ws.bind(WebSocketMessageType.WebRTCAnswer, sendWebRTCData);
-  ws.bind(WebSocketMessageType.IceCandidate, sendWebRTCData);
+  webSocketServer.bind(WebSocketMessageType.WebRTCOffer, sendWebRTCData);
+  webSocketServer.bind(WebSocketMessageType.WebRTCAnswer, sendWebRTCData);
+  webSocketServer.bind(WebSocketMessageType.IceCandidate, sendWebRTCData);
 }
diff --git a/server/src/websocket/websocket-server.ts b/server/src/websocket/websocket-server.ts
new file mode 100644
index 0000000..6750129
--- /dev/null
+++ b/server/src/websocket/websocket-server.ts
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { IncomingMessage } from 'node:http';
+import { Duplex } from 'node:stream';
+
+import { WebSocketCallbacks, WebSocketMessage, WebSocketMessageTable, WebSocketMessageType } from 'jami-web-common';
+import log from 'loglevel';
+import { Service } from 'typedi';
+import { URL } from 'whatwg-url';
+import * as WebSocket from 'ws';
+
+import { verifyJwt } from '../utils/jwt.js';
+
+@Service()
+export class WebSocketServer {
+  private wss = new WebSocket.WebSocketServer({ noServer: true });
+  private sockets = new Map<string, WebSocket.WebSocket[]>();
+  private callbacks: WebSocketCallbacks = {
+    [WebSocketMessageType.ConversationMessage]: [],
+    [WebSocketMessageType.ConversationView]: [],
+    [WebSocketMessageType.WebRTCOffer]: [],
+    [WebSocketMessageType.WebRTCAnswer]: [],
+    [WebSocketMessageType.IceCandidate]: [],
+  };
+
+  constructor() {
+    this.wss.on('connection', (ws: WebSocket.WebSocket, _request: IncomingMessage, accountId: string) => {
+      log.info('New connection for account', accountId);
+      const accountSockets = this.sockets.get(accountId);
+      if (accountSockets) {
+        accountSockets.push(ws);
+      } else {
+        this.sockets.set(accountId, [ws]);
+      }
+
+      ws.on('message', <T extends WebSocketMessageType>(messageString: string) => {
+        const message: WebSocketMessage<T> = JSON.parse(messageString);
+        if (message.type === undefined || message.data === undefined) {
+          log.warn('WebSocket message is not a valid WebSocketMessage (missing type or data fields)');
+          return;
+        }
+
+        if (!Object.values(WebSocketMessageType).includes(message.type)) {
+          log.warn(`Invalid WebSocket message type: ${message.type}`);
+          return;
+        }
+
+        const callbacks = this.callbacks[message.type];
+        for (const callback of callbacks) {
+          callback(message.data);
+        }
+      });
+
+      ws.on('close', () => {
+        log.info('Closing connection for account', accountId);
+        const accountSockets = this.sockets.get(accountId);
+        if (accountSockets === undefined) {
+          return;
+        }
+
+        const index = accountSockets.indexOf(ws);
+        if (index !== -1) {
+          accountSockets.splice(index, 1);
+          if (accountSockets.length === 0) {
+            this.sockets.delete(accountId);
+          }
+        }
+      });
+    });
+  }
+
+  async upgrade(request: IncomingMessage, socket: Duplex, head: Buffer): Promise<void> {
+    // Do not use parseURL because it returns a URLRecord and not a URL
+    const url = new URL(request.url ?? '/', 'http://localhost/');
+    const token = url.searchParams.get('accessToken') ?? undefined;
+    if (token === undefined) {
+      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
+      socket.destroy();
+      return;
+    }
+
+    try {
+      const { payload } = await verifyJwt(token);
+      const accountId = payload.accountId as string;
+      log.info('Authentication successful for account', accountId);
+      this.wss.handleUpgrade(request, socket, head, (ws) => {
+        this.wss.emit('connection', ws, request, accountId);
+      });
+    } catch (e) {
+      log.debug('Authentication failed:', e);
+      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
+      socket.destroy();
+    }
+  }
+
+  bind<T extends WebSocketMessageType>(type: T, callback: (data: WebSocketMessageTable[T]) => void): void {
+    this.callbacks[type].push(callback);
+  }
+
+  send<T extends WebSocketMessageType>(accountId: string, type: T, data: WebSocketMessageTable[T]): boolean {
+    const accountSockets = this.sockets.get(accountId);
+    if (accountSockets === undefined) {
+      return false;
+    }
+
+    const webSocketMessageString = JSON.stringify({ type, data });
+    for (const accountSocket of accountSockets) {
+      accountSocket.send(webSocketMessageString);
+    }
+    return true;
+  }
+}
diff --git a/server/src/ws.ts b/server/src/ws.ts
deleted file mode 100644
index d33dd76..0000000
--- a/server/src/ws.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-/*
- * Copyright (C) 2022 Savoir-faire Linux Inc.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this program.  If not, see
- * <https://www.gnu.org/licenses/>.
- */
-import { IncomingMessage } from 'node:http';
-import { Duplex } from 'node:stream';
-
-import { WebSocketCallbacks, WebSocketMessage, WebSocketMessageTable, WebSocketMessageType } from 'jami-web-common';
-import { jwtVerify } from 'jose';
-import log from 'loglevel';
-import { Service } from 'typedi';
-import { URL } from 'whatwg-url';
-import { WebSocket, WebSocketServer } from 'ws';
-
-import { Vault } from './vault.js';
-
-@Service()
-export class Ws {
-  private sockets: Map<string, WebSocket[]>;
-  private callbacks: WebSocketCallbacks;
-
-  constructor(private readonly vault: Vault) {
-    this.sockets = new Map();
-    this.callbacks = {
-      [WebSocketMessageType.ConversationMessage]: [],
-      [WebSocketMessageType.ConversationView]: [],
-      [WebSocketMessageType.WebRTCOffer]: [],
-      [WebSocketMessageType.WebRTCAnswer]: [],
-      [WebSocketMessageType.IceCandidate]: [],
-    };
-  }
-
-  async build() {
-    const wss = new WebSocketServer({ noServer: true });
-
-    wss.on('connection', (ws: WebSocket, _req: IncomingMessage, accountId: string) => {
-      log.info('New connection', accountId);
-      const accountSockets = this.sockets.get(accountId);
-      if (accountSockets) {
-        accountSockets.push(ws);
-      } else {
-        this.sockets.set(accountId, [ws]);
-      }
-
-      ws.on('message', <T extends WebSocketMessageType>(messageString: string) => {
-        const message: WebSocketMessage<T> = JSON.parse(messageString);
-        if (!message.type || !message.data) {
-          ws.send('Incorrect format (require type and data)');
-          return;
-        }
-        if (!Object.values(WebSocketMessageType).includes(message.type)) {
-          log.warn(`Unhandled account message type: ${message.type}`);
-          return;
-        }
-        const callbacks = this.callbacks[message.type];
-        for (const callback of callbacks) {
-          callback(message.data);
-        }
-      });
-
-      ws.on('close', () => {
-        log.info('Connection close', accountId);
-        const accountSockets = this.sockets.get(accountId);
-        const index = accountSockets?.indexOf(ws);
-        if (index !== undefined) {
-          accountSockets?.splice(index, 1);
-          if (accountSockets?.length === 0) {
-            this.sockets.delete(accountId);
-          }
-        }
-      });
-    });
-
-    return (request: IncomingMessage, socket: Duplex, head: Buffer) => {
-      // Do not use parseURL because it returns a URLRecord and not a URL.
-      const url = new URL(request.url ?? '/', 'http://localhost/');
-      const accessToken = url.searchParams.get('accessToken');
-      if (!accessToken) {
-        socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
-        socket.destroy();
-        return;
-      }
-
-      jwtVerify(accessToken, this.vault.publicKey, {
-        issuer: 'urn:example:issuer',
-        audience: 'urn:example:audience',
-      })
-        .then(({ payload }) => {
-          const id = payload.id as string;
-          log.info('Authentication successful', id);
-          wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request, id));
-        })
-        .catch((reason) => {
-          log.debug('Authentication failed', reason);
-          socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
-          socket.destroy();
-        });
-    };
-  }
-
-  bind<T extends WebSocketMessageType>(type: T, callback: (data: WebSocketMessageTable[T]) => void): void {
-    this.callbacks[type].push(callback);
-  }
-
-  send<T extends WebSocketMessageType>(accountId: string, type: T, data: WebSocketMessageTable[T]): boolean {
-    const accountSockets = this.sockets.get(accountId);
-    if (!accountSockets) {
-      return false;
-    }
-    for (const accountSocket of accountSockets) {
-      accountSocket.send(JSON.stringify({ type, data }));
-    }
-    return true;
-  }
-}