package web import ( "encoding/json" "errors" "io" "log/slog" "net/http" "time" "pete/internal/storage" ) // The action queue's web seam — the first verbs the web can play, as opposed to // the equip queue's dressing-up. // // Two audiences, same shape as equip and mischief. A signed-in owner clicks // "Pull out" on their own adventurer page or "Take your bout" on the war room; // gogobee hits the bearer-authed pair, polling pending orders and pushing a // verdict. Pete runs no game rule: it records that somebody asked, and renders // what gogobee answered. The UI says "asked for" and never claims it landed. // // The character is resolved from the SESSION, never from the request. A session // maps to exactly one localpart and a localpart to exactly one adventurer, so // there is nothing for the client to name and therefore nothing to forge — the // equip queue has to take an item id and a slot off the wire and re-resolve them; // this one has no such surface at all. // advOrderBurstWindow / advOrderBurstMax blunt a stuck mouse button. The real // gates are gogobee's — one extraction ends the run, one bout per day — and the // pending-order guard below stops the common double-click outright. const ( advOrderBurstWindow = time.Hour advOrderBurstMax = 30 ) // advOrderReq is the browser's request. Just the verb: see the file comment on // why nothing identifies the character. type advOrderReq struct { Action string `json:"action"` } // handleAdvOrder places a pending action for the signed-in owner. It asserts what // Pete can honestly know — the viewer is signed in, and gogobee has pushed a // self-detail row for them, which is gogobee's own proof that this person has an // adventurer. Everything about whether the action is legal *right now* is // gogobee's, at verdict time; the pre-checks here only produce a better message // than a verdict thirty seconds later would. func (s *Server) handleAdvOrder(w http.ResponseWriter, r *http.Request) { u := s.requireUser(w, r) if u == nil { return } owner := buyerLocalpart(u) if owner == "" { writeAdvOrderError(w, http.StatusConflict, "please sign in again") return } var req advOrderReq if !decodeStateBody(w, r, &req) { return } switch req.Action { case storage.AdvActionExtract, storage.AdvActionSiegeJoin: default: writeAdvOrderError(w, http.StatusBadRequest, "bad action") return } // Ownership. The self-detail row is gogobee's own owner<->adventurer proof, the // same join the who page's private panels and the alert sender use. No row means // this account has no adventurer — or gogobee has stopped pushing, in which case // an order it can't attribute is not one we should queue. token, ok := storage.SelfToken(owner) if !ok { writeAdvOrderError(w, http.StatusForbidden, "no adventurer on the board for this account") return } // One outstanding order per verb. Two queued extracts would apply in sequence // and the second would answer "no expedition to leave" — a rejection for // something that worked, which is the worst thing this strip could say. if pending, err := storage.HasPendingAdvOrder(u.Sub, req.Action); err != nil { slog.Error("orders: pending lookup", "err", err) writeAdvOrderError(w, http.StatusInternalServerError, "internal error") return } else if pending { writeAdvOrderError(w, http.StatusConflict, "already asked — waiting on the game box") return } since := time.Now().Add(-advOrderBurstWindow).Unix() if n, err := storage.CountAdvOrdersSince(u.Sub, since); err != nil { slog.Error("orders: burst count", "err", err) writeAdvOrderError(w, http.StatusInternalServerError, "internal error") return } else if n >= advOrderBurstMax { writeAdvOrderError(w, http.StatusTooManyRequests, "slow down, too many requests in a short while") return } // Per-verb pre-checks, all courtesy only. Both read Pete's snapshot copy, which // is up to two minutes behind the game box, so neither is authoritative and // neither is allowed to be the last word — a run that ended in that window comes // back from gogobee as rejected_not_running, which is the honest answer. characterName := "" entry, haveEntry, err := storage.RosterEntryByToken(token) if err != nil { slog.Error("orders: roster lookup", "err", err) writeAdvOrderError(w, http.StatusInternalServerError, "internal error") return } if haveEntry { characterName = entry.Name } switch req.Action { case storage.AdvActionExtract: if haveEntry && entry.Status != "expedition" { writeAdvOrderError(w, http.StatusConflict, "you're not on an expedition") return } case storage.AdvActionSiegeJoin: snap, known, err := storage.LoadSiege() if err != nil { slog.Error("orders: siege lookup", "err", err) writeAdvOrderError(w, http.StatusInternalServerError, "internal error") return } if known && !snap.Active { writeAdvOrderError(w, http.StatusConflict, "no Siege is camped outside town") return } } order, err := storage.InsertAdvOrder(u.Sub, owner, token, characterName, req.Action) if err != nil { slog.Error("orders: insert order", "err", err) writeAdvOrderError(w, http.StatusInternalServerError, "internal error") return } slog.Info("orders: action placed", "guid", order.GUID, "owner", owner, "action", req.Action) w.Header().Set("Cache-Control", "no-store") writeJSON(w, order) } // handleAdvOrders returns the signed-in owner's own recent actions for the status // strip, newest first. Scoped to their OIDC subject. func (s *Server) handleAdvOrders(w http.ResponseWriter, r *http.Request) { u := s.requireUser(w, r) if u == nil { return } orders, err := storage.AdvOrdersByOwner(u.Sub, 10) if err != nil { slog.Error("orders: by owner", "err", err) writeAdvOrderError(w, http.StatusInternalServerError, "internal error") return } if orders == nil { orders = []storage.AdvOrder{} } w.Header().Set("Cache-Control", "no-store") writeJSON(w, orders) } // ---- the gogobee wire: bearer-authed, idempotent ------------------------------- // advOrderPollLimit caps one poll, matching the equip and mischief seams. const advOrderPollLimit = 50 // handleAdvOrdersPending is gogobee's poll: every action still waiting. Like the // seams beside it there is no stale-reoffer window — a gogobee that dies mid-apply // leaves the order pending to be offered again, and its guid ledger makes the // replay a no-op. func (s *Server) handleAdvOrdersPending(w http.ResponseWriter, r *http.Request) { if !s.bearerOK(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } orders, err := storage.PendingAdvOrders(advOrderPollLimit) if err != nil { slog.Error("orders: pending", "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if orders == nil { orders = []storage.AdvOrder{} } writeJSON(w, orders) } // advOrderVerdict is gogobee's answer on an order: the terminal status and a // human note to render. type advOrderVerdict struct { GUID string `json:"guid"` Status string `json:"status"` Detail string `json:"detail,omitempty"` } // handleAdvOrderVerdict 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 // this seam's contract that parks the row for a human rather than retrying // forever against a row that will never exist. func (s *Server) handleAdvOrderVerdict(w http.ResponseWriter, r *http.Request) { if !s.bearerOK(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var v advOrderVerdict 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.ResolveAdvOrder(v.GUID, v.Status, v.Detail) if errors.Is(err, storage.ErrNoSuchAdvOrder) { slog.Error("orders: 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("orders: resolve", "guid", v.GUID, "status", v.Status, "err", err) http.Error(w, "bad verdict", http.StatusBadRequest) return } slog.Info("orders: action resolved", "guid", order.GUID, "action", order.Action, "status", order.Status) writeJSON(w, order) } func writeAdvOrderError(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}) }