Writing a module

Access control

Declare a module's permissions, scopes and roles with defineAccess, register them with registerAccess, add role requirements and policies, and test them with a role provider in a fixture.

This page is for module authors. A module says what can be done (permissions), who holds it by default (grantTo, roles), what a token may be given (scopes), and how a record narrows it (policies). The host app decides the rest in authorization.roles. The model is in Access control; the reasons are in the decision record.

A module declares all of it in one typed file, src/runtime/access.ts, with defineAccess from @frigear-nu/auth/access, and registers it in setup() with registerAccess from @frigear-nu/auth/utils. List @frigear-nu/auth in moduleDependencies and in package.json. All registrations of all modules and the host merge once every module has run, and any error fails the build.

Declare access

src/runtime/access.ts
import { defineAccess } from '@frigear-nu/auth/access'

export const eventsAccess = defineAccess('events', {
  permissions: {
    // Quote every key once one has a colon (eslint's `quote-props`).
    'manage': { label: 'Manage events', grantTo: ['admin', 'eventmanager'] }, // events:manage
    'pii': { label: 'See attendee details', sensitive: true, grantTo: ['admin'] }, // events:pii
    'refund': { label: 'Refund tickets', sessionOnly: true, grantTo: ['admin'] }, // events:refund
    'tickets:read': { label: 'Read your tickets', implicit: true }, // a full name: another resource
    'tickets:write': { label: 'Buy tickets', implicit: true },
  },
  scopes: {
    tickets: { label: 'Your tickets', icon: 'i-lucide-ticket', permissions: ['tickets:read', 'tickets:write'] },
  },
  roles: {
    eventmanager: { label: 'Event manager', grant: ['events:manage', 'events:refund', 'events:pii'] },
  },
})
src/module.ts
import { registerAccess } from '@frigear-nu/auth/utils'
import { eventsAccess } from './runtime/access'

// in setup(): the opt-in role only with `settings.roles`
registerAccess(nuxt, eventsAccess, { roles: !!options.settings?.roles, from: '@frigear-nu/events' })
  • Names come from the keys. manage becomes events:manage. A key with a colon is a full name, for a resource the module owns besides its own (events owns tickets:*).
  • The names are typed. eventsAccess.permissions.manage has the type 'events:manage': use it in routes, MCP tools, menus and tests instead of a string, so a rename is one edit. eventsAccess.scopes.tickets and eventsAccess.roles.eventmanager work the same way.
  • i18n keys are derived unless you pass labelKey / descriptionKey: <resource>.permissions.<key> and <resource>.permissions.<key>Description, <resource>.scopes.<key>, <resource>.roles.<key>. A full-name key is camel-cased (tickets:read → events.permissions.ticketsRead). Pass i18n: 'oauthServer' when the module's translations live under another namespace than the resource. A key that is missing from the locale files shows the label.
  • The file lives in src/runtime/, so server routes, app code and tests can import it as well as module.ts. @frigear-nu/auth/access has no Nuxt, h3 or Vue imports, so it is safe everywhere.

registerAccess(nuxt, access, options?) registers everything unless narrowed:

OptionMeaning
permissionsOnly these permission keys (['profile:read', 'profile:write'] when the admin API is off).
scopesfalse for none, or a list of scope keys.
rolesfalse for none, or a list of role keys. A grantTo naming one of the access's own roles that is left out is dropped with it, so an opt-in role needs no second permission list.
fromThe roles' provenance on the roles admin pages. Defaults to the resource.

Permission fields

FieldMeaning
label, labelKey, description, descriptionKey, groupWhat the roles admin and the token form show. group defaults to the resource of the name.
sensitiveNever covered by * or resource:*: held only when granted by name. On by default for :pii.
sessionOnlyOnly a cookie session may use it. Never held by a token, never offered as a scope.
implicitHeld by every signed-in user, also without @frigear-nu/authorization.
aliasesOld names that, when held, also hold this permission. Expanded on the held side only: a token with events:manage keeps working after you split it, while code that checks events:manage fails.
grantToRoles that get it by default, by name. Roles that inherit them get it too. A role that is not configured is skipped, with a build note. This replaces aggregateTo.

