소스 검색

improve auth middleware, add task apis

plus misc other improvements, including generic data types and inline api comments
Aneurin Barker Snook 1 년 전
부모
커밋
7b67648b7b
9개의 변경된 파일387개의 추가작업 그리고 117개의 파일을 삭제
  1. 30 31
      src/account/api.ts
  2. 34 19
      src/auth.ts
  3. 4 0
      src/db.ts
  4. 48 47
      src/herd/api.ts
  5. 4 4
      src/herd/types.ts
  6. 13 16
      src/http.ts
  7. 182 0
      src/task/api.ts
  8. 55 0
      src/task/model.ts
  9. 17 0
      src/task/types.ts

+ 30 - 31
src/account/api.ts

@@ -20,7 +20,7 @@ export function createAccount({ model }: Context): RequestHandler {
   const readRequestData = v.validate<RequestData>({
     account: {
       email: v.email,
-      password: v.seq(v.minLength(8)),
+      password: v.seq(v.str, v.minLength(8)),
     },
   })
 
@@ -45,23 +45,22 @@ 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.
- */
+/** Delete an account. */
 export function deleteAccount({ model }: Context): AuthRequestHandler {
   interface ResponseData {
     account: WithId<Account>
   }
 
   return async function (req, res, next) {
-    if (!req.verified) return sendUnauthorized(res, next)
+    if (!req.account) return sendUnauthorized(res, next)
 
-    const id = req.params.id || req.accountId
-    if (!id) return sendBadRequest(res, next, { reason: 'no ID' })
-    if (!req.accountId?.equals(id)) return sendForbidden(res, next)
+    // Get account ID and assert access
+    const id = req.params.id || req.account._id
+    if (!id) return sendBadRequest(res, next)
+    if (!req.account._id.equals(id)) return sendForbidden(res, next)
 
     try {
+      // Delete account
       /** @todo delete related data */
       const account = await model.account.collection.findOneAndDelete({ _id: new ObjectId(id) })
       if (!account) return sendNotFound(res, next)
@@ -75,27 +74,22 @@ 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 {
+/** Get an account. */
+export function getAccount(): AuthRequestHandler {
   interface ResponseData {
     account: WithId<Account>
   }
 
   return async function (req, res, next) {
-    if (!req.verified) return sendUnauthorized(res, next)
+    if (!req.account) return sendUnauthorized(res, next)
 
-    const id = req.params.id || req.accountId
-    if (!id) return sendBadRequest(res, next, { reason: 'no ID' })
-    if (!req.accountId?.equals(id)) return sendForbidden(res, next)
+    // Get account ID and assert access
+    const id = req.params.id || req.account._id
+    if (!req.account._id.equals(id)) return sendForbidden(res, next)
 
     try {
-      const account = await model.account.collection.findOne({ _id: new ObjectId(id) })
-      if (!account) return sendNotFound(res, next)
-
-      const output: ResponseData = { account }
+      // Send output
+      const output: ResponseData = { account: req.account }
       res.send(output)
       next()
     } catch (err) {
@@ -127,16 +121,21 @@ export function loginAccount({ auth, model }: Context): RequestHandler {
 
   return async function (req, res, next) {
     try {
+      // Read input
       const input = readRequestData(req.body)
 
+      // Get account
       const account = await model.account.collection.findOne({ email: input.account.email })
       if (!account) return sendNotFound(res, next)
 
+      // Validate password
       const password = model.account.hashPassword(input.account.password, account.passwordSalt)
       if (password !== account.password) return sendBadRequest(res, next, { reason: 'invalid password' })
 
+      // Create JWT
       const token = await auth.sign(account._id)
 
+      // Send output
       const output: ResponseData = { token, account }
       res.send(output)
       next()
@@ -151,10 +150,7 @@ 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.
- */
+/** Update an account. */
 export function updateAccount({ model }: Context): AuthRequestHandler {
   interface RequestData {
     account: AccountUpdate
@@ -167,26 +163,29 @@ export function updateAccount({ model }: Context): AuthRequestHandler {
   const readRequestData = v.validate<RequestData>({
     account: {
       email: v.seq(v.optional, v.email),
-      password: v.seq(v.optional, v.minLength(8)),
+      password: v.seq(v.optional, v.str, v.minLength(8)),
     },
   })
 
   return async function (req, res, next) {
-    if (!req.verified) return sendUnauthorized(res, next)
+    if (!req.account) return sendUnauthorized(res, next)
 
-    const id = req.params.id || req.accountId
-    if (!id) return sendBadRequest(res, next, { reason: 'no ID' })
-    if (!req.accountId?.equals(id)) return sendForbidden(res, next)
+    // Get account ID and assert access
+    const id = req.params.id || req.account._id
+    if (!req.account._id.equals(id)) return sendForbidden(res, next)
 
     try {
+      // Read input
       const input = readRequestData(req.body)
       if (!input.account.email && !input.account.password) {
         return sendBadRequest(res, next, { reason: 'no changes' })
       }
 
+      // Update account
       const account = await model.account.update(id, input.account)
       if (!account) return sendNotFound(res, next)
 
+      // Send output
       const output: ResponseData = { account }
       res.send(output)
       next()

+ 34 - 19
src/auth.ts

@@ -1,5 +1,7 @@
+import type { Account } from './account/types'
 import type { Context } from './types'
 import { ObjectId } from 'mongodb'
+import type { WithId } from 'mongodb'
 import jwt from 'jsonwebtoken'
 import type { NextFunction, Request, Response } from 'express'
 
@@ -13,14 +15,13 @@ 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.
+   * Account document set by authentication middleware.
+   * If this value exists, then the the request is authenticated with this account.
+   *
+   * @see Auth.verifyRequestMiddleware
    */
-  verified?: boolean
+  account?: WithId<Account>
 }
 
 /**
@@ -67,24 +68,38 @@ function createAuth(ctx: Context) {
   }
 
   /**
-   * Verify and decode a JWT provided in an HTTP request's authorization header.
+   * Express middleware to verify and decode a JWT provided in an HTTP request's authorization header.
+   *
+   * If an account ID is successfully decoded from the JWT, this will attempt to load the account automatically before
+   * further request processing.
+   * The account is attached to the request and tacitly available to subsequent request handlers that implement the
+   * AuthRequestHandler type.
    *
-   * 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.
+   * If an invalid token is provided or the account does not exist, an error will be passed along, blocking request
+   * handling.
    */
-  async function verifyRequest(req: Request): Promise<ObjectId | undefined> {
+  const verifyRequestMiddleware: AuthRequestHandler = async (req, res, next) => {
+    // Read header and skip processing if bearer token missing or malformed
     const header = req.header('authorization')
-    if (!header) return
-    if (!/^[Bb]earer /.test(header)) return
-    const token = header.substring(7)
-    const accountId = await verify(token);
-    (req as AuthRequest).accountId = accountId;
-    (req as AuthRequest).verified = true
-    return accountId
+    if (!header) return next()
+    if (!/^[Bb]earer /.test(header)) return next()
+
+    try {
+      // Read and verify token
+      const token = header.substring(7)
+      const _id = await verify(token)
+
+      // Load account
+      const account = await ctx.model.account.collection.findOne({ _id })
+      if (!account) throw new Error(`account ${_id.toString()} not found`)
+      req.account = account
+      next()
+    } catch (err) {
+      return next(err)
+    }
   }
 
-  return { sign, verify, verifyRequest }
+  return { sign, verify, verifyRequestMiddleware }
 }
 
 export default createAuth

+ 4 - 0
src/db.ts

@@ -2,8 +2,10 @@ import type { AccountModel } from './account/model'
 import type { Context } from './types'
 import type { HerdModel } from './herd/model'
 import { MongoClient } from 'mongodb'
+import type { TaskModel } from './task/model'
 import createAccountModel from './account/model'
 import createHerdModel from './herd/model'
+import createTaskModel from './task/model'
 
 /**
  * Models context.
@@ -12,6 +14,7 @@ import createHerdModel from './herd/model'
 export interface Models {
   account: AccountModel
   herd: HerdModel
+  task: TaskModel
 }
 
 /** Create a MongoDB connection and initialize models. */
@@ -26,6 +29,7 @@ async function createDatabase(ctx: Context) {
   const model = <Models>{
     account: await createAccountModel(dbCtx),
     herd: await createHerdModel(dbCtx),
+    task: await createTaskModel(dbCtx),
   }
 
   return { mongo, db, model }

+ 48 - 47
src/herd/api.ts

@@ -6,13 +6,10 @@ import type { WithId } from 'mongodb'
 import type { Herd, HerdCreate, HerdUpdate } from './types'
 import { sendBadRequest, sendForbidden, sendNotFound, sendUnauthorized } from '../http'
 
-/**
- * Create a herd.
- * The request must be verified, and the user may only create a herd in their own account.
- */
+/** Create a herd. */
 export function createHerd({ model }: Context): AuthRequestHandler {
   interface RequestData {
-    herd: Omit<HerdCreate, '_account'>
+    herd: HerdCreate<string>
   }
 
   interface ResponseData {
@@ -21,22 +18,26 @@ export function createHerd({ model }: Context): AuthRequestHandler {
 
   const readRequestData = v.validate<RequestData>({
     herd: {
-      name: v.minLength(1),
+      _account: v.seq(v.str, v.exactLength(24)),
+      name: v.seq(v.str, v.minLength(1)),
     },
   })
 
   return async function (req, res, next) {
-    if (!req.verified) return sendUnauthorized(res, next)
+    if (!req.account) return sendUnauthorized(res, next)
 
     try {
-      const account = await model.account.collection.findOne({ _id: req.accountId })
-      if (!account) return sendUnauthorized(res, next)
-
+      // Read input
       const input = readRequestData(req.body)
 
-      const herd = await model.herd.create({ ...input.herd, _account: req.accountId as ObjectId })
+      // Assert ability to assign herd
+      if (!req.account._id.equals(input.herd._account)) return sendForbidden(res, next)
+
+      // Create herd
+      const herd = await model.herd.create({ ...input.herd, _account: req.account._id })
       if (!herd) return sendNotFound(res, next, { reason: 'unexpectedly failed to get new herd' })
 
+      // Send output
       const output: ResponseData = { herd }
       res.send(output)
       next()
@@ -51,29 +52,26 @@ export function createHerd({ model }: Context): AuthRequestHandler {
   }
 }
 
-/**
- * Delete a herd.
- * The request must be verified, and the user may not modify any herd other than their own.
- */
+/** Delete a herd. */
 export function deleteHerd({ model }: Context): AuthRequestHandler {
   interface ResponseData {
     herd: WithId<Herd>
   }
 
   return async function (req, res, next) {
-    if (!req.verified) return sendUnauthorized(res, next)
-
-    const id = req.params.id
-    if (!id) return sendBadRequest(res, next, { reason: 'no ID' })
+    if (!req.account) return sendUnauthorized(res, next)
 
     try {
-      const herd = await model.herd.collection.findOne({ _id: new ObjectId(id) })
+      // Assert access to herd
+      const herd = await model.herd.collection.findOne({ _id: new ObjectId(req.params.id) })
       if (!herd) return sendNotFound(res, next)
-      if (!req.accountId?.equals(herd._account)) return sendForbidden(res, next)
+      if (!req.account._id.equals(herd._account)) return sendForbidden(res, next)
 
+      // Delete herd
       /** @todo delete related data */
-      await model.herd.collection.deleteOne({ _id: new ObjectId(id) })
+      await model.herd.collection.deleteOne({ _id: herd._id })
 
+      // Send output
       const output: ResponseData = { herd }
       res.send(output)
       next()
@@ -83,26 +81,22 @@ export function deleteHerd({ model }: Context): AuthRequestHandler {
   }
 }
 
-/**
- * Get a herd.
- * The request must be verified, and the user may not access any herd other than their own.
- */
+/** Get a herd. */
 export function getHerd({ model }: Context): AuthRequestHandler {
   interface ResponseData {
     herd: WithId<Herd>
   }
 
   return async function (req, res, next) {
-    if (!req.verified) return sendUnauthorized(res, next)
-
-    const id = req.params.id || req.accountId
-    if (!id) return sendBadRequest(res, next, { reason: 'no ID' })
+    if (!req.account) return sendUnauthorized(res, next)
 
     try {
-      const herd = await model.herd.collection.findOne({ _id: new ObjectId(id) })
+      // Assert access to herd
+      const herd = await model.herd.collection.findOne({ _id: new ObjectId(req.params.id) })
       if (!herd) return sendNotFound(res, next)
-      if (!req.accountId?.equals(herd._account)) return sendForbidden(res, next)
+      if (!req.account._id.equals(herd._account)) return sendForbidden(res, next)
 
+      // Send output
       const output: ResponseData = { herd }
       res.send(output)
       next()
@@ -112,13 +106,10 @@ export function getHerd({ model }: Context): AuthRequestHandler {
   }
 }
 
-/**
- * Update a herd.
- * The request must be verified, and the user may not modify any herd other than their own.
- */
+/** Update a herd. */
 export function updateHerd({ model }: Context): AuthRequestHandler {
   interface RequestData {
-    herd: HerdUpdate
+    herd: HerdUpdate<string>
   }
 
   interface ResponseData {
@@ -127,29 +118,39 @@ export function updateHerd({ model }: Context): AuthRequestHandler {
 
   const readRequestData = v.validate<RequestData>({
     herd: {
-      name: v.minLength(1),
+      _account: v.seq(v.optional, v.str, v.exactLength(24)),
+      name: v.seq(v.optional, v.str, v.minLength(1)),
     },
   })
 
   return async function (req, res, next) {
-    if (!req.verified) return sendUnauthorized(res, next)
-
-    const id = req.params.id || req.accountId
-    if (!id) return sendBadRequest(res, next, { reason: 'no ID' })
+    if (!req.account) return sendUnauthorized(res, next)
 
     try {
+      // Assert access to herd
+      let herd = await model.herd.collection.findOne({ _id: new ObjectId(req.params.id) })
+      if (!herd) return sendNotFound(res, next)
+      if (!req.account._id.equals(herd._account)) return sendForbidden(res, next)
+
+      // Read input
       const input = readRequestData(req.body)
-      if (!input.herd.name) {
+      if (!input.herd._account && !input.herd.name) {
         return sendBadRequest(res, next, { reason: 'no changes' })
       }
 
-      let herd = await model.herd.collection.findOne({ _id: new ObjectId(id) })
-      if (!herd) return sendNotFound(res, next)
-      if (!req.accountId?.equals(herd._account)) return sendForbidden(res, next)
+      // Assert ability to assign herd, if specified in update
+      if (input.herd._account) {
+        if (!req.account._id.equals(input.herd._account)) return sendForbidden(res, next)
+      }
 
-      herd = await model.herd.update(id, input.herd)
+      // Update herd
+      herd = await model.herd.update(herd._id, {
+        ...input.herd,
+        _account: input.herd._account && new ObjectId(input.herd._account) || undefined,
+      })
       if (!herd) return sendNotFound(res, next)
 
+      // Send output
       const output: ResponseData = { herd }
       res.send(output)
       next()

+ 4 - 4
src/herd/types.ts

@@ -1,15 +1,15 @@
 import type { ObjectId } from 'mongodb'
 
 /** Herd data. */
-export interface Herd {
+export interface Herd<T extends ObjectId | string = ObjectId> {
   /** Account ID. */
-  _account: ObjectId
+  _account: T
   /** Name. */
   name: string
 }
 
 /** Subset of herd data when creating a new herd. */
-export type HerdCreate = Pick<Herd, '_account' | 'name'>
+export type HerdCreate<T extends ObjectId | string = ObjectId> = Pick<Herd<T>, '_account' | 'name'>
 
 /** Subset of herd data when updating a herd. */
-export type HerdUpdate = Partial<Pick<Herd, 'name'>>
+export type HerdUpdate<T extends ObjectId | string = ObjectId> = Partial<Pick<Herd<T>, '_account' | 'name'>>

+ 13 - 16
src/http.ts

@@ -1,39 +1,36 @@
 import * as account from './account/api'
 import * as herd from './herd/api'
+import * as task from './task/api'
 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
+  // Initialize app with JSON and auth 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)
-    } catch (err) {
-      ctx.log.warn('Failed to verify request', err)
-    }
-    next()
-  })
+  app.use(ctx.auth.verifyRequestMiddleware)
 
   const prefix = ctx.config.api.prefix
 
   // Account APIs
   app.post(`${prefix}/account`, account.createAccount(ctx))
-  app.get(`${prefix}/account/:id?`, account.getAccount(ctx))
+  app.get(`${prefix}/account/:id?`, account.getAccount())
   app.put(`${prefix}/account/:id?`, account.updateAccount(ctx))
   app.delete(`${prefix}/account/:id?`, account.deleteAccount(ctx))
 
   // Herd APIs
   app.post(`${prefix}/herd`, herd.createHerd(ctx))
-  app.get(`${prefix}/herd/:id?`, herd.getHerd(ctx))
-  app.put(`${prefix}/herd/:id?`, herd.updateHerd(ctx))
-  app.delete(`${prefix}/herd/:id?`, herd.deleteHerd(ctx))
+  app.get(`${prefix}/herd/:id`, herd.getHerd(ctx))
+  app.put(`${prefix}/herd/:id`, herd.updateHerd(ctx))
+  app.delete(`${prefix}/herd/:id`, herd.deleteHerd(ctx))
+
+  // Task APIs
+  app.post(`${prefix}/task`, task.createTask(ctx))
+  app.get(`${prefix}/task/:id`, task.getTask(ctx))
+  app.put(`${prefix}/task/:id`, task.updateTask(ctx))
+  app.delete(`${prefix}/task/:id`, task.deleteTask(ctx))
 
   // Authentication APIs
   app.post(`${prefix}/login/account`, account.loginAccount(ctx))

+ 182 - 0
src/task/api.ts

@@ -0,0 +1,182 @@
+import * as v from '../validate'
+import type { AuthRequestHandler } from '../auth'
+import type { Context } from '../types'
+import { ObjectId } from 'mongodb'
+import type { WithId } from 'mongodb'
+import type { Task, TaskCreate, TaskUpdate } from './types'
+import { sendBadRequest, sendForbidden, sendNotFound, sendUnauthorized } from '../http'
+
+/** Create a task. */
+export function createTask({ model }: Context): AuthRequestHandler {
+  interface RequestData {
+    task: TaskCreate<string>
+  }
+
+  interface ResponseData {
+    task: WithId<Task>
+  }
+
+  const readRequestData = v.validate<RequestData>({
+    task: {
+      _herd: v.seq(v.str, v.exactLength(24)),
+      _account: v.seq(v.str, v.exactLength(24)),
+      description: v.seq(v.str, v.minLength(1)),
+    },
+  })
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    try {
+      // Read input
+      const input = readRequestData(req.body)
+
+      // Assert ability to assign task
+      if (!req.account._id.equals(input.task._account)) return sendForbidden(res, next)
+
+      // Assert access to herd
+      const herd = await model.herd.collection.findOne({ _id: new ObjectId(input.task._herd) })
+      if (!herd) return sendNotFound(res, next, { reason: 'herd not found' })
+      if (!req.account._id.equals(herd._account)) return sendForbidden(res, next)
+
+      const task = await model.task.create({
+        ...input.task,
+        _herd: new ObjectId(input.task._herd),
+        _account: new ObjectId(input.task._account),
+      })
+      if (!task) return sendNotFound(res, next, { reason: 'unexpectedly failed to get new task' })
+
+      const output: ResponseData = { task }
+      res.send(output)
+      next()
+    } catch (err) {
+      const name = (err as Error).name
+      if (name === 'ValidateError') {
+        const ve = err as v.ValidateError
+        return sendBadRequest(res, next, { param: ve.param, reason: ve.message })
+      }
+      return next(err)
+    }
+  }
+}
+
+/** Delete a task. */
+export function deleteTask({ model }: Context): AuthRequestHandler {
+  interface ResponseData {
+    task: WithId<Task>
+  }
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    try {
+      // Assert access to task
+      const task = await model.task.collection.findOne({ _id: new ObjectId(req.params.id) })
+      if (!task) return sendNotFound(res, next)
+      if (!req.account._id.equals(task._account)) return sendForbidden(res, next)
+
+      // Delete task
+      await model.task.collection.deleteOne({ _id: task._id })
+
+      // Send output
+      const output: ResponseData = { task }
+      res.send(output)
+      next()
+    } catch (err) {
+      next(err)
+    }
+  }
+}
+
+/** Get a task. */
+export function getTask({ model }: Context): AuthRequestHandler {
+  interface ResponseData {
+    task: WithId<Task>
+  }
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    try {
+      // Assert access to task
+      const task = await model.task.collection.findOne({ _id: new ObjectId(req.params.id) })
+      if (!task) return sendNotFound(res, next)
+      if (!req.account._id.equals(task._account)) return sendForbidden(res, next)
+
+      // Send output
+      const output: ResponseData = { task }
+      res.send(output)
+      next()
+    } catch (err) {
+      return next(err)
+    }
+  }
+}
+
+/** Update a task. */
+export function updateTask({ model }: Context): AuthRequestHandler {
+  interface RequestData {
+    task: TaskUpdate<string>
+  }
+
+  interface ResponseData {
+    task: WithId<Task>
+  }
+
+  const readRequestData = v.validate<RequestData>({
+    task: {
+      _herd: v.seq(v.optional, v.str, v.exactLength(24)),
+      _account: v.seq(v.optional, v.str, v.exactLength(24)),
+      description: v.seq(v.optional, v.str, v.minLength(1)),
+    },
+  })
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    try {
+      // Assert access to task
+      let task = await model.task.collection.findOne({ _id: new ObjectId(req.params.id) })
+      if (!task) return sendNotFound(res, next)
+      if (!req.account._id.equals(task._account)) return sendForbidden(res, next)
+
+      // Read input
+      const input = readRequestData(req.body)
+      if (Object.keys(input.task).length < 1) {
+        return sendBadRequest(res, next, { reason: 'no changes' })
+      }
+
+      // Assert ability to assign task, if specified in update
+      if (input.task._account) {
+        if (!req.account._id.equals(input.task._account)) return sendForbidden(res, next)
+      }
+
+      // Assert access to herd, if specified in update
+      if (input.task._herd) {
+        const herd = await model.task.collection.findOne({ _id: new ObjectId(input.task._herd) })
+        if (!herd) return sendNotFound(res, next, { reason: 'herd not found' })
+        if (!req.account._id.equals(herd._account)) return sendForbidden(res, next)
+      }
+
+      // Update task
+      task = await model.task.update(task._id, {
+        ...input.task,
+        _herd: input.task._herd && new ObjectId(input.task._herd) || undefined,
+        _account: input.task._account && new ObjectId(input.task._account) || undefined,
+      })
+      if (!task) return sendNotFound(res, next)
+
+      // Send output
+      const output: ResponseData = { task }
+      res.send(output)
+      next()
+    } catch (err) {
+      const name = (err as Error).name
+      if (name === 'ValidateError') {
+        const ve = err as v.ValidateError
+        return sendBadRequest(res, next, { param: ve.param, reason: ve.message })
+      }
+      return next(err)
+    }
+  }
+}

+ 55 - 0
src/task/model.ts

@@ -0,0 +1,55 @@
+import type { Context } from '../types'
+import { ObjectId } from 'mongodb'
+import type { Task, TaskCreate, TaskUpdate } from './types'
+
+/** Model for accessing and managing tasks. */
+export type TaskModel = Awaited<ReturnType<typeof createTaskModel>>
+
+/** Create a task model. */
+async function createTaskModel(ctx: Context) {
+  const collection = ctx.db.collection<Task>('task')
+
+  /** Create a task. */
+  async function create(input: TaskCreate) {
+    const result = await collection.insertOne({
+      _herd: input._herd,
+      _account: input._account,
+      description: input.description,
+    })
+
+    return await collection.findOne({ _id: result.insertedId })
+  }
+
+  /** Initialize the task collection. */
+  async function init() {
+    await ctx.db.createCollection('task')
+  }
+
+  /**
+   * Update a task.
+   */
+  async function update(id: ObjectId | string, input: TaskUpdate) {
+    const changes = <Partial<Task>>{}
+    if (input._herd) changes._herd = input._herd
+    if (input._account) changes._account = input._account
+    if (input.description) changes.description = input.description
+
+    return collection.findOneAndUpdate(
+      { _id: new ObjectId(id) },
+      { $set: changes },
+      { returnDocument: 'after' },
+    )
+  }
+
+  // Initialize on startup
+  await init()
+
+  return {
+    collection,
+    create,
+    init,
+    update,
+  }
+}
+
+export default createTaskModel

+ 17 - 0
src/task/types.ts

@@ -0,0 +1,17 @@
+import type { ObjectId } from 'mongodb'
+
+/** Task data. */
+export interface Task<T extends ObjectId | string = ObjectId> {
+  /** Herd ID. */
+  _herd: T
+  /** Account ID reflecting the task assignee. */
+  _account: T
+  /** Description. */
+  description: string
+}
+
+/** Subset of task data when creating a new task. */
+export type TaskCreate<T extends ObjectId | string = ObjectId> = Pick<Task<T>, '_herd' | '_account' | 'description'>
+
+/** Subset of task data when updating a task. */
+export type TaskUpdate<T extends ObjectId | string = ObjectId> = Partial<Pick<Task<T>, '_herd' | '_account' | 'description'>>