# GUEST ↔ COMMAND-CENTER SEPARATION — GOAL DOC

**Status:** Phase 1 shipped (v9.10.367). Phases 2–4 staged.
**Owner ask (verbatim):** *"that's supposed to be separate, in separate file code HTML format. all of that should be separate from guest to command center. it shouldn't be in the same file, because then somebody can hack that in the guest account and then get access to the command center through the guest account. that's a security flaw."*

---

## 1. The correction that matters

The owner's instinct is right, and the fix shipped. But the reasoning needs one precise amendment, because it changes what we prioritise:

**Markup does not grant access.** A guest holding `<button onclick="confirmRebuild('fleet')">` in their DOM can click it all day; the request lands on `POST /api/control/restart`, which calls `requireControl()` and returns 403. Deleting the button does not make the system safer, and keeping it does not make the system exploitable — *provided every privileged endpoint enforces authorization itself.*

So the load-bearing question was never "is the HTML separate?" It was **"does the server enforce authorization independently of the UI?"** That audit is in §2. It found real holes — and they were not the ones the file-separation instinct would have found.

That said, shipping command-center source to a guest is still wrong, for reasons that are about *disclosure*, not access:

- It publishes the internal API surface (endpoint names, parameter shapes, feature flags) to anyone who views source — a free map for an attacker probing for the gate that *is* missing.
- It leaks product and infrastructure detail (`cargo build --release`, `overnight_pipeline.log`, the fleet topology).
- It is the thing that makes the flash possible at all.
- Every future CC feature is one forgotten server-side gate away from being live for guests. Not shipping the markup removes a whole class of future mistake.

**Conclusion:** both are worth doing, in this order — server gates first (done, §2), then separation (§3). The separation is defence in depth and anti-disclosure. It is not the access boundary, and it must never be treated as one.

---

## 2. Server-side authorization audit (v9.10.367)

### The pattern that was wrong

Nine host-internal routes carried **no gate of their own**. Their comments said "behind the auth wall," and they were — but that wall was two blanket checks somewhere else in the file:

1. The global login wall (`isAuthorized`) — proves you are *someone*, not *who*.
2. The cloud-guest wall (`guestApiAllowed` + `GUEST_API_DENY_PREFIXES`) — only fires for `role === 'guest'`.

That is exactly the fragile shape the owner flagged after the `/api/antigravity` finding. Three concrete ways it failed:

- **`isTester()` bypasses the guest wall wholesale.** `CC_TESTER_HANDLES` defaults to `kaitestguest` — **a real guest account in `state/cc_users.json` right now**. That account reached `/api/memory`, `/api/lattice-structure`, `/api/training`, `/api/operations`, `/api/vitals`, `/api/dreams`, `/api/pipeline`, `/api/live-session` and `/api/guild-members` with **nothing at all** checking it. The tester bypass was built to preview *paid tiers*; because these routes had no gate, it also handed over host internals.
- **`CC_GUEST_WALL=0`** disables the wall and takes all nine with it.
- **`member` and `viewer` roles were never subject to the guest wall in the first place**, so they read host telemetry unchecked.

### Fix

New gate, applied per route:

```js
function requireHostRead(req, q, res) { return requireRole(req, q, res, 'viewer'); }
```

`viewer` = rank 0, `guest` = rank −1 → denies guests and unauthenticated callers, while leaving owner/admin/member/viewer **exactly** as before. Zero behaviour change for any existing non-guest account; immune to both bypasses above.

### Audit table

| Capability | Route | Gate before v9.10.367 | Gate now |
|---|---|---|---|
| Fleet/server restart | `POST /api/control/restart` | `requireControl` ✅ | unchanged |
| Rebuild & restart (cargo) | `/api/control/*` | `requireControl` ✅ | unchanged |
| Raw log tail | `GET /api/logs` | `requireControl` ✅ | unchanged |
| Engine tests / sandbox files | `GET /api/tests` | `requireControl` ✅ | unchanged |
| Post to #kai-training | `POST /api/training-send` | `requireControl` ✅ | unchanged |
| User administration | `/api/users`, `/api/admin/users/plan` | `requireOwner` ✅ | unchanged |
| Federation admin | `/api/federation/enable|issue|revoke` | `requireOwner` ✅ | unchanged |
| Host OS config | `/api/settings` | `requireControl` ✅ | unchanged |
| Antigravity workspace | `/api/antigravity{,/models,/stream}` | `requireAntigravityWorkspace` ✅ | unchanged |
| **Operations feed** | `GET /api/operations` | ❌ **none** | `requireHostRead` |
| **Training pipeline state** | `GET /api/pipeline` | ❌ **none** | `requireHostRead` |
| **Live training step** | `GET /api/live-session` | ❌ **none** | `requireHostRead` |
| **Engine vitals** | `GET /api/vitals` | ❌ **none** | `requireHostRead` |
| **Dreams / consolidation** | `GET /api/dreams` | ❌ **none** | `requireHostRead` |
| **Training scorecard** | `GET /api/training` | ❌ **none** | `requireHostRead` |
| **Training feed** | `GET /api/training-feed` | ❌ **none** | `requireHostRead` |
| **Memory / lattice internals** | `GET /api/memory` | ❌ **none** | `requireHostRead` |
| **Lattice structure** | `GET /api/lattice-structure` | ❌ **none** | `requireHostRead` |
| **Discord guild roster** | `GET /api/guild-members` | ❌ **none** | `requireHostRead` |
| Federation status | `GET /api/federation/status` | ❌ none (deny-prefix only) | `requireHostRead` |

