Skip to content

v0.20.2 patch: /public/* surface for agent-published pages#83

Merged
mcheemaa merged 3 commits intomainfrom
v0.20.2-public-surface
Apr 18, 2026
Merged

v0.20.2 patch: /public/* surface for agent-published pages#83
mcheemaa merged 3 commits intomainfrom
v0.20.2-public-surface

Conversation

@mcheemaa
Copy link
Copy Markdown
Member

Summary

Adds a public static subtree at URL /public/* mapped to disk public/public/*. Files under that tree are served without auth so Googlebot, OpenGraph scrapers, and unauthenticated visitors can fetch them. The existing /ui/* auth gate is untouched for every other path.

Why

Autonomous agents need a public surface to run blogs, tools, feeds, and sitemaps on their own domain. Today every agent-created page under public/* is served from /ui/* and cookie-gated at src/ui/serve.ts:260-267, so scrapers hit a 302 to /ui/login. This unblocks SEO indexing, OpenGraph previews, and any other public read of agent-published artifacts.

URL shape chosen for clean SEO and self-documenting paths:

  • truffle.ghostwright.dev/public/ -> public/public/index.html
  • truffle.ghostwright.dev/public/blog/first-post.html -> public/public/blog/first-post.html
  • truffle.ghostwright.dev/public/blog/ (directory) -> public/public/blog/index.html
  • truffle.ghostwright.dev/public/feed.xml -> public/public/feed.xml

Security posture

  • Traversal defense via path.resolve() and a strict containment check: the resolved absolute path must equal publicRoot or start with publicRoot + "/". Anything outside public/public/ returns 403.
  • Percent-escapes are decoded before the containment check, so /public/..%2Fsecret.html is rejected.
  • Null bytes and malformed escapes return 403.
  • Read-only: no POST/PUT/DELETE. The handler only serves files via Bun.file().
  • No new filesystem surface: public/public/* lives inside the existing container-writable public/ volume (uid 999).
  • No header leakage: the handler writes only Cache-Control: public, max-age=300.
  • EXCLUDED_ROOT_DIRS in src/ui/api/pages.ts gains "public" so agent-published blog posts do not also appear in the landing-page "recent pages" rail; each surface stays coherent.

Test plan

9 regression tests in src/core/__tests__/server.test.ts plus one in src/ui/api/__tests__/pages-api.test.ts:

  • GET /public/ with public/public/index.html present returns 200 and does NOT redirect to /ui/login
  • GET /public/ with no index returns 404, never 302
  • GET /public/blog/foo.html without cookie returns 200 when the file exists
  • GET /public/blog/ falls back to the directory's index.html
  • GET /public/..%2Fsecret.html (traversal attempt) returns 403
  • GET /public/..%2Fdashboard%2Fdashboard.js (traversal to reach a gated file) returns 403
  • Cache-Control on /public/* responses is public, max-age=300
  • Regression: GET /ui/foo.html without cookie still redirects to /ui/login
  • Regression: GET /ui/dashboard/dashboard.js without cookie still returns 401
  • /ui/api/pages does NOT include files under public/

Commands run locally, all green:

  • bun run lint clean
  • bun run typecheck clean
  • bun test: 1819 pass / 0 fail / 10 skip (was 1807 on main)

Does NOT change

  • phantom_create_page signature in src/ui/tools.ts (already accepts nested paths, untouched)
  • src/ui/serve.ts (every existing auth rule unchanged, same isAuthenticated call count as main)
  • Dashboard, chat, session, cookie behavior
  • CLAUDE.md, README.md, package.json, version strings

Rollout

No version bump in this PR. v0.20.2 bump + tag happen as a separate commit on main after merge.

@mcheemaa mcheemaa merged commit 8234453 into main Apr 18, 2026
1 check passed
mcheemaa added a commit that referenced this pull request Apr 18, 2026
Bumps 0.20.1 to 0.20.2 in every reference:
- package.json
- src/core/server.ts VERSION
- src/mcp/server.ts MCP server identity
- src/cli/index.ts phantom --version
- README.md version + tests badges (1,807 to 1,819)
- CLAUDE.md tagline + bun test count
- CONTRIBUTING.md test count

Tests: 1,819 pass / 10 skip / 0 fail. Typecheck + lint clean.

v0.20.2 ships #83:
  /public/* public static surface at truffle.ghostwright.dev/public/*
  mapped to public/public/* on disk. Serves without auth so Googlebot,
  OpenGraph scrapers, and the open web can read agent-published blog
  posts, tools, RSS feeds, and sitemaps. Traversal-defended via
  decodeURIComponent + path.resolve() containment against publicRoot + '/'.
  Auth gate on every other /ui/* path unchanged. 9 regression tests.
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