@brand-map/admin-extension-sdk
v0.0.10-alpha.34
Published
Public SDK for building trusted Brand Map admin extensions.
Downloads
2,586
Readme
Brand Map Admin Extension SDK
@brand-map/admin-extension-sdk is the public SDK for building Brand Map admin extensions.
The current model is React-based and ESM-first. An extension exports a manifest object plus lazy route, widget, and slot components. The admin host loads that manifest, validates it, mounts its pages under /extensions/:extensionId/..., and injects a small host API through React context.
This package is for extension authors.
Host-side catalog, installation, and marketplace APIs now live in @brand-map/admin-extension-management.
What an extension can do
Today an admin extension can:
- Register one or more admin pages.
- Register widgets that open over any admin page.
- Add UI into supported extension slots.
- Declare admin permissions and check them at runtime.
- Call the admin backend through the host session.
- Use host utilities such as navigation and toasts.
- Render docs anchors for host-owned guided docs.
The first supported slot is:
product.list.headerActions
Supported admin permissions are:
products:readproducts:writebrands:readbrands:writeusers:readusers:writesettings:readsettings:write
Installation
npm install @brand-map/admin-extension-sdk reactThe SDK expects the extension to run inside the admin host's React tree. In published extensions, keep React aligned with the host and avoid bundling a second React runtime.
Quick start
Create an entry module that exports a manifest with defineAdminExtension:
import { defineAdminExtension } from "@brand-map/admin-extension-sdk"
export default defineAdminExtension({
id: "acme.product-reviews",
name: "Product Reviews",
version: "1.0.0",
description: "Review tooling inside Brand Map admin.",
permissions: ["products:read"],
nav: [
{
label: "Reviews",
path: "reviews",
icon: "star",
order: 100,
},
],
routes: [
{
path: "reviews",
component: () => import("./pages/reviews-page"),
},
],
widgets: [
{
id: "review-assistant",
label: "Review Assistant",
icon: "message-circle",
order: 10,
component: () => import("./widgets/review-assistant"),
},
],
slots: [
{
slot: "product.list.headerActions",
component: () => import("./slots/product-list-header-action"),
order: 10,
},
],
})Then implement route, widget, or slot components with useAdmin():
import { useAdmin } from "@brand-map/admin-extension-sdk"
export default function ReviewsPage() {
const admin = useAdmin()
async function handleSync() {
const reviews = await admin.request<{ total: number }>("/reviews/stats")
admin.toast.success(`Loaded ${reviews.total} reviews.`)
}
return <button onClick={handleSync}>Signed in as {admin.currentUser?.email ?? "unknown user"}</button>
}Manifest reference
The extension manifest is the contract between your package and the admin host.
type AdminExtension = {
id: string
name: string
version: string
description?: string
permissions?: readonly AdminPermission[]
nav?: readonly AdminExtensionNavItem[]
routes?: readonly AdminExtensionRoute[]
slots?: readonly AdminSlotContribution[]
widgets?: readonly AdminExtensionWidget[]
}id
id is the stable runtime identifier for the extension.
Rules enforced by the host:
- Must be a non-empty string.
- Must match
^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$. - Must match the identifier used by the host registry entry.
- Must be unique across loaded extensions.
Good examples:
acme.product-reviewsnotificator
Rejected examples:
Acme.ProductReviewsacme_reviews.acme
name
Human-readable extension name shown by the host.
version
Human-readable version string. The host only validates that it is a non-empty string.
description
Optional descriptive text for catalog or host UI.
permissions
Permissions requested by the extension. The host validates both of these conditions:
- Every requested permission must be known.
- Every requested permission must also be granted by the host installation record.
If either check fails, the extension is rejected and does not render.
nav
Navigation items the host can surface for the extension.
type AdminExtensionNavItem = {
label: string
path: string
icon?: string
order?: number
}Notes:
pathmust be a relative extension path such asreviewsorreports/monthly.- The first route segment
docsis reserved by the host for extension Markdown docs. - Lower
ordervalues sort first. - Missing
orderis treated as0. - The host builds the final URL as
/extensions/{extensionId}/{path}.
routes
Route registrations for extension pages.
type AdminExtensionRoute = {
path: string
component: AdminExtensionComponentLoader<AdminExtensionRouteComponentProps>
}Notes:
- Components must be lazy loaders that resolve to a default React component export.
- Routes are sorted by normalized path in the host.
- A route path may be nested, for example
reports/monthly.
widgets
Widget registrations let an extension expose UI that opens over the current admin page from the right-side widget rail.
type AdminExtensionWidget = {
id: string
label: string
icon?: string
order?: number
component: AdminExtensionComponentLoader<AdminExtensionWidgetComponentProps>
}Notes:
idmust be unique inside the extension and use lower-case letters, numbers, dots, or dashes.labelis shown by the host for the widget launcher.- Components must be lazy loaders that resolve to a default React component export.
- Lower
ordervalues render first. - Widget components use
useAdmin()for the same host API as routes and slots, and can calluseAdminWidget()for widget-specific state such aswidgetIdandclose().
slots
Slot contributions let an extension inject UI into specific host locations.
type AdminSlotContribution<TSlotId extends AdminSlotId = AdminSlotId> = {
slot: TSlotId
component: AdminExtensionComponentLoader<AdminSlotPropsById[TSlotId]>
order?: number
}Notes:
- Components must be lazy loaders.
- Lower
ordervalues render first. - Unknown slots are rejected during manifest validation.
Route paths
Route and nav paths are relative to the extension root.
Valid:
overviewreports/monthlysettings/general
Rejected by the host:
/overview./overview../overviewdocsdocs/setuphttps://example.comoverview?tab=oneoverview#section
The host normalizes leading and trailing slashes when matching routes, but you should still declare clean relative paths in the manifest.
Route mounting
Extension routes are mounted under:
/{storeId}/extensions/{extensionId}
/{storeId}/extensions/{extensionId}/{path}Examples:
id: "acme.product-reviews"andpath: "reviews"becomes/extensions/acme.product-reviews/reviewsid: "acme.product-reviews"andpath: "reports/monthly"becomes/extensions/acme.product-reviews/reports/monthly
If a user navigates to an extension id that is not loaded, or to a path that the extension did not register, the host shows an extension status page instead of rendering the component.
Writing route components
Route components are ordinary React components with access to the host API through useAdmin().
The host currently passes these route props:
type AdminExtensionRouteComponentProps = {
extensionId: string
path: string
splat: string
storeId?: string
}Example:
import type { AdminExtensionRouteComponentProps } from "@brand-map/admin-extension-sdk"
import { useAdmin } from "@brand-map/admin-extension-sdk"
export default function ReportsPage({ extensionId, path }: AdminExtensionRouteComponentProps) {
const admin = useAdmin()
return (
<section>
<h1>{extensionId}</h1>
<p>Current extension path: {path}</p>
<button onClick={() => admin.navigate("/products")}>Back to products</button>
</section>
)
}Writing slot components
The currently supported slot prop shape is:
type ProductListHeaderActionsSlotProps = {
extensionId: string
storeId?: string
}Example:
import { useAdmin } from "@brand-map/admin-extension-sdk"
export default function ProductListHeaderAction() {
const admin = useAdmin()
return <button onClick={() => admin.toast.success("Rendered from extension")}>Run action</button>
}Slot components render inside the host UI, so keep them compact and resilient.
Host API
Use useAdmin() inside route and slot components:
function useAdmin(): AdminHostApiIf it is called outside an admin extension host, it throws:
useAdmin must be used inside an admin extension host.The host API shape is:
type AdminHostApi = {
currentUser: {
id: string
name?: string | null
email?: string | null
image?: string | null
} | null
extensionId: string
hasPermission: (permission: AdminPermission) => boolean
storeId?: string
navigate: (to: string) => void
toast: {
success: (message: string) => void
info: (message: string) => void
warning: (message: string) => void
error: (message: string) => void
}
request: <TResponse = unknown>(input: string | URL | Request, init?: RequestInit) => Promise<TResponse>
}currentUser
Information about the currently signed-in admin user, if available.
extensionId
Stable id of the hosted admin extension instance.
hasPermission(permission)
Checks whether the permission was granted to this extension instance.
There is also a convenience hook:
useAdminPermission(permission: AdminPermission): booleanExample:
import { useAdminPermission } from "@brand-map/admin-extension-sdk"
export default function ReviewsGate() {
const canWrite = useAdminPermission("products:write")
return canWrite ? <button>Edit review settings</button> : null
}Docs anchors
Use AdminDocsAnchor to mark interface locations that package-root Markdown docs can target with brand-map://anchor/<anchorId>?route=<route>.
import { AdminDocsAnchor } from "@brand-map/admin-extension-sdk"
export function SettingsSection() {
return (
<section>
<AdminDocsAnchor id="settings-form" />
<h2>Settings</h2>
</section>
)
}Anchor ids must contain only letters, numbers, dots, colons, underscores, or hyphens, and are scoped to the extension id by the host.
navigate(to)
Navigates using the admin host router. Use it for internal app navigation.
toast
User notifications surfaced by the host.
Example:
admin.toast.info("Sync started.")
admin.toast.success("Sync completed.")
admin.toast.error("Sync failed.")request(input, init)
Performs an authenticated request through the admin host.
Current host behavior:
- Attaches the current session bearer token.
- Only allows requests to the configured admin backend API origin and path.
- Throws an
Errorwhen the response is not2xx. - Returns
undefinedfor204 No Content. - Returns parsed JSON for JSON responses.
- Returns plain text for non-JSON responses.
That makes it the preferred way for extensions to talk to the Brand Map backend.
Example:
type ProductSummary = {
id: string
title: string
}
async function loadProducts(admin: ReturnType<typeof useAdmin>) {
return admin.request<ProductSummary[]>("/products")
}Host integration
If you are building the admin host, use @brand-map/admin-extension-management for catalog and installation operations, and keep the host runtime private to the admin app.
The admin host still needs an internal registry entry that looks like this:
type InstalledAdminExtension = {
extensionName?: string
id: string
enabled?: boolean
grantedPermissions: readonly AdminPermission[]
load: () => Promise<{ default: AdminExtension }>
}Example:
export const installedAdminExtensions = [
{
id: "acme.product-reviews",
enabled: true,
grantedPermissions: ["products:read"],
load: () => import("@acme/product-reviews-admin-extension"),
},
]The host runtime validates the loaded manifest before exposing it to users. Invalid manifests are reported as load errors and are not mounted.
Host-side runtime behavior
The current admin host does the following:
- Skips registry entries where
enabled === false. - Rejects duplicate extension ids.
- Sorts loaded extensions by
manifest.name. - Sorts nav items and slot contributions by
order. - Sorts routes by normalized path.
- Wraps lazy extension components in
React.Suspense. - Wraps extension rendering in an error boundary.
If a route or slot component throws during render, the host shows an "Extension failed to render" alert instead of crashing the admin app.
For catalog, installation, configuration metadata, and secret placeholders, use @brand-map/admin-extension-management.
Validation and failure cases
The current admin host rejects an extension when any of these are true:
- The module default export is not an object.
id,name, orversionis missing or empty.- The exported
iddoes not match the registry entry id. - The
idformat is invalid. permissions,nav,routes, orslotsis not an array.- A requested permission is unknown.
- A requested permission was not granted.
- A nav path or route path is not a relative route path.
- A route component loader is not a function.
- A slot id is unknown.
- A slot component loader is not a function.
The host also reports duplicate ids across registry entries.
Recommended package structure
One workable structure is:
src/
index.ts
pages/
reviews-page.tsx
reports-page.tsx
slots/
product-list-header-action.tsxWhere:
src/index.tsexports the extension manifest.pages/*export default React components for routes.slots/*export default React components for host slots.
Practical guidance
- Keep the manifest small and declarative. Put real UI code behind lazy imports.
- Use permission checks in the UI even when the host already validates grants.
- Treat
admin.request()as the single backend entry point from extension components. - Keep route and nav paths stable once users can bookmark them.
- Keep slot UI minimal so it feels native inside host pages.
- Fail gracefully. The host isolates render errors, but users still see the error message.
Minimal end-to-end example
// src/index.ts
import { defineAdminExtension } from "@brand-map/admin-extension-sdk"
export default defineAdminExtension({
id: "acme.inventory",
name: "Inventory",
version: "1.0.0",
permissions: ["products:read"],
nav: [{ label: "Inventory", path: "inventory" }],
routes: [
{
path: "inventory",
component: () => import("./pages/inventory-page"),
},
],
})// src/pages/inventory-page.tsx
import { useAdmin } from "@brand-map/admin-extension-sdk"
type InventoryRow = {
id: string
sku: string
}
export default function InventoryPage() {
const admin = useAdmin()
async function handleLoad() {
const rows = await admin.request<InventoryRow[]>("/inventory")
admin.toast.success(`Loaded ${rows.length} inventory rows.`)
}
return (
<div>
<h1>Inventory</h1>
<p>{admin.currentUser?.email ?? "Unknown user"}</p>
<button onClick={handleLoad}>Load inventory</button>
</div>
)
}Related source in this repo
- SDK entry point:
admin/packages/admin-extension-sdk/src/index.ts - Host runtime validation:
admin/modules/dashboard-extension/src/runtime.ts - Host provider/runtime registry:
admin/modules/dashboard-extension/src/providers/extension-provider.tsx - Host backend request adapter:
admin/modules/dashboard-extension/src/request-backend.ts
