@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/LICENSE +661 -0
- package/README.md +114 -0
- package/dist/KofiSupporters.svelte +198 -0
- package/dist/KofiSupporters.svelte.d.ts +4 -0
- package/dist/LunarContributors.svelte +28 -0
- package/dist/LunarContributors.svelte.d.ts +4 -0
- package/dist/api.ts.bak +61 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/store.d.ts +33 -0
- package/dist/store.js +94 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.js +1 -0
- package/dist/webhook.d.ts +15 -0
- package/dist/webhook.js +40 -0
- package/lexicons/uk/ewancroft/kofi/supporter.json +29 -0
- package/package.json +71 -0
- package/scripts/import-history.mjs +189 -0
- package/scripts/probe-api.mjs +37 -0
- package/scripts/probe-endpoints.mjs +33 -0
- package/scripts/simulate-webhook.mjs +62 -0
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,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
|
+
/>
|
package/dist/api.ts.bak
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
package/dist/store.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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>;
|
package/dist/webhook.js
ADDED
|
@@ -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
|
+
}
|