extends SceneTree
## GATE — admin commands run with the AUTHOR'S room role, not a claimed one
## (option a, 2026-10-04; the real fix — the daemon verifying the writer — is the
## hyper-games #92 follow-up).
## R1 offline: your own (or a legacy, author-less) command is admin; another's is peer
## R2 online: the role is the author's keet membership role (mod / peer / read-only);
## an unknown author and a legacy key get peer
## R3 a peer-role member claiming {"requester_role": "admin"} is REFUSED a mod call;
## a mod is allowed the same call
## R4 admin:cmd: is author-scoped: I may write my own, not someone else's
## R5 the TUI writes admin:cmd:<memberId>:<seq> and no longer claims admin
##
## Run: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_admin_role.gd
var ok := true
var checks := 0
var _frame := 0
func _p(c: bool, m: String, got: Variant = "") -> void:
checks += 1
print(("ok " if c else "FAIL: ") + m + ("" if str(got) == "" else " (" + str(got) + ")"))
if not c:
ok = false
func _process(_d: float) -> bool:
_frame += 1
if _frame < 5:
return false
_run()
print("\n%d checks — %s" % [checks, "PASS" if ok else "FAIL"])
quit(0 if ok else 1)
return true
func _run() -> void:
var af: Node = root.get_node("/root/AdminFacade")
var ks: Node = root.get_node("/root/KeetService")
var gs: Node = root.get_node("/root/GameState")
var saved_cache: Array = (ks.get("_member_cache") as Array).duplicate()
var saved_me: String = String(ks.get("current_member_id"))
# ---- R1: offline (no roster) ----
ks.set("_member_cache", [])
ks.set("current_member_id", "alice")
_p(af.requester_role_for("alice:1") == "admin", "R1 offline: my own command is admin")
_p(af.requester_role_for("legacy-seq") == "admin", "R1 offline: a legacy author-less command is admin (gates)")
_p(af.requester_role_for("bob:1") == "peer", "R1 offline: someone else's is peer")
# ---- R2: online, keet roster ----
ks.set("_member_cache", [{"memberId": "alice", "role": "mod"}, {"memberId": "bob", "role": "peer"},
{"memberId": "ro", "role": "read-only"}, {"memberId": "root", "role": "admin"}])
_p(af.requester_role_for("alice:7") == "mod", "R2 a mod's command runs as mod")
_p(af.requester_role_for("root:7") == "admin", "R2 an admin's as admin")
_p(af.requester_role_for("bob:7") == "peer", "R2 a peer's as peer")
_p(af.requester_role_for("ro:7") == "read-only", "R2 a read-only member's as read-only (below peer)")
_p(not af._role_satisfies(af.requester_role_for("ro:7"), "peer"), "R2 which does not satisfy peer")
_p(af.requester_role_for("eve:7") == "peer", "R2 an unknown author gets peer")
_p(af.requester_role_for("legacy-seq") == "peer", "R2 online, a legacy key gets peer")
# ---- R3: the claim is ignored ----
var claim := {"op": "call", "path": "/global/bots.register", "args": ["gate-r3-bob", ["session"], "none", {}],
"requester_role": "admin"}
af._dispatch_command("bob:r3", claim)
var rb: Variant = gs.get_state("admin:result:bob:r3")
_p(rb is Dictionary and not bool((rb as Dictionary).get("ok", true)), "R3 a peer claiming admin is REFUSED a mod call", rb)
var reg: Node = root.get_node("/root/BotRegistry")
_p(not reg.is_registered("gate-r3-bob"), "R3 and nothing was registered")
var claim2 := claim.duplicate(true)
(claim2["args"] as Array)[0] = "gate-r3-alice"
claim2.erase("requester_role")
af._dispatch_command("alice:r3", claim2)
var ra: Variant = gs.get_state("admin:result:alice:r3")
_p(ra is Dictionary and bool((ra as Dictionary).get("ok", false)), "R3 a mod is allowed the same call (no claim needed)", ra)
reg.despawn("gate-r3-alice")
# ---- R4: author-scoped route ----
ks.set("current_member_id", "bob")
_p(gs._author_of("admin:cmd:alice:5") == "alice", "R4 admin:cmd: is author-scoped (author = alice)")
_p(gs.can_write("admin:cmd:bob:5"), "R4 bob may write his own command")
_p(not gs.can_write("admin:cmd:alice:5"), "R4 bob may NOT write a command as alice")
# ---- R5: the TUI ----
var tui := FileAccess.get_file_as_string("res://lib/tui.js")
_p(tui.contains("const seq = me + ':'"), "R5 the TUI signs commands with its memberId")
_p(not tui.contains("requester_role: 'admin'"), "R5 and no longer claims admin")
ks.set("_member_cache", saved_cache)
ks.set("current_member_id", saved_me)