···22# You can find this in your Bluesky profile settings or at https://bsky.app
33PUBLIC_ATPROTO_DID=did:plc:your-did-here
4455+# Ko-fi Supporters (optional)
66+# PDS and DID are resolved automatically from PUBLIC_ATPROTO_DID above.
77+# The only secrets needed are the Ko-fi verification token and a PDS app password.
88+# KOFI_VERIFICATION_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
99+# ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
1010+511# Fallback URL (optional)
612# If a document cannot be found, redirect here
713# Example: https://archive.example.com
···11// BlueskyPostCard uses the app's DID-bound fetchLatestBlueskyPost wrapper — keep it local.
22export { default as BlueskyPostCard } from './BlueskyPostCard.svelte';
33+// SupportersCard — local because it uses the app's own service layer.
44+export { default as SupportersCard } from './SupportersCard.svelte';
35// The rest are data-in, presentation-only — re-export from the package.
46export { LinkCard, ProfileCard, PostCard, TangledRepoCard, MusicStatusCard, KibunStatusCard } from '@ewanc26/ui';
+4
src/lib/services/atproto/index.ts
···76767777// Export cache for advanced use cases
7878export { cache, ATProtoCache } from './cache';
7979+8080+// Export Ko-fi supporters
8181+export { fetchSupporters } from './supporters';
8282+export type { KofiSupporter, KofiEventType } from './supporters';
+65
src/lib/services/atproto/supporters.ts
···11+/**
22+ * Ko-fi supporters service
33+ *
44+ * Reads uk.ewancroft.kofi.supporter records from the PDS and aggregates them
55+ * into KofiSupporter objects. No auth required — records are publicly readable.
66+ *
77+ * The PDS URL is resolved automatically from PUBLIC_ATPROTO_DID via resolveIdentity.
88+ * No additional environment variables are needed for the read path.
99+ */
1010+1111+import { PUBLIC_ATPROTO_DID } from '$env/static/public';
1212+import { getPDSAgent } from '@ewanc26/atproto';
1313+import type { KofiSupporter, KofiEventType } from '@ewanc26/supporters';
1414+1515+export type { KofiSupporter, KofiEventType };
1616+1717+const COLLECTION = 'uk.ewancroft.kofi.supporter';
1818+1919+interface KofiEventRecord {
2020+ name: string;
2121+ type: KofiEventType;
2222+ tier?: string;
2323+}
2424+2525+function dedupe<T>(arr: T[], extra: T): T[] {
2626+ return Array.from(new Set([...arr, extra]));
2727+}
2828+2929+function aggregateEvents(events: KofiEventRecord[]): KofiSupporter[] {
3030+ const map = new Map<string, KofiSupporter>();
3131+3232+ for (const event of events) {
3333+ const existing = map.get(event.name);
3434+ map.set(event.name, {
3535+ name: event.name,
3636+ types: dedupe(existing?.types ?? [], event.type),
3737+ tiers: event.tier ? dedupe(existing?.tiers ?? [], event.tier) : (existing?.tiers ?? [])
3838+ });
3939+ }
4040+4141+ return Array.from(map.values());
4242+}
4343+4444+export async function fetchSupporters(): Promise<KofiSupporter[]> {
4545+ const agent = await getPDSAgent(PUBLIC_ATPROTO_DID);
4646+ const events: KofiEventRecord[] = [];
4747+ let cursor: string | undefined;
4848+4949+ do {
5050+ const res = await agent.com.atproto.repo.listRecords({
5151+ repo: PUBLIC_ATPROTO_DID,
5252+ collection: COLLECTION,
5353+ limit: 100,
5454+ cursor
5555+ });
5656+5757+ for (const record of res.data.records) {
5858+ events.push(record.value as unknown as KofiEventRecord);
5959+ }
6060+6161+ cursor = res.data.cursor;
6262+ } while (cursor);
6363+6464+ return aggregateEvents(events);
6565+}