Skip to content

feat: harden generator and generated site security - #19

Merged
luangjokaj merged 32 commits into
mainfrom
feat/generator-safety-and-security-hardening
Jul 30, 2026
Merged

feat: harden generator and generated site security#19
luangjokaj merged 32 commits into
mainfrom
feat/generator-safety-and-security-hardening

Conversation

@luangjokaj

@luangjokaj luangjokaj commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

High-severity review follow-up

  • Makes generated writes atomic with no-follow temporary files, revalidates
    claimed output-root identity before mutations, and rejects replaced roots or
    symlinked path components.
  • Reads MDX, starter documentation, configuration, font, analytics, and public
    files through validated no-follow source paths. Public copies preserve binary
    data and reject symlink and path-swap escapes.
  • Restricts recursive OpenAPI $ref files to the configured root spec
    directory, approved JSON/YAML extensions, and non-hidden paths while keeping
    the explicitly configured root filename unrestricted.
  • Uses one bounded, abort-aware JSON envelope reader for generated gate,
    playground, RAG, and MCP routes. The playground separately caps decoded UTF-8
    and base64 request payloads.
  • Keeps Chat and Search mutually exclusive, transfers opener ownership between
    dialogs, and restores focus only after the active modal closes.
  • Adds regression coverage for output-root replacement, atomic hard-link and
    symlink replacement, source path races, restricted OpenAPI references,
    request-envelope limits, abort handling, and modal focus ownership.
  • Documents the generated OpenAPI local-reference policy.

Generator safety and packaging

  • Claims output directories safely, serializes watcher mutations, and validates
    configuration and section routes before changing output.
  • Tracks generated route and Markdown-mirror ownership so watch-mode renames and
    deletes remove only files owned by the changed source.
  • Preserves user-owned public aggregate files and restores generated versions
    after overrides are deleted.
  • Caches dependency installs with a package-manager fingerprint, adds
    --skip-install, and provides generate as an alias for build.
  • Verifies the real npm tarball through an isolated production installation and
    installed binary execution.

Generated-site security and accessibility

  • Rechecks site-gate or API-key authorization inside content-bearing routes,
    hardens rate-limit identity, and tightens SSRF handling for IPv4 and IPv6.
  • Bounds MCP protocol batches, arguments, results, and search limits while
    propagating cancellation through RAG and MCP work.
  • Gives search, mobile chat, tabs, accordions, and sidebar regions appropriate
    semantics, keyboard behavior, focus containment, and reduced-motion support.
  • Preserves the chat close transition and prevents overlapping modal focus
    traps.

Generated documentation and presentation

  • Renders OpenAPI-controlled prose, schemas, and examples as inert content with
    the shared code component.
  • Adds and expands platform guidance for authentication, publishing, navigation,
    the API playground, and Agent Sync workflows.
  • Resolves the visible theme before first paint and keeps generated light/dark
    theme state synchronized without hydration flash.

Verification

  • pnpm test: 204 tests passed
  • pnpm test:package: 375 files verified, isolated production install passed,
    package import succeeded, and installed binary 0.0.140 executed
  • pnpm smoke:generated: generated type-check, ESLint, and Next.js production
    build passed
  • pnpm format:check
  • git diff --check

Test plan

  • TypeScript build and full Vitest suite
  • Prettier formatting check
  • Real npm package pack/install/import/bin verification
  • Generated-site type-check, lint, and production build
  • Diff whitespace validation

- Drop next, react, and react-dom from the CLI's own dependencies; they
  belong to the generated app's package.json, not the CLI runtime.
- Add scripts/clean.mjs and route build/prepare through it so a stale
  dist/ is never shipped or left over from a prior package manager.
- Add scripts/verify-package.mjs (test:package) to catch stale compiled
  output and a package-manager-specific prepare script.
- Add scripts/smoke-generated-site.mjs (smoke:generated) to generate a
  fixture site, then type-check, lint, and build it end to end.
- Run format:check, test:package, and smoke:generated in CI, and bump
  the pinned pnpm setup action to 11.17.0.
- Declare packageManager: pnpm@11.18.0 and engines.node >=22, ignore
  /.pnpm-store, and document the new scripts in CONTRIBUTING.
- Claim output directories safely (src/lib/output-safety.ts): refuse
  to overwrite an unrelated non-empty directory, recognize a
  Doccupine marker file and legacy generated apps, and resolve every
  generated path so it cannot escape the output directory.
