Files
popertotsandSisyphus 049c361e16 fix: adminnotify observability — money-critical rows sort first, flood-cap suppression surfaced to operator, stale coordination doc fixed
- notifications priority ordering: money-critical reasons (webhooks, sweeps, refunds, gift-card, manual-refund failures) above routine
- admin notifications page exposes the flood-cap suppressed count
- adminnotify.go contract doc: removed stale 2FA reissue-fail site, current insert-site list

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

147 lines
6.7 KiB
Go

// Package adminnotify owns the shared flood cap for the DB-backed admin
// notification centre (admin_notifications). That table is the single
// operator's ONLY pager for money events, so every site that inserts an
// operator-facing alert must bound its unacknowledged queue — a hostile flood
// (attacker-registered accounts triggering refresh_token_reuse / webhook
// alerts) must not be able to bury the notification centre under rows the
// operator can never work through.
//
// Coordination contract (Round 2 Loop B finding 1): the cap is applied
// atomically at every insert site in this codebase:
//
// - auth/jwt.go VerifyRefreshToken — the 'refresh_token_reuse' alert.
// - handlers/webhooks/square.go — the 'critical_payment_log' inserts
// (dispute, booking, unknown-event, orphan-replay).
// - handlers/user/account.go InsertSquareErasureCriticalNotification.
// - handlers/payments/sweep.go insertCriticalPaymentNotification.
// - handlers/scheduling/time-blockers.go InsertSquareCleanupCriticalNotification
// and the deposit-deadline cleanup insert.
// - internal/jobs/cleanup.go ScanCriticalPaymentLogs.
// - handlers/scheduling/scheduled-cleanup.go — the '1_week_no_pay',
// '1_month_no_pay' and 'default_hours_changed' inserts.
// - handlers/bookings/bookings.go — the 'new_booking' / 'pending_booking' /
// 'cancelled_booking' inserts.
// - handlers/bookings/manage.go — the 'cancelled_booking' / 'edit_requested'
// inserts.
// - handlers/payments/refunds.go — the 'refund_failed' insert.
//
// main.go has NO admin_notifications insert sites (it only mounts the
// notification read/ack routes), so nothing to cap there.
//
// Every site folds the cap INTO its INSERT (a conditional
// `INSERT ... SELECT ... WHERE (SELECT COUNT(*) ...) < $cap`) so the
// count-then-insert is ATOMIC — closing the TOCTOU where two concurrent
// inserts both read a below-cap count and overshoot together (finding 2).
// Every site ALSO pre-checks CriticalLogsCapExceeded to log the suppression;
// that pre-check is the single choke point that records the suppression in
// admin_notification_suppressions, so the operator-facing "suppressed this
// cycle" count on GET /api/admin/notifications stays accurate without any
// insert site having to change.
package adminnotify
import (
"context"
"log"
"time"
"crussell/db"
)
// MaxUnacknowledgedCriticalLogs is the GLOBAL cap on unacknowledged
// admin_notifications rows for one reason (named after the money-critical
// reason 'critical_payment_log' that the cap exists to protect). Once the
// unacknowledged queue for a reason reaches the cap, further inserts for that
// reason are dropped (with a suppression log for operator visibility) until
// the operator acknowledges outstanding rows. 100 is far beyond anything a
// single salon produces legitimately, so it only ever suppresses an abnormal
// flood.
const MaxUnacknowledgedCriticalLogs = 100
// CriticalLogsCapExceeded reports whether the unacknowledged admin-notification
// queue for reason has reached MaxUnacknowledgedCriticalLogs. q routes through
// the caller's transaction when one is active (the ctx-routed db.Conn proxy, or
// an explicit pgx.Tx).
//
// Best-effort and fail-OPEN: a count error is logged and false is returned, so
// a money alert is never dropped because the count query failed (the insert's
// own atomic cap condition below still guards the row in that case — the
// pre-check only decides whether to log a suppression).
//
// At the cap the suppression is ALSO recorded in admin_notification_suppressions
// (recordSuppression) so the admin notifications page can surface how many
// alerts were dropped. The record write is best-effort too — a failure only
// loses the counter, never the boolean decision.
func CriticalLogsCapExceeded(ctx context.Context, q db.Querier, reason string) bool {
var n int
err := q.QueryRow(ctx, `
SELECT COUNT(*) FROM admin_notifications
WHERE reason = $1::admin_notification_reason AND acknowledged_at IS NULL
`, reason).Scan(&n)
if err != nil {
log.Printf("adminnotify: failed to count unacknowledged %s admin notifications: %v", reason, err)
return false
}
if n >= MaxUnacknowledgedCriticalLogs {
recordSuppression(ctx, q, reason)
return true
}
return false
}
// recordSuppression upserts the per-reason flood-cap suppression counter
// (admin_notification_suppressions). Every insert site funnels its pre-check
// through CriticalLogsCapExceeded, so a suppression is recorded exactly once
// per dropped alert, and a running suppressed_count accumulates while the
// reason's unacknowledged queue stays at the cap. Best-effort: a failure is
// logged, never propagated — the alert is already being dropped at the cap and
// the operator-facing counter is informational, not load-bearing.
func recordSuppression(ctx context.Context, q db.Querier, reason string) {
if _, err := q.Exec(ctx, `
INSERT INTO admin_notification_suppressions (reason, suppressed_count, first_suppressed_at, last_suppressed_at)
VALUES ($1::admin_notification_reason, 1, NOW(), NOW())
ON CONFLICT (reason) DO UPDATE SET
suppressed_count = admin_notification_suppressions.suppressed_count + 1,
last_suppressed_at = NOW()
`, reason); err != nil {
log.Printf("adminnotify: failed to record flood-cap suppression for %s: %v", reason, err)
}
}
// Suppression is the per-reason flood-cap suppression counter surfaced to the
// operator: how many alerts were dropped for that reason while its
// unacknowledged queue sat at MaxUnacknowledgedCriticalLogs.
type Suppression struct {
Reason string
SuppressedCount int
LastSuppressedAt time.Time
}
// ActiveSuppressions returns the per-reason flood-cap suppressions whose
// underlying unacknowledged queue is STILL at the cap — i.e. the operator has
// not yet worked it down ("this cycle"). The admin notifications handler sums
// these into the response's suppressed count; the count naturally resets once
// the operator acknowledges the queue back below the cap. Best-effort: the
// caller must treat a non-nil error as "no suppression data available".
func ActiveSuppressions(ctx context.Context, q db.Querier) ([]Suppression, error) {
rows, err := q.Query(ctx, `
SELECT s.reason, s.suppressed_count, s.last_suppressed_at
FROM admin_notification_suppressions s
WHERE (SELECT COUNT(*) FROM admin_notifications an
WHERE an.reason = s.reason AND an.acknowledged_at IS NULL) >= $1
ORDER BY s.last_suppressed_at DESC
`, MaxUnacknowledgedCriticalLogs)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Suppression
for rows.Next() {
var s Suppression
if err := rows.Scan(&s.Reason, &s.SuppressedCount, &s.LastSuppressedAt); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}