diff --git a/internal/storage/mischief.go b/internal/storage/mischief.go new file mode 100644 index 0000000..efd14ae --- /dev/null +++ b/internal/storage/mischief.go @@ -0,0 +1,336 @@ +package storage + +import ( + "database/sql" + "errors" + "fmt" +) + +// The mischief storefront's half of the euro border. +// +// A buyer signs in, picks a mark off the anonymous roster board, and places a +// hit. Pete records only the *intent*: it never moves money and never runs a +// single game rule. gogobee's poll loop reads the pending orders, does the real +// work against its own ledger (debit, eligibility, open the contract), and hands +// back a verdict Pete files against the order. The guid is the idempotency key +// end to end — gogobee passes it to DebitIdem and stamps it on the contract — so +// a verdict whose ack is lost on the wire can be retried without the buyer paying +// twice or the mark catching two hits for one order. + +// MischiefOrder is one storefront order and its current standing. +type MischiefOrder struct { + GUID string `json:"guid"` + BuyerSub string `json:"-"` // OIDC subject; keys "my orders", never sent to gogobee + BuyerUsername string `json:"buyer_username"` // localpart gogobee turns into @username:server + TargetToken string `json:"target_token"` + TargetName string `json:"target_name"` + Tier string `json:"tier"` + Signed bool `json:"signed"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at,omitempty"` +} + +// Order states. These strings cross the wire to gogobee (it POSTs the verdict), +// so they are part of the contract — see the schema and gogobee_mischief_plan.md. +const ( + MischiefPending = "pending" // placed; gogobee hasn't acted yet + MischiefPlaced = "placed" // gogobee debited the buyer and opened a contract + MischiefBouncedFunds = "bounced_funds" // buyer couldn't afford it after all + MischiefBouncedIneligible = "bounced_ineligible" // target no longer a valid mark +) + +// validMischiefVerdict is the set of terminal states gogobee is allowed to hand +// back. An unknown verdict is a contract mismatch, not something to file blindly. +func validMischiefVerdict(status string) bool { + switch status { + case MischiefPlaced, MischiefBouncedFunds, MischiefBouncedIneligible: + return true + } + return false +} + +var ErrNoSuchOrder = errors.New("mischief: no such order") + +// InsertMischiefOrder records a fresh, pending order and returns it with a new +// guid. Money and eligibility are gogobee's problem; all Pete asserts here is +// that the buyer is signed in and the form was well formed (checked by the +// caller). The guid is minted here so the buyer sees a stable reference the +// instant they submit, before gogobee has ever heard of it. +func InsertMischiefOrder(buyerSub, buyerUsername, targetToken, targetName, tier string, signed bool) (MischiefOrder, error) { + guid, err := newGUID() + if err != nil { + return MischiefOrder{}, err + } + now := nowUnix() + sig := 0 + if signed { + sig = 1 + } + if _, err := Get().Exec( + `INSERT INTO mischief_orders + (guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + guid, buyerSub, buyerUsername, targetToken, targetName, tier, sig, MischiefPending, now, now, + ); err != nil { + return MischiefOrder{}, fmt.Errorf("mischief: insert order: %w", err) + } + return MischiefOrder{ + GUID: guid, BuyerSub: buyerSub, BuyerUsername: buyerUsername, + TargetToken: targetToken, TargetName: targetName, Tier: tier, + Signed: signed, Status: MischiefPending, CreatedAt: now, UpdatedAt: now, + }, nil +} + +// PendingMischiefOrders is what gogobee's poll loop reads: every order still +// waiting to be acted on. There is no claimed-but-stale window like escrow has, +// because a pending order carries no intermediate state — if gogobee dies partway +// through, the order simply stays pending and is offered again next poll, and the +// guid makes the replay a no-op. +func PendingMischiefOrders(limit int) ([]MischiefOrder, error) { + if limit <= 0 { + limit = 100 + } + rows, err := Get().Query( + `SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at + FROM mischief_orders + WHERE status = ? + ORDER BY created_at + LIMIT ?`, + MischiefPending, limit, + ) + if err != nil { + return nil, fmt.Errorf("mischief: pending orders: %w", err) + } + defer rows.Close() + return scanMischiefOrders(rows) +} + +// ResolveMischiefOrder files gogobee's verdict against a pending order. It is +// idempotent: gogobee's poll loop can re-offer and re-resolve the same order, so +// a verdict that arrives twice is a no-op the second time. Only a pending order +// moves; an order already in a terminal state reports what it already decided, +// which is what stops a retried verdict from overwriting the first one. +func ResolveMischiefOrder(guid, status, detail string) (MischiefOrder, error) { + if !validMischiefVerdict(status) { + return MischiefOrder{}, fmt.Errorf("mischief: bad verdict %q", status) + } + now := nowUnix() + res, err := Get().Exec( + `UPDATE mischief_orders SET status = ?, detail = ?, updated_at = ? + WHERE guid = ? AND status = ?`, + status, detail, now, guid, MischiefPending, + ) + if err != nil { + return MischiefOrder{}, fmt.Errorf("mischief: resolve order: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + // Either it doesn't exist or it's already terminal. Tell the caller which. + return MischiefOrderByGUID(guid) + } + return MischiefOrderByGUID(guid) +} + +// MischiefOrderByGUID reads one order. +func MischiefOrderByGUID(guid string) (MischiefOrder, error) { + rows, err := Get().Query( + `SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at + FROM mischief_orders WHERE guid = ?`, guid, + ) + if err != nil { + return MischiefOrder{}, fmt.Errorf("mischief: read order: %w", err) + } + defer rows.Close() + out, err := scanMischiefOrders(rows) + if err != nil { + return MischiefOrder{}, err + } + if len(out) == 0 { + return MischiefOrder{}, ErrNoSuchOrder + } + return out[0], nil +} + +// MischiefOrdersByBuyer returns a buyer's own recent orders, newest first, for +// the storefront status panel. Keyed on the OIDC subject so a username change +// doesn't strand a buyer's history. +func MischiefOrdersByBuyer(buyerSub string, limit int) ([]MischiefOrder, error) { + if limit <= 0 { + limit = 20 + } + rows, err := Get().Query( + `SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at + FROM mischief_orders + WHERE buyer_sub = ? + ORDER BY created_at DESC + LIMIT ?`, + buyerSub, limit, + ) + if err != nil { + return nil, fmt.Errorf("mischief: orders by buyer: %w", err) + } + defer rows.Close() + return scanMischiefOrders(rows) +} + +// CountMischiefOrdersSince counts how many orders a buyer has placed since a unix +// cutoff. It backs the storefront's burst guard — the real economic caps (per +// day, per target, boss weekly) live on gogobee, this only blunts form-spam. +func CountMischiefOrdersSince(buyerSub string, since int64) (int, error) { + var n int + err := Get().QueryRow( + `SELECT COUNT(*) FROM mischief_orders WHERE buyer_sub = ? AND created_at >= ?`, + buyerSub, since, + ).Scan(&n) + if err != nil { + return 0, fmt.Errorf("mischief: count recent orders: %w", err) + } + return n, nil +} + +func scanMischiefOrders(rows *sql.Rows) ([]MischiefOrder, error) { + var out []MischiefOrder + for rows.Next() { + var o MischiefOrder + var sig int + if err := rows.Scan(&o.GUID, &o.BuyerSub, &o.BuyerUsername, &o.TargetToken, + &o.TargetName, &o.Tier, &sig, &o.Status, &o.Detail, &o.CreatedAt, &o.UpdatedAt); err != nil { + return nil, fmt.Errorf("mischief: scan order: %w", err) + } + o.Signed = sig != 0 + out = append(out, o) + } + return out, rows.Err() +} + +// ---- user_euro: the buyer's own advisory balance ------------------------------- + +// ReplaceUserEuro swaps the whole balance table for a new snapshot, in one +// transaction, mirroring the roster: a buyer gogobee stopped reporting (opted +// out, deleted character) must drop out rather than keep a frozen number forever. +// Replace — never merge. +func ReplaceUserEuro(balances []MischiefBalance, snapshotAt int64) error { + tx, err := Get().Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec(`DELETE FROM user_euro`); err != nil { + return err + } + stmt, err := tx.Prepare(`INSERT INTO user_euro (username, euro, snapshot_at) VALUES (?, ?, ?)`) + if err != nil { + return err + } + defer stmt.Close() + for _, b := range balances { + if b.Username == "" { + continue + } + if _, err := stmt.Exec(b.Username, b.Euro, snapshotAt); err != nil { + return err + } + } + return tx.Commit() +} + +// MischiefBalance is one buyer's advisory euro balance in the roster push. +type MischiefBalance struct { + Username string `json:"username"` + Euro float64 `json:"euro"` +} + +// ---- mischief_tiers: the price catalog, pushed by gogobee ----------------------- + +// MischiefTier is one rung of the storefront's price list. gogobee is the sole +// authority on prices — it pushes the whole catalog on the roster tick so a fee +// retune reaches the storefront within a snapshot and Pete never hardcodes a +// number that can silently drift. Pete uses it only to render and to validate a +// submitted tier key; the real debit is always gogobee's, at its own price. +type MischiefTier struct { + Key string `json:"key"` + Display string `json:"display"` + Fee int `json:"fee"` + SignedFee int `json:"signed_fee"` + Blurb string `json:"blurb,omitempty"` +} + +// ReplaceMischiefTiers swaps the whole catalog for gogobee's latest, preserving +// the push order (grunt→boss) via an ordinal column. Replace, never merge — a +// tier gogobee dropped must disappear from the storefront. +func ReplaceMischiefTiers(tiers []MischiefTier) error { + tx, err := Get().Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec(`DELETE FROM mischief_tiers`); err != nil { + return err + } + stmt, err := tx.Prepare(`INSERT INTO mischief_tiers (key, display, fee, signed_fee, blurb, ordinal) VALUES (?, ?, ?, ?, ?, ?)`) + if err != nil { + return err + } + defer stmt.Close() + for i, t := range tiers { + if t.Key == "" { + continue + } + if _, err := stmt.Exec(t.Key, t.Display, t.Fee, t.SignedFee, t.Blurb, i); err != nil { + return err + } + } + return tx.Commit() +} + +// MischiefTiers returns the catalog in push order. Empty (not an error) until +// gogobee has pushed one — the storefront treats that as "catalog not ready yet". +func MischiefTiers() ([]MischiefTier, error) { + rows, err := Get().Query( + `SELECT key, display, fee, signed_fee, COALESCE(blurb, '') FROM mischief_tiers ORDER BY ordinal`) + if err != nil { + return nil, fmt.Errorf("mischief: read tiers: %w", err) + } + defer rows.Close() + var out []MischiefTier + for rows.Next() { + var t MischiefTier + if err := rows.Scan(&t.Key, &t.Display, &t.Fee, &t.SignedFee, &t.Blurb); err != nil { + return nil, fmt.Errorf("mischief: scan tier: %w", err) + } + out = append(out, t) + } + return out, rows.Err() +} + +// MischiefTierByKey looks up one tier for validating a submitted order. +func MischiefTierByKey(key string) (MischiefTier, bool, error) { + tiers, err := MischiefTiers() + if err != nil { + return MischiefTier{}, false, err + } + for _, t := range tiers { + if t.Key == key { + return t, true, nil + } + } + return MischiefTier{}, false, nil +} + +// UserEuro reads one buyer's advisory balance. The bool is false when gogobee has +// never reported a balance for them (never played, opted out) — the storefront +// then shows tiers without an affordability hint rather than a misleading €0. +func UserEuro(username string) (float64, bool, error) { + var euro float64 + err := Get().QueryRow(`SELECT euro FROM user_euro WHERE username = ?`, username).Scan(&euro) + if errors.Is(err, sql.ErrNoRows) { + return 0, false, nil + } + if err != nil { + return 0, false, fmt.Errorf("mischief: read user euro: %w", err) + } + return euro, true, nil +} diff --git a/internal/storage/mischief_test.go b/internal/storage/mischief_test.go new file mode 100644 index 0000000..cb723c8 --- /dev/null +++ b/internal/storage/mischief_test.go @@ -0,0 +1,178 @@ +package storage + +import ( + "errors" + "testing" + "time" +) + +func TestMischiefOrderLifecycle(t *testing.T) { + setupTestDB(t) + + o, err := InsertMischiefOrder("sub-1", "reala", "tok-josie", "Josie", "elite", true) + if err != nil { + t.Fatal(err) + } + if o.Status != MischiefPending { + t.Fatalf("fresh order status = %q, want pending", o.Status) + } + if !o.Signed { + t.Error("signed flag lost through insert") + } + + pending, err := PendingMischiefOrders(10) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 || pending[0].GUID != o.GUID { + t.Fatalf("pending = %+v, want the one order we just placed", pending) + } + + // gogobee places the contract. + got, err := ResolveMischiefOrder(o.GUID, MischiefPlaced, "the word is out") + if err != nil { + t.Fatal(err) + } + if got.Status != MischiefPlaced || got.Detail != "the word is out" { + t.Fatalf("resolved order = %+v, want placed with detail", got) + } + + // It must leave the pending set. + if pending, _ := PendingMischiefOrders(10); len(pending) != 0 { + t.Fatalf("placed order still pending: %+v", pending) + } +} + +// TestMischiefResolveIsIdempotent is the whole reason the guid is an end-to-end +// key: gogobee's poll loop retries, so a verdict can arrive twice, and the second +// arrival must not overwrite the first or error. +func TestMischiefResolveIsIdempotent(t *testing.T) { + setupTestDB(t) + + o, err := InsertMischiefOrder("sub-1", "reala", "tok", "Josie", "grunt", false) + if err != nil { + t.Fatal(err) + } + if _, err := ResolveMischiefOrder(o.GUID, MischiefPlaced, "first"); err != nil { + t.Fatal(err) + } + // A second, *different* verdict arrives. First one wins. + got, err := ResolveMischiefOrder(o.GUID, MischiefBouncedFunds, "second") + if err != nil { + t.Fatalf("re-resolve errored: %v", err) + } + if got.Status != MischiefPlaced || got.Detail != "first" { + t.Fatalf("idempotency broken: order became %+v", got) + } +} + +func TestMischiefResolveUnknownAndBadVerdict(t *testing.T) { + setupTestDB(t) + + if _, err := ResolveMischiefOrder("nope", MischiefPlaced, ""); !errors.Is(err, ErrNoSuchOrder) { + t.Fatalf("unknown guid err = %v, want ErrNoSuchOrder", err) + } + + o, _ := InsertMischiefOrder("sub-1", "reala", "tok", "Josie", "grunt", false) + if _, err := ResolveMischiefOrder(o.GUID, "exploded", ""); err == nil { + t.Error("a bogus verdict status was accepted") + } + // The order must survive a rejected verdict as still-pending. + if got, _ := MischiefOrderByGUID(o.GUID); got.Status != MischiefPending { + t.Fatalf("order moved off pending on a bad verdict: %q", got.Status) + } +} + +func TestMischiefOrdersByBuyerAndCount(t *testing.T) { + setupTestDB(t) + + for i := 0; i < 3; i++ { + if _, err := InsertMischiefOrder("sub-A", "alice", "tok", "Josie", "grunt", false); err != nil { + t.Fatal(err) + } + } + if _, err := InsertMischiefOrder("sub-B", "bob", "tok", "Josie", "grunt", false); err != nil { + t.Fatal(err) + } + + mine, err := MischiefOrdersByBuyer("sub-A", 20) + if err != nil { + t.Fatal(err) + } + if len(mine) != 3 { + t.Fatalf("alice sees %d orders, want 3 (and none of bob's)", len(mine)) + } + + n, err := CountMischiefOrdersSince("sub-A", time.Now().Add(-time.Hour).Unix()) + if err != nil { + t.Fatal(err) + } + if n != 3 { + t.Fatalf("count since an hour ago = %d, want 3", n) + } + if n, _ := CountMischiefOrdersSince("sub-A", time.Now().Add(time.Hour).Unix()); n != 0 { + t.Fatalf("count since the future = %d, want 0", n) + } +} + +func TestMischiefTiersReplaceAndLookup(t *testing.T) { + setupTestDB(t) + + tiers := []MischiefTier{ + {Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50, Blurb: "theatre"}, + {Key: "boss", Display: "Boss", Fee: 1200, SignedFee: 1500}, + } + if err := ReplaceMischiefTiers(tiers); err != nil { + t.Fatal(err) + } + + got, err := MischiefTiers() + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Key != "grunt" || got[1].Key != "boss" { + t.Fatalf("catalog order not preserved: %+v", got) + } + + tier, ok, err := MischiefTierByKey("boss") + if err != nil || !ok || tier.SignedFee != 1500 { + t.Fatalf("lookup boss = %+v ok=%v err=%v", tier, ok, err) + } + if _, ok, _ := MischiefTierByKey("dragon"); ok { + t.Error("lookup invented a tier that was never pushed") + } + + // Replace, never merge: a dropped tier vanishes. + if err := ReplaceMischiefTiers([]MischiefTier{{Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50}}); err != nil { + t.Fatal(err) + } + if _, ok, _ := MischiefTierByKey("boss"); ok { + t.Error("a tier dropped from the push survived the replace") + } +} + +func TestUserEuroReplaceAndRead(t *testing.T) { + setupTestDB(t) + + if err := ReplaceUserEuro([]MischiefBalance{{Username: "reala", Euro: 820.5}}, time.Now().Unix()); err != nil { + t.Fatal(err) + } + euro, has, err := UserEuro("reala") + if err != nil || !has || euro != 820.5 { + t.Fatalf("UserEuro(reala) = %v has=%v err=%v", euro, has, err) + } + + // A user gogobee has never reported reads as "unknown", not €0 — the + // storefront shows no hint rather than a discouraging, wrong zero. + if _, has, _ := UserEuro("stranger"); has { + t.Error("UserEuro claimed to know a stranger's balance") + } + + // Replace drops anyone the new snapshot omits. + if err := ReplaceUserEuro([]MischiefBalance{{Username: "bob", Euro: 10}}, time.Now().Unix()); err != nil { + t.Fatal(err) + } + if _, has, _ := UserEuro("reala"); has { + t.Error("a balance that fell out of the snapshot survived the replace") + } +} diff --git a/internal/storage/roster.go b/internal/storage/roster.go index db80dfb..2d15ec1 100644 --- a/internal/storage/roster.go +++ b/internal/storage/roster.go @@ -91,6 +91,27 @@ func LoadRoster() ([]RosterEntry, int64, error) { return out, RosterSnapshotAt(), nil } +// RosterEntryByToken looks up one adventurer by their roster token. The bool is +// false when no such token is on the current board — which is exactly the check +// the storefront needs: a buyer may only order a hit on a mark the live board is +// actually showing, never a stale or guessed token. +func RosterEntryByToken(token string) (RosterEntry, bool, error) { + var e RosterEntry + err := Get().QueryRow(` + SELECT token, name, level, COALESCE(class_race, ''), status, + COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at + FROM adventure_roster WHERE token = ?`, token).Scan( + &e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status, + &e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt) + if err == sql.ErrNoRows { + return RosterEntry{}, false, nil + } + if err != nil { + return RosterEntry{}, false, err + } + return e, true, nil +} + // RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has // never pushed one. Read from the meta row, not the entries, so a snapshot that // legitimately carried nobody still counts as a snapshot. diff --git a/internal/storage/schema.go b/internal/storage/schema.go index 6d9a3dc..82274f5 100644 --- a/internal/storage/schema.go +++ b/internal/storage/schema.go @@ -52,6 +52,65 @@ CREATE TABLE IF NOT EXISTS adventure_roster_meta ( snapshot_at INTEGER NOT NULL DEFAULT 0 ); +-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed. +-- Keyed by localpart (== Authentik preferred_username == the session's Username), +-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only +-- ever read for the one authenticated user asking about themselves, so the board +-- stays anonymous and no endpoint hands out anyone else's number. Advisory only — +-- the storefront greys out tiers it thinks you can't afford, but the real debit +-- happens on gogobee at claim time and a stale balance just bounces an order. +CREATE TABLE IF NOT EXISTS user_euro ( + username TEXT PRIMARY KEY, -- Matrix localpart == session Username + euro REAL NOT NULL DEFAULT 0, + snapshot_at INTEGER NOT NULL DEFAULT 0 +); + +-- A mischief contract a buyer placed from the web storefront, on its way to +-- gogobee. Pete never touches money and never runs the game rules — it only +-- records the *intent* and later the verdict gogobee hands back. The status +-- ladder: +-- +-- pending -> placed (gogobee debited the buyer and opened a contract) +-- -> bounced_funds (buyer couldn't actually afford it) +-- -> bounced_ineligible (target no longer a valid mark: no expedition, +-- a live contract already, cooldown, cap, ...) +-- +-- guid is the idempotency key end to end: gogobee passes it to DebitIdem and +-- stamps it on the contract, so a claim whose ack is lost on the wire can be +-- retried without charging the buyer twice or opening two contracts. buyer_sub +-- is the OIDC subject (stable across username changes) and keys "my orders"; +-- buyer_username is what gogobee turns into @username:server. target_token is +-- the roster token of the mark — the same anonymous token the board renders, so +-- ordering a hit never needs the victim's real handle. +CREATE TABLE IF NOT EXISTS mischief_orders ( + guid TEXT PRIMARY KEY, + buyer_sub TEXT NOT NULL, + buyer_username TEXT NOT NULL, + target_token TEXT NOT NULL, + target_name TEXT NOT NULL, -- display copy, frozen at order time + tier TEXT NOT NULL, + signed INTEGER NOT NULL DEFAULT 0, -- 1 = sign openly (+25%), 0 = anonymous + status TEXT NOT NULL, -- see the ladder above + detail TEXT, -- gogobee's human note on the verdict + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_mischief_orders_pending ON mischief_orders(status, created_at); +CREATE INDEX IF NOT EXISTS idx_mischief_orders_buyer ON mischief_orders(buyer_sub, created_at DESC); + +-- The storefront price list. gogobee is the sole authority on prices and pushes +-- the whole catalog on the roster tick, so a fee retune reaches the storefront +-- within a snapshot and Pete never hardcodes a number that can drift. ordinal +-- preserves the grunt->boss order the push arrived in. +CREATE TABLE IF NOT EXISTS mischief_tiers ( + key TEXT PRIMARY KEY, + display TEXT NOT NULL, + fee INTEGER NOT NULL, + signed_fee INTEGER NOT NULL, + blurb TEXT, + ordinal INTEGER NOT NULL DEFAULT 0 +); + CREATE TABLE IF NOT EXISTS post_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT NOT NULL, diff --git a/internal/web/mischief.go b/internal/web/mischief.go new file mode 100644 index 0000000..83abacc --- /dev/null +++ b/internal/web/mischief.go @@ -0,0 +1,250 @@ +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}) +} diff --git a/internal/web/mischief_test.go b/internal/web/mischief_test.go new file mode 100644 index 0000000..aa0f53c --- /dev/null +++ b/internal/web/mischief_test.go @@ -0,0 +1,222 @@ +package web + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "pete/internal/storage" +) + +// newStore is a server with the adventure seam on, a signed-in buyer, and a +// board + catalog already pushed. Auth is built by hand for the same reason the +// casino tests do it: the OIDC handshake is a network call and none of what's +// under test is about it. +func newStore(t *testing.T) *Server { + t.Helper() + s, _ := newAdvServer(t, "tok") + s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} + + now := time.Now().Unix() + push := rosterPush{ + SnapshotAt: now, + Adventurers: []storage.RosterEntry{entry("tok-josie", "Josie", "expedition", "holymachina")}, + Tiers: []storage.MischiefTier{ + {Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50}, + {Key: "boss", Display: "Boss", Fee: 1200, SignedFee: 1500}, + }, + Balances: []storage.MischiefBalance{{Username: "reala", Euro: 500}}, + } + if w := postRoster(t, s, "tok", push); w.Code != 200 { + t.Fatalf("seed roster = %d", w.Code) + } + return s +} + +// jsonReq builds a bearer-authed (or unauthed, empty token) machine request. +func jsonReq(t *testing.T, method, path, token string, body any) *http.Request { + t.Helper() + var buf bytes.Buffer + if body != nil { + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatal(err) + } + } + r := httptest.NewRequest(method, path, &buf) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + return r +} + +func placeOrder(t *testing.T, s *Server, username string, req mischiefOrderReq) *httptest.ResponseRecorder { + t.Helper() + r := as(t, s, username, "POST", "/api/mischief/order", req) + w := httptest.NewRecorder() + s.handleMischiefOrder(w, r) + return w +} + +// TestMischiefOrderHappyPath: a signed-in buyer names a mark on the board and a +// tier gogobee offers, and gets a pending order back. +func TestMischiefOrderHappyPath(t *testing.T) { + s := newStore(t) + + w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt", Signed: false}) + if w.Code != 200 { + t.Fatalf("order = %d body=%s", w.Code, w.Body.String()) + } + var o storage.MischiefOrder + if err := json.Unmarshal(w.Body.Bytes(), &o); err != nil { + t.Fatal(err) + } + if o.Status != storage.MischiefPending || o.BuyerUsername != "reala" || o.TargetName != "Josie" { + t.Fatalf("order = %+v", o) + } + if pending, _ := storage.PendingMischiefOrders(10); len(pending) != 1 { + t.Fatalf("order didn't land in the pending set") + } +} + +func TestMischiefOrderRejections(t *testing.T) { + s := newStore(t) + + if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "dragon"}); w.Code != 400 { + t.Errorf("unknown tier = %d, want 400", w.Code) + } + if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "ghost", Tier: "grunt"}); w.Code != 404 { + t.Errorf("off-board target = %d, want 404", w.Code) + } + if w := placeOrder(t, s, "reala", mischiefOrderReq{Tier: "grunt"}); w.Code != 400 { + t.Errorf("empty target = %d, want 400", w.Code) + } + // Signed in, but no username the ledger can name (a pre-storefront session). + if w := placeOrder(t, s, "", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 409 { + t.Errorf("usernameless session = %d, want 409", w.Code) + } +} + +func TestMischiefOrderBurstGuard(t *testing.T) { + s := newStore(t) + for i := 0; i < mischiefBurstMax; i++ { + if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 200 { + t.Fatalf("order %d = %d, want 200", i, w.Code) + } + } + if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 429 { + t.Fatalf("over-cap order = %d, want 429", w.Code) + } +} + +func TestMischiefOrderRequiresAuth(t *testing.T) { + s := newStore(t) + r := httptest.NewRequest("POST", "/api/mischief/order", nil) + w := httptest.NewRecorder() + s.handleMischiefOrder(w, r) + if w.Code != 401 { + t.Fatalf("anonymous order = %d, want 401", w.Code) + } +} + +func TestMischiefCatalogCarriesBalance(t *testing.T) { + s := newStore(t) + r := as(t, s, "reala", "GET", "/api/mischief/catalog", nil) + w := httptest.NewRecorder() + s.handleMischiefCatalog(w, r) + if w.Code != 200 { + t.Fatalf("catalog = %d", w.Code) + } + var resp mischiefCatalogResp + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if len(resp.Tiers) != 2 || !resp.HasBalance || resp.Euro != 500 { + t.Fatalf("catalog resp = %+v", resp) + } +} + +// ---- the bearer wire ----------------------------------------------------------- + +func TestMischiefPendingAndClaimBearer(t *testing.T) { + s := newStore(t) + if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "boss", Signed: true}); w.Code != 200 { + t.Fatalf("seed order = %d", w.Code) + } + + // Missing bearer is rejected. + { + w := httptest.NewRecorder() + s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "", nil)) + if w.Code != 401 { + t.Fatalf("no-bearer pending = %d, want 401", w.Code) + } + } + + // Poll returns the order. + var pending []storage.MischiefOrder + { + w := httptest.NewRecorder() + s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "tok", nil)) + if w.Code != 200 { + t.Fatalf("pending = %d", w.Code) + } + if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil { + t.Fatal(err) + } + } + if len(pending) != 1 { + t.Fatalf("pending count = %d, want 1", len(pending)) + } + guid := pending[0].GUID + + // Claim it placed. + { + w := httptest.NewRecorder() + s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok", + mischiefVerdict{GUID: guid, Status: storage.MischiefPlaced, Detail: "out for delivery"})) + if w.Code != 200 { + t.Fatalf("claim = %d body=%s", w.Code, w.Body.String()) + } + } + if got, _ := storage.MischiefOrderByGUID(guid); got.Status != storage.MischiefPlaced { + t.Fatalf("order status after claim = %q", got.Status) + } + + // And it's gone from the pending set. + { + w := httptest.NewRecorder() + s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "tok", nil)) + var again []storage.MischiefOrder + _ = json.Unmarshal(w.Body.Bytes(), &again) + if len(again) != 0 { + t.Fatalf("placed order still polled: %d", len(again)) + } + } +} + +func TestMischiefClaimUnknownGUIDis400(t *testing.T) { + s := newStore(t) + w := httptest.NewRecorder() + s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok", + mischiefVerdict{GUID: "nope", Status: storage.MischiefPlaced})) + if w.Code != 400 { + t.Fatalf("unknown-guid claim = %d, want 400 (so gogobee parks it)", w.Code) + } +} + +func TestMischiefClaimBadVerdictIs400(t *testing.T) { + s := newStore(t) + if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 200 { + t.Fatal("seed") + } + pending, _ := storage.PendingMischiefOrders(10) + w := httptest.NewRecorder() + s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok", + mischiefVerdict{GUID: pending[0].GUID, Status: "exploded"})) + if w.Code != 400 { + t.Fatalf("bad-verdict claim = %d, want 400", w.Code) + } +} diff --git a/internal/web/roster.go b/internal/web/roster.go index 4be4252..07df33f 100644 --- a/internal/web/roster.go +++ b/internal/web/roster.go @@ -41,13 +41,26 @@ const ( ) // rosterPush is the payload gogobee POSTs to /api/ingest/roster. +// +// Balances ride the same tick as the board but live in a separate keyspace: they +// are keyed by localpart (a buyer's own sign-in name), not by the anonymous +// roster token, and Pete only ever reads one back for the authenticated user +// asking about themselves. So the board stays anonymous while the storefront can +// still grey out tiers a buyer plainly can't afford. type rosterPush struct { - SnapshotAt int64 `json:"snapshot_at"` - Adventurers []storage.RosterEntry `json:"adventurers"` + SnapshotAt int64 `json:"snapshot_at"` + Adventurers []storage.RosterEntry `json:"adventurers"` + Balances []storage.MischiefBalance `json:"balances,omitempty"` + Tiers []storage.MischiefTier `json:"tiers,omitempty"` } // RosterView is one row as the page renders it. +// +// Token is the mark's anonymous roster token, exposed so the storefront can name +// a target without ever knowing their real handle. It is safe on a public page by +// design — non-reversible, stable, and already how gogobee keys the board. type RosterView struct { + Token string Name string Level int ClassRace string @@ -103,7 +116,21 @@ func (s *Server) handleRosterIngest(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) return } - slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers)) + // Advisory balances are best-effort: a board that landed is worth keeping + // even if the balances behind it didn't, so a failure here is logged, not + // fatal. Stale affordability only ever bounces an order, never miscounts money. + if err := storage.ReplaceUserEuro(push.Balances, push.SnapshotAt); err != nil { + slog.Error("roster ingest: balance replace failed", "err", err) + } + // The tier catalog is likewise best-effort and only refreshed when the push + // actually carried one, so a gogobee build that predates the storefront (no + // tiers field) can't wipe a catalog a newer one already established. + if len(push.Tiers) > 0 { + if err := storage.ReplaceMischiefTiers(push.Tiers); err != nil { + slog.Error("roster ingest: tier replace failed", "err", err) + } + } + slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers), "balances", len(push.Balances)) w.WriteHeader(http.StatusOK) } @@ -142,6 +169,7 @@ func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) { func toRosterView(e storage.RosterEntry) RosterView { v := RosterView{ + Token: e.Token, Name: e.Name, Level: e.Level, ClassRace: e.ClassRace, diff --git a/internal/web/server.go b/internal/web/server.go index d539f6a..26f8657 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -236,6 +236,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim) mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled) + // The mischief storefront's game-box wire: gogobee polls pending orders and + // pushes a verdict. Bearer-authed on the same ingest token, same reason as the + // escrow seam — the caller is a machine on the tailnet. The buyer-facing half + // hangs off the auth block below. + mux.HandleFunc("GET /api/mischief/pending", s.handleMischiefPending) + mux.HandleFunc("POST /api/mischief/claim", s.handleMischiefClaim) + // The casino. Signed-in only — there is money in it — so these hang off the // auth block, and gamesReady() also insists on a Matrix server name: without // one, no player can be named to gogobee's ledger and the tables stay shut. @@ -254,6 +261,16 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo mux.HandleFunc("GET /api/state", s.handleState) mux.HandleFunc("GET /bookmarks", s.handleBookmarks) mux.HandleFunc("GET /for-you", s.handleForYou) + + // The mischief storefront, buyer side. Signed-in only — an order names a + // buyer to gogobee's ledger — so like the casino it hangs off the auth + // block. Only registered when the adventure seam is on; otherwise there is + // no board to order a hit from. + if s.adv.Enabled { + mux.HandleFunc("GET /api/mischief/catalog", s.handleMischiefCatalog) + mux.HandleFunc("POST /api/mischief/order", s.handleMischiefOrder) + mux.HandleFunc("GET /api/mischief/orders", s.handleMischiefOrders) + } if s.cfg.Push.Enabled { mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe) mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe) diff --git a/internal/web/templates/channel.html b/internal/web/templates/channel.html index 3fe7c0d..5354ff1 100644 --- a/internal/web/templates/channel.html +++ b/internal/web/templates/channel.html @@ -25,19 +25,62 @@
+ Sign in to send something unpleasant their way. +
+ {{end}} + + {{if .User}} + + + + + + {{end}} {{end}}