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
DescriptorRendererfrom 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
- Backend authoring contract: Building Extensions.
- The import surface + compliance testkit: The Extension SDK & Testkit.
- GraphQL wire: GraphQL Reference.
- Per-extension operational docs: Extensions section.
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:tabsis a valid slot, but there is currently nocategories/[id]admin host page mounting it.- Core list pages lack row-selection UI, so a
bulk_actiondispatch currently receives an emptyids: [].
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 unknownpayload.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 passsanitizeHref(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:
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:
- Datasets —
extensionDataset(extension, dataset, params) → JSON: read-shaped queries (report rows, status blobs). - Actions —
extensionAction(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):
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:
- Submit with
validateOnly: true→ the callable validates and returns aUiFormResult({ok, fieldErrors}) without side effects. - Submit again with
validateOnly: falseto 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:
- Install state — only extensions loaded in the runtime registry contribute (a registered-but-failed activation keeps its manifest, but its contributions are not served).
- Viewer permission — a contribution carrying
permissionrequiresRequestContext.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 isRequestContext.is_staff. whenpredicate — 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 ofCORE_PERMISSIONSplus every codename in the given manifests'permissionslists. UsesINSERT … ON CONFLICT (codename) DO NOTHING(race-safe across replicas); existing rows are never updated and nothing is ever deleted. Runs at api boot (afterdiscover_and_load) and inside theinstallExtensionmutation.ensure_super_admin_role_permissions(super_admin backfill) — grants thesuper_adminrole everypermissionsrow. 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) ofcodename → roles. Today it grantstax.excise_engine.manageto theadminandmanagerroles: 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_rolesmanifest 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.