···9494 displayName: 'atmo.garden Top Week',
9595 description:
9696 'The most-liked submissions from every atmo.garden community over the last 7 days. Browse + submit at https://atmo.garden.'
9797+ },
9898+ {
9999+ rkey: 'following-hot',
100100+ displayName: 'atmo Following Hot',
101101+ description:
102102+ 'Hot submissions from only the atmo.garden communities you follow on Bluesky. Follow a community account to add it to this feed. Browse + submit at https://atmo.garden.'
103103+ },
104104+ {
105105+ rkey: 'following-new',
106106+ displayName: 'atmo Following New',
107107+ description:
108108+ 'Newest submissions from only the atmo.garden communities you follow on Bluesky. Follow a community account to add it to this feed. Browse + submit at https://atmo.garden.'
97109 }
98110];
99111
···2222 COOKIE_SECRET: string;
2323 OAUTH_PUBLIC_URL: string;
2424 PROFILE_CACHE?: KVNamespace;
2525+ /**
2626+ * KV namespace holding the materialized sorted feed lists
2727+ * (`sorted:<sort>` keys, one per PostSort). The cron tick
2828+ * rewrites these once per minute from `getCombinedFeed`,
2929+ * and both `getHomeFeed` (main page) and the bsky feed
3030+ * generator XRPC handler read from them through
3131+ * `src/lib/reddit/feed-cache.ts`.
3232+ */
3333+ FEEDS_CACHE: KVNamespace;
2534 DB: D1Database;
2635 COMMUNITY_ENCRYPTION_KEY: string;
2736 ROOKERY_HOSTNAME: string;
···3443 ATMO_GARDEN_IDENTIFIER?: string;
3544 ATMO_GARDEN_APP_PASSWORD?: string;
3645 ATMO_GARDEN_LIST_RKEY?: string;
4646+ /**
4747+ * at-uri of the single bsky post emitted as the first entry
4848+ * in following-* feeds when the viewer follows zero atmo
4949+ * communities. Created once via
5050+ * `scripts/create-placeholder-post.ts`. Empty string disables
5151+ * the placeholder and the feed falls back to the all-<sort>
5252+ * contents.
5353+ */
5454+ FOLLOWING_FEED_PLACEHOLDER_URI?: string;
3755 };
3856 }
3957 }
···17191719 jetstreamEvents: number;
17201720 postsRefreshed: number;
17211721 postsDeleted: number;
17221722+ feedCachesBuilt: number;
17221723 errors: string[];
17231724}> {
17241725 const db = env.DB;
···17981799 return 0;
17991800 });
1800180118021802+ // Rebuild the materialized sorted lists + community DID list in
18031803+ // KV so both the main page (`getHomeFeed`) and the bsky feed
18041804+ // generator XRPC handler can serve feed requests without hitting
18051805+ // D1 on the hot path. Runs last so it reflects the freshest state
18061806+ // after refresh + sweep. Non-fatal: any per-sort failure just
18071807+ // leaves the previous KV entry in place (the 5 min expirationTtl
18081808+ // safety net kicks in only if the cron stops running entirely).
18091809+ const feedCachesBuilt = await rebuildFeedCaches(env, db).catch((e) => {
18101810+ errors.push(`feed-cache: ${String(e)}`);
18111811+ return 0;
18121812+ });
18131813+18011814 return {
18021815 communitiesChecked: communities.length,
18031816 postsCreated,
···18061819 jetstreamEvents: jetstreamResult.events,
18071820 postsRefreshed,
18081821 postsDeleted,
18221822+ feedCachesBuilt,
18091823 errors
18101824 };
18251825+}
18261826+18271827+// -------------------------------------------------------------------------
18281828+// Feed-cache materialization
18291829+// -------------------------------------------------------------------------
18301830+18311831+/**
18321832+ * Rebuild the KV-backed sorted lists that both the atmo.garden home
18331833+ * page and the bsky feed generator XRPC handler read from.
18341834+ *
18351835+ * Writes:
18361836+ * - `sorted:hot`, `sorted:new`, `sorted:top-day`, `sorted:top-week`
18371837+ * — each a JSON-serialized `PostWithCommunity[]` of up to
18381838+ * `FEED_CACHE_LIMIT` rows from `getCombinedFeed`.
18391839+ * - `communities:dids` — a JSON array of all known community DIDs,
18401840+ * used by the following-feed path's `getRelationships` batch
18411841+ * lookup.
18421842+ *
18431843+ * Runs the 4 sort queries in parallel (they're independent) so the
18441844+ * total added latency to `runCronTick` is bounded by the slowest
18451845+ * query. Returns the number of keys successfully written.
18461846+ */
18471847+async function rebuildFeedCaches(
18481848+ env: App.Platform['env'],
18491849+ db: D1Database
18501850+): Promise<number> {
18511851+ const { FEED_CACHE_LIMIT, writeSortedList, writeAllCommunityDids } =
18521852+ await import('./feed-cache');
18531853+18541854+ // All five sorts the main page exposes in its SortTabs. The bsky
18551855+ // feed generator only dispatches hot/new/top-day/top-week, but
18561856+ // materializing top-month here too costs almost nothing (~500 KB
18571857+ // of KV storage) and lets `getHomeFeed` stay on the fast path
18581858+ // across all UI sorts instead of falling back to D1 for one sort.
18591859+ const sorts = ['hot', 'new', 'top-day', 'top-week', 'top-month'] as const;
18601860+ let written = 0;
18611861+18621862+ await Promise.all([
18631863+ // Full community DID list (small, but refresh it here so the
18641864+ // following-feed path picks up newly-registered communities
18651865+ // within one cron tick).
18661866+ writeAllCommunityDids(env, db)
18671867+ .then(() => {
18681868+ written++;
18691869+ })
18701870+ .catch((e) => {
18711871+ console.error('[rebuildFeedCaches] community DIDs write failed', e);
18721872+ }),
18731873+ // Per-sort materializations.
18741874+ ...sorts.map((sort) =>
18751875+ getCombinedFeed(db, FEED_CACHE_LIMIT, sort, 0)
18761876+ .then((rows) => writeSortedList(env, sort, rows))
18771877+ .then(() => {
18781878+ written++;
18791879+ })
18801880+ .catch((e) => {
18811881+ console.error(`[rebuildFeedCaches] ${sort} failed`, e);
18821882+ })
18831883+ )
18841884+ ]);
18851885+18861886+ return written;
18111887}
1812188818131889// Convenience re-export so routes don't need to import db.ts directly.
···11+// Shared cache layer for the feed pipeline.
22+//
33+// All of our feed surfaces — the atmo.garden home page
44+// (`getHomeFeed`), the bsky feed generator XRPC handler
55+// (`getFeedSkeleton`), and the following-feed variants — read from
66+// the same materialized sorted lists stored in Workers KV (binding
77+// `FEEDS_CACHE`). The cron tick rewrites those lists once per minute
88+// from `getCombinedFeed`, so the hot path never touches D1.
99+//
1010+// Two cache tiers:
1111+//
1212+// 1. Workers KV (`env.FEEDS_CACHE`) — global, durable, written by
1313+// the cron once per minute. Source of truth.
1414+// 2. Workers Cache API (per-colocation edge cache, free) — sits in
1515+// front of KV for both sorted lists and per-viewer follow
1616+// intersections. 30 s TTL on lists, 5 min TTL on follow sets.
1717+//
1818+// The following-feed paths also cache each viewer's set of followed
1919+// community DIDs through the same Cache API layer. Invalidation is
2020+// best-effort per-colo via `invalidateViewerCommunityFollows`,
2121+// triggered by the `POST /api/refresh-follows` endpoint the UI hits
2222+// after a user toggles a community follow.
2323+//
2424+// See `scripts/publish-feed-generators.ts` for the feed records this
2525+// cache eventually serves, and the main cron's `rebuildFeedCaches`
2626+// step for how the KV entries get populated.
2727+2828+import type { PostSort, PostWithCommunity } from './db';
2929+import { listCommunities } from './db';
3030+3131+// Cloudflare Workers Cache API — `caches.default` is a CF-specific
3232+// extension not present on the DOM's `CacheStorage` type, AND the
3333+// `caches` global itself isn't defined in vite's Node SSR runtime
3434+// during `pnpm dev`. We cast once at the top and fall back to a
3535+// no-op shim when the global is missing so the module still loads
3636+// in dev — all cache operations become harmless no-ops and every
3737+// request falls through to KV (which DOES work via platformProxy
3838+// against the remote binding). In production on Workers the real
3939+// `caches.default` takes over transparently.
4040+type MinimalCache = {
4141+ match(key: Request): Promise<Response | undefined>;
4242+ put(key: Request, value: Response): Promise<void>;
4343+ delete(key: Request): Promise<boolean>;
4444+};
4545+4646+const noopCache: MinimalCache = {
4747+ async match() {
4848+ return undefined;
4949+ },
5050+ async put() {
5151+ /* no-op */
5252+ },
5353+ async delete() {
5454+ return false;
5555+ }
5656+};
5757+5858+const cfCache: MinimalCache =
5959+ typeof caches !== 'undefined' &&
6060+ (caches as unknown as { default?: MinimalCache }).default
6161+ ? (caches as unknown as { default: MinimalCache }).default
6262+ : noopCache;
6363+6464+// ---------------------------------------------------------------------------
6565+// Sorted-list cache (materialized `getCombinedFeed` output per sort)
6666+// ---------------------------------------------------------------------------
6767+6868+/**
6969+ * Upper bound on how many rows per sort we materialize and store in
7070+ * KV. The main page's infinite scroll and every feed generator call
7171+ * share this cap — users can page through the top 1000 per sort, and
7272+ * scrolling past that stops. Bump if users start complaining; lower
7373+ * if KV storage gets tight. 1000 rows × ~500 bytes/row = ~500 KB per
7474+ * sort, well under KV's 25 MB value limit.
7575+ */
7676+export const FEED_CACHE_LIMIT = 1000;
7777+7878+/**
7979+ * KV key naming: one key per sort. Values are JSON-serialized
8080+ * `PostWithCommunity[]` with length ≤ `FEED_CACHE_LIMIT`, in sort
8181+ * order. `missing_since IS NULL` is already applied by the underlying
8282+ * `getCombinedFeed` query so we never surface rows that are pending
8383+ * sweep.
8484+ */
8585+function kvKeyForSort(sort: PostSort): string {
8686+ return `sorted:${sort}`;
8787+}
8888+8989+/**
9090+ * Edge-cache key for the sorted list. Workers Cache API keys are
9191+ * Request objects; the URL doesn't have to resolve — it's purely a
9292+ * deterministic identifier.
9393+ */
9494+function cacheKeyForSort(sort: PostSort): Request {
9595+ return new Request(`https://cache.internal/feeds/sorted/${sort}`);
9696+}
9797+9898+/**
9999+ * Write the materialized sorted list for a given sort into KV. Called
100100+ * by the cron's `rebuildFeedCaches` step. Includes a 5 min
101101+ * `expirationTtl` as a belt-and-suspenders safety net — the cron
102102+ * overwrites every minute, so the TTL only matters if the cron stops
103103+ * running entirely.
104104+ */
105105+export async function writeSortedList(
106106+ env: App.Platform['env'],
107107+ sort: PostSort,
108108+ rows: PostWithCommunity[]
109109+): Promise<void> {
110110+ await env.FEEDS_CACHE.put(kvKeyForSort(sort), JSON.stringify(rows), {
111111+ expirationTtl: 300
112112+ });
113113+}
114114+115115+/**
116116+ * Read a sorted list through the edge cache, falling back to KV.
117117+ *
118118+ * Flow:
119119+ * 1. Check Workers Cache API. Hit → return parsed JSON.
120120+ * 2. Miss → fetch from KV. Returns [] if the cron hasn't populated
121121+ * it yet (brand-new deploy or KV key was manually cleared).
122122+ * 3. Populate the edge cache with a 30 s `s-maxage`.
123123+ *
124124+ * The 30 s edge TTL is chosen to be well under the cron tick
125125+ * frequency, so each colo hits KV at most ~2×/min per sort — ~1.4 M
126126+ * KV reads/month across ~200 colos, cheap.
127127+ */
128128+export async function getCachedSortedList(
129129+ env: App.Platform['env'],
130130+ sort: PostSort
131131+): Promise<PostWithCommunity[]> {
132132+ const cacheKey = cacheKeyForSort(sort);
133133+ const cached = await cfCache.match(cacheKey);
134134+ if (cached) {
135135+ try {
136136+ return (await cached.json()) as PostWithCommunity[];
137137+ } catch (e) {
138138+ console.error(`[feed-cache] cached list parse failed for ${sort}`, e);
139139+ // fall through to re-fetch
140140+ }
141141+ }
142142+143143+ const raw = await env.FEEDS_CACHE.get(kvKeyForSort(sort));
144144+ const list: PostWithCommunity[] = raw ? (JSON.parse(raw) as PostWithCommunity[]) : [];
145145+146146+ await cfCache.put(
147147+ cacheKey,
148148+ new Response(JSON.stringify(list), {
149149+ headers: {
150150+ 'content-type': 'application/json',
151151+ 'cache-control': 'public, s-maxage=30'
152152+ }
153153+ })
154154+ );
155155+ return list;
156156+}
157157+158158+// ---------------------------------------------------------------------------
159159+// Community-DID list (small, ~100 entries, rarely changes)
160160+// ---------------------------------------------------------------------------
161161+162162+const COMMUNITY_DIDS_KV_KEY = 'communities:dids';
163163+const COMMUNITY_DIDS_CACHE_KEY = new Request('https://cache.internal/communities/dids');
164164+165165+/**
166166+ * Rewrite the list of all known community DIDs into KV. Called by
167167+ * the cron alongside the sorted-list rebuild — the community set
168168+ * changes rarely (new registrations) so hourly-ish staleness is
169169+ * fine, but running it on every tick is also trivially cheap.
170170+ */
171171+export async function writeAllCommunityDids(
172172+ env: App.Platform['env'],
173173+ db: D1Database
174174+): Promise<void> {
175175+ const rows = await listCommunities(db);
176176+ const dids = rows.map((r) => r.did);
177177+ await env.FEEDS_CACHE.put(COMMUNITY_DIDS_KV_KEY, JSON.stringify(dids), {
178178+ expirationTtl: 3600
179179+ });
180180+}
181181+182182+/**
183183+ * Read the list of all known community DIDs, edge-cached. Used by
184184+ * `fetchViewerCommunityRelationships` to know which DIDs to ask bsky
185185+ * about in each `getRelationships` batch.
186186+ */
187187+export async function getAllCommunityDids(env: App.Platform['env']): Promise<string[]> {
188188+ const cached = await cfCache.match(COMMUNITY_DIDS_CACHE_KEY);
189189+ if (cached) {
190190+ try {
191191+ return (await cached.json()) as string[];
192192+ } catch {
193193+ /* fall through */
194194+ }
195195+ }
196196+197197+ const raw = await env.FEEDS_CACHE.get(COMMUNITY_DIDS_KV_KEY);
198198+ const dids: string[] = raw ? (JSON.parse(raw) as string[]) : [];
199199+200200+ await cfCache.put(
201201+ COMMUNITY_DIDS_CACHE_KEY,
202202+ new Response(JSON.stringify(dids), {
203203+ headers: {
204204+ 'content-type': 'application/json',
205205+ 'cache-control': 'public, s-maxage=300'
206206+ }
207207+ })
208208+ );
209209+ return dids;
210210+}
211211+212212+// ---------------------------------------------------------------------------
213213+// Per-viewer community-follows cache
214214+// ---------------------------------------------------------------------------
215215+216216+const BSKY_APPVIEW_PUBLIC = 'https://public.api.bsky.app';
217217+218218+/**
219219+ * Max DIDs allowed per `app.bsky.graph.getRelationships` call, per
220220+ * the lexicon's `others` array maxLength. Any more and bsky returns
221221+ * 400.
222222+ */
223223+const RELATIONSHIPS_BATCH_SIZE = 30;
224224+225225+function viewerFollowsCacheKey(viewerDid: string): Request {
226226+ // URL-encode the DID so colons etc. don't confuse the cache.
227227+ return new Request(
228228+ `https://cache.internal/follows/${encodeURIComponent(viewerDid)}`
229229+ );
230230+}
231231+232232+/**
233233+ * Call `app.bsky.graph.getRelationships` against the public bsky
234234+ * appview in parallel batches, asking whether `viewerDid` follows
235235+ * each community DID. Returns the subset of community DIDs that
236236+ * `viewerDid` follows.
237237+ *
238238+ * Uses `getRelationships` instead of `getFollows` because we only
239239+ * care about a specific small set of DIDs (the community accounts),
240240+ * not the user's entire follow graph. This is O(communities) instead
241241+ * of O(user's follows) and scales with a stable number (~100).
242242+ */
243243+export async function fetchViewerCommunityRelationships(
244244+ viewerDid: string,
245245+ communityDids: string[]
246246+): Promise<string[]> {
247247+ if (communityDids.length === 0) return [];
248248+249249+ // Chunk into batches of 30.
250250+ const batches: string[][] = [];
251251+ for (let i = 0; i < communityDids.length; i += RELATIONSHIPS_BATCH_SIZE) {
252252+ batches.push(communityDids.slice(i, i + RELATIONSHIPS_BATCH_SIZE));
253253+ }
254254+255255+ const results = await Promise.all(
256256+ batches.map(async (batch) => {
257257+ const url = new URL(
258258+ `${BSKY_APPVIEW_PUBLIC}/xrpc/app.bsky.graph.getRelationships`
259259+ );
260260+ url.searchParams.set('actor', viewerDid);
261261+ // `others` is an array; repeated query params.
262262+ for (const did of batch) url.searchParams.append('others', did);
263263+ try {
264264+ const res = await fetch(url);
265265+ if (!res.ok) {
266266+ console.error(
267267+ '[feed-cache] getRelationships non-ok',
268268+ res.status,
269269+ batch.length
270270+ );
271271+ return [] as string[];
272272+ }
273273+ const body = (await res.json()) as {
274274+ relationships?: Array<
275275+ | { did: string; following?: string; followedBy?: string }
276276+ | { actor: string; notFound: true }
277277+ >;
278278+ };
279279+ const followed: string[] = [];
280280+ for (const rel of body.relationships ?? []) {
281281+ // `following` is set when `viewerDid` follows this DID.
282282+ if ('did' in rel && typeof rel.following === 'string') {
283283+ followed.push(rel.did);
284284+ }
285285+ }
286286+ return followed;
287287+ } catch (e) {
288288+ console.error('[feed-cache] getRelationships threw', e);
289289+ return [];
290290+ }
291291+ })
292292+ );
293293+294294+ // Flatten. Any batch that failed just contributes nothing — we
295295+ // return a possibly-incomplete follow set, which silently hides
296296+ // some of the user's real follows until the next refresh. Better
297297+ // than erroring out the whole feed.
298298+ return results.flat();
299299+}
300300+301301+/**
302302+ * Read the cached set of community DIDs the viewer follows. Cache
303303+ * miss triggers a fresh `getRelationships` fan-out (parallel batches,
304304+ * ~150ms worst case) and a 5 min edge-cache write.
305305+ *
306306+ * Returns the intersection of "communities on atmo.garden" ∩ "DIDs
307307+ * the viewer follows on bsky", using bsky's native graph as the
308308+ * source of truth. No D1 writes, no new follow table — the user's
309309+ * `app.bsky.graph.follow` records on their own PDS drive everything.
310310+ */
311311+export async function getCachedViewerCommunityFollows(
312312+ env: App.Platform['env'],
313313+ viewerDid: string
314314+): Promise<string[]> {
315315+ const cacheKey = viewerFollowsCacheKey(viewerDid);
316316+ const cached = await cfCache.match(cacheKey);
317317+ if (cached) {
318318+ try {
319319+ return (await cached.json()) as string[];
320320+ } catch {
321321+ /* fall through */
322322+ }
323323+ }
324324+325325+ const communityDids = await getAllCommunityDids(env);
326326+ const followed = await fetchViewerCommunityRelationships(viewerDid, communityDids);
327327+328328+ await cfCache.put(
329329+ cacheKey,
330330+ new Response(JSON.stringify(followed), {
331331+ headers: {
332332+ 'content-type': 'application/json',
333333+ 'cache-control': 'public, s-maxage=300'
334334+ }
335335+ })
336336+ );
337337+ return followed;
338338+}
339339+340340+/**
341341+ * Purge the cached follow set for a viewer and eagerly repopulate it
342342+ * from bsky. Called by `POST /api/refresh-follows` after the UI
343343+ * writes a new `app.bsky.graph.follow` record.
344344+ *
345345+ * Important caveat: Workers Cache API is **per-colocation**, so this
346346+ * only busts the cache at the colo handling the current request.
347347+ * Other colos will continue to serve stale data until their 5 min
348348+ * TTL expires. In practice this is fine — users' follow-then-reload
349349+ * flows stick to the same colo via TCP/TLS stickiness and geo
350350+ * routing. Cross-colo invalidation would require moving this cache
351351+ * to KV, which roughly triples monthly cost.
352352+ */
353353+export async function invalidateViewerCommunityFollows(
354354+ env: App.Platform['env'],
355355+ viewerDid: string
356356+): Promise<string[]> {
357357+ const cacheKey = viewerFollowsCacheKey(viewerDid);
358358+ await cfCache.delete(cacheKey);
359359+ // Eagerly repopulate so the refresh round-trip includes the fresh
360360+ // data — the UI can immediately use it (or at least knows the
361361+ // bsky graph has propagated).
362362+ return getCachedViewerCommunityFollows(env, viewerDid);
363363+}
364364+365365+// ---------------------------------------------------------------------------
366366+// JWT helper (unverified payload parsing)
367367+// ---------------------------------------------------------------------------
368368+369369+/**
370370+ * Pull the `iss` claim out of a service-auth JWT in the
371371+ * `Authorization: Bearer <jwt>` header. Does **not** verify the
372372+ * signature — the attack surface is "see how a different user's
373373+ * feed would look," which is bounded by the fact that bsky follow
374374+ * graphs are public anyway. Proper signature verification would
375375+ * require fetching the caller's DID doc and running ES256K against
376376+ * their repo signing key; worth doing eventually, not blocking for
377377+ * MVP.
378378+ *
379379+ * Also rejects obviously-expired JWTs as a minimal sanity check.
380380+ */
381381+export function parseViewerDidFromJwt(authHeader: string | null): string | null {
382382+ if (!authHeader) return null;
383383+ const m = authHeader.match(/^Bearer (.+)$/i);
384384+ if (!m) return null;
385385+ const parts = m[1].split('.');
386386+ if (parts.length !== 3) return null;
387387+388388+ try {
389389+ // base64url → base64 → atob
390390+ let payloadB64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
391391+ while (payloadB64.length % 4 !== 0) payloadB64 += '=';
392392+ const json = atob(payloadB64);
393393+ const obj = JSON.parse(json) as { iss?: unknown; exp?: unknown };
394394+395395+ // Sanity: reject JWTs whose expiry is already past.
396396+ if (typeof obj.exp === 'number' && obj.exp * 1000 < Date.now()) return null;
397397+398398+ const iss = obj.iss;
399399+ if (typeof iss !== 'string' || !iss.startsWith('did:')) return null;
400400+ return iss;
401401+ } catch {
402402+ return null;
403403+ }
404404+}
···11+// Purge the edge-cached community-follow set for the authenticated
22+// user, then eagerly repopulate it from bsky's public appview via
33+// `getRelationships`. Called by the community page's follow / unfollow
44+// button after a successful `app.bsky.graph.follow` (or delete) write
55+// on the user's PDS, so the next `getFeedSkeleton` call for this
66+// user picks up the new community subscription without waiting for
77+// the default 5 min TTL.
88+//
99+// Per-colo invalidation only — see `invalidateViewerCommunityFollows`
1010+// in `src/lib/reddit/feed-cache.ts` for the tradeoff. In practice the
1111+// user's "follow → reload feed" flow stays on one Cloudflare colo via
1212+// TCP/TLS stickiness + geo routing, so the stale-in-other-colos
1313+// window is a non-issue for interactive UX.
1414+1515+import type { RequestHandler } from './$types';
1616+import { json, error } from '@sveltejs/kit';
1717+import { invalidateViewerCommunityFollows } from '$lib/reddit/feed-cache';
1818+1919+export const POST: RequestHandler = async ({ platform, locals }) => {
2020+ const env = platform?.env;
2121+ if (!env) error(500, 'Platform env unavailable');
2222+ if (!locals.did) error(401, 'Not authenticated');
2323+2424+ // `invalidateViewerCommunityFollows` deletes the cached entry at
2525+ // the current colo and then re-fetches from bsky, so this request
2626+ // returns with the fresh data baked into the per-colo cache. The
2727+ // UI doesn't need the returned list today, but we include it in
2828+ // the response in case it wants to render immediate feedback
2929+ // ("you now follow N communities") without a second round-trip.
3030+ const followed = await invalidateViewerCommunityFollows(env, locals.did);
3131+3232+ return json({ ok: true, followedCount: followed.length });
3333+};
···188188 const result = await followUser({ did: community.did });
189189 followUri = result.uri;
190190 }
191191+ // Purge the edge-cached community-follow set for this
192192+ // viewer so the bsky following-hot / following-new feed
193193+ // generators pick up the change on the next request
194194+ // without waiting for the 5 min TTL. Fire-and-forget —
195195+ // the refresh is a nice-to-have, not load-bearing.
196196+ fetch('/api/refresh-follows', { method: 'POST' }).catch((e) => {
197197+ console.error('[community] refresh-follows failed', e);
198198+ });
191199 } catch (e) {
192200 console.error('[community] toggle join failed', e);
193201 } finally {
···11// Bluesky feed generator: serves the `app.bsky.feed.getFeedSkeleton`
22-// XRPC endpoint so the two `app.bsky.feed.generator` records published
33-// by atmo.garden (`all-hot` and `top-day`) can be subscribed to from
44-// any bsky client.
22+// XRPC endpoint. Handles two kinds of feeds:
33+//
44+// scope = 'all' — global feeds (all-hot, all-new, top-day,
55+// top-week). Reads from the KV-materialized
66+// sorted list, slices the requested page,
77+// maps to skeleton entries.
88+//
99+// scope = 'following' — per-viewer personalized feeds
1010+// (following-hot, following-new). Reads the
1111+// same KV list, filters down to posts
1212+// whose community_did is in the viewer's
1313+// followed-community set, then slices.
1414+// Zero subscriptions → emit an optional
1515+// placeholder post followed by the all-<sort>
1616+// contents as a fallback.
1717+//
1818+// Both paths are zero-D1 on the hot request path — all reads come
1919+// from Workers KV with an edge-cache tier in front (see
2020+// src/lib/reddit/feed-cache.ts). The cron tick rebuilds the KV
2121+// entries once per minute, so feed freshness is bounded by that.
2222+//
2323+// Following feeds pull the viewer DID from the `Authorization:
2424+// Bearer <jwt>` header's `iss` claim, parsed without signature
2525+// verification. The attack surface is "look at how a different user's
2626+// feed would render" — which an attacker could already compute from
2727+// bsky's public follow graph — so unverified parsing is acceptable
2828+// for MVP. Proper signature verification would require fetching the
2929+// caller's DID doc and running ES256K, worth doing eventually.
530//
631// Wire shape (per lexicons.atproto.com / app.bsky.feed.getFeedSkeleton):
732//
···1944// }
2045// }
2146//
2222-// atmo.garden serves reposts and quote posts differently:
4747+// Post routing for skeleton entries:
2348//
2449// - Community REPOST rows (`uri` → `app.bsky.feed.repost/...`):
2550// emit `{ post: quoted_post_uri, reason: skeletonReasonRepost(uri) }`.
···3156// emit `{ post: uri }`. The community's quote post is itself a
3257// valid bsky post with an `app.bsky.embed.record` embed, so the
3358// appview hydrates it with the original post rendered inline.
3434-//
3535-// Feeds are matched by the rkey of the `feed` query param
3636-// (`all-hot` / `top-day`). We intentionally don't validate the
3737-// authority DID — if someone publishes a generator record on their
3838-// own account that points at our service, they'll get the same
3939-// feed data, but they can't modify it, so it's harmless.
40594160import type { RequestHandler } from './$types';
4261import { json, error } from '@sveltejs/kit';
4343-import { getCombinedFeed, type PostSort, type PostWithCommunity } from '$lib/reddit/db';
6262+import type { PostSort, PostWithCommunity } from '$lib/reddit/db';
6363+import {
6464+ getCachedSortedList,
6565+ getCachedViewerCommunityFollows,
6666+ parseViewerDidFromJwt
6767+} from '$lib/reddit/feed-cache';
44684569const DEFAULT_LIMIT = 50;
4670const MAX_LIMIT = 100;
47714848-/** Map a known feed rkey → the `PostSort` we drive `getCombinedFeed` with. */
4949-const FEED_RKEY_TO_SORT: Record<string, PostSort> = {
5050- 'all-hot': 'hot',
5151- 'all-new': 'new',
5252- 'top-day': 'top-day',
5353- 'top-week': 'top-week'
7272+type FeedConfig = {
7373+ sort: PostSort;
7474+ scope: 'all' | 'following';
7575+};
7676+7777+/** Map a known feed rkey → config (sort + scope). */
7878+const FEED_RKEY_TO_CONFIG: Record<string, FeedConfig> = {
7979+ 'all-hot': { sort: 'hot', scope: 'all' },
8080+ 'all-new': { sort: 'new', scope: 'all' },
8181+ 'top-day': { sort: 'top-day', scope: 'all' },
8282+ 'top-week': { sort: 'top-week', scope: 'all' },
8383+ 'following-hot': { sort: 'hot', scope: 'following' },
8484+ 'following-new': { sort: 'new', scope: 'following' }
5485};
55865687type SkeletonFeedPost = {
···104135 return null;
105136}
106137107107-export const GET: RequestHandler = async ({ url, platform }) => {
138138+/**
139139+ * Build a response envelope with cursor handling. The cursor is an
140140+ * opaque string per the lexicon spec; we use base-10 offset because
141141+ * everything downstream is offset-paginated. Only emit a cursor
142142+ * when the page was filled — saves one empty paginated round-trip
143143+ * at the end of the feed.
144144+ */
145145+function buildResponse(
146146+ entries: SkeletonFeedPost[],
147147+ offset: number,
148148+ limit: number,
149149+ totalAvailable: number
150150+): { feed: SkeletonFeedPost[]; cursor?: string } {
151151+ const page = entries.slice(0, limit);
152152+ const nextOffset = offset + page.length;
153153+ const nextCursor = nextOffset < totalAvailable && page.length === limit ? String(nextOffset) : undefined;
154154+ return {
155155+ feed: page,
156156+ ...(nextCursor ? { cursor: nextCursor } : {})
157157+ };
158158+}
159159+160160+export const GET: RequestHandler = async ({ url, request, platform }) => {
108161 const env = platform?.env;
109109- if (!env?.DB) error(500, 'DB binding unavailable');
162162+ if (!env) error(500, 'Platform env unavailable');
110163111164 const feedUri = url.searchParams.get('feed');
112165 if (!feedUri) error(400, 'missing `feed` parameter');
113166114167 const rkey = parseFeedRkey(feedUri);
115115- const sort = rkey ? FEED_RKEY_TO_SORT[rkey] : undefined;
116116- if (!sort) {
168168+ const config = rkey ? FEED_RKEY_TO_CONFIG[rkey] : undefined;
169169+ if (!config) {
117170 // Unknown feed. The getFeedSkeleton lexicon defines a single
118171 // named error for this case (`errors: [{ name: "UnknownFeed" }]`)
119172 // and the atproto XRPC error convention is a 400 with a JSON
120173 // body of `{ error: "<name>", message: "<human>" }` — clients
121121- // branch on the name to render a "feed unavailable" state,
122122- // which is better UX than silently returning an empty feed.
174174+ // branch on the name to render a "feed unavailable" state.
123175 return json(
124176 { error: 'UnknownFeed', message: `Feed not found: ${feedUri}` },
125177 { status: 400 }
126178 );
127179 }
128180129129- // Clamp limit to the [1, MAX_LIMIT] range per the lexicon spec.
181181+ // Clamp limit to [1, MAX_LIMIT] per the lexicon spec.
130182 const limitRaw = url.searchParams.get('limit');
131183 const limitParsed = limitRaw ? parseInt(limitRaw, 10) : DEFAULT_LIMIT;
132184 const limit = Number.isFinite(limitParsed)
133185 ? Math.max(1, Math.min(MAX_LIMIT, limitParsed))
134186 : DEFAULT_LIMIT;
135187136136- // Cursor is an opaque string per spec; we use a base-10 offset
137137- // because `getCombinedFeed` already accepts offset-based pagination
138138- // and we don't need anything fancier. Invalid cursors fall back
139139- // to offset 0.
188188+ // Cursor → offset. Invalid cursors fall back to 0.
140189 const cursorRaw = url.searchParams.get('cursor');
141190 const cursorParsed = cursorRaw ? parseInt(cursorRaw, 10) : 0;
142191 const offset =
143192 Number.isFinite(cursorParsed) && cursorParsed >= 0 ? cursorParsed : 0;
144193145145- const rows = await getCombinedFeed(env.DB, limit, sort, offset);
146146- const feed = rows.map(rowToSkeleton).filter((x): x is SkeletonFeedPost => x !== null);
194194+ // Pull the materialized global sorted list once — both scopes
195195+ // read from the same cached entry.
196196+ const sortedList = await getCachedSortedList(env, config.sort);
197197+198198+ if (config.scope === 'all') {
199199+ const slice = sortedList
200200+ .slice(offset, offset + limit)
201201+ .map(rowToSkeleton)
202202+ .filter((x): x is SkeletonFeedPost => x !== null);
203203+ return json(buildResponse(slice, offset, limit, sortedList.length));
204204+ }
147205148148- // Only emit a cursor when we filled the page — saves one empty
149149- // paginated round-trip for the common "we've reached the end" case.
150150- const nextCursor = rows.length === limit ? String(offset + rows.length) : undefined;
206206+ // ----- scope === 'following' -----
151207152152- return json({
153153- feed,
154154- ...(nextCursor ? { cursor: nextCursor } : {})
155155- });
208208+ // Extract viewer DID from the service-auth JWT. Missing / malformed
209209+ // JWT → treat as zero-subscription fallback, which surfaces the
210210+ // placeholder + all-<sort> contents so an unauthenticated client
211211+ // peek at the feed still renders something useful.
212212+ const viewerDid = parseViewerDidFromJwt(request.headers.get('authorization'));
213213+214214+ let filtered: PostWithCommunity[] = [];
215215+ if (viewerDid) {
216216+ const followed = await getCachedViewerCommunityFollows(env, viewerDid);
217217+ if (followed.length > 0) {
218218+ const followedSet = new Set(followed);
219219+ filtered = sortedList.filter((r) => followedSet.has(r.community_did));
220220+ }
221221+ }
222222+223223+ if (filtered.length === 0) {
224224+ // Empty-state: show the placeholder post (if configured) then
225225+ // fall back to the all-<sort> list so the feed always has
226226+ // content. Placeholder absent (env var empty) → just fall back
227227+ // to the all-<sort> contents.
228228+ const placeholderUri = env.FOLLOWING_FEED_PLACEHOLDER_URI ?? '';
229229+ const fallback = sortedList
230230+ .slice(offset, offset + limit)
231231+ .map(rowToSkeleton)
232232+ .filter((x): x is SkeletonFeedPost => x !== null);
233233+234234+ // Only prepend the placeholder on the first page (offset 0) —
235235+ // otherwise subsequent pages would have it re-inserted.
236236+ const entries: SkeletonFeedPost[] =
237237+ offset === 0 && placeholderUri
238238+ ? [{ post: placeholderUri }, ...fallback].slice(0, limit)
239239+ : fallback;
240240+241241+ return json(buildResponse(entries, offset, limit, sortedList.length));
242242+ }
243243+244244+ // Normal path: viewer has real subscriptions.
245245+ const slice = filtered
246246+ .slice(offset, offset + limit)
247247+ .map(rowToSkeleton)
248248+ .filter((x): x is SkeletonFeedPost => x !== null);
249249+ return json(buildResponse(slice, offset, limit, filtered.length));
156250};