# 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 `plugins.json` 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. **Part of the contract for the future; no HTTP endpoint exposes it in v1.** Implement it anyway so the connector is ready. - **`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 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"}, }, } ``` `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. (Not yet called in v1.) 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 no built-in connectors yet, so `builtin/builtin.go` has an > empty import block. The **external** kind below needs no rebuild and is the > easier place to start. --- ## 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 `plugins.json` (gitignored; override the path with `PLUGINS_FILE`). Enabling calls `Init`; disabling calls `Shutdown`. - **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 `plugins.json`. 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): - **Invocation API** — an endpoint to call `Invoke` from clients, with a normalized request/response envelope and a provider→internal mapper. - **Resilience** — retry/backoff, circuit breaker, per-plugin latency/error metrics. - **Per-tenant credentials** — config keyed by org/user so users connect their own accounts. - **Audit logging** of plugin access. Until the invocation API lands, `Invoke` is dormant — plugins are discoverable, configurable, and health-checked, but not yet callable over HTTP.