From 5701a625e265d4157f76f4d936e3ecadec3c9c9d Mon Sep 17 00:00:00 2001
From: "Alexandro T. Netto"
Date: Tue, 22 Sep 2026 20:44:07 -0300
Subject: [PATCH 1/7] feat(pages): render authored HTML in isolated document
frames
---
.github/workflows/ci.yml | 3 +
DESIGN.md | 12 +-
README.md | 67 +-
apps/api/app.ts | 9 +-
apps/api/decks/analytics-aggregate-fixture.ts | 109 +++
apps/api/decks/analytics-aggregate.test.ts | 120 +---
apps/api/decks/analytics-aggregate.ts | 71 +-
apps/api/decks/analytics-html.test.ts | 147 +++++
apps/api/decks/analytics-types.ts | 22 +-
apps/api/decks/document-test-support.ts | 83 +++
apps/api/decks/domain-html.test.ts | 56 ++
apps/api/decks/domain.test.ts | 2 +-
apps/api/decks/domain.ts | 32 +-
apps/api/decks/html-document.test.ts | 123 ++++
apps/api/decks/html-document.ts | 82 +++
apps/api/decks/http-schemas.test.ts | 37 ++
apps/api/decks/http-schemas.ts | 1 +
.../0004-add-html-deck-revisions.ts | 9 +
apps/api/decks/repository-analytics.ts | 14 +
apps/api/decks/repository-deck-detail.ts | 4 +
apps/api/decks/repository-decks.ts | 18 +-
...y-engagement-readiness.integration.test.ts | 2 +-
apps/api/decks/repository-engagement.ts | 7 +-
.../decks/repository-html.integration.test.ts | 180 +++++
apps/api/decks/repository-viewer-access.ts | 108 +--
apps/api/decks/repository-viewer-deck.ts | 109 +++
...ository-visit-revision.integration.test.ts | 116 ++++
.../api/decks/routes-document-browser.test.ts | 81 +++
.../decks/routes-document.integration.test.ts | 279 ++++++++
apps/api/decks/routes-document.ts | 126 ++++
apps/api/decks/routes.ts | 2 +
apps/api/decks/schema.integration.test.ts | 43 +-
apps/api/decks/schema.ts | 2 +
apps/api/mcp/http-sdk.test.ts | 6 +-
apps/api/mcp/tools.test.ts | 7 +-
apps/api/mcp/tools.ts | 8 +-
apps/mcp/server.bundle.js | 27 +-
apps/web/analytics-format.ts | 4 +-
apps/web/analytics-visit-detail.test.ts | 24 +-
apps/web/csp.test.ts | 13 +
apps/web/csp.ts | 6 +-
apps/web/deck-analytics-filters-view.ts | 73 ++
apps/web/deck-analytics-view.ts | 130 ++--
apps/web/deck-detail-page-state.test.ts | 10 +
apps/web/deck-detail-page-state.ts | 5 +-
apps/web/deck-detail-preview-view.ts | 65 +-
apps/web/deck-detail.ts | 6 +-
apps/web/deck-types.ts | 13 +-
apps/web/deck-viewer.ts | 3 +
apps/web/document-frame.test.ts | 173 +++++
apps/web/document-frame.ts | 164 +++++
apps/web/home-content.ts | 6 +-
apps/web/permission-ux-copy.test.ts | 1 +
apps/web/privacy-page.ts | 11 +-
apps/web/product-layout-styles.ts | 4 +
apps/web/product-viewer-styles.ts | 5 +
apps/web/viewer-deck-view.ts | 11 +
apps/web/viewer-gate.ts | 6 +-
apps/web/viewer-interaction.ts | 15 +
apps/web/viewer-tracking.ts | 20 +-
apps/web/viewer-types.ts | 1 +
docs/PRD-v0.1.0-secure-deck-sharing.md | 283 ++++----
docs/openapi.yaml | 624 ++++++++++++++++--
skills/pagent/SKILL.md | 34 +-
tests/e2e/presentation-html-loading.spec.ts | 104 +++
tests/e2e/presentation-html.spec.ts | 267 ++++++++
tests/fixtures/authored-deck.html | 111 ++++
67 files changed, 3729 insertions(+), 587 deletions(-)
create mode 100644 apps/api/decks/analytics-aggregate-fixture.ts
create mode 100644 apps/api/decks/analytics-html.test.ts
create mode 100644 apps/api/decks/document-test-support.ts
create mode 100644 apps/api/decks/domain-html.test.ts
create mode 100644 apps/api/decks/html-document.test.ts
create mode 100644 apps/api/decks/html-document.ts
create mode 100644 apps/api/decks/http-schemas.test.ts
create mode 100644 apps/api/decks/migrations/0004-add-html-deck-revisions.ts
create mode 100644 apps/api/decks/repository-html.integration.test.ts
create mode 100644 apps/api/decks/repository-viewer-deck.ts
create mode 100644 apps/api/decks/repository-visit-revision.integration.test.ts
create mode 100644 apps/api/decks/routes-document-browser.test.ts
create mode 100644 apps/api/decks/routes-document.integration.test.ts
create mode 100644 apps/api/decks/routes-document.ts
create mode 100644 apps/web/deck-analytics-filters-view.ts
create mode 100644 apps/web/document-frame.test.ts
create mode 100644 apps/web/document-frame.ts
create mode 100644 tests/e2e/presentation-html-loading.spec.ts
create mode 100644 tests/e2e/presentation-html.spec.ts
create mode 100644 tests/fixtures/authored-deck.html
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 337b422..4574d51 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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 \
diff --git a/DESIGN.md b/DESIGN.md
index e32c5cb..aa527d6 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -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.
diff --git a/README.md b/README.md
index 2d86f90..1f1021a 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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": "Acme proposal Acme proposal Show next step Schedule a review.
"
+}
+```
+
+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`.
diff --git a/apps/api/app.ts b/apps/api/app.ts
index b3ee473..f2db660 100644
--- a/apps/api/app.ts
+++ b/apps/api/app.ts
@@ -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.
diff --git a/apps/api/decks/analytics-aggregate-fixture.ts b/apps/api/decks/analytics-aggregate-fixture.ts
new file mode 100644
index 0000000..50aeb9f
--- /dev/null
+++ b/apps/api/decks/analytics-aggregate-fixture.ts
@@ -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,
+ },
+ ],
+};
diff --git a/apps/api/decks/analytics-aggregate.test.ts b/apps/api/decks/analytics-aggregate.test.ts
index 39268b8..9afbbab 100644
--- a/apps/api/decks/analytics-aggregate.test.ts
+++ b/apps/api/decks/analytics-aggregate.test.ts
@@ -1,118 +1,16 @@
import { describe, expect, it } from 'vitest';
import { aggregateDeckAnalytics } from './analytics-aggregate.ts';
+import {
+ analyticsInput,
+ firstStartedAt,
+ firstLastActivityAt,
+ secondStartedAt,
+ secondLastActivityAt,
+} from './analytics-aggregate-fixture.ts';
describe('deck analytics aggregation', () => {
it('rolls up visits and engagements by visit identifier', () => {
// Given
- const firstStartedAt = new Date('2026-09-01T12:00:00.000Z');
- const firstLastActivityAt = new Date('2026-09-01T12:03:00.000Z');
- const secondStartedAt = new Date('2026-09-02T12:00:00.000Z');
- const secondLastActivityAt = new Date('2026-09-02T12:01:00.000Z');
- 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,
- },
- ],
- };
// When
const eventRows = analyticsInput.engagementRows.map((row) => ({
@@ -128,6 +26,8 @@ describe('deck analytics aggregation', () => {
// Then
expect(analytics).toEqual({
owner: analyticsInput.owner,
+ contentFormat: 'slides',
+ revisionNumbers: [1],
overview: {
totalVisits: 2,
uniqueViewers: 2,
@@ -206,6 +106,7 @@ describe('deck analytics aggregation', () => {
senderEmail: 'sender@example.test',
revisionNumber: 1,
totalActiveTimeMs: 300,
+ contentFormat: 'slides',
viewedSlides: 2,
completion: 1,
furthestSlide: 2,
@@ -240,6 +141,7 @@ describe('deck analytics aggregation', () => {
senderEmail: 'sender@example.test',
revisionNumber: 1,
totalActiveTimeMs: 100,
+ contentFormat: 'slides',
viewedSlides: 1,
completion: 0.5,
furthestSlide: 1,
diff --git a/apps/api/decks/analytics-aggregate.ts b/apps/api/decks/analytics-aggregate.ts
index 7e82db1..0508d56 100644
--- a/apps/api/decks/analytics-aggregate.ts
+++ b/apps/api/decks/analytics-aggregate.ts
@@ -4,6 +4,7 @@ import type {
AnalyticsEventRow,
AnalyticsVisitRow,
DeckAnalytics,
+ DeckContentFormat,
MutableVisitor,
SlideAccumulator,
SlideRollup,
@@ -20,6 +21,14 @@ function viewerKey(visit: AnalyticsVisitRow): string {
export function aggregateDeckAnalytics(input: AnalyticsAggregationInput): DeckAnalytics {
const { owner, visitRows, slideRows, engagementRows, eventRows } = input;
+ const revisionFormats = new Map(
+ input.revisionRows?.map((revision) => [revision.revisionNumber, revision.contentFormat]) ?? [
+ ...slideRows.map((slide) => [slide.revisionNumber, 'slides'] as const),
+ ...visitRows.map((visit) => [visit.revisionNumber, visit.contentFormat ?? 'slides'] as const),
+ ],
+ );
+ const formats = new Set(revisionFormats.values());
+ const contentFormat = formats.size > 1 ? 'mixed' : (formats.values().next().value ?? 'slides');
const slidesById = new Map(slideRows.map((slide) => [slide.id, slide]));
const eventsByVisit = new Map();
for (const event of eventRows) {
@@ -46,10 +55,33 @@ export function aggregateDeckAnalytics(input: AnalyticsAggregationInput): DeckAn
(events.length > 0 || engagements.length === 0);
const slideSequence = sequenceComplete ? qualifyingSlideSequence(events, slidesById) : [];
const distinct = new Set(qualified.map((engagement) => engagement.id));
- const totalActiveTimeMs = engagements.reduce(
- (total, engagement) => total + engagement.activeDurationMs,
- 0,
- );
+ const metricsByFormat = {
+ html: {
+ totalActiveTimeMs: events.reduce((total, event) => total + event.activeDurationMs, 0),
+ viewedSlides: null,
+ completion: null,
+ furthestSlide: null,
+ lastSlide: null,
+ sequenceComplete: false,
+ slideSequence: [],
+ },
+ slides: {
+ totalActiveTimeMs: engagements.reduce(
+ (total, engagement) => total + engagement.activeDurationMs,
+ 0,
+ ),
+ viewedSlides: distinct.size,
+ completion: visit.totalSlides === 0 ? 0 : distinct.size / visit.totalSlides,
+ furthestSlide:
+ qualified.length === 0
+ ? null
+ : Math.max(...qualified.map((engagement) => engagement.ordinal)),
+ lastSlide: slideSequence.at(-1)?.ordinal ?? null,
+ sequenceComplete,
+ slideSequence,
+ },
+ };
+ const visitFormat = visit.contentFormat ?? 'slides';
return {
id: visit.id,
viewer: visit.viewerEmail ?? 'Anonymous',
@@ -61,16 +93,8 @@ export function aggregateDeckAnalytics(input: AnalyticsAggregationInput): DeckAn
senderId: visit.senderId,
senderEmail: visit.senderEmail,
revisionNumber: visit.revisionNumber,
- totalActiveTimeMs,
- viewedSlides: distinct.size,
- completion: visit.totalSlides === 0 ? 0 : distinct.size / visit.totalSlides,
- furthestSlide:
- qualified.length === 0
- ? null
- : Math.max(...qualified.map((engagement) => engagement.ordinal)),
- lastSlide: slideSequence.at(-1)?.ordinal ?? null,
- sequenceComplete,
- slideSequence,
+ contentFormat: visitFormat,
+ ...metricsByFormat[visitFormat],
};
});
const visitDetailsById = new Map();
@@ -101,7 +125,9 @@ export function aggregateDeckAnalytics(input: AnalyticsAggregationInput): DeckAn
current.lastVisit > detail.lastActivityAt ? current.lastVisit : detail.lastActivityAt;
current.visits += 1;
current.totalActiveTimeMs += detail.totalActiveTimeMs;
- current.maximumCompletion = Math.max(current.maximumCompletion, detail.completion);
+ if (detail.completion !== null) {
+ current.maximumCompletion = Math.max(current.maximumCompletion ?? 0, detail.completion);
+ }
}
const slideAccumulators = new Map();
for (const slide of slideRows) {
@@ -143,7 +169,16 @@ export function aggregateDeckAnalytics(input: AnalyticsAggregationInput): DeckAn
exits: accumulator.exits,
}));
const totalActive = visits.reduce((total, visit) => total + visit.totalActiveTimeMs, 0);
- const totalCompletion = visits.reduce((total, visit) => total + visit.completion, 0);
+ const knownCompletions = visits.flatMap((visit) =>
+ visit.completion === null ? [] : [visit.completion],
+ );
+ const averageCompletion =
+ knownCompletions.length === 0
+ ? contentFormat === 'slides'
+ ? 0
+ : null
+ : knownCompletions.reduce((total, completion) => total + completion, 0) /
+ knownCompletions.length;
const lastViewed = visitRows.reduce(
(latest, visit) =>
latest === null || visit.lastActivityAt > latest ? visit.lastActivityAt : latest,
@@ -153,12 +188,14 @@ export function aggregateDeckAnalytics(input: AnalyticsAggregationInput): DeckAn
[...slides].sort((left, right) => right.totalActiveTimeMs - left.totalActiveTimeMs)[0] ?? null;
return {
owner,
+ contentFormat,
+ revisionNumbers: [...revisionFormats.keys()].sort((left, right) => right - left),
overview: {
totalVisits: visitRows.length,
uniqueViewers: uniqueViewerCount,
lastViewed,
averageActiveTimeMs: visits.length === 0 ? 0 : totalActive / visits.length,
- averageCompletion: visits.length === 0 ? 0 : totalCompletion / visits.length,
+ averageCompletion,
topSlide,
},
visitors: [...visitors.values()],
diff --git a/apps/api/decks/analytics-html.test.ts b/apps/api/decks/analytics-html.test.ts
new file mode 100644
index 0000000..d92cee4
--- /dev/null
+++ b/apps/api/decks/analytics-html.test.ts
@@ -0,0 +1,147 @@
+import { describe, expect, it } from 'vitest';
+import { aggregateDeckAnalytics } from './analytics-aggregate.ts';
+
+const startedAt = new Date('2026-09-01T12:00:00.000Z');
+const htmlVisit = {
+ id: 'html-visit',
+ viewerSessionId: 'session',
+ viewerUserId: null,
+ viewerEmail: 'viewer@example.test',
+ identityConfidence: 'unverified' as const,
+ startedAt,
+ lastActivityAt: startedAt,
+ revisionNumber: 2,
+ linkId: 'link',
+ linkName: 'Customer',
+ senderId: 'owner',
+ senderEmail: 'owner@example.test',
+ totalSlides: 0,
+ contentFormat: 'html' as const,
+};
+const htmlInput = {
+ owner: { id: 'owner', email: 'owner@example.test' },
+ revisionRows: [{ revisionNumber: 2, contentFormat: 'html' as const }],
+ visitRows: [htmlVisit],
+ slideRows: [],
+ engagementRows: [],
+ eventRows: [
+ {
+ visitId: 'html-visit',
+ slideId: null,
+ eventAt: startedAt,
+ sequence: 1,
+ activeDurationMs: 2000,
+ qualified: false,
+ },
+ {
+ visitId: 'html-visit',
+ slideId: null,
+ eventAt: startedAt,
+ sequence: 2,
+ activeDurationMs: 3000,
+ qualified: false,
+ },
+ ],
+};
+
+describe('HTML deck analytics', () => {
+ it('uses accepted visit events when an HTML revision has no slide engagement', () => {
+ // Given / When
+ const result = aggregateDeckAnalytics(htmlInput);
+
+ // Then
+ expect(result.visits[0]?.totalActiveTimeMs).toBe(5000);
+ expect(result.overview.averageActiveTimeMs).toBe(5000);
+ });
+
+ it('reports unknowable slide metrics as unavailable when content is HTML', () => {
+ // Given / When
+ const result = aggregateDeckAnalytics(htmlInput);
+
+ // Then
+ expect(result.visits[0]).toMatchObject({
+ viewedSlides: null,
+ completion: null,
+ furthestSlide: null,
+ lastSlide: null,
+ slideSequence: [],
+ });
+ expect(result.visitors[0]?.maximumCompletion).toBeNull();
+ expect(result.overview.averageCompletion).toBeNull();
+ expect(result.overview.topSlide).toBeNull();
+ expect(result.slides).toEqual([]);
+ });
+
+ it('classifies selected stored revisions when no visits exist', () => {
+ // Given
+ const input = { ...htmlInput, visitRows: [], eventRows: [] };
+
+ // When
+ const result = aggregateDeckAnalytics(input);
+
+ // Then
+ expect(result).toMatchObject({ contentFormat: 'html', revisionNumbers: [2] });
+ expect(result.overview.averageCompletion).toBeNull();
+ });
+
+ it('retains known slide completion when a viewer also visits an HTML revision', () => {
+ // Given
+ const slide = {
+ id: 'slide',
+ revisionNumber: 1,
+ stableSlideId: 'cover',
+ ordinal: 1,
+ title: 'Cover',
+ };
+ const input = {
+ ...htmlInput,
+ revisionRows: [
+ { revisionNumber: 1, contentFormat: 'slides' as const },
+ ...htmlInput.revisionRows,
+ ],
+ visitRows: [
+ ...htmlInput.visitRows,
+ {
+ ...htmlVisit,
+ id: 'slide-visit',
+ revisionNumber: 1,
+ totalSlides: 1,
+ contentFormat: 'slides' as const,
+ },
+ ],
+ slideRows: [slide],
+ engagementRows: [
+ {
+ ...slide,
+ visitId: 'slide-visit',
+ activeDurationMs: 1000,
+ viewCount: 1,
+ qualified: true,
+ firstSequence: 1,
+ lastSequence: 1,
+ },
+ ],
+ eventRows: [
+ ...htmlInput.eventRows,
+ {
+ visitId: 'slide-visit',
+ slideId: 'slide',
+ eventAt: startedAt,
+ sequence: 1,
+ activeDurationMs: 1000,
+ qualified: true,
+ },
+ ],
+ };
+
+ // When
+ const result = aggregateDeckAnalytics(input);
+
+ // Then
+ expect(result).toMatchObject({ contentFormat: 'mixed', revisionNumbers: [2, 1] });
+ expect(result.visits[1]?.completion).toBe(1);
+ expect(result.visitors[0]?.maximumCompletion).toBe(1);
+ expect(result.overview.averageCompletion).toBe(1);
+ expect(result.overview.averageActiveTimeMs).toBe(3000);
+ });
+});
diff --git a/apps/api/decks/analytics-types.ts b/apps/api/decks/analytics-types.ts
index 2d16b94..7bf0754 100644
--- a/apps/api/decks/analytics-types.ts
+++ b/apps/api/decks/analytics-types.ts
@@ -1,5 +1,12 @@
import type { IdentityConfidence } from './domain.ts';
+export type DeckContentFormat = 'html' | 'slides';
+
+export type AnalyticsRevisionRow = {
+ readonly revisionNumber: number;
+ readonly contentFormat: DeckContentFormat;
+};
+
export type AnalyticsVisitRow = {
readonly id: string;
readonly viewerSessionId: string;
@@ -14,6 +21,7 @@ export type AnalyticsVisitRow = {
readonly senderId: string;
readonly senderEmail: string;
readonly totalSlides: number;
+ readonly contentFormat?: DeckContentFormat;
};
export type AnalyticsSlideRow = {
@@ -54,8 +62,9 @@ export type VisitDetail = {
readonly senderEmail: string;
readonly revisionNumber: number;
readonly totalActiveTimeMs: number;
- readonly viewedSlides: number;
- readonly completion: number;
+ readonly contentFormat: DeckContentFormat;
+ readonly viewedSlides: number | null;
+ readonly completion: number | null;
readonly furthestSlide: number | null;
readonly lastSlide: number | null;
readonly sequenceComplete: boolean;
@@ -75,7 +84,7 @@ type VisitorRollup = {
readonly lastVisit: Date;
readonly visits: number;
readonly totalActiveTimeMs: number;
- readonly maximumCompletion: number;
+ readonly maximumCompletion: number | null;
};
export type SlideRollup = {
@@ -93,12 +102,14 @@ export type SlideRollup = {
export type DeckAnalytics = {
readonly owner: { readonly id: string; readonly email: string };
+ readonly contentFormat: DeckContentFormat | 'mixed';
+ readonly revisionNumbers: readonly number[];
readonly overview: {
readonly totalVisits: number;
readonly uniqueViewers: number;
readonly lastViewed: Date | null;
readonly averageActiveTimeMs: number;
- readonly averageCompletion: number;
+ readonly averageCompletion: number | null;
readonly topSlide: SlideRollup | null;
};
readonly visitors: readonly VisitorRollup[];
@@ -113,7 +124,7 @@ export type MutableVisitor = {
lastVisit: Date;
visits: number;
totalActiveTimeMs: number;
- maximumCompletion: number;
+ maximumCompletion: number | null;
};
export type SlideAccumulator = {
@@ -126,6 +137,7 @@ export type SlideAccumulator = {
export type AnalyticsAggregationInput = {
readonly owner: { readonly id: string; readonly email: string };
+ readonly revisionRows?: readonly AnalyticsRevisionRow[];
readonly visitRows: readonly AnalyticsVisitRow[];
readonly slideRows: readonly AnalyticsSlideRow[];
readonly engagementRows: readonly AnalyticsEngagementRow[];
diff --git a/apps/api/decks/document-test-support.ts b/apps/api/decks/document-test-support.ts
new file mode 100644
index 0000000..7529a17
--- /dev/null
+++ b/apps/api/decks/document-test-support.ts
@@ -0,0 +1,83 @@
+import { generateKeyPairSync } from 'node:crypto';
+import postgres from 'postgres';
+import { afterAll, beforeAll } from 'vitest';
+import { app } from '../app.ts';
+import { initKeys } from '../auth/jwt.ts';
+import { SESSION_COOKIE_NAME } from '../auth/middleware.ts';
+import { createSession } from '../auth/session.ts';
+import * as db from '../db.ts';
+import { publishDeckBodySchema } from './domain.ts';
+import { publishDeck } from './repository-decks.ts';
+import { createShareLink, grantViewerAccess } from './repository-sharing.ts';
+import { initDeckSchema } from './schema.ts';
+import { integrationDatabaseUrl } from './test-database.ts';
+
+export const rendererOrigin = 'https://renderer.pagent.test';
+export const documentHtml =
+ '\nExact Olá \n';
+export const databaseUrl = integrationDatabaseUrl('pagent_html_frame_test');
+export const setup = postgres(databaseUrl ?? '', { ssl: false, prepare: false });
+
+export function documentDatabaseLifecycle() {
+ beforeAll(async () => {
+ await setup`drop schema public cascade`;
+ await setup`create schema public`;
+ await db.init(databaseUrl ?? '');
+ await initDeckSchema(db.database());
+ const keys = generateKeyPairSync('ed25519');
+ await initKeys(
+ keys.privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64url'),
+ keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
+ );
+ });
+ afterAll(async () => {
+ await db.shutdown();
+ await setup.end({ timeout: 5 });
+ });
+}
+
+export async function createDocumentFixture() {
+ const owner = await db.upsertUser({
+ email: 'frame-owner@pagent.test',
+ handle: 'frame-owner',
+ name: 'Frame Owner',
+ avatarUrl: null,
+ });
+ const deck = await publishDeck(
+ owner,
+ publishDeckBodySchema.parse({ title: 'Document', html: documentHtml }),
+ );
+ const link = await createShareLink(owner.id, deck.deckId, {
+ name: 'Review',
+ access_mode: 'anyone',
+ allowed_emails: [],
+ allowed_domains: [],
+ });
+ const access = await grantViewerAccess({ token: link.token });
+ if (access.kind !== 'granted') throw new TypeError('Test viewer access must be granted');
+ return {
+ ...deck,
+ owner,
+ linkId: link.id,
+ sessionToken: access.sessionToken,
+ cookie: `${SESSION_COOKIE_NAME}=${await createSession(owner.id)}`,
+ };
+}
+
+type DocumentRequest = {
+ readonly audience: 'owner' | 'viewer';
+ readonly fields: Readonly>;
+ readonly headers?: Readonly>;
+ readonly origin?: string | null;
+};
+
+export function requestDocument(input: DocumentRequest) {
+ const body = new FormData();
+ for (const [key, value] of Object.entries(input.fields)) body.set(key, value);
+ const origin = input.origin === undefined ? rendererOrigin : input.origin;
+ return app.request(`/v1/${input.audience}/document`, {
+ method: 'POST',
+ headers: { ...(origin === null ? {} : { origin }), ...input.headers },
+ body,
+ });
+}
diff --git a/apps/api/decks/domain-html.test.ts b/apps/api/decks/domain-html.test.ts
new file mode 100644
index 0000000..c0b0f5e
--- /dev/null
+++ b/apps/api/decks/domain-html.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest';
+import { HTML_MAX_BYTES } from '../limits.ts';
+import { publishDeckBodySchema } from './domain.ts';
+
+describe('HTML deck publishing boundary', () => {
+ it('preserves a full document exactly when HTML is submitted', () => {
+ // Given
+ const html =
+ '\nOlá \n';
+
+ // When
+ const result = publishDeckBodySchema.safeParse({ title: 'Document', html });
+
+ // Then
+ expect(result.success).toBe(true);
+ if (result.success) expect(result.data).toEqual({ title: 'Document', html });
+ });
+
+ it('rejects ambiguous content when HTML and slides are both submitted', () => {
+ // Given
+ const input = {
+ title: 'Ambiguous',
+ html: 'HTML ',
+ slides: [{ id: 'one', html: 'Slide ' }],
+ };
+
+ // When
+ const result = publishDeckBodySchema.safeParse(input);
+
+ // Then
+ expect(result.success).toBe(false);
+ });
+
+ it.each([
+ ['empty', ''],
+ ['ASCII bytes over the limit', 'a'.repeat(HTML_MAX_BYTES + 1)],
+ ['multibyte bytes over the limit', 'é'.repeat(HTML_MAX_BYTES / 2 + 1)],
+ ])('rejects HTML when it contains %s', (_description, html) => {
+ // Given / When
+ const result = publishDeckBodySchema.safeParse({ title: 'Boundary', html });
+
+ // Then
+ expect(result.success).toBe(false);
+ });
+
+ it('accepts HTML when its UTF-8 size is exactly the limit', () => {
+ // Given
+ const html = 'é'.repeat(HTML_MAX_BYTES / 2);
+
+ // When
+ const result = publishDeckBodySchema.safeParse({ title: 'Boundary', html });
+
+ // Then
+ expect(result.success).toBe(true);
+ });
+});
diff --git a/apps/api/decks/domain.test.ts b/apps/api/decks/domain.test.ts
index aa28cca..c7d5c94 100644
--- a/apps/api/decks/domain.test.ts
+++ b/apps/api/decks/domain.test.ts
@@ -25,7 +25,7 @@ describe('deck boundary schemas', () => {
// Then
expect(result.success).toBe(true);
- if (result.success)
+ if (result.success && 'slides' in result.data)
expect(result.data.slides.map((slide) => slide.id)).toEqual(['cover', 'plan']);
});
diff --git a/apps/api/decks/domain.ts b/apps/api/decks/domain.ts
index 4d3d5fe..060a198 100644
--- a/apps/api/decks/domain.ts
+++ b/apps/api/decks/domain.ts
@@ -1,4 +1,5 @@
import { z } from 'zod';
+import { HTML_MAX_BYTES } from '../limits.ts';
const stableSlideIdSchema = z
.string()
@@ -18,12 +19,26 @@ export type IdentityConfidence = z.infer;
export const analyticsVisibilitySchema = z.enum(['private', 'selected', 'team', 'workspace']);
export type AnalyticsVisibility = z.infer;
-export const publishDeckBodySchema = z
- .object({
- title: z.string().trim().min(1).max(160),
- description: z.string().trim().max(2_000).optional(),
- client_label: z.string().trim().max(160).optional(),
- update_deck_id: deckIdSchema.optional(),
+const deckMetadataSchema = z.object({
+ title: z.string().trim().min(1).max(160),
+ description: z.string().trim().max(2_000).optional(),
+ client_label: z.string().trim().max(160).optional(),
+ update_deck_id: deckIdSchema.optional(),
+});
+
+export const htmlPublishDeckBodySchema = deckMetadataSchema
+ .extend({
+ html: z
+ .string()
+ .min(1)
+ .refine((html) => Buffer.byteLength(html, 'utf8') <= HTML_MAX_BYTES, {
+ message: `HTML must not exceed ${HTML_MAX_BYTES} UTF-8 bytes`,
+ }),
+ })
+ .strict();
+
+const slidesPublishDeckBodySchema = deckMetadataSchema
+ .extend({
slides: z
.array(
z
@@ -41,7 +56,12 @@ export const publishDeckBodySchema = z
}),
})
.strict();
+export const publishDeckBodySchema = z.union([
+ htmlPublishDeckBodySchema,
+ slidesPublishDeckBodySchema,
+]);
export type PublishDeckBody = z.infer;
+export type SlidesPublishDeckBody = z.infer;
const shareLinkBaseSchema = z.object({
name: z.string().trim().min(1).max(160),
diff --git a/apps/api/decks/html-document.test.ts b/apps/api/decks/html-document.test.ts
new file mode 100644
index 0000000..1df189f
--- /dev/null
+++ b/apps/api/decks/html-document.test.ts
@@ -0,0 +1,123 @@
+import { runInNewContext } from 'node:vm';
+import { describe, expect, it } from 'vitest';
+import { documentCsp, documentOrigins, renderHtmlDocument } from './html-document.ts';
+
+const origins = documentOrigins(['https://renderer.pagent.test']);
+const origin = origins[0];
+if (origin === undefined) throw new TypeError('Test renderer origin is invalid');
+const revisionId = '36e8ac8b-bb84-4d24-aee7-bea01f3c7ca8';
+
+function bridgeHarness(revision = revisionId) {
+ const origin = origins[0];
+ if (origin === undefined) throw new TypeError('Test renderer origin is invalid');
+ const messages: { readonly message: unknown; readonly origin: string }[] = [];
+ const handlers = new Map void>();
+ const clock = { now: 0 };
+ const html = 'Original ';
+ const served = renderHtmlDocument(html, origin, revision);
+ const script = served.slice(html.length).match(/^\s*\u2028\u2029';
+ // When
+ const bridge = bridgeHarness(unsafe);
+ // Then
+ expect(bridge.served.slice(bridge.html.length).match(/<\/script>/g)).toHaveLength(1);
+ expect(bridge.messages).toEqual([
+ { message: { type: 'pagent:ready', revisionId: unsafe }, origin },
+ ]);
+ });
+});
+
+describe('document renderer origins', () => {
+ it('fails closed when no renderer origin is configured', () => {
+ // Given / When
+ const origins = documentOrigins(undefined);
+ // Then
+ expect(origins).toEqual([]);
+ expect(documentCsp(origins)).toContain("frame-ancestors 'none'");
+ });
+
+ it('accepts only exact HTTP origins when configuration contains unsafe values', () => {
+ // Given
+ const configured = [
+ 'https://renderer.pagent.test',
+ 'http://localhost:5173',
+ '*',
+ 'null',
+ 'https://renderer.pagent.test/path',
+ 'https://renderer.pagent.test/',
+ 'https://user:pass@renderer.pagent.test',
+ 'https://renderer.pagent.test; script-src *',
+ 'data:text/html,a',
+ ];
+ // When
+ const origins = documentOrigins(configured);
+ // Then
+ expect(origins).toEqual(['https://renderer.pagent.test', 'http://localhost:5173']);
+ });
+});
diff --git a/apps/api/decks/html-document.ts b/apps/api/decks/html-document.ts
new file mode 100644
index 0000000..35c7a42
--- /dev/null
+++ b/apps/api/decks/html-document.ts
@@ -0,0 +1,82 @@
+import { z } from 'zod';
+
+const rendererOriginSchema = z
+ .string()
+ .url()
+ .pipe(
+ z.string().refine((value) => {
+ const url = new URL(value);
+ return (
+ (url.protocol === 'https:' || url.protocol === 'http:') &&
+ url.origin === value &&
+ !/[\s*,;]/.test(value)
+ );
+ }),
+ )
+ .brand('RendererOrigin');
+
+export type RendererOrigin = z.infer;
+
+export function documentOrigins(
+ configured: readonly string[] | undefined,
+): readonly RendererOrigin[] {
+ return (configured ?? []).flatMap((value) => {
+ const parsed = rendererOriginSchema.safeParse(value);
+ return parsed.success ? [parsed.data] : [];
+ });
+}
+
+export function documentCsp(origins: readonly RendererOrigin[]): string {
+ return [
+ 'sandbox allow-scripts',
+ "default-src 'none'",
+ "script-src 'unsafe-inline'",
+ "style-src 'unsafe-inline'",
+ 'img-src data: blob:',
+ 'font-src data:',
+ 'media-src data: blob:',
+ "connect-src 'none'",
+ "frame-src 'none'",
+ "object-src 'none'",
+ "worker-src 'none'",
+ "base-uri 'none'",
+ "form-action 'none'",
+ `frame-ancestors ${origins.length === 0 ? "'none'" : origins.join(' ')}`,
+ ].join('; ');
+}
+
+export function renderHtmlDocument(
+ html: string,
+ origin: RendererOrigin,
+ revisionId: string,
+): string {
+ const config = `{origin:${scriptString(origin)},revisionId:${scriptString(revisionId)}}`;
+ return `${html}\n`;
+}
+
+export function renderDocumentError(origin: RendererOrigin): string {
+ return `Page unavailable The page could not be opened. Return to the sharing page and try again.
`;
+}
+
+function scriptString(value: string): string {
+ return JSON.stringify(value)
+ .replace(//g, '\\u003e')
+ .replace(/&/g, '\\u0026')
+ .replace(/\u2028/g, '\\u2028')
+ .replace(/\u2029/g, '\\u2029');
+}
diff --git a/apps/api/decks/http-schemas.test.ts b/apps/api/decks/http-schemas.test.ts
new file mode 100644
index 0000000..b417c02
--- /dev/null
+++ b/apps/api/decks/http-schemas.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+import { startVisitBodySchema } from './http-schemas.ts';
+
+const readiness = {
+ visible: true,
+ interacted: true,
+ analyticsConsent: true,
+ deviceClass: 'desktop',
+ browserFamily: 'Chromium',
+ countryCode: null,
+};
+
+describe('visit revision boundary', () => {
+ it('accepts the displayed revision when a valid identifier is supplied', () => {
+ // Given
+ const revisionId = '11111111-1111-4111-8111-111111111111';
+ // When
+ const result = startVisitBodySchema.safeParse({ ...readiness, revisionId });
+ // Then
+ expect(result.success).toBe(true);
+ if (result.success) expect(result.data).toMatchObject({ revisionId });
+ });
+
+ it('accepts a shipped client when its request omits the revision', () => {
+ // Given / When
+ const result = startVisitBodySchema.safeParse(readiness);
+ // Then
+ expect(result.success).toBe(true);
+ });
+
+ it('rejects malformed revision identifiers when starting a visit', () => {
+ // Given / When
+ const result = startVisitBodySchema.safeParse({ ...readiness, revisionId: 'not-an-id' });
+ // Then
+ expect(result.success).toBe(false);
+ });
+});
diff --git a/apps/api/decks/http-schemas.ts b/apps/api/decks/http-schemas.ts
index 39ea3c0..650ff1e 100644
--- a/apps/api/decks/http-schemas.ts
+++ b/apps/api/decks/http-schemas.ts
@@ -12,6 +12,7 @@ export const accessDecisionBodySchema = z
export const startVisitBodySchema = z
.object({
+ revisionId: z.string().uuid().optional(),
visible: z.boolean(),
interacted: z.boolean(),
analyticsConsent: z.boolean(),
diff --git a/apps/api/decks/migrations/0004-add-html-deck-revisions.ts b/apps/api/decks/migrations/0004-add-html-deck-revisions.ts
new file mode 100644
index 0000000..7425f86
--- /dev/null
+++ b/apps/api/decks/migrations/0004-add-html-deck-revisions.ts
@@ -0,0 +1,9 @@
+import type { DeckMigration } from '../schema-migrations.ts';
+
+export const htmlDeckRevisionsMigration = {
+ version: 4,
+ name: 'add_html_deck_revisions',
+ async up(database) {
+ await database`alter table deck_revisions add column if not exists html text`;
+ },
+} satisfies DeckMigration;
diff --git a/apps/api/decks/repository-analytics.ts b/apps/api/decks/repository-analytics.ts
index 7721066..e5c0dae 100644
--- a/apps/api/decks/repository-analytics.ts
+++ b/apps/api/decks/repository-analytics.ts
@@ -5,6 +5,7 @@ import type {
AnalyticsEventRow,
AnalyticsSlideRow,
AnalyticsVisitRow,
+ AnalyticsRevisionRow,
DeckAnalytics,
} from './analytics-types.ts';
import { canViewAnalytics } from './authorization.ts';
@@ -98,6 +99,14 @@ export async function getDeckAnalytics(
const database = db.database();
const viewerSearch = filters.viewer === undefined ? null : `%${filters.viewer.toLowerCase()}%`;
const sender = access.restrictToCreator ? userId : (filters.sender ?? null);
+ const revisionRows = await database`
+ select revision_number as "revisionNumber",
+ case when html is null then 'slides' else 'html' end as "contentFormat"
+ from deck_revisions
+ where deck_id = ${deckId}
+ and (${filters.revision ?? null}::integer is null or revision_number = ${filters.revision ?? null})
+ order by revision_number desc
+ `;
const visits = await database<
{
id: string;
@@ -113,11 +122,13 @@ export async function getDeckAnalytics(
sender_id: string;
sender_email: string;
total_slides: string;
+ content_format: 'html' | 'slides';
}[]
>`
select v.id, v.viewer_session_id, vs.viewer_user_id, v.viewer_email,
v.identity_confidence, v.started_at, v.last_activity_at,
r.revision_number, sl.id as link_id, sl.name as link_name,
+ case when r.html is null then 'slides' else 'html' end as content_format,
sl.creator_id as sender_id, sender.email as sender_email,
(select count(*) from deck_slides ds where ds.revision_id = v.revision_id)::text as total_slides
from visits v
@@ -151,6 +162,7 @@ export async function getDeckAnalytics(
senderId: visit.sender_id,
senderEmail: visit.sender_email,
totalSlides: Number(visit.total_slides),
+ contentFormat: visit.content_format,
}));
const slideRows = await database<
{
@@ -177,6 +189,7 @@ export async function getDeckAnalytics(
if (visitRows.length === 0) {
return aggregateDeckAnalytics({
owner: { id: access.ownerId, email: access.ownerEmail },
+ revisionRows,
visitRows,
slideRows: slides,
engagementRows: [],
@@ -229,6 +242,7 @@ export async function getDeckAnalytics(
`;
return aggregateDeckAnalytics({
owner: { id: access.ownerId, email: access.ownerEmail },
+ revisionRows,
visitRows,
slideRows: slides,
engagementRows: engagements,
diff --git a/apps/api/decks/repository-deck-detail.ts b/apps/api/decks/repository-deck-detail.ts
index cf89537..9d90595 100644
--- a/apps/api/decks/repository-deck-detail.ts
+++ b/apps/api/decks/repository-deck-detail.ts
@@ -18,6 +18,7 @@ type DeckDetail = {
readonly revisions: readonly {
readonly id: string;
readonly revisionNumber: number;
+ readonly contentFormat: 'html' | 'slides';
readonly slideCount: number;
readonly createdByEmail: string;
readonly createdAt: Date;
@@ -72,12 +73,14 @@ export async function getDeckDetail(userId: string, deckId: string): Promise`
select r.id, r.revision_number, count(s.id)::text as slide_count,
+ case when r.html is null then 'slides' else 'html' end as content_format,
creator.email as created_by_email, r.created_at
from deck_revisions r
join users creator on creator.id = r.created_by
@@ -107,6 +110,7 @@ export async function getDeckDetail(userId: string, deckId: string): Promise ({
id: revision.id,
revisionNumber: revision.revision_number,
+ contentFormat: revision.content_format,
slideCount: Number(revision.slide_count),
createdByEmail: revision.created_by_email,
createdAt: revision.created_at,
diff --git a/apps/api/decks/repository-decks.ts b/apps/api/decks/repository-decks.ts
index 4f6e276..19acf51 100644
--- a/apps/api/decks/repository-decks.ts
+++ b/apps/api/decks/repository-decks.ts
@@ -1,7 +1,7 @@
import * as db from '../db.ts';
import { sanitize } from '../sanitize.ts';
import type postgres from 'postgres';
-import type { PublishDeckBody } from './domain.ts';
+import type { PublishDeckBody, SlidesPublishDeckBody } from './domain.ts';
import { DeckDataInvariantError } from './errors.ts';
type Transaction = postgres.TransactionSql>;
@@ -44,7 +44,7 @@ type SanitizedSlide = {
readonly ordinal: number;
};
-function sanitizedSlides(body: PublishDeckBody): readonly SanitizedSlide[] {
+function sanitizedSlides(body: SlidesPublishDeckBody): readonly SanitizedSlide[] {
return body.slides.map((slide, index) => {
const html = sanitize(slide.html).output.trim();
if (html.length === 0) throw new InvalidDeckContentError(slide.id);
@@ -85,7 +85,8 @@ export async function publishDeck(
user: PublishingUser,
body: PublishDeckBody,
): Promise {
- const slides = sanitizedSlides(body);
+ const slides = 'slides' in body ? sanitizedSlides(body) : [];
+ const html = 'html' in body ? body.html : null;
return db.database().begin(async (tx) => {
const workspaceId = await ensurePersonalWorkspace(tx, user);
const requestedDeckId = body.update_deck_id;
@@ -123,8 +124,8 @@ export async function publishDeck(
throw new DeckDataInvariantError('revision counter returned no value');
}
const revisions = await tx<{ id: string }[]>`
- insert into deck_revisions (deck_id, revision_number, created_by)
- values (${deckId}, ${revisionNumber}, ${user.id})
+ insert into deck_revisions (deck_id, revision_number, created_by, html)
+ values (${deckId}, ${revisionNumber}, ${user.id}, ${html})
returning id
`;
const revisionId = revisions[0]?.id;
@@ -141,7 +142,7 @@ export async function publishDeck(
insert into audit_log (workspace_id, deck_id, actor_user_id, action, details)
values (
${workspaceId}, ${deckId}, ${user.id}, 'deck.published',
- ${JSON.stringify({ revisionNumber, slideCount: slides.length })}::jsonb
+ ${JSON.stringify({ revisionNumber, contentFormat: html === null ? 'slides' : 'html', slideCount: slides.length })}::jsonb
)
`;
return { deckId, revisionId, revisionNumber };
@@ -153,6 +154,7 @@ export type DeckPreview = {
readonly title: string;
readonly revisionId: string;
readonly revisionNumber: number;
+ readonly html: string | null;
readonly slides: readonly {
readonly id: string;
readonly stableSlideId: string;
@@ -170,9 +172,10 @@ export async function getDeckPreview(userId: string, deckId: string): Promise`
- select d.id as deck_id, d.title as deck_title, r.id as revision_id, r.revision_number
+ select d.id as deck_id, d.title as deck_title, r.id as revision_id, r.revision_number, r.html
from decks d
join deck_revisions r
on r.deck_id = d.id and r.revision_number = d.latest_revision_number
@@ -207,6 +210,7 @@ export async function getDeckPreview(userId: string, deckId: string): Promise ({
id: slide.id,
stableSlideId: slide.stable_slide_id,
diff --git a/apps/api/decks/repository-engagement-readiness.integration.test.ts b/apps/api/decks/repository-engagement-readiness.integration.test.ts
index a3a7807..58fc515 100644
--- a/apps/api/decks/repository-engagement-readiness.integration.test.ts
+++ b/apps/api/decks/repository-engagement-readiness.integration.test.ts
@@ -228,7 +228,7 @@ integration('engagement readiness regressions', () => {
// Given
await ingestEngagement(token, visitId, [event(1, 3_000)]);
await db.database()`alter table engagement_events drop column qualified`;
- await db.database()`delete from deck_schema_migrations where version = 3`;
+ await db.database()`delete from deck_schema_migrations where version >= 3`;
// When
await initDeckSchema(db.database());
if (mixed) await ingestEngagement(token, visitId, [event(2, 7_000)]);
diff --git a/apps/api/decks/repository-engagement.ts b/apps/api/decks/repository-engagement.ts
index e62814a..081bb7d 100644
--- a/apps/api/decks/repository-engagement.ts
+++ b/apps/api/decks/repository-engagement.ts
@@ -5,6 +5,7 @@ import { DeckDataInvariantError } from './errors.ts';
import { hashOpaqueToken } from './tokens.ts';
export type StartVisitInput = {
+ readonly revisionId?: string;
readonly visible: boolean;
readonly interacted: boolean;
readonly analyticsConsent: boolean;
@@ -52,7 +53,10 @@ export async function startVisit(
join decks d on d.id = sl.deck_id
join workspaces w on w.id = d.workspace_id
join deck_revisions r
- on r.deck_id = d.id and r.revision_number = d.latest_revision_number
+ on r.deck_id = d.id and (
+ (${input.revisionId ?? null}::uuid is null and r.revision_number = d.latest_revision_number)
+ or r.id = ${input.revisionId ?? null}
+ )
where vs.token_hash = ${hashOpaqueToken(sessionToken)}
and vs.revoked_at is null and vs.expires_at > now()
and sl.revoked_at is null and (sl.expires_at is null or sl.expires_at > now())
@@ -86,6 +90,7 @@ export async function startVisit(
const existing = await tx<{ id: string }[]>`
select id from visits
where viewer_session_id = ${session.session_id} and share_link_id = ${session.share_link_id}
+ and revision_id = ${session.revision_id}
and ended_at is null and last_activity_at > now() - interval '30 minutes'
order by started_at desc limit 1
`;
diff --git a/apps/api/decks/repository-html.integration.test.ts b/apps/api/decks/repository-html.integration.test.ts
new file mode 100644
index 0000000..a32ce45
--- /dev/null
+++ b/apps/api/decks/repository-html.integration.test.ts
@@ -0,0 +1,180 @@
+import { randomUUID } from 'node:crypto';
+import postgres from 'postgres';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import * as db from '../db.ts';
+import { publishDeckBodySchema } from './domain.ts';
+import { getDeckAnalytics } from './repository-analytics.ts';
+import { getDeckDetail, getDeckPreview, publishDeck } from './repository-decks.ts';
+import { ingestEngagement, startVisit } from './repository-engagement.ts';
+import { createShareLink, getViewerDeck, grantViewerAccess } from './repository-sharing.ts';
+import { initDeckSchema } from './schema.ts';
+import { integrationDatabaseUrl } from './test-database.ts';
+
+const databaseUrl = integrationDatabaseUrl('pagent_html_backend_20260922_test');
+const integration = describe.runIf(databaseUrl !== undefined);
+const html =
+ '\nOlá \n';
+
+integration('HTML document repository', () => {
+ const setup = postgres(databaseUrl ?? '', { ssl: false, prepare: false });
+ let owner = { id: '', email: 'html-owner@pagent.test' };
+
+ beforeAll(async () => {
+ await setup`drop schema public cascade`;
+ await setup`create schema public`;
+ await db.init(databaseUrl ?? '');
+ await initDeckSchema(db.database());
+ owner = await db.upsertUser({
+ email: owner.email,
+ handle: 'html-owner',
+ name: 'HTML Owner',
+ avatarUrl: null,
+ });
+ });
+
+ afterAll(async () => {
+ await db.shutdown();
+ await setup.end({ timeout: 5 });
+ });
+
+ it('stores and returns the exact document when publishing an HTML revision', async () => {
+ // Given
+ const body = publishDeckBodySchema.parse({ title: 'Exact document', html });
+
+ // When
+ const published = await publishDeck(owner, body);
+ const preview = await getDeckPreview(owner.id, published.deckId);
+ const detail = await getDeckDetail(owner.id, published.deckId);
+
+ // Then
+ expect(preview).toMatchObject({ html, slides: [] });
+ expect(detail.revisions[0]).toMatchObject({ contentFormat: 'html', slideCount: 0 });
+ const rows = await setup<{ html: string; slide_count: number }[]>`
+ select r.html, (select count(*)::integer from deck_slides s where s.revision_id = r.id) as slide_count
+ from deck_revisions r where r.id = ${published.revisionId}
+ `;
+ expect(rows).toEqual([{ html, slide_count: 0 }]);
+ });
+
+ it('retains legacy slides and identifies an unvisited HTML update when revising a deck', async () => {
+ // Given
+ const original = await publishDeck(owner, {
+ title: 'Legacy',
+ slides: [{ id: 'cover', html: 'Legacy ' }],
+ });
+
+ // When
+ await publishDeck(
+ owner,
+ publishDeckBodySchema.parse({ title: 'Updated', update_deck_id: original.deckId, html }),
+ );
+ const analytics = await getDeckAnalytics(owner.id, original.deckId, {});
+ const filtered = await getDeckAnalytics(owner.id, original.deckId, { revision: 2 });
+ const detail = await getDeckDetail(owner.id, original.deckId);
+
+ // Then
+ expect(analytics).toMatchObject({ contentFormat: 'mixed', revisionNumbers: [2, 1] });
+ expect(filtered).toMatchObject({ contentFormat: 'html', revisionNumbers: [2], slides: [] });
+ expect(filtered.overview.averageCompletion).toBeNull();
+ expect(detail.revisions.map((revision) => revision.contentFormat)).toEqual(['html', 'slides']);
+ const rows = await setup<
+ { html: string }[]
+ >`select html from deck_slides where revision_id = ${original.revisionId}`;
+ expect(rows).toEqual([{ html: 'Legacy ' }]);
+ });
+
+ it('keeps earlier HTML bytes immutable when publishing another document revision', async () => {
+ // Given
+ const original = await publishDeck(
+ owner,
+ publishDeckBodySchema.parse({ title: 'Original', html }),
+ );
+ const updated = 'New document
';
+
+ // When
+ await publishDeck(
+ owner,
+ publishDeckBodySchema.parse({
+ title: 'Updated',
+ update_deck_id: original.deckId,
+ html: updated,
+ }),
+ );
+ const rows = await setup<{ revision_number: number; html: string }[]>`
+ select revision_number, html from deck_revisions where deck_id = ${original.deckId} order by revision_number
+ `;
+
+ // Then
+ expect(rows).toEqual([
+ { revision_number: 1, html },
+ { revision_number: 2, html: updated },
+ ]);
+ });
+
+ it('tracks accepted visit-level time without invented slides when an HTML viewer is active', async () => {
+ // Given
+ const published = await publishDeck(
+ owner,
+ publishDeckBodySchema.parse({ title: 'Track document', html }),
+ );
+ const link = await createShareLink(owner.id, published.deckId, {
+ name: 'Review',
+ access_mode: 'anyone',
+ allowed_emails: [],
+ allowed_domains: [],
+ });
+ const access = await grantViewerAccess({ token: link.token });
+ expect(access.kind).toBe('granted');
+ if (access.kind !== 'granted') return;
+ const viewer = await getViewerDeck(access.sessionToken);
+ const started = await startVisit(access.sessionToken, {
+ visible: true,
+ interacted: true,
+ analyticsConsent: true,
+ deviceClass: 'desktop',
+ browserFamily: 'Chromium',
+ countryCode: null,
+ });
+ expect(started.kind).toBe('started');
+ if (started.kind !== 'started') return;
+ const base = new Date(Date.now() - 10_000);
+ await setup`update visits set started_at = ${base}, last_activity_at = ${base} where id = ${started.visitId}`;
+ const event = {
+ id: randomUUID(),
+ eventType: 'heartbeat' as const,
+ eventAt: new Date(base.getTime() + 5000),
+ sequence: 1,
+ visibleRatio: 1,
+ visibleDurationMs: 5000,
+ tabVisible: true,
+ recentlyActive: true,
+ };
+
+ // When
+ await ingestEngagement(access.sessionToken, started.visitId, [event, event]);
+ const analytics = await getDeckAnalytics(owner.id, published.deckId, {});
+
+ // Then
+ expect(viewer).toMatchObject({ html, slides: [] });
+ expect(analytics.visits[0]).toMatchObject({
+ contentFormat: 'html',
+ totalActiveTimeMs: 5000,
+ viewedSlides: null,
+ completion: null,
+ furthestSlide: null,
+ lastSlide: null,
+ slideSequence: [],
+ });
+ expect(analytics.overview).toMatchObject({
+ totalVisits: 1,
+ averageActiveTimeMs: 5000,
+ averageCompletion: null,
+ topSlide: null,
+ });
+ expect(analytics.slides).toEqual([]);
+ const rows = await setup<
+ { count: number }[]
+ >`select count(*)::integer as count from slide_engagement where visit_id = ${started.visitId}`;
+ expect(rows).toEqual([{ count: 0 }]);
+ });
+});
diff --git a/apps/api/decks/repository-viewer-access.ts b/apps/api/decks/repository-viewer-access.ts
index a55009d..309e72d 100644
--- a/apps/api/decks/repository-viewer-access.ts
+++ b/apps/api/decks/repository-viewer-access.ts
@@ -5,7 +5,7 @@ import {
normalizeEmail,
type IdentityConfidence,
} from './domain.ts';
-import { DeckDataInvariantError, ShareUnavailableError, type UnavailableState } from './errors.ts';
+import { DeckDataInvariantError, ShareUnavailableError } from './errors.ts';
import { createOpaqueToken, hashOpaqueToken } from './tokens.ts';
type AuthenticatedViewer = {
@@ -169,107 +169,11 @@ export async function grantViewerAccess(input: ViewerAccessInput): Promise {
- const database = db.database();
- const rows = await database<
- {
- session_id: string;
- share_link_id: string;
- session_expires_at: Date;
- session_revoked_at: Date | null;
- link_expires_at: Date | null;
- link_revoked_at: Date | null;
- deck_id: string;
- deck_title: string;
- deleted_at: Date | null;
- revision_id: string;
- revision_number: number;
- identity_confidence: IdentityConfidence;
- preview: boolean;
- }[]
- >`
- select vs.id as session_id, sl.id as share_link_id,
- vs.expires_at as session_expires_at, vs.revoked_at as session_revoked_at,
- sl.expires_at as link_expires_at, sl.revoked_at as link_revoked_at,
- d.id as deck_id, d.title as deck_title, d.deleted_at,
- r.id as revision_id, r.revision_number, vs.identity_confidence, vs.preview
- from viewer_sessions vs
- join share_links sl on sl.id = vs.share_link_id
- join decks d on d.id = sl.deck_id
- join deck_revisions r
- on r.deck_id = d.id and r.revision_number = d.latest_revision_number
- where vs.token_hash = ${hashOpaqueToken(sessionToken)}
- `;
- const row = rows[0];
- if (row === undefined) throw new ViewerSessionUnavailableError('not_found');
- if (row.deleted_at !== null) throw new ViewerSessionUnavailableError('deleted');
- if (row.session_revoked_at !== null || row.link_revoked_at !== null) {
- throw new ViewerSessionUnavailableError('revoked');
- }
- if (
- row.session_expires_at.getTime() <= Date.now() ||
- (row.link_expires_at !== null && row.link_expires_at.getTime() <= Date.now())
- ) {
- throw new ViewerSessionUnavailableError('expired');
- }
- const slides = await database<
- {
- id: string;
- stable_slide_id: string;
- ordinal: number;
- title: string | null;
- html: string;
- }[]
- >`
- select id, stable_slide_id, ordinal, title, html
- from deck_slides where revision_id = ${row.revision_id} order by ordinal
- `;
- return {
- deckId: row.deck_id,
- deckTitle: row.deck_title,
- revisionId: row.revision_id,
- revisionNumber: row.revision_number,
- shareLinkId: row.share_link_id,
- viewerSessionId: row.session_id,
- identityConfidence: row.identity_confidence,
- preview: row.preview,
- slides: slides.map((slide) => ({
- id: slide.id,
- stableSlideId: slide.stable_slide_id,
- ordinal: slide.ordinal,
- title: slide.title,
- html: slide.html,
- })),
- };
-}
+export {
+ getViewerDeck,
+ ViewerSessionUnavailableError,
+ type ViewerDeck,
+} from './repository-viewer-deck.ts';
function assertNever(value: never): never {
throw new DeckDataInvariantError(`Unexpected share access mode: ${String(value)}`);
diff --git a/apps/api/decks/repository-viewer-deck.ts b/apps/api/decks/repository-viewer-deck.ts
new file mode 100644
index 0000000..2b5ecf4
--- /dev/null
+++ b/apps/api/decks/repository-viewer-deck.ts
@@ -0,0 +1,109 @@
+import * as db from '../db.ts';
+import type { IdentityConfidence } from './domain.ts';
+import type { UnavailableState } from './errors.ts';
+import { hashOpaqueToken } from './tokens.ts';
+
+export type ViewerDeck = {
+ readonly deckId: string;
+ readonly deckTitle: string;
+ readonly revisionId: string;
+ readonly revisionNumber: number;
+ readonly html: string | null;
+ readonly shareLinkId: string;
+ readonly viewerSessionId: string;
+ readonly identityConfidence: IdentityConfidence;
+ readonly preview: boolean;
+ readonly slides: readonly {
+ readonly id: string;
+ readonly stableSlideId: string;
+ readonly ordinal: number;
+ readonly title: string | null;
+ readonly html: string;
+ }[];
+};
+
+export class ViewerSessionUnavailableError extends Error {
+ readonly name = 'ViewerSessionUnavailableError';
+ readonly state: UnavailableState;
+
+ constructor(state: UnavailableState) {
+ super(`viewer session is ${state}`);
+ this.state = state;
+ }
+}
+
+export async function getViewerDeck(sessionToken: string): Promise {
+ const database = db.database();
+ const rows = await database<
+ {
+ session_id: string;
+ share_link_id: string;
+ session_expires_at: Date;
+ session_revoked_at: Date | null;
+ link_expires_at: Date | null;
+ link_revoked_at: Date | null;
+ deck_id: string;
+ deck_title: string;
+ deleted_at: Date | null;
+ revision_id: string;
+ revision_number: number;
+ html: string | null;
+ identity_confidence: IdentityConfidence;
+ preview: boolean;
+ }[]
+ >`
+ select vs.id as session_id, sl.id as share_link_id,
+ vs.expires_at as session_expires_at, vs.revoked_at as session_revoked_at,
+ sl.expires_at as link_expires_at, sl.revoked_at as link_revoked_at,
+ d.id as deck_id, d.title as deck_title, d.deleted_at,
+ r.id as revision_id, r.revision_number, r.html, vs.identity_confidence, vs.preview
+ from viewer_sessions vs
+ join share_links sl on sl.id = vs.share_link_id
+ join decks d on d.id = sl.deck_id
+ join deck_revisions r
+ on r.deck_id = d.id and r.revision_number = d.latest_revision_number
+ where vs.token_hash = ${hashOpaqueToken(sessionToken)}
+ `;
+ const row = rows[0];
+ if (row === undefined) throw new ViewerSessionUnavailableError('not_found');
+ if (row.deleted_at !== null) throw new ViewerSessionUnavailableError('deleted');
+ if (row.session_revoked_at !== null || row.link_revoked_at !== null) {
+ throw new ViewerSessionUnavailableError('revoked');
+ }
+ if (
+ row.session_expires_at.getTime() <= Date.now() ||
+ (row.link_expires_at !== null && row.link_expires_at.getTime() <= Date.now())
+ ) {
+ throw new ViewerSessionUnavailableError('expired');
+ }
+ const slides = await database<
+ {
+ id: string;
+ stable_slide_id: string;
+ ordinal: number;
+ title: string | null;
+ html: string;
+ }[]
+ >`
+ select id, stable_slide_id, ordinal, title, html
+ from deck_slides where revision_id = ${row.revision_id} order by ordinal
+ `;
+ return {
+ deckId: row.deck_id,
+ deckTitle: row.deck_title,
+ revisionId: row.revision_id,
+ revisionNumber: row.revision_number,
+ html: row.html,
+ shareLinkId: row.share_link_id,
+ viewerSessionId: row.session_id,
+ identityConfidence: row.identity_confidence,
+ preview: row.preview,
+ slides: slides.map((slide) => ({
+ id: slide.id,
+ stableSlideId: slide.stable_slide_id,
+ ordinal: slide.ordinal,
+ title: slide.title,
+ html: slide.html,
+ })),
+ };
+}
diff --git a/apps/api/decks/repository-visit-revision.integration.test.ts b/apps/api/decks/repository-visit-revision.integration.test.ts
new file mode 100644
index 0000000..ffd841b
--- /dev/null
+++ b/apps/api/decks/repository-visit-revision.integration.test.ts
@@ -0,0 +1,116 @@
+import postgres from 'postgres';
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import * as db from '../db.ts';
+import { deckIdSchema } from './domain.ts';
+import { publishDeck } from './repository-decks.ts';
+import { EngagementForbiddenError, startVisit } from './repository-engagement.ts';
+import { createShareLink, grantViewerAccess } from './repository-sharing.ts';
+import { initDeckSchema } from './schema.ts';
+import { integrationDatabaseUrl } from './test-database.ts';
+
+const databaseUrl = integrationDatabaseUrl('pagent_visit_revision_20260922_test');
+const integration = describe.runIf(databaseUrl !== undefined);
+const readiness = {
+ visible: true,
+ interacted: true,
+ analyticsConsent: true,
+ deviceClass: 'desktop' as const,
+ browserFamily: 'Chromium',
+ countryCode: null,
+};
+
+integration('visit revision binding', () => {
+ const setup = postgres(databaseUrl ?? '', { ssl: false, prepare: false });
+ let owner = { id: '', email: 'revision-owner@pagent.test' };
+ let original = { deckId: '', revisionId: '', revisionNumber: 0 };
+ let sessionToken = '';
+
+ beforeAll(async () => {
+ await setup`drop schema public cascade`;
+ await setup`create schema public`;
+ await db.init(databaseUrl ?? '');
+ await initDeckSchema(db.database());
+ owner = await db.upsertUser({
+ email: owner.email,
+ handle: 'revision-owner',
+ name: 'Revision Owner',
+ avatarUrl: null,
+ });
+ });
+
+ beforeEach(async () => {
+ original = await publishDeck(owner, {
+ title: 'Original',
+ slides: [{ id: 'cover', html: 'Legacy ' }],
+ });
+ const link = await createShareLink(owner.id, original.deckId, {
+ name: 'Review update',
+ access_mode: 'anyone',
+ allowed_emails: [],
+ allowed_domains: [],
+ });
+ const access = await grantViewerAccess({ token: link.token });
+ if (access.kind !== 'granted') throw new TypeError('Fixture viewer access must be granted');
+ sessionToken = access.sessionToken;
+ });
+
+ afterAll(async () => {
+ await db.shutdown();
+ await setup.end({ timeout: 5 });
+ });
+
+ it('starts a visit for the latest HTML revision when a legacy client reloads after publication', async () => {
+ // Given
+ const originalVisit = await startVisit(sessionToken, readiness);
+ const updated = await publishDeck(owner, {
+ title: 'HTML update',
+ update_deck_id: deckIdSchema.parse(original.deckId),
+ html: 'New',
+ });
+
+ // When
+ const htmlVisit = await startVisit(sessionToken, readiness);
+
+ // Then
+ expect(htmlVisit.kind).toBe('started');
+ expect(htmlVisit).not.toEqual(originalVisit);
+ if (htmlVisit.kind !== 'started') return;
+ const rows = await setup<
+ { revision_id: string }[]
+ >`select revision_id from visits where id = ${htmlVisit.visitId}`;
+ expect(rows).toEqual([{ revision_id: updated.revisionId }]);
+ });
+
+ it('binds the visit to loaded content when another revision publishes before interaction', async () => {
+ // Given
+ await publishDeck(owner, {
+ title: 'HTML update',
+ update_deck_id: deckIdSchema.parse(original.deckId),
+ html: 'New',
+ });
+ const input = { ...readiness, revisionId: original.revisionId };
+
+ // When
+ const visit = await startVisit(sessionToken, input);
+
+ // Then
+ expect(visit.kind).toBe('started');
+ if (visit.kind !== 'started') return;
+ const rows = await setup<
+ { revision_id: string }[]
+ >`select revision_id from visits where id = ${visit.visitId}`;
+ expect(rows).toEqual([{ revision_id: original.revisionId }]);
+ });
+
+ it('rejects a requested revision when it belongs to a different deck', async () => {
+ // Given
+ const unrelated = await publishDeck(owner, { title: 'Unrelated', html: 'Other' });
+ const input = { ...readiness, revisionId: unrelated.revisionId };
+
+ // When
+ const result = startVisit(sessionToken, input);
+
+ // Then
+ await expect(result).rejects.toBeInstanceOf(EngagementForbiddenError);
+ });
+});
diff --git a/apps/api/decks/routes-document-browser.test.ts b/apps/api/decks/routes-document-browser.test.ts
new file mode 100644
index 0000000..11850ad
--- /dev/null
+++ b/apps/api/decks/routes-document-browser.test.ts
@@ -0,0 +1,81 @@
+import { runInNewContext } from 'node:vm';
+import { describe, expect, it, vi } from 'vitest';
+
+vi.hoisted(() => {
+ process.env.ALLOWED_ORIGINS = 'https://renderer.pagent.test';
+});
+
+import { app } from '../app.ts';
+
+const rendererOrigin = 'https://renderer.pagent.test';
+const privateInput = 'private-viewer-session'.padEnd(32, 'x');
+
+function invalidDocumentRequest(headers: Readonly> = {}) {
+ const body = new FormData();
+ body.set('revision_id', 'invalid');
+ body.set('session_token', privateInput);
+ return app.request('/v1/viewer/document', {
+ method: 'POST',
+ headers: { origin: rendererOrigin, ...headers },
+ body,
+ });
+}
+
+describe('browser document failures', () => {
+ it('serves a generic sandboxed error document when an allowed renderer asks for HTML', async () => {
+ // Given / When
+ const response = await invalidDocumentRequest({
+ accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.8',
+ });
+ // Then
+ const document = await response.text();
+ expect(response.status).toBe(400);
+ expect(response.headers.get('content-type')?.toLowerCase()).toBe('text/html; charset=utf-8');
+ expect(response.headers.get('x-frame-options')).toBeNull();
+ expect(response.headers.get('content-security-policy')).toContain('sandbox allow-scripts;');
+ expect(response.headers.get('content-security-policy')).toContain(
+ `frame-ancestors ${rendererOrigin}`,
+ );
+ expect(response.headers.get('cache-control')).toBe('private, no-store');
+ expect(document).not.toContain(privateInput);
+ const script = document.match(/', revision_id: 'invalid' };
+ // When
+ const response = await requestDocument({ audience: 'viewer', fields });
+ // Then
+ expect(response.status).toBe(400);
+ expect(await response.json()).toEqual({ error: 'bad_request' });
+ });
+
+ it('does not authorize with URL credentials when the form omits its session', async () => {
+ // Given
+ const fixture = await createDocumentFixture();
+ const body = new FormData();
+ body.set('revision_id', fixture.revisionId);
+ // When
+ const response = await app.request(
+ `/v1/viewer/document?session_token=${fixture.sessionToken}`,
+ { method: 'POST', headers: { origin: rendererOrigin }, body },
+ );
+ // Then
+ expect(response.status).toBe(400);
+ expect(response.headers.get('x-frame-options')).toBe('DENY');
+ });
+
+ it('returns not found without document content when a viewer session is unknown', async () => {
+ // Given
+ const fields = { session_token: 'unknown'.padEnd(32, 'x'), revision_id: randomUUID() };
+ // When
+ const response = await requestDocument({ audience: 'viewer', fields });
+ // Then
+ expect(response.status).toBe(404);
+ expect(await response.json()).toEqual({ error: 'unavailable' });
+ });
+
+ it.each([
+ 'session_expired',
+ 'session_revoked',
+ 'link_expired',
+ 'link_revoked',
+ 'deleted',
+ ] as const)('rechecks access when state changes to %s', async (state) => {
+ // Given
+ const fixture = await createDocumentFixture();
+ switch (state) {
+ case 'session_expired':
+ await setup`update viewer_sessions set expires_at = now() - interval '1 second' where token_hash = ${hashOpaqueToken(fixture.sessionToken)}`;
+ break;
+ case 'session_revoked':
+ await setup`update viewer_sessions set revoked_at = now() where token_hash = ${hashOpaqueToken(fixture.sessionToken)}`;
+ break;
+ case 'link_expired':
+ await setup`update share_links set expires_at = now() - interval '1 second' where id = ${fixture.linkId}`;
+ break;
+ case 'link_revoked':
+ await setup`update share_links set revoked_at = now() where id = ${fixture.linkId}`;
+ break;
+ case 'deleted':
+ await setup`update decks set deleted_at = now() where id = ${fixture.deckId}`;
+ break;
+ }
+ // When
+ const response = await requestDocument({
+ audience: 'viewer',
+ fields: { session_token: fixture.sessionToken, revision_id: fixture.revisionId },
+ });
+ // Then
+ expect(response.status).toBe(410);
+ expect(await response.json()).toEqual({ error: 'unavailable' });
+ });
+
+ it('rejects a stale revision when the owner publishes an update', async () => {
+ // Given
+ const fixture = await createDocumentFixture();
+ await publishDeck(
+ fixture.owner,
+ publishDeckBodySchema.parse({
+ title: 'Updated',
+ html: 'New ',
+ update_deck_id: fixture.deckId,
+ }),
+ );
+ // When
+ const response = await requestDocument({
+ audience: 'viewer',
+ fields: { session_token: fixture.sessionToken, revision_id: fixture.revisionId },
+ });
+ // Then
+ expect(response.status).toBe(409);
+ expect(await response.json()).toEqual({ error: 'revision_changed' });
+ });
+
+ it('returns unsupported format when the revision contains legacy slides', async () => {
+ // Given
+ const fixture = await createDocumentFixture();
+ const legacy = await publishDeck(fixture.owner, {
+ title: 'Legacy',
+ slides: [{ id: 'one', html: 'Legacy ' }],
+ });
+ // When
+ const response = await requestDocument({
+ audience: 'owner',
+ fields: { deck_id: legacy.deckId, revision_id: legacy.revisionId },
+ headers: { cookie: fixture.cookie },
+ });
+ // Then
+ expect(response.status).toBe(415);
+ expect(await response.json()).toEqual({ error: 'unsupported_format' });
+ });
+
+ it('serves an owner preview when its cookie is authenticated', async () => {
+ // Given
+ const fixture = await createDocumentFixture();
+ // When
+ const response = await requestDocument({
+ audience: 'owner',
+ fields: { deck_id: fixture.deckId, revision_id: fixture.revisionId },
+ headers: { cookie: fixture.cookie },
+ });
+ // Then
+ expect(response.status).toBe(200);
+ expect((await response.text()).startsWith(documentHtml)).toBe(true);
+ });
+
+ it.each(['page:read', 'page:create'])(
+ 'uses read authorization for an owner POST when bearer scope is %s',
+ async (scope) => {
+ // Given
+ const fixture = await createDocumentFixture();
+ const token = await signAccessToken({
+ sub: fixture.owner.id,
+ email: fixture.owner.email,
+ handle: 'frame-owner',
+ clientId: 'frame-test',
+ scope,
+ });
+ // When
+ const response = await requestDocument({
+ audience: 'owner',
+ fields: { deck_id: fixture.deckId, revision_id: fixture.revisionId },
+ headers: { authorization: `Bearer ${token}` },
+ });
+ // Then
+ expect(response.status).toBe(scope === 'page:read' ? 200 : 403);
+ },
+ );
+
+ it('requires authentication when an owner request has no credentials', async () => {
+ // Given
+ const fields = { deck_id: randomUUID(), revision_id: randomUUID() };
+ // When
+ const response = await requestDocument({ audience: 'owner', fields });
+ // Then
+ expect(response.status).toBe(401);
+ expect(response.headers.get('x-frame-options')).toBe('DENY');
+ });
+
+ it.each([false, true])(
+ 'enforces content collaboration when a different user has permission %s',
+ async (canView) => {
+ // Given
+ const fixture = await createDocumentFixture();
+ const colleague = await db.upsertUser({
+ email: 'frame-colleague@pagent.test',
+ handle: 'frame-colleague',
+ name: 'Colleague',
+ avatarUrl: null,
+ });
+ if (canView) {
+ await addWorkspaceMember(fixture.owner.id, fixture.deckId, {
+ email: colleague.email,
+ role: 'member',
+ });
+ await setDeckCollaborators(fixture.owner.id, fixture.deckId, [colleague.id]);
+ }
+ const cookie = `${SESSION_COOKIE_NAME}=${await createSession(colleague.id)}`;
+ // When
+ const response = await requestDocument({
+ audience: 'owner',
+ fields: { deck_id: fixture.deckId, revision_id: fixture.revisionId },
+ headers: { cookie },
+ });
+ // Then
+ expect(response.status).toBe(canView ? 200 : 403);
+ },
+ );
+
+ it('keeps framing denied when requesting an unrelated API route', async () => {
+ // Given / When
+ const response = await app.request('/health');
+ // Then
+ expect(response.headers.get('x-frame-options')).toBe('DENY');
+ expect(response.headers.get('content-security-policy')).toBeNull();
+ });
+
+ it('caps document requests when one ingress IP exceeds sixty requests', async () => {
+ // Given
+ const input = {
+ audience: 'viewer' as const,
+ fields: { session_token: 'unknown'.padEnd(32, 'x'), revision_id: randomUUID() },
+ headers: { 'x-real-ip': '203.0.113.222' },
+ };
+ for (let index = 0; index < 60; index += 1) await requestDocument(input);
+ // When
+ const response = await requestDocument(input);
+ // Then
+ expect(response.status).toBe(429);
+ expect(response.headers.get('Retry-After')).toBe('60');
+ });
+});
diff --git a/apps/api/decks/routes-document.ts b/apps/api/decks/routes-document.ts
new file mode 100644
index 0000000..d9c8558
--- /dev/null
+++ b/apps/api/decks/routes-document.ts
@@ -0,0 +1,126 @@
+import { Hono, type Context, type MiddlewareHandler } from 'hono';
+import { rateLimiter } from 'hono-rate-limiter';
+import { z } from 'zod';
+import { ALLOWED_ORIGINS } from '../app/config.ts';
+import { requireAuth, requireScope, type AuthVariables } from '../auth/middleware.ts';
+import { clientKey } from '../client-key.ts';
+import { deckIdSchema } from './domain.ts';
+import {
+ documentCsp,
+ documentOrigins,
+ renderDocumentError,
+ renderHtmlDocument,
+ type RendererOrigin,
+} from './html-document.ts';
+import { DeckForbiddenError, getDeckPreview } from './repository-decks.ts';
+import { getViewerDeck, ViewerSessionUnavailableError } from './repository-viewer-access.ts';
+
+type DocumentVariables = AuthVariables & { readonly documentOrigin: RendererOrigin };
+type DocumentContext = Context<{ Variables: DocumentVariables }>;
+type DocumentSource = { readonly revisionId: string; readonly html: string | null };
+const origins = documentOrigins(ALLOWED_ORIGINS);
+const ownerFormSchema = z
+ .object({ deck_id: deckIdSchema, revision_id: z.string().uuid() })
+ .strict();
+const viewerFormSchema = z
+ .object({ session_token: z.string().min(20).max(200), revision_id: z.string().uuid() })
+ .strict();
+const frameLimiter = rateLimiter({
+ windowMs: 60_000,
+ limit: 60,
+ standardHeaders: 'draft-7',
+ keyGenerator: (c: Context) => clientKey(c.req.header('x-real-ip')),
+ handler: (c) => {
+ c.header('Retry-After', '60');
+ return c.json({ error: 'rate_limited', retry_after_seconds: 60 }, 429);
+ },
+});
+
+const requireRendererOrigin: MiddlewareHandler<{ Variables: DocumentVariables }> = async (
+ c,
+ next,
+) => {
+ c.header('Cache-Control', 'private, no-store');
+ const origin = origins.find((allowed) => allowed === c.req.header('origin'));
+ if (origin === undefined) return c.json({ error: 'forbidden' }, 403);
+ c.set('documentOrigin', origin);
+ await next();
+ const acceptsHtml = c.req
+ .header('accept')
+ ?.split(',')
+ .some((value) => value.split(';')[0]?.trim().toLowerCase() === 'text/html');
+ if (c.res.status < 400 || !acceptsHtml) return;
+ permitDocumentFrame(c);
+ c.header('Content-Type', 'text/html; charset=utf-8');
+ c.header('Content-Length', undefined);
+ c.res = new Response(renderDocumentError(origin), {
+ status: c.res.status,
+ headers: c.res.headers,
+ });
+};
+
+export const documentRoutes = new Hono<{ Variables: DocumentVariables }>();
+
+documentRoutes.post('/viewer/document', requireRendererOrigin, frameLimiter, async (c) => {
+ const parsed = viewerFormSchema.safeParse(await documentForm(c));
+ if (!parsed.success) return c.json({ error: 'bad_request' }, 400);
+ try {
+ return serveDocument(
+ c,
+ await getViewerDeck(parsed.data.session_token),
+ parsed.data.revision_id,
+ );
+ } catch (error) {
+ if (error instanceof ViewerSessionUnavailableError) {
+ return c.json({ error: 'unavailable' }, error.state === 'not_found' ? 404 : 410);
+ }
+ throw error;
+ }
+});
+
+documentRoutes.post(
+ '/owner/document',
+ requireRendererOrigin,
+ frameLimiter,
+ requireAuth(),
+ requireScope('page:read'),
+ async (c) => {
+ const parsed = ownerFormSchema.safeParse(await documentForm(c));
+ if (!parsed.success) return c.json({ error: 'bad_request' }, 400);
+ const user = c.var.user;
+ if (user === null) return c.json({ error: 'unauthorized' }, 401);
+ try {
+ return serveDocument(
+ c,
+ await getDeckPreview(user.id, parsed.data.deck_id),
+ parsed.data.revision_id,
+ );
+ } catch (error) {
+ if (error instanceof DeckForbiddenError) return c.json({ error: 'forbidden' }, 403);
+ throw error;
+ }
+ },
+);
+
+async function documentForm(c: DocumentContext) {
+ try {
+ return await c.req.parseBody({ all: true });
+ } catch (error) {
+ if (error instanceof TypeError) return null;
+ throw error;
+ }
+}
+
+function serveDocument(c: DocumentContext, document: DocumentSource, expectedRevision: string) {
+ if (document.revisionId !== expectedRevision) return c.json({ error: 'revision_changed' }, 409);
+ if (document.html === null) return c.json({ error: 'unsupported_format' }, 415);
+ permitDocumentFrame(c);
+ return c.html(renderHtmlDocument(document.html, c.var.documentOrigin, document.revisionId));
+}
+
+function permitDocumentFrame(c: DocumentContext): void {
+ c.header('Content-Security-Policy', documentCsp(origins));
+ c.header('Referrer-Policy', 'no-referrer');
+ c.header('X-Content-Type-Options', 'nosniff');
+ c.header('X-Frame-Options', undefined);
+}
diff --git a/apps/api/decks/routes.ts b/apps/api/decks/routes.ts
index ab0e41f..bda0641 100644
--- a/apps/api/decks/routes.ts
+++ b/apps/api/decks/routes.ts
@@ -5,6 +5,7 @@ import { ownerSharingRoutes } from './routes-sharing-owner.ts';
import { permissionRoutes } from './routes-permissions.ts';
import { viewerRoutes } from './routes-viewer.ts';
import { workspaceAdminRoutes } from './routes-workspace-admin.ts';
+import { documentRoutes } from './routes-document.ts';
export const deckRoutes = new Hono<{ Variables: AuthVariables }>();
@@ -13,3 +14,4 @@ deckRoutes.route('/', ownerSharingRoutes);
deckRoutes.route('/', permissionRoutes);
deckRoutes.route('/', viewerRoutes);
deckRoutes.route('/', workspaceAdminRoutes);
+deckRoutes.route('/', documentRoutes);
diff --git a/apps/api/decks/schema.integration.test.ts b/apps/api/decks/schema.integration.test.ts
index b130a6f..332d4e0 100644
--- a/apps/api/decks/schema.integration.test.ts
+++ b/apps/api/decks/schema.integration.test.ts
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { initialDeckSchemaMigration } from './migrations/0001-initial-deck-schema.ts';
import { viewerSessionAnalyticsExclusionMigration } from './migrations/0002-add-viewer-session-analytics-exclusion.ts';
import { engagementEventQualificationMigration } from './migrations/0003-add-engagement-event-qualification.ts';
+import { htmlDeckRevisionsMigration } from './migrations/0004-add-html-deck-revisions.ts';
import {
DeckMigrationHistoryError,
runDeckMigrations,
@@ -16,6 +17,7 @@ const integration = describe.runIf(databaseUrl !== undefined);
integration('deck database schema', () => {
const sql = postgres(databaseUrl ?? '', { ssl: false, prepare: false });
+ const legacyRevisionId = '33333333-3333-4333-8333-333333333333';
beforeAll(async () => {
// Given
@@ -29,6 +31,28 @@ integration('deck database schema', () => {
)
`;
+ await runDeckMigrations(sql, [
+ initialDeckSchemaMigration,
+ viewerSessionAnalyticsExclusionMigration,
+ engagementEventQualificationMigration,
+ ]);
+ await sql`
+ with owner as (
+ insert into users (email) values ('html-migration-legacy@pagent.test') returning id
+ ), workspace as (
+ insert into workspaces (name) values ('HTML migration legacy') returning id
+ ), deck as (
+ insert into decks (workspace_id, owner_id, title)
+ select workspace.id, owner.id, 'Legacy content' from workspace, owner returning id, owner_id
+ )
+ insert into deck_revisions (id, deck_id, revision_number, created_by)
+ select ${legacyRevisionId}, deck.id, 1, deck.owner_id from deck
+ `;
+ await sql`
+ insert into deck_slides (revision_id, stable_slide_id, ordinal, html)
+ values (${legacyRevisionId}, 'cover', 1, 'Legacy content ')
+ `;
+
// When
await initDeckSchema(sql);
});
@@ -76,6 +100,7 @@ integration('deck database schema', () => {
{ version: 1, name: 'initial_deck_schema' },
{ version: 2, name: 'add_viewer_session_analytics_exclusion' },
{ version: 3, name: 'add_engagement_event_qualification' },
+ { version: 4, name: 'add_html_deck_revisions' },
];
const rows = await sql<{ version: number; name: string }[]>`
@@ -85,6 +110,17 @@ integration('deck database schema', () => {
expect(rows).toEqual(expectedMigrations);
});
+ it('preserves slide-only revisions when upgrading to nullable HTML storage', async () => {
+ // Given / When
+ const rows = await sql<{ html: string | null; slide_html: string }[]>`
+ select r.html, s.html as slide_html from deck_revisions r
+ join deck_slides s on s.revision_id = r.id where r.id = ${legacyRevisionId}
+ `;
+
+ // Then
+ expect(rows).toEqual([{ html: null, slide_html: 'Legacy content ' }]);
+ });
+
it('preserves existing deck data when baselining a pre-ledger deployment', async () => {
const owner = await sql<{ id: string }[]>`
insert into users (email) values ('legacy-owner@pagent.test') returning id
@@ -120,13 +156,13 @@ integration('deck database schema', () => {
const rows = await sql<{ count: number }[]>`
select count(*)::integer as count from deck_schema_migrations
`;
- expect(rows).toEqual([{ count: 3 }]);
+ expect(rows).toEqual([{ count: 4 }]);
});
it('rolls back schema and ledger changes when a migration fails', async () => {
class PlannedMigrationError extends Error {}
const failingMigration = {
- version: 4,
+ version: 5,
name: 'planned_failure',
async up(database) {
await database`create table migration_should_roll_back (id integer primary key)`;
@@ -138,6 +174,7 @@ integration('deck database schema', () => {
initialDeckSchemaMigration,
viewerSessionAnalyticsExclusionMigration,
engagementEventQualificationMigration,
+ htmlDeckRevisionsMigration,
failingMigration,
]);
@@ -149,7 +186,7 @@ integration('deck database schema', () => {
const ledger = await sql<{ version: number }[]>`
select version from deck_schema_migrations order by version
`;
- expect(ledger).toEqual([{ version: 1 }, { version: 2 }, { version: 3 }]);
+ expect(ledger).toEqual([{ version: 1 }, { version: 2 }, { version: 3 }, { version: 4 }]);
});
it('rejects divergent applied migration history', async () => {
diff --git a/apps/api/decks/schema.ts b/apps/api/decks/schema.ts
index 611dc70..cfc239e 100644
--- a/apps/api/decks/schema.ts
+++ b/apps/api/decks/schema.ts
@@ -2,6 +2,7 @@ import type postgres from 'postgres';
import { initialDeckSchemaMigration } from './migrations/0001-initial-deck-schema.ts';
import { viewerSessionAnalyticsExclusionMigration } from './migrations/0002-add-viewer-session-analytics-exclusion.ts';
import { engagementEventQualificationMigration } from './migrations/0003-add-engagement-event-qualification.ts';
+import { htmlDeckRevisionsMigration } from './migrations/0004-add-html-deck-revisions.ts';
import { runDeckMigrations, type DeckMigration } from './schema-migrations.ts';
export type Database = ReturnType | postgres.TransactionSql;
@@ -11,6 +12,7 @@ const deckMigrations: readonly DeckMigration[] = [
initialDeckSchemaMigration,
viewerSessionAnalyticsExclusionMigration,
engagementEventQualificationMigration,
+ htmlDeckRevisionsMigration,
];
export async function initDeckSchema(database: DatabasePool): Promise {
diff --git a/apps/api/mcp/http-sdk.test.ts b/apps/api/mcp/http-sdk.test.ts
index e5fbaac..5d34271 100644
--- a/apps/api/mcp/http-sdk.test.ts
+++ b/apps/api/mcp/http-sdk.test.ts
@@ -58,13 +58,15 @@ beforeEach(() => {
revisionNumber: 1,
});
vi.mocked(getDeckAnalytics).mockResolvedValue({
+ contentFormat: 'html',
+ revisionNumbers: [1],
owner: { id: '00000000-0000-4000-8000-000000000010', email: 'owner@example.com' },
overview: {
totalVisits: 3,
uniqueViewers: 2,
lastViewed: null,
averageActiveTimeMs: 4_000,
- averageCompletion: 0.5,
+ averageCompletion: null,
topSlide: null,
},
visitors: [],
@@ -227,7 +229,7 @@ describe('SDK client', () => {
arguments: {
type: 'presentation',
title: 'Northstar',
- slides: [{ id: 'cover', html: 'Northstar ' }],
+ html: 'Northstar Northstar ',
},
});
expect(written.structuredContent).toMatchObject({
diff --git a/apps/api/mcp/tools.test.ts b/apps/api/mcp/tools.test.ts
index a34daf4..10a489d 100644
--- a/apps/api/mcp/tools.test.ts
+++ b/apps/api/mcp/tools.test.ts
@@ -99,9 +99,13 @@ describe('registerPagentTools', () => {
it('writes a durable presentation page and maps page_id to the existing revision model', async () => {
let updateDeckId: string | undefined;
+ let submittedHtml: unknown;
+ const html =
+ 'Northstar Go ';
const tools = makeTools({
writePresentation: async (input, publisher) => {
updateDeckId = input.update_deck_id;
+ submittedHtml = 'html' in input ? input.html : undefined;
expect(publisher).toEqual({
id: '00000000-0000-4000-8000-000000000010',
email: 'owner@example.com',
@@ -115,12 +119,13 @@ describe('registerPagentTools', () => {
type: 'presentation',
page_id: '00000000-0000-4000-8000-000000000001',
title: 'Northstar',
- slides: [{ id: 'cover', html: 'Northstar ' }],
+ html,
},
auth,
);
expect(updateDeckId).toBe('00000000-0000-4000-8000-000000000001');
+ expect(submittedHtml).toBe(html);
expect(structuredContent(result)).toMatchObject({ type: 'presentation', durable: true });
});
diff --git a/apps/api/mcp/tools.ts b/apps/api/mcp/tools.ts
index 0725c20..3e60b48 100644
--- a/apps/api/mcp/tools.ts
+++ b/apps/api/mcp/tools.ts
@@ -2,7 +2,7 @@ import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { AnySchema, ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js';
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
-import { deckIdSchema, publishDeckBodySchema, type PublishDeckBody } from '../decks/domain.ts';
+import { deckIdSchema, htmlPublishDeckBodySchema, type PublishDeckBody } from '../decks/domain.ts';
import { HTML_MAX_BYTES } from '../limits.ts';
export type PageState = 'open' | 'submitted' | 'received';
@@ -95,7 +95,7 @@ const documentWriteSchema = z
})
.strict();
-const presentationWriteSchema = publishDeckBodySchema
+const presentationWriteSchema = htmlPublishDeckBodySchema
.omit({ update_deck_id: true })
.extend({
type: z.literal('presentation'),
@@ -118,7 +118,7 @@ const readInputSchema = z
.strict();
const WRITE_DESCRIPTION =
- 'Write one Pagent page. Interactive and document pages are temporary. Presentation pages are durable, revisioned, shareable, and analyzable. Return the page URL to the user.';
+ 'Write one Pagent page. Interactive and document pages are temporary. A durable presentation is one complete HTML document, with its own layout and inline JavaScript in an isolated sandbox. No slide schema is required. Presentation pages are revisioned, shareable, and analyzable. Use self-contained assets; network access is blocked. Return the page URL to the user.';
const READ_DESCRIPTION =
'Read a page response or durable presentation analytics. The page id selects the sensible default; use include only to be explicit. This call returns immediately and never waits.';
@@ -160,7 +160,7 @@ export function registerPagentTools(server: PagentToolRegistrar, ops: PageOps):
title: input.title,
description: input.description,
client_label: input.client_label,
- slides: input.slides,
+ html: input.html,
};
const publishInput: PublishDeckBody =
input.page_id === undefined ? base : { ...base, update_deck_id: input.page_id };
diff --git a/apps/mcp/server.bundle.js b/apps/mcp/server.bundle.js
index 0c7833c..1bde89b 100755
--- a/apps/mcp/server.bundle.js
+++ b/apps/mcp/server.bundle.js
@@ -21415,17 +21415,27 @@ var StdioServerTransport = class {
// apps/mcp/server.ts
import { pathToFileURL } from "node:url";
+// apps/api/limits.ts
+var HTML_MAX_BYTES = 1e6;
+
// apps/api/decks/domain.ts
var stableSlideIdSchema = external_exports.string().min(1).max(120).regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/);
var deckIdSchema = external_exports.string().uuid().brand("DeckId");
var shareLinkIdSchema = external_exports.string().uuid().brand("ShareLinkId");
var identityConfidenceSchema = external_exports.enum(["anonymous", "unverified", "authenticated"]);
var analyticsVisibilitySchema = external_exports.enum(["private", "selected", "team", "workspace"]);
-var publishDeckBodySchema = external_exports.object({
+var deckMetadataSchema = external_exports.object({
title: external_exports.string().trim().min(1).max(160),
description: external_exports.string().trim().max(2e3).optional(),
client_label: external_exports.string().trim().max(160).optional(),
- update_deck_id: deckIdSchema.optional(),
+ update_deck_id: deckIdSchema.optional()
+});
+var htmlPublishDeckBodySchema = deckMetadataSchema.extend({
+ html: external_exports.string().min(1).refine((html) => Buffer.byteLength(html, "utf8") <= HTML_MAX_BYTES, {
+ message: `HTML must not exceed ${HTML_MAX_BYTES} UTF-8 bytes`
+ })
+}).strict();
+var slidesPublishDeckBodySchema = deckMetadataSchema.extend({
slides: external_exports.array(
external_exports.object({
id: stableSlideIdSchema,
@@ -21436,6 +21446,10 @@ var publishDeckBodySchema = external_exports.object({
message: "slide ids must be unique within a revision"
})
}).strict();
+var publishDeckBodySchema = external_exports.union([
+ htmlPublishDeckBodySchema,
+ slidesPublishDeckBodySchema
+]);
var shareLinkBaseSchema = external_exports.object({
name: external_exports.string().trim().min(1).max(160),
expires_at: external_exports.string().datetime().optional()
@@ -21484,9 +21498,6 @@ function mapAnalyticsFilters(value) {
var analyticsQuerySchema = analyticsFilterInputSchema.transform(mapAnalyticsFilters);
var analyticsUrlQuerySchema = analyticsFilterInputSchema.omit({ viewer: true }).transform(mapAnalyticsFilters);
-// apps/api/limits.ts
-var HTML_MAX_BYTES = 1e6;
-
// apps/api/mcp/tools.ts
var InsufficientScopeError = class extends Error {
requiredScope;
@@ -21518,7 +21529,7 @@ var documentWriteSchema = external_exports.object({
type: external_exports.literal("document"),
html: external_exports.string().min(1).max(HTML_MAX_BYTES)
}).strict();
-var presentationWriteSchema = publishDeckBodySchema.omit({ update_deck_id: true }).extend({
+var presentationWriteSchema = htmlPublishDeckBodySchema.omit({ update_deck_id: true }).extend({
type: external_exports.literal("presentation"),
page_id: deckIdSchema.optional()
}).strict();
@@ -21532,7 +21543,7 @@ var readInputSchema = external_exports.object({
page_id: external_exports.union([ephemeralPageIdSchema, deckIdSchema]),
include: external_exports.enum(["response", "analytics"]).optional()
}).strict();
-var WRITE_DESCRIPTION = "Write one Pagent page. Interactive and document pages are temporary. Presentation pages are durable, revisioned, shareable, and analyzable. Return the page URL to the user.";
+var WRITE_DESCRIPTION = "Write one Pagent page. Interactive and document pages are temporary. A durable presentation is one complete HTML document, with its own layout and inline JavaScript in an isolated sandbox. No slide schema is required. Presentation pages are revisioned, shareable, and analyzable. Use self-contained assets; network access is blocked. Return the page URL to the user.";
var READ_DESCRIPTION = "Read a page response or durable presentation analytics. The page id selects the sensible default; use include only to be explicit. This call returns immediately and never waits.";
function registerPagentTools(server2, ops) {
server2.registerTool(
@@ -21553,7 +21564,7 @@ function registerPagentTools(server2, ops) {
title: input.title,
description: input.description,
client_label: input.client_label,
- slides: input.slides
+ html: input.html
};
const publishInput = input.page_id === void 0 ? base : { ...base, update_deck_id: input.page_id };
const written = await ops.writePresentation(publishInput, publisher);
diff --git a/apps/web/analytics-format.ts b/apps/web/analytics-format.ts
index 09c6812..cc89a84 100644
--- a/apps/web/analytics-format.ts
+++ b/apps/web/analytics-format.ts
@@ -2,6 +2,7 @@ import { html, nothing, type TemplateResult } from 'lit';
import type { DeckAnalytics } from './deck-types.ts';
export function visitSequence(visit: DeckAnalytics['visits'][number]): TemplateResult {
+ if (visit.contentFormat === 'html') return html`HTML document · revision ${visit.revisionNumber}`;
if (visit.sequenceComplete === false) return html`Not recorded (older visit)`;
return html`
{
+ it('reports HTML page attention without slide navigation or completion metrics', () => {
+ const container = overview({
+ ...visit,
+ contentFormat: 'html',
+ completion: null,
+ viewedSlides: null,
+ furthestSlide: null,
+ lastSlide: null,
+ slideSequence: [],
+ });
+ expect(container.textContent).not.toContain('Average completion');
+ expect(container.querySelector('[data-label="Completion"]')).toBeNull();
+ expect(container.querySelector('[data-label="Document"]')?.textContent).toContain(
+ 'HTML document · revision 1',
+ );
+ expect(container.querySelector('details')).toBeNull();
+ expect(container.querySelector('#analytics-revision')?.textContent).toContain('Revision 1');
+ expect(container.querySelector('[data-label="Active"]')?.textContent).toBe('10s');
+ });
it('shows each ordered appearance and its own duration without replacing distinct completion', () => {
const container = overview(visit);
expect(container.querySelector('summary')?.textContent?.trim()).toBe('Time by slide');
@@ -83,6 +104,7 @@ describe('per-visit slide time', () => {
),
).toEqual(['Slide 1 · Opening: 3s', 'Slide 2: 4s', 'Slide 1 · Opening: 3s']);
expect(container.querySelector('[data-label="Completion"]')?.textContent).toBe('67%');
+ expect(container.textContent).not.toContain('HTML pages report');
});
it('does not invent a breakdown when an older visit has incomplete history', () => {
diff --git a/apps/web/csp.test.ts b/apps/web/csp.test.ts
index 5c180c1..02799b3 100644
--- a/apps/web/csp.test.ts
+++ b/apps/web/csp.test.ts
@@ -13,6 +13,19 @@ const ALL_DIRECTIVES = [
] as const;
describe('buildCsp', () => {
+ it('allows protected document navigation without allowing inline scripts in the app', () => {
+ const csp = buildCsp('https://api.pagent.link');
+ const directives = new Map(
+ csp.split('; ').map((directive) => {
+ const [name, ...values] = directive.split(' ');
+ return [name, values.join(' ')];
+ }),
+ );
+ expect(directives.get('script-src')).toBe("'self'");
+ expect(directives.get('frame-src')).toContain('https://api.pagent.link/v1/viewer/document');
+ expect(directives.get('form-action')).toContain('https://api.pagent.link/v1/owner/document');
+ });
+
it('includes only fixed application origins when VITE_API_URL is unset', () => {
const csp = buildCsp(undefined);
expect(csp).toContain("connect-src 'self'");
diff --git a/apps/web/csp.ts b/apps/web/csp.ts
index 137b2f4..5673814 100644
--- a/apps/web/csp.ts
+++ b/apps/web/csp.ts
@@ -10,9 +10,12 @@
export function buildCsp(apiUrl: string | undefined): string {
const googleFontsStylesheetOrigin = 'https://fonts.googleapis.com';
let connectSrc = `'self' ${googleFontsStylesheetOrigin}`;
+ let documentSources = "'self'";
if (apiUrl) {
try {
connectSrc = `'self' ${new URL(apiUrl).origin} ${googleFontsStylesheetOrigin}`;
+ const origin = new URL(apiUrl).origin;
+ documentSources += ` ${origin}/v1/viewer/document ${origin}/v1/owner/document`;
} catch {
connectSrc = `'self' ${googleFontsStylesheetOrigin}`;
}
@@ -25,7 +28,8 @@ export function buildCsp(apiUrl: string | undefined): string {
"img-src 'self' data: blob:",
`connect-src ${connectSrc}`,
"object-src 'none'",
- "form-action 'self'",
+ `form-action ${documentSources}`,
+ `frame-src ${documentSources}`,
].join('; ');
}
diff --git a/apps/web/deck-analytics-filters-view.ts b/apps/web/deck-analytics-filters-view.ts
new file mode 100644
index 0000000..7f8710a
--- /dev/null
+++ b/apps/web/deck-analytics-filters-view.ts
@@ -0,0 +1,73 @@
+import { html, type TemplateResult } from 'lit';
+import type { DeckAnalytics } from './deck-types.ts';
+
+export type AnalyticsFiltersInput = {
+ readonly base: DeckAnalytics | null;
+ readonly filtering: boolean;
+ readonly onApply: (event: Event) => void;
+ readonly onClear: () => void;
+ readonly onExport: () => void;
+};
+
+export function renderFilters(input: AnalyticsFiltersInput): TemplateResult {
+ const links = uniqueOptions(
+ (input.base?.visits ?? []).map((visit) => [visit.linkId, visit.linkName]),
+ );
+ const senders = uniqueOptions(
+ (input.base?.visits ?? []).map((visit) => [visit.senderId, visit.senderEmail]),
+ );
+ const revisions = [
+ ...new Set(
+ input.base?.revisionNumbers ??
+ (input.base?.slides ?? []).map((slide) => slide.revisionNumber),
+ ),
+ ].sort((a, b) => b - a);
+ return html``;
+}
+
+function uniqueOptions(
+ values: readonly (readonly [string, string])[],
+): readonly (readonly [string, string])[] {
+ return [...new Map(values).entries()];
+}
diff --git a/apps/web/deck-analytics-view.ts b/apps/web/deck-analytics-view.ts
index 6f13447..03d17b7 100644
--- a/apps/web/deck-analytics-view.ts
+++ b/apps/web/deck-analytics-view.ts
@@ -9,18 +9,14 @@ import {
visitSequence,
} from './analytics-format.ts';
import type { DeckAnalytics } from './deck-types.ts';
+import { renderFilters, type AnalyticsFiltersInput } from './deck-analytics-filters-view.ts';
export type AnalyticsView = 'overview' | 'visitors' | 'slides';
-type AnalyticsViewInput = {
- readonly base: DeckAnalytics | null;
+type AnalyticsViewInput = AnalyticsFiltersInput & {
readonly current: DeckAnalytics | null;
readonly view: AnalyticsView;
- readonly filtering: boolean;
readonly error: string | null;
- readonly onApply: (event: Event) => void;
- readonly onClear: () => void;
- readonly onExport: () => void;
};
export function renderAnalyticsPanel(input: AnalyticsViewInput): TemplateResult {
@@ -33,60 +29,6 @@ export function renderAnalyticsPanel(input: AnalyticsViewInput): TemplateResult
`;
}
-function renderFilters(input: AnalyticsViewInput): TemplateResult {
- const links = uniqueOptions(
- (input.base?.visits ?? []).map((visit) => [visit.linkId, visit.linkName]),
- );
- const senders = uniqueOptions(
- (input.base?.visits ?? []).map((visit) => [visit.senderId, visit.senderEmail]),
- );
- const revisions = [
- ...new Set((input.base?.slides ?? []).map((slide) => slide.revisionNumber)),
- ].sort((a, b) => b - a);
- return html``;
-}
-
function renderCurrentView(analytics: DeckAnalytics | null, view: AnalyticsView): TemplateResult {
if (view === 'visitors') return renderVisitors(analytics);
if (view === 'slides') return renderSlides(analytics);
@@ -101,10 +43,15 @@ function renderOverview(analytics: DeckAnalytics | null): TemplateResult {
${metric('Visits', overview.totalVisits)}${metric(
'Unique viewers',
overview.uniqueViewers,
- )}${metric('Average active time', duration(overview.averageActiveTimeMs))}${metric(
- 'Average completion',
- percent(overview.averageCompletion),
- )}${metric('Last viewed', dateTime(overview.lastViewed))}
+ )}${metric(
+ 'Average active time',
+ duration(overview.averageActiveTimeMs),
+ )}${overview.averageCompletion === null
+ ? nothing
+ : metric('Average completion', percent(overview.averageCompletion))}${metric(
+ 'Last viewed',
+ dateTime(overview.lastViewed),
+ )}
${overview.totalVisits === 0
? html`
@@ -114,14 +61,19 @@ function renderOverview(analytics: DeckAnalytics | null): TemplateResult {
previews stay excluded.
`
- : html`
- Top slide
- ${overview.topSlide?.title ?? `Slide ${overview.topSlide?.ordinal ?? '—'}`}
-
- Highest total active time in the selected scope. Engagement signals describe attention,
- not purchase intent.
-
- `}
+ : analytics.contentFormat === 'html'
+ ? html`
+ HTML pages report visits and active time. Their layout and navigation belong to the
+ author; slide completion is not inferred.
+
`
+ : html`
+ Top slide
+ ${overview.topSlide?.title ?? `Slide ${overview.topSlide?.ordinal ?? '—'}`}
+
+ Highest total active time in the selected scope. Engagement signals describe
+ attention, not purchase intent.
+
+ `}
${analytics.visits.length
? html`
Recent visits
@@ -132,6 +84,7 @@ function renderOverview(analytics: DeckAnalytics | null): TemplateResult {
}
function renderVisitors(analytics: DeckAnalytics | null): TemplateResult {
+ const showCompletion = analytics?.contentFormat !== 'html';
const visitors = analytics?.visitors ?? [];
if (visitors.length === 0)
return emptyAnalytics(
@@ -148,7 +101,7 @@ function renderVisitors(analytics: DeckAnalytics | null): TemplateResult {
Last visit
Visits
Active time
- Max completion
+ ${showCompletion ? html`Max completion ` : nothing}
@@ -165,7 +118,9 @@ function renderVisitors(analytics: DeckAnalytics | null): TemplateResult {
${dateTime(visitor.lastVisit)}
${visitor.visits}
${duration(visitor.totalActiveTimeMs)}
- ${percent(visitor.maximumCompletion)}
+ ${showCompletion
+ ? html`${percent(visitor.maximumCompletion)} `
+ : nothing}
`,
)}
@@ -214,17 +169,20 @@ function renderSlides(analytics: DeckAnalytics | null): TemplateResult {
}
function renderVisitTable(analytics: DeckAnalytics): TemplateResult {
+ const showSlides = analytics.contentFormat !== 'html';
return html`
Viewer
Sender
- Sequence
+ ${showSlides ? 'Sequence' : 'Document'}
Active
- Completion
- Furthest
- Last
+ ${showSlides
+ ? html`Completion
+ Furthest
+ Last `
+ : nothing}
@@ -239,20 +197,16 @@ function renderVisitTable(analytics: DeckAnalytics): TemplateResult {
${visit.senderEmail}${visit.linkName}
- ${visitSequence(visit)}
+ ${visitSequence(visit)}
${duration(visit.totalActiveTimeMs)}
- ${percent(visit.completion)}
- ${visit.furthestSlide ?? '—'}
- ${visit.lastSlide ?? '—'}
+ ${showSlides
+ ? html`${percent(visit.completion)}
+ ${visit.furthestSlide ?? '—'}
+ ${visit.lastSlide ?? '—'} `
+ : nothing}
`,
)}
`;
}
-
-function uniqueOptions(
- values: readonly (readonly [string, string])[],
-): readonly (readonly [string, string])[] {
- return [...new Map(values).entries()];
-}
diff --git a/apps/web/deck-detail-page-state.test.ts b/apps/web/deck-detail-page-state.test.ts
index 7f5d3af..3f0538a 100644
--- a/apps/web/deck-detail-page-state.test.ts
+++ b/apps/web/deck-detail-page-state.test.ts
@@ -30,4 +30,14 @@ describe('deck detail page state', () => {
'access',
]);
});
+
+ it('omits slide analytics for HTML-only pages', () => {
+ expect(availableDeckDetailTabs(true, true, false)).toEqual([
+ 'preview',
+ 'overview',
+ 'visitors',
+ 'links',
+ 'access',
+ ]);
+ });
});
diff --git a/apps/web/deck-detail-page-state.ts b/apps/web/deck-detail-page-state.ts
index 154f899..57def90 100644
--- a/apps/web/deck-detail-page-state.ts
+++ b/apps/web/deck-detail-page-state.ts
@@ -71,9 +71,12 @@ export function tabFromHash(hash: string): DetailTab {
export function availableDeckDetailTabs(
hasAnalytics: boolean,
canManage: boolean,
+ hasSlides = true,
): readonly DetailTab[] {
const analyticsTabs: readonly DetailTab[] = hasAnalytics
- ? ['overview', 'visitors', 'slides']
+ ? hasSlides
+ ? ['overview', 'visitors', 'slides']
+ : ['overview', 'visitors']
: [];
const managementTabs: readonly DetailTab[] = canManage ? ['links', 'access'] : [];
return ['preview', ...analyticsTabs, ...managementTabs];
diff --git a/apps/web/deck-detail-preview-view.ts b/apps/web/deck-detail-preview-view.ts
index 86ab639..a68934c 100644
--- a/apps/web/deck-detail-preview-view.ts
+++ b/apps/web/deck-detail-preview-view.ts
@@ -1,6 +1,7 @@
import { html, nothing, type TemplateResult } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import type { DeckDetail, DeckPreview } from './deck-types.ts';
+import './document-frame.ts';
export type DeckPreviewInput = {
readonly preview: DeckPreview | null;
@@ -18,30 +19,40 @@ export function renderDeckPreview(input: DeckPreviewInput): TemplateResult {
const slideCount = input.preview?.slides.length ?? 0;
return html`
-
-
${slide === undefined ? nothing : unsafeHTML(slide.html)}
-
-
- input.onMove(-1)}
- >
- Previous
-
- Slide ${slideCount === 0 ? 0 : input.slideIndex + 1} of ${slideCount}
- = slideCount - 1}
- @click=${() => input.onMove(1)}
- >
- Next
-
-
+ ${input.preview?.html != null
+ ? html`
+
+
`
+ : html`
+
+ ${slide === undefined ? nothing : unsafeHTML(slide.html)}
+
+
+
+ input.onMove(-1)}
+ >
+ Previous
+
+ Slide ${slideCount === 0 ? 0 : input.slideIndex + 1} of ${slideCount}
+ = slideCount - 1}
+ @click=${() => input.onMove(1)}
+ >
+ Next
+
+
`}
${input.canManage
@@ -80,8 +91,10 @@ export function renderDeckPreview(input: DeckPreviewInput): TemplateResult {
(revision) =>
html`
Revision ${revision.revisionNumber} ${revision.slideCount} slides · ${revision.createdByEmail} ·
- ${formatDate(revision.createdAt)} ${revision.contentFormat === 'html'
+ ? 'HTML document'
+ : `${revision.slideCount} slides`}
+ · ${revision.createdByEmail} · ${formatDate(revision.createdAt)}
`,
)}
diff --git a/apps/web/deck-detail.ts b/apps/web/deck-detail.ts
index 9321617..9fe6f68 100644
--- a/apps/web/deck-detail.ts
+++ b/apps/web/deck-detail.ts
@@ -138,7 +138,11 @@ class DeckDetailPage extends LitElement {
}
private availableTabs(): readonly DetailTab[] {
- return availableDeckDetailTabs(this.analytics !== null, this.canManage());
+ return availableDeckDetailTabs(
+ this.analytics !== null,
+ this.canManage(),
+ this.analytics?.contentFormat !== 'html',
+ );
}
private canManage(): boolean {
diff --git a/apps/web/deck-types.ts b/apps/web/deck-types.ts
index 47ee831..2939f2c 100644
--- a/apps/web/deck-types.ts
+++ b/apps/web/deck-types.ts
@@ -40,6 +40,7 @@ export const deckPreviewSchema = z.object({
title: z.string(),
revisionId: z.string().uuid(),
revisionNumber: z.number().int().positive(),
+ html: z.string().nullable().optional(),
slides: z.array(slideSchema),
});
export type DeckPreview = z.infer;
@@ -62,6 +63,7 @@ export const deckDetailSchema = z.object({
id: z.string().uuid(),
revisionNumber: z.number().int(),
slideCount: z.number().int(),
+ contentFormat: z.enum(['html', 'slides']).optional(),
createdByEmail: z.string().email(),
createdAt: dateSchema,
}),
@@ -169,13 +171,15 @@ const slideRollupSchema = z.object({
exits: z.number().int(),
});
export const analyticsSchema = z.object({
+ contentFormat: z.enum(['html', 'slides', 'mixed']).optional(),
+ revisionNumbers: z.array(z.number().int()).optional(),
owner: z.object({ id: z.string().uuid(), email: z.string().email() }),
overview: z.object({
totalVisits: z.number().int(),
uniqueViewers: z.number().int(),
lastViewed: nullableDateSchema,
averageActiveTimeMs: z.number(),
- averageCompletion: z.number(),
+ averageCompletion: z.number().nullable(),
topSlide: slideRollupSchema.nullable(),
}),
visitors: z.array(
@@ -186,7 +190,7 @@ export const analyticsSchema = z.object({
lastVisit: dateSchema,
visits: z.number().int(),
totalActiveTimeMs: z.number(),
- maximumCompletion: z.number(),
+ maximumCompletion: z.number().nullable(),
}),
),
slides: z.array(slideRollupSchema),
@@ -202,9 +206,10 @@ export const analyticsSchema = z.object({
senderId: z.string().uuid(),
senderEmail: z.string().email(),
revisionNumber: z.number().int(),
+ contentFormat: z.enum(['html', 'slides']).optional(),
totalActiveTimeMs: z.number(),
- viewedSlides: z.number().int(),
- completion: z.number(),
+ viewedSlides: z.number().int().nullable(),
+ completion: z.number().nullable(),
furthestSlide: z.number().int().nullable(),
lastSlide: z.number().int().nullable(),
sequenceComplete: z.boolean().optional(),
diff --git a/apps/web/deck-viewer.ts b/apps/web/deck-viewer.ts
index a4db760..472944a 100644
--- a/apps/web/deck-viewer.ts
+++ b/apps/web/deck-viewer.ts
@@ -224,6 +224,7 @@ class DeckViewer extends LitElement {
this.tracker?.stop();
const tracker = new EngagementTracker({
sessionToken: this.sessionToken,
+ revisionId: this.deck.revisionId,
currentSlideId: () => this.deck?.slides[this.slideIndex]?.id ?? null,
analyticsConsent: this.consent,
});
@@ -240,6 +241,7 @@ class DeckViewer extends LitElement {
}
private onKeyDown = (event: KeyboardEvent): void => {
+ if (this.deck?.html != null) return;
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement)
return;
this.tracker?.markActivity();
@@ -272,6 +274,7 @@ class DeckViewer extends LitElement {
state: this.state,
metadata: this.metadata,
deck: this.deck,
+ sessionToken: this.sessionToken ?? '',
message: this.message,
slideIndex: this.slideIndex,
consent: this.consent,
diff --git a/apps/web/document-frame.test.ts b/apps/web/document-frame.test.ts
new file mode 100644
index 0000000..25852c1
--- /dev/null
+++ b/apps/web/document-frame.test.ts
@@ -0,0 +1,173 @@
+// @vitest-environment happy-dom
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DocumentFrame } from './document-frame.ts';
+
+afterEach(() => {
+ document.body.replaceChildren();
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+});
+
+describe('document frame', () => {
+ it('posts viewer credentials in the body and allows scripts without same-origin privileges', async () => {
+ // Given
+ const submit = vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {});
+ const frame = new DocumentFrame();
+ frame.sessionToken = 'private-viewer-session';
+ frame.revisionId = '00000000-0000-4000-8000-000000000001';
+ document.body.append(frame);
+
+ // When
+ await frame.updateComplete;
+ frame.shadowRoot?.querySelector('iframe')?.dispatchEvent(new Event('load'));
+
+ // Then
+ const iframe = frame.shadowRoot?.querySelector('iframe');
+ const form = frame.shadowRoot?.querySelector('form');
+ expect(iframe?.getAttribute('sandbox')).toBe('allow-scripts');
+ expect(iframe?.getAttribute('src')).toBeNull();
+ expect(iframe?.getAttribute('srcdoc')).toBeNull();
+ expect(form?.method).toBe('post');
+ expect(form?.getAttribute('action')).toBe('/v1/viewer/document');
+ expect(form?.target).toBe(iframe?.name);
+ expect(
+ form === null || form === undefined ? null : new FormData(form).get('session_token'),
+ ).toBe('private-viewer-session');
+ expect(submit).toHaveBeenCalledOnce();
+ });
+
+ it('keeps loading through the initial blank iframe before submitting the protected document', async () => {
+ // Given
+ const submit = vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {});
+ const { frame, iframe } = await mountFrame();
+ expect(submit).not.toHaveBeenCalled();
+ // When
+ iframe.dispatchEvent(new Event('load'));
+ // Then
+ expect(submit).toHaveBeenCalledOnce();
+ expect(frame.state).toBe('loading');
+ });
+
+ it('reveals a loaded document when author policy or raw text prevents telemetry', async () => {
+ // Given
+ vi.useFakeTimers();
+ vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {});
+ const { frame, iframe } = await mountFrame();
+ iframe.dispatchEvent(new Event('load'));
+ // When
+ iframe.dispatchEvent(new Event('load'));
+ await vi.advanceTimersByTimeAsync(15_001);
+ await frame.updateComplete;
+ // Then
+ expect(frame.state).toBe('ready');
+ expect(frame.shadowRoot?.querySelector('.frame-status')).toBeNull();
+ });
+
+ it('retains a protected-route error when the error document finishes loading', async () => {
+ // Given
+ vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {});
+ const { frame, iframe } = await mountFrame();
+ iframe.dispatchEvent(new Event('load'));
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: 'null',
+ source: iframe.contentWindow,
+ data: { type: 'pagent:error' },
+ }),
+ );
+ // When
+ iframe.dispatchEvent(new Event('load'));
+ await frame.updateComplete;
+ // Then
+ expect(frame.state).toBe('error');
+ expect(frame.shadowRoot?.querySelector('[role="alert"]')).not.toBeNull();
+ });
+
+ it.each(['origin', 'source', 'payload'] as const)(
+ 'ignores spoofed errors when the %s is invalid',
+ async (invalid) => {
+ // Given
+ vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {});
+ const { frame, iframe } = await mountFrame();
+ iframe.dispatchEvent(new Event('load'));
+ const message = new MessageEvent('message', {
+ origin: invalid === 'origin' ? 'https://attacker.test' : 'null',
+ source: invalid === 'source' ? window : iframe.contentWindow,
+ data:
+ invalid === 'payload'
+ ? { type: 'pagent:error', html: '' }
+ : { type: 'pagent:error' },
+ });
+ // When
+ window.dispatchEvent(message);
+ // Then
+ expect(frame.state).toBe('loading');
+ },
+ );
+
+ it('does not treat an optional telemetry readiness message as a document load', async () => {
+ // Given
+ vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {});
+ const { frame, iframe } = await mountFrame();
+ iframe.dispatchEvent(new Event('load'));
+ // When
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: 'null',
+ source: iframe.contentWindow,
+ data: { type: 'pagent:ready', revisionId: frame.revisionId },
+ }),
+ );
+ // Then
+ expect(frame.state).toBe('loading');
+ });
+
+ it('reports activity when the loaded document emits a valid bridge event', async () => {
+ // Given
+ vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {});
+ const { frame, iframe } = await mountFrame();
+ const activity = vi.fn();
+ frame.addEventListener('document-activity', activity);
+ iframe.dispatchEvent(new Event('load'));
+ iframe.dispatchEvent(new Event('load'));
+ // When
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: 'null',
+ source: iframe.contentWindow,
+ data: { type: 'pagent:activity' },
+ }),
+ );
+ // Then
+ expect(activity).toHaveBeenCalledOnce();
+ });
+
+ it('uses the owner preview endpoint without a viewer token', async () => {
+ // Given
+ vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {});
+ const frame = new DocumentFrame();
+ frame.deckId = '00000000-0000-4000-8000-000000000002';
+ frame.revisionId = '00000000-0000-4000-8000-000000000003';
+ document.body.append(frame);
+
+ // When
+ await frame.updateComplete;
+
+ // Then
+ const form = frame.shadowRoot?.querySelector('form');
+ expect(form?.getAttribute('action')).toBe('/v1/owner/document');
+ expect(form?.querySelector('[name="session_token"]')).toBeNull();
+ });
+});
+
+async function mountFrame() {
+ const frame = new DocumentFrame();
+ frame.sessionToken = 'private-viewer-session';
+ frame.revisionId = '00000000-0000-4000-8000-000000000001';
+ document.body.append(frame);
+ await frame.updateComplete;
+ const iframe = frame.shadowRoot?.querySelector('iframe');
+ if (iframe === undefined || iframe === null) throw new TypeError('Test iframe was not rendered');
+ return { frame, iframe };
+}
diff --git a/apps/web/document-frame.ts b/apps/web/document-frame.ts
new file mode 100644
index 0000000..97dcea7
--- /dev/null
+++ b/apps/web/document-frame.ts
@@ -0,0 +1,164 @@
+import { LitElement, css, html, nothing, type PropertyValues } from 'lit';
+import { z } from 'zod';
+import { API_BASE } from './deck-api.ts';
+import { productStyles } from './product-styles.ts';
+
+const frameMessageSchema = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('pagent:error') }).strict(),
+ z.object({ type: z.literal('pagent:activity') }).strict(),
+]);
+
+export class DocumentFrame extends LitElement {
+ static properties = {
+ sessionToken: { attribute: false },
+ deckId: { attribute: false },
+ revisionId: { attribute: false },
+ documentTitle: { type: String },
+ state: { state: true },
+ };
+
+ static styles = [
+ productStyles,
+ css`
+ :host {
+ display: block;
+ position: relative;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ }
+ iframe {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ }
+ .frame-status {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-content: center;
+ padding: var(--pg-space-4);
+ background: var(--pg-canvas);
+ color: var(--pg-ink);
+ text-align: center;
+ }
+ `,
+ ];
+
+ declare sessionToken: string;
+ declare deckId: string;
+ declare revisionId: string;
+ declare documentTitle: string;
+ declare state: 'loading' | 'ready' | 'error';
+ private readonly frameName = `document-${crypto.randomUUID()}`;
+ private loadTimer: ReturnType | null = null;
+ private frameInitialized = false;
+ private responseFailed = false;
+
+ constructor() {
+ super();
+ this.sessionToken = '';
+ this.deckId = '';
+ this.revisionId = '';
+ this.documentTitle = 'Shared HTML page';
+ this.state = 'loading';
+ }
+
+ connectedCallback(): void {
+ super.connectedCallback();
+ window.addEventListener('message', this.onMessage);
+ }
+
+ disconnectedCallback(): void {
+ window.removeEventListener('message', this.onMessage);
+ if (this.loadTimer !== null) clearTimeout(this.loadTimer);
+ super.disconnectedCallback();
+ }
+
+ protected updated(changed: PropertyValues): void {
+ if (changed.has('sessionToken') || changed.has('deckId') || changed.has('revisionId'))
+ this.submit();
+ }
+
+ private submit(): void {
+ if (!this.frameInitialized) return;
+ if (this.revisionId === '' || (this.sessionToken === '' && this.deckId === '')) return;
+ const form = this.renderRoot.querySelector('form');
+ if (!(form instanceof HTMLFormElement)) return;
+ if (this.loadTimer !== null) clearTimeout(this.loadTimer);
+ this.state = 'loading';
+ this.responseFailed = false;
+ this.loadTimer = setTimeout(() => {
+ this.state = 'error';
+ }, 15_000);
+ form.submit();
+ }
+
+ private onLoad = (): void => {
+ if (!this.frameInitialized) {
+ this.frameInitialized = true;
+ this.submit();
+ return;
+ }
+ if (this.loadTimer !== null) clearTimeout(this.loadTimer);
+ if (!this.responseFailed) this.state = 'ready';
+ };
+
+ private onMessage = (event: MessageEvent): void => {
+ const iframe = this.renderRoot.querySelector('iframe');
+ if (event.origin !== 'null' || event.source !== iframe?.contentWindow) return;
+ const parsed = frameMessageSchema.safeParse(event.data);
+ if (!parsed.success) return;
+ switch (parsed.data.type) {
+ case 'pagent:error':
+ if (this.loadTimer !== null) clearTimeout(this.loadTimer);
+ this.responseFailed = true;
+ this.state = 'error';
+ break;
+ case 'pagent:activity':
+ if (this.state === 'ready')
+ this.dispatchEvent(
+ new CustomEvent('document-activity', { bubbles: true, composed: true }),
+ );
+ break;
+ }
+ };
+
+ render() {
+ const viewer = this.sessionToken !== '';
+ return html`
+
+
+ ${this.state === 'ready'
+ ? nothing
+ : html`
+ ${this.state === 'loading'
+ ? html`
Opening page…
`
+ : html`
+
The page could not be opened. Refresh if access or content has changed.
+
this.submit()}>Retry
+
`}
+
`}
+ `;
+ }
+}
+
+customElements.define('document-frame', DocumentFrame);
diff --git a/apps/web/home-content.ts b/apps/web/home-content.ts
index dcc2011..8836833 100644
--- a/apps/web/home-content.ts
+++ b/apps/web/home-content.ts
@@ -30,8 +30,8 @@ export const renderHomeContent = (
A presentation is a durable page with ordered slides, secure sharing, and engagement
- analytics. It lives in your signed-in workspace. A presentation is your HTML file, with its own layout and interactions, secure sharing,
+ and engagement analytics. It lives in your signed-in workspace.
@@ -133,7 +133,7 @@ export const renderHomeContent = (
Read what happened
read returns page state and submitted answers. Durable presentation pages
- also surface visits, slide activity, and drop-off analytics in your workspace.
+ also surface visits, viewers, and active reading time in your workspace.
diff --git a/apps/web/permission-ux-copy.test.ts b/apps/web/permission-ux-copy.test.ts
index f4bd965..7e2fbc7 100644
--- a/apps/web/permission-ux-copy.test.ts
+++ b/apps/web/permission-ux-copy.test.ts
@@ -111,5 +111,6 @@ describe('permission UX copy', () => {
expect(container.querySelectorAll('button')).toHaveLength(1);
expect(container.querySelector('a')?.getAttribute('href')).toBe('/privacy');
+ expect(container.textContent).not.toContain('records slide engagement');
});
});
diff --git a/apps/web/privacy-page.ts b/apps/web/privacy-page.ts
index 7789326..9a8e5e2 100644
--- a/apps/web/privacy-page.ts
+++ b/apps/web/privacy-page.ts
@@ -28,9 +28,14 @@ class PrivacyPage extends LitElement {
What is recorded
After a presentation page is at least half visible and you interact, Pagent may record
- the visit time, share link, presentation revision, viewed slides, active time,
- completion, device class, browser family, and coarse country. Background tabs and
- periods without activity for 60 seconds do not add active time.
+ the visit time, share link, presentation revision, active time, device class, browser
+ family, and coarse country. Older structured presentations also record viewed slides
+ and completion; HTML pages do not infer these measures. Background tabs and periods
+ without activity for 60 seconds do not add active time.
+
+
+ HTML engagement is best-effort telemetry. Author scripts or security policies can
+ interfere with measurement, so it is not proof of human attention.
Identity is shown as Anonymous , Unverified , or
diff --git a/apps/web/product-layout-styles.ts b/apps/web/product-layout-styles.ts
index 33bed73..92ec054 100644
--- a/apps/web/product-layout-styles.ts
+++ b/apps/web/product-layout-styles.ts
@@ -130,6 +130,10 @@ export const productLayoutStyles = [
grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.5fr);
gap: var(--pg-space-5);
}
+ .document-preview {
+ height: 70dvh;
+ border: 1px solid var(--pg-rule);
+ }
.slide-stage {
display: grid;
place-items: center;
diff --git a/apps/web/product-viewer-styles.ts b/apps/web/product-viewer-styles.ts
index b4905f6..133f481 100644
--- a/apps/web/product-viewer-styles.ts
+++ b/apps/web/product-viewer-styles.ts
@@ -1,6 +1,11 @@
import { css } from 'lit';
export const productViewerStyles = css`
+ .viewer-stage.document-stage {
+ display: block;
+ height: 100dvh;
+ padding: 0;
+ }
.viewer-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
diff --git a/apps/web/viewer-deck-view.ts b/apps/web/viewer-deck-view.ts
index 821af3a..845ef20 100644
--- a/apps/web/viewer-deck-view.ts
+++ b/apps/web/viewer-deck-view.ts
@@ -2,11 +2,13 @@ import { html, nothing, type TemplateResult } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { renderViewerGate, type ViewerState } from './viewer-gate.ts';
import { confidenceLabel, type ShareMetadata, type ViewerDeck } from './viewer-types.ts';
+import './document-frame.ts';
type DeckViewerViewInput = {
readonly state: ViewerState;
readonly metadata: ShareMetadata | null;
readonly deck: ViewerDeck | null;
+ readonly sessionToken?: string;
readonly message: string | null;
readonly slideIndex: number;
readonly consent: boolean;
@@ -43,6 +45,15 @@ export function renderDeckViewer(input: DeckViewerViewInput): TemplateResult {
`;
+ if (input.deck?.html != null)
+ return html`
+
+ `;
const slide = input.deck?.slides[input.slideIndex];
return html`
-
Privacy & analytics. Pagent records slide engagement only after the
- presentation is visible and you interact.
Read the privacy notice .${input
- .metadata?.analyticsConsentRequired
+
Privacy & analytics. Pagent records page engagement only after the presentation
+ is visible and you interact.
Read the privacy notice .${input.metadata
+ ?.analyticsConsentRequired
? html`
{
if (document.fullscreenElement === null) await element.requestFullscreen();
else await document.exitFullscreen();
}
+
+export function deviceClass(): 'mobile' | 'tablet' | 'desktop' {
+ if (innerWidth < 640) return 'mobile';
+ if (innerWidth < 1024) return 'tablet';
+ return 'desktop';
+}
+
+export function browserFamily(): string {
+ const agent = navigator.userAgent;
+ if (agent.includes('Firefox/')) return 'Firefox';
+ if (agent.includes('Edg/')) return 'Edge';
+ if (agent.includes('Chrome/')) return 'Chromium';
+ if (agent.includes('Safari/')) return 'Safari';
+ return 'Other';
+}
diff --git a/apps/web/viewer-tracking.ts b/apps/web/viewer-tracking.ts
index 6be2c30..72bcfd4 100644
--- a/apps/web/viewer-tracking.ts
+++ b/apps/web/viewer-tracking.ts
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { ApiError, apiJson } from './deck-api.ts';
+import { deviceClass, browserFamily } from './viewer-interaction.ts';
import {
EngagementDelivery,
isExpectedTrackingError,
@@ -14,6 +15,7 @@ const startVisitSchema = z.discriminatedUnion('kind', [
type TrackerInput = {
readonly sessionToken: string;
+ readonly revisionId?: string;
readonly currentSlideId: () => string | null;
readonly analyticsConsent: boolean;
};
@@ -28,6 +30,7 @@ type QueuedEvent = {
export class EngagementTracker {
private readonly sessionToken: string;
+ private readonly revisionId: string | undefined;
private readonly currentSlideId: () => string | null;
private analyticsConsent: boolean;
private visitId: string | null = null;
@@ -48,6 +51,7 @@ export class EngagementTracker {
constructor(input: TrackerInput) {
this.sessionToken = input.sessionToken;
+ this.revisionId = input.revisionId;
this.currentSlideId = input.currentSlideId;
this.analyticsConsent = input.analyticsConsent;
this.delivery = new EngagementDelivery(input.sessionToken, (error) => {
@@ -200,6 +204,7 @@ export class EngagementTracker {
method: 'POST',
headers: { 'x-viewer-session': this.sessionToken },
body: JSON.stringify({
+ revisionId: this.revisionId,
visible: this.visibleRatio >= 0.5,
interacted: true,
analyticsConsent: this.analyticsConsent,
@@ -256,18 +261,3 @@ export class EngagementTracker {
await this.delivery.enqueue(event, input.keepalive === true);
}
}
-
-function deviceClass(): 'mobile' | 'tablet' | 'desktop' {
- if (innerWidth < 640) return 'mobile';
- if (innerWidth < 1024) return 'tablet';
- return 'desktop';
-}
-
-function browserFamily(): string {
- const agent = navigator.userAgent;
- if (agent.includes('Firefox/')) return 'Firefox';
- if (agent.includes('Edg/')) return 'Edge';
- if (agent.includes('Chrome/')) return 'Chromium';
- if (agent.includes('Safari/')) return 'Safari';
- return 'Other';
-}
diff --git a/apps/web/viewer-types.ts b/apps/web/viewer-types.ts
index 3306128..81d14dd 100644
--- a/apps/web/viewer-types.ts
+++ b/apps/web/viewer-types.ts
@@ -38,6 +38,7 @@ export const viewerDeckSchema = z.object({
viewerSessionId: z.string().uuid(),
identityConfidence: identitySchema,
preview: z.boolean(),
+ html: z.string().nullable().optional(),
slides: z.array(slideSchema),
});
export type ViewerDeck = z.infer;
diff --git a/docs/PRD-v0.1.0-secure-deck-sharing.md b/docs/PRD-v0.1.0-secure-deck-sharing.md
index b5cca25..f10ae60 100644
--- a/docs/PRD-v0.1.0-secure-deck-sharing.md
+++ b/docs/PRD-v0.1.0-secure-deck-sharing.md
@@ -13,6 +13,15 @@
> pages; `read` retrieves an interactive response or authorized presentation
> analytics. The former tool names are removed with no compatibility aliases.
+> **Binding clarification — 2026-09-22:** The full submitted HTML document is
+> the durable presentation. `write` accepts `title` and `html`, with optional
+> metadata and `page_id` for a new revision. Pagent preserves the exact UTF-8
+> source in `deck_revisions.html`, imposes no slide structure, and runs author
+> JavaScript only in an isolated sandbox. This supersedes the earlier
+> structured-slide and no-script requirements for durable presentations.
+> Temporary document sanitization is unchanged. Legacy REST slide payloads and
+> stored slide revisions retain their existing renderer and analytics.
+
## 1. Executive summary
Pagent v0.1.0 turns an agent-generated presentation into a durable, controlled
@@ -23,7 +32,7 @@ recipient engaged with the proposal.
The release adds four connected capabilities:
1. **Controlled page sharing:** public links, allowed-email access, or creator-required authentication.
-2. **Viewer engagement analytics:** who viewed, active time, slides viewed, most-engaged slide, completion, and drop-off.
+2. **Viewer engagement analytics:** who viewed and active time; existing slide revisions also retain slide completion and drop-off metrics.
3. **Page management:** one dashboard to preview presentation pages, manage links and access, revoke sharing, and inspect engagement.
4. **Internal analytics permissions:** analytics can remain private or be shared with selected people, teams, or the verified workspace/domain.
@@ -42,7 +51,7 @@ These are recommended defaults, not hidden assumptions:
- For stronger protection, the creator can require authentication through a one-time code, magic link, Google sign-in, or an existing Pagent session.
- Private analytics are visible to the deck owner and the creator of the relevant share link. Workspace administrators see metadata but do not silently bypass private viewer-level analytics.
- Deck content is retained until deleted. Viewer-level analytics are retained for 12 months by default, subject to workspace policy.
-- The first release supports agent-generated presentation pages with explicit slide boundaries. It does not infer reliable slide analytics from arbitrary temporary document pages.
+- A presentation is a self-contained HTML document, including its own CSS and inline JavaScript. Pagent does not infer slides or completion from it. Legacy structured slides remain a REST/storage compatibility path only.
## 3. Problem
@@ -58,7 +67,7 @@ Pagent can currently render an agent-generated HTML artifact at a unique URL, bu
This makes Pagent useful for showing an artifact, but not yet suitable as the system of record for client proposals, fundraising decks, sales presentations, or confidential reports.
-## 4. Current product baseline
+## 4. Historical product baseline (v0.0.1)
Pagent v0.0.1 already provides useful foundations:
@@ -69,7 +78,7 @@ Pagent v0.0.1 already provides useful foundations:
- Coarse operational counters for page creation and page views.
- A view-only HTML renderer suitable for visual artifacts.
-The current model is not sufficient for this feature:
+That historical model was not sufficient for this feature:
- Pages expire after a short TTL and have no durable deck identity.
- The renderer has no stable slide model.
@@ -84,8 +93,8 @@ The current model is not sufficient for this feature:
- Let a sender publish and share a proposal in under two minutes.
- Let a sender protect a deck without creating unnecessary friction for a client.
-- Let a sender know who engaged, for how long, and with which slides.
-- Let a sender quickly identify the last slide reached and the main drop-off point.
+- Let a sender know who engaged and for how long.
+- Preserve meaningful slide completion and drop-off for legacy slide revisions, without inventing them for HTML documents.
- Let an owner control which colleagues can see deck content, links, and viewer analytics.
- Let a manager find proposals by sender, client/link, status, and recent engagement.
@@ -97,7 +106,7 @@ The current model is not sufficient for this feature:
### 5.3 Technical goals
-- Reuse the current auth, HTML sanitization, and Postgres foundations.
+- Reuse the current auth and Postgres foundations; retain sanitization for temporary documents and legacy slides, with isolated raw-HTML delivery for durable presentations.
- Expose exactly `write` and `read` over both MCP transports, with no legacy tool aliases.
- Keep page-type behavior simple: temporary interactive/document pages and durable presentation pages.
- Store raw engagement events separately from operational telemetry.
@@ -114,27 +123,27 @@ The current model is not sufficient for this feature:
- CRM, Slack, or email automation integrations.
- AI-generated engagement scores or claims about buyer intent.
- Public analytics pages for external clients.
-- Analytics for arbitrary document pages without explicit slide boundaries.
+- Inferred slide boundaries, slide completion, or reading progress for arbitrary HTML; analytics for temporary document pages.
## 7. Terminology
-| Term | Definition |
-| ------------------------ | ----------------------------------------------------------------------------------- |
-| **Page** | Pagent's public product primitive. |
-| **Presentation page** | Durable, revisioned Page owned by a Pagent user or workspace. |
-| **Deck** | Legacy/internal name for a presentation page. |
-| **Slide** | Ordered, stable unit within a deck, identified by an immutable slide ID. |
-| **Deck revision** | Immutable snapshot of deck content. |
-| **Share link** | Revocable URL for a deck, with its own name, access policy, expiry, and analytics. |
-| **Sender** | User who creates a share link. May differ from the deck owner. |
-| **Viewer** | External or internal person opening a share link. |
-| **Visit** | One engagement session for one viewer or anonymous browser on one share link. |
-| **Viewer access policy** | Rule controlling who may open a share link. |
-| **Analytics visibility** | Rule controlling which authenticated workspace members may inspect engagement data. |
-
-In product UI and agent-facing contracts, use **Page** for the artifact and
-**slide** for an ordered unit within a presentation page. Do not present Deck as
-a separate object model.
+| Term | Definition |
+| ------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
+| **Page** | Pagent's public product primitive. |
+| **Presentation page** | Durable, revisioned HTML document owned by a Pagent user or workspace; also includes retained legacy slide presentations. |
+| **Deck** | Legacy/internal name for a presentation page. |
+| **Slide** | Ordered, stable unit in a legacy structured-slide revision; not imposed on HTML documents. |
+| **Deck revision** | Immutable snapshot of deck content. |
+| **Share link** | Revocable URL for a deck, with its own name, access policy, expiry, and analytics. |
+| **Sender** | User who creates a share link. May differ from the deck owner. |
+| **Viewer** | External or internal person opening a share link. |
+| **Visit** | One engagement session for one viewer or anonymous browser on one share link. |
+| **Viewer access policy** | Rule controlling who may open a share link. |
+| **Analytics visibility** | Rule controlling which authenticated workspace members may inspect engagement data. |
+
+In product UI and agent-facing contracts, use **Page** for the artifact. Use
+**slide** only for a legacy structured-slide revision or the author's own
+document content. Do not present Deck as a separate object model.
## 8. Primary users and jobs
@@ -158,7 +167,7 @@ a separate object model.
### 9.1 Publish and share a controlled deck
-1. An agent publishes an ordered set of slides as a durable deck.
+1. An agent publishes a complete HTML document as a durable presentation page.
2. The owner opens the deck in Pagent and selects **Share**.
3. The owner names the link, for example `Acme - September proposal`.
4. The owner chooses an access mode and optional expiry.
@@ -199,10 +208,10 @@ sequenceDiagram
### 9.3 Review engagement
-1. The sender opens **Pages** and sees last viewed, unique viewers, average active time, and completion.
+1. The sender opens **Pages** and sees last viewed, unique viewers, and average active time.
2. The sender opens a deck and selects **Analytics**.
-3. The overview shows visits over time, average completion, top slide by active time, and drop-off distribution.
-4. The sender selects a viewer to see each visit, slide sequence, active time per slide, furthest slide reached, and last slide viewed.
+3. The overview shows visits over time and active time, identifying the content format and revisions in scope.
+4. The sender selects a viewer to see each visit and its active time. Legacy slide visits additionally show slide sequence, completion, furthest/last slide, and slide-level time. HTML visits leave these metrics unavailable, not zero.
5. Public-link visits appear as anonymous unless the viewer later authenticates in the same visit.
### 9.4 Share analytics internally
@@ -226,7 +235,7 @@ Agent-facing creation uses `write` with a required `type` discriminator:
- `interactive`: an A2UI specification for a temporary, single-response page.
- `document`: sanitized HTML for a temporary, view-only page.
-- `presentation`: title, ordered slides with stable IDs and sanitized HTML,
+- `presentation`: title and the full HTML document,
plus optional description, client/account label, or `page_id` update target.
Agent-facing retrieval uses `read`. It returns a temporary interactive response
@@ -237,9 +246,15 @@ mode is enabled. Requests that present invalid or expired Bearer credentials
fail closed with `401`; grace-mode callers choosing anonymous access omit the
`Authorization` header.
-Explicit slide boundaries remain a product requirement. MCP advertises only
-`write` and `read`; link management and governance stay in the authenticated
-web/REST surfaces.
+The submitted document is the deck, with its own layout and interactions.
+MCP advertises only `write` and `read`; link management and governance stay in
+the authenticated web/REST surfaces. `POST /v1/decks` accepts either `html` or
+legacy `slides`, never both. The legacy form remains solely for shipped
+clients and records; new agent-facing publishing uses `html`.
+
+HTML source is stored exactly, without sanitizing or fabricating a slide. The
+supported runtime is self-contained HTML/CSS/inline JavaScript in an
+opaque-origin sandbox. Section 14.1 defines its restrictions.
### 10.2 One presentation page, multiple share links
@@ -256,13 +271,13 @@ Analytics can be viewed per link or aggregated across the presentation page. Thi
### 10.3 Revisions
- Editing or republishing a presentation page creates a new immutable revision.
-- Authorized editors can browse and preview earlier revisions from the Page's
- version history.
-- Restoring an earlier revision creates a new latest revision from that
- snapshot; it never overwrites or deletes existing history.
+- Immutable snapshots are retained now. Browsing/previewing earlier revisions
+ and restoring a snapshot are P1, not a current UI capability.
+- A future restore creates a new latest revision from the snapshot; it never
+ overwrites or deletes existing history.
- By default, active share links follow the latest published revision.
-- Historical visits retain the revision ID viewed, so slide analytics remain interpretable.
-- Reordering or deleting slides never rewrites historical event meaning.
+- Historical visits retain the revision ID viewed and its content format, so updates never reinterpret earlier analytics.
+- Legacy slide revisions retain their stable slide IDs and historical event meaning.
Pinning a link to a specific revision is P1 if it threatens the release schedule.
@@ -283,7 +298,7 @@ Priority meanings:
| DM-03 | P0 | Users can search by page title, link/client name, owner, or sender. |
| DM-04 | P0 | Users can filter by Mine, Shared with me, Team/workspace, active, expired, and revoked. |
| DM-05 | P0 | A presentation-page detail screen contains Preview, Analytics, Share links, and Settings/Access. |
-| DM-06 | P0 | Owners can rename, archive, restore, and delete a page. Deletion requires confirmation and revokes all links immediately. |
+| DM-06 | P0 | Owners can rename, archive, restore an archived page, and delete a page. Deletion requires confirmation and revokes all links immediately. |
| DM-07 | P0 | Users can preview the exact latest presentation revision without generating analytics. |
| DM-08 | P1 | Users can duplicate a presentation page or create a new client-specific link from the list. |
| DM-09 | P0 | Workspace administrators can open `/admin` for membership, policy, audit, and page-metadata governance without implicit content or viewer-analytics access. |
@@ -320,16 +335,16 @@ Allowed email is still an access rule, but it only proves that the viewer knows
### 11.3 Viewer experience
-| ID | Priority | Requirement |
-| ----- | -------- | --------------------------------------------------------------------------------------------------------------- |
-| VX-01 | P0 | The share page clearly identifies the deck or sender before asking for an email or sign-in. |
-| VX-02 | P0 | Access screens explain why information is requested and link to the privacy notice. |
-| VX-03 | P0 | The deck supports next/previous controls, keyboard navigation, slide count, mobile layout, and fullscreen. |
-| VX-04 | P0 | Refreshing or reopening a valid viewer session does not repeat email entry or authentication unnecessarily. |
-| VX-05 | P0 | Denied, pending, expired, and revoked states have distinct human-readable screens without leaking deck content. |
-| VX-06 | P0 | Viewer tracking never blocks slide navigation. Events queue locally and send asynchronously. |
-| VX-07 | P0 | The viewer meets WCAG 2.2 AA for the access gate and presentation controls. |
-| VX-08 | P1 | The sender may add a short welcome message and contact information to a share link. |
+| ID | Priority | Requirement |
+| ----- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| VX-01 | P0 | The share page clearly identifies the deck or sender before asking for an email or sign-in. |
+| VX-02 | P0 | Access screens explain why information is requested and link to the privacy notice. |
+| VX-03 | P0 | HTML fills an isolated presentation frame with author-controlled layout and interactions; Pagent imposes no slide controls. Legacy slides retain next/previous, keyboard navigation, count, mobile layout, and fullscreen. |
+| VX-04 | P0 | Refreshing or reopening a valid viewer session does not repeat email entry or authentication unnecessarily. |
+| VX-05 | P0 | Denied, pending, expired, and revoked states have distinct human-readable screens without leaking deck content. |
+| VX-06 | P0 | Viewer tracking never blocks document interaction or legacy slide navigation. Events queue locally and send asynchronously. |
+| VX-07 | P0 | Pagent's access gate and viewer controls meet WCAG 2.2 AA; author-supplied HTML remains the author's accessibility responsibility. |
+| VX-08 | P1 | The sender may add a short welcome message and contact information to a share link. |
### 11.4 Engagement capture
@@ -337,30 +352,30 @@ Allowed email is still an access rule, but it only proves that the viewer knows
| ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| EA-01 | P0 | Pagent creates a visit only after client-side code confirms a visible, interactive browser session. Link preview bots must not count as human visits. |
| EA-02 | P0 | Each visit records share link, deck revision, viewer identity when known, identity confidence (Anonymous, Unverified email, or Authenticated), start/end, device class, browser family, and coarse country when legally and operationally appropriate. |
-| EA-03 | P0 | Each slide records first seen, active duration, view count within the visit, and order of appearance. |
-| EA-04 | P0 | Slide time accumulates only while at least 50% of the slide is visible, the tab is visible, and recent user activity indicates the viewer is present. |
+| EA-03 | P0 | HTML revisions record visit-level engagement only. Legacy slide revisions retain first seen, active duration, view count, and order of appearance per slide. |
+| EA-04 | P0 | Active time accumulates only while at least 50% of the document frame or legacy slide is visible, the tab is visible, and recent user activity indicates the viewer is present. |
| EA-05 | P0 | Tracking pauses when the tab is hidden and after 60 seconds without focus, navigation, pointer, touch, or keyboard activity. It resumes on activity. |
| EA-06 | P0 | The client sends a heartbeat at most every 10 seconds and flushes a final best-effort event on page hide. The server derives trusted durations and rejects impossible values. |
| EA-07 | P0 | Duplicate or retried events are idempotent. Events arriving out of order do not inflate duration. |
| EA-08 | P0 | Internal preview sessions and authenticated owner/editor visits are excluded from client analytics by default. |
| EA-09 | P0 | A visit closes after 30 minutes of inactivity. A later return creates a new visit. |
-| EA-10 | P1 | Track outbound link clicks and presentation CTA clicks with slide attribution. |
+| EA-10 | P1 | Consider CTA telemetry and a separately reviewed outbound-link policy; slide attribution applies only to legacy slides. |
| EA-11 | P1 | Let an owner remove a known bot/test visit from user-facing analytics while preserving an audit record. |
### 11.5 Analytics dashboard
-| ID | Priority | Requirement |
-| ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| AD-01 | P0 | Deck overview shows total visits, unique viewers, last viewed, average active time, and average completion for a selected date range. |
-| AD-02 | P0 | The dashboard shows the deck owner and the sender/link creator for attribution. |
-| AD-03 | P0 | The Slides view shows, per slide, unique viewers, view rate, average active time, total active time, and exits. |
-| AD-04 | P0 | The Visitors view lists viewer email or Anonymous, an Unverified/Authenticated identity label, first/last visit, visits, total active time, and maximum completion. |
-| AD-05 | P0 | A visit detail shows slide sequence, active time by slide, furthest slide reached, last slide viewed, and completion. |
-| AD-06 | P0 | Users can filter analytics by share link, viewer, sender, revision, and date range. |
-| AD-07 | P0 | Allowed-email visits show the entered address as Unverified. Public visits remain Anonymous. Neither is relabeled Authenticated without an authentication event in that visit. |
-| AD-08 | P0 | Empty and low-data states explain what will appear after a real visit. |
-| AD-09 | P1 | Export permitted analytics to CSV. Export obeys the same permissions as the dashboard and is audit logged. |
-| AD-10 | P1 | Send an optional notification to the link creator after the first human visit, including the visitor's identity-confidence label. |
+| ID | Priority | Requirement |
+| ----- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| AD-01 | P0 | Overview shows total visits, unique viewers, last viewed, and average active time, with content format and revision scope. Average completion is available only for known legacy slide visits. |
+| AD-02 | P0 | The dashboard shows the deck owner and the sender/link creator for attribution. |
+| AD-03 | P0 | Legacy slide rollups show unique viewers, view rate, average/total active time, and exits. HTML revisions have no slide rollups or inferred top slide. |
+| AD-04 | P0 | The Visitors view lists viewer email or Anonymous, identity confidence, first/last visit, visits, and total active time; maximum completion is null when no legacy slide completion is known. |
+| AD-05 | P0 | Visit detail identifies revision and content format. HTML visits show active time with null viewed-slides/completion/furthest/last-slide metrics; legacy slide visits retain their existing detail. |
+| AD-06 | P0 | Users can filter analytics by share link, viewer, sender, revision, and date range. |
+| AD-07 | P0 | Allowed-email visits show the entered address as Unverified. Public visits remain Anonymous. Neither is relabeled Authenticated without an authentication event in that visit. |
+| AD-08 | P0 | Empty and low-data states explain what will appear after a real visit. |
+| AD-09 | P1 | Export permitted analytics to CSV. Export obeys the same permissions as the dashboard and is audit logged. |
+| AD-10 | P1 | Send an optional notification to the link creator after the first human visit, including the visitor's identity-confidence label. |
#### Metric definitions
@@ -369,14 +384,20 @@ Allowed email is still an access rule, but it only proves that the viewer knows
| **Visit** | A visible human browser session on one share link, closed after 30 minutes of inactivity. |
| **Unique viewer** | Distinct authenticated viewer ID, normalized self-declared email within a link, or privacy-preserving link-scoped anonymous browser identifier. Confidence groups are shown separately. |
| **Active time** | Deduplicated server-derived time while the deck or slide satisfies the visibility and activity rules. |
-| **Slide viewed** | At least 50% visible continuously for at least 1 second. |
-| **Completion** | Distinct slides viewed divided by total slides in the viewed revision. |
+| **Slide viewed** | Legacy slides only: at least 50% visible continuously for at least 1 second. Unavailable for HTML. |
+| **Completion** | Legacy slides only: distinct slides viewed divided by total slides in the viewed revision. HTML completion is null. |
| **Furthest slide reached** | Highest slide ordinal that qualified as viewed, even if intermediate slides were skipped. |
| **Last slide viewed** | Final qualifying slide before the visit ended. |
| **Top slide** | Slide with the highest total active time for the selected scope and date range. |
| **Drop-off slide** | Last slide viewed in a visit that did not reach 100% completion. The deck view shows the distribution of these exits. |
-Furthest slide reached and completion must both be shown. A viewer who jumps directly to slide 10 did not necessarily view 100% of a 10-slide deck.
+Analytics expose `contentFormat: html | slides | mixed` and `revisionNumbers`.
+HTML visits have null `completion`, `viewedSlides`, `furthestSlide`, and
+`lastSlide`, an empty slide sequence, and no slide rollups. In mixed history,
+average and maximum completion use only known legacy slide completion, never
+HTML visits as zero. Top-slide and drop-off metrics also apply only to legacy
+slides. Their furthest-slide and completion metrics remain distinct: jumping
+directly to slide 10 is not 100% completion of a 10-slide deck.
### 11.6 Internal content and analytics permissions
@@ -425,12 +446,12 @@ Primary row actions: Preview, Analytics, Share, More.
Acme proposal [Preview] [Share]
Owner: Ana Latest revision: 4 Status: Active
-Overview | Visitors | Slides | Share links | Access & settings
+Overview | Visitors | Share links | Access & settings
```
-- **Overview:** headline metrics, visits chart, top slides, recent visits.
+- **Overview:** visits, active time, content/revision scope, and recent visits; slide metrics only where legacy slide history exists.
- **Visitors:** known and anonymous viewer rollup, with visit drill-down.
-- **Slides:** slide thumbnails and engagement metrics.
+- **Slides (legacy revisions only):** slide thumbnails and engagement metrics.
- **Share links:** client/link name, sender, access mode, expiry, status, views, actions.
- **Access & settings:** internal collaborators, analytics visibility, retention, archive/delete.
@@ -448,23 +469,23 @@ This is a product-level model, not a final migration design. The `decks`,
durable presentation pages; they do not add another MCP tool or public product
primitive.
-| Entity | Key fields and purpose |
-| ------------------------ | --------------------------------------------------------------------------------------------------- |
-| `workspaces` | Name, verified domains, policy settings. |
-| `workspace_members` | User, workspace, role, status. |
-| `teams` / `team_members` | Internal audience groups. |
-| `decks` | Workspace, owner, title, status, latest revision, timestamps. |
-| `deck_revisions` | Immutable revision number, content manifest, created by/at. |
-| `deck_slides` | Revision, stable slide ID, ordinal, title, sanitized content. |
-| `share_links` | Deck, sender, name/client, token hash, access mode, expiry, revoked state. |
-| `share_link_audience` | Allowed email, domain, Pagent user, or workspace membership rule. |
-| `access_requests` | Link, requested email/user, state, approver, timestamps. |
-| `viewer_sessions` | Link, entered or authenticated viewer identity, identity confidence, token hash, expiry/revocation. |
-| `deck_collaborators` | User/team/workspace subject and explicit internal permissions. |
-| `visits` | Link, revision, viewer or anonymous ID, timestamps, client metadata. |
-| `slide_engagement` | Visit, slide, first seen, active milliseconds, view count, last sequence. |
-| `engagement_events` | Append-only raw event stream used to derive visit and slide aggregates. |
-| `audit_log` | Permission, access, export, and link-management actions. |
+| Entity | Key fields and purpose |
+| ------------------------ | ------------------------------------------------------------------------------------------------------------------ |
+| `workspaces` | Name, verified domains, policy settings. |
+| `workspace_members` | User, workspace, role, status. |
+| `teams` / `team_members` | Internal audience groups. |
+| `decks` | Workspace, owner, title, status, latest revision, timestamps. |
+| `deck_revisions` | Immutable revision number, exact submitted UTF-8 `html` (null for legacy slides), content manifest, created by/at. |
+| `deck_slides` | Legacy revisions only: stable slide ID, ordinal, title, sanitized content; no synthetic slide for HTML. |
+| `share_links` | Deck, sender, name/client, token hash, access mode, expiry, revoked state. |
+| `share_link_audience` | Allowed email, domain, Pagent user, or workspace membership rule. |
+| `access_requests` | Link, requested email/user, state, approver, timestamps. |
+| `viewer_sessions` | Link, entered or authenticated viewer identity, identity confidence, token hash, expiry/revocation. |
+| `deck_collaborators` | User/team/workspace subject and explicit internal permissions. |
+| `visits` | Link, revision, viewer or anonymous ID, timestamps, client metadata. |
+| `slide_engagement` | Visit, slide, first seen, active milliseconds, view count, last sequence. |
+| `engagement_events` | Append-only raw event stream used to derive visit and slide aggregates. |
+| `audit_log` | Permission, access, export, and link-management actions. |
Sensitive bearer values are stored as hashes. Deck and analytics queries enforce authorization server-side, not only by hiding UI controls.
@@ -479,7 +500,40 @@ Sensitive bearer values are stored as hashes. Deck and analytics queries enforce
- Return generic responses for unknown, denied, and non-allowed emails to reduce address and policy enumeration.
- Revoke cached viewer sessions when a link or individual access rule is revoked.
- Apply authorization checks to deck content, thumbnails, analytics, and exports.
-- Preserve the existing HTML no-script and sanitization constraints.
+- Preserve no-script sanitization for temporary document pages and legacy slide content. Durable HTML is retained unchanged and rendered only in the isolated document frame described below.
+
+#### Durable HTML delivery and sandbox
+
+- The frame uses `sandbox="allow-scripts"`, never `allow-same-origin`. Author
+ JavaScript has an opaque origin, no Pagent account-cookie/storage access, and
+ no parent DOM access. No login or viewer credential is embedded in the HTML.
+- Self-contained HTML/CSS/inline JavaScript is the supported contract. CSP and
+ sandbox policy restrict external assets, network APIs, form submissions,
+ nested frames, popups, top navigation, `eval`, and workers. The parent frame
+ policy blocks external navigation. This is not an absolute guarantee that
+ arbitrary HTML can never attempt a network request.
+- `POST /v1/viewer/document` accepts form-encoded `session_token` and
+ `revision_id`. It reuses the live viewer session and all existing link rules.
+- `POST /v1/owner/document` accepts form-encoded `deck_id` and `revision_id`
+ with the existing auth cookie or a Bearer token with `page:read`. It checks
+ content access and does not create a client visit.
+- Both routes require an exact allowlisted `Origin` header. A body-supplied
+ `allowed_origin` is not accepted; no new frame token is issued. Credentials
+ remain in POST bodies or existing auth channels, never URLs.
+- Requested revisions must still be latest (`409` otherwise); legacy slide
+ revisions return `415`. Expired/revoked authorization and deleted content
+ fail closed. Responses carry `Cache-Control: no-store`,
+ `Referrer-Policy: no-referrer`, and a restrictive CSP.
+- Delivery appends an invisible activity bridge without rewriting the stored
+ source or wrapping it in slides. Its activity messages are best-effort
+ telemetry, not authenticated proof of a human visit. The viewer reveals HTML
+ on iframe load, independently of that bridge. An author's CSP may block the
+ bridge and prevent metrics, but must not leave the document hidden.
+- For an allowlisted Origin, document POST errors requested with
+ `Accept: text/html` return a generic sandboxed HTML error page that posts
+ `pagent:error` to the parent, preserving the HTTP status and no-store policy.
+ JSON clients keep existing error bodies; rejected Origins do not receive the
+ HTML error page.
### 14.2 Privacy
@@ -511,7 +565,7 @@ Research was performed against first-party product and help documentation availa
### 15.1 Product opportunities for Pagent
-- **Agent-native publishing:** the agent can publish a structured deck and create a controlled link in the same workflow.
+- **Agent-native publishing:** the agent publishes the HTML artifact itself; the owner configures controlled links in Pagent's authenticated web app.
- **Creator-controlled friction:** the creator can choose a simple allowed-email gate or require authentication, with explicit identity-confidence labels in analytics.
- **Permission clarity:** external viewer access and internal analytics access are separate, visible policies.
- **Portable automation later:** Pagent's MCP/API foundation can eventually let agents summarize engagement and prepare follow-ups with explicit user authorization.
@@ -540,7 +594,7 @@ Research was performed against first-party product and help documentation availa
### 16.2 Viewer experience
-- At least 95% of valid public-link opens render the first slide within 2 seconds at p75 on a broadband connection.
+- At least 95% of valid public-link opens render presentation content within 2 seconds at p75 on a broadband connection.
- At least 95% of viewers entering a matching email on an Allowed email link reach the deck within 15 seconds.
- At least 90% of pre-approved viewers on an Authenticated viewer link complete authentication and reach the deck within 90 seconds.
- Fewer than 2% of eligible access attempts produce unrecoverable errors.
@@ -550,7 +604,8 @@ Research was performed against first-party product and help documentation availa
- At least 99% of accepted heartbeats are processed idempotently.
- Dashboard totals reconcile with raw eligible visits within 1%.
- Owner previews and recognized link scanners contribute zero human visits.
-- At least 95% of normally closed or backgrounded visits flush slide engagement within 60 seconds.
+- At least 95% of normally closed or backgrounded visits flush engagement within 60 seconds.
+- HTML visits never produce fabricated slide completion or slide rollups.
### 16.4 Trust and control
@@ -562,8 +617,9 @@ Research was performed against first-party product and help documentation availa
### Phase A: Durable presentation-page foundation
-- Durable presentation-page, revision, and slide model.
-- `write` publishing contract with explicit slide boundaries and `read` analytics contract.
+- Durable presentation-page and exact-HTML revision model, retaining legacy slide compatibility.
+- `write` with title/full HTML and `read` analytics, with no additional MCP tools.
+- Protected POST delivery into an opaque-origin sandbox for author interactions.
- Page library, preview, ownership, and archive/delete.
- Workspace and minimal team membership required for internal permissions.
@@ -576,8 +632,8 @@ Research was performed against first-party product and help documentation availa
### Phase C: Analytics
-- Human-visit detection, activity heartbeats, slide engagement, and aggregation.
-- Presentation-page overview, Visitors, Slides, and visit detail.
+- Visit qualification, activity heartbeats, page-level engagement, and aggregation; legacy slide engagement unchanged.
+- Presentation-page overview, Visitors, visit detail, and legacy slide rollups where available.
- Filters and sender attribution.
### Phase D: Internal collaboration
@@ -607,16 +663,16 @@ The release is ready when all of the following are observable in production or a
1. Both MCP transports advertise exactly `write` and `read`; none of the former tool names are registered as aliases.
2. During grace mode, an anonymous caller can write and read a temporary interactive page; durable presentation writes and analytics reads still reject unauthenticated callers.
-3. An authenticated owner can use `write` to publish a 10-slide presentation page, find it in Pages, and preview it without generating a client visit.
+3. An authenticated owner can use `write` with title/full HTML to publish a self-contained interactive presentation, find it in Pages, and preview it without generating a client visit. The exact submitted source is retained in an immutable revision without fabricated slides.
4. The owner can create separate Anyone, Allowed email, and Authenticated viewer links for the same presentation page.
5. A viewer entering a matching address on an Allowed email link reaches the page without a verification code, magic link, or sign-in and is labeled Unverified in analytics.
6. A viewer entering a non-allowed address cannot receive presentation content.
7. A viewer on an Authenticated viewer link cannot receive presentation content before authenticating as an allowed email/user.
8. A non-allowed viewer can request access, and an approved request continues under the link creator's selected authentication setting.
9. Revoking a link prevents an already-open browser from fetching additional protected presentation content after its next authorization check.
-10. A client can navigate the full presentation on desktop and mobile using mouse/touch and keyboard controls.
-11. After a real visit, the sender can use the page analytics UI or authorized `read` to see identity confidence, active time, viewed slides, top slide, furthest slide, last slide, and completion.
-12. Skipping from slide 1 to slide 10 reports furthest slide 10 without reporting 100% completion.
+10. A client can use author-defined HTML controls, including inline JavaScript, on desktop and mobile within the isolated frame. Pagent does not impose slide navigation.
+11. After a real HTML visit, the analytics UI and authorized `read` show identity confidence, visits, active time, `contentFormat`, and `revisionNumbers`. HTML completion/viewed-slides/furthest/last-slide metrics are null, with no slide rollups; mixed completion includes only known legacy slide visits.
+12. Existing legacy REST slide publishing, stored-slide preview, navigation, and analytics still work. Skipping from slide 1 to slide 10 reports furthest slide 10 without reporting 100% completion.
13. Leaving a presentation page open in a background tab does not continue increasing active time.
14. Link preview bots and owner preview sessions do not appear as client visits.
15. A private presentation page's viewer analytics are denied to an ungranted workspace member at both UI and API layers.
@@ -625,19 +681,24 @@ The release is ready when all of the following are observable in production or a
18. Permission changes, access decisions, link revocation, and analytics exports are present in the audit log.
19. `/admin` exposes workspace membership, policy, audit, and page metadata without silently exposing private content or viewer-level analytics.
20. Deleting a presentation page revokes every share link and removes future access to content and analytics according to the retention/deletion policy.
+21. Protected document POSTs require an exact allowlisted Origin and existing authorization, reject stale revisions with `409` and legacy slides with `415`, and send no-store/no-referrer/CSP headers without credentials in URLs or HTML. Allowlisted browser HTML errors preserve the HTTP status and signal `pagent:error`; JSON behavior is unchanged.
+22. Sandbox checks verify account cookies/storage and parent DOM are inaccessible, and unsupported network APIs/assets, forms, nested frames, popups, top navigation, `eval`, and workers are restricted; parent frame policy blocks external navigation.
+23. Temporary document HTML remains sanitized and script-free. Retained immutable snapshots do not imply P1 historical preview/restore is shipped.
+24. HTML is revealed on iframe load even when author CSP blocks the appended activity bridge; missing telemetry does not keep the document hidden or imply zero engagement.
## 19. Risks and mitigations
-| Risk | Impact | Mitigation |
-| ------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
-| Arbitrary HTML has no stable slide boundaries. | Per-slide analytics are inaccurate or impossible. | Require structured presentation slides and keep temporary document HTML outside slide analytics. |
-| Email entry is mistaken for authentication. | A viewer who knows an allowed address may impersonate it. | Label Allowed email as Unverified, warn the creator, and recommend Authenticated viewer for confidential decks. |
-| Background tabs inflate engagement. | Senders make decisions from misleading data. | Visibility, activity, heartbeat, and idle rules; server-side duration validation. |
-| Link scanners create false views. | Noisy notifications and inflated metrics. | Create visits only after visible client execution and human-like interaction/heartbeat. |
-| Workspace admins conflict with private analytics. | Loss of sender/client trust. | Metadata-only admin visibility by default; require explicit analytics grant. |
-| Presentation updates corrupt historical metrics. | Old visits become uninterpretable. | Immutable revisions and stable slide IDs in every event. |
-| Client analytics contain personal data. | Regulatory and trust exposure. | Clear notice/consent control, minimal collection, retention, deletion, audit, and legal review. |
-| The release attempts to build a full data room. | Delayed delivery and unclear product. | Limit v0.1.0 to one deck per share link and defer multi-document rooms. |
+| Risk | Impact | Mitigation |
+| ------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
+| Arbitrary HTML has no stable slide boundaries. | Inferred slide analytics would mislead users. | Report visits/active time only for HTML, null unsupported metrics, and preserve slide analytics only for legacy revisions. |
+| Author JavaScript executes untrusted content. | Parent data or browser capabilities could be exposed. | Opaque-origin sandbox, restrictive CSP, protected POST delivery, exact Origin checks, and no embedded credentials. |
+| Email entry is mistaken for authentication. | A viewer who knows an allowed address may impersonate it. | Label Allowed email as Unverified, warn the creator, and recommend Authenticated viewer for confidential decks. |
+| Background tabs inflate engagement. | Senders make decisions from misleading data. | Visibility, activity, heartbeat, and idle rules; server-side duration validation. |
+| Link scanners create false views. | Noisy notifications and inflated metrics. | Create visits only after visible client execution and human-like interaction/heartbeat. |
+| Workspace admins conflict with private analytics. | Loss of sender/client trust. | Metadata-only admin visibility by default; require explicit analytics grant. |
+| Presentation updates corrupt historical metrics. | Old visits become uninterpretable. | Immutable revision-bound visits and content formats; stable slide IDs only for legacy slide events. |
+| Client analytics contain personal data. | Regulatory and trust exposure. | Clear notice/consent control, minimal collection, retention, deletion, audit, and legal review. |
+| The release attempts to build a full data room. | Delayed delivery and unclear product. | Limit v0.1.0 to one deck per share link and defer multi-document rooms. |
## 20. Open product decisions
@@ -647,7 +708,7 @@ These questions do not block the draft, but should be resolved before engineerin
2. **Access request notifications:** email only for v0.1.0, or in-app inbox plus email? Recommendation: in-app state with email notification.
3. **Consent default:** always show analytics consent, or make it a workspace policy? Recommendation: workspace policy with regionally appropriate defaults, finalized after legal review.
4. **Retention:** is 12 months appropriate for viewer-level data? Recommendation: 12 months, configurable downward, with deletion on request.
-5. **Link revision behavior:** version history and non-destructive restore are required. Should links always follow the latest revision or optionally stay pinned to the version originally sent? Recommendation: follow latest by default and add explicit pinning after the core history flow.
+5. **Link revision behavior:** snapshots are retained; historical preview and non-destructive restore are P1. Links follow latest by default. Consider explicit pinning after the history flow.
6. **Authenticated audience:** should v0.1.0 support only Pagent Google/magic-link accounts, or include enterprise SSO? Recommendation: reuse Pagent auth now and defer SSO.
## 21. Future extensions
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 1ea5cc1..3b30a23 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -3,23 +3,30 @@ info:
title: Pagent API
version: 0.1.0
description: |
- Hosted UI rendering for terminal-bound AI agents. The agent emits an
- A2UI surface to this service, prints a short URL, and reads the user's
- interactions back via this API.
+ Page publishing for AI agents. MCP exposes exactly `write` and `read`:
+ temporary interactive/document pages and durable HTML presentation pages.
+ Sharing, permissions, and management use the authenticated web app and
+ REST API. Internal deck route names are not another product primitive.
- Spec contents are opaque to the service. The `format` field on `POST /new`
- selects how the spec is interpreted:
+ The `format` field on temporary-page `POST /new` selects the renderer:
- `a2ui` (default) — `spec` is an A2UI v0.9 message array. Pages take user
input and produce a result.
- - `html` — `spec` is a UTF-8 HTML string (up to 1,000,000 bytes).
- Pages are view-only: they never transition past `open`, and
+ - `html` — `spec` is a sanitized, script-free UTF-8 HTML string
+ (up to 1,000,000 bytes). Pages are view-only: they never transition past `open`, and
`POST /{id}/result` returns 400 `invalid_for_format`.
+ Durable `POST /v1/decks` accepts `title` and the complete `html` document,
+ preserving the exact source in an immutable revision. Optional
+ `update_deck_id` corresponds to MCP `write`'s `page_id`. Legacy `slides`
+ remain accepted only for shipped clients and records. HTML runs in an
+ opaque-origin sandbox with inline scripts, not the temporary sanitizer.
+ HTML analytics report visits/active time, not inferred slide completion.
+
See https://github.com/blockful/pagent for the source.
- ## Page lifecycle
+ ## Temporary page lifecycle
- A page walks a 3-state machine: `open → submitted → received`.
+ An interactive page walks a 3-state machine: `open → submitted → received`.
- **open** — created, waiting for a browser submission.
- **submitted** — the browser has called `POST /{id}/result`. The agent
@@ -812,6 +819,14 @@ paths:
operationId: publishDeck
tags: [Decks]
summary: Publish a durable deck or immutable revision
+ description: |
+ Publish the submitted HTML document itself, without a Pagent slide
+ structure. HTML source is retained exactly; CSS and inline JavaScript
+ execute only in the protected document sandbox. The `slides` variant
+ is a legacy REST compatibility path and retains sanitization.
+ Exactly one of `html` or `slides` is required. The HTML value is capped
+ at 1,000,000 UTF-8 bytes; the overall HTTP body is also capped at
+ 1,000,000 bytes, so JSON escaping and metadata reduce the usable payload.
security: [{ CookieAuth: [] }, { BearerAuth: [] }]
requestBody:
required: true
@@ -819,10 +834,15 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/PublishDeckRequest' }
responses:
- '200': { description: Existing deck updated with a new revision. }
- '201': { description: Deck and first revision created. }
- '400': { description: Invalid or fully sanitized content. }
+ '200':
+ description: Existing deck updated with a new immutable revision.
+ content: { application/json: { schema: { $ref: '#/components/schemas/PublishedDeck' } } }
+ '201':
+ description: Deck and first revision created.
+ content: { application/json: { schema: { $ref: '#/components/schemas/PublishedDeck' } } }
+ '400': { description: Invalid input or legacy slide content empty after sanitization. }
'401': { description: Authentication required. }
+ '413': { description: Overall HTTP body exceeds 1, 000, 000 bytes. }
/v1/decks/{deckId}:
parameters: [{ $ref: '#/components/parameters/DeckId' }]
@@ -832,7 +852,9 @@ paths:
summary: Get deck metadata, revision history, and recent activity
security: [{ CookieAuth: [] }, { BearerAuth: [] }]
responses:
- '200': { description: Deck detail. }
+ '200':
+ description: Deck detail and retained revision metadata; historical preview/restore is P1.
+ content: { application/json: { schema: { $ref: '#/components/schemas/DeckDetail' } } }
'403': { description: Content access denied. }
patch:
operationId: renameDeck
@@ -870,10 +892,17 @@ paths:
get:
operationId: previewDeck
tags: [Decks]
- summary: Get the latest sanitized revision without recording analytics
+ summary: Get the latest revision without recording analytics
+ description: |
+ HTML revisions return the exact source in `html` and an empty `slides`
+ array. Legacy revisions return `html: null` and their sanitized slides.
+ Render HTML only through the protected owner document route, never in
+ the application's DOM. Earlier revision preview/restore remains P1.
security: [{ CookieAuth: [] }, { BearerAuth: [] }]
responses:
- '200': { description: Latest revision and ordered slides. }
+ '200':
+ description: Latest HTML or legacy slide revision.
+ content: { application/json: { schema: { $ref: '#/components/schemas/DeckPreview' } } }
'403': { description: Content access denied. }
/v1/decks/{deckId}/archive:
@@ -902,7 +931,12 @@ paths:
operationId: getDeckAnalytics
tags: [Analytics]
summary: Get authorized deck engagement analytics
- description: Viewer email filters are deliberately unavailable in URL query parameters; use the JSON query endpoint so raw emails do not enter analytics URLs or access logs.
+ description: |
+ Viewer email filters are deliberately unavailable in URL query
+ parameters; use the JSON query endpoint to keep emails out of URLs and
+ access logs. HTML visits have page-level time only, null slide metrics,
+ and no slide rollups. Mixed-history completion uses only known legacy
+ slide completion, never HTML visits as zero.
security: [{ CookieAuth: [] }, { BearerAuth: [] }]
parameters:
- { name: link_id, in: query, schema: { type: string, format: uuid } }
@@ -911,10 +945,10 @@ paths:
- { name: from, in: query, schema: { type: string, format: date-time } }
- { name: to, in: query, schema: { type: string, format: date-time } }
responses:
- {
- '200': { description: Overview, visitor, slide, and visit-detail analytics. },
- '403': { description: Analytics access denied. },
- }
+ '200':
+ description: Format-aware overview, visitor, legacy slide, and visit analytics.
+ content: { application/json: { schema: { $ref: '#/components/schemas/DeckAnalytics' } } }
+ '403': { description: Analytics access denied. }
/v1/decks/{deckId}/analytics/query:
parameters: [{ $ref: '#/components/parameters/DeckId' }]
@@ -927,10 +961,10 @@ paths:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/AnalyticsFilters' } } }
responses:
- {
- '200': { description: Filtered analytics. },
- '403': { description: Analytics access denied. },
- }
+ '200':
+ description: Filtered analytics with the same format-aware metric semantics as GET.
+ content: { application/json: { schema: { $ref: '#/components/schemas/DeckAnalytics' } } }
+ '403': { description: Analytics access denied. }
/v1/decks/{deckId}/analytics.csv:
parameters: [{ $ref: '#/components/parameters/DeckId' }]
@@ -1358,19 +1392,156 @@ paths:
get:
operationId: getViewerDeck
tags: [Viewer]
- summary: Fetch sanitized deck content for a live viewer session
+ summary: Fetch the latest revision for a live viewer session
+ description: |
+ HTML revisions return exact `html` and an empty `slides` array; legacy
+ revisions return `html: null` and their existing slides. Render HTML
+ only through `POST /v1/viewer/document`, not the parent application's DOM.
parameters: [{ $ref: '#/components/parameters/ViewerSession' }]
responses:
- {
- '200': { description: Ordered slides and identity confidence. },
- '410': { description: Session expired, revoked, or deck deleted. },
- }
+ '200':
+ description: HTML or legacy slide content and identity confidence.
+ content: { application/json: { schema: { $ref: '#/components/schemas/ViewerDeck' } } }
+ '410': { description: Session expired, revoked, or deck deleted. }
+
+ /v1/viewer/document:
+ post:
+ operationId: getViewerDocument
+ tags: [Viewer]
+ summary: Deliver protected HTML into the viewer's isolated frame
+ description: |
+ Submit a form to the document frame using the existing viewer session.
+ The exact Origin header must match an allowlisted renderer origin.
+ No `allowed_origin` body field or new frame token is accepted. All
+ existing Anyone, Allowed email (Unverified), and Authenticated access
+ rules still apply. Credentials must not appear in URLs or served HTML.
+ The requested revision must still be latest; reload revision metadata
+ after 409. The served copy appends a nonvisual activity bridge without
+ changing stored source. Activity messages are best-effort telemetry,
+ not authenticated proof of a human visit. The viewer reveals HTML on
+ iframe load independently of the bridge; author CSP may prevent metrics
+ without hiding the document. For an allowlisted Origin and
+ `Accept: text/html`, errors return generic sandboxed HTML posting
+ `pagent:error`, preserving the HTTP status and no-store policy. JSON
+ clients and rejected-Origin responses keep their existing error bodies.
+ parameters:
+ - { $ref: '#/components/parameters/RendererOrigin' }
+ - { $ref: '#/components/parameters/DocumentAccept' }
+ requestBody:
+ required: true
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ type: object
+ additionalProperties: false
+ required: [session_token, revision_id]
+ properties:
+ session_token: { type: string, minLength: 20, maxLength: 200 }
+ revision_id: { type: string, format: uuid }
+ responses:
+ '200': { $ref: '#/components/responses/HtmlDocument' }
+ '400':
+ { $ref: '#/components/responses/DocumentFrameError', description: Invalid form fields. }
+ '403':
+ description: Missing or non-allowlisted Origin header; remains JSON regardless of Accept.
+ content: { application/json: { schema: { $ref: '#/components/schemas/DocumentError' } } }
+ '404':
+ {
+ $ref: '#/components/responses/DocumentFrameError',
+ description: Viewer session unavailable.,
+ }
+ '409':
+ {
+ $ref: '#/components/responses/DocumentFrameError',
+ description: Requested revision is no longer latest; revision_changed in JSON.,
+ }
+ '410':
+ {
+ $ref: '#/components/responses/DocumentFrameError',
+ description: 'Session or link expired/revoked, or deck deleted.',
+ }
+ '415':
+ {
+ $ref: '#/components/responses/DocumentFrameError',
+ description: Legacy slide revision; unsupported_format in JSON.,
+ }
+ '429':
+ {
+ $ref: '#/components/responses/DocumentFrameError',
+ description: Document delivery rate limit exceeded.,
+ }
+
+ /v1/owner/document:
+ post:
+ operationId: getOwnerDocument
+ tags: [Decks]
+ summary: Deliver protected HTML for an authorized preview without a visit
+ description: |
+ Requires the existing auth cookie or a Bearer token with `page:read`,
+ plus permission to view deck content. No separate frame token is
+ issued. The exact Origin header must match an allowlisted renderer
+ origin; body `allowed_origin` is not accepted. The requested revision
+ must still be latest, and expired/revoked auth or deleted content fails
+ closed. Credentials never appear in URLs or the served document.
+ Immutable snapshots are retained, but historical preview/restore is P1.
+ An allowlisted request accepting `text/html` receives a generic
+ sandboxed HTML error that posts `pagent:error` to the parent, retaining
+ the HTTP status and no-store policy. JSON clients and rejected Origins
+ keep their existing error bodies.
+ security: [{ CookieAuth: [] }, { BearerAuth: [] }]
+ parameters:
+ - { $ref: '#/components/parameters/RendererOrigin' }
+ - { $ref: '#/components/parameters/DocumentAccept' }
+ requestBody:
+ required: true
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ type: object
+ additionalProperties: false
+ required: [deck_id, revision_id]
+ properties:
+ deck_id: { type: string, format: uuid }
+ revision_id: { type: string, format: uuid }
+ responses:
+ '200': { $ref: '#/components/responses/HtmlDocument' }
+ '400':
+ { $ref: '#/components/responses/DocumentFrameError', description: Invalid form fields. }
+ '401':
+ {
+ $ref: '#/components/responses/DocumentFrameError',
+ description: 'Authentication missing, expired, or invalid.',
+ }
+ '403':
+ {
+ $ref: '#/components/responses/DocumentFrameError',
+ description: 'Origin, scope, or content denied; includes deleted content. Rejected Origins remain JSON regardless of Accept.',
+ }
+ '409':
+ {
+ $ref: '#/components/responses/DocumentFrameError',
+ description: Requested revision is no longer latest; revision_changed in JSON.,
+ }
+ '415':
+ {
+ $ref: '#/components/responses/DocumentFrameError',
+ description: Legacy slide revision; unsupported_format in JSON.,
+ }
+ '429':
+ {
+ $ref: '#/components/responses/DocumentFrameError',
+ description: Document delivery rate limit exceeded.,
+ }
/v1/viewer/visits:
post:
operationId: startViewerVisit
tags: [Engagement]
summary: Start a human visit only after visibility and interaction qualification
+ description: |
+ Send `revisionId` to bind a visit to the revision actually displayed,
+ including when a new revision is published while the frame is open.
+ Omission retains the legacy behavior of using the latest revision.
parameters: [{ $ref: '#/components/parameters/ViewerSession' }]
requestBody:
required: true
@@ -1380,9 +1551,7 @@ paths:
{
'200':
{
- description: Visit started,
- excluded as preview or automation,
- or disabled by consent.,
+ description: 'Visit started, excluded as preview or automation, or disabled by consent.',
},
'429': { description: Per-client-IP viewer write limit exceeded. },
}
@@ -1636,6 +1805,17 @@ components:
description: HttpOnly browser session cookie issued after interactive login.
parameters:
+ DocumentAccept:
+ name: Accept
+ in: header
+ description: Including text/html selects a sandboxed HTML error for allowlisted Origins; other clients retain JSON errors.
+ schema: { type: string, example: text/html }
+ RendererOrigin:
+ name: Origin
+ in: header
+ required: true
+ description: Exact allowlisted renderer origin; never derived from a form field.
+ schema: { type: string, format: uri, example: 'https://pagent.link' }
PageId:
name: id
in: path
@@ -1666,6 +1846,50 @@ components:
description: Opaque viewer or owner-preview session token with a maximum seven-day lifetime.
schema: { type: string, minLength: 20 }
+ responses:
+ DocumentFrameError:
+ description: |
+ The HTTP error status is preserved. With an allowlisted Origin and
+ Accept containing text/html, returns generic sandboxed HTML with
+ no-referrer/CSP headers and a pagent:error message to the parent.
+ Otherwise the existing JSON error body is unchanged. An HTML error
+ never includes deck content or credentials.
+ headers:
+ Cache-Control:
+ schema: { type: string, const: 'private, no-store' }
+ content:
+ text/html:
+ schema:
+ { type: string, description: Generic sandboxed error document posting pagent:error. }
+ application/json:
+ schema: { $ref: '#/components/schemas/DocumentError' }
+ HtmlDocument:
+ description: |
+ Full HTML plus an invisible telemetry bridge. Stored UTF-8 source is
+ unchanged. Display is triggered by iframe load, not bridge readiness;
+ author CSP can block the bridge and prevent metrics without hiding
+ content. Serve only in a sandboxed document frame. CSP uses
+ `sandbox allow-scripts` without `allow-same-origin`; author code has no
+ Pagent account-cookie/storage or parent-DOM access. The supported
+ document is self-contained HTML/CSS/inline JavaScript. CSP and sandbox
+ restrict external assets, network APIs, forms, nested frames, popups,
+ top navigation, eval, and workers. Parent frame policy blocks external
+ navigation; this is not a blanket guarantee against any attempted
+ browser network request.
+ headers:
+ Cache-Control:
+ schema: { type: string, const: 'private, no-store' }
+ Referrer-Policy:
+ schema: { type: string, const: no-referrer }
+ Content-Security-Policy:
+ description: Restrictive document CSP including sandbox and allowlisted frame ancestors.
+ schema: { type: string }
+ X-Content-Type-Options:
+ schema: { type: string, const: nosniff }
+ content:
+ text/html:
+ schema: { type: string }
+
headers:
XRequestId:
description: >
@@ -1682,6 +1906,12 @@ components:
minimum: 1
schemas:
+ DocumentError:
+ type: object
+ required: [error]
+ description: Existing JSON error shape; extra fields depend on the error, such as retry_after_seconds.
+ properties:
+ error: { type: string }
WorkspaceMember:
type: object
additionalProperties: false
@@ -1781,6 +2011,33 @@ components:
retentionDays: { type: integer, minimum: 30, maximum: 2555 }
PublishDeckRequest:
+ description: Publish full HTML; legacy slides remain a REST-only compatibility form.
+ oneOf:
+ - $ref: '#/components/schemas/HtmlPublishDeckRequest'
+ - $ref: '#/components/schemas/LegacySlidesPublishDeckRequest'
+
+ HtmlPublishDeckRequest:
+ type: object
+ additionalProperties: false
+ required: [title, html]
+ properties:
+ title: { type: string, minLength: 1, maxLength: 160 }
+ description: { type: string, maxLength: 2000 }
+ client_label: { type: string, maxLength: 160 }
+ update_deck_id: { type: string, format: uuid }
+ html:
+ type: string
+ minLength: 1
+ maxLength: 1000000
+ description: |
+ Exact full HTML source, capped at 1,000,000 UTF-8 bytes without
+ trimming or sanitization. The overall HTTP body has the same byte
+ cap, so JSON escaping and metadata can lower the effective limit.
+ Use self-contained CSS/inline JavaScript; see protected document
+ delivery for sandbox restrictions. No slide structure is inferred.
+
+ LegacySlidesPublishDeckRequest:
+ description: Compatibility with shipped REST clients; not the advertised MCP write contract.
type: object
additionalProperties: false
required: [title, slides]
@@ -1798,10 +2055,296 @@ components:
additionalProperties: false
required: [id, html]
properties:
- id: { type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$' }
+ id: { type: string, maxLength: 120, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$' }
title: { type: string, maxLength: 200 }
html: { type: string, minLength: 1, maxLength: 1000000 }
+ PublishedDeck:
+ type: object
+ required: [deckId, revisionId, revisionNumber]
+ properties:
+ deckId: { type: string, format: uuid }
+ revisionId: { type: string, format: uuid }
+ revisionNumber: { type: integer, minimum: 1 }
+
+ DeckDetail:
+ type: object
+ required:
+ [
+ id,
+ title,
+ description,
+ clientLabel,
+ status,
+ ownerId,
+ ownerEmail,
+ analyticsVisibility,
+ latestRevisionNumber,
+ createdAt,
+ updatedAt,
+ lastPublishedAt,
+ revisions,
+ recentActivity,
+ ]
+ properties:
+ id: { type: string, format: uuid }
+ title: { type: string }
+ description: { type: [string, 'null'] }
+ clientLabel: { type: [string, 'null'] }
+ status: { type: string, enum: [active, archived] }
+ ownerId: { type: string, format: uuid }
+ ownerEmail: { type: string, format: email }
+ analyticsVisibility: { type: string, enum: [private, selected, team, workspace] }
+ latestRevisionNumber: { type: integer, minimum: 1 }
+ createdAt: { type: string, format: date-time }
+ updatedAt: { type: string, format: date-time }
+ lastPublishedAt: { type: string, format: date-time }
+ revisions:
+ type: array
+ description: Retained immutable revision metadata, newest first; not a historical preview/restore endpoint.
+ items:
+ type: object
+ required: [id, revisionNumber, contentFormat, slideCount, createdByEmail, createdAt]
+ properties:
+ id: { type: string, format: uuid }
+ revisionNumber: { type: integer, minimum: 1 }
+ contentFormat: { type: string, enum: [html, slides] }
+ slideCount:
+ {
+ type: integer,
+ minimum: 0,
+ description: Zero for HTML revisions; no slides are inferred.,
+ }
+ createdByEmail: { type: string, format: email }
+ createdAt: { type: string, format: date-time }
+ recentActivity:
+ type: array
+ items:
+ type: object
+ required: [id, action, actorEmail, createdAt]
+ properties:
+ id: { type: string, format: uuid }
+ action: { type: string }
+ actorEmail: { type: [string, 'null'], format: email }
+ createdAt: { type: string, format: date-time }
+
+ DeckSlide:
+ type: object
+ description: Existing sanitized legacy slide; HTML revisions never fabricate these records.
+ required: [id, stableSlideId, ordinal, title, html]
+ properties:
+ id: { type: string, format: uuid }
+ stableSlideId: { type: string }
+ ordinal: { type: integer, minimum: 1 }
+ title: { type: [string, 'null'] }
+ html: { type: string }
+
+ DeckPreview:
+ type: object
+ required: [deckId, title, revisionId, revisionNumber, html, slides]
+ properties:
+ deckId: { type: string, format: uuid }
+ title: { type: string }
+ revisionId: { type: string, format: uuid }
+ revisionNumber: { type: integer, minimum: 1 }
+ html:
+ type: [string, 'null']
+ description: Exact source for HTML revisions; null for legacy slides.
+ slides:
+ type: array
+ description: Empty for HTML revisions.
+ items: { $ref: '#/components/schemas/DeckSlide' }
+
+ ViewerDeck:
+ type: object
+ required:
+ [
+ deckId,
+ deckTitle,
+ revisionId,
+ revisionNumber,
+ html,
+ shareLinkId,
+ viewerSessionId,
+ identityConfidence,
+ preview,
+ slides,
+ ]
+ properties:
+ deckId: { type: string, format: uuid }
+ deckTitle: { type: string }
+ revisionId: { type: string, format: uuid }
+ revisionNumber: { type: integer, minimum: 1 }
+ html:
+ type: [string, 'null']
+ description: Exact source for HTML revisions; null for legacy slides.
+ shareLinkId: { type: string, format: uuid }
+ viewerSessionId: { type: string, format: uuid }
+ identityConfidence: { type: string, enum: [anonymous, unverified, authenticated] }
+ preview: { type: boolean }
+ slides:
+ type: array
+ description: Empty for HTML revisions.
+ items: { $ref: '#/components/schemas/DeckSlide' }
+
+ DeckAnalytics:
+ type: object
+ required: [owner, contentFormat, revisionNumbers, overview, visitors, slides, visits]
+ properties:
+ owner:
+ type: object
+ required: [id, email]
+ properties:
+ id: { type: string, format: uuid }
+ email: { type: string, format: email }
+ contentFormat:
+ type: string
+ enum: [html, slides, mixed]
+ description: Formats among retained revisions matching the revision filter, including revisions without matching visits.
+ revisionNumbers:
+ type: array
+ description: Retained revisions matching the revision filter, newest first; includes revisions without matching visits.
+ items: { type: integer, minimum: 1 }
+ overview:
+ type: object
+ required:
+ [
+ totalVisits,
+ uniqueViewers,
+ lastViewed,
+ averageActiveTimeMs,
+ averageCompletion,
+ topSlide,
+ ]
+ properties:
+ totalVisits: { type: integer, minimum: 0 }
+ uniqueViewers: { type: integer, minimum: 0 }
+ lastViewed: { type: [string, 'null'], format: date-time }
+ averageActiveTimeMs: { type: number, minimum: 0 }
+ averageCompletion:
+ type: [number, 'null']
+ minimum: 0
+ maximum: 1
+ description: Known legacy slide completions only; HTML visits are excluded, not zero. Null for HTML-only scope.
+ topSlide:
+ oneOf:
+ - $ref: '#/components/schemas/SlideRollup'
+ - type: 'null'
+ visitors:
+ type: array
+ items:
+ type: object
+ required:
+ [
+ viewer,
+ identityConfidence,
+ firstVisit,
+ lastVisit,
+ visits,
+ totalActiveTimeMs,
+ maximumCompletion,
+ ]
+ properties:
+ viewer: { type: string }
+ identityConfidence: { type: string, enum: [anonymous, unverified, authenticated] }
+ firstVisit: { type: string, format: date-time }
+ lastVisit: { type: string, format: date-time }
+ visits: { type: integer, minimum: 1 }
+ totalActiveTimeMs: { type: number, minimum: 0 }
+ maximumCompletion:
+ type: [number, 'null']
+ minimum: 0
+ maximum: 1
+ description: Null when no legacy slide completion is known.
+ slides:
+ type: array
+ description: Legacy slide rollups only; empty for HTML-only scope.
+ items: { $ref: '#/components/schemas/SlideRollup' }
+ visits:
+ type: array
+ items: { $ref: '#/components/schemas/AnalyticsVisit' }
+
+ SlideRollup:
+ type: object
+ required:
+ [
+ slideId,
+ stableSlideId,
+ revisionNumber,
+ ordinal,
+ title,
+ uniqueViewers,
+ viewRate,
+ averageActiveTimeMs,
+ totalActiveTimeMs,
+ exits,
+ ]
+ properties:
+ slideId: { type: string, format: uuid }
+ stableSlideId: { type: string }
+ revisionNumber: { type: integer, minimum: 1 }
+ ordinal: { type: integer, minimum: 1 }
+ title: { type: [string, 'null'] }
+ uniqueViewers: { type: integer, minimum: 0 }
+ viewRate: { type: number, minimum: 0, maximum: 1 }
+ averageActiveTimeMs: { type: number, minimum: 0 }
+ totalActiveTimeMs: { type: number, minimum: 0 }
+ exits: { type: integer, minimum: 0 }
+
+ AnalyticsVisit:
+ type: object
+ required:
+ [
+ id,
+ viewer,
+ identityConfidence,
+ startedAt,
+ lastActivityAt,
+ linkId,
+ linkName,
+ senderId,
+ senderEmail,
+ revisionNumber,
+ totalActiveTimeMs,
+ contentFormat,
+ viewedSlides,
+ completion,
+ furthestSlide,
+ lastSlide,
+ sequenceComplete,
+ slideSequence,
+ ]
+ properties:
+ id: { type: string, format: uuid }
+ viewer: { type: string }
+ identityConfidence: { type: string, enum: [anonymous, unverified, authenticated] }
+ startedAt: { type: string, format: date-time }
+ lastActivityAt: { type: string, format: date-time }
+ linkId: { type: string, format: uuid }
+ linkName: { type: string }
+ senderId: { type: string, format: uuid }
+ senderEmail: { type: string, format: email }
+ revisionNumber: { type: integer, minimum: 1 }
+ totalActiveTimeMs: { type: number, minimum: 0 }
+ contentFormat: { type: string, enum: [html, slides] }
+ viewedSlides: { type: [integer, 'null'], minimum: 0, description: Null for HTML. }
+ completion: { type: [number, 'null'], minimum: 0, maximum: 1, description: Null for HTML. }
+ furthestSlide: { type: [integer, 'null'], minimum: 0, description: Null for HTML. }
+ lastSlide: { type: [integer, 'null'], minimum: 0, description: Null for HTML. }
+ sequenceComplete: { type: boolean }
+ slideSequence:
+ type: array
+ description: Empty for HTML visits.
+ items:
+ type: object
+ required: [slideId, ordinal, title, activeDurationMs, viewCount]
+ properties:
+ slideId: { type: string, format: uuid }
+ ordinal: { type: integer, minimum: 1 }
+ title: { type: [string, 'null'] }
+ activeDurationMs: { type: number, minimum: 0 }
+ viewCount: { type: integer, minimum: 0 }
+
ShareLinkRequest:
type: object
additionalProperties: false
@@ -1842,6 +2385,10 @@ components:
additionalProperties: false
required: [visible, interacted, analyticsConsent, deviceClass, browserFamily, countryCode]
properties:
+ revisionId:
+ type: string
+ format: uuid
+ description: Displayed revision to bind this visit to; omitted by legacy clients to use latest.
visible:
{ type: boolean, description: True only when the viewer is at least 50 percent visible. }
interacted: { type: boolean }
@@ -1876,7 +2423,12 @@ components:
properties:
id: { type: string, format: uuid, description: Idempotency key. }
eventType: { type: string, enum: [start, heartbeat, slide_view, close] }
- slideId: { type: string, format: uuid }
+ slideId:
+ {
+ type: string,
+ format: uuid,
+ description: Legacy slide events only; omit for HTML visit-level events.,
+ }
eventAt: { type: string, format: date-time }
sequence: { type: integer, minimum: 0 }
visibleRatio: { type: number, minimum: 0, maximum: 1 }
@@ -1885,9 +2437,7 @@ components:
recentlyActive:
{
type: boolean,
- description: False after 60 seconds without mouse,
- touch,
- or keyboard activity.,
+ description: 'False after 60 seconds without mouse, touch, or keyboard activity.',
}
OAuthError:
diff --git a/skills/pagent/SKILL.md b/skills/pagent/SKILL.md
index c85a325..432b3e6 100644
--- a/skills/pagent/SKILL.md
+++ b/skills/pagent/SKILL.md
@@ -101,7 +101,9 @@ submission. Use `interactive` when a user must send anything back.
### Presentation
-Call `write` with stable slide IDs and explicit slide boundaries:
+Call `write` with a title and the complete HTML document. The submitted file
+is the presentation; do not convert it into Pagent slides or add a Pagent
+template/navigation layer:
```json
{
@@ -109,17 +111,27 @@ Call `write` with stable slide IDs and explicit slide boundaries:
"title": "Acme proposal",
"description": "September review",
"client_label": "Acme",
- "slides": [
- { "id": "intro", "title": "Introduction", "html": "Acme " },
- { "id": "plan", "title": "Plan", "html": "Next steps " }
- ]
+ "html": "Acme proposal Acme proposal Show next step Schedule a review.
"
}
```
The result includes `page_id`, `manage_url`, and `preview_url`. To publish a
new immutable revision, call `write` again with the same presentation fields
-plus that `page_id`. Keep slide IDs stable across revisions when the logical
-slide is unchanged; analytics use them to preserve meaning.
+plus that `page_id`. The exact UTF-8 HTML source is retained; the served copy
+adds only invisible activity instrumentation. Retained snapshots do not yet
+provide a version-history preview or restore UI; those are P1.
+
+The viewer displays the HTML on iframe load, independently of the optional
+activity bridge. Author CSP can block that bridge and prevent metrics without
+hiding the document; do not promise complete engagement data for every HTML file.
+
+Use self-contained HTML, CSS, and inline JavaScript. Author code runs in an
+opaque-origin `allow-scripts` sandbox without access to Pagent cookies, storage,
+or the parent DOM. Do not rely on external assets, network APIs, forms, nested
+frames, popups, top navigation, `eval`, or workers. The sandbox and CSP restrict
+these capabilities, and the parent frame policy blocks external navigation.
+This is distinct from temporary `document` HTML, which remains sanitized and
+script-free. Use `interactive` if a structured response must return to the agent.
Presentation creation does not implicitly create or configure a share link.
Open `manage_url` to set Anyone, Allowed email, or Authenticated viewer access.
@@ -148,6 +160,14 @@ If a temporary page is missing or expired, write a new page instead of retrying
the same ID forever. Presentation analytics require authentication and the
owner, sender, or an explicitly granted workspace permission.
+For HTML presentations, report visits and active time only. Completion,
+viewed-slides, furthest-slide, and last-slide metrics are null, with no slide
+rollups. `contentFormat` (`html`, `slides`, or `mixed`) and `revisionNumbers`
+describe the analytics scope. In mixed history, completion excludes HTML
+visits and uses only known legacy slide completion; do not treat null as zero.
+Existing slide revisions and legacy REST publishing remain compatible, but
+the advertised `write` presentation input is HTML.
+
## Access and identity
- **Anyone** may open the share link; analytics label unknown viewers Anonymous.
diff --git a/tests/e2e/presentation-html-loading.spec.ts b/tests/e2e/presentation-html-loading.spec.ts
new file mode 100644
index 0000000..8c51c23
--- /dev/null
+++ b/tests/e2e/presentation-html-loading.spec.ts
@@ -0,0 +1,104 @@
+import { randomUUID } from 'node:crypto';
+import { mkdir } from 'node:fs/promises';
+import { expect, request, test, type APIRequestContext } from '@playwright/test';
+import { z } from 'zod';
+import { createSession } from '../../apps/api/auth/session.ts';
+import * as db from '../../apps/api/db.ts';
+
+const apiUrl = process.env.E2E_API_URL ?? 'http://127.0.0.1:8787';
+const evidence = '.omo/evidence/html-document-decks/browser';
+let ownerApi: APIRequestContext;
+
+test.beforeAll(async () => {
+ await mkdir(evidence, { recursive: true });
+ await db.init(z.string().url().parse(process.env.DATABASE_URL));
+ const user = await db.upsertUser({
+ email: `html-loading-${randomUUID()}@example.test`,
+ handle: `loading-${randomUUID()}`,
+ name: 'HTML Loading Owner',
+ avatarUrl: null,
+ });
+ const session = await createSession(user.id);
+ ownerApi = await request.newContext({
+ baseURL: apiUrl,
+ extraHTTPHeaders: { Cookie: `pagent_session=${session}` },
+ });
+});
+
+test.afterAll(async () => {
+ await ownerApi?.dispose();
+ await db.shutdown();
+});
+
+async function publish(html: string) {
+ const response = await ownerApi.post('/v1/decks', {
+ data: { title: 'Author HTML policy', html },
+ });
+ expect(response.status()).toBe(201);
+ const { deckId } = z.object({ deckId: z.string().uuid() }).parse(await response.json());
+ const link = await ownerApi.post(`/v1/decks/${deckId}/share-links`, {
+ data: { name: 'Author policy', access_mode: 'anyone' },
+ });
+ expect(link.status()).toBe(201);
+ return {
+ deckId,
+ ...z.object({ id: z.string().uuid(), token: z.string() }).parse(await link.json()),
+ };
+}
+
+test('author CSP may block telemetry without blocking authored HTML or JavaScript', async ({
+ page,
+}) => {
+ const link = await publish(`
+
+ Author policy works Run authored JavaScript
+
+ `);
+ await page.goto(`/share/${link.token}`);
+ const frame = page.frameLocator('document-frame iframe');
+ await frame.getByRole('button', { name: 'Run authored JavaScript' }).click();
+ await expect(frame.getByRole('heading', { name: 'Author script ran' })).toBeVisible();
+ await expect(page.getByRole('alert')).toHaveCount(0);
+ await expect(page.getByText('Opening page…', { exact: true })).toHaveCount(0);
+ for (const width of [375, 768, 1280]) {
+ await page.setViewportSize({ width, height: 900 });
+ await page.screenshot({ path: `${evidence}/author-policy-${width}.png` });
+ }
+});
+
+test('a document consuming the appended script as raw text still remains visible', async ({
+ page,
+}) => {
+ const link = await publish(
+ 'Raw text document