···11-# Svelte library
11+# @ewanc26/supporters
2233-Everything you need to build a Svelte library, powered by [`sv`](https://npmjs.com/package/sv).
33+SvelteKit component library for displaying Ko-fi supporters, backed by an ATProto PDS.
4455-Read more about creating a library [in the docs](https://svelte.dev/docs/kit/packaging).
55+Ko-fi's webhook pushes payment events to your endpoint. Each event is stored as a record under the `uk.ewancroft.kofi.supporter` lexicon on your PDS, with a TID rkey derived from the transaction timestamp. The component reads those records and renders them.
6677-## Creating a project
77+---
88+99+## How it works
1010+1111+1. Ko-fi POSTs a webhook event to `/webhook` on each transaction
1212+2. The handler verifies the `verification_token`, respects `is_public`, and calls `appendEvent`
1313+3. `appendEvent` writes a record to your PDS under `uk.ewancroft.kofi.supporter`
1414+4. `readStore` fetches all records and aggregates them into `KofiSupporter` objects
1515+5. Pass the result to `<KofiSupporters>` or `<LunarContributors>`
81699-If you're seeing this, you've probably already done this step. Congrats!
1717+---
10181111-```sh
1212-# create a new project in the current directory
1313-npx sv create
1919+## Setup
14201515-# create a new project in my-app
1616-npx sv create my-app
1717-```
2121+### 1. Environment variables
18221919-To recreate this project with the same configuration:
2323+```env
2424+# Required — copy from ko-fi.com/manage/webhooks → Advanced → Verification Token
2525+KOFI_VERIFICATION_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
20262121-```sh
2222-# recreate this project
2323-pnpm dlx sv@0.12.5 create --template library --types ts --add prettier tailwindcss="plugins:typography" sveltekit-adapter="adapter:auto" --install pnpm ./supporters
2727+# Required — your ATProto identity and a dedicated app password
2828+ATPROTO_DID=did:plc:yourdidhex
2929+ATPROTO_PDS_URL=https://your-pds.example.com
3030+ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
2431```
25322626-## Developing
3333+Generate an app password at your PDS under **Settings → App Passwords**.
27342828-Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
3535+### 2. Register the webhook
29363030-```sh
3131-npm run dev
3737+Go to **ko-fi.com/manage/webhooks** and set your webhook URL to:
32383333-# or start the server and open the app in a new browser tab
3434-npm run dev -- --open
3939+```
4040+https://your-domain.com/webhook
3541```
36423737-Everything inside `src/lib` is part of your library, everything inside `src/routes` can be used as a showcase or preview app.
4343+### 3. Add the route
38443939-## Building
4545+Copy `src/routes/webhook/+server.ts` into your SvelteKit app's routes directory.
4646+4747+### 4. Use the component
40484141-To build your library:
4949+```ts
5050+// +page.server.ts
5151+import { readStore } from '@ewanc26/supporters';
42524343-```sh
4444-npm pack
5353+export const load = async () => ({
5454+ supporters: await readStore()
5555+});
4556```
46574747-To create a production version of your showcase app:
5858+```svelte
5959+<!-- +page.svelte -->
6060+<script lang="ts">
6161+ import { KofiSupporters } from '@ewanc26/supporters';
6262+ let { data } = $props();
6363+</script>
48644949-```sh
5050-npm run build
6565+<KofiSupporters supporters={data.supporters} />
5166```
52675353-You can preview the production build with `npm run preview`.
6868+---
54695555-> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
7070+## Components
56715757-## Publishing
7272+### `<KofiSupporters>`
58735959-Go into the `package.json` and give your package the desired name through the `"name"` option. Also consider adding a `"license"` field and point it to a `LICENSE` file which you can create from a template (one popular option is the [MIT license](https://opensource.org/license/mit/)).
7474+Displays all supporters with emoji type badges (☕ donation, ⭐ subscription, 🎨 commission, 🛍️ shop order).
60756161-To publish your library to [npm](https://www.npmjs.com):
7676+| Prop | Type | Default |
7777+|---|---|---|
7878+| `supporters` | `KofiSupporter[]` | `[]` |
7979+| `heading` | `string` | `'Supporters'` |
8080+| `description` | `string` | `'People who support my work on Ko-fi.'` |
8181+| `filter` | `KofiEventType[]` | `undefined` (show all) |
8282+| `loading` | `boolean` | `false` |
8383+| `error` | `string \| null` | `null` |
62846363-```sh
6464-npm publish
8585+### `<LunarContributors>`
8686+8787+Convenience wrapper around `<KofiSupporters>` pre-filtered to `Subscription` events.
8888+8989+---
9090+9191+## Importing historical data
9292+9393+Export your transaction history from **ko-fi.com/manage/transactions → Export CSV**, then:
9494+9595+```bash
9696+ATPROTO_DID=... ATPROTO_PDS_URL=... ATPROTO_APP_PASSWORD=... \
9797+ node node_modules/@ewanc26/supporters/scripts/import-history.mjs transactions.csv --dry-run
6598```
9999+100100+---
101101+102102+## Lexicon
103103+104104+Records are stored under `uk.ewancroft.kofi.supporter` (see `lexicons/`). Each record contains:
105105+106106+```ts
107107+{
108108+ name: string // display name from Ko-fi
109109+ type: string // "Donation" | "Subscription" | "Commission" | "Shop Order"
110110+ tier?: string // subscription tier name, if applicable
111111+}
112112+```
113113+114114+rkeys are TIDs derived from the transaction timestamp via [`@ewanc26/tid`](https://npmjs.com/package/@ewanc26/tid).
···11+<script lang="ts">
22+ /**
33+ * Convenience wrapper around KofiSupporters pre-filtered to Subscription
44+ * events, with the "Lunar Contributors" heading.
55+ *
66+ * Equivalent to:
77+ * <KofiSupporters filter={['Subscription']} heading="Lunar Contributors" />
88+ */
99+ import KofiSupporters from './KofiSupporters.svelte';
1010+ import type { KofiSupportersProps } from './types.js';
1111+1212+ let {
1313+ supporters = [],
1414+ heading = 'Lunar Contributors',
1515+ description = 'People who support my work on Ko-fi.',
1616+ loading = false,
1717+ error = null
1818+ }: Omit<KofiSupportersProps, 'filter'> = $props();
1919+</script>
2020+2121+<KofiSupporters
2222+ {supporters}
2323+ {heading}
2424+ {description}
2525+ filter={['Subscription']}
2626+ {loading}
2727+ {error}
2828+/>
+6-1
packages/supporters/src/lib/index.ts
···11-// Reexport your entry components here
11+export { default as KofiSupporters } from './KofiSupporters.svelte';
22+export { default as LunarContributors } from './LunarContributors.svelte';
33+export { readStore, appendEvent } from './store.js';
44+export type { KofiEventRecord } from './store.js';
55+export { parseWebhook, WebhookError } from './webhook.js';
66+export type { KofiSupporter, KofiWebhookPayload, KofiSupportersProps, KofiEventType } from './types.js';
+125
packages/supporters/src/lib/store.ts
···11+/**
22+ * ATProto PDS store for Ko-fi supporter data.
33+ *
44+ * Each Ko-fi event is stored as a separate record under:
55+ * uk.ewancroft.kofi.supporter
66+ *
77+ * rkeys are TIDs generated by @ewanc26/tid — one record per event.
88+ * The aggregated KofiSupporter view (deduped by name) is built at read time.
99+ *
1010+ * Reads are public (no auth). Writes use an app password.
1111+ *
1212+ * Required environment variables:
1313+ * ATPROTO_DID — your DID, e.g. did:plc:abc123
1414+ * ATPROTO_PDS_URL — your PDS URL, e.g. https://pds.ewancroft.uk
1515+ * ATPROTO_APP_PASSWORD — an app password from your PDS settings
1616+ */
1717+1818+import { AtpAgent } from '@atproto/api';
1919+import { generateTID } from '@ewanc26/tid';
2020+import type { KofiSupporter, KofiEventType } from './types.js';
2121+2222+const COLLECTION = 'uk.ewancroft.kofi.supporter';
2323+2424+/** The shape of a raw record stored in the PDS. */
2525+export interface KofiEventRecord {
2626+ name: string;
2727+ type: KofiEventType;
2828+ tier?: string;
2929+}
3030+3131+function requireEnv(key: string): string {
3232+ const val = process.env[key];
3333+ if (!val) throw new Error(`Missing required environment variable: ${key}`);
3434+ return val;
3535+}
3636+3737+function dedupe<T>(arr: T[], extra: T): T[] {
3838+ return Array.from(new Set([...arr, extra]));
3939+}
4040+4141+/** Authenticated agent for write operations. */
4242+async function authedAgent(): Promise<{ agent: AtpAgent; did: string }> {
4343+ const did = requireEnv('ATPROTO_DID');
4444+ const pdsUrl = requireEnv('ATPROTO_PDS_URL');
4545+ const password = requireEnv('ATPROTO_APP_PASSWORD');
4646+4747+ const agent = new AtpAgent({ service: pdsUrl });
4848+ await agent.login({ identifier: did, password });
4949+ return { agent, did };
5050+}
5151+5252+/**
5353+ * Read all event records from the PDS and aggregate into KofiSupporter objects.
5454+ * No auth required — collection is publicly readable.
5555+ */
5656+export async function readStore(): Promise<KofiSupporter[]> {
5757+ const did = requireEnv('ATPROTO_DID');
5858+ const pdsUrl = requireEnv('ATPROTO_PDS_URL');
5959+6060+ const agent = new AtpAgent({ service: pdsUrl });
6161+6262+ const events: KofiEventRecord[] = [];
6363+ let cursor: string | undefined;
6464+6565+ do {
6666+ const res = await agent.com.atproto.repo.listRecords({
6767+ repo: did,
6868+ collection: COLLECTION,
6969+ limit: 100,
7070+ cursor
7171+ });
7272+7373+ for (const record of res.data.records) {
7474+ events.push(record.value as unknown as KofiEventRecord);
7575+ }
7676+7777+ cursor = res.data.cursor;
7878+ } while (cursor);
7979+8080+ return aggregateEvents(events);
8181+}
8282+8383+/** Aggregate raw event records into deduplicated KofiSupporter objects. */
8484+function aggregateEvents(events: KofiEventRecord[]): KofiSupporter[] {
8585+ const map = new Map<string, KofiSupporter>();
8686+8787+ for (const event of events) {
8888+ const existing = map.get(event.name);
8989+ map.set(event.name, {
9090+ name: event.name,
9191+ types: dedupe(existing?.types ?? [], event.type),
9292+ tiers: event.tier
9393+ ? dedupe(existing?.tiers ?? [], event.tier)
9494+ : (existing?.tiers ?? [])
9595+ });
9696+ }
9797+9898+ return Array.from(map.values());
9999+}
100100+101101+/**
102102+ * Write a single Ko-fi event as a new record.
103103+ * rkey is a TID generated at call time.
104104+ */
105105+export async function appendEvent(
106106+ name: string,
107107+ type: KofiEventType,
108108+ tier: string | null,
109109+ timestamp: string
110110+): Promise<void> {
111111+ const { agent, did } = await authedAgent();
112112+113113+ const record: KofiEventRecord = {
114114+ name,
115115+ type,
116116+ ...(tier ? { tier } : {})
117117+ };
118118+119119+ await agent.com.atproto.repo.putRecord({
120120+ repo: did,
121121+ collection: COLLECTION,
122122+ rkey: generateTID(timestamp),
123123+ record: record as unknown as { [x: string]: unknown }
124124+ });
125125+}
+47
packages/supporters/src/lib/types.ts
···11+export type KofiEventType = 'Donation' | 'Subscription' | 'Commission' | 'Shop Order';
22+33+/**
44+ * Ko-fi webhook payload — sent as application/x-www-form-urlencoded.
55+ * The `data` field is a JSON string containing this structure.
66+ *
77+ * @see https://ko-fi.com/manage/webhooks
88+ */
99+export interface KofiWebhookPayload {
1010+ verification_token: string;
1111+ message_id: string;
1212+ timestamp: string;
1313+ type: KofiEventType;
1414+ is_public: boolean;
1515+ from_name: string;
1616+ message: string | null;
1717+ amount: string;
1818+ url: string;
1919+ email: string;
2020+ currency: string;
2121+ is_subscription_payment: boolean;
2222+ is_first_subscription_payment: boolean;
2323+ kofi_transaction_id: string;
2424+ shop_items: unknown | null;
2525+ tier_name: string | null;
2626+ shipping: unknown | null;
2727+}
2828+2929+/** A persisted supporter record, derived from one or more webhook events. */
3030+export interface KofiSupporter {
3131+ /** Display name from the Ko-fi payment */
3232+ name: string;
3333+ /** All event types seen from this person (deduplicated) */
3434+ types: KofiEventType[];
3535+ /** All tier names seen from this person (deduplicated, non-null) */
3636+ tiers: string[];
3737+}
3838+3939+export interface KofiSupportersProps {
4040+ supporters: KofiSupporter[];
4141+ heading?: string;
4242+ description?: string;
4343+ /** If set, only show supporters who have at least one event of these types */
4444+ filter?: KofiEventType[];
4545+ loading?: boolean;
4646+ error?: string | null;
4747+}
+47
packages/supporters/src/lib/webhook.ts
···11+/**
22+ * Validates and parses an incoming Ko-fi webhook request.
33+ *
44+ * Ko-fi sends application/x-www-form-urlencoded with a single `data` field
55+ * containing the payment JSON. We verify the embedded verification_token
66+ * matches the secret set in KOFI_VERIFICATION_TOKEN.
77+ *
88+ * @see https://ko-fi.com/manage/webhooks (Advanced → Verification Token)
99+ */
1010+1111+import type { KofiWebhookPayload } from './types.js';
1212+1313+export class WebhookError extends Error {
1414+ constructor(
1515+ message: string,
1616+ public readonly status: number
1717+ ) {
1818+ super(message);
1919+ }
2020+}
2121+2222+export async function parseWebhook(request: Request): Promise<KofiWebhookPayload> {
2323+ const secret = process.env.KOFI_VERIFICATION_TOKEN;
2424+ if (!secret) throw new WebhookError('KOFI_VERIFICATION_TOKEN is not set', 500);
2525+2626+ const contentType = request.headers.get('content-type') ?? '';
2727+ if (!contentType.includes('application/x-www-form-urlencoded')) {
2828+ throw new WebhookError('Unexpected content-type', 400);
2929+ }
3030+3131+ const body = await request.formData();
3232+ const raw = body.get('data');
3333+ if (!raw || typeof raw !== 'string') throw new WebhookError('Missing data field', 400);
3434+3535+ let payload: KofiWebhookPayload;
3636+ try {
3737+ payload = JSON.parse(raw);
3838+ } catch {
3939+ throw new WebhookError('Invalid JSON in data field', 400);
4040+ }
4141+4242+ if (payload.verification_token !== secret) {
4343+ throw new WebhookError('Verification token mismatch', 401);
4444+ }
4545+4646+ return payload;
4747+}
+6
packages/supporters/src/routes/+page.server.ts
···11+import { readStore } from '$lib/store.js';
22+import type { PageServerLoad } from './$types.js';
33+44+export const load: PageServerLoad = async () => {
55+ return { supporters: await readStore() };
66+};
+8-3
packages/supporters/src/routes/+page.svelte
···11-<h1>Welcome to your library project</h1>
22-<p>Create your package using @sveltejs/package and preview/showcase your work with SvelteKit</p>
33-<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
11+<script lang="ts">
22+ import KofiSupporters from '$lib/KofiSupporters.svelte';
33+ import type { PageData } from './$types.js';
44+55+ let { data }: { data: PageData } = $props();
66+</script>
77+88+<KofiSupporters supporters={data.supporters} heading="All Supporters" />
+31
packages/supporters/src/routes/webhook/+server.ts
···11+import { json } from '@sveltejs/kit';
22+import { parseWebhook, WebhookError } from '$lib/webhook.js';
33+import { appendEvent } from '$lib/store.js';
44+import type { RequestHandler } from './$types.js';
55+66+export const POST: RequestHandler = async ({ request }) => {
77+ let payload;
88+ try {
99+ payload = await parseWebhook(request);
1010+ } catch (err) {
1111+ if (err instanceof WebhookError) {
1212+ return json({ error: err.message }, { status: err.status });
1313+ }
1414+ throw err;
1515+ }
1616+1717+ // Respect the supporter's privacy preference.
1818+ if (!payload.is_public) {
1919+ return new Response(null, { status: 200 });
2020+ }
2121+2222+ await appendEvent(
2323+ payload.from_name,
2424+ payload.type,
2525+ payload.tier_name,
2626+ payload.timestamp
2727+ );
2828+2929+ // Ko-fi retries if it doesn't receive 200.
3030+ return new Response(null, { status: 200 });
3131+};