Files
tajniak81andClaude Opus 4.8 ae6ed4ac1e 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>
2026-07-16 22:29:45 +02:00

287 lines
9.1 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"strings"
)
// Role names as stored in the PocketBase users.role select field. A missing or
// empty value is treated as roleUser.
const (
roleUser = "user"
roleAdmin = "admin"
roleSuperadmin = "superadmin"
)
// publicPaths bypass authentication. Everything else under /api/ requires a
// valid PocketBase token.
var publicPaths = map[string]bool{
"/api/health": true,
"/api/status": true,
"/api/auth/login": true,
"/api/auth/validate": true,
"/healthz": true,
}
type ctxKey int
const ctxCaller ctxKey = iota
// callerIdentity is who the request token belongs to. It is resolved from
// PocketBase on each request, so a role change or a deletion takes effect
// immediately rather than lingering until a token expires.
type callerIdentity struct {
ID string
Email string
Name string
Role string
OrgID string // organization record id ("" when the user belongs to no org)
}
func (c *callerIdentity) isSuperadmin() bool { return c != nil && c.Role == roleSuperadmin }
func (c *callerIdentity) isManager() bool {
return c != nil && (c.Role == roleAdmin || c.Role == roleSuperadmin)
}
// caller returns the identity stashed on the request context by withAuth.
func caller(r *http.Request) *callerIdentity {
if v, ok := r.Context().Value(ctxCaller).(*callerIdentity); ok {
return v
}
return nil
}
// currentUserID returns the authenticated user's id. Handlers on non-public
// paths can treat "" as "not authenticated" — withAuth already rejected those.
func (s *Server) currentUserID(r *http.Request) string {
if who := caller(r); who != nil {
return who.ID
}
return ""
}
// bearerToken extracts the caller's token, accepting both "Bearer <token>" and
// a raw token (PocketBase's own SDKs send the latter).
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(after)
}
return strings.TrimSpace(h)
}
// identify resolves the caller's id/email/name/role/org from their PocketBase
// token by asking PocketBase to refresh it. A non-200 status means the token is
// invalid or expired.
func (s *Server) identify(ctx context.Context, token string) (*callerIdentity, int, error) {
raw, status, err := s.pb.AuthRefresh(ctx, s.usersCollection(), token)
if err != nil {
return nil, 0, err
}
if status != http.StatusOK {
return nil, status, nil
}
var out struct {
Record struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
Organization string `json:"organization"`
} `json:"record"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, status, err
}
role := out.Record.Role
if role == "" {
role = roleUser
}
return &callerIdentity{
ID: out.Record.ID,
Email: out.Record.Email,
Name: out.Record.Name,
Role: role,
OrgID: out.Record.Organization,
}, http.StatusOK, nil
}
// withAuth rejects requests to non-public /api/ paths that lack a valid
// PocketBase token, and stashes the resolved identity on the request context.
func (s *Server) withAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Public API paths, plus everything outside /api/ (the embedded web
// panel and its static assets), bypass authentication.
if publicPaths[r.URL.Path] || !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
who, ok := s.authenticate(w, r)
if !ok {
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxCaller, who)))
})
}
// authenticate resolves and validates the caller, writing the error response
// itself and returning ok=false when the request should not proceed.
func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (*callerIdentity, bool) {
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing bearer token")
return nil, false
}
who, status, err := s.identify(r.Context(), token)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return nil, false
}
if status != http.StatusOK || who == nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return nil, false
}
return who, true
}
// requireRole is the shared gate for privileged handlers: it needs the service
// account (every privileged flow runs through it) and a caller satisfying ok.
// withAuth has already established the identity.
func (s *Server) requireRole(next http.HandlerFunc, ok func(*callerIdentity) bool, denied string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.pb.Configured() {
writeError(w, http.StatusServiceUnavailable, "user management not configured on the server")
return
}
if !ok(caller(r)) {
writeError(w, http.StatusForbidden, denied)
return
}
next(w, r)
}
}
// requireManager wraps a handler so only managers (admin or superadmin) proceed.
func (s *Server) requireManager(next http.HandlerFunc) http.HandlerFunc {
return s.requireRole(next, func(c *callerIdentity) bool { return c.isManager() }, "admin role required")
}
// requireSuperadmin wraps a handler so only superadmins proceed.
func (s *Server) requireSuperadmin(next http.HandlerFunc) http.HandlerFunc {
return s.requireRole(next, func(c *callerIdentity) bool { return c.isSuperadmin() }, "superadmin role required")
}
// requireSuperadminAuth gates a handler on a superadmin caller WITHOUT requiring
// the service account to already be configured. Used by the PocketBase settings
// endpoints, whose whole purpose is to configure that service account.
func (s *Server) requireSuperadminAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !caller(r).isSuperadmin() {
writeError(w, http.StatusForbidden, "superadmin role required")
return
}
next(w, r)
}
}
// POST /api/auth/login
// Body: {"email"|"identity":"...","password":"..."}
// Proxies to the PocketBase users auth-with-password and relays its response —
// the client gets PocketBase's own token and user record. PocketBase's address
// lives only in this server and is never exposed to clients.
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Identity string `json:"identity"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
identity := body.Identity
if identity == "" {
identity = body.Email
}
if identity == "" || body.Password == "" {
writeError(w, http.StatusBadRequest, "email and password are required")
return
}
raw, status, err := s.pb.LoginWithPassword(r.Context(), s.usersCollection(), identity, body.Password)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
}
relay(w, status, raw)
}
// GET /api/auth/validate (Authorization: <pb token>)
// Proxies to PocketBase auth-refresh to confirm a token is still valid.
func (s *Server) handleAuthValidate(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
writeJSON(w, http.StatusUnauthorized, map[string]any{"valid": false})
return
}
raw, status, err := s.pb.AuthRefresh(r.Context(), s.usersCollection(), token)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"valid": false, "detail": err.Error()})
return
}
relay(w, status, raw)
}
// GET /api/auth/me — the caller's identity, from their token.
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
writeJSON(w, http.StatusOK, userInfo{ID: who.ID, Email: who.Email, Name: who.Name, Role: who.Role})
}
// GET /api/identity — like /api/auth/me, plus the caller's organization. The
// panel uses this to decide which management cards to show.
func (s *Server) handleIdentity(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
orgName := ""
if who.OrgID != "" {
orgName = s.orgName(r.Context(), who.OrgID)
}
writeJSON(w, http.StatusOK, map[string]any{
"id": who.ID,
"email": who.Email,
"name": who.Name,
"role": who.Role,
"organization": who.OrgID,
"organizationName": orgName,
})
}
// userInfo is the compact identity shape returned by /api/auth/me.
type userInfo struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role,omitempty"`
}
// relay copies an upstream PocketBase status + JSON body to the client.
func relay(w http.ResponseWriter, status int, body []byte) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(body)
}