Explorar o código

add full code comments

Aneurin Barker Snook hai 1 ano
pai
achega
7cec067e7a
Modificáronse 10 ficheiros con 139 adicións e 8 borrados
  1. 17 0
      src/account/api.ts
  2. 14 0
      src/account/model.ts
  3. 6 0
      src/account/types.ts
  4. 38 2
      src/auth.ts
  5. 8 0
      src/db.ts
  6. 13 3
      src/http.ts
  7. 5 0
      src/index.ts
  8. 13 0
      src/log.ts
  9. 1 3
      src/main.ts
  10. 24 0
      src/types.ts

+ 17 - 0
src/account/api.ts

@@ -7,6 +7,7 @@ import type { WithId } from 'mongodb'
 import type { Account, AccountCreate, AccountUpdate } from './types'
 import { sendBadRequest, sendForbidden, sendNotFound, sendUnauthorized } from '../http'
 
+/** Create an account. */
 export function createAccount({ model }: Context): RequestHandler {
   interface RequestData {
     account: AccountCreate
@@ -44,6 +45,10 @@ export function createAccount({ model }: Context): RequestHandler {
   }
 }
 
+/**
+ * Delete an account.
+ * The request must be verified, and the user may not modify any account other than their own.
+ */
 export function deleteAccount({ model }: Context): AuthRequestHandler {
   interface ResponseData {
     account: WithId<Account>
@@ -70,6 +75,10 @@ export function deleteAccount({ model }: Context): AuthRequestHandler {
   }
 }
 
+/**
+ * Get an account.
+ * The request must be verified, and the user may not access any account other than their own.
+ */
 export function getAccount({ model }: Context): AuthRequestHandler {
   interface ResponseData {
     account: WithId<Account>
@@ -95,6 +104,10 @@ export function getAccount({ model }: Context): AuthRequestHandler {
   }
 }
 
+/**
+ * Log in to an account.
+ * The token returned should be added to the authorization header of subsequent requests.
+ */
 export function loginAccount({ auth, model }: Context): RequestHandler {
   interface RequestData {
     account: Pick<Account, 'email' | 'password'>
@@ -138,6 +151,10 @@ export function loginAccount({ auth, model }: Context): RequestHandler {
   }
 }
 
+/**
+ * Update an account.
+ * The request must be verified, and the user may not modify any account other than their own.
+ */
 export function updateAccount({ model }: Context): AuthRequestHandler {
   interface RequestData {
     account: AccountUpdate

+ 14 - 0
src/account/model.ts

@@ -3,11 +3,17 @@ import { ObjectId } from 'mongodb'
 import crypto from 'crypto'
 import type { Account, AccountCreate, AccountUpdate } from './types'
 
+/** Model for accessing and managing accounts. */
 export type AccountModel = Awaited<ReturnType<typeof createAccountModel>>
 
+/** Create an account model. */
 async function createAccountModel(ctx: Context) {
   const collection = ctx.db.collection<Account>('account')
 
+  /**
+   * Create an account.
+   * The password is automatically hashed.
+   */
   async function create(input: AccountCreate) {
     const passwordSalt = generateSalt()
     const password = hashPassword(input.password, passwordSalt)
@@ -21,14 +27,17 @@ async function createAccountModel(ctx: Context) {
     return await collection.findOne({ _id: result.insertedId })
   }
 
+  /** Generate a salt for use in password hashing. */
   function generateSalt() {
     return crypto.randomBytes(32).toString('hex')
   }
 
+  /** Hash a password. */
   function hashPassword(password: string, salt: string) {
     return crypto.createHmac('sha256', salt).update(password).digest('hex')
   }
 
+  /** Initialize the account collection. */
   async function init() {
     const exists = await collection.indexExists('unq_email')
     if (!exists) {
@@ -36,6 +45,10 @@ async function createAccountModel(ctx: Context) {
     }
   }
 
+  /**
+   * Update an account.
+   * If a password is given, it is automatically hashed with a new salt.
+   */
   async function update(id: ObjectId | string, input: AccountUpdate) {
     const changes = <Partial<Account>>{}
     if (input.email) changes.email = input.email
@@ -51,6 +64,7 @@ async function createAccountModel(ctx: Context) {
     )
   }
 
+  // Initialize on startup
   await init()
 
   return {

+ 6 - 0
src/account/types.ts

@@ -1,9 +1,15 @@
+/** Account data. */
 export interface Account {
+  /** Email address. Used for authentication. */
   email: string
+  /** Password. Used for authentication. */
   password: string
+  /** Password salt. Used for authentication. */
   passwordSalt: string
 }
 
+/** Subset of account data when creating a new account. */
 export type AccountCreate = Pick<Account, 'email' | 'password'>
 
+/** Subset of account data when updating an account. */
 export type AccountUpdate = Partial<Pick<Account, 'email' | 'password'>>

+ 38 - 2
src/auth.ts

@@ -3,18 +3,44 @@ import { ObjectId } from 'mongodb'
 import jwt from 'jsonwebtoken'
 import type { NextFunction, Request, Response } from 'express'
 
+/**
+ * Authentication context.
+ * This provides functionality for signing and verifying JWTs using static configuration.
+ *
+ * @see https://jwt.io/introduction
+ */
 export type Auth = ReturnType<typeof createAuth>
 
+/** AuthRequest is an Express Request with some additional properties. */
 export type AuthRequest = Request & {
+  /** Account ID decoded from JWT. */
   accountId?: ObjectId
+  /**
+   * Indicates whether the request is verified with a JWT.
+   * This does not necessarily mean the corresponding accountId is internally valid or applicable to the contents of
+   * the request; only that the JWT was in itself valid.
+   */
   verified?: boolean
 }
 
+/**
+ * An AuthRequestHandler is essentially the same as an Express RequestHandler, but has access to additional request
+ * properties when `verifyRequest` is used earlier in request processing.
+ *
+ * @see AuthRequest
+ */
 export type AuthRequestHandler = (req: AuthRequest, res: Response, next: NextFunction) => void | Promise<void>
 
+/**
+ * Create an authentication context.
+ * This provides functionality for signing and verifying JWTs using static configuration.
+ *
+ * @see https://jwt.io/introduction
+ */
 function createAuth(ctx: Context) {
   const { expiresIn, secret } = ctx.config.auth.jwt
 
+  /** Sign a JWT containing an account ID. */
   function sign(accountId: ObjectId | string): Promise<string> {
     return new Promise((res, rej) => {
       jwt.sign({ _id: accountId.toString() }, secret, { expiresIn }, (err, enc) => {
@@ -25,6 +51,7 @@ function createAuth(ctx: Context) {
     })
   }
 
+  /** Verify and decode a JWT containing an account ID. */
   function verify(token: string): Promise<ObjectId> {
     return new Promise((res, rej) => {
       jwt.verify(token, secret, (err, dec) => {
@@ -39,13 +66,22 @@ function createAuth(ctx: Context) {
     })
   }
 
+  /**
+   * Verify and decode a JWT provided in an HTTP request's authorization header.
+   *
+   * If the JWT is verified, some additional properties are set on the request object as a side effect.
+   * These properties are then available to other, subsequent request handlers that use the `AuthRequestHandler` type.
+   * This function is thus suitable for use in Express middleware.
+   */
   async function verifyRequest(req: Request): Promise<ObjectId | undefined> {
     const header = req.header('authorization')
     if (!header) return
     if (!/^[Bb]earer /.test(header)) return
-    const token = header.substring(7);
-    (req as AuthRequest).accountId = await verify(token);
+    const token = header.substring(7)
+    const accountId = await verify(token);
+    (req as AuthRequest).accountId = accountId;
     (req as AuthRequest).verified = true
+    return accountId
   }
 
   return { sign, verify, verifyRequest }

+ 8 - 0
src/db.ts

@@ -3,14 +3,22 @@ import type { Context } from './types'
 import { MongoClient } from 'mongodb'
 import createAccountModel from './account/model'
 
+/**
+ * Models context.
+ * Provides access to various backend functionality.
+ */
 export interface Models {
   account: AccountModel
 }
 
+/** Create a MongoDB connection and initialize models. */
 async function createDatabase(ctx: Context) {
+  // Connect to MongoDB and select database
   const mongo = await MongoClient.connect(ctx.config.mongo.uri)
   const db = mongo.db(ctx.config.mongo.db)
 
+  // Create a temporary context and set up models.
+  // Some models may self-initialize to install indexes etc.
   const dbCtx = { ...ctx, mongo, db }
   const model = <Models>{
     account: await createAccountModel(dbCtx),

+ 13 - 3
src/http.ts

@@ -3,11 +3,14 @@ import type { Context } from './types'
 import express from 'express'
 import type { ErrorRequestHandler, NextFunction, Response } from 'express'
 
+/** Create an Express application. */
 export function createExpress(ctx: Context) {
+  // Initialize app with JSON middleware
   const app = express()
-
   app.use(express.json())
 
+  // Add request verification middleware.
+  // See ./auth.ts
   app.use(async (req, res, next) => {
     try {
       await ctx.auth.verifyRequest(req)
@@ -19,14 +22,16 @@ export function createExpress(ctx: Context) {
 
   const prefix = ctx.config.api.prefix
 
+  // Account APIs
   app.post(`${prefix}/account`, account.createAccount(ctx))
   app.get(`${prefix}/account/:id?`, account.getAccount(ctx))
   app.put(`${prefix}/account/:id?`, account.updateAccount(ctx))
   app.delete(`${prefix}/account/:id?`, account.deleteAccount(ctx))
 
+  // Authentication APIs
   app.post(`${prefix}/login/account`, account.loginAccount(ctx))
 
-  // Handle errors passed to next function
+  // Add middleware to handle any errors forwarded from previous handlers via `next(err)`
   const catchError: ErrorRequestHandler = (err, req, res, next) => {
     if (!res.headersSent) {
       sendInternalServerError(res, next, { reason: (err as Error).message })
@@ -35,7 +40,7 @@ export function createExpress(ctx: Context) {
   }
   app.use(catchError)
 
-  // Log request after handling
+  // Add request logging middleware
   app.use((req, res, next) => {
     ctx.log.debug(`[${req.socket.remoteAddress}] ${req.method} ${req.url} ${res.statusCode}`)
     next()
@@ -44,26 +49,31 @@ export function createExpress(ctx: Context) {
   return app
 }
 
+/** Send a 400 Bad Request error response. */
 export function sendBadRequest(res: Response, next: NextFunction, data?: Record<string, unknown>) {
   res.status(400).send({ message: 'Bad Request', ...data })
   next()
 }
 
+/** Send a 403 Forbidden error response. */
 export function sendForbidden(res: Response, next: NextFunction, data?: Record<string, unknown>) {
   res.status(403).send({ message: 'Forbidden', ...data })
   next()
 }
 
+/** Send a 500 Internal Server Error response. */
 export function sendInternalServerError(res: Response, next: NextFunction, data?: Record<string, unknown>) {
   res.status(500).send({ message: 'Internal Server Error', ...data })
   next()
 }
 
+/** Send a 404 Not Found error response. */
 export function sendNotFound(res: Response, next: NextFunction, data?: Record<string, unknown>) {
   res.status(404).send({ message: 'Not Found', ...data })
   next()
 }
 
+/** Send a 401 Unauthorized error response. */
 export function sendUnauthorized(res: Response, next: NextFunction, data?: Record<string, unknown>) {
   res.status(401).send({ message: 'Unauthorized', ...data })
   next()

+ 5 - 0
src/index.ts

@@ -4,8 +4,13 @@ import main from './main'
 
 dotenv.config()
 
+/**
+ * Some configuration may be dynamically generated at startup.
+ * This private object allows it to be preserved during the application's runtime.
+ */
 const dynamicConfig: Record<string, unknown> = {}
 
+// Run the app
 main({
   api: {
     prefix: process.env.API_PREFIX || '/api',

+ 13 - 0
src/log.ts

@@ -1,9 +1,12 @@
 import type { Context } from './types'
 
+/** Logging context. */
 export type Logger = ReturnType<typeof createLogger>
 
+/** Log level (or severity). */
 export type LogLevel = keyof typeof LogLevels
 
+/** Log levels and their corresponding numeric severity. */
 const LogLevels = {
   trace: 1,
   debug: 2,
@@ -12,6 +15,7 @@ const LogLevels = {
   error: 5,
 }
 
+/** Log level translation map for display. */
 const LogLevelStrings: Record<LogLevel, string> = {
   trace: 'TRC',
   debug: 'DBG',
@@ -20,10 +24,14 @@ const LogLevelStrings: Record<LogLevel, string> = {
   error: 'ERR',
 }
 
+/** Create a logging context. */
 function createLogger({ config }: Context) {
   // If an invalid log level is provided, default to info level
   const minLevel = LogLevels[config.log.level as LogLevel] || LogLevels.info
 
+  /**
+   * Write a log message at a specific level of severity.
+   */
   function write(level: LogLevel, ...a: unknown[]) {
     const time = new Date().toLocaleTimeString()
     if (level === 'trace') {
@@ -34,22 +42,27 @@ function createLogger({ config }: Context) {
     }
   }
 
+  /** Write a trace message. */
   function trace(...a: unknown[]) {
     if (minLevel <= LogLevels.trace) write('trace', ...a)
   }
 
+  /** Write a debug message. */
   function debug(...a: unknown[]) {
     if (minLevel <= LogLevels.debug) write('debug', ...a)
   }
 
+  /** Write an informational message. */
   function info(...a: unknown[]) {
     if (minLevel <= LogLevels.info) write('info', ...a)
   }
 
+  /** Write a warning message. */
   function warn(...a: unknown[]) {
     if (minLevel <= LogLevels.warn) write('warn', ...a)
   }
 
+  /** Write an error message. */
   function error(...a: unknown[]) {
     if (minLevel <= LogLevels.error) write('error', ...a)
   }

+ 1 - 3
src/main.ts

@@ -6,9 +6,7 @@ import createLogger from './log'
 import process from 'process'
 import type { Config, Context } from './types'
 
-/**
- * Server application entrypoint.
- */
+/** Server application entrypoint. */
 async function main(config: Config): Promise<void> {
   // Create context
   const ctx = <Context>{ config }

+ 24 - 0
src/types.ts

@@ -3,30 +3,54 @@ import type { Logger } from './log'
 import type { Models } from './db'
 import type { Db, MongoClient } from 'mongodb'
 
+/** Application configuration context. */
 export interface Config {
   api: {
+    /** URL prefix for all APIs (default: "/api") */
     prefix: string
   }
   auth: {
     jwt: {
+      /** Expiration time for JWTs (default: 86400 (seconds, or one day)) */
       expiresIn: number
+      /**
+       * JWT secret for signing and verification.
+       * If a value is not set, this is auto generated when the app starts
+       */
       secret: string
     }
   }
   http: {
+    /** HTTP bind host (default: empty) */
     host: string
+    /** HTTP bind port (default: 5001) */
     port: number
   }
   log: {
+    /** Log level (default: "info") */
     level: string
   }
   mongo: {
+    /** Database to use in MongoDB */
     db: string
+    /**
+     * MongoDB connection URI.
+     *
+     * @see https://www.mongodb.com/docs/drivers/node/current/quick-start/create-a-connection-string/
+     */
     uri: string
   }
+  /**
+   * If the application cannot shut down because a process has stalled, it will force shutdown with `process.exit(1)`
+   * after this period has elapsed (default: 60000 (milliseconds, or one minute))
+   */
   shutdownTimeout: number
 }
 
+/**
+ * Application context.
+ * This is shared throughout the entire codebase, providing access to almost all functionality wherever it's needed.
+ */
 export interface Context {
   auth: Auth
   config: Config