- Write every generated file atomically (writeFileAtomic) so the dev
  server and file watcher never observe a partially written file.
- Serialize watcher-triggered mutations through a queue so concurrent
  file events cannot race the shared aggregate files (layout,
  sitemap, llms files, API manifest).
- Fail generation with an actionable error on route collisions and on
  unsafe or duplicate section slugs/directories, instead of silently
  letting one page overwrite another.
- Validate doccupine.json (validateConfig): reject overlapping
  watch/output directories, invalid ports, and malformed openapi
  config, with a pointer to `doccupine config --reset`.
- Cache dependency installs with a package.json + package-manager
  fingerprint (.doccupine-install), skipping reinstall when nothing
  changed, and add a --skip-install flag.
- Add a `generate` alias for `build`, kept decoupled from installing
  dependencies or starting the dev server.
- Always generate sitemap.xml and robots.ts, even without a
  configured site URL, and read NEXT_PUBLIC_SITE_URL for both instead
  of only config.json.
- Build an API-reference index page that links every generated
  OpenAPI endpoint, grouped by spec and tag.
- Escape generated metadata (title, description, icon, image) with
  JSON.stringify instead of interpolating into template literals,
  closing a code-injection surface for untrusted frontmatter values.
- Document the new /api-reference index page, and clarify that the
  playground proxy blocks private, internal, metadata, and reserved
  network addresses, with loopback servers such as localhost allowed
  only when the spec declares them explicitly.
- Document that SITE_PASSWORD protection now also covers the API
  playground proxy, and that every content route re-checks its own
  session instead of relying solely on the middleware.
- Document the DOCS_API_KEY / SITE_PASSWORD interaction for the MCP
  endpoint: API-key auth when configured, otherwise the same gate
  session as the rest of the site.
- Document --skip-install, the `generate` alias for `build`, and the
  output-directory safety and section-slug validation rules.
- Document the `updated` frontmatter field, and that sitemap.xml and
  robots.txt are now always generated even without a configured site
  URL.
- Document the services/mcp/docs-content.json manifest that now
  powers MCP content indexing, replacing filesystem scanning of the
  generated app.
- Add the Space component reference page and link it from the
  components overview.
- Require the visitor's unlocked gate session (or DOCS_API_KEY for
  MCP) inside the rag, search, playground, and mcp route handlers
  themselves via src/templates/lib/access.ts, not just in middleware,
  so a direct route invocation can no longer bypass SITE_PASSWORD.
- Extend the playground proxy's SSRF guard to permanently block
  reserved, multicast, and IPv4-mapped IPv6 targets, and fail closed
  for any address outside an explicitly declared loopback server.
- Match the playground allowlist's base path on a path-segment
  boundary so a shared prefix (e.g. /v1x/... vs /v1/...) is no longer
  treated as a match.
- Bound the request rate limiter's in-memory map so a client rotating
  spoofed forwarding headers cannot grow it without limit.
- Compare API keys and bearer tokens with a constant-time comparison.
- Replace filesystem scanning of the generated app for MCP content
  with a build-time services/mcp/docs-content.json manifest, traced
  into the serverless functions so the docs corpus is not inlined
  into the function bundle and the server never scans user-reachable
  paths at runtime.
- Add src/templates/security.test.ts asserting these boundaries stay
  in place.
- Explain the output-directory claiming rules and the
  `doccupine config --reset` recovery path for an invalid config.
- Document `doccupine generate` as an alias for `build`, and the new
  --skip-install flag.
- Explain that /sitemap.xml is always served (empty without a
  configured site URL) and that NEXT_PUBLIC_SITE_URL is baked in at
  build time, so changing it needs a redeploy.
- Document that the OpenAPI integration now generates an
  /api-reference directory index in addition to per-operation pages.
- Document that SITE_PASSWORD protection now also covers the
  /api/playground proxy, and that the MCP endpoint falls back to the
  gate session when DOCS_API_KEY is not configured.
