Manage Menus
The recommended menu-management workflow follows the way an admin UI is usually built: create a menu config first, then create menus, views, page load APIs, actions, and response fields. permission-core generates the runtime API contracts and response-field inventory when these objects are saved; admin forms only maintain the objects themselves.
The smallest useful mental model is:
Saving menus is not user authorization. It records what the system can expose; role grants decide what each user can see and invoke. To make those permissions effective for a user, bind the role with userRoles.assign() or userRoles.set().
If you are building a normal admin UI, read the first half first and use the incremental object methods. The later MenuConfigInput, menus.config.save(), and batch-import sections are mainly for plugins, CI/CD, and config-as-code.
Which API should a normal admin UI use?
For a normal admin UI, keep one main line in mind: manage config with menus.configs/items/views/loadApis/actions/responses, grant roles with roles.menuPermissions, and project runtime state with subject.menus.*.
When you see menus.config.*, treat it as the advanced full-config entrypoint for import or config-as-code, not the default save method for form screens. Historical migration internals stay out of this guide's main path so a first integration does not have to learn storage implementation details.
Open the management page: read the full tree first
When a menu-management page opens, the first step is usually not creating a menu. Read the full tree first:
menuTree is the complete management config tree. It includes menus, child menus, views, page load APIs, actions, and response-field config.
Do not use list() to read the tree. menus.configs.list() / menus.config.list() only pages through summaries for multiple menu configs, such as admin, merchant-console, and ops-console; it does not return tree nodes.
Do not confuse this with user runtime subject.menus.getViewTree(): that method filters by the current user's grants and is for frontend navigation. A management config page should read the complete tree with scoped.menus.configs.get(configId).
Recommended admin page layout
The easiest admin page to understand looks like this:
The user selects a menu or view on the left, and the right side shows only the things that node can configure. The frontend forms then map almost directly to backend methods:
Which entrypoint should I use?
Normal admin screens should use the incremental methods first. Use the advanced entries only for batch import or config-as-code.
Recommended admin UI workflow
With the left tree and right-side forms in place, each form saves only the thing it owns.
The first scope argument is still only the permission namespace, such as tenant and app. The second argument is the default audit context for this admin request. That keeps actorId out of the tenant scope while avoiding repeated options on every write.
That is the usual persistence order for a menu-management page:
A real admin usually has more than one menu. Extend the same model like this:
For ordinary create/update/add/set work, call the execution method directly as above. permission-core internally previews the change, commits it if there is no conflict, and throws MENU_MANAGEMENT_PREVIEW_CONFLICT when the change is not safe to auto-commit. At that point, show an explicit preview to the administrator.
If one admin page needs to commit several form actions with one Save button, use menus.management.applyChanges():
menus.configs.create() can create an empty config so the admin UI can build the tree gradually. menus.config.save({ menus: [] }) still fails because the full-config batch entrypoint means “replace the whole config”; an empty array can accidentally delete the whole menu model.
When should you use explicit preview?
idempotencyKey is an optional advanced override. Normal code does not need to write it by hand. When requestId is present, permission-core derives an internal idempotency key for duplicate-submission protection: double-clicked Save buttons, browser timeout retries, or gateway redelivery. Pass idempotencyKey explicitly only when integrating an external gateway, queue, or existing idempotency protocol.
With explicit preview, preview.executable === true means the current change has no conflicts and can be executed with the same input. preview.executable === false means it cannot be submitted; show preview.conflicts instead. Do not mutate the input after preview and before execution.
Management actions in detail
Object methods are best for normal admin forms. Every write follows the same pattern:
create/update/add/set() returns MutationResult<MenuManagementResult> and really writes the config, API contracts, and response-field inventory needed at runtime. preview*() returns ImpactPreview<MenuManagementPlan> and is read-only; use it for deletes, capacity risk, or UIs that need to show impact before committing.
Prefer binding actorId/requestId once with pc.scope(scope, defaults). Per-call options are only needed to override defaults, submit explicit preview credentials, or acknowledge capacity risk. idempotencyKey is not part of the permission model; it only overrides the default strategy for advanced integrations.
This section focuses on which method to use and when. Detailed response fields such as revisions, operationId, auditId, manifestOperations, and other debug counters can be skipped at first; use the Menus API when you need the exact shape.
Create an empty config
When initializing menu management, the backend usually reads the config by configId first; if it does not exist, call menus.configs.create() to create the empty container. The UI does not need a separate first-visit flag and should not call create on every page open. Existing configs should be read or updated, then menus and views can be added incrementally.
If the UI wants to show impact first, call previewCreate(). A successful write returns the latest config, an operation summary, and an audit ID; see the Menus API for the exact response shape.
Create, update, and delete menus
A menu is a left-side navigation node. Omit parentId for a top-level menu; pass parentId for a child menu.
After creation, this menu node appears in current.data.menus. See the Menus API when you need the full response fields.
Rename the menu or change its icon:
Delete a menu:
cascade: true deletes descendant menus, views, actions, and response fields together. revokeGrants: true also revokes role-menu grants that would become invalid.
Create, update, and delete views
Views live under menus. A single menu can have several pages, and can also contain tab, dialog, or drawer views.
After creation, this view appears under the owning menu's views.
Update a view:
Delete a view:
Deleting a view can affect its load APIs, actions, and response fields. Check preview.plan.affectedRoles before executing.
Add, update, and delete page load APIs
A page load API is a backend API that must be called when the page opens. You do not write action: 'invoke'; the system compiles it into invoke + api:*.
After saving, this API appears in the view's load list and becomes part of later role authorization and backend API protection.
Update load API response fields or metadata:
Ordinary updates do not need per-call options. If actorId/requestId were bound with pc.scope(scope, defaults), audit and idempotency context are carried automatically.
Delete a load API:
Create, update, and delete actions
Actions live under views. If resource is api:*, the action calls a backend endpoint; if resource is ui:button:*, it is a frontend-only permission point.
After saving, this action appears in the view's actions. Role authorization can select it, and runtime code uses getActionMap() to project its state.
Frontend-only action:
Update an action:
Delete an action:
Set and delete API response fields
Response-field permissions belong to the API response DTO, not to database fields. Field configuration must point to an existing page load API or API action.
After saving, these fields become selectable in role authorization. Whether a user actually receives them still depends on role-menu grants.
Action APIs can also declare response fields:
Delete selected fields:
Omit fields to delete the whole response-field config for the API. If fields have already been granted to roles, use revokeGrants: true to avoid stale field grants.
Commit several admin-form actions at once
If one admin page saves a menu, view, API, and action together, use menus.management.applyChanges(). It uses the same compiler as the object methods, but combines many changes into one internal preview and one commit. If the page wants to show impact first, call previewChanges() before executing.
If the admin UI wants to show “what will be created”, call previewChanges() first. A successful commit returns a summary of created, updated, and deleted assets; see the Menus API for the exact response shape.
Advanced: config-as-code and batch imports
If plugins, CI/CD jobs, or config files import a complete menu, keep the large MenuConfigInput workflow out of the normal admin path. Read the dedicated advanced page instead: Menu Config as Code and Batch Imports.
Grant menu capabilities to a role
After saving the config, the role still has no permission. To let the role see the orders page, call the load API, see the export action, and receive only selected fields, grant the menu capabilities separately:
views is what the administrator selected. By default, the selection includes page load APIs, does not automatically include actions, and does not select all response fields. include.loads: true grants the page load APIs; include.actions: true grants the page buttons or operations; responseFields explicitly grants selected fields. For paginated responses, use target: 'items' to name the projected array. include.responseFields: 'none' means only fields listed in responseFields are granted.
See Authorize Role Menus for the full authorization rules.
Read user menus and API responses
getViewTree() feeds frontend navigation; getViewState() decides whether a view can be entered; getActionMap() returns visibility and enabled state for each action; filterResponse() first checks whether the user can invoke the api: resource and then projects the response according to field grants. The actual projected business payload is in response.data. This is backend response filtering, not frontend field hiding.
Common misunderstandings
Next, configure page load APIs, action APIs, and response fields in Configure APIs and Response Fields. See the runnable Menu Admin Example, exact Menus API, and Configure APIs and Response Fields API.