package web import ( "encoding/json" "errors" "io" "log/slog" "net/http" "time" "pete/internal/storage" ) // The equip queue's web seam. // // Two audiences, same shape as mischief. A signed-in owner, on their own detail // page, clicks Equip or Unequip; the OIDC-gated buy half records the intent after // proving they own the page and the item. gogobee hits the bearer-authed pair: it // polls pending orders and pushes a verdict. Pete never runs an equip rule — it // records intent and files the verdict; the item actually moves on the game box, // on gogobee's next poll tick. The UI says "queued" and never claims it landed. // equipBurstWindow / equipBurstMax are Pete's own anti-spam guard, nothing more. // The real eligibility — still-owned, wearable, the 3-bond cap — is gogobee's, at // verdict time. This only stops a stuck mouse button from spooling the table. const ( equipBurstWindow = time.Hour equipBurstMax = 40 ) // equipOrderReq is the browser's request. The owner names the page they're on // (proving ownership), what they're doing, and the item — by its inventory row id // for an equip, or by slot for an unequip. Pete resolves the display facts itself // from the owner's own detail, never trusting the client for name or slot. type equipOrderReq struct { Token string `json:"token"` Action string `json:"action"` ItemID int64 `json:"item_id"` Slot string `json:"slot"` Tier int `json:"tier"` // upgrade only: the target standard tier; verified against the pushed slot view } // handleEquipOrder places a pending equip/unequip for the signed-in owner. It // asserts what Pete can honestly know: the viewer is signed in, owns this exact // page (proven by a row gogobee pushed, never by the token alone), and the item // is actually in the panel they claim. Bond caps and the rest of the rulebook are // gogobee's, checked when it drains the order. func (s *Server) handleEquipOrder(w http.ResponseWriter, r *http.Request) { u := s.requireUser(w, r) if u == nil { return } owner := buyerLocalpart(u) if owner == "" { writeEquipError(w, http.StatusConflict, "please sign in again") return } var req equipOrderReq if !decodeStateBody(w, r, &req) { return } if req.Token == "" { writeEquipError(w, http.StatusBadRequest, "no character") return } switch req.Action { case storage.EquipActionEquip, storage.EquipActionUnequip, storage.EquipActionUpgrade, storage.EquipActionRepair: default: writeEquipError(w, http.StatusBadRequest, "bad action") return } // Ownership: only the localpart that owns this exact page token may dress it. // The detail row is gogobee's own proof of owner<->page; a token alone proves // nothing. pd, ok, err := storage.PlayerDetailByOwner(owner, req.Token) if err != nil { slog.Error("equip: owner lookup", "err", err) writeEquipError(w, http.StatusInternalServerError, "internal error") return } if !ok { writeEquipError(w, http.StatusForbidden, "that's not your adventurer") return } // Resolve every fact of the order from the owner's own pushed detail — never // from the client — so a forged name, slot, tier, or price can't ride in. An // equip names a backpack item by its row id; an unequip/take-off names a worn // slot (a magic DnD slot in Equipped, or a masterwork/arena standard slot in // Slots); upgrade and repair name a standard slot in Slots and move money, so // the target tier is trusted only when it matches the slot's pushed NextTier. var ( itemName string slot string itemID int64 tier int ) switch req.Action { case storage.EquipActionEquip: it, found := findBackpackItem(pd.Inventory, req.ItemID) if !found { writeEquipError(w, http.StatusBadRequest, "that item isn't in your pack") return } itemName, slot, itemID = it.Name, it.Slot, req.ItemID case storage.EquipActionUnequip: // Magic take-off keys on a DnD slot in Equipped; masterwork/arena take-off // keys on a standard slot in Slots (CanTakeOff). The vocabularies are disjoint, // so try each — gogobee disambiguates the same way. A plain shop-tier slot has // nothing to round-trip, so it never resolves here (revert is an upgrade path). if it, found := findWornSlot(pd.Equipped, req.Slot); found { itemName, slot = it.Name, it.Slot } else if sv, found := findSlotView(pd.Slots, req.Slot); found && sv.CanTakeOff { itemName, slot = sv.Name, sv.Slot } else { writeEquipError(w, http.StatusBadRequest, "nothing to take off there") return } case storage.EquipActionUpgrade: sv, found := findSlotView(pd.Slots, req.Slot) if !found || sv.NextTier == 0 { writeEquipError(w, http.StatusBadRequest, "no upgrade available for that slot") return } if req.Tier != sv.NextTier { // The web offers the next tier only; a request for anything else is a stale // page or a forged jump. Refuse rather than debit for a tier the owner never // saw priced. writeEquipError(w, http.StatusConflict, "that upgrade is out of date, reload the page") return } itemName, slot, tier = sv.NextName, sv.Slot, sv.NextTier case storage.EquipActionRepair: sv, found := findSlotView(pd.Slots, req.Slot) if !found || sv.RepairCost == 0 { writeEquipError(w, http.StatusBadRequest, "nothing to repair there") return } itemName, slot = sv.Name, sv.Slot } since := time.Now().Add(-equipBurstWindow).Unix() if n, err := storage.CountEquipOrdersSince(u.Sub, since); err != nil { slog.Error("equip: burst count", "err", err) writeEquipError(w, http.StatusInternalServerError, "internal error") return } else if n >= equipBurstMax { writeEquipError(w, http.StatusTooManyRequests, "slow down, too many changes in a short while") return } characterName := "" if entry, ok, err := storage.RosterEntryByToken(req.Token); err == nil && ok { characterName = entry.Name } order, err := storage.InsertEquipOrder(u.Sub, owner, characterName, itemID, itemName, slot, req.Action, tier) if err != nil { slog.Error("equip: insert order", "err", err) writeEquipError(w, http.StatusInternalServerError, "internal error") return } slog.Info("equip: order placed", "guid", order.GUID, "owner", owner, "action", req.Action, "slot", slot) w.Header().Set("Cache-Control", "no-store") writeJSON(w, order) } // findBackpackItem finds a wearable backpack item by its row id. Only magic items // carry a non-zero ID, so a zero id can never match — the id both names the item // and gates the action to the magic-item equip path. func findBackpackItem(items []storage.ItemView, id int64) (storage.ItemView, bool) { if id == 0 { return storage.ItemView{}, false } for _, it := range items { if it.ID == id { return it, true } } return storage.ItemView{}, false } // findWornSlot finds a worn item by the slot it fills. func findWornSlot(items []storage.ItemView, slot string) (storage.ItemView, bool) { if slot == "" { return storage.ItemView{}, false } for _, it := range items { if it.Slot == slot { return it, true } } return storage.ItemView{}, false } // findSlotView finds one of the 5 standard equipment slots by name. It is the // server-side source of truth for a take-off / upgrade / repair: the request // names a slot, and every other fact (name, next tier, price, repair cost) is // read from here rather than trusted from the client. func findSlotView(slots []storage.EquipSlotView, slot string) (storage.EquipSlotView, bool) { if slot == "" { return storage.EquipSlotView{}, false } for _, sv := range slots { if sv.Slot == slot { return sv, true } } return storage.EquipSlotView{}, false } // handleEquipOrders returns the signed-in owner's own recent equip orders for the // status strip, newest first. Scoped to their OIDC subject. func (s *Server) handleEquipOrders(w http.ResponseWriter, r *http.Request) { u := s.requireUser(w, r) if u == nil { return } orders, err := storage.EquipOrdersByOwner(u.Sub, 20) if err != nil { slog.Error("equip: orders by owner", "err", err) writeEquipError(w, http.StatusInternalServerError, "internal error") return } if orders == nil { orders = []storage.EquipOrder{} } w.Header().Set("Cache-Control", "no-store") writeJSON(w, orders) } // ---- the gogobee wire: bearer-authed, idempotent ------------------------------- // equipPollLimit caps one poll, matching the mischief seam. const equipPollLimit = 50 // handleEquipPending is gogobee's poll: every order still waiting. Like mischief // there is no stale-reoffer window — a gogobee that dies mid-apply leaves the order // pending to be offered again, and gogobee's guid guard makes the replay a no-op. func (s *Server) handleEquipPending(w http.ResponseWriter, r *http.Request) { if !s.bearerOK(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } orders, err := storage.PendingEquipOrders(equipPollLimit) if err != nil { slog.Error("equip: pending", "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if orders == nil { orders = []storage.EquipOrder{} } writeJSON(w, orders) } // equipVerdict is gogobee's answer on an order: the terminal status and a human // note to render. type equipVerdict struct { GUID string `json:"guid"` Status string `json:"status"` Detail string `json:"detail,omitempty"` } // handleEquipVerdict 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 moves the order. An unknown guid is a 400 — under the seam's // contract that parks the row for a human rather than retrying forever against a // row that will never exist. func (s *Server) handleEquipVerdict(w http.ResponseWriter, r *http.Request) { if !s.bearerOK(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var v equipVerdict 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.ResolveEquipOrder(v.GUID, v.Status, v.Detail) if errors.Is(err, storage.ErrNoSuchEquipOrder) { slog.Error("equip: verdict for an order we've never heard of", "guid", v.GUID, "status", v.Status) http.Error(w, "no such order", http.StatusBadRequest) return } if err != nil { slog.Error("equip: resolve", "guid", v.GUID, "status", v.Status, "err", err) http.Error(w, "bad verdict", http.StatusBadRequest) return } slog.Info("equip: order resolved", "guid", order.GUID, "status", order.Status) writeJSON(w, order) } func writeEquipError(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}) }