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.
Files changed (133) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.js +11113 -0
  3. package/dist/templates/base/.env.example +5 -0
  4. package/dist/templates/base/app/demo/page.tsx +25 -0
  5. package/dist/templates/base/app/globals.css +95 -0
  6. package/dist/templates/base/app/layout.tsx +39 -0
  7. package/dist/templates/base/app/page.tsx +83 -0
  8. package/dist/templates/base/components/FediDevToolbar/FediDevToolbar.tsx +170 -0
  9. package/dist/templates/base/components/providers.tsx +63 -0
  10. package/dist/templates/base/env.ts +10 -0
  11. package/dist/templates/base/hooks/useFediInternal.ts +41 -0
  12. package/dist/templates/base/lib/fedi-types.ts +96 -0
  13. package/dist/templates/base/lib/fedi.ts +18 -0
  14. package/dist/templates/base/lib/nostr/hooks.ts +52 -0
  15. package/dist/templates/base/lib/nostr/index.ts +9 -0
  16. package/dist/templates/base/lib/nostr/mock.ts +60 -0
  17. package/dist/templates/base/lib/nostr/provider.tsx +64 -0
  18. package/dist/templates/base/lib/utils.ts +3 -0
  19. package/dist/templates/base/lib/webln/hooks.ts +67 -0
  20. package/dist/templates/base/lib/webln/index.ts +12 -0
  21. package/dist/templates/base/lib/webln/mock.ts +96 -0
  22. package/dist/templates/base/lib/webln/provider.tsx +52 -0
  23. package/dist/templates/base/next.config.ts +3 -0
  24. package/dist/templates/base/package.json +40 -0
  25. package/dist/templates/base/proxy.ts +8 -0
  26. package/dist/templates/base/tsconfig.json +20 -0
  27. package/dist/templates/base/vitest.config.ts +6 -0
  28. package/dist/templates/base/vitest.setup.ts +40 -0
  29. package/dist/templates/modules/ai-assistant/app/api/assistant/route.ts +45 -0
  30. package/dist/templates/modules/ai-assistant/app/demo/assistant/AssistantDemoClient.tsx +70 -0
  31. package/dist/templates/modules/ai-assistant/app/demo/assistant/page.tsx +23 -0
  32. package/dist/templates/modules/ai-assistant/components/ai/Assistant.tsx +220 -0
  33. package/dist/templates/modules/ai-assistant/components/ai/AssistantProvider.tsx +71 -0
  34. package/dist/templates/modules/ai-assistant/lib/ai/providers.ts +49 -0
  35. package/dist/templates/modules/ai-assistant/module.json +48 -0
  36. package/dist/templates/modules/ai-chat-gated/app/api/chat/invoice/route.ts +15 -0
  37. package/dist/templates/modules/ai-chat-gated/app/api/chat/route.ts +57 -0
  38. package/dist/templates/modules/ai-chat-gated/app/demo/ai-chat/page.tsx +58 -0
  39. package/dist/templates/modules/ai-chat-gated/components/ai/ChatMessage.tsx +50 -0
  40. package/dist/templates/modules/ai-chat-gated/components/ai/GatedChat.tsx +181 -0
  41. package/dist/templates/modules/ai-chat-gated/components/ai/PaymentGate.tsx +168 -0
  42. package/dist/templates/modules/ai-chat-gated/lib/ai/providers.ts +49 -0
  43. package/dist/templates/modules/ai-chat-gated/lib/chat-payment.ts +161 -0
  44. package/dist/templates/modules/ai-chat-gated/module.json +62 -0
  45. package/dist/templates/modules/ai-rules/.cursorrules +8 -0
  46. package/dist/templates/modules/ai-rules/.github/copilot-instructions.md +8 -0
  47. package/dist/templates/modules/ai-rules/CLAUDE.md +8 -0
  48. package/dist/templates/modules/ai-rules/module.json +20 -0
  49. package/dist/templates/modules/ai-rules/rules/OVERVIEW.md +56 -0
  50. package/dist/templates/modules/ai-rules/rules/architecture.md +108 -0
  51. package/dist/templates/modules/ai-rules/rules/design-system.md +94 -0
  52. package/dist/templates/modules/ai-rules/rules/fedi-api.md +120 -0
  53. package/dist/templates/modules/ai-rules/rules/nostr.md +232 -0
  54. package/dist/templates/modules/ai-rules/rules/patterns.md +408 -0
  55. package/dist/templates/modules/ai-rules/rules/testing.md +238 -0
  56. package/dist/templates/modules/ai-rules/rules/webln.md +241 -0
  57. package/dist/templates/modules/database/drizzle/supabase/0000_initial.sql +7 -0
  58. package/dist/templates/modules/database/drizzle/supabase/meta/_journal.json +13 -0
  59. package/dist/templates/modules/database/drizzle/turso/0000_initial.sql +7 -0
  60. package/dist/templates/modules/database/drizzle/turso/meta/_journal.json +13 -0
  61. package/dist/templates/modules/database/drizzle.config.supabase.ts +10 -0
  62. package/dist/templates/modules/database/drizzle.config.turso.ts +11 -0
  63. package/dist/templates/modules/database/env.supabase.ts +24 -0
  64. package/dist/templates/modules/database/env.turso.ts +23 -0
  65. package/dist/templates/modules/database/lib/db/index.supabase.ts +19 -0
  66. package/dist/templates/modules/database/lib/db/index.turso.ts +20 -0
  67. package/dist/templates/modules/database/lib/db/schema.supabase.ts +13 -0
  68. package/dist/templates/modules/database/lib/db/schema.turso.ts +13 -0
  69. package/dist/templates/modules/database/module.json +110 -0
  70. package/dist/templates/modules/ecash-balance/app/demo/ecash/page.tsx +115 -0
  71. package/dist/templates/modules/ecash-balance/components/fedi/BalanceDisplay.tsx +162 -0
  72. package/dist/templates/modules/ecash-balance/components/fedi/FediVersionBadge.tsx +39 -0
  73. package/dist/templates/modules/ecash-balance/components/fedi/InstallMiniAppButton.tsx +74 -0
  74. package/dist/templates/modules/ecash-balance/hooks/useFediBalance.ts +65 -0
  75. package/dist/templates/modules/ecash-balance/module.json +14 -0
  76. package/dist/templates/modules/lnurl/app/api/lnurlauth/route.ts +118 -0
  77. package/dist/templates/modules/lnurl/app/api/lnurlp/[username]/route.ts +70 -0
  78. package/dist/templates/modules/lnurl/app/api/lnurlw/route.ts +57 -0
  79. package/dist/templates/modules/lnurl/app/demo/lnurl/page.tsx +136 -0
  80. package/dist/templates/modules/lnurl/components/lnurl/LnurlAuth.tsx +156 -0
  81. package/dist/templates/modules/lnurl/components/lnurl/LnurlPay.tsx +36 -0
  82. package/dist/templates/modules/lnurl/components/lnurl/LnurlQR.tsx +96 -0
  83. package/dist/templates/modules/lnurl/components/lnurl/LnurlWithdraw.tsx +141 -0
  84. package/dist/templates/modules/lnurl/lib/lnurl-auth-verify.ts +87 -0
  85. package/dist/templates/modules/lnurl/lib/lnurl-store.ts +112 -0
  86. package/dist/templates/modules/lnurl/lib/lnurl-utils.ts +56 -0
  87. package/dist/templates/modules/lnurl/module.json +27 -0
  88. package/dist/templates/modules/multispend-demo/app/demo/multispend/MultispendDemoClient.tsx +109 -0
  89. package/dist/templates/modules/multispend-demo/app/demo/multispend/page.tsx +23 -0
  90. package/dist/templates/modules/multispend-demo/components/multispend/ApprovalVote.tsx +122 -0
  91. package/dist/templates/modules/multispend-demo/components/multispend/MultispendDemo.tsx +220 -0
  92. package/dist/templates/modules/multispend-demo/components/multispend/MultispendProposal.tsx +213 -0
  93. package/dist/templates/modules/multispend-demo/components/multispend/ProposalList.tsx +49 -0
  94. package/dist/templates/modules/multispend-demo/hooks/useMultispendDemo.ts +127 -0
  95. package/dist/templates/modules/multispend-demo/lib/multispend-types.ts +33 -0
  96. package/dist/templates/modules/multispend-demo/lib/multispend-utils.ts +69 -0
  97. package/dist/templates/modules/multispend-demo/module.json +18 -0
  98. package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/NostrFeedDemoClient.tsx +134 -0
  99. package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/page.tsx +23 -0
  100. package/dist/templates/modules/nostr-feed/components/nostr/NostrFeedProvider.tsx +47 -0
  101. package/dist/templates/modules/nostr-feed/components/nostr/NoteCard.tsx +68 -0
  102. package/dist/templates/modules/nostr-feed/components/nostr/NoteFeed.tsx +109 -0
  103. package/dist/templates/modules/nostr-feed/components/nostr/PublishNote.tsx +104 -0
  104. package/dist/templates/modules/nostr-feed/components/nostr/ZapButton.tsx +140 -0
  105. package/dist/templates/modules/nostr-feed/lib/nostr/relay.ts +107 -0
  106. package/dist/templates/modules/nostr-feed/lib/nostr-zap.ts +159 -0
  107. package/dist/templates/modules/nostr-feed/module.json +25 -0
  108. package/dist/templates/modules/nostr-identity/app/demo/nostr/page.tsx +136 -0
  109. package/dist/templates/modules/nostr-identity/components/nostr/IdentityBadge.tsx +109 -0
  110. package/dist/templates/modules/nostr-identity/components/nostr/NostrLogin.tsx +107 -0
  111. package/dist/templates/modules/nostr-identity/components/nostr/SignedMessage.tsx +103 -0
  112. package/dist/templates/modules/nostr-identity/hooks/useIdentityFlow.ts +61 -0
  113. package/dist/templates/modules/nostr-identity/lib/nostr-utils.ts +30 -0
  114. package/dist/templates/modules/nostr-identity/module.json +15 -0
  115. package/dist/templates/modules/payment-gated-content/app/api/payment-gate/invoice/route.ts +25 -0
  116. package/dist/templates/modules/payment-gated-content/app/api/payment-gate/verify/route.ts +39 -0
  117. package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/article/page.tsx +71 -0
  118. package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/page.tsx +134 -0
  119. package/dist/templates/modules/payment-gated-content/components/payment-gated/PayGate.tsx +267 -0
  120. package/dist/templates/modules/payment-gated-content/lib/payment-gate.ts +195 -0
  121. package/dist/templates/modules/payment-gated-content/lib/payment-store.ts +104 -0
  122. package/dist/templates/modules/payment-gated-content/module.json +24 -0
  123. package/dist/templates/modules/payment-gated-content/proxy.ts +27 -0
  124. package/dist/templates/modules/webln-payments/app/demo/webln/page.tsx +176 -0
  125. package/dist/templates/modules/webln-payments/components/webln/InvoiceCard.tsx +170 -0
  126. package/dist/templates/modules/webln-payments/components/webln/PayButton.tsx +92 -0
  127. package/dist/templates/modules/webln-payments/components/webln/PaymentHistory.tsx +102 -0
  128. package/dist/templates/modules/webln-payments/hooks/__tests__/usePaymentFlow.test.tsx +182 -0
  129. package/dist/templates/modules/webln-payments/hooks/usePaymentFlow.ts +100 -0
  130. package/dist/templates/modules/webln-payments/lib/payment-history.ts +75 -0
  131. package/dist/templates/modules/webln-payments/module.json +17 -0
  132. package/dist/templates/modules/webln-payments/tests/e2e/webln-payment.spec.ts +41 -0
  133. 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 |