Gated self-hosting docs at /docs/self-hosting for entitled customers

Merged
KJ
10d ago
+3791-6

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:

ReaderResponse
Signed out, or Cloud rejects the session307 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.
EntitledAn 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

  1. Same-domain session. In production Cloud's agent_relay_session cookie is Domain=.agentrelay.com; Path=/; HttpOnly, so the browser sends it to /docs/*. The page forwards only that cookie, server to server, to Cloud's new GET /cloud/api/v1/me/selfhost-docs (AgentWorkforce/cloud#5985).
  2. 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 selfhostDocs RPC over the private service binding (AgentWorkforce/relay-agent#100).
  3. 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 carries Cache-Control: private, no-store, X-Robots-Tag: noindex, nofollow, Referrer-Policy: no-referrer and frame-ancestors 'none'. These are set in next.config.mjs and 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 evaluate needs new Function, which Workers forbid). It drops raw HTML and images, and keeps only /, #, https: and mailto: 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-next runs in verify.yml (after next build), and in preview.yml / deploy.yml after populateCache, before upload or deploy.
  • It fails when:
    • a gated route is prerendered or present in assets;
    • prerender-manifest.json lists one;
    • any text file contains fingerprinted runbook text.
  • CI can't hold the private text, so web/security/selfhost-runbook-fingerprints.json stores 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: vitest 54 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> and javascript: 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-store with redirect: manual. It also rejects oversized or malformed replies, and the header config is checked.
  • Router: 120 tests passed, including the new recorder exclusion. tsc passed for web and router; oxlint reports 0 errors.
  • cf:build plus populateCache 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 --local on the OpenNext output, with a loopback mock of Cloud's API):
CaseResult
Signed out, /docs/self-hosting/gke-install-runbook307 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
Entitled200 runbook with canaries, copy button, selfhost: link, ph-sensitive, private, no-store
Same URL signed out, then unentitled, right after an entitled read307, then the access page, 0 canaries (no cache poisoning)
RSC requests307, no content
Unknown runbook, nested path404, no-store
Public /docs/sessionsUnchanged static cache, carries the nav link
sitemap.xml, llms.txt0 self-hosting mentions
Mock Cloud logReceived only agent_relay_session (PostHog and other cookies were not forwarded)

Deploy order (do not merge; production deploys)

  1. relay-agent #99, then relay-agent #100 (selfhostDocs RPC).
  2. Cloud #5984, then Cloud #5985 (merge-train:hold). Without (1), the API returns 503 and never content.
  3. 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
Open in [code]smith 路 Need help on this PR? Tag @codesmith-bot with what you need.


Agent Relay sessions

  • claude session a19e66d9 路 opened via gh pr create 路 last active 2026-11-04