Supercommerce API Docs
Admin API

Admin RBAC Module

HTTP surface for managing platform-admin roles (custom role definitions with a permission map) and reading the catalog of available permissions. Also exports the PermissionsGuard…

HTTP surface for managing platform-admin roles (custom role definitions with a permission map) and reading the catalog of available permissions. Also exports the PermissionsGuard and @RequirePermissions(...) decorator that every admin endpoint across the platform uses to gate access.

Source: api-modules/admin-rbac (registered via AdminRbacModule.forRoot() in apps/api/src/app.module.ts).

RBAC works on platform admins only — vendor-side scoping uses resolveActiveVendorId(session) (active organization). Built-in admin roles (superAdmin, admin) live in code; this module's role table holds dynamic roles administrators create at runtime.


Conventions

Authentication

All endpoints require a Better-Auth session and a role with the matching role:* permission.

Endpoint groupPermission
GET /admin/rbac/roles, GET /admin/rbac/roles/:idrole: read
POST /admin/rbac/rolesrole: create
PUT /admin/rbac/roles/:idrole: update
DELETE /admin/rbac/roles/:idrole: delete
GET /admin/rbac/permissionsany platform admin (no per-resource permission — listing the catalog is read-only metadata)
GET /admin/rbac/me/permissionsany platform admin (no per-resource permission — your own effective grants)
GET /admin/staffuser: list
POST /admin/staffuser: create
POST /admin/staff/:id/ban, POST /admin/staff/:id/unbanuser: ban
POST /admin/staff/:id/set-roleuser: set-role

Every @RequirePermissions endpoint additionally requires the session to be a staff/admin session (a built-in admin/superAdmin role or a dynamicRole); a customer/vendor session is rejected 403 even if it somehow held a matching grant. This is defense-in-depth on top of the catalog check.

Response envelope

Successful responses are wrapped by ResponseInterceptor:

{
  "data": <payload>,
  "message": "Success",
  "statusCode": 200,
  "metadata": { /* optional, e.g. pagination */ }
}

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
409UNIQUE_VIOLATION (role name taken)
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

How permissions work

A permission requirement is a record from resource → array of allowed actions. A user satisfies it if any of their assigned roles grants every listed action on every listed resource.

Controller-side, the requirement is declared via @RequirePermissions(...):

import { PermissionsGuard, RequirePermissions } from "@sc/admin-rbac";

@Controller("admin/orders")
@UseGuards(BetterAuthGuard, PermissionsGuard)
export class AdminOrdersController {
  @Get()
  @RequirePermissions({ order: ["view"] })            // single resource
  list() { /* ... */ }

  @Post(":id/cancel")
  @RequirePermissions({ order: ["cancel"] })          // distinct action
  cancel() { /* ... */ }
}

Multi-resource requirements are an AND — the user needs all of them:

@RequirePermissions({ order: ["view"], user: ["list"] })
// requires: order:view AND user:list

Multiple actions on one resource is also AND:

@RequirePermissions({ vendor: ["view", "update"] })
// requires: vendor:view AND vendor:update

When the guard rejects, it responds 403 Forbidden (FORBIDDEN).


Permission catalog

The full platform permissions map lives in @sc/auth/access-control and is the source of truth for what's grantable to a dynamic role.

ResourceActions
usercreate, update, list, set-role, ban, impersonate, impersonate-admins, delete, set-password
sessionlist, revoke, delete
vendorapprove, suspend, view, update
orderview, update, refund, cancel
invoiceview, regenerate
productview, update, delete, approve
productAttributecreate, read, update, delete
categorycreate, read, update, delete, approve
tagcreate, read, update, delete, approve
brandcreate, read, update, delete, approve
ingredientcreate, read, update, delete, approve
bannercreate, read, update, delete
contentcreate, read, update, delete, manageRawHtml
globalScriptcreate, read, update, delete
adminSettingread, update
storeSettingread, update
platformVendorSettingread, update
rolecreate, read, update, delete
discountcreate, read, update, archive, delete
freeGiftcreate, read, update, archive, delete
dynamicLinkGroupcreate, read, update, delete
dynamicLinkcreate, read, update, delete
cartview, manage
analyticsview, refresh
productSoldMetricsview, refresh
auditview
searchreindex
searchAnalyticsview
reportsview, export
reviewread, create, update, delete, approve, reject, mark-spam
notificationsview, broadcast
stockNotificationread, delete, notify-vendor
serviceabilitycreate, read, update, delete
emailTemplateview, update, test
notificationTemplateview, update
campaignTemplateview, manage
payoutview, create, mark_paid, cancel, adjust, configure
fbtview, rebuild
rewardsread, manage
affiliateApplicationread, review
affiliatePayoutread, create, process
affiliateProfileread, manage, suspend
affiliateOverrideread, manage
affiliateCommissionread
googleMerchantview, manage
metaCatalogview, manage
klaviyoview, manage

