Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ jobs:
pagent_deck_engagement_d3a0_test \
pagent_deck_engagement_concurrent_d3a0_test \
pagent_engagement_readiness_20260922_test \
pagent_html_backend_20260922_test \
pagent_html_frame_test \
pagent_visit_revision_20260922_test \
pagent_deck_permissions_d3a0_test \
pagent_deck_sharing_d3a0_test \
pagent_deck_routes_sharing_d3a0_test \
Expand Down
12 changes: 11 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,17 @@ may remain literal when they do not represent a reusable spacing decision.
- **Motion**: button press feedback only; removed under reduced motion.
- **Layout**: centered document-scrolling surface on the warm canvas at mobile, tablet, and desktop widths.

### Presentation frame and controls
### HTML document frame

- **Structure**: the submitted HTML document in an isolated iframe, with no Pagent canvas, slide navigation, imposed aspect ratio, or injected visual styling.
- **Variants**: full-viewport shared page and bounded owner preview. Previously published structured-slide revisions retain their legacy controls.
- **States**: loading, ready, load failure with retry, unavailable access.
- **Accessibility**: a descriptive iframe title and accessible loading/error feedback. Authors own their document's semantics, navigation, responsive layout, and accessibility.
- **Security**: scripts run with an opaque origin; no Pagent cookies, storage, parent DOM, external resources, forms, popups, or top navigation. The stored HTML is unchanged; the served document adds nonvisual activity instrumentation only.
- **Analytics**: visits and active time apply to HTML pages. Do not show invented slide counts or completion percentages when the author supplies no slide model.
- **Layout**: the shared document fills `100dvh` without product chrome. Owner preview uses the existing detail grid and a viewport-height frame. Pagent tokens apply only to surrounding management and status UI, never inside authored content.

### Legacy presentation frame and controls

- **Structure**: presentation page identity, slide viewport, previous/next, current count, fullscreen, optional filmstrip.
- **Variants**: owner preview and tracked viewer.
Expand Down
79 changes: 62 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,25 @@ the authenticated owner manages sharing and reads engagement analytics.
| `document` | Temporary | No response; view-only | Optional only while anonymous grace mode is enabled |
| `presentation` | Durable, revisioned, shareable | Engagement analytics | Required |

Presentation pages have explicit slide boundaries so Pagent can calculate
active time, completion, furthest slide, and drop-off reliably. The web app
provides the authenticated page library, page detail, sharing controls, and an
`/admin` workspace-governance surface. Internal `/v1/decks` REST routes and
`deck_*` tables retain established implementation names; they are not separate
product primitives or MCP tools.
The submitted HTML document is the presentation: Pagent does not impose slide
boundaries, navigation, or a template. Its exact UTF-8 source is retained in an
immutable revision. Self-contained HTML, CSS, and inline JavaScript run in an
isolated, opaque-origin sandbox; temporary `document` pages remain sanitized
and script-free. HTML presentation analytics report visits and active time,
not inferred slide completion.

The web app provides the authenticated page library, page detail, sharing
controls, and an `/admin` workspace-governance surface. Internal `/v1/decks`
REST routes and `deck_*` tables retain established implementation names; they
are not separate product primitives or MCP tools. Existing REST slide payloads
and stored slide revisions remain supported for compatibility, including their
slide navigation and analytics.

### The two MCP tools

- `write` accepts one discriminated page type: `interactive` with an A2UI
`spec`, `document` with sanitized `html`, or `presentation` with a title and
ordered slides. Supplying a presentation `page_id` creates a new immutable
`spec`, `document` with sanitized `html`, or `presentation` with `title` and
the full `html` document. Supplying a presentation `page_id` creates a new immutable
revision of that durable page.
- `read` accepts a `page_id`. Temporary interactive pages return a response
state immediately; durable presentation pages return authorized analytics.
Expand All @@ -76,16 +83,48 @@ product primitives or MCP tools.
There are no extra management tools. Sharing, access policy, membership,
retention, and audit operations live in the authenticated web app and REST API.

### Durable HTML presentations

