From 12792589fe1013ecf9034af18b29a17482040c0a Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Tue, 11 Aug 2026 00:59:44 +0530 Subject: [PATCH 1/2] feat: expose read-only ChatGPT data --- src/lib/__tests__/articles-db-search.test.ts | 15 +++- src/lib/articles-db.ts | 54 ++++++++++- src/lib/auth-api.test.ts | 43 +++++++++ src/lib/auth-api.ts | 17 +++- src/worker.ts | 2 + src/worker/routes/__tests__/mcp.test.ts | 94 ++++++++++++++++++++ src/worker/routes/mcp.ts | 81 +++++++++++++++++ 7 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 src/lib/auth-api.test.ts create mode 100644 src/worker/routes/__tests__/mcp.test.ts create mode 100644 src/worker/routes/mcp.ts diff --git a/src/lib/__tests__/articles-db-search.test.ts b/src/lib/__tests__/articles-db-search.test.ts index 268ba38..94b73df 100644 --- a/src/lib/__tests__/articles-db-search.test.ts +++ b/src/lib/__tests__/articles-db-search.test.ts @@ -12,7 +12,7 @@ vi.mock('../db/client', () => ({ import { describe, expect, it } from 'vitest'; -import { searchArticles } from '../articles-db'; +import { searchArticles, searchArticleSummaries } from '../articles-db'; describe('searchArticles', () => { it('handles search terms with regex characters without throwing', async () => { @@ -44,4 +44,17 @@ describe('searchArticles', () => { await expect(searchArticles('user-1', 'a(')).resolves.toHaveLength(1); }); + + it('fails closed for a project identifier outside the owner virtual project', async () => { + mockedSelect.mockClear(); + await expect( + searchArticleSummaries('user-1', { + query: 'agent', + projectId: 'another-user_default', + limit: 10, + offset: 0, + }) + ).resolves.toEqual({ items: [], total: 0, nextOffset: null }); + expect(mockedSelect).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/articles-db.ts b/src/lib/articles-db.ts index 8869a11..3f03c9a 100644 --- a/src/lib/articles-db.ts +++ b/src/lib/articles-db.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import { and, desc, eq, inArray } from 'drizzle-orm'; +import { and, count, desc, eq, inArray, like, or, type SQL } from 'drizzle-orm'; import type { IOptions } from 'sanitize-html'; import sanitizeHtml from 'sanitize-html'; @@ -382,6 +382,58 @@ export async function fetchArticleSummaries( } } +export type ArticleSearchOptions = { + query?: string; + listId?: string; + projectId?: string; + type?: 'article' | 'link' | 'pdf'; + limit: number; + offset: number; +}; + +/** Bounded owner-scoped search for integration clients. */ +export async function searchArticleSummaries( + userId: string, + options: ArticleSearchOptions +): Promise<{ items: ArticleSummary[]; total: number; nextOffset: number | null }> { + if (options.projectId && options.projectId !== defaultProjectId(userId)) { + return { items: [], total: 0, nextOffset: null }; + } + const conditions: SQL[] = [eq(articles.userId, userId)]; + const query = options.query?.trim().replaceAll(/[%_]/g, '').slice(0, 200); + if (query) { + const pattern = `%${query}%`; + const search = or( + like(articles.title, pattern), + like(articles.url, pattern), + like(articles.byline, pattern), + like(articles.tags, pattern) + ); + if (search) conditions.push(search); + } + if (options.type) conditions.push(eq(articles.type, options.type)); + if (options.listId) { + conditions.push(like(articles.listIds, `%"${options.listId.replaceAll('"', '')}"%`)); + } + const where = and(...conditions); + const [rows, totals] = await Promise.all([ + db + .select() + .from(articles) + .where(where) + .orderBy(desc(articles.createdAt)) + .limit(options.limit) + .offset(options.offset), + db.select({ value: count() }).from(articles).where(where), + ]); + const total = totals[0]?.value ?? 0; + return { + items: rows.map(rowToSummary), + total, + nextOffset: options.offset + rows.length < total ? options.offset + rows.length : null, + }; +} + export async function fetchArticlesForSourceMap(userId: string): Promise { try { const rows = await db diff --git a/src/lib/auth-api.test.ts b/src/lib/auth-api.test.ts new file mode 100644 index 0000000..ddbc17b --- /dev/null +++ b/src/lib/auth-api.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + verifyApiKey: vi.fn(), + createAuth: vi.fn(), +})); + +vi.mock('./api-keys', () => ({ + API_KEY_PREFIX: 'rdr_', + verifyApiKey: mocks.verifyApiKey, +})); +vi.mock('./auth', () => ({ createAuth: mocks.createAuth })); + +import { getApiKeyUserId } from './auth-api'; + +describe('Reader API-key-only authentication', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('accepts only an exact rdr bearer token', async () => { + mocks.verifyApiKey.mockResolvedValue('owner-1'); + await expect( + getApiKeyUserId(new Headers({ Authorization: 'Bearer rdr_valid-token' })) + ).resolves.toBe('owner-1'); + expect(mocks.verifyApiKey).toHaveBeenCalledWith('rdr_valid-token'); + expect(mocks.createAuth).not.toHaveBeenCalled(); + }); + + it('rejects cookies, JWTs, other token scopes, and ambiguous bearer values', async () => { + const inputs = [ + new Headers({ Cookie: 'better-auth.session_token=browser-session' }), + new Headers({ Authorization: 'Bearer header.payload.signature' }), + new Headers({ Authorization: 'Bearer calorie_read_wrong-scope' }), + new Headers({ Authorization: 'Bearer rdr_value extra' }), + ]; + for (const headers of inputs) { + await expect(getApiKeyUserId(headers)).resolves.toBeNull(); + } + expect(mocks.verifyApiKey).not.toHaveBeenCalled(); + expect(mocks.createAuth).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/auth-api.ts b/src/lib/auth-api.ts index 0ee1d8b..19a2c3a 100644 --- a/src/lib/auth-api.ts +++ b/src/lib/auth-api.ts @@ -1,6 +1,21 @@ import { API_KEY_PREFIX, verifyApiKey } from './api-keys'; import { createAuth, type AuthEnv } from './auth'; +/** Resolve only a dedicated long-lived Reader API key; never use browser auth. */ +export async function getApiKeyUserId(headers: Headers): Promise { + const authHeader = headers.get('authorization') ?? headers.get('Authorization'); + if (!authHeader) return null; + const [scheme, value, extra] = authHeader.trim().split(/\s+/, 3); + if ( + extra !== undefined || + scheme?.toLowerCase() !== 'bearer' || + !value?.startsWith(API_KEY_PREFIX) + ) { + return null; + } + return verifyApiKey(value); +} + /** * Resolve the authenticated user for an API request. * @@ -18,7 +33,7 @@ export async function getAuthenticatedUserId( if (authHeader) { const [scheme, value] = authHeader.split(' ', 2); if (scheme?.toLowerCase() === 'bearer' && value?.startsWith(API_KEY_PREFIX)) { - const userId = await verifyApiKey(value); + const userId = await getApiKeyUserId(headers); if (userId) return userId; return null; } diff --git a/src/worker.ts b/src/worker.ts index 56d3c96..9425cf2 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -10,6 +10,7 @@ import boardsRoutes from './worker/routes/boards'; import keysRoutes from './worker/routes/keys'; import listsRoutes from './worker/routes/lists'; import memoriesRoutes from './worker/routes/memories'; +import mcpReadRoutes from './worker/routes/mcp'; import miscRoutes from './worker/routes/misc'; import pdfRoutes from './worker/routes/pdf'; import rssRoutes from './worker/routes/rss'; @@ -60,6 +61,7 @@ api.route('/api/articles', articlesRoutes); api.route('/api/boards', boardsRoutes); api.route('/api/lists', listsRoutes); api.route('/api/memories', memoriesRoutes); +api.route('/api/mcp', mcpReadRoutes); api.route('/api/ai', aiRoutes); api.route('/api/keys', keysRoutes); api.route('/api/pdfs', pdfRoutes); diff --git a/src/worker/routes/__tests__/mcp.test.ts b/src/worker/routes/__tests__/mcp.test.ts new file mode 100644 index 0000000..ac1528d --- /dev/null +++ b/src/worker/routes/__tests__/mcp.test.ts @@ -0,0 +1,94 @@ +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getApiKeyUserId: vi.fn(), + searchArticleSummaries: vi.fn(), + fetchArticleById: vi.fn(), + fetchLists: vi.fn(), +})); + +vi.mock('../../../lib/auth-api', () => ({ + getApiKeyUserId: mocks.getApiKeyUserId, +})); +vi.mock('../../../lib/articles-db', () => ({ + searchArticleSummaries: mocks.searchArticleSummaries, + fetchArticleById: mocks.fetchArticleById, +})); +vi.mock('../../../lib/lists-db', () => ({ fetchLists: mocks.fetchLists })); + +import mcpRoutes from '../mcp'; + +const app = new Hono(); +app.route('/api/mcp', mcpRoutes); + +describe('Reader MCP read projections', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getApiKeyUserId.mockResolvedValue('owner-1'); + mocks.searchArticleSummaries.mockResolvedValue({ items: [], total: 0, nextOffset: null }); + mocks.fetchLists.mockResolvedValue([]); + }); + + it('fails closed without an owner credential', async () => { + mocks.getApiKeyUserId.mockResolvedValue(null); + const response = await app.request('/api/mcp/reading?q=test'); + expect(response.status).toBe(401); + expect(mocks.searchArticleSummaries).not.toHaveBeenCalled(); + }); + + it('does not treat a browser session cookie as an MCP credential', async () => { + mocks.getApiKeyUserId.mockResolvedValue(null); + const response = await app.request('/api/mcp/reading?q=test', { + headers: { Cookie: 'better-auth.session_token=browser-session' }, + }); + expect(response.status).toBe(401); + expect(mocks.getApiKeyUserId).toHaveBeenCalledOnce(); + expect(mocks.searchArticleSummaries).not.toHaveBeenCalled(); + }); + + it('clamps search pagination and retains owner scope', async () => { + const response = await app.request('/api/mcp/reading?q=agents&limit=500&offset=2'); + expect(response.status).toBe(200); + expect(mocks.searchArticleSummaries).toHaveBeenCalledWith('owner-1', { + query: 'agents', + listId: undefined, + projectId: undefined, + type: undefined, + limit: 50, + offset: 2, + }); + }); + + it('passes the bounded virtual project filter to the owner-scoped query', async () => { + const response = await app.request( + '/api/mcp/reading?q=agents&projectId=owner-1_default&limit=10' + ); + expect(response.status).toBe(200); + expect(mocks.searchArticleSummaries).toHaveBeenCalledWith( + 'owner-1', + expect.objectContaining({ projectId: 'owner-1_default' }) + ); + }); + + it('projects item content without PDF download or credential fields', async () => { + mocks.fetchArticleById.mockResolvedValue({ + id: 'article-1', + url: 'https://example.com', + title: 'Example', + content: 'Readable content', + status: 'in_progress', + tags: [], + notes: [], + type: 'pdf', + listIds: [], + pdfUrl: '/api/pdfs/article-1', + aiChat: [{ role: 'user', content: 'private provider context' }], + }); + const response = await app.request('/api/mcp/reading/article-1'); + const body = await response.json(); + expect(response.status).toBe(200); + expect(body.item.pdfUrl).toBeUndefined(); + expect(body.item.aiChat).toBeUndefined(); + }); +}); diff --git a/src/worker/routes/mcp.ts b/src/worker/routes/mcp.ts new file mode 100644 index 0000000..9b5891b --- /dev/null +++ b/src/worker/routes/mcp.ts @@ -0,0 +1,81 @@ +import { Hono } from 'hono'; + +import { fetchArticleById, searchArticleSummaries } from '../../lib/articles-db'; +import { getApiKeyUserId } from '../../lib/auth-api'; +import { fetchLists } from '../../lib/lists-db'; +import type { WorkerEnv } from '../../lib/worker-env'; + +const mcpReads = new Hono<{ Bindings: WorkerEnv }>(); + +function pageValue(value: string | undefined, fallback: number, max: number) { + const parsed = Number(value); + return Number.isInteger(parsed) ? Math.min(Math.max(parsed, 0), max) : fallback; +} + +async function ownerId(headers: Headers) { + return getApiKeyUserId(headers); +} + +mcpReads.get('/reading', async (c) => { + const userId = await ownerId(c.req.raw.headers); + if (!userId) return c.json({ code: 'UNAUTHORIZED', message: 'Read credential required.' }, 401); + const type = c.req.query('type'); + const result = await searchArticleSummaries(userId, { + query: c.req.query('q'), + listId: c.req.query('listId'), + projectId: c.req.query('projectId'), + type: type === 'article' || type === 'link' || type === 'pdf' ? type : undefined, + limit: Math.max(1, pageValue(c.req.query('limit'), 10, 50)), + offset: pageValue(c.req.query('offset'), 0, 1_000_000), + }); + return c.json({ ...result, generatedAt: new Date().toISOString() }); +}); + +mcpReads.get('/reading/:id', async (c) => { + const userId = await ownerId(c.req.raw.headers); + if (!userId) return c.json({ code: 'UNAUTHORIZED', message: 'Read credential required.' }, 401); + const article = await fetchArticleById(c.req.param('id'), userId); + if (!article) return c.json({ code: 'NOT_FOUND', message: 'Saved item not found.' }, 404); + const item = { + id: article.id, + url: article.url, + title: article.title, + byline: article.byline, + content: article.content, + status: article.status, + tags: article.tags, + notes: article.notes, + summary: article.summary, + keyPoints: article.keyPoints, + type: article.type, + category: article.category, + createdAt: article.createdAt, + updatedAt: article.updatedAt, + listIds: article.listIds, + }; + return c.json({ item }); +}); + +mcpReads.get('/collections', async (c) => { + const userId = await ownerId(c.req.raw.headers); + if (!userId) return c.json({ code: 'UNAUTHORIZED', message: 'Read credential required.' }, 401); + const limit = Math.max(1, pageValue(c.req.query('limit'), 10, 50)); + const offset = pageValue(c.req.query('offset'), 0, 1_000_000); + const lists = await fetchLists(userId); + const items = lists.slice(offset, offset + limit).map((list) => ({ + id: list.id, + name: list.name, + color: list.color, + isDefault: list.isDefault, + createdAt: list.createdAt, + updatedAt: list.updatedAt, + })); + return c.json({ + generatedAt: new Date().toISOString(), + items, + total: lists.length, + nextOffset: offset + items.length < lists.length ? offset + items.length : null, + }); +}); + +export default mcpReads; From 9637136e7405a760890df6cff6a0c31be9ccda54 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Tue, 11 Aug 2026 01:04:01 +0530 Subject: [PATCH 2/2] fix: use Reader AI summary field --- src/worker/routes/mcp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/worker/routes/mcp.ts b/src/worker/routes/mcp.ts index 9b5891b..b88fd04 100644 --- a/src/worker/routes/mcp.ts +++ b/src/worker/routes/mcp.ts @@ -45,7 +45,7 @@ mcpReads.get('/reading/:id', async (c) => { status: article.status, tags: article.tags, notes: article.notes, - summary: article.summary, + summary: article.aiSummary, keyPoints: article.keyPoints, type: article.type, category: article.category,