The same name registered by two sources must agree on sensitive, sessionOnly and implicit, or the build fails.

Typed everywhere else

Once a module registered its access, auth generates .nuxt/types/auth-access.d.ts with every registered permission and role. The Permission type (@frigear-nu/auth/access) becomes their union in the app, the server and nuxt.config.ts, so a misspelled permission fails typecheck in authorize, allows, definePolicy, defineSharedPolicy, useAuthorization().can, <Can permission>, route rules, definePageMeta({ authorize }), MCP definitions and menu, widget and tab items. In a module package's own build nothing is generated, so Permission is string there and the package still builds.

Naming rules

  • resource:action, resource first, one colon: events:update, member-area:view. Reuse the actions read, list, create, update, delete, manage.
  • Sensitive data ends in :pii. A permission that exposes other people's names, emails or answers (events:pii, forms:pii) is its own permission, never a side effect of manage.
  • Money and credentials are sessionOnly, each with a permission of its own (events:refund, forms:credentials, users:manage). The permission carries the rule, so a route cannot forget it. When you split one out of an existing permission, roles that name the old one need the new one: say so in the changelog. resource:* covers it.
  • Self-service is implicit. What every signed-in user does with their own data (tickets:read, profile:write, payments:read) needs no role. Check the record with a policy, so users reach only their own.
  • Staff permissions are not implicit. Put them in grantTo: ['admin']. Add a role of your own only when staff of your module differ from the host's admins.

Scopes

A scope is what a token or an OAuth client may be given. A permission is a scope by itself, as are resource:* and * (for clients allowed wildcards). A named scope (the scopes of defineAccess) groups permissions under one consent line: tickets expands to tickets:read and tickets:write.

A named scope may expand only to permissions that a token can hold: no sensitive and no sessionOnly permission, and its name cannot be a registered permission. Scopes work for API tokens and OAuth alike, so a module does not depend on OAuth Server to offer one.

Roles

A module may ship a role (the roles of defineAccess) when its staff are not the host's admins. Make it opt-in when most hosts will not want it, and leave it out with registerAccess(nuxt, access, { roles: false }).

  • Two modules defining one role merge grant and inherit as unions. Conflicting requirements fail the build.
  • The host's authorization.roles.<name> merges into a module role: its label and description win, grant and inherit are unions, its requirements and auto win. With replace: true it drops the module's definition and every grantTo grant into it.
  • Grant by name what a wildcard does not cover: a role with grant: ['events:*'] lacks events:pii.

Register requirements and policies

A requirement lets a role count only while something holds, such as an active subscription. The file's default export decides, and a throw means not met:

src/module.ts
import { addRoleRequirement } from '@frigear-nu/auth/utils'

addRoleRequirement(nuxt, { name: 'subscription', path: resolver.resolve('./runtime/server/requirements/subscription') })
src/runtime/server/requirements/subscription.ts
export default async ({ user, value }: { user: { id: number | string }, value: unknown }) => {
  return hasActiveSubscription(user.id, value) // value: what the role set, `'*'` or plan keys
}

A policy refines one permission against a record. It can deny, never grant. Register the file; module policies run before the host's:

src/module.ts
import { addPolicies } from '@frigear-nu/auth/utils'

addPolicies(nuxt, { path: resolver.resolve('./runtime/server/policies') })
src/runtime/server/policies.ts
// definePolicy is auto-imported in server code. Return false to deny, true or undefined to keep the result.
export const ownTicket = definePolicy<{ userId: number }>('tickets:read', ({ user, record }) => record.userId === user.id)

A policy runs only for a check that passes a record, unless you set { withoutRecord: true }. A shared policy (defineSharedPolicy from @frigear-nu/auth/policies, registered with shared: true) also runs in the browser's can(): it must stay synchronous and import nothing from Nuxt, h3 or the database.

Check them in routes and tools

Authorize first, load second, authorize with the record third. Name the permission through the access declaration:

