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 a401. - A native
WWW-AuthenticateBasic-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 — includingintrospect.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/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 shows the "Database console not enabled" empty state, andintrospect.queryreturns{ ok: false, code: "UNSUPPORTED", error }. 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.