Core

Permissions

Declare capabilities and check what the current user can do.

A layer declares capabilities. One backend layer registers a permissions driver that loads the capability list of the current user. Core reads that list to decide what the admin shows.

Declare a capability

app/plugins/pluto-registrations.ts
export default defineNuxtPlugin(() => {
  definePlutoExtension({
    id: 'supabase-blog',
    capabilities: [
      {
        id: 'posts-publish',
        key: 'posts:publish',
        label: 'Publish posts',
        description: 'Change a post from draft to published.'
      }
    ]
  })
})

A capability key has the format <namespace>:<action>, lowercase. Examples: posts:publish, settings:manage.

A setting key is a different string. It uses a dot, for example blog.posts_per_page. Do not mix the two formats.

key and id

A capability has both an id and a key. They hold different strings on purpose.

  • id identifies the registry entry. The registry rewrites it to <extensionId>:<id>.
  • key is the capability string. The registry never changes it.

Use a plain id such as posts-publish. Put the real string in key.

usePlutoCapabilities()

const { items, groups, groupsFor } = usePlutoCapabilities()

items lists every declared capability. The list is for documentation and typing. The database stores capability strings as free text. A capability works even if no layer declares it.

groups groups the capabilities by owner, for a picker such as the Roles editor. A layer names its group with the locale key <owner>.capabilities.group. groupsFor(keys) adds an "Other" group for strings that no layer declares.

usePlutoPermissions()

const { granted, loaded, status, hasDriver, isAdmin, load, ensureLoaded, clear, can } = usePlutoPermissions()

await ensureLoaded(user.id)
can('posts:publish')
MemberMeaning
grantedThe capability strings that the driver returned
loadedtrue after load() completed once
status'idle' or 'loading'
hasDrivertrue when a layer registered a permissions driver
isAdmintrue when granted includes *
load(userId?)Calls the driver. One request runs at a time.
ensureLoaded(userId)Returns at once when the list is loaded for this user. It rejects when the driver rejects.
clear()Resets the state. Core calls it on sign-out.
can(capability)true if the user holds the capability or *
can() fails open when no permissions driver is registered. A site with no permissions backend has no server-side guard, so hiding UI would give no protection. Check hasDriver if you need to tell the two cases apart.
can() is a UI convenience. It is never a security boundary. A user can call your API directly. The real enforcement is the server-side guard of the backend layer, backed by row-level security.

Staff-only admin

Core uses the capability list in two places:

  • The plugin pluto-auth-sync.client.ts loads the list on sign-in and clears it on sign-out. It also syncs the admin language.
  • The route guard waits for the list. A signed-in user with no capability goes to /admin/no-access.

See Admin shell.

Limits

  • Core has no roles UI. The supabase layer adds one. See Users and roles.
  • There are no capability hierarchies and no namespace wildcards. The only wildcard is *.
  • A permissions driver for an admin must return ['*']. If it returns [], the admin is locked out of the admin pages.