Files
DriverVault/API Server/internal/api/integrations.go
T
tajniak81andClaude Opus 5 358ee68f94 Cars: create a car from a manufacturer service, with a per-car data tab
A car can now be imported straight from the account its owner already has
with the manufacturer, and every reading that service exposes shows up on
the car's own tab. MyToyota is the first provider.

API Server — internal/api/vehicleproviders.go adds a generic layer over a
plugin that can enumerate vehicles and read data about them. Adding the
next manufacturer is one vehicleSource adapter plus a line in
vehicleSources(): no new endpoints, no Web App changes.

  GET  /api/vehicle-providers                     providers + connect state
  GET  /api/vehicle-providers/{p}/vehicles        the caller's vehicles
  POST /api/vehicle-providers/{p}/import          create a car from one
  GET  /api/cars/{id}/provider                    live snapshot for the tab
  POST /api/cars/{id}/provider                    link / unlink a car
  POST /api/cars/{id}/provider/sync               re-apply provider data

Two properties shape it. Credentials are always the caller's own, resolved
through the same global -> org -> user cascade as the integration settings,
so a shared car shows provider data only when that vehicle is on the
viewer's account — the owner's credentials are never borrowed. And upstream
shapes are not modelled: these are unofficial APIs, so the layer searches
payloads by key name for the readings worth promoting (odometer, fuel,
battery, range) and flattens the rest to dotted key/value pairs alongside
the raw JSON. A renamed field costs one blank value, not a broken page.

The Toyota gate and its wording now live in toyotaSource, so the older
/api/integrations/toyota/vehicles endpoint and the new ones cannot drift.

Manager.InvokeBatchWith shares one transient plugin instance across a batch
of actions. The tab pulls seven capabilities, and InvokeWith builds a fresh
instance per call — which for a connector that authenticates lazily means a
fresh OAuth login per call. Batching logs in once.

cars gains provider + provider_vehicle_id (schema.go and
setup-pocketbase.mjs both). carPayload deliberately omits them, so an
ordinary car edit can neither reassign the car nor break its link;
carProviderPayload writes the link on its own.

Web App — Dashboard grows an "import from service" button beside "add car",
shown only once an account is connected, opening CarImportModal: pick the
vehicle, choose what to pull (identity / fuel type / dates / odometer, all
on by default), import. ProviderPanel becomes the car's first tab, ahead of
Information, labelled with the service: headline readings, the vehicle
record, one card per capability with its raw response, and an offer to take
the provider's odometer when it is ahead of the stored one. On an unlinked
car the tab instead offers to link it, VIN-matched. Info stays the default
selection — landing on the provider tab would fire a login on every car
page view. Full en/pl/da translations.

Tests cover the payload walking, Toyota normalization, import-selection
defaults, and — through the real handler chain against a stand-in
PocketBase — that every route is registered and that a closed gate is soft
on a listing (200 + a reason the UI can show) but hard on a write (4xx, so
a caller cannot read the reply as a created car).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:13:11 +02:00

