Plugins
Plugins extend HappyView with WebAssembly modules sourced from the plugin registry or any URL serving a manifest.json. Most endpoints take a registry project or a plugin manifest URL and load (or reload) the plugin in place — no restart needed. Encrypted plugin secrets require TOKEN_ENCRYPTION_KEY to be configured.
const TOKEN = "hv_..."; // your API keyconst headers = { Authorization: `Bearer ${TOKEN}` };const TOKEN = "hv_..."; // your API keyconst headers = { Authorization: `Bearer ${TOKEN}` };let token = "hv_..."; // your API keytoken := "hv_..." // your API key# All examples assume $TOKEN is an API key (hv_...)AUTH="Authorization: Bearer $TOKEN"List installed plugins
Section titled “List installed plugins”GET /admin/pluginsRequires plugins:read. Returns every loaded plugin with its source, required secrets, configuration status, capability report, and any pending updates from the official registry cache. Pass ?type=library|interpreter|auth to list only plugins of one plugin_type; omit it to list every type.
interface RequiredSecret { key: string; name: string; description: string;}
interface CapabilityEntry { name: string; // e.g. "database:read" risk: "low" | "medium" | "high" | "critical"; description: string;}
interface CapabilityReport { declared: CapabilityEntry[]; required_by_imports: CapabilityEntry[]; undeclared: string[]; // non-empty only for a plugin the loader would refuse today}
interface PluginDependency { id: string; version: string; // semver requirement, e.g. ">=1.0.0"}
interface PluginSummary { id: string; name: string; version: string; source: string; url: string; sha256: string | null; enabled: boolean; auth_type: string; required_secrets: RequiredSecret[]; secrets_configured: boolean; loaded_at: string | null; update_available: boolean; latest_version?: string; // where the followed channel points latest_version_installable?: boolean; latest_state?: "active" | "deprecated" | "unavailable" | "retired"; pending_releases: ReleaseEntry[]; registry: PluginRegistryStatus | null; plugin_type: "auth" | "library" | "interpreter"; namespace: string | null; // library plugins only dependencies: PluginDependency[]; allowed_hosts: string[]; capabilities: CapabilityReport;}
interface ReleaseEntry { version: string; name: string; published_at: string | null; body: string | null; // the changelog, as markdown state: "active" | "deprecated" | "unavailable" | "retired"; reason: string | null; suspect: boolean; installable: boolean; availability: "pending" | "available" | "deletedAtSource"; version_uri: string; manifest_cid: string; package_cid: string; manifest_blob_cid: string;}
interface PluginRegistryStatus { did: string | null; // the publisher; null for an unresolved URL install name: string | null; // the project name channel: "latest" | "beta" | "alpha" | null; // null when pinned to a version version_uri: string | null; installed_state: string | null; // the installed version's lifecycle state installed_reason: string | null; // the publisher's reason for it channel_state: string | null; // the state of the version the channel points at unresolved: boolean; // a URL install no registry project matched}
interface PluginsResponse { encryption_configured: boolean; plugins: PluginSummary[];}
const response = await fetch("http://127.0.0.1:3000/admin/plugins?type=library", { headers,});const data: PluginsResponse = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/plugins", { headers,});const data = await response.json();let response = client .get("http://127.0.0.1:3000/admin/plugins") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/plugins", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)curl http://127.0.0.1:3000/admin/plugins -H "$AUTH"Response: 200 OK
{ "encryption_configured": true, "plugins": [ { "id": "steam", "name": "Steam", "version": "1.2.0", "source": "url", "url": "https://example.com/plugins/steam/manifest.json", "sha256": null, "enabled": true, "auth_type": "openid", "required_secrets": [ { "key": "PLUGIN_STEAM_API_KEY", "name": "Steam Web API Key", "description": "Get your API key at steamcommunity.com/dev/apikey" } ], "secrets_configured": true, "loaded_at": null, "update_available": false, "latest_version": "1.2.0", "pending_releases": [], "plugin_type": "auth", "namespace": null, "dependencies": [], "allowed_hosts": [], "capabilities": { "declared": [], "required_by_imports": [ { "name": "network:request:unrestricted", "risk": "high", "description": "Make HTTP requests to any host on the internet, including internal services this server can reach." } ], "undeclared": [] } } ]}secrets_configured is true if the plugin has no required secrets, or if a row exists for it in happyview_plugin_configs. update_available, latest_version, latest_version_installable, latest_state and pending_releases come from the cached registry listing — call POST /admin/plugins/{id}/check-update to refresh them. For a registry plugin, an update is offered when the channel it follows points at a version other than the installed one and that version may be installed. A plugin installed from a URL is offered nothing until it is attached to a registry project (see Installing from a URL); once attached, it follows its channel but is offered only a newer version until its first install from the registry. A version may be installed when its state is active or deprecated, the registry holds its files, and it isn’t flagged suspect. When the channel moves to a version that may not be installed, latest_version_installable is false and latest_state says why; nothing is offered. pending_releases lists the installable releases on the channel’s track (latest carries releases, beta and alpha their own prereleases) newer than the installed version, up to the channel’s. The cache keeps every project an installed registry plugin follows, so a plugin whose project shares its name with another publisher’s in the registry is still judged by its own.
registry is set for a plugin installed from a registry project. installed_state is the installed version’s lifecycle state: a plugin whose version its publisher later retires keeps running, and installed_state is "retired", with installed_reason when the publisher gave one.
plugin_type, namespace, dependencies, and allowed_hosts mirror the manifest fields (see Plugin Types). capabilities is the same report POST /admin/plugins/preview returns: declared is what the manifest lists, required_by_imports is the least-privilege set the module’s imports need (computed from the imports alone), and undeclared is anything required but not declared. A plugin is granted exactly declared; undeclared is non-empty only for a plugin the loader refuses.
Preview a plugin before installing
Section titled “Preview a plugin before installing”POST /admin/plugins/previewRequires plugins:create. Fetches the manifest and downloads the WASM without installing it, so the capability report and sha256 are what an install would use. A 400 Bad Request carries the loader’s own message: an undeclared import, an unsupported api_version, or a bad manifest.
The body names the plugin by url, or by registry project:
{ "registry": { "did": "did:plc:mo7vq4jbioxwafy5zkcc3eux", "name": "happyview-lua", "channel": "latest" } }channel is latest, beta or alpha and defaults to latest; pass version instead to name one version. The server resolves the version, refuses one that may not be installed with 400 Bad Request naming its state and the publisher’s reason, fetches the manifest and module from the registry and checks each against the CID the version names. Bytes that don’t match are refused with a message naming both CIDs. A registry preview also returns registry: { did, name, channel, version, version_uri, state }.
interface PluginPreview { id: string; name: string; version: string; description: string; icon_url: string; auth_type: string; required_secrets: RequiredSecret[]; manifest_url: string; wasm_url: string; plugin_type: "auth" | "library" | "interpreter"; namespace: string | null; dependencies: PluginDependency[]; allowed_hosts: string[]; capabilities: CapabilityReport; sha256: string; // sha256 of the downloaded WASM binary}
const response = await fetch("http://127.0.0.1:3000/admin/plugins/preview", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://example.com/plugins/steam/manifest.json", }),});const data: PluginPreview = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/plugins/preview", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://example.com/plugins/steam/manifest.json", }),});const data = await response.json();let response = client .post("http://127.0.0.1:3000/admin/plugins/preview") .bearer_auth(token) .json(&serde_json::json!({ "url": "https://example.com/plugins/steam/manifest.json" })) .send() .await?;let data: serde_json::Value = response.json().await?;body := bytes.NewBufferString(`{ "url": "https://example.com/plugins/steam/manifest.json" }`)req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/plugins/preview", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)curl -X POST http://127.0.0.1:3000/admin/plugins/preview \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/plugins/steam/manifest.json" }'Response: 200 OK
{ "id": "steam", "name": "Steam", "version": "1.2.0", "description": "Import your Steam game library and playtime data.", "icon_url": "https://example.com/steam-icon.png", "auth_type": "openid", "required_secrets": [ { "key": "PLUGIN_STEAM_API_KEY", "name": "Steam Web API Key", "description": "..." } ], "manifest_url": "https://example.com/plugins/steam/manifest.json", "wasm_url": "https://example.com/plugins/steam/steam.wasm", "plugin_type": "auth", "namespace": null, "dependencies": [], "allowed_hosts": ["api.steampowered.com"], "capabilities": { "declared": [ { "name": "secrets:read", "risk": "low", "description": "Read the secrets you configure for this plugin." }, { "name": "network:request", "risk": "medium", "description": "Make HTTP requests, only to the hosts it lists. Redirects are not followed." } ], "required_by_imports": [ { "name": "secrets:read", "risk": "low", "description": "Read the secrets you configure for this plugin." }, { "name": "network:request", "risk": "medium", "description": "Make HTTP requests, only to the hosts it lists. Redirects are not followed." } ], "undeclared": [] }, "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85"}A plugin is granted exactly what it declares. required_by_imports is computed from the module’s imports alone, so it can be narrower than declared: the http plugin declares network:request:unrestricted, while its imports only require network:request. undeclared lists imports the manifest does not cover. This endpoint runs the same validation as installation, so undeclared is always empty in a 200; an undeclared import is refused with the 400 below.
Returns 400 Bad Request if the manifest can’t be fetched or parsed, if the WASM can’t be downloaded or fails its import analysis, if api_version is missing or below "2", or if the manifest doesn’t declare a capability its imports require.
Install a plugin
Section titled “Install a plugin”POST /admin/pluginsRequires plugins:create. Fetches the manifest, downloads the WASM, registers the plugin, and persists it. Name the plugin by url, or by registry project as in the preview; exactly one.
const response = await fetch("http://127.0.0.1:3000/admin/plugins", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://example.com/plugins/steam/manifest.json", sha256: "abc123...", accepted_capabilities: ["network:request:unrestricted"], }),});const data: PluginSummary = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/plugins", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://example.com/plugins/steam/manifest.json", sha256: "abc123...", }),});const data = await response.json();let response = client .post("http://127.0.0.1:3000/admin/plugins") .bearer_auth(token) .json(&serde_json::json!({ "url": "https://example.com/plugins/steam/manifest.json", "sha256": "abc123..." })) .send() .await?;let data: serde_json::Value = response.json().await?;body := bytes.NewBufferString(`{ "url": "https://example.com/plugins/steam/manifest.json", "sha256": "abc123..."}`)req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/plugins", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)curl -X POST http://127.0.0.1:3000/admin/plugins \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/plugins/steam/manifest.json", "sha256": "abc123..." }'| Field | Type | Required | Description |
|---|---|---|---|
url | string | one of | URL to the plugin’s manifest.json |
registry | object | one of | { did, name, channel? , version?, expected_version? }: a registry project and the channel to follow (latest by default) or the version to pin. expected_version is the version a preview showed: when the project now resolves to another, the install is refused with 409 Conflict. The install records the project, the channel, the version and the CIDs its files were checked against, and the project’s DID as its publisher |
sha256 | string | no | Optional sha256 of the WASM binary, with url only. If provided, install fails when the downloaded hash mismatches |
accepted_capabilities | string[] | no | Capability names the operator consents to grant (e.g. from a consent dialog built on POST /admin/plugins/preview’s capabilities report). When present, every capability the plugin would actually be granted must be covered, or the install is refused |
Response: 200 OK returning the same PluginSummary shape as the list endpoint. secrets_configured will be false if the plugin requires any secrets — call PUT /admin/plugins/{id}/secrets to configure them before the plugin can run.
400 Bad Request on any of:
- The manifest can’t be fetched/parsed, the WASM can’t be downloaded,
sha256doesn’t match, orapi_versionis unsupported. - Both or neither of
urlandregistrywere given,sha256was given withregistry(a registry plugin is checked against its CIDs), orregistrynames both a channel and a version, or a channel other thanlatest,betaoralpha. - The registry version may not be installed, its files don’t match their CIDs, or its manifest names a plugin other than the project.
- The plugin’s
dependenciesaren’t satisfied — a dependency isn’t installed, an installed dependency’s version doesn’t satisfy the requirement, installing would create a dependency cycle, or the manifest depends on itself. The error names the specific dependency graph problem. - The manifest doesn’t declare a capability its compiled imports require — the same check
previewsurfaces ascapabilities.undeclared. accepted_capabilitieswas provided but doesn’t cover every capability the plugin would be granted — the error lists the missing capability names.
A registry project or version that doesn’t exist is 404 Not Found; a registry that can’t be read is 502 Bad Gateway.
List official plugins
Section titled “List official plugins”GET /admin/plugins/officialRequires plugins:read. Returns the cached list of plugins from the official registry. The cache is refreshed periodically by the server; use POST /admin/plugins/{id}/check-update to force-refresh a single entry.
Response: 200 OK
{ "last_refreshed_at": "2026-04-13T11:00:00Z", "plugins": [ { "id": "steam", "publisher_did": "did:plc:mo7vq4jbioxwafy5zkcc3eux", "official": true, "name": "Steam", "description": "Import your Steam game library and playtime data.", "icon_url": "https://api.happyproto.at/xrpc/at.happyproto.registry.getArtifact?cid=bafkrei...", "plugin_type": "auth", "latest_version": "1.2.0", "channels": { "latest": { "version": "1.2.0", "state": "active", "installable": true, "availability": "available" } }, "lifecycle_state": "active", "releases": [], "sections": {} } ]}An entry names a project, not a URL: install it by its publisher_did and id through registry.
Remove a plugin
Section titled “Remove a plugin”DELETE /admin/plugins/{id}Requires plugins:delete. Unregisters the plugin from the runtime and deletes its row from the happyview_plugins table. Secrets stay in happyview_plugin_configs, so they’re reused if you reinstall.
If another installed library plugin still depends on this one, the removal is refused:
curl -X DELETE http://127.0.0.1:3000/admin/plugins/db -H "$AUTH"Response: 409 Conflict
{ "error": "plugin 'db' is required by: record, xrpc", "dependents": ["record", "xrpc"]}Pass ?force=true to cascade the removal instead — every dependent (and anything that in turn depends on them) is removed along with the target, dependents-first:
curl -X DELETE "http://127.0.0.1:3000/admin/plugins/db?force=true" -H "$AUTH"Response: 200 OK with every plugin id actually removed, in dependents-first order:
{ "removed": ["xrpc", "record", "db"] }When exactly one plugin is removed (the common case: no dependents, or force=true on a plugin nothing depends on), the response is 204 No Content instead, with no body. Returns 404 Not Found if no plugin with that id is loaded.
Reload a plugin
Section titled “Reload a plugin”POST /admin/plugins/{id}/reloadRequires plugins:create. Re-fetches the plugin from its current source and re-registers it. Useful after publishing a new version of a plugin you host yourself.
A plugin installed from the registry reloads from the registry. With no body it fetches the version it has again, by CID. To update it, switch channel or pin a version, pass registry:
{ "registry": { "channel": "beta" } }{ "registry": {} } installs the current version of the channel it follows (or its pinned version again); channel switches to that channel’s version and follows it from then on; version pins one version. Add expected_version, the version a preview showed, and a channel that has since moved elsewhere is refused with 409 Conflict instead of installing a version nobody reviewed. The project is the one it was installed from, and its publisher must be the one the install recorded, or the reload is refused with 409 Conflict. A version that may not be installed is refused with 400 Bad Request, and the installed version keeps running. A registry plugin can’t be reloaded from a url; remove it and add it from the URL instead. Adding a plugin from a URL over a registry plugin makes it a URL install, with no registry project.
The body is optional. To point the plugin at a new URL, pass:
{ "url": "https://example.com/plugins/steam/manifest.json" }When a new URL is provided, the stored sha256 is cleared (the new version has its own hash). File-based plugins cannot be reloaded via this endpoint and return 400 Bad Request.
A plugin that recorded a publisher stays with it, and no URL can be checked against a publisher, so a reload of such a plugin to a new URL is refused with 409 Conflict and the installed version keeps running. Update it from the registry, or remove it and add it again from the URL. A plugin installed from a URL records no publisher and may be pointed at a new one.
A reloaded version can import host functions the operator never consented to. accepted_capabilities guards a reload the same way it guards POST /admin/plugins: when present, every capability the reloaded plugin would be granted must be listed, or the reload is refused with 400 Bad Request naming the missing ones. The check runs before the installed version is removed, so a refusal leaves it running.
{ "url": "https://example.com/plugins/steam/manifest.json", "accepted_capabilities": ["network:request:unrestricted"]}Response: 200 OK with the refreshed PluginSummary.
Check for plugin updates
Section titled “Check for plugin updates”POST /admin/plugins/{id}/check-updateRequires plugins:create. Reads one plugin’s project from the registry now, then returns the updated PluginSummary. A registry plugin’s own project is read, even when another publisher’s project holds its name in the registry. An update is offered only from the publisher the install recorded; apply it with POST /admin/plugins/{id}/reload and { "registry": {} }.
Response: 200 OK with a PluginSummary.
Get plugin secrets
Section titled “Get plugin secrets”GET /admin/plugins/{id}/secretsRequires plugins:read. Returns the plugin’s configured secrets with values masked (last 4 characters shown for values longer than 8 characters, otherwise fully masked). Requires TOKEN_ENCRYPTION_KEY to be configured.
Response: 200 OK
{ "plugin_id": "steam", "secrets": { "PLUGIN_STEAM_API_KEY": "********ABCD" }}Update plugin secrets
Section titled “Update plugin secrets”PUT /admin/plugins/{id}/secretsRequires plugins:create. Encrypts the provided secret values with TOKEN_ENCRYPTION_KEY (AES-256-GCM) and upserts them into happyview_plugin_configs.
const response = await fetch("http://127.0.0.1:3000/admin/plugins/steam/secrets", { method: "PUT", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ secrets: { PLUGIN_STEAM_API_KEY: "your-new-api-key", }, }),});const response = await fetch("http://127.0.0.1:3000/admin/plugins/steam/secrets", { method: "PUT", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ secrets: { PLUGIN_STEAM_API_KEY: "your-new-api-key", }, }),});let response = client .put("http://127.0.0.1:3000/admin/plugins/steam/secrets") .bearer_auth(token) .json(&serde_json::json!({ "secrets": { "PLUGIN_STEAM_API_KEY": "your-new-api-key" } })) .send() .await?;body := bytes.NewBufferString(`{ "secrets": { "PLUGIN_STEAM_API_KEY": "your-new-api-key" }}`)req, _ := http.NewRequest("PUT", "http://127.0.0.1:3000/admin/plugins/steam/secrets", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)curl -X PUT http://127.0.0.1:3000/admin/plugins/steam/secrets \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "secrets": { "PLUGIN_STEAM_API_KEY": "your-new-api-key" } }'Special handling:
- Values starting with
********are treated as masked placeholders and the existing encrypted value is preserved (so you canGETthenPUTwithout re-typing every secret). - Empty string values are not stored — use them to clear a secret.
Response: 204 No Content
Get plugin allowed hosts
Section titled “Get plugin allowed hosts”GET /admin/plugins/{id}/allowed-hostsRequires plugins:read. Returns the hosts an operator has listed for a plugin declaring network:request:defined. This is separate from a plugin’s manifest allowed_hosts, which network:request:defined requires to be empty.
Response: 200 OK
{ "hosts": ["api.example.com"]}Update plugin allowed hosts
Section titled “Update plugin allowed hosts”PUT /admin/plugins/{id}/allowed-hostsRequires plugins:create. Replaces the plugin’s allowed hosts list. Entries are bare hostnames, optionally prefixed *. to allow subdomains — no scheme, path, port, or whitespace.
const response = await fetch("http://127.0.0.1:3000/admin/plugins/steam/allowed-hosts", { method: "PUT", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ hosts: ["api.steampowered.com"], }),});const response = await fetch("http://127.0.0.1:3000/admin/plugins/steam/allowed-hosts", { method: "PUT", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ hosts: ["api.steampowered.com"], }),});let response = client .put("http://127.0.0.1:3000/admin/plugins/steam/allowed-hosts") .bearer_auth(token) .json(&serde_json::json!({ "hosts": ["api.steampowered.com"] })) .send() .await?;body := bytes.NewBufferString(`{ "hosts": ["api.steampowered.com"]}`)req, _ := http.NewRequest("PUT", "http://127.0.0.1:3000/admin/plugins/steam/allowed-hosts", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)curl -X PUT http://127.0.0.1:3000/admin/plugins/steam/allowed-hosts \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "hosts": ["api.steampowered.com"] }'Returns 400 Bad Request for an invalid host entry, or if the plugin does not declare network:request:defined. Returns 404 Not Found for an unknown plugin.
Response: 200 OK
{ "hosts": ["api.steampowered.com"]}
