A directory publishes two indexes, one per kind: /plugins/index.json and /themes/index.json, each a JSON array with one entry per approved repository, plus a detail document per entry at /plugins/<name>.json or /themes/<name>.json. goblog.live hosts the public ones; every goblog's Admin → Plugins and Admin → Themes read the index at its plugin_directory_url and theme_directory_url settings, which default to goblog.live's. Any goblog can host its own directory with the same shapes — see Running a private directory.
The types behind these documents are IndexEntry, DetailDoc and ReleaseDoc in plugins/directory/registry/build.go; their JSON tags are the contract, and both the directory and the installers are compiled from the same definitions.
index.json
The array is sorted by name; the listings sort it by stars before showing it. An entry describes the latest vX.Y.Z release of one repository, and it is the whole of what an installer needs: the installers never read the detail document.
| Field | Type | Plugin | Theme |
|---|---|---|---|
name |
string | The manifest's name: ^[a-z0-9-]+$, unique per kind. Becomes the module's file name under plugins/wasm/. |
Same rule; becomes the directory under themes/installed/ and the theme setting. |
display_name |
string | From the manifest; shown in listings. | Same. |
description |
string | From the manifest. | Same. |
version |
string | The release tag without v, e.g. 2.0.0. Must equal what the module's identity export reports. |
The tag without v. |
author |
string | From the manifest. | Same. |
license |
string | The manifest's SPDX identifier. | Same. |
source_url |
string | https://github.com/<owner>/<repo>. |
Same. |
download_url |
string | The release asset named by the manifest's entry (plugin.wasm), as GitHub's browser download URL: …/releases/download/vX.Y.Z/plugin.wasm. |
GitHub's archive of the tag: …/archive/refs/tags/vX.Y.Z.zip. |
sha256 |
string | Hex SHA-256 of the asset's bytes. | The content hash of the extracted templates/ and static/ files (below), not of the zip. |
min_goblog_version |
string | From the manifest; an installer refuses the entry when the running goblog is older. Development builds are treated as compatible. | Same; at least 0.5.0. |
install_type |
string | wasm. The plugin installer only installs entries with this value. |
theme. The theme installer only installs entries with this value. |
runtime |
string | wasm. |
Empty. |
allowed_hosts |
array of strings | The manifest's list, never null: [] when the plugin makes no requests. The installer writes it next to the module and enforces it; Admin → Plugins shows it as "Talks to: …" before Install. |
Always []. |
released_at |
string | The release's publish time, RFC 3339 in UTC. | Same. |
detail_url |
string | Absolute URL of the detail document, <site_url>/plugins/<name>.json, or a bare path when the directory has no site_url set. |
<site_url>/themes/<name>.json. |
stars |
number | The repository's GitHub star count at the last refresh; 0 when GitHub could not be asked. Listings sort by it. |
Same. |
kind |
string | plugin. Entries built by a directory older than the field carry ""; the plugin installer keys on install_type and does not look at it. |
theme. The theme installer requires it. |
screenshot_url |
string | Absent (omitempty). |
The raw GitHub URL of screenshot.png (or .jpg) at the tag: https://raw.githubusercontent.com/<owner>/<repo>/vX.Y.Z/screenshot.png. |
The two entries below are copied from goblog.live's indexes on the day this page was written, with one edit: hello's live entry may still say "kind": "" because it was built before kind existed, and the value the code writes today is "plugin".
A plugin — goblog-plugin-hello v2.0.0, from /plugins/index.json:
{
"name": "hello",
"display_name": "Hello",
"description": "Appends a configurable greeting to the footer of every page. The reference goblog WebAssembly plugin.",
"version": "2.0.0",
"author": "Jason Ernst",
"license": "Apache-2.0",
"source_url": "https://github.com/goblogplatform/goblog-plugin-hello",
"download_url": "https://github.com/goblogplatform/goblog-plugin-hello/releases/download/v2.0.0/plugin.wasm",
"sha256": "f04d6cc971b9008d61c8ee99dc1f59d5a4c6d35bd5d5e40e92f33806d0218e3b",
"min_goblog_version": "0.2.9",
"install_type": "wasm",
"runtime": "wasm",
"allowed_hosts": [],
"released_at": "2026-09-19T22:09:16Z",
"detail_url": "https://www.goblog.live/plugins/hello.json",
"stars": 0,
"kind": "plugin"
}
A theme — goblog-theme-forest v1.0.0, from /themes/index.json:
{
"name": "forest",
"display_name": "Forest",
"description": "Misty greens, a soft serif and a full-bleed forest backdrop — goblog's forest theme.",
"version": "1.0.0",
"author": "Jason Ernst",
"license": "Apache-2.0",
"source_url": "https://github.com/goblogplatform/goblog-theme-forest",
"download_url": "https://github.com/goblogplatform/goblog-theme-forest/archive/refs/tags/v1.0.0.zip",
"sha256": "d62d893981605ce2baa51fe1c2acd1ed2e3f148b23d5f6e1a14ee63d275ae95b",
"min_goblog_version": "0.5.0",
"install_type": "theme",
"runtime": "",
"allowed_hosts": [],
"released_at": "2026-09-20T18:07:28Z",
"detail_url": "https://www.goblog.live/themes/forest.json",
"stars": 0,
"kind": "theme",
"screenshot_url": "https://raw.githubusercontent.com/goblogplatform/goblog-theme-forest/v1.0.0/screenshot.png"
}
An index is served with Content-Type: application/json, indented with two spaces, and without HTML escaping. When a directory has nothing approved for a kind, the body is [].
<name>.json
The detail document is the index entry with three more fields — the rendered documentation and the release history. It backs the directory's page for an entry (/plugins/<name>), published as JSON for tooling and mirrors; goblog's installer does not read it.
| Field | Type | Meaning |
|---|---|---|
| every index field | As in index.json, same values. |
|
readme_html |
string | README.md at the release tag, rendered by GitHub's markdown API. |
changelog_html |
string | CHANGELOG.md at the tag, rendered the same way; "" when the repository has none. |
releases |
array | Every published, non-prerelease vX.Y.Z release, as ReleaseDoc objects, newest first by publish date. |
Each element of releases:
| Field | Type | Meaning |
|---|---|---|
version |
string | The tag without v. |
released_at |
string | Publish time, RFC 3339 in UTC. |
notes_html |
string | The release body, rendered by GitHub's markdown API. |
url |
string | The release's page on GitHub. |
The HTML fields are rendered and sanitized by GitHub — the same renderer that draws a README on github.com — and are the only fields the directory's pages insert unescaped. Each of readme_html and changelog_html is at most 1 MiB, or the build is refused. Hello's document, abridged:
{
"name": "hello",
"version": "2.0.0",
"…": "the rest of the index entry",
"readme_html": "<h1 dir=\"auto\">goblog-plugin-hello</h1>\n<p dir=\"auto\">The reference …",
"changelog_html": "<h1 dir=\"auto\">Changelog</h1>\n<h2 dir=\"auto\">2.0.0</h2>\n<ul dir=\"auto\">…",
"releases": [
{
"version": "2.0.0",
"released_at": "2026-09-19T22:09:16Z",
"notes_html": "<p dir=\"auto\">Rewritten as a WebAssembly plugin — sandboxed, installable from Admin → Plugins in goblog 0.2.9 or newer. …</p>",
"url": "https://github.com/goblogplatform/goblog-plugin-hello/releases/tag/v2.0.0"
},
{
"version": "1.0.0",
"released_at": "2026-09-15T01:14:50Z",
"notes_html": "<p dir=\"auto\">Initial release: configurable footer greeting with <code class=\"notranslate\">enabled</code> and <code class=\"notranslate\">message</code> settings.</p>",
"url": "https://github.com/goblogplatform/goblog-plugin-hello/releases/tag/v1.0.0"
}
]
}
How installers verify
Both installers start from the cached index entry and refuse before touching the disk. Before any download: the name must match ^[a-z0-9-]+$, install_type must be the right one, and min_goblog_version must be satisfied; a name that is already installed is refused too. download_url must be https — plain http is accepted only to loopback — and a redirect to anything else aborts the download.
A plugin (plugin/installer):
- Download
download_url, at most 16 MiB. - SHA-256 the bytes and compare with
sha256, case-insensitively. A mismatch isErrChecksum: "the downloaded file does not match the checksum in the directory index; the index may be stale, refresh and try again". - Load the module in the sandbox with the entry's
allowed_hosts; a module that fails to load is refused. - Call its
identityexport: the name must equalnameand the version must equalversion, or the module is closed and the install refused. - Only then write the sidecar (the host list) and the module to
plugins/wasm/<name>.wasm, atomically, register it, and run itson_init. If registration fails the files are removed again.
A theme (theme/installer):
-
Download
download_url, at most 16 MiB. -
Unpack the zip in memory under the archive rules in Publishing a theme, keeping
templates/andstatic/and stripping the single wrapper folder GitHub puts everything under. -
Compute the content hash and compare with
sha256; a mismatch is refused with the theme'sErrChecksumtext. The hash is SHA-256 over every kept file in sorted path order, each contributing its path, a NUL byte, its length in decimal, a NUL byte, and its bytes:for path in sorted(files): h.write(path); h.write("\0"); h.write(str(len(bytes))); h.write("\0"); h.write(bytes) sha256 = hex(h.sum())Hashing the extracted files rather than the zip is deliberate: GitHub does not promise a tag's archive is byte-stable, and a re-compressed zip would otherwise break every install of an unchanged release.
-
Parse every
templates/*.htmlon top of goblog's shared and default templates — the same check the directory ran — and refuse the theme if any file fails or is over 256 KiB. -
Only then write the files into a temporary directory under the installed root and rename it into place as
themes/installed/<name>/, together with agoblog-theme.jsonthat records the version. An update sets the old directory aside as<name>.prevuntil the swap succeeds and restores it if it does not.
Nothing is written on a refused install, and an index that has gone stale — the directory rebuilt a release after you fetched the index — shows up as a checksum error rather than a half-installed plugin; press ↻ and try again.
Running a private directory
The directory is a plugin compiled into every goblog, off by default. To host one:
- Admin → Plugins, open Settings next to Plugin Directory, and switch it on (
enabledtotrue). Your site now serves/plugins,/themes, bothindex.jsonfiles, the detail documents and the submit forms.refresh_minutes(default 360, minimum 15) is how often listed repositories are re-checked for new releases; an optionalgithub_tokenwith no scopes lifts GitHub's API limit from 60 to 5000 requests an hour. - Set
site_urlto the address other goblogs will reach you at.detail_urlis built from it; without it the field is a bare path. - Admin → Plugins → Directory — the tab appears once the plugin is enabled — and Add repository with a GitHub URL or
owner/name. The repository is validated exactly as a public submission would be (the checks are in Publishing a plugin and Publishing a theme) and listed at once, without the review step; entries submitted through/plugins/submitor/themes/submitland under Pending review on the same tab for you to approve or reject. Rebuild re-checks one entry now; Delist removes it. - On each goblog that should install from it, set
plugin_directory_urltohttps://<your site>/plugins/index.jsonandtheme_directory_urltohttps://<your site>/themes/index.jsonunder Admin → Settings → Directories. The installer notices the changed URL on the next visit to Admin → Plugins or Admin → Themes and fetches the new index at once. To go back, set them tohttps://www.goblog.live/plugins/index.jsonandhttps://www.goblog.live/themes/index.jsonagain.
Those two URLs are a trust decision: the index they point at chooses what code Install offers, and the checks above verify that the download matches the index, not that the index is honest. A private directory is only as trustworthy as the person adding repositories to it, and a plugin from any directory is trusted with the hosts its entry lists.
Caching
Both indexes and every detail document are served with Cache-Control: public, max-age=300, so a proxy or CDN in front of a directory may hold them for five minutes. The directory itself rebuilds the index in memory whenever an entry is added, approved, rebuilt or delisted, and after every scheduled refresh.
An installer keeps one in-memory copy of its index per kind. It fetches it the first time it is needed — retrying at most every 30 seconds while nothing has been fetched yet, so a directory outage does not turn every admin page load into a hung request — and re-fetches when the copy is older than one hour the next time Admin → Plugins or Admin → Themes loads or an install looks an entry up, or immediately when the URL setting has changed. There is no background timer. The ↻ button next to the search box on either page forces a fetch now. A fetch that fails keeps the previous copy and logs the error; the admin page only reports an index error when it has never fetched anything, so a stale list looks like a current one — the index_fetched_at timestamp in the status API is what changes. Index responses are read up to 8 MiB, with a 10 second timeout.