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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/pagent_e2e?sslmode=disable
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/pagent_e2e?sslmode=verify-full
PAGENT_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable
steps:
- uses: actions/checkout@v4
Expand All @@ -52,6 +52,31 @@ jobs:
- name: Audit dependencies
run: npm audit --audit-level=high

- name: Configure trusted PostgreSQL TLS for production-mode tests
env:
PAGENT_POSTGRES_CONTAINER: ${{ job.services.postgres.id }}
PGPASSWORD: postgres
PGSSLMODE: disable
run: |
pagent_tls_dir=$(mktemp -d)
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$pagent_tls_dir/server.key" -out "$pagent_tls_dir/server.crt" \
-days 1 -subj '/CN=localhost' -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1'
docker cp "$pagent_tls_dir/server.key" "$PAGENT_POSTGRES_CONTAINER:/tmp/pagent-test.key"
docker cp "$pagent_tls_dir/server.crt" "$PAGENT_POSTGRES_CONTAINER:/tmp/pagent-test.crt"
docker exec --user root "$PAGENT_POSTGRES_CONTAINER" chown postgres:postgres /tmp/pagent-test.key /tmp/pagent-test.crt
docker exec --user root "$PAGENT_POSTGRES_CONTAINER" chmod 600 /tmp/pagent-test.key
psql --host=127.0.0.1 --port=5432 --username=postgres --dbname=postgres \
-c "ALTER SYSTEM SET ssl_cert_file = '/tmp/pagent-test.crt'" \
-c "ALTER SYSTEM SET ssl_key_file = '/tmp/pagent-test.key'" \
-c "ALTER SYSTEM SET ssl = 'on'" -c 'SELECT pg_reload_conf()'
echo "NODE_EXTRA_CA_CERTS=$pagent_tls_dir/server.crt" >> "$GITHUB_ENV"
echo "PGSSLROOTCERT=$pagent_tls_dir/server.crt" >> "$GITHUB_ENV"

- name: Verify production test database TLS
run: |
test "$(psql "$DATABASE_URL" -XAtc 'select ssl from pg_stat_ssl where pid = pg_backend_pid()')" = t

- name: Prepare deck integration databases
env:
PGPASSWORD: postgres
Expand All @@ -61,6 +86,8 @@ jobs:
pagent_deck_schema_d3a0_test \
pagent_deck_routes_permissions_d3a0_test \
pagent_deck_engagement_d3a0_test \
pagent_deck_engagement_concurrent_d3a0_test \
pagent_engagement_readiness_20260922_test \
pagent_deck_permissions_d3a0_test \
pagent_deck_sharing_d3a0_test \
pagent_deck_routes_sharing_d3a0_test \
Expand Down
12 changes: 7 additions & 5 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ OTEL_SERVICE_NAME=pagent-api
LOG_LEVEL=info

# Per-client-IP, in-memory write limit for temporary page creation and public
# viewer access/visit starts. Engagement event delivery receives 20x this cap.
# viewer access/visit starts. Engagement delivery allows 4x this IP cap, plus
# 30 requests per viewer session per window and at most 10 events per request.
RATE_LIMIT_MAX=30
RATE_LIMIT_WINDOW_MS=60000

Expand All @@ -74,15 +75,16 @@ RATE_LIMIT_WINDOW_MS=60000
TRUSTED_PROXY_MODE=

# --- Auth (OAuth 2.1 + sessions) ---------------------------------------------
# Auth enforcement is behind the REQUIRE_AUTH toggle. While REQUIRE_AUTH=false,
# protected page operations accept anonymous requests. OAuth routes remain
# mounted; flows needing unconfigured providers (such as SMTP) fail closed.
# Durable presentation writes and analytics reads always require authentication.
# REQUIRE_AUTH=false permits anonymous temporary page writes and response reads
# during rollout grace mode. Invalid credentials still fail closed. OAuth routes
# remain mounted; flows needing unconfigured providers (such as SMTP) fail closed.
# When REQUIRE_AUTH=true, the following are required at boot or the server
# exits with a clear error: JWT_SIGNING_KEY, JWT_PUBLIC_KEY,
# AUTH_STATE_SECRET, SMTP_HOST, SMTP_USER, SMTP_PASS.
# See docs/superpowers/specs/2026-05-17-auth-design.md.

