Skip to content

Docs Wiki Implementation Plan ​

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A VitePress wiki over the repo's docs/ markdown, added as an Nx project, with instant HMR, live sidebar updates when a new .md is dropped in, built-in search, and a deployable (Cloudflare Pages behind Cloudflare Access) static build.

Architecture: VitePress is rooted at the existing repo-root docs/ directory; markdown stays in place. A small hand-written scanner (docs/.vitepress/sidebar.ts) builds the sidebar from the tree at config-load, anchored to its own directory via import.meta.url (cwd-independent, so it's correct under Nx's cwd: docs). A ~20-line Vite plugin restarts the dev server on .md add/remove so new files appear in the sidebar live. Nx nx:run-commands targets wrap vitepress dev/build/preview. Deploy mirrors the existing web deploy.

Tech Stack: VitePress (dev dependency, installed with bun), Vite (transitive), Nx nx:run-commands, bun test (built-in) for the scanner, Cloudflare Pages + Cloudflare Access, GitHub Actions.

Global Constraints ​

  • Package manager is bun. Install with bun add, run scripts with bunx / bun. Never npm/npx/yarn.
  • Run tasks through Nx where a target exists: bunx nx serve docs, bunx nx build docs.
  • Content scope: docs/**/*.md only. Never serve non-markdown. Explicitly exclude docs/implementation/contract-qa/**. The sensitive docs/google_client_secret*.json and docs/samples/*.pdf are non-markdown and are never served — do not add config that would surface them.
  • cwd-independence: the sidebar scanner MUST resolve paths from import.meta.url, never process.cwd().
  • Deploy is gated. The Cloudflare Pages site MUST sit behind a Cloudflare Access application with an explicit email allow-list (not a whole-domain rule): damien@seeg.dev, damien@seeg.io, damien@seeg.co, damiens@gmail.com, damien@damien.in. Never promote the deploy workflow to push: before the gate is confirmed.
  • Do not push / open a PR until the batch is locally green and the reviewer signs off (repo push discipline). Commit locally per task.
  • Brand accent derives from Dispatch primary #C1D841 and primary-content #10130B.
  • Branch: feature/docs-wiki (already checked out).

Task 1: VitePress skeleton as an Nx project ​

Stand up a minimal, buildable VitePress site rooted at docs/, registered as the Nx project docs. No custom sidebar yet — this task proves the toolchain (install, serve, build, Nx wiring) end to end.

Files:

  • Modify: package.json (adds vitepress to devDependencies — done by bun add)
  • Create: docs/.vitepress/config.mts
  • Create: docs/index.md
  • Create: docs/project.json
  • Modify: .gitignore (append VitePress ignores)

Interfaces:

  • Consumes: nothing.

  • Produces: Nx project docs with targets serve, build, preview; VitePress config default export from docs/.vitepress/config.mts.

  • [ ] Step 1: Install VitePress

Run:

bash
bun add -d vitepress

Expected: vitepress appears under devDependencies in package.json; bun.lock updates.

  • [ ] Step 2: Write the minimal VitePress config

Create docs/.vitepress/config.mts:

ts
import { defineConfig } from 'vitepress'

// Rooted at the repo-root docs/ directory. The dev server and build run with
// cwd: docs (see docs/project.json), so root defaults to docs/.
export default defineConfig({
  title: 'Dispatch Docs',
  description: 'Internal documentation for the Dispatch monorepo',
  // contract-qa is a vendored prototype tree, not documentation.
  srcExclude: ['**/contract-qa/**'],
  // Existing docs were not authored for VitePress routing; keep the build green.
  ignoreDeadLinks: true,
  themeConfig: {
    nav: [{ text: 'Home', link: '/' }],
    search: { provider: 'local' },
    outline: { level: [2, 3] },
  },
})
  • [ ] Step 3: Write the landing page

Create docs/index.md:

md
---
layout: home
hero:
  name: Dispatch Docs
  tagline: Internal documentation for the Dispatch monorepo. Browse from the sidebar or search.
features:
  - title: Overview
    details: What Dispatch is and how the pieces fit together.
    link: /dispatch-overview
  - title: Production Design
    details: Architecture and the Angular handoff.
    link: /dispatch-production-design
  - title: Auth
    details: Auth setup and the authorization matrix.
    link: /auth-setup
---
  • [ ] Step 4: Register the Nx project

Create docs/project.json:

json
{
  "name": "docs",
  "$schema": "../node_modules/nx/schemas/project-schema.json",
  "projectType": "application",
  "sourceRoot": "docs",
  "targets": {
    "serve": {
      "executor": "nx:run-commands",
      "options": { "command": "bunx vitepress dev", "cwd": "docs" }
    },
    "build": {
      "executor": "nx:run-commands",
      "options": { "command": "bunx vitepress build", "cwd": "docs" },
      "outputs": ["{projectRoot}/.vitepress/dist"],
      "cache": true
    },
    "preview": {
      "executor": "nx:run-commands",
      "options": { "command": "bunx vitepress preview", "cwd": "docs" }
    }
  }
}
  • [ ] Step 5: Ignore VitePress build artifacts

Append to .gitignore:

gitignore

# VitePress docs site
docs/.vitepress/dist
docs/.vitepress/cache
  • [ ] Step 6: Verify Nx sees the project

Run:

bash
bunx nx show projects | grep -x docs

Expected: prints docs.

  • [ ] Step 7: Verify the build succeeds

Run:

bash
bunx nx build docs

Expected: VitePress builds; output written to docs/.vitepress/dist (contains index.html). If it fails on a page with literal {{ … }} outside a code fence, wrap that block in <div v-pre> … </div> in the offending .md, or add the page to srcExclude, then re-run. Note each such change in the commit message.

  • [ ] Step 8: Verify serve runs

Run (starts a dev server; stop it with Ctrl-C after confirming):

bash
bunx nx serve docs

Expected: prints a local URL (e.g. http://localhost:5173/); opening it shows the "Dispatch Docs" home page. Stop the server.

  • [ ] Step 9: Commit
bash
git add package.json bun.lock docs/.vitepress/config.mts docs/index.md docs/project.json .gitignore
git commit -m "feat(docs): scaffold VitePress site as Nx project 'docs'"

Task 2: Hand-written sidebar scanner (TDD) ​

Build the sidebar from the docs tree, cwd-independent, unit-tested. Then wire it into the config.

Files:

  • Create: docs/.vitepress/sidebar.ts
  • Create: docs/.vitepress/sidebar.test.ts
  • Modify: docs/.vitepress/config.mts

Interfaces:

  • Consumes: defineConfig from Task 1's config.

  • Produces:

    • titleFromContent(md: string): string | null — first # H1 of a markdown body (frontmatter stripped), else null.
    • prettifyName(slug: string): string — filename/folder slug → Title Case (-/_ → space, .md stripped).
    • interface SidebarItem { text: string; link?: string; items?: SidebarItem[]; collapsed?: boolean }.
    • buildSidebar(root?: string): SidebarItem[] — nested sidebar; root defaults to the docs directory (parent of .vitepress).
  • [ ] Step 1: Write the failing test

Create docs/.vitepress/sidebar.test.ts:

ts
import { test, expect } from 'bun:test'
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { titleFromContent, prettifyName, buildSidebar } from './sidebar'

test('titleFromContent returns the first H1, stripping frontmatter', () => {
  expect(titleFromContent('# Hello world\n\nbody')).toBe('Hello world')
  expect(titleFromContent('---\ntitle: x\n---\n\n# Real Title\n')).toBe('Real Title')
  expect(titleFromContent('no heading here')).toBeNull()
})

test('prettifyName title-cases slugs', () => {
  expect(prettifyName('auth-setup')).toBe('Auth Setup')
  expect(prettifyName('COST_OPTIMIZATION')).toBe('Cost Optimization')
  expect(prettifyName('overview.md')).toBe('Overview')
})

test('buildSidebar groups folders, sorts dates newest-first, excludes contract-qa and index', () => {
  const root = mkdtempSync(join(tmpdir(), 'docs-'))
  writeFileSync(join(root, 'index.md'), '# Home')
  writeFileSync(join(root, 'overview.md'), '# Overview\n')
  mkdirSync(join(root, 'reviews'))
  writeFileSync(join(root, 'reviews', '2026-01-01-a.md'), '# Review A')
  writeFileSync(join(root, 'reviews', '2026-02-02-b.md'), '# Review B')
  mkdirSync(join(root, 'implementation', 'contract-qa'), { recursive: true })
  writeFileSync(join(root, 'implementation', 'contract-qa', 'README.md'), '# Vendored')

  const sb = buildSidebar(root)

  // top-level file, titled by its H1, index.md excluded
  expect(sb.find((i) => i.link === '/overview')?.text).toBe('Overview')
  expect(sb.some((i) => i.link === '/index')).toBe(false)

  // reviews group, newest date first
  const reviews = sb.find((i) => i.text === 'Reviews')
  expect(reviews?.collapsed).toBe(true)
  expect(reviews?.items?.map((i) => i.text)).toEqual(['Review B', 'Review A'])

  // implementation contained only contract-qa -> empty -> omitted
  expect(sb.find((i) => i.text === 'Implementation')).toBeUndefined()
})
  • [ ] Step 2: Run the test to verify it fails

Run:

bash
bun test docs/.vitepress/sidebar.test.ts

Expected: FAIL — module ./sidebar cannot be resolved / exports missing.

  • [ ] Step 3: Write the scanner

Create docs/.vitepress/sidebar.ts:

ts
import { readdirSync, readFileSync } from 'node:fs'
import { join, relative, sep } from 'node:path'
import { fileURLToPath } from 'node:url'

export interface SidebarItem {
  text: string
  link?: string
  items?: SidebarItem[]
  collapsed?: boolean
}

// Anchor to THIS file's directory, never process.cwd(): docs/ is the parent of
// docs/.vitepress/. Correct regardless of where Nx runs the command from.
const DOCS_ROOT = fileURLToPath(new URL('..', import.meta.url))

const SKIP_DIRS = new Set(['.vitepress', 'node_modules', 'samples'])
const CONTRACT_QA = /(^|[\\/])contract-qa([\\/]|$)/

export function titleFromContent(md: string): string | null {
  const body = md.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '')
  const m = body.match(/^#\s+(.+?)\s*$/m)
  return m ? m[1].trim() : null
}

export function prettifyName(slug: string): string {
  return slug
    .replace(/\.md$/i, '')
    .replace(/[-_]+/g, ' ')
    .trim()
    .split(/\s+/)
    .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
    .join(' ')
}

function isDateNamed(name: string): boolean {
  return /^\d{4}-\d{2}-\d{2}/.test(name)
}

function titleForFile(absPath: string, fileName: string): string {
  try {
    const t = titleFromContent(readFileSync(absPath, 'utf8'))
    if (t) return t
  } catch {
    // unreadable file → fall back to the prettified name
  }
  return prettifyName(fileName)
}

export function buildSidebar(root: string = DOCS_ROOT): SidebarItem[] {
  function walk(dir: string): SidebarItem[] {
    const entries = readdirSync(dir, { withFileTypes: true })

    const dirs = entries
      .filter((e) => e.isDirectory() && !SKIP_DIRS.has(e.name))
      .map((e) => e.name)
      .sort((a, b) => a.localeCompare(b))

    const files = entries
      .filter(
        (e) =>
          e.isFile() &&
          e.name.toLowerCase().endsWith('.md') &&
          e.name.toLowerCase() !== 'index.md',
      )
      .map((e) => e.name)
      .sort((a, b) =>
        isDateNamed(a) && isDateNamed(b) ? b.localeCompare(a) : a.localeCompare(b),
      )

    const items: SidebarItem[] = []

    for (const name of dirs) {
      const abs = join(dir, name)
      if (CONTRACT_QA.test(relative(root, abs))) continue
      const children = walk(abs)
      if (children.length) {
        items.push({ text: prettifyName(name), collapsed: true, items: children })
      }
    }

    for (const name of files) {
      const abs = join(dir, name)
      const rel = relative(root, abs).split(sep).join('/')
      if (CONTRACT_QA.test(rel)) continue
      items.push({ text: titleForFile(abs, name), link: '/' + rel.replace(/\.md$/i, '') })
    }

    return items
  }

  return walk(root)
}
  • [ ] Step 4: Run the test to verify it passes

Run:

bash
bun test docs/.vitepress/sidebar.test.ts

Expected: PASS — 3 tests pass.

  • [ ] Step 5: Wire the sidebar into the config

In docs/.vitepress/config.mts, add the import at the top:

ts
import { buildSidebar } from './sidebar'

and set the sidebar inside themeConfig (replace the existing themeConfig block):

ts
  themeConfig: {
    nav: [{ text: 'Home', link: '/' }],
    sidebar: buildSidebar(),
    search: { provider: 'local' },
    outline: { level: [2, 3] },
  },
  • [ ] Step 6: Verify the build still succeeds with the real sidebar

Run:

bash
bunx nx build docs

Expected: build succeeds. (Apply the same <div v-pre> / srcExclude remediation from Task 1 Step 7 if any page fails.)

  • [ ] Step 7: Verify the sidebar content by eye

Run:

bash
bunx nx serve docs

Expected: the left sidebar lists top-level docs (e.g. "Dispatch Overview") and collapsible groups (Reviews, Implementation, Superpowers, Tracking, …); no contract-qa entries appear. Stop the server.

  • [ ] Step 8: Commit
bash
git add docs/.vitepress/sidebar.ts docs/.vitepress/sidebar.test.ts docs/.vitepress/config.mts
git commit -m "feat(docs): auto-generated sidebar from the docs tree (bun-tested)"

Task 3: Live sidebar on file add/remove ​

Make a brand-new .md appear in the sidebar without a manual restart.

Files:

  • Create: docs/.vitepress/live-sidebar.ts
  • Modify: docs/.vitepress/config.mts

Interfaces:

  • Consumes: buildSidebar() (indirectly, via VitePress's config-change restart re-running the config).
  • Produces: liveSidebar(): Plugin — a Vite plugin that touches config.mts on .md add/unlink to trigger a config-change restart.

Mechanism note (learned during implementation): do NOT call server.restart() directly. With search.provider: 'local', a plugin-initiated server.restart() does not reset the MiniSearch index, so every restart throws MiniSearch: duplicate ID … → server restart failed and the sidebar never refreshes (vuejs/vitepress#4251). Instead, bump the mtime of config.mts (via utimes, no content change so git stays clean) — VitePress's own config-change watcher then performs a clean, search-index-clearing restart.

  • [ ] Step 1: Write the plugin

Create docs/.vitepress/live-sidebar.ts:

ts
import { utimes } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Plugin, ViteDevServer } from 'vite'

// Touching the config file triggers VitePress's OWN config-change restart, which
// (unlike a raw server.restart() from a plugin) clears the local-search MiniSearch
// index — avoiding the "MiniSearch: duplicate ID / server restart failed" bug
// (vuejs/vitepress#4251). On that restart, config.mts re-runs buildSidebar() and
// the added/removed .md appears in the sidebar. Anchored to import.meta.url, not cwd.
const CONFIG_FILE = fileURLToPath(new URL('./config.mts', import.meta.url))

export function liveSidebar(): Plugin {
  let timer: ReturnType<typeof setTimeout> | undefined
  return {
    name: 'dispatch-live-sidebar',
    configureServer(server: ViteDevServer) {
      const onAddOrRemove = (file: string) => {
        if (!file.endsWith('.md')) return
        clearTimeout(timer)
        timer = setTimeout(() => {
          const now = new Date()
          // Bump mtime only (no content change → git stays clean). Swallow any
          // transient fs error so a one-off failure just skips this refresh
          // rather than becoming an unhandled rejection that crashes the server.
          void utimes(CONFIG_FILE, now, now).catch(() => undefined)
        }, 150)
      }
      server.watcher.on('add', onAddOrRemove)
      server.watcher.on('unlink', onAddOrRemove)
    },
  }
}
  • [ ] Step 2: Register the plugin in the config

In docs/.vitepress/config.mts, add the import at the top:

ts
import { liveSidebar } from './live-sidebar'

and add a top-level vite field to the defineConfig({ … }) object (sibling of themeConfig):

ts
  vite: {
    plugins: [liveSidebar()],
  },
  • [ ] Step 3: Verify build is unaffected

Run:

bash
bunx nx build docs

Expected: build still succeeds (the plugin only runs on the dev server, not the build).

  • [ ] Step 4: Verify live-add behavior manually

Run the dev server, then in a second terminal create a file:

bash
bunx nx serve docs
bash
# second terminal:
printf '# Live Add Test\n\nhello\n' > docs/zzz-live-add-test.md

Expected: the server logs a restart; within ~1s the sidebar shows a "Live Add Test" entry (top-level, sorted after other root docs) without a manual restart. Then remove it and confirm it disappears:

bash
rm docs/zzz-live-add-test.md

Expected: the entry disappears after the next restart. Stop the server.

  • [ ] Step 5: Commit
bash
git add docs/.vitepress/live-sidebar.ts docs/.vitepress/config.mts
git commit -m "feat(docs): live sidebar refresh on markdown add/remove"

Task 4: Brand accent theme ​

Give the site a thin Dispatch accent so it doesn't read as stock VitePress. Colour only; no external fonts.

Files:

  • Create: docs/.vitepress/theme/index.ts
  • Create: docs/.vitepress/theme/custom.css

Interfaces:

  • Consumes: VitePress default theme.

  • Produces: a custom theme (default export) that extends the default theme and loads custom.css.

  • [ ] Step 1: Create the theme entry

Create docs/.vitepress/theme/index.ts:

ts
import DefaultTheme from 'vitepress/theme'
import './custom.css'

export default DefaultTheme
  • [ ] Step 2: Create the brand accent CSS

Create docs/.vitepress/theme/custom.css:

css
/* Thin Dispatch accent over the default VitePress theme.
   Derived from the design-system primary (#C1D841) / primary-content (#10130B).
   Raw #C1D841 is a surface colour, not a text colour: links use a darkened lime
   on light, the raw lime on dark; solid accents keep the exact brand pairing. */
