@ewanc26/supporters 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # @ewanc26/supporters
2
+
3
+ SvelteKit component library for displaying Ko-fi supporters, backed by an ATProto PDS.
4
+
5
+ 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.
6
+
7
+ ---
8
+
9
+ ## How it works
10
+
11
+ 1. Ko-fi POSTs a webhook event to `/webhook` on each transaction
12
+ 2. The handler verifies the `verification_token`, respects `is_public`, and calls `appendEvent`
13
+ 3. `appendEvent` writes a record to your PDS under `uk.ewancroft.kofi.supporter`
14
+ 4. `readStore` fetches all records and aggregates them into `KofiSupporter` objects
15
+ 5. Pass the result to `<KofiSupporters>` or `<LunarContributors>`
16
+
17
+ ---
18
+
19
+ ## Setup
20
+
21
+ ### 1. Environment variables
22
+
23
+ ```env
24
+ # Required — copy from ko-fi.com/manage/webhooks → Advanced → Verification Token
25
+ KOFI_VERIFICATION_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
26
+
27
+ # Required — your ATProto identity and a dedicated app password
28
+ ATPROTO_DID=did:plc:yourdidhex
29
+ ATPROTO_PDS_URL=https://your-pds.example.com
30
+ ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
31
+ ```
32
+
33
+ Generate an app password at your PDS under **Settings → App Passwords**.
34
+
35
+ ### 2. Register the webhook
36
+
37
+ Go to **ko-fi.com/manage/webhooks** and set your webhook URL to:
38
+
39
+ ```
40
+ https://your-domain.com/webhook
41
+ ```
42
+
43
+ ### 3. Add the route
44
+
45
+ Copy `src/routes/webhook/+server.ts` into your SvelteKit app's routes directory.
46
+
47
+ ### 4. Use the component
48
+
49
+ ```ts
50
+ // +page.server.ts
51
+ import { readStore } from '@ewanc26/supporters';
52
+
53
+ export const load = async () => ({
54
+ supporters: await readStore()
55
+ });
56
+ ```
57
+
58
+ ```svelte
59
+ <!-- +page.svelte -->
60
+ <script lang="ts">
61
+ import { KofiSupporters } from '@ewanc26/supporters';
62
+ let { data } = $props();
63
+ </script>
64
+
65
+ <KofiSupporters supporters={data.supporters} />
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Components
71
+
72
+ ### `<KofiSupporters>`
73
+
74
+ Displays all supporters with emoji type badges (☕ donation, ⭐ subscription, 🎨 commission, 🛍️ shop order).
75
+
76
+ | Prop | Type | Default |
77
+ |---|---|---|
78
+ | `supporters` | `KofiSupporter[]` | `[]` |
79
+ | `heading` | `string` | `'Supporters'` |
80
+ | `description` | `string` | `'People who support my work on Ko-fi.'` |
81
+ | `filter` | `KofiEventType[]` | `undefined` (show all) |
82
+ | `loading` | `boolean` | `false` |
83
+ | `error` | `string \| null` | `null` |
84
+
85
+ ### `<LunarContributors>`
86
+
87
+ Convenience wrapper around `<KofiSupporters>` pre-filtered to `Subscription` events.
88
+
89
+ ---
90
+
91
+ ## Importing historical data
92
+
93
+ Export your transaction history from **ko-fi.com/manage/transactions → Export CSV**, then:
94
+
95
+ ```bash
96
+ ATPROTO_DID=... ATPROTO_PDS_URL=... ATPROTO_APP_PASSWORD=... \
97
+ node node_modules/@ewanc26/supporters/scripts/import-history.mjs transactions.csv --dry-run
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Lexicon
103
+
104
+ Records are stored under `uk.ewancroft.kofi.supporter` (see `lexicons/`). Each record contains:
105
+
106
+ ```ts
107
+ {
108
+ name: string // display name from Ko-fi
109
+ type: string // "Donation" | "Subscription" | "Commission" | "Shop Order"
110
+ tier?: string // subscription tier name, if applicable
111
+ }
112
+ ```
113
+
114
+ rkeys are TIDs derived from the transaction timestamp via [`@ewanc26/tid`](https://npmjs.com/package/@ewanc26/tid).
@@ -0,0 +1,198 @@
1
+ <script lang="ts">
2
+ import type { KofiSupportersProps, KofiEventType } from './types.js';
3
+
4
+ let {
5
+ supporters = [],
6
+ heading = 'Supporters',
7
+ description = 'People who support my work on Ko-fi.',
8
+ filter = undefined,
9
+ loading = false,
10
+ error = null
11
+ }: KofiSupportersProps = $props();
12
+
13
+ const TYPE_LABELS: Record<KofiEventType, string> = {
14
+ Donation: '☕',
15
+ Subscription: '⭐',
16
+ Commission: '🎨',
17
+ 'Shop Order': '🛍️'
18
+ };
19
+
20
+ let visible = $derived(
21
+ filter
22
+ ? supporters.filter((s) => s.types.some((t) => filter!.includes(t)))
23
+ : supporters
24
+ );
25
+
26
+ /** Deterministic pastel colour from a name string. */
27
+ function nameToHsl(name: string): string {
28
+ let hash = 0;
29
+ for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
30
+ const h = Math.abs(hash) % 360;
31
+ return `hsl(${h} 55% 70%)`;
32
+ }
33
+
34
+ /** Initials from a display name. */
35
+ function initials(name: string): string {
36
+ return name
37
+ .split(/\s+/)
38
+ .slice(0, 2)
39
+ .map((w) => w[0]?.toUpperCase() ?? '')
40
+ .join('');
41
+ }
42
+ </script>
43
+
44
+ <section class="kofi-supporters" aria-label={heading}>
45
+ <header class="kofi-supporters__header">
46
+ <h2 class="kofi-supporters__heading">{heading}</h2>
47
+ {#if description}
48
+ <p class="kofi-supporters__description">{description}</p>
49
+ {/if}
50
+ </header>
51
+
52
+ {#if loading}
53
+ <ul class="kofi-supporters__grid" aria-busy="true" aria-label="Loading supporters">
54
+ {#each { length: 6 } as _}
55
+ <li class="kofi-supporters__item kofi-supporters__item--skeleton" aria-hidden="true">
56
+ <span class="kofi-supporters__avatar kofi-supporters__avatar--skeleton"></span>
57
+ <span class="kofi-supporters__name kofi-supporters__name--skeleton"></span>
58
+ </li>
59
+ {/each}
60
+ </ul>
61
+ {:else if error}
62
+ <p class="kofi-supporters__error" role="alert">{error}</p>
63
+ {:else if visible.length === 0}
64
+ <p class="kofi-supporters__empty">No supporters yet — be the first!</p>
65
+ {:else}
66
+ <ul class="kofi-supporters__grid">
67
+ {#each visible as supporter (supporter.name)}
68
+ {@const typeIcons = supporter.types.map((t) => TYPE_LABELS[t]).join('')}
69
+ <li class="kofi-supporters__item">
70
+ <span
71
+ class="kofi-supporters__card"
72
+ title="{supporter.name} · {supporter.types.join(', ')}{supporter.tiers.length ? ` · ${supporter.tiers.join(', ')}` : ''}"
73
+ >
74
+ <span
75
+ class="kofi-supporters__avatar"
76
+ style="background-color: {nameToHsl(supporter.name)}"
77
+ aria-hidden="true"
78
+ >
79
+ {initials(supporter.name)}
80
+ </span>
81
+ <span class="kofi-supporters__name">{supporter.name}</span>
82
+ <span class="kofi-supporters__icons" aria-label={supporter.types.join(', ')}>{typeIcons}</span>
83
+ </span>
84
+ </li>
85
+ {/each}
86
+ </ul>
87
+ {/if}
88
+ </section>
89
+
90
+ <style>
91
+ .kofi-supporters {
92
+ container-type: inline-size;
93
+ }
94
+
95
+ .kofi-supporters__header {
96
+ margin-block-end: 1rem;
97
+ }
98
+
99
+ .kofi-supporters__heading {
100
+ font-size: 1.25rem;
101
+ font-weight: 700;
102
+ margin: 0;
103
+ }
104
+
105
+ .kofi-supporters__description {
106
+ margin-block-start: 0.25rem;
107
+ font-size: 0.875rem;
108
+ opacity: 0.75;
109
+ }
110
+
111
+ .kofi-supporters__grid {
112
+ display: flex;
113
+ flex-wrap: wrap;
114
+ gap: 0.75rem;
115
+ list-style: none;
116
+ margin: 0;
117
+ padding: 0;
118
+ }
119
+
120
+ .kofi-supporters__card {
121
+ display: flex;
122
+ flex-direction: column;
123
+ align-items: center;
124
+ gap: 0.25rem;
125
+ padding: 0.5rem;
126
+ border-radius: 0.5rem;
127
+ cursor: default;
128
+ }
129
+
130
+ .kofi-supporters__avatar {
131
+ width: 3rem;
132
+ height: 3rem;
133
+ border-radius: 50%;
134
+ display: flex;
135
+ align-items: center;
136
+ justify-content: center;
137
+ font-size: 1rem;
138
+ font-weight: 700;
139
+ color: #fff;
140
+ flex-shrink: 0;
141
+ }
142
+
143
+ .kofi-supporters__name {
144
+ font-size: 0.75rem;
145
+ text-align: center;
146
+ max-width: 5rem;
147
+ overflow: hidden;
148
+ text-overflow: ellipsis;
149
+ white-space: nowrap;
150
+ }
151
+
152
+ .kofi-supporters__icons {
153
+ font-size: 0.65rem;
154
+ line-height: 1;
155
+ }
156
+
157
+ /* Skeletons */
158
+ .kofi-supporters__item--skeleton {
159
+ display: flex;
160
+ flex-direction: column;
161
+ align-items: center;
162
+ gap: 0.375rem;
163
+ padding: 0.5rem;
164
+ }
165
+
166
+ .kofi-supporters__avatar--skeleton {
167
+ display: block;
168
+ width: 3rem;
169
+ height: 3rem;
170
+ border-radius: 50%;
171
+ background-color: color-mix(in srgb, currentColor 15%, transparent);
172
+ animation: ks-pulse 1.4s ease-in-out infinite;
173
+ }
174
+
175
+ .kofi-supporters__name--skeleton {
176
+ display: block;
177
+ width: 4rem;
178
+ height: 0.75rem;
179
+ border-radius: 0.25rem;
180
+ background-color: color-mix(in srgb, currentColor 15%, transparent);
181
+ animation: ks-pulse 1.4s ease-in-out 200ms infinite;
182
+ }
183
+
184
+ @keyframes ks-pulse {
185
+ 0%, 100% { opacity: 1; }
186
+ 50% { opacity: 0.4; }
187
+ }
188
+
189
+ .kofi-supporters__error,
190
+ .kofi-supporters__empty {
191
+ font-size: 0.875rem;
192
+ opacity: 0.75;
193
+ }
194
+
195
+ .kofi-supporters__error {
196
+ color: #c0392b;
197
+ }
198
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { KofiSupportersProps } from './types.js';
2
+ declare const KofiSupporters: import("svelte").Component<KofiSupportersProps, {}, "">;
3
+ type KofiSupporters = ReturnType<typeof KofiSupporters>;
4
+ export default KofiSupporters;
@@ -0,0 +1,28 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Convenience wrapper around KofiSupporters pre-filtered to Subscription
4
+ * events, with the "Lunar Contributors" heading.
5
+ *
6
+ * Equivalent to:
7
+ * <KofiSupporters filter={['Subscription']} heading="Lunar Contributors" />
8
+ */
9
+ import KofiSupporters from './KofiSupporters.svelte';
10
+ import type { KofiSupportersProps } from './types.js';
11
+
12
+ let {
13
+ supporters = [],
14
+ heading = 'Lunar Contributors',
15
+ description = 'People who support my work on Ko-fi.',
16
+ loading = false,
17
+ error = null
18
+ }: Omit<KofiSupportersProps, 'filter'> = $props();
19
+ </script>
20
+
21
+ <KofiSupporters
22
+ {supporters}
23
+ {heading}
24
+ {description}
25
+ filter={['Subscription']}
26
+ {loading}
27
+ {error}
28
+ />
@@ -0,0 +1,4 @@
1
+ import type { KofiSupportersProps } from './types.js';
2
+ declare const LunarContributors: import("svelte").Component<Omit<KofiSupportersProps, "filter">, {}, "">;
3
+ type LunarContributors = ReturnType<typeof LunarContributors>;
4
+ export default LunarContributors;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * ko-fi.tools API client for fetching top supporters.
3
+ *
4
+ * ko-fi.tools is the only third-party service providing a public REST API for
5
+ * Ko-fi page data. Their V2 API docs are still incomplete; once they publish
6
+ * them confirm the endpoint and auth scheme at:
7
+ * https://ko-fi.tools/support/api-documentation
8
+ *
9
+ * Profile images are served from: https://cdn.ko-fi.tools/profile/{pageId}
10
+ */
11
+
12
+ import type { KofiSupporter } from './types.js';
13
+
14
+ /** Base URL inferred from cdn.ko-fi.tools CDN pattern and V2 launch announcement. */
15
+ const API_BASE = 'https://api.ko-fi.tools/v2';
16
+
17
+ export type KofiToolsRawSupporter = {
18
+ /** Ko-fi page ID for this supporter */
19
+ id: string;
20
+ /** Display name */
21
+ name: string;
22
+ /** Profile URL on ko-fi.com */
23
+ url?: string;
24
+ };
25
+
26
+ /**
27
+ * Fetches top supporters for a Ko-fi page via ko-fi.tools.
28
+ *
29
+ * @param pageId Your Ko-fi page ID (the alphanumeric string in your Ko-fi URL,
30
+ * e.g. for ko-fi.com/A0A1B2C3 the pageId is "A0A1B2C3").
31
+ * @param fetchFn Optional fetch override for use in SvelteKit `+page.server.ts`
32
+ * (pass the native `fetch` from the `load` function for cookie
33
+ * forwarding and caching hints).
34
+ *
35
+ * @throws If the network request fails or the response is not OK.
36
+ *
37
+ * TODO: Verify endpoint path + auth headers once ko-fi.tools V2 API docs land.
38
+ */
39
+ export async function fetchLunarContributors(
40
+ pageId: string,
41
+ fetchFn: typeof fetch = fetch
42
+ ): Promise<KofiSupporter[]> {
43
+ const url = `${API_BASE}/${encodeURIComponent(pageId)}/supporters`;
44
+
45
+ const res = await fetchFn(url, {
46
+ headers: { Accept: 'application/json' }
47
+ });
48
+
49
+ if (!res.ok) {
50
+ throw new Error(`ko-fi.tools API error ${res.status}: ${res.statusText}`);
51
+ }
52
+
53
+ const raw: KofiToolsRawSupporter[] = await res.json();
54
+
55
+ return raw.map((s) => ({
56
+ pageId: s.id,
57
+ name: s.name,
58
+ avatarUrl: `https://cdn.ko-fi.tools/profile/${s.id}`,
59
+ profileUrl: s.url ?? `https://ko-fi.com/${s.id}`
60
+ }));
61
+ }
@@ -0,0 +1,6 @@
1
+ export { default as KofiSupporters } from './KofiSupporters.svelte';
2
+ export { default as LunarContributors } from './LunarContributors.svelte';
3
+ export { readStore, appendEvent } from './store.js';
4
+ export type { KofiEventRecord } from './store.js';
5
+ export { parseWebhook, WebhookError } from './webhook.js';
6
+ export type { KofiSupporter, KofiWebhookPayload, KofiSupportersProps, KofiEventType } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { default as KofiSupporters } from './KofiSupporters.svelte';
2
+ export { default as LunarContributors } from './LunarContributors.svelte';
3
+ export { readStore, appendEvent } from './store.js';
4
+ export { parseWebhook, WebhookError } from './webhook.js';
@@ -0,0 +1,33 @@
1
+ /**
2
+ * ATProto PDS store for Ko-fi supporter data.
3
+ *
4
+ * Each Ko-fi event is stored as a separate record under:
5
+ * uk.ewancroft.kofi.supporter
6
+ *
7
+ * rkeys are TIDs generated by @ewanc26/tid — one record per event.
8
+ * The aggregated KofiSupporter view (deduped by name) is built at read time.
9
+ *
10
+ * Reads are public (no auth). Writes use an app password.
11
+ *
12
+ * Required environment variables:
13
+ * ATPROTO_DID — your DID, e.g. did:plc:abc123
14
+ * ATPROTO_PDS_URL — your PDS URL, e.g. https://pds.ewancroft.uk
15
+ * ATPROTO_APP_PASSWORD — an app password from your PDS settings
16
+ */
17
+ import type { KofiSupporter, KofiEventType } from './types.js';
18
+ /** The shape of a raw record stored in the PDS. */
19
+ export interface KofiEventRecord {
20
+ name: string;
21
+ type: KofiEventType;
22
+ tier?: string;
23
+ }
24
+ /**
25
+ * Read all event records from the PDS and aggregate into KofiSupporter objects.
26
+ * No auth required — collection is publicly readable.
27
+ */
28
+ export declare function readStore(): Promise<KofiSupporter[]>;
29
+ /**
30
+ * Write a single Ko-fi event as a new record.
31
+ * rkey is a TID generated at call time.
32
+ */
33
+ export declare function appendEvent(name: string, type: KofiEventType, tier: string | null, timestamp: string): Promise<void>;
package/dist/store.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * ATProto PDS store for Ko-fi supporter data.
3
+ *
4
+ * Each Ko-fi event is stored as a separate record under:
5
+ * uk.ewancroft.kofi.supporter
6
+ *
7
+ * rkeys are TIDs generated by @ewanc26/tid — one record per event.
8
+ * The aggregated KofiSupporter view (deduped by name) is built at read time.
9
+ *
10
+ * Reads are public (no auth). Writes use an app password.
11
+ *
12
+ * Required environment variables:
13
+ * ATPROTO_DID — your DID, e.g. did:plc:abc123
14
+ * ATPROTO_PDS_URL — your PDS URL, e.g. https://pds.ewancroft.uk
15
+ * ATPROTO_APP_PASSWORD — an app password from your PDS settings
16
+ */
17
+ import { AtpAgent } from '@atproto/api';
18
+ import { generateTID } from '@ewanc26/tid';
19
+ const COLLECTION = 'uk.ewancroft.kofi.supporter';
20
+ function requireEnv(key) {
21
+ const val = process.env[key];
22
+ if (!val)
23
+ throw new Error(`Missing required environment variable: ${key}`);
24
+ return val;
25
+ }
26
+ function dedupe(arr, extra) {
27
+ return Array.from(new Set([...arr, extra]));
28
+ }
29
+ /** Authenticated agent for write operations. */
30
+ async function authedAgent() {
31
+ const did = requireEnv('ATPROTO_DID');
32
+ const pdsUrl = requireEnv('ATPROTO_PDS_URL');
33
+ const password = requireEnv('ATPROTO_APP_PASSWORD');
34
+ const agent = new AtpAgent({ service: pdsUrl });
35
+ await agent.login({ identifier: did, password });
36
+ return { agent, did };
37
+ }
38
+ /**
39
+ * Read all event records from the PDS and aggregate into KofiSupporter objects.
40
+ * No auth required — collection is publicly readable.
41
+ */
42
+ export async function readStore() {
43
+ const did = requireEnv('ATPROTO_DID');
44
+ const pdsUrl = requireEnv('ATPROTO_PDS_URL');
45
+ const agent = new AtpAgent({ service: pdsUrl });
46
+ const events = [];
47
+ let cursor;
48
+ do {
49
+ const res = await agent.com.atproto.repo.listRecords({
50
+ repo: did,
51
+ collection: COLLECTION,
52
+ limit: 100,
53
+ cursor
54
+ });
55
+ for (const record of res.data.records) {
56
+ events.push(record.value);
57
+ }
58
+ cursor = res.data.cursor;
59
+ } while (cursor);
60
+ return aggregateEvents(events);
61
+ }
62
+ /** Aggregate raw event records into deduplicated KofiSupporter objects. */
63
+ function aggregateEvents(events) {
64
+ const map = new Map();
65
+ for (const event of events) {
66
+ const existing = map.get(event.name);
67
+ map.set(event.name, {
68
+ name: event.name,
69
+ types: dedupe(existing?.types ?? [], event.type),
70
+ tiers: event.tier
71
+ ? dedupe(existing?.tiers ?? [], event.tier)
72
+ : (existing?.tiers ?? [])
73
+ });
74
+ }
75
+ return Array.from(map.values());
76
+ }
77
+ /**
78
+ * Write a single Ko-fi event as a new record.
79
+ * rkey is a TID generated at call time.
80
+ */
81
+ export async function appendEvent(name, type, tier, timestamp) {
82
+ const { agent, did } = await authedAgent();
83
+ const record = {
84
+ name,
85
+ type,
86
+ ...(tier ? { tier } : {})
87
+ };
88
+ await agent.com.atproto.repo.putRecord({
89
+ repo: did,
90
+ collection: COLLECTION,
91
+ rkey: generateTID(timestamp),
92
+ record: record
93
+ });
94
+ }
@@ -0,0 +1,44 @@
1
+ export type KofiEventType = 'Donation' | 'Subscription' | 'Commission' | 'Shop Order';
2
+ /**
3
+ * Ko-fi webhook payload — sent as application/x-www-form-urlencoded.
4
+ * The `data` field is a JSON string containing this structure.
5
+ *
6
+ * @see https://ko-fi.com/manage/webhooks
7
+ */
8
+ export interface KofiWebhookPayload {
9
+ verification_token: string;
10
+ message_id: string;
11
+ timestamp: string;
12
+ type: KofiEventType;
13
+ is_public: boolean;
14
+ from_name: string;
15
+ message: string | null;
16
+ amount: string;
17
+ url: string;
18
+ email: string;
19
+ currency: string;
20
+ is_subscription_payment: boolean;
21
+ is_first_subscription_payment: boolean;
22
+ kofi_transaction_id: string;
23
+ shop_items: unknown | null;
24
+ tier_name: string | null;
25
+ shipping: unknown | null;
26
+ }
27
+ /** A persisted supporter record, derived from one or more webhook events. */
28
+ export interface KofiSupporter {
29
+ /** Display name from the Ko-fi payment */
30
+ name: string;
31
+ /** All event types seen from this person (deduplicated) */
32
+ types: KofiEventType[];
33
+ /** All tier names seen from this person (deduplicated, non-null) */
34
+ tiers: string[];
35
+ }
36
+ export interface KofiSupportersProps {
37
+ supporters: KofiSupporter[];
38
+ heading?: string;
39
+ description?: string;
40
+ /** If set, only show supporters who have at least one event of these types */
41
+ filter?: KofiEventType[];
42
+ loading?: boolean;
43
+ error?: string | null;
44
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Validates and parses an incoming Ko-fi webhook request.
3
+ *
4
+ * Ko-fi sends application/x-www-form-urlencoded with a single `data` field
5
+ * containing the payment JSON. We verify the embedded verification_token
6
+ * matches the secret set in KOFI_VERIFICATION_TOKEN.
7
+ *
8
+ * @see https://ko-fi.com/manage/webhooks (Advanced → Verification Token)
9
+ */
10
+ import type { KofiWebhookPayload } from './types.js';
11
+ export declare class WebhookError extends Error {
12
+ readonly status: number;
13
+ constructor(message: string, status: number);
14
+ }
15
+ export declare function parseWebhook(request: Request): Promise<KofiWebhookPayload>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Validates and parses an incoming Ko-fi webhook request.
3
+ *
4
+ * Ko-fi sends application/x-www-form-urlencoded with a single `data` field
5
+ * containing the payment JSON. We verify the embedded verification_token
6
+ * matches the secret set in KOFI_VERIFICATION_TOKEN.
7
+ *
8
+ * @see https://ko-fi.com/manage/webhooks (Advanced → Verification Token)
9
+ */
10
+ export class WebhookError extends Error {
11
+ status;
12
+ constructor(message, status) {
13
+ super(message);
14
+ this.status = status;
15
+ }
16
+ }
17
+ export async function parseWebhook(request) {
18
+ const secret = process.env.KOFI_VERIFICATION_TOKEN;
19
+ if (!secret)
20
+ throw new WebhookError('KOFI_VERIFICATION_TOKEN is not set', 500);
21
+ const contentType = request.headers.get('content-type') ?? '';
22
+ if (!contentType.includes('application/x-www-form-urlencoded')) {
23
+ throw new WebhookError('Unexpected content-type', 400);
24
+ }
25
+ const body = await request.formData();
26
+ const raw = body.get('data');
27
+ if (!raw || typeof raw !== 'string')
28
+ throw new WebhookError('Missing data field', 400);
29
+ let payload;
30
+ try {
31
+ payload = JSON.parse(raw);
32
+ }
33
+ catch {
34
+ throw new WebhookError('Invalid JSON in data field', 400);
35
+ }
36
+ if (payload.verification_token !== secret) {
37
+ throw new WebhookError('Verification token mismatch', 401);
38
+ }
39
+ return payload;
40
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "lexicon": 1,
3
+ "id": "uk.ewancroft.kofi.supporter",
4
+ "defs": {
5
+ "main": {
6
+ "type": "record",
7
+ "description": "A single Ko-fi payment event. One record per event, rkey is a TID.",
8
+ "key": "tid",
9
+ "record": {
10
+ "type": "object",
11
+ "required": ["name", "type"],
12
+ "properties": {
13
+ "name": {
14
+ "type": "string",
15
+ "description": "Display name from Ko-fi."
16
+ },
17
+ "type": {
18
+ "type": "string",
19
+ "description": "Ko-fi event type: Donation, Subscription, Commission, or Shop Order."
20
+ },
21
+ "tier": {
22
+ "type": "string",
23
+ "description": "Subscription tier name, if applicable."
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }