Skip to content

Admin Contributions

The admin contribution bus lets an extension add admin-panel UI — sidebar entries, command-palette actions, dashboard widgets, entity tabs, list columns, settings panels — declaratively, without editing core admin code. It is the admin-panel counterpart of the backend contract described in Building Extensions.

Two rendering models sit on one wire:

  • Descriptors — server-described UI drawn by the admin's DescriptorRenderer from a capped 14-kind vocabulary. No extension code runs in the browser.
  • Components — a synced Svelte component from the extension's admin/ package-data, mounted only when it is present in this admin build.

Everything is server-filtered before it reaches the browser (install state, viewer permission, an optional when predicate). The admin renders what it receives without re-filtering.

Where this fits

Declaring contributions

An extension declares admin UI on ExtensionManifest.admin_contributions, a list of dicts (or AdminContribution instances). Each entry:

manifest = ExtensionManifest(
    name="package_protection",
    # ...
    admin_contributions=[
        {
            "slot": "dashboard:widgets",
            "kind": "component",
            "payload": {"component": "ClaimsWidget.svelte"},
            "permission": "package_protection.manage",
            "order": 50,
            "when": {"eq": ["extension_installed:package_protection", True]},
        },
    ],
)

Fields (backend/vectis/core/admin_contributions.py, AdminContribution):

Field Meaning
slot Canonical slot id (validated — see Slots).
kind descriptor or component.
payload Kind-specific dict, opaque to core.
permission Optional codename gating server-side visibility (absent ⇒ any authenticated admin).
order Sort within a slot (default 100; manifest order breaks ties).
when Optional no-eval predicate AST (see when predicates).

Per-surface failure isolation. Contributions are validated at manifest-registration time. A bad one (unknown slot, unknown kind, malformed when, unknown permission codename) is disabled and logged — it never fails the extension's activation, and the other contributions still serve. Disabled contributions surface on the health surface.

Legacy trio auto-conversion. The older manifest fields admin_nav_items, admin_quick_actions, and admin_page_tabs are auto-converted into contributions (slots nav, command-palette, and page:<route>:tabs) at registration — existing extensions need zero edits.

Permission codename rule

A contribution's permission must be a core permission (CORE_PERMISSIONS in vectis/core/seed.py) or a codename declared in this extension's own manifest permissions list. Gating on another extension's codename is rejected at validation — declare it in your own manifest (an idempotent upsert; see Permission model) instead.

Slots

Slot ids are validated against a canonical registry (admin_contributions.is_valid_slot). Unknown slots disable the contribution (logged, never fatal).

Static slots

Slot Surface
nav Sidebar entries
command-palette Cmd-K quick actions
dashboard:widgets Dashboard cards
login:providers Extra login buttons (e.g. Keycloak SSO)

Patterned slots

Pattern Values Surface
entity:<e>:tabs e ∈ {product, order, customer, category} Detail-page tab strip
entity:<e>:panels same Detail-page tab body (counterpart of :tabs)
entity:<e>:header same Detail-page header
list:<l>:columns l ∈ {orders, products, customers} List columns
list:<l>:filters same List filters
list:<l>:bulk-actions same List bulk actions
form:<entity>:fields entity = lowercase token Form fields
page:<route>:tabs route = absolute admin route Page tab strip
settings:<s>:provider-panels s ∈ {payments, shipping, fulfillment, geocoding, email} Settings provider panels

entity:<e>:panels

:panels is the tab-body counterpart of :tabs: host detail pages mount the tab strip and the panel area at different DOM points, so a two-part component contribution needs two canonical ids.

Not a slot

