X Enterprises

fastify-xadmin

Admin dashboard API plugin for Fastify v5 — tenants, RBAC roles/permissions, user administration, audit logging, impersonation, and Stripe billing management.

fastify-xadmin

Fastify v5 plugin that mounts a complete admin-dashboard API — tenant management, roles/permissions (RBAC), user administration, audit logging, impersonation, and Stripe billing management — for SaaS applications that already use Prisma and an auth plugin. Built as the backend for the @xenterprises/nuxt-x-app-admin Nuxt layer, but usable by any admin frontend.

Installation

npm install @xenterprises/fastify-xadmin fastify@5 @prisma/client

fastify (^5) and @prisma/client (^5 || ^6 || ^7) are peer dependencies.

Quick Start

import Fastify from "fastify";
import xConfig from "@xenterprises/fastify-xconfig"; // provides fastify.prisma
import xAuthBetter from "@xenterprises/fastify-xauth-better"; // provides fastify.authenticate
import xAdmin from "@xenterprises/fastify-xadmin";

const app = Fastify({ logger: true });

await app.register(xConfig, { /* ... */ });
await app.register(xAuthBetter, { /* ... */ });
await app.register(xAdmin); // all defaults: routes at /api/admin

await app.listen({ port: 3000 });

xAdmin does not read process.env — the consumer owns env access and passes values to the plugins that need them (Prisma connection to xConfig, Stripe keys to xStripe, and so on). xAdmin itself takes no secrets.

Options

OptionTypeDefaultRequiredDescription
prefixstring"/api/admin"NoRoute prefix for all admin endpoints. Must start with /.
permissionsarray[]NoCustom permissions to add. Each entry needs a string id like "resource:action" (plus resource, action, optional category).
skipAuthbooleanfalseNoSkip authentication/permission checks. Development only.
stripe.enabledbooleanauto-detected from the fastify.stripe decoratorNoEnable Stripe routes. Defaults to true when a fastify.stripe decorator is registered, false otherwise. Explicit true without the decorator logs a warning and the routes return 503.
cache.healthnumber10NoCache TTL (seconds) for GET /dashboard/health.
cache.metricsnumber60NoCache TTL (seconds) for GET /dashboard/metrics and GET /dashboard/errors.
cache.activeUsersnumber30NoCache TTL (seconds) for GET /dashboard/active-users.
cache.permissionsnumber300NoPermission cache TTL (seconds). Reserved — permission checks are in-memory config lookups and are not cached.

Registration fails fast with an actionable error when an option has the wrong shape, when fastify.prisma is missing, or when fastify.authenticate is missing and skipAuth is not true.

Dashboard read caching

Dashboard aggregation reads (/dashboard/health, /dashboard/metrics, /dashboard/active-users, /dashboard/errors) are cached in memory with the TTLs above. The cache is per-process (one instance per Fastify application, created at registration) with no external dependencies; entries expire lazily on read. Successful user and tenant writes (create/update/delete/suspend/activate) invalidate the affected keys (metrics, activeUsers), so reads never serve stale counts after a mutation. The cache is not shared between processes — with multiple app replicas each has its own copy.

fastify.xAdmin Decorator

All API is exposed under a single namespace, fastify.xAdmin:

MemberSignatureDescription
configobjectResolved plugin configuration (prefix, permissions, skipAuth, stripe, cache).
prefixstringResolved route prefix.
cacheobjectResolved cache TTLs.
skipAuthbooleanWhether auth checks are skipped.
stripeEnabledbooleantrue only when Stripe routes are enabled AND fastify.stripe exists.
permissions.allarrayAll permissions (defaults + custom).
permissions.mapMapPermission id → permission object.
permissions.has(userPermissions, required)booleanPermission check; admin:* grants all, <resource>:manage implies other actions on that resource.
permissions.get(id)object | undefinedLook up a permission by id.
permissions.byCategory()objectPermissions grouped by category.
permissions.requirePermission(id)preHandlerSame as requirePermission below.
requirePermission(id)preHandlerRoute preHandler factory; reads request.user.permissions and throws 403 when the permission is missing. No-op when skipAuth is true.
audit.log(request, entry)Promise<void>Write an audit entry: { action, resource, resourceId?, description?, metadata? }. Failures are logged (message only) and swallowed.

Named exports are also available for direct use: DEFAULT_PERMISSIONS, hasPermission, AUDIT_ACTIONS, pagination helpers (parsePaginationQuery, buildPaginationMeta, buildOrderBy), status-coded error helpers (httpError, badRequest, unauthorized, forbidden, notFound, conflict, serviceUnavailable), the cache helper (createCache, CACHE_KEYS), and the impersonation/health/metrics service functions.

Permission checking in your own routes

app.get("/custom", {
  preHandler: [app.xAdmin.requirePermission("custom:read")],
}, async (request) => {
  // only users with 'custom:read' reach here
});

Audit logging from your own routes

await app.xAdmin.audit.log(request, {
  action: "create",
  resource: "orders",
  resourceId: order.id,
  description: "Created order #1234",
  metadata: { amount: 100 },
});

