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>
287 lines
9.1 KiB
Go
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)
|
|
}
|