src/runtime/server/routes/admin/event.patch.ts
import { eventsAccess } from '../../../access'

export default defineEventHandler(async (event) => {
  await authorize(event, eventsAccess.permissions.manage)
  const { id } = await getValidatedRouterParams(event, idParams().parse)
  const record = await loadEvent(id)
  await authorize(event, eventsAccess.permissions.manage, record)
  // ...
})

An unregistered name is 403 forbidden for everyone, so a typo is not a hole. MCP tools and resources pass { permission } and nothing else. Never check another user with allows: userCan is for assignment and target checks only. HTTP routes has the rest of the handler order.

Menus, widgets and tabs take permission to hide what a visitor cannot use. That is display only, so the route behind them checks too.

Under the hood

registerAccess calls the low-level build functions of @frigear-nu/auth/utils, which stay available for what defineAccess does not cover, such as scopes read from module options (OAuth Server's oauthServer.scopes) or a test fixture's one-off role:

FunctionRegisters
addPermission(nuxt, ...definitions)Permissions, each with its full name.
addScope(nuxt, ...scopes)Named scopes.
addRole(nuxt, ...roles)Roles, with name and from (the provenance).
addRoleRequirement(nuxt, { name, path })A role requirement.
addPolicies(nuxt, { path, shared? })A file of policies.
addPermission(nuxt, { name: 'events:manage', label: 'Manage events', labelKey: 'events.permissions.manage', grantTo: ['admin'] })
addRole(nuxt, { name: 'eventmanager', label: 'Event manager', grant: ['events:manage'], from: '@frigear-nu/events' })

Test it

A fixture does not need @frigear-nu/authorization to have roles. Register the roles your tests use with addRole, and give the fixture a role provider that reads a field of the test user. The events fixture decides by email domain, so a cookie session and an API token get the same answer:

test/fixtures/authenticated/nuxt.config.ts
import { fileURLToPath } from 'node:url'
import { addRole, setRoleProvider } from '@frigear-nu/auth/utils'
import MyModule from '../../../src/module'

export default defineNuxtConfig({
  modules: [
    '@nuxthub/core',
    '@frigear-nu/users',
    MyModule,
    (_options, nuxt) => {
      addRole(nuxt, { name: 'wild', label: 'Wildcard staff', grant: ['events:*'] })
      setRoleProvider(nuxt, { path: fileURLToPath(new URL('./server/roles/provider', import.meta.url)) })
    },
  ],
})
test/fixtures/authenticated/server/roles/provider.ts
import { db } from '@nuxthub/db'
import type { AuthRoleProvider } from '@frigear-nu/auth/utils'

const provider: AuthRoleProvider = {
  getAssignedRoles: async (_event, userId) => {
    const actor = await db.query.users.findFirst({ where: (t, { eq }) => eq(t.id, Number(userId)) })
    return actor?.email.endsWith('@staff.test') ? ['eventmanager'] : []
  },
  setRoles: async () => {},
}

export default provider

The real example is packages/events/test/fixtures/authenticated.

  • Cover the credentials. For each route, test a cookie session, an API token that carries the permission, one that does not (403 insufficient_scope), and for a sessionOnly permission a token that does (403 session_required). apiClient({ cookie?, bearer? }) and expectApiError(promise, status, code?) from @frigear-nu/module-kit/testing do the calls.
  • Cover the wildcard. A role with resource:* must not reach the :pii permission.
  • Migrations. API tokens freeze their permissions in auth_user_tokens.permissions. Every fixture that includes a module using authorize needs the auth_user_tokens migration with that column, and regenerate it when auth adds one: Generate test fixture migrations. Without it every token write fails, which looks like a missing session cookie.
  • Name permissions through the access file in tests too (eventsAccess.permissions.refund), so a renamed permission breaks the test at compile time instead of silently testing a 403 forbidden.
  • Unit tests. The matcher and the decision order are pure (packages/auth/src/runtime/utils/permissions.ts). Test a rule of yours without a Nuxt server under test/unit/.

Next steps

Copyright © 2026