Plugin SDK
This is the developer reference for building a ROPARC plugin. For installing one, see Managing plugins; for using one on a dashboard, see Widgets & Plugins.
A plugin is a self-contained built app (HTML/JS/CSS) that runs inside a sandboxed iframe on a dashboard and talks to ROPARC over a postMessage bridge.
How plugins work
- A plugin is static files — there is no plugin server. Everything it needs from ROPARC goes through the bridge.
- The plugin never sees the auth token. Every
ctx.api.*call is a request the host runs on the viewer’s behalf, automatically scoped to the current view context (active baseline, change-request branch, or variant). A plugin shows the right data in every view mode for free. - Plugins are git-committed and version-pinned by the installer, so a dashboard viewed at a historic baseline serves the contemporaneous build.
initRoparcPlugin() and ctx
Two small, dependency-free files form the in-iframe SDK — copy them into
your plugin’s repo verbatim and build against RoparcPluginCtx:
pluginProtocol.ts— the postMessage contract + typesroparcPluginSdk.ts—initRoparcPlugin()returningctx
import { initRoparcPlugin } from './roparcPluginSdk';
const ctx = await initRoparcPlugin(); // resolves once the host sends `init`
initRoparcPlugin() announces readiness to the host and resolves with ctx
when the host replies. Outside a ROPARC host it never resolves — guard with
a timeout in development.
ctx fields
| Field | Type | Meaning |
|---|---|---|
ctx.workareaId | string | The active workarea. |
ctx.baseline | string | null | Active baseline name, or null at HEAD. |
ctx.dna | string | null | Active variant, or null. |
ctx.params | Record<string, unknown> | The widget’s Parameters (JSON) from the config dialog. |
ctx.dashboardId | string | null | The dashboard hosting this widget (null in previews). |
ctx.widgetId | string | This widget instance’s stable id — namespace your per-instance config by it. |
ctx.user | { id, displayName } | The viewer. |
ctx.theme | { mode: 'light' | 'dark' } | Current theme. |
ctx.urlParams | Record<string, string> | The page URL’s query string at load — see URL parameters & deep links below. |
ctx.api | RoparcPluginApi | Backend calls (below). |
ctx.ui | RoparcPluginUi | Host-rendered UI (rich text, resolved field values, the item panel). |
ctx.setUrlParams(params, opts?) | (params, {push?}) → Promise | Rewrite the URL so links reflect what the user sees. |
ctx.onUrlParamsChange(cb) | (params) ⇒ unsubscribe | Back/forward and host navigation. |
ctx.onThemeChange(cb) | (mode) ⇒ unsubscribe | Subscribe to theme switches. |
ctx.onItemPanelChange(cb) | (change) ⇒ unsubscribe | Edits made in a delegated item panel — see The Item Detail Panel. |
ctx.setHeight(px) | (number) => void | Ask the host to size the iframe. |
ctx.api — backend calls
All return Promises and run with the viewer’s credentials at the current view context. Items and documents are queried with the same query language as the Items page.
| Method | Signature | Notes |
|---|---|---|
query | (rql?, {page?, pageSize?}) → {items, total, page, pageSize} | Query items. |
getItem | (id) → Item | |
createItem | (payload) → Item | {type, title, description?, custom?, links?, commitMessage?} |
updateItem | (id, payload) → Item | Partial; same shape as create. |
deleteItem | (id) → void | |
getLinks | (id) → {links_to, linked_from} | |
getDocument | (id) → Document | Includes sections. |
listDocuments | (rql?, {page?, pageSize?}) → {documents, total} | |
createDocument | (payload) → Document | |
updateDocument | (id, payload) → Document | |
getConfig | () → ResolvedWorkareaConfig | item types, link types, enums, fields. |
updateConfig | (patch) → config | Patches workarea config (for in-widget config wizards). |
getUsers | () → {users:[{id, display_name}]} | |
getCurrentUser | () → {id, display_name} | |
listAttachments | (entity, id) → {attachments:[…]} | entity = 'item' | 'document' | 'dashboard'. |
uploadAttachment | (entity, id, {filename, mimeType, dataBase64}) → {url, filename} | |
getAttachment | (entity, id, filename) → {mimeType, dataBase64} | Blob, base64-encoded. |
getStatuses | () → StatusCatalogue | The resolved statuses per item type. The type-to-workflow binding is server-side — never re-derive it. |
getTransitions | (id) → {transitions} | Transitions available from the item’s current status, with guards and permissions already evaluated. |
batch | ({operations, transitions, commitMessage}) → result | Field writes and transitions in ONE all-or-nothing commit. Prefer it over a loop of updateItem. |
raw | (path, init?) → {status, ok, body} | Escape hatch — unversioned call, token injected by host, view context not auto-applied. path must be an absolute same-origin path under /api/ (/api/workareas/…); it is normalised before the check, so anything escaping /api/ is rejected. The host sets Authorization itself and ignores any you pass. |
ctx.ui — host-rendered surfaces
A plugin can’t embed ROPARC’s editor or its item panel (they live in the host, and image uploads need the token), so the host renders them for you and returns plain data. This is how a plugin shows descriptions, field values and item details that look identical to everywhere else in ROPARC — and keep looking identical as those surfaces gain tabs and fields.
The item panel has its own guide: The Item Detail Panel.
| Method | Signature | Notes |
|---|---|---|
editRichText | (value, opts?) → string | null | Opens the host’s editor in a modal seeded with value (ROPARC rich-text JSON or ''). Resolves the edited JSON, or null on cancel. opts = {title?, placeholder?, attachmentTarget?}; pass attachmentTarget: {kind:'item'|'document', id} to enable image uploads. |
renderRichText | (value) → string | ROPARC rich-text JSON (or legacy markdown) → sanitised HTML for read-only display. |
renderField | (itemType, fieldKey, value) → {label, color?, icon?} | Resolves a stored value to the label, colour and icon the app itself shows — never render a raw key or enum value. |
openItemPanel | (id, {mode?}) → void | Opens ROPARC’s own item detail panel docked beside the plugin. mode is 'managed' (default, the panel saves) or 'delegated' (you save). |
closeItemPanel | () → void | Closes it. Closing an already-closed panel is not an error. |
URL parameters & deep links
A plugin owns the query string of the page it is on. That is how a link can point at a particular view inside a plugin — the top node of a tree, a selected element — and how the address bar keeps up as the user navigates, so a copied URL reproduces exactly what they were looking at.
const ctx = await initRoparcPlugin();
let root = ctx.urlParams.root ?? null; // deep link in
render(root);
ctx.onUrlParamsChange((p) => render(p.root ?? null)); // back / forward
function onSelectNode(id) {
ctx.setUrlParams({ ...ctx.urlParams, root: id }); // deep link out
}
The dashboard URL then reads
…/dashboards/my-board?root=REQ-42 — plain, readable, hand-editable.
Rules
- Reserved keys.
dna,baseline,refandtabbelong to ROPARC. They are hidden fromctx.urlParamsand rejected bysetUrlParams— a plugin cannot change the view context out from under the page it lives on. (View context reaches you asctx.baseline/ctx.dnainstead.) Everything else in the query string is yours. - The slice is replaced wholesale. Omitting a key deletes it, so spread the
current params (
{ ...ctx.urlParams, root: id }) when you mean to patch. - Flat strings only. Keys match
[A-Za-z0-9_-](≤ 64 chars); values are strings ≤ 1024 chars; ≤ 24 keys; ≤ 2048 chars once encoded. Anything larger belongs in Per-instance configuration (below) — a URL ends up in browser history, referrer headers and server logs. - History. Writes replace the current entry by default. Pass
{ push: true }only where the user would expect Back to undo the change; pushing on every expand makes the back button useless. - Your own writes are not echoed back to
onUrlParamsChange, so a render-on-change handler cannot loop. Rapid writes are coalesced.
Treat incoming params as untrusted
Anyone who can send the viewer a link controls ctx.urlParams, and your calls
run with that viewer’s credentials. Validate before use — the same care you
would take with any query string. Never interpolate a param straight into
innerHTML or into a raw() path.
One instance per dashboard. Params are not namespaced per widget, so two instances of the same plugin on one dashboard share a query string and will fight over it. Use Per-instance configuration (below) for anything that must differ between two widgets on the same board.
Packaging a plugin
A plugin zip must contain an index.html at its root. A single wrapping
folder is auto-stripped, so zipping either dist/ or the contents of dist/
both work.
my-plugin.zip
├── index.html ← required, at the root
├── assets/
│ ├── app.js
│ └── app.css
├── manifest.json ← optional (see below)
└── icon.svg ← optional, referenced by manifest.icon
- Use relative asset paths (
./assets/app.js, not/assets/app.js). With Vite, setbase: './'. Assets are served under a per-session signed path, so absolute paths break. - Limits: 25 MB / 2000 files per bundle; icon ≤ 256 KB.
manifest.json (optional)
Describes how the plugin appears in the Add Widget picker. All fields optional:
{
"name": "Fault Tree",
"description": "Build and edit fault tree analyses traced to requirements.",
"icon": "icon.svg",
"defaultSize": { "w": 10, "h": 10, "minW": 5, "minH": 5 }
}
| Field | Default | Notes |
|---|---|---|
name | the plugin id | Tile title (shown instead of the raw id). |
description | "" | Tile subtitle. |
icon | none | Path relative to the plugin root; inlined as a data URI (≤ 256 KB). SVG or PNG. |
defaultSize | {w:8,h:8,minW:4,minH:4} | Initial grid footprint when dropped. |
Examples
Minimal plugin (vanilla, no bundler)
index.html:
<!doctype html>
<html>
<body style="font-family: sans-serif; margin: 12px">
<h3 id="title">…</h3>
<ul id="list"></ul>
<script type="module" src="./app.js"></script>
</body>
</html>
app.js:
import { initRoparcPlugin } from './roparcPluginSdk.js';
const ctx = await initRoparcPlugin();
document.getElementById('title').textContent =
`Hi ${ctx.user.displayName} — workarea ${ctx.workareaId}`;
const { items } = await ctx.api.query('type = "requirement"', { pageSize: 10 });
document.getElementById('list').innerHTML = items
.map((i) => `<li>${i.id} — ${i.title}</li>`)
.join('');
Read a configured parameter and react to theme
const ctx = await initRoparcPlugin();
// documentId comes from the widget's Parameters (JSON): { "documentId": "D-ROP-2" }
const docId = ctx.params.documentId;
const doc = docId ? await ctx.api.getDocument(docId) : null;
ctx.onThemeChange((mode) => (document.documentElement.dataset.theme = mode));
Edit a description through ROPARC’s own editor
const item = await ctx.api.getItem('ROP-42');
const edited = await ctx.ui.editRichText(item.description ?? '', {
title: 'Edit ROP-42',
attachmentTarget: { kind: 'item', id: 'ROP-42' }, // images upload onto this item
});
if (edited !== null) {
await ctx.api.updateItem('ROP-42', { description: edited, commitMessage: 'Edit via plugin' });
}
// Display stored rich text read-only:
panel.innerHTML = await ctx.ui.renderRichText(item.description ?? '');
Per-instance configuration
A plugin dropped on two dashboards is two instances that may need different
settings. Rather than cramming everything into ctx.params, store the
instance’s config as a dashboard attachment keyed by ctx.widgetId:
const KEY = `${ctx.widgetId}.config.json`;
async function loadConfig() {
if (!ctx.dashboardId) return {}; // preview / unsaved dashboard
try {
const a = await ctx.api.getAttachment('dashboard', ctx.dashboardId, KEY);
return JSON.parse(atob(a.dataBase64));
} catch {
return {}; // not set yet
}
}
async function saveConfig(cfg) {
if (!ctx.dashboardId) return;
await ctx.api.uploadAttachment('dashboard', ctx.dashboardId, {
filename: KEY,
mimeType: 'application/json',
dataBase64: btoa(JSON.stringify(cfg)),
});
}
The plugin renders its own settings UI and persists the result as a
git-backed attachment. Namespacing by widgetId keeps two instances on the same
dashboard from clobbering each other.
Local development
Iterating through upload → install on every change is slow. In development
builds only, the Add Widget dialog offers Plugin (by URL · dev) and the
config dialog offers a Custom URL… option, so you can point a widget at a
local dev server (e.g. a Vite server at http://localhost:5180/) and get hot
reload. This path is stripped from production builds — a deployed dashboard
can only ever load an installed, git-served plugin, never an arbitrary URL. When
ready, build your plugin and install the zip from
Settings → Extensions & Logs → Plugins.
Security
- Plugins run as the viewer. A plugin can do nothing the viewer couldn’t do through the API with their own session — there is no elevation.
- The token never crosses the bridge.
ctx.api.*calls are run by the host, which attaches the credential itself and confines every call to/api/on its own origin. - A plugin cannot steer the host. It writes only its own slice of the query
string; the keys that control view context (
dna,baseline,ref,tab) are reserved and rejected. - Installing is privileged — workarea installs need
widgets:author; group and organization installs need a site administrator. See Managing plugins.