Keeping a space admin-ready
The platform ships a per-space admin at unfolder.space/<slug>/* (owner/admin
only). Most of it — workspace viewer, media manager, members, settings, deploys —
runs entirely from platform state and needs nothing from your deployed code.
Two private RPC hooks on your App Durable Object let your deployed code
extend the admin. Both are DO RPC methods, not HTTP routes — the platform
calls them after authenticating the owner/admin, and they are never reachable on
your public host (<your-space-slug>.unfolder.space). Facet storage is isolated; there is no
platform backdoor, so these hooks are the only way in.
__querypowers the Database console (/<slug>/database) — read/run SQL against your running site's SQLite (read-only by default). Scaffolded for free (below).__adminFetch/__adminManifestpower custom admin views — your own dashboards, rendered right inside the admin shell (charts, tables, analytics over your facet's data). Opt-in; see "Custom admin views" below.
You get this for free
A brand-new space is scaffolded admin-ready: src/index.ts already inlines the
vendored MIT browsable query engine and exposes the __query /
__queryTransaction hooks below. If you never touch them, the Database console
just works.
If you write your own App
When you replace the scaffold's src/index.ts with your own worker, keep these
two methods on your exported App class (paste the snippet verbatim). Without
them the Database console degrades gracefully — it shows a "redeploy to enable"
empty state — but every other admin section keeps working.
The read-only gate lives in the trusted platform, not in your code: the
console runs a single SELECT/PRAGMA/EXPLAIN by default, with an explicit
"Allow writes" opt-in. Your hook simply executes whatever the platform forwards.
import { DurableObject } from "cloudflare:workers";
// ── vendored: outerbase/browsable-durable-object (MIT) — query engine only ──
class BrowsableHandler {
constructor(sql) {
this.sql = sql;
}
async executeTransaction(queries) {
const results = [];
for (const query of queries) {
results.push(
await this.executeQuery({ sql: query.sql, params: query.params ?? [], isRaw: true }),
);
}
return results;
}
executeRawQuery(opts) {
const { sql, params } = opts;
if (params && params.length) return this.sql.exec(sql, ...params);
return this.sql.exec(sql);
}
async executeQuery(opts) {
const cursor = this.executeRawQuery(opts);
if (!cursor) return [];
if (opts.isRaw) {
return {
columns: cursor.columnNames,
rows: Array.from(cursor.raw()),
meta: { rows_read: cursor.rowsRead, rows_written: cursor.rowsWritten },
};
}
return cursor.toArray();
}
}
export class App extends DurableObject {
async fetch(request) {
// …your site…
}
// Platform data console hook — PRIVATE RPC, never reachable on your public
// host. Backed by the vendored MIT browsable handler above.
async __query(sql, params) {
try {
const handler = new BrowsableHandler(this.ctx.storage.sql);
const { columns, rows, meta } = await handler.executeQuery({
sql: String(sql ?? ""),
params: Array.isArray(params) ? params : [],
isRaw: true,
});
return {
ok: true,
columns,
rows,
meta: { rowsRead: meta.rows_read, rowsWritten: meta.rows_written },
};
} catch (err) {
return { ok: false, code: "QUERY_ERROR", error: err?.message ?? String(err) };
}
}
// Optional — the full browsable transaction contract (array of { sql, params }).
async __queryTransaction(queries) {
try {
const handler = new BrowsableHandler(this.ctx.storage.sql);
const results = await handler.executeTransaction(Array.isArray(queries) ? queries : []);
return { ok: true, results };
} catch (err) {
return { ok: false, code: "QUERY_ERROR", error: err?.message ?? String(err) };
}
}
}
Custom admin views
Extend the admin with your own UI — a dashboard for a custom form's submissions,
analytics charts, an editor for a table you created. The platform authenticates
the owner/admin, then proxies into two optional hooks on your App. Your view
renders inside a sandboxed iframe in the admin shell, so it can use JavaScript
(for charts) without any access to the platform session or other spaces.
The two hooks
export class App extends DurableObject {
async fetch(request) { /* …your public site… */ }
// Optional: partial sidebar-nav HTML for your views (the platform injects it
// into the admin sidebar). You OWN these links — prefix them with ctx.adminBase
// and target the shell's #admin-main so the platform swaps in your view.
async __adminManifest(ctx) {
return `<li><a href="${ctx.adminBase}/forms" up-follow up-target="#admin-main">Forms</a></li>`;
}
// Optional: render a view. `request` is your admin request (path is relative to
// ctx.adminBase). Return a normal HTML body (DaisyUI classes; you may include
// <script> using the platform-provided Chart / mermaid / gridjs globals). Declare
// which libs to load with the `X-Admin-Libs` response header.
async __adminFetch(request, ctx) {
// ctx = { user: { id, email }, role, adminBase } — trusted, set by the platform.
const url = new URL(request.url); // e.g. /forms (adminBase stripped)
const rows = this.ctx.storage.sql
.exec("SELECT created_at, email FROM submissions ORDER BY created_at DESC LIMIT 100")
.toArray();
const body = `
<h1 class="text-xl font-semibold mb-4">Form submissions</h1>
<div class="stats shadow mb-4"><div class="stat">
<div class="stat-title">Total</div><div class="stat-value">${rows.length}</div>
</div></div>
<canvas id="byDay" class="mb-6"></canvas>
<div id="grid"></div>
<script>
const rows = ${JSON.stringify(rows)};
new gridjs.Grid({ data: rows.map(r => [r.email, new Date(r.created_at).toLocaleString()]),
columns: ["Email", "When"] }).render(document.getElementById("grid"));
// …bucket rows by day and feed new Chart(document.getElementById("byDay"), {…})…
</script>`;
return new Response(body, {
headers: { "content-type": "text/html; charset=utf-8", "X-Admin-Libs": "chart,gridjs" },
});
}
}
The contract
- It's the same facet. Your public
fetchand__adminFetchshare one SQLite — the form a visitor submits on your public site writes rows that this view reads. No bridge, no token, no copy. - Identity is trusted.
ctx.user/ctx.rolecome from the platform's verified session — there's no header to spoof. The mere fact__adminFetchwas called means an authenticated admin is here. - Render a fragment, not a document. Return the body's inner HTML — no
<html>/<head>/<style>; the platform's admin stylesheet (DaisyUI + Tailwind) is already loaded in the frame. Use class names directly. - JavaScript is allowed, from a fixed set only. The frame loads Unpoly
(always) plus whichever of
chart(Chart.js →window.Chart),gridjs(Grid.js →window.gridjs),mermaid(→window.mermaid) you name in theX-Admin-Libsresponse header. A strict CSP blocks every other external script. You canfetch()your own admin endpoints (see below); third-party network calls are still blocked. - Links & forms = ordinary HTML, and they just work. Prefix every
href/actionwithctx.adminBaseand write plain<a href>/<form method="post">. The view runs in a sandboxed, opaque-origin iframe (it can't carry the session cookie itself, andconnect-src 'none'blocks raw XHR), so the platform bridges every navigation to the parent (cookie-authenticated) and re-drives the frame. By default that's a full-frame swap; forms POST then redirect (PRG) — write, then return a303to anadminBase-relative path (e.g./settings?saved=1) and the frame re-renders the GET. - Want partial swaps? Use Unpoly — it works in-frame now. Add
up-follow/up-target="#…"to a link, orup-submit/up-targetto a form, and the platform routes Unpoly's request through the same cookie-authed bridge — so you get fragment swaps without a full reload. Plainfetch("…", …)to your ownadminBase-relative endpoints works too (it's transparently proxied; the response comes back as a normalResponse). Requests are gated to your/<slug>/app/*— you can't reach other spaces or the open internet. (Author your links/forms exactly as you would on any Unpoly page; just keep them underctx.adminBase.) #hashlinks & media links behave naturally. Same-page#anchorlinks scroll the admin page to the target; a link to a media file opens it in a new tab. You can also inline-embed a media PDF/HTML with<iframe>/<embed>/<object>pointing at a root-relative media path (<iframe src="/handbook.pdf">).- Media in CSS resolves too — including custom fonts. A root-relative
url(...)in a<style>block or an inlinestyle="…"is rebased to your space's media host just like an<img src>, so an uploaded face just works:
Upload the font/image to media first (it's binary — never commit it to the workspace), then reference it by its root-relative path. Private (<style> @font-face { font-family: "Brand"; src: url("/fonts/brand.woff2") format("woff2"); } .brand { font-family: "Brand"; } .hero { background-image: url("/img/hero-bg.jpg"); } </style>_…) paths get a signed URL automatically. A<link rel="stylesheet">to an external sheet stays blocked by the frame CSP — keep your CSS inline in a<style>block. - File inputs don't work — by design.
<input type=file>bytes can't cross the frame boundary, so a form containing a selected file is blocked with an inline message (not silently dropped). Use a media-picker button (below) instead — it stores a path string, never bytes. Other text fields bridge normally. In-frame JS (Chart/Grid/Mermaid, your own scripts) always runs. - Sidebar links are different. Links returned from
__adminManifestrender in the parent admin sidebar, so they DO use Unpoly:up-follow up-target="#admin-main"(the platform also stampsup-historyso the URL tracks the active view). - Production by default. The view follows the admin's global branch selector; with a preview branch selected it proxies that preview facet instead.
- No hooks → graceful. A live space with no
__adminFetchshows a built-in "build your own admin views" empty state. Add the hooks and redeploy to fill it.
Testing a view from run_code
You don't need to open the browser admin (and log in as the owner) to check a
view works. After you deploy, probe __adminFetch straight from the sandbox with
introspect.adminFetch — the agent-facing twin of the platform proxy:
// in run_code:
async () => {
const res = await introspect.adminFetch("/forms");
// → { ok, facet, status, statusText, headers, body, truncated }
return { status: res.status, libs: res.headers["x-admin-libs"], head: res.body.slice(0, 200) };
}
The arguments are the standard new Request(input, init) pair plus an optional
third facet argument: pass init ({ method, headers, body }) to exercise a
form POST, and facet to hit a preview. A bare path like /forms works — the
host is ignored; only the path + query reach your hook. The probe runs under a
synthetic owner identity — there's no owner session in the sandbox — so it's a
true dry run of what the owner would see. A deploy with no __adminFetch comes
back { ok: false, code: "UNSUPPORTED" }; an unknown facet, NOT_DEPLOYED.
Picking media (the media selector)
A file <input type=file> cannot be bridged out of the frame (above), and you
should never push image/video bytes through a form anyway — the platform's rule is
bytes go to storage, your data stores only a path string. So instead of an upload
input, render a media-picker trigger and let the platform return a path:
<input type="text" name="hero_image" value="${current}" readonly>
<button type="button"
data-unfolder-media="hero_image" <!-- the field the path is written into -->
data-visibility="public" <!-- public | private | any (default public) -->
data-accept="image/*" <!-- optional MIME filter (like <input accept>) -->
data-multiple> <!-- present → multi-select (a gallery) -->
Choose…
</button>
<img data-unfolder-media-preview="hero_image" src="${heroPreviewSrc}" /> <!-- live preview -->
Clicking the button opens the platform's media picker (the same Media view you
see in the admin, as a modal — pick existing files in a tree with checkboxes/radio,
or upload new ones right there). On confirm, the platform writes the chosen
path string into input[name="hero_image"] and fires input/change; with
data-multiple the paths are newline-joined (split on \n). Then submit your
form normally — the path rides the text bridge. No bytes ever cross the frame.
Preview an already-saved value yourself. The
data-unfolder-media-previewelement is filled by the platform only when you pick — it does not back-fill on page load. So when you render the view with a stored value, set the initialsrcyourself, and remember a bare private path has no URL — it must be signed:const heroPreviewSrc = current ? `/${current}` : ""; // public: bare space-host path const docPreviewSrc = saved ? await env.MEDIA.sign(saved) : ""; // private: sign it (env.MEDIA)Leave
srcempty (omit it) when there's no value, so an empty field shows no broken image. A subsequent pick overwrites whatever you rendered.
- Public picks return a plain path (
hero/cover.png); render it directly —<img src="/hero/cover.png">— and it's served straight from storage. - Private picks (
data-visibility="private", or a_…path) return a path with no plain URL — but the picker also hands the optionaldata-unfolder-media-preview="<field>"element a signed preview URL, so a private pick previews in-frame just like a public one. To render it on your own page, mint a signed, time-limitedmedia.url("_hero/cover.png", { expiresIn })(the supervisor serves it — no worker code; good for a shareable link). For per-identity gating, decide in your worker and delegate serving with a signed URL — never proxy the bytes:
See media for the signed-URL model +// a request handler for the stored path, e.g. "/_hero/cover.png" if (!(await isAuthorized(request))) return new Response("Members only", { status: 401 }); const signed = await env.MEDIA.sign("_hero/cover.png", { expiresIn: 600 }); // strip the leading slash return Response.redirect(signed, 302);env.MEDIA.sign. Usedata-visibility="public"(the default) whenever the file can be served straight from its URL — it's the zero-code path.
Notes
- Don't expose
/query/rawpublicly. The upstream package's@Browsable()decorator wrapsfetchand would serve queries on your public host. Use the private RPC hooks above instead — that's what the platform calls. __querymust be present and return the right shape — two distinct failure modes:- Missing (a customized
Appthat dropped it, or a space scaffolded before the query engine was inlined) → the Database console +introspect.queryshow the "redeploy to enable" empty state. Redeploy from the current scaffold, or paste the snippet above. - Wrong shape (e.g. an older
__querythat returns a plain array instead of{ ok, columns, rows, meta }) → the console +introspectfail withUNSUPPORTED("__queryreturned an unexpected shape…"). Paste the snippet above verbatim — the vendoredBrowsableHandlerproduces the exact shape — and redeploy. There is no in-platform compatibility shim; this snippet is the whole contract.
- Missing (a customized
- Power users: because
__querymirrors the standard browsable/query/rawshape, you can point a compatible desktop client at a token-gated endpoint of your own for a full SQL editor — without the platform hosting any third-party UI.