Skip to content

Admin

@keryxjs/admin gives you a database admin dashboard in the spirit of RailsAdmin and Django's admin site: browse, filter, and edit any table in your application, without writing a screen per model.

Tables come from api.db.schema, the registry the framework builds from your schema/ directory. A table added by a migration is browsable the moment it exists. There's no scaffolding step and nothing to regenerate.

Everything the dashboard does is an action, so the same surface is reachable from HTTP, curl, and — if you opt in — MCP.

Installation

bash
bun add @keryxjs/admin

Requires keryx 0.43.0 or newer, the version that introduced api.db.schema.

Configuration

adminPlugin is a factory with one required option: resolveRole, which decides who gets in. There's no default, and that's deliberate — how your app identifies an admin is a property of your schema, and a plugin that guessed would guess wrong.

ts
// config/plugins.ts
import { adminPlugin, roleFromUserColumn } from "@keryxjs/admin";
import type { KeryxPlugin } from "keryx";
import type { SessionImpl } from "../actions/session";
import { users } from "../schema/users";

export default {
  plugins: [
    adminPlugin({
      resolveRole: roleFromUserColumn<SessionImpl>({
        table: users,
        sessionKey: (session) => session.userId,
        role: (user) =>
          user.admin_role === "full" || user.admin_role === "read-only"
            ? user.admin_role
            : null,
      }),
      columns: {
        users: { hidden: ["password_hash"], readOnly: ["created_at"] },
      },
    }),
  ] as KeryxPlugin[],
};

The dashboard is now at /api/admin.

Options

OptionPurpose
resolveRoleRequired. Returns "full", "read-only", or null to deny
tables{ include, exclude } lists of SQL table names. Default: every table in schema/
columnsPer-table { hidden, readOnly } column rules, keyed by SQL table name
extraMiddlewareMiddleware appended to every data action, after the role gate
writeMiddlewareMiddleware appended to the create, update, and delete actions only
serveUiServe the built-in HTML dashboard. Set false to keep the JSON APIs and skip the HTML action, so you can ship your own UI on that route

Config and environment

ConfigEnvDefaultPurpose
admin.enabledADMIN_ENABLEDtrueWhen false, every admin action responds 404
admin.serveUiADMIN_SERVE_UItrueWhen false, the built-in HTML dashboard is not served. JSON actions stay available
admin.mcpADMIN_MCP_ENABLEDfalseExposes all data actions as MCP tools, as one group
admin.routeADMIN_ROUTE/adminRoute prefix for the dashboard and its API
admin.defaultLimitADMIN_DEFAULT_LIMIT25Rows per page when the caller doesn't ask
admin.maxLimitADMIN_MAX_LIMIT500Ceiling on rows per page

Roles

There are two, and both are resolved on every request — so revoking someone's access takes effect immediately rather than at their next login.

RoleCan do
read-onlyBrowse, filter, sort, and read single records
fullEverything above, plus create, update, and delete

null denies access entirely (HTTP 401). A read-only caller attempting a write gets HTTP 403, enforced server-side — the UI hides the buttons as a courtesy, not as the control.

Resolving the role yourself

roleFromUserColumn covers the common case: load the caller's user row, map a column to a role. When roles live somewhere a single row lookup can't reach — a join table, a permissions service — write the callback directly. It receives the live connection, so anything on the request is fair game:

ts
adminPlugin({
  resolveRole: async (connection) => {
    const userId = connection.session?.data.userId;
    if (!userId) return null;

    const membership = await api.db.db
      .select()
      .from(teamMembers)
      .where(and(eq(teamMembers.userId, userId), eq(teamMembers.role, "owner")))
      .limit(1);

    return membership.length > 0 ? "full" : null;
  },
});

Filtering

Filters are a tree. Leaves compare a single column; branches combine other filters with and, or, and not, nested as deeply as you need:

ts
{
  and: [
    { column: "active", op: "eq", value: true },
    {
      or: [
        { column: "email", op: "endsWith", value: "@example.com" },
        { column: "tier", op: "in", value: ["pro", "enterprise"] },
      ],
    },
  ]
}
OperatorsNotes
eq, neqvalue: null becomes IS NULL / IS NOT NULL
lt, lte, gt, gte
contains, startsWith, endsWithCase-insensitive, and LIKE wildcards in the value are escaped
like, ilikePattern passed through, for when you want wildcards
in, notInNon-empty array
between[min, max] array
isNull, isNotNullNo value

Two properties worth knowing about. Column names are resolved against the table's real Drizzle columns before any SQL is built, so an unknown or hidden column fails loudly rather than reaching the database. And values bind as query parameters, never interpolated — which is also why contains escapes % and _: searching for 100% finds that text instead of matching every row.

The database validates writes

The dashboard does not re-implement your schema's rules in application code. Unique indexes, foreign keys, NOT NULL, CHECK constraints, and type coercion already live in the database, and that's the only place they can't drift out of sync with reality.

So writes are attempted optimistically, and PostgreSQL's SQLSTATE codes are translated into readable errors that name the constraint which rejected them:

create row in "users": a row with these values already exists
(constraint "users_email_idx"). Key (email)=(ada@example.com) already exists.

Add a constraint in a migration and the dashboard enforces it immediately, with no plugin change and no config to update.

TIP

Hiding a column that is NOT NULL with no default makes the table un-insertable through the dashboard — there's no way to supply the required value. That's usually what you want for something like password_hash: browse and edit users here, create them through your signup action.

