#!/usr/bin/env python3 """GX Hub Studio validator — check a game folder against the GX Hub's REAL rules. Every rule below is ported from the live hub (hub.gxhub.workers.dev) and cites its source: LIMITS and checkEntries and the automatic checker (TEXT_RULES / CODE_RULES) in the hub's index.js, findLogo/LOGO, the earn CAPS, and GUIDE.md. Levels mirror the hub's own: FAIL = the hub's automatic scan rejects this (level "reject"), or the file cannot be accepted at all (structure/logo/manifest). WARN = the hub flags it for the human reviewer (level "review"), or a hub feature will silently not work. Warnings do not fail validation. Usage: python3 validate.py [--title T] [--description D] Exit code 0 = no FAILs, 1 = at least one FAIL. """ import json import os import re import struct import sys # ---- hub constants, copied from the live source (src/lib.js, src/index.js) ---- EXTS = ["html", "htm", "js", "css", "png", "jpg", "jpeg", "webp", "gif", "svg", "ogg", "mp3", "wav", "wasm", "json", "txt"] # LIMITS.exts MAX_FILES = 500 # LIMITS.maxFiles MAX_UNZIPPED = 25 * 1024 * 1024 * 4 # upload: total > maxZipBytes*4 -> 413 LOGO_MAX = 200 * 1024 # LOGO.maxBytes LOGO_MIN = 256 # LOGO.minPx LOGO_NAMES = ["logo.png", "logo.jpg", "logo.jpeg", "logo.webp"] HUB_SDK = "hub.gxhub.workers.dev/sdk/grokonians.js" TEXTF = re.compile(r"\.(html?|js|css|json|txt|svg)$", re.I) IMG = re.compile(r"\.(png|jpe?g|webp|gif|svg)$", re.I) IP_FILES = re.compile(r"(^|/)[^/]*(x[-_]?logo|twitter[-_]?(logo|bird)|grok[-_]?logo|xai[-_]?logo)[^/]*$", re.I) # Checker text rules (reject level) — hub TEXT_RULES, wording condensed. TEXT_REJECT = [ ("extremist terms", r"\bheil hitler\b|\bsieg heil\b|\bwhite power\b|\b14 ?88\b|\bisis\b recruit|\bjoin (the )?jihad\b|\bkkk\b"), ("hateful phrases", r"\b(kill|gas|exterminate) (all|the) (jews|muslims|christians|blacks|whites|gays|immigrants)\b|\bsubhuman\b|\bethnic cleansing\b|\bgo back to your country\b"), ("threats / incitement", r"\bkill yourself\b|\bkys\b|\bschool shoot(ing|er)\b|\bi will kill\b|\bbomb (the|a) (school|church|mosque|synagogue)\b"), ("illegal goods", r"\bbuy (cocaine|heroin|meth|fentanyl|guns?|weed)\b|\bfake (id|passport)s?\b|\bstolen (cards?|accounts?)\b|\bcvv dumps?\b"), ("scam / spam copy", r"\bfree (money|robux|v-?bucks|crypto)\b|\bdouble your (btc|bitcoin|eth|crypto|money)\b|\bsend (btc|eth|crypto)\b|\bseed phrase\b|\bclaim your (prize|reward|airdrop)\b|\bairdrop\b|\bguaranteed (profit|returns)\b"), ("claims to be official X / xAI", r"\bofficial (x|twitter|grok|xai) (game|app|hub)\b|\b(made|built|endorsed) by (x corp|xai|twitter)\b|\bx corp(oration)? (presents|official)\b"), ] TEXT_REVIEW = [ ("adult terms", r"\bporn|\bnsfw\b|\bxxx\b|\bnude|\bnaked\b|\bhentai\b|\bsex\b|\bonlyfans\b|\berotic"), ("graphic terms", r"\bgore\b|\bbehead|\bdismember|\bmutilat|\btorture\b"), ] # Checker code rules — hub CODE_RULES verbatim patterns. CODE_REJECT = [ ("crypto miner", r"coinhive|cryptonight|coin-?imp|webminer|minero\.cc|stratum\+tcp|cryptoloot"), ("packed / obfuscated code", r"eval\s*\(\s*function\s*\(\s*p\s*,\s*a\s*,\s*c\s*,\s*k\s*,\s*e|(\\x[0-9a-f]{2}){40,}|_0x[0-9a-f]{4,}.*_0x[0-9a-f]{4,}.*_0x[0-9a-f]{4,}"), ("eval of decoded/remote code", r"(eval|new\s+Function|setTimeout|setInterval)\s*\(\s*(atob|unescape|decodeURIComponent|await\s+fetch|fetch|String\.fromCharCode)"), ("top-level navigation", r"\b(window\.)?top\.location|parent\.location|window\.top\s*\.|top\s*\.\s*document"), ("password field", r"type\s*=\s*[\"']?password|autocomplete\s*=\s*[\"']?(current-|new-)?password"), ("card / identity fields", r"autocomplete\s*=\s*[\"']?cc-|name\s*=\s*[\"']?(card|cardnumber|cc|cvv|cvc|ssn|seed|mnemonic)[\"'\s>]|placeholder\s*=\s*[\"'][^\"']*(card number|cvv|social security|seed phrase|x password|twitter password)"), ] CODE_REVIEW = [ ("long encoded blob", r"[A-Za-z0-9+/]{3000,}={0,2}"), ("eval / new Function", r"\beval\s*\(|new\s+Function\s*\("), ("network access (fetch/XHR/WebSocket/beacon)", r"\bfetch\s*\(|XMLHttpRequest|\bWebSocket\b|EventSource|sendBeacon|RTCPeerConnection|importScripts\s*\(\s*['\"]https?:"), ("popups", r"window\.open\s*\(|target\s*=\s*[\"']_blank"), ] REMOTE_TAG = re.compile(r"<(script|iframe|link)[^>]+(src|href)\s*=\s*[\"']?(https?:)?//", re.I) results = [] # (level, rule, message, source) def add(level, rule, message, source): results.append((level, rule, message, source)) def image_size(data): """(type, w, h) from magic bytes — mirrors the hub's imageSize (png/jpg) + webp.""" if data[:8] == b"\x89PNG\r\n\x1a\n" and len(data) > 24: w, h = struct.unpack(">II", data[16:24]) return ("png", w, h) if data[:2] == b"\xff\xd8": i = 2 while i + 9 < len(data): if data[i] != 0xFF: i += 1 continue m = data[i + 1] if 0xC0 <= m <= 0xCF and m not in (0xC4, 0xC8, 0xCC): h, w = struct.unpack(">HH", data[i + 5:i + 9]) return ("jpg", w, h) i += 2 + struct.unpack(">H", data[i + 2:i + 4])[0] return None if data[:4] == b"RIFF" and data[8:12] == b"WEBP": kind = data[12:16] try: if kind == b"VP8 ": w = struct.unpack("> 14) & 0x3FFF) + 1) if kind == b"VP8X": w = int.from_bytes(data[24:27], "little") + 1 h = int.from_bytes(data[27:30], "little") + 1 return ("webp", w, h) except Exception: return None return None def main(): args = sys.argv[1:] if not args: print(__doc__) return 2 root = args[0] opts = dict(zip(args[1::2], args[2::2])) if len(args) > 1 else {} title = opts.get("--title", "") description = opts.get("--description", "") if not os.path.isdir(root): print(f"FAIL folder — {root} is not a folder") return 1 files = [] # (relpath, abspath, size) for dirpath, _dirs, names in os.walk(root): for n in names: ap = os.path.join(dirpath, n) rel = os.path.relpath(ap, root).replace(os.sep, "/") files.append((rel, ap, os.path.getsize(ap))) files.sort() total = sum(s for _r, _a, s in files) # ---- structure (hub: checkEntries + LIMITS) ---- names = [r for r, _a, _s in files] if "index.html" not in names: add("FAIL", "index.html", "no index.html at the folder root", "hub checkEntries: 'zip must contain index.html'") else: add("PASS", "index.html", "index.html at the root", "hub checkEntries") if len(files) > MAX_FILES: add("FAIL", "file count", f"{len(files)} files (max {MAX_FILES})", "hub LIMITS.maxFiles") else: add("PASS", "file count", f"{len(files)} files (max {MAX_FILES})", "hub LIMITS.maxFiles") if total > MAX_UNZIPPED: add("FAIL", "total size", f"{total} bytes unzipped (max {MAX_UNZIPPED})", "hub upload handler: total > maxZipBytes*4 -> 413") else: add("PASS", "total size", f"{total} bytes (unzipped max {MAX_UNZIPPED})", "hub upload handler") for rel, _a, _s in files: if ".." in rel or rel.startswith("/") or "\\" in rel or "\x00" in rel: add("FAIL", "bad path", rel, "hub checkEntries path rules") ext = rel.rsplit(".", 1)[-1].lower() if "." in rel else "" if ext not in EXTS: add("FAIL", "file type", f"{rel}: type '{ext}' is not allowed", "hub LIMITS.exts (html htm js css png jpg jpeg webp gif svg ogg mp3 wav wasm json txt)") if not any(r[0] == "FAIL" and r[1] == "file type" for r in results): add("PASS", "file types", "every file type is allowed", "hub LIMITS.exts") # ---- manifest + listing (hub: findLogo + checker required rules) ---- manifest = {} man = [f for f in files if f[0] == "manifest.json"] if man: try: manifest = json.loads(open(man[0][1], encoding="utf-8").read()) except Exception: add("FAIL", "manifest.json", "manifest.json is not valid JSON", "hub findLogo: invalid manifest -> submission refused") title = title or str(manifest.get("title", "")) description = description or str(manifest.get("description", "")) if title: if len(title.strip()) < 3 or len(title) > 80: add("FAIL", "title", f"title must be 3-80 chars (got {len(title)})", "hub checker: 'title required (3+ chars)'; submit form maxlength 80") else: add("PASS", "title", f"title ok ({len(title)} chars)", "hub checker + submit form") else: add("WARN", "title", "no title given (--title or manifest) — the hub asks on the submit form; it must be 3-80 chars", "hub checker 'title required (3+ chars)'") if description: if len(description.strip()) < 10 or len(description) > 1000: add("FAIL", "description", f"description must be 10-1000 chars (got {len(description)})", "hub checker: 'description required (10+ chars)'; submit form maxlength 1000") else: add("PASS", "description", f"description ok ({len(description)} chars)", "hub checker + submit form") else: add("WARN", "description", "no description given — the hub asks on the submit form; it must be 10-1000 chars", "hub checker 'description required (10+ chars)'") # ---- logo (hub: findLogo / LOGO) ---- logo_path = None for n in LOGO_NAMES: if n in names: logo_path = n break if not logo_path and isinstance(manifest.get("logo"), str): p = manifest["logo"].lstrip("./") if p in names: logo_path = p if not logo_path: add("FAIL", "logo", "no logo.png / logo.jpg / logo.webp at the top (or \"logo\" in manifest.json)", "hub findLogo: 'Every game needs its own logo'") else: rel, ap, size = next(f for f in files if f[0] == logo_path) ext = rel.rsplit(".", 1)[-1].lower() if ext not in ("png", "jpg", "jpeg", "webp"): add("FAIL", "logo", f"{rel}: logo must be PNG, JPG or WebP", "hub findLogo") elif size > LOGO_MAX: add("FAIL", "logo", f"{rel}: {size} bytes (max {LOGO_MAX})", "hub LOGO.maxBytes") else: sz = image_size(open(ap, "rb").read()) if not sz: add("FAIL", "logo", f"{rel}: not a real PNG/JPG/WebP image", "hub findLogo imageSize") elif (sz[0] == "jpg") != (ext in ("jpg", "jpeg")) or (sz[0] == "png") != (ext == "png"): add("FAIL", "logo", f"{rel}: file content ({sz[0]}) does not match its extension", "hub findLogo") elif sz[1] != sz[2]: add("FAIL", "logo", f"{rel}: must be square (got {sz[1]}x{sz[2]})", "hub findLogo") elif sz[1] < LOGO_MIN: add("FAIL", "logo", f"{rel}: too small ({sz[1]}x{sz[2]}, min {LOGO_MIN})", "hub LOGO.minPx") else: add("PASS", "logo", f"{rel}: square {sz[1]}x{sz[2]}, {size} bytes", "hub findLogo / LOGO") # ---- content scan (hub: TEXT_RULES / CODE_RULES / IP_FILES, same texts) ---- sources = [("title", title), ("description", description)] + [("filename", r) for r in names] texts = {} for rel, ap, _s in files: if TEXTF.search(rel): try: texts[rel] = open(ap, encoding="utf-8", errors="replace").read() except Exception: pass sources += list(texts.items()) hit = {"FAIL": set(), "WARN": set()} for where, text in sources: if not text: continue for label, pat in TEXT_REJECT: m = re.search(pat, text, re.I) if m and label not in hit["FAIL"]: hit["FAIL"].add(label) add("FAIL", "text scan", f"{label} — {where}: \"{m.group(0)[:40]}\"", "hub checker TEXT_RULES (level reject)") for label, pat in TEXT_REVIEW: m = re.search(pat, text, re.I) if m and label not in hit["WARN"]: hit["WARN"].add(label) add("WARN", "text scan", f"{label} — {where}: \"{m.group(0)[:40]}\" (human reviewer decides)", "hub checker TEXT_RULES (level review)") if where not in ("title", "description", "filename"): for label, pat in CODE_REJECT: m = re.search(pat, text, re.I | re.S) if m and label not in hit["FAIL"]: hit["FAIL"].add(label) add("FAIL", "code scan", f"{label} — {where}: \"{m.group(0)[:40]}\"", "hub checker CODE_RULES (level reject)") for label, pat in CODE_REVIEW: m = re.search(pat, text, re.I | re.S) if m and label not in hit["WARN"]: hit["WARN"].add(label) add("WARN", "code scan", f"{label} — {where} (human reviewer decides)", "hub checker CODE_RULES (level review)") for m in REMOTE_TAG.finditer(text): url = m.group(0) if HUB_SDK in text[max(0, m.start() - 200):m.end() + 200]: add("PASS", "remote script", "the hub's own Grokonians SDK script tag (expected)", "GUIDE.md step 3; hub serves it at /sdk/grokonians.js") elif "remote" not in hit["WARN"]: hit["WARN"].add("remote") add("WARN", "remote script/frame", f"{where}: \"{url[:60]}\" — hosted games are served with " "CSP default-src 'self' and connect-src 'none', so remote code will not load there; " "the reviewer will look at it either way", "hub checker CODE_RULES 'remote script/frame' (review) + gameCSP") for rel in names: if IP_FILES.search(rel): add("FAIL", "logo files", f"{rel}: X / Twitter / Grok / xAI logo files are rejected", "hub checker IP_FILES (level reject)") imgs = [f for f in files if IMG.search(f[0])] img_bytes = sum(s for _r, _a, s in imgs) if len(imgs) >= 5 or (total and img_bytes / total > 0.5): add("WARN", "image-heavy", f"{len(imgs)} images, {round(100 * img_bytes / max(total,1))}% of bytes — the hub flags this for human image review", "hub checker 'image-heavy game' (level review)") if not hit["FAIL"]: add("PASS", "content scan", "no reject-level text or code patterns found", "hub checker TEXT_RULES / CODE_RULES") # ---- the bridge (hub: SDK + CAPS + scores) ---- all_text = "\n".join(texts.values()) has_sdk = HUB_SDK in all_text or "grokonians:hello" in all_text or "/sdk/grokonians.js" in all_text if has_sdk: add("PASS", "bridge", "Grokonian bridge present (SDK script or hello handshake)", "hub grokonians-hub.js SDK; GUIDE.md step 3") else: add("WARN", "bridge", "no Grokonians SDK / hello handshake found — the game can still be listed, " "but it cannot earn Grokonians and will not wear the connected badge", "hub: earn endpoint refuses games whose SDK handshake was never seen") if "Grokonians.earn" in all_text or "grokonians:earn" in all_text: capped = re.search(r"Math\.min\(\s*10\b", all_text) or re.search(r"\.earn\(\s*\d{1,2}\s*[,\)]", all_text) paced = re.search(r"1000|1_000", all_text) if capped and paced: add("PASS", "earn pacing", "earn calls are chunked (<=10) and paced (>=1000 ms)", "hub CAPS: maxPerEvent 10, minGapMs 1000, maxPerSession 300, maxPerGamePerDay 200") else: add("WARN", "earn pacing", "earn is called without an obvious <=10 chunk and >=1000 ms pacing — " "the hub refuses amounts over 10 and calls faster than 1/second", "hub CAPS: maxPerEvent 10, minGapMs 1000") if "scores:submit" in all_text: add("PASS", "leaderboard", "scores:submit message present (score + waves)", "hub bridge in the game page; SCORE_CAPS: score <= 10^7, waves <= 10^5, 30/hour") else: add("WARN", "leaderboard", "no scores:submit message — the hub leaderboard will not hear this game (optional)", "GUIDE.md step 3 (optional leaderboard)") # ---- report ---- fails = [r for r in results if r[0] == "FAIL"] warns = [r for r in results if r[0] == "WARN"] for level, rule, msg, src in results: print(f"{level:4} {rule:16} {msg}\n source: {src}") print(f"\n{len(results) - len(fails) - len(warns)} passed, {len(warns)} warnings, {len(fails)} failures") print("VERDICT:", "FAIL — fix the FAIL lines before submitting" if fails else "PASS — hub-ready (read any WARN lines)") return 1 if fails else 0 if __name__ == "__main__": sys.exit(main())