Extensions let an operator put small companion tools next to Studio without forking its templates: sidebar menu items, stylesheets and scripts on Studio pages, embed pages that wrap another app in the Studio shell, and a forward-auth endpoint so a companion app can reuse Studio's login.
Everything is off by default. With SIMPLEAUDIT_EXTENSIONS unset, Studio
renders exactly what it renders without the feature, and /x/* and
/ext/authz return 404.
The manifest is operator-controlled code, not user input. Read this before you enable it:
- Extension scripts run with full same-origin privileges: they can read
every page the user can see and call every API as that user (they get the
CSRF token in
window.Studio). Only list scripts you would merge into Studio itself. - The manifest comes from the deployment's configuration (an environment variable or a file), never from the database or the UI. Changes should go through the same review as any other deployment change. A database-backed editor for scripts is deliberately not provided: storing admin-edited JavaScript is a stored-XSS risk by design.
- Scripts are not sandboxed. For third-party URLs, pin them with
integrity. - A companion app protected by
/ext/authzmust be reachable only through the proxy, so clients cannot forge theX-Studio-*headers. This is the same rule as for the embedded Open WebUI (see chat.md).
SIMPLEAUDIT_EXTENSIONS holds either inline JSON or the path of a JSON file.
A value starting with [ or { is read as JSON; anything else is a file path.
The file is re-read when it changes, without a restart.
The JSON is a list of entries, or {"extensions": [...]}. An invalid manifest
is logged as an error (simpleaudit.extensions) and ignored; it never takes
the UI down. A complete example is in
examples/extensions.json, with its assets in
static/extensions/example/.
| Field | Required | Meaning |
|---|---|---|
slug |
yes | Lowercase letters, digits and dashes; unique. Used in /x/<slug>/. |
label |
no | Menu text. Defaults to the slug. |
icon |
no | Menu icon (an emoji or symbol). Defaults to ◇. |
section |
no | Heading the item is grouped under in the sidebar. Entries without one are grouped together, without a heading. |
order |
no | Integer sort key within a section (default 100), then label. |
roles |
no | Who sees the entry; default ["admin"]. See Roles. |
href |
no | Menu link: an http(s):// URL or a path starting with /. |
embed |
no | A same-origin path (starting with /) shown in an iframe at /x/<slug>/. The menu item points at that page. Give href or embed, not both. |
css, js |
no | A URL or a list of URLs (http(s):// or / paths). |
pages |
no | Glob patterns on the request path (/runs/*) limiting where css/js load. Default: every page. |
integrity |
no | Map of asset URL to a Subresource Integrity hash (sha256-, sha384- or sha512-). |
An entry with neither href nor embed adds no menu item; use that for
asset-only extensions.
Roles form a ladder: user < viewer < auditor < admin < superuser.
A user's level is the highest role they hold in any workspace; any signed-in
user is at least user, and a Django superuser is superuser. An entry is
shown to users at or above the lowest role it lists. Entries are instance-wide:
there are no per-workspace manifests.
Filtering happens on the server. An entry a user may not see is absent from the HTML, its assets are not loaded for that user, and its embed page returns 404, the same as an unknown slug.
Menu items appear in the sidebar below the built-in pages, grouped by
section.
Slots. Stylesheets are emitted at the end of <head>. At the end of
<body> Studio emits, in order:
window.Studio: the page context (below), from a<script type="application/json" id="studio-page-context">block.- The extension scripts, each with
defer(andintegritypluscrossorigin="anonymous"when a hash is given). - A
studio:readyevent ondocument, dispatched atDOMContentLoaded, after every deferred extension script has run.
Every element Studio adds carries a data-studio-extension attribute.
| Field | Meaning |
|---|---|
version |
Studio version. |
user |
{id, username, name, email}. |
role |
The user's level on the role ladder (user … superuser). |
workspace |
{id, name} of the active workspace, or null. |
csrfToken |
Send as X-CSRFToken on unsafe requests to Studio's API. |
path |
The request path the page was rendered for. |
extensions |
Slugs of the extensions whose assets load on this page. |
onPage(pattern, fn) |
Runs fn(Studio) once ready if location.pathname matches the glob (or any glob in a list). |
Studio.onPage("/runs/*", (studio) => {
console.log("run page for", studio.user.username, "in", studio.workspace?.name);
});
document.addEventListener("studio:ready", (e) => { /* e.detail === window.Studio */ });Studio sends no Content-Security-Policy, so no nonce is needed. If you add a CSP at your proxy, allow the script origins you list and Studio's inline scripts.
/x/<slug>/ renders the normal Studio shell (sidebar, top bar, footer) around
an iframe of the entry's embed path. Signed-out users are sent to the login
page. Studio sends X-Frame-Options: SAMEORIGIN, so serve the companion app
on the same origin, for example under /feedback/ behind the same proxy.
A companion app behind any forward-auth-capable proxy can use Studio's login.
The contract matches /chat/authz:
| Session | Response |
|---|---|
Signed in, at or above ?role= (default admin) |
200 with X-Studio-Email, X-Studio-Name, X-Studio-Role (admin or user) and X-Studio-Username |
| Signed out | 401, with X-Studio-Login set to the login URL (carrying next from X-Forwarded-Uri when the proxy sends it) |
| Signed in, below the role | 403 |
Unknown role value |
400 |
Caddy example protecting a companion app at /feedback/ for admins:
handle /feedback/* {
forward_auth studio:8000 {
uri /ext/authz?role=admin
copy_headers X-Studio-Email X-Studio-Name X-Studio-Role X-Studio-Username
@signedout status 401
handle_response @signedout {
redir * /login/?next={uri} 302
}
}
reverse_proxy feedback-app:3000
}Strip any client-supplied X-Studio-* headers before the request reaches the
companion app (Caddy's copy_headers overwrites them), and do not expose the
companion app on any other route.