Admin PanelResources & CRUD

Resources & CRUD

Every drizzle table you pass to the admin panel becomes a browse-able resource with a full CRUD UI. The UI includes a paginated list with search/filter/sort, a detail view with related rows, create and edit forms, and per-row custom actions. The built-in tables from @colyseus/database (users, configs, leaderboards, cloud saves, analytics events, roles, user notes, audit log) are registered automatically. You customize them and add your own via defineAdminResource().

Built-in resources

The following tables come pre-registered with sensible defaults:

ResourceSource tableNotes
Userscolyseus_users
Configscolyseus_configs
Cloud savescolyseus_cloud_saves
Leaderboardscolyseus_leaderboards
Leaderboard entriescolyseus_leaderboard_entries
Analytics eventscolyseus_analytics_eventsAppend-only on the write path.
Rolescolyseus_rolesBlocked for mods by the core role rule itself (not a policy). Modifying it would bypass the role system.
User notescolyseus_user_notesauthorId auto-filled from session on create.
Audit logcolyseus_admin_auditRead-only via policies: all writes denied (append-only contract).

Unless a table declares its own policies, access follows the standard role rule everywhere. Only the audit log declares its own by default.

  • admin: full CRUD.
  • mod: list, read, and update, only on resources they’re scoped to.
  • user: cannot open the panel UI (403). Direct API calls to list/read endpoints are still permitted.

defineAdminResource()

Override any of the defaults (or add a new resource) by passing defineAdminResource(table, config) into admin({ resources: { … } }):

src/app.config.ts
import { admin, defineAdminResource } from "@colyseus/admin";
import { guilds } from "./db/schema";
 
const guildResource = defineAdminResource(guilds, {
    label: "Guilds",
    icon: "safety",
    list: {
        columns: ["name", "ownerId", "memberCount", "createdAt"],
        sortable: ["name", "createdAt"],
        defaultSort: { field: "createdAt", order: "desc" },
    },
    form: {
        fields: ["name", "description", "ownerId"],
    },
    columns: {
        created_at: { label: "Founded" },
    },
    actions: [{
        name: "rename",
        label: "Rename guild",
        perRow: true,
        confirm: { title: "Rename this guild?" },
        handler: async (row, { userId }) => {
            // ...rename logic
        },
        roles: ["admin", "mod"],
    }],
    policies: {
        delete: ["admin"], // admin-only deletion
    },
});
 
routes: createRouter({
    ...admin({
        database: db,
        resources: {
            guilds: guildResource,
        },
    }),
}),

Configuration reference

defineAdminResource(table, {
    label,        // sidebar/page title; defaults to humanized table name
    icon,         // sidebar icon from the built-in icon set (see below)
    list:    { columns, sortable, defaultSort },
    form:    { fields },
    show:    { fields },
    create:  { defaults },
    columns: { <colName>: { label, linkTo } },
    relations: { <relName>: { label } },
    actions: [ /* ResourceAction[] */ ],
    policies: { list, read, create, update, delete },
})
SectionFieldDescription
listcolumnsColumns shown in the list view. Omit to show all.
sortableColumn names that get a sort affordance in the header.
defaultSort{ field, order: 'asc' | 'desc' } applied when no _sort is requested. Useful for append-only tables.
formfieldsColumns shown in create + edit forms. Omit to show all non-defaulted columns.
showfieldsColumns shown on the read-only detail page.
createdefaults(ctx) => Record<string, any>: server-side defaults merged into the body before INSERT. Receives { operatorId, resource }. The user’s request body wins on conflict.
columns<col>.labelOverride the auto-humanized column label.
<col>.linkToDecorate the cell as a link to another resource’s show page. Static: { resource: "users" }; for the dynamic form, see FK auto-linking.
relations<rel>.labelOverride the auto-humanized relation tab label.
actionsResourceAction[]Custom per-row or bulk operations. See below.
policieslist | read | create | update | deletePer-action RBAC. See Policies.

Server-side defaults on create

The create.defaults function runs on every INSERT with the resolved operator in scope. Use it to auto-fill audit fields like authorId, createdBy, or anything else derived from the session:

create: {
    defaults: ({ operatorId }) => ({ authorId: operatorId }),
}

Keys can be either SQL column names (author_id) or drizzle JS keys (authorId). Both forms are normalized. The user’s request body wins on conflict, matching how SQL DEFAULTs behave per-column.

Custom actions

Custom actions are operations beyond create/update/delete: things like “reset a player’s level”, “refund a transaction”, “rename a guild”. They appear as buttons in the panel UI and resolve to a POST request:

POST /admin-api/:resource/_action/:name

Per-row actions

