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 🙏

© 2025 – Pkg Stats / Ryan Hefner

@kamalyb/mongoose-explore

v2.4.0

Published

an **opinionated** and highly customizable ~~admin interface~~ interface generated using [pug](https://pugjs.org/api/getting-started.html) to _explore_ your mongoose models. strongly inspired by [adminjs](https://adminjs.co)

Downloads

246

Readme

mongoose explore

an opinionated and highly customizable ~~admin interface~~ interface generated using pug to explore your mongoose models. strongly inspired by adminjs

table of contents

installation

npm install @kamalyb/mongoose-explore
# or
yarn add @kamalyb/mongoose-explore
# or
pnpm add @kamalyb/mongoose-explore

prerequisites

  1. do not disable mongoose's schema types casting as Model.castObject() is used to accurately convert url-encoded data to align with the corresponding model's schema when creating and editing documents
  2. if your application utilizes helmet middleware, allow the unsafe-inline script source directive in your content security policy
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        "script-src": ["'unsafe-inline'", "https://cdn.jsdelivr.net"],
        "script-src-attr": ["'unsafe-inline'"]
      }
    }
  })
);

quick start

import express from "express";
import mongoose from "mongoose";
import { MongooseExplorer } from "@kamalyb/mongoose-explore";

const app = express();

const explorer = new MongooseExplorer({ mongoose });

app.use(explorer.rootpath, explorer.router());

await mongoose.connect();

app.listen(port);

voila! you now have an ~~admin interface~~ interface on /admin/explorer to explore and manipulate your mongoose models

configuration options

the MongooseExplorer constructor accepts a configuration object with the following options:

mongoose (required)

the mongoose instance to use for database operations

rootpath

  • type: string
  • default: /admin/explorer
  • description: the base URL path where the explorer will be mounted
const explorer = new MongooseExplorer({
  mongoose,
  rootpath: "/custom-admin" // access via http://your-app.com/custom-admin
});

css

  • type: string
  • description: specifies global or custom styles for the explorer. this can be an absolute path to a css file or a string containing inline css. the styles are applied globally or can be used for custom pages and HTML
const explorer = new MongooseExplorer({
  mongoose,
  css: `
    .custom-class {
      color: red;
    }
  `,
  dashboard: {
    render: () => {
      return `
        <div>
          <p class="custom-class">hello world</p>
        </div>
      `;
    }
  }
});

datetime_formatter

  • type: (date: Date) => string
  • default: 24-hour format with short month (e.g., "Mar 22, 2024, 13:45")
  • description: formatted string representation of dates
// default implementation
const explorer = new MongooseExplorer({
  mongoose,
  datetime_formatter: (date) =>
    date.toLocaleString("en-US", {
      month: "short",
      day: "numeric",
      year: "numeric",
      hour: "2-digit",
      minute: "2-digit",
      hour12: false
    })
});

explorables

  • type: string[]
  • description: list of model names to expose in the explorer. if not provided, all models will be available. if set, only the resources included in it are available, rendering any explorable: true setting under individual resource configuration irrelevant. supports exclusions by prefixing model names with a - sign
const explorer = new MongooseExplorer({
  mongoose,
  explorables: ["User", "Post", "Comment"] // only these models will be accessible
});

const explorer = new MongooseExplorer({
  mongoose,
  explorables: ["-Password"] // all models will be accessible except Password
});

query_runner

  • type: boolean | string[]
  • default: false
  • description: enable custom query execution and instantly view the result. can be limited to specific models. it supports find, findOne, aggregate, countDocuments, and estimatedDocumentCount mongoose operations. query options are applicable to find and findOne operations only
// enable for all models
const explorer = new MongooseExplorer({
  mongoose,
  query_runner: true
});

// enable only for specific models
const explorer = new MongooseExplorer({
  mongoose,
  query_runner: ["User", "Post"]
});

fallback_value

  • type: string
  • default: -
  • description: string or html content to display when a property has no value (null or undefined)
const explorer = new MongooseExplorer({
  mongoose,

  // plain text
  fallback_value: "N/A",

  // or html for styled content
  fallback_value: "<span style="color: orange">no data</span>"
});

timestamps

  • type: Timestamps
  • default: { created: "created_at", updated: "updated_at" }
  • description: maps your mongoose schema's timestamp field names to prevent their modification in the interface among other things

Timestamps definition

interface Timestamps {
  created?: string;
  updated?: string;
}
const explorer = new MongooseExplorer({
  mongoose,
  timestamps: {
    created: "insertedAt", // if your schema uses insertedAt
    updated: "modifiedAt" // if your schema uses modifiedAt
  }
});

titles

  • type: Titles
  • default: 'explorer'
  • description: customize html page titles for different views and operations in the interface

Titles definition

interface Titles {
  dashboard?: string;
  list?: (modelname: string) => string;
  view?: (modelname: string, doc: object) => string;
  edit?: (modelname: string, doc: object) => string;
  create?: (modelname: string) => string;
  query_runner?: (modelname: string) => string;
}
const explorer = new MongooseExplorer({
  mongoose,
  titles: {
    // static title for dashboard
    dashboard: "Admin Dashboard",

    // dynamic titles using model names and documents
    list: (modelname) => `${modelname} List`,
    view: (modelname, doc) => `${doc.name} - ${modelname} Details`,
    edit: (modelname, doc) => `Edit ${modelname}: ${doc.name}`,
    create: (modelname) => `New ${modelname}`,
    query_runner: (modelname) => `Query ${modelname}`
  }
});

