npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@verisure-italy/express-router-middleware

v1.9.1

Published

Express middleware for Verisure Italy AAA

Readme

@verisure-italy/express-router-middleware

Typed Express routing layer that connects route configuration, Zod validation, ACL, and DynamoDB-backed CRUD operations. The package is designed to reduce glue code between domain contracts and HTTP APIs.

Installation

pnpm add @verisure-italy/express-router-middleware

Main Exports

  • createRouter()
  • createRestResource() and createRestResources()
  • restControllers and createTypedControllers()
  • middleware building blocks: authentication, authorization, preAuth, validation, dataTransformer, entityEnrichment, requestDataModel, requestProjectionFields
  • paramResolver
  • types such as RouterSettings, RouteConfig, RouteOptions, and RestResourceConfig

What This Package Gives You

  • typed route definitions that stay aligned with your domain entities
  • opt-in auth and ACL handling on a per-route basis
  • Zod-based validation for body, query, and params
  • DynamoDB-backed CRUD handlers and list-query parsing
  • a declarative way to map dataModels to tables, indexes, hooks, and list behavior

Request Lifecycle

For each route, the package builds the middleware chain in this order:

  1. preAuth
  2. secure -> authentication
  3. acl -> authorization
  4. validate
  5. dataTransformer
  6. projectionFields
  7. dataModel
  8. entityEnrichment
  9. handler

At startup it also:

  • validates that every configured dataModel exists in settings.dataModels
  • registers router.param() handlers for each data model
  • exposes /keepalive by default

Quick Start

import express from 'express'
import { errorHandler } from '@verisure-italy/express-error-handler-middleware'
import { dynamoAuthMiddleware } from '@verisure-italy/express-authentication-middleware'
import {
  createRestResource,
  createRouter,
  type RestResourceConfig,
} from '@verisure-italy/express-router-middleware'
import { userSchema, type User } from '@verisure-italy/aaa-types'

type DataModels = {
  user: User
}

const userRoutes = createRestResource({
  dataModel: 'user',
  basePath: '/users',
  sharedOptions: {
    secure: true,
    acl: {
      allow: {
        roles: ['ROLE_AAA_ADMIN'],
      },
    },
  },
  methodOptions: {
    create: {
      validate: {
        body: userSchema.omit({ id: true }).strip(),
      },
    },
    update: {
      validate: {
        body: userSchema.partial().omit({ id: true }).strip(),
      },
    },
    list: {
      projectionFields: ['id', 'username', 'roles'],
    },
  },
} satisfies RestResourceConfig<User, 'user'>)

const app = express()
app.use(express.json())
app.use(dynamoAuthMiddleware())
app.use('/api', createRouter<DataModels>(userRoutes, {
  dataModels: {
    user: {
      tableName: 'users',
      idField: 'id',
    },
  },
}))
app.use(errorHandler())

RouterSettings

| Field | Type | Required | Description | | --- | --- | --- | --- | | dynamoConfig | Partial<DynamoConfig> | No | DynamoDB configuration passed to @verisure-italy/dynamo-kit | | dataModels | record of EntityConfig values | No | Entity definitions keyed by data-model name | | webhook | WebhookConfig | No | Webhook endpoint configuration | | keepalive | KeepaliveConfig | No | Keepalive endpoint configuration |

EntityConfig

| Field | Type | Required | Description | | --- | --- | --- | --- | | tableName | string | Yes | Physical base table name | | idField | string | No | Entity id field. Defaults to id | | indexes | record of index config | No | Index metadata used by list parsing and queries | | generateId | (entity) => string \| Promise<string> | No | Custom id generator for create flows | | queryConfig | QueryConfig<TEntity> | No | Filtering and sorting configuration for list routes | | hooks | EntityHooks<TEntity> | No | Create, update, and delete lifecycle hooks |

hooks

| Hook | Runs when | Typical use | | --- | --- | --- | | preCreate | Before persisting a new entity | Normalize payloads, inject derived fields | | postCreate | After persisting a new entity | Publish events, trigger side effects | | preUpdate | Before writing an update | Validate transitions, derive fields | | postUpdate | After writing an update | Audit, notifications, side effects | | preDelete | Before deleting an entity | Prevent deletion or run guard checks | | postDelete | After deleting an entity | Cleanup or async side effects |

QueryConfig

QueryConfig controls how the built-in list controller interprets query parameters.

| Field | Type | Required | Description | | --- | --- | --- | --- | | defaultIndex | string | No | Default index name used when query conditions match an index | | filterableFields | record | No | Allowed filter fields and operators | | sortableFields | (keyof TEntity)[] | No | Allowed sort fields | | defaultSort | { field, direction } | No | Default sort applied when ?sort= is missing | | defaultPageSize | number | No | Default list page size | | maxPageSize | number | No | Upper bound for ?pageSize= |

