Backend plugins run as WebAssembly (WASM) modules inside the services/api Go process. The host uses wazero — a pure-Go, zero-CGo WASM runtime — to load and execute plugin modules in a sandboxed environment.
Each plugin:
- Is a single
.wasmbinary compiled from Go (via TinyGo), Rust, AssemblyScript, or any other language with a WASM target. - Declares the host functions it needs in its
plugin.jsonmanifest (capability-based permission model). - Communicates with the host exclusively through a well-defined host function bridge — it cannot read host memory, access the filesystem, or make raw network calls.
The host function bridge exposes a set of typed functions that WASM modules can import from the env module. All host functions use a linear memory protocol: the plugin writes a request payload (Protocol Buffers) into shared linear memory and calls the host function with a pointer and length. The host reads the request, executes it, writes the response back into a plugin-provided buffer, and returns the response length.
Plugin memory layout for a host call:
[req_ptr, req_len] → plugin writes serialised proto request
host function called → host reads request, executes, writes response
[resp_ptr, resp_len] → plugin reads serialised proto response
The SDK (see sdk-reference.md) hides this transport entirely behind idiomatic Go or Rust function calls.
All host functions are imported from the paca module namespace.
| Function | Description |
|---|---|
paca.db_query(req_ptr, req_len, resp_ptr, max_len) → resp_len |
Execute a typed read query. Results are filtered to the plugin's authorised scope. |
paca.db_exec(req_ptr, req_len, resp_ptr, max_len) → resp_len |
Execute a write (insert/update/delete) against plugin-owned tables or allowed shared tables. |
paca.db_tx_begin() → tx_id |
Begin a database transaction. |
paca.db_tx_commit(tx_id) |
Commit a transaction. |
paca.db_tx_rollback(tx_id) |
Roll back a transaction. |
Plugins cannot issue arbitrary SQL. They use typed query builders exposed by the SDK, which map to pre-approved query templates validated by the host bridge.
Each plugin gets a dedicated namespace in the database (plugin_data_{pluginId}) for its own tables. Schema migrations for plugin-owned tables are declared in the plugin manifest and run by the host during plugin installation/upgrade using a restricted migration runner.
| Function | Description |
|---|---|
paca.storage_get(key_ptr, key_len, resp_ptr, max_len) → resp_len |
Get a value from the plugin's key-value store (backed by a PostgreSQL JSONB column). |
paca.storage_set(key_ptr, key_len, val_ptr, val_len) → ok |
Set a value in the plugin's key-value store. |
paca.storage_delete(key_ptr, key_len) → ok |
Delete a key from the plugin's key-value store. |
Backed by the host's shared Valkey/Redis instance, keyed under a per-plugin namespace so plugins can't read or overwrite each other's entries. Unlike Plugin-Owned Storage above, entries can expire and there is no durability guarantee — treat the cache as recomputable data only. If the host has no cache backend configured, cache_get always misses and cache_set/cache_delete are no-ops, so plugins should always be able to fall back to recomputing a value on a miss.
| Function | Description |
|---|---|
paca.cache_get(key_ptr, key_len, value_ptr_ptr, value_len_ptr) |
Get a value from the plugin's cache. A zero-length response means a miss (never cached, expired, or no cache backend configured). |
paca.cache_set(key_ptr, key_len, val_ptr, val_len, ttl_seconds) → ok |
Set a value in the plugin's cache with the given TTL in seconds. ttl_seconds = 0 stores the value with no expiry; negative values are rejected (ok = 0). |
paca.cache_delete(key_ptr, key_len) → ok |
Delete a key from the plugin's cache. |
| Function | Description |
|---|---|
paca.tasks_list(req_ptr, req_len, resp_ptr, max_len) → resp_len |
List tasks for the authorised project(s) with filter options. |
paca.task_get(req_ptr, req_len, resp_ptr, max_len) → resp_len |
Get a single task by ID (project scope enforced). |
paca.project_get(req_ptr, req_len, resp_ptr, max_len) → resp_len |
Get project metadata. |
paca.members_list(req_ptr, req_len, resp_ptr, max_len) → resp_len |
List project members. |
| Function | Description |
|---|---|
paca.event_subscribe(event_ptr, event_len) → ok |
Subscribe to a named core domain event (e.g., task.created, sprint.closed). Events are delivered at plugin startup based on manifest declarations. |
paca.event_emit(req_ptr, req_len) → ok |
Emit a plugin-namespaced event (e.g., com.paca.bdd.scenario_created) to the Valkey Stream for consumption by the realtime service or other listeners. |
| Function | Description |
|---|---|
paca.http_respond(req_ptr, req_len) |
Write an HTTP response from within a registered route handler. Includes status code, headers, and body. |
paca.http_request_body(resp_ptr, max_len) → resp_len |
Read the incoming HTTP request body inside a route handler. |
paca.http_request_headers(resp_ptr, max_len) → resp_len |
Read incoming request headers. |
paca.http_caller_identity(resp_ptr, max_len) → resp_len |
Read the authenticated caller's user ID and project membership (JWT claims, validated by the host). |
| Function | Description |
|---|---|
paca.log(level, msg_ptr, msg_len) |
Write a structured log entry at the given level. Entries are tagged with the plugin ID. |
Each host function group maps to a permission in plugin.json:
{
"permissions": [
"db:read:tasks",
"db:read:members",
"db:write:plugin_data",
"http:register_routes",
"events:subscribe:task.*",
"events:emit"
]
}The host validates the requested permissions at install time against the allowlist for the installation tier (self-hosted installations can grant all permissions; future SaaS tiers may restrict).
{
"id": "com.paca.bdd",
"name": "BDD Scenarios",
"version": "1.0.0",
"description": "Adds Given/When/Then acceptance criteria to tasks.",
"author": "Paca Core Team",
"license": "MIT",
"minCoreVersion": "0.5.0",
"frontend": {
"remoteEntryUrl": "https://plugins.paca.app/bdd/1.0.0/remoteEntry.js",
"extensionPoints": [
{
"point": "task.detail.section",
"component": "TaskDetailSection",
"label": "BDD Scenarios",
"order": 10
},
{
"point": "project.settings.tab",
"component": "ProjectSettingsTab",
"label": "BDD",
"order": 20
}
]
},
"backend": {
"wasm": "bdd.wasm",
"permissions": [
"db:read:tasks",
"db:write:plugin_data",
"http:register_routes",
"events:subscribe:task.deleted",
"events:emit"
],
"routes": [
{
"method": "GET",
"path": "/tasks/:taskId/bdd-scenarios"
},
{
"method": "POST",
"path": "/tasks/:taskId/bdd-scenarios",
"middlewares": [
{ "name": "authn" },
{ "name": "requireFreshPassword" },
{
"name": "requireActions",
"scope": "project",
"actions": ["tasks:write"]
}
]
},
{
"method": "POST",
"path": "/webhook",
"middlewares": [
{ "name": "optionalAuthn" }
]
}
],
"migrations": [
"0001_create_bdd_scenarios.sql"
],
"eventSubscriptions": [
"task.deleted"
]
}
}All routes declared under backend.routes are automatically mounted at /api/v1/plugins/{pluginId}/projects/:projectId/{path}.
A manifest may declare minCoreVersion as a strict X.Y.Z (or vX.Y.Z) semver string — the lowest Paca (host) build the plugin is compatible with. The host enforces this on every path that persists a manifest:
POST /api/v1/admin/plugins(direct install)POST /api/v1/admin/plugins/marketplace/install(marketplace install)PATCH /api/v1/admin/plugins/:pluginIdandPOST /api/v1/admin/plugins/:pluginId/upgrade(manifest updates/upgrades)
If the running build's version is older than minCoreVersion, the request fails with PLUGIN_INCOMPATIBLE_HOST_VERSION before any migrations run or the new manifest is loaded into the runtime, and nothing is persisted. Omitting the field means the plugin has no minimum. A host not built from a tagged release (PACA_VERSION unset, reporting "dev") is treated as unconstrained, since there's no release version to compare against.
The error response's error field is an English-only message meant for logs/CLI use. Clients that need to render a localized message should instead use error_details, an {"error_details": {...}} object included alongside error_code/error for this code, carrying plugin_id, required_version, and host_version as plain (non-localized) strings to interpolate into their own translated copy.
Each backend route can declare a host-enforced middleware chain in backend.routes[].middlewares.
Supported middleware names:
authnoptionalAuthnrequireFreshPasswordrequireJWTAuthrequireActions
requireActions options:
scope:globalorproject(default:global)projectParam: route param name for project scope (default:projectId)actions: IAM actions the caller must ALL be allowed, each of the form<domain>:<verb>— built-in (for exampleprojects:read,tasks:write) or plugin-declared (for exampletime_logging:manage_all). The same migration converted everycustomPermissions[].keyandrequiredPermissionto an action.
Each action is decided by the host's IAM engine on a resource:
projectscope, with the project id present in the path:project/<projectId>for a built-in action, andproject/<projectId>/plugin/<plugin row id>for one of the plugin's own actions, so a role can grant a plugin's permission in one project only. Grants onproject/<projectId>/*cover both.- otherwise: the action's platform root (
users:*→user/*,roles:*→role/*,plugins:*→plugin/*,settings:*→settings,settings.sso:*→sso,agents:*→agent/*,projects:*→project), or — for a plugin's own or any other non-platform action — the plugin's own resourceplugin/<plugin row id>(covered by grants onplugin/*or*).
A plugin declares the permissions it adds in customPermissions. Each key is
an IAM action of the form <namespace>:<verb>, where the namespace is the last
segment of the plugin id with - replaced by _ (com.paca.time-logging →
time_logging:manage_all). Declared actions are known to the host while the
plugin is installed: role policies may name them, the role editor lists them,
and requireActions/paca.permission_check can check them. scope decides which role editor lists the permission: project (default), global, or both for an action checked inside a project and across projects (plugins that used both must be reinstalled to pick up the manifest). Two plugins cannot
declare the same action. A nav item's or extension point's requiredPermission is
an action too, built-in or the plugin's own, and must have the form
<domain>:<verb>: a malformed value (such as settings.write) would lock the
page for every user, so install/update rejects it. The web app hides nav items,
settings tabs, extension points, slots and view types the caller lacks
requiredPermission for (inside a project, the project's effective actions
decide); the backend still enforces every route.
The legacy requirePermissions middleware (dotted permission keys) is no
longer supported: installing or updating a manifest that declares it fails
with "requirePermissions is no longer supported; use requireActions with IAM
actions", and a stored manifest still declaring it fails closed (500) at
request time. Migration 000065_plugin_manifest_actions.sql rewrote every
installed manifest (requirePermissions → requireActions, each key's last
. replaced by :, e.g. time_logging.manage_all →
time_logging:manage_all).
Legacy permissions warning. The migration rewrites the stored manifest, but a plugin package on disk may still ship a plugin.json that declares requirePermissions. GET /api/v1/plugins flags such an installed plugin with "legacy_permissions": true (only for plugins with a backend). Administrators who can write plugins (plugins:write) then see a dismissible banner on the home page listing the affected plugins, and a "legacy permissions" badge on the plugin's card in the marketplace panel. The plugin keeps working with its converted manifest, but reinstalling or updating that build is rejected until its author ships a version that uses requireActions.
The backend's paca.permission_check host function (SDK:
ctx.Permissions().Check(action)) takes an IAM action too and is decided
on the same resource rule, with the request's project (if any) as the project.
If middlewares is omitted, the host applies the authenticated-only default
policy:
authnrequireFreshPassword
(no action check — declare requireActions explicitly; an anonymous caller is
refused, any authenticated caller is admitted).
For legacy manifests, backend.routes[].public: true is still supported and means "no host auth middleware" for that route.
- At
services/apistartup, the host reads the list of enabled plugins from thepluginstable. - For each plugin, it loads the
.wasmbinary from the plugin store (local disk path or object storage URL). - A
wazeromodule instance is created per plugin with its declared host function imports. - The host calls the plugin's exported
Init()function, passing aPluginContextproto with the plugin's ID, granted permissions, and config. - The plugin registers its route handlers by calling
paca.http_register_route(or the SDK equivalent) duringInit(). - Route registrations are applied to the Gin router under the plugin's namespace.
When an HTTP request matches a plugin-registered route:
- Gin invokes the host's plugin dispatch handler.
- The host serialises the request context (method, path params, body) into a proto and writes it to shared memory.
- The host calls the plugin's exported
HandleRequest(route_id)function. - The plugin reads the request, executes its logic (calling host functions as needed), and calls
paca.http_respondwith the response. - The host reads the response from shared memory and writes it to the
gin.Context.
- When a subscribed core event fires (e.g.,
task.deleted), the host serialises the event payload. - The host calls the plugin's exported
HandleEvent(event_id)function in the plugin's WASM instance. - The plugin processes the event (e.g., deleting orphan BDD scenarios when a task is deleted).
The host calls the plugin's Shutdown() export before unloading. Plugins should flush any buffered state and release resources.
Plugins declare SQL migration files in their bundle. Migrations are run by the host using a restricted migration runner that only allows DDL within the plugin_data_{pluginId} schema. The runner uses the same sequential migration pattern as the core (000001_name.sql, 000002_name.sql, ...).
Migrations are run:
- On first plugin installation.
- On plugin upgrade, running only new migration files.
- On plugin uninstall (optional
downmigrations if provided).
Each WASM module instance is constrained by wazero's resource controls:
| Resource | Default Limit |
|---|---|
| Memory | 64 MB per module instance |
| CPU (via instruction counting) | Configurable; default prevents runaway loops |
| Concurrent goroutines | N/A — WASM is single-threaded per instance |
| DB connections | Plugins use the host's connection pool; max 5 concurrent queries per plugin |
| Inbound HTTP request body | 10 MB per request to a plugin route; larger requests are rejected with 413 before reaching the plugin |
Limits are configurable via environment variables (see services/api/.env.example):
| Variable | Default | Controls |
|---|---|---|
PLUGINS_MAX_CALL_DURATION |
5s |
Max time for a single plugin function call |
PLUGINS_MAX_MEMORY_PAGES |
1024 (64 MiB) |
Max WASM linear-memory pages per module instance |
PLUGINS_MAX_REQUEST_BODY_BYTES |
1048576 (1 MiB) |
Max inbound HTTP request body / event payload size |
The host reads plugin binaries from a configured path:
- Local (development/self-hosted):
./plugins/dist/{pluginId}/directory. - Object storage (production): S3-compatible bucket, same credentials as attachment storage.
The server config entry:
plugins:
store: local # or "s3"
local_path: ./plugins/dist
s3_bucket: paca-plugins
cdn_allowlist:
- https://plugins.paca.app
- https://cdn.example.compaca.fetch checks the request URL against backend.allowedOutboundDomains. Matching is exact and case-insensitive; the literal entry "*" allows any host. Whatever the manifest says, the URL must be https and the host must not resolve to a private or internal address. The address check runs before the request, and again at dial time on the address actually connected to, so a DNS answer that changes in between (rebinding) is still refused.
Redirects are followed. Only the dial-time address check applies to them: a redirect target does not have to be in the manifest and may use http, as long as it does not resolve to a private or internal address.
A self-hosted service on the same LAN or VPN as Paca, such as a GitLab at gitlab.internal → 10.0.0.5, is blocked by that rule. An operator can open it with PLUGIN_FETCH_PRIVATE_HOSTS on the API service, a comma-separated list of hostnames. It is empty by default.
PLUGIN_FETCH_PRIVATE_HOSTS=gitlab.internal,git.corp.exampleA request to a private address then needs all of the following:
- The hostname is in
PLUGIN_FETCH_PRIVATE_HOSTS. Entries are exact ASCII names: IP literals, wildcards, URLs,host:portvalues, names with a leading or trailing dot and non-ASCII names are ignored, with a warning in the API log at startup. Write an internationalized name in its punycode form (xn--...). - The plugin's manifest names the host in
allowedOutboundDomains."*"does not count, because plugins that use it call user-supplied URLs. A plugin whose manifest does not name a listed host cannot call it at all, whatever it resolves to, and the dialer checks the manifest again on new connections. So neither a DNS answer that changes after the first check nor a pooled connection opened for another plugin opens a listed host to a"*"plugin. - The URL is
https, and the certificate is trusted by the API container. For an internal CA, mount a bundle holding the system roots and your CA into theapiservice and setSSL_CERT_FILEto its path, in a Compose override or the Helm chart's pod spec. - The address is in
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16orfc00::/7.
A redirect to a listed host must meet the same conditions; redirects to other hosts follow the rule above. Hostnames are compared in the IDNA (punycode) form that is actually dialed, so a Unicode spelling of a listed name counts as that name. The connection stays pinned to the checked address. Everything else stays blocked even for a listed host: loopback, link-local (including 169.254.169.254 and other cloud metadata endpoints), the CGNAT range 100.64.0.0/10, and IPv6 transition addresses, whatever IPv4 address they carry.
Security: listing a host lets the plugins that name it reach that internal service, and the API sends them its responses. List only hosts those plugins are meant to talk to, keep the list short, and treat a change to it like a firewall change. The list also trusts whoever controls the DNS records of those names: they can point a listed name at any other private address, so use names from DNS you control. Tailscale addresses sit in the CGNAT range and are not reachable this way.
- WASM modules have no filesystem access (wazero's WASI filesystem is not mounted).
- WASM modules have no raw network access; all external communication must go through host functions.
- All DB host functions enforce project-scope isolation — a plugin enabled for project A cannot query project B data.
- Plugin WASM binaries should be signed by the publisher. The host verifies the signature against a public key stored in the plugin manifest before loading. (v1: signature check is enforced for third-party plugins; first-party plugins bypass in dev mode.)
- WASM execution errors are caught by wazero and converted to 500 responses; the host never panics due to a plugin crash.
- Inbound request bodies are capped (see Resource Limits) before being handed to a plugin. Plugin SDK allocators are simple bump allocators with no bounds checking of their own, so an oversized payload would otherwise advance a plugin's allocator cursor past the end of its memory; the host always resets a plugin's allocator after each call, including on failure, so a single bad request cannot leave an instance unable to serve later ones.
- Secrets (e.g., API keys the plugin needs) are stored encrypted in the host's secrets store and passed to the plugin through
paca.config_get— never baked into the WASM binary.