:root {
  --vp-c-brand-1: #66761f; /* darkened lime — ~5:1 on white, clears WCAG AA for link text */
  --vp-c-brand-2: #566219;
  --vp-c-brand-3: #c1d841; /* solid accents / active */
  --vp-c-brand-soft: rgba(193, 216, 65, 0.2);

  --vp-button-brand-bg: #c1d841;
  --vp-button-brand-text: #10130b;
  --vp-button-brand-hover-bg: #cfe25f;
  --vp-button-brand-hover-text: #10130b;
  --vp-button-brand-active-bg: #b3ca34;
  --vp-button-brand-active-text: #10130b;
}

.dark {
  --vp-c-brand-1: #c1d841; /* lime reads well on the dark base */
  --vp-c-brand-2: #cfe25f;
  --vp-c-brand-3: #c1d841;
}
  • [ ] Step 3: Verify the accent renders

Run:

bash
bunx nx serve docs

Expected: the hero "get started"-style accents, links, and active sidebar item use the lime/green accent (not VitePress default green-blue); the home hero action button is lime with dark text. Toggle dark mode and confirm links are the brighter lime. Stop the server.

  • [ ] Step 4: Verify the build embeds the theme

Run:

bash
bunx nx build docs

Expected: build succeeds (the custom theme is bundled).

  • [ ] Step 5: Commit
