Build for Dunea
Admin extensions
Put your plugin's cards and actions inside Dunea's own panels: on a product, an order, a selection of orders, a funnel, a payment, a review or the app overview.
A workbench panel is a page of its own. An admin extension is smaller: a card or a menu action that sits inside one of Dunea's views, next to Dunea's own cards. A shipping plugin can show the label status on each order; a loyalty plugin can add "Award points" to an order's menu; an invoicing plugin can print invoices for a selection of orders.
Each extension is a page you host. Dunea renders it in a sandboxed iframe, the same way it renders panels, with a session token that also says which view it was opened in and which resource the owner is looking at.
Declare them in the manifest
{
"permissions": ["orders:read"],
"adminExtensions": [
{
"target": "shop.order.details",
"title": "Shipping label",
"url": "https://plugins.example.com/dunea/order-card",
"height": 180
},
{
"target": "shop.orders.bulk-actions",
"title": "Print labels",
"url": "https://plugins.example.com/dunea/print"
}
]
}| Field | Rules |
|---|---|
target | One of the targets below. |
title | 2 to 40 characters. The card heading, or the menu item's label. |
url | A public https:// URL. It is reviewed like panel.url. |
height | Blocks only: the initial iframe height, 60 to 800 CSS px (default 160). The frame can resize itself afterwards. |
A plugin can declare up to 10 extensions, and more than one for the same target.
Targets
A target whose name ends in actions is an action: an item in that view's actions menu, which opens your page in a modal. Every other target is a block: a card rendered inline in the view.
| Target | Kind | Shown in | resourceId | Needs |
|---|---|---|---|---|
app.dashboard | block | Marketplace, at the top of Installed | none | app:read |
shop.product.details | block | A product's detail view | product id | shop:read |
shop.product.actions | action | A product's actions menu | product id | shop:read |
shop.order.details | block | An order's drawer | order id | orders:read |
shop.order.actions | action | An order's actions menu | order id | orders:read |
shop.orders.bulk-actions | action | The orders list, on the selected orders | order ids, joined by , (up to 100) | orders:read |
shop.collection.details | block | A collection, while it is being edited | collection id | shop:read |
funnels.funnel.details | block | A funnel's editor | funnel id | app:read |
payments.payment.details | block | A payment row, expanded | payment id | payments:read |
reviews.review.actions | action | A review's actions menu | review id | reviews:read |
data.metaobject.details | block | A metaobject entry | metaobject id | data:read |
The manifest is refused unless permissions includes what each target needs. Dunea also checks the install at runtime: an extension only appears, and a session is only signed, when the owner granted that permission.
Dunea's own features use the same targets. Its cards (Reviews' rating summary on a product, for example) come first; installed plugins follow, ordered by plugin name and then by their order in the manifest.
How an extension loads
When the view opens, Dunea asks for a session for your extension and loads:
https://plugins.example.com/dunea/order-card#dunea_session=v1.eyJpbnN0YWxs….3q2-7w…This is the same token as a panel session, signed with your client secret and valid for 10 minutes, with two more claims:
interface PanelSessionClaims {
installId: string;
appId: string;
userId: string;
exp: number;
/** The target the frame was opened for. */
target?: AdminTarget;
/** The resource id; for bulk actions, the ids joined by ",". Absent for app.dashboard. */
resourceId?: string;
}Before signing, Dunea checks that the resource belongs to the app (for a bulk action, every selected id). A frame can never be opened for another app's order. The URL is always the one in your reviewed manifest; nothing the browser sends can change it. Any hash you put in url is kept, with dunea_session added.
Plain panel tokens are unchanged: they carry neither claim. Use target to tell the two apart if one page serves both.
Read it in the browser
getPanelSession() returns the target and resource next to the token, so one page can serve several targets:
import { getPanelSession, panel } from '@dunea/plugin-sdk/panel';
const session = getPanelSession();
if (session?.target === 'shop.order.details') {
const res = await fetch(`/api/orders/${session.resourceId}/label`, {
headers: { authorization: `Bearer ${session.token}` },
});
render(await res.json());
panel.resize(document.body.scrollHeight);
}
if (session?.target === 'shop.orders.bulk-actions') {
renderPrintForm(session.resourceIds); // ['ord_…', 'ord_…']
}Like the claims, target, resourceId and resourceIds are decoded but not verified in the browser. Use them to choose what to render; let your server act on the verified claims.
Verify on your server
import { sessionResourceIds, verifyPanelSession } from '@dunea/plugin-sdk';
const claims = verifyPanelSession(token, process.env.DUNEA_CLIENT_SECRET!);
if (claims.target !== 'shop.order.details' || claims.resourceId !== req.params.orderId) {
return res.status(403).end();
}
// Safe: Dunea checked the order belongs to claims.appId before signing.
const order = await dunea.orders.get(claims.resourceId);verifyPanelSession refuses a token whose target is not a known target, or that has a resourceId without a target. sessionResourceIds(claims) splits a bulk resourceId into ids.
Bind every request to the claims: check target and that the resource your endpoint touches is the one in resourceId. A token opened on order A must not let the page act on order B.
Talking to the workbench
Extensions use the same messages as panels (panel.toast, panel.navigate, panel.resize, panel.close):
- Blocks start at the manifest
heightand followpanel.resize, between 60 and 800 px. Keep them compact: they sit inside a view the owner is working in. - Actions open in a modal and fill it;
panel.close()closes the modal (call it when the action is done, after a success toast). panel.navigate('/products/sunset-tee')opens that path in the live preview;panel.navigate('orders')switches to a workbench tool.
The iframe sandbox is allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin, so OAuth and payment pages you open in a popup run normally. Allow framing by Dunea in your CSP (frame-ancestors https://*.dunea.ai).
Review
- Every extension URL must pass the public-endpoint check (DUN-U001), and is probed at submission like your panel (DUN-U003). An unreachable URL is a warning for the reviewer, and keeps the version off the fast track.
- An update is fast-tracked only when
adminExtensionsis unchanged: a new, removed, moved, retitled or resized extension is reviewed by a person.
Good extensions
- Show something useful in the first 160 px; resize only when there is more to show.
- Render a clear empty state ("No label yet") instead of a blank card.
- Make actions do one thing, confirm it with
panel.toast, thenpanel.close(). - Keep bulk actions idempotent: the owner may run them twice on overlapping selections.
Blocks: sections, blocks and app embeds
Ship owner-editable sections in a plugin or theme: block anatomy, settings, presets, embeds, the import allowlist and how blocks are reviewed.
Checkout functions
Run your own discount, shipping, payment-method, validation, tax and offer-price logic in the middle of a Dunea checkout, with a hard time budget and a safe fallback.