main.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. // Command admind is the admin HTTP API + (later) UI host. Tenant CRUD,
  2. // DLQ inspection, replay, audit log.
  3. //
  4. // M0: /health, /metrics, /v1/ping.
  5. // M8: + DLQ surface —
  6. // GET /v1/dlq — list/filter DLQ rows
  7. // GET /v1/dlq/{id} — single row (with payload)
  8. // POST /v1/dlq/{id}/replay — re-INSERT into the deliverd NATS subject
  9. // POST /v1/dlq/{id}/discard — mark discarded (hidden from default list)
  10. // GET /dlq — minimal HTML UI
  11. //
  12. // Auth: M8 ships without auth (LAN-only). The M11 work
  13. // gates /v1/dlq* behind an operator JWT.
  14. package main
  15. import (
  16. "context"
  17. "embed"
  18. "encoding/json"
  19. "errors"
  20. "fmt"
  21. "html"
  22. "html/template"
  23. "log/slog"
  24. "net/http"
  25. "os"
  26. "os/signal"
  27. "strconv"
  28. "strings"
  29. "syscall"
  30. "time"
  31. "git3.techno-world.net/lrosales/broad-announce/internal/authd"
  32. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  33. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  34. "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
  35. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  36. "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
  37. "github.com/jackc/pgx/v5"
  38. "github.com/nats-io/nats.go"
  39. )
  40. //go:embed ui/*
  41. var uiFS embed.FS
  42. // dlqRow is the wire shape returned by /v1/dlq and
  43. // rendered by the HTML UI. The payload field is omitted
  44. // from the list endpoint (operators can fetch the full
  45. // row via /v1/dlq/{id}).
  46. type dlqRow struct {
  47. ID int64 `json:"id"`
  48. AlertID string `json:"alert_id"`
  49. CompanyID string `json:"company_id"`
  50. IndividualID string `json:"individual_id"`
  51. Channel string `json:"channel"`
  52. Target string `json:"target"`
  53. OriginalSubject string `json:"original_subject"`
  54. Attempts int `json:"attempts"`
  55. LastError string `json:"last_error"`
  56. Discarded bool `json:"discarded"`
  57. DiscardedAt *time.Time `json:"discarded_at,omitempty"`
  58. DiscardedBy string `json:"discarded_by,omitempty"`
  59. CreatedAt time.Time `json:"created_at"`
  60. Payload json.RawMessage `json:"payload,omitempty"`
  61. }
  62. func main() {
  63. cfg, err := config.LoadCommon("admind")
  64. if err != nil {
  65. os.Stderr.WriteString("config: " + err.Error() + "\n")
  66. os.Exit(1)
  67. }
  68. logger := observability.Init(cfg.Env, cfg.LogLevel, "admind")
  69. logger.Info("starting", "env", cfg.Env, "addr", cfg.HTTPAddr)
  70. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  71. defer stop()
  72. br, err := broker.Connect(ctx, cfg.NATSURL)
  73. if err != nil {
  74. logger.Error("nats connect", "err", err)
  75. os.Exit(1)
  76. }
  77. defer br.Close()
  78. pool, err := postgres.Connect(ctx, cfg.PostgresDSN)
  79. if err != nil {
  80. logger.Error("postgres connect", "err", err)
  81. os.Exit(1)
  82. }
  83. defer pool.Close()
  84. reg, _ := observability.NewRegistry("admind")
  85. srv := httpserver.New(httpserver.Config{
  86. Addr: cfg.HTTPAddr,
  87. ServiceName: "admind",
  88. ShutdownGrace: cfg.ShutdownGrace,
  89. }, logger, observability.MetricsHandler(reg))
  90. mux := srv.Mux()
  91. mux.HandleFunc("GET /v1/ping", handlePing)
  92. mux.HandleFunc("GET /dlq", handleDLQUI(pool, logger))
  93. // M13a W3: JWT-gate the /v1/dlq* routes when BA_AUTHD_JWT_SECRET
  94. // is set. When unset, the routes stay unauthenticated (the
  95. // pre-M13 behavior) so the LAN-only deploy path keeps working.
  96. wireDLQRoutes(mux, br, pool, logger)
  97. errCh := make(chan error, 1)
  98. go func() { errCh <- srv.Start() }()
  99. select {
  100. case <-ctx.Done():
  101. logger.Info("shutdown signal received")
  102. case err := <-errCh:
  103. if err != nil {
  104. logger.Error("http server", "err", err)
  105. os.Exit(1)
  106. }
  107. }
  108. if err := srv.Shutdown(ctx); err != nil {
  109. logger.Warn("graceful shutdown", "err", err)
  110. }
  111. logger.Info("bye")
  112. }
  113. func handlePing(w http.ResponseWriter, r *http.Request) {
  114. w.Header().Set("Content-Type", "application/json")
  115. _ = json.NewEncoder(w).Encode(map[string]any{
  116. "pong": true,
  117. "service": "admind",
  118. "timestamp": time.Now().UTC().Format(time.RFC3339Nano),
  119. })
  120. }
  121. // listFilters is the parsed query string for /v1/dlq.
  122. type listFilters struct {
  123. CompanyID string
  124. Channel string
  125. AlertID string
  126. Include string // "all" to show discarded; default hides them
  127. Limit int
  128. Offset int
  129. }
  130. func parseListFilters(r *http.Request) listFilters {
  131. q := r.URL.Query()
  132. limit, _ := strconv.Atoi(q.Get("limit"))
  133. if limit <= 0 || limit > 500 {
  134. limit = 100
  135. }
  136. offset, _ := strconv.Atoi(q.Get("offset"))
  137. if offset < 0 {
  138. offset = 0
  139. }
  140. return listFilters{
  141. CompanyID: strings.TrimSpace(q.Get("company_id")),
  142. Channel: strings.TrimSpace(q.Get("channel")),
  143. AlertID: strings.TrimSpace(q.Get("alert_id")),
  144. Include: strings.TrimSpace(q.Get("include")),
  145. Limit: limit,
  146. Offset: offset,
  147. }
  148. }
  149. // handleListDLQ returns a paginated list of DLQ rows.
  150. // The list excludes discarded rows by default; pass
  151. // include=all to see them.
  152. func handleListDLQ(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  153. return func(w http.ResponseWriter, r *http.Request) {
  154. f := parseListFilters(r)
  155. rows, err := queryDLQ(r.Context(), pool, dlqQuery{
  156. CompanyID: f.CompanyID,
  157. Channel: f.Channel,
  158. AlertID: f.AlertID,
  159. IncludeDiscarded: f.Include == "all",
  160. Limit: f.Limit,
  161. Offset: f.Offset,
  162. })
  163. if err != nil {
  164. logger.Error("dlq list", "err", err)
  165. http.Error(w, "dlq list: "+err.Error(), http.StatusInternalServerError)
  166. return
  167. }
  168. w.Header().Set("Content-Type", "application/json")
  169. _ = json.NewEncoder(w).Encode(map[string]any{
  170. "rows": rows,
  171. "limit": f.Limit,
  172. "offset": f.Offset,
  173. })
  174. }
  175. }
  176. // handleGetDLQ returns one row by id, with the payload
  177. // included. Used by the UI's "view payload" expand.
  178. func handleGetDLQ(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  179. return func(w http.ResponseWriter, r *http.Request) {
  180. id, ok := parseID(r)
  181. if !ok {
  182. http.Error(w, "bad id", http.StatusBadRequest)
  183. return
  184. }
  185. row, err := getDLQ(r.Context(), pool, id)
  186. if err != nil {
  187. logger.Error("dlq get", "err", err, "id", id)
  188. http.Error(w, "dlq get: "+err.Error(), http.StatusInternalServerError)
  189. return
  190. }
  191. if row == nil {
  192. http.Error(w, "not found", http.StatusNotFound)
  193. return
  194. }
  195. w.Header().Set("Content-Type", "application/json")
  196. _ = json.NewEncoder(w).Encode(row)
  197. }
  198. }
  199. // handleReplayDLQ re-publishes the original NATS envelope
  200. // (stored in deliveries_dlq.payload) onto the original
  201. // subject, then marks the DLQ row discarded=true (with
  202. // discarded_by='replay') and writes a fresh audit row in
  203. // the live deliveries table.
  204. //
  205. // Idempotency: a single replay succeeds or fails. If it
  206. // fails, the DLQ row is NOT marked discarded so the
  207. // operator can retry. There is no race because the
  208. // discarded=true UPDATE happens in the same handler call
  209. // as the Publish; a second concurrent replay would
  210. // publish twice (the second one will hit dedupe at the
  211. // ingestd layer if the alert_id has been seen recently,
  212. // but for the DLQ replay path we don't dedupe — the
  213. // operator explicitly asked for a re-send).
  214. func handleReplayDLQ(br *broker.Client, pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  215. return func(w http.ResponseWriter, r *http.Request) {
  216. id, ok := parseID(r)
  217. if !ok {
  218. http.Error(w, "bad id", http.StatusBadRequest)
  219. return
  220. }
  221. row, err := getDLQ(r.Context(), pool, id)
  222. if err != nil {
  223. logger.Error("dlq replay get", "err", err, "id", id)
  224. http.Error(w, "dlq replay: "+err.Error(), http.StatusInternalServerError)
  225. return
  226. }
  227. if row == nil {
  228. http.Error(w, "not found", http.StatusNotFound)
  229. return
  230. }
  231. if row.Discarded {
  232. http.Error(w, "already discarded", http.StatusConflict)
  233. return
  234. }
  235. // Re-publish the original payload (json.RawMessage
  236. // holds the NATS envelope bytes verbatim).
  237. nc := br.NC()
  238. if err := nc.Publish(row.OriginalSubject, row.Payload); err != nil {
  239. logger.Error("dlq replay publish", "err", err, "id", id, "subject", row.OriginalSubject)
  240. http.Error(w, "publish: "+err.Error(), http.StatusBadGateway)
  241. return
  242. }
  243. // Flush so the message is on the wire before we
  244. // mark the row discarded. If the worker can't
  245. // deliver it, it will go back into the DLQ with
  246. // a fresh row, and the operator will see both.
  247. if err := nc.Flush(); err != nil {
  248. logger.Warn("dlq replay flush", "err", err, "id", id)
  249. }
  250. // Mark the DLQ row discarded. We do this AFTER
  251. // the publish so a publish failure leaves the
  252. // DLQ row in place for retry.
  253. now := time.Now().UTC()
  254. _, dbErr := pool.Exec(r.Context(), `
  255. UPDATE deliveries_dlq
  256. SET discarded = true,
  257. discarded_at = $1,
  258. discarded_by = $2
  259. WHERE id = $3 AND created_at IN (
  260. SELECT created_at FROM deliveries_dlq WHERE id = $3 LIMIT 1
  261. )
  262. `, now, "replay", id)
  263. if dbErr != nil {
  264. // Publish succeeded but the DB update
  265. // didn't. The replay still happened —
  266. // log loud, return 200, and let the
  267. // operator handle the duplicate UI row
  268. // if they re-replay.
  269. logger.Error("dlq replay: publish ok but update failed",
  270. "err", dbErr, "id", id)
  271. }
  272. logger.Info("dlq replay ok", "id", id, "subject", row.OriginalSubject, "alert_id", row.AlertID)
  273. w.Header().Set("Content-Type", "application/json")
  274. _ = json.NewEncoder(w).Encode(map[string]any{
  275. "replayed": true,
  276. "id": id,
  277. "subject": row.OriginalSubject,
  278. "alert_id": row.AlertID,
  279. "company_id": row.CompanyID,
  280. "original_attempts": row.Attempts,
  281. })
  282. }
  283. }
  284. // handleDiscardDLQ marks a DLQ row as discarded. After
  285. // discard, the row is hidden from /v1/dlq by default and
  286. // from the HTML UI.
  287. func handleDiscardDLQ(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  288. return func(w http.ResponseWriter, r *http.Request) {
  289. id, ok := parseID(r)
  290. if !ok {
  291. http.Error(w, "bad id", http.StatusBadRequest)
  292. return
  293. }
  294. now := time.Now().UTC()
  295. // The (id, created_at) PK means the UPDATE
  296. // has to match the row's created_at too. We
  297. // fetch first, then UPDATE with both keys.
  298. row, err := getDLQ(r.Context(), pool, id)
  299. if err != nil {
  300. logger.Error("dlq discard get", "err", err, "id", id)
  301. http.Error(w, "dlq discard: "+err.Error(), http.StatusInternalServerError)
  302. return
  303. }
  304. if row == nil {
  305. http.Error(w, "not found", http.StatusNotFound)
  306. return
  307. }
  308. if row.Discarded {
  309. // Idempotent: a second discard is a no-op.
  310. w.Header().Set("Content-Type", "application/json")
  311. _ = json.NewEncoder(w).Encode(map[string]any{"discarded": true, "id": id, "already": true})
  312. return
  313. }
  314. _, dbErr := pool.Exec(r.Context(), `
  315. UPDATE deliveries_dlq
  316. SET discarded = true,
  317. discarded_at = $1,
  318. discarded_by = $2
  319. WHERE id = $3 AND created_at = $4
  320. `, now, "operator", id, row.CreatedAt)
  321. if dbErr != nil {
  322. logger.Error("dlq discard", "err", dbErr, "id", id)
  323. http.Error(w, "discard: "+dbErr.Error(), http.StatusInternalServerError)
  324. return
  325. }
  326. logger.Info("dlq discard ok", "id", id, "alert_id", row.AlertID)
  327. w.Header().Set("Content-Type", "application/json")
  328. _ = json.NewEncoder(w).Encode(map[string]any{
  329. "discarded": true,
  330. "id": id,
  331. "discarded_at": now,
  332. })
  333. }
  334. }
  335. // dlqQuery is the structured query for the list endpoint.
  336. type dlqQuery struct {
  337. CompanyID string
  338. Channel string
  339. AlertID string
  340. IncludeDiscarded bool
  341. Limit int
  342. Offset int
  343. }
  344. // queryDLQ returns the rows that match the filters.
  345. // Excludes the payload from the list view (operators
  346. // fetch /v1/dlq/{id} for the full row).
  347. func queryDLQ(ctx context.Context, pool *postgres.Pool, q dlqQuery) ([]dlqRow, error) {
  348. // Build the WHERE clause dynamically. We always
  349. // filter on created_at > now - 30d for the list
  350. // view (operators rarely need ancient rows; the
  351. // archiver ships them to CH).
  352. conds := []string{"created_at > now() - INTERVAL '30 days'"}
  353. args := []any{}
  354. if q.CompanyID != "" {
  355. args = append(args, q.CompanyID)
  356. conds = append(conds, fmt.Sprintf("company_id = $%d", len(args)))
  357. }
  358. if q.Channel != "" {
  359. args = append(args, q.Channel)
  360. conds = append(conds, fmt.Sprintf("channel = $%d", len(args)))
  361. }
  362. if q.AlertID != "" {
  363. args = append(args, q.AlertID)
  364. conds = append(conds, fmt.Sprintf("alert_id = $%d", len(args)))
  365. }
  366. if !q.IncludeDiscarded {
  367. conds = append(conds, "discarded = false")
  368. }
  369. where := strings.Join(conds, " AND ")
  370. args = append(args, q.Limit, q.Offset)
  371. q1 := fmt.Sprintf(`
  372. SELECT id, alert_id, company_id, individual_id, channel, target,
  373. original_subject, attempts, last_error, discarded,
  374. discarded_at, discarded_by, created_at
  375. FROM deliveries_dlq
  376. WHERE %s
  377. ORDER BY created_at DESC
  378. LIMIT $%d OFFSET $%d
  379. `, where, len(args)-1, len(args))
  380. rows, err := pool.Query(ctx, q1, args...)
  381. if err != nil {
  382. return nil, err
  383. }
  384. defer rows.Close()
  385. var out []dlqRow
  386. for rows.Next() {
  387. var r dlqRow
  388. var discardedAt *time.Time
  389. var discardedBy *string
  390. if err := rows.Scan(
  391. &r.ID, &r.AlertID, &r.CompanyID, &r.IndividualID, &r.Channel, &r.Target,
  392. &r.OriginalSubject, &r.Attempts, &r.LastError, &r.Discarded,
  393. &discardedAt, &discardedBy, &r.CreatedAt,
  394. ); err != nil {
  395. return nil, err
  396. }
  397. r.DiscardedAt = discardedAt
  398. if discardedBy != nil {
  399. r.DiscardedBy = *discardedBy
  400. }
  401. out = append(out, r)
  402. }
  403. return out, rows.Err()
  404. }
  405. // getDLQ returns one row by id (with payload). Returns
  406. // nil, nil if the row doesn't exist.
  407. func getDLQ(ctx context.Context, pool *postgres.Pool, id int64) (*dlqRow, error) {
  408. row := pool.QueryRow(ctx, `
  409. SELECT id, alert_id, company_id, individual_id, channel, target,
  410. original_subject, attempts, last_error, payload, discarded,
  411. discarded_at, discarded_by, created_at
  412. FROM deliveries_dlq
  413. WHERE id = $1
  414. LIMIT 1
  415. `, id)
  416. var r dlqRow
  417. var payload []byte
  418. var discardedAt *time.Time
  419. var discardedBy *string
  420. if err := row.Scan(
  421. &r.ID, &r.AlertID, &r.CompanyID, &r.IndividualID, &r.Channel, &r.Target,
  422. &r.OriginalSubject, &r.Attempts, &r.LastError, &payload, &r.Discarded,
  423. &discardedAt, &discardedBy, &r.CreatedAt,
  424. ); err != nil {
  425. if errors.Is(err, pgx.ErrNoRows) {
  426. return nil, nil
  427. }
  428. return nil, err
  429. }
  430. r.Payload = json.RawMessage(payload)
  431. r.DiscardedAt = discardedAt
  432. if discardedBy != nil {
  433. r.DiscardedBy = *discardedBy
  434. }
  435. return &r, nil
  436. }
  437. func parseID(r *http.Request) (int64, bool) {
  438. raw := r.PathValue("id")
  439. id, err := strconv.ParseInt(raw, 10, 64)
  440. if err != nil || id <= 0 {
  441. return 0, false
  442. }
  443. return id, true
  444. }
  445. // ── HTML UI ──────────────────────────────────────────────────────
  446. // uiData is the template data for the /dlq HTML page.
  447. type uiData struct {
  448. Rows []dlqRow
  449. Filters listFilters
  450. ShowAll bool
  451. HasFilters bool
  452. }
  453. // handleDLQUI serves a minimal HTML page with the DLQ
  454. // list and inline replay/discard buttons. POSTs go to
  455. // the same /v1/dlq/{id}/{replay,discard} endpoints; the
  456. // JS does a fetch and reloads the page.
  457. func handleDLQUI(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  458. tpl := template.Must(template.New("dlq").Funcs(template.FuncMap{
  459. "safeHTML": func(s string) template.HTML { return template.HTML(html.EscapeString(s)) },
  460. "shortErr": func(s string) string {
  461. if len(s) > 80 {
  462. return s[:80] + "…"
  463. }
  464. return s
  465. },
  466. "ago": func(t time.Time) string {
  467. d := time.Since(t)
  468. switch {
  469. case d < time.Minute:
  470. return fmt.Sprintf("%ds ago", int(d.Seconds()))
  471. case d < time.Hour:
  472. return fmt.Sprintf("%dm ago", int(d.Minutes()))
  473. case d < 24*time.Hour:
  474. return fmt.Sprintf("%dh ago", int(d.Hours()))
  475. default:
  476. return fmt.Sprintf("%dd ago", int(d.Hours()/24))
  477. }
  478. },
  479. }).ParseFS(uiFS, "ui/*.html"))
  480. return func(w http.ResponseWriter, r *http.Request) {
  481. f := parseListFilters(r)
  482. filters := dlqQuery{
  483. CompanyID: f.CompanyID,
  484. Channel: f.Channel,
  485. AlertID: f.AlertID,
  486. IncludeDiscarded: f.Include == "all",
  487. Limit: f.Limit,
  488. Offset: f.Offset,
  489. }
  490. rows, err := queryDLQ(r.Context(), pool, filters)
  491. if err != nil {
  492. logger.Error("dlq ui list", "err", err)
  493. http.Error(w, "dlq ui: "+err.Error(), http.StatusInternalServerError)
  494. return
  495. }
  496. data := uiData{
  497. Rows: rows,
  498. Filters: f,
  499. ShowAll: f.Include == "all",
  500. HasFilters: f.CompanyID != "" || f.Channel != "" || f.AlertID != "",
  501. }
  502. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  503. if err := tpl.ExecuteTemplate(w, "dlq.html", data); err != nil {
  504. logger.Error("dlq ui render", "err", err)
  505. }
  506. }
  507. }
  508. // broker re-export so the handler signatures stay clean.
  509. // We import broker just for the Connect + Conn() pair.
  510. var _ = nats.Conn{}
  511. // wireDLQRoutes decides whether the /v1/dlq* routes go behind the
  512. // JWT gate or stay open, based on BA_AUTHD_JWT_SECRET. Extracted so
  513. // main() stays linear (no goto, no early returns from main).
  514. func wireDLQRoutes(mux *http.ServeMux, br *broker.Client, pool *postgres.Pool, logger *slog.Logger) {
  515. if !authd.EnvEnabled() {
  516. logger.Warn("dlq routes are UNAUTHENTICATED (set BA_AUTHD_JWT_SECRET to enable JWT gate)")
  517. mux.HandleFunc("GET /v1/dlq", handleListDLQ(pool, logger))
  518. mux.HandleFunc("GET /v1/dlq/{id}", handleGetDLQ(pool, logger))
  519. mux.HandleFunc("POST /v1/dlq/{id}/replay", handleReplayDLQ(br, pool, logger))
  520. mux.HandleFunc("POST /v1/dlq/{id}/discard", handleDiscardDLQ(pool, logger))
  521. return
  522. }
  523. ad, err := authd.NewFromEnv()
  524. if err != nil {
  525. logger.Error("BA_AUTHD_JWT_SECRET set but authd init failed; falling back to UNAUTHENTICATED routes", "err", err)
  526. mux.HandleFunc("GET /v1/dlq", handleListDLQ(pool, logger))
  527. mux.HandleFunc("GET /v1/dlq/{id}", handleGetDLQ(pool, logger))
  528. mux.HandleFunc("POST /v1/dlq/{id}/replay", handleReplayDLQ(br, pool, logger))
  529. mux.HandleFunc("POST /v1/dlq/{id}/discard", handleDiscardDLQ(pool, logger))
  530. return
  531. }
  532. logger.Info("dlq routes enabled with JWT gate")
  533. // replay and discard are destructive — require admin role.
  534. // list and get are read-only — any authenticated user.
  535. mux.Handle("GET /v1/dlq", ad.RequireAuth(http.HandlerFunc(handleListDLQ(pool, logger))))
  536. mux.Handle("GET /v1/dlq/{id}", ad.RequireAuth(http.HandlerFunc(handleGetDLQ(pool, logger))))
  537. mux.Handle("POST /v1/dlq/{id}/replay", ad.RequireRole("super_admin", "tenant_admin")(http.HandlerFunc(handleReplayDLQ(br, pool, logger))))
  538. mux.Handle("POST /v1/dlq/{id}/discard", ad.RequireRole("super_admin", "tenant_admin")(http.HandlerFunc(handleDiscardDLQ(pool, logger))))
  539. }