examples of generated titles:

// for a User model with document { name: "John Doe" }
{
  dashboard: "Admin Dashboard";
  list: "User List";
  view: "John Doe - User Details";
  edit: "Edit User: John Doe";
  create: "New User";
  query_runner: "Query User";
}

theming

  • type: ThemingOptions
  • description: customize the visual appearance of the interface

ThemingOptions definition

type Theme = DeepRequired<ThemingOptions>;

interface ThemingOptions {
  colors?: Colors;
  font?: Font;
}

interface Colors {
  primary?: string;
  secondary?: string;
  danger?: string;
  warning?: string;
  success?: string;
  border?: string;
  text?: string;
  custom?: Record<string, `#${string}`>;
}

interface Font {
  url: string;
  family: string;
}

type Color =
  | "primary"
  | "secondary"
  | "success"
  | "danger"
  | "warning"
  | "dark"
  | "gray"
  | "white"
  | (string & {}); // allows custom color strings

type Variant = "filled" | "outline" | "light" | "subtle";

type Size =
  | "xs"
  | "compact-xs"
  | "sm"
  | "compact-sm"
  | "md"
  | "compact-md"
  | "lg"
  | "compact-lg"
  | "xl"
  | "compact-xl";

color customization

  • all color inputs generate CSS variables and classes
  • creates var(--color-{name}) and .color-{name}
  • background fixed at #101113 (dark shade, non-customizable)
  • custom colors can be added through custom object

default theme

  • predefined theme with Finlandica font
  • default color palette for the interface
{
  font: {
    family: `"Finlandica", sans-serif`,
    url: "https://fonts.googleapis.com/css2?family=Finlandica&display=swap"
  },
  colors: {
    primary: "#4f46e5",
    secondary: "#DCE546",
    danger: "#dc2626",
    success: "#059669",
    warning: "#d97706",
    border: "#4B5563",
    text: "#fff"
  }
};

css variables

the following CSS variables are available throughout the interface:

/* typography */
--font-family
--font-size

/* colors */
--color-primary
--color-secondary
--color-success
--color-danger
--color-warning
--color-dark
--color-gray
--color-{custom-name}  /* generated for each custom color */

/* spacing */
--spacing-xs
--spacing-sm
--spacing-md
--spacing-lg
--spacing-xl

/* border radius */
--radius-xs
--radius-sm
--radius-md
--radius-lg
--radius-xl

css classes

button classes

the .button class must be combined with variant, color, and size modifiers for styling:

/* base class (no styling by itself) */
.button

/* size modifiers */
.button.size-xs
.button.size-sm
.button.size-md
.button.size-lg
.button.size-xl

/* compact variants */
.button.size-compact-xs  /* smaller version of xs */
.button.size-compact-sm  /* smaller version of sm */
.button.size-compact-md  /* smaller version of md */
.button.size-compact-lg  /* smaller version of lg */
.button.size-compact-xl  /* smaller version of xl */

/* variant modifiers */
.button.variant-filled
.button.variant-light
.button.variant-outline
.button.variant-subtle

/* color modifiers - generated for all predefined and custom colors */
.button.color-primary
.button.color-secondary
.button.color-success
/* etc... */
form element classes
/* input */
.input
.input.size-xs
.input.size-sm
.input.size-md
.input.size-lg
.input.size-xl

/* textarea */
.textarea
.textarea.size-xs
.textarea.size-sm
.textarea.size-md
.textarea.size-lg
.textarea.size-xl

/* select */
.select
.select.size-xs
.select.size-sm
.select.size-md
.select.size-lg
.select.size-xl

configuration example

  • customize theme colors and font
  • add custom colors with custom property
  • override default theme settings
const explorer = new MongooseExplorer({
  mongoose,
  theming: {
    colors: {
      primary: "#4f46e5",
      custom: {
        coral: "#FF7F50"
      }
    },
    font: {
      family: '"Custom Font", sans-serif',
      url: "https://fonts.googleapis.com/css2?family=CustomFont&display=swap"
    }
  }
});

// button html example
<button class="button color-primary variant-filled size-compact-sm">
  Click me
</button>

// input html example
<input class="input size-md" type="text">

version_key

  • type: string
  • default: __v
  • description: the version key of your schema, if enabled

show_indexes

  • type: boolean
  • default: false
  • description: controls visibility of model indexes in the interface

bulk_delete

  • type: boolean
  • default: false
  • description: enable or disable bulk deletion of documents

dashboard

  • type: DashboardOptions
  • description: customize the dashboard page content and styling. by default, the dashboard is empty. any html content returned by the render function will be wrapped in the interface layout to maintain consistent navigation

DashboardOptions definition

