feat(worker): read-only Observation TV broadcast behind CLAUDE_MEM_TV_TOKEN

* feat(ui): observation TV — fullscreen fading titles off the existing SSE stream

Adds a standalone, dependency-free page that consumes the same /stream the
React viewer does and plays each observation's title as a fullscreen fading
card. Live arrivals play first; a seeded backlog from /api/observations cycles
while the worker is idle, so the screen is never blank.

Picture-in-picture without a broadcast library: Document PiP (Chromium) moves
the real DOM into the floating window so the CSS fades keep running, and
everywhere else — including iOS Safari, the phone case — the card is painted
to a canvas whose captureStream() feeds a muted video into native PiP.

Served two ways: express.static already exposes plugin/ui, so /tv.html works
with no route change, and a /tv alias is cached at boot the same way
viewer.html is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6QPdnPducVehMwCM2HYNC

* docs(plans): observation TV read-only broadcast + shared-secret token

Phased plan for the locked 2026-09-05 decision: expose Observation TV to a
second device on the LAN without exposing the rest of the worker.

The worker has no request authentication anywhere; its only defence is the
loopback bind, and the codebase says so out loud (ServerService.ts:129-131).
So CLAUDE_MEM_WORKER_HOST=0.0.0.0 today does not put the TV on the LAN, it
puts GET /api/settings — which returns the user's Gemini and OpenRouter API
keys in plaintext — on the LAN, alongside the settings writer, the row
deletes, bulk import, and better-auth's key issuance.