bash
git add docs/.vitepress/theme/index.ts docs/.vitepress/theme/custom.css
git commit -m "feat(docs): Dispatch brand accent theme"

Task 5: Deploy artifacts (Cloudflare Pages) ​

Add the Pages config and a manual deploy workflow that builds and publishes the static site. This task produces the deploy artifacts; the Access gate (Task 6) must be in place before the workflow is actually run.

Files:

  • Create: infra/cloudflare/docs.wrangler.toml
  • Create: .github/workflows/deploy-docs.yml

Interfaces:

  • Consumes: bunx nx build docs output at docs/.vitepress/dist; existing repo secrets CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID.

  • Produces: a dispatch-docs Cloudflare Pages deployment on manual dispatch.

  • [ ] Step 1: Add the Pages config

Create infra/cloudflare/docs.wrangler.toml:

toml
# Cloudflare Pages config for the Dispatch docs wiki (VitePress).
# Build:  bunx nx build docs
# Deploy: wrangler pages deploy docs/.vitepress/dist --project-name=dispatch-docs
# The site is gated by a Cloudflare Access application (explicit email allow-list).
name = "dispatch-docs"
compatibility_date = "2026-06-15"
pages_build_output_dir = "../../docs/.vitepress/dist"
  • [ ] Step 2: Add the manual deploy workflow

