Skip to content

Commit 06c9382

Browse files
authored
Merge pull request #19 from abskrj/fix/nango-connection-reconcile
fix: reconcile Nango connection IDs when webhook races ahead
2 parents 15448f7 + 36e0b5f commit 06c9382

3 files changed

Lines changed: 199 additions & 4 deletions

File tree

services/control-plane/internal/api/connections_test.go

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,10 @@ func setupWithNango(t *testing.T) *testEnv {
144144
_ = json.NewDecoder(r.Body).Decode(&req)
145145
if len(req.AllowedIntegrations) > 0 {
146146
if _, ok := configuredIntegrations[req.AllowedIntegrations[0]]; !ok {
147-
w.WriteHeader(http.StatusBadRequest)
148-
_, _ = w.Write([]byte(`{"error":"provider config not found"}`))
149-
return
150-
}
147+
w.WriteHeader(http.StatusBadRequest)
148+
_, _ = w.Write([]byte(`{"error":"provider config not found"}`))
149+
return
150+
}
151151
}
152152
w.Header().Set("Content-Type", "application/json")
153153
w.WriteHeader(http.StatusOK)
@@ -157,6 +157,43 @@ func setupWithNango(t *testing.T) *testEnv {
157157
case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/connection/"):
158158
w.WriteHeader(http.StatusNoContent)
159159

160+
// GET /connections — returns connection records for reconciliation after OAuth.
161+
case r.Method == http.MethodGet && r.URL.Path == "/connections":
162+
type conn struct {
163+
ConnectionID string `json:"connection_id"`
164+
ProviderConfigKey string `json:"provider_config_key"`
165+
Provider string `json:"provider"`
166+
Tags map[string]string `json:"tags"`
167+
Created string `json:"created"`
168+
}
169+
connections := []conn{
170+
{
171+
ConnectionID: "mock-connection-github",
172+
ProviderConfigKey: "github",
173+
Provider: "github",
174+
Tags: map[string]string{"velane_alias": "default"},
175+
Created: "2026-01-01T00:00:00Z",
176+
},
177+
{
178+
ConnectionID: "mock-connection-figma",
179+
ProviderConfigKey: "figma",
180+
Provider: "figma",
181+
Tags: map[string]string{"velane_alias": "default"},
182+
Created: "2026-01-01T00:00:00Z",
183+
},
184+
}
185+
for key, provider := range configuredIntegrations {
186+
connections = append(connections, conn{
187+
ConnectionID: "mock-connection-" + key,
188+
ProviderConfigKey: key,
189+
Provider: provider,
190+
Created: "2026-01-02T00:00:00Z",
191+
})
192+
}
193+
w.Header().Set("Content-Type", "application/json")
194+
w.WriteHeader(http.StatusOK)
195+
_ = json.NewEncoder(w).Encode(map[string]any{"connections": connections})
196+
160197
// GET /providers — returns a minimal provider list.
161198
case r.Method == http.MethodGet && r.URL.Path == "/providers":
162199
w.Header().Set("Content-Type", "application/json")
@@ -279,6 +316,26 @@ func TestConnections_Record(t *testing.T) {
279316
}
280317
}
281318

