Mirror PilotVault's API Server layout and add the superadmin console,
plugin system, runtime PocketBase settings, and user/organization
management. The car domain (cars, service records, parts, sharing) is
carried over unchanged apart from the auth switch.
Layout: main.go -> cmd/server/main.go; module carcontrol/api ->
drivervault/apiserver. internal/api is split by concern (auth, users,
orgs, settings, plugins, status, health, respond).
Auth: replace the server-minted HS256 JWT and the sessions collection
with a PocketBase token proxy. /api/auth/login relays PocketBase's
{token, record}, and every protected request re-resolves that token
against PocketBase, so a role change or deletion takes effect at once
instead of waiting out a token. AUTH_SECRET is obsolete and internal/auth
is gone. Per-device session listing/revocation goes with it: PocketBase
tokens are stateless. Changing a password rotates the user's token key,
which invalidates every token already issued.
Roles: add superadmin alongside user/admin, plus an organizations
collection and users.organization. Admins are scoped to their own
organization; superadmins span all of them. Guards prevent changing your
own role, deleting your own account, an admin touching a superadmin, and
deleting an organization that still has members.
Plugins: new internal/plugins package with one contract over two kinds --
builtin (compiled in) and external (any HTTP service, registered at
runtime with no rebuild). State persists to plugins.json; secrets are
masked on read and preserved when saved back at the mask.
PocketBase settings: /api/admin/pb-config applies a new connection at
runtime and persists it to .env. It deliberately does not require a
working service account, so a wrong or unreachable connection can still
be fixed from the panel.
Panel: rebuilt as the superadmin console -- login gate, status, users,
organizations, PocketBase, plugins, and the endpoint reference.
Clients: update the Web App and Phone App for the PocketBase token shape,
the move of user management to /api/users ({users}/{user} envelopes, with
password resets folded into PATCH), and the removal of sessions. Both now
mirror the server's real guards rather than the old last-admin rule, and
parse PocketBase's field-level error shape.
Config: modern POCKETBASE_*/API_ADDR names with legacy PB_*/PORT
fallbacks, so existing .env files keep working. Also fixes /api/status
probing the Web App on 8090 instead of DriverVault's 5173.
Run scripts/setup-pocketbase.mjs to add the organizations collection and
grow users.role; every client must log in once more.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
310 lines
11 KiB
Markdown
310 lines
11 KiB
Markdown
# 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/<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. (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: <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 `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.
|