interface DashboardOptions {
  /**
   * function to generate dashboard's html content
   */
  render?: (req: Request, theme: Theme) => string | Promise<string>;
  /**
   * additional html to inject into the <head> section.
   * useful for including external resources like stylesheets, remote scripts, etc
   */
  head?: string;
  /**
   * javascript for the dashboard page.
   * can either be an absolute file path (e.g., "C:/users/john/project/dashboard.js")
   * or inline javascript as a string
   */
  js?: string;
  /**
   * css styles for the dashboard page.
   * can either be an absolute file path (e.g., "C:/users/john/project/dashboard.css")
   * or inline css as a string
   */
  css?: string;
  /**
   * custom css styles for different dashboard components.
   * all style properties accept css rules as strings
   */
  styles?: {
    /**
     *  style container that holds all dashboard widgets
     */
    widgets?: string;
    /**
     * style main wrapper containing both rendered html and widgets container
     */
    wrapper?: string;
    /**
     * style statistical widget containers (displays single value metrics)
     */
    stat?: string;
    /**
     *  style pie chart widget containers
     */
    piechart?: string;
    /**
     * style bar chart widget containers
     */
    barchart?: string;
    /**
     * style tabular data widget containers
     */
    tabular?: string;
    /**
     * style doughnut chart widget containers
     */
    doughnutchart?: string;
    /**
     *  style line chart widget containers
     */
    linechart?: string;
    /**
     * style radar chart widget containers
     */
    radarchart?: string;
  };
}

example usage

const explorer = new MongooseExplorer({
  mongoose,
  dashboard: {
    render: async (req, theme) => {
      const count = await User.countDocuments();

      return `
        <div class="dashboard-stats">
          <div class="stat-card">
            <h3>Total Users</h3>
            <p>${count}</p>
          </div>
        </div>
      `;
    },

    // include external resources
    head: `
      <link rel="stylesheet" href="https://cdn.example.com/custom-styles.css">
      <script src="https://cdn.example.com/animation-library.js"></script>
    `,

    // custom javascript (using absolute path)
    js: "C:/users/john/project/dashboard.js",
    // or inline javascript
    js: `
      // add custom event handlers
      document.querySelector(".stat-card).addEventListener("click", () => {
        //
      });
    `,

    css: `
      .stat-card {
        border-radius: var(--radius-md);
        box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px;
      }
    `,

    // custom styles for different components
    styles: {
      // style the wrapper containing everything
      wrapper: `
        padding: 2rem;
        background: #f5f5f5;
        display: grid;
        gap: 1rem;
      `,
      // style the widgets container
      widgets: `
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
        gap: 1rem;
        padding: 1rem;
      `,
      // style stat widget containers
      stat: `
        background: white;
        padding: 1.5rem;
        border-radius: 8px;
        box-shadow: 0 2px 4px rgba(0,0,0,0.1);
      `,
      // style chart widget containers
      piechart: `
        background: white;
        padding: 1rem;
        border-radius: 8px;
        min-height: 300px;
      `
    }
  }
});

widgets

  • type: Widget[]
  • description: An array of widgets to display on the dashboard. each widget can be of different types (stat, bar chart, pie chart, tabular, doughnut chart, line chart, radar chart, custom)

Widget definition

type Widget =
  | StatWidget
  | BarChartWidget
  | PieChartWidget
  | TabularWidget
  | DoughnutWidget
  | LineWidget
  | RadarWidget
  | CustomWidget;

interface StatWidget {
  type: "stat";
  key?: string;
  title: string;
  resolver: () => Promise<string | number>;
  /**
   * custom rendering function for the displayed value.
   * can return a plain string or html string to be rendered
   */
  render?: (value: string | number, theme: Theme) => string;
}

interface BarChartWidget {
  type: "bar chart";
  key?: string;
  title: string;
  resolver: () => Promise<{
    labels: string[];
    datasets: {
      label: string;
      data: number[];
    }[];
  }>;
}

interface PieChartWidget {
  type: "pie chart";
  key?: string;
  title: string;
  resolver: () => Promise<{
    labels: string[];
    data: number[];
  }>;
}

interface TabularWidget {
  type: "tabular";
  key?: string;
  title: string;
  resolver: () => Promise<object[]>;
  /**
   * determines whether table header is displayed
   */
  header?: boolean;
}

interface DoughnutWidget {
  type: "doughnut chart";
  key?: string;
  title: string;
  resolver: () => Promise<{
    labels: string[];
    data: number[];
  }>;
}

interface LineWidget {
  type: "line chart";
  key?: string;
  title: string;
  resolver: () => Promise<{
    labels: string[];
    datasets: {
      label: string;
      data: number[];
    }[];
  }>;
}

interface RadarWidget {
  type: "radar chart";
  key?: string;
  title: string;
  resolver: () => Promise<{
    labels: string[];
    datasets: {
      label: string;
      data: number[];
    }[];
  }>;
}

interface CustomWidget {
  type: "custom";
  key?: string;
  title: string;
  render: (theme: Theme) => string | Promise<string>;
  element?: {
    style?: string;
  };
}

note on key: explicitly for access control. acts as a unique identifier for the widget. if key is absent, title will be used as a fallback for compatibility. however, using unique key values is highly recommended to avoid ambiguity

example usage