319+
func TestConnections_RecordBackfillsNangoConnectionID(t *testing.T) {
320+
env := setupWithNango(t)
321+
path := "/v1/tenant/connections"
322+
profile := configureProfile(t, env, "github", "default", true)
323+
324+
rec := env.do(t, http.MethodPost, path, env.manageKey, map[string]any{
325+
"provider": "github",
326+
"credential_profile_id": profile["id"],
327+
})
328+
if rec.Code != http.StatusCreated {
329+
t.Fatalf("status = %d; want 201\nbody: %s", rec.Code, rec.Body.String())
330+
}
331+
332+
body := decodeJSON(t, rec)
333+
want := "mock-connection-" + profile["nango_provider_config_key"].(string)
334+
if body["nango_connection_id"] != want {
335+
t.Errorf("nango_connection_id = %q; want %q", body["nango_connection_id"], want)
336+
}
337+
}
338+
282339
func TestConnections_ListAfterRecord(t *testing.T) {
283340
env := setupWithNango(t)
284341
path := "/v1/tenant/connections"

services/control-plane/internal/api/handlers/connections.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,44 @@ func (h *ConnectionsHandler) RecordConnection(w http.ResponseWriter, r *http.Req
145145
writeError(w, http.StatusInternalServerError, "failed to record connection")
146146
return
147147
}
148+
if conn.NangoConnectionID == "" && h.nango != nil {
149+
nangoConnID, err := h.nango.FindConnectionID(r.Context(), tenant.ID, providerConfigKey, req.Alias)
150+
if err != nil {
151+
h.log.Warn("record connection: failed to reconcile nango connection id",
152+
zap.String("provider", req.Provider),
153+
zap.String("provider_config_key", providerConfigKey),
154+
zap.String("alias", req.Alias),
155+
zap.Error(err),
156+
)
157+
} else if nangoConnID != "" {
158+
if updated, err := h.store.UpdateNangoConnectionIDByProviderConfigKey(r.Context(), tenant.ID, providerConfigKey, nangoConnID); err != nil {
159+
h.log.Warn("record connection: failed to store reconciled nango connection id",
160+
zap.String("provider", req.Provider),
161+
zap.String("provider_config_key", providerConfigKey),
162+
zap.String("alias", req.Alias),
163+
zap.String("nango_connection_id", nangoConnID),
164+
zap.Error(err),
165+
)
166+
} else {
167+
conn = updated
168+
if err := h.nango.PatchConnectionMetadata(r.Context(), nangoConnID, providerConfigKey, map[string]any{
169+
"velane_alias": conn.Alias,
170+
"velane_tenant_id": tenant.ID,
171+
}); err != nil {
172+
h.log.Warn("record connection: failed to patch nango connection metadata (non-fatal)",
173+
zap.String("nango_connection_id", nangoConnID),
174+
zap.Error(err),
175+
)
176+
}
177+
}
178+
} else {
179+
h.log.Warn("record connection: nango connection id not found during reconciliation",
180+
zap.String("provider", req.Provider),
181+
zap.String("provider_config_key", providerConfigKey),
182+
zap.String("alias", req.Alias),
183+
)
184+
}
185+
}
148186

149187
if h.auditor != nil {
150188
actorID, actorType := resolveActor(r)
@@ -314,5 +352,9 @@ func (h *ConnectionsHandler) Proxy(w http.ResponseWriter, r *http.Request) {
314352
if providerConfigKey == "" {
315353
providerConfigKey = provider
316354
}
355+
if conn.NangoConnectionID == "" {
356+
writeError(w, http.StatusBadRequest, "connection is not fully linked; reconnect provider")
357+
return
358+
}
317359
h.nango.Proxy(w, r, conn.NangoConnectionID, providerConfigKey, path)
318360
}

services/control-plane/internal/nango/client.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,102 @@ func (c *Client) CreateConnectSession(ctx context.Context, tenantID, tenantName,
8181
return out.Data.Token, nil
8282
}
8383

84+
// FindConnectionID returns the newest Nango connection matching Velane's tenant,
85+
// provider config, and alias tags.
86+
func (c *Client) FindConnectionID(ctx context.Context, tenantID, providerConfigKey, alias string) (string, error) {
87+
if alias == "" {
88+
alias = "default"
89+
}
90+
91+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/connections", nil)
92+
if err != nil {
93+
return "", err
94+
}
95+
c.setAuth(req)
96+
97+
resp, err := c.httpClient.Do(req)
98+
if err != nil {
99+
return "", fmt.Errorf("nango FindConnectionID: %w", err)
100+
}
101+
defer resp.Body.Close()
102+
103+
if resp.StatusCode >= 300 {
104+
raw, _ := io.ReadAll(resp.Body)
105+
return "", fmt.Errorf("nango FindConnectionID %d: %s", resp.StatusCode, raw)
106+
}
107+
108+
type nangoConnection struct {
109+
ConnectionID string `json:"connection_id"`
110+
ProviderConfigKey string `json:"provider_config_key"`
111+
Tags map[string]string `json:"tags"`
112+
Metadata map[string]any `json:"metadata"`
113+
Created string `json:"created"`
114+
}
115+
var envelope struct {
116+
Connections []nangoConnection `json:"connections"`
117+
}
118+
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
119+
return "", fmt.Errorf("nango FindConnectionID decode: %w", err)
120+
}
121+
122+
var newest nangoConnection
123+
var newestAt time.Time
124+
for _, conn := range envelope.Connections {
125+
if conn.ConnectionID == "" || conn.ProviderConfigKey != providerConfigKey {
126+
continue
127+
}
128+
if !nangoConnectionMatchesTenant(conn.Tags, conn.Metadata, tenantID) {
129+
continue
130+
}
131+
if !nangoConnectionMatchesAlias(conn.Tags, conn.Metadata, alias) {
132+
continue
133+
}
134+
135+
createdAt, err := time.Parse(time.RFC3339Nano, conn.Created)
136+
if err != nil {
137+
if newest.ConnectionID == "" {
138+
newest = conn
139+
}
140+
continue
141+
}
142+
if newest.ConnectionID == "" || createdAt.After(newestAt) {
143+
newest = conn
144+
newestAt = createdAt
145+
}
146+
}
147+
return newest.ConnectionID, nil
148+
}
149+
150+
func nangoConnectionMatchesTenant(tags map[string]string, metadata map[string]any, tenantID string) bool {
151+
if tenantID == "" {
152+
return true
153+
}
154+
if tags["end_user_id"] == tenantID || tags["organization_id"] == tenantID {
155+
return true
156+
}
157+
if metadata != nil {
158+
if value, ok := metadata["velane_tenant_id"].(string); ok && value != "" {
159+
return value == tenantID
160+
}
161+
}
162+
return tags["end_user_id"] == "" && tags["organization_id"] == ""
163+
}
164+
165+
func nangoConnectionMatchesAlias(tags map[string]string, metadata map[string]any, alias string) bool {
166+
if alias == "" {
167+
alias = "default"
168+
}
169+
if value := tags["velane_alias"]; value != "" {
170+
return value == alias
171+
}
172+
if metadata != nil {
173+
if value, ok := metadata["velane_alias"].(string); ok && value != "" {
174+
return value == alias
175+
}
176+
}
177+
return true
178+
}
179+
84180
// PatchConnectionMetadata updates metadata on an existing Nango connection without
85181
// overwriting fields not included in the patch (PATCH semantics).
86182
func (c *Client) PatchConnectionMetadata(ctx context.Context, connectionID, providerConfigKey string, metadata map[string]any) error {

0 commit comments

Comments
 (0)