package web import ( "encoding/json" "errors" "io" "log/slog" "net/http" "strings" "time" "pete/internal/storage" ) // buyerLocalpart normalises a session's Authentik username to the Matrix // localpart gogobee keys everything on. Matrix localparts are lowercase; an // Authentik display of the same name may not be, and gogobee pushes balances and // resolves orders under the lowercase form. Lowercasing on both sides is what // keeps the buyer's balance lookup and their order's identity pointing at the // same person. func buyerLocalpart(u *SessionUser) string { return strings.ToLower(strings.TrimSpace(u.Username)) } // The mischief storefront's web seam. // // Two audiences, two auth models, same file. Browsers hit the OIDC-gated buy // endpoints: a signed-in buyer picks a mark off the anonymous board and places a // hit, and reads back their own orders' standing. gogobee hits the bearer-authed // pair: it polls pending orders and pushes a verdict, exactly like the escrow // seam in games.go and under the same standing rule — Pete never calls gogobee, // never moves money, never runs a game rule. It records intent and files a // verdict; everything real happens on the game box. // mischiefBurstWindow / mischiefBurstMax are Pete's own anti-spam guard, and // nothing more. The real economic caps — two contracts a day, one live per mark, // a boss a week — are gogobee's and are enforced at claim time. This only stops a // signed-in buyer from spooling the orders table with a stuck mouse button. const ( mischiefBurstWindow = time.Hour mischiefBurstMax = 20 ) // mischiefOrderReq is the browser's buy request. Price and eligibility are not // the buyer's to assert: they send a tier key and a mark, never a number. type mischiefOrderReq struct { TargetToken string `json:"target_token"` Tier string `json:"tier"` Signed bool `json:"signed"` } // handleMischiefOrder places a pending order for the signed-in buyer. It asserts // only what Pete can honestly know: the buyer is signed in with a username the // game box can resolve, the tier is one gogobee currently offers, and the mark is // actually on the live board. Money and the full rulebook are gogobee's, checked // when it claims the order. func (s *Server) handleMischiefOrder(w http.ResponseWriter, r *http.Request) { u := s.requireUser(w, r) if u == nil { return } // A session minted before the storefront existed carries no username, and // without one gogobee can't name the buyer to its ledger. Send them back // through sign-in to mint a fresh one rather than place an unfulfillable order. buyer := buyerLocalpart(u) if buyer == "" { writeMischiefError(w, http.StatusConflict, "please sign in again") return } var req mischiefOrderReq if !decodeStateBody(w, r, &req) { return } if req.TargetToken == "" { writeMischiefError(w, http.StatusBadRequest, "no target") return } tier, ok, err := storage.MischiefTierByKey(req.Tier) if err != nil { slog.Error("mischief: tier lookup", "err", err) writeMischiefError(w, http.StatusInternalServerError, "internal error") return } if !ok { writeMischiefError(w, http.StatusBadRequest, "no such tier") return } mark, ok, err := storage.RosterEntryByToken(req.TargetToken) if err != nil { slog.Error("mischief: target lookup", "err", err) writeMischiefError(w, http.StatusInternalServerError, "internal error") return } if !ok { // The board moved on, or the token was never real. Either way there is no // one to send trouble to. writeMischiefError(w, http.StatusNotFound, "that adventurer is no longer on the board") return } // Burst guard. Keyed on the OIDC subject so a username change can't reset it. since := time.Now().Add(-mischiefBurstWindow).Unix() if n, err := storage.CountMischiefOrdersSince(u.Sub, since); err != nil { slog.Error("mischief: burst count", "err", err) writeMischiefError(w, http.StatusInternalServerError, "internal error") return } else if n >= mischiefBurstMax { writeMischiefError(w, http.StatusTooManyRequests, "slow down — too many orders in a short while") return } order, err := storage.InsertMischiefOrder(u.Sub, buyer, mark.Token, mark.Name, tier.Key, req.Signed) if err != nil { slog.Error("mischief: insert order", "err", err) writeMischiefError(w, http.StatusInternalServerError, "internal error") return } slog.Info("mischief: order placed", "guid", order.GUID, "buyer", buyer, "tier", tier.Key, "signed", req.Signed) w.Header().Set("Cache-Control", "no-store") writeJSON(w, order) } // handleMischiefOrders returns the signed-in buyer's own orders for the status // panel, newest first. Scoped to their OIDC subject — a buyer sees their history // and no one else's. func (s *Server) handleMischiefOrders(w http.ResponseWriter, r *http.Request) { u := s.requireUser(w, r) if u == nil { return } orders, err := storage.MischiefOrdersByBuyer(u.Sub, 20) if err != nil { slog.Error("mischief: orders by buyer", "err", err) writeMischiefError(w, http.StatusInternalServerError, "internal error") return } if orders == nil { orders = []storage.MischiefOrder{} } w.Header().Set("Cache-Control", "no-store") writeJSON(w, orders) } // mischiefCatalogResp is what the storefront reads to draw itself: the live price // list plus the signed-in buyer's own advisory balance, so it can grey out tiers // they plainly can't afford. HasBalance distinguishes "gogobee says €0" from // "gogobee has never mentioned this buyer" — the latter shows no affordability // hint rather than a discouraging, possibly-wrong zero. type mischiefCatalogResp struct { Tiers []storage.MischiefTier `json:"tiers"` Euro float64 `json:"euro"` HasBalance bool `json:"has_balance"` } // handleMischiefCatalog serves the price list and the buyer's balance together. func (s *Server) handleMischiefCatalog(w http.ResponseWriter, r *http.Request) { u := s.requireUser(w, r) if u == nil { return } tiers, err := storage.MischiefTiers() if err != nil { slog.Error("mischief: catalog", "err", err) writeMischiefError(w, http.StatusInternalServerError, "internal error") return } if tiers == nil { tiers = []storage.MischiefTier{} } resp := mischiefCatalogResp{Tiers: tiers} if lp := buyerLocalpart(u); lp != "" { euro, has, err := storage.UserEuro(lp) if err != nil { slog.Error("mischief: balance", "err", err) } else { resp.Euro, resp.HasBalance = euro, has } } w.Header().Set("Cache-Control", "no-store") writeJSON(w, resp) } // ---- the gogobee wire: bearer-authed, idempotent ------------------------------- // handleMischiefPending is gogobee's poll: every order still waiting to be acted // on. A pending order carries no intermediate state, so unlike escrow there is no // stale-reoffer window — a gogobee that dies mid-claim simply leaves the order // pending to be offered again, and the guid makes the replay a no-op. func (s *Server) handleMischiefPending(w http.ResponseWriter, r *http.Request) { if !s.bearerOK(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } orders, err := storage.PendingMischiefOrders(mischiefPollLimit) if err != nil { slog.Error("mischief: pending", "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if orders == nil { orders = []storage.MischiefOrder{} } writeJSON(w, orders) } // mischiefPollLimit caps one poll, matching the escrow seam. const mischiefPollLimit = 50 // mischiefVerdict is gogobee's answer on a claimed order: the terminal status and // a human note to render. type mischiefVerdict struct { GUID string `json:"guid"` Status string `json:"status"` Detail string `json:"detail,omitempty"` } // handleMischiefClaim files gogobee's verdict against a pending order. Idempotent: // gogobee's poll loop retries, so the same verdict can arrive more than once and // only the first one moves the order. An unknown guid is a 400 — by this point // gogobee has already debited the buyer for an order Pete has no record of, and // under the seam's contract a 400 parks the row for a human rather than retrying // forever against a row that will never exist. func (s *Server) handleMischiefClaim(w http.ResponseWriter, r *http.Request) { if !s.bearerOK(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var v mischiefVerdict if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if v.GUID == "" { http.Error(w, "guid is required", http.StatusBadRequest) return } order, err := storage.ResolveMischiefOrder(v.GUID, v.Status, v.Detail) if errors.Is(err, storage.ErrNoSuchOrder) { slog.Error("mischief: verdict for an order we have never heard of — "+ "gogobee may have debited a buyer for it", "guid", v.GUID, "status", v.Status) http.Error(w, "no such order", http.StatusBadRequest) return } if err != nil { // A malformed verdict status lands here — also a contract mismatch, so 400. slog.Error("mischief: resolve", "guid", v.GUID, "status", v.Status, "err", err) http.Error(w, "bad verdict", http.StatusBadRequest) return } slog.Info("mischief: order resolved", "guid", order.GUID, "status", order.Status) writeJSON(w, order) } func writeMischiefError(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) }