Extension points
Your module has a Billing page. It should appear in the account menu, put a widget on the admin dashboard and offer a tool to AI agents, without you editing the Account, Admin or MCP modules. After this page you can decide which extension point to use for each of those, and where its full reference lives.
Which one do I use?
| You want to | Use | Read |
|---|---|---|
| Add a link to a menu, such as the account menu | extendMenu | Menus |
| Add a link to the admin sidebar or a card to the admin dashboard | extendAdminNav, extendAdminDashboard | Admin navigation and dashboard |
| Show a component in a named slot, such as the account overview | extendWidgets | Widget areas and tabs |
| Add a tab to a detail page another module owns | extendTabs | Widget areas and tabs |
| Let the host app override who may do something | authorization:before | Hooks |
| Say what your routes need, and who holds it by default | addPermission, addRole | Auth extension points |
| Let users give a token access to your routes | addScope | Auth extension points |
| Offer tools to AI agents | addMcpDefinitions | MCP tools |
| Load your data in one request with other modules' data | registerBootstrapProvider | Bootstrap providers |
| Add a column to another module's table | extendDatabaseTable | Database extensions |
Terms
- Extension point. A place where one module adds things to another without importing it, either a registry or a hook.
- Registry. A build-time list that modules add entries to, such as menus, widgets, tabs, permissions, roles, scopes and Model Context Protocol (MCP) tools.
- Hook. A runtime hook (Nitro or app) that a module calls and that listeners in the host app or other modules answer.
- Widget area. A named slot where the Widget module renders the components modules add.
How it works
Extension points come in two kinds:
| Kind | When it runs | Example |
|---|---|---|
| Registry | In a module's setup(), collected once every module is done | extendMenu, extendWidgets, addPermission, addMcpDefinitions |
| Hook | At run time, a Nitro or app hook answered by the host app or another module | authorization:before, auth:email, widgets:filter |
Registration order between modules never matters: items are merged after modules:done.
auth:resolvers and widgets:resolved, run at build time and are not extension points.
Module Kit has the build-time helpers for module authors.Registries
Most registries are built on defineVirtualRegistry from @frigear-nu/module-kit/utils.
- The module that owns the registry calls it from its
setup(). - Other modules add entries during their own
setup(), through the owner's helper. - Once every module is done, the entries are bundled into one virtual module that the runtime imports.
There is no scanning at run time, which keeps it working on Cloudflare Workers.
Registries and menus has the signature and options of
defineVirtualRegistry, including its app and server targets.
Hooks: ask the host app
A hook is a question a module asks and the host app answers. Access control has two Nitro hooks, both answered by your host app (or another module), and three app hooks that filter what the interface shows:
| Hook | Fired by | The listener decides |
|---|---|---|
authorization:before | Auth, on every authorize, allows and canGrantScope | Allow or deny any permission, after the credential checks. Context: { event, principal, user, permission, record, allow(), deny() }. |
authorization:roles | Auth, while it resolves a user's roles | Roles to add or remove, such as an environment allowlist mapped to admin. Context: { event, user, roles }. |
menus:filter | Menu, when it resolves a menu | Which items the viewer sees (item.permission). Authorization answers it with can(). |
widgets:filter, tabs:filter | The widget components | Which widgets and tabs the viewer sees (permission on the entry). |
oauth:application:access | OAuth Server | Who may sign in to a given OAuth application. |
authorization:before is the single override. Results are collected across listeners: any deny() or a listener that
throws denies; otherwise any allow() allows. It runs after the credential checks, so it can never give a token a
permission it was not issued, nor a token a sessionOnly permission. A listener identifies the caller from ctx.user,
never from the session on ctx.event. The filter hooks only hide things; the server still checks.
Access control explains both Nitro hooks. At build time,
auth:access-compiled hands the compiled permissions, roles and scopes to any module that wants to read them, for
example to list them in documentation or an admin page.
| Removed hook | Replace it with |
|---|---|
events:authorize, forms:authorize, payments:authorize, oauth:authorize, authorization:authorize, users:admin:authorize | authorization:before, or a policy (definePolicy) on that module's permissions |
users:admin:roles | Roles in authorization.roles |
authorization:role | authorization:roles |
auth:user-permissions | Roles, or authorization:before |
auth:scope-available | authorization:before; minting goes through canGrantScope |
oauth:scope:staff | Roles, or authorization:before; consent goes through canGrantScope |
oauth:user:roles | authorization:roles; an application's allowedRoles resolves through the role provider |
Menus
Menu holds named menus, such as main, dashboard and account. A menu path is split on
its first dot: dashboard.more is group more of menu dashboard. Items from Nuxt Content, from modules and from
runtime sources are merged and sorted by order.
An item may name a permission. The item is hidden for a viewer who lacks it, and the menu is resolved again on sign-in
and sign-out. The route behind the item checks the permission itself.
A module adds static items at build time:
import { extendMenu } from '@frigear-nu/menu/utils'
extendMenu(nuxt, 'dashboard.more', [
{ label: 'Billing', description: 'Manage your subscription', to: '/billing' },
])
Dynamic items come from a runtime source file. A module that must work without Menu registers its source with
addOptionalMenuSource from @frigear-nu/module-kit/utils. It does nothing when Menu is not installed.
Widget areas and tabs
Widget offers two registries with the same ordering and gating. A widget or tab may name a
permission; widgets:filter and tabs:filter hide it from viewers who lack it.
- Widgets fill a named widget area. The host app renders
<WidgetArea name="admin.dashboard">wherever it likes. Modules add components withextendWidgets. - Tabs fill a detail page.
<WidgetTabs name="admin.users">renders every registered tab, with the active tab in?tab=.
import { extendWidgets, extendTabs } from '@frigear-nu/widget/utils'
extendWidgets(nuxt, 'admin.dashboard', { component: resolver.resolve('./runtime/app/widgets/NewMembers.vue'), order: 10 })
extendTabs(nuxt, 'admin.users', { id: 'payments', component: resolver.resolve('./runtime/app/tabs/Payments.vue'), label: 'Payments' })
An entry registered again under the same id replaces the earlier one. That is how a host app overrides a module's
widget.
WidgetTabs when other modules should add sections to a page. Use PageTabs when the page owns them (see
Pages).Admin navigation and dashboard
Admin draws the admin area entirely from the two registries above. The sidebar is three menu groups:
| Group | Menu path | Put here |
|---|---|---|
| Overview | admin.nav.overview | The dashboard |
| Manage | admin.nav | What staff manage every day |
| Settings | admin.nav.settings | Configuration |
The dashboard is three widget areas:
| Dashboard area | Put here |
|---|---|
admin.dashboard.top | One StatCard per widget |
admin.dashboard | Lists and tables |
admin.dashboard.side | Short ListCards |
import { extendAdminNav, extendAdminDashboard, adminRoute } from '@frigear-nu/admin/utils'
extendAdminNav(nuxt, { label: 'Forms', to: adminRoute(nuxt, 'forms'), icon: 'i-lucide-clipboard-list', order: 20 })
extendAdminDashboard(nuxt, { component: resolver.resolve('./runtime/app/widgets/Submissions.vue'), order: 10 })
New features go into one of the existing groups. A module can also call extendMenu and extendWidgets directly,
without depending on Admin. See Layouts and navigation.
Account menu and overview
Account uses the same registries for the account pages:
- The
accountmenu is the account navigation and the overview's shortcuts. account.dropdownis the account menu in the header.- A module adds an account page by registering the route and a menu item. It never draws its own account navigation.
- The
account.overviewwidget area fills the overview page. Its widgets receive the sessionuseras a prop. orderplaces a page between others. Membership sits at 15, Tickets at 40 and Applications at 50.
MCP tools
MCP serves one Model Context Protocol endpoint for everybody. A module adds tools from its
setup() with addMcpDefinitions. It defines them with defineScopedMcpTool, defineScopedMcpResource and
defineScopedMcpPrompt:
addPermission(nuxt, { name: 'notes:read', label: 'Read your notes', implicit: true })
if (options.settings?.mcp === true) addMcpDefinitions(nuxt, resolver.resolve('./runtime/server/mcp'))
- Every definition declares the
permissionit needs. - A tool is listed and callable only when
allows(event, permission)is true for the caller's credential, so a token that holds onlytickets:readnever sees an admin tool. - A tool takes the user from
ctx.user, never as an argument. ctx.can(permission, record?)asks a further permission for the same caller, such as an opt-in*:pii.- Exposing tools is opt-in per module, through
settings.mcp.
Auth extension points
@frigear-nu/auth/utils offers these build-time registries. Any module may call them from its setup():
| Helper | Registers |
|---|---|
addPermission | The resource:action permissions your module checks, with implicit, sensitive, sessionOnly, aliases and grantTo. |
addRole | A default role, such as eventmanager. The host's config merges into it. |
addScope | A named scope that expands to permissions, for API tokens and OAuth. |
addRoleRequirement | A requirement a role can name, such as subscription. |
addPolicies | A file of definePolicy and defineSharedPolicy exports. |
addAuthResolver | A custom bearer credential, by prefix or as a JWT. |
setAuthUserStore | Where users are stored. The first registration wins. |
Users registers the user store. That is why Auth works without depending on it. Authorization registers the role provider in the same way. Access control shows each one in use, and how the host's config merges with what modules register.
Bootstrap providers
Some pages show several modules' data for the signed-in user, such as the account pages. Without help they make one
request per module. registerBootstrapProvider adds a module's piece to the single
GET /api/auth/bootstrap?scope=<scope> request of that area. See Module Kit.
Common mistakes
ctx.user, never from the session on
ctx.event. The session is empty for bearer requests.Next steps
- Add an admin page – put the registries to work in a real page.
- Database extensions – use the one extension point that changes the schema.
- Writing a module – start a module that adds to others.
Access control
How a server route learns who is calling and decides what they may do, with one vocabulary of permissions, roles, scopes, policies and a before hook, and what fails closed.
Database extensions
How your app or a module adds columns to a table another module owns, why every schema ships once per dialect, and which migrations to commit.