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
5 changes: 5 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
handleV2BatchFeedFetch,
handleV2FeedDiscover,
handleV2BatchDocumentFetch,
handleV2GetDocument,
handleV2SocialContext,
handleV2Mentions,
handleV2MentionLane,
Expand Down Expand Up @@ -225,6 +226,10 @@ export default {
if (!session) return unauthorizedResponse(headers);
response = await handleV2BatchDocumentFetch(request, env, session);
break;
case url.pathname === '/api/v2/documents/get':
if (!session) return unauthorizedResponse(headers);
response = await handleV2GetDocument(request, env, session);
break;
case url.pathname === '/api/v2/social-context':
if (!session) return unauthorizedResponse(headers);
response = await handleV2SocialContext(request, env);
Expand Down
55 changes: 55 additions & 0 deletions backend/src/routes/feeds-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,61 @@ export async function handleV2BatchDocumentFetch(
}
}

/**
* GET /api/v2/documents/get?uri=at://...
*
* On-demand fetch of a single standard.site document — the in-app reader path
* for a curated Collection piece whose author the user doesn't subscribe to (so
* it's in no batch response). Thin pass-through to the proxy, then the same
* per-user read annotation the batch path applies (item_type 'document').
*/
export async function handleV2GetDocument(
request: Request,
env: Env,
session: Session
): Promise<Response> {
if (request.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json' },
});
}

const uri = new URL(request.url).searchParams.get('uri');
if (!uri || !uri.startsWith('at://')) {
return new Response(JSON.stringify({ error: 'Missing or invalid uri' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}

try {
const client = new FeedProxyClient(env);
const document = await client.fetchDocument(uri);
if (!document) {
return new Response(JSON.stringify({ error: 'Document not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}

// Stamp per-user read state, mirroring the batch path's annotation.
const readUris = await getReadKeys(env, session.did, 'document', [document.recordUri]);
document.read = readUris.has(document.recordUri);

return new Response(JSON.stringify({ document }), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('V2 get document error:', error);
const message = error instanceof Error ? error.message : 'Proxy fetch failed';
return new Response(JSON.stringify({ error: message }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
}

/**
* POST /api/v2/social-context
*
Expand Down
56 changes: 56 additions & 0 deletions backend/src/services/feed-proxy-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,54 @@ export interface ProxyDocument {
indexedAt?: string;
createdAt: string;
siteIcon?: string;
// Present when the document is a Standard Reader "Collection" (curated edition).
// The proxy resolves each curated item to a preview; we forward it untouched.
readerCollection?: ProxyReaderCollection;
// Stamped by handleV2BatchDocumentFetch from a per-user read join (item_type
// 'document'). Not returned by the proxy itself — annotation only.
read?: boolean;
}

/** A publication's `basicTheme` palette (RGB triples), used by the magazine view. */
export interface BasicTheme {
accent?: { r: number; g: number; b: number };
background?: { r: number; g: number; b: number };
foreground?: { r: number; g: number; b: number };
accentForeground?: { r: number; g: number; b: number };
}

/** A curated collection item, resolved by the proxy to a renderable preview. */
export interface ProxyReaderCollectionItem {
document: string;
note?: string;
authorDid?: string;
title?: string;
description?: string;
canonicalUrl?: string;
siteIcon?: string;
sourceName?: string;
publishedAt?: string;
}

/** Google Font family names for a collections publication's typography. */
export interface PublicationFonts {
title?: string;
body?: string;
}

/** A resolved curated edition: editorial/colophon markdown + resolved items.
* `publicationName`/`theme`/`fonts`/`authorHandle` describe the edition's
* publication, consumed by the optional themed magazine view. */
export interface ProxyReaderCollection {
editorial?: { title?: string; body?: string };
colophon?: { body?: string };
items: ProxyReaderCollectionItem[];
publicationName?: string;
theme?: BasicTheme;
fonts?: PublicationFonts;
authorHandle?: string;
}

export interface ProxyDocumentEntry {
did: string;
siteUri?: string;
Expand Down Expand Up @@ -463,6 +506,19 @@ export class FeedProxyClient {
return raw.authors;
}

/**
* Fetch a single standard.site document by its at:// URI (on-demand read of a
* curated Collection piece). Returns the resolved document, or null when the
* proxy can't resolve it (bad URI / missing record).
*/
async fetchDocument(uri: string): Promise<ProxyDocument | null> {
const raw = await this.fetch<{ document?: ProxyDocument; error?: string }>(
`/document?uri=${encodeURIComponent(uri)}`,
{ method: 'GET' }
);
return raw.document ?? null;
}

/**
* Fetch Constellation social context for a batch of link posts (Phase 3):
* recommend/quote counts + "who else linked this article" (with handles +
Expand Down
30 changes: 30 additions & 0 deletions feed-proxy/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { parseFeed } from './feed-parser';
import type { ParsedFeed, FeedItem } from './types';
import {
fetchDocumentsForAuthor,
fetchSingleDocument,
filterByPublication,
digestScope,
MAX_DOCUMENTS_PER_AUTHOR,
Expand Down Expand Up @@ -786,6 +787,15 @@ export function initDatabase(db: Database): void {
cached_at INTEGER NOT NULL
)
`);
// Migration: add name + theme + fonts columns, all consumed by the optional
// magazine view of a curated Collection. `name` is the publication title,
// `theme` is the JSON `basicTheme` palette (colors), and `fonts` is the JSON
// `publicationTheme` typography (Google Font family names).
const pubColumns = db.query<{ name: string }, []>(`PRAGMA table_info(publication_cache)`).all();
const pubColNames = new Set(pubColumns.map((c) => c.name));
if (!pubColNames.has('name')) db.run(`ALTER TABLE publication_cache ADD COLUMN name TEXT`);
if (!pubColNames.has('theme')) db.run(`ALTER TABLE publication_cache ADD COLUMN theme TEXT`);
if (!pubColNames.has('fonts')) db.run(`ALTER TABLE publication_cache ADD COLUMN fonts TEXT`);

// Per-author resolved standard.site documents. Same freshness/backoff shape as
// `cache`, keyed by the author DID. Documents are stored unfiltered (full
Expand Down Expand Up @@ -2164,6 +2174,26 @@ export function createApp(db: Database, config: AppConfig) {
// Bulk standard.site document endpoint. Symmetric with /feeds, but keyed by
// author DID instead of feed URL. Returns each requested author's documents
// (scoped to a publication and trimmed to what the client hasn't seen yet).
// On-demand fetch of a single standard.site document by at:// URI. Serves
// pieces opened from a Collection edition whose authors the reader doesn't
// subscribe to (so they're in no cached author list). Resolved fresh.
app.get('/document', async (c) => {
if (proxySecret && c.req.header('X-Proxy-Secret') !== proxySecret) {
return c.json({ error: 'Unauthorized' }, 401);
}

const uri = c.req.query('uri');
if (!uri || !uri.startsWith('at://')) {
return c.json({ error: 'Missing or invalid uri' }, 400);
}

const document = await fetchSingleDocument(db, uri);
if (!document) {
return c.json({ error: 'Document not found' }, 404);
}
return c.json({ document });
});

app.post('/documents', async (c) => {
if (proxySecret && c.req.header('X-Proxy-Secret') !== proxySecret) {
return c.json({ error: 'Unauthorized' }, 401);
Expand Down
Loading
Loading