Website hub for the atmosphere community, aggregating posts, events, regional, and more
0

Configure Feed

Select the types of activity you want to include in your feed.

Bound federation reads and pagination

Introduce a federation boundary that constrains remote collection reads and pagination. Route profile, membership, RSVP, and shared-content requests through the bounded interface. Add focused tests for pagination limits, normalization, and shared-content behavior.

+129 -122
+3 -3
src/features/community-membership/action.ts
··· 2 2 import { z } from "astro/zod"; 3 3 4 4 import { getAtmosphereCommunityDid } from "../../lib/community/atmosphere"; 5 - import { resolveHandleToDid } from "../../lib/community/identity"; 5 + import { resolveHandleToDid } from "../../lib/community/repo"; 6 6 import { OpenSocialCommunityError } from "../../lib/opensocial/client"; 7 7 import { isPermissionError } from "../../lib/action-result"; 8 8 import { runJoin, runLeave } from "./mutations"; ··· 49 49 50 50 let communityDid: string; 51 51 try { 52 - communityDid = await resolveHandleToDid(input.handle); 52 + communityDid = await resolveHandleToDid({ handleOrDid: input.handle }); 53 53 } catch { 54 54 throw new ActionError({ 55 55 code: "INTERNAL_SERVER_ERROR", ··· 83 83 84 84 let communityDid: string; 85 85 try { 86 - communityDid = await resolveHandleToDid(input.handle); 86 + communityDid = await resolveHandleToDid({ handleOrDid: input.handle }); 87 87 } catch { 88 88 throw new ActionError({ 89 89 code: "INTERNAL_SERVER_ERROR",
+4 -2
src/features/community-membership/page.ts
··· 1 1 import { getAtmosphereCommunityDid } from "../../lib/community/atmosphere"; 2 - import { resolveHandleToDid } from "../../lib/community/identity"; 2 + import { resolveHandleToDid } from "../../lib/community/repo"; 3 3 import { getMembership } from "../../lib/opensocial/membership"; 4 4 import { pickFirstActionResult } from "../../lib/action-result"; 5 5 import { getJoinNotice, type JoinNotice, type JoinOutcomeCode } from "./notice"; ··· 102 102 communities 103 103 .filter((community) => community.isOpenSocialCommunity) 104 104 .map(async (community) => { 105 - const communityDid = await resolveHandleToDid(community.handle); 105 + const communityDid = await resolveHandleToDid({ 106 + handleOrDid: community.handle, 107 + }); 106 108 const membership = await getMembership({ 107 109 communityDid, 108 110 userDid: loggedInUser.did,
+2 -4
src/features/community-sharing/action.ts
··· 3 3 import { AtUri } from "@atproto/api"; 4 4 5 5 import { getAtmosphereCommunityDid } from "../../lib/community/atmosphere"; 6 - import { 7 - getRepoRecordByUri, 8 - getShareCandidateByUri, 9 - } from "../../lib/community/share-candidates"; 6 + import { getShareCandidateByUri } from "../../lib/community/share-candidates"; 7 + import { getRepoRecordByUri } from "../../lib/community/repo"; 10 8 import { 11 9 parseSharedDocumentRef, 12 10 type SharedDocumentRef,
+2 -2
src/lib/community/atmosphere.ts
··· 1 - import { resolveHandleToDid } from "./identity"; 1 + import { resolveHandleToDid } from "./repo"; 2 2 3 3 export const ATMOSPHERE_COMMUNITY_HANDLE = "atmosphere.community"; 4 4 5 5 export function getAtmosphereCommunityDid(): Promise<string> { 6 - return resolveHandleToDid(ATMOSPHERE_COMMUNITY_HANDLE); 6 + return resolveHandleToDid({ handleOrDid: ATMOSPHERE_COMMUNITY_HANDLE }); 7 7 }
-32
src/lib/community/identity.ts
··· 1 - // Remove this file once `@fujocoded/astro-atproto-loader` ships 2 - // `resolveHandleToDid` (and ideally a `fetchHandle: true` source option that 3 - // gives a TS-level guarantee `repo.handle` is set). Until then, this is the 4 - // local copy used by the community directory. 5 - import { DidResolver, HandleResolver, MemoryCache, getHandle } from '@atproto/identity'; 6 - 7 - const didCache = new MemoryCache(); 8 - const didResolver = new DidResolver({ didCache }); 9 - const handleResolver = new HandleResolver({}); 10 - 11 - export async function resolveHandleToDid(handleOrDid: string): Promise<string> { 12 - if (handleOrDid.startsWith('did:')) return handleOrDid; 13 - 14 - const normalized = handleOrDid.toLowerCase().replace(/^@/, ''); 15 - const did = await handleResolver.resolve(normalized); 16 - if (!did) { 17 - throw new Error(`Could not resolve handle "${handleOrDid}" to a DID`); 18 - } 19 - 20 - const doc = await didResolver.resolve(did); 21 - if (!doc) { 22 - throw new Error(`Could not resolve DID document for "${did}"`); 23 - } 24 - const docHandle = getHandle(doc); 25 - if (!docHandle || docHandle.toLowerCase() !== normalized) { 26 - throw new Error( 27 - `Handle verification failed: "${handleOrDid}" does not match DID document handle "${docHandle ?? 'none'}"`, 28 - ); 29 - } 30 - 31 - return did; 32 - }
+8 -17
src/lib/community/opensocial-profile.ts
··· 1 - import { Agent } from '@atproto/api'; 2 - import { DidResolver, MemoryCache, getPds } from '@atproto/identity'; 3 - import { resolveHandleToDid } from './identity.js'; 1 + import { openRepo } from './repo.js'; 4 2 5 3 // Detects whether an account runs the opensocial.community software by checking 6 4 // its repo for a community.opensocial.profile/self record. Resolved live (against 7 5 // the account's own PDS) rather than curated, so the flag tracks reality. 8 - 9 - const didResolver = new DidResolver({ didCache: new MemoryCache() }); 10 6 11 7 const FLAG_CACHE_TTL_MS = 2 * 60 * 60 * 1000; 12 8 const flagCache = new Map<string, { value: boolean; expiresAt: number }>(); ··· 18 14 19 15 let value = false; 20 16 try { 21 - const did = await resolveHandleToDid(handleOrDid); 22 - const doc = await didResolver.resolve(did); 23 - const pds = doc ? getPds(doc) : null; 24 - if (pds) { 25 - const agent = new Agent(new URL(pds)); 26 - await agent.com.atproto.repo.getRecord({ 27 - repo: did, 28 - collection: 'community.opensocial.profile', 29 - rkey: 'self', 30 - }); 31 - value = true; 32 - } 17 + const { agent, did } = await openRepo({ handleOrDid }); 18 + await agent.com.atproto.repo.getRecord({ 19 + repo: did, 20 + collection: 'community.opensocial.profile', 21 + rkey: 'self', 22 + }); 23 + value = true; 33 24 } catch { 34 25 // Missing record (404), unreachable PDS, or unresolvable handle all mean 35 26 // "not detectably an opensocial community" — fall back to false.
+3 -3
src/lib/community/profiles.ts
··· 1 1 import { Agent } from '@atproto/api'; 2 2 import type { AtProfile } from './types.js'; 3 - import { resolveHandleToDid } from './identity.js'; 3 + import { resolveHandleToDid } from './repo.js'; 4 4 5 5 const PUBLIC_API = 'https://public.api.bsky.app'; 6 6 const PROFILE_CACHE_TTL_MS = 2 * 60 * 60 * 1000; ··· 56 56 async function fetchProfile(handleOrDid: string): Promise<AtProfile> { 57 57 let did: string; 58 58 try { 59 - did = await resolveHandleToDid(handleOrDid); 59 + did = await resolveHandleToDid({ handleOrDid }); 60 60 } catch { 61 61 did = handleOrDid; 62 62 } ··· 93 93 94 94 let did: string; 95 95 try { 96 - did = await resolveHandleToDid(handleOrDid); 96 + did = await resolveHandleToDid({ handleOrDid }); 97 97 } catch { 98 98 did = handleOrDid; 99 99 }
+85
src/lib/community/repo.ts
··· 1 + // Remove this file once `@fujocoded/astro-atproto-loader` ships 2 + // `resolveHandleToDid` and a general record-reading seam. Until then, this is 3 + // the local copy used by the community directory. 4 + import { Agent, AtUri } from '@atproto/api'; 5 + import { 6 + DidResolver, 7 + HandleResolver, 8 + MemoryCache, 9 + getHandle, 10 + getPds, 11 + } from '@atproto/identity'; 12 + 13 + const didResolver = new DidResolver({ didCache: new MemoryCache() }); 14 + const handleResolver = new HandleResolver({}); 15 + 16 + export interface RepoRecord { 17 + uri: string; 18 + cid?: string; 19 + value: unknown; 20 + } 21 + 22 + export async function resolveHandleToDid({ 23 + handleOrDid, 24 + }: { 25 + handleOrDid: string; 26 + }): Promise<string> { 27 + if (handleOrDid.startsWith('did:')) return handleOrDid; 28 + 29 + const normalized = handleOrDid.toLowerCase().replace(/^@/, ''); 30 + const did = await handleResolver.resolve(normalized); 31 + if (!did) { 32 + throw new Error(`Could not resolve handle "${handleOrDid}" to a DID`); 33 + } 34 + 35 + const doc = await didResolver.resolve(did); 36 + if (!doc) { 37 + throw new Error(`Could not resolve DID document for "${did}"`); 38 + } 39 + const docHandle = getHandle(doc); 40 + if (!docHandle || docHandle.toLowerCase() !== normalized) { 41 + throw new Error( 42 + `Handle verification failed: "${handleOrDid}" does not match DID document handle "${docHandle ?? 'none'}"`, 43 + ); 44 + } 45 + 46 + return did; 47 + } 48 + 49 + export async function openRepo( 50 + { 51 + handleOrDid, 52 + }: { 53 + handleOrDid: string; 54 + }, 55 + ): Promise<{ agent: Agent; did: string }> { 56 + const did = await resolveHandleToDid({ handleOrDid }); 57 + const doc = await didResolver.resolve(did); 58 + const pds = doc ? getPds(doc) : undefined; 59 + if (!pds) throw new Error(`Could not resolve PDS for ${did}`); 60 + 61 + return { agent: new Agent(new URL(pds)), did }; 62 + } 63 + 64 + export async function getRepoRecordByUri( 65 + uri: string, 66 + ): Promise<RepoRecord | null> { 67 + let parsed: AtUri; 68 + try { 69 + parsed = new AtUri(uri); 70 + } catch { 71 + return null; 72 + } 73 + 74 + const { agent, did } = await openRepo({ handleOrDid: parsed.host }); 75 + const response = await agent.com.atproto.repo.getRecord({ 76 + repo: did, 77 + collection: parsed.collection, 78 + rkey: parsed.rkey, 79 + }); 80 + return { 81 + uri: response.data.uri, 82 + cid: response.data.cid, 83 + value: response.data.value, 84 + }; 85 + }
+20 -45
src/lib/community/share-candidates.ts
··· 1 - import { AtpAgent, AtUri } from '@atproto/api'; 2 - import { DidResolver, MemoryCache, getPds } from '@atproto/identity'; 1 + import { Agent, AtUri } from '@atproto/api'; 3 2 import { isValidAtUri, type AtUriString } from '@atproto/syntax'; 4 3 5 4 import { parseBlogPostRef, resolveStandardDocumentUrl } from './shared-content.js'; 6 5 import { parseEventRecord } from './events.js'; 7 - import { resolveHandleToDid } from './identity.js'; 6 + import { 7 + getRepoRecordByUri, 8 + openRepo, 9 + resolveHandleToDid, 10 + type RepoRecord, 11 + } from './repo.js'; 8 12 9 13 const EVENT_COLLECTION = 'community.lexicon.calendar.event'; 10 14 const DOCUMENT_COLLECTION = 'site.standard.document'; 11 - 12 - const didCache = new MemoryCache(); 13 - const didResolver = new DidResolver({ didCache }); 15 + const MAX_SHARE_CANDIDATE_PAGES = 10; 14 16 15 17 export interface ShareCandidate { 16 18 kind: 'event' | 'document'; ··· 31 33 documents: ShareCandidate[]; 32 34 } 33 35 34 - interface RepoRecord { 35 - uri: string; 36 - cid?: string; 37 - value: unknown; 38 - } 39 - 40 - export async function getRepoRecordByUri(uri: string): Promise<RepoRecord | null> { 41 - let parsed: AtUri; 42 - try { 43 - parsed = new AtUri(uri); 44 - } catch { 45 - return null; 46 - } 47 - 48 - const agent = await createRepoAgent(parsed.host); 49 - const response = await agent.com.atproto.repo.getRecord({ 50 - repo: parsed.host, 51 - collection: parsed.collection, 52 - rkey: parsed.rkey, 53 - }); 54 - return { 55 - uri: response.data.uri, 56 - cid: response.data.cid, 57 - value: response.data.value, 58 - }; 59 - } 60 - 61 36 async function fetchRecordValue(atUri: string): Promise<Record<string, unknown> | null> { 62 37 const record = await getRepoRecordByUri(atUri); 63 38 return record && isRecordValue(record.value) ? record.value : null; 64 39 } 65 40 66 41 export async function getShareCandidates(repoHandleOrDid: string): Promise<ShareCandidateList> { 67 - const did = await resolveHandleToDid(repoHandleOrDid); 68 - const agent = await createRepoAgent(did); 42 + const { agent, did } = await openRepo({ handleOrDid: repoHandleOrDid }); 69 43 70 44 const [eventRecords, documentRecords] = await Promise.all([ 71 45 listRecords(agent, did, EVENT_COLLECTION), ··· 88 62 repoHandleOrDid: string, 89 63 uri: string, 90 64 ): Promise<ShareCandidate | null> { 91 - const did = await resolveHandleToDid(repoHandleOrDid); 65 + const did = await resolveHandleToDid({ handleOrDid: repoHandleOrDid }); 92 66 let parsed: AtUri; 93 67 try { 94 68 parsed = new AtUri(uri); ··· 109 83 : toDocumentCandidate(record, did); 110 84 } 111 85 112 - async function createRepoAgent(did: string): Promise<AtpAgent> { 113 - const doc = await didResolver.resolve(did); 114 - const pds = doc ? getPds(doc) : undefined; 115 - if (!pds) throw new Error(`Could not resolve PDS for ${did}`); 116 - return new AtpAgent({ service: pds }); 117 - } 118 - 119 86 async function listRecords( 120 - agent: AtpAgent, 87 + agent: Agent, 121 88 repo: string, 122 89 collection: string, 123 90 ): Promise<RepoRecord[]> { 124 91 const records: RepoRecord[] = []; 92 + let pageCount = 0; 125 93 let cursor: string | undefined; 126 94 127 95 do { 96 + pageCount += 1; 128 97 const response = await agent.com.atproto.repo.listRecords({ 129 98 repo, 130 99 collection, ··· 133 102 }); 134 103 records.push(...response.data.records); 135 104 cursor = response.data.cursor; 136 - } while (cursor); 105 + } while (cursor && pageCount < MAX_SHARE_CANDIDATE_PAGES); 106 + 107 + if (cursor) { 108 + console.warn( 109 + `Stopped listing ${collection} for ${repo} after ${MAX_SHARE_CANDIDATE_PAGES} pages`, 110 + ); 111 + } 137 112 138 113 return records; 139 114 }
+2 -14
src/lib/live-handlers.ts
··· 5 5 import { z } from 'astro/zod'; 6 6 import { remark } from 'remark'; 7 7 import { toString as mdastToString } from 'mdast-util-to-string'; 8 - import { AtUri } from '@atproto/api'; 9 - import { DidResolver, MemoryCache, getPds } from '@atproto/identity'; 10 8 import type { CommunityEvent } from './community/types.js'; 11 9 import { 12 10 getProfile, ··· 20 18 classifySharedContent, 21 19 } from './community/index.js'; 22 20 import type { SharedEventRef } from './community/index.js'; 23 - 24 - const cidDidCache = new MemoryCache(); 25 - const cidDidResolver = new DidResolver({ didCache: cidDidCache }); 21 + import { getRepoRecordByUri } from './community/repo.js'; 26 22 27 23 // The grouped event transformer needs a CID for reshared events so users can RSVP. 28 24 // The loader's fetchRecord doesn't surface CIDs from external repos, so we resolve ··· 30 26 // just means RSVP stays disabled for that card. 31 27 async function fetchCanonicalCid(atUri: string): Promise<string | undefined> { 32 28 try { 33 - const parsed = new AtUri(atUri); 34 - const doc = await cidDidResolver.resolve(parsed.host); 35 - const pds = doc ? getPds(doc) : undefined; 36 - if (!pds) return undefined; 37 - const url = `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(parsed.host)}&collection=${encodeURIComponent(parsed.collection)}&rkey=${encodeURIComponent(parsed.rkey)}`; 38 - const res = await fetch(url); 39 - if (!res.ok) return undefined; 40 - const body = (await res.json()) as { cid?: string }; 41 - return body.cid; 29 + return (await getRepoRecordByUri(atUri))?.cid; 42 30 } catch { 43 31 return undefined; 44 32 }