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:
co-authored by
Claude Opus 4.8
parent
7d55f0a4cd
commit
ae6ed4ac1e
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user