Foundry Foundry

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: public or access: 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.json drives 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: private on 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

LayerWhat's GatedHow
Express static middlewareHTML pages for private docsCheck request path against .access.json, require auth
API doc endpoints/api/docs/* for private contentSame path check + auth middleware
API searchSearch resultsFilter results by access level before returning
Frontend navSidebar entriesFetch .access.json + auth state → hide private entries
MCP doc toolssearch_docs, get_page, get_sectionServer-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'));

Currently the nav is built at compile time from nav.yaml. For public/private filtering, the frontend needs to know:

  1. Which nav entries are private (from .access.json)
  2. 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_path maps to private in .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.json generation in build script, foundry.config.yaml access 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/access endpoint — serves .access.json (public, no auth required)
  • Load .access.json into memory at server startup
  • Create getAccessLevel(docPath, accessJson) utility — resolves a doc path to its access level by matching against .access.json prefixes
  • 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.json endpoint returns correct data

Acceptance Criteria:

  • GET /api/access returns .access.json content
  • 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/design matches projects/ 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/access on 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/search endpoint: filter Anvil results by access level before returning
  • Unauthenticated request → exclude results where doc_path resolves to private
  • Authenticated request → return all results
  • Update MCP search_docs tool: same server-side filtering
  • Update MCP get_page and get_section tools: check access level, return error for private content without auth
  • list_pages MCP 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_docs filters by access level
  • MCP get_page returns error for private docs without auth
  • MCP get_section returns error for private docs without auth
  • MCP list_pages excludes 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.md with public/private configuration:
    • How to set access in foundry.config.yaml
    • How same-repo path splitting works
    • What unauthenticated vs authenticated users see
  • Update foundry.config.yaml comments with access field documentation
  • Update README.md with 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.md documents access configuration clearly
  • foundry.config.yaml has 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

#QuestionDecisionDate
1Access granularitySource-level (per-path in config), not doc-level (frontmatter)Mar 31
2Private doc handlingServer-gated: built into container, served only with authMar 31
3Nav for unauthenticatedPrivate entries disappear entirely (no lock icons)Mar 31
4Search filteringServer-side: private results excluded for unauthenticated usersMar 31
5Build strategyOne build, server-gated. Fly.io is primary deployment with auth.Mar 31
6Auth mechanismSame E5 bearer token — no new auth systemMar 31
7401 vs 404 for private401 (user knows content exists, needs auth) — not 404Mar 31
8GitHub Pages public buildDeferred optimization — not needed for MVPMar 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

Review

🔒

Enter your access token to view annotations