# Building DriverVault Plugins

A **plugin** integrates an external third-party service (vehicle data, parts
catalogs, notifications, file storage, …) behind one uniform contract. There are
two kinds:

| Kind | Written as | Added by | Rebuild? | Use when |
|---|---|---|---|---|
| **built-in** | Go code in this repo | a rebuild | yes | first-party, high-trust, type-safe connectors |
| **external** | any HTTP service | registering a URL at runtime | **no** | third-party / less-trusted / independently deployed |

Both implement the same behaviour; the server treats them identically. Enable
state and per-plugin config persist to PocketBase and load on boot. Every plugin
is managed by a **superadmin** from the panel (`/`) or the `/api/admin/plugins*`
API.

---

## The contract

All plugins satisfy the Go interface in [`plugin.go`](plugin.go):

```go
type Plugin interface {
    Descriptor() Descriptor
    Init(ctx context.Context, config map[string]string) error
    HealthCheck(ctx context.Context) Health
    Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error)
    Shutdown(ctx context.Context) error
}
```

- **`Descriptor`** — static metadata (name, provider, version, capabilities,
  auth type, config fields). Drives the panel UI.
- **`Init`** — called with the resolved config (secrets included) whenever the
  plugin is enabled or its config changes. Prepare clients/tokens here.
- **`HealthCheck`** — probe the upstream and classify: `Health{Status, LatencyMs, Detail}`
  where `Status` is `StatusOK` / `StatusDegraded` / `StatusDown`.
- **`Invoke`** — run a named capability. There is no *generic* invoke endpoint yet,
  but this is live: the integration routes and the vehicle-provider layer call it
  through `Manager.InvokeWith` / `InvokeBatchWith`, so implement it properly. It
  must be safe for concurrent use — the live instance is shared across requests,
  and `InvokeBatchWith` runs a batch of actions in parallel on one instance.
- **`Shutdown`** — release resources.

### Descriptor & config fields

```go
Descriptor{
    Name:         "acme",              // unique id, [a-z0-9-]
    Provider:     "ACME Corp",         // human label
    Version:      "1.0.0",
    Kind:         plugins.KindBuiltin, // or KindExternal
    Category:     plugins.CategoryAPIsExternal, // which panel tab it lands under
    Capabilities: []plugins.Capability{
        {ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."},
    },
    AuthType:     plugins.AuthAPIKey,  // None | APIKey | Basic | OAuth2 | Webhook (metadata only)
    ConfigFields: []plugins.ConfigField{
        {Key: "apiKey", Label: "API key", Type: "password", Required: true, Secret: true,
         Help: "Found under ACME → Settings → API."},
        {Key: "region", Label: "Region", Type: "text", Help: "e.g. eu-west-1"},
    },
}
```

`Category` groups the plugin under a tab in the panel's Plugins card — one of
`CategoryVehicles`, `CategoryChargers`, `CategoryNotifications`,
`CategoryAPIsExternal`, `CategoryDrivesExternal` or `CategoryDrivesLocal`. An
empty or unrecognised value is shown under *Other APIs*, so a plugin never
disappears. Adding a **new** category means adding the constant in
[`plugin.go`](plugin.go), the tab order in `panel/src/components/PluginsCard.vue`,
and its label in all three `panel/src/i18n/*.json`.