Built-in roles (not in this table — defined in code):

  • superAdmin — every action on every resource. Cannot be edited or deleted via this API. The break-glass role.
  • admingrants nothing on its own. A staff admin's capabilities come exclusively from a dynamicRole assigned to them; the built-in admin statement is empty. This makes dynamicRole the single source of truth for staff (including for Better-Auth-backed actions like impersonate/set-password, which the platform performs behind PermissionsGuard rather than via Better-Auth's static-role check). Better-Auth's adminRoles is set to ["admin", "superAdmin"] purely for impersonation target protection (neither can be impersonated without user:impersonate-admins).

Use the dynamic-roles API below to build the roles staff actually need (e.g. "Support" with order:view + user:list + review:read). Every staff admin must be assigned a dynamicRole — without one they can do nothing.


Domain types

RoleResponse

type RoleResponse = {
  id: string;                                  // UUID
  name: string;                                // 1..255 chars; unique
  description: string | null;
  permissions: Record<string, string[]>;       // resourceactions
  createdAt: string;                           // ISO
  updatedAt: string;
};

PermissionRequirement / StoredPermissions

Type-narrowed equivalents used inside the codebase. Each resource key is constrained to the literal union from platformPermissions, and the action array to the actions that resource declares:

type PermissionRequirement = {
  user?:           ("create" | "update" | "list" | ...)[];
  order?:          ("view" | "update" | "refund" | "cancel")[];
  // ... one optional key per resource in the catalog
};

The wire format (above) is the looser Record<string, string[]> because zod can't enforce the per-resource action constraint cleanly — the service validates each (resource, action) pair against the catalog before persisting.


Roles

Base path: /admin/rbac/roles. Permissions on the role:* resource.

GET /admin/rbac/roles — List dynamic roles

Required permission: role: read. Standard QueryDto (page / limit / search / filters[]).

Response 200 — paginated envelope of RoleResponse[].


GET /admin/rbac/roles/:id — Get a role

Required permission: role: read.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id

POST /admin/rbac/roles — Create a role

Required permission: role: create. The service validates every (resource, action) pair against platformPermissions — unknown pairs are rejected with 400 VALIDATION_ERROR (service-layer, not zod).

Privilege-escalation guard: a non-superAdmin author may only grant permissions they themselves hold (their own effective set, per GET /admin/rbac/me/permissions). Requesting a (resource, action) outside that set is rejected 403 FORBIDDEN. superAdmin is unrestricted. This applies to both create and update.

Body

{
  "name": "Support",
  "description": "Read-only support staff",
  "permissions": {
    "order":  ["view"],
    "user":   ["list"],
    "review": ["read", "mark-spam"]
  }
}
FieldTypeConstraints
namestring1..255 chars. Unique among non-deleted roles
descriptionstring?Free-form
permissionsRecord<string, string[]>At least one resource. Each (resource, action) must exist in platformPermissions

Response 201RoleResponse.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod; unknown resource/action pair
409UNIQUE_VIOLATIONRole with this name already exists

PUT /admin/rbac/roles/:id — Update a role

Required permission: role: update. Partial update — every field is optional, but permissions (when sent) replaces the entire map (whole-set semantics; not deep-merge).

Changes take effect on the next request a user makes — there is no session invalidation; the next call evaluates against the new map.

Body

{
  "name": "Support Lead",
  "description": "...",
  "permissions": { "order": ["view", "update"], "user": ["list"] }
}

Response 200 — updated RoleResponse.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id

DELETE /admin/rbac/roles/:id — Delete a role

Required permission: role: delete. Users currently holding this role lose its grants on their next request; their session itself is not affected.

Response 200 — the deleted RoleResponse.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id

Permissions catalog endpoint

GET /admin/rbac/permissions — List all permissions

Returns the full platformPermissions map verbatim. The role-builder UI uses this to render a checklist that's always in sync with the codebase.

Response 200

