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
+150 -11
View File
@@ -17,14 +17,18 @@ import (
)
// Client is a concurrency-safe PocketBase REST client with auto re-auth.
//
// The target address and service-account credentials are guarded by mu because
// a superadmin can retarget them at runtime (Settings → PocketBase in the panel)
// while requests are in flight.
type Client struct {
http *http.Client
mu sync.RWMutex
baseURL string
email string
password string
http *http.Client
mu sync.RWMutex
token string
token string
}
func New(baseURL, email, password string) *Client {
@@ -36,6 +40,38 @@ func New(baseURL, email, password string) *Client {
}
}
// creds snapshots the connection under lock so a concurrent Reconfigure can't
// tear it mid-request.
func (c *Client) creds() (baseURL, email, password string) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.baseURL, c.email, c.password
}
// BaseURL returns the PocketBase address currently in use.
func (c *Client) BaseURL() string {
baseURL, _, _ := c.creds()
return baseURL
}
// Configured reports whether a service account has been supplied. Endpoints that
// need superuser access check this and return 503 when it is false.
func (c *Client) Configured() bool {
_, email, password := c.creds()
return email != "" && password != ""
}
// Reconfigure retargets the client at a new PocketBase and/or new credentials,
// invalidating any cached superuser token so the next call re-authenticates.
func (c *Client) Reconfigure(baseURL, email, password string) {
c.mu.Lock()
c.baseURL = baseURL
c.email = email
c.password = password
c.token = ""
c.mu.Unlock()
}
// APIError carries the HTTP status and body from a failed PocketBase call.
type APIError struct {
Status int
@@ -49,9 +85,10 @@ func (e *APIError) Error() string {
// Authenticate obtains a superuser token. It tries the PocketBase v0.23+
// (_superusers collection) endpoint first, then the legacy admins endpoint.
func (c *Client) Authenticate(ctx context.Context) error {
baseURL, email, password := c.creds()
body, _ := json.Marshal(map[string]string{
"identity": c.email,
"password": c.password,
"identity": email,
"password": password,
})
endpoints := []string{
@@ -61,7 +98,7 @@ func (c *Client) Authenticate(ctx context.Context) error {
var lastErr error
for _, ep := range endpoints {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+ep, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+ep, bytes.NewReader(body))
if err != nil {
return err
}
@@ -129,7 +166,7 @@ func (c *Client) attempt(ctx context.Context, method, path string, payload any)
reader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL()+path, reader)
if err != nil {
return nil, 0, err
}
@@ -149,6 +186,108 @@ func (c *Client) attempt(ctx context.Context, method, path string, payload any)
return raw, resp.StatusCode, err
}
// Raw performs a superuser request and returns the upstream body and status
// WITHOUT translating a non-2xx into an error. Handlers that want to relay
// PocketBase's own validation errors to the client verbatim (user/organization
// management) use this; handlers that want Go errors use the typed CRUD helpers.
func (c *Client) Raw(ctx context.Context, method, path string, payload any) ([]byte, int, error) {
raw, status, err := c.attempt(ctx, method, path, payload)
if err != nil {
return nil, 0, err
}
if status == http.StatusUnauthorized {
if err := c.Authenticate(ctx); err != nil {
return nil, 0, err
}
return c.attempt(ctx, method, path, payload)
}
return raw, status, nil
}
// AuthRefresh validates an end user's auth token against PocketBase and returns
// the refreshed auth response body and status. Unlike the superuser calls this
// carries the *caller's* token, not the service account's — it is how the server
// resolves who a request belongs to.
func (c *Client) AuthRefresh(ctx context.Context, collection, token string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.BaseURL()+"/api/collections/"+collection+"/auth-refresh", nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("Authorization", token)
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
return raw, resp.StatusCode, err
}
// LoginWithPassword forwards a login to PocketBase's auth-with-password and
// returns its response body and status untouched, so the caller can relay both
// (token + record) straight back to the client.
func (c *Client) LoginWithPassword(ctx context.Context, collection, identity, password string) ([]byte, int, error) {
body, _ := json.Marshal(map[string]string{"identity": identity, "password": password})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.BaseURL()+"/api/collections/"+collection+"/auth-with-password", bytes.NewReader(body))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
return raw, resp.StatusCode, err
}
// SuperuserAuth performs a one-off superuser auth-with-password against an
// arbitrary PocketBase and returns the HTTP status. It shares no state with any
// Client, so the settings endpoints can test a *candidate* connection before
// applying it. Both the v0.23+ (_superusers) and legacy (admins) endpoints are
// tried, matching Client.Authenticate.
func SuperuserAuth(ctx context.Context, httpClient *http.Client, baseURL, email, password string) (int, error) {
body, _ := json.Marshal(map[string]string{"identity": email, "password": password})
var lastStatus int
var lastErr error
for _, ep := range []string{
"/api/collections/_superusers/auth-with-password",
"/api/admins/auth-with-password",
} {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+ep, bytes.NewReader(body))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return 0, err
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var out struct {
Token string `json:"token"`
}
if err := json.Unmarshal(raw, &out); err != nil || out.Token == "" {
return resp.StatusCode, fmt.Errorf("superuser auth: no token in response")
}
return resp.StatusCode, nil
}
lastStatus = resp.StatusCode
lastErr = &APIError{Status: resp.StatusCode, Body: string(raw)}
}
return lastStatus, lastErr
}
// AuthRecord is the user record returned by a successful password auth.
type AuthRecord struct {
ID string `json:"id"`
@@ -161,7 +300,7 @@ type AuthRecord struct {
// the superuser token. Returns the matched user record on success.
func (c *Client) AuthWithPassword(ctx context.Context, collection, identity, password string) (*AuthRecord, error) {
body, _ := json.Marshal(map[string]string{"identity": identity, "password": password})
url := c.baseURL + "/api/collections/" + collection + "/auth-with-password"
url := c.BaseURL() + "/api/collections/" + collection + "/auth-with-password"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
@@ -277,7 +416,7 @@ func (c *Client) GetFile(ctx context.Context, collection, recordID, filename str
}
func (c *Client) attemptGetFile(ctx context.Context, path string) ([]byte, string, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL()+path, nil)
if err != nil {
return nil, "", 0, err
}
@@ -340,7 +479,7 @@ func (c *Client) attemptMultipart(ctx context.Context, collection, id string, fi
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.baseURL+"/api/collections/"+collection+"/records/"+id, &buf)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.BaseURL()+"/api/collections/"+collection+"/records/"+id, &buf)
if err != nil {
return 0, err
}