| 1 | // Package integrations is the loader for the integrations registry |
| 2 | // (registry/README.md): one JSON manifest per third-party service, embedded |
| 3 | // into the binary the way recipes and knowledge are. A manifest is the WHOLE |
| 4 | // of what an integration can do — the one API host its ops may touch and the |
| 5 | // closed list of operations — so the loader fails closed: a manifest that |
| 6 | // bends any rule never loads at all, and an op that isn't declared cannot be |
| 7 | // expressed. |
| 8 | package integrations |
| 9 | |
| 10 | import ( |
| 11 | "embed" |
| 12 | "encoding/json" |
| 13 | "fmt" |
| 14 | "net/url" |
| 15 | "regexp" |
| 16 | "sort" |
| 17 | "sync" |
| 18 | ) |
| 19 | |
| 20 | //go:embed registry/manifests/*.json |
| 21 | var manifestFS embed.FS |
| 22 | |
| 23 | // Manifest is one service's entry in the registry (schema v1 — see |
| 24 | // registry/README.md for the annotated shape). |
| 25 | type Manifest struct { |
| 26 | Schema int `json:"schema"` |
| 27 | ID string `json:"id"` |
| 28 | Name string `json:"name"` |
| 29 | Category string `json:"category"` |
| 30 | Auth Auth `json:"auth"` |
| 31 | Ops []Op `json:"ops"` |
| 32 | } |
| 33 | |
| 34 | // Auth describes how a member authenticates and — critically — the single |
| 35 | // host every op is pinned to (exec.go enforces the pin at request time). |
| 36 | type Auth struct { |
| 37 | Kind string `json:"kind"` // "pat" | "oauth" | "both" |
| 38 | Base string `json:"base"` |
| 39 | Authorization string `json:"authorization"` // header scheme; default Bearer; "none" = bare token |
| 40 | Headers map[string]string `json:"headers"` |
| 41 | PATHelp string `json:"pat_help"` |
| 42 | OAuth *OAuthInfo `json:"oauth"` |
| 43 | } |
| 44 | |
| 45 | // OAuthInfo is present when the service supports the broker OAuth flow |
| 46 | // (client secrets live on the broker, never here). The URLs are public |
| 47 | // provider endpoints; the broker reads them from its embedded copy of this |
| 48 | // manifest, so registering a new OAuth service is: manifest gains this block, |
| 49 | // broker env gains the client id + secret. |
| 50 | type OAuthInfo struct { |
| 51 | AuthorizeURL string `json:"authorize_url"` |
| 52 | TokenURL string `json:"token_url"` // the code→token exchange endpoint (broker-only) |
| 53 | Scopes []string `json:"scopes"` |
| 54 | // ScopesWrite replaces Scopes on the authorize URL when the member chose a |
| 55 | // read & write connection at connect time — a read-only connection's |
| 56 | // provider token is then minted under read-only provider scopes, not merely |
| 57 | // capped client-side. Absent = Scopes serves both caps. |
| 58 | ScopesWrite []string `json:"scopes_write"` |
| 59 | } |
| 60 | |
| 61 | // Op is one declared operation. Everything a call can vary is a named string |
| 62 | // arg; `{arg}` placeholders in the path/body/confirm templates are the only |
| 63 | // way member input reaches the wire. |
| 64 | type Op struct { |
| 65 | ID string `json:"id"` |
| 66 | Label string `json:"label"` |
| 67 | Access string `json:"access"` // "read" | "write" |
| 68 | Method string `json:"method"` |
| 69 | Path string `json:"path"` |
| 70 | Args map[string]ArgSpec `json:"args"` |
| 71 | BodyJSON map[string]any `json:"body_json"` |
| 72 | BodyForm map[string]string `json:"body_form"` |
| 73 | Encoding string `json:"encoding"` // "" | "json" | "form" |
| 74 | Confirm string `json:"confirm"` // required on writes |
| 75 | Pick *Pick `json:"pick"` // response projection (see Pick) |
| 76 | } |
| 77 | |
| 78 | // ArgSpec declares one string argument. |
| 79 | type ArgSpec struct { |
| 80 | Required bool `json:"required"` |
| 81 | } |
| 82 | |
| 83 | // Pick bounds what an op's RESPONSE carries back into the chat. Provider APIs |
| 84 | // answer with far more than an app needs (a GitHub repo object is ~100 fields), |
| 85 | // and an op result travels inside one end-to-end-encrypted message with a hard |
| 86 | // size cap — so the manifest declares the projection, making "what data can |
| 87 | // flow back" as reviewable as "what request can go out". Root descends into an |
| 88 | // envelope ("data" for DNSimple's {"data": …}); fields projects an object — or |
| 89 | // each object of an array — down to the named dot-paths. Absent pick = the raw |
| 90 | // body, still subject to the caller's size cap. |
| 91 | type Pick struct { |
| 92 | Root string `json:"root"` // dot-path to the value of interest; "" = whole body |
| 93 | Fields []string `json:"fields"` // dot-paths kept per object; empty = keep the value whole |
| 94 | } |
| 95 | |
| 96 | // Registry is the loaded, validated catalog. |
| 97 | type Registry struct { |
| 98 | byID map[string]*Manifest |
| 99 | } |
| 100 | |
| 101 | // Get returns the manifest for a service id, or nil. |
| 102 | func (r *Registry) Get(id string) *Manifest { return r.byID[id] } |
| 103 | |
| 104 | // List returns every manifest, sorted by id (stable catalog order). |
| 105 | func (r *Registry) List() []*Manifest { |
| 106 | out := make([]*Manifest, 0, len(r.byID)) |
| 107 | for _, m := range r.byID { |
| 108 | out = append(out, m) |
| 109 | } |
| 110 | sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) |
| 111 | return out |
| 112 | } |
| 113 | |
| 114 | // Op returns the manifest's op by id, or nil — nil means the op cannot run, |
| 115 | // full stop (the executor and the sidecar both treat absence as refusal). |
| 116 | func (m *Manifest) Op(id string) *Op { |
| 117 | for i := range m.Ops { |
| 118 | if m.Ops[i].ID == id { |
| 119 | return &m.Ops[i] |
| 120 | } |
| 121 | } |
| 122 | return nil |
| 123 | } |
| 124 | |
| 125 | var ( |
| 126 | idRE = regexp.MustCompile(`^[a-z0-9-]+$`) |
| 127 | opIDRE = regexp.MustCompile(`^[a-z0-9]+(\.[a-z0-9]+)*$`) |
| 128 | // placeholderRE is the ONE definition of what a `{arg}` placeholder looks |
| 129 | // like — the validator and the executor must agree, or a hole opens |
| 130 | // between "validated" and "substituted". |
| 131 | placeholderRE = regexp.MustCompile(`\{([a-z0-9_]+)\}`) |
| 132 | // pickPathRE is a pick root/field: dot-separated plain JSON keys. No |
| 133 | // wildcards, no indexing — a projection names exactly what it keeps. |
| 134 | pickPathRE = regexp.MustCompile(`^[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$`) |
| 135 | ) |
| 136 | |
| 137 | var validMethods = map[string]bool{"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true} |
| 138 | |
| 139 | // Parse unmarshals and validates one manifest. Exported so tests (and, later, |
| 140 | // the public-registry CI) can reject a bad manifest without embedding it. |
| 141 | func Parse(raw []byte) (*Manifest, error) { |
| 142 | var m Manifest |
| 143 | if err := json.Unmarshal(raw, &m); err != nil { |
| 144 | return nil, fmt.Errorf("manifest: %w", err) |
| 145 | } |
| 146 | if err := validate(&m); err != nil { |
| 147 | return nil, err |
| 148 | } |
| 149 | return &m, nil |
| 150 | } |
| 151 | |
| 152 | // validate enforces the rules in registry/README.md. Every check fails closed: |
| 153 | // there is no "load with warnings" — a manifest is either wholly valid or |
| 154 | // wholly absent. |
| 155 | func validate(m *Manifest) error { |
| 156 | if m.Schema != 1 { |
| 157 | return fmt.Errorf("manifest %q: unsupported schema %d (want 1)", m.ID, m.Schema) |
| 158 | } |
| 159 | if !idRE.MatchString(m.ID) { |
| 160 | return fmt.Errorf("manifest: bad id %q", m.ID) |
| 161 | } |
| 162 | if err := validateBase(m.Auth.Base); err != nil { |
| 163 | return fmt.Errorf("manifest %q: %w", m.ID, err) |
| 164 | } |
| 165 | // An OAuth-capable service must spell both provider endpoints, https — |
| 166 | // the broker builds its flow from these. |
| 167 | if m.Auth.Kind == "oauth" || m.Auth.Kind == "both" { |
| 168 | o := m.Auth.OAuth |
| 169 | if o == nil { |
| 170 | return fmt.Errorf("manifest %q: auth.kind %q without auth.oauth", m.ID, m.Auth.Kind) |
| 171 | } |
| 172 | for name, u := range map[string]string{"authorize_url": o.AuthorizeURL, "token_url": o.TokenURL} { |
| 173 | parsed, err := url.Parse(u) |
| 174 | if err != nil || parsed.Scheme != "https" || parsed.Host == "" { |
| 175 | return fmt.Errorf("manifest %q: auth.oauth.%s %q must be an https URL", m.ID, name, u) |
| 176 | } |
| 177 | } |
| 178 | } |
| 179 | seen := map[string]bool{} |
| 180 | for i := range m.Ops { |
| 181 | op := &m.Ops[i] |
| 182 | if !opIDRE.MatchString(op.ID) { |
| 183 | return fmt.Errorf("manifest %q: bad op id %q", m.ID, op.ID) |
| 184 | } |
| 185 | if seen[op.ID] { |
| 186 | return fmt.Errorf("manifest %q: duplicate op id %q", m.ID, op.ID) |
| 187 | } |
| 188 | seen[op.ID] = true |
| 189 | if op.Access != "read" && op.Access != "write" { |
| 190 | return fmt.Errorf("manifest %q: op %q: bad access %q", m.ID, op.ID, op.Access) |
| 191 | } |
| 192 | // Writes carry the consent chip's exact line: no confirm, no write. |
| 193 | if op.Access == "write" && op.Confirm == "" { |
| 194 | return fmt.Errorf("manifest %q: op %q: write op without confirm", m.ID, op.ID) |
| 195 | } |
| 196 | if !validMethods[op.Method] { |
| 197 | return fmt.Errorf("manifest %q: op %q: bad method %q", m.ID, op.ID, op.Method) |
| 198 | } |
| 199 | switch op.Encoding { |
| 200 | case "form": |
| 201 | if len(op.BodyForm) == 0 { |
| 202 | return fmt.Errorf("manifest %q: op %q: encoding form without body_form", m.ID, op.ID) |
| 203 | } |
| 204 | if op.BodyJSON != nil { |
| 205 | return fmt.Errorf("manifest %q: op %q: encoding form with body_json", m.ID, op.ID) |
| 206 | } |
| 207 | case "", "json": |
| 208 | if op.BodyForm != nil { |
| 209 | return fmt.Errorf("manifest %q: op %q: body_form requires encoding form", m.ID, op.ID) |
| 210 | } |
| 211 | default: |
| 212 | return fmt.Errorf("manifest %q: op %q: bad encoding %q", m.ID, op.ID, op.Encoding) |
| 213 | } |
| 214 | if err := validatePlaceholders(op); err != nil { |
| 215 | return fmt.Errorf("manifest %q: op %q: %w", m.ID, op.ID, err) |
| 216 | } |
| 217 | if err := validatePick(op.Pick); err != nil { |
| 218 | return fmt.Errorf("manifest %q: op %q: %w", m.ID, op.ID, err) |
| 219 | } |
| 220 | } |
| 221 | return nil |
| 222 | } |
| 223 | |
| 224 | // validatePick rejects a projection that names nothing or names it badly — an |
| 225 | // empty pick would read as "keep everything" while looking like a bound. |
| 226 | func validatePick(p *Pick) error { |
| 227 | if p == nil { |
| 228 | return nil |
| 229 | } |
| 230 | if p.Root == "" && len(p.Fields) == 0 { |
| 231 | return fmt.Errorf("pick with neither root nor fields") |
| 232 | } |
| 233 | if p.Root != "" && !pickPathRE.MatchString(p.Root) { |
| 234 | return fmt.Errorf("bad pick root %q", p.Root) |
| 235 | } |
| 236 | for _, f := range p.Fields { |
| 237 | if !pickPathRE.MatchString(f) { |
| 238 | return fmt.Errorf("bad pick field %q", f) |
| 239 | } |
| 240 | } |
| 241 | return nil |
| 242 | } |
| 243 | |
| 244 | // validateBase requires an https URL that is a bare host: no path, query, |
| 245 | // fragment, or credentials. The base is the executor's pin — anything beyond |
| 246 | // the host would blur what "pinned" means. |
| 247 | func validateBase(base string) error { |
| 248 | u, err := url.Parse(base) |
| 249 | if err != nil { |
| 250 | return fmt.Errorf("bad auth.base %q: %w", base, err) |
| 251 | } |
| 252 | if u.Scheme != "https" { |
| 253 | return fmt.Errorf("auth.base %q must be https", base) |
| 254 | } |
| 255 | if u.Host == "" { |
| 256 | return fmt.Errorf("auth.base %q has no host", base) |
| 257 | } |
| 258 | if u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil { |
| 259 | return fmt.Errorf("auth.base %q must be scheme+host only", base) |
| 260 | } |
| 261 | return nil |
| 262 | } |
| 263 | |
| 264 | // validatePlaceholders checks that every `{arg}` appearing anywhere member |
| 265 | // input can flow — path, confirm, form values, JSON string leaves — names a |
| 266 | // declared arg. An undeclared placeholder would substitute to nothing at run |
| 267 | // time; refusing it at load time keeps the failure at review, not in a chat. |
| 268 | func validatePlaceholders(op *Op) error { |
| 269 | check := func(where, s string) error { |
| 270 | for _, match := range placeholderRE.FindAllStringSubmatch(s, -1) { |
| 271 | if _, ok := op.Args[match[1]]; !ok { |
| 272 | return fmt.Errorf("%s references undeclared arg %q", where, match[1]) |
| 273 | } |
| 274 | } |
| 275 | return nil |
| 276 | } |
| 277 | if err := check("path", op.Path); err != nil { |
| 278 | return err |
| 279 | } |
| 280 | if err := check("confirm", op.Confirm); err != nil { |
| 281 | return err |
| 282 | } |
| 283 | for k, v := range op.BodyForm { |
| 284 | if err := check("body_form."+k, v); err != nil { |
| 285 | return err |
| 286 | } |
| 287 | } |
| 288 | return checkJSONLeaves("body_json", op.BodyJSON, check) |
| 289 | } |
| 290 | |
| 291 | // checkJSONLeaves walks a body_json template and runs check on every string |
| 292 | // leaf (the only place a placeholder can live in JSON). |
| 293 | func checkJSONLeaves(where string, v any, check func(where, s string) error) error { |
| 294 | switch t := v.(type) { |
| 295 | case string: |
| 296 | return check(where, t) |
| 297 | case map[string]any: |
| 298 | for k, e := range t { |
| 299 | if err := checkJSONLeaves(where+"."+k, e, check); err != nil { |
| 300 | return err |
| 301 | } |
| 302 | } |
| 303 | case []any: |
| 304 | for i, e := range t { |
| 305 | if err := checkJSONLeaves(fmt.Sprintf("%s[%d]", where, i), e, check); err != nil { |
| 306 | return err |
| 307 | } |
| 308 | } |
| 309 | } |
| 310 | return nil |
| 311 | } |
| 312 | |
| 313 | // RenderConfirm substitutes `{arg}` into the op's confirm template. A missing |
| 314 | // arg is left literal — the chip then reads visibly wrong instead of silently |
| 315 | // asking consent for a blank. |
| 316 | func (o *Op) RenderConfirm(args map[string]string) string { |
| 317 | return placeholderRE.ReplaceAllStringFunc(o.Confirm, func(ph string) string { |
| 318 | name := ph[1 : len(ph)-1] |
| 319 | if v, ok := args[name]; ok { |
| 320 | return v |
| 321 | } |
| 322 | return ph |
| 323 | }) |
| 324 | } |
| 325 | |
| 326 | var ( |
| 327 | loadOnce sync.Once |
| 328 | loaded *Registry |
| 329 | loadErr error |
| 330 | ) |
| 331 | |
| 332 | // Load parses every embedded manifest once and caches the result. Any single |
| 333 | // bad manifest fails the whole load — the registry is reviewed code, so a |
| 334 | // partial catalog would mean shipping around a known-bad entry. |
| 335 | func Load() (*Registry, error) { |
| 336 | loadOnce.Do(func() { |
| 337 | entries, err := manifestFS.ReadDir("registry/manifests") |
| 338 | if err != nil { |
| 339 | loadErr = err |
| 340 | return |
| 341 | } |
| 342 | reg := &Registry{byID: map[string]*Manifest{}} |
| 343 | for _, e := range entries { |
| 344 | raw, err := manifestFS.ReadFile("registry/manifests/" + e.Name()) |
| 345 | if err != nil { |
| 346 | loadErr = err |
| 347 | return |
| 348 | } |
| 349 | m, err := Parse(raw) |
| 350 | if err != nil { |
| 351 | loadErr = fmt.Errorf("%s: %w", e.Name(), err) |
| 352 | return |
| 353 | } |
| 354 | if reg.byID[m.ID] != nil { |
| 355 | loadErr = fmt.Errorf("%s: duplicate manifest id %q", e.Name(), m.ID) |
| 356 | return |
| 357 | } |
| 358 | reg.byID[m.ID] = m |
| 359 | } |
| 360 | loaded = reg |
| 361 | }) |
| 362 | return loaded, loadErr |
| 363 | } |
| 364 | |
| 365 | // MustRegistry returns the embedded registry, panicking on a malformed |
| 366 | // manifest: embedded content is code, so a bad manifest is a build bug that |
| 367 | // must never limp into production. |
| 368 | func MustRegistry() *Registry { |
| 369 | reg, err := Load() |
| 370 | if err != nil { |
| 371 | panic("integrations: " + err.Error()) |
| 372 | } |
| 373 | return reg |
| 374 | } |
| 375 | |