Admin extensions
An admin extension adds screens for your site's content to the MicroHost admin UI (for example a table of posts with create, edit, publish and delete). You describe the screens declaratively; the data comes from private handlers in your own release. No HTML, CSS or script of yours runs in the admin UI: it renders a closed set of native elements. The full contract is in docs/design/admin-extensions-spec.md; the validator is crates/core/src/admin_extensions.rs. The working example is examples/blog.
Status. Implemented: validation, descriptor hashing, the enable flow, the signed private bridge and the admin UI pages. An extension declaration never grants capabilities; the private handlers use the same grants as the rest of the site. Declarations that fail validation are dropped with a warning instead of failing the deploy.
Pieces
| Piece | What it is |
|---|---|
| Declaration | The admin block of the manifest: extensions with queries, actions and pages. Written in microhost.json, or with defineExtension in microhost.config.js. |
| Private route | An exact path under /_admin/ that you declare as an ordinary manifest route and point to a handler script. The public router never serves /_admin. |
| Private handler | A script (convention: api/_admin/*.js) that verifies the call with verifyAdminRequest and answers with JSON. |
| Enablement | An administrator turns the extension on for the site, bound to the descriptor's hash. |
Declaration reference
defineExtension({
id: "blog", label: "Blog", icon: "book-open",
queries: [{ id: "list", route: "/_admin/posts", input: {...}, output: {...} }],
actions: [{ id: "publish", route: "/_admin/posts", input: {...} }],
pages: [{ id: "posts", label: "Posts", elements: [...] }],
})
Extension. id ([a-z][a-z0-9_-]{0,39}), label (at most 80 characters), optional icon, 1 to 8 pages, and queries plus actions together at most 32. At most 4 extensions per site. Icons come from a fixed list: file-text, list, plus, pencil, trash-2, check, x, book-open, calendar, tag.
Request (query or action). Fields: id, method, route, input, output, minRole, timeoutMs, write.
- Queries are
GET,write: false; actions arePOST,write: true. Anything else is invalid. Defaults for the missing keys are filled in bydefineExtension. routeis an exact/_admin/...path (lowercase letters, digits,/,_,-; at most 200 bytes), and the manifest must declare that route with a method list that includes the request's method. Several requests can share one route and are told apart byidentity.operation.minRole:viewer,editor,developerorowner. An action must require at leasteditor.timeoutMsis fixed at 5000.inputis an object of named parameters, at most 32. A parameter hastype(string,number,boolean,date) and optionallyrequired,minLength,maxLength(1 to 4096),minimum,maximum,enum(1 to 32 values). Names follow the id pattern, except the reserved namessortKey,sortDirandconfirmName.output:{ kind: "object" | "rows", fields, idField?, totalField? }.fieldshas at most 32 entries, each{ type, maxLength?, enum?, nullable? }withtypeone ofstring,number,boolean,date,status. Arowsoutput requiresidField(naming a declared field of typestring) andtotalField(the key of the total count in the response); anobjectoutput must have neither. Arowsresponse looks like{ "rows": [...], "total": 42 }.
Page. id, label (at most 80), optional icon, routeParams (at most 4 ids; they become URL parts of the page), elements (at most 24 per page including nested tab content).
Elements (closed set; unknown properties invalidate the extension):
| Type | Properties |
|---|---|
heading |
text, level (2 or 3), optional icon |
text |
text (at most 2,000 characters; plain text) |
empty-state |
text |
key-value |
query, input, items: [{ label, key }] (at most 16); the query must return an object |
table |
query (a rows query), input, columns (at most 12), rowActions (at most 6), pagination, sort: { key, direction } |
form |
fields (at most 16), optional prefillQuery (an object query), input, submit |
button |
action (an action reference) |
tabs |
tabs: [{ id, label, elements }] (at most 8) |
- A column is
{ key, label, type, ... }withtypetext,number,date,statusorinternal-link;statuscolumns taketones(neutral,success,warning,danger);internal-linktakespageandrouteParamto link to a page of the same extension;sortableis a boolean. - A form field is
{ id, type, label, ... }withtypetext,textarea,select,checkbox,numberordate, optionalrequired,minLength,maxLength,minimum,maximum,options: [{ value, label }](at most 32), andreadOnlyWhen(a condition; only fortextfields with aprefillQuery). - An action reference is
{ label, action, input, tone?, confirmation?, icon?, visibleWhen? }.actionis the id of a declared action,toneisprimary,secondaryordestructive, andconfirmation: { title, message, typedNameFrom }asks the user to type a name before the action runs (the typed value must equal theconfirmNameinput). - Bindings fill inputs:
{ from: "field" | "route" | "row" | "literal", key?, value? }(a form field, a page route parameter, a table row cell, or a constant). - A condition is
{ field, equals }or{ field, in: [up to 8 strings] }over an output field; use it forvisibleWhenandreadOnlyWhen. - References are checked: queries and actions must exist, a table must bind a
rowsquery, columns and items must name declared output fields, and column types must match field types. Labels are at most 120 characters. Everything is shown as text; there is no Markdown, HTML or user-supplied URL.
A complete real declaration is examples/blog/microhost.config.js with its page layouts in examples/blog/admin-pages.js.
The private handler
The control plane calls your handler through the private route with a signed request:
export default async function handle(request, ctx) {
const admin = verifyAdminRequest(request, ctx); // throws AdminRequestError on any failure
// admin: { userId, role, workspace, site, extension, name, operation, issuedAt }
const input = await request.json(); // the validated input object (also for GET)
if (admin.operation === "list") { /* ... */ }
if (admin.operation === "publish") {
if (admin.role === "viewer") return Response.json({ error: "forbidden" }, { status: 403 });
/* ... */
}
return Response.json({ ok: true });
}
verifyAdminRequest(request, ctx)is synchronous. The host verifies the HMAC, the exact method, route and body, the site and workspace binding, the clock skew, and a single-use nonce. The signing key never reaches JavaScript. On failure it throwsAdminRequestErrorwith codeinvalid_admin_request.ctx.adminexists only on these private calls.- The eight headers
x-mh-admin-user,-role,-workspace,-site,-operation,-timestamp,-nonce,-signatureare untrusted until the check succeeds. Never read identity from them directly. - The control plane already enforces
minRole; check the verified role again in handlers that write, and use bound SQL parameters for every value. - The request body is JSON of at most 32 KiB. Query parameters travel in the JSON body, not in a URL query string.
request.paramsis empty. - Respond with status 200 and
content-type: application/json(at most 256 KiB), matching the declaredoutput. For an action you may instead return 204 with an empty body, which counts as{ "ok": true }. A 4xx is reported to the caller as a rejection. Any other status, a wrong content type, or an oversize body is a failure. A call must finish within 5 seconds. - Rows: at most 500 per query response; the UI pages at 25 by default and 100 at most.
- Handler scripts under
api/_admin/can never be reached by public requests, even through the/api/<name>convention or an explicit route. - Keep shared SQL and validation in
lib/modules so public and private handlers can import them without sharing an entry point.
Enabling and descriptor hashes
An extension is off until an administrator enables it for the site. In the UI that is Website, Extensions; the API is PUT /admin/api/v1/sites/{id}/extensions/{ext} with { "enabled": true, "descriptorHash": "<hash>" } (needs developer-level code access). The descriptor hash is the SHA-256 of the canonical validated declaration.
- If the hash in the request is not the active release's current hash, the call is refused with 409
extension_stale. - When a new release changes the declaration, its hash changes and the extension shows as
needs_re_enable; calls fail with 409extension_staleuntil an administrator enables the new version. Releases whose declaration is semantically identical keep the same hash and stay enabled. - Viewers and editors only see extensions that are enabled. Managers also see disabled ones and the validation warnings.
- Calls:
GET /admin/api/v1/sites/{id}/extensionslists them;POST .../extensions/{ext}/queries/{name}and.../actions/{name}run one. Calls are throttled per user and site (a burst of 10, refilling 0.5 per second) and limited to 4 concurrent per site, and are audited.
Validation warnings you may see (stable codes, no free text): admin_invalid_schema, admin_schema_unsupported, admin_unknown_field, admin_limit_exceeded, admin_duplicate_id, admin_invalid_route, admin_invalid_reference, admin_invalid_element. A warning means that extension was dropped; the deploy still succeeded.