mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
f6938b33a5
* fix(webhooks): 408, 425 and 429 are transient on the owner and app delivery paths, through one shared RetryableStatus (TASK-3409) * feat(webhooks): owner hooks keep per-endpoint delivery state: a retry goes only to endpoints still owed, a 4xx is final for the event, and endpoints still owed when retention gives up are counted as dropped (GET /webhooks, pad webhook list) (TASK-3409) * fix(webhooks): an owner endpoint deactivated while an event is still owed to it is recorded and counted as dropped, not silently skipped (TASK-3409, codex r1) * fix(store): the automatic deactivation settles the endpoint's owed deliveries as dropped and counts them, in its own transaction (TASK-3409, codex r2)
296 lines
10 KiB
Go
296 lines
10 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// CreateWebhook registers a new webhook for a workspace.
|
|
//
|
|
// With WithPlanLimit() the workspace's webhooks cap is counted in the insert's
|
|
// transaction, under acquirePlanLimitLock, and a reached cap refuses with
|
|
// *PlanLimitError (BUG-2808).
|
|
func (s *Store) CreateWebhook(workspaceID string, input models.WebhookCreate, opts ...MintOption) (*models.Webhook, error) {
|
|
mint := resolveMintOptions(opts)
|
|
id := newID()
|
|
ts := now()
|
|
|
|
evts := input.Events
|
|
if evts == "" {
|
|
evts = `["*"]`
|
|
}
|
|
|
|
// Encrypt the HMAC secret at rest. With no encryption key configured
|
|
// (common on self-host) encrypt() returns the plaintext unchanged, so
|
|
// this stays a no-op fallback rather than a hard requirement.
|
|
encSecret, err := s.encrypt(input.Secret)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encrypt webhook secret: %w", err)
|
|
}
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert webhook: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if mint.planLimit {
|
|
if err := s.acquirePlanLimitLock(tx, workspaceID, "webhooks"); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.enforceWorkspaceLimitTx(tx, workspaceID, "webhooks"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
_, err = tx.Exec(s.q(`
|
|
INSERT INTO webhooks (id, workspace_id, url, secret, events, active, created_at, updated_at, failure_count)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
|
`), id, workspaceID, input.URL, encSecret, evts, s.dialect.BoolToInt(true), ts, ts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert webhook: %w", err)
|
|
}
|
|
// Read back INSIDE the transaction, before the commit (TASK-3406, the
|
|
// BUG-3405 shape): a deletion landing after the commit (an account
|
|
// deletion, a revoke, a cascade) cannot turn a successful write into
|
|
// a nil result.
|
|
out, err := s.getWebhookQ(tx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("insert webhook: %w", err)
|
|
}
|
|
afterCommitReadback("webhook", id)
|
|
return out, nil
|
|
}
|
|
|
|
// GetWebhook retrieves a single webhook by ID.
|
|
func (s *Store) GetWebhook(id string) (*models.Webhook, error) {
|
|
return s.getWebhookQ(s.db, id)
|
|
}
|
|
|
|
func (s *Store) getWebhookQ(q Queryer, id string) (*models.Webhook, error) {
|
|
var wh models.Webhook
|
|
var active bool
|
|
var createdAt, updatedAt string
|
|
var lastTriggeredAt *string
|
|
|
|
err := q.QueryRow(s.q(`
|
|
SELECT id, workspace_id, url, secret, events, active, created_at, updated_at, last_triggered_at, failure_count, dropped_count
|
|
FROM webhooks
|
|
WHERE id = ?
|
|
`), id).Scan(
|
|
&wh.ID, &wh.WorkspaceID, &wh.URL, &wh.Secret, &wh.Events,
|
|
&active, &createdAt, &updatedAt, &lastTriggeredAt, &wh.FailureCount, &wh.DroppedCount,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get webhook: %w", err)
|
|
}
|
|
|
|
// Decrypt the secret for internal use (the dispatcher signs with the
|
|
// plaintext). Pre-encryption rows lack the "enc:" prefix and pass through
|
|
// unchanged, so existing plaintext secrets keep working.
|
|
if wh.Secret, err = s.decrypt(wh.Secret); err != nil {
|
|
return nil, fmt.Errorf("decrypt webhook secret: %w", err)
|
|
}
|
|
wh.HasSecret = wh.Secret != ""
|
|
wh.Active = active
|
|
wh.CreatedAt = parseTime(createdAt)
|
|
wh.UpdatedAt = parseTime(updatedAt)
|
|
wh.LastTriggeredAt = parseTimePtr(lastTriggeredAt)
|
|
return &wh, nil
|
|
}
|
|
|
|
// GetWebhookScoped retrieves a single webhook by ID, but only if it belongs to
|
|
// the given workspace. The workspace_id predicate is applied in SQL BEFORE the
|
|
// secret is decrypted, so a foreign (or nonexistent) ID returns (nil, nil)
|
|
// without touching the ciphertext — this both closes the cross-workspace
|
|
// existence oracle (TASK-266) and avoids a decrypt-failure 500 leaking that a
|
|
// foreign webhook exists. Returns (nil, nil) when no webhook matches both.
|
|
func (s *Store) GetWebhookScoped(id, workspaceID string) (*models.Webhook, error) {
|
|
var wh models.Webhook
|
|
var active bool
|
|
var createdAt, updatedAt string
|
|
var lastTriggeredAt *string
|
|
|
|
err := s.db.QueryRow(s.q(`
|
|
SELECT id, workspace_id, url, secret, events, active, created_at, updated_at, last_triggered_at, failure_count, dropped_count
|
|
FROM webhooks
|
|
WHERE id = ? AND workspace_id = ? AND app_install_id IS NULL
|
|
`), id, workspaceID).Scan(
|
|
&wh.ID, &wh.WorkspaceID, &wh.URL, &wh.Secret, &wh.Events,
|
|
&active, &createdAt, &updatedAt, &lastTriggeredAt, &wh.FailureCount, &wh.DroppedCount,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get webhook: %w", err)
|
|
}
|
|
|
|
if wh.Secret, err = s.decrypt(wh.Secret); err != nil {
|
|
return nil, fmt.Errorf("decrypt webhook secret: %w", err)
|
|
}
|
|
wh.HasSecret = wh.Secret != ""
|
|
wh.Active = active
|
|
wh.CreatedAt = parseTime(createdAt)
|
|
wh.UpdatedAt = parseTime(updatedAt)
|
|
wh.LastTriggeredAt = parseTimePtr(lastTriggeredAt)
|
|
return &wh, nil
|
|
}
|
|
|
|
// ListWebhooks returns all webhooks for a workspace.
|
|
// WorkspaceLive reports whether a workspace exists and is not soft-deleted.
|
|
// The webhook dispatcher re-checks it before every send (BUG-3340).
|
|
func (s *Store) WorkspaceLive(workspaceID string) (bool, error) {
|
|
var one int
|
|
err := s.db.QueryRow(s.q(`SELECT 1 FROM workspaces WHERE id = ? AND deleted_at IS NULL`), workspaceID).Scan(&one)
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("workspace live: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// ListWebhooks lists a workspace's webhooks. A soft-deleted workspace has
|
|
// none (BUG-3340): the dispatcher's two delivery paths list through here, so
|
|
// its events are dropped rather than delivered during the restore window, and
|
|
// a restore brings the webhooks back.
|
|
func (s *Store) ListWebhooks(workspaceID string) ([]models.Webhook, error) {
|
|
rows, err := s.db.Query(s.q(`
|
|
SELECT wh.id, wh.workspace_id, wh.url, wh.secret, wh.events, wh.active, wh.created_at, wh.updated_at, wh.last_triggered_at, wh.failure_count, wh.dropped_count
|
|
FROM webhooks wh
|
|
JOIN workspaces w ON w.id = wh.workspace_id
|
|
WHERE wh.workspace_id = ? AND w.deleted_at IS NULL AND wh.app_install_id IS NULL
|
|
ORDER BY wh.created_at ASC
|
|
`), workspaceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list webhooks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []models.Webhook
|
|
for rows.Next() {
|
|
var wh models.Webhook
|
|
var active bool
|
|
var createdAt, updatedAt string
|
|
var lastTriggeredAt *string
|
|
|
|
if err := rows.Scan(
|
|
&wh.ID, &wh.WorkspaceID, &wh.URL, &wh.Secret, &wh.Events,
|
|
&active, &createdAt, &updatedAt, &lastTriggeredAt, &wh.FailureCount, &wh.DroppedCount,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan webhook: %w", err)
|
|
}
|
|
if wh.Secret, err = s.decrypt(wh.Secret); err != nil {
|
|
return nil, fmt.Errorf("decrypt webhook secret: %w", err)
|
|
}
|
|
wh.HasSecret = wh.Secret != ""
|
|
wh.Active = active
|
|
wh.CreatedAt = parseTime(createdAt)
|
|
wh.UpdatedAt = parseTime(updatedAt)
|
|
wh.LastTriggeredAt = parseTimePtr(lastTriggeredAt)
|
|
result = append(result, wh)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// DeleteWebhookScoped removes a webhook by ID, verifying it belongs to the given
|
|
// workspace. Prevents cross-workspace deletion (TASK-266): an owner of one
|
|
// workspace must not be able to delete another workspace's webhook by ID. The
|
|
// scoped DELETE is also atomic and avoids decrypting the secret just to delete —
|
|
// a rotated/missing encryption key would otherwise leave a broken webhook
|
|
// undeletable. Returns sql.ErrNoRows when no webhook matches both id and workspace.
|
|
func (s *Store) DeleteWebhookScoped(id, workspaceID string) error {
|
|
// An app's hook is the install's (TASK-3408): never an owner's to delete.
|
|
result, err := s.db.Exec(s.q("DELETE FROM webhooks WHERE id = ? AND workspace_id = ? AND app_install_id IS NULL"), id, workspaceID)
|
|
if err != nil {
|
|
return fmt.Errorf("delete webhook: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateWebhookFailure increments or resets the failure count for a webhook.
|
|
// If failed is true, the failure_count is incremented. If it reaches the
|
|
// threshold of 10, the webhook is auto-deactivated.
|
|
// If failed is false, the failure_count is reset to 0 and last_triggered_at is updated.
|
|
// webhookDeactivateAfter consecutive failures deactivate a webhook (the
|
|
// documented "deactivated after 10 consecutive delivery failures").
|
|
const webhookDeactivateAfter = 10
|
|
|
|
func (s *Store) UpdateWebhookFailure(id string, failed bool) error {
|
|
ts := now()
|
|
if failed {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("update webhook failure: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
q := `SELECT active, failure_count FROM webhooks WHERE id = ?`
|
|
if s.dialect.Driver() == DriverPostgres {
|
|
q += ` FOR UPDATE`
|
|
}
|
|
var wasActive bool
|
|
var failures int
|
|
if err := tx.QueryRow(s.q(q), id).Scan(&wasActive, &failures); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil // deleted meanwhile; nothing to count
|
|
}
|
|
return fmt.Errorf("update webhook failure: read: %w", err)
|
|
}
|
|
if _, err := tx.Exec(s.q(`
|
|
UPDATE webhooks
|
|
SET failure_count = failure_count + 1,
|
|
updated_at = ?,
|
|
active = CASE WHEN failure_count + 1 >= 10 THEN FALSE ELSE active END
|
|
WHERE id = ?
|
|
`), ts, id); err != nil {
|
|
return fmt.Errorf("update webhook failure: %w", err)
|
|
}
|
|
// TASK-3409 (codex r2): the deactivation is where the endpoint stops
|
|
// being owed what it was still owed, so its pending rows are settled
|
|
// here, in the same transaction, as dropped and counted. The event
|
|
// whose failure caused it has no row yet (its outcome is recorded
|
|
// after this); the drain's next pass drops that one.
|
|
if wasActive && failures+1 >= webhookDeactivateAfter {
|
|
res, err := tx.Exec(s.q(`UPDATE webhook_deliveries SET status = ?, last_error = ?, updated_at = ?
|
|
WHERE webhook_id = ? AND status NOT IN ('delivered', 'permanent', 'refused', 'skipped', 'dropped')`),
|
|
DeliveryDropped, "endpoint deactivated", ts, id)
|
|
if err != nil {
|
|
return fmt.Errorf("update webhook failure: settle deliveries: %w", err)
|
|
}
|
|
if n, _ := res.RowsAffected(); n > 0 {
|
|
if _, err := tx.Exec(s.q(`UPDATE webhooks SET dropped_count = dropped_count + ? WHERE id = ?`), n, id); err != nil {
|
|
return fmt.Errorf("update webhook failure: count drops: %w", err)
|
|
}
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
} else {
|
|
_, err := s.db.Exec(s.q(`
|
|
UPDATE webhooks
|
|
SET failure_count = 0,
|
|
last_triggered_at = ?,
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
`), ts, ts, id)
|
|
if err != nil {
|
|
return fmt.Errorf("update webhook success: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|