Docs content is data, never code. This page lists what LaRecipe guarantees, so you can review it and know where your own code takes over:
- Content cannot run code. Raw HTML is escaped, unsafe links are dropped, Blade is never evaluated, and only components your app registered render.
- Content cannot read secrets. No component reads configuration or environment values, and snippet includes refuse files that may hold secrets.
- One access policy. Every surface, from the HTML page to the MCP server, asks the same policy, and a page a reader may not see does not exist for them.
- Agents are read-only by default. The web MCP endpoint only reads, unless you open author tools on a database set.
- No AI in CI. The optional CI check only reads and reports.
Each section below has the detail.
Content cannot run code
Pages cannot run PHP or inject scripts, whichever source they come from. A database set adds an HTML sanitizer on top, because people editing in the admin UI may be less trusted than your codebase.
How content is kept inert
- Raw HTML is escaped. A
<script>, an<iframe>or an inlineonclickin Markdown shows up as text. A repository set, whose pages are reviewed in git, may allow raw HTML withhtml_inputset toallow; even then the tags GitHub filters, such as<script>,<iframe>and<style>, stay escaped. A database set always escapes it, and every page it renders also goes through an HTML sanitizer, so a gap anywhere else can never put a script, an event handler or ajavascript:URL on a page. See Markdown. - Unsafe links are dropped.
javascript:,vbscript:,file:and mostdata:links in Markdown lose their target, and a component's URL props (href,src,poster) arrive empty when they use one of those schemes. Links in navigation, the navbar, the footer and the repository link must behttp,httpsormailtoURLs or paths on your site: anything else is left out of every page,larecipe:validatereports it aslinks.unsafe, and a database set refuses to save it. - Attributes are allowlisted. The
{#id .class}syntax may setidandclass, nothing else. - No Blade. Markdown is never compiled or evaluated as Blade.
{{ $user->email }}in a page is text. - Components are allowlisted. A tag renders only when a PHP definition exists for it: a built-in, or one your app registered with
LaRecipe::component(). Any other tag is escaped text and acomponents.unknownerror. Props are checked against the definition on every render and bylarecipe:validate. Built-in components drop every prop they do not declare, and so does every component on a database set, so a value LaRecipe derives (a video's player URL, a diagram's source) never comes from the author. AVideoframes only YouTube, Vimeo and Loom players overhttps. Component code ships only from your app's own build, never from content. - Settings from content are checked. Theme tokens and analytics IDs are checked against the shape they should have before they reach the page, a custom analytics script must be an
httpsURL, and nothing inlarecipe.jsoncan add inline code. A database set takes its analytics fromconfig/larecipe.phponly, and a self-hosted analytics host counts only from config. See analytics. - Visibility fails closed. A
visibilityin frontmatter or on a version is trimmed and lowercased, and any value other thanpublicmakes the page or version private.
Content cannot read secrets
There is no <Config> and no <Env> component, on purpose: a page that mentions a setting names it in inline code. Snippet includes stay inside their root and refuse the files that may hold secrets, the same list the larecipe:sync agent never reads.
Live references, includes, paths and the asset route
- Live references show only public shape.
<Route>,<Artisan>,<Model>and<Event>describe a route's method, URI and middleware names, a command's signature, an event's payload and listeners, and models only in the namespacesreferences.models.allowlists. A<Route>shows the names of a form request's rules but never their arguments, middleware without parameters, never the domain, and only routesreferences.routes.allowallows (by default, your app's own, not those of packages invendor/). It never runs the route. An<Artisan>never describes a hidden command, and shows a default only when the command's$signaturedeclares it. See live references. - Snippet includes stay inside their root.
<<< @/pathresolves only insidesnippets.root: your app's base path for repository sets, and nothing at all for database sets unless you set a root. Even inside the root, dot paths such as.env,config/,storage/,vendor/, keys, credentials, databases, dumps and logs are refused. A refused include renders a warning in its place and asnippets.deniederror. Symlinks are followed to where the file really is, and checked there. - Paths never leave their folder. Pages, docs assets and LaRecipe's own UI files are resolved to real paths and refused when they land outside their root, so
../in a URL or a link reaches nothing. - The asset route serves only docs files.
/{prefix}/_assets/...never serves a page's Markdown source (.md, in any case),larecipe.json,navigation.json, a dot file or a file of unknown type, and every file goes out withX-Content-Type-Options: nosniff. A private version's files need the same access as its pages. The route is rate limited per reader byassets.rate_limit(600 requests a minute by default).
One access policy
Every surface asks the same policy whether a reader may see a page: the HTML page, its Markdown twin, the navigation JSON, llms.txt, llms-full.txt, skill.md, the sitemap, search, MCP, Ask AI and page feedback. A hidden page answers 404, never 403, in every format. Gate blocks and private docs covers access modes, gates and private pages.
How the policy holds on every surface
- Who may enter is the set's
access.mode:public,auth(any signed-in user) orgate(yourviewLarecipegate). Private versions and pages always need the gate. - A hidden page is a 404, never a 403, in every format. The response is the same as for a page that does not exist, so access rules never confirm that a private page is there, and the suggestions on a Markdown 404 list only pages the caller may see.
- Gate blocks are removed, not hidden. A
<Can>block a reader may not see is taken out of the page before it renders, so it is not in the HTML, the Markdown, the table of contents or the search records they get. - Search never ships what a reader may not see. Each reader's browser downloads an index built for what they may see, and asking for another reader's index file redirects to your own. The server ranker filters the same way. See search.
Responses and shared caches
Every page response says how it may be cached, so a shared cache or a CDN never hands one reader's page to another:
| Response | Cache-Control |
Also |
|---|---|---|
| A public page, for a guest | public, max-age=60, s-maxage=600 |
Vary: Accept, an ETag |
| A public page that carries a CSRF token, or any format a signed-in reader gets of a public page | private, no-cache |
Vary: Accept, Cookie, an ETag that never matches the guest's |
A page in a set that is not public, a private page or version, a page opened with a share link, or a page whose gate blocks granted this reader something guests do not get |
private, no-store |
X-Robots-Tag: noindex, no ETag |
| A public page with gate blocks, served to a guest | public, max-age=60, s-maxage=600 |
Vary: Accept, Cookie |
A page with noindex: true in its frontmatter |
unchanged | X-Robots-Tag: noindex |
A page opened with a share link also sends Referrer-Policy: no-referrer, so the token in its URL never leaks to the next site. A signed-in reader's response is personal in every format (HTML, the JSON the UI fetches, and Markdown), because its navigation can list pages guests never see. Agent surfaces lists the other headers every page sends.
Custom components
Your own React components run with your app's permissions, so LaRecipe keeps their reach small: request() calls your own routes only, no user data reaches the browser unless you share it, and CSRF tokens are never cached.
request(), shareUser(), CSRF and CSP nonces
request()calls your own routes only. It refuses any URL on another origin, sends the session cookie and Laravel's XSRF token, and goes through your routes and middleware like any other request.- No user data reaches the browser unless you share it with
LaRecipe::shareUser(), which picks the fields:LaRecipe::shareUser(fn ($user) => ['name' => $user->name]). - CSRF tokens are never cached. Rendered pages are cached and shared between readers, so a fallback form prints
LaRecipe::csrfField(), a placeholder LaRecipe fills with the reader's own token on every response. A response that carries one is markedprivate, and a static export empties it. - A Content Security Policy nonce set with
LaRecipe::useCspNonce()is added to every script tag LaRecipe prints: the UI, the import map, your extension, the color-mode script and the analytics tags.
Agents are read-only by default
The web MCP endpoint of each set serves the read tools and submit_feedback, through the access policy, with a rate limit. Author tools never run on the web for a repository set, and on a database set only when you turn them on for callers your manageLarecipe gate allows.
What each MCP server may do
- The web MCP endpoint of each set serves the read tools (
search_docs,read_page,list_pages) andsubmit_feedback, which fires an event and stores nothing. Every call goes through the access policy with the caller's identity, and the endpoint is rate limited bymcp.web.rate_limit. To serve private docs to agents, put authentication in front of it withmcp.web.middleware, for example['auth:sanctum']. validate_docsreads every page, private ones included, so the web endpoint offers it only to callers yourmanageLarecipegate allows.- Author tools never run on the web for a repository set: its changes belong in git. For a database set they appear only when you turn on
mcp.web.author_tools, and then only for a signed-in caller themanageLarecipegate allows. - The local MCP server (
php artisan larecipe:mcp) has the author tools. It runs on your machine, as you, for the coding agent you started.
See the MCP server.
No AI in CI
The optional CI check that larecipe:install can add runs larecipe:validate and larecipe:affected on pull requests. It runs no AI agent, never commits or pushes, and needs no secrets for a repository set, so it is safe on pull requests from forks. Its token can read the repository and, in comment mode, write the comment, nothing else. See the CI check.
Next
- Gate blocks and private docs: content per reader, on every surface.
- Custom React components:
request(),shareUser()and the fallback form. - Deployment: the production checklist.