```json
{
"type": "presentation",
"title": "Acme proposal",
"html": "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Acme proposal</title><style>body{font-family:system-ui;padding:2rem}</style></head><body><h1>Acme proposal</h1><button id=\"details\">Show next step</button><p id=\"next\" hidden>Schedule a review.</p><script>document.getElementById('details').onclick=()=>{document.getElementById('next').hidden=false}</script></body></html>"
}
```

Keep the document self-contained. The sandbox uses `allow-scripts` without
`allow-same-origin`: author code cannot access Pagent account cookies, storage,
or the parent DOM. External assets, network APIs, forms, nested frames, popups,
top navigation, `eval`, and workers are outside the supported contract and are
restricted by the sandbox and CSP. The parent frame policy blocks external
navigation. These controls are not a promise that arbitrary HTML can never
attempt a network request.

Protected HTML delivery uses POST forms, never credentials in URLs. The served
document adds an invisible activity bridge; stored source stays unchanged.
The viewer reveals the document on iframe load without waiting for that bridge.
An author's CSP can block the bridge and prevent metrics without hiding the
document. For an allowlisted Origin, document errors requested with
`Accept: text/html` return a generic sandboxed error page that signals
`pagent:error`, preserving the HTTP status and no-store policy; JSON clients
keep their existing error responses.
HTML visits have no slide rollups: completion, viewed-slides, furthest-slide,
and last-slide values are null. Analytics identify `contentFormat` as `html`,
`slides`, or `mixed` and include `revisionNumbers`; mixed completion uses only
visits with known legacy slide completion. Retained immutable snapshots are
available now; browsing, previewing, and restoring earlier revisions remain P1.

### Breaking migration

The two-tool contract is intentionally breaking. There are no legacy aliases:

| Removed tool | Replacement |
| -------------- | ----------------------------------------------------- |
| `show_ui` | `write({ type: "interactive", spec })` |
| `show_html` | `write({ type: "document", html })` |
| `publish_deck` | `write({ type: "presentation", title, slides, ... })` |
| `check_result` | `read({ page_id, include: "response" })` |
| Removed tool | Replacement |
| -------------- | --------------------------------------------------- |
| `show_ui` | `write({ type: "interactive", spec })` |
| `show_html` | `write({ type: "document", html })` |
| `publish_deck` | `write({ type: "presentation", title, html, ... })` |
| `check_result` | `read({ page_id, include: "response" })` |

Restart the MCP client after upgrading so it refreshes the advertised tool
list. Clients should see exactly `write` and `read`.
Expand Down Expand Up @@ -135,7 +174,7 @@ Each app validates its environment at boot/build with Zod and fails loudly on mi
| | `NODE_EXTRA_CA_CERTS` | Supabase in production | Path to the bundled Supabase root CA so `verify-full` can validate the pooler certificate. |
| | `PUBLIC_URL` | **production** | HTTPS renderer origin. Used in `write` responses. |
| | `API_PUBLIC_URL` | **production** | HTTPS API origin. Used for OAuth issuer, callbacks, magic links, and discovery metadata. |
| | `ALLOWED_ORIGINS` | **production** | Comma-separated origin list. CORS allow-list. |
| | `ALLOWED_ORIGINS` | **HTML frames / prod** | Exact renderer origins for CORS and protected HTML frames. Required for HTML in every environment, e.g. `http://localhost:8788` locally. |
| | `PORT` | optional | Coerced to number. Default `8787`. Railway sets this. |
| | `PAGE_TTL_MS` | optional | Coerced to number. Default `1800000` (30 min). |
| | `RATE_LIMIT_MAX` | optional | Positive integer. Default `30`; caps writes and viewer starts per IP. Events: 4x/IP, 30/session/window, 10/request. |
Expand Down Expand Up @@ -289,7 +328,7 @@ must deploy from the repository root because it uses npm workspaces and serves
3. Set environment variables (see `apps/api/.env.example`):
- `PUBLIC_URL` — the Vercel URL of `apps/web` (e.g. `https://pagent.link`). Used in MCP `write` responses. **Required in production.** Boot fails loudly if missing.
- `API_PUBLIC_URL` — the Railway public origin of `apps/api` (e.g. `https://api.pagent.link`). Used for OAuth issuer/discovery, default Google callbacks, magic links, and MCP auth metadata. **Required in production.** Must be HTTPS.
- `ALLOWED_ORIGINS` — comma-separated origins allowed to call the API (set to your Vercel URL). **Required in production.** API boot fails loudly if missing.
- `ALLOWED_ORIGINS` — comma-separated exact renderer origins (set to your Vercel origin). **Required for HTML frames in every environment**; use the actual browser origin, e.g. `http://localhost:8788` locally. Missing/mismatched origins return 403. Production API boot also fails if missing.
- `PORT` — Railway sets this automatically; the server reads it.
- `PAGE_TTL_MS` — optional; default 30 minutes.
- `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MS` — optional. Per-IP limits for temporary writes and public viewer access/visit starts. Engagement delivery allows 4x the base cap per IP, 30 requests per viewer session, and at most 10 events per request. Defaults: 30 / 60000.
Expand Down Expand Up @@ -411,6 +450,12 @@ in Grafana from the trace and log streams.

