The web half of Mischief Makers M3. A signed-in buyer picks a mark off the anonymous roster board and pays for a monster to find them; gogobee does the real work and hands back a verdict Pete files against the order. - mischief_orders: intent in, verdict out, idempotent on a guid that is the end-to-end key gogobee passes to DebitIdem and stamps on the contract - user_euro + mischief_tiers: advisory balance and the live price list, pushed on the roster tick so the storefront never hardcodes a number that can drift - OIDC-gated buy API (target + tier + signed), bearer-authed poll/claim wire - roster board grows a 'send trouble' button, a tier picker, and a status panel Pete never touches money and never runs a game rule. It records what a buyer wants and what gogobee said happened.
251 lines
8.9 KiB
Go
251 lines
8.9 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// 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.
|
|
if u.Username == "" {
|
|
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, u.Username, 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", u.Username, "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 u.Username != "" {
|
|
euro, has, err := storage.UserEuro(u.Username)
|
|
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})
|
|
}
|