# Going public — the runbook vidtheque is exposed to the internet by a Cloudflare tunnel from the box that runs it: a welcome page, a read-only dashboard, and a demo over the AI Engineer corpus, with the MCP server itself reachable so anyone can point their own agent at it. This document turns that day into a checklist. **What is on the box, since the front-end cutover.** Four processes and one way in: ``` Cloudflare edge ──tunnel──▶ cloudflared ──▶ caddy ──▶ web the pages │ (Next.js) TLS ends here └──▶ mcp /api, /frames, /mcp, /dashboard/api, the writes, /healthz └──▶ worker (GPU, loopback) ``` `caddy` is the origin: it is the only listener the tunnel can reach, and which of the two servers answers a given path is `deploy/Caddyfile`, which implements `docs/design/frontend-migration.md` §1a and §1d — including three paths that are split by **method**, `GET` to the front end and the form's `POST` to Python. Python renders no page at all since 2026-09-06 (`dashboard.md` §23). Everything this runbook said about binding the origin to loopback is now said about the edge, and is stronger for it: `mcp` and `web` publish no port at all. **Who runs this.** The operator, with an agent alongside. The tunnel, the domain, the DNS records and the security audit were reserved for "together" from the start, and that reservation is the point of §1: everything below §1 is mechanical, and §1 is not. **What this is not.** It is not a hosting guide for anyone else's box, and it is not a substitute for the contracts. `docs/design/demo-site.md` is what public mode *is*; `docs/design/dashboard.md` §2.3–§2.4 is what the read-only projection shows; `deploy/.env.example` is the document of record for every variable named here. When this file disagrees with one of those, they win and this file is wrong. **Order matters.** §1 gates §2. §2 gates §5. Do not create a DNS record for a box you have not audited: a hostname that resolves is a hostname that gets scanned, and the tunnel goes live the moment `cloudflared` connects. --- ## 1. The gate — the security audit is required before first public traffic **This is a hard gate.** The deferred security audit happens **before** the first public request, not after the URL is shared and not "next week". It is run **with the operator**, as a decision-making session, not unilaterally by an agent: several of the findings below are *policy* questions — how much of the corpus is public, whether ask mode ships on day one, what a scraper is allowed to take — and an agent cannot answer those. Write the outcome to `research/security-audit-.md` (research docs are append-only; add a clearly-headed section). Every item below is either **pass**, **accepted risk with a sentence of reasoning**, or **blocked**. A blocked item means the URL is not shared. ### 1.1 What the audit must cover **Auth surfaces.** `VIDTHEQUE_AUTH` is one branch chosen at boot (`mcp/src/vidtheque_mcp/config.py:190`). For the public box the answer is `none` — see §2.2 — which means *there is no 401 anywhere in the app*. So the audit's question is not "is auth configured", it is **"is everything reachable without a credential something we are content to publish?"** Enumerate the surface and answer per route: `/api/search|videos|meta`, `/api/ask`, `/frames/*.jpg`, `/dashboard/api/*`, `/videos/{id}/export.md`, `/mcp`, `/healthz`. Under `none`, `auth.routes` is empty and `/auth/*`, `/.well-known/*` and the SDK's root-level `/authorize`, `/token`, `/register` and `/revoke` do not exist — confirm that, do not assume it. **Two processes answer that hostname now, and only one of them is in this list.** Python serves no page since 2026-09-06 — `/`, `/demo`, `/videos*` and every `/dashboard` page are the Next.js app's, and the edge decides which process gets a request (`deploy/Caddyfile`, `docs/design/frontend-migration.md` §1a and §1d). The enumeration above is the *credential* surface and it is unchanged by that, because the front end holds no credential and reads only what an anonymous visitor could read anyway. What the audit gains is one question: **does the edge route what the contract says it routes**, since a misroute is a page where an API should be. `deploy/staging/install.md` §5b is that check. **That list was incomplete, corrected 2026-08-11.** It omitted `/dashboard/videos*`, `/dashboard/jobs*`, `/dashboard/api/jobs*`, `/dashboard/static/*` and the `/dashboard/` redirect — all mounted anonymously — and, on `/mcp`, the **three public resource URIs** and the session lifecycle. (Of those, the pages and `/dashboard/static/*` are gone from Python entirely; the JSON and the redirect are what is left, and they are in the list above.) Two of the audit's findings lived in exactly the gap: `/dashboard/videos/{id}` published every stage's model id and raw error while the jobs view beside it redacted both, and sessions accumulated without limit because nobody had enumerated the lifecycle as part of the surface. Enumerate from the router, not from this list. **Readonly masking completeness.** Two independent mechanisms have to both be on, and they are on for different reasons: - `VIDTHEQUE_PUBLIC_READONLY=1` derives the masked tool set from the contract's `readOnlyHint` annotations (`public/readonly.py:16`), so the write tools are **never registered** rather than registered-and-refusing. Verify against a live `tools/list` through the tunnel, not against the source. - `VIDTHEQUE_AUTH=none` means the dashboard's write side is never registered at all (`dashboard/access.py`, `write_side_enabled`), and so does `VIDTHEQUE_PUBLIC_READONLY=1` — the same predicate, either half sufficient. `WRITE_ROUTES` is five paths since phase 3 (login, logout, index, reindex, tags); what to confirm is that **none of them is in the router**, and that each 404s rather than 403ing: `for p in login logout index; do curl -s -o /dev/null -w "%{http_code} /$p\n" -X POST http://127.0.0.1:8100/dashboard/$p; done` — expect `404` three times. Then check the redactions actually applied to data: `_redacted()` is `assembled.public.enabled` (`dashboard/read_models.py`, which is where the assemblers moved when `views.py` was deleted with the Jinja pages), and it drops `error_message` from job cards, `source_url` and `error_message` from job items, and `message` from job events. Everything else on that view — states, codes, counts, clocks — is deliberately kept (`dashboard.md` §10.4). **Resolved in dashboard phase 4 (2026-08-09): fixed, not accepted.** This runbook found the *corpus overview* unredacted — declared embedding model ids, the vector-leg state including `vectors.reason`, database and keyframe byte totals, and `auth={{ auth_mode }}` in the page chrome — against §2.4's promise of "no settings, no paths". There were no filesystem paths in it, but the model ids *are* settings. The projection now drops all four (`read_models.redacted`, and the amendment under §2.4's table), keeping the one thing a visitor can act on: that vector search is off, without the mismatch that caused it. So the audit's job here is now **verification, not a decision** — §2.5 has the grep. **"Fixed" was true of the dashboard and not of the deployment — 2026-08-11.** Three other anonymous read paths published the same class of data, and §2.5's greps could not see any of them because they only ever read dashboard HTML: MCP `job-status` handed back the submitted URL and the yt-dlp error text the jobs view had just withheld (a visitor reads a job id off `/dashboard/jobs`, which renders them as links, and quotes it at `/mcp`); `corpus-summary` printed raw `video_stages.error`; `/dashboard/videos/{id}` published `model_key` and stage errors. All three are fixed and the pairing now has a test, because nothing tested it and that is why it survived. The lesson worth keeping: redaction here is written per *view*, so each new view starts unredacted and remembers on its own. §2.5's greps check one surface. The question to ask of any new read path is what it prints, not whether the flag is set. **Clamp policy on `/dashboard/api/*` — RESOLVED 2026-08-09 (dashboard phase 5): fixed, not accepted.** The three answers below are kept because the reasoning is the record; what changed is that answer 3 was taken, in its honest form. **The clamp policy now follows the credential, not the flag and not the prefix** (`public/api.py:policy_for`, `auth/credential.py:is_owner`, dashboard.md §2.4): a bearer, a `vidtheque_session` cookie or a socket peer inside `VIDTHEQUE_DASHBOARD_TRUSTED_CIDRS` gets `OWNER_CLAMPS` on either prefix in every mode; everything else, **including every request in `AUTH=none`**, gets `PUBLIC_CLAMPS` on either prefix. What is left for this runbook is verification: ```bash # Both must now say "public" on the public deployment. curl -s http://127.0.0.1:8100/api/meta | jq .clamps curl -s http://127.0.0.1:8100/dashboard/api/meta | jq .clamps # The hatch, the thing that actually mattered: the reply must be truncated, # with the `…` marker, not a full transcript. curl -s 'http://127.0.0.1:8100/dashboard/api/search?q=transformer&max_text_chars=0' \ | jq '[.results[].text | length] | max' # expect <= ~480, never thousands ``` If the instance has a token configured, the contrast is the check that the fix is credential-keyed rather than mode-keyed — the same URL with `-H "Authorization: Bearer $VIDTHEQUE_TOKEN"` must come back `"owner"`. **And the audit must check the allowlist itself.** `VIDTHEQUE_DASHBOARD_TRUSTED_CIDRS` is now one of the three things that says "owner", and it is decided on the **socket peer** — the address the kernel reports. Behind a tunnel that address is *cloudflared's*, not the visitor's: the connector speaks from loopback, or from a docker bridge if it runs in compose. So a CIDR covering the network the connector connects from makes **every anonymous visitor through the tunnel an owner** — owner clamps on both prefixes, the full-transcript hatch, and with `AUTH=token` the credential-free write side (indexing, re-indexing, tagging). `X-Forwarded-For`/`CF-Connecting-IP` cannot cause this — authorization never reads them (§4) — but they also cannot save you from it, because the socket is genuinely the proxy's. On the public deployment the correct value is **empty**, which is the default. Raised by the 2026-08-09 review; a fuller answer (rejecting proxy-origin CIDRs outright, or requiring a credential when the request came through the tunnel) is deferred to this audit. What ships tonight is a boot-time warning: the server logs `WARNING … treated as the owner` when the allowlist overlaps loopback, RFC1918 or IPv6 unique-local **and** `VIDTHEQUE_TRUSTED_IP_HEADER` is set, since a trusted header is the tell that a proxy is in front. Verify both halves: ```bash # 1. The config. On the public box this should print nothing at all. grep -E '^VIDTHEQUE_DASHBOARD_TRUSTED_CIDRS=.+' deploy/.env # 2. The boot log. Any hit here is a finding, not a note. docker compose logs mcp 2>&1 | grep -i 'treated as the owner' # 3. The behaviour, from outside, with no credential: must be "public". curl -s https:///dashboard/api/meta | jq .clamps.policy ``` A LAN deployment with no proxy in front is the legitimate case the warning cannot distinguish, which is why it is a warning and not a refusal. If that is this box, record the reasoning in the audit write-up rather than ignoring the line. *Amended 2026-10-21: the header is no longer a condition.* The warning became a boot refusal on 2026-08-11 (gate G2), and since 2026-10-21 it fires on a proxy-origin CIDR plus a non-loopback `VIDTHEQUE_PUBLIC_HOSTNAME` whatever `VIDTHEQUE_TRUSTED_IP_HEADER` says: an empty header is what `.env.example` advises behind a host caddy, and that caddy is the socket peer all the same. A LAN box with no public hostname is still untouched (`dashboard/settings.py:refuse_proxy_origin_cidrs`). The original finding, and the three answers it landed on, follow. Found while shipping phase 4, not fixed there, because it is the "what is a scraper allowed to take" question this section says an agent cannot answer. `/dashboard/api/*` is registered in **every** mode, and it was registered with `OWNER_CLAMPS` (`dashboard/__init__.py`, `public/api.py`); in the intended production combination (`READONLY=1` + `AUTH=none`) there is no credential in front of it — so an anonymous visitor got the *owner's* bounds on the JSON facade rather than the demo's: ```bash curl -s http://127.0.0.1:8100/api/meta | jq .clamps # policy "public", 20/50 curl -s http://127.0.0.1:8100/dashboard/api/meta | jq .clamps # policy "owner", 50/100 ``` Two of the three differences are only "a bigger page of the same public listing". The third is not: `OWNER_CLAMPS.search_text_chars is None` passes the caller's `max_text_chars` through, **including the documented `0` opt-out**, so `/dashboard/api/search?q=…&max_text_chars=0` returns untruncated transcript text — the full-transcript escape hatch `demo-site.md` §2 reserves for "an owner's agent, not anonymous traffic". At `VIDTHEQUE_RATE_DASHBOARD_PER_MIN=120` the whole corpus's transcripts are a short crawl. The audit lands on one of, in increasing order of cost: 1. **Accept it.** The corpus is talks that are public on YouTube and the transcripts are derived from them; if the answer to §1's first question is "all of it is public", this changes nothing. 2. **`VIDTHEQUE_DASHBOARD=0`**, or the ingress rule in `deploy/cloudflared.example.yml` that 404s `^/dashboard` at the edge — which also costs the browsable corpus, so it is the answer only if the dashboard was not wanted publicly anyway. 3. **Make the policy follow the mode**, one line: `api_routes(PUBLIC_CLAMPS if readonly else OWNER_CLAMPS, ROOT, ask=False)`. It is deliberately not done here because it rewrites a phase-1 assertion (`test_one_set_of_handlers_serves_both_prefixes` pins owner clamps on that prefix *in demo mode*), and because a read-only deployment that also has a credential configured would then clamp its own owner. If it is taken, the honest version keys off the credential rather than the flag, which is a design change and belongs in phase 5 beside search-and-ask moving in. **Taken — the honest version, and not the one-liner.** Both of the costs this entry priced were real and both were paid: two phase-1 assertions were rewritten (the one named here, and `test_the_dashboard_json_is_clamped_ server_side`, which read the owner ceiling on an anonymous request), and the mode-keyed form was rejected on its own argument — the reference deployment is read-only *and* has a token. Trusted CIDRs were ruled authenticated- equivalent: that setting already grants the whole write side with no credential presented, so a network trusted to change the corpus but not to read a transcript of it would be a boundary with no shape, and it is what gives an `AUTH=none` LAN instance its owner back. **Rate-limit bypass.** The limiter is in-memory, single-process, and charges before the handler runs (`public/ratelimit.py`). Probe, through the tunnel: per-IP `search` (30/min), `ask` (5/min), `frames` (120/min), `dashboard` (120/min), and the server-wide `ask_global` (50/day). Check the bypasses specifically: a forged client-IP header (§4), a path that maps to no bucket (`bucket_for` returns `None` for anything outside `/api/`, `/frames/`, `/dashboard` and `/mcp`), and whether the refund paths can be driven to mint credit (they refill-then-cap, `Bucket.give_back`, so they should not). **Corrected 2026-08-11: `/mcp` is no longer unlimited.** It was, deliberately, on the argument that an agent's traffic is not a browser's — which is true and still left the surface that reaches the GPU unmetered: MCP `search` embeds its query on the worker, so concurrent anonymous calls were forward passes with nothing bounding them. It now has its own loose bucket (`VIDTHEQUE_RATE_MCP_PER_MIN`, 120), and query embedding happens inside search admission rather than before it (2026-08-10 audit, F-1). Probe it like the others. **SSRF in the CIMD fetcher.** `auth/cimd.py:guard_ssrf` requires https, refuses credentials in the URL, and refuses any host that resolves to a private, loopback, link-local or reserved address — *unless* `allow_insecure` is set, which returns early and skips the private-address check entirely (`cimd.py:74`). `allow_insecure` is `not settings.public_url.startswith("https://")` (`auth/modes.py:123`, `auth/provider.py:56`). **So an `http://` PUBLIC_URL turns the SSRF guard off.** In `AUTH=none` the fetcher is never constructed and the whole question is moot; the audit's job is to confirm that, and to confirm PUBLIC_URL is https anyway (§3), so the guard is armed if the mode ever changes. If the box ever runs `oauth`, re-audit: `_resolves_to_private` does a DNS lookup and the fetch happens afterwards, which is a TOCTOU rebinding window, and `follow_redirects=False` is what currently keeps it narrow. **The frames signer.** Under `AUTH=none` there is no signer: `thumb_url` emits unsigned `/frames/.jpg?w=&q=` and the route answers everyone (`http/frames.py:190`, `public/api.py:149-156`). That is the shipped decision (`demo-site.md` §5) and the guard is the rate limiter, not a signature. What the audit checks is that the *unsigned* path is still bounded: `FRAME_ID` only matches `