Something went wrong. Try again.
Read-only ActivityPub → atproto bridge for the threadiverse using Coves lexicons
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180package accept
import ( "crypto/subtle" "encoding/json" stderrors "errors" "log/slog" "net/http"
"github.com/go-chi/chi/v5"
"tidepool/internal/errors")
// AdminOptions wires the admissions admin surface.type AdminOptions struct { // Token is the bearer token protecting /admin (config.AdminToken), matching // the ingest.Admin pattern. Token string // Admissions is the decision ledger the list endpoint reads. Admissions *Admissions // Engine serves the force re-admit (Readmit). Engine *Engine // Logger receives rejection/authz warnings. Nil uses slog.Default(). Logger *slog.Logger}
// Admin is the operator API for the acceptance engine's admission ledger://// GET /admin/admissions ?status=&community= (list decisions + reasons)// POST /admin/admissions/readmit {"post":"at://..."} (force re-admit one post)//// Both endpoints require "Authorization: Bearer $ADMIN_TOKEN". post.getStatus// reads a post's admission state from the firehose-visible acceptance/removal// records; THIS surface is the bridge operator's own window on the WHY of every// rejection, which those records cannot carry.type Admin struct { token string admissions *Admissions engine *Engine logger *slog.Logger}
// NewAdmin validates options and builds the admin API.func NewAdmin(opts AdminOptions) (*Admin, error) { switch { case opts.Token == "": return nil, errors.NewValidationError("token", "must not be empty") case opts.Admissions == nil: return nil, errors.NewValidationError("admissions", "must not be nil") case opts.Engine == nil: return nil, errors.NewValidationError("engine", "must not be nil") } logger := opts.Logger if logger == nil { logger = slog.Default() } return &Admin{token: opts.Token, admissions: opts.Admissions, engine: opts.Engine, logger: logger}, nil}
// Routes mounts the admissions admin API, bearer-protected like the rest of// /admin. It registers its full /admin/... paths inside a middleware GROUP// rather than a Route("/admin") subrouter, so it composes onto a router that// already mounts a /admin subtree (ingest.Admin) instead of colliding with it.func (a *Admin) Routes(r chi.Router) { r.Group(func(r chi.Router) { r.Use(adminBearer(a.token, a.logger)) r.Get("/admin/admissions", a.handleList) r.Post("/admin/admissions/readmit", a.handleReadmit) })}
// adminListItem is one admission on the wire: the operator's triage view of a// decision. The evaluated_snapshot is deliberately omitted (it can be large and// is only readmit's input).type adminListItem struct { Post string `json:"post"` Community string `json:"community"` Status string `json:"status"` DecisionCode string `json:"decisionCode"` EvaluatedCID string `json:"evaluatedCid"`}
// handleList lists admissions with their status, decision_code and evaluated_cid,// filterable by ?status= and/or ?community=.func (a *Admin) handleList(w http.ResponseWriter, r *http.Request) { admissions, err := a.admissions.List(r.Context(), AdmissionFilter{ Status: r.URL.Query().Get("status"), Community: r.URL.Query().Get("community"), }) if err != nil { a.logger.Error("admin list admissions failed", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) return } items := make([]adminListItem, 0, len(admissions)) for _, adm := range admissions { items = append(items, adminListItem{ Post: adm.PostURI, Community: adm.CommunityDID, Status: adm.Status, DecisionCode: adm.DecisionCode, EvaluatedCID: adm.EvaluatedCID, }) } writeJSON(w, http.StatusOK, map[string]any{"admissions": items})}
// readmitRequest is the POST body naming the post to force re-admit.type readmitRequest struct { Post string `json:"post"`}
// readmitResult is the wire outcome of a force re-admit.type readmitResult struct { Post string `json:"post"` Status string `json:"status"` DecisionCode string `json:"decisionCode"` Enqueued bool `json:"enqueued"`}
// handleReadmit force re-runs admission for one post (Engine.Readmit). A post// that passes now is accepted + enqueued; one that still fails returns the// current rejection (a reported outcome, HTTP 200, not an error). An unknown post// is 404; a post whose stored snapshot did not survive is 422 (unrecoverable// without a task-18 getRecord fetch).func (a *Admin) handleReadmit(w http.ResponseWriter, r *http.Request) { var body readmitRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Post == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body must be {\"post\":\"at://...\"}"}) return } result, err := a.engine.Readmit(r.Context(), body.Post) switch { case err == nil: writeJSON(w, http.StatusOK, readmitResult{ Post: result.PostURI, Status: result.Status, DecisionCode: result.DecisionCode, Enqueued: result.Enqueued, }) case errors.IsNotFound(err): writeJSON(w, http.StatusNotFound, map[string]string{"error": "no admission for " + body.Post}) case stderrors.Is(err, ErrUnrecoverableReadmit): // Surfaced, never a silent no-op: the record body the post was decided // against did not survive (legacy row, pre-migration-022), so there is // nothing to re-run admission from until a getRecord fetch lands (task 18). writeJSON(w, http.StatusUnprocessableEntity, map[string]string{ "error": "readmit unrecoverable: no stored record snapshot", "post": body.Post}) default: a.logger.Error("admin readmit failed", "post", body.Post, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) }}
// adminBearer is the constant-time bearer guard, mirroring ingest.requireBearer// (kept local so this surface stays self-contained).func adminBearer(token string, logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { const prefix = "Bearer " header := r.Header.Get("Authorization") if len(header) <= len(prefix) || header[:len(prefix)] != prefix || subtle.ConstantTimeCompare([]byte(header[len(prefix):]), []byte(token)) != 1 { logger.Warn("admin request rejected: bad bearer token", "path", r.URL.Path) http.Error(w, "unauthorized", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) }}
// writeJSON is the shared JSON responder the admin handlers use.func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body)}