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
+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) }