# Lab Report — §MESH-02: Map-Tab Connection Center (sub-tabs · discovery · ACL UI · shared blocklists) **Date:** 2026-07-06 · **Status:** ✅ SHIPPED 2026-07-07 (increments a–j) **Parents:** §MESH-01 (closed; `lab-report-mesh-multiuser.md`), mesh-acl (§MESH-01-FU 11) **Ship records:** `14bd572` (a) → `d86aeea` (f) → `c03cdc5` (g, closes a–j); archive: `plan-archive.md` §"Archived 2026-07-07" **Verified against HEAD:** 2026-08-17 (§DOC-02bx) — see §VII ## Abstract §MESH-01 gave the game a working seven-layer mesh and hid every control for it behind a status strip and one modal. §MESH-02 promotes those controls to a **Connection Center**: a sub-tab bar on the Map sheet carrying **Connect · Discover · Lists** panes, three server endpoints (`GET/PUT /api/mesh/acl`, `GET /api/mesh/blocklist`, `POST /api/mesh/connect`), two ambient presence features (💬 chat history, 👣 footprints), and CLI parity through `./api.sh mesh`. The organising principle is a **trust boundary drawn once and enforced everywhere**: a peer's blocklist is share-OUT only and never auto-imported, sharing is the operator's opt-in, and auto-fetch is confined to hosts the player approved. Everything added here is connection/display layer; the mover reads none of it. Re-measured 42 days later, **every locked decision D1–D7 holds and every endpoint, constant and list key is byte-exact**; the drift is entirely in the *labels* of the sub-tab bar, which absorbed two more panes the day after ship without a contract change. ## I. Introduction ### A. Problem All multiplayer UI lived in a status strip (🌐) and a modal (Shift+🌐). Consequences: 1. The authoritative server ACL (`mesh-acl.json`) had **no UI at all** — it was a file you edited by hand and restarted around. 2. There was no way to **find** a server: not one listening on your own machine, not one on a list a friend published. 3. There was no way to say *"never show me that server again"* short of not typing its address. ### B. Motivation — what this buys the player A mesh nobody can join is a single-player game with extra latency. The Connection Center is where multiplayer stops being an operator feature and becomes a *place in the game*: - **The world tab is the social tab.** Joining another world is framed as an act of navigation, not configuration — you find people on the Map sheet, next to the map. - **Discovery replaces address-typing.** 🖥 Scan-local finds the server you started ten minutes ago; a list source lets one person curate a server list for a group and everyone else subscribe to it. - **Refusal is a first-class verb.** A blacklist that hides rows *and* refuses Join means a player can permanently opt out of a griefer's world in one click, on every surface at once. - **Trust is opt-in in both directions.** You may publish who you block; nobody may install their judgement into your client. *Nobody else curates your blocklist* is the whole of D2. - **Presence gains a memory.** 💬 chat history and 👣 footprints make an empty cell readable: *"Aldric passed through here 4 min ago"* turns 90×360 cells of wilderness from an empty grid into a place other people are demonstrably using. ### C. Constraint inherited from §MESH-01 Free-Movement: the mover kernel never consults presence, and presence stays single-writer. This report adds five new data structures and **none of them is readable from movement code** — a property re-verified in §VII, not merely asserted. ## II. Locked Decisions and Their Outcome | # | Decision | Rationale | Outcome at HEAD | |---|----------|-----------|-----------------| | D1 | Multiplayer UI moves into the **Map sheet** as sub-tabs. The 🌐 strip and Shift+🌐 modal stay as shortcuts. | Map is the world-facing surface. | ✅ **held, and outgrew the spec** — the bar shipped with 4 tabs and carries **6** since §MP-MAPTABS (2026-07-08); pane ids unchanged | | D2 | **Blocklists are share-OUT only, never auto-imported.** A peer's list is fetched manually, previewed, merged only on explicit click. | Nobody else curates your blocklist. | ✅ exact — merge is reachable only from a button rendered by the preview | | D3 | Sharing is **opt-in** via ACL field `shareBlocklist:true` (absent/false → 403). | Publishing who you block is itself information. | ✅ exact, both directions (403 → 200 → 403) | | D4 | **Auto-fetch only for sources whose host the user approved** (client whitelist). | User request; unapproved sources load on click only. | ✅ exact, and covered by a fetch-count assertion | | D5 | Client keeps quick lists in localStorage; the authoritative ACL stays `mesh-acl.json`, edited via new endpoints. | Client lists work offline/`file://`; the server ACL gates the mesh itself. | ✅ exact — offline renders a hint, never an error | | D6 | Local discovery = parallel `GET /api/manifest` probes of `localhost` **1360–1380**, ~600 ms each. | A browser cannot listen, only probe. Zero server changes. | ✅ exact (`[1360, 1380]` inclusive, 600 ms abort) | | D7 | Everything here is connection/display layer. **The mover never reads any of it.** | Standing §MESH-01 invariant. | ✅ measured 0 hits (§VII) | ## III. As-Built Inventory ### A. Server surface (`src/js/wbapi-server.js`; at the root as `wbapi-server.js` until `5e48dd7`, 2026-07-09) ``` GET /api/mesh/acl → { ok, file, exists, acl:{ mode, shareBlocklist, blockServerIds[], blockIps[], blockWorldHashes[], allowServerIds[], allowIps[], allowWorldHashes[] } } PUT /api/mesh/acl body: any subset of the acl fields (unknown field → 400) GET /api/mesh/blocklist → 403 {ok:false, reason:'not-shared'} until the D3 opt-in, then { ok, serverId, engineVer, block*[] } — allow* never leaks POST /api/mesh/connect {addr:'host:port'} | {tracker:'http(s)://…'} — dials in-request GET /api/session/chat ?limit=1..200 (default 100), optional &r=&c= cell filter ``` - `src/js/wbapi-server.js:if (parts[0] === 'mesh' && parts[1] === 'acl'@2978` — the GET reply is rebuilt from a whitelist of keys, so `'//'`-comment keys are stripped on the way out and **preserved in the file** on the way in (merge-write). - PUT validation: mode ∈ {open, allowlist}; lists are arrays of ≤200 non-empty strings (`src/js/wbapi-server.js:ab[k].length <= 200@3006`), trimmed and deduped through a `Set`; `shareBlocklist` boolean. The write bumps the file mtime and `getAcl()` hot-reloads on the next mesh packet — **no restart between an edit and its effect**. - `src/js/wbapi-server.js:return json(res, 403, { ok: false, reason: 'not-shared' });@3019` — the D3 gate, and the exact string the client renders as *"does not share its blocklist."* - `src/js/mesh.js:function aclAllows({ serverId, ip, worldHash }) {@63` — blocks are checked first and beat an allowlist match. **`shareBlocklist` occurs zero times in `src/js/mesh.js`**: it is metadata about publication, never a gate. The report's original sentence, exact. - Footprints (j): `src/js/wbapi-server.js:const FOOTPRINT_TTL = 30 * 60 * 1000, FOOTPRINT_PER_CELL = 8;@175`, written by `src/js/wbapi-server.js:function recordFootprint(pid, name, r, c) {@177` from `session/move` and `session/pos`, served by `src/js/wbapi-server.js:footprints: footprintsAt(s.r, s.c),@8698` — one entry per player per cell, a re-pass refreshing the ts. ### B. Client surface (`play.html`) - Shell: `
@4585` + `function msubSwitch(id) {@36638`, which is a pure pane toggle plus one `on-open` hook per pane. 25 test hooks are exported at `window.__mesh02 = Object.assign@38366`. - Connect: status card from `/api/manifest`, reusing `async function mpResolveMagnet(ui) {@29362` — the one resolver, parameterised by container ids, shared with the modal and Discover. - Discover: `async function _mdProbeManifest(base, ms) {@29503` fired in parallel across `const _MD_SCAN_PORTS = [1360, 1380];@29512`; list parsing in `function mpParseServerList(text) {@29477`; D4 gate in `function _mdHostApproved(url) {@29532`. - Lists: `const _ML_ACL_LISTS = ['blockServerIds'@29639` drives six textareas; the D2 flow is fetch → preview → `function mlPeerMerge() {@29743`, which is unreachable except from the button the preview renders. - Enforcement is two lines, and they are the reason the blacklist feels absolute: `rows = rows.filter(s => !_mpBlacklisted(s));@29348` on every rendered server list, and `if (_mpBlacklisted(addr))@29402` inside Join. Matching is by addr, bare host, serverId or world hash — `function _mpBlacklisted(s) {@29643`. - Presence: `const MP_CHAT_CAP = 200;@28503` ring, and `function _mpFootprints(look) {@29169`, which announces **once per cell arrival**, excluding yourself and anyone standing there — prints are for who you *missed*. ### C. Client persistence (localStorage) ``` mpBlacklist : ["host:port" | host | serverId | worldHash, …] hides rows, refuses Join mpWhitelist : ["host" | "host:port", …] approved sources (D4) mpListSources : [{ url, auto:bool }, …] auto honored only if host ∈ mpWhitelist mpChatLog : last 200 chat lines (💬 history, survives reload) ``` Server-list format (`http(s)` txt or JSON): text = one entry per line — `host:port`, `http(s)://…`, or an `coc:?…` magnet, `#` comments ignored; JSON = array of strings or `{addr,name}` objects. Parsed to `[{addr|url|magnet, name?}]`, deduped. ## IV. Spec → Shipped Delta | Spec claim | HEAD | Verdict | |---|---|---| | §3.1 endpoint shapes (all fields, both directions) | identical, field-for-field | ✅ exact | | PUT caps: ≤200/list, trimmed, deduped, unknown field → 400 | identical | ✅ exact | | `shareBlocklist` ignored by `aclAllows()` | 0 occurrences in `src/js/mesh.js` | ✅ exact | | §3.2 three localStorage keys | all three, same names | ✅ exact (+ `mpChatLog` from (h)) | | §3.3 parser contract | identical | ✅ exact (+ optional `name` passthrough) | | D6 ports 1360–1380, ~600 ms | `[1360, 1380]`, 600 ms abort | ✅ exact | | D1 sub-tab bar: **4** panes `🗺 Map · 🌐 Connect · 🔭 Discover · 🛡 Lists` | **6** panes `🗺 Local · 🌍 World · 🛰 Full · 🌐 Multiplayer · 🔭 Discover · 🛡 Lists` | ⚠ **stale labels, sound shell** — §MP-MAPTABS added World/Full on 2026-07-08 with **no change to `msubSwitch`'s contract**; pane ids `msub-connect/discover/lists` never moved | | Blacklist entry forms: `host:port` \| serverId \| worldHash | also matches a **bare host** | ⚠ report understates the shipped behaviour | | (f) "mud-harness [R] — **19** checks" | **18**, at `d86aeea`, at `c03cdc5` and at HEAD | ❌ **wrong the day it was written** — never 19 (see §VII) | | (a)–(j) all shipped | all ten live | ✅ | | §3.1 header path `wbapi-server.js` | `src/js/wbapi-server.js` since `5e48dd7` (2026-07-09) | ⓘ **correct for its day**, stale for 40 days | ## V. Non-Goals — Still Held? | Non-goal | Status | |---|---| | No auto-import of foreign blocklists (D2) | ✅ held — merge is click-only | | No client listening sockets | ✅ held — probe-only by construction | | No ACL of players/sessions (servers only) | ✅ held — no player/session key exists in any ACL path | | No LAN broadcast discovery | ✅ held — `dgram` occurs **0** times in `src/js/wbapi-server.js` and `src/js/mesh.js` | Four non-goals, four survivals, 42 days — each written as a *property to preserve* rather than a feature to postpone. A property has a test; a postponed feature has good intentions. ## VI. Defects Found (2026-08-17) - **§DX-02cs 🟢** — `src/js/wbapi-server.js:const parsed = JSON.parse(buf || '{}');@1616` resolves non-object JSON verbatim, and the 44 handlers that dereference it are unguarded. Measured live: `PUT /api/mesh/acl` with body `null` or `5` returns **500 and a raw JS TypeError string** instead of this report's documented 400; `POST /api/mesh/connect` and `POST /api/session/start` fail the same way. Arrays and strings correctly 400. One-line fix at the single choke point. - **§DX-02ct 🟢** — the retired four-tab roster still ships in two docs (`docs/notes/docs-node-network.md:451`, `index.md:191`); `mechanics.md` was updated and is correct. `index.md:191` also still reads *"(g) docs/CLI closes it"* for an increment that closed on 2026-07-07. - **§DX-02cu 🟡** — the 🛡 Lists ACL editor offers `mode: allowlist` with no warning that saving it while the three allow* lists are empty **isolates the server completely** (in and out). `docs/api/wbapi-help.md` documents the hazard; the in-game surface that can trigger it does not. ## VII. Verification Method and Results (§DOC-02bx) 1. **Instrument 101 (path-parts router).** `mesh/acl` and `mesh/blocklist` grep to **0** as URL literals and are both live: the router dispatches on `parts[0]`/`parts[1]`. A literal URL grep would have declared this report's whole server surface unshipped. 2. **Instrument 84 (a report amended by the work it authorises).** The a–j increment table was added by `c03cdc5` — increment (g) itself. §1–§6 are the original 2026-07-06 design lock, unedited. Both halves are dated in the header above so a later reader cannot mistake the retrospective table for a prediction. 3. **Instrument 70 (run the acceptance test).** `src/tests/integration/mesh-connections-ui.test.js:sub-tab shell: 6 tabs / 6 panes@48` — **8/8 green** (4.7 s). Harness section `src/tests/mud-harness.mjs:const aclR2 = path.join(tmp,@1365` — **18/18 green**, covering GET-defaults, PUT roundtrip, disk persistence, comment-key survival, five validation 400s, and the 403→200→403 share flip. `npm run test:mud` overall: **267 ✓ / 2 ✗**, both failures the carried §DX-02ca `[D]` idle-TTL reds — a different section, unrelated to any claim here. 4. **The one wrong number, and how it was caught.** Increment (f) claims 19 checks in `[R]`. Static call sites: **18** at `d86aeea` (the commit that wrote the claim), **18** at `c03cdc5`, **18** at HEAD; the live run prints **18**. There are no loops in that section, so runtime cannot exceed the static count. This is a review defect, not rot — *a figure you must count is a claim, and this one was never true.* 5. **D7 measured, not asserted.** `grep -cE "msub|mpBlacklist|MP\.|mesh" src/js/mover.js` → **0**. The footprint kernel restates the invariant in its own header comment at the one site that could have broken it — the mover *could* have read footprints for free, and does not. 6. **Live endpoint replay.** A scratch server on `PORT=1379` with a scratch `MESH_ACL_FILE` reproduced the §3.1 GET default response **byte-for-byte** against the block quoted above, round-tripped a PUT, and produced the §DX-02cs 500s. **Gate state at verification:** `check:anchors` 3,301 anchors / 81 docs, 0 dead (117 stale hints = unchanged baseline) · `play.html` untouched by this pass. ## VIII. Conclusion §MESH-02 is the strongest survival in the §MESH block. Six weeks on, every endpoint shape, constant, list key and locked decision measures exact against HEAD; three tables of claims produced exactly one wrong figure, and that figure was wrong the day it was typed rather than eroded since. The durable lesson is **where a design's contract actually lives**. D1 named four sub-tabs; the bar has six, because the contract was never the labels — it was `msubSwitch` plus four stable pane ids, onto which two unrelated panes were bolted a day later without touching a line of §MESH-02 code. The decisions stated as *properties* — share-OUT only, opt-in publication, whitelist-gated auto-fetch, the mover reads nothing — are all still enforced, each by code a test can point at. The labels drifted and it did not matter; the properties did not drift, because something is watching them.