E6: Public/Private Doc Access Control
Status: Step 1 Complete — Ready for Step 2 (Agent Prompt Crafting) Epic: Foundry v0.2 Created: March 31, 2026 Updated: March 31, 2026 Authors: Dan Hannah & Clay
Overview
What Is This Epic?
Add content visibility controls to Foundry: public docs are readable by anyone, private docs require authentication. This enables Dan to publish CSDLC methodology docs publicly while keeping project-specific docs (GMPPU, QuoteAI, etc.) behind a token. Built entirely on E5's bearer token auth — no new auth system needed.
Problem Statement
Dan's Foundry instance contains two types of content:
- Methodology docs (CSDLC process, values, templates) — meant to be public, shareable, part of the professional brand
- Project docs (Routr, QuoteAI, Foundry internals, GMPPU) — private, contain business logic, architecture decisions, and client-specific information
Currently all content has the same visibility. Without access control:
- Publishing Foundry to the internet exposes everything
- Dan can't share methodology docs without also exposing project internals
- No way to have a public-facing knowledge base AND private project documentation in one tool
Goals
- Source-level access control via
foundry.config.yaml(access: publicoraccess: private) - Server-side gating: private doc routes return 401 without valid token
- Nav sidebar hides private docs for unauthenticated users
- Search results filtered by access level (server-side)
-
.access.jsondrives all access decisions (already generated by build script) - Same token from E5 — no new auth mechanism
Non-Goals (Deferred)
- Doc-level access control (frontmatter
access: privateon individual files) — source-level is sufficient for now - Multiple access tiers (e.g., team, admin, public) — just public vs. private
- GitHub Pages public-only build — nice optimization, not required for MVP
- Lock icons in nav for unauthenticated users — private docs simply don't appear
Architecture
How It Works
The plumbing already exists. foundry.config.yaml already has access fields per source, and the build script already generates .access.json mapping content paths to access levels.
# foundry.config.yaml (already configured)
sources:
- repo: danhannah94/csdlc-docs
branch: main
paths:
- "docs/methodology/"
- "docs/about/"
access: public
- repo: danhannah94/csdlc-docs
branch: main
paths:
- "docs/projects/"
access: private
// .access.json (already generated by build script)
{
"methodology/": "public",
"about/": "public",
"projects/": "private"
}
Content Flow
Build time:
foundry.config.yaml → build.sh → ALL docs built (public + private)
.access.json generated with access mapping
Request time (unauthenticated):
GET /docs/methodology/process → ✅ served (public)
GET /docs/projects/routr/design → ❌ 401 (private, no token)
GET /api/docs/projects/routr/design → ❌ 401 (private, no token)
GET /api/search?q=routr → results filtered, private docs excluded
Request time (authenticated):
GET /docs/projects/routr/design → ✅ served (token valid)
GET /api/search?q=routr → all results included
Nav sidebar shows all docs
Where Gating Happens
| Layer | What's Gated | How |
|---|---|---|
| Express static middleware | HTML pages for private docs | Check request path against .access.json, require auth |
| API doc endpoints | /api/docs/* for private content | Same path check + auth middleware |
| API search | Search results | Filter results by access level before returning |
| Frontend nav | Sidebar entries | Fetch .access.json + auth state → hide private entries |
| MCP doc tools | search_docs, get_page, get_section | Server-side filtering, same as API |
Static File Serving Strategy
The key challenge: Astro builds static HTML files that Express serves. We need to intercept requests for private doc pages before Express serves them.
// Middleware order matters:
// 1. Check if requested path maps to private content
// 2. If private → require auth (same E5 middleware)
// 3. If public or authenticated → serve static file
app.use('/docs', (req, res, next) => {
const docPath = req.path.replace(/^\//, '').replace(/\/index\.html$/, '').replace(/\/$/, '');
const accessLevel = getAccessLevel(docPath, accessJson);
if (accessLevel === 'private') {
return requireAuth(req, res, next);
}
next();
});
app.use('/docs', express.static('packages/site/dist/docs'));
Nav Sidebar Changes
Currently the nav is built at compile time from nav.yaml. For public/private filtering, the frontend needs to know:
- Which nav entries are private (from
.access.json) - Whether the user is authenticated (from localStorage token)
Approach: Expose .access.json via a public API endpoint (GET /api/access). Frontend fetches it on load, cross-references with nav entries, hides private entries when unauthenticated. On token entry (E5 modal), page refreshes and full nav appears.
Search Filtering
Anvil search returns results with doc_path fields. The API search endpoint filters results:
- Unauthenticated: exclude results where
doc_pathmaps toprivatein.access.json - Authenticated: return all results
Filtering happens server-side — private doc content never reaches unauthenticated clients.
Dependencies
- E5 (Auth): Bearer token middleware, frontend token modal, localStorage token management
- Existing:
.access.jsongeneration in build script,foundry.config.yamlaccess fields
E6 adds zero new auth infrastructure. Everything extends E5.
Stories
S1: Access JSON API + Static File Gating
Batch: 1 (foundation, must ship first)
Scope:
- Create
GET /api/accessendpoint — serves.access.json(public, no auth required) - Load
.access.jsoninto memory at server startup - Create
getAccessLevel(docPath, accessJson)utility — resolves a doc path to its access level by matching against.access.jsonprefixes - Add middleware to static file serving: intercept requests for private doc paths, require E5 bearer token auth
- Private doc request without token → 401 (not 404 — user knows content exists but needs auth)
- Update
/api/docs/*endpoints with same access check - Tests: public paths served without auth, private paths return 401, private paths return 200 with valid token,
.access.jsonendpoint returns correct data
Acceptance Criteria:
-
GET /api/accessreturns.access.jsoncontent - Static HTML for public docs served without auth
- Static HTML for private docs returns 401 without token
- Static HTML for private docs served with valid token
-
/api/docs/*endpoints respect access levels -
getAccessLevel()correctly resolves nested paths (e.g.,projects/routr/designmatchesprojects/prefix) - Tests cover public access, private access denied, private access granted
Boundaries:
- No nav changes (that's S2)
- No search changes (that's S3)
- No MCP changes (that's S3)
S2: Nav Sidebar Access Filtering
Batch: 2 (depends on S1)
Scope:
- Frontend fetches
/api/accesson page load - Cross-reference nav entries with access levels
- Unauthenticated: hide nav entries that map to private content paths
- Authenticated (localStorage token exists): show all nav entries
- On token entry via E5 modal → page refresh → full nav rendered
- Handle edge case: nav section where ALL children are private → hide the section header too
- No visual indicator for "this section is hidden" — private docs simply don't appear
Acceptance Criteria:
- Unauthenticated users see only public doc entries in nav
- Authenticated users see all doc entries in nav
- Section headers with only private children are hidden
- Token entry triggers page refresh showing full nav
- Token removal (401 clear) triggers refresh showing public-only nav
- No 🔒 icons or "hidden content" indicators — clean nav
Boundaries:
- No static file gating (that's S1)
- No search changes (that's S3)
- Nav structure (hierarchy, collapse, breadcrumbs) unchanged — just visibility filtering
S3: Search + MCP Access Filtering
Batch: 2 ⚡ (parallel with S2, depends on S1)
Scope:
- Update
/api/searchendpoint: filter Anvil results by access level before returning - Unauthenticated request → exclude results where
doc_pathresolves toprivate - Authenticated request → return all results
- Update MCP
search_docstool: same server-side filtering - Update MCP
get_pageandget_sectiontools: check access level, return error for private content without auth list_pagesMCP tool: filter private pages for unauthenticated clients- Tests: search excludes private results without auth, includes with auth; MCP tools respect access levels
Acceptance Criteria:
- Search results exclude private docs for unauthenticated requests
- Search results include private docs for authenticated requests
- MCP
search_docsfilters by access level - MCP
get_pagereturns error for private docs without auth - MCP
get_sectionreturns error for private docs without auth - MCP
list_pagesexcludes private pages without auth - Private doc content never returned to unauthenticated clients (server-side filtering)
- Tests cover all filtering scenarios
Boundaries:
- No nav changes (that's S2)
- No new search functionality — just access filtering on existing search
- No Anvil changes — filtering happens at the Foundry API layer
S4: Documentation + Deploy Config
Batch: 3 (depends on all above)
Scope:
- Update
DEPLOY.mdwith public/private configuration:- How to set
accessinfoundry.config.yaml - How same-repo path splitting works
- What unauthenticated vs authenticated users see
- How to set
- Update
foundry.config.yamlcomments with access field documentation - Update
README.mdwith public/private feature description - Verify Docker build with access-gated content
- End-to-end test: build container → verify public docs accessible → verify private docs gated → verify auth unlocks everything
Acceptance Criteria:
-
DEPLOY.mddocuments access configuration clearly -
foundry.config.yamlhas inline comments explaining access field - Docker build passes with access-gated content
- End-to-end flow verified: public open, private gated, auth unlocks all
Boundaries:
- No code changes (documentation + verification only)
- No GitHub Pages public-only build (deferred optimization)
Execution Plan
Batch 1: S1 (access JSON API + static file gating) — foundation
Batch 2: S2 + S3 (nav filtering + search filtering) — Lightning Strike ⚡, git worktrees
Batch 3: S4 (documentation + deploy verification)
3 batches, 4 stories. Mirrors E5's structure — foundation → parallel features → docs/deploy.
Decisions Log
| # | Question | Decision | Date |
|---|---|---|---|
| 1 | Access granularity | Source-level (per-path in config), not doc-level (frontmatter) | Mar 31 |
| 2 | Private doc handling | Server-gated: built into container, served only with auth | Mar 31 |
| 3 | Nav for unauthenticated | Private entries disappear entirely (no lock icons) | Mar 31 |
| 4 | Search filtering | Server-side: private results excluded for unauthenticated users | Mar 31 |
| 5 | Build strategy | One build, server-gated. Fly.io is primary deployment with auth. | Mar 31 |
| 6 | Auth mechanism | Same E5 bearer token — no new auth system | Mar 31 |
| 7 | 401 vs 404 for private | 401 (user knows content exists, needs auth) — not 404 | Mar 31 |
| 8 | GitHub Pages public build | Deferred optimization — not needed for MVP | Mar 31 |
Roadmap Context
E5: Bearer token auth → deployment gate ✅ (designed)
E6 (this): Public/private docs → content visibility
Someday: Doc-level access (frontmatter), multiple tiers, GitHub Pages public mirror