Long inline `code`/`kbd` spans previously stretched to fill the
container width. Constrain them to max-content so they hug their
text like other inline elements.
Update the generated app's posthog-js dependency from ^1.407.8 to
^1.408.0.
@luangjokaj luangjokaj self-assigned this Jul 30, 2026
- rateLimit: stop keying buckets off the client-supplied
  X-Forwarded-For header, which let anyone bypass the limit by
  rotating a spoofed value. Derive identity only from headers that a
  recognized hosting platform (Vercel, Cloudflare, Fly) itself
  overwrites, falling back to one shared bucket for unknown/self-hosted
  deployments instead of trusting an attacker-controlled address.
- ssrfGuard: rewrite IPv6 blocking to parse the full 128-bit address
  instead of matching prefixes with startsWith, which could both
  over- and under-match adjacent ranges. Correctly resolves
  IPv4-mapped, IPv4-compatible, IPv4-translated, and NAT64 forms to
  their embedded IPv4 address before applying the IPv4 block list, and
  restricts the allowPrivate loopback opt-in to non-production so a
  generated spec cannot reach a private target in a deployed build.
- mcp route + server: bound request bodies, protocol batches (at most
  one tools/call per request), tool arguments, search result counts,
  and serialized responses; wire AbortSignal through search,
  embedding, and tool calls so a cancelled request actually stops
  work instead of finishing unattended; validate both REST and
  protocol tool calls against shared strict zod schemas.
- rag route: add RAG_API_KEY bearer auth for server-to-server access
  on public sites (browser chat cannot safely hold the secret), bound
  request size, and wire request cancellation through indexing,
  search, and model streaming.
- gate and playground routes: switch to the new rateLimit(Request)
  signature.
- env.example / gitignore: document RAG_API_KEY and DOCS_API_KEY, and
  ignore every .env* variant instead of only .env*.local.
- security.test.ts: cover the above.
- OpenApiRegistry.load: build the next registry into a scratch
  instance and swap it in only after every configured spec parses and
  ingests successfully, then throw on failure. Previously a spec that
  failed to parse was logged and skipped in place, leaving the live
  registry half-updated instead of surfacing the error.
- safeMatter: detect YAML-incompatible tagged frontmatter delimiters
  (e.g. a language tag right after the opening `---`) and treat them
  as unsupported instead of handing them to gray-matter, which can
  invoke a non-YAML parsing engine for those tags.
- SearchModalContent: proper dialog + combobox model (role="dialog",
  aria-modal, role="combobox"/"listbox"/"option",
  aria-activedescendant), a focus trap with focus restored to the
  opener on close, and a polite live region announcing result counts.
- Chat: mobile chat becomes a real modal (role="dialog", inert +
  aria-hidden while closed, focus trap, focus restored to the opener
  on close), autoscroll only when already near the bottom, and
  respects prefers-reduced-motion.
- Accordion / sidebar groups: collapsed content is inert and
  aria-hidden instead of just visually hidden; sidebar toggles gain
  aria-controls wired to the region they open.
- Tabs: implements the standard horizontal ARIA tabs keyboard pattern
  (role="tablist"/"tab"/"tabpanel", roving tabIndex, Arrow/Home/End).
