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,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'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
|
+
}
|