Add OpenWeather plugin with full Web App settings cascade

Builtin API Server connector for the OpenWeather API (current weather +
5-day forecast for a point, API-key auth, stdlib-only), surfaced to end
users through the same three-layer settings cascade as OpenSky/WebDAV.

- New builtin plugin (internal/plugins/builtin/openweather) with
  health probe, weather.current/forecast.5day capabilities, and tests.
- resolveOpenWeather cascade (global -> org -> user) with per-field
  independent resolution and API-key masking; GET/PUT/health endpoints.
- Web App BFF relays, api.js client, and a Settings card under
  APIs - External (scope switch, enable toggles, test connection).
- Resolver unit tests + rebuilt embedded frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-14 11:21:48 +02:00
co-authored by Claude Opus 4.8
parent 816db9b42f
commit 6231b43076
14 changed files with 1379 additions and 25 deletions
@@ -0,0 +1,423 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
)
// This file exposes the "openweather" plugin's settings to end users through the
// same three-layer cascade OpenSky uses (superadmin/global → organization →
// user); see integrations.go for the shared helpers (lockedFor, layerRank,
// maskPresent, callerFromRecord) and integrations_webdav.go for the endpoint shape.
//
// - global (L1): the plugin's config in plugins.json, set in the API Server panel.
// - org (L2): pluginSettings.openweather on the caller's organization record.
// - user (L3): pluginSettings.openweather on the caller's own user record.
//
// Unlike WebDAV (a connection group), every OpenWeather field resolves
// *independently* — top layer wins, a blank field falls through. There is a
// single secret (the API key) with no paired half, so no field group is needed.
//
// The API key is never returned to a lower-privileged client: the effective
// config is resolved server-side and only masked values leave the API. Live
// probes run server-side against the resolved config.
const openWeatherPlugin = "openweather"
// owFields are the plugin's ConfigField keys, in display order. Kept in sync with
// the plugin descriptor so the cascade covers every setting.
var owFields = []string{"apiKey", "units", "lat", "lon", "lang"}
// owSecretKeys are masked in every view and preserved on save when left at the mask.
var owSecretKeys = map[string]bool{"apiKey": true}
// owConfig is one layer's openweather settings. Values are strings to match the
// plugin's ConfigField keys 1:1 (they are handed straight to plugins.Init).
type owConfig struct {
APIKey string `json:"apiKey"`
Units string `json:"units"`
Lat string `json:"lat"`
Lon string `json:"lon"`
Lang string `json:"lang"`
}
// owStored is what we persist per user/org under pluginSettings.openweather.
type owStored struct {
Config owConfig `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 (mirrors OpenSky). Only meaningful on the org record.
Disabled bool `json:"disabled,omitempty"`
}
// owSettingsDoc is the openweather slice of the shared pluginSettings JSON.
type owSettingsDoc struct {
OpenWeather owStored `json:"openweather"`
}
// owResolution is the fully-resolved openweather state for one caller.
type owResolution struct {
eff owConfig // effective (unmasked) — used only server-side (probes)
userOwn owConfig // caller's personal (L3) values (unmasked)
orgOwn owConfig // 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 master switch (default true; gates the org's users)
enabled bool // caller's personal enable flag
}
// owConfigFromMap builds an owConfig from a flat string map (global plugin config).
func owConfigFromMap(m map[string]string) owConfig {
return owConfig{APIKey: m["apiKey"], Units: m["units"], Lat: m["lat"], Lon: m["lon"], Lang: m["lang"]}
}
// owGet returns a config field by the plugin's key name.
func owGet(c owConfig, key string) string {
switch key {
case "apiKey":
return c.APIKey
case "units":
return c.Units
case "lat":
return c.Lat
case "lon":
return c.Lon
case "lang":
return c.Lang
}
return ""
}
// owSet writes a config field by the plugin's key name.
func owSet(c *owConfig, key, v string) {
switch key {
case "apiKey":
c.APIKey = v
case "units":
c.Units = v
case "lat":
c.Lat = v
case "lon":
c.Lon = v
case "lang":
c.Lang = v
}
}
// resolveOpenWeather computes the cascade for a caller. userRaw is the caller's
// pluginSettings blob (from their auth-refresh record).
func (s *Server) resolveOpenWeather(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) owResolution {
g, masterEnabled, _ := s.plugins.RawConfig(openWeatherPlugin)
gc := owConfigFromMap(g)
var oStored owStored
if who.OrgID != "" {
oStored, _ = s.orgOpenWeather(ctx, who.OrgID)
}
oc := oStored.Config
var uStored owStored
if len(userRaw) > 0 {
var d owSettingsDoc
_ = json.Unmarshal(userRaw, &d)
uStored = d.OpenWeather
}
uc := uStored.Config
res := owResolution{
source: map[string]string{},
userOwn: uc,
orgOwn: oc,
isSuper: who.isSuperadmin(),
// An org admin may edit the organization layer in addition to their own.
// Requires the service account (org writes go through it).
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(),
available: masterEnabled,
orgEnabled: !oStored.Disabled,
enabled: uStored.Enabled,
}
// Ordered layers, top (highest priority) first.
type layer struct {
name string
c owConfig
}
layers := []layer{{"global", gc}}
if who.OrgID != "" {
layers = append(layers, layer{"org", oc})
}
layers = append(layers, layer{"user", uc})
// Every field cascades independently: top wins, blanks fall through.
for _, key := range owFields {
src := "unset"
for _, l := range layers {
if v := strings.TrimSpace(owGet(l.c, key)); v != "" {
owSet(&res.eff, key, v)
src = l.name
break
}
}
res.source[key] = src
}
return res
}
// orgOpenWeather reads an organization's stored openweather 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) orgOpenWeather(ctx context.Context, orgID string) (owStored, json.RawMessage) {
if orgID == "" || !s.admin.configured() {
return owStored{}, nil
}
data, status, err := s.admin.do(ctx, http.MethodGet,
"/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
if err != nil || status != http.StatusOK {
return owStored{}, nil
}
var rec struct {
PluginSettings json.RawMessage `json:"pluginSettings"`
}
_ = json.Unmarshal(data, &rec)
var doc owSettingsDoc
if len(rec.PluginSettings) > 0 {
_ = json.Unmarshal(rec.PluginSettings, &doc)
}
return doc.OpenWeather, rec.PluginSettings
}
// mergeOpenWeather applies a mutation to the openweather entry of a pluginSettings
// blob, preserving any other plugin keys (e.g. opensky, webdav), and returns the
// new blob.
func mergeOpenWeather(existing json.RawMessage, apply func(*owStored)) 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 ow owStored
if raw, ok := doc["openweather"]; ok {
_ = json.Unmarshal(raw, &ow)
}
apply(&ow)
b, _ := json.Marshal(ow)
doc["openweather"] = b
out, _ := json.Marshal(doc)
return out
}
// GET /api/integrations/openweather — resolved view for the caller.
func (s *Server) handleGetOpenWeather(w http.ResponseWriter, r *http.Request) {
who, userRaw, ok := s.integrationCaller(w, r)
if !ok {
return
}
res := s.resolveOpenWeather(r.Context(), who, userRaw)
writeJSON(w, http.StatusOK, s.openWeatherView(who, res))
}
// owScopeView builds the masked field set for one editable scope. editable is the
// layer the caller edits ("user" | "org" | "none"); a field is locked when its
// effective value is set above that layer.
func (s *Server) owScopeView(res owResolution, editable string) map[string]any {
own := res.userOwn
if editable == "org" {
own = res.orgOwn
}
fields := map[string]osFieldView{}
for _, key := range owFields {
src := res.source[key]
fv := osFieldView{Source: src, Locked: lockedFor(src, editable)}
if owSecretKeys[key] {
fv.Effective, fv.Own = maskPresent(owGet(res.eff, key)), maskPresent(owGet(own, key))
} else {
fv.Effective, fv.Own = owGet(res.eff, key), owGet(own, key)
}
fields[key] = fv
}
return map[string]any{"editableLayer": editable, "fields": fields}
}
// openWeatherView builds the masked, client-safe response body. It exposes a
// "user" scope for everyone plus, for org admins, an "org" scope.
func (s *Server) openWeatherView(who *callerIdentity, res owResolution) 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 {
out["editableLayer"] = "none"
out["scopes"] = map[string]any{"user": s.owScopeView(res, "none")}
return out
}
scopes := map[string]any{"user": s.owScopeView(res, "user")}
if res.canOrg {
scopes["org"] = s.owScopeView(res, "org")
}
out["scopes"] = scopes
return out
}
// PUT /api/integrations/openweather — save the caller's editable layer. Body:
// {enabled?, scope?, config?}. Fields locked above the caller are ignored; the
// API key left at the mask is preserved.
func (s *Server) handlePutOpenWeather(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
var body struct {
Enabled *bool `json:"enabled"`
Scope string `json:"scope"`
Config map[string]string `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
who, userRaw, ok := s.integrationCaller(w, r)
if !ok {
return
}
res := s.resolveOpenWeather(r.Context(), who, userRaw)
// Resolve which layer this write targets.
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"
}
// Overlay the fields the caller may change in this scope onto its own values.
newOwn := res.userOwn
if editable == "org" {
newOwn = res.orgOwn
}
for _, key := range owFields {
v, present := body.Config[key]
if !present || lockedFor(res.source[key], editable) {
continue
}
if owSecretKeys[key] && v == openSkySecretMask {
continue // keep current secret
}
owSet(&newOwn, key, strings.TrimSpace(v))
}
// Persist the organization layer (admins) via the service account.
if editable == "org" {
if who.OrgID == "" {
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
return
}
if !s.admin.configured() {
writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server")
return
}
_, orgRaw := s.orgOpenWeather(r.Context(), who.OrgID)
newDoc := mergeOpenWeather(orgRaw, func(ow *owStored) {
ow.Config = newOwn
if body.Enabled != nil {
ow.Disabled = !*body.Enabled // org master switch, stored inverted
}
})
_, st, err := s.admin.do(r.Context(), http.MethodPatch,
"/api/collections/organizations/records/"+url.PathEscape(who.OrgID),
map[string]json.RawMessage{"pluginSettings": newDoc})
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
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), and so does the personal config layer when this write targets user.
personalEnable := body.Enabled != nil && editable != "org"
if personalEnable || editable == "user" {
newDoc := mergeOpenWeather(userRaw, func(ow *owStored) {
if personalEnable {
ow.Enabled = *body.Enabled
}
if editable == "user" {
ow.Config = newOwn
}
})
if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
} else if code != http.StatusOK {
writeError(w, http.StatusBadGateway, "could not save user settings")
return
}
}
// Re-resolve and return the fresh view.
fresh, st, err := s.pbAuthRefresh(r.Context(), token)
if err != nil || st != http.StatusOK || fresh == nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
res2 := s.resolveOpenWeather(r.Context(), who, fresh.Record["pluginSettings"])
writeJSON(w, http.StatusOK, s.openWeatherView(who, res2))
}
// POST /api/integrations/openweather/health — live probe using the caller's
// resolved config. Never returns the API key.
func (s *Server) handleOpenWeatherHealth(w http.ResponseWriter, r *http.Request) {
who, userRaw, ok := s.integrationCaller(w, r)
if !ok {
return
}
if !who.isSuperadmin() {
if _, _, ok := s.plugins.RawConfig(openWeatherPlugin); !ok {
writeError(w, http.StatusNotFound, "unknown plugin")
return
}
}
res := s.resolveOpenWeather(r.Context(), who, userRaw)
if !res.available {
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
"status": "down", "detail": "OpenWeather is disabled by the administrator"}})
return
}
if !res.orgEnabled {
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
"status": "down", "detail": "OpenWeather is disabled for your organization"}})
return
}
if strings.TrimSpace(res.eff.APIKey) == "" {
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
"status": "down", "detail": "No API key configured — add one to connect"}})
return
}
cfg := map[string]string{}
for _, k := range owFields {
cfg[k] = owGet(res.eff, k)
}
h, err := s.plugins.HealthCheckWith(r.Context(), openWeatherPlugin, cfg)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"health": h})
}
@@ -0,0 +1,145 @@
package api
import (
"context"
"encoding/json"
"testing"
"pilotvault/apiserver/internal/plugins"
)
// newOWServer builds a minimal Server whose plugin manager has openweather
// enabled with the given global config. admin is left nil (not configured), so
// the org layer is skipped and the cascade covers global + user only.
func newOWServer(t *testing.T, global map[string]string) *Server {
t.Helper()
mgr := plugins.NewManager(t.TempDir() + "/plugins.json")
if _, err := mgr.Upsert(context.Background(), openWeatherPlugin, true, global); err != nil {
t.Fatalf("enable openweather: %v", err)
}
return &Server{plugins: mgr}
}
// userRaw builds a pluginSettings blob with an openweather user layer.
func userRaw(t *testing.T, cfg owConfig, enabled bool) json.RawMessage {
t.Helper()
b, err := json.Marshal(owSettingsDoc{OpenWeather: owStored{Config: cfg, Enabled: enabled}})
if err != nil {
t.Fatal(err)
}
return b
}
func TestResolveOpenWeatherCascade(t *testing.T) {
// Global supplies the API key + units and leaves the location blank so the
// user layer fills it in. (A value set globally locks the lower layers — that
// is verified separately by the masked/locked apiKey below.)
s := newOWServer(t, map[string]string{"apiKey": "GLOBAL-KEY", "units": "metric"})
who := &callerIdentity{ID: "u1", Role: roleUser} // org-less
uRaw := userRaw(t, owConfig{Lat: "51.5", Lon: "-0.12"}, true)
res := s.resolveOpenWeather(context.Background(), who, uRaw)
if !res.available {
t.Error("available should be true when the plugin is enabled")
}
if !res.enabled {
t.Error("enabled should reflect the user's personal opt-in")
}
// API key + units come from global (user left them blank).
if res.eff.APIKey != "GLOBAL-KEY" || res.source["apiKey"] != "global" {
t.Errorf("apiKey = %q src %q, want GLOBAL-KEY/global", res.eff.APIKey, res.source["apiKey"])
}
if res.eff.Units != "metric" || res.source["units"] != "global" {
t.Errorf("units = %q src %q, want metric/global", res.eff.Units, res.source["units"])
}
// Location comes from the user layer (top-wins over the global default).
if res.eff.Lat != "51.5" || res.source["lat"] != "user" {
t.Errorf("lat = %q src %q, want 51.5/user", res.eff.Lat, res.source["lat"])
}
if res.eff.Lon != "-0.12" || res.source["lon"] != "user" {
t.Errorf("lon = %q src %q, want -0.12/user", res.eff.Lon, res.source["lon"])
}
}
// A field set globally locks the lower layers: even when the user supplies units,
// the global value stays in force and is sourced to "global".
func TestResolveOpenWeatherGlobalLocksUser(t *testing.T) {
s := newOWServer(t, map[string]string{"apiKey": "K", "units": "metric"})
who := &callerIdentity{ID: "u1", Role: roleUser}
uRaw := userRaw(t, owConfig{Units: "imperial"}, false)
res := s.resolveOpenWeather(context.Background(), who, uRaw)
if res.eff.Units != "metric" || res.source["units"] != "global" {
t.Errorf("units = %q src %q, want metric/global (global must lock the user's imperial)", res.eff.Units, res.source["units"])
}
}
// The view must never leak the concrete API key — only presence, masked.
func TestOpenWeatherViewMasksKey(t *testing.T) {
s := newOWServer(t, map[string]string{"apiKey": "GLOBAL-KEY", "units": "metric"})
who := &callerIdentity{ID: "u1", Role: roleUser}
res := s.resolveOpenWeather(context.Background(), who, nil)
view := s.openWeatherView(who, res)
scopes := view["scopes"].(map[string]any)
user := scopes["user"].(map[string]any)
fields := user["fields"].(map[string]osFieldView)
if got := fields["apiKey"].Effective; got != openSkySecretMask {
t.Errorf("apiKey effective = %q, want the mask (never the raw key)", got)
}
if fields["apiKey"].Source != "global" || !fields["apiKey"].Locked {
t.Errorf("apiKey field = %+v, want source global + locked for a plain user", fields["apiKey"])
}
// A non-secret field is shown in the clear.
if fields["units"].Effective != "metric" {
t.Errorf("units effective = %q, want metric", fields["units"].Effective)
}
}
// mergeOpenWeather must preserve sibling plugin keys (opensky/webdav) untouched.
func TestMergeOpenWeatherPreservesSiblings(t *testing.T) {
existing := json.RawMessage(`{"opensky":{"enabled":true},"webdav":{"config":{"baseURL":"https://x"}}}`)
out := mergeOpenWeather(existing, func(ow *owStored) {
ow.Config.APIKey = "K"
ow.Enabled = true
})
var doc map[string]json.RawMessage
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatal(err)
}
if _, ok := doc["opensky"]; !ok {
t.Error("opensky key was dropped by mergeOpenWeather")
}
if _, ok := doc["webdav"]; !ok {
t.Error("webdav key was dropped by mergeOpenWeather")
}
var ow owStored
if err := json.Unmarshal(doc["openweather"], &ow); err != nil {
t.Fatal(err)
}
if ow.Config.APIKey != "K" || !ow.Enabled {
t.Errorf("openweather entry = %+v, want APIKey K + enabled", ow)
}
}
// A saved secret left at the mask on PUT must not be re-checked here, but the
// merge/keep logic lives in the handler; this guards the field/secret metadata
// the handler relies on stays consistent with the plugin descriptor.
func TestOpenWeatherFieldMetadata(t *testing.T) {
if !owSecretKeys["apiKey"] {
t.Error("apiKey must be a secret key")
}
// Every declared field must round-trip through owGet/owSet.
var c owConfig
for _, k := range owFields {
owSet(&c, k, "v-"+k)
}
for _, k := range owFields {
if owGet(c, k) != "v-"+k {
t.Errorf("owGet/owSet mismatch for %q", k)
}
}
}
+3
View File
@@ -112,6 +112,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/integrations/webdav", s.handleGetWebDav)
mux.HandleFunc("PUT /api/integrations/webdav", s.handlePutWebDav)
mux.HandleFunc("POST /api/integrations/webdav/health", s.handleWebDavHealth)
mux.HandleFunc("GET /api/integrations/openweather", s.handleGetOpenWeather)
mux.HandleFunc("PUT /api/integrations/openweather", s.handlePutOpenWeather)
mux.HandleFunc("POST /api/integrations/openweather/health", s.handleOpenWeatherHealth)
// User-management — gated on the caller being a manager (admin or superadmin).
// Admins are scoped to their own organization inside each handler.