Add Google Analytics, Dotfile, Segment and Square access-review connectors

Two OAuth2 and two API-key connectors:

- Google Analytics (GA4): OAuth2 with both analytics.readonly and
  analytics.manage.users.readonly (readonly alone 403s on the accounts
  list); v1alpha accessBindings enumerated at account and property level
  and merged by email; manual account picker (Pattern 1) with a
  per-connection probe and name resolver; distinct from Google Workspace.
- Dotfile: API key in the X-DOTFILE-API-KEY header (Pattern 3); GET
  /v1/users (owner/admin, suspended_at) with a static probe.
- Segment (Twilio): Public API token as Bearer with a required Region
  setting (US or EU) mapped to the regional host; GET /users plus per-user
  GET /users/{id} for roles and /invites for pending members; per-connection
  BuildProbeURL.
- Square: OAuth2 (EMPLOYEES_READ) or a personal access token (Pattern 3);
  POST /v2/team-members/search returns email/status/is_owner directly, so no
  role resolution; custom probe and name resolver.

Google Analytics and Square are confidential OAuth clients, wired into the
bootstrap OAuth provider list and .env.example. Segment carries a required
extra setting, so the console add-source dialog maps region onto its
segmentRegion API-key input; without that mapping the value is silently
dropped and the create is rejected.

Cassette-backed driver tests plus unit tests for the Segment probe URL and
the bootstrap OAuth provider list.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-07-11 23:15:03 +02:00
parent 724c169876
commit 60628645ae
31 changed files with 2124 additions and 28 deletions

View File

@@ -1661,3 +1661,102 @@ func (r *crispNameResolver) ResolveInstanceName(ctx context.Context) (string, er
return resp.Data.Name, nil
}
// squareNameResolver resolves the Square merchant's business name via
// GET /v2/merchants/me. A Square token — OAuth or PAT — is scoped to a single
// merchant, so "me" resolves it for both connection kinds.
type squareNameResolver struct {
httpClient *http.Client
}
func NewSquareNameResolver(httpClient *http.Client) NameResolver {
return &squareNameResolver{httpClient: httpClient}
}
func (r *squareNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://connect.squareup.com/v2/merchants/me", nil)
if err != nil {
return "", fmt.Errorf("cannot create square merchant request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Square-Version", squareAPIVersion)
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute square merchant request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
// A non-2xx (revoked token, missing scope) is terminal: keep the generic
// source name rather than make the source-name worker retry forever.
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", nil
}
var resp struct {
Merchant struct {
BusinessName string `json:"business_name"`
} `json:"merchant"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode square merchant response: %w", err)
}
return resp.Merchant.BusinessName, nil
}
// googleAnalyticsNameResolver resolves a GA4 account's display name.
type googleAnalyticsNameResolver struct {
httpClient *http.Client
accountID string
}
func NewGoogleAnalyticsNameResolver(httpClient *http.Client, accountID string) NameResolver {
return &googleAnalyticsNameResolver{httpClient: httpClient, accountID: accountID}
}
func (r *googleAnalyticsNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
if r.accountID == "" {
return "", nil
}
endpoint, err := url.JoinPath("https://"+googleAnalyticsAPIHost, "v1alpha", "accounts", url.PathEscape(r.accountID))
if err != nil {
return "", fmt.Errorf("cannot build google analytics account URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", fmt.Errorf("cannot create google analytics account request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute google analytics account request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
// A non-2xx (revoked token, renamed/deleted account) is terminal: keep the
// generic source name rather than retry forever.
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", nil
}
var resp struct {
DisplayName string `json:"displayName"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode google analytics account response: %w", err)
}
return resp.DisplayName, nil
}