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"
    }
  ]
}
FieldRules
targetOne of the targets below.
title2 to 40 characters. The card heading, or the menu item's label.
urlA public https:// URL. It is reviewed like panel.url.
heightBlocks 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.

TargetKindShown inresourceIdNeeds
app.dashboardblockMarketplace, at the top of Installednoneapp:read
shop.product.detailsblockA product's detail viewproduct idshop:read
shop.product.actionsactionA product's actions menuproduct idshop:read
shop.order.detailsblockAn order's drawerorder idorders:read
shop.order.actionsactionAn order's actions menuorder idorders:read
shop.orders.bulk-actionsactionThe orders list, on the selected ordersorder ids, joined by , (up to 100)orders:read
shop.collection.detailsblockA collection, while it is being editedcollection idshop:read
funnels.funnel.detailsblockA funnel's editorfunnel idapp:read
payments.payment.detailsblockA payment row, expandedpayment idpayments:read
reviews.review.actionsactionA review's actions menureview idreviews:read
data.metaobject.detailsblockA metaobject entrymetaobject iddata: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 height and follow panel.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 adminExtensions is 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, then panel.close().
  • Keep bulk actions idempotent: the owner may run them twice on overlapping selections.

On this page