Each roster name becomes /adventure/who/{token}. Anyone sees the public sheet —
stats and equipped gear, decoded from the detail_json gogobee now hangs on each
board entry — with a live JSON re-poll so an open tab tracks HP and room as they
move. The signed-in owner sees the same page enriched with their private
inventory, vault, house, and pets, unlocked by an ownership join in the new
player_self_detail table (localpart owns token) — Pete never reverses the
anonymous token to decide it. buyerLocalpart is extracted so the storefront and
the ownership check lowercase the session name the same way.
263 lines
9.4 KiB
Go
263 lines
9.4 KiB
Go
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})
|
|
}
|