diff --git a/.gitignore b/.gitignore index 36ea1ed..b814829 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ node_modules/ # Left behind by `pip install .` of the engine (setuptools metadata), not source. /behavision.egg-info/ /.prod/ +/.demo/ diff --git a/agent/main.go b/agent/main.go index c1a8339..814168a 100644 --- a/agent/main.go +++ b/agent/main.go @@ -161,6 +161,7 @@ func cmdStatus() error { // which is the default. Reading it here is what stops every call the agent // makes to the engine coming back 401 on a stock install. cfg = cfg.WithEngineCredentials(paths.APICredentials()) + creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword) q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax) if err != nil { return err @@ -169,7 +170,7 @@ func cmdStatus() error { Command: func(context.Context) *exec.Cmd { return nil }, HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health", StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats", - User: cfg.APIUser, Password: cfg.APIPassword, + User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -204,6 +205,7 @@ func cmdRun() error { // which is the default. Reading it here is what stops every call the agent // makes to the engine coming back 401 on a stock install. cfg = cfg.WithEngineCredentials(paths.APICredentials()) + creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword) // Opened before the engine starts: detections arriving in the first second // must have somewhere to land. q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax) @@ -244,7 +246,7 @@ func cmdRun() error { LogWriter: logFile, HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health", StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats", - User: cfg.APIUser, Password: cfg.APIPassword, + User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds, }) ctx, stop := signal.NotifyContext(context.Background(), @@ -279,6 +281,7 @@ func cmdRun() error { cloud := cameras.NewCloudClient(cfg.CloudBase, cfg.AgentToken) cloud.Upload = uploader.UploadBytes eng := cameras.NewEngineClient(cfg.APIBase, cfg.APIUser, cfg.APIPassword) + eng.Creds = creds go cameras.New(eng, cloud, logger).Run(ctx) // The live relay, which uploads nothing until somebody at head office is // actually watching a camera. diff --git a/agent/pkg/cameras/clients.go b/agent/pkg/cameras/clients.go index f1854d7..5a83fac 100644 --- a/agent/pkg/cameras/clients.go +++ b/agent/pkg/cameras/clients.go @@ -14,6 +14,7 @@ import ( "time" "github.com/loyaly/behavision-agent/pkg/bridge" + "github.com/loyaly/behavision-agent/pkg/config" ) // EngineClient talks to the recognition engine on this PC's loopback. @@ -21,7 +22,12 @@ type EngineClient struct { Base string User string Password string - Client *http.Client + // Creds re-reads the engine's generated credential when one is rejected. + // Without it a fresh install is 401 for the life of the process: the agent + // starts the engine, and the engine writes its credential file seconds + // after the agent has already read (and failed to find) it. + Creds *config.Creds + Client *http.Client } func NewEngineClient(base, user, password string) *EngineClient { @@ -50,14 +56,24 @@ func (e *EngineClient) do(ctx context.Context, method, path string, body, out an if body != nil { req.Header.Set("Content-Type", "application/json") } - if e.User != "" { - req.SetBasicAuth(e.User, e.Password) + user, pass := e.User, e.Password + if e.Creds != nil { + user, pass = e.Creds.Get() + } + if user != "" { + req.SetBasicAuth(user, pass) } resp, err := e.Client.Do(req) if err != nil { return err } defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized && e.Creds != nil && e.Creds.Refresh() { + // The engine generated its credential after we last looked. Read it + // and try once more rather than failing for the life of the process. + resp.Body.Close() + return e.do(ctx, method, path, body, out) + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { // The engine's message, not just a status. "camera stored but failed to // start: connection refused" is something an operator can act on; diff --git a/agent/pkg/config/credentials.go b/agent/pkg/config/credentials.go index 93b75a4..a2137cf 100644 --- a/agent/pkg/config/credentials.go +++ b/agent/pkg/config/credentials.go @@ -4,6 +4,7 @@ import ( "bufio" "os" "strings" + "sync" ) // EngineCredentials reads the Basic credentials the engine generated for @@ -60,3 +61,60 @@ func (c Config) WithEngineCredentials(path string) Config { c.APIUser, c.APIPassword = EngineCredentials(path) return c } + +// Creds resolves the engine's Basic credentials, re-reading the file when it +// has none. +// +// Reading once at startup is wrong on a fresh install, and that is the case +// that matters: the agent starts the engine, the engine generates its +// credential and writes the file a few seconds later, and an agent that read +// the file before that holds "" forever. Every call it makes - health, stats, +// camera sync, the embedding for a visit - then comes back 401 for the life of +// the process, on a brand new shop PC, with the tray showing a red engine that +// is running perfectly. Measured on a fresh state directory: three 401s and no +// camera ever reconciled. +// +// A configured credential is never re-read: an operator who set +// BEHAVISION_API_USER means it. +type Creds struct { + path string + mu sync.Mutex + user string + pass string + fixed bool +} + +// NewCreds takes whatever the config already has. Non-empty means configured, +// and is used unchanged. +func NewCreds(path, user, password string) *Creds { + c := &Creds{path: path, user: user, pass: password} + c.fixed = user != "" || password != "" + return c +} + +// Get returns the current pair, reading the file if it has nothing yet. +func (c *Creds) Get() (string, string) { + c.mu.Lock() + defer c.mu.Unlock() + if c.user == "" && !c.fixed { + c.user, c.pass = EngineCredentials(c.path) + } + return c.user, c.pass +} + +// Refresh re-reads the file after a rejection and reports whether the pair +// changed. Callers retry once when it did - which covers both the fresh-install +// race and a credential the engine regenerated under a running agent. +func (c *Creds) Refresh() bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.fixed { + return false + } + u, p := EngineCredentials(c.path) + if u == c.user && p == c.pass { + return false + } + c.user, c.pass = u, p + return u != "" +} diff --git a/agent/pkg/config/credentials_firstrun_test.go b/agent/pkg/config/credentials_firstrun_test.go new file mode 100644 index 0000000..1c3c619 --- /dev/null +++ b/agent/pkg/config/credentials_firstrun_test.go @@ -0,0 +1,77 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// The sequence on a brand new shop PC, in order: +// +// agent starts -> file does not exist yet +// agent starts the engine +// engine generates its credential and writes the file +// agent calls the engine -> must now succeed +// +// Read once at startup, the agent holds "" for the life of the process and +// every engine call is 401: health, stats, camera sync, the embedding for a +// visit. The tray shows a red engine that is running perfectly, and nothing +// says why. Measured on a fresh state directory before this existed. +func TestCredentialsArriveAfterTheAgentHasAlreadyLooked(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "api_credentials.txt") + + creds := NewCreds(path, "", "") // nothing configured, file not there yet + if u, _ := creds.Get(); u != "" { + t.Fatalf("expected no credential before the engine has written one, got %q", u) + } + + // the engine starts and writes its credential + if err := os.WriteFile(path, []byte("username=behavision\npassword=s3cret\n"), 0o600); err != nil { + t.Fatal(err) + } + + // a 401 makes the agent look again + if !creds.Refresh() { + t.Fatal("Refresh did not pick up the credential the engine just wrote") + } + u, p := creds.Get() + if u != "behavision" || p != "s3cret" { + t.Fatalf("got %q/%q", u, p) + } +} + +// An operator who set BEHAVISION_API_USER means it, and a file must never +// override them. +func TestAConfiguredCredentialIsNeverReplacedByTheFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "api_credentials.txt") + if err := os.WriteFile(path, []byte("username=generated\npassword=nope\n"), 0o600); err != nil { + t.Fatal(err) + } + creds := NewCreds(path, "chosen", "byhand") + if u, p := creds.Get(); u != "chosen" || p != "byhand" { + t.Fatalf("configured credential was replaced: %q/%q", u, p) + } + if creds.Refresh() { + t.Fatal("Refresh overrode a configured credential") + } +} + +// A credential the engine regenerates under a running agent is picked up too - +// the same mechanism, and the reason paths.APICredentials says the agent reads +// the file "rather than storing a second copy". +func TestARegeneratedCredentialIsPickedUp(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "api_credentials.txt") + os.WriteFile(path, []byte("username=behavision\npassword=old\n"), 0o600) + creds := NewCreds(path, "", "") + creds.Get() + os.WriteFile(path, []byte("username=behavision\npassword=new\n"), 0o600) + if !creds.Refresh() { + t.Fatal("a regenerated password was not picked up") + } + if _, p := creds.Get(); p != "new" { + t.Fatalf("still holding %q", p) + } +} diff --git a/agent/pkg/engine/supervisor.go b/agent/pkg/engine/supervisor.go index a7df0c7..d25d63b 100644 --- a/agent/pkg/engine/supervisor.go +++ b/agent/pkg/engine/supervisor.go @@ -25,6 +25,8 @@ import ( "strconv" "strings" "sync" + + "github.com/loyaly/behavision-agent/pkg/config" "time" ) @@ -59,6 +61,9 @@ type Options struct { // no captured output is undiagnosable, which on a customer site means a // site visit. LogWriter io.Writer + // Creds re-reads the engine's generated credential when one is rejected, + // which is the ordinary case on a first run. + Creds *config.Creds // HealthURL, StatsURL, User, Password address the engine's own API. HealthURL string StatsURL string @@ -409,14 +414,24 @@ func (s *Supervisor) getJSON(ctx context.Context, url string, out any) error { if err != nil { return err } - if s.opts.User != "" { - req.SetBasicAuth(s.opts.User, s.opts.Password) + user, pass := s.opts.User, s.opts.Password + if s.opts.Creds != nil { + user, pass = s.opts.Creds.Get() + } + if user != "" { + req.SetBasicAuth(user, pass) } resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) if err != nil { return err } defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized && s.opts.Creds != nil && s.opts.Creds.Refresh() { + // See cameras.EngineClient: on a fresh install the engine writes its + // credential after the agent has already read for one. + resp.Body.Close() + return s.getJSON(ctx, url, out) + } if resp.StatusCode != http.StatusOK { return fmt.Errorf("%s returned %s", url, resp.Status) } diff --git a/demo/console.html b/demo/console.html new file mode 100644 index 0000000..05ffecf --- /dev/null +++ b/demo/console.html @@ -0,0 +1,251 @@ + + + + +Behavision — live demo + + + +
+
+ +
Behavision — live demo
+
+ engine… + camera… + cloud… +
+ +
+
+
+

