mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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": []
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user