Create .github/workflows/deploy-docs.yml:

yaml
name: Deploy Docs

# Manual deploy of the VitePress docs wiki to Cloudflare Pages (project
# dispatch-docs). Kept separate from the product deploy.yml so docs and app
# releases stay decoupled. Requires repo secrets CLOUDFLARE_API_TOKEN and
# CLOUDFLARE_ACCOUNT_ID. The Pages site MUST be behind a Cloudflare Access
# application (explicit email allow-list) before this is used — see
# docs/superpowers/specs/2026-07-16-docs-wiki-design.md.
on:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  docs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: 1.3.11
      - run: bun install --frozen-lockfile
      - run: bunx nx build docs
      - name: Deploy to Cloudflare Pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy docs/.vitepress/dist --project-name=dispatch-docs
  • [ ] Step 3: Verify the build output path the workflow expects

Run:

bash
bunx nx build docs && test -f docs/.vitepress/dist/index.html && echo OK

Expected: prints OK (confirms docs/.vitepress/dist is the correct publish directory referenced by both files).

  • [ ] Step 4: Verify the workflow YAML is valid

Run (uses actionlint via bunx if available; otherwise a YAML parse check):

bash
bunx --yes actionlint .github/workflows/deploy-docs.yml || bunx --yes js-yaml .github/workflows/deploy-docs.yml >/dev/null && echo "yaml-ok"

