feat: introduce pages v1

Closed
HA
2mo ago
+14545-60

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):

TableRole
pagesidentity: slug (public URL path), title, visibility, optional version pin
page_versionsimmutable; unique(page_id, version) doubles as the concurrent-publish guard
workspace_pagesthe republish key: (workspace_id, entry_path) → page
page_comment_threadsone thread pinned to one place, against one version
page_commentsreplies; 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

SurfaceWhat it does
CLIpages publish / list / get / pull / versions, pages comments list / reply / resolve
MCPthe same seven operations as tools, so an agent can publish and answer comments
Web (/p/<slug>)viewer, comment overlay, visibility menu
Desktopnative page pane, Pages sidebar tab, comment overlay, hand-off-to-agent menu
Skillplugins/superset/skills/page — how to author for the sandbox, which is where most of the sharp edges are

Design Decisions

DecisionChoiceRationale
Assetsnone — one file, inlinedThe 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_idbare uuid, no FKOnly 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 versionpage_version_id not-nullAn 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.
Slugtitle + 5 random chars, alwaysRemoves 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 — websrcdocBlob serves HTML as an attachment under its own default-src 'none' CSP, so framing the URL directly cannot work.
Frame — desktopsuperset-page: schemeA 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.
Sandboxno allow-same-originGives 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 visibilityjust_meWidening a page is a deliberate act, so nobody discovers too late that a draft was org-visible all along.
Publish / visibilitycreator onlyAn 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

  • typecheck clean across db, shared, cli-framework, cli, trpc, auth, ui, web, desktop

  • biome check clean

  • 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.ts
    

    Deliberately not named *.test.ts — it needs a migrated database and full env validation, so bun test would 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_me page's URL stays fetchable by anyone who has seen it, and changing visibility revokes nothing. Fine for org-internal use; not sufficient if just_me must 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.
  • everyone visibility 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.tsx streams the shell before notFound() is reached. Cosmetic while the route sits behind sign-in, since no crawler ever reaches it.
  • Quote-range anchors are unimplemented. page_comment_anchor_kind carries text, but the viewer only places element and page anchors, and the API rejects the rest.

Follow-ups

  1. A user-content domain (supersetusercontent.com-style, not a superset.sh subdomain). Closes the blob-URL gap, lifts the inherited-CSP workaround on desktop, and unlocks everyone.
  2. The orphan sweep.
  3. Rate limiting on page.publish.
  4. pages delete, pages open, markdown pages, shared_version pinning 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/web needs no new env var — the page URL derives from NEXT_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.