Hiding a primary key goes further and makes the whole table read-only. A key the caller can't see is a key it can't send back, so the table reports no primary key at all rather than offering edit and delete actions that could only fail.

Actions

ActionRouteAccess
admin:uiGET /adminnone — returns no data
admin:tablesGET /admin/tablesread
admin:table:schemaGET /admin/tables/:table/schemaread
admin:table:listPOST /admin/tables/:table/listread
admin:record:showPOST /admin/tables/:table/showread
admin:record:createPUT /admin/tables/:table/recordwrite
admin:record:updatePOST /admin/tables/:table/recordwrite
admin:record:destroyDELETE /admin/tables/:table/recordwrite

list and show are POST because a filter tree and a composite primary key are nested objects, and Keryx only merges JSON bodies for non-GET methods. Query strings can't carry { or: [...] } without inventing an encoding.

Records are addressed by a pk object keyed by primary key column, which is what makes composite keys work without a positional convention you'd have to guess:

bash
curl -X POST http://localhost:8080/api/admin/tables/memberships/show \
  -H 'Content-Type: application/json' \
  -d '{"pk": {"user_id": 7, "team_id": 3}}'

A table with no primary key is reported as read-only. Without one there's no safe way to address a single row, and an admin tool that issues unbounded UPDATEs is a loaded gun.

MCP

ADMIN_MCP_ENABLED exposes every admin data action as an MCP tool. It's a single switch for the whole group, because granting an agent generic read and write access to your database is one decision, not eight.

It's off by default, and also requires the MCP server itself:

bash
MCP_SERVER_ENABLED=true
ADMIN_MCP_ENABLED=true

The HTML UI action is never registered as a tool. Everything else about MCP applies as usual — tool calls go through connection.act(), so the role gate and any extraMiddleware still run.

The UI

One self-contained HTML file with inline CSS and JavaScript, served by admin:ui. No bundler, no build step, no dist/ directory, and no CDN at runtime — the package ships raw TypeScript plus one .html file, so it behaves identically whether installed from npm or linked from a workspace.

The client is deliberately thin: it fetches table metadata from admin:table:schema and generates the grid and edit form from it, so it renders any schema without knowing anything about yours. Dark mode follows prefers-color-scheme.

The /admin route is unauthenticated because it returns no data. Every byte of content arrives through the JSON actions, which are gated. An anonymous visitor gets a "not authorized" prompt, not a database.

Bring your own UI

The JSON actions are the product; the HTML file is one client of them. If you'd rather build that client yourself — a page in your Vite app, a different layout, extra screens — turn the built-in shell off. Prefer the factory option so the HTML action is never registered and GET /admin stays free for your own action:

ts
adminPlugin({
  resolveRole,
  serveUi: false,
});

ADMIN_SERVE_UI=false does the same at registration. Setting config.admin.serveUi = false after boot 404s the shell if it was already registered, but doesn't unbind the route — use the factory option when you want to occupy it yourself.

Composing with CSRF

The dashboard can delete any row, so its writes are worth protecting. If your app uses @keryxjs/csrf, pass CsrfMiddleware through writeMiddleware:

ts
import { CsrfMiddleware } from "@keryxjs/csrf";

adminPlugin({
  resolveRole,
  writeMiddleware: [CsrfMiddleware],
});

Use writeMiddleware rather than extraMiddleware here. Guarding reads too would push the token into a query string on GET requests, where it lands in access logs and referrer headers — and reads change no state, so there's nothing for CSRF to protect.

The dashboard's UI handles its side automatically: it fetches a token from /csrf-token on boot and includes it in every write. A 404 there is expected when the CSRF plugin isn't installed, and is ignored.

The write actions declare an optional csrfToken input for you. That matters more than it looks: Zod objects strip keys the schema doesn't mention, so without it the token would be gone before the middleware ran, and CsrfMiddleware would reject every write rather than protecting it. If you write your own middleware that reads a param, make sure the action declares it.

Middleware runs after the role gate, so unauthorized callers are turned away before your middleware does any work on their behalf.

Local development

To poke at the dashboard without wiring it into an app, the plugin ships a dev server that seeds a demo schema:

bash
bun dev:admin                              # http://localhost:8080/api/admin
ADMIN_DEV_ROLE=read-only bun dev:admin     # see the role-gated UI

This grants every visitor an admin role with no authentication whatsoever. It's for looking at the UI, nothing else.

Security notes

  • resolveRole is the whole perimeter. It's required for a reason. Every data action calls it on every request.
  • Hide your secrets. Columns holding password hashes, API keys, or tokens belong in hidden. Hidden columns are absent from listings, records, schema output, filters, and writes — a caller can't read them or tell them apart from columns that don't exist.
  • Narrow the surface. tables.include is the tightest setting: an allowlist of exactly the tables the dashboard may touch. Anything not in your schema/ directory is already invisible, which keeps migration bookkeeping tables out of reach.
  • Turn it off where it doesn't belong. ADMIN_ENABLED=false makes every admin action respond 404, which is a better answer than 403 for an endpoint you'd rather nobody knew about. serveUi: false is the narrower switch: JSON stays, HTML goes.
  • Think twice before enabling MCP. ADMIN_MCP_ENABLED=true hands an agent generic read and write access to every exposed table. Combine it with tables.include and read-only roles.

Released under the MIT License.