eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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.
8package integrations
9
10import (
11 "embed"
12 "encoding/json"
13 "fmt"
14 "net/url"
15 "regexp"
16 "sort"
17 "sync"
18)
19
20//go:embed registry/manifests/*.json
21var 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).
25type 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).
36type 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.
50type 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.
64type 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.
79type 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.
91type 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.
97type Registry struct {
98 byID map[string]*Manifest
99}
100
101// Get returns the manifest for a service id, or nil.
102func (r *Registry) Get(id string) *Manifest { return r.byID[id] }
103
104// List returns every manifest, sorted by id (stable catalog order).
105func (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).
116func (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
125var (
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
137var 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.
141func 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.
155func 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.
226func 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.
247func 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.
268func 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).
293func 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.
316func (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
326var (
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.
335func 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.
368func MustRegistry() *Registry {
369 reg, err := Load()
370 if err != nil {
371 panic("integrations: " + err.Error())
372 }
373 return reg
374}
375