{
  "data": {
    "user":   ["create", "update", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password"],
    "order":  ["view", "update", "refund", "cancel"],
    "review": ["read", "create", "update", "delete", "approve", "reject", "mark-spam"]
    /* ...one entry per resource */
  },
  "message": "Success",
  "statusCode": 200
}

GET /admin/rbac/me/permissions — Current admin's effective permissions

Authenticated only — no per-resource permission (every admin may read their own grants). Returns the caller's effective permission map, mirroring PermissionsGuard's resolution: superAdmin → the full catalog; everyone else → their dynamicRole's stored permissions (the built-in admin role contributes nothing). Frontends use this to show/hide action UI; the backend still enforces every action.

Response 200

{
  "data": { "user": ["list", "ban"], "session": ["revoke"] },
  "message": "Success",
  "statusCode": 200
}

Staff management

Base path: /admin/staff. First-party admin-user management gated by the user:* permissions — the platform-native replacement for calling Better-Auth's admin plugin from the client (which only works for a static admin/superAdmin role and bypasses the permission catalog). Each action performs the Better-Auth operation server-side behind PermissionsGuard, mirroring the native-mint pattern used for impersonation/set-password.

A staff account is any user with a built-in admin role (admin/superAdmin) or a dynamicRole. Customer ids resolve to 404 here (their existence can't be probed). A superAdmin target may only be managed by a superAdmin (else 403).

StaffResponse

type StaffResponse = {
  id: string;
  name: string;
  email: string;
  emailVerified: boolean;
  image: string | null;
  role: string | null;            // built-in role: "admin" | "superAdmin"
  dynamicRole: string | null;     // admin_role id conferring permissions
  banned: boolean;
  banReason: string | null;
  banExpires: string | null;      // ISO
  createdAt: string;              // ISO
};

GET /admin/staff — List staff accounts

Required permission: user: list. Standard QueryDto (limit / offset / searchValue matches name or email / sortBy ∈ name|email|createdAt / sortDirection).

Response 200 — paginated envelope of StaffResponse[].

POST /admin/staff — Create a staff account

Required permission: user: create. Mints a user with the built-in admin role (grants nothing on its own) plus an optional dynamicRole, and a credential account with the configured password hasher.

Body

{ "name": "Jane", "email": "jane@shop.test", "password": "min-8-chars", "dynamicRole": "<role-uuid|null>" }

Response 201StaffResponse.

Errors409 UNIQUE_VIOLATION (email taken); 400 BAD_REQUEST (unknown dynamicRole).

POST /admin/staff/:id/ban — Ban a staff account

Required permission: user: ban. Sets the ban and revokes all the target's sessions in the same transaction. Body: { reason?: string, banExpiresInDays?: number } (omit banExpiresInDays ⇒ permanent).

Response 200 — updated StaffResponse.

POST /admin/staff/:id/unban — Lift a ban

Required permission: user: ban. Response 200 — updated StaffResponse.

POST /admin/staff/:id/set-role — Assign/clear dynamicRole

Required permission: user: set-role. Body { dynamicRole: string | null } (null clears). Validates the role exists and revokes the target's sessions so a shrunk role can't be used until re-auth.

Response 200 — updated StaffResponse.

Errors404 NOT_FOUND (not a staff account); 403 FORBIDDEN (superAdmin target, non-superAdmin caller); 400 BAD_REQUEST (unknown dynamicRole).


Programmatic API

Other modules consume admin-rbac via the exported PermissionsGuard + RequirePermissions decorator. To gate a new endpoint:

import { PermissionsGuard, RequirePermissions } from "@sc/admin-rbac";
import { BetterAuthGuard } from "@sc/auth";

@Controller("admin/widgets")
@UseGuards(BetterAuthGuard, PermissionsGuard)
export class AdminWidgetController {
  @Get()
  @RequirePermissions({ widget: ["read"] })   // requires `widget:read`
  list() { /* ... */ }
}

For new permissions to exist, add the resource + actions to platformPermissions in api-modules/auth/src/access-control/permissions.ts. The catalog endpoint, role builder, and @RequirePermissions type checker all pick them up at compile time.


  • auth — owns platformPermissions, the user/role tables, and the BetterAuthGuard that runs before PermissionsGuard. Without the session, PermissionsGuard has nothing to check against.
  • vendor — uses RequirePermissions({ vendor: ["approve"] }) to gate the vendor-application approval flow. See vendor.md.
  • Every other admin-facing module (order, catalog, reviews, discount, banner, …) gates its admin controllers via this module.

On this page