Set perRow: true to make the action operate on a specific row. The panel renders it as a row-level button; the handler receives the row data:

actions: [{
    name: "reset_progress",
    label: "Reset progress",
    perRow: true,
    confirm: {
        title: "Reset this player's progress?",
        description: "This clears all cloud saves and resets the user's level to 1.",
    },
    handler: async (row, { userId, resource }) => {
        await db.saves.delete(row.id);
        await db.drizzle.update(users).set({ level: 1 }).where(eq(users.id, row.id));
        return { ok: true };
    },
    roles: ["admin"],
}]

The handler’s return value is JSON-serialized and sent back to the panel for toast/notification display.

Bulk actions

Omit perRow for actions that operate on the table at large (e.g. “reseed leaderboard”, “purge old entries”). The handler receives null for the row argument.

Confirmation prompts

confirm is optional but recommended for destructive or expensive operations:

confirm: {
    title: "Refund this transaction?",
    description: "The amount will be credited back to the user's wallet.",
}

Audit logging

Custom action invocations are recorded to the audit log automatically: operator, action name, target row id, and result. See Audit log below.

Policies

Per-resource RBAC overrides the role + scope defaults. policies is keyed by action (list, read, create, update, delete) with values:

ValueMeaning
['admin']Only admins (full role) can perform this action.
['admin', 'mod']Admins or any mod, regardless of resource scope assignment.
'everyone'No auth required. Use sparingly. Combine with route-level guarding if needed.
'deny'Blocked entirely, even for admins. Useful for read-only resources like the audit log.
policies: {
    delete: ["admin"],         // admin-only deletion
    create: ["admin", "mod"],  // mods can create, regardless of scope
    update: "deny",            // table is read-only
}

A policy set for an action replaces the standard RBAC rule for that action on that resource. The policy is not layered on top. Only actions with no policy fall back to the standard db.moderation.can(userId, action, resource) rule.

Custom action policies

Custom actions have their own roles field (independent of policies):

actions: [{
    name: "rename",
    handler: ...,
    roles: ["admin", "mod"],  // restrict to admins + mods
}]
⚠️

A non-empty roles list is checked literally: it does not implicitly include admins, so always list "admin" explicitly. And an empty roles: [] skips the role check entirely, allowing any authenticated identity to invoke the action. To remove an action, omit it from the resource definition instead.

FK auto-linking

The panel reads database.relations (set on the GameDatabase constructor) to auto-decorate foreign-key columns as links. A userId column on cloudSaves becomes a clickable link to the user’s show page automatically (no per-column override needed).

For columns the panel can’t auto-detect, use a dynamic linkTo. A generic targetId column on the audit log, for example, references different tables depending on resource:

columns: {
    target_id: {
        label: "Target",
        linkTo: { resourceFromColumn: "resource" },
        // value of `resource` on the same row picks the link target
    },
}

Icons

The icon field accepts the names of icons from the panel’s built-in icon set. Types are surfaced as the AdminIconName union. Common picks: user, team, safety, trophy, file-text, setting, line-chart, database, thunderbolt. See ADMIN_ICON_NAMES in @colyseus/admin for the full list.

Audit log

Every mutation from the panel is recorded to the append-only colyseus_admin_audit table. The log covers CRUD (create, update, delete), ban/unban/session-revoke, custom-action invocations, and live-room mutations (kick, lock, state edit, dispose). Each entry stores:

  • created_at: when it happened
  • operator_id: which admin did it
  • action: create / update / delete / user.ban / room.kick / etc. (full AuditAction enum in @colyseus/database)
  • resource: the table name
  • target_id: the affected row id
  • payload: JSON with the before + after diff on updates, reason/until on bans, custom-action result, etc.

Browse the log inside the panel under the Audit log resource (admin-read-only: writes from the panel are denied via policies: { create: 'deny', update: 'deny', delete: 'deny' }).

Recording from your own code

If your own tooling mutates the DB outside the panel (e.g. a custom HTTP endpoint), record audit entries the same way the panel does:

await db.audit.record({
    operatorId,
    action: "custom",
    resource: "users",
    targetId: userId,
    payload: { reason: "VIP grant", before: { vip: false }, after: { vip: true } },
});

For updates where you want a structured diff, use recordUpdate(). It stores a column-level diff of the changed fields (not both full rows):

await db.audit.recordUpdate({
    operatorId,
    resource: "users",
    targetId: userId,
    before: oldRow,
    after: newRow,
});

The panel’s own endpoints log audit-write failures without propagating them, so a failed audit write never blocks a successful mutation. A direct db.audit.record() call from your own code throws on failure instead. Wrap it yourself if you want the same behavior.

Next steps