495 lines
18 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
)
// Integrations exposes the Toyota Connected Europe plugin to end users under a
// three-layer cascade so that everyone can run it under their own MyToyota
// account, while a superadmin (and, in an organization, an org admin) can impose
// settings from above.
//
// Resolution is a cascade: the top layer wins, and a lower layer only fills a
// field the layers above left blank.
//
// - global (L1): the plugin's config in plugins.json, set in the API Server
// panel by a superadmin. This is the top of the cascade for everyone.
// - org (L2): pluginSettings.toyota on the caller's organization record,
// editable by an org admin. Present only for users who belong to an org.
// - user (L3): pluginSettings.toyota on the caller's own user record.
//
// So the effective cascade is:
// - org user: global → org → user
// - org-less user: global → user
//
// The MyToyota username + password resolve together as a *pair* from the highest
// layer that supplies a username, so credential halves are never mixed across
// layers. The brand resolves on its own. Enablement is strictly per-user (L3),
// gated by the global master switch (the plugin being enabled in the panel) and,
// for org users, by the org gate.
//
// Secrets (and inherited usernames) are never returned to a lower-privileged
// client: the effective config is resolved server-side and only masked values
// leave the API. The live probe runs server-side against the resolved config.
const (
toyotaPlugin = "toyota"
toyotaSecretMask = "••••••••"
)
// toyotaConfig is one layer's Toyota settings.
type toyotaConfig struct {
Username string `json:"username"`
Password string `json:"password"`
Brand string `json:"brand"`
}
// toyotaStored is what we persist per user/org under pluginSettings.toyota.
type toyotaStored struct {
Config toyotaConfig `json:"config"`
// Enabled is the personal per-user opt-in (user layer). Default false.
Enabled bool `json:"enabled"`
// Disabled is the organization layer's off switch, stored inverted so that
// absent == enabled. Only meaningful on the org record; ignored on user records.
Disabled bool `json:"disabled,omitempty"`
}
// toyotaSettingsDoc is the pluginSettings JSON shape (only toyota today).
type toyotaSettingsDoc struct {
Toyota toyotaStored `json:"toyota"`
}
// toyotaFieldView is one field's resolved state for the UI.
type toyotaFieldView struct {
Effective string `json:"effective"` // resolved value in force (secrets/inherited creds masked)
Own string `json:"own"` // the caller's own editable-layer value (secret masked)
Source string `json:"source"` // global | org | user | unset
Locked bool `json:"locked"` // set above the caller's editable layer
}
// toyotaResolution is the fully-resolved Toyota state for one caller. It captures
// the effective cascade plus both editable layers (personal + organization), so
// an org admin can manage each independently.
type toyotaResolution struct {
eff toyotaConfig // effective (unmasked) — used only server-side (probes)
userOwn toyotaConfig // caller's personal (L3) values (unmasked)
orgOwn toyotaConfig // organization (L2) values (unmasked)
source map[string]string // field -> layer name (global|org|user|unset)
isSuper bool // superadmin: manages the global layer in the panel
canOrg bool // caller may edit the organization layer (org admin)
available bool // global master switch (plugin enabled in the panel)
orgEnabled bool // org gate (default true; gates the org's users)
enabled bool // caller's personal enable flag
}
// toyotaLayerRank orders the cascade layers; a higher number is lower priority.
var toyotaLayerRank = map[string]int{"global": 1, "org": 2, "user": 3}
// resolveToyota computes the cascade for a caller. userRaw is the caller's
// pluginSettings blob (read from their user record).
func (s *Server) resolveToyota(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) toyotaResolution {
g, masterEnabled, _ := s.plugins.RawConfig(toyotaPlugin)
gc := toyotaConfig{Username: g["username"], Password: g["password"], Brand: g["brand"]}
var oStored toyotaStored
if who.OrgID != "" {
oStored, _ = s.orgToyota(ctx, who.OrgID)
}
oc := oStored.Config
var uStored toyotaStored
if len(userRaw) > 0 {
var d toyotaSettingsDoc
_ = json.Unmarshal(userRaw, &d)
uStored = d.Toyota
}
uc := uStored.Config
res := toyotaResolution{
source: map[string]string{},
userOwn: uc,
orgOwn: oc,
isSuper: who.isSuperadmin(),
// An org admin may edit the organization layer in addition to their own
// personal layer. Requires the service account (org writes go through it).
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.pb.Configured(),
available: masterEnabled,
orgEnabled: !oStored.Disabled, // default true; off only when the org disabled it
enabled: uStored.Enabled,
}
// Ordered layers, top (highest priority) first.
type layer struct {
name string
c toyotaConfig
}
layers := []layer{{"global", gc}}
if who.OrgID != "" {
layers = append(layers, layer{"org", oc})
}
layers = append(layers, layer{"user", uc})
// Brand resolves independently: the highest layer that sets it wins.
res.source["brand"] = "unset"
for _, l := range layers {
if v := strings.TrimSpace(l.c.Brand); v != "" {
res.eff.Brand, res.source["brand"] = v, l.name
break
}
}
// Credentials resolve as a pair from the highest layer with a username, so
// the username and password never come from different layers.
credSrc := "unset"
for _, l := range layers {
if u := strings.TrimSpace(l.c.Username); u != "" {
res.eff.Username, res.eff.Password, credSrc = u, l.c.Password, l.name
break
}
}
res.source["username"] = credSrc
res.source["password"] = credSrc
return res
}
// toyotaLockedFor reports whether a field whose value comes from source is locked
// for a caller whose editable layer is editable (i.e. the value is set above them).
func toyotaLockedFor(source, editable string) bool {
if editable == "none" {
return true // superadmin edits the global layer in the panel, not here
}
sr, ok := toyotaLayerRank[source]
if !ok {
return false // unset — the caller may be the first to set it
}
return sr < toyotaLayerRank[editable]
}
// toyotaMaskPresent returns the secret mask when v is non-empty, else "".
func toyotaMaskPresent(v string) string {
if strings.TrimSpace(v) != "" {
return toyotaSecretMask
}
return ""
}
// orgToyota reads an organization's stored Toyota settings (config + the org
// gate) and its raw pluginSettings blob via the service account. Best effort:
// zero values on any miss so callers proceed as if the org layer were empty.
func (s *Server) orgToyota(ctx context.Context, orgID string) (toyotaStored, json.RawMessage) {
if orgID == "" || !s.pb.Configured() {
return toyotaStored{}, nil
}
data, status, err := s.pb.Raw(ctx, http.MethodGet,
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
if err != nil || status != http.StatusOK {
return toyotaStored{}, nil
}
var rec struct {
PluginSettings json.RawMessage `json:"pluginSettings"`
}
_ = json.Unmarshal(data, &rec)
var doc toyotaSettingsDoc
if len(rec.PluginSettings) > 0 {
_ = json.Unmarshal(rec.PluginSettings, &doc)
}
return doc.Toyota, rec.PluginSettings
}
// userPluginSettings reads a user's raw pluginSettings blob via the service
// account. Best effort: nil on any miss.
func (s *Server) userPluginSettings(ctx context.Context, id string) json.RawMessage {
if id == "" || !s.pb.Configured() {
return nil
}
var rec struct {
PluginSettings json.RawMessage `json:"pluginSettings"`
}
if err := s.pb.GetOne(ctx, s.usersCollection(), id, &rec); err != nil {
return nil
}
return rec.PluginSettings
}
// mergeToyota applies a mutation to the toyota entry of a pluginSettings blob,
// preserving any other plugin keys, and returns the new blob.
func mergeToyota(existing json.RawMessage, apply func(*toyotaStored)) json.RawMessage {
doc := map[string]json.RawMessage{}
if len(existing) > 0 {
_ = json.Unmarshal(existing, &doc)
}
if doc == nil {
doc = map[string]json.RawMessage{} // existing was JSON null
}
var ts toyotaStored
if raw, ok := doc["toyota"]; ok {
_ = json.Unmarshal(raw, &ts)
}
apply(&ts)
b, _ := json.Marshal(ts)
doc["toyota"] = b
out, _ := json.Marshal(doc)
return out
}
// toyotaScopeView builds the masked field set for one editable scope. editable is
// the layer the caller edits in this scope ("user" | "org" | "none"); a field is
// locked when its effective value is set above that layer.
func (s *Server) toyotaScopeView(res toyotaResolution, editable string) map[string]any {
own := res.userOwn
if editable == "org" {
own = res.orgOwn
}
field := func(key, eff, ownv string, secret bool) toyotaFieldView {
src := res.source[key]
locked := toyotaLockedFor(src, editable)
fv := toyotaFieldView{Source: src, Locked: locked}
switch {
case secret:
// Never expose a secret; show only presence.
fv.Effective, fv.Own = toyotaMaskPresent(eff), toyotaMaskPresent(ownv)
case key == "username" && locked:
// Inherited username — hide the concrete value from a lower layer.
fv.Effective, fv.Own = toyotaMaskPresent(eff), toyotaMaskPresent(ownv)
default:
fv.Effective, fv.Own = eff, ownv
}
return fv
}
return map[string]any{
"editableLayer": editable,
"fields": map[string]toyotaFieldView{
"username": field("username", res.eff.Username, own.Username, false),
"password": field("password", res.eff.Password, own.Password, true),
"brand": field("brand", res.eff.Brand, own.Brand, false),
},
}
}
// toyotaView builds the masked, client-safe response body from a resolution. It
// exposes a "user" scope for everyone plus, for org admins, an "org" scope — each
// with its own locked-field state — so the UI can present the two independently.
func (s *Server) toyotaView(who *callerIdentity, res toyotaResolution) map[string]any {
out := map[string]any{
"available": res.available,
"orgEnabled": res.orgEnabled,
"enabled": res.enabled,
"role": who.Role,
"orgId": who.OrgID,
"canEditOrg": res.canOrg,
"isSuperadmin": res.isSuper,
}
if res.isSuper {
// Superadmin manages the global layer in the panel; here it is read-only.
out["editableLayer"] = "none"
out["scopes"] = map[string]any{"user": s.toyotaScopeView(res, "none")}
return out
}
scopes := map[string]any{"user": s.toyotaScopeView(res, "user")}
if res.canOrg {
scopes["org"] = s.toyotaScopeView(res, "org")
}
out["scopes"] = scopes
return out
}
// GET /api/integrations/toyota — resolved Toyota view for the caller.
func (s *Server) handleGetToyota(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
res := s.resolveToyota(r.Context(), who, userRaw)
writeJSON(w, http.StatusOK, s.toyotaView(who, res))
}
// PUT /api/integrations/toyota — save the caller's editable layer. Body:
// {enabled?: bool, scope?: "user"|"org", config?: {username, password, brand}}.
// Fields locked above the caller are ignored; a password left at the mask is
// preserved.
func (s *Server) handlePutToyota(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
if !s.pb.Configured() {
writeError(w, http.StatusServiceUnavailable, "integration settings not configured on the server")
return
}
var body struct {
Enabled *bool `json:"enabled"`
Scope string `json:"scope"` // "user" (default) | "org" (admins only)
Config map[string]string `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
res := s.resolveToyota(r.Context(), who, userRaw)
// Resolve which layer this write targets. Everyone edits their own personal
// (user) layer by default; an org admin may target the organization layer by
// asking for scope "org". Superadmins are read-only here (they manage global
// in the panel) and may only toggle their personal enable flag.
editable := "user"
switch {
case res.isSuper:
editable = "none"
case strings.EqualFold(strings.TrimSpace(body.Scope), "org"):
if !res.canOrg {
writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings")
return
}
editable = "org"
}
// Build the new target-layer config from its current own values, overlaying
// only fields the caller is allowed to change in this scope.
newOwn := res.userOwn
if editable == "org" {
newOwn = res.orgOwn
}
applyField := func(key string, set func(*toyotaConfig, string)) {
v, ok := body.Config[key]
if !ok || toyotaLockedFor(res.source[key], editable) {
return
}
if key == "password" && v == toyotaSecretMask {
return // keep current secret
}
set(&newOwn, strings.TrimSpace(v))
}
applyField("username", func(c *toyotaConfig, v string) { c.Username = v })
applyField("password", func(c *toyotaConfig, v string) { c.Password = v })
applyField("brand", func(c *toyotaConfig, v string) { c.Brand = v })
// Persist the organization layer (admins) via the service account.
if editable == "org" {
_, orgRaw := s.orgToyota(r.Context(), who.OrgID)
newDoc := mergeToyota(orgRaw, func(ts *toyotaStored) {
ts.Config = newOwn
// In the org scope the enable flag is the org master switch, stored
// inverted (disabled) so absent means enabled.
if body.Enabled != nil {
ts.Disabled = !*body.Enabled
}
})
_, st, err := s.pb.Raw(r.Context(), http.MethodPatch,
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(who.OrgID),
map[string]json.RawMessage{"pluginSettings": newDoc})
if err != nil {
writeUpstreamDown(w, err)
return
}
if st != http.StatusOK {
writeError(w, http.StatusBadGateway, "could not save organization settings")
return
}
}
// Persist the user record: the personal enable flag lives here (user/
// superadmin scope — in the org scope it targets the org gate instead), and
// so does the personal config layer when this write targets the user scope.
personalEnable := body.Enabled != nil && editable != "org"
if personalEnable || editable == "user" {
newDoc := mergeToyota(userRaw, func(ts *toyotaStored) {
if personalEnable {
ts.Enabled = *body.Enabled
}
if editable == "user" {
ts.Config = newOwn
}
})
if err := s.pb.Update(r.Context(), s.usersCollection(), who.ID,
map[string]any{"pluginSettings": newDoc}, nil); err != nil {
writePBError(w, err)
return
}
}
// Re-resolve and return the fresh view.
fresh := s.userPluginSettings(r.Context(), who.ID)
res2 := s.resolveToyota(r.Context(), who, fresh)
writeJSON(w, http.StatusOK, s.toyotaView(who, res2))
}
// POST /api/integrations/toyota/health — live probe using the caller's resolved
// config. Never returns secrets.
func (s *Server) handleToyotaHealth(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
res := s.resolveToyota(r.Context(), who, userRaw)
down := func(detail string) {
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{"status": "down", "detail": detail}})
}
switch {
case !res.available:
down("The Toyota integration is disabled by the administrator")
return
case !res.orgEnabled:
down("The Toyota integration is disabled for your organization")
return
case strings.TrimSpace(res.eff.Username) == "" || strings.TrimSpace(res.eff.Password) == "":
down("Enter your MyToyota email and password to connect")
return
}
cfg := map[string]string{
"username": res.eff.Username,
"password": res.eff.Password,
"brand": res.eff.Brand,
}
h, err := s.plugins.HealthCheckWith(r.Context(), toyotaPlugin, cfg)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"health": h})
}
// GET /api/integrations/toyota/vehicles — the caller's Toyota vehicles as the
// upstream returned them, fetched server-side under their resolved credentials.
// Gated by the same switches as the settings view (global master, org gate,
// personal opt-in, credentials present); when any gate is off it returns 200 with
// an empty list plus a reason, so the UI can degrade quietly rather than error.
//
// The gate and its wording live in toyotaSource (vehicleproviders_toyota.go), so
// this endpoint and the generic vehicle-provider endpoints cannot drift apart.
// This one stays because it relays the raw payload; /api/vehicle-providers/
// toyota/vehicles returns the normalized, importable shape.
func (s *Server) handleToyotaVehicles(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
cfg, ok, detail := toyotaSource{}.gate(r.Context(), s, who, userRaw)
if !ok {
writeJSON(w, http.StatusOK, map[string]any{"vehicles": []any{}, "unavailable": true, "detail": detail})
return
}
raw, err := s.plugins.InvokeWith(r.Context(), toyotaPlugin, cfg, "vehicles", nil)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
// Relay the upstream JSON verbatim under "vehicles".
writeJSON(w, http.StatusOK, map[string]any{"vehicles": json.RawMessage(raw)})
}