feat: introduce pages v1
Summary
- Publish a self-contained HTML file as a Page from the CLI, an agent, or MCP — it gets a stable URL, and republishing the same file adds a version instead of minting a second page.
- Readers pin comments to any element on a published page, in the web viewer or in a native desktop pane.
- A comment thread can be handed to an agent, which reads the anchors and replies in-thread — which is what makes a page a working surface rather than an export.
- Pages render inside a sandboxed frame with an opaque origin, so user-authored HTML never executes anywhere that holds a session.
Why / Context
Agents produce HTML worth sharing — reports, dashboards, one-off microsites — and today there is nowhere to put it. This adds the smallest thing that makes that shareable and actionable: one file, one URL, a version history, and a comment loop that feeds straight back to an agent.
The unit is deliberately Claude's: a version is one self-contained HTML file. Not a static site, not a build output, no accompanying media library — CSS, JS, and images are inlined by whoever authors the file.
How It Works
Five new Neon tables, one migration (0091, with 0092 relaxing an FK):
| Table | Role |
|---|---|
pages | identity: slug (public URL path), title, visibility, optional version pin |
page_versions | immutable; unique(page_id, version) doubles as the concurrent-publish guard |
workspace_pages | the republish key: (workspace_id, entry_path) → page |
page_comment_threads | one thread pinned to one place, against one version |
page_comments | replies; soft-deleted so removing the first comment can't take the thread |
Publish is a single transaction — resolve the target page, mint the next version, upsert the workspace edge. The blob upload happens outside it, so a multi-MB body never holds a write-pool connection open. A blobCommitted flag distinguishes a pre-COMMIT failure (blob is certainly orphaned, delete it) from a failure at COMMIT itself (may well have landed — deleting would strip a live version of its bytes).
Comments store an anchor, never a position. Coordinates are a function of viewport width, font loading, and content, so the viewer re-resolves them on every layout change. A small runtime is injected into the page frame and reports geometry outward; it renders nothing, holds no comment text, and knows no author identity. Everything a reader sees is drawn by the parent, out of reach of page-authored JavaScript.
The agent loop: a human activates a thread for an agent (agent_activated_at), the desktop hands the thread — anchor path included, not just the quoted text — to a live agent session, and the reply lands as author_kind = 'agent' while still naming the person who dispatched it. Activation is cleared on republish, since the new version is not what anyone agreed to.
Surfaces
| Surface | What it does |
|---|---|
| CLI | pages publish / list / get / pull / versions, pages comments list / reply / resolve |
| MCP | the same seven operations as tools, so an agent can publish and answer comments |
Web (/p/<slug>) | viewer, comment overlay, visibility menu |
| Desktop | native page pane, Pages sidebar tab, comment overlay, hand-off-to-agent menu |
| Skill | plugins/superset/skills/page — how to author for the sandbox, which is where most of the sharp edges are |
Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Assets | none — one file, inlined | The previous revision of this PR shipped a files/attachments media library with parser-based URL rewriting. It was dropped: it roughly doubled the surface area, and the sandbox already forbids the interesting cases. A page is one file. |
workspace_pages.workspace_id | bare uuid, no FK | Only cloud_workspaces holds live workspace rows in Neon; local workspaces live in a machine's own SQLite. An FK would restrict the feature to cloud sandboxes. |
| Threads anchor to a version | page_version_id not-null | An anchor only means something against the bytes it was recorded on. A thread whose version is unknown can never be resolved or honestly marked orphaned. |
| Slug | title + 5 random chars, always | Removes the collision path entirely — no "is this taken" lookup, no retry loop. Minted once and frozen, so a retitle never moves a shared link. |
| Frame — web | srcdoc | Blob serves HTML as an attachment under its own default-src 'none' CSP, so framing the URL directly cannot work. |
| Frame — desktop | superset-page: scheme | A srcdoc document inherits the embedder's CSP, and the renderer's policy has no 'unsafe-inline' — it silently killed both the comment runtime and the page's own scripts. Main serves the bytes under their own policy instead. |
| Sandbox | no allow-same-origin | Gives the frame an opaque origin: no cookies, no storage, no parent DOM. allow-scripts with allow-same-origin would let it strip its own sandbox, and cross-subdomain cookies mean a *.superset.sh host would hand user JS a live session. |
| Default visibility | just_me | Widening a page is a deliberate act, so nobody discovers too late that a draft was org-visible all along. |
| Publish / visibility | creator only | An org page is readable org-wide, but a reader who could publish to it could replace a colleague's page at its shared URL. |
Testing
-
typecheckclean acrossdb,shared,cli-framework,cli,trpc,auth,ui,web,desktop -
biome checkclean -
42 unit tests across 5 new files — slug minting, upload validation, entry-path canonicalisation, publish rules, page access
-
20 integration tests against real Postgres — version arithmetic, concurrent-publish races, workspace resolution, cross-org and cross-member access:
BLAXEL_API_KEY=test BLAXEL_REGION=test BLAXEL_SANDBOX_IMAGE=test \ BLAXEL_WORKSPACE=test OPENAI_API_KEY=test \ bun test --env-file=.env ./packages/trpc/src/router/page/publish.integration.tsDeliberately not named
*.test.ts— it needs a migrated database and full env validation, sobun testwould fail at import time in CI. The leading./matters or bun runs nothing.
Manually verified: the desktop page pane rendering, and both web 404 screens against a local dev server.
[!IMPORTANT]
The end-to-end publish checklist from the previous revision is stale — it exercised the asset-upload flow that this revision removes. Publish → republish → version history → comment → agent reply wants one clean pass through the real CLI against the real API before merge. Publishing from a cloud workspace is still unverified (no sandbox to hand).
Known Limitations
- Blob URLs are public and permanent. Access control is enforced at the API, not at the bytes. A
just_mepage's URL stays fetchable by anyone who has seen it, and changing visibility revokes nothing. Fine for org-internal use; not sufficient ifjust_memust mean confidential. - No orphan sweep. A blob whose transaction failed after COMMIT, or whose page was later cascaded away, is never collected.
- Same-org squatting of a local workspace key. A member can claim
(workspace_id, entry_path)for a colleague's local workspace, so the colleague's next republish versions the squatter's page. Cross-org is closed; this needs local workspaces to have a verifiable Neon identity. everyonevisibility is in the DB enum but not offered by the API. Serving a page without a session needs a user-content domain. Postgres cannot drop an enum value, so it stays in the type.- No rate limiting on
page.publish. - The viewer's 404 returns HTTP 200.
loading.tsxstreams the shell beforenotFound()is reached. Cosmetic while the route sits behind sign-in, since no crawler ever reaches it. - Quote-range anchors are unimplemented.
page_comment_anchor_kindcarriestext, but the viewer only places element and page anchors, and the API rejects the rest.
Follow-ups
- A user-content domain (
supersetusercontent.com-style, not asuperset.shsubdomain). Closes the blob-URL gap, lifts the inherited-CSP workaround on desktop, and unlockseveryone. - The orphan sweep.
- Rate limiting on
page.publish. pages delete,pages open, markdown pages,shared_versionpinning UI, quote-range anchors.
Risks / Rollout
- Risk: one additive migration creating five new tables and three enums. No existing table is altered, so the blast radius is limited to new objects.
- Rollout: migrations apply through the normal deploy flow. No backfill, no data movement.
apps/webneeds no new env var — the page URL derives fromNEXT_PUBLIC_WEB_URL. - Rollback: the feature is inert if unused. Reverting the code leaves five empty tables behind; dropping them is safe while nothing has published.
Summary by CodeRabbit
- New Features
- Added published HTML Pages with visibility controls, version history, workspace linking, and page URLs.
- Added threaded comments with replies, editing, resolution, deletion, and agent handoff support.
- Added Pages browsing and viewing in the desktop app, including comment tools and secure rendering.
- Added CLI commands for publishing, listing, viewing, pulling, versioning, and managing comments.
- Added MCP tools for page and comment workflows.
- Added access-aware web page views with loading, error, and not-found states.
- Documentation
- Added Pages guidance covering publishing, rendering, visibility, versioning, and comments.