Registered Routes

All routes are mounted under prefix (default /api/admin) and require the listed permission unless skipAuth is true. fastify.authenticate runs first on every route. Every route validates its body, params, and querystring with JSON Schema (querystring values are type-coerced, so ?limit=10 arrives as a number and ?hardDelete=false as a boolean); invalid input is rejected with a 400 before the handler runs.

Successful responses use a consistent envelope: { success: true, data, meta? }meta carries pagination info on list endpoints.

GroupRoutesPage
Dashboard4Dashboard Routes
Tenants9Tenants Routes
Roles9Roles Routes
Users9Users Routes
Stripe10Stripe Routes
Audit log2Audit Log Routes
Impersonation3Impersonation Routes

Error Reference

Registration Errors

Registration throws plain Errors naming the plugin, the option/decorator, and a usage example:

ErrorCause
xadmin: option `prefix` must be a string starting with '/', e.g. `app.register(xAdmin, { prefix: '/api/admin' })` prefix is not a string or does not start with /.
xadmin: option `permissions` must be an array of permission objects with a string `id` like 'resource:action', e.g. `app.register(xAdmin, { permissions: [{ id: 'orders:read', resource: 'orders', action: 'read' }] })` permissions is not an array of objects with a valid id.
xadmin: option `skipAuth` must be a boolean, e.g. `app.register(xAdmin, { skipAuth: true })` skipAuth is not a boolean.
xadmin: option `stripe` must be an object like `{ enabled: boolean }`, e.g. `app.register(xAdmin, { stripe: { enabled: false } })` stripe has the wrong shape.
xadmin: option `cache` must be an object of positive-number TTLs in seconds, e.g. `app.register(xAdmin, { cache: { health: 10, metrics: 60, activeUsers: 30, permissions: 300 } })` cache values are not positive numbers.
xadmin: missing required decorator `fastify.prisma`. Register a Prisma plugin first, e.g. `app.register(xConfig, ...)` before `app.register(xAdmin, ...)` No Prisma decorator registered.
xadmin: missing required decorator `fastify.authenticate`. Register an auth plugin (e.g. xAuthBetter) first, or pass `app.register(xAdmin, { skipAuth: true })` for development No auth plugin registered and skipAuth is not true.

Route Errors

Routes throw status-coded errors handled by Fastify's error machinery; responses are Fastify's standard { statusCode, error, message }:

StatusMeaning
400Bad input — including JSON Schema validation failures on body, params, or querystring.
401Unauthenticated.
403Missing permission, or forbidden target (e.g. impersonating an admin).
404Missing resource.
409Conflict (duplicate email, role with assigned users, duplicate role assignment).
503Stripe not configured (fastify.stripe decorator missing).

Errors from Prisma or Stripe propagate as 500s. Audit-log write failures are caught and logged (error message only, never raw SDK error objects) so they never break the operation being audited. No secrets are logged: no API keys, tokens, passwords, or Authorization headers ever reach the logs.

Default Permissions

CategoryPermissions
User Managementusers:create, users:read, users:update, users:delete, users:manage
Tenant Managementtenants:create, tenants:read, tenants:update, tenants:delete, tenants:manage
Role Managementroles:create, roles:read, roles:update, roles:delete, roles:manage
Billingbilling:read, billing:manage, invoices:read, invoices:refund
Auditaudit:read, audit:export
Securityimpersonation:use
Dashboarddashboard:read
Super Adminadmin:* (grants all permissions)

Permission checks treat admin:* as a grant-all wildcard, and <resource>:manage implies the other actions on that resource.

Database Schema

See docs/prisma-schema.prisma in the plugin repo for the required models: Tenant, Role, UserRole, AuditLog, ImpersonationSession, StripeWebhookLog. ErrorLog is optional (dashboard /errors returns [] without it), as are StripeWebhookLog and the UserRole join table (routes degrade gracefully).

Requirements

  • Node.js >= 20
  • Fastify ^5
  • Prisma (@prisma/client ^5 || ^6 || ^7) via a fastify.prisma decorator (e.g. xConfig)
  • An auth plugin providing fastify.authenticate (e.g. xAuthBetter), unless skipAuth: true
  • Optional: @xenterprises/fastify-xstripe for the Stripe routes

AI Context

package: "@xenterprises/fastify-xadmin"
type: fastify-plugin
use-when: SaaS admin dashboards needing tenant/role/user management, RBAC, audit logging, impersonation, and Stripe billing administration
decorator: fastify.xAdmin (config, permissions, requirePermission, audit.log)
routes: 46 routes under configurable prefix (default /api/admin); all carry JSON Schema validation; response envelope { success, data, meta? }
requires: fastify.prisma decorator (xConfig) and fastify.authenticate (xAuthBetter), unless skipAuth: true
stripe: enabled auto-detects the fastify.stripe decorator; routes return 503 without it
cache: in-memory per-process TTL cache for dashboard reads (cache.health/metrics/activeUsers options)
env: none — plugin never reads process.env; consumer passes all config via app.register options
Copyright © 2026