- eslint.config: enable the complete jsx-a11y recommended rule set,
  with narrow scoped exceptions for the two generated components that
  intentionally break a rule (ApiPlayground's user-controlled media
  preview, SiteGate's autofocus login field).
- New accessibility.test.ts pins the above down.
- src/cli.ts: commander wiring (watch/build/config commands),
  dependency-install caching, and process orchestration, extracted
  from src/index.ts.
- src/mdx-to-nextjs-generator.ts: the MDXToNextJSGenerator engine
  (scaffolding, watchers, page/layout regeneration), also extracted
  from src/index.ts.
- src/lib/generated-artifacts.ts: new GeneratedArtifacts class that
  tracks generated route and Markdown-mirror ownership in
  .doccupine-artifacts.json, migrating from the older
  .doccupine-llms-manifest.json / .doccupine-api-manifest.json files.
  Watch-mode renames and deletes now consult this registry instead of
  guessing routes from filenames, so a changed section removes only
  the files it owns.
- src/index.ts shrinks to a thin re-export of the split modules.
- Add matching test files for the new modules.
pnpm test now runs pnpm build && vitest run, so tests always exercise
a fresh dist output locally and in CI. Drop the now-redundant explicit
pnpm build step from the CI workflow.
Update the seeded sample docs to match this round of changes:
- ai-assistant / authentication: RAG_API_KEY for server-to-server
  /api/rag access, and that .env* (not just .env.local) is ignored.
- api-playground: IPv6-translated private targets are blocked too,
  loopback servers are development/test-only, and proxy requests are
  rate limited per trusted client address.
- commands: starter docs are only seeded into an empty source
  directory, non-YAML frontmatter is rejected, and generated route
  ownership is tracked in .doccupine-artifacts.json.
- model-context-protocol: get_doc/list_docs work without an embedding
  provider, rate limiting uses the platform's trusted client address,
  and the request/argument/result size limits.
- tabs: document the horizontal ARIA keyboard pattern.
- README: note RAG_API_KEY, the renamed .doccupine-artifacts.json
  (was .doccupine-llms-manifest.json), and that get_doc/list_docs work
  without an embedding provider.
- SECURITY: document RAG_API_KEY for protecting paid RAG spend on
  public sites, and clarify that /api/mcp falls back to the site gate
  when DOCS_API_KEY is unset.
Doc pages are force-static, so the server always renders the light
theme; the previous fix (6d57864) resolved the mode before paint via a
"dark" class but still had to hide the body until Cherry's
ClientThemeProvider swapped in themeDark on mount, because theme.ts
still resolved colors/shadows to literal hex per mode. Two things
combined to leave a residual flash: the provider strips its own
"dark" class for a frame on mount while it reconciles the
server-rendered light theme against the cookie, and nothing painted
purely from CSS custom properties, so anything keyed off that class
(or off the literal theme object) had a light frame to flash through.

- app/theme.ts: every painted color and shadow is now a
  var(--color-*) / var(--shadow-*) reference built by a new
  cssVarTokens helper, so the CSS styled-components generates is
  identical in both modes. theme and themeDark now differ only in
  isDark.
- lib/layout.ts: the blocking theme-init script only has to decide the
  mode and stamp it on <html> before paint; drop the body hiding, the
  forced #000 background, the transition suppression, and the
  MutationObserver that existed only because the server always
  rendered light.
- The visible mode is keyed to a data-theme attribute we own, not to
  Cherry's "dark" class, since that class is the one the provider
  strips for a frame on mount. Selectors move to
  :root[data-theme="dark"] in GlobalStyles, Callout, Code, Badge, and
  CopyButton; CherryThemeProvider gains a ThemeModeAttribute component
  that mirrors later mode changes onto the attribute.
- GlobalStyles declares color-scheme per mode (previously set inline
  on <html>, which stuck to the first visit's mode after a toggle).
- CherryThemeProvider now owns the theme-color meta tag
  ($themeColor={false}), because Cherry would otherwise copy the
  literal string "var(--color-light)" into it.
- Card / Steps: icon tint moves into CSS, since var() does not resolve
  inside an SVG presentation attribute (lucide's color prop).
- DemoTheme: presets now only write --color-* overrides.
- New utils/polishedCompat.ts plus a turbopack.resolveAlias in
  next.config.ts (registered in lib/structures.ts): published
  cherry-styled-components (0.2.11) calls polished's
  rgba/darken/lighten on theme colors, and polished throws on a var()
  reference, crashing the render. The shim performs those three
  operations with color-mix instead. The dependency pin stays
  ^0.2.11, so it picks up 0.2.12 (which needs no shim) automatically
  once that is published.
- New theming.test.ts locks the contract down: mode resolved before
  first paint onto data-theme, nothing hidden while waiting for
  hydration, every token painted through a custom property, icons
  tinted in CSS, and the polished shim wired up.
StyledChat and StyledChatForm transitioned "all", which animates
visibility, so the panel stayed hidden for the whole 0.3s slide-in and
the focus() call that follows opening was a silent no-op. Both now
list their transitions explicitly with "visibility 0s linear": snapping
visible immediately on open, and delaying the hide on close so the
slide-out still animates.
Revert an unwanted restyle: the search input, its Ask-AI pill, and the
chat input go back to plain styled.input / styled.button instead of
Cherry's Input/Button (the five affected styled blocks are
byte-identical to HEAD again). The three accessibility.test.ts
assertions that described the Cherry adoption itself are dropped;
every other accessibility assertion is untouched.

Also, in the search modal:
- Hide the platform clear button on the type="search" field
  (::-webkit-search-cancel-button / -decoration), since it duplicated
  the modal's own close control.
- Add an 8px gap between the spinner and "Searching documentation...".
@luangjokaj luangjokaj changed the title Harden generation safety and close route-level auth bypasses Harden generation safety, accessibility, and flashless dark mode Jul 30, 2026
cherry-styled-components 0.2.12 replaced its polished-based color math
with color-mix and dropped polished from its dependency tree, which is
what the compatibility shim added in e7d328a existed to work around.
With 0.2.12 published, migrate off the shim.

- package.ts: bump the generated app's cherry-styled-components pin
  from ^0.2.11 to ^0.2.12. This is what makes the removal safe: it
  invalidates the dependencyFingerprint install stamp (forcing a
  reinstall) and the new range no longer resolves to 0.2.11, so no
  project can still be running the version the shim protected
  against.
- utils/polishedCompat.ts: delete the shim (rgba/darken/lighten
  reimplemented with color-mix).
- structures.ts: drop the shim's import and its appStructure entry,
  and add "utils/polishedCompat.ts" to obsoleteFiles so projects
  generated before this change delete their stale copy on the next
  run.
- next.config.ts: remove the turbopack.resolveAlias mapping polished
  to the shim, in both the PostHog and non-PostHog branches.
- theming.test.ts: drop the test case asserting the shim wiring and
  its three now-unused imports.

Verified in a regenerated fixture: the stale shim file and
next.config alias are gone, pnpm install upgrades
cherry-styled-components to 0.2.12 on its own with polished absent
from the tree, and the fixture passes type-check, lint, and next
build. In headless Chrome against 0.2.12 with no shim: no light frame
during load, correct paint in both modes with every JS chunk blocked,
no html class flicker, and the ThemeToggle knob now positions itself
before hydration. CLI: pnpm build, 142 tests (12 files), and pnpm
format:check all pass.
- Prompt.ts: switch the header's text mixin from styledSmall to
  styledText, dropping the now-unused styledSmall import.
- Change the header's align-items from center to flex-start and give
  the leading icon a 4px top margin, so it lines up with the first
  line of the now-larger text instead of the vertical center of the
  whole (possibly multi-line) block.
Pack the built CLI with npm, install it into an isolated production
project, and verify both its module import and installed binary.
Reject missing or unexpected tarball contents.
Render spec-controlled prose as inert JSX text and resolve only local
OpenAPI files inside rootDir. Snapshot references before dereferencing
to prevent network access, path escapes, and mutable rereads.
Reject symlinked generated paths and require strict legacy ownership
fingerprints before claiming output. Validate and roll back hot config
reloads while preserving user-owned public aggregate files.
Classify API, MCP, Next.js, and analytics paths by complete route
segments. Avoid treating similarly prefixed documentation routes as
reserved or bypassing the generated site gate.
Start transform and opacity transitions immediately when chat closes,
then delay visibility for 300ms so the closing animation can finish.
@luangjokaj luangjokaj changed the title Harden generation safety, accessibility, and flashless dark mode feat: harden generator security and chat transitions Jul 30, 2026
- Add authentication guidance for site passwords, sessions, and API
  keys.
- Document publish validation, granular discards, navigation slug rules,
  and API spec requirements.
- Expand Agent Sync setup, auto-linking, and repository workflows, and
  align related platform docs.
@luangjokaj luangjokaj changed the title feat: harden generator security and chat transitions feat: harden generator and improve generated documentation Jul 30, 2026
- Explain that $horizontal belongs inside a line of inline content,
  where the gap has something to sit between.
- Replace the two side-by-side buttons, which had no real inline gap
  to demonstrate, with a single button whose label and icon are
  spaced apart by the Space component.
Secure atomic output mutations and validate claimed output-root
identity before generated operations. Read MDX, starter documentation,
configuration, and public assets without following links.

Restrict local OpenAPI references to the configured spec directory and
safe extensions. Bound JSON request envelopes across generated routes.
Keep chat and search mutually exclusive while transferring opener
ownership between dialogs. Restore focus only after the active overlay
closes.
@luangjokaj luangjokaj changed the title feat: harden generator and improve generated documentation feat: harden generator and generated site security Jul 30, 2026
@luangjokaj
luangjokaj merged commit 80e9766 into main Jul 30, 2026
1 check passed
@luangjokaj
luangjokaj deleted the feat/generator-safety-and-security-hardening branch July 30, 2026 14:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant