feat(stats): expose privacy-aware reading insights (#662)

* build(deps): synchronize i18n lockfile

Record the locale dependencies so frozen installs can resolve the client workspace.

* feat(stats): expose privacy-aware reading insights

- Give administrators permission-gated access to bounded account activity.
- Let readers opt into auditable shared reading insights.

Ref #501
This commit is contained in:
Chris Mansell
2026-07-12 23:11:35 -06:00
committed by GitHub
parent c70ff593ca
commit a7c281955a
74 changed files with 19164 additions and 48 deletions
+198 -10
View File
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'url';
import { ConfigService } from '@nestjs/config';
import { Permission } from '@bookorbit/types';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { and, eq, inArray } from 'drizzle-orm';
import { createCoverToken } from '../src/modules/opds/opds-auth.guard';
import * as schema from '../src/db/schema';
@@ -224,6 +225,7 @@ describe('Authorization matrix (e2e)', () => {
const session = await createReadingSession(ctx, {
userId: personas.ownerUser.userId,
bookId: bookB.bookId,
bookFileId: bookB.bookFileId,
startedAt: new Date('2026-01-01T10:00:00.000Z'),
endedAt: new Date('2026-01-01T10:30:00.000Z'),
@@ -275,6 +277,168 @@ describe('Authorization matrix (e2e)', () => {
await closeAuthorizationMatrixE2EContext(ctx);
});
describe('shared reading insights privacy contract', () => {
it('enforces permission, consent, session snapshots, revocation, and one audit event per profile view', async () => {
const subjectId = personas.basicUser.userId;
const rawGenreName = `private-label-${randomUUID()}`;
const duplicateBroadGenreNames = [`fantasy-${randomUUID()}`, `fantastik-${randomUUID()}`];
const insertedGenres = await ctx.db
.insert(schema.genres)
.values([rawGenreName, ...duplicateBroadGenreNames].map((name) => ({ name })))
.returning({ id: schema.genres.id, name: schema.genres.name });
await ctx.db.insert(schema.bookGenres).values(insertedGenres.map((genre) => ({ bookId: bookA.bookId, genreId: genre.id })));
await ctx.db.insert(schema.readingSessions).values({
userId: subjectId,
bookId: bookA.bookId,
bookFileId: bookA.bookFileId,
sessionId: `shared-insights-${randomUUID()}`,
source: 'web',
startedAt: new Date(Date.now() - 20 * 60 * 1000),
endedAt: new Date(Date.now() - 5 * 60 * 1000),
durationSeconds: 15 * 60,
});
const privateAsSuperuser = await ctx.app.inject({
method: 'POST',
url: `/api/v1/shared-reading-insights/${subjectId}/view-sessions`,
headers: authHeader(personas.targetSuperuser.accessToken),
});
expect(privateAsSuperuser.statusCode).toBe(403);
expect(privateAsSuperuser.json()).toEqual(expect.objectContaining({ errorCode: 'READING_INSIGHTS_PRIVATE' }));
const enableSummary = await ctx.app.inject({
method: 'PATCH',
url: '/api/v1/reading-insights-sharing/me',
headers: authHeader(personas.basicUser.accessToken),
payload: { sharingLevel: 'summary' },
});
expect(enableSummary.statusCode).toBe(200);
const consentWithoutPermission = await ctx.app.inject({
method: 'POST',
url: `/api/v1/shared-reading-insights/${subjectId}/view-sessions`,
headers: authHeader(personas.otherUser.accessToken),
});
expectError(consentWithoutPermission, 403, 'Missing permission: view_user_activity');
const summarySessionResponse = await ctx.app.inject({
method: 'POST',
url: `/api/v1/shared-reading-insights/${subjectId}/view-sessions`,
headers: authHeader(personas.allPermsUser.accessToken),
});
expect(summarySessionResponse.statusCode).toBe(201);
const summarySession = summarySessionResponse.json() as { viewSessionId: string; sharingLevel: string };
expect(summarySession.sharingLevel).toBe('summary');
const sessionHeaders = {
...authHeader(personas.allPermsUser.accessToken),
'x-reading-insights-view-session': summarySession.viewSessionId,
};
const summaryResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/shared-reading-insights/${subjectId}/summary?days=90`,
headers: sessionHeaders,
});
expect(summaryResponse.statusCode).toBe(200);
const summaryBody = summaryResponse.json() as Record<string, unknown>;
expect(summaryBody).not.toHaveProperty('topBooks');
expect(summaryBody).not.toHaveProperty('recentBooks');
expect(summaryBody).not.toHaveProperty('bookId');
expect(summaryBody).not.toHaveProperty('title');
expect(summaryBody.genreDistribution).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'other' })]));
expect(summaryBody.genreDistribution).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'fantasy', readingSeconds: 15 * 60 })]));
expect(JSON.stringify(summaryBody)).not.toContain(rawGenreName);
const summaryOnlyDetail = await ctx.app.inject({
method: 'GET',
url: `/api/v1/shared-reading-insights/${subjectId}/detailed?days=90`,
headers: sessionHeaders,
});
expect(summaryOnlyDetail.statusCode).toBe(403);
expect(summaryOnlyDetail.json()).toEqual(expect.objectContaining({ errorCode: 'READING_INSIGHTS_SUMMARY_ONLY' }));
await ctx.app.inject({
method: 'GET',
url: `/api/v1/shared-reading-insights/${subjectId}/summary?days=30`,
headers: sessionHeaders,
});
const firstHistory = await ctx.app.inject({
method: 'GET',
url: '/api/v1/reading-insights-sharing/me/access-history?page=1&pageSize=20',
headers: authHeader(personas.basicUser.accessToken),
});
expect(firstHistory.statusCode).toBe(200);
expect(firstHistory.json()).toEqual(expect.objectContaining({ total: 1, items: [expect.objectContaining({ sharingLevel: 'summary' })] }));
const enableDetailed = await ctx.app.inject({
method: 'PATCH',
url: '/api/v1/reading-insights-sharing/me',
headers: authHeader(personas.basicUser.accessToken),
payload: { sharingLevel: 'detailed' },
});
expect(enableDetailed.statusCode).toBe(200);
const oldSessionAfterUpgrade = await ctx.app.inject({
method: 'GET',
url: `/api/v1/shared-reading-insights/${subjectId}/detailed?days=90`,
headers: sessionHeaders,
});
expect(oldSessionAfterUpgrade.statusCode).toBe(403);
expect(oldSessionAfterUpgrade.json()).toEqual(expect.objectContaining({ errorCode: 'READING_INSIGHTS_SUMMARY_ONLY' }));
const detailedSessionResponse = await ctx.app.inject({
method: 'POST',
url: `/api/v1/shared-reading-insights/${subjectId}/view-sessions`,
headers: authHeader(personas.allPermsUser.accessToken),
});
expect(detailedSessionResponse.statusCode).toBe(201);
const detailedSession = detailedSessionResponse.json() as { viewSessionId: string };
const detailedResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/shared-reading-insights/${subjectId}/detailed?days=90`,
headers: {
...authHeader(personas.allPermsUser.accessToken),
'x-reading-insights-view-session': detailedSession.viewSessionId,
},
});
expect(detailedResponse.statusCode).toBe(200);
const auditResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/audit-log?action=reading_insights.profile.view&userId=${personas.allPermsUser.userId}`,
headers: authHeader(personas.allPermsUser.accessToken),
});
expect(auditResponse.statusCode).toBe(200);
expect(auditResponse.json()).toEqual(expect.objectContaining({ total: 2 }));
const withdraw = await ctx.app.inject({
method: 'PATCH',
url: '/api/v1/reading-insights-sharing/me',
headers: authHeader(personas.basicUser.accessToken),
payload: { sharingLevel: 'private' },
});
expect(withdraw.statusCode).toBe(200);
const afterWithdrawal = await ctx.app.inject({
method: 'GET',
url: `/api/v1/shared-reading-insights/${subjectId}/summary?days=90`,
headers: {
...authHeader(personas.allPermsUser.accessToken),
'x-reading-insights-view-session': detailedSession.viewSessionId,
},
});
expect(afterWithdrawal.statusCode).toBe(403);
expect(afterWithdrawal.json()).toEqual(expect.objectContaining({ errorCode: 'READING_INSIGHTS_PRIVATE' }));
const otherHistory = await ctx.app.inject({
method: 'GET',
url: '/api/v1/reading-insights-sharing/me/access-history?page=1&pageSize=20',
headers: authHeader(personas.otherUser.accessToken),
});
expect(otherHistory.statusCode).toBe(200);
expect(otherHistory.json()).toEqual(expect.objectContaining({ total: 0, items: [] }));
});
});
describe('guard matrix - jwt/permission/library/default-password', () => {
it('rejects unauthenticated access to every non-public route', async () => {
const failures: MatrixFailure[] = [];
@@ -396,6 +560,12 @@ describe('Authorization matrix (e2e)', () => {
path: '/metadata-preferences/global',
token: 'allPerms',
},
[Permission.ManageIcons]: {
method: 'PATCH',
path: '/custom-icons/:slug',
payload: {},
token: 'allPerms',
},
[Permission.ManageAppSettings]: {
method: 'GET',
path: '/app-settings',
@@ -411,6 +581,11 @@ describe('Authorization matrix (e2e)', () => {
path: '/audit-log',
token: 'allPerms',
},
[Permission.ViewUserActivity]: {
method: 'GET',
path: '/account-activity/summary',
token: 'allPerms',
},
[Permission.NotificationAccess]: {
method: 'GET',
path: '/notifications',
@@ -699,9 +874,20 @@ describe('Authorization matrix (e2e)', () => {
});
expect(response.statusCode).toBe(200);
const entitlements = response.json() as unknown[];
const syncedBookIds = extractKoboEntitlementBookIds(entitlements);
expect(syncedBookIds).toContain(bookA.bookId);
expect(syncedBookIds).not.toContain(bookB.bookId);
const syncedEntitlementIds = extractKoboEntitlementIds(entitlements);
const identities = await ctx.db
.select({ bookId: schema.koboBookEntitlements.bookId, entitlementId: schema.koboBookEntitlements.entitlementId })
.from(schema.koboBookEntitlements)
.where(
and(
eq(schema.koboBookEntitlements.userId, personas.koboActive.userId),
inArray(schema.koboBookEntitlements.bookId, [bookA.bookId, bookB.bookId]),
),
);
const entitlementByBookId = new Map(identities.map((identity) => [identity.bookId, identity.entitlementId]));
expect(syncedEntitlementIds).toContain(entitlementByBookId.get(bookA.bookId));
const inaccessibleEntitlementId = entitlementByBookId.get(bookB.bookId);
if (inaccessibleEntitlementId) expect(syncedEntitlementIds).not.toContain(inaccessibleEntitlementId);
});
it('does not expose metadata for books outside active library access', async () => {
@@ -754,10 +940,15 @@ describe('Authorization matrix (e2e)', () => {
url: `/api/v1/kobo/${koboActiveDeviceToken}/v1/library/${bookA.bookId}/state`,
});
expect(readState.statusCode).toBe(200);
const [identity] = await ctx.db
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
.from(schema.koboBookEntitlements)
.where(and(eq(schema.koboBookEntitlements.userId, personas.koboActive.userId), eq(schema.koboBookEntitlements.bookId, bookA.bookId)))
.limit(1);
expect(readState.json()).toEqual(
expect.arrayContaining([
expect.objectContaining({
EntitlementId: String(bookA.bookId),
EntitlementId: identity.entitlementId,
}),
]),
);
@@ -1237,8 +1428,8 @@ describe('Authorization matrix (e2e)', () => {
return asRecord[key] ?? null;
}
function extractKoboEntitlementBookIds(entitlements: unknown[]): number[] {
const ids = new Set<number>();
function extractKoboEntitlementIds(entitlements: unknown[]): string[] {
const ids = new Set<string>();
for (const item of entitlements) {
if (!item || typeof item !== 'object') continue;
const record = item as Record<string, unknown>;
@@ -1249,10 +1440,7 @@ describe('Authorization matrix (e2e)', () => {
if (!payload || typeof payload !== 'object') continue;
const entitlement = payload.BookEntitlement as Record<string, unknown> | undefined;
const rawId = entitlement?.Id ?? entitlement?.RevisionId ?? entitlement?.CrossRevisionId;
const parsedId = typeof rawId === 'string' ? Number.parseInt(rawId, 10) : Number.NaN;
if (Number.isFinite(parsedId)) {
ids.add(parsedId);
}
if (typeof rawId === 'string') ids.add(rawId);
}
return [...ids];
}
@@ -86,6 +86,7 @@ export interface CreateBookDockRowInput {
export interface CreateReadingSessionInput {
userId: number;
bookId: number;
bookFileId: number;
startedAt: Date;
endedAt: Date;
@@ -405,6 +406,7 @@ export async function createReadingSession(
.insert(schema.readingSessions)
.values({
userId: input.userId,
bookId: input.bookId,
bookFileId: input.bookFileId,
sessionId: input.sessionId ?? `session-${randomUUID()}`,
startedAt: input.startedAt,
@@ -1,12 +1,12 @@
{
"totalRoutes": 354,
"totalRoutes": 367,
"byPermission": {
"(none)": 161,
"(none)": 164,
"Permission.ManageMetadataConfig": 32,
"Permission.EmailSend": 30,
"Permission.LibraryEditMetadata": 31,
"Permission.BookDockAccess": 17,
"Permission.ManageAppSettings": 23,
"Permission.ManageAppSettings": 22,
"Permission.ManageLibraries": 14,
"Permission.ManageUsers": 10,
"Permission.ManageEmail": 8,
@@ -16,13 +16,27 @@
"Permission.KoreaderSync": 8,
"Permission.OpdsAccess": 4,
"Permission.ViewAuditLog": 1,
"Permission.ViewUserActivity": 6,
"Permission.LibraryDeleteBooks": 1,
"Permission.LibraryDownload": 1,
"Permission.LibraryUpload": 1
"Permission.LibraryUpload": 1,
"Permission.ManageIcons": 1,
"Permission.HardcoverSync": 1,
"Permission.ReadwiseSync": 1,
"Permission.StorygraphSync": 1
},
"byLibraryAccess": {
"'viewer'": 3,
"'editor'": 1
},
"routeChunks": ["routes-01.json", "routes-02.json", "routes-03.json", "routes-04.json", "routes-05.json", "routes-06.json", "routes-07.json"]
"routeChunks": [
"routes-01.json",
"routes-02.json",
"routes-03.json",
"routes-04.json",
"routes-05.json",
"routes-06.json",
"routes-07.json",
"routes-08.json"
]
}
@@ -0,0 +1,170 @@
[
{
"file": "server/src/modules/account-activity/account-activity.controller.ts",
"className": "AccountActivityController",
"methodName": "list",
"httpMethod": "GET",
"path": "/account-activity",
"permissions": ["Permission.ViewUserActivity"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/account-activity/account-activity.controller.ts",
"className": "AccountActivityController",
"methodName": "getSummary",
"httpMethod": "GET",
"path": "/account-activity/summary",
"permissions": ["Permission.ViewUserActivity"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/account-activity/account-activity.controller.ts",
"className": "AccountActivityController",
"methodName": "getDetail",
"httpMethod": "GET",
"path": "/account-activity/:userId",
"permissions": ["Permission.ViewUserActivity"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/shared-reading-insights/reading-insights-sharing.controller.ts",
"className": "ReadingInsightsSharingController",
"methodName": "getMySettings",
"httpMethod": "GET",
"path": "/reading-insights-sharing/me",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/shared-reading-insights/reading-insights-sharing.controller.ts",
"className": "ReadingInsightsSharingController",
"methodName": "updateMySettings",
"httpMethod": "PATCH",
"path": "/reading-insights-sharing/me",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/shared-reading-insights/reading-insights-sharing.controller.ts",
"className": "ReadingInsightsSharingController",
"methodName": "getMyPreview",
"httpMethod": "GET",
"path": "/reading-insights-sharing/me/preview",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/shared-reading-insights/reading-insights-sharing.controller.ts",
"className": "ReadingInsightsSharingController",
"methodName": "getMyAccessHistory",
"httpMethod": "GET",
"path": "/reading-insights-sharing/me/access-history",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/shared-reading-insights/shared-reading-insights.controller.ts",
"className": "SharedReadingInsightsController",
"methodName": "createViewSession",
"httpMethod": "POST",
"path": "/shared-reading-insights/:userId/view-sessions",
"permissions": ["Permission.ViewUserActivity"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/shared-reading-insights/shared-reading-insights.controller.ts",
"className": "SharedReadingInsightsController",
"methodName": "getSummary",
"httpMethod": "GET",
"path": "/shared-reading-insights/:userId/summary",
"permissions": ["Permission.ViewUserActivity"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/shared-reading-insights/shared-reading-insights.controller.ts",
"className": "SharedReadingInsightsController",
"methodName": "getDetailed",
"httpMethod": "GET",
"path": "/shared-reading-insights/:userId/detailed",
"permissions": ["Permission.ViewUserActivity"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/custom-icon/custom-icon.controller.ts",
"className": "CustomIconController",
"methodName": "update",
"httpMethod": "PATCH",
"path": "/custom-icons/:slug",
"permissions": ["Permission.ManageIcons"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/hardcover/hardcover.controller.ts",
"className": "HardcoverController",
"methodName": "getSettings",
"httpMethod": "GET",
"path": "/hardcover/settings",
"permissions": ["Permission.HardcoverSync"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/readwise/readwise.controller.ts",
"className": "ReadwiseController",
"methodName": "getSettings",
"httpMethod": "GET",
"path": "/readwise/settings",
"permissions": ["Permission.ReadwiseSync"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/storygraph/storygraph.controller.ts",
"className": "StorygraphController",
"methodName": "getSettings",
"httpMethod": "GET",
"path": "/storygraph/settings",
"permissions": ["Permission.StorygraphSync"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
}
]