create-fedi-app 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/dist/index.d.ts +2 -0
- package/dist/index.js +11113 -0
- package/dist/templates/base/.env.example +5 -0
- package/dist/templates/base/app/demo/page.tsx +25 -0
- package/dist/templates/base/app/globals.css +95 -0
- package/dist/templates/base/app/layout.tsx +39 -0
- package/dist/templates/base/app/page.tsx +83 -0
- package/dist/templates/base/components/FediDevToolbar/FediDevToolbar.tsx +170 -0
- package/dist/templates/base/components/providers.tsx +63 -0
- package/dist/templates/base/env.ts +10 -0
- package/dist/templates/base/hooks/useFediInternal.ts +41 -0
- package/dist/templates/base/lib/fedi-types.ts +96 -0
- package/dist/templates/base/lib/fedi.ts +18 -0
- package/dist/templates/base/lib/nostr/hooks.ts +52 -0
- package/dist/templates/base/lib/nostr/index.ts +9 -0
- package/dist/templates/base/lib/nostr/mock.ts +60 -0
- package/dist/templates/base/lib/nostr/provider.tsx +64 -0
- package/dist/templates/base/lib/utils.ts +3 -0
- package/dist/templates/base/lib/webln/hooks.ts +67 -0
- package/dist/templates/base/lib/webln/index.ts +12 -0
- package/dist/templates/base/lib/webln/mock.ts +96 -0
- package/dist/templates/base/lib/webln/provider.tsx +52 -0
- package/dist/templates/base/next.config.ts +3 -0
- package/dist/templates/base/package.json +40 -0
- package/dist/templates/base/proxy.ts +8 -0
- package/dist/templates/base/tsconfig.json +20 -0
- package/dist/templates/base/vitest.config.ts +6 -0
- package/dist/templates/base/vitest.setup.ts +40 -0
- package/dist/templates/modules/ai-assistant/app/api/assistant/route.ts +45 -0
- package/dist/templates/modules/ai-assistant/app/demo/assistant/AssistantDemoClient.tsx +70 -0
- package/dist/templates/modules/ai-assistant/app/demo/assistant/page.tsx +23 -0
- package/dist/templates/modules/ai-assistant/components/ai/Assistant.tsx +220 -0
- package/dist/templates/modules/ai-assistant/components/ai/AssistantProvider.tsx +71 -0
- package/dist/templates/modules/ai-assistant/lib/ai/providers.ts +49 -0
- package/dist/templates/modules/ai-assistant/module.json +48 -0
- package/dist/templates/modules/ai-chat-gated/app/api/chat/invoice/route.ts +15 -0
- package/dist/templates/modules/ai-chat-gated/app/api/chat/route.ts +57 -0
- package/dist/templates/modules/ai-chat-gated/app/demo/ai-chat/page.tsx +58 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/ChatMessage.tsx +50 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/GatedChat.tsx +181 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/PaymentGate.tsx +168 -0
- package/dist/templates/modules/ai-chat-gated/lib/ai/providers.ts +49 -0
- package/dist/templates/modules/ai-chat-gated/lib/chat-payment.ts +161 -0
- package/dist/templates/modules/ai-chat-gated/module.json +62 -0
- package/dist/templates/modules/ai-rules/.cursorrules +8 -0
- package/dist/templates/modules/ai-rules/.github/copilot-instructions.md +8 -0
- package/dist/templates/modules/ai-rules/CLAUDE.md +8 -0
- package/dist/templates/modules/ai-rules/module.json +20 -0
- package/dist/templates/modules/ai-rules/rules/OVERVIEW.md +56 -0
- package/dist/templates/modules/ai-rules/rules/architecture.md +108 -0
- package/dist/templates/modules/ai-rules/rules/design-system.md +94 -0
- package/dist/templates/modules/ai-rules/rules/fedi-api.md +120 -0
- package/dist/templates/modules/ai-rules/rules/nostr.md +232 -0
- package/dist/templates/modules/ai-rules/rules/patterns.md +408 -0
- package/dist/templates/modules/ai-rules/rules/testing.md +238 -0
- package/dist/templates/modules/ai-rules/rules/webln.md +241 -0
- package/dist/templates/modules/database/drizzle/supabase/0000_initial.sql +7 -0
- package/dist/templates/modules/database/drizzle/supabase/meta/_journal.json +13 -0
- package/dist/templates/modules/database/drizzle/turso/0000_initial.sql +7 -0
- package/dist/templates/modules/database/drizzle/turso/meta/_journal.json +13 -0
- package/dist/templates/modules/database/drizzle.config.supabase.ts +10 -0
- package/dist/templates/modules/database/drizzle.config.turso.ts +11 -0
- package/dist/templates/modules/database/env.supabase.ts +24 -0
- package/dist/templates/modules/database/env.turso.ts +23 -0
- package/dist/templates/modules/database/lib/db/index.supabase.ts +19 -0
- package/dist/templates/modules/database/lib/db/index.turso.ts +20 -0
- package/dist/templates/modules/database/lib/db/schema.supabase.ts +13 -0
- package/dist/templates/modules/database/lib/db/schema.turso.ts +13 -0
- package/dist/templates/modules/database/module.json +110 -0
- package/dist/templates/modules/ecash-balance/app/demo/ecash/page.tsx +115 -0
- package/dist/templates/modules/ecash-balance/components/fedi/BalanceDisplay.tsx +162 -0
- package/dist/templates/modules/ecash-balance/components/fedi/FediVersionBadge.tsx +39 -0
- package/dist/templates/modules/ecash-balance/components/fedi/InstallMiniAppButton.tsx +74 -0
- package/dist/templates/modules/ecash-balance/hooks/useFediBalance.ts +65 -0
- package/dist/templates/modules/ecash-balance/module.json +14 -0
- package/dist/templates/modules/lnurl/app/api/lnurlauth/route.ts +118 -0
- package/dist/templates/modules/lnurl/app/api/lnurlp/[username]/route.ts +70 -0
- package/dist/templates/modules/lnurl/app/api/lnurlw/route.ts +57 -0
- package/dist/templates/modules/lnurl/app/demo/lnurl/page.tsx +136 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlAuth.tsx +156 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlPay.tsx +36 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlQR.tsx +96 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlWithdraw.tsx +141 -0
- package/dist/templates/modules/lnurl/lib/lnurl-auth-verify.ts +87 -0
- package/dist/templates/modules/lnurl/lib/lnurl-store.ts +112 -0
- package/dist/templates/modules/lnurl/lib/lnurl-utils.ts +56 -0
- package/dist/templates/modules/lnurl/module.json +27 -0
- package/dist/templates/modules/multispend-demo/app/demo/multispend/MultispendDemoClient.tsx +109 -0
- package/dist/templates/modules/multispend-demo/app/demo/multispend/page.tsx +23 -0
- package/dist/templates/modules/multispend-demo/components/multispend/ApprovalVote.tsx +122 -0
- package/dist/templates/modules/multispend-demo/components/multispend/MultispendDemo.tsx +220 -0
- package/dist/templates/modules/multispend-demo/components/multispend/MultispendProposal.tsx +213 -0
- package/dist/templates/modules/multispend-demo/components/multispend/ProposalList.tsx +49 -0
- package/dist/templates/modules/multispend-demo/hooks/useMultispendDemo.ts +127 -0
- package/dist/templates/modules/multispend-demo/lib/multispend-types.ts +33 -0
- package/dist/templates/modules/multispend-demo/lib/multispend-utils.ts +69 -0
- package/dist/templates/modules/multispend-demo/module.json +18 -0
- package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/NostrFeedDemoClient.tsx +134 -0
- package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/page.tsx +23 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NostrFeedProvider.tsx +47 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NoteCard.tsx +68 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NoteFeed.tsx +109 -0
- package/dist/templates/modules/nostr-feed/components/nostr/PublishNote.tsx +104 -0
- package/dist/templates/modules/nostr-feed/components/nostr/ZapButton.tsx +140 -0
- package/dist/templates/modules/nostr-feed/lib/nostr/relay.ts +107 -0
- package/dist/templates/modules/nostr-feed/lib/nostr-zap.ts +159 -0
- package/dist/templates/modules/nostr-feed/module.json +25 -0
- package/dist/templates/modules/nostr-identity/app/demo/nostr/page.tsx +136 -0
- package/dist/templates/modules/nostr-identity/components/nostr/IdentityBadge.tsx +109 -0
- package/dist/templates/modules/nostr-identity/components/nostr/NostrLogin.tsx +107 -0
- package/dist/templates/modules/nostr-identity/components/nostr/SignedMessage.tsx +103 -0
- package/dist/templates/modules/nostr-identity/hooks/useIdentityFlow.ts +61 -0
- package/dist/templates/modules/nostr-identity/lib/nostr-utils.ts +30 -0
- package/dist/templates/modules/nostr-identity/module.json +15 -0
- package/dist/templates/modules/payment-gated-content/app/api/payment-gate/invoice/route.ts +25 -0
- package/dist/templates/modules/payment-gated-content/app/api/payment-gate/verify/route.ts +39 -0
- package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/article/page.tsx +71 -0
- package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/page.tsx +134 -0
- package/dist/templates/modules/payment-gated-content/components/payment-gated/PayGate.tsx +267 -0
- package/dist/templates/modules/payment-gated-content/lib/payment-gate.ts +195 -0
- package/dist/templates/modules/payment-gated-content/lib/payment-store.ts +104 -0
- package/dist/templates/modules/payment-gated-content/module.json +24 -0
- package/dist/templates/modules/payment-gated-content/proxy.ts +27 -0
- package/dist/templates/modules/webln-payments/app/demo/webln/page.tsx +176 -0
- package/dist/templates/modules/webln-payments/components/webln/InvoiceCard.tsx +170 -0
- package/dist/templates/modules/webln-payments/components/webln/PayButton.tsx +92 -0
- package/dist/templates/modules/webln-payments/components/webln/PaymentHistory.tsx +102 -0
- package/dist/templates/modules/webln-payments/hooks/__tests__/usePaymentFlow.test.tsx +182 -0
- package/dist/templates/modules/webln-payments/hooks/usePaymentFlow.ts +100 -0
- package/dist/templates/modules/webln-payments/lib/payment-history.ts +75 -0
- package/dist/templates/modules/webln-payments/module.json +17 -0
- package/dist/templates/modules/webln-payments/tests/e2e/webln-payment.spec.ts +41 -0
- package/package.json +29 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
# Canonical Fedi Mini App Patterns
|
|
2
|
+
|
|
3
|
+
Copy these patterns directly. Each one is tested, idiomatic, and handles the edge cases specific to Fedi's WebView environment.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Pattern 1: Check if running inside Fedi
|
|
8
|
+
|
|
9
|
+
Detect the Fedi environment and degrade gracefully for users who open the app URL in a regular browser.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
'use client';
|
|
13
|
+
import { useWebLN } from '@create-fedi-app/webln';
|
|
14
|
+
|
|
15
|
+
export function FediGuard({ children }: { children: React.ReactNode }) {
|
|
16
|
+
const { isConnected, isLoading } = useWebLN();
|
|
17
|
+
|
|
18
|
+
if (isLoading) {
|
|
19
|
+
return (
|
|
20
|
+
<div className="flex items-center justify-center min-h-[200px]">
|
|
21
|
+
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
|
22
|
+
Connecting…
|
|
23
|
+
</span>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!isConnected) {
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
className="rounded-lg p-6 text-center"
|
|
32
|
+
style={{
|
|
33
|
+
background: 'var(--color-surface)',
|
|
34
|
+
borderRadius: 'var(--radius-lg)',
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
<p className="text-sm font-semibold mb-1" style={{ color: 'var(--color-text)' }}>
|
|
38
|
+
Open in Fedi
|
|
39
|
+
</p>
|
|
40
|
+
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
|
41
|
+
This app requires the Fedi wallet. Download Fedi to continue.
|
|
42
|
+
</p>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return <>{children}</>;
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Pattern 2: Request a Lightning payment
|
|
54
|
+
|
|
55
|
+
Pay a BOLT11 invoice. Handles pending, success, and error states.
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
'use client';
|
|
59
|
+
import { useState } from 'react';
|
|
60
|
+
import { usePayment } from '@create-fedi-app/webln';
|
|
61
|
+
|
|
62
|
+
interface PayInvoiceProps {
|
|
63
|
+
invoice: string;
|
|
64
|
+
onSuccess: (preimage: string) => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function PayInvoice({ invoice, onSuccess }: PayInvoiceProps) {
|
|
68
|
+
const { sendPayment, isPaying, paymentError } = usePayment();
|
|
69
|
+
const [paid, setPaid] = useState(false);
|
|
70
|
+
|
|
71
|
+
async function handlePay() {
|
|
72
|
+
const result = await sendPayment(invoice);
|
|
73
|
+
if (result?.preimage) {
|
|
74
|
+
setPaid(true);
|
|
75
|
+
onSuccess(result.preimage);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (paid) {
|
|
80
|
+
return (
|
|
81
|
+
<p className="text-sm font-semibold" style={{ color: 'var(--color-accent)' }}>
|
|
82
|
+
Payment sent ✓
|
|
83
|
+
</p>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="flex flex-col gap-2">
|
|
89
|
+
<button
|
|
90
|
+
onClick={handlePay}
|
|
91
|
+
disabled={isPaying || !invoice}
|
|
92
|
+
className="inline-flex items-center justify-center gap-2 rounded-lg px-5 py-3 text-sm font-semibold transition-opacity hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
93
|
+
style={{ background: 'var(--color-accent)', color: '#fff', borderRadius: 'var(--radius-md)' }}
|
|
94
|
+
>
|
|
95
|
+
{isPaying ? (
|
|
96
|
+
<>
|
|
97
|
+
<span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
|
98
|
+
Paying…
|
|
99
|
+
</>
|
|
100
|
+
) : (
|
|
101
|
+
'Pay Invoice'
|
|
102
|
+
)}
|
|
103
|
+
</button>
|
|
104
|
+
{paymentError && (
|
|
105
|
+
<p className="text-xs" style={{ color: '#ef4444' }}>
|
|
106
|
+
{paymentError.message}
|
|
107
|
+
</p>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Pattern 3: Create an invoice (receive payment)
|
|
117
|
+
|
|
118
|
+
Generate a BOLT11 invoice so someone can pay you. Displays the invoice string and a QR-friendly value.
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
'use client';
|
|
122
|
+
import { useState } from 'react';
|
|
123
|
+
import { usePayment } from '@create-fedi-app/webln';
|
|
124
|
+
|
|
125
|
+
export function ReceivePayment() {
|
|
126
|
+
const { makeInvoice, isCreatingInvoice, paymentError, lastInvoice } = usePayment();
|
|
127
|
+
const [sats, setSats] = useState('');
|
|
128
|
+
|
|
129
|
+
async function handleCreate() {
|
|
130
|
+
if (!sats || isNaN(Number(sats))) return;
|
|
131
|
+
await makeInvoice({ amount: sats, defaultMemo: 'Payment request' });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className="flex flex-col gap-4">
|
|
136
|
+
<div className="flex gap-2">
|
|
137
|
+
<input
|
|
138
|
+
type="number"
|
|
139
|
+
value={sats}
|
|
140
|
+
onChange={(e) => setSats(e.target.value)}
|
|
141
|
+
placeholder="Amount in sats"
|
|
142
|
+
className="flex-1 rounded-lg px-3 py-2 text-sm"
|
|
143
|
+
style={{
|
|
144
|
+
background: 'var(--color-surface-2)',
|
|
145
|
+
color: 'var(--color-text)',
|
|
146
|
+
border: '1px solid var(--color-border)',
|
|
147
|
+
borderRadius: 'var(--radius-md)',
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
<button
|
|
151
|
+
onClick={handleCreate}
|
|
152
|
+
disabled={isCreatingInvoice || !sats}
|
|
153
|
+
className="px-4 py-2 rounded-lg text-sm font-semibold transition-opacity hover:opacity-80 disabled:opacity-40"
|
|
154
|
+
style={{ background: 'var(--color-accent)', color: '#fff', borderRadius: 'var(--radius-md)' }}
|
|
155
|
+
>
|
|
156
|
+
{isCreatingInvoice ? 'Creating…' : 'Create Invoice'}
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{lastInvoice && (
|
|
161
|
+
<div
|
|
162
|
+
className="p-3 rounded-lg break-all"
|
|
163
|
+
style={{
|
|
164
|
+
background: 'var(--color-surface)',
|
|
165
|
+
border: '1px solid var(--color-border)',
|
|
166
|
+
borderRadius: 'var(--radius-md)',
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<p className="text-xs font-mono" style={{ color: 'var(--color-text-muted)' }}>
|
|
170
|
+
{lastInvoice.slice(0, 40)}…
|
|
171
|
+
</p>
|
|
172
|
+
<button
|
|
173
|
+
onClick={() => navigator.clipboard.writeText(lastInvoice)}
|
|
174
|
+
className="mt-2 text-xs font-semibold"
|
|
175
|
+
style={{ color: 'var(--color-accent)' }}
|
|
176
|
+
>
|
|
177
|
+
Copy invoice
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{paymentError && (
|
|
183
|
+
<p className="text-xs" style={{ color: '#ef4444' }}>
|
|
184
|
+
{paymentError.message}
|
|
185
|
+
</p>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Pattern 4: Get user's Nostr identity
|
|
195
|
+
|
|
196
|
+
A connect button that resolves to the user's public key. Once connected, shows their truncated npub.
|
|
197
|
+
|
|
198
|
+
```tsx
|
|
199
|
+
'use client';
|
|
200
|
+
import { useIdentity } from '@create-fedi-app/nostr';
|
|
201
|
+
|
|
202
|
+
export function NostrIdentity() {
|
|
203
|
+
const { pubkey, displayNpub, getPublicKey, isConnecting } = useIdentity();
|
|
204
|
+
|
|
205
|
+
if (pubkey) {
|
|
206
|
+
return (
|
|
207
|
+
<div className="inline-flex items-center gap-2">
|
|
208
|
+
<span
|
|
209
|
+
className="h-6 w-6 rounded-full flex items-center justify-center text-xs font-bold"
|
|
210
|
+
style={{
|
|
211
|
+
background: `hsl(${(parseInt(pubkey.slice(0, 2), 16) / 255) * 360}, 60%, 50%)`,
|
|
212
|
+
color: '#fff',
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
{pubkey.slice(0, 1).toUpperCase()}
|
|
216
|
+
</span>
|
|
217
|
+
<span className="font-mono text-sm" style={{ color: 'var(--color-text)' }}>
|
|
218
|
+
{displayNpub}
|
|
219
|
+
</span>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<button
|
|
226
|
+
onClick={getPublicKey}
|
|
227
|
+
disabled={isConnecting}
|
|
228
|
+
className="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold transition-opacity hover:opacity-80 disabled:opacity-40"
|
|
229
|
+
style={{ background: 'var(--color-accent)', color: '#fff', borderRadius: 'var(--radius-md)' }}
|
|
230
|
+
>
|
|
231
|
+
{isConnecting ? 'Connecting…' : 'Connect Nostr'}
|
|
232
|
+
</button>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Pattern 5: Pay-to-unlock content
|
|
240
|
+
|
|
241
|
+
Gate content behind a Lightning payment. The server verifies the preimage before revealing the content. This is the most common Fedi Mini App monetisation pattern.
|
|
242
|
+
|
|
243
|
+
```tsx
|
|
244
|
+
// Client component
|
|
245
|
+
'use client';
|
|
246
|
+
import { useState } from 'react';
|
|
247
|
+
import { usePayment } from '@create-fedi-app/webln';
|
|
248
|
+
|
|
249
|
+
interface PaywallProps {
|
|
250
|
+
contentId: string;
|
|
251
|
+
priceSats: number;
|
|
252
|
+
onUnlocked: (content: string) => void;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function Paywall({ contentId, priceSats, onUnlocked }: PaywallProps) {
|
|
256
|
+
const { makeInvoice, sendPayment, isPaying, isCreatingInvoice, paymentError } = usePayment();
|
|
257
|
+
const [step, setStep] = useState<'idle' | 'paying' | 'verifying' | 'error'>('idle');
|
|
258
|
+
|
|
259
|
+
async function unlock() {
|
|
260
|
+
setStep('paying');
|
|
261
|
+
|
|
262
|
+
// 1. Get invoice from your server
|
|
263
|
+
const res = await fetch(`/api/invoice?contentId=${contentId}&sats=${priceSats}`);
|
|
264
|
+
const { invoice } = await res.json();
|
|
265
|
+
|
|
266
|
+
// 2. User pays it
|
|
267
|
+
const payment = await sendPayment(invoice);
|
|
268
|
+
if (!payment) {
|
|
269
|
+
setStep('error');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 3. Send preimage to server as proof of payment
|
|
274
|
+
setStep('verifying');
|
|
275
|
+
const verifyRes = await fetch('/api/verify', {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: { 'Content-Type': 'application/json' },
|
|
278
|
+
body: JSON.stringify({ contentId, preimage: payment.preimage }),
|
|
279
|
+
});
|
|
280
|
+
const { content } = await verifyRes.json();
|
|
281
|
+
onUnlocked(content);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const isWorking = step === 'paying' || step === 'verifying' || isPaying || isCreatingInvoice;
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<div
|
|
288
|
+
className="p-6 rounded-lg text-center"
|
|
289
|
+
style={{ background: 'var(--color-surface)', borderRadius: 'var(--radius-lg)' }}
|
|
290
|
+
>
|
|
291
|
+
<p className="text-sm font-semibold mb-1" style={{ color: 'var(--color-text)' }}>
|
|
292
|
+
Unlock this content
|
|
293
|
+
</p>
|
|
294
|
+
<p className="text-sm mb-4" style={{ color: 'var(--color-text-muted)' }}>
|
|
295
|
+
{priceSats.toLocaleString()} sats
|
|
296
|
+
</p>
|
|
297
|
+
<button
|
|
298
|
+
onClick={unlock}
|
|
299
|
+
disabled={isWorking}
|
|
300
|
+
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-semibold transition-opacity hover:opacity-80 disabled:opacity-40"
|
|
301
|
+
style={{ background: 'var(--color-accent)', color: '#fff', borderRadius: 'var(--radius-md)' }}
|
|
302
|
+
>
|
|
303
|
+
{step === 'verifying' ? 'Verifying…' : isWorking ? 'Paying…' : `Pay ${priceSats} sats`}
|
|
304
|
+
</button>
|
|
305
|
+
{(paymentError || step === 'error') && (
|
|
306
|
+
<p className="mt-2 text-xs" style={{ color: '#ef4444' }}>
|
|
307
|
+
{paymentError?.message ?? 'Payment failed. Please try again.'}
|
|
308
|
+
</p>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
## Pattern 6: Nostr-authenticated API call
|
|
318
|
+
|
|
319
|
+
Use a signed Nostr event as a bearer token to prove identity to your server. No passwords, no JWTs issued by a third party — the user's private key is the credential.
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
// lib/nostr-auth.ts
|
|
323
|
+
import type { NostrEvent } from '@create-fedi-app/nostr';
|
|
324
|
+
|
|
325
|
+
// NIP-98 HTTP Auth event (kind 27235)
|
|
326
|
+
export async function buildAuthHeader(
|
|
327
|
+
url: string,
|
|
328
|
+
method: string,
|
|
329
|
+
signEvent: (event: Omit<NostrEvent, 'id' | 'sig'>) => Promise<NostrEvent | null>
|
|
330
|
+
): Promise<string | null> {
|
|
331
|
+
const event = await signEvent({
|
|
332
|
+
kind: 27235,
|
|
333
|
+
pubkey: '', // filled by signEvent
|
|
334
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
335
|
+
tags: [
|
|
336
|
+
['u', url],
|
|
337
|
+
['method', method],
|
|
338
|
+
],
|
|
339
|
+
content: '',
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (!event) return null;
|
|
343
|
+
return 'Nostr ' + btoa(JSON.stringify(event));
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
```tsx
|
|
348
|
+
// Usage in a component
|
|
349
|
+
'use client';
|
|
350
|
+
import { useIdentity } from '@create-fedi-app/nostr';
|
|
351
|
+
import { buildAuthHeader } from '../lib/nostr-auth';
|
|
352
|
+
|
|
353
|
+
export function ProtectedAction() {
|
|
354
|
+
const { pubkey, getPublicKey, signEvent } = useIdentity();
|
|
355
|
+
|
|
356
|
+
async function callProtectedAPI() {
|
|
357
|
+
if (!pubkey) await getPublicKey();
|
|
358
|
+
|
|
359
|
+
const url = `${window.location.origin}/api/protected`;
|
|
360
|
+
const authHeader = await buildAuthHeader(url, 'POST', signEvent);
|
|
361
|
+
if (!authHeader) return;
|
|
362
|
+
|
|
363
|
+
const res = await fetch(url, {
|
|
364
|
+
method: 'POST',
|
|
365
|
+
headers: {
|
|
366
|
+
Authorization: authHeader,
|
|
367
|
+
'Content-Type': 'application/json',
|
|
368
|
+
},
|
|
369
|
+
body: JSON.stringify({ action: 'do-thing' }),
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const data = await res.json();
|
|
373
|
+
// handle response
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<button onClick={callProtectedAPI}>
|
|
378
|
+
{pubkey ? 'Call API' : 'Connect & Call API'}
|
|
379
|
+
</button>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
// Server-side verification (Next.js App Router route handler)
|
|
386
|
+
// app/api/protected/route.ts
|
|
387
|
+
import { headers } from 'next/headers';
|
|
388
|
+
|
|
389
|
+
export async function POST(req: Request) {
|
|
390
|
+
const authHeader = headers().get('Authorization');
|
|
391
|
+
if (!authHeader?.startsWith('Nostr ')) {
|
|
392
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const event = JSON.parse(atob(authHeader.slice(6)));
|
|
396
|
+
|
|
397
|
+
// Verify: kind must be 27235, timestamp must be recent, tags must match
|
|
398
|
+
if (event.kind !== 27235) return Response.json({ error: 'Invalid event kind' }, { status: 401 });
|
|
399
|
+
if (Math.abs(Date.now() / 1000 - event.created_at) > 60) {
|
|
400
|
+
return Response.json({ error: 'Event expired' }, { status: 401 });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// event.pubkey is the verified user identity
|
|
404
|
+
const userPubkey = event.pubkey;
|
|
405
|
+
|
|
406
|
+
return Response.json({ ok: true, user: userPubkey });
|
|
407
|
+
}
|
|
408
|
+
```
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Testing Reference
|
|
2
|
+
|
|
3
|
+
## Test setup
|
|
4
|
+
|
|
5
|
+
The project uses **Vitest** with **React Testing Library** for unit/integration tests. Playwright is available for E2E.
|
|
6
|
+
|
|
7
|
+
Configuration: `vitest.config.ts` and `vitest.setup.ts`.
|
|
8
|
+
|
|
9
|
+
## Running tests
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm test # run all tests once
|
|
13
|
+
pnpm test:watch # watch mode
|
|
14
|
+
pnpm test:e2e # Playwright E2E (requires running dev server)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Testing WebLN flows
|
|
18
|
+
|
|
19
|
+
Always use `MockWebLNProvider` — never mock `window.webln` directly. The mock implements the full interface and validates inputs (e.g. rejects non-`lnbc` invoices).
|
|
20
|
+
|
|
21
|
+
### Basic payment test
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
25
|
+
import { WebLNProvider } from '@create-fedi-app/webln';
|
|
26
|
+
import { MockWebLNProvider } from '@create-fedi-app/webln';
|
|
27
|
+
import { PayButton } from '../components/webln/PayButton';
|
|
28
|
+
|
|
29
|
+
const VALID_INVOICE = 'lnbc1000n1p00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000';
|
|
30
|
+
|
|
31
|
+
test('pays invoice and shows preimage', async () => {
|
|
32
|
+
const mock = new MockWebLNProvider({ paymentDelay: 0 });
|
|
33
|
+
const onSuccess = vi.fn();
|
|
34
|
+
|
|
35
|
+
render(
|
|
36
|
+
<WebLNProvider mock={mock}>
|
|
37
|
+
<PayButton invoice={VALID_INVOICE} onSuccess={onSuccess} />
|
|
38
|
+
</WebLNProvider>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
fireEvent.click(screen.getByRole('button', { name: /pay invoice/i }));
|
|
42
|
+
await waitFor(() => expect(screen.getByText(/payment sent/i)).toBeInTheDocument());
|
|
43
|
+
expect(onSuccess).toHaveBeenCalledWith(expect.any(String));
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Testing payment failure
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
test('shows error on payment failure', async () => {
|
|
51
|
+
const mock = new MockWebLNProvider({
|
|
52
|
+
shouldFail: true,
|
|
53
|
+
failureMessage: 'Insufficient funds',
|
|
54
|
+
paymentDelay: 0,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
render(
|
|
58
|
+
<WebLNProvider mock={mock}>
|
|
59
|
+
<PayButton invoice={VALID_INVOICE} onSuccess={vi.fn()} />
|
|
60
|
+
</WebLNProvider>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
fireEvent.click(screen.getByRole('button', { name: /pay invoice/i }));
|
|
64
|
+
await waitFor(() => expect(screen.getByText(/insufficient funds/i)).toBeInTheDocument());
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Testing loading state
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
test('shows paying state during payment', async () => {
|
|
72
|
+
const mock = new MockWebLNProvider({ paymentDelay: 5000 });
|
|
73
|
+
|
|
74
|
+
render(
|
|
75
|
+
<WebLNProvider mock={mock}>
|
|
76
|
+
<PayButton invoice={VALID_INVOICE} onSuccess={vi.fn()} />
|
|
77
|
+
</WebLNProvider>
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
fireEvent.click(screen.getByRole('button', { name: /pay invoice/i }));
|
|
81
|
+
expect(screen.getByText(/paying/i)).toBeInTheDocument();
|
|
82
|
+
expect(screen.getByRole('button')).toBeDisabled();
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Testing fallback when WebLN unavailable
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
test('shows fallback when not in Fedi', () => {
|
|
90
|
+
render(
|
|
91
|
+
<WebLNProvider mock={null}> {/* null = no provider */}
|
|
92
|
+
<FediGuard>
|
|
93
|
+
<p>Protected content</p>
|
|
94
|
+
</FediGuard>
|
|
95
|
+
</WebLNProvider>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(screen.getByText(/open in fedi/i)).toBeInTheDocument();
|
|
99
|
+
expect(screen.queryByText(/protected content/i)).not.toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Testing Nostr flows
|
|
104
|
+
|
|
105
|
+
Use `MockNostrProvider`. It signs events with a real secp256k1 key, so signatures are valid and can be verified.
|
|
106
|
+
|
|
107
|
+
### Basic identity test
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
111
|
+
import { NostrProvider } from '@create-fedi-app/nostr';
|
|
112
|
+
import { MockNostrProvider } from '@create-fedi-app/nostr';
|
|
113
|
+
import { IdentityBadge } from '../components/nostr/IdentityBadge';
|
|
114
|
+
|
|
115
|
+
const TEST_PUBKEY = '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798';
|
|
116
|
+
|
|
117
|
+
test('connects and shows truncated npub', async () => {
|
|
118
|
+
render(
|
|
119
|
+
<NostrProvider mock={new MockNostrProvider()}>
|
|
120
|
+
<IdentityBadge />
|
|
121
|
+
</NostrProvider>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
fireEvent.click(screen.getByRole('button', { name: /connect nostr/i }));
|
|
125
|
+
await waitFor(() => expect(screen.queryByRole('button')).not.toBeInTheDocument());
|
|
126
|
+
// truncated npub should be visible
|
|
127
|
+
expect(screen.getByText(/npub1.+\.\.\..+/)).toBeInTheDocument();
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Testing event signing
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
import { useIdentity } from '@create-fedi-app/nostr';
|
|
135
|
+
import { MockNostrProvider } from '@create-fedi-app/nostr';
|
|
136
|
+
|
|
137
|
+
test('signs a text note event', async () => {
|
|
138
|
+
const mock = new MockNostrProvider();
|
|
139
|
+
const { result } = renderHook(() => useIdentity(), {
|
|
140
|
+
wrapper: ({ children }) => (
|
|
141
|
+
<NostrProvider mock={mock}>{children}</NostrProvider>
|
|
142
|
+
),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await act(async () => {
|
|
146
|
+
await result.current.getPublicKey();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const event = await result.current.signEvent({
|
|
150
|
+
kind: 1,
|
|
151
|
+
content: 'test',
|
|
152
|
+
tags: [],
|
|
153
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(event).not.toBeNull();
|
|
157
|
+
expect(event!.id).toHaveLength(64);
|
|
158
|
+
expect(event!.sig).toHaveLength(128);
|
|
159
|
+
expect(event!.pubkey).toBe(TEST_PUBKEY);
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Vitest setup
|
|
164
|
+
|
|
165
|
+
`vitest.setup.ts` configures jsdom globals. Key things it provides:
|
|
166
|
+
- `window`, `document`, `navigator` (jsdom)
|
|
167
|
+
- `@testing-library/jest-dom` matchers (`toBeInTheDocument`, `toBeDisabled`, etc.)
|
|
168
|
+
- Does **not** pre-inject `window.webln` or `window.nostr` — use the provider mocks instead
|
|
169
|
+
|
|
170
|
+
## Playwright E2E patterns
|
|
171
|
+
|
|
172
|
+
E2E tests live in `e2e/` and use `@playwright/test`.
|
|
173
|
+
|
|
174
|
+
### Setup
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
// e2e/playwright.config.ts — already configured
|
|
178
|
+
// Base URL is http://localhost:3000 (dev server)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Mock the Fedi environment in E2E
|
|
182
|
+
|
|
183
|
+
Since Playwright runs a real browser, inject the mock providers via page evaluation:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
// e2e/helpers/inject-webln.ts
|
|
187
|
+
import { Page } from '@playwright/test';
|
|
188
|
+
|
|
189
|
+
export async function injectMockWebLN(page: Page) {
|
|
190
|
+
await page.addInitScript(() => {
|
|
191
|
+
window.webln = {
|
|
192
|
+
async enable() {},
|
|
193
|
+
async getInfo() {
|
|
194
|
+
return { node: { alias: 'Test', pubkey: '0000', color: '#FF6B35' }, methods: [] };
|
|
195
|
+
},
|
|
196
|
+
async sendPayment(_invoice: string) {
|
|
197
|
+
return { preimage: 'deadbeef'.repeat(8) };
|
|
198
|
+
},
|
|
199
|
+
async makeInvoice(_args: unknown) {
|
|
200
|
+
return { paymentRequest: 'lnbc1000n1test' };
|
|
201
|
+
},
|
|
202
|
+
async signMessage(message: string) {
|
|
203
|
+
return { message, signature: 'aabbcc'.repeat(21) + 'aabb' };
|
|
204
|
+
},
|
|
205
|
+
async verifyMessage() {},
|
|
206
|
+
async sendKeysend(_args: unknown) {
|
|
207
|
+
return { preimage: 'deadbeef'.repeat(8) };
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
// e2e/payment.spec.ts
|
|
216
|
+
import { test, expect } from '@playwright/test';
|
|
217
|
+
import { injectMockWebLN } from './helpers/inject-webln';
|
|
218
|
+
|
|
219
|
+
test('user can pay an invoice', async ({ page }) => {
|
|
220
|
+
await injectMockWebLN(page);
|
|
221
|
+
await page.goto('/demo/webln');
|
|
222
|
+
|
|
223
|
+
await page.getByRole('button', { name: /pay invoice/i }).click();
|
|
224
|
+
await expect(page.getByText(/payment sent/i)).toBeVisible();
|
|
225
|
+
});
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## What to test vs. what to skip
|
|
229
|
+
|
|
230
|
+
| Test | How |
|
|
231
|
+
|------|-----|
|
|
232
|
+
| Payment success/failure flows | Vitest + MockWebLNProvider |
|
|
233
|
+
| Nostr connect + signing | Vitest + MockNostrProvider |
|
|
234
|
+
| UI state transitions (loading, error, success) | Vitest + RTL |
|
|
235
|
+
| Form validation | Vitest + RTL |
|
|
236
|
+
| Full user journey | Playwright E2E with injected mocks |
|
|
237
|
+
| `window.webln` implementation | Don't test — Fedi owns it |
|
|
238
|
+
| `window.nostr` implementation | Don't test — Fedi owns it |
|