filterableFields

Each filterable field defines:

| Field | Type | Required | Description | | --- | --- | --- | --- | | operators | FilterOperator[] | Yes | Allowed query operators | | transform | (value) => any | No | Optional value transformer before the filter is applied |

Supported route-config operators:

  • =
  • <>
  • <
  • <=
  • >
  • >=
  • begins_with
  • contains
  • between
  • in
  • exists
  • not_exists

RouteOptions

| Field | Type | Required | Description | | --- | --- | --- | --- | | secure | boolean | No | Require req._auth | | preAuth | PreAuthHandler | No | Run a custom auth check before the auth middleware | | acl | AclConfig | No | Role-based or custom authorization rules | | validate | ValidationConfig | No | Zod schemas for body, query, and params | | dataTransformer | DataTransformerConfig<TEntity> | No | Mutate the request body before the controller | | dataModel | TDataModel | No | Entity name used by controllers and the param resolver | | projectionFields | (keyof TEntity)[] | No | Limit fields returned by read and list responses | | entityEnrichment | enrichment callbacks | No | Enrich the resolved entity before responding | | cache | CacheConfig | No | Reserved for future cache middleware support | | disableWebhook | boolean | No | Reserved for future webhook delivery control |

AclConfig

| Field | Type | Required | Description | | --- | --- | --- | --- | | allow.roles | UserRole[] | No | At least one role must match | | deny.roles | UserRole[] | No | Any matching role denies the request | | handler | (auth, req) => boolean \| Promise<boolean> | No | Custom auth logic on top of role checks |

Route-Option Examples

Public route

const healthRoute = {
  method: 'get',
  path: '/health',
  handler: (_req, res) => res.json({ ok: true }),
}

Authenticated route

const meRoute = {
  method: 'get',
  path: '/me',
  handler: (req, res) => res.json(req._auth),
  options: {
    secure: true,
  },
}

Role-gated route

const adminUsersRoute = {
  method: 'get',
  path: '/admin/users',
  handler: restControllers.list<User>,
  options: {
    secure: true,
    dataModel: 'user',
    acl: {
      allow: {
        roles: ['ROLE_AAA_ADMIN', 'ROLE_AAA_READER'],
      },
      deny: {
        roles: ['ROLE_AAA_READER'],
      },
    },
  },
}

Route with preAuth

const rebuildRoute = {
  method: 'post',
  path: '/internal/rebuild',
  handler: async (_req, res) => res.status(202).json({ queued: true }),
  options: {
    preAuth: req => req.header('x-internal-key') === process.env.INTERNAL_KEY,
  },
}

Route with validation

import { z } from 'zod'

const readUserRoute = {
  method: 'get',
  path: '/users/:user',
  handler: restControllers.read<User>,
  options: {
    secure: true,
    dataModel: 'user',
    validate: {
      params: z.object({
        user: z.string().min(1),
      }),
      query: z.object({
        verbose: z.coerce.boolean().optional(),
      }),
    },
  },
}

Route with dataTransformer

const createUserRoute = {
  method: 'post',
  path: '/users',
  handler: restControllers.create<User>,
  options: {
    dataModel: 'user',
    dataTransformer: {
      body: body => ({
        ...body,
        username: body.username?.trim().toLowerCase(),
      }),
    },
  },
}

Route with projectionFields

const projectedReadRoute = {
  method: 'get',
  path: '/users/:user',
  handler: restControllers.read<User>,
  options: {
    dataModel: 'user',
    projectionFields: ['id', 'username', 'roles'],
  },
}

Route with entityEnrichment

const enrichedReadRoute = {
  method: 'get',
  path: '/users/:user',
  handler: restControllers.read<User>,
  options: {
    dataModel: 'user',
    entityEnrichment: {
      user: async (user, req) => ({
        ...user,
        isCurrentUser: req._auth?.user.id === user.id,
      }),
    },
  },
}

Type-Safe Resource Pattern

import {
  createRestResource,
  createRouter,
  type RestResourceConfig,
} from '@verisure-italy/express-router-middleware'
import { userSchema, type User } from '@verisure-italy/aaa-types'

interface DataModels {
  user: User
}

const userResourceConfig = {
  dataModel: 'user',
  basePath: '/users',
  methods: ['create', 'read', 'update', 'list', 'delete'],
  sharedOptions: {
    secure: true,
    acl: {
      allow: {
        roles: ['ROLE_AAA_ADMIN'],
      },
    },
  },
  methodOptions: {
    create: {
      validate: {
        body: userSchema.omit({ id: true }).strip(),
      },
    },
    update: {
      validate: {
        body: userSchema.partial().omit({ id: true }).strip(),
      },
    },
  },
} satisfies RestResourceConfig<User, 'user'>

