# 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//`. 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: ` 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`).