Skip to content

Commit 6a16d20

Browse files
committed
add support for collections
1 parent d048238 commit 6a16d20

26 files changed

Lines changed: 2405 additions & 109 deletions

backend/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
handleV2BatchFeedFetch,
1212
handleV2FeedDiscover,
1313
handleV2BatchDocumentFetch,
14+
handleV2GetDocument,
1415
handleV2SocialContext,
1516
handleV2Mentions,
1617
handleV2MentionLane,
@@ -225,6 +226,10 @@ export default {
225226
if (!session) return unauthorizedResponse(headers);
226227
response = await handleV2BatchDocumentFetch(request, env, session);
227228
break;
229+
case url.pathname === '/api/v2/documents/get':
230+
if (!session) return unauthorizedResponse(headers);
231+
response = await handleV2GetDocument(request, env, session);
232+
break;
228233
case url.pathname === '/api/v2/social-context':
229234
if (!session) return unauthorizedResponse(headers);
230235
response = await handleV2SocialContext(request, env);

backend/src/routes/feeds-v2.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,61 @@ export async function handleV2BatchDocumentFetch(
445445
}
446446
}
447447

448+
/**
449+
* GET /api/v2/documents/get?uri=at://...
450+
*
451+
* On-demand fetch of a single standard.site document — the in-app reader path
452+
* for a curated Collection piece whose author the user doesn't subscribe to (so
453+
* it's in no batch response). Thin pass-through to the proxy, then the same
454+
* per-user read annotation the batch path applies (item_type 'document').
455+
*/
456+
export async function handleV2GetDocument(
457+
request: Request,
458+
env: Env,
459+
session: Session
460+
): Promise<Response> {
461+
if (request.method !== 'GET') {
462+
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
463+
status: 405,
464+
headers: { 'Content-Type': 'application/json' },
465+
});
466+
}
467+
468+
const uri = new URL(request.url).searchParams.get('uri');
469+
if (!uri || !uri.startsWith('at://')) {
470+
return new Response(JSON.stringify({ error: 'Missing or invalid uri' }), {
471+
status: 400,
472+
headers: { 'Content-Type': 'application/json' },
473+
});
474+
}
475+
476+
try {
477+
const client = new FeedProxyClient(env);
478+
const document = await client.fetchDocument(uri);
479+
if (!document) {
480+
return new Response(JSON.stringify({ error: 'Document not found' }), {
481+
status: 404,
482+
headers: { 'Content-Type': 'application/json' },
483+
});
484+
}
485+
486+
// Stamp per-user read state, mirroring the batch path's annotation.
487+
const readUris = await getReadKeys(env, session.did, 'document', [document.recordUri]);
488+
document.read = readUris.has(document.recordUri);
489+
490+
return new Response(JSON.stringify({ document }), {
491+
headers: { 'Content-Type': 'application/json' },
492+
});
493+
} catch (error) {
494+
console.error('V2 get document error:', error);
495+
const message = error instanceof Error ? error.message : 'Proxy fetch failed';
496+
return new Response(JSON.stringify({ error: message }), {
497+
status: 502,
498+
headers: { 'Content-Type': 'application/json' },
499+
});
500+
}
501+
}
502+
448503
/**
449504
* POST /api/v2/social-context
450505
*

backend/src/services/feed-proxy-client.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,54 @@ export interface ProxyDocument {
9696
indexedAt?: string;
9797
createdAt: string;
9898
siteIcon?: string;
99+
// Present when the document is a Standard Reader "Collection" (curated edition).
100+
// The proxy resolves each curated item to a preview; we forward it untouched.
101+
readerCollection?: ProxyReaderCollection;
99102
// Stamped by handleV2BatchDocumentFetch from a per-user read join (item_type
100103
// 'document'). Not returned by the proxy itself — annotation only.
101104
read?: boolean;
102105
}
103106

107+
/** A publication's `basicTheme` palette (RGB triples), used by the magazine view. */
108+
export interface BasicTheme {
109+
accent?: { r: number; g: number; b: number };
110+
background?: { r: number; g: number; b: number };
111+
foreground?: { r: number; g: number; b: number };
112+
accentForeground?: { r: number; g: number; b: number };
113+
}
114+
115+
/** A curated collection item, resolved by the proxy to a renderable preview. */
116+
export interface ProxyReaderCollectionItem {
117+
document: string;
118+
note?: string;
119+
authorDid?: string;
120+
title?: string;
121+
description?: string;
122+
canonicalUrl?: string;
123+
siteIcon?: string;
124+
sourceName?: string;
125+
publishedAt?: string;
126+
}
127+
128+
/** Google Font family names for a collections publication's typography. */
129+
export interface PublicationFonts {
130+
title?: string;
131+
body?: string;
132+
}
133+
134+
/** A resolved curated edition: editorial/colophon markdown + resolved items.
135+
* `publicationName`/`theme`/`fonts`/`authorHandle` describe the edition's
136+
* publication, consumed by the optional themed magazine view. */
137+
export interface ProxyReaderCollection {
138+
editorial?: { title?: string; body?: string };
139+
colophon?: { body?: string };
140+
items: ProxyReaderCollectionItem[];
141+
publicationName?: string;
142+
theme?: BasicTheme;
143+
fonts?: PublicationFonts;
144+
authorHandle?: string;
145+
}
146+
104147
export interface ProxyDocumentEntry {
105148
did: string;
106149
siteUri?: string;
@@ -463,6 +506,19 @@ export class FeedProxyClient {
463506
return raw.authors;
464507
}
465508

509+
/**
510+
* Fetch a single standard.site document by its at:// URI (on-demand read of a
511+
* curated Collection piece). Returns the resolved document, or null when the
512+
* proxy can't resolve it (bad URI / missing record).
513+
*/
514+
async fetchDocument(uri: string): Promise<ProxyDocument | null> {
515+
const raw = await this.fetch<{ document?: ProxyDocument; error?: string }>(
516+
`/document?uri=${encodeURIComponent(uri)}`,
517+
{ method: 'GET' }
518+
);
519+
return raw.document ?? null;
520+
}
521+
466522
/**
467523
* Fetch Constellation social context for a batch of link posts (Phase 3):
468524
* recommend/quote counts + "who else linked this article" (with handles +

feed-proxy/src/app.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { parseFeed } from './feed-parser';
55
import type { ParsedFeed, FeedItem } from './types';
66
import {
77
fetchDocumentsForAuthor,
8+
fetchSingleDocument,
89
filterByPublication,
910
digestScope,
1011
MAX_DOCUMENTS_PER_AUTHOR,
@@ -786,6 +787,15 @@ export function initDatabase(db: Database): void {
786787
cached_at INTEGER NOT NULL
787788
)
788789
`);
790+
// Migration: add name + theme + fonts columns, all consumed by the optional
791+
// magazine view of a curated Collection. `name` is the publication title,
792+
// `theme` is the JSON `basicTheme` palette (colors), and `fonts` is the JSON
793+
// `publicationTheme` typography (Google Font family names).
794+
const pubColumns = db.query<{ name: string }, []>(`PRAGMA table_info(publication_cache)`).all();
795+
const pubColNames = new Set(pubColumns.map((c) => c.name));
796+
if (!pubColNames.has('name')) db.run(`ALTER TABLE publication_cache ADD COLUMN name TEXT`);
797+
if (!pubColNames.has('theme')) db.run(`ALTER TABLE publication_cache ADD COLUMN theme TEXT`);
798+
if (!pubColNames.has('fonts')) db.run(`ALTER TABLE publication_cache ADD COLUMN fonts TEXT`);
789799

790800
// Per-author resolved standard.site documents. Same freshness/backoff shape as
791801
// `cache`, keyed by the author DID. Documents are stored unfiltered (full
@@ -2164,6 +2174,26 @@ export function createApp(db: Database, config: AppConfig) {
21642174
// Bulk standard.site document endpoint. Symmetric with /feeds, but keyed by
21652175
// author DID instead of feed URL. Returns each requested author's documents
21662176
// (scoped to a publication and trimmed to what the client hasn't seen yet).
2177+
// On-demand fetch of a single standard.site document by at:// URI. Serves
2178+
// pieces opened from a Collection edition whose authors the reader doesn't
2179+
// subscribe to (so they're in no cached author list). Resolved fresh.
2180+
app.get('/document', async (c) => {
2181+
if (proxySecret && c.req.header('X-Proxy-Secret') !== proxySecret) {
2182+
return c.json({ error: 'Unauthorized' }, 401);
2183+
}
2184+
2185+
const uri = c.req.query('uri');
2186+
if (!uri || !uri.startsWith('at://')) {
2187+
return c.json({ error: 'Missing or invalid uri' }, 400);
2188+
}
2189+
2190+
const document = await fetchSingleDocument(db, uri);
2191+
if (!document) {
2192+
return c.json({ error: 'Document not found' }, 404);
2193+
}
2194+
return c.json({ document });
2195+
});
2196+
21672197
app.post('/documents', async (c) => {
21682198
if (proxySecret && c.req.header('X-Proxy-Secret') !== proxySecret) {
21692199
return c.json({ error: 'Unauthorized' }, 401);

0 commit comments

Comments
 (0)