ADR-0002: Public/internal isolation is physical, not a route filter
Context
Section titled “Context”The build brief is explicit: “Do not rely on hiding links… the public
build must physically exclude internal source content from generated
output whenever possible.” Starlight’s docsLoader() loads every file
present under src/content/docs/ into the docs collection and routes
it — there’s no supported per-request visibility filter at that layer, and
even if there were, a filter is one bug away from a leak (wrong middleware
order, a cached response, a search index built before the filter ran).
Decision
Section titled “Decision”content/docs/public/** and content/docs/internal/** are the canonical
source trees. scripts/stage-content.mjs <public|internal> copies ONLY
the requested tree’s contents into src/content/docs/ before Astro is
invoked; the other tree is never read. npm run build:public runs this
staging step first, so an internal page literally does not exist as a file
during that build — it can’t leak into HTML, the Pagefind search index, a
source map, or an error stack trace, because the bytes were never on disk.
scripts/check-isolation.mjs (npm run test) enforces this in CI: it
writes a throwaway internal-only page with a unique marker, builds public,
asserts the marker is absent from dist/public (recursively, all file
types), then builds internal and asserts the marker IS present there — so
the test can’t pass by accident if staging silently no-ops.
Alternatives Considered
Section titled “Alternatives Considered”- Frontmatter
visibilityfilter in the Starlight loader config — rejected per the “don’t rely on hiding” requirement above; also fragile against schema evolution and third-party integrations that might all-list the content directory (e.g. Pagefind’s indexer). - Two separate Starlight
docscollections, one per audience, always both present — rejected: still leaves internal source bytes on disk during a public build, which is exactly the risk this ADR closes. - Robots.txt / noindex + route-level auth only — kept as a complement
(internal pages do carry
noindex, nofollow), never a substitute: noindex doesn’t stop a direct URL fetch, and Atlas’s public build shouldn’t even serve a URL that returns internal content, authenticated or not.
Consequences
Section titled “Consequences”src/content/docs/is generated, not committed (.gitignore) — anyone reading the repo must know to look incontent/docs/{public,internal}/for source, notsrc/content/docs/.- Every build script variant (
dev,dev:public,dev:internal,build:public,build:internal) must remember to stage first; forgetting produces an empty or wrong-audience site rather than a leak, which is the safe failure direction. - A contributor can’t preview “both audiences at once” in one dev server —
npm run dev:publicandnpm run dev:internalare separate processes. Judged an acceptable tradeoff for the security property.
Related Work
Section titled “Related Work”- ADR-0001: Atlas platform choice
scripts/stage-content.mjs,scripts/check-isolation.mjs