#!/usr/bin/env python3 """Lane G — GODVERSE tier-2: the two READ endpoints (R27 ledger #1). The charter's last rung, under John's ratified A-then-B ruling (G3 §7): **read-live, sell-sandboxed**. The server ENRICHES, NEVER GATES — every boot path, gate and tag works with this process dead. Nothing here can write to the shop. python3 pipeline/godverse_server.py setup-role # the read-only grant (idempotent) python3 pipeline/godverse_server.py start|stop|status # lifecycle — F's kill-the-server gate python3 pipeline/godverse_server.py serve # foreground python3 pipeline/godverse_server.py make-fixture --sold 3 # a controlled POS for F's gate THE SURFACE (G3 §4, as built): GET /godverse/v1/health -> { ok, shops } (is the server up) GET /godverse/v1/shop//stock -> live crate metadata + `gone` + etag GET /godverse/v1/shop//crates -> the real crate menu (rotation) GET anything else -> 404 (with the ruling in the body) POST/PUT/PATCH/DELETE anything -> **501**, measured — see below **`/reserve` and `/buy` are ABSENT, not stubbed** — no route, no handler, no flag. A sandbox a config typo can flip isn't a sandbox (G3 §7). **→ Lane F, assert 501 and not 404 for the write verbs.** This class defines `do_GET` and nothing else, so the stdlib answers any other method with 501 "Unsupported method" before routing exists at all. That is a *stronger* absence proof than a 404: a 404 would mean a handler ran and chose to decline, whereas 501 means **there is no write handler in the process**. Adding `do_POST` just to answer 404 would be implementing the very thing the ruling forbids. (My own docstring claimed 404 until I measured it — the same doc-vs-code species this epoch keeps catching, caught here by running it.) THREE FENCES, each enforced by the machine and not by a code review: 1. **The credential.** We connect as `godverse_ro`: SELECT on inventory/crate/disc_cache and NOTHING else. Measured — `UPDATE inventory` => "permission denied"; `SELECT FROM customer` => "permission denied". That one grant carries the sandbox AND G3 §9's PII fence. 2. **Metadata only.** The API serves facts, never pixels. Covers stay static tier-1 files. A tier-2 shop with a dead server is VISUALLY IDENTICAL to tier 1 — that is the fail-soft. 3. **The id-namespace fence** (R26 law). `gone[]` carries the atlas's own `id` form verbatim (`sku_`), so the client does `gone.includes(item.id)` with ZERO transformation. *G3 §4 as ratified said `gone:[sku]` — bare numbers. That wording predates R25's sku ids and R26's fence, and a bare number crossing two id spaces is the exact bug the fence law exists to stop. The design doc was the stale thing; §4 is updated as-built.* TIER 2 IS FOR REAL SHOPS ONLY. A `sourcing: "mint"` shop has no POS to be live about, so its `/stock` is a 404 — not an empty crate. Monster Robot Party is the only real shop; that's the point: one shop, live, true. """ import argparse, hashlib, json, os, signal, subprocess, sys, tempfile, time from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) STOCK = os.path.join(ROOT, "web", "assets", "stock_godverse") PORT = int(os.environ.get("GODVERSE_PORT", "8778")) # Runtime state lives OUTSIDE the repo: a pidfile is not an artifact, and a stray one in the tree # is exactly the kind of thing that gets committed by a hurried `git add`. PIDFILE = os.path.join(tempfile.gettempdir(), f"godverse-{PORT}.pid") # The POS. Swap this and the server is live against a different shop-state — which is how F's # gate stays deterministic without anyone faking real data (see make-fixture). POS_DB = os.environ.get("GODVERSE_POS_DB", "recordgod") POS_USER = os.environ.get("GODVERSE_POS_USER", "godverse_ro") POS_HOST = os.environ.get("GODVERSE_POS_HOST", "127.0.0.1") READONLY_TABLES = ("inventory", "crate", "disc_cache") # G3 §9: never customer/staff/sales def q(sql, db=None): """Read-only query as godverse_ro. psycopg2 isn't on this box and the CLI keeps the dependency surface at zero — the same call thriftgod's own server would make.""" dsn = f"host={POS_HOST} dbname={db or POS_DB} user={POS_USER}" r = subprocess.run(["psql", dsn, "-tAF\t", "-c", sql], capture_output=True, text=True) if r.returncode: raise RuntimeError(r.stderr.strip().splitlines()[-1] if r.stderr.strip() else "psql failed") return [ln.split("\t") for ln in r.stdout.splitlines() if ln.strip()] def manifest(): with open(os.path.join(STOCK, "index.json")) as fh: return json.load(fh) def real_shops(): """Real-sourced (POS-backed) shops, per the manifest. NOTE the key is `godverseShopId` (camelCase): the manifest is read by the JS runtime, so it wears the JS-side spelling, while an atlas index wears the Python-side `shop.godverse_id`. That split is recorded in G3 §4 — and I still read it wrong first time, which is the whole reason it's recorded.""" return [s for s in manifest().get("shops", []) if s.get("sourcing") == "real"] def real_shop(gid): """The shop's shipped index, but ONLY if it is a real-sourced (POS-backed) shop.""" for s in real_shops(): if str(s.get("godverseShopId")) == str(gid): for kind in s.get("types", ["record"]): p = os.path.join(STOCK, str(gid), f"stock_{kind}_index.json") if os.path.isfile(p): with open(p) as fh: return json.load(fh) return None def etag_of(payload): """Deterministic: same POS state => same etag. `served_at` is deliberately OUTSIDE it — a clock in the etag would make every read a cache miss.""" return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()[:16] def stock(gid): idx = real_shop(gid) if not idx: return None baked = {it["id"]: it["id"][4:] for it in idx["items"] if it["id"].startswith("sku_")} if not baked: return None skus = ",".join("'" + s.replace("'", "''") + "'" for s in baked.values()) rows = q(f"""SELECT i.sku, COALESCE(i.slot_number::text,''), i.in_stock, COALESCE(d.artist,''), COALESCE(d.title, COALESCE(i.title,'')), i.price, COALESCE(i.condition,''), COALESCE(i.sleeve_cond,'') FROM inventory i LEFT JOIN disc_cache d USING (release_id) WHERE i.sku IN ({skus}) ORDER BY i.slot_number NULLS LAST, i.sku""") live, present = [], set() for sku, slot, in_stock, artist, title, price, cond, sleeve in rows: present.add(sku) if in_stock != "t": continue live.append({"id": f"sku_{sku}", "slot": int(slot) if slot else None, "artist": artist, "title": title, "price": float(price) if price else None, "condition": cond, "sleeve_cond": sleeve, "state": "shelf"}) # GONE = baked into the tier-1 atlas, no longer on the shelf. Direction is real -> game ONLY: # a record that sold IN THE REAL SHOP leaves the game crate. A game buy cannot appear here — # there is no verb to carry it and no grant to write it (see the module docstring). gone = sorted(i for i, sku in baked.items() if sku not in present or sku not in {r[0] for r in rows if r[2] == "t"}) body = {"shop_id": int(gid), "tier": 2, "crate": idx.get("crate"), "items": live, "gone": gone, "counts": {"baked": len(baked), "live": len(live), "gone": len(gone)}} body["etag"] = etag_of(body) return body def crates(gid): if not real_shop(gid): return None rows = q("""SELECT c.id, c.name, COALESCE(c.label_text,''), count(i.sku) FROM crate c LEFT JOIN inventory i ON i.crate_id = c.id AND i.in_stock GROUP BY c.id, c.name, c.label_text HAVING count(i.sku) > 0 ORDER BY c.id""") body = {"shop_id": int(gid), "tier": 2, "crates": [{"id": int(a), "name": b, "label": c, "count": int(n)} for a, b, c, n in rows]} body["etag"] = etag_of(body) return body class H(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, *a): pass # the gate reads assertions, not our chatter def _send(self, code, obj, etag=None): raw = json.dumps(obj).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(raw))) self.send_header("Access-Control-Allow-Origin", "*") # the game is served from :8130 if etag: self.send_header("ETag", etag) self.end_headers() self.wfile.write(raw) def _404(self): self._send(404, {"error": "not found", "note": "read-only surface: /health, /shop//stock, /shop//crates. " "Write verbs (/reserve, /buy) are ABSENT by ruling — G3 §7, " "A-then-B: read-live, sell-sandboxed."}) def do_GET(self): u = urlparse(self.path) parts = [p for p in u.path.strip("/").split("/") if p] try: if parts == ["godverse", "v1", "health"]: return self._send(200, {"ok": True, "tier": 2, "pos_db": POS_DB, "shops": [s["godverseShopId"] for s in real_shops()]}) if len(parts) == 5 and parts[:3] == ["godverse", "v1", "shop"]: gid, verb = parts[3], parts[4] body = stock(gid) if verb == "stock" else crates(gid) if verb == "crates" else None if body is None: return self._404() if parse_qs(u.query).get("since", [None])[0] == body["etag"]: self.send_response(304); self.send_header("ETag", body["etag"]) self.send_header("Content-Length", "0"); self.end_headers() return body["served_at"] = datetime.now(timezone.utc).isoformat() return self._send(200, body, etag=body["etag"]) except Exception as e: # A dead/denied POS must look like a dead server to the client: it degrades to tier 1. return self._send(503, {"error": "pos unavailable", "detail": str(e)[:200]}) self._404() # No do_POST / do_PUT / do_PATCH / do_DELETE. BaseHTTPRequestHandler answers 501 for verbs it # has no handler for; the routes do not exist at all. Absent, not stubbed. def serve(): print(f"godverse tier-2 :{PORT} · POS={POS_DB} as {POS_USER} (read-only) · write verbs ABSENT") ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever() def setup_role(_): """The grant that IS the sandbox (G3 §7). Idempotent.""" subprocess.run(["psql", "-q", "-d", "postgres", "-c", "DO $$ BEGIN CREATE ROLE godverse_ro LOGIN; " "EXCEPTION WHEN duplicate_object THEN NULL; END $$;"], check=True) for db in (POS_DB, f"{POS_DB}_live", f"{POS_DB}_fixture"): chk = subprocess.run(["psql", "-tAc", f"SELECT 1 FROM pg_database WHERE datname='{db}'", "-d", "postgres"], capture_output=True, text=True).stdout.strip() if not chk: continue subprocess.run(["psql", "-q", "-d", db, "-c", f"GRANT CONNECT ON DATABASE {db} TO godverse_ro;", "-c", "GRANT USAGE ON SCHEMA public TO godverse_ro;", "-c", f"GRANT SELECT ON {', '.join('public.' + t for t in READONLY_TABLES)} " "TO godverse_ro;"], check=True) print(f" {db}: SELECT on {', '.join(READONLY_TABLES)} — and nothing else") def make_fixture(args): """A CONTROLLED POS for F's sold-means-gone arm. Why this exists: the real POS has no movement in our window — last sale 2026-07-01, last write 2026-07-09, and 0 of crate 550's 120 records have ever sold, so a real `gone` is EMPTY and an assertion over it would have no subject (the vacuous-gate law). This is the honest way out: a labelled fixture proves the MECHANISM deterministically (gone computed -> sent -> consumed -> sleeve vanishes), while production reads the real POS unchanged. A fixture is not a lie; a green assert over an absent subject is. """ fx = f"{POS_DB}_fixture" subprocess.run(["dropdb", "--if-exists", fx], check=True) subprocess.run(["createdb", "-T", POS_DB, fx], check=True) idx = real_shop(args.shop) skus = [it["id"][4:] for it in idx["items"] if it["id"].startswith("sku_")][:args.sold] lst = ",".join(f"'{s}'" for s in skus) subprocess.run(["psql", "-q", "-d", fx, "-c", f"UPDATE inventory SET in_stock=false, sold_date=now() WHERE sku IN ({lst});"], check=True) setup_role(args) print(f"fixture {fx}: {len(skus)} record(s) marked SOLD -> they must appear in `gone` and " f"must never render:\n " + "\n ".join("sku_" + s for s in skus)) def start(_): if status(None, quiet=True): print("already running"); return os.makedirs(os.path.dirname(PIDFILE), exist_ok=True) p = subprocess.Popen([sys.executable, os.path.abspath(__file__), "serve"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) open(PIDFILE, "w").write(str(p.pid)) for _ in range(50): time.sleep(0.1) if status(None, quiet=True): print(f"started pid={p.pid} :{PORT}"); return print("failed to start"); sys.exit(1) def stop(_): """F's kill-the-server gate calls this mid-session. SIGKILL, not a graceful drain: the gate is testing what happens when the server DIES, not when it says goodbye.""" try: pid = int(open(PIDFILE).read().strip()) os.kill(pid, signal.SIGKILL) os.remove(PIDFILE) print(f"killed pid={pid}") except Exception as e: print(f"not running ({e})") def status(_, quiet=False): import urllib.request try: with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/godverse/v1/health", timeout=1) as r: up = json.loads(r.read()) if not quiet: print(f"UP :{PORT} · POS={up['pos_db']} · real shops={up['shops']}") return True except Exception: if not quiet: print(f"DOWN :{PORT}") return False ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) for name, fn in (("serve", lambda a: serve()), ("start", start), ("stop", stop), ("status", status), ("setup-role", setup_role)): sub.add_parser(name).set_defaults(func=fn) mf = sub.add_parser("make-fixture", help="a controlled POS so F's gate has a subject") mf.add_argument("--shop", default="3962749") mf.add_argument("--sold", type=int, default=3) mf.set_defaults(func=make_fixture) a = ap.parse_args() a.func(a)