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,136 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useState } from 'react';
5
+ import { LnurlAuth } from '../../../components/lnurl/LnurlAuth';
6
+ import { LnurlPay } from '../../../components/lnurl/LnurlPay';
7
+ import { LnurlWithdraw } from '../../../components/lnurl/LnurlWithdraw';
8
+
9
+ const DEMO_USERNAME = 'demo-user';
10
+
11
+ export default function LnurlDemoPage() {
12
+ const [howItWorksOpen, setHowItWorksOpen] = useState(false);
13
+
14
+ return (
15
+ <div className="min-h-dvh bg-[var(--color-bg)] font-[family-name:var(--font-body)] text-[var(--color-text)]">
16
+ <div
17
+ className="mx-auto w-full max-w-[390px] px-4 pt-6"
18
+ style={{ paddingBottom: 'max(5rem, env(safe-area-inset-bottom, 20px))' }}
19
+ >
20
+ <Link
21
+ href="/demo"
22
+ className="mb-6 inline-block text-xs text-[var(--color-text-muted)] transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
23
+ >
24
+ ← back
25
+ </Link>
26
+
27
+ <header className="mb-8 space-y-2">
28
+ <h1 className="font-[family-name:var(--font-display)] text-2xl font-bold leading-tight text-[var(--color-text)]">
29
+ LNURL
30
+ </h1>
31
+ <p className="max-w-[75ch] text-sm leading-[1.65] text-[var(--color-text-muted)]">
32
+ Encode Lightning interactions as scannable links: pay, authenticate, and withdraw
33
+ without pasting BOLT11 strings manually.
34
+ </p>
35
+ </header>
36
+
37
+ <div className="space-y-8">
38
+ <section className="space-y-4">
39
+ <div className="max-w-[75ch] space-y-1.5">
40
+ <h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
41
+ LNURL-pay
42
+ </h2>
43
+ <p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
44
+ Static QR for <code className="font-mono text-xs">/api/lnurlp/[username]</code>.
45
+ Wallets fetch metadata, then call your callback with an amount in millisats to receive
46
+ a BOLT11 invoice.
47
+ </p>
48
+ </div>
49
+ <LnurlPay username={DEMO_USERNAME} />
50
+ </section>
51
+
52
+ <section className="space-y-4">
53
+ <div className="max-w-[75ch] space-y-1.5">
54
+ <h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
55
+ LNURL-auth
56
+ </h2>
57
+ <p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
58
+ Passwordless login via Lightning wallet or Nostr. This demo signs a kind-22242 event
59
+ with your <code className="font-mono text-xs">challenge</code> tag and completes the
60
+ callback.
61
+ </p>
62
+ </div>
63
+ <LnurlAuth />
64
+ </section>
65
+
66
+ <section className="space-y-4">
67
+ <div className="max-w-[75ch] space-y-1.5">
68
+ <h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
69
+ LNURL-withdraw
70
+ </h2>
71
+ <p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
72
+ Service pays the user&apos;s invoice. The demo simulates a wallet calling your
73
+ callback with a BOLT11 from <code className="font-mono text-xs">makeInvoice()</code>.
74
+ </p>
75
+ </div>
76
+ <LnurlWithdraw />
77
+ </section>
78
+
79
+ <section className="space-y-3">
80
+ <button
81
+ type="button"
82
+ onClick={() => setHowItWorksOpen((open) => !open)}
83
+ className="flex w-full items-center justify-between rounded-lg px-4 py-3 text-left text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
84
+ style={{
85
+ background: 'var(--color-surface-1)',
86
+ border: '1px solid var(--color-border)',
87
+ color: 'var(--color-text)',
88
+ borderRadius: 'var(--radius-lg)',
89
+ }}
90
+ aria-expanded={howItWorksOpen}
91
+ aria-controls="lnurl-how-it-works"
92
+ >
93
+ How LNURL works
94
+ <span aria-hidden>{howItWorksOpen ? '−' : '+'}</span>
95
+ </button>
96
+
97
+ {howItWorksOpen && (
98
+ <div
99
+ id="lnurl-how-it-works"
100
+ className="space-y-3 rounded-lg px-4 py-3 text-sm leading-[1.65]"
101
+ style={{
102
+ background: 'var(--color-surface-1)',
103
+ border: '1px solid var(--color-border)',
104
+ color: 'var(--color-text-muted)',
105
+ borderRadius: 'var(--radius-lg)',
106
+ }}
107
+ >
108
+ <p>
109
+ <strong className="text-[var(--color-text)]">LNURL</strong> wraps HTTPS endpoints
110
+ in bech32 strings (prefix <code className="font-mono text-xs">lnurl1…</code>) so
111
+ Lightning wallets can scan one QR and discover what to do next.
112
+ </p>
113
+ <p>
114
+ <strong className="text-[var(--color-text)]">Pay</strong>: metadata at{' '}
115
+ <code className="font-mono text-xs">/api/lnurlp/user</code>, invoice at the same
116
+ URL with <code className="font-mono text-xs">?amount=</code> in millisats.
117
+ </p>
118
+ <p>
119
+ <strong className="text-[var(--color-text)]">Auth</strong>: server issues a{' '}
120
+ <code className="font-mono text-xs">k1</code> challenge; the wallet proves key
121
+ ownership via signature or signed Nostr event.
122
+ </p>
123
+ <p>
124
+ <strong className="text-[var(--color-text)]">Withdraw</strong>: service sends sats
125
+ to an invoice the user supplies on callback. Replace mock invoices with your node
126
+ in production and set <code className="font-mono text-xs">LNURL_SERVER_URL</code>{' '}
127
+ behind a reverse proxy.
128
+ </p>
129
+ </div>
130
+ )}
131
+ </section>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ );
136
+ }
@@ -0,0 +1,156 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import { useIdentity } from '../../lib/nostr';
5
+ import { encodeLnurl } from '../../lib/lnurl-utils';
6
+ import { LnurlQR } from './LnurlQR';
7
+
8
+ type TAuthChallenge = {
9
+ tag: string;
10
+ k1: string;
11
+ lnurl: string;
12
+ url: string;
13
+ };
14
+
15
+ type TAuthResult = {
16
+ status: string;
17
+ pubkey?: string;
18
+ reason?: string;
19
+ };
20
+
21
+ /**
22
+ * Demo LNURL-auth flow: fetches a k1 challenge, shows QR, and completes login via Nostr.
23
+ */
24
+ export function LnurlAuth({ className }: { className?: string }) {
25
+ const { signEvent, getPublicKey, pubkey } = useIdentity();
26
+ const [challenge, setChallenge] = useState<TAuthChallenge | null>(null);
27
+ const [result, setResult] = useState<TAuthResult | null>(null);
28
+ const [error, setError] = useState<string | null>(null);
29
+ const [isLoading, setIsLoading] = useState(false);
30
+ const [isAuthenticating, setIsAuthenticating] = useState(false);
31
+
32
+ const loadChallenge = useCallback(async () => {
33
+ setIsLoading(true);
34
+ setError(null);
35
+ setResult(null);
36
+ try {
37
+ const res = await fetch('/api/lnurlauth');
38
+ if (!res.ok) throw new Error('Failed to load auth challenge');
39
+ const data = (await res.json()) as TAuthChallenge;
40
+ setChallenge(data);
41
+ } catch (err) {
42
+ setError(err instanceof Error ? err.message : 'Could not load challenge');
43
+ } finally {
44
+ setIsLoading(false);
45
+ }
46
+ }, []);
47
+
48
+ useEffect(() => {
49
+ void loadChallenge();
50
+ }, [loadChallenge]);
51
+
52
+ async function handleLogin() {
53
+ if (!challenge) return;
54
+ setIsAuthenticating(true);
55
+ setError(null);
56
+
57
+ try {
58
+ const pk = pubkey ?? (await getPublicKey());
59
+ if (!pk) {
60
+ setError('Nostr provider not available. Open inside Fedi to sign the challenge.');
61
+ return;
62
+ }
63
+
64
+ const unsigned = {
65
+ kind: 22242,
66
+ created_at: Math.floor(Date.now() / 1000),
67
+ tags: [['challenge', challenge.k1]],
68
+ content: '',
69
+ };
70
+
71
+ const signed = await signEvent(unsigned);
72
+ if (!signed) {
73
+ setError('Signing failed');
74
+ return;
75
+ }
76
+
77
+ const res = await fetch('/api/lnurlauth', {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({
81
+ k1: challenge.k1,
82
+ key: pk,
83
+ tag: 'login',
84
+ event: signed,
85
+ }),
86
+ });
87
+ const data = (await res.json()) as TAuthResult;
88
+ setResult(data);
89
+
90
+ if (data.status !== 'OK') {
91
+ setError(data.reason ?? 'Authentication failed');
92
+ }
93
+ } catch (err) {
94
+ setError(err instanceof Error ? err.message : 'Authentication failed');
95
+ } finally {
96
+ setIsAuthenticating(false);
97
+ }
98
+ }
99
+
100
+ const lnurl = challenge?.lnurl ?? (challenge?.url ? encodeLnurl(challenge.url) : '');
101
+
102
+ return (
103
+ <div className={`space-y-4 ${className ?? ''}`}>
104
+ {isLoading && (
105
+ <p className="text-sm" style={{ color: 'var(--color-text-subtle)' }} aria-live="polite">
106
+ Loading challenge…
107
+ </p>
108
+ )}
109
+
110
+ {challenge && lnurl && <LnurlQR value={lnurl} label="LNURL-auth QR code" />}
111
+
112
+ <button
113
+ type="button"
114
+ onClick={handleLogin}
115
+ disabled={!challenge || isAuthenticating}
116
+ className="w-full rounded-lg px-4 py-3 text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-90 active:opacity-80 disabled:opacity-50"
117
+ style={{
118
+ background: 'var(--color-accent)',
119
+ color: 'var(--color-primary-foreground)',
120
+ borderRadius: 'var(--radius-md)',
121
+ }}
122
+ >
123
+ {isAuthenticating ? 'Signing…' : 'Complete login (Nostr)'}
124
+ </button>
125
+
126
+ <button
127
+ type="button"
128
+ onClick={() => void loadChallenge()}
129
+ className="w-full text-xs transition-opacity duration-200 hover:opacity-80"
130
+ style={{ color: 'var(--color-text-muted)' }}
131
+ >
132
+ Refresh challenge
133
+ </button>
134
+
135
+ {result?.status === 'OK' && result.pubkey && (
136
+ <div
137
+ className="rounded-lg px-3 py-2 text-sm"
138
+ style={{
139
+ background: 'var(--color-accent-dim)',
140
+ color: 'var(--color-accent)',
141
+ borderRadius: 'var(--radius-md)',
142
+ }}
143
+ role="status"
144
+ >
145
+ Authenticated as {result.pubkey.slice(0, 8)}…{result.pubkey.slice(-6)}
146
+ </div>
147
+ )}
148
+
149
+ {error && (
150
+ <p className="text-xs" style={{ color: 'var(--color-error, #ef4444)' }} role="alert">
151
+ {error}
152
+ </p>
153
+ )}
154
+ </div>
155
+ );
156
+ }
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+ import { encodeLnurl } from '../../lib/lnurl-utils';
5
+ import { LnurlQR } from './LnurlQR';
6
+
7
+ interface ILnurlPayProps {
8
+ /** Username segment in `/api/lnurlp/[username]`. */
9
+ username: string;
10
+ /** Optional override for the public origin (defaults to `window.location.origin`). */
11
+ baseUrl?: string;
12
+ className?: string;
13
+ }
14
+
15
+ /**
16
+ * Builds an LNURL-pay link for the given username and displays it as a QR code.
17
+ */
18
+ export function LnurlPay({ username, baseUrl, className }: ILnurlPayProps) {
19
+ const payUrl = useMemo(() => {
20
+ const origin =
21
+ baseUrl?.replace(/\/$/, '') ??
22
+ (typeof window !== 'undefined' ? window.location.origin : '');
23
+ return `${origin}/api/lnurlp/${encodeURIComponent(username)}`;
24
+ }, [username, baseUrl]);
25
+
26
+ const lnurl = useMemo(() => encodeLnurl(payUrl), [payUrl]);
27
+
28
+ return (
29
+ <div className={`space-y-3 ${className ?? ''}`}>
30
+ <LnurlQR value={lnurl} label={`LNURL-pay QR for @${username}`} />
31
+ <p className="text-center text-xs" style={{ color: 'var(--color-text-subtle)' }}>
32
+ Wallets scan this code, fetch metadata, then request an invoice from your callback.
33
+ </p>
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,96 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useState } from 'react';
4
+ import { QRCodeSVG } from 'qrcode.react';
5
+ import { decodeLnurl, encodeLnurl } from '../../lib/lnurl-utils';
6
+
7
+ interface ILnurlQRProps {
8
+ /** Raw HTTPS URL or already-encoded LNURL string. */
9
+ value: string;
10
+ /** Human-readable label for assistive tech. */
11
+ label?: string;
12
+ size?: number;
13
+ className?: string;
14
+ }
15
+
16
+ function toLnurlString(value: string): string {
17
+ const trimmed = value.trim();
18
+ if (trimmed.toLowerCase().startsWith('lnurl')) {
19
+ return trimmed.toUpperCase();
20
+ }
21
+ return encodeLnurl(trimmed);
22
+ }
23
+
24
+ /**
25
+ * Renders a bech32-encoded LNURL as a scannable QR code with copy support.
26
+ */
27
+ export function LnurlQR({ value, label = 'LNURL QR code', size = 160, className }: ILnurlQRProps) {
28
+ const [copied, setCopied] = useState(false);
29
+
30
+ const lnurl = useMemo(() => toLnurlString(value), [value]);
31
+ const decodedUrl = useMemo(() => {
32
+ try {
33
+ return decodeLnurl(lnurl);
34
+ } catch {
35
+ return null;
36
+ }
37
+ }, [lnurl]);
38
+
39
+ async function handleCopy() {
40
+ await navigator.clipboard.writeText(lnurl);
41
+ setCopied(true);
42
+ window.setTimeout(() => setCopied(false), 2000);
43
+ }
44
+
45
+ return (
46
+ <div
47
+ className={`flex flex-col gap-3 rounded-xl p-4 ${className ?? ''}`}
48
+ style={{
49
+ background: 'var(--color-surface-1)',
50
+ border: '1px solid var(--color-border)',
51
+ borderRadius: 'var(--radius-lg)',
52
+ }}
53
+ >
54
+ <div
55
+ className="mx-auto flex w-full max-w-[200px] items-center justify-center rounded-lg p-3"
56
+ style={{ background: 'var(--color-surface-2)' }}
57
+ aria-label={label}
58
+ >
59
+ <QRCodeSVG
60
+ value={lnurl}
61
+ size={size}
62
+ level="M"
63
+ bgColor="transparent"
64
+ fgColor="var(--color-text)"
65
+ />
66
+ </div>
67
+
68
+ <p
69
+ className="break-all text-center font-mono text-xs leading-relaxed"
70
+ style={{ color: 'var(--color-text-muted)' }}
71
+ >
72
+ {lnurl.slice(0, 24)}…{lnurl.slice(-12)}
73
+ </p>
74
+
75
+ {decodedUrl && (
76
+ <p className="break-all text-center text-xs" style={{ color: 'var(--color-text-subtle)' }}>
77
+ {decodedUrl}
78
+ </p>
79
+ )}
80
+
81
+ <button
82
+ type="button"
83
+ onClick={handleCopy}
84
+ className="w-full rounded-lg px-4 py-2 text-sm font-medium transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70"
85
+ style={{
86
+ background: 'var(--color-surface-2)',
87
+ color: 'var(--color-text)',
88
+ borderRadius: 'var(--radius-md)',
89
+ }}
90
+ aria-label={copied ? 'LNURL copied' : 'Copy LNURL string'}
91
+ >
92
+ {copied ? 'Copied!' : 'Copy LNURL'}
93
+ </button>
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,141 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import { usePayment } from '../../lib/webln';
5
+ import { LnurlQR } from './LnurlQR';
6
+
7
+ type TWithdrawRequest = {
8
+ tag: string;
9
+ callback: string;
10
+ k1: string;
11
+ minWithdrawable: number;
12
+ maxWithdrawable: number;
13
+ defaultDescription: string;
14
+ lnurl: string;
15
+ };
16
+
17
+ type TWithdrawResult = {
18
+ status: string;
19
+ reason?: string;
20
+ };
21
+
22
+ /**
23
+ * Demo LNURL-withdraw flow: shows withdraw LNURL and simulates wallet payout via WebLN invoice.
24
+ */
25
+ export function LnurlWithdraw({ className }: { className?: string }) {
26
+ const { makeInvoice, isCreatingInvoice } = usePayment();
27
+ const [request, setRequest] = useState<TWithdrawRequest | null>(null);
28
+ const [result, setResult] = useState<TWithdrawResult | null>(null);
29
+ const [error, setError] = useState<string | null>(null);
30
+ const [isWithdrawing, setIsWithdrawing] = useState(false);
31
+
32
+ const loadRequest = useCallback(async () => {
33
+ setError(null);
34
+ setResult(null);
35
+ try {
36
+ const res = await fetch('/api/lnurlw');
37
+ if (!res.ok) throw new Error('Failed to load withdraw request');
38
+ const data = (await res.json()) as TWithdrawRequest;
39
+ setRequest(data);
40
+ } catch (err) {
41
+ setError(err instanceof Error ? err.message : 'Could not load withdraw request');
42
+ }
43
+ }, []);
44
+
45
+ useEffect(() => {
46
+ void loadRequest();
47
+ }, [loadRequest]);
48
+
49
+ async function handleSimulateWithdraw() {
50
+ if (!request) return;
51
+ setIsWithdrawing(true);
52
+ setError(null);
53
+
54
+ try {
55
+ const invoiceRes = await makeInvoice({
56
+ amount: '21',
57
+ defaultMemo: request.defaultDescription,
58
+ });
59
+
60
+ if (!invoiceRes?.paymentRequest) {
61
+ setError('WebLN not available. Open inside Fedi to create a withdraw invoice.');
62
+ return;
63
+ }
64
+
65
+ const params = new URLSearchParams({
66
+ k1: request.k1,
67
+ pr: invoiceRes.paymentRequest,
68
+ tag: 'withdrawLink',
69
+ });
70
+
71
+ const res = await fetch(`${request.callback}?${params.toString()}`);
72
+ const data = (await res.json()) as TWithdrawResult;
73
+ setResult(data);
74
+
75
+ if (data.status !== 'OK') {
76
+ setError(data.reason ?? 'Withdraw failed');
77
+ }
78
+ } catch (err) {
79
+ setError(err instanceof Error ? err.message : 'Withdraw failed');
80
+ } finally {
81
+ setIsWithdrawing(false);
82
+ }
83
+ }
84
+
85
+ return (
86
+ <div className={`space-y-4 ${className ?? ''}`}>
87
+ {request?.lnurl && (
88
+ <LnurlQR value={request.lnurl} label="LNURL-withdraw QR code" />
89
+ )}
90
+
91
+ {request && (
92
+ <p className="text-center text-xs" style={{ color: 'var(--color-text-subtle)' }}>
93
+ Max withdrawable: {Math.floor(request.maxWithdrawable / 1000)} sats (demo cap)
94
+ </p>
95
+ )}
96
+
97
+ <button
98
+ type="button"
99
+ onClick={handleSimulateWithdraw}
100
+ disabled={!request || isWithdrawing || isCreatingInvoice}
101
+ className="w-full rounded-lg px-4 py-3 text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-90 active:opacity-80 disabled:opacity-50"
102
+ style={{
103
+ background: 'var(--color-accent)',
104
+ color: 'var(--color-primary-foreground)',
105
+ borderRadius: 'var(--radius-md)',
106
+ }}
107
+ >
108
+ {isWithdrawing || isCreatingInvoice ? 'Processing…' : 'Simulate wallet withdraw'}
109
+ </button>
110
+
111
+ <button
112
+ type="button"
113
+ onClick={() => void loadRequest()}
114
+ className="w-full text-xs transition-opacity duration-200 hover:opacity-80"
115
+ style={{ color: 'var(--color-text-muted)' }}
116
+ >
117
+ Refresh withdraw link
118
+ </button>
119
+
120
+ {result?.status === 'OK' && (
121
+ <div
122
+ className="rounded-lg px-3 py-2 text-sm"
123
+ style={{
124
+ background: 'var(--color-accent-dim)',
125
+ color: 'var(--color-accent)',
126
+ borderRadius: 'var(--radius-md)',
127
+ }}
128
+ role="status"
129
+ >
130
+ Withdraw callback accepted. Invoice submitted to service.
131
+ </div>
132
+ )}
133
+
134
+ {error && (
135
+ <p className="text-xs" style={{ color: 'var(--color-error, #ef4444)' }} role="alert">
136
+ {error}
137
+ </p>
138
+ )}
139
+ </div>
140
+ );
141
+ }
@@ -0,0 +1,87 @@
1
+ import { schnorr } from '@noble/curves/secp256k1.js';
2
+ import { sha256 } from '@noble/hashes/sha256';
3
+ import type { NostrEvent } from './fedi-types';
4
+
5
+ function hexToBytes(hex: string): Uint8Array {
6
+ const bytes = new Uint8Array(hex.length / 2);
7
+ for (let i = 0; i < hex.length; i += 2) {
8
+ bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
9
+ }
10
+ return bytes;
11
+ }
12
+
13
+ function bytesToHex(bytes: Uint8Array): string {
14
+ return Array.from(bytes)
15
+ .map((b) => b.toString(16).padStart(2, '0'))
16
+ .join('');
17
+ }
18
+
19
+ /**
20
+ * Verifies a compact secp256k1 Schnorr signature of SHA256(k1).
21
+ * Used when wallets sign the raw k1 challenge.
22
+ */
23
+ export function verifyK1SchnorrSignature(
24
+ k1: string,
25
+ sigHex: string,
26
+ pubkeyHex: string,
27
+ ): boolean {
28
+ try {
29
+ const k1Bytes = hexToBytes(k1);
30
+ const msg = sha256(k1Bytes);
31
+ const sig = hexToBytes(sigHex);
32
+ const pubkey = hexToBytes(pubkeyHex);
33
+ return schnorr.verify(sig, msg, pubkey);
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Verifies a NIP-01 signed event used as an LNURL-auth proof.
41
+ * Expects kind 22242 with a `challenge` tag matching k1.
42
+ */
43
+ export function verifyLnurlAuthEvent(k1: string, eventJson: string): {
44
+ valid: boolean;
45
+ pubkey?: string;
46
+ reason?: string;
47
+ } {
48
+ let event: NostrEvent;
49
+ try {
50
+ event = JSON.parse(eventJson) as NostrEvent;
51
+ } catch {
52
+ return { valid: false, reason: 'Invalid event JSON' };
53
+ }
54
+
55
+ if (event.kind !== 22242) {
56
+ return { valid: false, reason: 'Event must be kind 22242' };
57
+ }
58
+
59
+ const challengeTag = event.tags.find((t) => t[0] === 'challenge');
60
+ if (!challengeTag?.[1] || challengeTag[1] !== k1) {
61
+ return { valid: false, reason: 'challenge tag must match k1' };
62
+ }
63
+
64
+ const serialized = JSON.stringify([
65
+ 0,
66
+ event.pubkey,
67
+ event.created_at,
68
+ event.kind,
69
+ event.tags,
70
+ event.content,
71
+ ]);
72
+ const idBytes = sha256(new TextEncoder().encode(serialized));
73
+ const idHex = bytesToHex(idBytes);
74
+
75
+ if (idHex !== event.id) {
76
+ return { valid: false, reason: 'Invalid event id' };
77
+ }
78
+
79
+ try {
80
+ const sigBytes = hexToBytes(event.sig);
81
+ const valid = schnorr.verify(sigBytes, idBytes, hexToBytes(event.pubkey));
82
+ if (!valid) return { valid: false, reason: 'Invalid signature' };
83
+ return { valid: true, pubkey: event.pubkey };
84
+ } catch {
85
+ return { valid: false, reason: 'Signature verification failed' };
86
+ }
87
+ }