Full pages under /extensions/<name>/** are not contributions — they ship as admin_pages package-data synced by the extension UI pipeline and never go through the bus. See Extension UI Sync v2.

Known slot gaps (open items)

Derived from shipped code; recorded here so authors aren't surprised:

  • entity:category:tabs is a valid slot, but there is currently no categories/[id] admin host page mounting it.
  • Core list pages lack row-selection UI, so a bulk_action dispatch currently receives an empty ids: [].

Descriptor kinds

A kind: "descriptor" contribution's payload.kind names one of the capped Tier-1 descriptor kinds (admin/src/lib/admin-sdk/descriptor.ts, DESCRIPTOR_KINDS). Growth of this vocabulary is a deliberate core change. The renderer is defensive — a wrong-typed key degrades to a per-descriptor error card, never a broken page.

Kind Payload shape
form {action, schema, title?, description?, submitLabel?} — validate-then-execute over extensionAction.
action {action, label, title?, description?, params?, confirm?, primary?}
status_card {title, dataset, params?, fields?: [{key, label}]} — without fields, top-level scalars of the dataset result render.
data_table {dataset, columns: [{key, label, align?}], title?, params?, rowsKey?='rows'}
metric {label, value} or {label, dataset, valueKey, params?}
badge {label, tone?: success\|error\|warning\|info}
link {label, href} (href sanitized)
nav_item / quick_action / tab_link Legacy trio payloads (same shapes as admin_nav_items / admin_quick_actions / admin_page_tabs). Consumed by the sidebar / palette / page-tab hosts.
column {key, label, align?: left\|center\|right, default?}
filter {key, label, type?: select\|date, options?: [{value, label}]}
bulk_action {action, label, params?} — dispatches via extensionAction with selected row ids merged into params.
field {key, label, fieldType? (alias: type), required?} — types follow the configSchema field engine (string/password/boolean/integer/enum).

Conventions (descriptor.ts):

  • descriptorVersion — absent ⇒ 1. A payload declaring a version higher than the admin build understands (or an unknown payload.kind) renders a "requires a newer admin" placeholder card instead of guessing.
  • {"$daysAgo": N} param marker — manifest payloads are static JSON, so a param value of exactly {"$daysAgo": N} resolves client-side to the ISO 8601 datetime N days before now.
  • Security — payloads are extension-supplied strings. The renderer never emits {@html} on payload data; text is rendered as text nodes; hrefs pass sanitizeHref (http/https/relative only — javascript:, data:, vbscript: rejected, including control-character obfuscation).
  • Containment — each descriptor renders inside its own <svelte:boundary>; a malformed payload short-circuits to an error card.

Live reference bench: /settings/extensions/contributions renders every bus contribution plus a renderer self-test (unknown kind, newer version, malformed payloads, href sanitization).

Component contributions

A kind: "component" contribution references a Svelte file from the extension's admin/ package-data:

{"slot": "dashboard:widgets", "kind": "component",
 "payload": {"component": "ClaimsWidget.svelte"}}

The file is synced into the admin build by make sync-admin-extensions (see Extension UI Sync v2), which generates admin/src/lib/extensions/_registry.ts. ExtensionSlot renders the component only when it appears in both sets — the server-approved bus contribution and the build-time registry — matched by (extension, file). Consequences:

  • An uninstalled extension's compiled-in widgets stop rendering because the server stops serving their contribution.
  • A contribution declared by a manifest whose files aren't in this build is skipped.

Each component mounts inside its own <svelte:boundary> carrying data-extension provenance: a render-time throw becomes a "provided by <ext>" error card, never a broken host page.

A contributed .svelte file may also self-declare its slot via a module-scope export, which the sync registry reads:

<script context="module" lang="ts">
  export const slot = 'dashboard:widgets';
  export const title = 'Package Protection';
</script>

The dataset / action wire

Descriptors that show data or run verbs dispatch through two generic GraphQL surfaces implemented once in core (backend/vectis/core/admin_wire.py), calling callables the extension registers during on_activate:

  • DatasetsextensionDataset(extension, dataset, params) → JSON: read-shaped queries (report rows, status blobs).
  • ActionsextensionAction(extension, action, params) + extensionActionStatus(jobId): verbs (test-connection, trigger-sync).

Registering callables

from vectis_sdk.extension import register_admin_dataset, register_admin_action

async def claims_report(session, params):
    # params always carries params["validateOnly"]: bool
    return {"rows": [...]}

def on_activate(self):
    register_admin_dataset("claims", claims_report,
                           permission="package_protection.manage")
    register_admin_action("sync_now", run_sync,
                          permission="package_protection.manage",
                          long_running=True)

Registration is lifecycle-tied through the registration ledger: ownership comes from the activation context, a cross-extension key claim is a hard activation error, same-extension re-registration (hot-reload / re-activate) is a clean overwrite, and deactivate/uninstall auto-unregisters. The callable's permission is validated at registration against the same core-∪-manifest codename set as contributions.

Callable contract (both surfaces):

async def fn(session: AsyncSession, params: dict) -> Any

The session arrives inside an open transaction on both dispatch paths — committed on success, rolled back on raise. Callables never call session.commit() themselves.

Return-value mapping (normalize_action_outcome):

Return Outcome
None completed, empty message
str completed, message
dict / list completed, JSON result
UiFormResult result={ok, fieldErrors}; ok=False ⇒ status failed
raise contained: logged with extension attribution, returned as null (dataset) or a failed result (action) — never a GraphQL 500

UiFormResult + validateOnly

params always carries params["validateOnly"]: bool (the validateOnly GraphQL argument). Descriptor forms use this for a validate-then-execute round-trip:

  1. Submit with validateOnly: true → the callable validates and returns a UiFormResult ({ok, fieldErrors}) without side effects.
  2. Submit again with validateOnly: false to execute.

UiFormResult.ok=False maps an action to status failed, and the per-field fieldErrors render under their form fields. validateOnly always runs inline server-side (validation must be fast and side-effect free), even for a long_running action.

Dispatch gate chain

extensionDataset / extensionAction gate, in order: staff auth (GraphQL error) → install state + channel enablement (a typed not-found, indistinguishable from an unknown name) → the callable's declared permission codename.

Channel enablement applies to the wire, not the bus

Channel enablement is a gate on the dispatch wire and on storefront/runtime strategy surfaces, but it was removed as a visibility filter on adminContributions (install + permission + when only) — an admin must be able to reach a channel-disabled extension's settings surfaces in order to enable it.

Long-running actions — per-process caveat

An action registered long_running=True runs on an in-process job registry (fire-and-poll via extensionActionStatus) instead of blocking the resolver.

v1 persistence constraint

Job records live in a per-process module dict. The dev compose api runs a single uvicorn process, so polls hit the same process. But Dockerfile.prod runs gunicorn --workers 4 today: a poll lands on the submitting worker only ~1 in 4 times and otherwise returns the typed unknown status while the work still runs to completion invisibly. Do not rely on long_running actions under the prod topology until the documented upgrade path (DB-persisted job rows, the ImportRun pattern) lands.

Dev api also runs uvicorn --reload: a file save restarts the process, dropping job records and in-flight tasks. A lost job id returns a typed unknown status, never a 500. Long actions that must survive restarts belong on Temporal (a registered callable may itself start_workflow + await handle.result()).

Statuses: queued | running | completed | failed | unknown.

Server-side filtering (adminContributions)

The root adminContributions(slots) query returns every enabled contribution the viewer may see, optionally narrowed to given slot ids (extension_resolvers.py). Filters, in order:

  1. Install state — only extensions loaded in the runtime registry contribute (a registered-but-failed activation keeps its manifest, but its contributions are not served).
  2. Viewer permission — a contribution carrying permission requires RequestContext.has_permission; without one it is visible to any authenticated admin. Unauthenticated and authenticated non-staff principals (e.g. a storefront customer on the shared GraphQL endpoint) get [] — the bar is "any authenticated admin", so the gate is RequestContext.is_staff.
  3. when predicate — evaluated against the v1 server context; a predicate that turns out malformed at evaluation is skipped and logged (per-surface isolation), never an error.

Output is stably sorted by order and carries source_extension provenance. slots: null means no filter; an explicit slots: [] filters everything out.

The Command Palette consumes this server-filtered bus, and it also permission-filters core nav (canSeeNavItem) for sidebar/palette consistency.

when predicates

when is a safe, no-eval boolean AST evaluated against a server-built context. Composites: {"all": [...]}, {"any": [...]}, {"not": node}. Leaves: {"eq"|"ne": [key, value]}, {"in": [key, [v...]]}, {"gt"|"lt": [key, number]}, {"exists": key}.

v1 evaluation context (build_when_context):

Key Type Meaning
channel_count int number of Channel rows
multi_channel_enabled bool the platform.multi_channel_enabled setting
extension_installed:<name> bool True per loaded extension

Missing-key semantics: eq/ne/in/gt/lt on an absent key are False — use {"exists": key} to test presence. Nesting is capped at 32 levels; a deeper AST is treated as malformed (disabled + logged).

Permission model

Extension admin surfaces are gated by permission codenames, backed by a boot-time sync (backend/vectis/core/permission_sync.py):

  • sync_permissions — additive-only upsert of CORE_PERMISSIONS plus every codename in the given manifests' permissions lists. Uses INSERT … ON CONFLICT (codename) DO NOTHING (race-safe across replicas); existing rows are never updated and nothing is ever deleted. Runs at api boot (after discover_and_load) and inside the installExtension mutation.
  • ensure_super_admin_role_permissions (super_admin backfill) — grants the super_admin role every permissions row. Mandatory companion to the sync: super_admin's "*" is expanded at seed time only, so a permission row inserted by the sync after seeding would be held by no role — a filtered surface gated on it would then vanish for everyone, super_admin included. The backfill closes that gap, at both sync call sites.
  • ensure_extension_role_grants — a hard-scoped, owner-approved table (EXTENSION_ROLE_GRANTS) of codename → roles. Today it grants tax.excise_engine.manage to the admin and manager roles: because the palette consumes the server-filtered bus, this codename's quick actions would otherwise vanish for every role but super_admin. This is not a general grant mechanism — default_roles manifest semantics and an admin grant UI are not yet shipped.

All three are idempotent, additive-only, and never revoke.

Extension health

extensionHealth is a pure read (staff-gated; non-staff get []) that aggregates per-extension operational state without any side effects — no activation, no DDL, no advisory lock. It surfaces in the admin health panel. Signals (build_extension_health):

Category What it reports
contribution Disabled admin contributions (with their validation errors).
readiness A payment strategy the DB asks for but that isn't registered.
migration Schema-revision drift: ledger revision ≠ package head.
lock extensions.lock skew — missing / extra / version-skewed, with uninstalled (expected) distinguished from a genuine lock warning.
activation Failed activations this boot, and uninstalled-skipped extensions.

Each extension rolls up to error (any error issue), warning (any warning), or ok, plus its ledger install_state (installed / uninstalled / never). Every signal is wrapped so one failing probe never empties the whole surface.

Not yet shipped

Marked here so authors don't build against absent tooling:

  • Public PyPI publishing of the developer surface (vectis-sdk / vectis-testkit) — authored but dormant; see Extension Packaging & Extraction.
  • DB-persisted long-running jobs — the upgrade path off the per-process job registry described above.

Storefront contribution bus

The storefront now has its own contribution bus — storefrontContributions, StorefrontSlot, the lazy $plugins registry, PaymentMethodModule, admin-approved CSP grants, the age gate, notification opt-ins, and the search adapter registry. See Storefront Contributions.