Build for Dunea

Workbench panels

Add your own page to the Dunea workbench, identify the install with a session token, and talk to the workbench.

A panel is a page you host that appears in the owner's workbench, next to their app's other tools. Use it for anything that needs a real interface: a dashboard, a setup wizard, a list of shipments.

{
  "panel": { "url": "https://plugins.example.com/panel", "title": "Shipments" }
}

title is 2 to 40 characters and is shown as the panel's name in the workbench.

To put a card or an action inside one of Dunea's views instead (an order's drawer, a product page, the orders list), use admin extensions. They load the same way, with the same session token plus the view and resource they were opened for.

How a panel loads

When the owner opens your panel, Dunea renders panel.url in a sandboxed iframe and appends a session token to the URL fragment:

https://plugins.example.com/panel#dunea_session=v1.eyJpbnN0YWxs….3q2-7w…

The token proves which install and which user opened the panel. It is signed with your client secret and expires after 10 minutes (PANEL_SESSION_TTL_SECONDS). Because it is in the fragment (after #), the browser never sends it to your server in the page request; your page reads it and sends it on.

Its claims are:

interface PanelSessionClaims {
  installId: string;
  appId: string;
  userId: string;
  /** Unix seconds. */
  exp: number;
}

Read the token in the browser

getPanelSession() from @dunea/plugin-sdk/panel reads the token from location.hash, removes it from the URL (so it does not end up in bookmarks, referrers or analytics) and caches it for later calls. It returns null when the page was not opened by Dunea.

panel/main.ts
import { getPanelSession } from '@dunea/plugin-sdk/panel';

const session = getPanelSession();

if (!session) {
  document.body.textContent = 'Open this page from your Dunea workbench.';
} else {
  const response = await fetch('/api/shipments', {
    headers: { authorization: `Bearer ${session.token}` },
  });
  // ...render the response
}

session.claims is decoded but not verified. Use it for display only, never to decide what the user may see or do.

Verify the token on your server

Verify the token with verifyPanelSession before you return any data. It checks the signature and expiry and returns the claims, or throws DuneaSessionError with a reason of malformed, mismatch or expired.

src/panel-api.ts
import { verifyPanelSession, DuneaSessionError } from '@dunea/plugin-sdk';

declare function listShipments(installId: string): Promise<unknown[]>;

export async function handleShipments(request: Request): Promise<Response> {
  const token = request.headers.get('authorization')?.replace(/^Bearer /, '') ?? '';

  try {
    const claims = verifyPanelSession(token, process.env.DUNEA_CLIENT_SECRET!);

    return Response.json(await listShipments(claims.installId));
  } catch (error) {
    if (error instanceof DuneaSessionError) {
      return Response.json({ error: error.reason }, { status: 401 });
    }
    throw error;
  }
}

Like verifyWebhook, the secret can be an array while you rotate it.

The token lasts 10 minutes. If you want a longer session, exchange it once for your own session (for example, a cookie or a token your server issues) scoped to that installId and userId.

A panel-only plugin (no webhookUrl) never receives plugin.installed, so it has no Plugin API access token yet. Your server can exchange the session token, with your client secret, for one: see Getting a new access token.

Without the SDK

The token format is v1.<payload>.<signature>, where payload is the base64url-encoded JSON claims and signature is the base64url HMAC-SHA256 of v1.<payload> keyed with your client secret. Verify the signature with a constant-time comparison, then check that exp is later than now.

Talking to the workbench

The panel helper sends messages to the workbench around your iframe:

import { panel } from '@dunea/plugin-sdk/panel';

panel.toast('Label created', 'success');   // 'info' (default), 'success' or 'error'
panel.navigate('orders');                  // a workbench tool id, or an app path like '/products'
panel.resize(document.body.scrollHeight);  // iframe height in CSS px, up to 4,000
panel.close();                             // close the panel
HelperMessage postedNotes
panel.toast(message, variant?){ source: 'dunea-plugin', type: 'toast', message, variant }message is cut to 300 characters.
panel.navigate(toolOrPath){ source: 'dunea-plugin', type: 'navigate', to }A workbench tool id (such as shop or orders), or an app-relative path (/products/sunset-tee), which opens in the live preview. At most 300 characters.
panel.resize(height){ source: 'dunea-plugin', type: 'resize', height }Rounded and clamped to 1 to 4,000 px.
panel.close(){ source: 'dunea-plugin', type: 'close' }

Messages are posted to window.parent with target origin *, because your page cannot know which Dunea host embeds it. They carry no secrets. The workbench only acts on messages that come from the iframe it created for your install, so other frames cannot pose as your panel.

If you do not use the SDK, post the same objects yourself with window.parent.postMessage(message, '*').

The iframe sandbox is allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin: a popup you open (an OAuth consent screen, a Stripe page) runs as a normal page, outside the sandbox.

Good panels

  • Keep the session check on every request your panel makes to your server.
  • Show a clear state when your service is not set up yet, and link to your settings or docs.
  • Do not ask the owner to log in to a separate account when the session token already tells you who they are.

On this page