eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package integrations
2
3import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net/http"
10 "net/url"
11 "os"
12 "regexp"
13 "strings"
14 "time"
15)
16
17// The executor: runs one manifest op against the service's pinned host. The
18// manifest is reviewed code; the args and token are member input — so every
19// arg is bounded and escaped into the slot its template put it in, and the
20// final URL is re-checked against auth.base's host before anything is sent.
21// A value can steer WHAT an op says, never WHERE it goes.
22
23const (
24 maxArgs = 16
25 maxArgBytes = 2048
26 maxRespBody = 256 << 10 // response read cap for un-projected ops
27 // A projected op may read much more: provider list responses run to
28 // hundreds of KB raw (a real /user/repos page is ~400KB), and the pick
29 // throws almost all of it away immediately — the projected result is what
30 // travels, not this buffer.
31 maxPickedRespBody = 4 << 20
32 execTimeout = 20 * time.Second
33)
34
35// purePlaceholderRE matches a template that is EXACTLY one placeholder — the
36// shape of an optional-arg slot like `"body": "{body}"`, whose key is dropped
37// from the JSON when the arg is absent (so optional means absent, not "").
38var purePlaceholderRE = regexp.MustCompile(`^\{[a-z0-9_]+\}$`)
39
40// StatusError is a non-2xx provider response. The message keeps the exact
41// shape errors always had; the code lets a caller distinguish "token expired"
42// (401 → refresh and retry) from "the request was wrong".
43type StatusError struct {
44 Service, Op string
45 Code int
46 Detail string
47}
48
49func (e *StatusError) Error() string {
50 return fmt.Sprintf("%s.%s: status %d: %s", e.Service, e.Op, e.Code, e.Detail)
51}
52
53// baseOverride returns the test-only base URL for a service, mirroring how
54// LCHAT_DNSIMPLE_BASE points the dnsimple client at a loopback httptest
55// server (internal/bot/dnsimple.go). Never set in production units.
56func baseOverride(id string) string {
57 return os.Getenv("LCHAT_INTEGRATIONS_BASE_" + strings.ToUpper(strings.ReplaceAll(id, "-", "_")))
58}
59
60// Execute runs one op with the member's args and token, returning the
61// response body as a string. Errors are values — the sidecar feeds them back
62// into the thread as an honest failure, never a silent gap.
63func Execute(m *Manifest, opID string, args map[string]string, token string) (string, error) {
64 return ExecuteAs(m, opID, args, token, "")
65}
66
67// ExecuteAs is Execute plus the credential's kind ("pat" | "oauth"; "" for
68// legacy credentials stored before kinds existed). An OAuth token is a Bearer
69// token by definition (RFC 6750), whatever scheme the manifest picked for
70// pasted keys — Linear's personal API keys go bare while its OAuth tokens
71// carry Bearer. Any other kind keeps the manifest's scheme.
72func ExecuteAs(m *Manifest, opID string, args map[string]string, token, tokenKind string) (string, error) {
73 op := m.Op(opID)
74 if op == nil {
75 // Absence is refusal: an op the manifest doesn't declare cannot run.
76 return "", fmt.Errorf("%s: unknown op %q", m.ID, opID)
77 }
78 if err := validateArgs(op, args); err != nil {
79 return "", fmt.Errorf("%s.%s: %w", m.ID, opID, err)
80 }
81
82 base := m.Auth.Base
83 override := baseOverride(m.ID)
84 if override != "" {
85 base = override
86 }
87 baseURL, err := url.Parse(base)
88 if err != nil || baseURL.Host == "" {
89 return "", fmt.Errorf("%s.%s: bad base %q", m.ID, opID, base)
90 }
91
92 final := strings.TrimRight(base, "/") + renderPath(op.Path, args)
93 u, err := url.Parse(final)
94 if err != nil {
95 return "", fmt.Errorf("%s.%s: bad url: %w", m.ID, opID, err)
96 }
97 // Pin the host: re-check the URL we actually built, not just the inputs —
98 // if escaping ever failed to contain a value, this is the backstop.
99 // Skipped only under the env override (loopback test servers are http).
100 if override == "" {
101 if u.Scheme != "https" || u.Host != baseURL.Host {
102 return "", fmt.Errorf("%s.%s: url escaped its pinned host", m.ID, opID)
103 }
104 }
105
106 var body io.Reader
107 contentType := ""
108 switch {
109 case op.Encoding == "form":
110 vals := url.Values{}
111 for k, tmpl := range op.BodyForm {
112 vals.Set(k, substitute(tmpl, args))
113 }
114 body = strings.NewReader(vals.Encode())
115 contentType = "application/x-www-form-urlencoded"
116 case op.BodyJSON != nil:
117 rendered := substituteJSONMap(op.BodyJSON, args)
118 raw, err := json.Marshal(rendered)
119 if err != nil {
120 return "", fmt.Errorf("%s.%s: body: %w", m.ID, opID, err)
121 }
122 body = bytes.NewReader(raw)
123 contentType = "application/json"
124 }
125
126 req, err := http.NewRequest(op.Method, u.String(), body)
127 if err != nil {
128 return "", fmt.Errorf("%s.%s: %w", m.ID, opID, err)
129 }
130 // Default Accept first, then the manifest's headers — a manifest Accept
131 // (GitHub's vnd header) wins over the default, never the reverse.
132 req.Header.Set("Accept", "application/json")
133 for k, v := range m.Auth.Headers {
134 req.Header.Set(k, v)
135 }
136 scheme := m.Auth.Authorization
137 if scheme == "" {
138 scheme = "Bearer"
139 }
140 if tokenKind == "oauth" {
141 scheme = "Bearer"
142 }
143 if scheme == "none" {
144 // Bare-token APIs: Linear rejects a Bearer prefix on personal API
145 // keys — the header value is the token alone.
146 req.Header.Set("Authorization", token)
147 } else {
148 req.Header.Set("Authorization", scheme+" "+token)
149 }
150 if contentType != "" {
151 req.Header.Set("Content-Type", contentType)
152 }
153
154 client := &http.Client{
155 Timeout: execTimeout,
156 CheckRedirect: func(req *http.Request, via []*http.Request) error {
157 if len(via) >= 5 {
158 return errors.New("too many redirects")
159 }
160 // A redirect is a new URL: hold it to the same pin. Off-host
161 // redirects are refused outright — an integration API that
162 // bounces us elsewhere is outside the manifest's contract.
163 if req.URL.Host != u.Host {
164 return errors.New("redirect off the pinned host blocked")
165 }
166 if override == "" && req.URL.Scheme != "https" {
167 return errors.New("redirect downgraded from https blocked")
168 }
169 return nil
170 },
171 }
172 resp, err := client.Do(req)
173 if err != nil {
174 return "", fmt.Errorf("%s.%s: %w", m.ID, opID, err)
175 }
176 defer resp.Body.Close()
177 readCap := int64(maxRespBody)
178 if op.Pick != nil {
179 readCap = maxPickedRespBody
180 }
181 raw, err := io.ReadAll(io.LimitReader(resp.Body, readCap+1))
182 if err != nil {
183 return "", fmt.Errorf("%s.%s: read: %w", m.ID, opID, err)
184 }
185 // Over the cap: refuse honestly — a silently truncated body would surface
186 // as a baffling JSON parse error (or worse, truncated data downstream).
187 if int64(len(raw)) > readCap {
188 return "", fmt.Errorf("%s.%s: response too large (over %d bytes)", m.ID, opID, readCap)
189 }
190 if resp.StatusCode/100 != 2 {
191 snippet := raw
192 if len(snippet) > 300 {
193 snippet = snippet[:300]
194 }
195 // Typed so a caller can react to the code (the sidecar refreshes an
196 // expired token on a 401) without parsing the message.
197 return "", &StatusError{Service: m.ID, Op: opID, Code: resp.StatusCode, Detail: string(snippet)}
198 }
199 if op.Pick != nil {
200 projected, err := project(raw, op.Pick)
201 if err != nil {
202 return "", fmt.Errorf("%s.%s: %w", m.ID, opID, err)
203 }
204 return projected, nil
205 }
206 return string(raw), nil
207}
208
209// project applies an op's declared response projection (registry.go's Pick):
210// descend the root path, then keep only the named fields of the object — or of
211// each object of the array — found there. The manifest is the review surface,
212// so a response that doesn't match its declared shape is an honest error, never
213// a silent empty result.
214func project(raw []byte, p *Pick) (string, error) {
215 var v any
216 if err := json.Unmarshal(raw, &v); err != nil {
217 return "", fmt.Errorf("response isn't JSON: %w", err)
218 }
219 for _, key := range splitPath(p.Root) {
220 obj, ok := v.(map[string]any)
221 if !ok {
222 return "", fmt.Errorf("response has no %q envelope", p.Root)
223 }
224 if v, ok = obj[key]; !ok {
225 return "", fmt.Errorf("response has no %q envelope", p.Root)
226 }
227 }
228 if len(p.Fields) > 0 {
229 switch t := v.(type) {
230 case []any:
231 out := make([]any, 0, len(t))
232 for _, e := range t {
233 if obj, ok := e.(map[string]any); ok {
234 out = append(out, pickFields(obj, p.Fields))
235 }
236 }
237 v = out
238 case map[string]any:
239 v = pickFields(t, p.Fields)
240 default:
241 return "", fmt.Errorf("response %q isn't an object or array", p.Root)
242 }
243 }
244 out, err := json.Marshal(v)
245 if err != nil {
246 return "", err
247 }
248 return string(out), nil
249}
250
251// pickFields projects one object down to the named dot-paths. A field the
252// response doesn't carry is omitted (provider APIs grow and shrink; the client
253// treats absence as "—"). The output key is the full dot-path, flat — so
254// "user.login" comes back as {"user.login": "octocat"}.
255func pickFields(obj map[string]any, fields []string) map[string]any {
256 out := make(map[string]any, len(fields))
257 for _, f := range fields {
258 v := any(obj)
259 found := true
260 for _, key := range splitPath(f) {
261 m, ok := v.(map[string]any)
262 if !ok {
263 found = false
264 break
265 }
266 if v, ok = m[key]; !ok {
267 found = false
268 break
269 }
270 }
271 if found {
272 out[f] = v
273 }
274 }
275 return out
276}
277
278// splitPath splits a validated pick path into keys; "" is the empty path.
279func splitPath(p string) []string {
280 if p == "" {
281 return nil
282 }
283 return strings.Split(p, ".")
284}
285
286// validateArgs bounds member input before any of it touches a template:
287// declared keys only, required keys present, values short and free of
288// control characters.
289func validateArgs(op *Op, args map[string]string) error {
290 if len(args) > maxArgs {
291 return fmt.Errorf("too many args (%d > %d)", len(args), maxArgs)
292 }
293 for k, v := range args {
294 if _, ok := op.Args[k]; !ok {
295 return fmt.Errorf("undeclared arg %q", k)
296 }
297 if len(v) > maxArgBytes {
298 return fmt.Errorf("arg %q too long (%d bytes)", k, len(v))
299 }
300 for i := 0; i < len(v); i++ {
301 if v[i] < 0x20 || v[i] == 0x7f {
302 return fmt.Errorf("arg %q contains control characters", k)
303 }
304 }
305 }
306 for k, spec := range op.Args {
307 if spec.Required && args[k] == "" {
308 return fmt.Errorf("missing required arg %q", k)
309 }
310 }
311 return nil
312}
313
314// renderPath substitutes `{arg}` into a path template, escaping each value
315// for the slot it lands in: path-segment escaping before the first "?",
316// query escaping after it. A value can never introduce an un-escaped
317// `/`, `?`, `#`, or `&` — it stays inside its segment or query slot.
318func renderPath(path string, args map[string]string) string {
319 pathPart, queryPart, hasQuery := strings.Cut(path, "?")
320 out := substituteWith(pathPart, args, escapePathValue)
321 if hasQuery {
322 out += "?" + substituteWith(queryPart, args, url.QueryEscape)
323 }
324 return out
325}
326
327// escapePathValue is url.PathEscape plus the sub-delims PathEscape leaves
328// alone (`&;=+,`). They're legal in a path segment, but our promise is
329// stronger than legality: a substituted value must not be able to smuggle
330// anything a server might parse as a delimiter.
331func escapePathValue(v string) string {
332 return strings.NewReplacer(
333 "&", "%26", ";", "%3B", "=", "%3D", "+", "%2B", ",", "%2C",
334 ).Replace(url.PathEscape(v))
335}
336
337// substitute replaces `{arg}` with the raw arg value (bodies — no URL
338// escaping; the encoder for the body format does its own). An absent
339// optional arg substitutes to "".
340func substitute(tmpl string, args map[string]string) string {
341 return substituteWith(tmpl, args, func(s string) string { return s })
342}
343
344func substituteWith(tmpl string, args map[string]string, escape func(string) string) string {
345 return placeholderRE.ReplaceAllStringFunc(tmpl, func(ph string) string {
346 return escape(args[ph[1:len(ph)-1]])
347 })
348}
349
350// substituteJSONMap deep-copies a body_json template with `{arg}` substituted
351// into string leaves. Keys whose template was exactly one placeholder and
352// substituted to "" are dropped — an optional arg the member didn't give is
353// omitted from the request, not sent as an empty string.
354func substituteJSONMap(m map[string]any, args map[string]string) map[string]any {
355 out := make(map[string]any, len(m))
356 for k, v := range m {
357 switch t := v.(type) {
358 case string:
359 s := substitute(t, args)
360 if s == "" && purePlaceholderRE.MatchString(t) {
361 continue
362 }
363 out[k] = s
364 case map[string]any:
365 out[k] = substituteJSONMap(t, args)
366 case []any:
367 out[k] = substituteJSONSlice(t, args)
368 default:
369 out[k] = v
370 }
371 }
372 return out
373}
374
375func substituteJSONSlice(s []any, args map[string]string) []any {
376 out := make([]any, len(s))
377 for i, v := range s {
378 switch t := v.(type) {
379 case string:
380 out[i] = substitute(t, args)
381 case map[string]any:
382 out[i] = substituteJSONMap(t, args)
383 case []any:
384 out[i] = substituteJSONSlice(t, args)
385 default:
386 out[i] = v
387 }
388 }
389 return out
390}
391