Files
Pete/internal/web/orders.go
T
prosolis 556b9440b8 adventure: don't strand an order on a database blip, don't misroute a tap
Two things a code review turned up in the W9 seams.

The verdict handler answered 400 for everything ResolveAdvOrder could fail
with, not just a bad verdict. gogobee's contract says a 400 means "park this
row for a human", so a SQLite busy or a disk hiccup permanently stranded an
extract or a bout that was perfectly resolvable. Split the two apart with
ErrBadAdvVerdict: a verdict outside the terminal set is still 400, because
gogobee will never send it successfully, and a genuine storage failure is now
500 and comes back on the next poll.

The push URL builders concatenated the guid and the run id raw, while every
other builder beside them path-escapes because these values arrive over a wire.
A guid carrying a slash sent the notification tap to a different page.
2026-07-24 22:36:35 -07:00

361 lines
14 KiB
Go

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: the verb, and for the three verbs that
// take arguments, which zone / which loadout / how many days. See the file
// comment on why nothing here identifies the character.
//
// None of these fields is trusted. Each is looked up in the owner's OWN offer
// list — the one gogobee pushed onto their private self-detail row — and the
// order stores what was found there, not what was sent. So a forged zone id
// resolves to nothing and is refused before an order exists.
type advOrderReq struct {
Action string `json:"action"`
Zone string `json:"zone,omitempty"`
Loadout string `json:"loadout,omitempty"`
Days int `json:"days,omitempty"`
}
// 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,
storage.AdvActionExpedition, storage.AdvActionResume, storage.AdvActionBabysit,
storage.AdvActionAbandon, storage.AdvActionLeave, storage.AdvActionBabysitCancel:
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
}
}
// Resolve the verb's arguments against this owner's own offers. Everything
// this returns came out of gogobee's push, so the stored order can only ever
// name a zone, a loadout and a price the game itself quoted to this player.
params, msg := resolveAdvOrderParams(owner, token, req)
if msg != "" {
writeAdvOrderError(w, http.StatusConflict, msg)
return
}
order, err := storage.InsertAdvOrder(u.Sub, owner, token, characterName, req.Action, params)
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)
}
// resolveAdvOrderParams turns the browser's arguments into the stored ones by
// looking each up in the owner's pushed offer list, and returns the reason to
// refuse when it cannot. Five of the eight verbs take no arguments and resolve to
// nil — but babysit_cancel still comes through here, because the offer row is
// the one place Pete can see that there is no sitter to dismiss.
//
// Note what W5a's "Pete has never heard about it" asymmetry does NOT need to
// become here. There is no such state to defer on: the detail row this reads is
// the same row SelfToken already found, so by the time we get here it exists.
// What a gogobee too old to push offers produces is an EMPTY offer list, and
// then the page renders no picker at all — so there is no dead button to protect
// against, only forged arguments to refuse.
func resolveAdvOrderParams(owner, token string, req advOrderReq) (*storage.AdvOrderParams, string) {
switch req.Action {
case storage.AdvActionExtract, storage.AdvActionSiegeJoin:
return nil, ""
case storage.AdvActionAbandon, storage.AdvActionLeave:
// No snapshot pre-check for either, deliberately, and it is the same call
// W5a made for extract: the board is up to two minutes stale, so the only
// thing Pete could test — "the mark reads idle" — would refuse actions the
// game would have allowed. Abandon is worse than extract in that respect,
// because an *extracted* expedition is still abandonable while its owner
// reads as standing in town. gogobee answers rejected_not_running /
// rejected_not_leader / rejected_is_leader, and the strip shows it.
return nil, ""
}
detail, haveDetail, err := storage.PlayerDetailByOwner(owner, token)
if err != nil {
slog.Error("orders: detail lookup", "err", err)
return nil, "couldn't read your adventurer just now"
}
if !haveDetail {
// Only reachable if the row went away between SelfToken and here — the
// roster push replaces the whole table. Refuse rather than guess.
return nil, "couldn't read your adventurer just now"
}
switch req.Action {
case storage.AdvActionExpedition:
if req.Zone == "" {
return nil, "pick somewhere to go first"
}
if len(detail.Zones) == 0 {
return nil, "you're already out there"
}
for _, z := range detail.Zones {
if z.ID != req.Zone {
continue
}
for _, l := range z.Loadouts {
if l.Key == req.Loadout {
return &storage.AdvOrderParams{Zone: z.ID, Loadout: l.Key}, ""
}
}
return nil, "that isn't a loadout for that zone"
}
return nil, "that zone isn't open to you"
case storage.AdvActionResume:
if detail.Resume == nil {
return nil, "there's no expedition waiting for you"
}
for _, l := range detail.Resume.Loadouts {
if l.Key == req.Loadout {
return &storage.AdvOrderParams{Loadout: l.Key}, ""
}
}
return nil, "that isn't a loadout for that zone"
case storage.AdvActionBabysit:
if req.Days != 7 && req.Days != 30 {
return nil, "the sitter works by the week or by the month"
}
if detail.Babysit != nil && detail.Babysit.Active {
return nil, "a sitter is already looking after your camp"
}
return &storage.AdvOrderParams{Days: req.Days}, ""
case storage.AdvActionBabysitCancel:
// The mirror of the check above, and the one W9 verb where the snapshot
// really does contradict the request: a sitter's engagement is a fact about
// the character, not about where they are standing, so it does not go stale
// the way "on an expedition" does. A missing offer is still not a refusal —
// that is a gogobee too old to push one, not a player without a sitter.
if detail.Babysit != nil && !detail.Babysit.Active {
return nil, "there's no sitter to dismiss"
}
return nil, ""
}
return nil, "bad action"
}
// 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 errors.Is(err, storage.ErrBadAdvVerdict) {
slog.Error("orders: verdict outside the terminal set", "guid", v.GUID, "status", v.Status)
http.Error(w, "bad verdict", http.StatusBadRequest)
return
}
if err != nil {
// A storage failure, not a bad request. 400 here would park a perfectly
// resolvable order forever on a transient database error; 500 gets it
// retried on gogobee's next poll.
slog.Error("orders: resolve", "guid", v.GUID, "status", v.Status, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
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})
}