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
15 changes: 14 additions & 1 deletion src/lib/__tests__/articles-db-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
});
});
54 changes: 53 additions & 1 deletion src/lib/articles-db.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<Article[]> {
try {
const rows = await db
Expand Down
43 changes: 43 additions & 0 deletions src/lib/auth-api.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
17 changes: 16 additions & 1 deletion src/lib/auth-api.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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.
*
Expand All @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
94 changes: 94 additions & 0 deletions src/worker/routes/__tests__/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
81 changes: 81 additions & 0 deletions src/worker/routes/mcp.ts
Original file line number Diff line number Diff line change
@@ -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.aiSummary,
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;