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

" +} +``` + +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 = + '\n
Exact 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 = + '\n
Olá
\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 = + '\n
Olá
\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'; 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`
- + - - - + ${showSlides + ? html` + + ` + : nothing} @@ -239,20 +197,16 @@ function renderVisitTable(analytics: DeckAnalytics): TemplateResult { - + - - - + ${showSlides + ? html` + + ` + : nothing} `, )}
Viewer SenderSequence${showSlides ? 'Sequence' : 'Document'} ActiveCompletionFurthestLastCompletionFurthestLast
${visit.senderEmail}
${visit.linkName}
${visitSequence(visit)}${visitSequence(visit)} ${duration(visit.totalActiveTimeMs)}${percent(visit.completion)}${visit.furthestSlide ?? '—'}${visit.lastSlide ?? '—'}${percent(visit.completion)}${visit.furthestSlide ?? '—'}${visit.lastSlide ?? '—'}
`; } - -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)}
-
-
- - Slide ${slideCount === 0 ? 0 : input.slideIndex + 1} of ${slideCount} - -
+ ${input.preview?.html != null + ? html`
+ +
` + : html`
+
+ ${slide === undefined ? nothing : unsafeHTML(slide.html)} +
+
+
+ + Slide ${slideCount === 0 ? 0 : input.slideIndex + 1} of ${slideCount} + +
`}
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`