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>
110 lines
3.6 KiB
Go
110 lines
3.6 KiB
Go
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"drivervault/apiserver/internal/plugins"
|
||
)
|
||
|
||
// GET /api/admin/plugins — every known plugin (registry ∪ persisted), secrets masked.
|
||
func (s *Server) handleListPlugins(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, http.StatusOK, map[string]any{"plugins": s.plugins.List()})
|
||
}
|
||
|
||
// GET /api/admin/plugins/{name} — one plugin's view.
|
||
func (s *Server) handleGetPlugin(w http.ResponseWriter, r *http.Request) {
|
||
v, ok := s.plugins.Get(r.PathValue("name"))
|
||
if !ok {
|
||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"plugin": v})
|
||
}
|
||
|
||
// PUT /api/admin/plugins/{name} — enable/disable + merge config. Body:
|
||
// {enabled?, config?}. A secret left at the mask keeps its stored value.
|
||
func (s *Server) handleUpdatePlugin(w http.ResponseWriter, r *http.Request) {
|
||
name := r.PathValue("name")
|
||
current, ok := s.plugins.Get(name)
|
||
if !ok {
|
||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||
return
|
||
}
|
||
var body struct {
|
||
Enabled *bool `json:"enabled"`
|
||
Config map[string]string `json:"config"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid json")
|
||
return
|
||
}
|
||
enabled := current.Enabled
|
||
if body.Enabled != nil {
|
||
enabled = *body.Enabled
|
||
}
|
||
|
||
v, err := s.plugins.Upsert(r.Context(), name, enabled, body.Config)
|
||
if err != nil {
|
||
if plugins.IsUnknown(err) {
|
||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||
return
|
||
}
|
||
// A failed init (e.g. bad credentials) is reported but the state was saved.
|
||
writeJSON(w, http.StatusOK, map[string]any{"plugin": v, "warning": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"plugin": v})
|
||
}
|
||
|
||
// POST /api/admin/plugins — register an external (remote HTTP) plugin. Body:
|
||
// {name, baseURL, provider?}. This is the "add a plugin without a rebuild" path.
|
||
func (s *Server) handleRegisterPlugin(w http.ResponseWriter, r *http.Request) {
|
||
var body struct {
|
||
Name string `json:"name"`
|
||
BaseURL string `json:"baseURL"`
|
||
Provider string `json:"provider"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid json")
|
||
return
|
||
}
|
||
body.Name = strings.TrimSpace(body.Name)
|
||
if body.Name == "" || body.BaseURL == "" {
|
||
writeError(w, http.StatusBadRequest, "name and baseURL are required")
|
||
return
|
||
}
|
||
if err := s.plugins.RegisterExternal(body.Name, body.BaseURL, body.Provider); err != nil {
|
||
writeError(w, http.StatusConflict, err.Error())
|
||
return
|
||
}
|
||
v, _ := s.plugins.Get(body.Name)
|
||
writeJSON(w, http.StatusCreated, map[string]any{"plugin": v})
|
||
}
|
||
|
||
// DELETE /api/admin/plugins/{name} — remove an external plugin (builtins can
|
||
// only be disabled).
|
||
func (s *Server) handleDeletePlugin(w http.ResponseWriter, r *http.Request) {
|
||
if err := s.plugins.Remove(r.Context(), r.PathValue("name")); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||
}
|
||
|
||
// POST /api/admin/plugins/{name}/health — run a health check now. Works on
|
||
// disabled plugins too, so a config can be verified before enabling it.
|
||
func (s *Server) handlePluginHealth(w http.ResponseWriter, r *http.Request) {
|
||
h, err := s.plugins.HealthCheck(r.Context(), r.PathValue("name"))
|
||
if err != nil {
|
||
if plugins.IsUnknown(err) {
|
||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"health": h})
|
||
}
|