const explorer = new MongooseExplorer({
  mongoose,
  widgets: [
    {
      type: "stat",
      title: "Total Users",
      resolver: async () => {
        return await User.countDocuments();
      },
      render: (value, theme) => `
        <div style="text-align: center">
          <h3 style="color: ${theme.colors.primary}">${value}</h3>
          <p>registered users</p>
        </div>
      `
    },

    {
      type: "bar chart",
      title: "Monthly Signups",
      resolver: async () => {
        const data = await User.aggregate([
          {
            $group: {
              _id: { $month: "$created_at" },
              count: { $sum: 1 }
            }
          }
        ]);

        return {
          labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
          datasets: [
            {
              label: "New Users",
              data: data.map((d) => d.count)
            }
          ]
        };
      }
    },

    {
      type: "custom",
      title: "Custom Content",
      render: () => `
        <div style="text-align: center; padding: 1rem;">
          <h3>Custom Widget</h3>
          <p>This is a fully custom widget!</p>
        </div>
      `,
      element: {
        style:
          "background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"
      }
    }
  ]
});

resources

  • type: ResourcesOptions
  • default: {}
  • description: configures how each mongoose model is handled in the explorer

ResourcesOptions definition

type ResourcesOptions = Record<string, ResourceOptions>;

interface ResourceOptions {
  /**
   * whether the resource is explorable
   *
   * @default true
   */
  explorable?: boolean;
  /**
   * allow creating new documents
   *
   * @default true
   */
  creatable?: boolean;
  /**
   * allow document deletion
   *
   * @default true
   */
  deletable?: boolean | ((doc: object) => boolean);
  /**
   * allow document editing
   *
   * @default true
   */
  editable?: boolean | ((doc: object) => boolean);
  /**
   * allow sorting
   *
   * @default true
   */
  sortable?: boolean;
  /**
   * number of documents displayed per page.
   * can be changed from the interface
   */
  limit?: number;
  /**
   * properties which are shown in the interface.
   * this takes precedence over the `viewable` option at the property level
   */
  viewables?: string[];
  /**
   * properties which can be used as a filtering criteria.
   * this takes precedence over the `filterable` option at the property level
   */
  filterables?: string[];
  /**
   * properties which can be created.
   * this takes precedence over the `creatable` option at the property level
   */
  creatables?: string[];
  /**
   * properties which can be edited.
   * this takes precedence over the `editable` option at the property level
   */
  editables?: string[];
  /**
   * properties which can be sorted.
   * this takes precedence over the `sortable` option at the property level
   */
  sortables?: string[];
  /**
   * open referenced documents in new tab
   *
   * @default false
   */
  ref_newtab?: boolean;
  /**
   * custom computed fields that appear in the interface
   */
  virtuals?: Record<string, (doc: object) => string>;
  /**
   * display model indexes
   *
   * @default false
   */
  show_indexes?: boolean;
  /**
   * enable custom query execution and instantly view the result
   */
  query_runner?: {
    /**
     * this takes precedence over the option `query_runner` option at the explorer level
     *
     * @default false
     */
    enabled?: boolean;
    /**
     * predefined queries that can be run with a single click.
     * the concept is similar to PostgreSQL views where you dont have to type the query each time you need it
     */
    presets?: {
      name: string;
      resolver: () => Promise<any>;
    }[];
  };
  bulk_delete?: {
    /**
     * enables bulk deletion of documents.
     * this takes precedence over `bulk_delete` option at the explorer level
     *
     * @default false
     */
    enabled?: boolean;
    /**
     * determines how deletion is performed.
     * documents are deleted using `Document.deleteOne()` if true, otherwise the
     * default behavior is `Model.deleteMany()`
     *
     * @default false
     */
    use_document?: boolean;
  };
  /**
   * cascade delete behavior for dependent documents.
   * if a function is passed, the session is defined if mongodb is in a replica set.
   * uses transaction to delete documents if mongodb is in a replica set or `Promise.all` otherwise.
   * currently not supported for bulk delete
   */
  cascade_delete?:
    | {
        /**
         * related model to delete from
         */
        model: mongoose.Model;
        relation: {
          /**
           * field in the current model
           */
          local_field: string;
          /**
           * field in the related model
           */
          foreign_field: string;
        };
      }[]
    | ((
        doc: mongoose.Document,
        session?: mongodb.ClientSession
      ) => Promise<void>);
  /**
   * document operations
   */
  actions?: Actions;
  /**
   * individual property configuration
   */
  properties?: Record<string, PropertyOptions>;
  /**
   * access control settings for the resource
   * see [resource-level access control](#resource-level-access-control)
   */
  access_control?: ResourceAccessControlPolicy;
}

interface Actions {
  custom?: CustomActions;
  /**
   * override default document creation (new Model().save())
   */
  create?: (body: AnyObject) => Promise<mongoose.Document>;
  /**
   * override default deletion (Document.deleteOne())
   */
  delete?(doc: mongoose.Document): Promise<mongoose.Document>;
  /**
   * override default update (Document.set().save({ validateModifiedOnly: true, timestamps }))
   */
  update?(
    doc: mongoose.Document,
    body: AnyObject,
    timestamps?: boolean
  ): Promise<mongoose.Document>;
}

