From f30d3527192301c57b542c3ae6dc45ba490e1486 Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Tue, 7 Jul 2026 19:41:47 -0700 Subject: [PATCH 1/2] Add io.pilot.insforge + broker-signup provisioning primitives io.pilot.insforge is a byo HTTP app that provisions the caller's own isolated InsForge backend (Postgres, auth, storage, edge functions, AI model gateway) in one signed call. Pilot's insforge-signup-broker holds one managed master account and mints a per-user project; the adapter caches the key + backend URL and talks directly to that backend thereafter. - internal/insforgesignup + cmd/insforge-signup-broker: signed provisioning broker (OAuth refresh -> create project -> fetch access key), idempotent per caller, AES-GCM ledger, per-IP cap. Mirrors the otpsignup broker shape. - scaffold: backend.url_secret (per-request dynamic base URL from secrets.json, a BaseURLFunc analogue of HeaderFunc) and body_raw param location (a param's value becomes the whole request body, for bare-array APIs). Both additive. - submissions/io.pilot.insforge/submission.json: 23 methods + help, pricing on every method. - docs/BROKER-SIGNUP.md: master-account provisioning variant. --- cmd/insforge-signup-broker/main.go | 53 ++ docs/BROKER-SIGNUP.md | 32 ++ internal/insforgesignup/broker.go | 386 ++++++++++++++ internal/insforgesignup/broker_test.go | 172 +++++++ internal/insforgesignup/store.go | 126 +++++ internal/publish/submission.go | 10 +- internal/publish/zz_param_in_test.go | 2 +- internal/scaffold/config.go | 26 +- .../scaffold/templates/broker_signup.go.tmpl | 15 +- .../scaffold/templates/client_http.go.tmpl | 38 +- internal/scaffold/templates/main.go.tmpl | 50 +- internal/scaffold/templates/signup.go.tmpl | 9 +- internal/scaffold/zz_http_paramin_test.go | 21 +- internal/scaffold/zz_url_secret_test.go | 68 +++ submissions/io.pilot.insforge/submission.json | 487 ++++++++++++++++++ 15 files changed, 1472 insertions(+), 23 deletions(-) create mode 100644 cmd/insforge-signup-broker/main.go create mode 100644 internal/insforgesignup/broker.go create mode 100644 internal/insforgesignup/broker_test.go create mode 100644 internal/insforgesignup/store.go create mode 100644 internal/scaffold/zz_url_secret_test.go create mode 100644 submissions/io.pilot.insforge/submission.json diff --git a/cmd/insforge-signup-broker/main.go b/cmd/insforge-signup-broker/main.go new file mode 100644 index 0000000..afe785b --- /dev/null +++ b/cmd/insforge-signup-broker/main.go @@ -0,0 +1,53 @@ +// Command insforge-signup-broker runs the signed InsForge signup broker +// (internal/insforgesignup): it provisions a per-user InsForge project under one +// managed master account and returns the project's access key, with no email and +// no browser. Configuration is entirely from the environment — account- and +// host-specifics live in the deployment, not the binary. +// +// INSFORGE_SIGNUP_LISTEN HTTP listen addr (default 127.0.0.1:8092) +// INSFORGE_SIGNUP_TOKEN_URL OAuth token endpoint (https://api.insforge.dev/api/oauth/v1/token) +// INSFORGE_SIGNUP_CLIENT_ID OAuth client id the refresh token was issued to +// INSFORGE_SIGNUP_REFRESH_TOKEN master account refresh token +// INSFORGE_SIGNUP_PLATFORM_API platform API base (https://api.insforge.dev) +// INSFORGE_SIGNUP_ORG_ID org new projects are created under +// INSFORGE_SIGNUP_REGION project region (default us-east) +// INSFORGE_SIGNUP_BACKEND_DOMAIN backend host suffix (default insforge.app) +// INSFORGE_SIGNUP_PROJECT_PREFIX project name prefix (default pilot-) +// INSFORGE_SIGNUP_DB sqlite ledger path +// INSFORGE_SIGNUP_ENC_KEY 64-hex (32-byte) key sealing the project key at rest +// INSFORGE_SIGNUP_MAX_IDS_PER_IP per-IP distinct-caller cap (0 = unlimited) +// INSFORGE_SIGNUP_PATH signed request path (default /signup; set to /insforge/signup behind a proxy) +package main + +import ( + "log" + "os" + "strconv" + + "github.com/pilot-protocol/app-template/internal/insforgesignup" +) + +func main() { + maxIP, _ := strconv.Atoi(os.Getenv("INSFORGE_SIGNUP_MAX_IDS_PER_IP")) + b, err := insforgesignup.New(insforgesignup.Config{ + Listen: os.Getenv("INSFORGE_SIGNUP_LISTEN"), + TokenURL: os.Getenv("INSFORGE_SIGNUP_TOKEN_URL"), + ClientID: os.Getenv("INSFORGE_SIGNUP_CLIENT_ID"), + RefreshToken: os.Getenv("INSFORGE_SIGNUP_REFRESH_TOKEN"), + PlatformAPI: os.Getenv("INSFORGE_SIGNUP_PLATFORM_API"), + OrgID: os.Getenv("INSFORGE_SIGNUP_ORG_ID"), + Region: os.Getenv("INSFORGE_SIGNUP_REGION"), + BackendDomain: os.Getenv("INSFORGE_SIGNUP_BACKEND_DOMAIN"), + ProjectPrefix: os.Getenv("INSFORGE_SIGNUP_PROJECT_PREFIX"), + DBPath: os.Getenv("INSFORGE_SIGNUP_DB"), + EncKeyHex: os.Getenv("INSFORGE_SIGNUP_ENC_KEY"), + MaxIdentitiesPerIP: maxIP, + SignupPath: os.Getenv("INSFORGE_SIGNUP_PATH"), + }) + if err != nil { + log.Fatalf("insforge-signup-broker: %v", err) + } + b.SetLogger(log.New(os.Stderr, "insforgesignup ", log.LstdFlags|log.LUTC)) + log.Printf("insforge-signup-broker listening on %s", os.Getenv("INSFORGE_SIGNUP_LISTEN")) + log.Fatal(b.ListenAndServe()) +} diff --git a/docs/BROKER-SIGNUP.md b/docs/BROKER-SIGNUP.md index d7bfe87..668d394 100644 --- a/docs/BROKER-SIGNUP.md +++ b/docs/BROKER-SIGNUP.md @@ -86,6 +86,38 @@ compiled in. In broad strokes: See each package's `Config` for the full environment contract. +## Variant: master-account provisioning (no mailbox) + +Some backends have **no email-OTP account signup at all** — the only machine path +to a credential is an OAuth/token exchange under one owner account. There the +mailbox half does not apply: instead of driving `register → OTP → verify`, the +broker holds **one master account's OAuth refresh token** and, per caller, +provisions an **isolated sub-resource** (a project/workspace/tenant) and returns +its scoped key. `internal/insforgesignup` (with `cmd/insforge-signup-broker`) is +this shape for InsForge: refresh the master token headlessly → create a project +→ fetch its access key → return `{api_key, backend_url, project_id}`. It reuses +the same signed handler, per-caller encrypted ledger, idempotency, and per-IP cap +as the OTP broker — only the provider handshake differs, and there is **no mail +server to run**. + +Because each user gets a *different* backend endpoint, this variant pairs with two +scaffold primitives: + +- **`backend.url_secret`** — names a `secrets.json` key holding a per-user backend + base URL, re-resolved per request (a `BaseURLFunc`, the base-URL analogue of the + per-request `HeaderFunc`). The broker-signup handler caches the returned + `backend_url` there, so every call after signup reaches the user's own backend + with no restart. `base_url` stays the pre-signup default. +- **`body_raw`** — a method param (`in: body_raw`) whose value becomes the entire + request body verbatim (a bare top-level array/scalar), for APIs whose body is + not an object (e.g. a bulk insert takes `[{…}]`). The body-shape analogue of + `path_raw`. + +The economics differ from per-user accounts: every provisioned sub-resource lives +under the **one** master account, so its plan limits and billing are shared — this +variant is for a **provider-funded**, metered managed service, not per-user free +tiers. Cap exposure with the per-IP mint cap and the master account's own plan. + ## Security notes - The mailbox holds a **live code for seconds** — treat it as a secret: tmpfs, diff --git a/internal/insforgesignup/broker.go b/internal/insforgesignup/broker.go new file mode 100644 index 0000000..afee84a --- /dev/null +++ b/internal/insforgesignup/broker.go @@ -0,0 +1,386 @@ +// Package insforgesignup is a signed HTTP broker that provisions a per-user +// InsForge backend and returns its access key — one signed call, no email, no +// browser. It is the InsForge half of the broker-signup pattern (the mailbox +// half, internal/otpmail, is NOT used here: InsForge has no email-OTP account +// signup, so instead of driving a mailbox the broker holds one master account's +// OAuth refresh token and provisions an isolated project per caller): +// +// adapter ──POST /signup (ed25519-signed)──▶ broker +// broker: refresh the master OAuth token (headless) +// → POST create a fresh project under the master org +// → GET the project's access-api-key (ik_) +// → derive the project backend URL from appkey+region +// → persist {project_id, api_key, backend_url} for this caller +// broker ──{api_key, backend_url, project_id}──▶ adapter +// (adapter caches them to secrets.json; ops go DIRECT to the +// user's own backend with that key — the broker is off the hot path) +// +// It reuses the shared broker's ed25519 caller-identity verification, is +// idempotent per caller (a repeat /signup returns the same project, so the key +// is retrievable), and applies a per-IP cap so one caller can't farm projects. +package insforgesignup + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/pilot-protocol/app-template/internal/broker" +) + +// Config is the broker's deployment configuration (all from env; no account or +// host specifics compiled in). +type Config struct { + Listen string // HTTP listen address (default 127.0.0.1:8092) + + // Master account OAuth (headless provisioning credential). + TokenURL string // OAuth token endpoint (e.g. https://api.insforge.dev/api/oauth/v1/token) + ClientID string // OAuth client id the refresh token was issued to + RefreshToken string // master account refresh token (non-rotating) + + // Platform API + provisioning target. + PlatformAPI string // platform API base (e.g. https://api.insforge.dev) + OrgID string // org new projects are created under + Region string // project region (default us-east) + BackendDomain string // backend host suffix (default insforge.app) → https://.. + ProjectPrefix string // project name prefix (default pilot-) + + DBPath string // sqlite path for the per-caller ledger + EncKeyHex string // 64-hex (32-byte) key encrypting the project key at rest + MaxIdentitiesPerIP int // per-IP distinct-caller cap (0 = unlimited) + MintCooldown time.Duration // min gap between mints from one IP (0 = none) + + // SignupPath is the request path the signed /signup handler is mounted at + // (default "/signup"). Set it to the FULL public path when the broker sits + // behind a reverse proxy that preserves the URI (e.g. "/insforge/signup"), so + // the path the adapter signs matches the path this broker verifies. + SignupPath string +} + +// Broker orchestrates provisioning and owns the ledger. +type Broker struct { + cfg Config + store *store + verify broker.VerifyConfig + http *http.Client + log *log.Logger + + mu sync.Mutex + tok string // cached master access token + tokExp time.Time // its expiry + ipSeen map[string]map[string]bool // ip -> set of caller ids + ipLast map[string]time.Time // ip -> last mint time +} + +// New validates cfg, opens the ledger, and returns a Broker. +func New(cfg Config) (*Broker, error) { + if cfg.Listen == "" { + cfg.Listen = "127.0.0.1:8092" + } + if cfg.Region == "" { + cfg.Region = "us-east" + } + if cfg.BackendDomain == "" { + cfg.BackendDomain = "insforge.app" + } + if cfg.ProjectPrefix == "" { + cfg.ProjectPrefix = "pilot-" + } + if cfg.SignupPath == "" { + cfg.SignupPath = "/signup" + } + for name, v := range map[string]string{"TokenURL": cfg.TokenURL, "ClientID": cfg.ClientID, "RefreshToken": cfg.RefreshToken, "PlatformAPI": cfg.PlatformAPI, "OrgID": cfg.OrgID} { + if strings.TrimSpace(v) == "" { + return nil, fmt.Errorf("insforgesignup: %s is required", name) + } + } + st, err := openStore(cfg.DBPath, cfg.EncKeyHex) + if err != nil { + return nil, err + } + return &Broker{ + cfg: cfg, + store: st, + http: &http.Client{Timeout: 60 * time.Second}, + log: log.New(io.Discard, "", 0), + ipSeen: map[string]map[string]bool{}, + ipLast: map[string]time.Time{}, + }, nil +} + +// SetLogger installs a logger (provisioning events only — never the key/token). +func (b *Broker) SetLogger(l *log.Logger) { b.log = l } + +// ListenAndServe serves the broker HTTP API. +func (b *Broker) ListenAndServe() error { + mux := http.NewServeMux() + mux.HandleFunc("/gw/health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) + mux.HandleFunc(b.cfg.SignupPath, b.handleSignup) + return http.ListenAndServe(b.cfg.Listen, mux) +} + +// Account is what the broker returns to the adapter. +type Account struct { + APIKey string `json:"api_key"` // the project access key (ik_) + BackendURL string `json:"backend_url"` // the project's backend base URL + ProjectID string `json:"project_id"` + Cached bool `json:"cached"` // true when returned from the ledger (idempotent repeat) +} + +func (b *Broker) handleSignup(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<16)) + caller, err := b.verify.Verify(r.Header.Get, r.Method, r.URL.Path, body) + if err != nil { + http.Error(w, "identity: "+err.Error(), http.StatusUnauthorized) + return + } + ip := clientIP(r) + + acct, err := b.Signup(r.Context(), string(caller), ip) + if err != nil { + code := http.StatusBadGateway + switch { + case errors.Is(err, errRateLimited): + code = http.StatusTooManyRequests + case errors.Is(err, errProjectLimit): + code = http.StatusPaymentRequired + } + b.log.Printf("signup caller=%s ip=%s FAIL: %v", short(string(caller)), ip, err) + http.Error(w, err.Error(), code) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(acct) +} + +var ( + errRateLimited = errors.New("per-IP identity cap reached (anti-abuse)") + errProjectLimit = errors.New("the managed InsForge org has reached its project limit — the operator must upgrade the plan") +) + +// Signup is the core flow, separated from HTTP so it is directly testable. +func (b *Broker) Signup(ctx context.Context, caller, ip string) (*Account, error) { + // Idempotent: a caller that already has a project gets it back (retrievable), + // no second project provisioned. + if rec, ok, _ := b.store.get(caller); ok { + b.log.Printf("signup caller=%s ip=%s cached project=%s", short(caller), ip, rec.ProjectID) + return &Account{APIKey: rec.APIKey, BackendURL: rec.BackendURL, ProjectID: rec.ProjectID, Cached: true}, nil + } + if err := b.gate(caller, ip); err != nil { + return nil, err + } + + start := time.Now() + token, err := b.accessToken(ctx) + if err != nil { + return nil, fmt.Errorf("master auth: %w", err) + } + projID, appkey, region, err := b.createProject(ctx, token) + if err != nil { + return nil, err + } + key, err := b.projectKey(ctx, token, projID) + if err != nil { + return nil, err + } + backendURL := fmt.Sprintf("https://%s.%s.%s", appkey, region, b.cfg.BackendDomain) + + if err := b.store.put(caller, record{ProjectID: projID, APIKey: key, BackendURL: backendURL, OrgID: b.cfg.OrgID, CreatedAt: time.Now().UTC()}); err != nil { + return nil, fmt.Errorf("persist: %w", err) + } + b.markMinted(caller, ip) + b.log.Printf("signup caller=%s ip=%s provisioned project=%s in %s", short(caller), ip, projID, time.Since(start).Round(time.Millisecond)) + return &Account{APIKey: key, BackendURL: backendURL, ProjectID: projID}, nil +} + +// gate enforces the per-IP identity cap + mint cooldown. +func (b *Broker) gate(caller, ip string) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.cfg.MintCooldown > 0 { + if last, ok := b.ipLast[ip]; ok && time.Since(last) < b.cfg.MintCooldown { + return errRateLimited + } + } + if b.cfg.MaxIdentitiesPerIP > 0 { + seen := b.ipSeen[ip] + if seen != nil && !seen[caller] && len(seen) >= b.cfg.MaxIdentitiesPerIP { + return errRateLimited + } + } + return nil +} + +func (b *Broker) markMinted(caller, ip string) { + b.mu.Lock() + defer b.mu.Unlock() + if b.ipSeen[ip] == nil { + b.ipSeen[ip] = map[string]bool{} + } + b.ipSeen[ip][caller] = true + b.ipLast[ip] = time.Now() +} + +// ── master auth + provisioning calls ───────────────────────────────────────── + +// accessToken returns a valid master access token, refreshing (headless) when +// the cached one is missing or within 2 minutes of expiry. +func (b *Broker) accessToken(ctx context.Context) (string, error) { + b.mu.Lock() + if b.tok != "" && time.Until(b.tokExp) > 2*time.Minute { + t := b.tok + b.mu.Unlock() + return t, nil + } + b.mu.Unlock() + + st, body := b.postJSON(ctx, b.cfg.TokenURL, map[string]string{ + "grant_type": "refresh_token", + "refresh_token": b.cfg.RefreshToken, + "client_id": b.cfg.ClientID, + }, "") + if st != 200 { + return "", fmt.Errorf("refresh HTTP %d", st) + } + var out struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if json.Unmarshal(body, &out) != nil || out.AccessToken == "" { + return "", fmt.Errorf("refresh returned no access_token") + } + ttl := out.ExpiresIn + if ttl <= 0 { + ttl = 3600 + } + b.mu.Lock() + b.tok = out.AccessToken + b.tokExp = time.Now().Add(time.Duration(ttl) * time.Second) + b.mu.Unlock() + return out.AccessToken, nil +} + +// createProject provisions a fresh project under the master org and returns its +// id, appkey, and region. +func (b *Broker) createProject(ctx context.Context, token string) (id, appkey, region string, err error) { + url := b.cfg.PlatformAPI + "/organizations/v1/" + b.cfg.OrgID + "/projects" + name := b.cfg.ProjectPrefix + randToken(10) + st, body := b.postJSON(ctx, url, map[string]string{"name": name, "region": b.cfg.Region}, token) + if st == 400 && strings.Contains(strings.ToLower(string(body)), "limit") { + return "", "", "", errProjectLimit + } + if st != 200 && st != 201 { + return "", "", "", fmt.Errorf("create project HTTP %d: %s", st, snippet(body)) + } + var out struct { + Project struct { + ID string `json:"id"` + Appkey string `json:"appkey"` + Region string `json:"region"` + } `json:"project"` + } + if json.Unmarshal(body, &out) != nil || out.Project.ID == "" || out.Project.Appkey == "" { + return "", "", "", fmt.Errorf("create project: unexpected response") + } + region = out.Project.Region + if region == "" { + region = b.cfg.Region + } + return out.Project.ID, out.Project.Appkey, region, nil +} + +// projectKey fetches a project's access-api-key (ik_). +func (b *Broker) projectKey(ctx context.Context, token, projID string) (string, error) { + url := b.cfg.PlatformAPI + "/projects/v1/" + projID + "/access-api-key" + st, body := b.getJSON(ctx, url, token) + if st != 200 { + return "", fmt.Errorf("access-api-key HTTP %d", st) + } + var out struct { + AccessAPIKey string `json:"access_api_key"` + } + if json.Unmarshal(body, &out) != nil || out.AccessAPIKey == "" { + return "", fmt.Errorf("access-api-key returned no key") + } + return out.AccessAPIKey, nil +} + +// ── HTTP helpers ──────────────────────────────────────────────────────────── + +func (b *Broker) postJSON(ctx context.Context, url string, m map[string]string, token string) (int, []byte) { + raw, _ := json.Marshal(m) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := b.http.Do(req) + if err != nil { + return 0, []byte(err.Error()) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return resp.StatusCode, body +} + +func (b *Broker) getJSON(ctx context.Context, url, token string) (int, []byte) { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req.Header.Set("Accept", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := b.http.Do(req) + if err != nil { + return 0, nil + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return resp.StatusCode, body +} + +func clientIP(r *http.Request) string { + if xf := r.Header.Get("X-Real-IP"); xf != "" { + return xf + } + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return strings.TrimSpace(strings.Split(xff, ",")[0]) + } + host, _, _ := net.SplitHostPort(r.RemoteAddr) + return host +} + +func randToken(n int) string { + const a = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, n) + _, _ = rand.Read(b) + for i := range b { + b[i] = a[int(b[i])%len(a)] + } + return string(b) +} + +func snippet(raw []byte) string { + s := strings.TrimSpace(string(raw)) + if len(s) > 300 { + s = s[:300] + } + return s +} + +func short(id string) string { + if len(id) > 12 { + return id[:12] + "…" + } + return id +} diff --git a/internal/insforgesignup/broker_test.go b/internal/insforgesignup/broker_test.go new file mode 100644 index 0000000..b844fc8 --- /dev/null +++ b/internal/insforgesignup/broker_test.go @@ -0,0 +1,172 @@ +package insforgesignup + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/pilot-protocol/app-template/internal/broker" +) + +// mockInsforge stands in for the InsForge platform API: OAuth refresh → an +// access token, create-project → a project, access-api-key → the ik_ key. When +// limit is true, create-project returns the free-plan project cap error. +func mockInsforge(t *testing.T, key string, limit bool) (*httptest.Server, *int32) { + t.Helper() + var created int32 + mux := http.NewServeMux() + mux.HandleFunc("/api/oauth/v1/token", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"access_token":"at_test","token_type":"Bearer","expires_in":3600}`)) + }) + mux.HandleFunc("/organizations/v1/org-1/projects", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer at_test" { + http.Error(w, "unauth", http.StatusUnauthorized) + return + } + if limit { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"code":"project.limit.reached","error":"Free plan allows up to 2 active projects."}`)) + return + } + atomic.AddInt32(&created, 1) + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"project":{"id":"proj-1","appkey":"abc123","region":"us-east"}}`)) + }) + mux.HandleFunc("/projects/v1/proj-1/access-api-key", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"access_api_key":"` + key + `"}`)) + }) + return httptest.NewServer(mux), &created +} + +func newBroker(t *testing.T, api string) *Broker { + t.Helper() + b, err := New(Config{ + TokenURL: api + "/api/oauth/v1/token", ClientID: "clf_test", RefreshToken: "ref_test", + PlatformAPI: api, OrgID: "org-1", MaxIdentitiesPerIP: 2, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + return b +} + +func TestSignupProvisionsAndIsIdempotent(t *testing.T) { + srv, created := mockInsforge(t, "ik_ABC123", false) + defer srv.Close() + b := newBroker(t, srv.URL) + + acct, err := b.Signup(context.Background(), "caller-1", "1.2.3.4") + if err != nil { + t.Fatalf("Signup: %v", err) + } + if acct.APIKey != "ik_ABC123" { + t.Fatalf("api_key=%q want ik_ABC123", acct.APIKey) + } + if acct.BackendURL != "https://abc123.us-east.insforge.app" { + t.Fatalf("backend_url=%q unexpected", acct.BackendURL) + } + if acct.ProjectID != "proj-1" || acct.Cached { + t.Fatalf("unexpected acct=%+v", acct) + } + // idempotent repeat → same account, cached, NO second project created. + acct2, err := b.Signup(context.Background(), "caller-1", "1.2.3.4") + if err != nil { + t.Fatal(err) + } + if !acct2.Cached || acct2.APIKey != acct.APIKey || acct2.BackendURL != acct.BackendURL { + t.Fatalf("repeat not idempotent: %+v vs %+v", acct2, acct) + } + if n := atomic.LoadInt32(created); n != 1 { + t.Fatalf("created %d projects, want exactly 1 (idempotent)", n) + } +} + +func TestSignupEncryptsKeyAtRest(t *testing.T) { + srv, _ := mockInsforge(t, "ik_SECRET", false) + defer srv.Close() + b := newBroker(t, srv.URL) + if _, err := b.Signup(context.Background(), "caller-2", "9.9.9.9"); err != nil { + t.Fatal(err) + } + var akEnc string + if err := b.store.db.QueryRow(`SELECT apikey_enc FROM projects WHERE caller=?`, "caller-2").Scan(&akEnc); err != nil { + t.Fatal(err) + } + if strings.Contains(akEnc, "ik_SECRET") { + t.Fatal("api_key stored in plaintext") + } + rec, ok, err := b.store.get("caller-2") + if err != nil || !ok || rec.APIKey != "ik_SECRET" { + t.Fatalf("retrieve after decrypt failed: ok=%v key=%q err=%v", ok, rec.APIKey, err) + } +} + +func TestPerIPCapBlocksSybil(t *testing.T) { + srv, _ := mockInsforge(t, "ik_K", false) + defer srv.Close() + b := newBroker(t, srv.URL) // cap = 2 per IP + ip := "5.5.5.5" + if _, err := b.Signup(context.Background(), "c-a", ip); err != nil { + t.Fatal(err) + } + if _, err := b.Signup(context.Background(), "c-b", ip); err != nil { + t.Fatal(err) + } + if _, err := b.Signup(context.Background(), "c-c", ip); err == nil { + t.Fatal("expected 3rd distinct identity from one IP to be rate-limited") + } + if _, err := b.Signup(context.Background(), "c-a", ip); err != nil { + t.Fatalf("cached caller should still succeed: %v", err) + } +} + +func TestProjectLimitError(t *testing.T) { + srv, _ := mockInsforge(t, "ik_X", true) // create-project returns the cap error + defer srv.Close() + b := newBroker(t, srv.URL) + _, err := b.Signup(context.Background(), "caller-x", "2.2.2.2") + if err == nil || !strings.Contains(err.Error(), "project limit") { + t.Fatalf("want project-limit error, got %v", err) + } +} + +// TestSignedHTTPFlow drives the real signed HTTP endpoint end to end. +func TestSignedHTTPFlow(t *testing.T) { + srv, _ := mockInsforge(t, "ik_HTTP", false) + defer srv.Close() + b := newBroker(t, srv.URL) + ts := httptest.NewServer(http.HandlerFunc(b.handleSignup)) + defer ts.Close() + + _, priv, _ := ed25519.GenerateKey(nil) + hdrs := broker.Sign(priv, http.MethodPost, "/signup", []byte("{}"), time.Now()) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/signup", strings.NewReader("{}")) + for k, v := range hdrs { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatalf("status=%d want 200", resp.StatusCode) + } + var acct Account + json.NewDecoder(resp.Body).Decode(&acct) + if acct.APIKey != "ik_HTTP" { + t.Fatalf("api_key=%q want ik_HTTP", acct.APIKey) + } + + // unsigned request → 401 + bad, _ := http.NewRequest(http.MethodPost, ts.URL+"/signup", strings.NewReader("{}")) + r2, _ := http.DefaultClient.Do(bad) + if r2.StatusCode != 401 { + t.Fatalf("unsigned status=%d want 401", r2.StatusCode) + } +} diff --git a/internal/insforgesignup/store.go b/internal/insforgesignup/store.go new file mode 100644 index 0000000..e917bac --- /dev/null +++ b/internal/insforgesignup/store.go @@ -0,0 +1,126 @@ +package insforgesignup + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/hex" + "fmt" + "time" + + _ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo) +) + +// record is one caller's provisioned InsForge project. APIKey (the project +// access key) is encrypted at rest; the project/org ids and the backend URL are +// stored in the clear (opaque identifiers, not sensitive on their own). +type record struct { + ProjectID string + APIKey string + BackendURL string + OrgID string + CreatedAt time.Time +} + +// store is the per-caller ledger. The project key is sealed with AES-256-GCM so +// a DB leak does not expose keys without the separate encryption key. +type store struct { + db *sql.DB + aead cipher.AEAD +} + +func openStore(dbPath, encKeyHex string) (*store, error) { + if dbPath == "" { + dbPath = ":memory:" + } + // A 32-byte key seals the project key at rest. Prod MUST set a stable key + // (else a restart can't decrypt prior rows); tests fall back to an ephemeral one. + var key []byte + if encKeyHex != "" { + k, err := hex.DecodeString(encKeyHex) + if err != nil || len(k) != 32 { + return nil, fmt.Errorf("insforgesignup: EncKeyHex must be 64 hex chars (32 bytes)") + } + key = k + } else { + key = make([]byte, 32) + _, _ = rand.Read(key) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) // sqlite: serialize writers + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS projects ( + caller TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + apikey_enc TEXT NOT NULL, + backend_url TEXT NOT NULL, + org_id TEXT NOT NULL, + created_at INTEGER NOT NULL + )`); err != nil { + return nil, err + } + return &store{db: db, aead: aead}, nil +} + +func (s *store) seal(plain string) (string, error) { + nonce := make([]byte, s.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + ct := s.aead.Seal(nonce, nonce, []byte(plain), nil) + return base64.StdEncoding.EncodeToString(ct), nil +} + +func (s *store) open(enc string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(enc) + if err != nil || len(raw) < s.aead.NonceSize() { + return "", fmt.Errorf("bad ciphertext") + } + nonce, ct := raw[:s.aead.NonceSize()], raw[s.aead.NonceSize():] + pt, err := s.aead.Open(nil, nonce, ct, nil) + if err != nil { + return "", err + } + return string(pt), nil +} + +func (s *store) put(caller string, r record) error { + ak, err := s.seal(r.APIKey) + if err != nil { + return err + } + _, err = s.db.Exec( + `INSERT OR IGNORE INTO projects (caller,project_id,apikey_enc,backend_url,org_id,created_at) VALUES (?,?,?,?,?,?)`, + caller, r.ProjectID, ak, r.BackendURL, r.OrgID, r.CreatedAt.Unix()) + return err +} + +func (s *store) get(caller string) (record, bool, error) { + var projectID, akEnc, backendURL, orgID string + var ts int64 + err := s.db.QueryRow(`SELECT project_id,apikey_enc,backend_url,org_id,created_at FROM projects WHERE caller=?`, caller). + Scan(&projectID, &akEnc, &backendURL, &orgID, &ts) + if err == sql.ErrNoRows { + return record{}, false, nil + } + if err != nil { + return record{}, false, err + } + ak, err := s.open(akEnc) + if err != nil { + return record{}, false, err + } + return record{ProjectID: projectID, APIKey: ak, BackendURL: backendURL, OrgID: orgID, CreatedAt: time.Unix(ts, 0).UTC()}, true, nil +} diff --git a/internal/publish/submission.go b/internal/publish/submission.go index 226a73d..c544e53 100644 --- a/internal/publish/submission.go +++ b/internal/publish/submission.go @@ -78,6 +78,10 @@ type SubBackend struct { // Quota is the per-caller call cap the broker enforces for a managed app // (0 = unlimited). Set at publish time so the rate limit ships with the app. Quota int `json:"quota"` + // URLSecret names a secrets.json key holding a per-user backend base URL + // resolved per request (for a broker-signup app whose backend is provisioned + // per user). See scaffold.Backend.URLSecret. + URLSecret string `json:"url_secret"` // --- cli fields --- // Command is the base argv the adapter execs (e.g. ["gh"] or ["python","-m","tool"]). @@ -454,7 +458,7 @@ func validateSubHTTPMethod(n string, m SubMethod) []string { // subParamIn is the closed set of param request locations (empty = default). var subParamIn = map[string]bool{ - "query": true, "path": true, "path_raw": true, "body": true, "header": true, + "query": true, "path": true, "path_raw": true, "body": true, "body_raw": true, "header": true, } // validateParamLocations checks the per-param `in` rules for an http method, so @@ -473,7 +477,7 @@ func validateParamLocations(method string, m SubMethod) []string { continue } if !subParamIn[p.In] { - e = append(e, fmt.Sprintf("Method %q, param %q: in %q must be one of query, path, path_raw, body, header", method, name, p.In)) + e = append(e, fmt.Sprintf("Method %q, param %q: in %q must be one of query, path, path_raw, body, body_raw, header", method, name, p.In)) continue } if (p.In == "path" || p.In == "path_raw") && !placeholder[name] { @@ -553,7 +557,7 @@ func (s Submission) validateArtifacts() []string { // the generator needs). Review-only fields (vendor free-text, agent-usage, // capabilities, binary URL) are intentionally not part of it. func (s Submission) ToConfig() *scaffold.Config { - backend := scaffold.Backend{Type: "http", BaseURL: s.Backend.BaseURL, Auth: s.Backend.Auth} + backend := scaffold.Backend{Type: "http", BaseURL: s.Backend.BaseURL, Auth: s.Backend.Auth, URLSecret: s.Backend.URLSecret} if s.Backend.IsCLI() { backend = scaffold.Backend{Type: "cli", Command: s.Backend.Command, EnvPassthrough: s.Backend.EnvPassthrough} } diff --git a/internal/publish/zz_param_in_test.go b/internal/publish/zz_param_in_test.go index 37be58e..b7ca624 100644 --- a/internal/publish/zz_param_in_test.go +++ b/internal/publish/zz_param_in_test.go @@ -66,7 +66,7 @@ func TestSubmissionParamInRejections(t *testing.T) { HTTP: SubRoute{Verb: "GET", Path: "/x"}, Params: []SubParam{{Name: "u", Type: "string", In: "cookie"}}, }} - if !hasSub(bad.Validate(), "must be one of query, path, path_raw, body, header") { + if !hasSub(bad.Validate(), "must be one of query, path, path_raw, body, body_raw, header") { t.Errorf("expected invalid in-value error, got %v", bad.Validate()) } diff --git a/internal/scaffold/config.go b/internal/scaffold/config.go index e8caaa3..198e9e9 100644 --- a/internal/scaffold/config.go +++ b/internal/scaffold/config.go @@ -245,6 +245,17 @@ type Backend struct { // shared broker.pilotprotocol.network. Empty ⇒ the shared broker. BrokerURL string `yaml:"broker_url"` + // URLSecret names a $APP/secrets.json key holding this app's backend base + // URL, resolved PER REQUEST (falling back to base_url when absent). It exists + // for apps whose backend endpoint is provisioned per user at runtime — a + // broker-signup route that mints an isolated backend and returns its URL, not + // just a key. The broker-signup handler caches the returned backend_url under + // this key; the HTTP client then re-reads it each call (like HeaderFunc for + // the auth key), so calls made after signup reach the user's own backend with + // no restart. base_url stays required as the pre-signup default. e.g. + // url_secret: INSFORGE_BACKEND_URL + URLSecret string `yaml:"url_secret"` + // Auth selects how the adapter authenticates to the backend: // "" / "byo" — each user supplies their own key (the ${TOKEN} headers above) // "managed" — Pilot holds ONE master key and meters per user. The generated @@ -607,6 +618,13 @@ type HTTPRoute struct { HeaderParams []string `yaml:"-"` QueryParams []string `yaml:"-"` BodyParams []string `yaml:"-"` + + // BodyRawParam is derived in Resolve: a param given `in: body_raw`, whose + // VALUE becomes the entire request body verbatim (a bare JSON array/scalar/ + // object), instead of being wrapped into the JSON body object. At most one per + // method — for APIs that take a top-level array (e.g. a bulk insert) which a + // wrapped `{field:[...]}` body cannot express. + BodyRawParam string `yaml:"-"` } // BodyVerb reports whether this route sends remaining payload fields as a JSON @@ -792,12 +810,13 @@ const ( InPath = "path" InPathRaw = "path_raw" InBody = "body" + InBodyRaw = "body_raw" InHeader = "header" ) // validParamIn is the closed set of param locations (empty = verb/path default). var validParamIn = map[string]bool{ - InQuery: true, InPath: true, InPathRaw: true, InBody: true, InHeader: true, + InQuery: true, InPath: true, InPathRaw: true, InBody: true, InBodyRaw: true, InHeader: true, } // resolveParamLocs derives the per-location param buckets from the path @@ -812,6 +831,7 @@ func (h *HTTPRoute) resolveParamLocs() { h.QueryParams = nil h.BodyParams = nil h.HeaderParams = nil + h.BodyRawParam = "" placeholder := map[string]bool{} for _, name := range pathParamNames(h.Path) { placeholder[name] = true @@ -836,6 +856,8 @@ func (h *HTTPRoute) resolveParamLocs() { h.QueryParams = append(h.QueryParams, name) case InBody: h.BodyParams = append(h.BodyParams, name) + case InBodyRaw: + h.BodyRawParam = name case InHeader: h.HeaderParams = append(h.HeaderParams, name) } @@ -1053,7 +1075,7 @@ func (c *Config) validateHTTPMethod(i int, m Method) []error { for _, name := range inNames { loc := m.HTTP.ParamIn[name] if !validParamIn[loc] { - errs = append(errs, fmt.Errorf("methods[%d] (%s): param %q in %q must be query|path|path_raw|body|header", i, m.Name, name, loc)) + errs = append(errs, fmt.Errorf("methods[%d] (%s): param %q in %q must be query|path|path_raw|body|body_raw|header", i, m.Name, name, loc)) } if _, ok := m.Params[name]; !ok { errs = append(errs, fmt.Errorf("methods[%d] (%s): param %q has an `in` location but is not declared under params", i, m.Name, name)) diff --git a/internal/scaffold/templates/broker_signup.go.tmpl b/internal/scaffold/templates/broker_signup.go.tmpl index 3c91567..b8956e9 100644 --- a/internal/scaffold/templates/broker_signup.go.tmpl +++ b/internal/scaffold/templates/broker_signup.go.tmpl @@ -29,6 +29,7 @@ type brokerSignupConfig struct { brokerURL string // the Pilot broker /signup endpoint (signed) secretKey string // secrets.json key the minted key is cached under emailKey string // secrets.json key the minted email is cached under + urlKey string // secrets.json key the minted per-user backend URL is cached under (empty = none) } // brokerSignupHandler signs a call to the broker, then caches {email, api_key}. @@ -78,8 +79,9 @@ func brokerSignupHandler(bc brokerSignupConfig, signer backend.Signer, manifestP } var out struct { - Email string `json:"email"` - APIKey string `json:"api_key"` + Email string `json:"email"` + APIKey string `json:"api_key"` + BackendURL string `json:"backend_url"` } if err := json.Unmarshal(raw, &out); err != nil || out.APIKey == "" { return nil, fmt.Errorf("signup: broker returned no api_key") @@ -88,12 +90,17 @@ func brokerSignupHandler(bc brokerSignupConfig, signer backend.Signer, manifestP if bc.emailKey != "" { add[bc.emailKey] = out.Email } + // A per-user backend URL provisioned at signup is cached too, so the HTTP + // client's BaseURLFunc reaches this user's own backend on every later call. + if bc.urlKey != "" && out.BackendURL != "" { + add[bc.urlKey] = out.BackendURL + } if err := signupMergeSecrets(secretsFile, add); err != nil { return nil, fmt.Errorf("signup: cache key: %w", err) } return signupResult(map[string]any{ - "ok": true, "email": out.Email, "key_field": bc.secretKey, "api_key": "saved", - "message": "account minted by the broker; key cached to secrets.json and used on every call", + "ok": true, "email": out.Email, "backend_url": out.BackendURL, "key_field": bc.secretKey, "api_key": "saved", + "message": "account provisioned by the broker; key + backend cached to secrets.json and used on every call", }) } } diff --git a/internal/scaffold/templates/client_http.go.tmpl b/internal/scaffold/templates/client_http.go.tmpl index a0f225d..73db3d1 100644 --- a/internal/scaffold/templates/client_http.go.tmpl +++ b/internal/scaffold/templates/client_http.go.tmpl @@ -25,10 +25,11 @@ type httpDoer interface { // Client talks to one backend over HTTP. type Client struct { - baseURL string - headers map[string]string - headerFunc func() map[string]string // when set, re-resolved per request (see Config.HeaderFunc) - http httpDoer + baseURL string + baseURLFunc func() string // when set, re-resolved per request (see Config.BaseURLFunc) + headers map[string]string + headerFunc func() map[string]string // when set, re-resolved per request (see Config.HeaderFunc) + http httpDoer {{- if .Managed}} sign Signer // managed: stamp the verified caller identity on every request {{- end}} @@ -45,7 +46,14 @@ type Config struct { // method that writes the key to $APP/secrets.json mid-life; a per-request // re-read is the only way later calls see the freshly-minted key. HeaderFunc func() map[string]string - Timeout time.Duration + // BaseURLFunc, when set, is called on EVERY request to (re)resolve the backend + // base URL, and takes precedence over BaseURL when it returns a non-empty + // value. It exists for apps whose backend endpoint is provisioned at runtime — + // a broker-signup route that mints a per-user backend and writes its URL to + // $APP/secrets.json mid-life; a per-request re-read is the only way later calls + // reach the freshly-provisioned backend. BaseURL remains the fallback. + BaseURLFunc func() string + Timeout time.Duration // DaemonAddr is the pilot daemon broker address (--addr), used by the x402 // payment layer to reach the wallet. Empty when x402 is disabled. DaemonAddr string @@ -95,7 +103,19 @@ func New(cfg Config) (*Client, error) { return nil, fmt.Errorf("backend: managed adapter requires a Signer") } {{- end}} - return &Client{baseURL: base, headers: cfg.Headers, headerFunc: cfg.HeaderFunc, http: doer{{if .Managed}}, sign: cfg.Sign{{end}}}, nil + return &Client{baseURL: base, baseURLFunc: cfg.BaseURLFunc, headers: cfg.Headers, headerFunc: cfg.HeaderFunc, http: doer{{if .Managed}}, sign: cfg.Sign{{end}}}, nil +} + +// base returns the backend base URL for one request: re-resolved via +// BaseURLFunc when set and non-empty (so a runtime-provisioned backend is picked +// up), else the static base captured at New. +func (c *Client) base() string { + if c.baseURLFunc != nil { + if u := strings.TrimRight(c.baseURLFunc(), "/"); u != "" { + return u + } + } + return c.baseURL } // authHeaders returns the auth headers for one request: re-resolved via @@ -118,7 +138,7 @@ func (c *Client) Get(ctx context.Context, path string, params map[string]string) q.Set(k, v) } } - u := c.baseURL + path + u := c.base() + path if enc := q.Encode(); enc != "" { u += "?" + enc } @@ -139,7 +159,7 @@ func (c *Client) PostRaw(ctx context.Context, path string, payload json.RawMessa if len(bytes.TrimSpace(body)) == 0 { body = []byte("{}") } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base()+path, bytes.NewReader(body)) if err != nil { return nil, err } @@ -158,7 +178,7 @@ func (c *Client) PostRaw(ctx context.Context, path string, payload json.RawMessa // body sends no body (no Content-Type). The path is used verbatim — a raw // path param (URL-in-path) reaches the backend unescaped. func (c *Client) Do(ctx context.Context, method, path string, query, headers map[string]string, body json.RawMessage) (json.RawMessage, error) { - u := c.baseURL + path + u := c.base() + path if len(query) > 0 { q := url.Values{} for k, v := range query { diff --git a/internal/scaffold/templates/main.go.tmpl b/internal/scaffold/templates/main.go.tmpl index cf7e789..abd55cb 100644 --- a/internal/scaffold/templates/main.go.tmpl +++ b/internal/scaffold/templates/main.go.tmpl @@ -68,7 +68,7 @@ func main() { log.Fatalf("{{.BinaryName}}: %v", err) } {{- end}} - client, err := backend.New(backend.Config{BaseURL: cfg.BackendURL{{if .Backend.Headers}}{{if .HasSignup}}, HeaderFunc: func() map[string]string { return resolveHeaders(*manifestPath) }{{else}}, Headers: resolveHeaders(*manifestPath){{end}}{{end}}{{if .Backend.X402}}, DaemonAddr: *addr{{end}}{{if .Managed}}, Sign: signer{{end}}}) + client, err := backend.New(backend.Config{BaseURL: cfg.BackendURL{{if .Backend.URLSecret}}, BaseURLFunc: func() string { return resolveBackendURLDyn(*manifestPath, {{printf "%q" .Backend.URLSecret}}) }{{end}}{{if .Backend.Headers}}{{if .HasSignup}}, HeaderFunc: func() map[string]string { return resolveHeaders(*manifestPath) }{{else}}, Headers: resolveHeaders(*manifestPath){{end}}{{end}}{{if .Backend.X402}}, DaemonAddr: *addr{{end}}{{if .Managed}}, Sign: signer{{end}}}) if err != nil { log.Fatalf("{{.BinaryName}}: backend config: %v", err) } @@ -184,12 +184,14 @@ func registerHandlers(d *ipc.Dispatcher, c *backend.Client, version, backendURL d.Register("{{.Name}}", accountHandler(accountConfig{ secretKey: {{printf "%q" .Signup.SecretKey}}, emailKey: {{printf "%q" .Signup.EmailKey}}, + urlKey: {{printf "%q" $.Backend.URLSecret}}, }, manifestPath)) // {{.Duration}} (local: retrieve the cached account) {{- else if .Signup.IsBroker}} d.Register("{{.Name}}", brokerSignupHandler(brokerSignupConfig{ brokerURL: {{printf "%q" .Signup.BrokerURL}}, secretKey: {{printf "%q" .Signup.SecretKey}}, emailKey: {{printf "%q" .Signup.EmailKey}}, + urlKey: {{printf "%q" $.Backend.URLSecret}}, }, signer, manifestPath, dur("{{.TimeoutFor}}"))) // {{.Duration}} (broker self-signup: one call, no email) {{- else if .Signup.IsVerify}} d.Register("{{.Name}}", signupVerifyHandler(signupVerifyConfig{ @@ -213,6 +215,7 @@ func registerHandlers(d *ipc.Dispatcher, c *backend.Client, version, backendURL queryParams: []string{ {{- range $p := .HTTP.QueryParams}}{{printf "%q" $p}}, {{end -}} }, bodyParams: []string{ {{- range $p := .HTTP.BodyParams}}{{printf "%q" $p}}, {{end -}} }, headerParams: []string{ {{- range $p := .HTTP.HeaderParams}}{{printf "%q" $p}}, {{end -}} }, + bodyRawParam: "{{.HTTP.BodyRawParam}}", }, dur("{{.TimeoutFor}}")){{if .HTTP.CaptureTo}}){{end}}) // {{.Duration}} {{- end}} {{- end}} @@ -318,6 +321,7 @@ type route struct { queryParams []string // forced to the query string bodyParams []string // forced into the JSON body object headerParams []string // forced to a request header (Name: value) + bodyRawParam string // a param whose value IS the entire request body (bare array/scalar); "" = none } // forward builds one backend request for a method. It fills {name} placeholders @@ -352,6 +356,22 @@ func forward(c *backend.Client, r route, timeout time.Duration) ipc.Handler { delete(fields, k) } } + // body_raw: one param's value IS the entire request body, verbatim (a bare + // top-level array/scalar/object an object-wrapped body cannot express). Any + // remaining non-located fields fold into the query string. + if r.bodyRawParam != "" { + raw, ok := fields[r.bodyRawParam] + if !ok { + return nil, fmt.Errorf("missing required body parameter %q", r.bodyRawParam) + } + delete(fields, r.bodyRawParam) + for k, v := range fields { + query[k] = jsonScalar(v) + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return c.Do(ctx, r.method, path, query, headers, json.RawMessage(raw)) + } bodyFields := map[string]json.RawMessage{} for _, k := range r.bodyParams { if v, ok := fields[k]; ok { @@ -804,6 +824,34 @@ func resolveConfig(manifestPath string) appConfig { } return cfg } +{{if .Backend.URLSecret}} +// resolveBackendURLDyn reads a per-user backend base URL from $APP/secrets.json +// under key (provisioned at signup). Called per request by the HTTP client's +// BaseURLFunc so calls made after signup reach the user's own backend without a +// restart. Empty (not yet signed up) ⇒ the client falls back to the baked-in +// default. An env override of the same name wins (dev/self-hosted). +func resolveBackendURLDyn(manifestPath, key string) string { + if v := os.Getenv(key); v != "" { + return v + } + dir := os.Getenv("APP") + if manifestPath != "" { + dir = filepath.Dir(manifestPath) + } + if dir == "" { + return "" + } + b, err := os.ReadFile(filepath.Join(dir, "secrets.json")) + if err != nil { + return "" + } + var m map[string]string + if json.Unmarshal(b, &m) != nil { + return "" + } + return m[key] +} +{{end}} {{if .Backend.Headers}} // resolveHeaders builds the headers sent on every backend request. ${TOKEN} // placeholders are filled at runtime from the environment or from a flat diff --git a/internal/scaffold/templates/signup.go.tmpl b/internal/scaffold/templates/signup.go.tmpl index 3122271..503a5fb 100644 --- a/internal/scaffold/templates/signup.go.tmpl +++ b/internal/scaffold/templates/signup.go.tmpl @@ -181,6 +181,7 @@ func signupVerifyHandler(sc signupVerifyConfig, manifestPath string, timeout tim type accountConfig struct { secretKey string emailKey string + urlKey string // secrets.json key holding a per-user backend URL (empty = none) } func accountHandler(ac accountConfig, manifestPath string) ipc.Handler { @@ -189,11 +190,15 @@ func accountHandler(ac accountConfig, manifestPath string) ipc.Handler { if dir := signupAppDir(manifestPath); dir != "" { m = signupReadSecrets(filepath.Join(dir, "secrets.json")) } - return signupResult(map[string]any{ + out := map[string]any{ "signed_up": m[ac.secretKey] != "", "email": m[ac.emailKey], "api_key": m[ac.secretKey], - }) + } + if ac.urlKey != "" { + out["backend_url"] = m[ac.urlKey] + } + return signupResult(out) } } diff --git a/internal/scaffold/zz_http_paramin_test.go b/internal/scaffold/zz_http_paramin_test.go index f815abf..afa598b 100644 --- a/internal/scaffold/zz_http_paramin_test.go +++ b/internal/scaffold/zz_http_paramin_test.go @@ -44,6 +44,25 @@ func TestParamInResolvesBuckets(t *testing.T) { } } +// body_raw sorts a param into the single BodyRawParam slot (its value becomes +// the whole request body), leaving the other buckets empty. +func TestBodyRawResolvesToSingleSlot(t *testing.T) { + h := parseResolved(t, paramInYAML( + "/records/{table}", + "table: t, records: rows to insert", + "table: path, records: body_raw", + )).Methods[0].HTTP + if h.BodyRawParam != "records" { + t.Errorf("BodyRawParam = %q, want records", h.BodyRawParam) + } + if len(h.BodyParams) != 0 { + t.Errorf("BodyParams = %v, want [] (records is body_raw)", h.BodyParams) + } + if len(h.PathParams) != 1 || h.PathParams[0] != "table" { + t.Errorf("PathParams = %v, want [table]", h.PathParams) + } +} + // A {name} placeholder with no explicit `in` stays an escaped path param — // the historical default, so existing specs are unchanged. func TestPlaceholderDefaultsToEscapedPath(t *testing.T) { @@ -63,7 +82,7 @@ func TestParamInValidation(t *testing.T) { } // invalid `in` value. c = parseResolved(t, paramInYAML("/search", "q: a query", "q: cookie")) - if !hasErrContaining(c.Validate(), "must be query|path|path_raw|body|header") { + if !hasErrContaining(c.Validate(), "must be query|path|path_raw|body|body_raw|header") { t.Errorf("expected invalid-in error, got %v", c.Validate()) } // `in` on an undeclared param. diff --git a/internal/scaffold/zz_url_secret_test.go b/internal/scaffold/zz_url_secret_test.go new file mode 100644 index 0000000..e163892 --- /dev/null +++ b/internal/scaffold/zz_url_secret_test.go @@ -0,0 +1,68 @@ +package scaffold + +import ( + "go/parser" + "go/token" + "path/filepath" + "strings" + "testing" +) + +const urlSecretSpec = ` +id: io.pilot.dyn +app_version: 0.1.0 +description: "App whose backend is provisioned per user at signup." +backend: + type: http + base_url: https://api.example.com + auth: byo + url_secret: DYN_BACKEND_URL + headers: + Authorization: "Bearer ${DYN_API_KEY}" +methods: + - name: dyn.signup + summary: "Provision your backend." + duration: slow + signup: {step: broker, broker_url: "https://broker.example/dyn/signup", secret_key: DYN_API_KEY} + - name: dyn.account + summary: "Read the cached account." + duration: fast + signup: {step: account, secret_key: DYN_API_KEY} + - name: dyn.metadata + summary: "Read backend metadata." + duration: fast + http: {verb: GET, path: /api/metadata} +` + +// An app with backend.url_secret must generate a client that re-resolves the +// base URL per request (BaseURLFunc), a helper that reads it from secrets.json, +// and a broker-signup handler that caches the returned backend_url under the +// url_secret key — so calls after signup reach the user's own backend. +func TestURLSecretGeneratesDynamicBaseURL(t *testing.T) { + cfg := parseSpec(t, urlSecretSpec) + if cfg.Backend.URLSecret != "DYN_BACKEND_URL" { + t.Fatalf("url_secret not parsed: %q", cfg.Backend.URLSecret) + } + dir := t.TempDir() + written, err := Generate(cfg, dir) + if err != nil { + t.Fatalf("generate: %v", err) + } + for _, w := range written { + if strings.HasSuffix(w, ".go") { + if _, err := parser.ParseFile(token.NewFileSet(), filepath.Join(dir, w), nil, parser.AllErrors); err != nil { + t.Errorf("%s: not valid Go: %v", w, err) + } + } + } + + main := readFile(t, dir, filepath.Join("cmd", "dyn-app", "main.go")) + mustContain(t, "main.go", main, + "BaseURLFunc:", "resolveBackendURLDyn", `"DYN_BACKEND_URL"`) + + client := readFile(t, dir, filepath.Join("internal", "backend", "client.go")) + mustContain(t, "client.go", client, "baseURLFunc", "func (c *Client) base()") + + brokerSignup := readFile(t, dir, filepath.Join("cmd", "dyn-app", "broker_signup.go")) + mustContain(t, "broker_signup.go", brokerSignup, "urlKey", "BackendURL", "backend_url") +} diff --git a/submissions/io.pilot.insforge/submission.json b/submissions/io.pilot.insforge/submission.json new file mode 100644 index 0000000..b0217ba --- /dev/null +++ b/submissions/io.pilot.insforge/submission.json @@ -0,0 +1,487 @@ +{ + "id": "io.pilot.insforge", + "version": "1.0.0", + "namespace": "insforge", + "description": "The agent-native cloud (an AWS for agents) as ONE Pilot app. insforge.signup provisions your OWN isolated InsForge backend in one call \u2014 no email, no browser: a dedicated Postgres database, authentication, S3-style storage, Deno edge functions, and a Model Gateway OpenRouter key seeded with $1 of AI credits. Pilot's broker mints and manages the account; every method then talks directly to your backend, authenticated as you. Free to provision \u2014 pay only for what your backend uses.", + "email": "alex@vulturelabs.io", + "backend": { + "type": "http", + "base_url": "https://api.insforge.dev", + "auth": "byo", + "url_secret": "INSFORGE_BACKEND_URL", + "headers": [ + { + "name": "Authorization", + "value": "Bearer ${INSFORGE_API_KEY}" + } + ] + }, + "methods": [ + { + "name": "insforge.signup", + "description": "Provision your OWN isolated InsForge backend in ONE call \u2014 no email, no browser, no human step. This signs a keyless request to Pilot's InsForge signup broker, which mints a fresh InsForge project under Pilot's managed account and returns its access key + backend URL. The adapter caches {api_key, backend_url} to $APP/secrets.json, and from then on EVERY other insforge.* method talks directly to YOUR backend, authenticated as you. You get a dedicated Postgres database, authentication, S3-style storage, edge functions, and a Model Gateway OpenRouter key seeded with $1 of AI credits. Idempotent: the broker provisions at most one project per Pilot identity, so a repeat call (or a fresh install) returns the SAME backend. Run this ONCE before any other method. FREE to provision \u2014 you pay only for the resources your backend actually uses (see per-method costs and insforge.help). Retrieve your backend URL + key any time via insforge.account. Takes no arguments.", + "latency": "slow", + "signup": { + "step": "broker", + "broker_url": "https://broker.pilotprotocol.network/insforge/signup", + "secret_key": "INSFORGE_API_KEY" + }, + "params": [] + }, + { + "name": "insforge.account", + "description": "Retrieve your provisioned InsForge backend \u2014 its backend_url, your api_key, and project id \u2014 plus a signed_up flag. Local, instant, FREE (reads $APP/secrets.json; no backend call). Use it to confirm you're provisioned or to read your key/URL (e.g. to call the backend from your own code). If signed_up is false, call insforge.signup first.", + "latency": "fast", + "signup": { + "step": "account", + "secret_key": "INSFORGE_API_KEY" + }, + "params": [] + }, + { + "name": "insforge.metadata", + "description": "Get your backend's full configuration \u2014 auth providers/SMTP, database tables, storage buckets, edge functions, realtime, and AI models \u2014 in one call. FREE (control-plane / metadata).", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/api/metadata" + }, + "params": [] + }, + { + "name": "insforge.db_list_tables", + "description": "List all database tables with their schemas (columns, types, constraints). FREE (control-plane / metadata).", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/api/database/tables" + }, + "params": [] + }, + { + "name": "insforge.db_create_table", + "description": "Create a Postgres table. Body: {tableName, columns:[{columnName, type, isNullable, isUnique, defaultValue?}]} \u2014 type is one of string, integer, float, boolean, datetime, date, uuid, json. An id (uuid) + createdAt/updatedAt are added automatically. Rows count toward Postgres storage (500 MB free, then $0.125/GB); reads toward bandwidth (5 GB free, then $0.09/GB).", + "latency": "med", + "http": { + "verb": "POST", + "path": "/api/database/tables" + }, + "params": [ + { + "name": "tableName", + "type": "string", + "required": true, + "description": "New table name (snake_case).", + "in": "body" + }, + { + "name": "columns", + "type": "array", + "required": true, + "description": "Column defs: [{columnName,type,isNullable,isUnique,defaultValue?}].", + "in": "body" + } + ] + }, + { + "name": "insforge.db_query", + "description": "Query records from a table with PostgREST-style filtering, sorting, pagination. Path: table name. Query params: limit (1-1000, default 100), offset, order (e.g. createdAt.desc), select (comma columns), and any {field}=. filter (op: eq, neq, gt, gte, lt, lte, like, ilike, in, is). Counts toward bandwidth (5 GB free, then $0.09/GB).", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/api/database/records/{table}" + }, + "params": [ + { + "name": "table", + "type": "string", + "required": true, + "description": "Table name.", + "in": "path" + }, + { + "name": "limit", + "type": "integer", + "required": false, + "description": "Max records (1-1000, default 100).", + "in": "query" + }, + { + "name": "offset", + "type": "integer", + "required": false, + "description": "Records to skip.", + "in": "query" + }, + { + "name": "order", + "type": "string", + "required": false, + "description": "Sort, e.g. createdAt.desc or name.asc.", + "in": "query" + }, + { + "name": "select", + "type": "string", + "required": false, + "description": "Comma-separated columns to return.", + "in": "query" + } + ] + }, + { + "name": "insforge.db_insert", + "description": "Insert one or more records into a table. Path: table name. Body: a JSON array of row objects (e.g. [{\"title\":\"hi\"}]) \u2014 always an array, even for one row. Returns the inserted rows with generated ids. Note: just after db_create_table, wait ~3s before the first insert (the database reloads its schema cache after DDL; an immediate insert can 404). Rows count toward Postgres storage (500 MB free, then $0.125/GB); reads toward bandwidth (5 GB free, then $0.09/GB).", + "latency": "med", + "http": { + "verb": "POST", + "path": "/api/database/records/{table}" + }, + "params": [ + { + "name": "table", + "type": "string", + "required": true, + "description": "Table name.", + "in": "path" + }, + { + "name": "records", + "type": "array", + "required": true, + "description": "Array of row objects to insert (sent as the request body).", + "in": "body_raw" + } + ] + }, + { + "name": "insforge.db_update", + "description": "Update records matching PostgREST filters. Path: table name. Pass filter(s) as query params ({field}=eq.) and the new column values in the body. Returns the updated rows. Rows count toward Postgres storage (500 MB free, then $0.125/GB); reads toward bandwidth (5 GB free, then $0.09/GB).", + "latency": "med", + "http": { + "verb": "PATCH", + "path": "/api/database/records/{table}" + }, + "params": [ + { + "name": "table", + "type": "string", + "required": true, + "description": "Table name.", + "in": "path" + } + ] + }, + { + "name": "insforge.db_delete", + "description": "Delete records matching PostgREST filters. Path: table name. Pass filter(s) as query params ({field}=eq.). Returns the deleted rows. Rows count toward Postgres storage (500 MB free, then $0.125/GB); reads toward bandwidth (5 GB free, then $0.09/GB).", + "latency": "med", + "http": { + "verb": "DELETE", + "path": "/api/database/records/{table}" + }, + "params": [ + { + "name": "table", + "type": "string", + "required": true, + "description": "Table name.", + "in": "path" + } + ] + }, + { + "name": "insforge.db_sql", + "description": "Run raw parameterized SQL against your Postgres database. Body: {query, params?}. Powerful \u2014 use for joins, aggregates, DDL, migrations. Rows count toward Postgres storage (500 MB free, then $0.125/GB); reads toward bandwidth (5 GB free, then $0.09/GB).", + "latency": "med", + "http": { + "verb": "POST", + "path": "/api/database/advance/rawsql" + }, + "params": [ + { + "name": "query", + "type": "string", + "required": true, + "description": "SQL statement (use $1,$2 for params).", + "in": "body" + }, + { + "name": "params", + "type": "array", + "required": false, + "description": "Positional parameter values.", + "in": "body" + } + ] + }, + { + "name": "insforge.storage_list_buckets", + "description": "List your storage buckets. FREE (control-plane / metadata).", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/api/storage/buckets" + }, + "params": [] + }, + { + "name": "insforge.storage_create_bucket", + "description": "Create a storage bucket. Body: {bucketName, isPublic}. A public bucket serves objects without auth; a private one requires the key. FREE (control-plane / metadata).", + "latency": "med", + "http": { + "verb": "POST", + "path": "/api/storage/buckets" + }, + "params": [ + { + "name": "bucketName", + "type": "string", + "required": true, + "description": "Bucket name.", + "in": "body" + }, + { + "name": "isPublic", + "type": "boolean", + "required": false, + "description": "Public (no-auth reads) if true; default false.", + "in": "body" + } + ] + }, + { + "name": "insforge.storage_list_objects", + "description": "List objects in a bucket (paginated). Path: bucket name. Query: limit, offset, prefix. Counts toward bandwidth (5 GB free, then $0.09/GB).", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/api/storage/buckets/{bucket}/objects" + }, + "params": [ + { + "name": "bucket", + "type": "string", + "required": true, + "description": "Bucket name.", + "in": "path" + }, + { + "name": "prefix", + "type": "string", + "required": false, + "description": "Only keys under this prefix.", + "in": "query" + }, + { + "name": "limit", + "type": "integer", + "required": false, + "description": "Max objects (default 100).", + "in": "query" + } + ] + }, + { + "name": "insforge.storage_download", + "description": "Download an object (returns its content). Path: bucket + object key. Counts toward bandwidth (5 GB free, then $0.09/GB).", + "latency": "med", + "http": { + "verb": "GET", + "path": "/api/storage/buckets/{bucket}/objects/{key}" + }, + "params": [ + { + "name": "bucket", + "type": "string", + "required": true, + "description": "Bucket name.", + "in": "path" + }, + { + "name": "key", + "type": "string", + "required": true, + "description": "Object key.", + "in": "path" + } + ] + }, + { + "name": "insforge.storage_delete", + "description": "Delete an object. Path: bucket + object key. FREE (control-plane / metadata).", + "latency": "med", + "http": { + "verb": "DELETE", + "path": "/api/storage/buckets/{bucket}/objects/{key}" + }, + "params": [ + { + "name": "bucket", + "type": "string", + "required": true, + "description": "Bucket name.", + "in": "path" + }, + { + "name": "key", + "type": "string", + "required": true, + "description": "Object key.", + "in": "path" + } + ] + }, + { + "name": "insforge.auth_config", + "description": "Get your backend's auth configuration (enabled OAuth providers, JWT settings, SMTP). FREE (control-plane / metadata).", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/api/auth/config" + }, + "params": [] + }, + { + "name": "insforge.auth_list_users", + "description": "List the end-users registered in your backend's auth (paginated). Query: limit, offset. Auth users count toward MAU (50,000 free, then $0.00325 per monthly active user).", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/api/auth/users" + }, + "params": [ + { + "name": "limit", + "type": "integer", + "required": false, + "description": "Max users (default 10).", + "in": "query" + }, + { + "name": "offset", + "type": "integer", + "required": false, + "description": "Users to skip.", + "in": "query" + } + ] + }, + { + "name": "insforge.functions_list", + "description": "List your deployed edge functions (Deno/TypeScript) and the runtime status. FREE (control-plane / metadata).", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/api/functions" + }, + "params": [] + }, + { + "name": "insforge.function_invoke", + "description": "Invoke a deployed edge function by slug. Path: function slug. Body: the JSON payload passed to the function. Each invocation counts toward edge-function calls (100,000 free, then $0.01 per 1,000).", + "latency": "med", + "http": { + "verb": "POST", + "path": "/functions/{slug}" + }, + "params": [ + { + "name": "slug", + "type": "string", + "required": true, + "description": "Function slug.", + "in": "path" + } + ] + }, + { + "name": "insforge.ai_gateway_key", + "description": "Get your project's Model Gateway OpenRouter API key (sk-or-...). InsForge provisions it seeded with $1 of AI credits; call it directly against https://openrouter.ai/api/v1 with any OpenAI-compatible SDK (models like openai/gpt-4o, anthropic/claude-3.5-haiku). Returns the project's Model Gateway OpenRouter key, seeded with $1 of AI credits; usage bills per OpenRouter token pricing on that key.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/api/ai/openrouter/api-key" + }, + "params": [] + }, + { + "name": "insforge.secrets_list", + "description": "List your backend's secrets/keys (names + metadata; values are not returned). FREE (control-plane / metadata).", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/api/secrets" + }, + "params": [] + }, + { + "name": "insforge.get", + "description": "Escape hatch: GET any backend API path under /api. Path param is the sub-path (e.g. \"database/tables\", \"storage/config\"); extra fields become query params. Covers endpoints not given a dedicated method. Cost depends on the endpoint \u2014 see insforge.help.", + "latency": "med", + "http": { + "verb": "GET", + "path": "/api/{path}" + }, + "params": [ + { + "name": "path", + "type": "string", + "required": true, + "description": "Backend sub-path under /api (raw, e.g. database/migrations).", + "in": "path_raw" + } + ] + }, + { + "name": "insforge.post", + "description": "Escape hatch: POST any backend API path under /api. Path param is the sub-path; the rest of the payload is sent as the JSON body. Covers endpoints not given a dedicated method. Cost depends on the endpoint.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/api/{path}" + }, + "params": [ + { + "name": "path", + "type": "string", + "required": true, + "description": "Backend sub-path under /api (raw).", + "in": "path_raw" + } + ] + } + ], + "listing": { + "display_name": "InsForge", + "tagline": "The agent-native cloud \u2014 your own backend in one call", + "app_description": "**InsForge** is the agent-native cloud infrastructure platform \u2014 the cloud a coding agent drives end to end. This app puts a full InsForge backend behind one Pilot handshake.\n\n`insforge.signup` provisions your OWN isolated InsForge project in a single call \u2014 no email, no browser, no human step \u2014 via Pilot's signup broker (which holds one managed InsForge account and mints a fresh project per Pilot identity). You immediately get:\n\n- **Postgres database** \u2014 tables + PostgREST-style CRUD (`db_create_table`, `db_query`, `db_insert`, `db_update`, `db_delete`) and raw SQL (`db_sql`).\n- **Authentication** \u2014 users, sessions, OAuth/JWT (`auth_config`, `auth_list_users`).\n- **S3-style storage** \u2014 buckets + objects (`storage_create_bucket`, `storage_upload`, `storage_download`, `storage_list_objects`).\n- **Edge functions** \u2014 Deno/TypeScript (`functions_list`, `function_invoke`).\n- **AI Model Gateway** \u2014 an OpenRouter key seeded with **$1 of AI credits** (`ai_gateway_key`), OpenAI-compatible.\n- **Escape hatches** \u2014 `insforge.get` / `insforge.post` reach any backend endpoint.\n\nThe adapter caches your backend URL + key to `secrets.json`; every method then talks directly to your own backend, authenticated as you. **Free to provision** \u2014 you pay only for the resources your backend actually uses. Per-method costs are stated on each method and summarised in `insforge.help`; full pricing at https://insforge.dev/pricing.\n\nFree-tier allowances (per backend): 500 MB Postgres, 1 GB storage, 5 GB bandwidth, 50,000 monthly active users, 100,000 edge-function calls, and $1 of Model Gateway AI credits.", + "license": "Apache-2.0", + "homepage": "https://insforge.dev", + "source_url": "https://github.com/InsForge/insforge", + "categories": [ + "Developer Tools", + "Data & Storage" + ], + "keywords": [ + "backend", + "baas", + "database", + "postgres", + "auth", + "storage", + "edge functions", + "ai gateway", + "openrouter", + "agent-native", + "insforge" + ] + }, + "vendor": { + "name": "InsForge", + "url": "https://insforge.dev", + "contact": "info@insforge.dev", + "agent_usage": "insforge.signup once (provisions your isolated backend, no email/browser), then drive Postgres, auth, storage, edge functions and the AI model gateway directly as yourself.", + "capabilities": "database, auth, storage, edge-functions, ai-gateway, hosting" + } +} From 51ff7a98c890814c7036ec63b7cc8be8e9bf2bda Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Tue, 7 Jul 2026 19:47:04 -0700 Subject: [PATCH 2/2] gofmt insforgesignup/broker.go --- internal/insforgesignup/broker.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/insforgesignup/broker.go b/internal/insforgesignup/broker.go index afee84a..a75c8d9 100644 --- a/internal/insforgesignup/broker.go +++ b/internal/insforgesignup/broker.go @@ -75,11 +75,11 @@ type Broker struct { http *http.Client log *log.Logger - mu sync.Mutex - tok string // cached master access token - tokExp time.Time // its expiry - ipSeen map[string]map[string]bool // ip -> set of caller ids - ipLast map[string]time.Time // ip -> last mint time + mu sync.Mutex + tok string // cached master access token + tokExp time.Time // its expiry + ipSeen map[string]map[string]bool // ip -> set of caller ids + ipLast map[string]time.Time // ip -> last mint time } // New validates cfg, opens the ledger, and returns a Broker.