Gated self-hosting docs at /docs/self-hosting for entitled customers
What
Adds human-readable self-hosting docs at agentrelay.com/docs/self-hosting. Only signed-in members of an organization with the self-host entitlement can read them. The page states:
| Reader | Response |
|---|---|
| Signed out, or Cloud rejects the session | 307 to /login?next=/docs/self-hosting/<doc>. After OAuth, Cloud lands on /cloud/docs/self-hosting/<doc>, which 303s back here. |
| Signed in, organization not entitled | "Request access" page (founders@agentrelay.com, plus a note to switch organization in Cloud). It contains no runbook names or text. |
| Entitled | An overview, then each runbook. The overview has the architecture and request-flow diagrams from helm-charts, the oci://ghcr.io/agentworkforce/charts/agent-relay chart, and Ask the setup agent. That section covers the Cloud MCP, a claude mcp add command and a copyable ask_customer_support prompt for the guided GKE install. Pages have a runbook nav (sidebar, plus an in-article list on narrow screens), an on-page TOC, copy buttons on code blocks, and selfhost:<name> cross-references rendered as links. Styling reuses the docs CSS. |
| Cloud error or timeout | "Temporarily unavailable". Never content. |
How the protection works
- Same-domain session. In production Cloud's
agent_relay_sessioncookie isDomain=.agentrelay.com; Path=/; HttpOnly, so the browser sends it to/docs/*. The page forwards only that cookie, server to server, to Cloud's newGET /cloud/api/v1/me/selfhost-docs(AgentWorkforce/cloud#5985). - Cloud is the gate. It accepts browser sessions only and resolves the organization from fresh membership. It reads the staff-granted entitlement (AgentWorkforce/cloud#5984) on each request. Only then does it call relay-agent's
selfhostDocsRPC over the private service binding (AgentWorkforce/relay-agent#100). - Single source, never in this repo. The runbooks are relay-agent's
knowledge/private/selfhost/, the same files the support agent reads (AgentWorkforce/relay-agent#99). This repository is public, so "copy + drift check" and "shared package" were ruled out. Rendering from that directory at request time through Cloud means a runbook edit updates both audiences on the next relay-agent deploy, and nothing can drift.
Caching and leak controls
force-dynamic. Every/docs/self-hosting*response carriesCache-Control: private, no-store,X-Robots-Tag: noindex, nofollow,Referrer-Policy: no-referrerandframe-ancestors 'none'. These are set innext.config.mjsand asserted by a test.- One generic
<title>for every state. - Nothing is prerendered, so OpenNext's static-assets incremental cache has no entry for these routes. With
no-store, the Cloudflare edge won't keep a copy either. - The routes are not in the sitemap,
llms.txt, docs search or the[slug]static params. - Links into the gated pages use
prefetch={false}, including the public nav's "Customers > Self-hosting" link. - Entitled content sits under
ph-sensitive, the PostHog replay block selector. - The markdown renderer uses unified, remark and hast-to-jsx, with no eval (MDX's
evaluateneedsnew Function, which Workers forbid). It drops raw HTML and images, and keeps only/,#,https:andmailto:links. - Router: the R2 traffic recorder now skips
/docs/self-hosting*,/cloud/docs/self-hosting*and/cloud/api/v1/me/selfhost-docs, so runbook bodies never land in the replay corpus.
Build-output check (CI)
web/scripts/selfhost-docs-leak-check.mjs check .next .open-nextruns inverify.yml(afternext build), and inpreview.yml/deploy.ymlafterpopulateCache, before upload or deploy.- It fails when:
- a gated route is prerendered or present in assets;
prerender-manifest.jsonlists one;- any text file contains fingerprinted runbook text.
- CI can't hold the private text, so
web/security/selfhost-runbook-fingerprints.jsonstores truncated SHA-256 hashes of sampled 12-word windows (2,180 hashes). That is enough to recognise a copied passage, even JSON-escaped inside a bundle, and useless for reading one. Windows that also occur in this repo's public sources are excluded. - Regenerate the file when the runbooks change:
node web/scripts/selfhost-docs-leak-check.mjs fingerprint --runbooks <relay-agent>/knowledge/private/selfhost --ref <sha>. verify-worker.mjs(preview smoke) now also asserts the signed-out redirect and its headers on the built Worker.
Design choice: docs site, not the dashboard
Same-domain verification works because the cookie is apex-scoped with Path=/. So the docs live with the rest of the docs: same nav, styling and URLs, linkable from public pages. Cloud remains the only thing that decides entitlement. The alternative was /cloud/dashboard/self-hosting. It would skip one server-to-server hop, but it would need a second docs renderer and stylesheet in the dashboard, and its URLs would only make sense inside Cloud. Cost of this choice: one extra round trip (docs Worker to router to Cloud to relay-agent) per page view. Pages are uncached by design, so that is the price of the gate.
Tests
On sf-frame:
- Unit and page tests:
vitest54 files / 605 tests passed.- Signed out: redirect with the return path, and Cloud is not called.
- Cloud 401: redirect.
- Unentitled: the access page, checked for zero canary strings across titles, descriptions, body and headings.
- Entitled: content, the copy button, cross-links, unique heading ids and tables;
<script>andjavascript:are dropped. - Overview: diagrams, chart and setup agent.
- Unknown or nested path: 404. Cloud error: no content.
- The fetch forwards only the cookie and uses
no-storewithredirect: manual. It also rejects oversized or malformed replies, and the header config is checked.
- Router: 120 tests passed, including the new recorder exclusion.
tscpassed for web and router;oxlintreports 0 errors. cf:buildpluspopulateCache local, then the leak check: clean. A positive control (a 260-character runbook excerpt planted as a JS string in.open-next/assets) was detected and the check exited 1.- Built-Worker E2E (
wrangler dev --localon the OpenNext output, with a loopback mock of Cloud's API):
| Case | Result |
|---|---|
Signed out, /docs/self-hosting/gke-install-runbook | 307 to /login?next=%2Fdocs%2Fself-hosting%2Fgke-install-runbook, private, no-store, noindex |
| Unentitled session (plus PostHog and other cookies) | 200 access page, 0 private canaries |
| Entitled | 200 runbook with canaries, copy button, selfhost: link, ph-sensitive, private, no-store |
| Same URL signed out, then unentitled, right after an entitled read | 307, then the access page, 0 canaries (no cache poisoning) |
| RSC requests | 307, no content |
| Unknown runbook, nested path | 404, no-store |
Public /docs/sessions | Unchanged static cache, carries the nav link |
sitemap.xml, llms.txt | 0 self-hosting mentions |
| Mock Cloud log | Received only agent_relay_session (PostHog and other cookies were not forwarded) |
Deploy order (do not merge; production deploys)
- relay-agent #99, then relay-agent #100 (
selfhostDocsRPC). - Cloud #5984, then Cloud #5985 (
merge-train:hold). Without (1), the API returns 503 and never content. - This PR: the web Worker, then the router (
deploy-router.yml) for the recorder exclusion. The router change is independent and safe to ship first.
Until (2) is live, entitled readers see "temporarily unavailable" and everyone else is redirected or sees the access page. No runbook text can appear.
馃 Generated with Claude Code
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Open in [code]smith 路 Need help on this PR? Tag @codesmith-bot with what you need.
Agent Relay sessions
claudesessiona19e66d9路 opened viagh pr create路 last active 2026-11-04