interface CustomActions {
  /**
   * actions that can be performed on multiple selected documents
   */
  bulk?: {
    /**
     * must be unique
     */
    operation: string;
    /**
     * operation implementation
     */
    execute: (ids: string[]) => void | Promise<void>;
    /**
     * window.confirm prompt message before executing handler.
     * if true default message "are you sure?" is used, otherwise no prompt
     * and handler is executed immediately
     *
     * @default false
     */
    guard?: boolean | string;
    element?: {
      /**
       * visual variant for the button, `filled`, `outline`, `light`, or `subtle`
       *
       * @default outline
       */
      variant?: Variant;
      /**
       * color of the button. can be one of the predefined colors (primary, secondary, success,
       * danger, warning, dark, gray, white) or any custom color defined in theming.colors.custom
       *
       * @default white
       */
      color?: Color;
      /**
       * size of the button, `xs`, `compact-xs`, `sm`, `compact-sm`, `md`, `compact-md`, `lg`,
       * `compact-lg`, `xl` and `compact-xl`
       *
       * @default compact-sm
       */
      size?: Size;
      /**
       * label of the operation's button
       *
       * @default ${operation}
       */
      label?: string;
      /**
       * extra styling for the operation's button
       */
      style?: string;
    };
  }[];
  /**
   * actions that can be performed on individual documents
   */
  document?: {
    /**
     * must be unique
     */
    operation: string;
    execute: (doc: mongoose.Document) => void | Promise<void>;
    /**
     * determines whether the action is applicable to the document
     */
    applicable?: (doc: object) => boolean;
    guard?: boolean | string;
    element?: {
      variant?: Variant;
      color?: Color;
      size?: Size;
      label?: string;
      style?: string;
    };
  }[];
}

interface PropertyOptions {
  /**
   * custom display label for the field in the interface
   */
  label?: string;
  /**
   * controls whether this field can be modified
   */
  editable?: boolean;
  /**
   * enables sorting by this field
   */
  sortable?: boolean;
  /**
   * controls field visibility in the interface
   */
  viewable?: boolean;
  /**
   * determines whether this field can be used as a filtering criteria
   */
  filterable?: boolean;
  /**
   * determines if this field is required when creating and editing.
   * this is purely for UI purposes and does not modify it at the schema level
   */
  required?: boolean | ((value?: any) => boolean);
  /**
   * allows the creation of this field
   */
  creatable?: boolean;
  /**
   * use a textarea input instead of a regular text input.
   * this is only considered if property is a string
   */
  textarea?: boolean;
  /**
   * displays the field value as an image instead of the url string,
   * assuming the value is a valid image url
   */
  as_image?: boolean;
  /**
   * customize how fields are displayed for different views.
   * renderer functions are expected to return html as a string, and input
   * elements in forms are expected to contain `name` attribute matching the field name
   */
  renderers?: Renderers;
}

interface Renderers {
  /**
   * customize how the value appears in the table view
   */
  list?: (value: any) => string;
  /**
   * customize the input field in create form
   */
  create?: (enumvalues: any[] | null) => string;
  /**
   * customize how the filter input appears
   */
  filter?: (enumvalues: any[] | null) => string;
  /**
   * customize how the value appears in detail view
   */
  view?: (value: any) => string;
  /**
   * customize the input field in edit form
   */
  edit?: (params: { value: any; enumvalues: any[] | null }) => string;
}

complete example

const explorer = new MongooseExplorer({
  mongoose,
  resources: {
    User: {
      explorable: true,
      creatable: false,
      deletable: (user) => doc.role !== "admin",
      editable: (user) => !user.active,
      sortable: true,
      limit: 50,
      viewables: ["email", "role"],
      filterables: ["role"],
      creatables: ["email", "username", "role"], // redundant since creatable is false
      editables: ["role", "active"],
      sortables: ["role", "active"],
      ref_newtab: true,
      virtuals: {
        info: (user) => `${user.username} ${user.email} - ${user.role}`
      },
      show_indexes: true,
      query_runner: {
        enabled: true,
        presets: [
          {
            name: "Inactive Users",
            resolver: () =>
              User.find({
                last_login: {
                  $lt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
                }
              }).lean()
          }
        ]
      },
      actions: {
        create: async (body) => {
          const user = await User.create(body);

          await services.email.verification(user.email);

          return user;
        },
        delete: async (user) => {
          await user.deleteOne();

          await posthog.capture("user.deleted", user.toObject());

          return user;
        },
        custom: {
          bulk: [
            {
              operation: "ban",
              execute: async (ids) => {
                await User.updateMany(
                  { _id: { $in: ids } },
                  { $set: { is_banned: true } }
                );
              },
              guard: true,
              element: {
                variant: "filled",
                color: "danger",
                size: "lg",
                label: "BAN",
                style: "border-radius: 10px"
              }
            }
          ],
          document: [
            {
              operation: "promote-to-admin",
              execute: async (user) => {
                await user.set("role", "admin").save();
              },
              applicable: (user) => user.role !== "admin",
              guard: "promote to admin fr?",
              element: {
                variant: "outline",
                color: "primary",
                size: "md",
                label: "Promote To Admin",
                style: "font-style: italic; font-weight: bold"
              }
            }
          ]
        }
      },
      bulk_delete: {
        enabled: true,
        use_document: true
      },
      cascade_delete: [
        {
          model: Post,
          relation: {
            local_field: "_id",
            foreign_field: "author_id"
          }
        },
        {
          model: Notification,
          relation: {
            local_field: "_id",
            foreign_field: "recipient_id"
          }
        }
      ],
      // or manually
      cascade_delete: async (doc, session) => {
        if (!session)
          await Promise.all([
            Post.deleteMany({ author_id: doc._id }),
            Notification.deleteMany({ recipient_id: doc._id })
          ]);
        else {
          await Post.deleteMany({ author_id: doc._id }, { session });

          await Notification.deleteMany({ recipient_id: doc._id }, { session });
        }
      },
      properties: {
        email: {
          label: "E-Mail",
          editable: false,
          sortable: false,
          viewable: true,
          filterable: false,
          required: true,
          creatable: true,
          textarea: false,
          as_image: false,
          renderers: {
            list: (email) => mask(email),
            create: () => `
              <input type="email" name="email" class="input size-sm" style="border-radius: 20px"></input>
            `,
            view: (email) => mask(email)
          }
        },
        /**
         * target properties nested within an object by using dot notation
         */
        "profile.settings.notificatons": {}
      }
    }
  }
});

