Skip to main content

Admin Command Palette Actions

A registry-backed contribution point that lets every backend module add action buttons to the Admin UI's command palette (the ⌘K / Ctrl+K modal — what the operator sees as the Actions group). Two actions ship hardcoded today (New product, Import products); those, and every future action, are declared once in their owning module's manifest and surfaced through this registry.

The platform side lives at packages/modules/admin_actions/ and the admin runtime at admin/src/lib/admin-actions/.

What a module declares​

A module's manifest.ts may declare zero or more actions inline alongside its existing settings and i18n fields:

import { defineModuleManifest } from '@endora-commerce/contracts';

export const manifest = defineModuleManifest({
id: 'catalog',
name: 'Catalog',
version: '1.4.0',
dependencies: [],
i18n: { bundlesDir: 'i18n' },
actions: [
{
id: 'new-product',
labelKey: 'catalog.actions.newProduct.label',
descriptionKey: 'catalog.actions.newProduct.description',
icon: 'Plus',
targetRoute: '/catalog/products/new',
requiredPermission: 'catalog:write',
keywords: ['product', 'new', 'add', 'create', 'produkt', 'nowy', 'dodaj'],
weight: 100,
},
],
});

Each entry MUST carry a stable id, a translatable labelKey, an icon from the closed allowlist, and a targetRoute. Optional fields are descriptionKey, requiredPermission, keywords (up to 10), and weight (default 100).

requiredPermission is optional in the schema and all but mandatory in practice: it must be the code the backend enforces on the route behind targetRoute, so the palette never advertises a 403 and never hides a screen from an operator entitled to open it. Both failures happened — settings/open-settings shipped with no code at all against a settings:read route, and inventory/open-inventory declared catalog:write against an orders:read one — and the permission inventory could not see either, because it sweeps whether a code is enforced somewhere, not whether it is enforced here. pnpm --filter backend run check:action-route-permissions compares the two, resolving the SPA targetRoute to the admin API route that gates it. Leave the field unset only when the destination genuinely has no gate; where the screen is read-gated but the action's label promises a write, the field cannot say both, and the disagreement is recorded in that check's ledger rather than guessed at.

Within-module id uniqueness is enforced by the manifest's Zod schema — installing a manifest with two actions sharing an id fails the install with a clear, indexed error.

Public surface​

Verb + PathPurpose
GET /api/v1/admin/admin-actions?language=<en|pl>Returns the operator-visible action list, already filtered by the operator's permissions and the module's installed state, sorted by (weight, locale-aware label), with labels and descriptions resolved into the requested language (falling back to English then to the raw key, identical to the Admin UI i18n fallback chain). Permission: any authenticated admin.

The response carries a meta.registryVersion field — MAX(version) over the visible rows — useful for diagnostics. The admin SPA does not poll on it; refreshes are driven by the operator's language flip and on-mount fetch.

How an operator sees actions​

  1. The operator opens the Admin UI and presses ⌘K (or Ctrl+K on Windows / Linux).
  2. The palette renders two groups: Navigate (static jump targets) and Actions.
  3. The Actions group shows every action whose owning module is installed AND whose requiredPermission (if any) the operator's role grants. The wildcard * permission held by platform_admin satisfies every action.
  4. Typing in the search box filters across both groups by case-insensitive, diacritic-insensitive substring match against the row's label, description, and keywords. Polish operators can type latwy to match łatwy, English operators can type import to match Importuj produkty, etc.
  5. Clicking a row or pressing Enter navigates to the action's targetRoute and closes the palette.

When no action is visible to the operator (rare; only with a no-permission role and no modules contributing permissionless actions), the Actions group is hidden entirely.

Lifecycle integration​

The orchestrator install path runs module_actions reconciliation between the i18n bundle install and the module's own install hook. The reconciler UPSERTs every declared action and prunes any rows the new manifest no longer declares — install order, version upgrades, and action removals are all idempotent. On hard-uninstall (module:uninstall --hard) the reconciler deletes every action row owned by the module before the i18n bundle removal step.

State is persisted in module_actions (composite PK (module_id, action_id)); soft- uninstall (state → disabled) does NOT delete rows — it relies on the visibility read filtering every row whose module is not effectively present, which hides the actions while preserving them for re-enable.

That filter asks the kernel's effective-state combiner, through a probe the composition root contributes, and it asks it for both presence axes: the deployment's module_registrations state and the operator's activation Setting. It used to ask only the second that way and join module_registrations.state = 'installed' for the first, which meant the palette and the route gates read one question out of two sources — disagreeing for the length of every registryCache.refreshFromDb, so a palette could advertise an action whose route answered 503 and hide one the route would still serve. The registry table is still the record for the platform axis; the palette simply no longer reads it behind the platform's back.

Storage shape​

ColumnTypeNotes
module_idvarchar(64)Part of PK.
action_idvarchar(64)Part of PK.
label_keyvarchar(255)i18n key resolved at read time.
description_keyvarchar(255) NULLOptional.
iconvarchar(64)One of the closed-allowlist names.
target_routevarchar(255)Admin route.
required_permissionvarchar(64) NULLPermission code, any notation.
keywordsjsonbArray of strings.
weightintegerSort key (default 100).
versionbigintPer-row sequence; bumped on every UPSERT.
installed_at, updated_attimestamptzRow metadata.

There is no foreign key on module_id — modules are filesystem-driven and module_registrations is the registry of record. Cleanup is enforced by the hard-uninstall path of the reconciler, mirroring the translation_bundles choice.

Weights are advisory but reviewers expect new actions to land in the appropriate band:

BandUse case
0–99Reserved for the platform shell.
100–199Primary creation (e.g., New product, New page, New post).
200–299Secondary creation / configuration entry points.
300–399Workflow / inbox actions.
400–499Less-frequent navigation / utilities.
≥ 500Rarely-used actions; sink to the bottom.

Icon allowlist​

Allowed icon names are an enum in packages/contracts/src/admin-actions.ts. Adding a new icon is a one-line PR that edits both the enum and the admin's icon-map.ts.

v1 seed set​

The initial release ships ten actions across nine modules: catalog/new-product, import_export/import-products, import_export/open-import-export-center, inventory/open-inventory, quote_requests/open-rfq-inbox, cms/new-page, blog/new-post, megamenu/edit-megamenu, sales_channels/new-sales-channel, settings/open-settings. Eight further candidates from the spec were deferred until their target admin pages exist (Adjust stock, New draft order, Find order by number, New customer, New price list, New promotion, Upload asset, New category).