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.

One private RPC hook on your App Durable Object lets your deployed code extend the admin: __query powers the Database console (/<slug>/database) — read/run SQL against your running site's SQLite (read-only by default). It is a DO RPC method, not an HTTP route — the platform calls it after authenticating the owner/admin, and it is never reachable on your public host (<your-space-slug>.unfolder.space). Facet storage is isolated; there is no platform backdoor, so this hook is the only way in. Scaffolded for free (below).

Your own admin pages: build them behind /__

The platform admin covers files, media, members, settings, deploys, and the Database console. When you need an admin surface the platform doesn't ship — a content editor, a submissions inbox, a design-system gallery — build it as an ordinary page in your own App, on a path that starts with /__.

/__* paths are owner-gated at the edge, for free. Every request whose path starts with /__ is intercepted by the platform's manage gate before your worker runs: owners/admins pass, everyone else gets a plain 404 (the gate never reveals itself). So a route like /__manage, /__admin, or /__manage/design is private to the space's owners and admins with no auth code of your own.

// Ordinary routes — the /__ prefix is the only thing that makes them private.
app.get("/__manage", (c) => c.html(renderDashboard()))
app.get("/__manage/design", (c) => c.html(renderDesignGallery()))
app.post("/__manage/posts", async (c) => { /* write to ctx.storage.sql */ })

Do NOT put your own auth (Basic Auth, a bearer token, a password form) in front of a /__ path. It adds no protection — the edge gate already made the page owner-only — and it actively breaks the owner's own dashboard:

  • The dashboard frames a /__ place in an iframe using an auto-minted owner token that satisfies the platform gate. That token carries no credentials for your Basic Auth, so your own middleware answers the owner's preview with a 401.
  • A native WWW-Authenticate Basic-Auth prompt usually can't even be completed inside a cross-origin iframe, so the owner is simply locked out of the page you built for them.
  • A sitewide basicAuth("*") is worse: it 401s your public pages and every /__ page, so the whole site looks down and nothing is reachable — including introspect.fetch, which otherwise enters below the gate.

If you inherit a space that wraps its routes in its own auth, remove it and let the edge gate be the only gate for /__ paths. If an admin API endpoint performs writes, put it under /__ too (e.g. POST /__manage/save, not POST /api/admin/save) — otherwise dropping the old auth leaves the write endpoint public.

Register each admin page as a place so the owner can reach it — a /__ page has no public URL, so the dashboard's place frame is the way in:

// in run_code, after you publish
await place.add("/__manage", "Dashboard")
await place.add("/__manage/design", "Design system")

Verify with introspect.fetch (in run_code), which enters below the gate — a /__ page that serves for you there will serve for the owner in the dashboard:

await introspect.fetch("/__manage")          // → { status: 200, ... }

What your route receives: an ordinary request. The gate proves an owner/admin reached the page; it does not inject a user identity or role. For a single-owner space that's all you need. (If you ever must tell two admins apart, add that check inside the already-gated route — most spaces never do.)

Media picker: an admin page that needs to pick an image can borrow the platform's own media picker over window.postMessage instead of building an upload UI — see "Borrowing the platform's media picker from a place" in run-code.

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 "Database console not enabled" empty state ("publish from the current scaffold to enable it") — 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) };
		}
	}
}

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 shows the "Database console not enabled" empty state, and introspect.query returns { ok: false, code: "UNSUPPORTED", error }. 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.