`ConfigField.Type` is `"text"`, `"password"`, or `"number"` (form input hint).
Set **`Secret: true`** for credentials — the server never echoes them back in
clear; the panel shows a mask (`••••••••`), and on save a field left at the mask
keeps its stored value (so operators don't retype secrets). **`Required: true`**
fields must be non-empty before the plugin can be enabled.

---

## Building a built-in plugin

1. **Create a package** under `internal/plugins/builtin/<name>/`.
2. **Implement `Plugin`** and **register it in `init()`**.
3. **Blank-import** your package from [`builtin/builtin.go`](builtin/builtin.go).
4. **Rebuild** the server.

### Minimal example — `internal/plugins/builtin/acme/acme.go`

```go
package acme

import (
    "context"
    "encoding/json"
    "net/http"
    "strings"
    "time"

    "drivervault/apiserver/internal/plugins"
)

func init() {
    plugins.Register("acme", func() plugins.Plugin { return &Plugin{} })
}

type Plugin struct {
    apiKey string
    region string
    client *http.Client
}

func (p *Plugin) Descriptor() plugins.Descriptor {
    return plugins.Descriptor{
        Name: "acme", Provider: "ACME Corp", Version: "1.0.0",
        Kind: plugins.KindBuiltin, AuthType: plugins.AuthAPIKey,
        Capabilities: []plugins.Capability{
        {ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."},
    },
        ConfigFields: []plugins.ConfigField{
            {Key: "apiKey", Label: "API key", Type: "password", Required: true, Secret: true},
            {Key: "region", Label: "Region", Type: "text"},
        },
    }
}

func (p *Plugin) Init(_ context.Context, config map[string]string) error {
    p.apiKey = strings.TrimSpace(config["apiKey"])
    p.region = strings.TrimSpace(config["region"])
    p.client = &http.Client{Timeout: 10 * time.Second}
    return nil
}

func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
    start := time.Now()
    req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.acme.example/ping", nil)
    req.Header.Set("Authorization", "Bearer "+p.apiKey)
    resp, err := p.client.Do(req)
    lat := time.Since(start).Milliseconds()
    if err != nil {
        return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
    }
    defer resp.Body.Close()
    if resp.StatusCode >= 200 && resp.StatusCode < 300 {
        return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: "reachable"}
    }
    return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: "HTTP " + resp.Status}
}

func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
    // Implement your capabilities; return normalized JSON. Must be safe for
    // concurrent use — one instance serves many requests.
    return json.RawMessage(`{"ok":true}`), nil
}

func (p *Plugin) Shutdown(context.Context) error { return nil }
```

### Register it for compilation — `internal/plugins/builtin/builtin.go`

```go
import (
    _ "drivervault/apiserver/internal/plugins/builtin/acme"
)
```

### Rebuild

```powershell
cd "API Server"
go build -o bin/api-server.exe ./cmd/server
```

Restart the server. The plugin appears in the panel's **Plugins** card,
**disabled** by default.

> DriverVault ships four built-in connectors today — `toyota` (Toyota Connected /
> MyToyota, read-only vehicle data), `anker-solix` (Anker Solix V1 EV charger),
> `greencell` (Greencell HabuDen EV charger, read over the owner's MQTT broker
> rather than a cloud API) and `apprise` (notifications, through an apprise-api
> gateway the operator runs) — all blank-imported from `builtin/builtin.go`. The
> **external** kind below needs no rebuild and is the easier place to start a new
> one.

---

## Building an external plugin (no rebuild)

An external plugin is **any HTTP service** you host (Go recommended, but any
language works). You register its base URL at runtime; the server drives it over
a tiny JSON contract.

### The HTTP contract

| Method & path | Purpose | Response |
|---|---|---|
| `GET {base}/manifest` | describe the plugin (optional) | `{provider, version, capabilities, authType, configFields}` |
| `GET {base}/health` | health probe (required) | `2xx` = healthy; optional body `{status, detail}` |
| `POST {base}/invoke` | run a capability (optional; unused in v1) | `{action, params}` in → arbitrary JSON out |

Health rules the server applies: transport error or `5xx` → `down`; `2xx` → `ok`;
anything else → `degraded`. An explicit `{"status":"ok|degraded|down","detail":"…"}`
body overrides the status-code heuristic. Bodies are size-limited (health 64 KiB,
manifest 1 MiB).

### Minimal example — a Go plugin service

```go
package main

import (
    "encoding/json"
    "net/http"
)

func main() {
    http.HandleFunc("/manifest", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(map[string]any{
            "provider":     "ACME Cloud",
            "version":      "2.1.0",
            "authType":     "apikey",
            "capabilities": []map[string]any{
                {"id": "widgets.list", "method": "GET", "endpoint": "/widgets", "description": "List widgets."},
            }, // a plain []string{"widgets.list"} is also accepted
            "configFields": []map[string]any{
                {"key": "apiKey", "label": "API key", "type": "password", "required": true, "secret": true},
            },
        })
    })
    http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(map[string]any{"status": "ok", "detail": "acme cloud reachable"})
    })
    http.HandleFunc("/invoke", func(w http.ResponseWriter, r *http.Request) {
        var in struct {
            Action string          `json:"action"`
            Params json.RawMessage `json:"params"`
        }
        json.NewDecoder(r.Body).Decode(&in)
        json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": in.Action})
    })
    http.ListenAndServe(":9100", nil)
}
```

### Register it

From the panel's **Plugins** card → *Register external plugin* (name + base URL),
or via the API:

```bash
curl -X POST http://localhost:8080/api/admin/plugins \
  -H "Authorization: $SUPERADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"acme-cloud","baseURL":"http://127.0.0.1:9100","provider":"ACME Cloud"}'
```

It starts **disabled**; enable it and run a health check from the panel. Because
it runs as its own process/container, an external plugin is also the
**sandboxing** path for less-trusted integrations.

---

## Lifecycle, config & secrets

- **Enable/disable** and **config** persist to PocketBase: the `app_settings`
  record keyed `global`, in its `pluginSettings` field. That is the top (L1)
  layer of the integration cascade, stored the same way the org (L2) and user
  (L3) layers are. Enabling calls `Init`; disabling calls `Shutdown`.
- **Before the settings have been read** — a cold database, or a service account
  still to be configured — every `/api/admin/plugins*` endpoint answers **503**
  and no write is accepted. The server never treats an unreachable database as
  "no plugins configured", so an outage cannot quietly erase the settings; it
  retries in the background until the read succeeds.
- **If the `app_settings` collection is missing** — an upgrade on a stack that
  runs with `PB_BOOTSTRAP=false`, so the on-boot schema pass never created it —
  the server creates that one collection itself and reads again. A missing
  collection is told apart from a database that is merely unreachable, because
  the remedy differs: creating collections is the wrong reflex during an outage.
- **Secrets** (`Secret: true` fields) are returned masked. On save, a field still
  equal to the mask keeps its stored value; send a new value to change it, or an
  empty string to clear it.
- **Required** fields are validated when enabling — enabling fails with a clear
  error if one is blank.
- If `Init` fails (e.g. bad credentials), the state is still saved and the API
  returns the plugin plus a `warning`; fix the config and re-save.

---

## Managing plugins (superadmin API)

All endpoints require a superadmin bearer token (`Authorization: <token>` from
`POST /api/auth/login`). See the panel's **Management API** reference too.

| Method | Path | Body | Purpose |
|---|---|---|---|
| `GET` | `/api/admin/plugins` | — | list all plugins + state + last health |
| `GET` | `/api/admin/plugins/{name}` | — | one plugin |
| `PUT` | `/api/admin/plugins/{name}` | `{enabled?, config?}` | enable/disable + configure |
| `POST` | `/api/admin/plugins` | `{name, baseURL, provider?}` | register an external plugin |
| `DELETE` | `/api/admin/plugins/{name}` | — | remove an external plugin (built-ins only disable) |
| `POST` | `/api/admin/plugins/{name}/health` | — | run a health check now |

---

## Testing your plugin

1. Build + restart (built-in) or start your service (external) and register it.
2. `GET /api/admin/plugins` → confirm your descriptor, config fields, capabilities.
3. `PUT /api/admin/plugins/{name} {"enabled":true, "config":{…}}` → enable with config.
4. `POST /api/admin/plugins/{name}/health` → confirm the live probe classifies correctly.
5. Restart the server → confirm state reloads from PocketBase.

A Go unit test can exercise a built-in directly:

```go
p := &acme.Plugin{}
_ = p.Init(context.Background(), map[string]string{"apiKey": "test"})
if h := p.HealthCheck(context.Background()); h.Status == "" {
    t.Fatal("expected a health status")
}
```

---

## Not yet implemented (roadmap)

The contract is shaped for these; see [`doc.go`](doc.go):

- **Generic invocation API** — an endpoint to call *any* plugin's `Invoke` from a
  client, with a normalized request/response envelope. The purpose-built callers
  exist (`Manager.InvokeWith` / `InvokeBatchWith`, driven by the integration routes
  and `internal/api/vehicleproviders.go`); what is missing is the generic route.
- **Resilience** — retry/backoff, circuit breaker, per-plugin latency/error metrics.
- **Per-tenant credentials _for arbitrary plugins_** — the three per-user
  connectors already have them, through the hand-written
  `/api/integrations/toyota`, `/api/integrations/anker-solix` and
  `/api/integrations/greencell` routes and their **superadmin → org admin →
  user** config cascade. Each is a near-copy of the last, which is the argument
  for the generic version: per-org/per-user config keyed off `ConfigFields`, so a
  newly registered plugin gets the same treatment without new endpoints.
  `apprise` deliberately sits outside that cascade — the notification gateway is
  infrastructure the operator runs, not an account a driver owns — so its config
  is global only, and `baseUrl` is `Required` because nothing further down can
  supply it.
- **Audit logging** of plugin access. (Charger *control* commands are already
  audited to the `control_audit` collection; this is the wider plugin case.)

Until the generic invocation API lands, `Invoke` is reachable only through the
purpose-built routes: the two integrations' own endpoints, and the vehicle-provider
layer that builds a car from a manufacturer account and feeds the car's provider
tab (see `internal/api/vehicleproviders.go`).