### Rollback

After HTML migration 4 has run, prefer a forward fix. A pre-HTML API rejects the
applied migration and cannot read HTML revisions. Any revert must preserve migration
4's registration and HTML storage/read compatibility; never delete the migration
ledger entry, column, or saved content. Verify API, renderer, and rebuilt MCP together
against migrated legacy and HTML data. See [the release runbook](docs/RELEASING.md#rollback).

The MCP plugin marketplace tracks `main`. Rolling back means reverting the bad
commit on `main`; Railway and Vercel auto-redeploy on push.

Expand Down Expand Up @@ -444,7 +489,7 @@ behaviour without touching code. `apps/api/.env.example` is the source of truth.
| `PUBLIC_URL` | _(required in prod)_ | Base URL of the renderer, returned in MCP `write` responses. Redeploy required after change. |
| `API_PUBLIC_URL` | _(required in prod)_ | API origin used for OAuth issuer, callbacks, magic links, and MCP discovery. Restart required. |
| `PAGE_TTL_MS` | `1800000` (30 min) | How long a page lives before expiring. Raising it keeps pages alive longer but grows the DB. |
| `ALLOWED_ORIGINS` | _(required in prod)_ | Comma-separated origins the CORS middleware allows. Add an origin here and restart — no redeploy. |
| `ALLOWED_ORIGINS` | _(required for HTML)_ | Exact renderer origins for CORS and protected HTML frames in all environments. Update and restart the API. Production boot also requires this setting. |
| `RATE_LIMIT_MAX` | `30` | Base per-IP write cap for temporary pages and public viewer access/visit starts; event delivery uses 4x per IP plus a 30-request viewer-session cap and 10-event batches. |
| `RATE_LIMIT_WINDOW_MS` | `60000` (60 s) | Shared rolling window for the write and viewer limits. |
| `TRUSTED_PROXY_MODE` | _(required in prod)_ | Set to `railway` to use Railway's `X-Real-IP` for abuse limits; `X-Forwarded-For` is ignored. |
Expand Down
8 changes: 5 additions & 3 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ API_PUBLIC_URL=http://localhost:8787
# How long pages live, in milliseconds (default 30 minutes).
PAGE_TTL_MS=1800000

# Comma-separated list of origins allowed to call the API. Leave unset for dev (allows all).
# Required when NODE_ENV=production — boot will fail loudly otherwise. Set to your Vercel URL (e.g. https://pagent.link).
ALLOWED_ORIGINS=
# Comma-separated exact renderer origins for CORS and protected HTML document frames.
# HTML frames require this in EVERY environment; missing or mismatched origins return 403.
# Use the browser's actual origin (localhost and 127.0.0.1 differ). Production boot also
# requires this setting; set to your Vercel origin, e.g. https://pagent.link.
ALLOWED_ORIGINS=http://localhost:8788

# Supabase Postgres connection string (Session pooler, port 5432). In
# production, sslmode=disable is rejected; the API always uses verify-full.
Expand Down
9 changes: 6 additions & 3 deletions apps/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,17 @@ const newPageLimiter = createNewPageLimiter();

export const app = new Hono<{ Variables: RequestIdVariables & AuthVariables }>();
app.use('*', requestId());
app.use('*', async (c, next) => {
c.header('X-Frame-Options', 'DENY');
await next();
});
app.use(
'*',
secureHeaders({
// contentSecurityPolicy is intentionally omitted — Hono does not set CSP
// by default, and its HTML-page preset would be noise on a JSON API.
// Hono defaults X-Frame-Options to SAMEORIGIN; bump to DENY — this API
// has no frames to embed and DENY is more restrictive.
xFrameOptions: 'DENY',
// Framing defaults run before handlers; only protected document responses opt out.
xFrameOptions: false,
// Browsers default Cross-Origin-Resource-Policy to same-origin which would
// block the renderer at pagent.link from reading API responses at
// api.pagent.link. CORS already gates cross-origin reads explicitly.
Expand Down
109 changes: 109 additions & 0 deletions apps/api/decks/analytics-aggregate-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
export const firstStartedAt = new Date('2026-09-01T12:00:00.000Z');
export const firstLastActivityAt = new Date('2026-09-01T12:03:00.000Z');
export const secondStartedAt = new Date('2026-09-02T12:00:00.000Z');
export const secondLastActivityAt = new Date('2026-09-02T12:01:00.000Z');
export const analyticsInput = {
owner: { id: 'owner-1', email: 'owner@example.test' },
visitRows: [
{
id: 'visit-1',
viewerSessionId: 'session-1',
viewerUserId: null,
viewerEmail: 'viewer@example.test',
identityConfidence: 'authenticated' as const,
startedAt: firstStartedAt,
lastActivityAt: firstLastActivityAt,
revisionNumber: 1,
linkId: 'link-1',
linkName: 'Customer link',
senderId: 'sender-1',
senderEmail: 'sender@example.test',
totalSlides: 2,
},
{
id: 'visit-2',
viewerSessionId: 'session-2',
viewerUserId: null,
viewerEmail: null,
identityConfidence: 'anonymous' as const,
startedAt: secondStartedAt,
lastActivityAt: secondLastActivityAt,
revisionNumber: 1,
linkId: 'link-1',
linkName: 'Customer link',
senderId: 'sender-1',
senderEmail: 'sender@example.test',
totalSlides: 2,
},
],
slideRows: [
{
id: 'slide-1',
revisionNumber: 1,
stableSlideId: 'stable-slide-1',
ordinal: 1,
title: 'Introduction',
},
{
id: 'slide-2',
revisionNumber: 1,
stableSlideId: 'stable-slide-2',
ordinal: 2,
title: 'Details',
},
],
engagementRows: [
{
visitId: 'visit-1',
id: 'slide-1',
revisionNumber: 1,
stableSlideId: 'stable-slide-1',
ordinal: 1,
title: 'Introduction',
activeDurationMs: 100,
viewCount: 1,
qualified: true,
firstSequence: 2,
lastSequence: 2,
},
{
visitId: 'visit-1',
id: 'slide-2',
revisionNumber: 1,
stableSlideId: 'stable-slide-2',
ordinal: 2,
title: 'Details',
activeDurationMs: 200,
viewCount: 1,
qualified: true,
firstSequence: 1,
lastSequence: 1,
},
{
visitId: 'visit-2',
id: 'slide-1',
revisionNumber: 1,
stableSlideId: 'stable-slide-1',
ordinal: 1,
title: 'Introduction',
activeDurationMs: 100,
viewCount: 1,
qualified: true,
firstSequence: 1,
lastSequence: 1,
},
{
visitId: 'missing-visit',
id: 'slide-1',
revisionNumber: 1,
stableSlideId: 'stable-slide-1',
ordinal: 1,
title: 'Introduction',
activeDurationMs: 999,
viewCount: 1,
qualified: true,
firstSequence: 1,
lastSequence: 1,
},
],
};
Loading
Loading