The camera

+
+ +
waiting for the camera…
+ +
+
+ +
+

The customer type a name, then walk past again

+
+
Nobody yetWalk in front of the camera.
+
+
+
+ +
+
+

What just happened

+
WaitingEvery step below lights up as it really happens.
+
+ +
+

What the mobile app receives

+
GET /api/visits
+
…
+
+ +
+

What the dashboard receives

+
GET /api/reports/footfall
+
…
+
Both of these are the real production API at mcp.loyaly.ai, called from this + machine with a staff login — not a mock, and not the local engine.
+
+
+
+ + + + diff --git a/demo/console.py b/demo/console.py new file mode 100644 index 0000000..421f901 --- /dev/null +++ b/demo/console.py @@ -0,0 +1,308 @@ +"""A one-screen live demo of the whole Behavision chain, for showing someone. + +Run it on the shop PC (here, this Mac) while the engine and agent are running. +It holds every credential itself and the browser holds none, so the page can be +put on a projector without putting a token on it. + +What it shows, and why each part is there: + +- the live camera, so the person walking past sees themselves; +- the CHAIN, measured rather than described: the engine recognised a face at + this instant, the same visit appeared in the cloud API this many seconds + later. That number is the product's claim, and it is computed here from two + independent sources rather than asserted; +- the customer, editable - type a name, walk past again, watch the name come + back through the cloud instead of "Visitor 5"; +- the raw JSON a phone and a dashboard receive, side by side, because a + colleague's real question is "is this actually wired up or is it a mock". + + .venv/bin/python demo/console.py # http://127.0.0.1:8099 +""" +from __future__ import annotations + +import base64 +import json +import os +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +STATE = Path(os.environ.get("BEHAVISION_DATA_DIR", ROOT / ".demo")) +CLOUD = os.environ.get("BEHAVISION_CLOUD", "https://mcp.loyaly.ai") +ENGINE = "http://127.0.0.1:8010" +PORT = int(os.environ.get("DEMO_PORT", "8099")) + +# Whoever the demo signs in as. Staff on purpose: it is the weakest role that +# can do everything the shop floor does, so nothing here is only possible +# because we used an owner. +EMAIL = os.environ.get("DEMO_EMAIL", "staff.demo@tenext.in") +PASSWORD = os.environ.get("DEMO_PASSWORD", "admin@123") + + +def engine_auth() -> str: + """The engine invents a Basic credential when none is configured, and + writes it here. Read it rather than keeping a second copy.""" + f = STATE / "data" / "api_credentials.txt" + if not f.exists(): + return "" + user = pw = "" + for line in f.read_text().splitlines(): + # `key=value`, and `key: value` too - the engine writes one and people + # read the other, and which is which is not worth a support call. + if "=" in line or ":" in line: + k, v = line.split("=", 1) if "=" in line else line.split(":", 1) + if k.strip().lower() == "username": + user = v.strip() + elif k.strip().lower() == "password": + pw = v.strip() + if not user: + return "" + return "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode() + + +def fetch(url: str, *, headers=None, body=None, method="GET", timeout=20): + req = urllib.request.Request(url, method=method, + data=json.dumps(body).encode() if body is not None else None, + headers={k: v for k, v in (headers or {}).items() if v}) + if body is not None: + req.add_header("content-type", "application/json") + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + raw = r.read() + return r.status, (json.loads(raw) if raw and r.headers.get("content-type", "").startswith("application/json") else raw) + except urllib.error.HTTPError as e: + raw = e.read() + try: + return e.code, json.loads(raw or b"{}") + except Exception: + return e.code, raw[:400] + except Exception as e: + return 0, {"error": str(e)} + + +class Cloud: + """The signed-in session, refreshed when it expires.""" + + def __init__(self): + self.token = "" + self.lock = threading.Lock() + + def sign_in(self) -> bool: + st, d = fetch(f"{CLOUD}/api/auth/login", method="POST", + body={"email": EMAIL, "password": PASSWORD, "device": "Demo console"}) + if st == 200 and isinstance(d, dict): + self.token = d.get("access_token", "") + return True + return False + + def call(self, path, method="GET", body=None, retry=True): + with self.lock: + if not self.token and not self.sign_in(): + return 0, {"error": "cannot sign in to the platform"} + tok = self.token + st, d = fetch(f"{CLOUD}{path}", method=method, body=body, + headers={"authorization": f"Bearer {tok}"}) + if st == 401 and retry: + with self.lock: + self.sign_in() + return self.call(path, method, body, retry=False) + return st, d + + +cloud = Cloud() + +# The chain, as the watcher builds it. One dict per recognition, newest first. +events: list[dict] = [] +events_lock = threading.Lock() + + +def watch(): + """Poll the engine's own event log and the cloud feed, and join them. + + They are joined on the identity and the second, not on a shared id, + because the engine numbers identities locally and the server numbers them + per tenant - the two are deliberately different (see CLAUDE.md). What + matters for the demo is the LATENCY between one seeing a person and the + other, and that only needs the same person and the same moment. + """ + seen_local: set[str] = set() + while True: + try: + auth = engine_auth() + st, d = fetch(f"{ENGINE}/api/events?limit=25", headers={"authorization": auth}) + if st == 200 and isinstance(d, dict): + for e in d.get("events", []): + if e.get("type") not in ("person.new", "person.seen"): + continue + key = f"{e.get('ts')}|{e.get('camera_id')}|{(e.get('data') or {}).get('identity_id')}" + if key in seen_local: + continue + seen_local.add(key) + data = e.get("data") or {} + with events_lock: + events.insert(0, { + "key": key, + "at": time.time(), + "kind": "new" if e["type"] == "person.new" else "seen", + "engine": { + "label": data.get("label"), + "identity_id": data.get("identity_id"), + "similarity": data.get("similarity"), + "quality": data.get("quality"), + "gender": data.get("gender"), + "age": data.get("age"), + "camera": e.get("camera_id"), + "ts": e.get("ts"), + }, + "cloud": None, + "latency": None, + }) + del events[40:] + except Exception: + pass + + # the other half: has the cloud got it yet? + try: + with events_lock: + pending = [e for e in events if e["cloud"] is None][:6] + if pending: + st, d = cloud.call("/api/visits?limit=12") + arrivals = (d or {}).get("arrivals", []) if isinstance(d, dict) else [] + for e in pending: + for a in arrivals: + # same camera, and the cloud's visit is not older than + # the engine's sighting + if a.get("camera_id") != e["engine"]["camera"]: + continue + if a.get("visit_id") in [x["cloud"].get("visit_id") for x in events if x["cloud"]]: + continue + with events_lock: + e["cloud"] = { + "visit_id": a.get("visit_id"), + "visitor_id": a.get("visitor_id"), + "label": a.get("label"), + "ref": a.get("customer_ref") or a.get("ref"), + "is_new": a.get("is_new_visitor"), + "similarity": a.get("similarity"), + "site": a.get("site"), + "occurred_at": a.get("occurred_at"), + "image": a.get("image"), + } + e["latency"] = round(time.time() - e["at"], 1) + break + except Exception: + pass + time.sleep(1.0) + + +def snapshot() -> dict: + """Everything the page draws, in one reply.""" + auth = engine_auth() + _, health = fetch(f"{ENGINE}/api/health", headers={"authorization": auth}) + _, stats = fetch(f"{ENGINE}/api/stats", headers={"authorization": auth}) + st_v, visits = cloud.call("/api/visits?limit=3") + st_f, foot = cloud.call("/api/reports/footfall?from=%s&to=%s" + % (time.strftime("%Y-%m-%d", time.localtime(time.time() - 7 * 86400)), + time.strftime("%Y-%m-%d"))) + with events_lock: + chain = json.loads(json.dumps(events[:8])) + cams = (stats or {}).get("cameras", []) if isinstance(stats, dict) else [] + return { + "engine": { + "up": isinstance(health, dict) and bool(health.get("status")), + "model": (health or {}).get("recognition_model") if isinstance(health, dict) else None, + "cameras": [{"id": c.get("camera_id"), "connected": c.get("connected"), + "frames": c.get("frames"), "faces": c.get("faces")} for c in cams], + }, + "cloud_ok": st_v == 200, + "chain": chain, + "mobile": {"request": "GET /api/visits?limit=3", "status": st_v, "body": visits}, + "dashboard": {"request": "GET /api/reports/footfall?from=…&to=…", "status": st_f, "body": foot}, + } + + +def main(): + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + from urllib.parse import urlparse, parse_qs + + page = (Path(__file__).parent / "console.html").read_bytes() + + class H(BaseHTTPRequestHandler): + def log_message(self, *a): # quiet + pass + + def _send(self, code, body, ctype="application/json"): + self.send_response(code) + self.send_header("content-type", ctype) + self.send_header("content-length", str(len(body))) + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + + def do_GET(self): + u = urlparse(self.path) + if u.path == "/": + return self._send(200, page, "text/html; charset=utf-8") + if u.path == "/api/snapshot": + return self._send(200, json.dumps(snapshot()).encode()) + if u.path == "/api/customer": + vid = parse_qs(u.query).get("id", [""])[0] + if not vid: + return self._send(400, b'{"error":"no id"}') + st, d = cloud.call(f"/api/visitors/{vid}/history?limit=8") + return self._send(200, json.dumps({"status": st, "history": d}).encode()) + if u.path == "/camera.mjpeg": + cam = parse_qs(u.query).get("id", [""])[0] + return self._proxy_stream(f"{ENGINE}/api/cameras/{cam}/stream.mjpeg") + self._send(404, b'{"error":"no"}') + + def do_POST(self): + u = urlparse(self.path) + n = int(self.headers.get("content-length", 0)) + body = json.loads(self.rfile.read(n) or b"{}") + if u.path == "/api/profile": + vid = body.pop("id", "") + st, d = cloud.call(f"/api/visitors/{vid}/profile", method="PUT", body=body) + return self._send(200, json.dumps({"status": st, "body": d}).encode()) + self._send(404, b'{"error":"no"}') + + def _proxy_stream(self, url): + """The engine's MJPEG, relayed so the browser needs no credential. + + The engine's API is Basic-authenticated with a credential it + generated locally; putting that in a page would hand the whole + biometric API to anyone who opened it. + """ + try: + req = urllib.request.Request(url, headers={"authorization": engine_auth()}) + up = urllib.request.urlopen(req, timeout=20) + except Exception: + return self._send(502, b'{"error":"camera not available"}') + self.send_response(200) + self.send_header("content-type", up.headers.get("content-type", "multipart/x-mixed-replace")) + self.end_headers() + try: + while True: + chunk = up.read(8192) + if not chunk: + break + self.wfile.write(chunk) + self.wfile.flush() + except Exception: + pass + finally: + up.close() + + threading.Thread(target=watch, daemon=True).start() + print(f"\n Demo console → http://127.0.0.1:{PORT}\n") + print(f" engine {ENGINE} · cloud {CLOUD} · signed in as {EMAIL}\n") + ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/desktop/app.go b/desktop/app.go index db94f46..8be79aa 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -66,7 +66,7 @@ func NewApp() *App { return &App{ cfg: cfg, cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")), - local: local.New(base, cfg.APIUser, cfg.APIPassword), + local: localWithCreds(base, cfg), proxy: newStreamProxy(), } } @@ -815,3 +815,12 @@ func envOr(key, def string) string { } return def } + +// localWithCreds builds the engine client with a credential resolver, so a +// first run - where the engine writes its credential after the app has looked +// for it - recovers by itself instead of 401ing for the life of the process. +func localWithCreds(base string, cfg agentcfg.Config) *local.Client { + c := local.New(base, cfg.APIUser, cfg.APIPassword) + c.Creds = agentcfg.NewCreds(agentpaths.APICredentials(), cfg.APIUser, cfg.APIPassword) + return c +} diff --git a/desktop/internal/local/client.go b/desktop/internal/local/client.go index b650721..32a7d6d 100644 --- a/desktop/internal/local/client.go +++ b/desktop/internal/local/client.go @@ -10,6 +10,7 @@ import ( "context" "encoding/json" "fmt" + agentconfig "github.com/loyaly/behavision-agent/pkg/config" "io" "net/http" "strings" @@ -20,7 +21,11 @@ type Client struct { Base string User string Password string - http *http.Client + // Creds re-reads the engine's generated credential when one is rejected. + // On a first run the app starts the engine, and the engine writes that + // file seconds later - after the app has already looked for it. + Creds *agentconfig.Creds + http *http.Client } func New(base, user, password string) *Client { @@ -48,8 +53,12 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err if body != nil { req.Header.Set("Content-Type", "application/json") } - if c.User != "" { - req.SetBasicAuth(c.User, c.Password) + user, pass := c.User, c.Password + if c.Creds != nil { + user, pass = c.Creds.Get() + } + if user != "" { + req.SetBasicAuth(user, pass) } resp, err := c.http.Do(req) if err != nil {