@xenterprises/fastify-xadmin
v1.1.0
Published
Fastify plugin for admin dashboard APIs - tenants, roles, users, audit logs, impersonation, and Stripe management.
Readme
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.
Install
npm install @xenterprises/fastify-xadmin fastify@5 @prisma/clientfastify (^5) and @prisma/client (^5 || ^6 || ^7) are peer dependencies.
Minimal example
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.
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.
Decorators
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 },
});Routes
All routes are mounted under prefix (default /api/admin) and require the
listed permission unless skipAuth is true. 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.
Dashboard (4)
| Method | Path | Permission | Description |
|--------|------|------------|-------------|
| GET | /dashboard/health | dashboard:read | System health (database, Stripe) |
| GET | /dashboard/metrics | dashboard:read | Platform metrics |
| GET | /dashboard/active-users | dashboard:read | Active user count |
| GET | /dashboard/errors | dashboard:read | Recent errors (requires ErrorLog model) |
Tenants (9)
| Method | Path | Permission | Description |
|--------|------|------------|-------------|
| GET | /tenants | tenants:read | List tenants (paginated) |
| POST | /tenants | tenants:create | Create tenant |
| GET | /tenants/:id | tenants:read | Get tenant |
| PUT | /tenants/:id | tenants:update | Update tenant |
| DELETE | /tenants/:id | tenants:delete | Delete tenant |
| POST | /tenants/:id/suspend | tenants:manage | Suspend tenant |
| POST | /tenants/:id/activate | tenants:manage | Activate tenant |
| PUT | /tenants/:id/plan | tenants:manage | Change plan |
| PUT | /tenants/:id/settings | tenants:manage | Update settings |
Roles (9)
| Method | Path | Permission | Description |
|--------|------|------------|-------------|
| GET | /roles | roles:read | List roles |
| POST | /roles | roles:create | Create role |
| GET | /roles/permissions | roles:read | List all permissions |
| GET | /roles/:id | roles:read | Get role |
| PUT | /roles/:id | roles:update | Update role |
| DELETE | /roles/:id | roles:delete | Delete role (409 while users assigned) |
| PUT | /roles/:id/permissions | roles:manage | Update role permissions (400 on unknown ids) |
| POST | /roles/:id/assign | roles:manage | Assign role to user |
| POST | /roles/:id/revoke | roles:manage | Revoke role from user |
Users (9)
| Method | Path | Permission | Description |
|--------|------|------------|-------------|
| GET | /users | users:read | List users (paginated) |
| POST | /users | users:create | Create user (409 on duplicate email) |
| GET | /users/:id | users:read | Get user |
| PUT | /users/:id | users:update | Update user |
| DELETE | /users/:id | users:delete | Soft delete by default; ?hardDelete=true hard-deletes (any other value, including false, soft-deletes) |
| PUT | /users/:id/role | users:manage | Change role |
| POST | /users/:id/suspend | users:manage | Suspend user |
| POST | /users/:id/unsuspend | users:manage | Unsuspend user |
| POST | /users/:id/reset-password | users:manage | Trigger password reset |
Stripe (10)
Requires the fastify.stripe decorator (from @xenterprises/fastify-xstripe);
returns 503 otherwise. The Stripe client is created once by xStripe and
reused — xAdmin never creates Stripe clients.
| Method | Path | Permission | Description |
|--------|------|------------|-------------|
| GET | /stripe/customers | billing:read | List customers |
| GET | /stripe/customers/:id | billing:read | Get customer |
| POST | /stripe/customers/:id/sync | billing:manage | Sync from Stripe |
| GET | /stripe/subscriptions | billing:read | List subscriptions |
| POST | /stripe/subscriptions/:id/cancel | billing:manage | Cancel subscription |
| GET | /stripe/invoices | invoices:read | List invoices |
| POST | /stripe/invoices/:id/refund | invoices:refund | Refund invoice |
| POST | /stripe/invoices/:id/void | billing:manage | Void invoice |
| GET | /stripe/payment-methods | billing:read | List payment methods |
| GET | /stripe/webhook-logs | billing:read | List webhook events (requires StripeWebhookLog model) |
Audit log (2)
| Method | Path | Permission | Description |
|--------|------|------------|-------------|
| GET | /audit-log | audit:read | List entries (paginated, filterable) |
| POST | /audit-log/export | audit:export | Request an export |
Impersonation (3)
| Method | Path | Permission | Description |
|--------|------|------------|-------------|
| POST | /impersonation/start | impersonation:use | Start impersonating a user (403 for admin targets, 401 without an authenticated admin) |
| POST | /impersonation/stop | impersonation:use | Stop impersonating (token via x-impersonation-token header or body) |
| GET | /impersonation/status | impersonation:use | Current session status |
Error behavior
- Registration throws
Errors naming the plugin, the option/decorator, and an example, e.g.xadmin: option `skipAuth` must be a boolean, e.g. `app.register(xAdmin, { skipAuth: true })`. - Routes throw status-coded errors handled by Fastify's error machinery:
400 (bad input — including JSON Schema validation failures on body, params,
or querystring), 401 (unauthenticated), 403 (missing permission / forbidden
target), 404 (missing resource), 409 (conflict), 503 (Stripe not
configured). Responses are Fastify's standard
{ statusCode, error, message }. - 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.
Database schema
See docs/prisma-schema.prisma 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).
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) |
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
License
Proprietary — All Rights Reserved X Enterprises. See LICENSE.
