# Backend Plugin System

## Overview

Backend plugins run as **WebAssembly (WASM) modules** inside the `services/api` Go process. The host uses [wazero](https://github.com/tetratelabs/wazero) — a pure-Go, zero-CGo WASM runtime — to load and execute plugin modules in a sandboxed environment.

Each plugin:
- Is a single `.wasm` binary compiled from Go (via TinyGo), Rust, AssemblyScript, or any other language with a WASM target.
- Declares the host functions it needs in its `plugin.json` manifest (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.

## Host Function Bridge

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.

### Protobuf Message Transport

```
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](sdk-reference.md)) hides this transport entirely behind idiomatic Go or Rust function calls.

### Available Host Functions

All host functions are imported from the `paca` module namespace.

#### Database Access

| 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.

#### Plugin-Owned Storage

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. |

#### Core Data Access (Read-Only, Scoped)

| 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. |

#### Event System

| 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. |

#### HTTP Response (used inside route handlers)

| 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). |

#### Logging

| 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. |

### Capability Permissions

Each host function group maps to a permission in `plugin.json`:

```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).

## Plugin Manifest (`plugin.json`)

```json
{
  "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": "requirePermissions",
            "scope": "project",
            "permissions": ["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}`.

### Route Middleware Policy

Each backend route can declare a host-enforced middleware chain in `backend.routes[].middlewares`.

Supported middleware names:
- `authn`
- `optionalAuthn`
- `requireFreshPassword`
- `requireJWTAuth`
- `requirePermissions`

`requirePermissions` options:
- `scope`: `global` or `project` (default: `global`)
- `projectParam`: route param name for project scope (default: `projectId`)
- `permissions`: required permission keys (for example `projects.read`, `tasks.write`)

If `middlewares` is omitted, the host applies the backward-compatible default policy:
- `optionalAuthn`
- `requireFreshPassword`
- `requirePermissions` with project scope and `projects.read`

For legacy manifests, `backend.routes[].public: true` is still supported and means "no host auth middleware" for that route.

## Plugin Runtime Lifecycle

### Startup

1. At `services/api` startup, the host reads the list of enabled plugins from the `plugins` table.
2. For each plugin, it loads the `.wasm` binary from the plugin store (local disk path or object storage URL).
3. A `wazero` module instance is created per plugin with its declared host function imports.
4. The host calls the plugin's exported `Init()` function, passing a `PluginContext` proto with the plugin's ID, granted permissions, and config.
5. The plugin registers its route handlers by calling `paca.http_register_route` (or the SDK equivalent) during `Init()`.
6. Route registrations are applied to the Gin router under the plugin's namespace.

### Request Handling

When an HTTP request matches a plugin-registered route:
1. Gin invokes the host's plugin dispatch handler.
2. The host serialises the request context (method, path params, body) into a proto and writes it to shared memory.
3. The host calls the plugin's exported `HandleRequest(route_id)` function.
4. The plugin reads the request, executes its logic (calling host functions as needed), and calls `paca.http_respond` with the response.
5. The host reads the response from shared memory and writes it to the `gin.Context`.

### Event Delivery

1. When a subscribed core event fires (e.g., `task.deleted`), the host serialises the event payload.
2. The host calls the plugin's exported `HandleEvent(event_id)` function in the plugin's WASM instance.
3. The plugin processes the event (e.g., deleting orphan BDD scenarios when a task is deleted).

### Shutdown / Cleanup

The host calls the plugin's `Shutdown()` export before unloading. Plugins should flush any buffered state and release resources.

## Plugin-Owned Database Migrations

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 `down` migrations if provided).

## Resource Limits

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 |

Limits are configurable in the server config under `plugins.limits`.

## Plugin Storage Location

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:
```yaml
plugins:
  store: local          # or "s3"
  local_path: ./plugins/dist
  s3_bucket: paca-plugins
  cdn_allowlist:
    - https://plugins.paca.app
    - https://cdn.example.com
```

## Security Considerations

- 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.
- 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.
