// Package integrations is the loader for the integrations registry // (registry/README.md): one JSON manifest per third-party service, embedded // into the binary the way recipes and knowledge are. A manifest is the WHOLE // of what an integration can do — the one API host its ops may touch and the // closed list of operations — so the loader fails closed: a manifest that // bends any rule never loads at all, and an op that isn't declared cannot be // expressed. package integrations import ( "embed" "encoding/json" "fmt" "net/url" "regexp" "sort" "sync" ) //go:embed registry/manifests/*.json var manifestFS embed.FS // Manifest is one service's entry in the registry (schema v1 — see // registry/README.md for the annotated shape). type Manifest struct { Schema int `json:"schema"` ID string `json:"id"` Name string `json:"name"` Category string `json:"category"` Auth Auth `json:"auth"` Ops []Op `json:"ops"` } // Auth describes how a member authenticates and — critically — the single // host every op is pinned to (exec.go enforces the pin at request time). type Auth struct { Kind string `json:"kind"` // "pat" | "oauth" | "both" Base string `json:"base"` Authorization string `json:"authorization"` // header scheme; default Bearer; "none" = bare token Headers map[string]string `json:"headers"` PATHelp string `json:"pat_help"` OAuth *OAuthInfo `json:"oauth"` } // OAuthInfo is present when the service supports the broker OAuth flow // (client secrets live on the broker, never here). The URLs are public // provider endpoints; the broker reads them from its embedded copy of this // manifest, so registering a new OAuth service is: manifest gains this block, // broker env gains the client id + secret. type OAuthInfo struct { AuthorizeURL string `json:"authorize_url"` TokenURL string `json:"token_url"` // the code→token exchange endpoint (broker-only) Scopes []string `json:"scopes"` // ScopesWrite replaces Scopes on the authorize URL when the member chose a // read & write connection at connect time — a read-only connection's // provider token is then minted under read-only provider scopes, not merely // capped client-side. Absent = Scopes serves both caps. ScopesWrite []string `json:"scopes_write"` } // Op is one declared operation. Everything a call can vary is a named string // arg; `{arg}` placeholders in the path/body/confirm templates are the only // way member input reaches the wire. type Op struct { ID string `json:"id"` Label string `json:"label"` Access string `json:"access"` // "read" | "write" Method string `json:"method"` Path string `json:"path"` Args map[string]ArgSpec `json:"args"` BodyJSON map[string]any `json:"body_json"` BodyForm map[string]string `json:"body_form"` Encoding string `json:"encoding"` // "" | "json" | "form" Confirm string `json:"confirm"` // required on writes Pick *Pick `json:"pick"` // response projection (see Pick) } // ArgSpec declares one string argument. type ArgSpec struct { Required bool `json:"required"` } // Pick bounds what an op's RESPONSE carries back into the chat. Provider APIs // answer with far more than an app needs (a GitHub repo object is ~100 fields), // and an op result travels inside one end-to-end-encrypted message with a hard // size cap — so the manifest declares the projection, making "what data can // flow back" as reviewable as "what request can go out". Root descends into an // envelope ("data" for DNSimple's {"data": …}); fields projects an object — or // each object of an array — down to the named dot-paths. Absent pick = the raw // body, still subject to the caller's size cap. type Pick struct { Root string `json:"root"` // dot-path to the value of interest; "" = whole body Fields []string `json:"fields"` // dot-paths kept per object; empty = keep the value whole } // Registry is the loaded, validated catalog. type Registry struct { byID map[string]*Manifest } // Get returns the manifest for a service id, or nil. func (r *Registry) Get(id string) *Manifest { return r.byID[id] } // List returns every manifest, sorted by id (stable catalog order). func (r *Registry) List() []*Manifest { out := make([]*Manifest, 0, len(r.byID)) for _, m := range r.byID { out = append(out, m) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } // Op returns the manifest's op by id, or nil — nil means the op cannot run, // full stop (the executor and the sidecar both treat absence as refusal). func (m *Manifest) Op(id string) *Op { for i := range m.Ops { if m.Ops[i].ID == id { return &m.Ops[i] } } return nil } var ( idRE = regexp.MustCompile(`^[a-z0-9-]+$`) opIDRE = regexp.MustCompile(`^[a-z0-9]+(\.[a-z0-9]+)*$`) // placeholderRE is the ONE definition of what a `{arg}` placeholder looks // like — the validator and the executor must agree, or a hole opens // between "validated" and "substituted". placeholderRE = regexp.MustCompile(`\{([a-z0-9_]+)\}`) // pickPathRE is a pick root/field: dot-separated plain JSON keys. No // wildcards, no indexing — a projection names exactly what it keeps. pickPathRE = regexp.MustCompile(`^[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$`) ) var validMethods = map[string]bool{"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true} // Parse unmarshals and validates one manifest. Exported so tests (and, later, // the public-registry CI) can reject a bad manifest without embedding it. func Parse(raw []byte) (*Manifest, error) { var m Manifest if err := json.Unmarshal(raw, &m); err != nil { return nil, fmt.Errorf("manifest: %w", err) } if err := validate(&m); err != nil { return nil, err } return &m, nil } // validate enforces the rules in registry/README.md. Every check fails closed: // there is no "load with warnings" — a manifest is either wholly valid or // wholly absent. func validate(m *Manifest) error { if m.Schema != 1 { return fmt.Errorf("manifest %q: unsupported schema %d (want 1)", m.ID, m.Schema) } if !idRE.MatchString(m.ID) { return fmt.Errorf("manifest: bad id %q", m.ID) } if err := validateBase(m.Auth.Base); err != nil { return fmt.Errorf("manifest %q: %w", m.ID, err) } // An OAuth-capable service must spell both provider endpoints, https — // the broker builds its flow from these. if m.Auth.Kind == "oauth" || m.Auth.Kind == "both" { o := m.Auth.OAuth if o == nil { return fmt.Errorf("manifest %q: auth.kind %q without auth.oauth", m.ID, m.Auth.Kind) } for name, u := range map[string]string{"authorize_url": o.AuthorizeURL, "token_url": o.TokenURL} { parsed, err := url.Parse(u) if err != nil || parsed.Scheme != "https" || parsed.Host == "" { return fmt.Errorf("manifest %q: auth.oauth.%s %q must be an https URL", m.ID, name, u) } } } seen := map[string]bool{} for i := range m.Ops { op := &m.Ops[i] if !opIDRE.MatchString(op.ID) { return fmt.Errorf("manifest %q: bad op id %q", m.ID, op.ID) } if seen[op.ID] { return fmt.Errorf("manifest %q: duplicate op id %q", m.ID, op.ID) } seen[op.ID] = true if op.Access != "read" && op.Access != "write" { return fmt.Errorf("manifest %q: op %q: bad access %q", m.ID, op.ID, op.Access) } // Writes carry the consent chip's exact line: no confirm, no write. if op.Access == "write" && op.Confirm == "" { return fmt.Errorf("manifest %q: op %q: write op without confirm", m.ID, op.ID) } if !validMethods[op.Method] { return fmt.Errorf("manifest %q: op %q: bad method %q", m.ID, op.ID, op.Method) } switch op.Encoding { case "form": if len(op.BodyForm) == 0 { return fmt.Errorf("manifest %q: op %q: encoding form without body_form", m.ID, op.ID) } if op.BodyJSON != nil { return fmt.Errorf("manifest %q: op %q: encoding form with body_json", m.ID, op.ID) } case "", "json": if op.BodyForm != nil { return fmt.Errorf("manifest %q: op %q: body_form requires encoding form", m.ID, op.ID) } default: return fmt.Errorf("manifest %q: op %q: bad encoding %q", m.ID, op.ID, op.Encoding) } if err := validatePlaceholders(op); err != nil { return fmt.Errorf("manifest %q: op %q: %w", m.ID, op.ID, err) } if err := validatePick(op.Pick); err != nil { return fmt.Errorf("manifest %q: op %q: %w", m.ID, op.ID, err) } } return nil } // validatePick rejects a projection that names nothing or names it badly — an // empty pick would read as "keep everything" while looking like a bound. func validatePick(p *Pick) error { if p == nil { return nil } if p.Root == "" && len(p.Fields) == 0 { return fmt.Errorf("pick with neither root nor fields") } if p.Root != "" && !pickPathRE.MatchString(p.Root) { return fmt.Errorf("bad pick root %q", p.Root) } for _, f := range p.Fields { if !pickPathRE.MatchString(f) { return fmt.Errorf("bad pick field %q", f) } } return nil } // validateBase requires an https URL that is a bare host: no path, query, // fragment, or credentials. The base is the executor's pin — anything beyond // the host would blur what "pinned" means. func validateBase(base string) error { u, err := url.Parse(base) if err != nil { return fmt.Errorf("bad auth.base %q: %w", base, err) } if u.Scheme != "https" { return fmt.Errorf("auth.base %q must be https", base) } if u.Host == "" { return fmt.Errorf("auth.base %q has no host", base) } if u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil { return fmt.Errorf("auth.base %q must be scheme+host only", base) } return nil } // validatePlaceholders checks that every `{arg}` appearing anywhere member // input can flow — path, confirm, form values, JSON string leaves — names a // declared arg. An undeclared placeholder would substitute to nothing at run // time; refusing it at load time keeps the failure at review, not in a chat. func validatePlaceholders(op *Op) error { check := func(where, s string) error { for _, match := range placeholderRE.FindAllStringSubmatch(s, -1) { if _, ok := op.Args[match[1]]; !ok { return fmt.Errorf("%s references undeclared arg %q", where, match[1]) } } return nil } if err := check("path", op.Path); err != nil { return err } if err := check("confirm", op.Confirm); err != nil { return err } for k, v := range op.BodyForm { if err := check("body_form."+k, v); err != nil { return err } } return checkJSONLeaves("body_json", op.BodyJSON, check) } // checkJSONLeaves walks a body_json template and runs check on every string // leaf (the only place a placeholder can live in JSON). func checkJSONLeaves(where string, v any, check func(where, s string) error) error { switch t := v.(type) { case string: return check(where, t) case map[string]any: for k, e := range t { if err := checkJSONLeaves(where+"."+k, e, check); err != nil { return err } } case []any: for i, e := range t { if err := checkJSONLeaves(fmt.Sprintf("%s[%d]", where, i), e, check); err != nil { return err } } } return nil } // RenderConfirm substitutes `{arg}` into the op's confirm template. A missing // arg is left literal — the chip then reads visibly wrong instead of silently // asking consent for a blank. func (o *Op) RenderConfirm(args map[string]string) string { return placeholderRE.ReplaceAllStringFunc(o.Confirm, func(ph string) string { name := ph[1 : len(ph)-1] if v, ok := args[name]; ok { return v } return ph }) } var ( loadOnce sync.Once loaded *Registry loadErr error ) // Load parses every embedded manifest once and caches the result. Any single // bad manifest fails the whole load — the registry is reviewed code, so a // partial catalog would mean shipping around a known-bad entry. func Load() (*Registry, error) { loadOnce.Do(func() { entries, err := manifestFS.ReadDir("registry/manifests") if err != nil { loadErr = err return } reg := &Registry{byID: map[string]*Manifest{}} for _, e := range entries { raw, err := manifestFS.ReadFile("registry/manifests/" + e.Name()) if err != nil { loadErr = err return } m, err := Parse(raw) if err != nil { loadErr = fmt.Errorf("%s: %w", e.Name(), err) return } if reg.byID[m.ID] != nil { loadErr = fmt.Errorf("%s: duplicate manifest id %q", e.Name(), m.ID) return } reg.byID[m.ID] = m } loaded = reg }) return loaded, loadErr } // MustRegistry returns the embedded registry, panicking on a malformed // manifest: embedded content is code, so a bad manifest is a build bug that // must never limp into production. func MustRegistry() *Registry { reg, err := Load() if err != nil { panic("integrations: " + err.Error()) } return reg }