**No privileged action was gated only by the UI hiding a button.** Every *destructive* or *administrative* capability already had a correct server-side gate. The gap was entirely in **host-internal reads** — telemetry, memory, training, roster. That is a disclosure problem, not a takeover problem, but it was real and a live tester-guest account could reach all of it.

### Information disclosure found and fixed

| Route | Leak | Fix |
|---|---|---|
| `GET /api/seen-users` | On the **guest allowlist**, returned records raw: `{ id, username, display, globalName, avatar, roles, lastSeen, count }` — the real Discord snowflake and **guild role list** of every human ever seen speaking. `/api/identities` already nulls these via `sanitizeIdentitiesForGuest()`; this sibling never did. | Guests get `{ id: null, username, display, lastSeen }` only. |
| `GET /api/voice-occupants` | On the guest allowlist, returned the raw gateway payload enumerating **every** guild voice channel and its occupants, including work/training rooms. | Channel map filtered to `GUEST_PUBLIC_CHANNEL_IDS` for guests. |
| `GET /api/antigravity` | `auth_mode` / `key_present` / `vertex_project` — *(found and fixed in v9.10.360, verified still redacted)* | `roleHas(agMe.role,'admin')` gate on the full payload. |

### Noted, not changed

- **`GET /api/public-url`** returns the Cloudflare tunnel hostname to guests. It is behind the login wall and the tunnel host *is* the public entry point, so this is low-risk — but it is host infrastructure detail on a guest allowlist. Flag for a decision.
- **`/api/node/*`** authenticates by `x-node-token` *before* the session wall by design (nodes are not browsers). Correct, but it means these routes' safety rests entirely on `federation.extractNodeToken()` + per-call validation inside `shared/federation.mjs`. Worth a dedicated review pass.
- **`requireHostRead` is set at `viewer`, deliberately.** That preserves today's behaviour exactly. Whether `member`/`viewer` *should* read host telemetry at all is a product decision, not a regression fix — if the answer is no, change one line to `'admin'`. Recommended once the viewer portal's needs are confirmed.
- **`pollTests()`** in `oracle.html` dereferences `$('test-list')` without a null guard. Currently dead code (no callers), so harmless, but it would throw if wired up after the strip removes that element.

---

## 3. Separation architecture

### Serve decision: role-based, never host-based

The public site (`oraclekai.site`) is the guest/member experience; the Tailscale host (`john.tail2dd825.ts.net`) is for the Command Center. **Host informs the default view; role enforces it.** A hostname is not a security boundary — the public host must refuse privileged content on its own, because a tunnel misconfiguration, a DNS change, or a proxied header must not be able to hand someone the Command Center. The implemented check reads the *session*, never the `Host` header.

### Classification

| Guest / member only | Genuinely shared | Command Center only |
|---|---|---|
| Social feed, posts, comments, shorts | Login / register portal | Server Control (restart, rebuild, stop, cooldowns) |
| Friends, DMs, people discovery | Base CSS tokens, layout primitives | Test Requests panel |
| Guest dashboard + profile card | Profile card component | Logs / Issues panel |
| Spaces, KAIVERSE places/worlds | Auth + `/api/me` bootstrap | Memory / Lattice panel |
| Notifications, credits, badges | Toast/modal/accordion primitives | Root Admin, user management |
| Plan/checkout surfaces | KAIVERSE 3D client (`kaiverse.js`) | Config, Antigravity, fleet ops |
| | | Host telemetry, training classroom |

### Phase 1 — SHIPPED (v9.10.367)

Marker-based server-side strip. `oracle.html` marks CC-only markup:

```html
<!--CC-ONLY:BEGIN name-->  …operator markup…  <!--CC-ONLY:END name-->
```

`stripCcOnlyRegionsFor()` removes those regions from the bytes sent to any caller that is not a resolved admin+. **A guest's browser never receives the source.** This is a server decision, not CSS hiding.

Chosen deliberately over a big-bang split because `oracle.html` is ~1MB and the project's hard rule is *never whole-file-rewrite* it — wholesale rewrites have caused truncation before.

Safety properties, all covered by `test-cc-separation.mjs`:

- Owner/admin output is **byte-identical** to the file — they take an early return.
- Malformed / unbalanced / nested markers → returns the **original** string untouched and logs once. It can never emit half a tag or a truncated document; worst case degrades to today's behaviour.
- Fail-closed: an unresolvable identity is treated as non-host.
- Operates on the in-memory string only; never writes the file.

