feat: phase 4 integrations — connectors, rybbit analytics, webhooks

- core.integrations (migration 00005) with AES-256-GCM-encrypted
  credentials (PRIMORA_ENCRYPTION_KEY, hex/base64, dev fallback);
  has_credentials serialization only — secrets never leave the server
- /projects/:id/integrations list/create/delete/test; health check hits
  the stored base URL with a 5s timeout and records status
- Rybbit connector: GET …/integrations/:id/analytics proxies
  /api/organizations + /api/sites/* server-side into a normalized shape
- core.webhooks + core.webhook_deliveries; CRUD, deliveries, test, and
  POST …/deploy-markers. Events: issue.created (new fingerprint on
  ingest), deploy.marker, webhook.test. X-Primora-Signature HMAC-SHA256
  over the raw body; in-process dispatcher, 3 attempts, quadratic
  backoff, 10s timeout, pending-scan on boot
- HTTPS enforced for public targets; HTTP allowed for loopback/private/
  self-hosted sinks
- Integrations page (connectors + webhooks tabs, inline analytics,
  delivery log, secret-shown-once), demo-mode stubs
- 12 new primora_* MCP tools
- workspace 0.4.0

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-16 18:47:31 +02:00
1 parent 8cfddf9184
commit e25eb92339
82 files changed
+5710 -61

No files matched your search

@@ -89,6 +89,7 @@ func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor,
projectID := *actor.ProjectID
var inserted []TelemetryEvent
var newIssues []TelemetryEvent
err := s.repo.WithTx(ctx, func(q *db.Queries) error {
compCache := map[string]*uuid.UUID{}
for _, ev := range events {
@@ -136,6 +137,16 @@ func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor,
if len(payload) == 0 {
payload = json.RawMessage(`{}`)
}
newFingerprint := false
if ev.Type == "error" && fp != "" {
seen, err := q.HasFingerprintSeen(ctx, db.HasFingerprintSeenParams{
ProjectID: projectID,
Fingerprint: fp,
})
if err == nil && !seen {
newFingerprint = true
}
}
var cid pgtype.UUID
if compID != nil {
cid = pgtype.UUID{Bytes: *compID, Valid: true}
@@ -168,6 +179,9 @@ func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor,
out.ComponentName = ev.Component
}
inserted = append(inserted, out)
if newFingerprint {
newIssues = append(newIssues, out)
}
}
return nil
})
@@ -178,6 +192,18 @@ func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor,
for _, ev := range inserted {
s.hub.Broadcast(ev.ProjectID.String(), ev)
}
// A fresh error fingerprint is a new issue group — notify webhooks.
for _, issue := range newIssues {
s.dispatchEvent(ctx, projectID, WebhookEventIssueCreated, map[string]any{
"fingerprint": issue.Fingerprint,
"message": issue.Message,
"severity": issue.Severity,
"component_id": issue.ComponentID,
"component_name": issue.ComponentName,
"event_id": issue.ID,
"first_seen": issue.TS,
})
}
return len(inserted), nil
}