fastify-xadmin
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
| Option | Type | Default | Required | Description |
|---|---|---|---|---|
prefix | string | "/api/admin" | No | Route prefix for all admin endpoints. Must start with /. |
permissions | array | [] | No | Custom permissions to add. Each entry needs a string id like "resource:action" (plus resource, action, optional category). |
skipAuth | boolean | false | No | Skip authentication/permission checks. Development only. |
stripe.enabled | boolean | auto-detected from the fastify.stripe decorator | No | Enable 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.health | number | 10 | No | Cache TTL (seconds) for GET /dashboard/health. |
cache.metrics | number | 60 | No | Cache TTL (seconds) for GET /dashboard/metrics and GET /dashboard/errors. |
cache.activeUsers | number | 30 | No | Cache TTL (seconds) for GET /dashboard/active-users. |
cache.permissions | number | 300 | No | Permission 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:
| Member | Signature | Description |
|---|---|---|
config | object | Resolved plugin configuration (prefix, permissions, skipAuth, stripe, cache). |
prefix | string | Resolved route prefix. |
cache | object | Resolved cache TTLs. |
skipAuth | boolean | Whether auth checks are skipped. |
stripeEnabled | boolean | true only when Stripe routes are enabled AND fastify.stripe exists. |
permissions.all | array | All permissions (defaults + custom). |
permissions.map | Map | Permission id → permission object. |
permissions.has(userPermissions, required) | → boolean | Permission check; admin:* grants all, <resource>:manage implies other actions on that resource. |
permissions.get(id) | → object | undefined | Look up a permission by id. |
permissions.byCategory() | → object | Permissions grouped by category. |
permissions.requirePermission(id) | → preHandler | Same as requirePermission below. |
requirePermission(id) | → preHandler | Route 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.
| Group | Routes | Page |
|---|---|---|
| Dashboard | 4 | Dashboard Routes |
| Tenants | 9 | Tenants Routes |
| Roles | 9 | Roles Routes |
| Users | 9 | Users Routes |
| Stripe | 10 | Stripe Routes |
| Audit log | 2 | Audit Log Routes |
| Impersonation | 3 | Impersonation Routes |
Error Reference
Registration Errors
Registration throws plain Errors naming the plugin, the option/decorator, and a usage example:
| Error | Cause |
|---|---|
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 }:
| Status | Meaning |
|---|---|
| 400 | Bad input — including JSON Schema validation failures on body, params, or querystring. |
| 401 | Unauthenticated. |
| 403 | Missing permission, or forbidden target (e.g. impersonating an admin). |
| 404 | Missing resource. |
| 409 | Conflict (duplicate email, role with assigned users, duplicate role assignment). |
| 503 | Stripe 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
| Category | Permissions |
|---|---|
| User Management | users:create, users:read, users:update, users:delete, users:manage |
| Tenant Management | tenants:create, tenants:read, tenants:update, tenants:delete, tenants:manage |
| Role Management | roles:create, roles:read, roles:update, roles:delete, roles:manage |
| Billing | billing:read, billing:manage, invoices:read, invoices:refund |
| Audit | audit:read, audit:export |
| Security | impersonation:use |
| Dashboard | dashboard:read |
| Super Admin | admin:* (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 afastify.prismadecorator (e.g. xConfig) - An auth plugin providing
fastify.authenticate(e.g. xAuthBetter), unlessskipAuth: true - Optional:
@xenterprises/fastify-xstripefor 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