The design is one guard middleware mounted at position zero in the Server
constructor, the only spot that covers /api/auth/*, /api/admin/*, the static
mount, and every route registered later. It is a no-op for loopback and, for
non-loopback requests, default-deny with a four-path exact-match allowlist
behind a new CLAUDE_MEM_TV_TOKEN. An empty token means the guard is never
mounted, so every existing install — including the documented Docker 0.0.0.0
setup — is byte-identical to today.

Phase 0 is written out rather than delegated: ~45 routes inventoried with
file:line, the copy-ready patterns named (requireLocalhost, parseBearerToken,
safeEqualHex, the securityHeaders opt-in precedent), and five traps recorded,
including that SettingsDefaultsManager.get() cannot see settings.json and that
the worker never calls finalizeRoutes() so the guard must write its own
responses. Appendix B lists every rejected option with its reason —
cloudflared first among them.

Plan only. Nothing implemented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMh2GZST1UgKDSML17qCmh

* feat(worker): read-only Observation TV broadcast behind CLAUDE_MEM_TV_TOKEN

The worker's HTTP surface (45+ routes) has no request authentication; the
loopback bind is its only defence. So setting CLAUDE_MEM_WORKER_HOST=0.0.0.0 —
which the Docker docs tell people to do — puts GET /api/settings (provider API
keys in plaintext), POST /api/admin/restart, DELETE /api/observation/:id,
POST /api/import and better-auth on the LAN.

Add one guard middleware, mounted at position zero in the Server constructor —
the only spot that covers /api/auth/*, /api/admin/*, the static mount and every
route registered later, including routes that do not exist yet. It is a no-op
for loopback and, for non-loopback requests, default-deny with an exact-match
four-path allowlist behind a shared secret:

  /tv, /tv.html, /stream, GET /api/observations

A GET/HEAD method gate kills every mutation; non-allowlisted paths get 404 so a
scanner is not told which routes exist; the token is compared constant-time and
accepted as Authorization: Bearer, X-Api-Key, or ?token= (the query form exists
only because EventSource cannot set headers). The token is never logged.

Empty token means the guard is never mounted, so every existing install behaves
exactly as before and CLAUDE_MEM_WORKER_HOST keeps its 127.0.0.1 default. A
boot-time SECURITY warning fires when the host is non-loopback with no token —
warn, not refuse, so the documented Docker deployment keeps working.

Also fixes createCorsMiddleware forwarding next(new Error('CORS not allowed')):
the worker never calls finalizeRoutes(), so that reached Express's default
handler and returned a 500 HTML stack trace with absolute filesystem paths —
newly reachable from the LAN. It now writes its own 403 JSON.

tv.html carries the token through to both of its calls, and cards now show
platform_source with a per-source accent colour in both the DOM and canvas
render paths.

No new dependencies. 38 tests in tests/server/tv-remote-guard.test.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xcn8Gf6ACkfDqLYaULAj2k

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-09-05 17:59:00 -07:00
committed by GitHub
parent b6e05382e2
commit ad3dcaa2fc
12 changed files with 2654 additions and 3 deletions
+62
View File
@@ -20,6 +20,7 @@ Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created
| `CLAUDE_MEM_CONTEXT_OBSERVATIONS` | `50` | Number of observations to inject |
| `CLAUDE_MEM_WORKER_PORT` | `37700 + (uid % 100)` | Worker service port (per-user default; override for fixed port) |
| `CLAUDE_MEM_WORKER_HOST` | `127.0.0.1` | Worker service host address |
| `CLAUDE_MEM_TV_TOKEN` | — | Shared secret for Observation TV remote access. Empty = off. With a non-loopback `CLAUDE_MEM_WORKER_HOST`, only `/tv`, `/tv.html`, `/stream` and `GET /api/observations` are reachable, and only with this token. |
| `CLAUDE_MEM_DATA_DIR` | `~/.claude-mem` | Data root — every other path (database, chroma, logs, settings.json, worker.pid) derives from this |
| `CLAUDE_MEM_SKIP_TOOLS` | `ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion` | Comma-separated tools to exclude from observations |
@@ -185,6 +186,67 @@ Search operations are provided via:
Worker service is managed by Bun as a background process. The worker auto-starts on first session and runs continuously in the background.
### Observation TV Remote Access
The worker binds to `127.0.0.1` by default and has **no request authentication** — the loopback bind is its only defence. `CLAUDE_MEM_TV_TOKEN` adds a read-only broadcast surface so a second device on your LAN (a phone, an iPad, a spare monitor) can watch Observation TV and do nothing else.
Mint a token:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
```
The host setting and the token work as a pair:
| `CLAUDE_MEM_WORKER_HOST` | `CLAUDE_MEM_TV_TOKEN` | Result |
|---|---|---|
| `127.0.0.1` (default) | empty | Today. Nothing reachable off-box. **Recommended for everyone not using the TV remotely.** |
| `127.0.0.1` | set | Token is inert — nothing can reach the port anyway. Harmless. |
| `0.0.0.0` | **empty** | **Dangerous, and unchanged from today**: the full API — settings incl. provider API keys, deletes, import, better-auth — is on the LAN. This is what the Docker setup already tells people to do, so the worker warns rather than refuses to bind. |
| `0.0.0.0` | set | The point of the feature. LAN devices reach `/tv`, `/tv.html`, `/stream`, `GET /api/observations` with the secret, and get 404/403/401 for everything else. |
Booting with a non-loopback host and no token logs a `SECURITY` warning; the worker still binds.
**A local port-forward reopens the whole API.** The guard exempts loopback by socket peer, so anything that arrives *as* loopback bypasses it entirely — including `ssh -L <port>:127.0.0.1:<port> <box>` from another device, or any local forwarding proxy. To the worker that traffic is indistinguishable from the operator's own browser, so it gets the full API, `GET /api/settings` and its provider API keys included. This is the one concrete way "the device can do nothing else" stops being true: only forward the port to devices you would hand the machine to.
**Docker and bridge networking.** With `CLAUDE_MEM_WORKER_HOST=0.0.0.0` inside a bridge-network container, requests from the host's own browser arrive from the bridge gateway address, not `127.0.0.1`. Once a token is set, that browser is treated as remote like any other device and must supply `?token=` to open the TV — and the React viewer at `/` is denied outright, exactly as it is for any other remote device.
Open the TV on the second device at `http://<lan-ip>:<port>/tv.html?token=<secret>`.
**The React viewer at `/` stops working remotely when a token is set.** That is intended — the viewer needs the write and settings routes the guard denies. Loopback is never gated, so the viewer keeps working normally on the machine running the worker.
**Treat the URL as the secret.** The token rides in the query string for `/stream` because the browser's `EventSource` API cannot set request headers. It is *not* written to the worker log (the request logger records `req.path`, which excludes the query string), but it **will** be in the browser's history on the device you open it on. `Authorization: Bearer <token>` and `X-Api-Key: <token>` also work for anything that can set headers.
**This is plain HTTP.** The token stops an unauthenticated device from reading the stream; it does not encrypt it. Anyone who can sniff your LAN sees observation titles. For a home network that is the accepted tradeoff; for anything else, terminate TLS in front of the worker (out of scope here) — and still never a tunnel to the unmodified worker, which would publish `GET /api/settings` and its provider API keys to the open internet.
**Use the exact paths.** The allowlist is exact-match and case-sensitive: `/tv.html`, `/tv`, `/stream`, `/api/observations` and nothing else. A trailing slash or a different case — `/tv/`, `/API/observations`, `/api/observations/by-file` — returns 404 even with a valid token. That is deliberate fail-closed behavior, not a bug.
The TV's `?project=` and `?source=` filters are **client-side only**. They are display filters, not access control — a token holder still receives every project's observations.
Accepted risks, stated plainly:
1. **`GET /api/observations` returns full observation bodies** — `narrative`, `facts`, `text`, `files_read`, `files_modified` — for **every project on the box**, not just the four fields the TV renders. A token holder can page through the entire memory database with `offset`.
2. **`/stream` is unfiltered.** It carries every observation for every project on the box, plus the project catalog and processing status. There is no per-client filtering.
3. **No rate limiting.** A token holder can hammer `GET /api/observations` freely.
4. **The token is readable on loopback** via `GET /api/settings`, exactly like your provider API keys are today. It is deliberately *not* writable through `POST /api/settings` (that route is unauthenticated), so it can only be set by someone with filesystem or environment access.
5. **`/stream` is not side-effect-free.** Every new connection triggers an `initial_load` broadcast to *all* connected clients, and the client list is unbounded. A token holder reconnecting in a loop can therefore disturb the operator's own local viewer. This is pre-existing worker behavior; the token is what newly makes it reachable from the LAN.
### Manual Configuration
Edit `~/.claude-mem/settings.json`:
```json
{
"CLAUDE_MEM_WORKER_HOST": "0.0.0.0",
"CLAUDE_MEM_TV_TOKEN": "your-minted-token"
}
```
Then restart the worker:
```bash
npm run worker:restart
```
## Folder Context Files
Claude-mem can automatically generate `CLAUDE.md` files in your project folders with activity timelines. This feature is disabled by default.
@@ -0,0 +1,799 @@
# Observation TV — read-only broadcast + shared-secret token
**Date:** 2026-09-05
**Worktree / branch:** `.claude/worktrees/observation-tv` / `worktree-observation-tv`
**Builds on:** commit `8489f78b`*feat(ui): observation TV — fullscreen fading titles off the existing SSE stream*
**Live status doc:** `/workspace/obs-broadcast/STATUS.md`
**Distinct from:** Pepper / Booth X Live. Nothing here touches those.
---
## Primary goal
**Alex can open Observation TV on a second device on his LAN — phone, iPad, spare monitor — and that
device can see observation titles stream by, and can do NOTHING ELSE to the worker.** Not restart it,
not read `~/.claude-mem/settings.json`, not delete an observation, not import rows, not toggle MCP.
Everything below is measured against that sentence. A task that does not move a request from
"the whole worker API is reachable" toward "exactly four read-only paths are reachable, and only
with the secret" does not belong in this plan.
**Locked decision (Prioritizer, 2026-09-05):**
- Local-only for now. No phone-over-internet expose in this slice.
- Build **option (b)**: read-only broadcast surface + shared-secret token.
- **cloudflared / any tunnel in front of the unmodified worker is REJECTED.** It publishes
`POST /api/admin/restart`, `POST /api/settings`, and `GET /api/settings` (which returns the
user's Gemini and OpenRouter API keys in plaintext) to the open internet. Do not propose it
again in this plan's phases, in comments, or in docs.
---
## The problem in one paragraph
The worker's entire HTTP surface — **45+ routes across 13 route classes** — has *no request
authentication of any kind*. Its only defence is the loopback bind. The codebase says so out loud:
`src/server/runtime/ServerService.ts:129-131` turns on hardening headers for the server runtime with
the comment *"server runtime is reachable over the network in Docker, so it emits hardening headers
(the worker, loopback-only, does not)."* `requireLocalhost` (`src/services/worker/http/middleware.ts:64-86`)
guards only three routes: `/api/admin/restart`, `/api/admin/shutdown`, `/api/admin/doctor`
(`src/services/server/Server.ts:291,305,326`). Everything else — `POST /api/settings`,
`GET /api/settings`, `DELETE /api/observation/:id`, `POST /api/import`, `POST /api/logs/clear`,
`DELETE /api/corpus/:name`, `ALL /api/auth/*splat` — is wide open to anything that can reach the port.
So flipping `CLAUDE_MEM_WORKER_HOST=0.0.0.0` today does not "put the TV on the LAN"; it puts the
**entire memory database, the settings writer, and the provider API keys** on the LAN.
**The fix shape:** one guard middleware, mounted first, that is a *no-op for loopback* and for
non-loopback requests is *default-deny with a four-path allowlist behind a shared secret*.
---
## Binding constraints
1. **Allowlist, never denylist.** The route count grows every release. A denylist is a list of
the routes someone remembered; the next PR adds route 46 and it is remote-readable by default.
2. **No new dependency.** No `helmet`, no `express-rate-limit`, no `passport`, no `jsonwebtoken`.
The repo has a documented policy against exactly this (`Server.ts:96-105`: helmet was declined
and the headers hand-rolled to keep the esbuild bundle unchanged).
3. **Default behavior is byte-identical to today.** Token unset ⇒ guard is not mounted ⇒ nothing
changes for any existing install, including the documented Docker `0.0.0.0` setup
(`docs/docker.md:12`).
4. **`CLAUDE_MEM_WORKER_HOST` default stays `127.0.0.1`.** This plan never changes it. It documents
how to change it and what the token does when you do.
5. Diffs the size of the defect. Do not refactor `Server.ts`, do not restructure the route classes.
6. Do not edit `CHANGELOG.md` (generated).
---
## Phase 0 — Consolidated discovery (READ THIS; DO NOT RE-DERIVE)
Verified 2026-09-05 against `worktree-observation-tv` @ `8489f78b` by direct file reads. Every claim
below carries a `file:line`. If a later phase's instruction disagrees with something here, stop and
re-read the file — do not guess.
### 0.1 How the express app is assembled
`src/services/server/Server.ts:119-134` — the constructor, in exact order:
```ts
127: this.app = express();
128: this.app.disable('x-powered-by');
129: this.setupSecurityHeaders(); // Server.ts:198-206 — opt-in via options.securityHeaders
130: this.setupCors(); // Server.ts:208-210
131: this.setupPreBodyParserRoutes(); // Server.ts:212-214 — mounts ALL /api/auth/*splat
132: this.setupMiddleware(); // Server.ts:193-196 — json parser, logger, express.static
133: this.setupCoreRoutes(); // Server.ts:216-366 — mounts /api/admin/*, /api/health, ...
```
Then, **after construction**, `src/services/worker-service.ts:299` calls `registerRoutes()`
(`worker-service.ts:314-366`), which mounts Chroma → an init gate → Viewer → Session → Data →
Settings → Logs → Memory → ServerV1. Three more classes mount later still, inside
`initializeBackground()`: SearchRoutes (`worker-service.ts:564`), CorpusRoutes (`:573`),
CloudSyncRoutes (`:580`).
**Consequence that decides the design:** the only position that covers `/api/auth/*` (mounted at
constructor step 131), `/api/admin/*` (step 133), the static file mount (step 132), *and* every
route added later — including routes that do not exist yet — is **position zero, at the very top of
the constructor.** Anything mounted afterwards leaves earlier routes uncovered.
### 0.2 What already exists that we will copy
| Thing | Location | Why it matters here |
|---|---|---|
| `requireLocalhost(req,res,next)` | `src/services/worker/http/middleware.ts:64-86` | The house idiom for an IP check: reads `req.ip \|\| req.connection.remoteAddress`, compares against `127.0.0.1` / `::1` / `::ffff:127.0.0.1` / `localhost`, logs `logger.warn('SECURITY', ...)`, answers `403` JSON. **Copy this file's shape for the new guard.** |
| `isLocalhost(req)` | `src/server/middleware/request-auth-helpers.ts:15-21` | Identical check, already extracted as a pure helper, already unit-testable. Prefer importing this over re-implementing. |
| `parseBearerToken(header)` | `src/server/middleware/request-auth-helpers.ts:10-13` | `/^Bearer\s+(.+)$/i` → trimmed token or `null`. Copy-ready. |
| `hasForwardedClientHeaders(req)` | `src/server/middleware/request-auth-helpers.ts:44-51` | Detects `forwarded` / `x-forwarded-for` / `x-forwarded-host` / `x-real-ip`. Used in the existing auth to refuse a "loopback" claim that came through a proxy. |
| Header precedence `Bearer``X-Api-Key` | `src/server/middleware/auth.ts:40-46` | The established order and the exact 401 message wording. |
| `safeEqualHex(a,b)` | `src/server/auth/sqlite-api-key-service.ts:83-97` | Length pre-check then `crypto.timingSafeEqual`, with a `logger.warn` on malformed input. **Copy this verbatim shape** for the token comparison. |
| `createRawServerApiKey()` | `src/server/auth/sqlite-api-key-service.ts:155-157` | `` `cmem_${randomBytes(32).toString('base64url')}` `` — the house secret-minting idiom. |
| Opt-in `ServerOptions` flag | `src/services/server/Server.ts:88-93` (`securityHeaders?: boolean`) + `:198-206` | The precedent for "a Server capability the worker opts into and the other runtime does not". **Model the new option on this exactly.** |
| "Feature is on iff the token setting is non-empty" | `src/services/worker/DatabaseManager.ts:39-47` (CloudSync) | The house pattern for a secret-gated optional subsystem. Comment at `:39-41` explains the reasoning. |
| Settings declaration + default | `src/shared/SettingsDefaultsManager.ts:22-131` (interface), `:134-238` (defaults) | Where a new `CLAUDE_MEM_*` key is declared. `CLAUDE_MEM_CLOUD_SYNC_TOKEN: ''` at `:200` is the nearest analogue. |
| Settings read | `SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH)` — `worker-service.ts:453`, `DatabaseManager.ts:23` | The canonical read. **See the trap in 0.5.** |
| Settings write whitelist | `src/services/worker/http/routes/SettingsRoutes.ts:77-108` | Keys absent from this array **cannot be written** through `POST /api/settings`. Chroma / Telegram / CloudSync keys are all deliberately absent. |
| Test harness for a booted Server | `tests/server/server-security-headers.test.ts:10-45` | Real `new Server(baseOptions({...}))`, `await server.listen(randomPort, '127.0.0.1')`, then `fetch`. **Copy this file as the skeleton for the guard tests.** |
| Docs table format | `docs/public/configuration.mdx:14-24` | `\| \`CLAUDE_MEM_X\` \| \`default\` \| one-line description \|` |
### 0.3 The routes, grouped by what a remote request must NOT reach
Complete inventory taken from a `.get(|.post(|.put(|.patch(|.delete(|.all(` sweep over
`src/services/worker/http/`, `src/services/server/Server.ts`, `src/server/auth/BetterAuthRoutes.ts`,
`src/server/routes/v1/ServerV1Routes.ts`.
**Catastrophic if remote (secrets / process control / destruction):**
| Route | File:line | Guard today | What it gives an attacker |
|---|---|---|---|
| `GET /api/settings` | `SettingsRoutes.ts:29` | **none** | Returns the whole settings file: `CLAUDE_MEM_GEMINI_API_KEY`, `CLAUDE_MEM_OPENROUTER_API_KEY`, `CLAUDE_MEM_CLOUD_SYNC_TOKEN`, `CLAUDE_MEM_CHROMA_API_KEY` |
| `POST /api/settings` | `SettingsRoutes.ts:30` | **none** | Rewrites provider/model/host/port config |
| `POST /api/admin/restart` | `Server.ts:291` | `requireLocalhost` | Kills the worker |
| `POST /api/admin/shutdown` | `Server.ts:305` | `requireLocalhost` | Kills the worker |
| `GET /api/admin/doctor` | `Server.ts:326` | `requireLocalhost` | pids, env-clean state, dependency dump |
| `DELETE /api/observation\|summary\|prompt/:id` | `DataRoutes.ts:93,94,95` | **none** | Irreversible row deletion |
| `POST /api/import` | `DataRoutes.ts:102` | **none** | Bulk-writes into the memory DB |
| `DELETE /api/corpus/:name` | `CorpusRoutes.ts:76` | **none** | Destroys a built knowledge corpus |
| `POST /api/logs/clear` | `LogsRoutes.ts:85` | **none** | Erases the audit trail |
| `POST /api/mcp/toggle` | `SettingsRoutes.ts:34` | **none** | Disables the MCP subsystem |
| `ALL /api/auth/*splat` | `BetterAuthRoutes.ts:31` | **none** | better-auth: sessions, orgs, **API-key issuance** |
**Bulk data read (every project on the box, full text):** `GET /api/observations`, `/api/summaries`,
`/api/prompts`, `/api/observation/:id`, `/api/observations/by-file`, `/api/session/:id`,
`/api/prompt/:id`, `/api/stats`, `/api/projects`, `/api/logs`, `/api/search*`, `/api/timeline*`,
`/api/context/*` — `DataRoutes.ts:83-100`, `LogsRoutes.ts:84`, `SearchRoutes.ts:146-158`.
**Everything under `/v1/*`** (`ServerV1Routes.ts:73-260`) — already behind `requireServerAuth`, and
stays behind it; the new guard denies it remotely as well, belt and braces.
**The four the TV actually needs:** `GET /tv` (`ViewerRoutes.ts:162`), `GET /tv.html` (served by
`express.static(plugin/ui)` at `middleware.ts:36-38`), `GET /stream` (`ViewerRoutes.ts:164`,
handler `:224-266`), `GET /api/observations` (`DataRoutes.ts:83`, handler `:104-109`).
Confirmed from the page itself — `src/ui/tv.html` makes exactly two network calls:
`fetch('/api/observations?' + qs)` at `tv.html:265` and `new EventSource('/stream')` at `tv.html:277`.
Nothing else. tv.html is dependency-free with no external resources, so it needs **zero** static assets.
### 0.4 Existing middleware, and what it does and does not do
- **CORS** — `middleware.ts:43-62`, mounted globally at `Server.ts:208-210`. Rejects an `Origin`
that is not `http://localhost:*` / `http://127.0.0.1:*` by calling `next(new Error('CORS not allowed'))`.
Two things to know: (a) it only fires when an `Origin` header is present, so `curl` and
`EventSource` from a same-origin page sail past it; (b) it is **not** a security boundary — CORS
restricts what a *browser page from another origin* may read, not what a device on the LAN may request.
- **No `trust proxy`.** Verified: `app.set('trust proxy', ...)` appears nowhere in `Server.ts` or
`worker-service.ts`. So `req.ip` is the real socket peer and `X-Forwarded-For` is ignored by
Express — an attacker **cannot** spoof a loopback `req.ip` with a header. Good. Do not add
`trust proxy` in this plan; it would break that property.
- **Request logger** — `middleware.ts:12-34`. Logs `req.path`, which in Express **excludes the query
string**. So a `?token=` never reaches the log file through this path. This is load-bearing for
the query-param decision in Phase 2 — do not "improve" the logger to log `req.originalUrl`.
- **`finalizeRoutes()` is never called on the worker.** Verified: `grep -rn finalizeRoutes src/`
returns the definition at `Server.ts:187-191` and exactly one call site,
`src/server/runtime/ServerService.ts:217` (the *other* runtime). The worker therefore has **no**
`notFoundHandler` and **no** terminal `errorHandler`. **Consequence:** the new guard must write its
own response. Never `next(err)` from it — that lands in Express's default handler and returns an
HTML stack page.
- **No rate limiting exists for HTTP.** `globalRateLimitStore` (`src/services/worker/RateLimitStore.ts:101`)
tracks *LLM provider* quota, consumed only at `ClaudeProvider.ts:303,317` and reported at
`Server.ts:239`. `src/server/middleware/rate-limit.ts` is Postgres-backed and wired only into the
Server-Beta Postgres routes. **Neither is reusable here.** Do not try.
### 0.5 Traps
1. **`SettingsDefaultsManager.get(key)` does NOT read `settings.json`.** `SettingsDefaultsManager.ts:245-247`
is `return process.env[key] ?? this.DEFAULTS[key]`. A token written to `~/.claude-mem/settings.json`
is invisible to `.get()`. The token must be read with
`SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH)` — as CloudSync does
(`DatabaseManager.ts:23`, `worker-service.ts:453`). Precedence is env > settings.json > DEFAULTS
(`applyEnvOverrides`, `SettingsDefaultsManager.ts:253-261`).
2. **`SettingsManager` ≠ `SettingsDefaultsManager`.** `src/services/worker/SettingsManager.ts` is a
SQLite `viewer_settings` table holding `sidebarOpen` / `selectedProject` / `theme`. Unrelated.
Do not put the token there.
3. **EventSource cannot set request headers.** This is a hard constraint of the browser API and it is
the *only* reason a query parameter is in the design. It is not laziness.
4. **`/tv.html` is served by `express.static`, not by a route handler.** `middleware.ts:36-38` mounts
`plugin/ui`; `ViewerRoutes.ts:157-158` additionally mounts `ui`. Both sit inside the constructor,
so both are behind a position-zero guard. A guard mounted in `ViewerRoutes.setupRoutes` would be
too late for `middleware.ts:36`.
5. **The environment has no `node_modules`.** Per STATUS.md, the previous session could not run
`npm run build` or `tsc`. Phase 1 begins with `npm install`; if it fails, say so and stop rather
than shipping type-unchecked route code.
### 0.6 Allowed APIs (use only these; anything else, go read the file first)
From `express`: `RequestHandler`, `Request`, `Response`, `NextFunction`, `req.ip`, `req.socket.remoteAddress`,
`req.method`, `req.path`, `req.query`, `req.header(name)`, `res.status().json()`, `res.setHeader()`.
From `node:crypto`: `randomBytes`, `createHash`, `timingSafeEqual`.
From the repo: `logger` (`src/utils/logger.js`), `isLocalhost` / `parseBearerToken` / `hasForwardedClientHeaders`
(`src/server/middleware/request-auth-helpers.js`), `SettingsDefaultsManager`
(`src/shared/SettingsDefaultsManager.js`), `USER_SETTINGS_PATH` / `paths.settings()` (`src/shared/paths.js`).
### 0.7 Anti-patterns — do not do these
- ❌ **cloudflared / ngrok / any tunnel to the unmodified worker.** Rejected by decision. See the
catastrophic-routes table for why.
- ❌ **Reusing `requireServerAuth` / better-auth for the TV.** Deliberate rejection, not an oversight.
It is DB-backed (`verifyServerApiKey` → `AuthRepository` → `bun:sqlite`), scope-based, and needs
the DB open — but the TV must render during the init window when `worker-service.ts:328-351`
is still answering `503` for `/api/*`. It also cannot authenticate an `EventSource`. Park it for Pro
(Appendix A).
- ❌ **A denylist of "dangerous" routes.** See constraint 1.
- ❌ **Adding a dependency** (helmet / express-rate-limit / passport / jsonwebtoken / cors).
- ❌ **Inventing a scopes system, a JWT, a login page, or a session cookie.** One shared secret.
- ❌ **Changing the `CLAUDE_MEM_WORKER_HOST` default to `0.0.0.0`.**
- ❌ **`app.set('trust proxy', true)`.** It would let a header spoof loopback (0.4).
- ❌ **`next(err)` from the guard.** No error handler exists on the worker (0.4).
- ❌ **`===` on the token.** Non-constant-time. Use the `safeEqualHex` shape.
- ❌ **Logging the token**, at any level, including `logger.debug`. Log a `sha256:<first8>` fingerprint if you must.
- ❌ **Adding `CLAUDE_MEM_TV_TOKEN` to the `settingKeys` array** in `SettingsRoutes.ts:77-108`. See Phase 1 task 3.
---
## Phase 1 — The setting: `CLAUDE_MEM_TV_TOKEN`
**How this serves the primary goal:** the token is the single switch. Empty ⇒ no remote surface at
all and today's behavior unchanged. Non-empty ⇒ the guard exists and the four paths open to whoever
holds it.
### 1.1 Declare the setting
`src/shared/SettingsDefaultsManager.ts` — add to the `SettingsDefaults` interface (block `:22-131`)
and to `DEFAULTS` (block `:134-238`). Copy the shape of `CLAUDE_MEM_CLOUD_SYNC_TOKEN: ''` at `:200`:
```ts
// Observation TV remote broadcast. EMPTY = OFF: the read-only guard is not
// mounted and the worker behaves exactly as before. Set (with a non-loopback
// CLAUDE_MEM_WORKER_HOST) to expose ONLY /tv, /tv.html, /stream and
// GET /api/observations to holders of this secret. Mint with:
// node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
CLAUDE_MEM_TV_TOKEN: '',
```
### 1.2 Read it the way CloudSync does
Copy `src/services/worker/DatabaseManager.ts:39-47` — the "active iff the secret is non-empty" shape.
The read site is `src/services/worker-service.ts:453`, which already holds
`const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH)`.
**Do not** use `SettingsDefaultsManager.get('CLAUDE_MEM_TV_TOKEN')` (trap 0.5.1).
Normalize once: `const tvToken = (settings.CLAUDE_MEM_TV_TOKEN ?? '').trim()`. A whitespace-only
token is OFF, not a secret equal to a space.
### 1.3 Deliberately do NOT expose it through the settings API or UI
Leave `CLAUDE_MEM_TV_TOKEN` **out** of the `settingKeys` array at `SettingsRoutes.ts:77-108`, out of
`src/ui/viewer/constants/settings.ts`, out of `src/ui/viewer/types.ts`, and out of
`ContextSettingsModal.tsx`.
Reasoning, to be written as a code comment at the `settingKeys` array so the next person does not
"fix" the omission: `POST /api/settings` has no authentication (`SettingsRoutes.ts:30`). If the token
were writable there, any page the user visits could `fetch('http://127.0.0.1:37700/api/settings', {method:'POST', ...})`
and set a token it chose, then read the memory DB from the LAN. Keeping it off the whitelist means
the token can only be set by the person who has the filesystem or the environment. This matches how
every Chroma / Telegram / CloudSync key is already handled.
Known residual exposure to state in the plan and the docs, not to fix here: `GET /api/settings`
(`SettingsRoutes.ts:37-42`) returns the whole file, so the token is readable by anything already on
loopback — exactly like `CLAUDE_MEM_GEMINI_API_KEY` is today. Redacting it there would break the
settings modal's read-modify-write round trip and is a separate change.
### 1.4 Mint-a-token helper
Add a tiny exported helper next to the guard (Phase 2), not a new CLI surface:
```ts
export function generateTvToken(): string {
return randomBytes(32).toString('base64url');
}
```
Copy the idiom from `createRawServerApiKey()` (`src/server/auth/sqlite-api-key-service.ts:155-157`)
but **without** the `cmem_` prefix — that prefix means "a DB-backed scoped API key" in this codebase
and this is not one.
### 1.5 Verification checklist — Phase 1
```bash
npm install # prerequisite; see trap 0.5.5
npm run typecheck # tsc --noEmit && viewer tsconfig
grep -n "CLAUDE_MEM_TV_TOKEN" src/shared/SettingsDefaultsManager.ts # expect 2 hits: interface + default
grep -rn "CLAUDE_MEM_TV_TOKEN" src/services/worker/http/routes/SettingsRoutes.ts # expect 0 hits
grep -rn "CLAUDE_MEM_TV_TOKEN" src/ui/ # expect 0 hits
grep -rn "SettingsDefaultsManager.get('CLAUDE_MEM_TV_TOKEN')" src/ # expect 0 hits (trap 0.5.1)
```
- [ ] `npm run typecheck` passes.
- [ ] A fresh `~/.claude-mem/settings.json` (delete it, boot the worker) contains
`"CLAUDE_MEM_TV_TOKEN": ""`.
- [ ] `POST /api/settings` with `{"CLAUDE_MEM_TV_TOKEN":"pwned"}` returns success **and the file is
unchanged** — the key is not on the whitelist. This is the test that proves 1.3.
---
## Phase 2 — The guard: `createRemoteReadOnlyGuard`
**How this serves the primary goal:** this is the whole security boundary. Everything else in the
plan is setting, plumbing, or documentation.
### 2.1 Where the code goes
New export in `src/services/worker/http/middleware.ts`, directly beneath `requireLocalhost`
(`:64-86`). Same file, same style, same `logger.warn('SECURITY', ...)` idiom. Do not create a new
directory; `src/services/worker/http/middleware/` currently holds only `validateBody.ts` and is for
per-route validators.
### 2.2 The contract
```ts
export interface RemoteReadOnlyOptions {
/** Called per request. Empty string ⇒ this guard should never have been mounted. */
getToken: () => string;
}
export function createRemoteReadOnlyGuard(options: RemoteReadOnlyOptions): RequestHandler;
```
Behavior, in this exact order — write it in this order, because each step depends on the one before:
1. **Loopback ⇒ `next()` immediately.** Use `isLocalhost(req)` from
`src/server/middleware/request-auth-helpers.js:15-21`. Additionally refuse the loopback claim if
`hasForwardedClientHeaders(req)` is true (`:44-51`) — copy that composition from
`src/server/middleware/auth.ts:48-56`. Nothing about local behavior changes, ever.
2. **Method gate.** If `req.method` is not `GET` or `HEAD` ⇒ `403`. One line that kills every
mutation, including future ones and including `ALL /api/auth/*splat`.
3. **Path allowlist.** Exact-match set, no prefixes, no regex, no `startsWith`:
```ts
const REMOTE_READABLE_PATHS: ReadonlySet<string> = new Set([
'/tv',
'/tv.html',
'/stream',
'/api/observations',
]);
```
Not in the set ⇒ **`404`** with a bare `{ error: 'Not found' }`. 404 rather than 403 so a LAN
scanner learns nothing about what the worker is or which paths exist. Compare `req.path` (query
string already excluded by Express — 0.4).
**Note `/api/observations` is exact**, so `/api/observations/by-file` (`DataRoutes.ts:88`) and
`POST /api/observations/batch` (`:89`) are both denied. That is intended.
4. **Token extraction**, in the precedence established at `src/server/middleware/auth.ts:40-46`:
`parseBearerToken(req.header('authorization') ?? '')` → `req.header('x-api-key')?.trim()` →
`req.query.token` (string only; if `Array.isArray`, reject — a repeated `?token=` is not a
valid request).
The query parameter exists solely because `EventSource` cannot set headers (trap 0.5.3).
5. **Constant-time compare.** SHA-256 both sides to a fixed length, then `timingSafeEqual`. Copy the
`safeEqualHex` shape from `src/server/auth/sqlite-api-key-service.ts:83-97` including its
length pre-check and its `try/catch` + `logger.warn`:
```ts
const digest = (s: string) => createHash('sha256').update(s, 'utf8').digest();
// both are always 32 bytes, so timingSafeEqual never throws on length
```
No token / wrong token ⇒ **`401`** with
`{ error: 'Unauthorized', message: 'Missing or invalid Observation TV token' }` — wording copied
from `auth.ts:70-76`.
6. **Pass ⇒ `next()`**, plus `res.setHeader('Cache-Control', 'no-store')`.
**Never** `next(err)` — write the response (0.4).
### 2.3 Logging
- On a denied non-loopback request: `logger.warn('SECURITY', 'Remote request denied', { path: req.path, method: req.method, clientIp, reason })`
where `reason` ∈ `'method'|'path'|'token'`. Copy the field shape from `middleware.ts:74-78`.
- **Never log the token or the raw query string.** If a fingerprint helps debugging, log
`createHash('sha256').update(presented).digest('hex').slice(0,8)`.
- Rate-limit the warn to avoid a scanner filling the log: keep a module-level counter and log at
most once per second per `clientIp`, or simply log at `debug` after the first 10 from an IP. Keep
this to ~10 lines; do not build a rate-limiter abstraction.
### 2.4 Mounting it
`src/services/server/Server.ts`:
- Add to `ServerOptions` (block `:76-94`), modeled exactly on `securityHeaders?: boolean` at `:88-93`
including a comment explaining who opts in:
```ts
/**
* Observation TV remote broadcast. When present, a guard runs BEFORE every
* other middleware and route: loopback requests are untouched, and non-loopback
* requests may reach only /tv, /tv.html, /stream and GET /api/observations, and
* only with the shared secret. Absent (the default, and the server runtime's
* choice — it has its own API-key auth) ⇒ nothing is mounted and behavior is
* unchanged.
*/
remoteReadOnly?: RemoteReadOnlyOptions;
```
- Mount it as the **first** statement after `this.app.disable('x-powered-by')` at `Server.ts:128`,
i.e. above `setupSecurityHeaders()`:
```ts
128: this.app.disable('x-powered-by');
129: this.setupRemoteReadOnlyGuard(); // NEW — must precede everything (Phase 0.1)
130: this.setupSecurityHeaders();
```
with `private setupRemoteReadOnlyGuard(): void { if (!this.options.remoteReadOnly) return; this.app.use(createRemoteReadOnlyGuard(this.options.remoteReadOnly)); }`
— copy the early-return shape from `setupSecurityHeaders` (`:198-206`).
`src/services/worker-service.ts` — in the `new Server({...})` call at `:273-297`, alongside the
existing `preBodyParserRoutes`:
```ts
...(tvToken ? { remoteReadOnly: { getToken: () => tvToken } } : {}),
```
Read `tvToken` per 1.2. Because the option is conditional, an install without a token constructs the
Server with an identical option object to today.
`src/server/runtime/ServerService.ts:129` — **do not touch.** That runtime has `requireServerAuth`
on `/v1/*` and `securityHeaders: true`; the TV guard is not its concern.
### 2.5 Startup log line
At `worker-service.ts` near the existing `await this.server.listen(port, host)` (`:420`), when a token
is configured, emit one `logger.info('SYSTEM', 'Observation TV remote broadcast enabled', { host, allowedPaths: [...] })`.
Operators need to see, in the log, that the surface is open. Do not log the token.
### 2.6 Tests — `tests/server/tv-remote-guard.test.ts`
Copy the skeleton from `tests/server/server-security-headers.test.ts:10-45`: `baseOptions()` factory,
`new Server(baseOptions({ remoteReadOnly: { getToken: () => 'secret' } }))`,
`await server.listen(41000 + Math.floor(Math.random()*9000), '127.0.0.1')`, `fetch`, `afterEach` close.
Loopback tests hit `127.0.0.1` directly. To exercise the *non-loopback* branch without a second NIC,
factor the decision out of the express plumbing:
```ts
export function decideRemoteAccess(input: {
method: string; path: string; presentedToken: string | null; expectedToken: string;
}): { allow: true } | { allow: false; status: 403 | 404 | 401; reason: 'method'|'path'|'token' };
```
and have the middleware be a thin adapter over it. Unit-test `decideRemoteAccess` directly — this
mirrors how `assertServerRuntimeForCli` is tested as a pure function in
`tests/server/server-runtime-guard.test.ts:9-50`.
Required cases:
| # | Input | Expect |
|---|---|---|
| 1 | loopback `GET /api/settings`, no token | reaches the handler (loopback is never gated) |
| 2 | loopback `POST /api/admin/restart` | unchanged behavior — `requireLocalhost` still governs |
| 3 | remote `GET /tv` + correct `?token=` | allow |
| 4 | remote `GET /tv` + correct `Authorization: Bearer` | allow |
| 5 | remote `GET /tv` + correct `X-Api-Key` | allow |
| 6 | remote `GET /tv` + wrong token | 401 |
| 7 | remote `GET /tv` + **no** token | 401 |
| 8 | remote `GET /stream` + correct token | allow |
| 9 | remote `GET /api/observations` + correct token | allow |
| 10 | remote `GET /api/settings` + **correct** token | **404** ← the headline test |
| 11 | remote `GET /api/observations/by-file` + correct token | 404 (exact-match, not prefix) |
| 12 | remote `POST /api/admin/restart` + correct token | 403 (method gate fires before path) |
| 13 | remote `POST /api/settings` + correct token | 403 |
| 14 | remote `POST /api/observations/batch` + correct token | 403 |
| 15 | remote `GET /api/auth/session` + correct token | 404 |
| 16 | remote `GET /v1/info` + correct token | 404 |
| 17 | remote `GET /` and `/viewer.html` + correct token | 404 |
| 18 | remote `GET /restart` + correct token | 404 |
| 19 | remote `GET /api/logs` + correct token | 404 |
| 20 | remote `GET /health` + correct token | 404 (pid/platform disclosure) |
| 21 | `?token=` repeated (array) | 401, no crash |
| 22 | expected token `''` | `decideRemoteAccess` denies everything (guard should not be mounted, but must fail closed if it is) |
| 23 | loopback `req.ip` with `X-Forwarded-For: 1.2.3.4` present | treated as remote, not loopback |
### 2.7 Verification checklist — Phase 2
```bash
npm run typecheck
bun test tests/server/tv-remote-guard.test.ts
bun test tests/server/ # no regressions in the Server suite
grep -n "next(err\|next(error" src/services/worker/http/middleware.ts # expect no hit inside the new guard
grep -rn "trust proxy" src/ # expect 0 hits
grep -c "" <<< "$(git diff --stat)" # sanity: diff stays small
```
- [ ] All 23 cases green.
- [ ] With **no** token configured, `git stash`-free manual check: boot the worker, confirm
`/api/settings` still answers on loopback and the constructed `ServerOptions` has no
`remoteReadOnly` key.
- [ ] The guard never appears in the diff of any route file — it is mounted in exactly one place.
---
## Phase 3 — tv.html carries the token
**How this serves the primary goal:** without this, the page loads on the phone and then both of its
requests 401. Two lines of change.
### 3.1 The change
`src/ui/tv.html` already parses URL knobs (`?project=`, `?source=`, `?dwell=`, `?fade=`, `?seed=`,
`?replay=`). Add `token` to that same parsing block, then:
- `tv.html:265` — the seed fetch. Append `token` to the existing `qs` `URLSearchParams` when present.
- `tv.html:277` — `new EventSource('/stream')` → `new EventSource('/stream' + (token ? '?token=' + encodeURIComponent(token) : ''))`.
Nothing else. No new dependency, no auth UI, no localStorage of the secret (a bookmark holds it; a
stored secret outlives the intent).
The flow on the phone is: open `http://<lan-ip>:37700/tv.html?token=<secret>` — the page is served
because `/tv.html` is on the allowlist and the token is in the query, and the page then reuses the
same token for its two calls.
### 3.2 Rebuild the copy
`scripts/build-viewer.js:43-47` already copies `src/ui/tv.html` → `plugin/ui/tv.html` verbatim. Run
`npm run build-and-sync` (per CLAUDE.md) so `plugin/ui/tv.html` matches — the repo ships built plugin
artifacts and a stale bundle has bitten this project before (`4da9ffc6`, issue #3857).
### 3.3 Verification checklist — Phase 3
```bash
node --check <(sed -n '/<script>/,/<\/script>/p' src/ui/tv.html) # the inline script parses
npm run build-and-sync
diff <(sed 's/\r$//' src/ui/tv.html) <(sed 's/\r$//' plugin/ui/tv.html) && echo "copies match"
grep -n "token" src/ui/tv.html # knob parse + 2 call sites
```
- [ ] `src/ui/tv.html` and `plugin/ui/tv.html` are byte-identical.
- [ ] Loopback, **no** token configured: `http://127.0.0.1:37700/tv.html` still works with no `?token=`
(the guard is not mounted). This is the regression that matters most — the local TV must not
become harder to use.
- [ ] Loopback, token configured: `http://127.0.0.1:37700/tv.html` **still works without a token**
(loopback is never gated).
- [ ] From a second device on the LAN with `CLAUDE_MEM_WORKER_HOST=0.0.0.0` and a token set:
`http://<lan-ip>:<port>/tv.html?token=<secret>` renders cards and the SSE stream stays open.
- [ ] Same device, same URL, token removed ⇒ 401. Same device, `/` ⇒ 404. Same device,
`/api/settings?token=<secret>` ⇒ 404 and **no API keys in the response body**. Capture this
last one as a terminal transcript in the PR description.
---
## Phase 4 — Host bind, the boot warning, and the docs
**How this serves the primary goal:** the token is useless if nobody knows the two settings work as a
pair, and dangerous if someone opens the bind without it.
### 4.1 Do not change the default
`CLAUDE_MEM_WORKER_HOST` stays `127.0.0.1` (`SettingsDefaultsManager.ts:138`). The validation regex at
`SettingsRoutes.ts:180-186` already accepts `0.0.0.0` and specific IPs; leave it alone.
The four states, which belong verbatim in the docs:
| `CLAUDE_MEM_WORKER_HOST` | `CLAUDE_MEM_TV_TOKEN` | Result |
|---|---|---|
| `127.0.0.1` (default) | empty | Today. Nothing reachable off-box. **Recommended for everyone not using the TV remotely.** |
| `127.0.0.1` | set | Token is inert — nothing can reach the port anyway. Harmless. |
| `0.0.0.0` | **empty** | **Dangerous, and unchanged from today**: the full API — settings incl. provider API keys, deletes, import, better-auth — is on the LAN. This is what `docs/docker.md:12` already tells people to do, so we warn rather than break it. |
| `0.0.0.0` | set | The point of this plan. LAN devices reach `/tv`, `/tv.html`, `/stream`, `GET /api/observations` with the secret, and get 404/403/401 for everything else. |
State plainly, in the docs, the tradeoff the fourth row buys: the **React viewer at `/` stops working
remotely** when a token is set. That is intended — the viewer needs the write and settings routes the
guard denies. If someone wants the full viewer on another device, that is the Pro remote-hosting path
(Appendix A), not a wider allowlist.
### 4.2 Boot-time warning
At `worker-service.ts` next to the `listen` call (`:420`), after resolving `host` (`:403`):
- host is non-loopback **and** token empty ⇒
`logger.warn('SECURITY', 'Worker bound to a non-loopback host with no CLAUDE_MEM_TV_TOKEN — the full worker API, including provider API keys via GET /api/settings, is reachable from the network', { host })`.
- **Warn; do not refuse to bind.** Refusing would break the documented Docker deployment
(`docs/docker.md:12`) for people who have made their own network decisions. Copy the tone of the
existing `logger.warn('SECURITY', ...)` at `middleware.ts:74-78`.
### 4.3 Docs
`docs/public/configuration.mdx` — add one row to the Core Settings table (`:14-24`), matching the
existing column format exactly:
```
| `CLAUDE_MEM_TV_TOKEN` | — | Shared secret for Observation TV remote access. Empty = off. With a non-loopback `CLAUDE_MEM_WORKER_HOST`, only `/tv`, `/tv.html`, `/stream` and `GET /api/observations` are reachable, and only with this token. |
```
Then a short new section following the file's own convention (a `###` heading, a table, then a
"Manual Configuration" fenced `json` block — copy the shape at `:83-91`), containing: how to mint a
token, the four-state table from 4.1, the "viewer stops working remotely" tradeoff, and an explicit
"the token rides in the URL for `/stream` because `EventSource` cannot set headers; it is not written
to the worker log (`req.path` excludes the query string) but it *will* be in browser history — treat
the URL as the secret."
Also note in that section, honestly: **a LAN observer can still see the traffic.** This is plain HTTP.
The token stops an unauthenticated device from reading the stream; it does not encrypt it. Anyone who
can sniff the LAN sees observation titles. For the home-network case that is the accepted tradeoff;
for anything else, terminate TLS in front (out of scope here) — and still not a tunnel to the
unmodified worker.
### 4.4 Accepted risks — write these into the plan's PR description
1. **`GET /api/observations` returns full observation bodies** — `narrative`, `facts`, `text`,
`files_read`, `files_modified`, for **every project on the box**, not just the TV's four fields.
A token holder can page through the entire memory database with `offset`. Accepted for this slice
because the token holder is the user's own device. **Follow-up (parked, not this slice):** a
`fields=tv` projection, or a dedicated `GET /api/tv/observations` returning only
`id, title, project, platform_source, created_at_epoch`. Deliberately not done now — it would add
a route and a shape to maintain for a threat (a leaked token) the projection only narrows, not closes.
2. **`/stream` is unfiltered** — it carries every `new_observation` for every project on the box, plus
`initial_load` (the project catalog) and `processing_status`. `SSEBroadcaster.broadcast`
(`src/services/worker/SSEBroadcaster.ts:24-38`) writes one payload to every client with no
per-client filter; adding one means either a second broadcaster or a per-client predicate. Out of
scope. The TV's `?project=` / `?source=` filters are client-side only — say so in the docs so
nobody mistakes them for access control.
3. **No rate limiting.** A token holder can hammer `/api/observations`. There is no HTTP limiter in
the codebase to reuse (0.4). Accepted.
4. **The token is readable on loopback** via `GET /api/settings` (1.3).
### 4.5 Verification checklist — Phase 4
- [ ] Boot with `CLAUDE_MEM_WORKER_HOST=0.0.0.0` and no token ⇒ the SECURITY warn appears in
`npm run worker:logs`, and the worker **still binds**.
- [ ] Boot with default host ⇒ no warning, no behavior change.
- [ ] `docs/public/configuration.mdx` renders (Mintlify table syntax; check the pipe count matches
neighbouring rows).
- [ ] The docs contain the four-state table and all four accepted risks.
- [ ] `grep -rn "cloudflared\|ngrok" docs/ plans/2026-09-05-observation-tv-readonly-broadcast.md`
finds them only as *rejected* options, never as instructions.
---
## Phase 5 — (adjacent, small) `platform_source` on the card
**Fits cleanly — keep it.** This is STATUS.md open question 2, and it is genuinely small because the
field already exists end to end: `platform_source` is on `ObservationSSEPayload`
(`src/services/worker/agents/types.ts:13,31`) and already comes back from `/api/observations`. No
worker change, no schema change, no new route. `src/ui/tv.html` only.
**How this serves the primary goal:** with several agents fanning into one `/stream`, a card that says
only *what* happened but not *who* reads as noise. Attribution is what makes the TV legible.
### 5.1 The change
`src/ui/tv.html` only:
- Render `observation.platform_source` as a small label beside the existing `project` label.
- Derive a stable per-source accent colour. Use the canonical vocabulary from
`src/shared/platform-source.ts`: `DEFAULT_PLATFORM_SOURCE = 'claude'` (`:1`), and
`normalizePlatformSource` collapses inputs to `claude` / `codex` / `cursor` / passthrough (`:7-19`).
`sortPlatformSources` (`:26-39`) gives the display priority `claude, codex, cursor, …alphabetical`.
Do **not** re-derive that mapping in the HTML — mirror those three names and hash anything else to a hue.
- Both render paths must be updated: the Document-PiP DOM path *and* the canvas path
(`captureStream()`), which paints text manually. A change to only one silently regresses iOS.
### 5.2 Verification checklist — Phase 5
- [ ] Cards show `project` + `platform_source`; a `claude` card and a `cursor` card differ visibly.
- [ ] The canvas/PiP path shows the label too (open PiP with `p` and confirm).
- [ ] Unknown source (e.g. `grok-bot`) renders with a derived colour, not blank and not a crash.
- [ ] `node --check` on the inline script; `npm run build-and-sync`; the two `tv.html` copies match.
- [ ] No change outside `src/ui/tv.html` and `plugin/ui/tv.html`.
**Parked, explicitly not this slice** (STATUS.md open questions 3 and 4): pacing under burst load
(shrink dwell when the live queue is deep) and worker-side short titles. Both are TV-quality work
with no security content; they do not belong in a slice whose job is the boundary.
---
## Phase 6 — Final verification
Run in a fresh context with no assumptions carried from earlier phases.
### 6.1 Prove the boundary, not the code
Boot a worker with `CLAUDE_MEM_WORKER_HOST=0.0.0.0` and a real token, then from a **second machine**
(not the worker's own box — a curl to `127.0.0.1` proves nothing, it takes the loopback branch):
```bash
LAN=http://<worker-lan-ip>:<port>
T='<token>'
# MUST succeed
curl -sf "$LAN/tv.html?token=$T" > /dev/null && echo "OK tv.html"
curl -sf "$LAN/tv?token=$T" > /dev/null && echo "OK /tv"
curl -sf "$LAN/api/observations?limit=5&token=$T" | head -c 200
curl -sN "$LAN/stream?token=$T" | head -c 200 # expect: data: {"type":"connected"...
curl -sf -H "Authorization: Bearer $T" "$LAN/api/observations?limit=1" > /dev/null && echo "OK bearer"
# MUST fail — and the settings one must show NO api keys
curl -si "$LAN/api/settings?token=$T" | head -1 # expect 404
curl -si "$LAN/api/settings?token=$T" | grep -ci "api_key\|apikey\|sk-\|OPENROUTER" # expect 0
curl -si -X POST "$LAN/api/admin/restart?token=$T" | head -1 # expect 403
curl -si -X POST "$LAN/api/settings?token=$T" | head -1 # expect 403
curl -si -X DELETE "$LAN/api/observation/1?token=$T" | head -1 # expect 403
curl -si "$LAN/?token=$T" | head -1 # expect 404
curl -si "$LAN/health?token=$T" | head -1 # expect 404
curl -si "$LAN/api/auth/session?token=$T" | head -1 # expect 404
curl -si "$LAN/v1/info?token=$T" | head -1 # expect 404
curl -si "$LAN/api/logs?token=$T" | head -1 # expect 404
curl -si "$LAN/tv.html" | head -1 # expect 401 (no token)
curl -si "$LAN/tv.html?token=wrong" | head -1 # expect 401
```
**And confirm the worker is still alive after all of that** — `curl -sf $LAN/tv?token=$T` — which
proves the `POST /api/admin/restart` above really was refused rather than merely returning an error
after restarting.
### 6.2 Prove nothing regressed for the default install
With **no** token and the default host:
```bash
npm run typecheck
bun test tests # full suite
curl -sf http://127.0.0.1:$PORT/api/settings > /dev/null && echo "OK loopback settings"
curl -sf http://127.0.0.1:$PORT/tv.html > /dev/null && echo "OK loopback tv"
curl -sN http://127.0.0.1:$PORT/stream | head -c 60
```
### 6.3 Anti-pattern grep
```bash
# no new dependency
git diff main -- package.json | grep -E '^\+.*"(helmet|express-rate-limit|passport|jsonwebtoken|cors)"' && echo "FAIL: dependency added"
# guard is mounted in exactly one place
grep -rn "createRemoteReadOnlyGuard" src/ | tee /dev/stderr | wc -l # expect 3: definition, import, one app.use
# no denylist crept in
grep -rniE "denyl(ist)?|blockl(ist)?|blacklist" src/services/worker/http/middleware.ts # expect 0
# no prefix matching in the allowlist
grep -n "startsWith\|RegExp\|\.test(" src/services/worker/http/middleware.ts | grep -i "path" # expect 0 in the guard
# constant-time compare, not ===
grep -n "timingSafeEqual" src/services/worker/http/middleware.ts # expect >= 1
# token never logged
grep -rn "token" src/services/worker/http/middleware.ts | grep -i "logger" # expect 0
# trust proxy never introduced
grep -rn "trust proxy" src/ # expect 0
# tunnels are not a solution
grep -rn "cloudflared\|ngrok" src/ # expect 0
```
### 6.4 Sign-off
- [ ] Every 6.1 line produced its expected status, run from a genuinely different machine.
- [ ] `bun test tests` green.
- [ ] `npm run typecheck` green.
- [ ] All 6.3 greps produced their expected counts.
- [ ] `src/ui/tv.html` and `plugin/ui/tv.html` byte-identical; `npm run build-and-sync` clean.
- [ ] The four accepted risks (4.4) appear in the PR description, not only in this file.
---
## Appendix A — Release shape: OSS vs Pro
Not a build. One paragraph so the boundary is decided before someone has to guess.
**Everything in this plan ships OSS.** It is ~4 files: one guard in `middleware.ts`, one option on
`Server`, one setting, two lines in `tv.html`. It makes an existing OSS feature usable on a second
screen and, incidentally, closes a real footgun — anyone who has ever set `CLAUDE_MEM_WORKER_HOST=0.0.0.0`
(which `docs/docker.md:12` tells them to) is currently serving their provider API keys to their
network. A security improvement is not a paid feature.
**What is Pro-shaped, and is explicitly NOT this slice:** anything that puts observations on a device
*not on the same LAN*. That needs an identity story (better-auth is already mounted at
`/api/auth/*splat`, `BetterAuthRoutes.ts:31`, and `requireServerAuth` + `sqlite-api-key-service`
already implement scoped keys — `src/server/middleware/auth.ts:35-95`), a relay the worker dials
*out* to rather than a port it opens *in*, and TLS. The existing cloud-sync path
(`CLAUDE_MEM_CLOUD_SYNC_HUB_URL` + `CLAUDE_MEM_CLOUD_SYNC_TOKEN`, `worker-service.ts:528-534`) is the
right shape to extend: outbound, authenticated, already built. A shared secret on a LAN port is the
right answer for "my phone on my desk"; it is the wrong answer for "my phone at the airport", and the
temptation to widen this allowlist until it becomes the second thing is the failure mode to guard
against. When that day comes it is a new plan, not more paths in `REMOTE_READABLE_PATHS`.
---
## Appendix B — Rejected, with reasons (do not re-propose)
| Option | Why not |
|---|---|
| `cloudflared tunnel --url http://127.0.0.1:PORT` | Publishes `POST /api/admin/restart`, `POST /api/settings`, `GET /api/settings` (provider API keys), `DELETE /api/observation/:id`, `POST /api/import` and better-auth to the open internet. Rejected by decision 2026-09-05. |
| Reuse `requireServerAuth` / better-auth for the TV | DB-backed and scope-based; needs the DB open, but the TV must render during the init window when `worker-service.ts:328-351` answers 503 for `/api/*`. Also cannot authenticate an `EventSource`. Right answer for Pro (Appendix A), wrong for this. |
| Denylist of dangerous routes | 45+ routes across 13 classes, growing. The next route added is remote-readable by default. |
| Mount the guard in `ViewerRoutes.setupRoutes` | Too late: `express.static(plugin/ui)` (`middleware.ts:36`), `/api/auth/*` and `/api/admin/*` are all mounted before it (0.1). |
| `express-rate-limit` / helmet / passport | New dependency; the repo declined helmet on record (`Server.ts:96-105`) and hand-rolled instead. |
| Reuse `globalRateLimitStore` or `src/server/middleware/rate-limit.ts` | The first tracks LLM provider quota; the second is Postgres-backed Server-Beta only (0.4). |
| Refuse to bind when host is non-loopback and no token | Breaks the documented Docker deployment (`docs/docker.md:12`) for people who made their own network call. Warn loudly instead (4.2). |
| Change the `CLAUDE_MEM_WORKER_HOST` default to `0.0.0.0` | Would silently expose every existing install on upgrade. |
| Put the token in the settings UI / `settingKeys` | `POST /api/settings` is unauthenticated; any page the user visits could then set a token of its choosing (1.3). |
| A second SSE broadcaster to filter `/stream` per client | Real work for marginal gain in a slice whose job is the boundary. Parked (4.4 risk 2). |
| OBS / ffmpeg / canvas recording | Already rejected in the thin slice. A page plus browser PiP does the job. |
+505
View File
@@ -0,0 +1,505 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark">
<title>claude-mem · Observation TV</title>
<style>
:root {
--bg: #0d0d0c;
--fg: #f4f2ee;
--dim: rgba(244, 242, 238, 0.45);
--accent: #c15f3c;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 400 16px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
overflow: hidden;
-webkit-user-select: none;
user-select: none;
}
#stage {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: max(4vmin, env(safe-area-inset-top)) 6vmin;
text-align: center;
}
#card {
max-width: 22ch;
opacity: 0;
transition: opacity 900ms ease, transform 900ms ease;
transform: scale(0.985);
will-change: opacity, transform;
}
#card.show { opacity: 1; transform: scale(1); }
#title {
margin: 0;
font-size: clamp(2rem, 7.5vw, 6.5rem);
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.06;
text-wrap: balance;
}
#meta {
margin-top: 3vmin;
font-size: clamp(0.75rem, 1.6vw, 1.1rem);
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--dim);
}
#meta .type { color: var(--accent); }
#idle {
position: fixed;
inset: 0;
display: grid;
place-items: center;
color: var(--dim);
font-size: clamp(0.8rem, 1.6vw, 1rem);
letter-spacing: 0.18em;
text-transform: uppercase;
opacity: 0;
transition: opacity 600ms ease;
pointer-events: none;
}
#idle.show { opacity: 1; }
#hud {
position: fixed;
top: max(1rem, env(safe-area-inset-top));
right: max(1rem, env(safe-area-inset-right));
display: flex;
gap: 0.5rem;
align-items: center;
opacity: 0;
transition: opacity 400ms ease;
}
#hud.show { opacity: 1; }
#hud button {
font: inherit;
font-size: 0.8rem;
letter-spacing: 0.06em;
padding: 0.45rem 0.85rem;
color: var(--fg);
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
cursor: pointer;
}
#hud button:hover { background: rgba(255, 255, 255, 0.14); }
#dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: #666;
margin-right: 0.25rem;
}
#dot.live { background: #4ec07a; }
/* Off-screen source for the canvas route into picture-in-picture. */
#pip { position: fixed; left: -9999px; top: 0; width: 640px; height: 360px; }
#pipcanvas { position: fixed; left: -9999px; top: 0; }
@media (prefers-reduced-motion: reduce) {
#card { transition-duration: 1ms; }
}
</style>
</head>
<body>
<div id="stage">
<div id="card">
<h1 id="title"></h1>
<div id="meta"></div>
</div>
</div>
<div id="idle">waiting for observations</div>
<div id="hud">
<span id="dot" title="stream status"></span>
<button id="pipBtn" type="button">PiP</button>
<button id="fsBtn" type="button">Full screen</button>
</div>
<video id="pip" muted playsinline></video>
<canvas id="pipcanvas" width="1280" height="720"></canvas>
<script>
(() => {
'use strict';
function clampInt(raw, fallback, min, max) {
const n = parseInt(raw, 10);
if (!Number.isFinite(n)) return fallback;
return Math.min(Math.max(n, min), max);
}
const params = new URLSearchParams(location.search);
const project = params.get('project') || null;
const source = params.get('source') || null;
const DWELL_MS = clampInt(params.get('dwell'), 6000, 1200, 60000);
const FADE_MS = clampInt(params.get('fade'), 900, 100, 5000);
const REPLAY = params.get('replay') !== '0';
const SEED_LIMIT = clampInt(params.get('seed'), 25, 0, 100);
// Shared secret for remote (non-loopback) access. Loopback never needs it.
// Held by the bookmark only — deliberately never stored and never logged.
const TOKEN = params.get('token') || '';
const els = {
stage: document.getElementById('stage'),
card: document.getElementById('card'),
title: document.getElementById('title'),
meta: document.getElementById('meta'),
idle: document.getElementById('idle'),
hud: document.getElementById('hud'),
dot: document.getElementById('dot'),
pipBtn: document.getElementById('pipBtn'),
fsBtn: document.getElementById('fsBtn'),
video: document.getElementById('pip'),
canvas: document.getElementById('pipcanvas'),
};
// live = observations that arrived while this page was open; they play first,
// in arrival order. replay = the recent backlog, cycled whenever live is
// empty so an idle worker still leaves something on screen.
const live = [];
let replay = [];
let replayIndex = 0;
let current = null;
function leafProject(name) {
if (!name) return '';
const str = String(name);
return str.includes('/') ? str.split('/').pop() : str;
}
// The canonical vocabulary lives in src/shared/platform-source.ts —
// DEFAULT_PLATFORM_SOURCE is 'claude' and the three known names are
// claude / codex / cursor. Only those three names are mirrored here; the full
// normalizePlatformSource() mapping is not duplicated. Anything else keeps
// its own name and is hashed to a hue.
const SOURCE_COLORS = {
claude: '#c15f3c',
codex: '#4f9d8c',
cursor: '#8b7ad6',
};
function platformSource(value) {
const raw = String(value == null ? '' : value).trim().toLowerCase().replace(/\s+/g, '-');
return raw || 'claude';
}
function sourceColor(name) {
if (Object.prototype.hasOwnProperty.call(SOURCE_COLORS, name)) return SOURCE_COLORS[name];
let hash = 0;
for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
return 'hsl(' + (hash % 360) + ', 58%, 62%)';
}
function normalize(obs) {
if (!obs) return null;
const title = (obs.title || obs.subtitle || obs.text || '').trim();
if (!title) return null;
return {
id: obs.id,
title,
type: (obs.type || 'observation').replace(/_/g, ' '),
project: leafProject(obs.project),
platform: platformSource(obs.platform_source),
at: obs.created_at_epoch || Date.now(),
};
}
function matchesFilter(obs) {
if (project && leafProject(obs.project) !== leafProject(project)) return false;
if (source && (obs.platform_source || 'claude') !== source) return false;
return true;
}
function nextItem() {
if (live.length) return live.shift();
if (!REPLAY || replay.length === 0) return null;
const item = replay[replayIndex % replay.length];
replayIndex += 1;
return item;
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function show(on) {
els.card.classList.toggle('show', on);
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
function relTime(epoch) {
const secs = Math.max(0, Math.round((Date.now() - epoch) / 1000));
if (secs < 60) return 'just now';
const mins = Math.round(secs / 60);
if (mins < 60) return mins + 'm ago';
const hrs = Math.round(mins / 60);
if (hrs < 24) return hrs + 'h ago';
return Math.round(hrs / 24) + 'd ago';
}
function render(item) {
els.title.textContent = item.title;
const bits = ['<span class="type">' + escapeHtml(item.type) + '</span>'];
if (item.project) bits.push(escapeHtml(item.project));
// The colour comes from sourceColor(), never from the raw field, so it is
// always a literal this file produced.
bits.push(
'<span class="source" style="color: ' + sourceColor(item.platform) + '">'
+ escapeHtml(item.platform) + '</span>'
);
bits.push(escapeHtml(relTime(item.at)));
els.meta.innerHTML = bits.join(' &nbsp;·&nbsp; ');
document.title = item.title + ' · claude-mem TV';
}
async function loop() {
for (;;) {
const item = nextItem();
if (!item) {
current = null;
show(false);
els.idle.classList.add('show');
await sleep(500);
continue;
}
els.idle.classList.remove('show');
current = item;
render(item);
show(true);
await sleep(DWELL_MS);
show(false);
await sleep(FADE_MS);
}
}
async function seed() {
if (SEED_LIMIT === 0) return;
const qs = new URLSearchParams({ limit: String(SEED_LIMIT) });
if (project) qs.set('project', project);
if (source) qs.set('platformSource', source);
if (TOKEN) qs.set('token', TOKEN);
try {
const res = await fetch('/api/observations?' + qs.toString(), { cache: 'no-store' });
if (!res.ok) return;
const body = await res.json();
// The API returns newest first; the TV plays oldest first so the backlog
// reads forward in time.
replay = (body.items || []).map(normalize).filter(Boolean).reverse();
} catch {
// The live stream alone is enough to run the TV.
}
}
function connect() {
// EventSource cannot set request headers, so a remote stream carries the
// token in the query string.
const es = new EventSource('/stream' + (TOKEN ? '?token=' + encodeURIComponent(TOKEN) : ''));
es.onopen = () => els.dot.classList.add('live');
es.onerror = () => {
els.dot.classList.remove('live');
es.close();
setTimeout(connect, 3000);
};
es.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
} catch {
return;
}
if (data.type !== 'new_observation' || !data.observation) return;
if (!matchesFilter(data.observation)) return;
const item = normalize(data.observation);
if (!item) return;
live.push(item);
replay.push(item);
if (replay.length > 100) replay.shift();
};
}
/* Picture-in-picture, two routes and no library. Document PiP (Chromium)
* moves the real DOM into the floating window, so the CSS fades keep
* running. Everywhere else — including iOS Safari, which is the phone case —
* PiP only accepts a <video>, so the card is painted into a canvas whose
* captureStream() feeds a muted video element. */
const hasDocumentPiP = 'documentPictureInPicture' in window;
let pipWindow = null;
let canvasTimer = null;
async function toggleDocumentPiP() {
if (pipWindow) {
pipWindow.close();
pipWindow = null;
return;
}
pipWindow = await window.documentPictureInPicture.requestWindow({ width: 480, height: 270 });
for (const sheet of document.styleSheets) {
try {
const css = Array.from(sheet.cssRules).map((rule) => rule.cssText).join('\n');
const style = pipWindow.document.createElement('style');
style.textContent = css;
pipWindow.document.head.appendChild(style);
} catch {
// Cross-origin sheet — nothing to copy.
}
}
pipWindow.document.body.appendChild(els.stage);
pipWindow.addEventListener('pagehide', () => {
document.body.prepend(els.stage);
pipWindow = null;
});
}
function wrap(ctx, text, maxWidth, fontSize) {
let size = fontSize;
for (;;) {
ctx.font = '650 ' + size + 'px ui-sans-serif, system-ui, sans-serif';
const words = String(text).split(/\s+/);
const lines = [];
let line = '';
for (const word of words) {
const candidate = line ? line + ' ' + word : word;
if (ctx.measureText(candidate).width > maxWidth && line) {
lines.push(line);
line = word;
} else {
line = candidate;
}
}
if (line) lines.push(line);
if (lines.length <= 4 || size <= 32) return lines.slice(0, 4);
size -= 8;
}
}
function paintCanvas(ctx) {
const w = els.canvas.width;
const h = els.canvas.height;
const style = getComputedStyle(document.body);
ctx.globalAlpha = 1;
ctx.fillStyle = style.getPropertyValue('--bg').trim() || '#0d0d0c';
ctx.fillRect(0, 0, w, h);
if (!current) return;
const alpha = els.card.classList.contains('show') ? 1 : 0.12;
ctx.globalAlpha = alpha;
ctx.fillStyle = style.getPropertyValue('--fg').trim() || '#f4f2ee';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const lines = wrap(ctx, current.title, w * 0.84, 72);
const lineHeight = 78;
const startY = h / 2 - ((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, i) => ctx.fillText(line, w / 2, startY + i * lineHeight));
ctx.globalAlpha = alpha * 0.5;
ctx.font = '500 24px ui-sans-serif, system-ui, sans-serif';
const subY = startY + lines.length * lineHeight + 24;
const sep = ' · ';
// The platform source is painted in its own accent, so the sub line is laid
// out left-to-right by hand instead of as one centred string.
const lead = [current.type, current.project].filter(Boolean).join(sep).toUpperCase();
const leadText = lead ? lead + sep : '';
const sourceText = current.platform.toUpperCase();
const leadWidth = ctx.measureText(leadText).width;
const startX = w / 2 - (leadWidth + ctx.measureText(sourceText).width) / 2;
ctx.textAlign = 'left';
if (leadText) ctx.fillText(leadText, startX, subY);
ctx.fillStyle = sourceColor(current.platform);
ctx.fillText(sourceText, startX + leadWidth, subY);
ctx.textAlign = 'center';
ctx.globalAlpha = 1;
}
function startCanvas() {
if (canvasTimer) return;
const ctx = els.canvas.getContext('2d');
canvasTimer = setInterval(() => paintCanvas(ctx), 1000 / 15);
}
function stopCanvas() {
if (!canvasTimer) return;
clearInterval(canvasTimer);
canvasTimer = null;
}
async function toggleVideoPiP() {
const video = els.video;
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
stopCanvas();
return;
}
startCanvas();
if (!video.srcObject) video.srcObject = els.canvas.captureStream(15);
await video.play();
if (video.requestPictureInPicture) {
await video.requestPictureInPicture();
} else if (video.webkitSetPresentationMode) {
video.webkitSetPresentationMode('picture-in-picture');
}
}
async function togglePiP() {
try {
if (hasDocumentPiP) {
await toggleDocumentPiP();
return;
}
await toggleVideoPiP();
} catch (err) {
console.warn('[tv] picture-in-picture failed', err);
}
}
function toggleFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen();
}
}
let hudTimer = null;
function pokeHud() {
els.hud.classList.add('show');
clearTimeout(hudTimer);
hudTimer = setTimeout(() => els.hud.classList.remove('show'), 2500);
}
els.pipBtn.addEventListener('click', togglePiP);
els.fsBtn.addEventListener('click', toggleFullscreen);
document.addEventListener('mousemove', pokeHud);
document.addEventListener('touchstart', pokeHud, { passive: true });
document.addEventListener('keydown', (event) => {
if (event.key === 'f') toggleFullscreen();
if (event.key === 'p') togglePiP();
});
pokeHud();
connect();
seed().finally(loop);
})();
</script>
</body>
</html>
+7
View File
@@ -40,6 +40,12 @@ async function buildViewer() {
htmlTemplate
);
// Observation TV is a standalone page with no bundle — copy it verbatim.
fs.copyFileSync(
path.join(rootDir, 'src/ui/tv.html'),
path.join(rootDir, 'plugin/ui/tv.html')
);
const fontsDir = path.join(rootDir, 'src/ui/viewer/assets/fonts');
const outputFontsDir = path.join(rootDir, 'plugin/ui/assets/fonts');
@@ -67,6 +73,7 @@ async function buildViewer() {
console.log('✓ React viewer built successfully');
console.log(' - plugin/ui/viewer-bundle.js');
console.log(' - plugin/ui/viewer.html (from viewer-template.html)');
console.log(' - plugin/ui/tv.html (from src/ui/tv.html)');
console.log(' - plugin/ui/assets/fonts/* (font files)');
console.log(` - plugin/ui/icon-thick-*.svg (${iconFiles.length} icon files)`);
} catch (error) {
+28 -1
View File
@@ -5,7 +5,13 @@ import * as fs from 'fs';
import path from 'path';
import { ALLOWED_OPERATIONS, ALLOWED_TOPICS } from './allowed-constants.js';
import { logger } from '../../utils/logger.js';
import { createCorsMiddleware, createMiddleware, requireLocalhost } from '../worker/http/middleware.js';
import {
createCorsMiddleware,
createMiddleware,
createRemoteReadOnlyGuard,
requireLocalhost,
type RemoteReadOnlyOptions,
} from '../worker/http/middleware.js';
import { errorHandler, notFoundHandler } from './ErrorHandler.js';
import { getSupervisor } from '../../supervisor/index.js';
import { isPidAlive } from '../../supervisor/process-registry.js';
@@ -95,6 +101,15 @@ export interface ServerOptions {
// (the same headers helmet's defaults emit) before any route runs. Opt-in so
// the in-plugin worker runtime is unchanged; the server runtime sets it.
securityHeaders?: boolean;
/**
* Observation TV remote broadcast. When present, a guard runs BEFORE every
* other middleware and route: loopback requests are untouched, and non-loopback
* requests may reach only /tv, /tv.html, /stream and GET /api/observations, and
* only with the shared secret. Absent (the default, and the server runtime's
* choice — it has its own API-key auth) ⇒ nothing is mounted and behavior is
* unchanged.
*/
remoteReadOnly?: RemoteReadOnlyOptions;
}
// #2572 — hand-rolled security headers.
@@ -126,6 +141,11 @@ export class Server {
this.options = options;
this.app = express();
this.app.disable('x-powered-by');
// Position zero is load-bearing: /api/auth/*splat (setupPreBodyParserRoutes),
// the express.static mount (setupMiddleware), /api/admin/* (setupCoreRoutes)
// and every route registered later all mount after this point. Anything
// mounted afterwards leaves earlier routes uncovered.
this.setupRemoteReadOnlyGuard();
this.setupSecurityHeaders();
this.setupCors();
this.setupPreBodyParserRoutes();
@@ -195,6 +215,13 @@ export class Server {
middlewares.forEach(mw => this.app.use(mw));
}
private setupRemoteReadOnlyGuard(): void {
if (!this.options.remoteReadOnly) {
return;
}
this.app.use(createRemoteReadOnlyGuard(this.options.remoteReadOnly));
}
private setupSecurityHeaders(): void {
if (!this.options.securityHeaders) {
return;
+33 -1
View File
@@ -9,7 +9,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { getWorkerPort, getWorkerHost, fetchWithTimeout, resolveWorkerScriptPath } from '../shared/worker-utils.js';
import { getCurrentWorkerPid, verifyRestartedWorker } from './restart-verify.js';
import { runShutdownSequence, type WorkerShutdownReason } from './worker-shutdown.js';
import { DATA_DIR, DB_PATH, ensureDir } from '../shared/paths.js';
import { DATA_DIR, DB_PATH, USER_SETTINGS_PATH, ensureDir } from '../shared/paths.js';
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
import { getUptimeSeconds } from '../shared/uptime.js';
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
@@ -202,6 +202,8 @@ export class WorkerService implements WorkerRef {
// the previous run's stale PID file + the clean-shutdown sentinel.
private previousShutdown: 'clean' | 'crash' | 'unknown' = 'unknown';
private previousUptimeSeconds: number | null = null;
// Observation TV shared secret, resolved once in the constructor. Empty = off.
private tvToken: string = '';
private mcpClient: Client;
private mcpReady: boolean = false;
@@ -270,6 +272,18 @@ export class WorkerService implements WorkerRef {
version: packageVersion
}, { capabilities: {} });
// Observation TV remote broadcast secret. Must be read from settings.json —
// SettingsDefaultsManager.get() only consults process.env and DEFAULTS, so a
// token written to ~/.claude-mem/settings.json would be invisible to it.
// Whitespace-only is OFF, not a secret equal to a space.
// RESOLUTION POINT: the token is the single switch. It is passed to the
// Server below as `remoteReadOnly`, and only when it is non-empty — so a
// token-less install constructs an options object identical to today's and
// the read-only guard is never mounted.
const workerSettings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const tvToken = (workerSettings.CLAUDE_MEM_TV_TOKEN ?? '').trim();
this.tvToken = tvToken;
this.server = new Server({
getInitializationComplete: () => this.initializationCompleteFlag,
getMcpReady: () => this.mcpReady,
@@ -294,6 +308,7 @@ export class WorkerService implements WorkerRef {
preBodyParserRoutes: [
new BetterAuthRoutes(() => this.dbManager.getConnection()),
],
...(tvToken ? { remoteReadOnly: { getToken: () => tvToken } } : {}),
});
this.registerRoutes();
@@ -419,6 +434,23 @@ export class WorkerService implements WorkerRef {
await this.server.listen(port, host);
if (this.tvToken) {
// Operators need to see, in the log, that a remote surface is open.
// Never log the token itself.
logger.info('SYSTEM', 'Observation TV remote broadcast enabled', {
host,
allowedPaths: ['/tv', '/tv.html', '/stream', 'GET /api/observations'],
});
} else if (host !== '127.0.0.1' && host !== '::1' && host !== '::ffff:127.0.0.1' && host !== 'localhost') {
// Warn, do not refuse to bind: docs/docker.md tells people to set
// CLAUDE_MEM_WORKER_HOST=0.0.0.0, and refusing would break that install.
logger.warn(
'SECURITY',
'Worker bound to a non-loopback host with no CLAUDE_MEM_TV_TOKEN — the full worker API, including provider API keys via GET /api/settings, is reachable from the network',
{ host }
);
}
writePidFile({
pid: process.pid,
port,
+237 -1
View File
@@ -1,7 +1,13 @@
import express, { Request, Response, NextFunction, RequestHandler } from 'express';
import path from 'path';
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
import { getPackageRoot } from '../../../shared/paths.js';
import {
hasForwardedClientHeaders,
isLocalhost,
parseBearerToken,
} from '../../../server/middleware/request-auth-helpers.js';
import { logger } from '../../../utils/logger.js';
export function createMiddleware(): RequestHandler[] {
@@ -45,7 +51,12 @@ export function createCorsMiddleware(): RequestHandler {
const origin = req.headers.origin;
if (origin) {
if (!origin.startsWith('http://localhost:') && !origin.startsWith('http://127.0.0.1:')) {
next(new Error('CORS not allowed'));
// Write the response here rather than forwarding an error. The worker never
// calls finalizeRoutes(), so it has no terminal error handler: a
// forwarded error lands in Express's default handler, which returns a
// 500 HTML page containing a stack trace with absolute filesystem
// paths. Same rule as the remote read-only guard below.
res.status(403).json({ error: 'Forbidden', message: 'CORS not allowed' });
return;
}
res.setHeader('Access-Control-Allow-Origin', origin);
@@ -85,6 +96,231 @@ export function requireLocalhost(req: Request, res: Response, next: NextFunction
next();
}
// ---------------------------------------------------------------------------
// Observation TV remote read-only broadcast guard.
//
// The worker's HTTP surface has no request authentication; its only defence is
// the loopback bind. When the operator opens the bind (CLAUDE_MEM_WORKER_HOST)
// so a phone or a spare monitor can watch Observation TV, this guard is the
// whole security boundary: loopback requests are untouched, and every
// non-loopback request is default-denied except an exact-match allowlist of
// four read-only paths behind a shared secret.
//
// Allowlist only, never the inverse: the route count grows every release, and
// a list of "dangerous" routes is only a list of the ones someone remembered.
// ---------------------------------------------------------------------------
export interface RemoteReadOnlyOptions {
/** Called per request. Empty string ⇒ this guard should never have been mounted. */
getToken: () => string;
}
// Exact match only. No prefixes, no regular expressions: `/api/observations`
// admits `/api/observations` and nothing else, so `/api/observations/by-file`
// and `POST /api/observations/batch` are both denied. That is intended.
const REMOTE_READABLE_PATHS: ReadonlySet<string> = new Set([
'/tv',
'/tv.html',
'/stream',
'/api/observations',
]);
// One line that kills every mutation, including routes that do not exist yet
// and including `ALL /api/auth/*splat`.
const REMOTE_READABLE_METHODS: ReadonlySet<string> = new Set(['GET', 'HEAD']);
/**
* Mint an Observation TV shared secret. Same idiom as `createRawServerApiKey()`
* but deliberately WITHOUT the `cmem_` prefix that prefix means "a DB-backed
* scoped API key" in this codebase, and this is not one.
*/
export function generateTvToken(): string {
return randomBytes(32).toString('base64url');
}
function safeEqualSecret(presented: string, expected: string): boolean {
// SHA-256 both sides to a fixed 32 bytes so lengths never leak and
// timingSafeEqual never throws on a length mismatch, then compare in
// constant time. Never use `===` here.
const a = createHash('sha256').update(presented, 'utf8').digest();
const b = createHash('sha256').update(expected, 'utf8').digest();
if (a.length !== b.length) {
return false;
}
try {
return timingSafeEqual(a, b);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.warn('SECURITY', 'timing-safe secret comparison failed', {}, err);
return false;
}
}
export type RemoteAccessDecision =
| { allow: true }
| { allow: false; status: 403 | 404 | 401; reason: 'method' | 'path' | 'token' };
/**
* The whole policy, as a pure function so it can be unit-tested without a
* second network interface. The middleware below is a thin adapter over it.
* An empty `expectedToken` denies everything the guard should not be
* mounted at all in that case, but it must fail closed if it is.
*/
export function decideRemoteAccess(input: {
method: string;
path: string;
presentedToken: string | null;
expectedToken: string;
}): RemoteAccessDecision {
// 2. Method gate — before the path check, so a mutation of an allowlisted
// path is 403 rather than leaking that the path exists.
if (!REMOTE_READABLE_METHODS.has(input.method.toUpperCase())) {
return { allow: false, status: 403, reason: 'method' };
}
// 3. Path allowlist. 404 rather than 403 so a scanner is not told which
// NON-allowlisted routes exist — a probe of `/api/settings` looks the same
// as a probe of a path that was never mounted. It is not blanket
// anonymity: an unauthenticated caller still gets 401 (not 404) on the four
// allowlisted paths, so those are enumerable, and the 403 body on a
// mutation names Observation TV.
if (!REMOTE_READABLE_PATHS.has(input.path)) {
return { allow: false, status: 404, reason: 'path' };
}
// 5. Constant-time compare. A missing token, or an unconfigured expected
// token, denies.
if (!input.expectedToken || !input.presentedToken) {
return { allow: false, status: 401, reason: 'token' };
}
if (!safeEqualSecret(input.presentedToken, input.expectedToken)) {
return { allow: false, status: 401, reason: 'token' };
}
return { allow: true };
}
// Rate-limit the denial warn so a LAN scanner cannot fill the log file: the
// first 10 denials from a client inside a rolling one-minute window are warned,
// the rest are demoted to debug. The window is what makes this a limiter rather
// than a permanent gag — without it a single port scan would silence warn-level
// alerting for that IP for the life of the worker. On overflow only entries
// whose window has already expired are evicted, never the whole map: clearing
// it wholesale would let an attacker with many source addresses restore
// warn-level logging at will. Deliberately not an abstraction.
const REMOTE_DENIAL_WARN_LIMIT = 10;
const REMOTE_DENIAL_IP_LIMIT = 512;
const REMOTE_DENIAL_WINDOW_MS = 60_000;
const remoteDenialCounts = new Map<string, { count: number; windowStart: number }>();
function logRemoteDenial(fields: { path: string; method: string; clientIp: string; reason: string }): void {
const now = Date.now();
let entry = remoteDenialCounts.get(fields.clientIp);
if (entry && now - entry.windowStart > REMOTE_DENIAL_WINDOW_MS) {
entry.count = 0;
entry.windowStart = now;
}
if (!entry) {
if (remoteDenialCounts.size >= REMOTE_DENIAL_IP_LIMIT) {
let oldestIp: string | null = null;
let oldestStart = Infinity;
for (const [ip, seen] of remoteDenialCounts) {
if (now - seen.windowStart > REMOTE_DENIAL_WINDOW_MS) {
remoteDenialCounts.delete(ip);
} else if (seen.windowStart < oldestStart) {
oldestStart = seen.windowStart;
oldestIp = ip;
}
}
// Nothing had expired — drop only the single oldest live counter so the
// map stays bounded without handing every other IP a fresh warn budget.
if (oldestIp !== null && remoteDenialCounts.size >= REMOTE_DENIAL_IP_LIMIT) {
remoteDenialCounts.delete(oldestIp);
}
}
entry = { count: 0, windowStart: now };
remoteDenialCounts.set(fields.clientIp, entry);
}
entry.count += 1;
if (entry.count <= REMOTE_DENIAL_WARN_LIMIT) {
logger.warn('SECURITY', 'Remote request denied', fields);
} else {
logger.debug('SECURITY', 'Remote request denied', fields);
}
}
// The query parameter exists solely because EventSource cannot set request
// headers. A repeated `?token=` arrives as an array and is not a valid request.
function readQueryToken(req: Request): string | null {
const raw = req.query?.token;
if (Array.isArray(raw) || typeof raw !== 'string') {
return null;
}
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function createRemoteReadOnlyGuard(options: RemoteReadOnlyOptions): RequestHandler {
return (req: Request, res: Response, next: NextFunction): void => {
// 1. Loopback ⇒ untouched, always. A forwarded-client header means the
// request reached us through a proxy, so the socket peer is not the
// real client and the loopback claim is refused.
if (isLocalhost(req) && !hasForwardedClientHeaders(req)) {
next();
return;
}
const clientIp = req.ip || req.socket.remoteAddress || '';
// 4. Token extraction, in the house precedence: Bearer, then X-Api-Key,
// then the query parameter.
const presentedToken =
parseBearerToken(req.header('authorization') ?? '')
|| req.header('x-api-key')?.trim()
|| readQueryToken(req)
|| null;
const decision = decideRemoteAccess({
method: req.method,
path: req.path,
presentedToken,
expectedToken: options.getToken(),
});
if (!decision.allow) {
// Never log the secret or the raw query string. `req.path` excludes the
// query string in Express, which is what makes that safe.
logRemoteDenial({
path: req.path,
method: req.method,
clientIp,
reason: decision.reason,
});
// Always write the response. The worker never calls finalizeRoutes(), so
// it has no terminal error handler — forwarding an error to Express
// would land in its default handler and return an HTML stack page.
if (decision.status === 404) {
res.status(404).json({ error: 'Not found' });
} else if (decision.status === 403) {
res.status(403).json({
error: 'Forbidden',
message: 'Observation TV remote access is read-only'
});
} else {
res.status(401).json({
error: 'Unauthorized',
message: 'Missing or invalid Observation TV token'
});
}
return;
}
// 6. Pass.
res.setHeader('Cache-Control', 'no-store');
next();
};
}
export function summarizeRequestBody(method: string, path: string, body: any): string {
if (!body || Object.keys(body).length === 0) return '';
@@ -74,6 +74,10 @@ export class SettingsRoutes extends BaseRouteHandler {
}
}
// Write whitelist. Secrets are deliberately absent: the Observation TV token,
// and the Chroma / Telegram / CloudSync keys. POST /api/settings has no
// authentication, so any page the user visits could set one of its choosing.
// Keeping them off this list means only the filesystem or the env can set them.
const settingKeys = [
'CLAUDE_MEM_MODEL',
'CLAUDE_MEM_CONTEXT_OBSERVATIONS',
@@ -17,9 +17,22 @@ const VIEWER_HTML_CANDIDATE_PATHS: readonly string[] = (() => {
];
})();
const TV_HTML_CANDIDATE_PATHS: readonly string[] = (() => {
const packageRoot = getPackageRoot();
return [
path.join(packageRoot, 'ui', 'tv.html'),
path.join(packageRoot, 'plugin', 'ui', 'tv.html'),
];
})();
const resolvedViewerHtmlPath: string | null =
VIEWER_HTML_CANDIDATE_PATHS.find((candidate) => existsSync(candidate)) ?? null;
const resolvedTvHtmlPath: string | null =
TV_HTML_CANDIDATE_PATHS.find((candidate) => existsSync(candidate)) ?? null;
const tvHtmlBytes: Buffer | null = resolvedTvHtmlPath ? readFileSync(resolvedTvHtmlPath) : null;
const viewerHtmlBytes: Buffer | null = resolvedViewerHtmlPath
? readFileSync(resolvedViewerHtmlPath)
: null;
@@ -146,6 +159,7 @@ export class ViewerRoutes extends BaseRouteHandler {
app.get('/health', this.handleHealth.bind(this));
app.get('/', this.handleViewerUI.bind(this));
app.get('/tv', this.handleTvUI.bind(this));
app.get('/restart', this.handleRestartPage.bind(this));
app.get('/stream', this.handleSSEStream.bind(this));
}
@@ -172,6 +186,19 @@ export class ViewerRoutes extends BaseRouteHandler {
res.send(viewerHtmlBytes);
});
/**
* Observation TV: the same /stream the viewer consumes, rendered as a
* full-screen fading title card. Static, dependency-free, and served from
* the same origin so the EventSource needs no CORS of its own.
*/
private handleTvUI = this.wrapHandler((req: Request, res: Response): void => {
if (!tvHtmlBytes) {
throw new Error('Observation TV UI not found at any expected location');
}
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(tvHtmlBytes);
});
/**
* The target of the restart link in the observer-outage warning
* (renderObserverHealthWarning). Serves an INERT page: the restart itself is
+12
View File
@@ -90,6 +90,12 @@ export interface SettingsDefaults {
CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID: string;
CLAUDE_MEM_CLOUD_SYNC_DEVICE_NAME: string;
CLAUDE_MEM_CLOUD_SYNC_WS: string; // advisory WebSocket speed layer (Phase 4) — 'false' = HTTP polling only
// Observation TV remote broadcast. EMPTY = OFF: the read-only guard is not
// mounted and the worker behaves exactly as before. Set (with a non-loopback
// CLAUDE_MEM_WORKER_HOST) to expose ONLY /tv, /tv.html, /stream and
// GET /api/observations to holders of this secret. Mint with:
// node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
CLAUDE_MEM_TV_TOKEN: string;
// claude-mem sign-in funnel state, written by the installer's browser-login
// step (install.ts promptBrowserLogin/completeTrialPairing). Declared here so
// loadFromFile round-trips them instead of dropping unknown keys.
@@ -203,6 +209,12 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID: '', // Minted at first CloudSync start, then persisted back here
CLAUDE_MEM_CLOUD_SYNC_DEVICE_NAME: hostname(), // Human-readable label for the cmem.ai Devices panel
CLAUDE_MEM_CLOUD_SYNC_WS: 'true', // Advisory WebSocket speed layer (plan Phase 4). 'false' = HTTP polling only — sync stays fully correct, just poll-latency (prime directive #2)
// Observation TV remote broadcast. EMPTY = OFF: the read-only guard is not
// mounted and the worker behaves exactly as before. Set (with a non-loopback
// CLAUDE_MEM_WORKER_HOST) to expose ONLY /tv, /tv.html, /stream and
// GET /api/observations to holders of this secret. Mint with:
// node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
CLAUDE_MEM_TV_TOKEN: '',
// claude-mem sign-in funnel state: all empty until the installer's
// browser-login step writes them.
CLAUDE_MEM_PRO_TRIAL_EMAIL: '', // Email the sign-in link was sent to (don't-re-nag marker)
+505
View File
@@ -0,0 +1,505 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark">
<title>claude-mem · Observation TV</title>
<style>
:root {
--bg: #0d0d0c;
--fg: #f4f2ee;
--dim: rgba(244, 242, 238, 0.45);
--accent: #c15f3c;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 400 16px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
overflow: hidden;
-webkit-user-select: none;
user-select: none;
}
#stage {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: max(4vmin, env(safe-area-inset-top)) 6vmin;
text-align: center;
}
#card {
max-width: 22ch;
opacity: 0;
transition: opacity 900ms ease, transform 900ms ease;
transform: scale(0.985);
will-change: opacity, transform;
}
#card.show { opacity: 1; transform: scale(1); }
#title {
margin: 0;
font-size: clamp(2rem, 7.5vw, 6.5rem);
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.06;
text-wrap: balance;
}
#meta {
margin-top: 3vmin;
font-size: clamp(0.75rem, 1.6vw, 1.1rem);
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--dim);
}
#meta .type { color: var(--accent); }
#idle {
position: fixed;
inset: 0;
display: grid;
place-items: center;
color: var(--dim);
font-size: clamp(0.8rem, 1.6vw, 1rem);
letter-spacing: 0.18em;
text-transform: uppercase;
opacity: 0;
transition: opacity 600ms ease;
pointer-events: none;
}
#idle.show { opacity: 1; }
#hud {
position: fixed;
top: max(1rem, env(safe-area-inset-top));
right: max(1rem, env(safe-area-inset-right));
display: flex;
gap: 0.5rem;
align-items: center;
opacity: 0;
transition: opacity 400ms ease;
}
#hud.show { opacity: 1; }
#hud button {
font: inherit;
font-size: 0.8rem;
letter-spacing: 0.06em;
padding: 0.45rem 0.85rem;
color: var(--fg);
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
cursor: pointer;
}
#hud button:hover { background: rgba(255, 255, 255, 0.14); }
#dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: #666;
margin-right: 0.25rem;
}
#dot.live { background: #4ec07a; }
/* Off-screen source for the canvas route into picture-in-picture. */
#pip { position: fixed; left: -9999px; top: 0; width: 640px; height: 360px; }
#pipcanvas { position: fixed; left: -9999px; top: 0; }
@media (prefers-reduced-motion: reduce) {
#card { transition-duration: 1ms; }
}
</style>
</head>
<body>
<div id="stage">
<div id="card">
<h1 id="title"></h1>
<div id="meta"></div>
</div>
</div>
<div id="idle">waiting for observations</div>
<div id="hud">
<span id="dot" title="stream status"></span>
<button id="pipBtn" type="button">PiP</button>
<button id="fsBtn" type="button">Full screen</button>
</div>
<video id="pip" muted playsinline></video>
<canvas id="pipcanvas" width="1280" height="720"></canvas>
<script>
(() => {
'use strict';
function clampInt(raw, fallback, min, max) {
const n = parseInt(raw, 10);
if (!Number.isFinite(n)) return fallback;
return Math.min(Math.max(n, min), max);
}
const params = new URLSearchParams(location.search);
const project = params.get('project') || null;
const source = params.get('source') || null;
const DWELL_MS = clampInt(params.get('dwell'), 6000, 1200, 60000);
const FADE_MS = clampInt(params.get('fade'), 900, 100, 5000);
const REPLAY = params.get('replay') !== '0';
const SEED_LIMIT = clampInt(params.get('seed'), 25, 0, 100);
// Shared secret for remote (non-loopback) access. Loopback never needs it.
// Held by the bookmark only — deliberately never stored and never logged.
const TOKEN = params.get('token') || '';
const els = {
stage: document.getElementById('stage'),
card: document.getElementById('card'),
title: document.getElementById('title'),
meta: document.getElementById('meta'),
idle: document.getElementById('idle'),
hud: document.getElementById('hud'),
dot: document.getElementById('dot'),
pipBtn: document.getElementById('pipBtn'),
fsBtn: document.getElementById('fsBtn'),
video: document.getElementById('pip'),
canvas: document.getElementById('pipcanvas'),
};
// live = observations that arrived while this page was open; they play first,
// in arrival order. replay = the recent backlog, cycled whenever live is
// empty so an idle worker still leaves something on screen.
const live = [];
let replay = [];
let replayIndex = 0;
let current = null;
function leafProject(name) {
if (!name) return '';
const str = String(name);
return str.includes('/') ? str.split('/').pop() : str;
}
// The canonical vocabulary lives in src/shared/platform-source.ts —
// DEFAULT_PLATFORM_SOURCE is 'claude' and the three known names are
// claude / codex / cursor. Only those three names are mirrored here; the full
// normalizePlatformSource() mapping is not duplicated. Anything else keeps
// its own name and is hashed to a hue.
const SOURCE_COLORS = {
claude: '#c15f3c',
codex: '#4f9d8c',
cursor: '#8b7ad6',
};
function platformSource(value) {
const raw = String(value == null ? '' : value).trim().toLowerCase().replace(/\s+/g, '-');
return raw || 'claude';
}
function sourceColor(name) {
if (Object.prototype.hasOwnProperty.call(SOURCE_COLORS, name)) return SOURCE_COLORS[name];
let hash = 0;
for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
return 'hsl(' + (hash % 360) + ', 58%, 62%)';
}
function normalize(obs) {
if (!obs) return null;
const title = (obs.title || obs.subtitle || obs.text || '').trim();
if (!title) return null;
return {
id: obs.id,
title,
type: (obs.type || 'observation').replace(/_/g, ' '),
project: leafProject(obs.project),
platform: platformSource(obs.platform_source),
at: obs.created_at_epoch || Date.now(),
};
}
function matchesFilter(obs) {
if (project && leafProject(obs.project) !== leafProject(project)) return false;
if (source && (obs.platform_source || 'claude') !== source) return false;
return true;
}
function nextItem() {
if (live.length) return live.shift();
if (!REPLAY || replay.length === 0) return null;
const item = replay[replayIndex % replay.length];
replayIndex += 1;
return item;
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function show(on) {
els.card.classList.toggle('show', on);
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
function relTime(epoch) {
const secs = Math.max(0, Math.round((Date.now() - epoch) / 1000));
if (secs < 60) return 'just now';
const mins = Math.round(secs / 60);
if (mins < 60) return mins + 'm ago';
const hrs = Math.round(mins / 60);
if (hrs < 24) return hrs + 'h ago';
return Math.round(hrs / 24) + 'd ago';
}
function render(item) {
els.title.textContent = item.title;
const bits = ['<span class="type">' + escapeHtml(item.type) + '</span>'];
if (item.project) bits.push(escapeHtml(item.project));
// The colour comes from sourceColor(), never from the raw field, so it is
// always a literal this file produced.
bits.push(
'<span class="source" style="color: ' + sourceColor(item.platform) + '">'
+ escapeHtml(item.platform) + '</span>'
);
bits.push(escapeHtml(relTime(item.at)));
els.meta.innerHTML = bits.join(' &nbsp;·&nbsp; ');
document.title = item.title + ' · claude-mem TV';
}
async function loop() {
for (;;) {
const item = nextItem();
if (!item) {
current = null;
show(false);
els.idle.classList.add('show');
await sleep(500);
continue;
}
els.idle.classList.remove('show');
current = item;
render(item);
show(true);
await sleep(DWELL_MS);
show(false);
await sleep(FADE_MS);
}
}
async function seed() {
if (SEED_LIMIT === 0) return;
const qs = new URLSearchParams({ limit: String(SEED_LIMIT) });
if (project) qs.set('project', project);
if (source) qs.set('platformSource', source);
if (TOKEN) qs.set('token', TOKEN);
try {
const res = await fetch('/api/observations?' + qs.toString(), { cache: 'no-store' });
if (!res.ok) return;
const body = await res.json();
// The API returns newest first; the TV plays oldest first so the backlog
// reads forward in time.
replay = (body.items || []).map(normalize).filter(Boolean).reverse();
} catch {
// The live stream alone is enough to run the TV.
}
}
function connect() {
// EventSource cannot set request headers, so a remote stream carries the
// token in the query string.
const es = new EventSource('/stream' + (TOKEN ? '?token=' + encodeURIComponent(TOKEN) : ''));
es.onopen = () => els.dot.classList.add('live');
es.onerror = () => {
els.dot.classList.remove('live');
es.close();
setTimeout(connect, 3000);
};
es.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
} catch {
return;
}
if (data.type !== 'new_observation' || !data.observation) return;
if (!matchesFilter(data.observation)) return;
const item = normalize(data.observation);
if (!item) return;
live.push(item);
replay.push(item);
if (replay.length > 100) replay.shift();
};
}
/* Picture-in-picture, two routes and no library. Document PiP (Chromium)
* moves the real DOM into the floating window, so the CSS fades keep
* running. Everywhere else — including iOS Safari, which is the phone case —
* PiP only accepts a <video>, so the card is painted into a canvas whose
* captureStream() feeds a muted video element. */
const hasDocumentPiP = 'documentPictureInPicture' in window;
let pipWindow = null;
let canvasTimer = null;
async function toggleDocumentPiP() {
if (pipWindow) {
pipWindow.close();
pipWindow = null;
return;
}
pipWindow = await window.documentPictureInPicture.requestWindow({ width: 480, height: 270 });
for (const sheet of document.styleSheets) {
try {
const css = Array.from(sheet.cssRules).map((rule) => rule.cssText).join('\n');
const style = pipWindow.document.createElement('style');
style.textContent = css;
pipWindow.document.head.appendChild(style);
} catch {
// Cross-origin sheet — nothing to copy.
}
}
pipWindow.document.body.appendChild(els.stage);
pipWindow.addEventListener('pagehide', () => {
document.body.prepend(els.stage);
pipWindow = null;
});
}
function wrap(ctx, text, maxWidth, fontSize) {
let size = fontSize;
for (;;) {
ctx.font = '650 ' + size + 'px ui-sans-serif, system-ui, sans-serif';
const words = String(text).split(/\s+/);
const lines = [];
let line = '';
for (const word of words) {
const candidate = line ? line + ' ' + word : word;
if (ctx.measureText(candidate).width > maxWidth && line) {
lines.push(line);
line = word;
} else {
line = candidate;
}
}
if (line) lines.push(line);
if (lines.length <= 4 || size <= 32) return lines.slice(0, 4);
size -= 8;
}
}
function paintCanvas(ctx) {
const w = els.canvas.width;
const h = els.canvas.height;
const style = getComputedStyle(document.body);
ctx.globalAlpha = 1;
ctx.fillStyle = style.getPropertyValue('--bg').trim() || '#0d0d0c';
ctx.fillRect(0, 0, w, h);
if (!current) return;
const alpha = els.card.classList.contains('show') ? 1 : 0.12;
ctx.globalAlpha = alpha;
ctx.fillStyle = style.getPropertyValue('--fg').trim() || '#f4f2ee';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const lines = wrap(ctx, current.title, w * 0.84, 72);
const lineHeight = 78;
const startY = h / 2 - ((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, i) => ctx.fillText(line, w / 2, startY + i * lineHeight));
ctx.globalAlpha = alpha * 0.5;
ctx.font = '500 24px ui-sans-serif, system-ui, sans-serif';
const subY = startY + lines.length * lineHeight + 24;
const sep = ' · ';
// The platform source is painted in its own accent, so the sub line is laid
// out left-to-right by hand instead of as one centred string.
const lead = [current.type, current.project].filter(Boolean).join(sep).toUpperCase();
const leadText = lead ? lead + sep : '';
const sourceText = current.platform.toUpperCase();
const leadWidth = ctx.measureText(leadText).width;
const startX = w / 2 - (leadWidth + ctx.measureText(sourceText).width) / 2;
ctx.textAlign = 'left';
if (leadText) ctx.fillText(leadText, startX, subY);
ctx.fillStyle = sourceColor(current.platform);
ctx.fillText(sourceText, startX + leadWidth, subY);
ctx.textAlign = 'center';
ctx.globalAlpha = 1;
}
function startCanvas() {
if (canvasTimer) return;
const ctx = els.canvas.getContext('2d');
canvasTimer = setInterval(() => paintCanvas(ctx), 1000 / 15);
}
function stopCanvas() {
if (!canvasTimer) return;
clearInterval(canvasTimer);
canvasTimer = null;
}
async function toggleVideoPiP() {
const video = els.video;
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
stopCanvas();
return;
}
startCanvas();
if (!video.srcObject) video.srcObject = els.canvas.captureStream(15);
await video.play();
if (video.requestPictureInPicture) {
await video.requestPictureInPicture();
} else if (video.webkitSetPresentationMode) {
video.webkitSetPresentationMode('picture-in-picture');
}
}
async function togglePiP() {
try {
if (hasDocumentPiP) {
await toggleDocumentPiP();
return;
}
await toggleVideoPiP();
} catch (err) {
console.warn('[tv] picture-in-picture failed', err);
}
}
function toggleFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen();
}
}
let hudTimer = null;
function pokeHud() {
els.hud.classList.add('show');
clearTimeout(hudTimer);
hudTimer = setTimeout(() => els.hud.classList.remove('show'), 2500);
}
els.pipBtn.addEventListener('click', togglePiP);
els.fsBtn.addEventListener('click', toggleFullscreen);
document.addEventListener('mousemove', pokeHud);
document.addEventListener('touchstart', pokeHud, { passive: true });
document.addEventListener('keydown', (event) => {
if (event.key === 'f') toggleFullscreen();
if (event.key === 'p') togglePiP();
});
pokeHud();
connect();
seed().finally(loop);
})();
</script>
</body>
</html>
+435
View File
@@ -0,0 +1,435 @@
// SPDX-License-Identifier: Apache-2.0
//
// Observation TV read-only broadcast guard.
//
// The worker's HTTP surface has no request authentication; its only defence is
// the loopback bind. When the operator opens that bind so a second device can
// watch Observation TV, `createRemoteReadOnlyGuard` is the whole security
// boundary. These tests cover the 23 cases the plan requires: loopback is never
// gated, and every non-loopback request is default-denied except an exact-match
// allowlist of four read-only paths behind a shared secret.
//
// The pure `decideRemoteAccess` carries the policy so it can be exercised
// without a second network interface (the same shape as
// `assertServerRuntimeForCli` in server-runtime-guard.test.ts). The HTTP tests
// reach the non-loopback branch by sending `X-Forwarded-For`: the guard refuses
// a loopback claim that arrived with a forwarded-client header, which is both
// case 23 and the only way to drive the real token-extraction path in-process.
import { afterAll, beforeAll, describe, expect, it, spyOn } from 'bun:test';
import type { Application, Request, Response } from 'express';
import { logger } from '../../src/utils/logger.js';
import { Server, type ServerOptions } from '../../src/services/server/Server.js';
import { decideRemoteAccess, generateTvToken } from '../../src/services/worker/http/middleware.js';
const TOKEN = 'tv-test-secret-token';
const REMOTE = { 'x-forwarded-for': '203.0.113.5' };
function baseOptions(overrides: Partial<ServerOptions> = {}): ServerOptions {
return {
getInitializationComplete: () => true,
getMcpReady: () => true,
onShutdown: () => Promise.resolve(),
onRestart: () => Promise.resolve(),
workerPath: '/test/worker-service.cjs',
getAiStatus: () => ({ provider: 'disabled', authMethod: 'api-key', lastInteraction: null }),
...overrides,
};
}
// Stubs for the routes the real worker mounts after construction, so an
// allowed request lands on a handler instead of Express's default 404.
const stubRoutes = {
setupRoutes(app: Application): void {
const ok = (_req: Request, res: Response) => { res.json({ stub: true }); };
app.get('/api/settings', ok);
app.get('/api/observations', ok);
app.get('/api/observations/by-file', ok);
app.get('/api/logs', ok);
app.get('/api/auth/session', ok);
app.get('/v1/info', ok);
app.get('/viewer.html', ok);
app.get('/restart', ok);
app.get('/tv', ok);
app.get('/tv.html', ok);
app.get('/stream', ok);
app.post('/api/settings', ok);
app.post('/api/observations/batch', ok);
app.delete('/api/observation/:id', ok);
app.get('/', ok);
},
};
let guarded: Server;
let guardedPort = 0;
let unguarded: Server;
let unguardedPort = 0;
let emptyToken: Server;
let emptyTokenPort = 0;
let spies: ReturnType<typeof spyOn>[] = [];
async function start(options: ServerOptions): Promise<{ server: Server; port: number }> {
let lastError: unknown = null;
for (let attempt = 0; attempt < 12; attempt++) {
const server = new Server(options);
const port = 41000 + Math.floor(Math.random() * 9000);
try {
await server.listen(port, '127.0.0.1');
server.registerRoutes(stubRoutes);
return { server, port };
} catch (error) {
lastError = error;
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
beforeAll(async () => {
spies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
// POST /api/admin/restart answers through flushResponseThen, whose
// `finish` handler calls process.exit(0). Case 2 exercises that route on
// loopback, so the exit is neutered for the length of this file — without
// it the runner dies mid-suite and reports nothing.
spyOn(process, 'exit').mockImplementation(() => undefined as never),
];
({ server: guarded, port: guardedPort } = await start(
baseOptions({ remoteReadOnly: { getToken: () => TOKEN } }),
));
({ server: unguarded, port: unguardedPort } = await start(baseOptions()));
({ server: emptyToken, port: emptyTokenPort } = await start(
baseOptions({ remoteReadOnly: { getToken: () => '' } }),
));
});
afterAll(async () => {
for (const server of [guarded, unguarded, emptyToken]) {
if (server?.getHttpServer()) {
try { await server.close(); } catch { /* ignore */ }
}
}
spies.forEach(s => s.mockRestore());
spies = [];
});
function url(path: string): string {
return `http://127.0.0.1:${guardedPort}${path}`;
}
// A request that passes the guard is stamped `Cache-Control: no-store`, which
// makes "allowed" observable independently of whatever handler runs next.
function allowed(res: globalThis.Response): boolean {
return res.headers.get('cache-control') === 'no-store';
}
describe('Observation TV guard — loopback is never gated', () => {
it('case 1: loopback GET /api/settings with no token reaches the handler', async () => {
const res = await fetch(url('/api/settings'));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ stub: true });
// Not stamped by the guard: loopback short-circuits before the pass path.
expect(res.headers.get('cache-control')).toBeNull();
});
it('case 2: loopback POST /api/admin/restart still behaves as today (requireLocalhost governs)', async () => {
// The guard must not touch this: it is a POST, and a POST from a remote
// client is 403 (case 12). From loopback it reaches requireLocalhost and
// the admin handler exactly as before the guard existed.
const res = await fetch(url('/api/admin/restart'), { method: 'POST' });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ status: 'restarting' });
expect(res.headers.get('cache-control')).toBeNull();
});
it('loopback is unchanged when no guard is mounted at all', async () => {
const res = await fetch(`http://127.0.0.1:${unguardedPort}/api/settings`);
expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toBeNull();
});
});
describe('Observation TV guard — remote allow paths', () => {
it('case 3: remote GET /tv with the correct ?token= is allowed', async () => {
const res = await fetch(url(`/tv?token=${encodeURIComponent(TOKEN)}`), { headers: REMOTE });
expect(res.status).toBe(200);
expect(allowed(res)).toBe(true);
});
it('case 4: remote GET /tv with a correct Authorization: Bearer is allowed', async () => {
const res = await fetch(url('/tv'), {
headers: { ...REMOTE, authorization: `Bearer ${TOKEN}` },
});
expect(res.status).toBe(200);
expect(allowed(res)).toBe(true);
});
it('case 5: remote GET /tv with a correct X-Api-Key is allowed', async () => {
const res = await fetch(url('/tv'), { headers: { ...REMOTE, 'x-api-key': TOKEN } });
expect(res.status).toBe(200);
expect(allowed(res)).toBe(true);
});
it('case 8: remote GET /stream with the correct token is allowed', async () => {
const res = await fetch(url(`/stream?token=${encodeURIComponent(TOKEN)}`), { headers: REMOTE });
expect(res.status).toBe(200);
expect(allowed(res)).toBe(true);
});
it('case 9: remote GET /api/observations with the correct token is allowed', async () => {
const res = await fetch(url(`/api/observations?token=${encodeURIComponent(TOKEN)}`), { headers: REMOTE });
expect(res.status).toBe(200);
expect(allowed(res)).toBe(true);
});
it('remote GET /tv.html with the correct token is allowed', async () => {
const res = await fetch(url(`/tv.html?token=${encodeURIComponent(TOKEN)}`), { headers: REMOTE });
// Both halves matter: the guard stamps `no-store` BEFORE calling next(), so
// the header alone would still be there if the downstream stack blew up.
expect(res.status).toBe(200);
expect(allowed(res)).toBe(true);
});
// Token extraction trims deliberately (parseBearerToken trims, x-api-key is
// .trim()ed, readQueryToken trims), so surrounding whitespace is accepted.
it('a token with surrounding whitespace is accepted — extraction trims before the compare', async () => {
const res = await fetch(url(`/tv?token=${encodeURIComponent(` ${TOKEN} `)}`), { headers: REMOTE });
expect(res.status).toBe(200);
expect(allowed(res)).toBe(true);
const viaHeader = await fetch(url('/tv'), { headers: { ...REMOTE, 'x-api-key': ` ${TOKEN} ` } });
expect(viaHeader.status).toBe(200);
expect(allowed(viaHeader)).toBe(true);
});
// Regression: createCorsMiddleware used to answer a foreign Origin with
// next(new Error('CORS not allowed')). The worker never calls
// finalizeRoutes(), so that reached Express's default error handler and
// returned a 500 HTML page with a stack trace full of absolute paths — on any
// allowlisted path, to any token holder who sent an Origin header.
it('an allowed remote request with a foreign Origin is never a 500 stack page', async () => {
const res = await fetch(url(`/tv?token=${encodeURIComponent(TOKEN)}`), {
headers: { ...REMOTE, origin: 'http://evil.example' },
});
expect(res.status).not.toBe(500);
expect(res.status).toBe(403);
const body = await res.text();
expect(body).not.toContain('Error:');
expect(body).not.toContain(' at ');
expect(body).not.toContain('middleware.ts');
expect(body).not.toContain('/workspace');
expect(JSON.parse(body)).toEqual({ error: 'Forbidden', message: 'CORS not allowed' });
});
});
describe('Observation TV guard — remote token failures are 401', () => {
it('case 6: remote GET /tv with a wrong token is 401', async () => {
const res = await fetch(url('/tv?token=wrong'), { headers: REMOTE });
expect(res.status).toBe(401);
expect(await res.json()).toEqual({
error: 'Unauthorized',
message: 'Missing or invalid Observation TV token',
});
});
it('case 7: remote GET /tv with no token at all is 401', async () => {
const res = await fetch(url('/tv'), { headers: REMOTE });
expect(res.status).toBe(401);
expect(await res.json()).toEqual({
error: 'Unauthorized',
message: 'Missing or invalid Observation TV token',
});
});
it('case 21: a repeated ?token= (array) is 401 and does not crash the worker', async () => {
const res = await fetch(url(`/tv?token=${encodeURIComponent(TOKEN)}&token=other`), { headers: REMOTE });
expect(res.status).toBe(401);
// Still serving afterwards.
const alive = await fetch(url(`/tv?token=${encodeURIComponent(TOKEN)}`), { headers: REMOTE });
expect(alive.status).toBe(200);
});
});
describe('Observation TV guard — everything off the allowlist is 404', () => {
const cases: Array<[string, string]> = [
['case 10 (the headline)', '/api/settings'],
['case 11 (exact match, not prefix)', '/api/observations/by-file'],
['case 15', '/api/auth/session'],
['case 16', '/v1/info'],
['case 17a', '/'],
['case 17b', '/viewer.html'],
['case 18', '/restart'],
['case 19', '/api/logs'],
['case 20 (pid/platform disclosure)', '/health'],
];
for (const [label, path] of cases) {
it(`${label}: remote GET ${path} with the CORRECT token is 404`, async () => {
const res = await fetch(url(`${path}?token=${encodeURIComponent(TOKEN)}`), { headers: REMOTE });
expect(res.status).toBe(404);
expect(await res.json()).toEqual({ error: 'Not found' });
});
}
it('case 10: the 404 body carries no settings payload', async () => {
const res = await fetch(url(`/api/settings?token=${encodeURIComponent(TOKEN)}`), { headers: REMOTE });
const body = await res.text();
expect(body).toBe(JSON.stringify({ error: 'Not found' }));
expect(body.toLowerCase()).not.toContain('api_key');
expect(body).not.toContain('stub');
});
});
describe('Observation TV guard — every mutation is 403 (method gate fires first)', () => {
it('case 12: remote POST /api/admin/restart with the correct token is 403', async () => {
const res = await fetch(url(`/api/admin/restart?token=${encodeURIComponent(TOKEN)}`), {
method: 'POST',
headers: REMOTE,
});
expect(res.status).toBe(403);
expect(await res.json()).toEqual({
error: 'Forbidden',
message: 'Observation TV remote access is read-only',
});
// The worker is still alive, which proves the restart was refused rather
// than merely answered after running.
const alive = await fetch(url(`/tv?token=${encodeURIComponent(TOKEN)}`), { headers: REMOTE });
expect(alive.status).toBe(200);
});
it('case 13: remote POST /api/settings with the correct token is 403', async () => {
const res = await fetch(url(`/api/settings?token=${encodeURIComponent(TOKEN)}`), {
method: 'POST',
headers: REMOTE,
});
expect(res.status).toBe(403);
});
it('case 14: remote POST /api/observations/batch with the correct token is 403', async () => {
const res = await fetch(url(`/api/observations/batch?token=${encodeURIComponent(TOKEN)}`), {
method: 'POST',
headers: REMOTE,
});
expect(res.status).toBe(403);
});
it('remote DELETE /api/observation/1 with the correct token is 403', async () => {
const res = await fetch(url(`/api/observation/1?token=${encodeURIComponent(TOKEN)}`), {
method: 'DELETE',
headers: REMOTE,
});
expect(res.status).toBe(403);
});
});
describe('Observation TV guard — fail closed and forwarded-header handling', () => {
it('case 22: decideRemoteAccess denies everything when the expected token is empty', () => {
expect(decideRemoteAccess({ method: 'GET', path: '/tv', presentedToken: '', expectedToken: '' }))
.toEqual({ allow: false, status: 401, reason: 'token' });
expect(decideRemoteAccess({ method: 'GET', path: '/tv', presentedToken: 'anything', expectedToken: '' }))
.toEqual({ allow: false, status: 401, reason: 'token' });
expect(decideRemoteAccess({ method: 'GET', path: '/stream', presentedToken: null, expectedToken: '' }))
.toEqual({ allow: false, status: 401, reason: 'token' });
expect(decideRemoteAccess({ method: 'GET', path: '/api/settings', presentedToken: 'x', expectedToken: '' }))
.toEqual({ allow: false, status: 404, reason: 'path' });
expect(decideRemoteAccess({ method: 'POST', path: '/tv', presentedToken: 'x', expectedToken: '' }))
.toEqual({ allow: false, status: 403, reason: 'method' });
});
it('case 22 (mounted): a guard with an empty token denies every remote request', async () => {
const tv = await fetch(`http://127.0.0.1:${emptyTokenPort}/tv?token=${encodeURIComponent(TOKEN)}`, { headers: REMOTE });
expect(tv.status).toBe(401);
const settings = await fetch(`http://127.0.0.1:${emptyTokenPort}/api/settings`, { headers: REMOTE });
expect(settings.status).toBe(404);
// ...and loopback still works.
const local = await fetch(`http://127.0.0.1:${emptyTokenPort}/api/settings`);
expect(local.status).toBe(200);
});
it('case 23: a loopback req.ip carrying X-Forwarded-For is treated as remote, not loopback', async () => {
const spoofed = await fetch(url('/api/settings'), { headers: { 'x-forwarded-for': '1.2.3.4' } });
expect(spoofed.status).toBe(404);
expect(await spoofed.json()).toEqual({ error: 'Not found' });
// The same request without the header is plain loopback and reaches the handler.
const plain = await fetch(url('/api/settings'));
expect(plain.status).toBe(200);
expect(await plain.json()).toEqual({ stub: true });
});
it('the other forwarded-client headers are refused too', async () => {
for (const header of ['forwarded', 'x-forwarded-host', 'x-real-ip']) {
const res = await fetch(url('/api/settings'), { headers: { [header]: 'proxy.example' } });
expect(res.status).toBe(404);
}
});
});
describe('decideRemoteAccess — the policy as a pure function', () => {
const expected = TOKEN;
it('allows only GET/HEAD on the four allowlisted paths with the right token', () => {
for (const path of ['/tv', '/tv.html', '/stream', '/api/observations']) {
for (const method of ['GET', 'HEAD']) {
expect(decideRemoteAccess({ method, path, presentedToken: expected, expectedToken: expected }))
.toEqual({ allow: true });
}
}
});
it('gates the method before the path, so a mutation never reveals which paths exist', () => {
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
expect(decideRemoteAccess({ method, path: '/tv', presentedToken: expected, expectedToken: expected }))
.toEqual({ allow: false, status: 403, reason: 'method' });
expect(decideRemoteAccess({ method, path: '/api/settings', presentedToken: expected, expectedToken: expected }))
.toEqual({ allow: false, status: 403, reason: 'method' });
}
});
it('matches paths exactly — no prefixes, no trailing slash, no case folding', () => {
for (const path of [
'/api/observations/by-file',
'/api/observations/batch',
'/api/observations/',
'/tv/',
'/tvx',
'/TV',
'/stream/x',
'/api/settings',
'/health',
'/',
]) {
expect(decideRemoteAccess({ method: 'GET', path, presentedToken: expected, expectedToken: expected }))
.toEqual({ allow: false, status: 404, reason: 'path' });
}
});
it('rejects a missing, empty or wrong token on an allowlisted path', () => {
expect(decideRemoteAccess({ method: 'GET', path: '/tv', presentedToken: null, expectedToken: expected }))
.toEqual({ allow: false, status: 401, reason: 'token' });
expect(decideRemoteAccess({ method: 'GET', path: '/tv', presentedToken: '', expectedToken: expected }))
.toEqual({ allow: false, status: 401, reason: 'token' });
expect(decideRemoteAccess({ method: 'GET', path: '/tv', presentedToken: expected.slice(0, -1), expectedToken: expected }))
.toEqual({ allow: false, status: 401, reason: 'token' });
});
// The pure function compares byte-exactly and does NOT trim — but no caller
// ever reaches it untrimmed, because every extraction path in the middleware
// trims first. Over real HTTP a padded token is therefore accepted; see the
// whitespace case in "remote allow paths".
it('compares byte-exactly — trimming is the extraction layer\'s job, not this function\'s', () => {
expect(decideRemoteAccess({ method: 'GET', path: '/tv', presentedToken: `${expected} `, expectedToken: expected }))
.toEqual({ allow: false, status: 401, reason: 'token' });
});
});
describe('generateTvToken', () => {
it('mints a 32-byte base64url secret with no cmem_ prefix', () => {
const token = generateTvToken();
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(token.startsWith('cmem_')).toBe(false);
expect(generateTvToken()).not.toBe(token);
});
});