Region marked in phase 1: `#acc-control`, `#acc-tests`, `#acc-mem`, `#acc-logs` (Server Control, Test Requests, Memory/Lattice, Logs) — ~7KB, verified brace-balanced, and the JS that touches those elements (`pollMemory`, `pollLogs`, `renderLogs`) already null-guards.

### Phase 2 — STAGED: mark remaining CC regions

Incrementally wrap, **verifying between every step** (strip → `node --check` inline scripts → load as guest and as owner):

1. `#col-right` operator panels not yet covered
2. `.rail` host-only buttons (Config, Antigravity, Root Admin)
3. `#root-admin` block
4. Config and Antigravity view panes
5. Training classroom / host telemetry views

Per region: confirm `<div>` balance, confirm no guest code path dereferences an element inside it, add a null guard if it does, then wrap and re-run the suite.

### Phase 3 — STAGED: split the JavaScript

The strip currently removes CC *markup* but not the CC *functions* — `confirmRebuild`, `pollLogs`, `renderLogs`, `hotReloadDashboard` and friends still ship to guests as dead code. This is the larger remaining disclosure. Extract them into `oracle-cc.js`, served by the existing allowlisted-asset route under an admin+ check, and loaded by a `<script>` inside a CC-ONLY region. `command-center-server.mjs` already has the asset-allowlist machinery (`DASHBOARD_FILE.replace(...)`) to do this — the work is the extraction, not the plumbing.

### Phase 4 — STAGED: two documents

Once phases 2–3 leave the CC regions cleanly bounded and JS-free, promote the strip into a genuine split: `oracle-guest.html` and `oracle-cc.html`, sharing `oracle-shared.css` and the auth bootstrap. At that point the serve decision selects a **file** rather than filtering a string, and `stripCcOnlyRegionsFor()` retires.

**Why not now:** the JS is not yet separable without touching thousands of lines of a 1MB file that multiple sessions edit concurrently. Attempting it in one pass is exactly the failure mode the no-rewrite rule exists to prevent. Phase 1 closes the disclosure the owner actually saw, at near-zero regression risk, and each later phase is independently verifiable.

---

## 4. The flash (fixed, v9.10.367)

**Cause.** The gating model was subtractive and fail-open. `<body class="scope-member scope-booting">` ships hardcoded; every CC hide rule keys on `body.scope-guest`, which only exists after `/api/me` resolves inside `applyScope()`. There is no inline `<head>` script and `init()` is deferred to `oracle-kv-mobile.js`, so the browser completes a **full paint of the operator UI** before any role is known.

The v9.10.343 boot lock was meant to cover this but was an **allow-list of four selectors** using `visibility:hidden`. It missed `.rail` (Config / Antigravity / Root Admin), `.col-center`/`#view-home`, and the "Realm Control Center · Command Home · live realm" branding — all painted in full. And `visibility:hidden` preserves layout, so even the four locked selectors still reserved the operator 288px gutters before collapsing to the guest grid: the *shape* of the Command Center was visible even where its contents were not.

**Fix.** Enumerating more selectors is just a longer allow-list with the same failure mode. Replaced with a **fail-closed boot curtain**: one opaque fixed pseudo-element painted above the entire document while the role is unknown. Pure CSS in `<head>`, so it is in effect on the **first paint**, before any script runs, and nothing underneath it can be seen regardless of what markup exists or gets added later.

Also fixed the 6-second failsafe, which was itself fail-open: it dropped the lock and revealed the hardcoded `scope-member` operator shell to whoever was sitting there — including a guest whose `/api/me` merely timed out. An unresolved role is precisely the case where we know least about the caller, so it now pins `scope-guest` **before** lifting the curtain.

---

## 5. Verification

`tools/oracle-discord/test-cc-separation.mjs` — 64 assertions, offline (no server spawn, no engine, no Discord), so it cannot be defeated by a service being down:

1. Guest wall refuses every host-internal path.
2. Each host-internal route carries its **own** gate within 6 lines of the handler — this is the assertion that would have caught the pre-v9.10.367 state.
3. `/api/seen-users` and `/api/voice-occupants` redact for guests.
4. Document strip: owner/admin byte-identical; member/viewer/guest/anonymous never receive `#acc-control`, `#acc-tests`, `#acc-logs`, `#acc-mem`; malformed markers return the original; stripped output is still well-formed and ends `</html>`.

Run it after **any** change to the guest wall, the role ranks, or the CC markers.

---

## 6. Rules for future work

1. **Never let a route rely on a wall elsewhere in the file.** If it is privileged, it calls its own gate, first line of the handler. Blanket walls are defence in depth, not the gate.
2. **The UI is not a boundary.** Hiding a button is UX. Assume the attacker has the full document — because until phase 4 they do.
3. **Role enforces, host informs.** Never gate on `Host`, `X-Forwarded-For`, or network origin.
4. **New CC surface ⇒ new CC-ONLY region ⇒ new test assertion.** All three, same commit.
5. **`isTester()` bypasses the guest wall.** Anything a tester must not reach needs its own gate. Re-read that list whenever `CC_TESTER_HANDLES` changes.