const userRoutes = createRestResource(userResourceConfig)

const router = createRouter<DataModels>(userRoutes, {
  dataModels: {
    user: {
      tableName: 'users',
      idField: 'id',
    },
  },
})

This pattern keeps the dataModel literal aligned between the resource config and RouterSettings.

REST Helpers

createRestResource() generates the CRUD routes below:

  • create -> POST /resource
  • read -> GET /resource/:dataModel
  • update -> PUT /resource/:dataModel
  • list -> GET /resource
  • delete -> DELETE /resource/:dataModel

Because the route parameter name is the dataModel key, dataModel: 'user' results in paths like /users/:user. That is what lets paramResolver automatically load req.user.

createRestResources() Example

const routes = createRestResources([
  {
    dataModel: 'user',
    basePath: '/users',
    sharedOptions: { secure: true },
  },
  {
    dataModel: 'client',
    basePath: '/clients',
    methods: ['read', 'list'],
    sharedOptions: {
      secure: true,
      acl: {
        allow: {
          roles: ['ROLE_AAA_ADMIN', 'ROLE_AAA_READER'],
        },
      },
    },
  },
] as const)

Shared defaults with per-method overrides

const resource = createRestResource<User>({
  dataModel: 'user',
  basePath: '/users',
  sharedOptions: {
    secure: true,
  },
  methodOptions: {
    list: {
      projectionFields: ['id', 'username'],
    },
    create: {
      acl: {
        allow: { roles: ['ROLE_AAA_ADMIN'] },
      },
    },
  },
})

Partial CRUD

const resource = createRestResource<User>({
  dataModel: 'user',
  basePath: '/users',
  methods: ['read', 'list'],
})

List Query Syntax

The built-in list controller understands these query-string patterns:

  • ?filter[field][operator]=value
  • ?sort=field
  • ?sort=-field
  • ?pageSize=20
  • ?nextToken=...
  • ?fields=id,username
  • ?index=name-of-index

Examples:

GET /users?fields=id,username
GET /users?filter[username][begins_with]=adm
GET /users?filter[createdAt][between]=1700000000,1800000000
GET /users?filter[roles][contains]=ROLE_AAA_ADMIN
GET /users?sort=-createdAt&pageSize=50
GET /leads?index=source-createdAt-index&filter[source][=]=source-1&sort=-createdAt

Index Selection Rules

When the list controller can find:

  • a configured index in EntityConfig.indexes
  • an index name from ?index= or queryConfig.defaultIndex
  • an equality filter on that index hash key

it uses repo.queryIndex() instead of repo.scan().

If the selected index also has a range key and the request includes a filter on that field, the range condition is promoted into the DynamoDB KeyConditionExpression.

EntityConfig Example With Hooks and Query Config

const router = createRouter<DataModels>(routes, {
  dataModels: {
    user: {
      tableName: 'users',
      idField: 'id',
      indexes: {
        'username-index': {
          hashKey: 'username',
        },
      },
      generateId: () => crypto.randomUUID(),
      queryConfig: {
        defaultIndex: 'username-index',
        filterableFields: {
          username: {
            operators: ['=', 'begins_with'],
          },
          createdAt: {
            operators: ['>=', '<=', 'between'],
            transform: value => Number(value),
          },
        },
        sortableFields: ['createdAt'],
        defaultSort: {
          field: 'createdAt',
          direction: 'desc',
        },
        defaultPageSize: 20,
        maxPageSize: 100,
      },
      hooks: {
        preCreate: entity => ({
          ...entity,
          username: entity.username?.trim().toLowerCase(),
        }),
        postCreate: async user => {
          console.log('created user', user.id)
        },
      },
    },
  },
})

Working With Raw Controllers

If you do not want to use createRestResource(), you can wire the controllers yourself:

import {
  createRouter,
  restControllers,
} from '@verisure-italy/express-router-middleware'

const routes = [
  {
    method: 'get',
    path: '/users/:user',
    handler: restControllers.read<User>,
    options: {
      secure: true,
      dataModel: 'user',
      projectionFields: ['id', 'username'],
    },
  },
]

Runtime Notes

  • validation() replaces req.body, req.query, and req.params with validated values and stores the originals on req.original.
  • paramResolver loads the entity and exposes it as req[dataModel].
  • keepalive is enabled by default on /keepalive.
  • authentication only checks for req._auth; you are expected to mount an auth middleware such as @verisure-italy/express-authentication-middleware before this router.
  • Webhook configuration exists in the public API. Inbound webhook routes can be registered today, but outbound trigger delivery is not yet implemented in the runtime.