pages

  • type: CustomPage[]
  • description: custom pages that will be rendered at custom routes

CustomPage definition

interface CustomPage {
  /**
   * path where the custom page will be mounted.
   * this value must not be prefixed with `/` and must be unique
   */
  path: string;
  /**
   * absolute path to the custom pug file for rendering the page
   */
  pug: string;
  /**
   * the label for the custom page link, which will be displayed in the navigation.
   * if not provided, defaults to the `path` value
   */
  label?: string;
  /**
   * additional html to inject into the <head> section.
   * useful for including external resources like stylesheets, remote scripts, etc
   */
  head?: string;
  /**
   * page specific javascript.
   * can either be an absolute path to a javascript file or inline javascript as a string
   */
  js?: string;
  /**
   * page specific styles.
   * can either be an absolute path to a css file or inline css as a string
   */
  css?: string;
  /**
   * define local variables for the page
   */
  locals?: (req: Request) => object | Promise<object>;
  /**
   * action handler for the custom page. this will be triggered when a POST
   * request is made in the page route.
   * to use this, include a form with `method="post"` in the page.
   * `params.body` is the parsed url-encoded body, while the raw body is still available via `req.body`
   */
  action?: (params: { req: Request; body: object }) => void | Promise<void>;
  /**
   * joi schema for validating `req.body` in the action handler.
   * the following validation options are applied by default:
   * - `allowUnknown: false`
   * - `presence: "required"`
   * - `abortEarly: false`
   * - `errors.wrap.label: false`
   */
  schema?: joi.ObjectSchema;
}

template rendering

within the pug template, you'll have access to the following:

  • theme: Theme: the theme object. see theming for more details
  • link: (path: string) => string: a function that generates links to other custom pages

example

new MongooseExplorer({
  mongoose,
  pages: [
    {
      path: "analytics",
      pug: "/path/to/custom-analytics.pug",
      label: "Analytics Dashboard",
      locals: async (req) => {
        const analytics = await calculcate_analytics();

        return {
          user: req.user,
          analytics
        };
      }
    }
  ]
});

access_control

  • type: ExplorerAccessControlPolicy
  • description: the access control system allows you to define granular permissions for different roles at both the explorer and resource levels. this provides fine-grained control over what actions users can perform and what data they can access

explorer-level access control

ExplorerAccessControlPolicy definition

interface ExplorerAccessControlPolicy {
  /**
   * determines user's role from the request.
   * crucial for the entire access control system
   */
  role_resolver: (req: Request) => string;
  /**
   * role-based access rules
   */
  rules?: Record<Role, ExplorerAccessControlRule>;
  /**
   * dynamically modify permissions
   */
  augment?: (
    role: string,
    req: Request,
    rule: ExplorerAccessControlRule
  ) =>
    | string
    | { rule: ExplorerAccessControlRule; replace?: boolean }
    | Promise<string>
    | Promise<{ rule: ExplorerAccessControlRule; replace?: boolean }>;
}

interface ExplorerAccessControlRule {
  /**
   * controls which pages are accessible to users based on their roles.
   * page names or "*" for full access.
   * or object specifying view/action permissions per page
   */
  pages?: string[] | Record<string, PageAccessControl>;
  /**
   * controls which resources are accessible to users based on their roles
   */
  resources?: string[];
  /**
   * controls which widgets are accessible to users based on their roles
   */
  widgets?: string[];
}

interface PageAccessControl {
  /**
   * whether the role can view the page
   */
  view: boolean;
  /**
   * whether the role can execute the page's action
   */
  action: boolean;
}

type Role = string;

example

new MongooseExplorer({
  mongoose,
  access_control: {
    role_resolver: (req) => req.user.role,
    rules: {
      "super-admin": {
        resources: ["*"], // full access to all resources
        pages: ["*"], // full access to all pages and actions
        widgets: ["*"] // full access to all widgets
      },
      admin: {
        resources: ["User", "Post", "AuditLog", "Ticket"], // access to these specific resources
        pages: {
          "*": {
            // default permissions for all pages
            view: true,
            action: false
          },
          "emergency-actions": {
            // override for specific page
            view: false,
            action: false
          }
        },
        widgets: ["total-users", "monthly-signups"]
      },
      moderator: {
        resources: [], // no access to any resource
        pages: ["content-moderation"],
        widgets: [] // no access to any widget
      }
    },
    augment: async (role, req, rule) => {
      if (role !== "super-admin" && (await is_on_duty(req.user)))
        return {
          pages: {
            "emergency-actions": {
              view: true,
              action: true
            }
          }
        };

      return {};
    }
  }
});