Expected: no errors (actionlint clean, or yaml-ok printed).

  • [ ] Step 5: Commit
bash
git add infra/cloudflare/docs.wrangler.toml .github/workflows/deploy-docs.yml
git commit -m "feat(docs): Cloudflare Pages deploy artifacts for the docs wiki"

Task 6: Cloudflare Access gate (operational) ​

Stand up the Pages project and put it behind Cloudflare Access with the explicit email allow-list. This is an operational task performed against Cloudflare (dashboard or API) plus a verification; it does not change repo code. Do not run the deploy workflow for real until Steps 2–3 are done.

Files: none (Cloudflare-side configuration).

Interfaces:

  • Consumes: the deploy-docs.yml workflow and dispatch-docs project name from Task 5.

  • Produces: a gated, reachable-only-by-allow-listed-emails docs site.

  • [ ] Step 1: Create the Pages project + first deploy

Trigger the Deploy Docs workflow (GitHub → Actions → Deploy Docs → Run workflow) once, OR deploy locally:

bash
bunx nx build docs
bunx wrangler pages deploy docs/.vitepress/dist --project-name=dispatch-docs

Expected: a dispatch-docs Pages project exists with a production URL (https://dispatch-docs.pages.dev) and a preview subdomain pattern (*.dispatch-docs.pages.dev).

  • [ ] Step 2: Create the Access application

In Cloudflare Zero Trust → Access → Applications → Add → Self-hosted, add an application covering BOTH the production and preview hostnames so preview deployments are gated too:

  • Application domain 1: dispatch-docs.pages.dev

  • Application domain 2 (wildcard subdomain): *.dispatch-docs.pages.dev

  • [ ] Step 3: Add the allow-list policy

Add one policy: Action Allow, rule type Emails, with exactly these addresses:

damien@seeg.dev
damien@seeg.io
damien@seeg.co
damiens@gmail.com
damien@damien.in

Everything else is blocked at Cloudflare's edge. Save.

  • [ ] Step 4: Verify the gate blocks anonymous access

Run:

bash
curl -sI https://dispatch-docs.pages.dev | grep -iE 'HTTP/|location'

Expected: a redirect (HTTP 302) to a *.cloudflareaccess.com login URL — NOT a 200 serving the docs. Repeat against a preview URL if one is available and confirm the same redirect. Then confirm an allow-listed email can log in and view the site in a browser.

  • [ ] Step 5: Record the outcome

No code change. In the batch's review notes / STATUS, record that dispatch-docs is deployed and gated, and that deploy-docs.yml stays workflow_dispatch (never promoted to push:) until the gate is reconfirmed after any hostname change.


Self-Review ​

Spec coverage:

  • VitePress rooted at docs/, Nx project docs → Task 1. ✓
  • Auto-generated sidebar, cwd-independent, titles from H1, date sort, contract-qa excluded → Task 2. ✓
  • Live sidebar on add/remove → Task 3. ✓
  • Content scope docs/**/*.md, sensitive files never served (non-markdown), srcExclude contract-qa, ignoreDeadLinks → Tasks 1–2. ✓
  • Built-in local search → Task 1 (search: { provider: 'local' }). ✓
  • Brand accent theme from #C1D841/#10130B → Task 4. ✓
  • Deploy to Cloudflare Pages dispatch-docs, dedicated manual workflow, mirrors web deploy → Task 5. ✓
  • Cloudflare Access gate, explicit email allow-list, prod + preview hostnames → Task 6. ✓
  • Vue-interpolation build risk → remediation baked into Task 1 Step 7 / Task 2 Step 6. ✓
  • bun test for the scanner, no new deps → Task 2. ✓

Placeholder scan: No TBD/TODO; every code step shows complete code; verification steps have exact commands + expected output. Cloudflare Access (Task 6) is inherently operational — its steps are concrete dashboard actions + a curl assertion, not placeholders.

Type consistency: SidebarItem, titleFromContent, prettifyName, buildSidebar are defined in Task 2 and used consistently; liveSidebar() returns Vite Plugin and is the only symbol Task 3 adds; config imports match the exported names (buildSidebar, liveSidebar). Publish dir docs/.vitepress/dist is identical across Tasks 1, 5, 6. Project name dispatch-docs consistent across Tasks 5–6.