Rebuild API Server on the PilotVault structure

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>
This commit is contained in:
tajniak81
2026-07-16 22:29:45 +02:00
co-authored by Claude Opus 4.8
parent 7d55f0a4cd
commit ae6ed4ac1e
56 changed files with 4474 additions and 1475 deletions
+309
View File
@@ -0,0 +1,309 @@
# 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.
@@ -0,0 +1,12 @@
// Package builtin blank-imports every built-in plugin so their init() functions
// register them with the plugin registry. Import this package once (from the api
// package) to make all built-in connectors available.
//
// DriverVault ships no built-in connectors yet — add one under
// internal/plugins/builtin/<name>/ and blank-import it here, e.g.
//
// import _ "drivervault/apiserver/internal/plugins/builtin/acme"
//
// Until then, plugins are added at runtime as the "external" HTTP kind, which
// needs no rebuild. See ../README.md.
package builtin
+20
View File
@@ -0,0 +1,20 @@
package plugins
// Deferred extension points (deliberately NOT in v1 — the "Management MVP").
// The contract and manager are shaped so these can be added without a redesign:
//
// - Invocation API: the Plugin.Invoke method already exists; a
// POST /api/admin/plugins/{name}/action endpoint + a normalized request/
// response envelope would expose it. Add a mapper layer so core logic never
// depends on a provider's schema.
// - Resilience: wrap plugin calls with retry/backoff + a circuit breaker, and
// record per-plugin latency/error/quota metrics for the panel.
// - Per-tenant credentials: today config is a single global blob per plugin.
// A (pluginName, orgID/userID) → config store would let users connect their
// own third-party accounts.
// - Audit logging: record which plugin accessed what and when.
// - Sandboxing: the "external" plugin kind is the isolation story — run less
// trusted plugins as separate processes/containers behind the HTTP contract.
// - Hot-adding builtin Go code without a rebuild is intentionally unsupported
// (Go .so plugins are Linux-only and toolchain-fragile); use the external
// HTTP kind to add plugins at runtime instead.
+149
View File
@@ -0,0 +1,149 @@
package plugins
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"time"
)
// externalPlugin adapts a remote HTTP service to the Plugin contract. The remote
// side implements a tiny JSON contract:
//
// GET {baseURL}/manifest → { provider, version, capabilities, authType, configFields }
// GET {baseURL}/health → 2xx, optionally { status, detail }
// POST {baseURL}/invoke → { action, params } → arbitrary JSON (v1: unused)
//
// This is the "add a plugin without a rebuild" path: register a base URL at
// runtime and the server drives it over HTTP. It is also the sandboxing story —
// a less-trusted plugin runs as its own process/container.
type externalPlugin struct {
name string
baseURL string
desc Descriptor
client *http.Client
}
func newExternalPlugin(name, baseURL, provider string) *externalPlugin {
if provider == "" {
provider = "External"
}
return &externalPlugin{
name: name,
baseURL: baseURL,
client: &http.Client{Timeout: 8 * time.Second},
desc: Descriptor{
Name: name,
Provider: provider,
Version: "external",
Kind: KindExternal,
Category: CategoryAPIsExternal, // remote HTTP service; a manifest may override
AuthType: AuthNone,
},
}
}
func (e *externalPlugin) Descriptor() Descriptor { return e.desc }
// Init best-effort fetches the remote manifest to enrich the descriptor. A
// missing/broken manifest is non-fatal — the basic descriptor stands.
func (e *externalPlugin) Init(ctx context.Context, _ map[string]string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.baseURL+"/manifest", nil)
if err != nil {
return nil
}
resp, err := e.client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var man struct {
Provider string `json:"provider"`
Version string `json:"version"`
Category string `json:"category"`
Capabilities []Capability `json:"capabilities"`
AuthType AuthType `json:"authType"`
ConfigFields []ConfigField `json:"configFields"`
}
if json.Unmarshal(data, &man) == nil {
if man.Provider != "" {
e.desc.Provider = man.Provider
}
if man.Version != "" {
e.desc.Version = man.Version
}
if man.AuthType != "" {
e.desc.AuthType = man.AuthType
}
if man.Category != "" {
e.desc.Category = man.Category
}
e.desc.Capabilities = man.Capabilities
e.desc.ConfigFields = man.ConfigFields
}
return nil
}
func (e *externalPlugin) HealthCheck(ctx context.Context) Health {
start := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.baseURL+"/health", nil)
if err != nil {
return Health{Status: StatusDown, Detail: err.Error()}
}
resp, err := e.client.Do(req)
lat := time.Since(start).Milliseconds()
if err != nil {
return Health{Status: StatusDown, LatencyMs: lat, Detail: err.Error()}
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
// Honour an explicit {status, detail} body when present.
var body struct {
Status string `json:"status"`
Detail string `json:"detail"`
}
_ = json.Unmarshal(data, &body)
h := Health{LatencyMs: lat, Detail: body.Detail}
switch {
case body.Status != "":
h.Status = body.Status
case resp.StatusCode >= 200 && resp.StatusCode < 300:
h.Status = StatusOK
case resp.StatusCode >= 500:
h.Status = StatusDown
default:
h.Status = StatusDegraded
}
if h.Detail == "" && h.Status != StatusOK {
h.Detail = "HTTP " + resp.Status
}
return h
}
// Invoke proxies to the remote /invoke endpoint. Part of the contract; no HTTP
// endpoint exposes it in v1.
func (e *externalPlugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
payload, _ := json.Marshal(map[string]any{"action": action, "params": params})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+"/invoke", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := e.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
return data, nil
}
func (e *externalPlugin) Shutdown(context.Context) error { return nil }
+346
View File
@@ -0,0 +1,346 @@
package plugins
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
)
// secretMask is what a set secret value is echoed back as. On save, a field that
// still equals the mask is left unchanged (mirrors the pb-config password flow).
const secretMask = "••••••••"
// record is the persisted state for one plugin. For builtins, Kind/BaseURL are
// omitted (the descriptor comes from the registry); external plugins set them.
type record struct {
Kind string `json:"kind,omitempty"`
BaseURL string `json:"baseURL,omitempty"`
Provider string `json:"provider,omitempty"`
Enabled bool `json:"enabled"`
Config map[string]string `json:"config,omitempty"`
}
// View is the plugin shape returned to the panel (secrets masked).
type View struct {
Descriptor
Enabled bool `json:"enabled"`
Config map[string]string `json:"config"`
BaseURL string `json:"baseURL,omitempty"`
Health *Health `json:"health,omitempty"`
}
// Manager owns the plugin registry, persisted state, and live instances.
type Manager struct {
path string
mu sync.Mutex
factories map[string]Factory
records map[string]*record
live map[string]Plugin
health map[string]*Health
client *http.Client
}
// NewManager builds a Manager backed by the JSON state file at path.
func NewManager(path string) *Manager {
return &Manager{
path: path,
factories: builtinFactories(),
records: map[string]*record{},
live: map[string]Plugin{},
health: map[string]*Health{},
client: &http.Client{Timeout: 12 * time.Second},
}
}
// Load reads the state file and initialises every enabled plugin. A missing file
// is fine (no plugins configured yet).
func (m *Manager) Load() error {
m.mu.Lock()
defer m.mu.Unlock()
if data, err := os.ReadFile(m.path); err == nil {
var recs map[string]*record
if err := json.Unmarshal(data, &recs); err != nil {
return err
}
m.records = recs
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
ctx := context.Background()
for name, rec := range m.records {
if !rec.Enabled {
continue
}
p := construct(name, m.factories[name], rec)
if p == nil {
log.Printf("plugins: cannot construct %q (unknown builtin?)", name)
continue
}
if err := p.Init(ctx, rec.Config); err != nil {
log.Printf("plugins: init %q failed: %v", name, err)
continue
}
m.live[name] = p
}
return nil
}
// construct builds a plugin instance from a builtin factory or an external record.
func construct(name string, f Factory, rec *record) Plugin {
if f != nil {
return f()
}
if rec != nil && rec.Kind == KindExternal {
return newExternalPlugin(name, rec.BaseURL, rec.Provider)
}
return nil
}
// descriptorFor returns a plugin's descriptor without needing a live instance.
func (m *Manager) descriptorFor(name string, rec *record) Descriptor {
if p := m.live[name]; p != nil {
return p.Descriptor()
}
if f := m.factories[name]; f != nil {
return f().Descriptor()
}
if rec != nil && rec.Kind == KindExternal {
return newExternalPlugin(name, rec.BaseURL, rec.Provider).Descriptor()
}
return Descriptor{Name: name}
}
// maskConfig echoes config back with secret fields masked when set.
func maskConfig(d Descriptor, cfg map[string]string) map[string]string {
out := map[string]string{}
for k, v := range cfg {
out[k] = v
}
for _, f := range d.ConfigFields {
if f.Secret && out[f.Key] != "" {
out[f.Key] = secretMask
}
}
return out
}
// List returns every known plugin (registry persisted), sorted by name.
func (m *Manager) List() []View {
m.mu.Lock()
defer m.mu.Unlock()
names := map[string]bool{}
for n := range m.factories {
names[n] = true
}
for n := range m.records {
names[n] = true
}
out := make([]View, 0, len(names))
for name := range names {
rec := m.records[name]
d := m.descriptorFor(name, rec)
v := View{Descriptor: d, Health: m.health[name]}
if rec != nil {
v.Enabled = rec.Enabled
v.BaseURL = rec.BaseURL
v.Config = maskConfig(d, rec.Config)
} else {
v.Config = map[string]string{}
}
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Get returns a single plugin view (ok=false when unknown).
func (m *Manager) Get(name string) (View, bool) {
for _, v := range m.List() {
if v.Name == name {
return v, true
}
}
return View{}, false
}
// Upsert enables/disables a plugin and merges its config, then (re)initialises or
// shuts down the live instance to match. Secrets left at the mask are preserved.
func (m *Manager) Upsert(ctx context.Context, name string, enabled bool, incoming map[string]string) (View, error) {
m.mu.Lock()
_, isBuiltin := m.factories[name]
rec := m.records[name]
if !isBuiltin && (rec == nil || rec.Kind != KindExternal) {
m.mu.Unlock()
return View{}, errUnknown
}
if rec == nil {
rec = &record{}
m.records[name] = rec
}
d := m.descriptorFor(name, rec)
merged := map[string]string{}
for k, v := range rec.Config {
merged[k] = v
}
// Apply incoming values, honouring the secret-mask keep-current rule.
secretKeys := map[string]bool{}
for _, f := range d.ConfigFields {
if f.Secret {
secretKeys[f.Key] = true
}
}
for k, v := range incoming {
if secretKeys[k] && v == secretMask {
continue // keep existing secret
}
merged[k] = strings.TrimSpace(v)
}
// Validate required fields when enabling.
if enabled {
for _, f := range d.ConfigFields {
if f.Required && merged[f.Key] == "" {
m.mu.Unlock()
return View{}, errors.New("missing required setting: " + f.Label)
}
}
}
rec.Enabled = enabled
rec.Config = merged
if err := m.persistLocked(); err != nil {
m.mu.Unlock()
return View{}, err
}
// Reconcile the live instance.
if old := m.live[name]; old != nil {
_ = old.Shutdown(ctx)
delete(m.live, name)
}
var initErr error
if enabled {
p := construct(name, m.factories[name], rec)
if p != nil {
if err := p.Init(ctx, merged); err != nil {
initErr = err
} else {
m.live[name] = p
}
}
}
m.mu.Unlock()
v, _ := m.Get(name)
return v, initErr
}
// RegisterExternal adds a new external (remote HTTP) plugin at runtime — the
// "add a plugin without a rebuild" path. It starts disabled.
func (m *Manager) RegisterExternal(name, baseURL, provider string) error {
name = strings.TrimSpace(name)
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if name == "" || baseURL == "" {
return errors.New("name and baseURL are required")
}
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
baseURL = "http://" + baseURL
}
m.mu.Lock()
defer m.mu.Unlock()
if _, dup := m.factories[name]; dup {
return errors.New("a builtin plugin already uses that name")
}
if _, dup := m.records[name]; dup {
return errors.New("a plugin with that name already exists")
}
m.records[name] = &record{Kind: KindExternal, BaseURL: baseURL, Provider: provider}
return m.persistLocked()
}
// Remove deletes an external plugin registration. Builtins can only be disabled.
func (m *Manager) Remove(ctx context.Context, name string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec := m.records[name]
if rec == nil || rec.Kind != KindExternal {
return errors.New("only external plugins can be removed")
}
if p := m.live[name]; p != nil {
_ = p.Shutdown(ctx)
delete(m.live, name)
}
delete(m.records, name)
delete(m.health, name)
return m.persistLocked()
}
// HealthCheck probes a plugin now, building a transient instance if it is not
// currently live (so disabled plugins can still be tested). Result is cached.
func (m *Manager) HealthCheck(ctx context.Context, name string) (Health, error) {
m.mu.Lock()
p := m.live[name]
transient := false
var cfg map[string]string
if p == nil {
rec := m.records[name]
if rec != nil {
cfg = rec.Config
}
p = construct(name, m.factories[name], rec)
transient = true
}
m.mu.Unlock()
if p == nil {
return Health{}, errUnknown
}
if transient {
_ = p.Init(ctx, cfg)
defer func() { _ = p.Shutdown(context.Background()) }()
}
h := p.HealthCheck(ctx)
m.mu.Lock()
hc := h
m.health[name] = &hc
m.mu.Unlock()
return h, nil
}
// Shutdown tears down every live plugin instance. Wire into graceful shutdown.
func (m *Manager) Shutdown(ctx context.Context) {
m.mu.Lock()
defer m.mu.Unlock()
for name, p := range m.live {
_ = p.Shutdown(ctx)
delete(m.live, name)
}
}
// persistLocked writes the state file. Caller must hold m.mu.
func (m *Manager) persistLocked() error {
data, err := json.MarshalIndent(m.records, "", " ")
if err != nil {
return err
}
return os.WriteFile(m.path, append(data, '\n'), 0o600)
}
var errUnknown = errors.New("unknown plugin")
// IsUnknown reports whether err came from addressing a plugin that doesn't exist.
func IsUnknown(err error) bool { return errors.Is(err, errUnknown) }
+181
View File
@@ -0,0 +1,181 @@
// Package plugins is the API Server's plugin system: a uniform contract for
// integrating external third-party services (vehicle data, parts catalogs,
// notifications, file storage, …).
//
// Two plugin kinds share one contract:
// - "builtin" — a Go connector compiled into the server (type-safe, first-party).
// Adding a new builtin requires a rebuild. See builtin/builtin.go.
// - "external" — a remote service registered at runtime (no rebuild) that speaks
// a small JSON contract over HTTP. See external.go.
//
// Enable-state and per-plugin config (including secrets) are persisted to a local
// plugins.json by the Manager, mirroring how the PocketBase connection persists to
// .env. See doc.go for the deliberately-deferred extension points.
package plugins
import (
"context"
"encoding/json"
)
// Plugin kinds.
const (
KindBuiltin = "builtin"
KindExternal = "external"
)
// AuthType describes how a plugin authenticates to its upstream. It is metadata
// for the UI/operators; each plugin implements the mechanics itself.
type AuthType string
const (
AuthNone AuthType = "none"
AuthAPIKey AuthType = "apikey"
AuthBasic AuthType = "basic"
AuthOAuth2 AuthType = "oauth2"
AuthWebhook AuthType = "webhook"
)
// Health status values.
const (
StatusOK = "ok"
StatusDegraded = "degraded"
StatusDown = "down"
)
// SelectOption is one choice for a ConfigField of Type "select".
type SelectOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// ConfigField declares one configurable setting a plugin accepts. It drives the
// panel's generated config form and controls secret masking.
type ConfigField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // "text" | "password" | "number" | "select"
Required bool `json:"required"`
Secret bool `json:"secret"` // never echoed back to clients in clear
Help string `json:"help,omitempty"`
Default string `json:"default,omitempty"` // effective default when unset
Options []SelectOption `json:"options,omitempty"` // for Type "select"
}
// Capability is one operation a plugin exposes. It maps a stable id to the
// upstream endpoint it calls and a human description shown in the panel.
type Capability struct {
ID string `json:"id"`
Method string `json:"method,omitempty"` // e.g. "GET"
Endpoint string `json:"endpoint,omitempty"` // upstream path, e.g. "/states/all"
Description string `json:"description,omitempty"`
}
// UnmarshalJSON accepts either a bare string ("states.all") or a full object, so
// external manifests can advertise capabilities in either form.
func (c *Capability) UnmarshalJSON(b []byte) error {
var s string
if json.Unmarshal(b, &s) == nil {
c.ID = s
return nil
}
type alias Capability
var a alias
if err := json.Unmarshal(b, &a); err != nil {
return err
}
*c = Capability(a)
return nil
}
// Category groups a plugin under a tab in the admin panel. A plugin with an
// empty category is treated as CategoryAPIsExternal by the panel.
const (
CategoryAPIsExternal = "apis-external" // remote HTTP APIs (external plugins)
CategoryDrivesExternal = "drives-external" // remote file stores (FTP/SFTP)
CategoryDrivesLocal = "drives-local" // drives on the host machine
)
// Descriptor is the static metadata a plugin advertises about itself.
type Descriptor struct {
Name string `json:"name"`
Provider string `json:"provider"`
Version string `json:"version"`
Kind string `json:"kind"` // KindBuiltin | KindExternal
Category string `json:"category"` // one of Category* — groups the plugin in the panel
Capabilities []Capability `json:"capabilities"`
AuthType AuthType `json:"authType"`
ConfigFields []ConfigField `json:"configFields"`
}
// Health is the outcome of a plugin's HealthCheck.
type Health struct {
Status string `json:"status"` // StatusOK | StatusDegraded | StatusDown
LatencyMs int64 `json:"latencyMs,omitempty"`
Detail string `json:"detail,omitempty"`
Credits *HealthCredits `json:"credits,omitempty"`
Usage *HealthUsage `json:"usage,omitempty"`
}
// HealthUsage is optional call-usage accounting a plugin may report when its
// upstream does NOT expose remaining quota (e.g. OpenWeather). Unlike
// HealthCredits — which reflects a balance the upstream reports — these are
// process-local counts of the calls this server has made, bucketed into the
// current minute and day, so the UI can render an approximate usage gauge.
type HealthUsage struct {
MinuteUsed int `json:"minuteUsed"` // calls made in the current minute
MinuteLimit int `json:"minuteLimit,omitempty"` // the plan's per-minute limit
DayUsed int `json:"dayUsed"` // calls made so far today (UTC)
}
// HealthCredits is optional structured rate-limit/credit accounting a plugin may
// report alongside a probe, when its upstream exposes a remaining balance. It
// lets the UI render a dedicated usage meter instead of parsing it back out of
// Detail.
type HealthCredits struct {
Remaining *int `json:"remaining,omitempty"` // credits left today; nil when the upstream didn't report it (e.g. anonymous)
Daily int `json:"daily,omitempty"` // the plan's daily allowance
ProbeCost int `json:"probeCost,omitempty"` // credits one query/probe costs
Mode string `json:"mode,omitempty"` // "authenticated" | "anonymous"
}
// Plugin is the contract every plugin (builtin or external) implements.
type Plugin interface {
// Descriptor returns the plugin's static metadata. It may be enriched after
// Init (e.g. an external plugin fetching its manifest).
Descriptor() Descriptor
// Init prepares the plugin with its resolved config (secrets included). It is
// called when the plugin is enabled or its config changes.
Init(ctx context.Context, config map[string]string) error
// HealthCheck probes the upstream and classifies the result.
HealthCheck(ctx context.Context) Health
// Invoke runs a named capability. Part of the contract for future use; v1
// exposes no HTTP endpoint for it.
Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error)
// Shutdown releases any resources held by the plugin.
Shutdown(ctx context.Context) error
}
// Factory builds a fresh instance of a builtin plugin.
type Factory func() Plugin
// registry holds the builtin plugin factories keyed by descriptor name.
var registry = map[string]Factory{}
// Register adds a builtin plugin factory. Called from a builtin package's init().
// Panics on a duplicate name so wiring mistakes surface at startup.
func Register(name string, f Factory) {
if _, dup := registry[name]; dup {
panic("plugins: duplicate registration for " + name)
}
registry[name] = f
}
// builtinFactories returns a copy of the registered builtin factories.
func builtinFactories() map[string]Factory {
out := make(map[string]Factory, len(registry))
for k, v := range registry {
out[k] = v
}
return out
}