resource-level access control

ResourceAccessControlPolicy definition

interface ResourceAccessControlPolicy {
  rules: Record<Role, ResourceAccessControlRule>;
  augment?: (
    role: string,
    req: Request,
    rule: ResourceAccessControlRule
  ) =>
    | string
    | { rule: ResourceAccessControlRule; replace?: boolean }
    | Promise<string>
    | Promise<{ rule: ResourceAccessControlRule; replace?: boolean }>;
}

interface ResourceAccessControlRule {
  /**
   * allowed operations on the resource
   */
  operations: (
    | "create"
    | "read"
    | "update"
    | "delete"
    | "bulk_delete"
    | "query_runner"
    | "*"
  )[];
  /**
   * field-level access control
   */
  fields?: {
    /**
     * fields that can be viewed
     */
    viewables?: string[];
    /**
     * fields that can be set during creation of documents of the resource
     */
    creatables?: string[];
    /**
     * fields that can be modified
     */
    editables?: string[];
    /**
     * fields that can be used for filtering
     */
    filterables?: string[];
    /**
     * fields that can be used for sorting
     */
    sortables?: string[];
  };
  /**
   * query runner permissions
   */
  query_runner?: {
    /**
     * allowed operation
     */
    operations?: (
      | "find"
      | "findOne"
      | "aggregate"
      | "countDocuments"
      | "estimatedDocumentCount"
      | "*"
    )[];
    /**
     * allowed preset queries
     * "*" for all presets or specific preset names
     */
    presets?: string[];
  };
  /**
   * custom actions permissions
   */
  actions?: {
    /**
     * allowed bulk actions
     * "*" for all actions
     */
    bulk?: string[];
    /**
     * allowed document actions
     * "*" for all actions
     */
    document?: string[];
  };
}

example

{
  resources: {
    User: {
      access_control: {
        rules: {
          admin: {
            operations: ["read", "update"],
            fields: {
              viewables: ["*"],
              editables: ["status", "role"],
              filterables: ["status", "role", "created_at"],
              sortables: ["created_at", "status"]
            },
            query_runner: {
              operations: ["find", "countDocuments"],
              presets: ["active users", "recent signups"]
            },
            actions: {
              bulk: ["verify emails"],
              document: ["send verification email", "send welcome email"]
            }
          },
          moderator: {
            operations: ["read"],
            fields: {
              viewables: ["username", "email", "status"],
              filterables: ["status"],
              sortables: ["created_at"]
            },
            query_runner: {
              operations: ["find"],
              presets: ["reported users"]
            },
            actions: {
              document: ["flag for review"]
            }
          }
        },
        augment: async (role, req, rule) => {
          if (await has_temporary_elevated_access(req.user))
            return {
              operations: ["create", "delete"],
              fields: {
                editables: ["*"]
              }
            };

          return {};
        }
      }
    }
  }
}

key principles

  1. the role_resolver is the single source of truth for roles. it determines the user's role, which is used for both explorer-level and resource-level access control.
  2. if no access control rules are specified for a role or resource, full access is granted by default.
  3. in custom pages, a can_execute_action variable is available as a local variable. this can be used to conditionally render action forms based on the user's permissions.
  4. use * wildcard for full access in any category.
  5. the augment function allows for dynamic modification of access rules based on runtime conditions.
    • can return three types of modification:
      • a new role string to change the user's role
      • a modified rule object with optional replace flag
      • an object with a new rule and replace flag
    • if replace is true, the returned rule completely replaces the existing rule, otherwise the rule is merged with the existing rule
  6. returning "*" wildcard from the augment function grants full access by removing all restrictions. this is a shorthand alternative to returning a complete rule structure with wildcards.
  7. explorer-level access control and resource-level access control are distinct features and do not directly interact with each other. however, the role_resolver function, which is configured at the explorer level, is crucial for resource-level access control.
  8. exclusion patterns using - prefix are supported for explorer-level and resource-level rules (except fields).
  9. when using string arrays in ExplorerAccessControlRule.pages, page names are case-insensitive when matching against custom page paths. however, all other string configurations throughout the access control system (including pages when using object syntax) require exact case matching.

custom pages and access control

if can_execute_action
  form(method="post")
    // form fields
    button(type="submit") submit

this ensures that the action form is only displayed to users with the appropriate permissions, as determined by the access control system

events

event system that allows you to hook into various events that occur within the the explorer. this is particularly useful for implementing audit logs of actions performed by users, monitoring system activity, or triggering side effects

available events

  • create: triggered when a new document is created
  • update: triggered when a document is modified
  • delete: triggered when a document is deleted
  • bulk_delete: triggered when multiple documents are deleted
  • query_runner: triggered when a custom query is executed
  • bulk_action: triggered for bulk operations
  • document_action: triggered for single document operations
  • page_action: triggered for custom page interactions

each event passes two parameters to the handler function:

  • req: the request object
  • event: an object containing details about the event

event type definitions

interface CreateEvent {
  timestamp: Date;
  model: string;
  operation: "create";
  doc: RawDoc;
}

interface UpdateEvent {
  timestamp: Date;
  model: string;
  operation: "update";
  doc: RawDoc;
  changes: {
    previous: AnyObject;
    current: AnyObject;
  };
}

