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.

  1. __query powers the Database console (/<slug>/database) — read/run SQL against your running site's SQLite (read-only by default). Scaffolded for free (below).
  2. __adminFetch / __adminManifest power 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 fetch and __adminFetch share 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.role come from the platform's verified session — there's no header to spoof. The mere fact __adminFetch was 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 the X-Admin-Libs response header. A strict CSP blocks every other external script. You can fetch() your own admin endpoints (see below); third-party network calls are still blocked.
  • Links & forms = ordinary HTML, and they just work. Prefix every href/action with ctx.adminBase and write plain <a href> / <form method="post">. The view runs in a sandboxed, opaque-origin iframe (it can't carry the session cookie itself, and connect-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 a 303 to an adminBase-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, or up-submit / up-target to a form, and the platform routes Unpoly's request through the same cookie-authed bridge — so you get fragment swaps without a full reload. Plain fetch("…", …) to your own adminBase-relative endpoints works too (it's transparently proxied; the response comes back as a normal Response). 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 under ctx.adminBase.)
  • #hash links & media links behave naturally. Same-page #anchor links 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 inline style="…" is rebased to your space's media host just like an <img src>, so an uploaded face just works:
    <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>
    
    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 (_…) 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 __adminManifest render in the parent admin sidebar, so they DO use Unpoly: up-follow up-target="#admin-main" (the platform also stamps up-history so 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 __adminFetch shows 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-preview element 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 initial src yourself, 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 src empty (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 optional data-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-limited media.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:
    // 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);
    
    See media for the signed-URL model + env.MEDIA.sign. Use data-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/raw publicly. The upstream package's @Browsable() decorator wraps fetch and would serve queries on your public host. Use the private RPC hooks above instead — that's what the platform calls.
  • __query must be present and return the right shape — two distinct failure modes:
    • Missing (a customized App that dropped it, or a space scaffolded before the query engine was inlined) → the Database console + introspect.query show the "redeploy to enable" empty state. Redeploy from the current scaffold, or paste the snippet above.
    • Wrong shape (e.g. an older __query that returns a plain array instead of { ok, columns, rows, meta }) → the console + introspect fail with UNSUPPORTED ("__query returned an unexpected shape…"). Paste the snippet above verbatim — the vendored BrowsableHandler produces the exact shape — and redeploy. There is no in-platform compatibility shim; this snippet is the whole contract.
  • Power users: because __query mirrors the standard browsable /query/raw shape, 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.