# Master switch. Set to true to require auth on mutation endpoints (e.g. POST /new).
# Set to true to also require auth for temporary pages (e.g. POST /new).
# Leave false during the grace-period rollout.
REQUIRE_AUTH=false

Expand Down
12 changes: 11 additions & 1 deletion apps/api/decks/analytics-aggregate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,15 @@ describe('deck analytics aggregation', () => {
};

// When
const analytics = aggregateDeckAnalytics(analyticsInput);
const eventRows = analyticsInput.engagementRows.map((row) => ({
visitId: row.visitId,
slideId: row.id,
eventAt: firstStartedAt,
sequence: row.firstSequence,
activeDurationMs: row.activeDurationMs,
qualified: row.qualified,
}));
const analytics = aggregateDeckAnalytics({ ...analyticsInput, eventRows });

// Then
expect(analytics).toEqual({
Expand Down Expand Up @@ -202,6 +210,7 @@ describe('deck analytics aggregation', () => {
completion: 1,
furthestSlide: 2,
lastSlide: 1,
sequenceComplete: true,
slideSequence: [
{
slideId: 'slide-2',
Expand Down Expand Up @@ -235,6 +244,7 @@ describe('deck analytics aggregation', () => {
completion: 0.5,
furthestSlide: 1,
lastSlide: 1,
sequenceComplete: true,
slideSequence: [
{
slideId: 'slide-1',
Expand Down
28 changes: 18 additions & 10 deletions apps/api/decks/analytics-aggregate.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type {
AnalyticsAggregationInput,
AnalyticsEngagementRow,
AnalyticsEventRow,
AnalyticsVisitRow,
DeckAnalytics,
MutableVisitor,
SlideAccumulator,
SlideRollup,
VisitDetail,
} from './analytics-types.ts';
import { qualifyingSlideSequence } from './analytics-sequence.ts';

function viewerKey(visit: AnalyticsVisitRow): string {
if (visit.viewerUserId !== null) return `user:${visit.viewerUserId}`;
Expand All @@ -17,7 +19,14 @@ function viewerKey(visit: AnalyticsVisitRow): string {
}

export function aggregateDeckAnalytics(input: AnalyticsAggregationInput): DeckAnalytics {
const { owner, visitRows, slideRows, engagementRows } = input;
const { owner, visitRows, slideRows, engagementRows, eventRows } = input;
const slidesById = new Map(slideRows.map((slide) => [slide.id, slide]));
const eventsByVisit = new Map<string, AnalyticsEventRow[]>();
for (const event of eventRows) {
const current = eventsByVisit.get(event.visitId) ?? [];
current.push(event);
eventsByVisit.set(event.visitId, current);
}
const visitRowsById = new Map<string, AnalyticsVisitRow>();
for (const visit of visitRows) {
if (!visitRowsById.has(visit.id)) visitRowsById.set(visit.id, visit);
Expand All @@ -31,7 +40,11 @@ export function aggregateDeckAnalytics(input: AnalyticsAggregationInput): DeckAn
const visits: VisitDetail[] = visitRows.map((visit) => {
const engagements = engagementsByVisit.get(visit.id) ?? [];
const qualified = engagements.filter((engagement) => engagement.qualified);
const ordered = [...qualified].sort((left, right) => left.firstSequence - right.firstSequence);
const events = eventsByVisit.get(visit.id) ?? [];
const sequenceComplete =
events.every((event) => event.qualified !== null) &&
(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,
Expand All @@ -55,14 +68,9 @@ export function aggregateDeckAnalytics(input: AnalyticsAggregationInput): DeckAn
qualified.length === 0
? null
: Math.max(...qualified.map((engagement) => engagement.ordinal)),
lastSlide: ordered.at(-1)?.ordinal ?? null,
slideSequence: ordered.map((engagement) => ({
slideId: engagement.id,
ordinal: engagement.ordinal,
title: engagement.title,
activeDurationMs: engagement.activeDurationMs,
viewCount: engagement.viewCount,
})),
lastSlide: slideSequence.at(-1)?.ordinal ?? null,
sequenceComplete,
slideSequence,
};
});
const visitDetailsById = new Map<string, VisitDetail>();
Expand Down
143 changes: 143 additions & 0 deletions apps/api/decks/analytics-appearances.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import { aggregateDeckAnalytics } from './analytics-aggregate.ts';

const slides = [1, 2, 3].map((ordinal) => ({
id: `slide-${ordinal}`,
stableSlideId: `stable-${ordinal}`,
ordinal,
title: `Slide ${ordinal}`,
revisionNumber: 1,
}));
const input = {
owner: { id: 'owner', email: 'owner@example.test' },
visitRows: [
{
id: 'visit',
viewerSessionId: 'session',
viewerUserId: null,
viewerEmail: null,
identityConfidence: 'anonymous' as const,
startedAt: new Date('2026-09-22T12:00:00Z'),
lastActivityAt: new Date('2026-09-22T12:00:10Z'),
revisionNumber: 1,
linkId: 'link',
linkName: 'Link',
senderId: 'owner',
senderEmail: 'owner@example.test',
totalSlides: 3,
},
],
slideRows: slides,
engagementRows: slides.slice(0, 2).map((slide) => ({
...slide,
visitId: 'visit',
activeDurationMs: slide.ordinal === 1 ? 6_000 : 4_000,
viewCount: slide.ordinal === 1 ? 2 : 1,
qualified: true,
firstSequence: slide.ordinal,
lastSequence: slide.ordinal === 1 ? 3 : 2,
})),
};

function event(slide: number, sequence: number, duration: number) {
return {
visitId: 'visit',
slideId: `slide-${slide}`,
sequence,
eventAt: new Date(Date.UTC(2026, 8, 22, 12, 0, sequence)),
activeDurationMs: duration,
qualified: true,
};
}

describe('qualifying analytics appearances', () => {
it('preserves a return to an earlier slide and counts the final exit there', () => {
// Given
const fixture = {
...input,
eventRows: [event(1, 1, 3_000), event(2, 2, 4_000), event(1, 3, 3_000)],
};
// When
const analytics = aggregateDeckAnalytics(fixture);
// Then
expect(analytics.visits[0]?.slideSequence.map((slide) => slide.ordinal)).toEqual([1, 2, 1]);
expect(analytics.visits[0]).toMatchObject({ lastSlide: 1, viewedSlides: 2, completion: 2 / 3 });
expect(analytics.slides.map((slide) => slide.exits)).toEqual([1, 0, 0]);
});

it('counts accepted intervals once across repeated appearances and heartbeats', () => {
// Given
const fixture = {
...input,
eventRows: [
event(1, 1, 1_000),
{ ...event(1, 2, 2_000), qualified: false },
event(2, 3, 4_000),
event(1, 4, 3_000),
],
};
// When
const sequence = aggregateDeckAnalytics(fixture).visits[0]?.slideSequence;
// Then
expect(sequence?.map((slide) => slide.activeDurationMs)).toEqual([3_000, 4_000, 3_000]);
expect(sequence?.reduce((total, slide) => total + slide.activeDurationMs, 0)).toBe(10_000);
expect(sequence?.map((slide) => slide.viewCount)).toEqual([1, 1, 1]);
});

it('does not turn an unqualified later appearance into the last viewed slide', () => {
// Given
const fixture = {
...input,
eventRows: [
event(1, 1, 3_000),
event(2, 2, 4_000),
{ ...event(1, 3, 500), qualified: false },
],
};
// When
const visit = aggregateDeckAnalytics(fixture).visits[0];
// Then
expect(visit?.slideSequence.map((slide) => slide.ordinal)).toEqual([1, 2]);
expect(visit?.lastSlide).toBe(2);
});

it('orders appearances by event time when batches arrive out of order or sequence resets', () => {
// Given
const fixture = {
...input,
eventRows: [{ ...event(1, 3, 3_000), sequence: 0 }, event(2, 2, 4_000), event(1, 1, 3_000)],
};
// When
const visit = aggregateDeckAnalytics(fixture).visits[0];
// Then
expect(visit?.slideSequence.map((slide) => slide.ordinal)).toEqual([1, 2, 1]);
});

it.each([false, true])(
'preserves historical totals but reports unknown order for legacy or mixed events (mixed: %s)',
(mixed) => {
// Given
const fixture = {
...input,
eventRows: [
{ ...event(1, 1, 3_000), qualified: null },
{ ...event(2, 2, 4_000), qualified: mixed ? true : null },
],
};
// When
const analytics = aggregateDeckAnalytics(fixture);
const visit = analytics.visits[0];
// Then
expect(visit).toMatchObject({
sequenceComplete: false,
lastSlide: null,
slideSequence: [],
viewedSlides: 2,
completion: 2 / 3,
});
expect(visit?.totalActiveTimeMs).toBe(10_000);
expect(analytics.slides.map((slide) => slide.totalActiveTimeMs)).toEqual([6_000, 4_000, 0]);
expect(analytics.slides.map((slide) => slide.exits)).toEqual([0, 0, 0]);
},
);
});
39 changes: 39 additions & 0 deletions apps/api/decks/analytics-sequence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { AnalyticsEventRow, AnalyticsSlideRow, VisitDetail } from './analytics-types.ts';

export function qualifyingSlideSequence(
events: readonly AnalyticsEventRow[],
slides: ReadonlyMap<string, AnalyticsSlideRow>,
): VisitDetail['slideSequence'] {
const appearances: {
slideId: string;
ordinal: number;
title: string | null;
activeDurationMs: number;
viewCount: number;
}[] = [];
let current: (typeof appearances)[number] | undefined;
const ordered = [...events].sort(
(left, right) =>
left.eventAt.getTime() - right.eventAt.getTime() || left.sequence - right.sequence,
);
for (const event of ordered) {
const slide = event.slideId === null ? undefined : slides.get(event.slideId);
if (slide === undefined) {
current = undefined;
continue;
}
if (current?.slideId !== slide.id || (event.qualified && current.viewCount > 0)) {
current = {
slideId: slide.id,
ordinal: slide.ordinal,
title: slide.title,
activeDurationMs: 0,
viewCount: 0,
};
appearances.push(current);
}
current.activeDurationMs += event.activeDurationMs;
if (event.qualified) current.viewCount += 1;
}
return appearances.filter((appearance) => appearance.viewCount > 0);
}
11 changes: 11 additions & 0 deletions apps/api/decks/analytics-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ export type AnalyticsEngagementRow = AnalyticsSlideRow & {
readonly lastSequence: number;
};

export type AnalyticsEventRow = {
readonly visitId: string;
readonly slideId: string | null;
readonly eventAt: Date;
readonly sequence: number;
readonly activeDurationMs: number;
readonly qualified: boolean | null;
};

export type VisitDetail = {
readonly id: string;
readonly viewer: string;
Expand All @@ -49,6 +58,7 @@ export type VisitDetail = {
readonly completion: number;
readonly furthestSlide: number | null;
readonly lastSlide: number | null;
readonly sequenceComplete: boolean;
readonly slideSequence: readonly {
readonly slideId: string;
readonly ordinal: number;
Expand Down Expand Up @@ -119,4 +129,5 @@ export type AnalyticsAggregationInput = {
readonly visitRows: readonly AnalyticsVisitRow[];
readonly slideRows: readonly AnalyticsSlideRow[];
readonly engagementRows: readonly AnalyticsEngagementRow[];
readonly eventRows: readonly AnalyticsEventRow[];
};
2 changes: 1 addition & 1 deletion apps/api/decks/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const HEARTBEAT_CEILING_MS = 10_000;
const FUTURE_SKEW_MS = 30_000;
export const FUTURE_SKEW_MS = 30_000;

export type VisitSummary = {
readonly completion: number;
Expand Down
Loading
Loading