interface DeleteEvent {
  timestamp: Date;
  model: string;
  operation: "delete";
  doc: RawDoc;
}

interface BulkDeleteEvent {
  timestamp: Date;
  model: string;
  operation: "bulk_delete";
  document_ids: mongoose.Types.ObjectId[];
}

interface QueryRunnerEvent {
  timestamp: Date;
  model: string;
  operation: "query_runner";
  sub_operation: string;
  query: AnyObject | mongoose.PipelineStage[];
  options: mongoose.QueryOptions;
}

interface BulkActionEvent {
  timestamp: Date;
  model: string;
  operation: "bulk_action";
  sub_operation: string;
  document_ids: mongoose.Types.ObjectId[];
}

interface DocumentActionEvent {
  timestamp: Date;
  model: string;
  operation: "document_action";
  sub_operation: string;
  doc: RawDoc;
}

interface PageActionEvent {
  timestamp: Date;
  operation: "page_action";
  page: string;
  body: AnyObject;
}

type RawDoc = {
  _id: mongoose.Types.ObjectId;
} & AnyObject;

suggested audit log schema

to keep an audit log, you can create a mongoose model based on the events described. note that this is just a suggestion - you can modify it according to your specific needs

const ExplorerAuditLogSchema = new mongoose.Schema({
  timestamp: {
    type: Date,
    required: true
  },
  model: {
    type: String,
    required: true
  },
  actor_id: {
    type: mongoose.Schema.Types.ObjectId,
    ref: "User",
    required: true
  },
  operation: {
    type: String,
    required: true,
    enum: [
      "create",
      "update",
      "delete",
      "bulk_delete",
      "query_runner",
      "bulk_action",
      "document_action",
      "page_action"
    ]
  },
  document_id: {
    type: mongoose.Schema.Types.ObjectId,
    refPath: "model
  },
  document_ids: {
    type: [mongoose.Schema.Types.ObjectId],
    default: undefined,
    refPath: "model"
  },
  changes: {
    previous: mongoose.Schema.Types.Mixed,
    current: mongoose.Schema.Types.Mixed
  },
  query: mongoose.Schema.Types.Mixed,
  query_options: mongoose.Schema.Types.Mixed,
  page: String,
  body: mongoose.Schema.Types.Mixed,
  metadata: mongoose.Schema.Types.Mixed
});

ExplorerAuditLogSchema.index({ timestamp: -1 });
ExplorerAuditLogSchema.index({ operation: 1 });
ExplorerAuditLogSchema.index({ model: 1 });
ExplorerAuditLogSchema.index({ actor_id: 1 });
ExplorerAuditLogSchema.index({ document_id: 1 }, { sparse: true });

const ExplorerAuditLog = mongoose.model(
  "ExplorerAuditLog",
  ExplorerAuditLogSchema
);

example usage

const explorer = new MongooseExplorer({ mongoose });

explorer.on("create", async (req, event) => {
  await ExplorerAuditLog.create({
    operation: event.operation,
    model: event.model,
    document_id: event.doc._id,
    actor_id: req.user._id,
    timestamp: event.timestamp
  });
});

explorer.on("update", async (req, event) => {
  await ExplorerAuditLog.create({
    operation: event.operation,
    model: event.model,
    document_id: event.doc._id,
    changes: event.changes,
    actor_id: req.user._id,
    timestamp: event.timestamp
  });
});

explorer.on("delete", async (req, event) => {
  await ExplorerAuditLog.create({
    operation: event.operation,
    model: event.model,
    timestamp: event.timestamp,
    actor_id: req.user._id,
    metadata: {
      deleted_document: event.doc
    }
  });
});

remember that this is just a suggested schema - you can modify it based on your specific needs. the key is to capture the information that's most relevant for your use case

features and behavior

1. string filtering with regex

the interface supports filtering strings using regex patterns enclosed within / delimiters. example: a filter input of /doe/i/ is processed as /doe/i and applied using mongodb's $regex operator

2. query identification: is_explorer_operation

  • type: (subject: mongoose.Query | mongoose.Document | mongoose.Aggregate) => boolean
  • description: a utility function to determine if any operation originates from the explorer. this is helpful for configuring middlewares that should behave differently for operations originated from the explorer

example usage

import { is_explorer_operation } from "@kamalyb/mongoose-explore";

schema.pre(/^find/, function (next) {
  if (!is_explorer_operation(this)) this.where({ deleted: { $ne: true } });

  next();
});

schema.pre("aggregate", function (next) {
  if (!is_explorer_operation(this))
    this.pipeline().unshift({ $match: { deleted: { $ne: true } } });

  next();
});

schema.pre("deleteOne", { query: false, document: true }, function (next) {
  if (is_explorer_operation(this)) something();

  next();
});

schema.post("save", function (next) {
  if (!is_explorer_operation(this)) another();

  next();
});

3. inclusion and exclusion patterns

  • inclusion and exclusion patterns cannot be mixed in the same array
  • if any item in an array starts with -, the entire array is treated as an exclusion list

limitations

  1. filtering properties of type map is not supported
  2. filtering and editing properties of type mixed and array of objects is not supported
  3. sorting properties of type map, mixed, and array is not supported