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..fd5514d 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
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`.
@@ -135,7 +174,7 @@ Each app validates its environment at boot/build with Zod and fails loudly on mi
| | `NODE_EXTRA_CA_CERTS` | Supabase in production | Path to the bundled Supabase root CA so `verify-full` can validate the pooler certificate. |
| | `PUBLIC_URL` | **production** | HTTPS renderer origin. Used in `write` responses. |
| | `API_PUBLIC_URL` | **production** | HTTPS API origin. Used for OAuth issuer, callbacks, magic links, and discovery metadata. |
-| | `ALLOWED_ORIGINS` | **production** | Comma-separated origin list. CORS allow-list. |
+| | `ALLOWED_ORIGINS` | **HTML frames / prod** | Exact renderer origins for CORS and protected HTML frames. Required for HTML in every environment, e.g. `http://localhost:8788` locally. |
| | `PORT` | optional | Coerced to number. Default `8787`. Railway sets this. |
| | `PAGE_TTL_MS` | optional | Coerced to number. Default `1800000` (30 min). |
| | `RATE_LIMIT_MAX` | optional | Positive integer. Default `30`; caps writes and viewer starts per IP. Events: 4x/IP, 30/session/window, 10/request. |
@@ -289,7 +328,7 @@ must deploy from the repository root because it uses npm workspaces and serves
3. Set environment variables (see `apps/api/.env.example`):
- `PUBLIC_URL` — the Vercel URL of `apps/web` (e.g. `https://pagent.link`). Used in MCP `write` responses. **Required in production.** Boot fails loudly if missing.
- `API_PUBLIC_URL` — the Railway public origin of `apps/api` (e.g. `https://api.pagent.link`). Used for OAuth issuer/discovery, default Google callbacks, magic links, and MCP auth metadata. **Required in production.** Must be HTTPS.
- - `ALLOWED_ORIGINS` — comma-separated origins allowed to call the API (set to your Vercel URL). **Required in production.** API boot fails loudly if missing.
+ - `ALLOWED_ORIGINS` — comma-separated exact renderer origins (set to your Vercel origin). **Required for HTML frames in every environment**; use the actual browser origin, e.g. `http://localhost:8788` locally. Missing/mismatched origins return 403. Production API boot also fails if missing.
- `PORT` — Railway sets this automatically; the server reads it.
- `PAGE_TTL_MS` — optional; default 30 minutes.
- `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MS` — optional. Per-IP limits for temporary writes and public viewer access/visit starts. Engagement delivery allows 4x the base cap per IP, 30 requests per viewer session, and at most 10 events per request. Defaults: 30 / 60000.
@@ -411,6 +450,12 @@ in Grafana from the trace and log streams.
### Rollback
+After HTML migration 4 has run, prefer a forward fix. A pre-HTML API rejects the
+applied migration and cannot read HTML revisions. Any revert must preserve migration
+4's registration and HTML storage/read compatibility; never delete the migration
+ledger entry, column, or saved content. Verify API, renderer, and rebuilt MCP together
+against migrated legacy and HTML data. See [the release runbook](docs/RELEASING.md#rollback).
+
The MCP plugin marketplace tracks `main`. Rolling back means reverting the bad
commit on `main`; Railway and Vercel auto-redeploy on push.
@@ -444,7 +489,7 @@ behaviour without touching code. `apps/api/.env.example` is the source of truth.
| `PUBLIC_URL` | _(required in prod)_ | Base URL of the renderer, returned in MCP `write` responses. Redeploy required after change. |
| `API_PUBLIC_URL` | _(required in prod)_ | API origin used for OAuth issuer, callbacks, magic links, and MCP discovery. Restart required. |
| `PAGE_TTL_MS` | `1800000` (30 min) | How long a page lives before expiring. Raising it keeps pages alive longer but grows the DB. |
-| `ALLOWED_ORIGINS` | _(required in prod)_ | Comma-separated origins the CORS middleware allows. Add an origin here and restart — no redeploy. |
+| `ALLOWED_ORIGINS` | _(required for HTML)_ | Exact renderer origins for CORS and protected HTML frames in all environments. Update and restart the API. Production boot also requires this setting. |
| `RATE_LIMIT_MAX` | `30` | Base per-IP write cap for temporary pages and public viewer access/visit starts; event delivery uses 4x per IP plus a 30-request viewer-session cap and 10-event batches. |
| `RATE_LIMIT_WINDOW_MS` | `60000` (60 s) | Shared rolling window for the write and viewer limits. |
| `TRUSTED_PROXY_MODE` | _(required in prod)_ | Set to `railway` to use Railway's `X-Real-IP` for abuse limits; `X-Forwarded-For` is ignored. |
diff --git a/apps/api/.env.example b/apps/api/.env.example
index 677795e..ce2dd09 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -22,9 +22,11 @@ API_PUBLIC_URL=http://localhost:8787
# How long pages live, in milliseconds (default 30 minutes).
PAGE_TTL_MS=1800000
-# Comma-separated list of origins allowed to call the API. Leave unset for dev (allows all).
-# Required when NODE_ENV=production — boot will fail loudly otherwise. Set to your Vercel URL (e.g. https://pagent.link).
-ALLOWED_ORIGINS=
+# Comma-separated exact renderer origins for CORS and protected HTML document frames.
+# HTML frames require this in EVERY environment; missing or mismatched origins return 403.
+# Use the browser's actual origin (localhost and 127.0.0.1 differ). Production boot also
+# requires this setting; set to your Vercel origin, e.g. https://pagent.link.
+ALLOWED_ORIGINS=http://localhost:8788
# Supabase Postgres connection string (Session pooler, port 5432). In
# production, sslmode=disable is rejected; the API always uses verify-full.
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..e15a9a3 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) {
@@ -127,23 +153,42 @@ export function aggregateDeckAnalytics(input: AnalyticsAggregationInput): DeckAn
accumulator.exits += 1;
}
const uniqueViewerCount = new Set(visitRows.map(viewerKey)).size;
- const slides: SlideRollup[] = [...slideAccumulators.values()].map((accumulator) => ({
- slideId: accumulator.definition.id,
- stableSlideId: accumulator.definition.stableSlideId,
- revisionNumber: accumulator.definition.revisionNumber,
- ordinal: accumulator.definition.ordinal,
- title: accumulator.definition.title,
- uniqueViewers: accumulator.viewers.size,
- viewRate: uniqueViewerCount === 0 ? 0 : accumulator.viewers.size / uniqueViewerCount,
- averageActiveTimeMs:
- accumulator.qualifiedVisits === 0
- ? 0
- : accumulator.activeDurationMs / accumulator.qualifiedVisits,
- totalActiveTimeMs: accumulator.activeDurationMs,
- exits: accumulator.exits,
- }));
+ const viewersByRevision = new Map>();
+ for (const visit of visitRows) {
+ if ((visit.contentFormat ?? 'slides') !== 'slides') continue;
+ const viewers = viewersByRevision.get(visit.revisionNumber) ?? new Set();
+ viewers.add(viewerKey(visit));
+ viewersByRevision.set(visit.revisionNumber, viewers);
+ }
+ const slides: SlideRollup[] = [...slideAccumulators.values()].map((accumulator) => {
+ const eligibleViewers = viewersByRevision.get(accumulator.definition.revisionNumber)?.size ?? 0;
+ return {
+ slideId: accumulator.definition.id,
+ stableSlideId: accumulator.definition.stableSlideId,
+ revisionNumber: accumulator.definition.revisionNumber,
+ ordinal: accumulator.definition.ordinal,
+ title: accumulator.definition.title,
+ uniqueViewers: accumulator.viewers.size,
+ viewRate: eligibleViewers === 0 ? 0 : accumulator.viewers.size / eligibleViewers,
+ averageActiveTimeMs:
+ accumulator.qualifiedVisits === 0
+ ? 0
+ : accumulator.activeDurationMs / accumulator.qualifiedVisits,
+ totalActiveTimeMs: accumulator.activeDurationMs,
+ 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 +198,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..3e864be
--- /dev/null
+++ b/apps/api/decks/analytics-html.test.ts
@@ -0,0 +1,165 @@
+import { describe, expect, it } from 'vitest';
+import { aggregateDeckAnalytics } from './analytics-aggregate.ts';
+import { analyticsInput } from './analytics-aggregate-fixture.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.each([
+ { revisionNumber: 2, contentFormat: 'html' as const },
+ { revisionNumber: 3, contentFormat: 'slides' as const },
+ ])('scopes legacy slide view rates to eligible revision viewers: %j', (revision) => {
+ const input = {
+ ...analyticsInput,
+ visitRows: [
+ ...analyticsInput.visitRows,
+ { ...htmlVisit, ...revision, viewerEmail: 'other-revision@example.test' },
+ ],
+ eventRows: [],
+ };
+ const result = aggregateDeckAnalytics(input);
+ expect(result.overview.uniqueViewers).toBe(3);
+ expect(result.slides.map((slide) => slide.viewRate)).toEqual([1, 0.5]);
+ });
+
+ 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..59555f6
--- /dev/null
+++ b/apps/api/decks/domain-html.test.ts
@@ -0,0 +1,57 @@
+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', ''],
+ ['a NUL byte', 'Before\u0000after'],
+ ['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..33fd37f 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,27 @@ 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`,
+ })
+ .refine((html) => !html.includes('\u0000'), { message: 'HTML cannot contain NUL bytes' }),
+ })
+ .strict();
+
+const slidesPublishDeckBodySchema = deckMetadataSchema
+ .extend({
slides: z
.array(
z
@@ -41,7 +57,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: '
- 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`