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,159 @@
|
|
|
1
|
+
import { bech32 } from '@scure/base';
|
|
2
|
+
import type { NostrEvent, UnsignedNostrEvent } from './fedi-types';
|
|
3
|
+
import type { RelayManager } from './nostr/relay';
|
|
4
|
+
|
|
5
|
+
const ZAP_REQUEST_KIND = 9734;
|
|
6
|
+
const ZAP_RECEIPT_KIND = 9735;
|
|
7
|
+
const PROFILE_KIND = 0;
|
|
8
|
+
|
|
9
|
+
export interface IProfileLightning {
|
|
10
|
+
lud16?: string;
|
|
11
|
+
lud06?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ILnurlPayResponse {
|
|
15
|
+
callback: string;
|
|
16
|
+
minSendable: number;
|
|
17
|
+
maxSendable: number;
|
|
18
|
+
allowsNostr?: boolean;
|
|
19
|
+
nostrPubkey?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ILnurlZapInvoice {
|
|
23
|
+
pr: string;
|
|
24
|
+
successAction?: { tag: string; message?: string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Converts `user@domain.com` lud16 to an LNURL-pay HTTPS endpoint. */
|
|
28
|
+
export function lud16ToLnurlPayUrl(lud16: string): string {
|
|
29
|
+
const [name, domain] = lud16.split('@');
|
|
30
|
+
if (!name || !domain) {
|
|
31
|
+
throw new Error('Invalid lud16 address');
|
|
32
|
+
}
|
|
33
|
+
return `https://${domain}/.well-known/lnurlp/${name}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Decodes a bech32 `lnurl…` lud06 string to an HTTPS LNURL endpoint. */
|
|
37
|
+
export function lud06ToHttps(lud06: string): string {
|
|
38
|
+
const { words } = bech32.decode(lud06, 2000);
|
|
39
|
+
const bytes = bech32.fromWords(words);
|
|
40
|
+
return new TextDecoder().decode(bytes);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parses kind-0 profile metadata for Lightning identifiers. */
|
|
44
|
+
export function parseProfileLightning(content: string): IProfileLightning {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(content) as Record<string, unknown>;
|
|
47
|
+
return {
|
|
48
|
+
lud16: typeof parsed.lud16 === 'string' ? parsed.lud16 : undefined,
|
|
49
|
+
lud06: typeof parsed.lud06 === 'string' ? parsed.lud06 : undefined,
|
|
50
|
+
};
|
|
51
|
+
} catch {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Fetches the recipient's kind-0 profile from relays to resolve a zap LNURL endpoint.
|
|
58
|
+
*/
|
|
59
|
+
export async function fetchRecipientLnurl(
|
|
60
|
+
manager: RelayManager,
|
|
61
|
+
relayUrls: string[],
|
|
62
|
+
recipientPubkey: string,
|
|
63
|
+
): Promise<string | null> {
|
|
64
|
+
const profiles = await manager.query(relayUrls, {
|
|
65
|
+
kinds: [PROFILE_KIND],
|
|
66
|
+
authors: [recipientPubkey],
|
|
67
|
+
limit: 1,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const profile = profiles.sort((a, b) => b.created_at - a.created_at)[0];
|
|
71
|
+
if (!profile) return null;
|
|
72
|
+
|
|
73
|
+
const { lud16, lud06 } = parseProfileLightning(profile.content);
|
|
74
|
+
if (lud16) return lud16ToLnurlPayUrl(lud16);
|
|
75
|
+
if (lud06) return lud06ToHttps(lud06);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Builds an unsigned NIP-57 zap request (kind 9734). */
|
|
80
|
+
export function buildZapRequest(params: {
|
|
81
|
+
noteId: string;
|
|
82
|
+
notePubkey: string;
|
|
83
|
+
relayUrls: string[];
|
|
84
|
+
amountMsats: number;
|
|
85
|
+
content?: string;
|
|
86
|
+
}): UnsignedNostrEvent {
|
|
87
|
+
return {
|
|
88
|
+
kind: ZAP_REQUEST_KIND,
|
|
89
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
90
|
+
tags: [
|
|
91
|
+
['e', params.noteId],
|
|
92
|
+
['p', params.notePubkey],
|
|
93
|
+
...params.relayUrls.map((url) => ['relays', url] as [string, string]),
|
|
94
|
+
['amount', String(params.amountMsats)],
|
|
95
|
+
],
|
|
96
|
+
content: params.content ?? '',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Fetches LNURL-pay metadata and validates Nostr zap support. */
|
|
101
|
+
export async function fetchLnurlPayMetadata(lnurl: string): Promise<ILnurlPayResponse> {
|
|
102
|
+
const res = await fetch(lnurl);
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
throw new Error(`LNURL lookup failed (${res.status})`);
|
|
105
|
+
}
|
|
106
|
+
const data = (await res.json()) as ILnurlPayResponse;
|
|
107
|
+
if (!data.allowsNostr) {
|
|
108
|
+
throw new Error('This Lightning address does not support Nostr zaps (NIP-57)');
|
|
109
|
+
}
|
|
110
|
+
return data;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Requests a BOLT11 invoice from an LNURL-pay callback using a signed zap request.
|
|
115
|
+
*/
|
|
116
|
+
export async function requestZapInvoice(
|
|
117
|
+
callback: string,
|
|
118
|
+
amountMsats: number,
|
|
119
|
+
signedZapRequest: NostrEvent,
|
|
120
|
+
): Promise<string> {
|
|
121
|
+
const url = new URL(callback);
|
|
122
|
+
url.searchParams.set('amount', String(amountMsats));
|
|
123
|
+
url.searchParams.set('nostr', JSON.stringify(signedZapRequest));
|
|
124
|
+
|
|
125
|
+
const res = await fetch(url.toString());
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
throw new Error(`Zap invoice request failed (${res.status})`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const data = (await res.json()) as ILnurlZapInvoice;
|
|
131
|
+
if (!data.pr) {
|
|
132
|
+
throw new Error('LNURL response did not include an invoice');
|
|
133
|
+
}
|
|
134
|
+
return data.pr;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Builds a signed zap receipt (kind 9735) after payment. */
|
|
138
|
+
export function buildZapReceipt(params: {
|
|
139
|
+
noteId: string;
|
|
140
|
+
notePubkey: string;
|
|
141
|
+
payerPubkey: string;
|
|
142
|
+
bolt11: string;
|
|
143
|
+
preimage: string;
|
|
144
|
+
zapRequest: NostrEvent;
|
|
145
|
+
}): UnsignedNostrEvent {
|
|
146
|
+
return {
|
|
147
|
+
kind: ZAP_RECEIPT_KIND,
|
|
148
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
149
|
+
tags: [
|
|
150
|
+
['p', params.notePubkey],
|
|
151
|
+
['e', params.noteId],
|
|
152
|
+
['P', params.payerPubkey],
|
|
153
|
+
['bolt11', params.bolt11],
|
|
154
|
+
['preimage', params.preimage],
|
|
155
|
+
['description', JSON.stringify(params.zapRequest)],
|
|
156
|
+
],
|
|
157
|
+
content: '',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nostr-feed",
|
|
3
|
+
"description": "Read and post to a Nostr relay",
|
|
4
|
+
"dependencies": ["nostr-tools", "react-markdown"],
|
|
5
|
+
"devDependencies": [],
|
|
6
|
+
"files": [
|
|
7
|
+
{ "src": "lib/nostr/relay.ts", "dest": "lib/nostr/relay.ts", "merge": "add" },
|
|
8
|
+
{ "src": "lib/nostr-zap.ts", "dest": "lib/nostr-zap.ts", "merge": "add" },
|
|
9
|
+
{ "src": "components/nostr/NostrFeedProvider.tsx", "dest": "components/nostr/NostrFeedProvider.tsx", "merge": "add" },
|
|
10
|
+
{ "src": "components/nostr/NoteFeed.tsx", "dest": "components/nostr/NoteFeed.tsx", "merge": "add" },
|
|
11
|
+
{ "src": "components/nostr/NoteCard.tsx", "dest": "components/nostr/NoteCard.tsx", "merge": "add" },
|
|
12
|
+
{ "src": "components/nostr/PublishNote.tsx", "dest": "components/nostr/PublishNote.tsx", "merge": "add" },
|
|
13
|
+
{ "src": "components/nostr/ZapButton.tsx", "dest": "components/nostr/ZapButton.tsx", "merge": "add" },
|
|
14
|
+
{ "src": "app/demo/nostr-feed/page.tsx", "dest": "app/demo/nostr-feed/page.tsx", "merge": "add" },
|
|
15
|
+
{ "src": "app/demo/nostr-feed/NostrFeedDemoClient.tsx", "dest": "app/demo/nostr-feed/NostrFeedDemoClient.tsx", "merge": "add" }
|
|
16
|
+
],
|
|
17
|
+
"envVars": [
|
|
18
|
+
{
|
|
19
|
+
"key": "NEXT_PUBLIC_NOSTR_RELAY",
|
|
20
|
+
"description": "Nostr relay WebSocket URL(s), comma-separated",
|
|
21
|
+
"example": "wss://relay.damus.io,wss://relay.nostr.band",
|
|
22
|
+
"required": false
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { IdentityBadge } from '../../../components/nostr/IdentityBadge';
|
|
6
|
+
import { NostrLogin } from '../../../components/nostr/NostrLogin';
|
|
7
|
+
import { SignedMessage } from '../../../components/nostr/SignedMessage';
|
|
8
|
+
|
|
9
|
+
export default function NostrDemoPage() {
|
|
10
|
+
const [howItWorksOpen, setHowItWorksOpen] = useState(false);
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="min-h-dvh bg-[var(--color-bg)] font-[family-name:var(--font-body)] text-[var(--color-text)]">
|
|
14
|
+
<div
|
|
15
|
+
className="mx-auto w-full max-w-[390px] px-4 pt-6"
|
|
16
|
+
style={{ paddingBottom: 'max(5rem, env(safe-area-inset-bottom, 20px))' }}
|
|
17
|
+
>
|
|
18
|
+
<Link
|
|
19
|
+
href="/demo"
|
|
20
|
+
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"
|
|
21
|
+
>
|
|
22
|
+
← back
|
|
23
|
+
</Link>
|
|
24
|
+
|
|
25
|
+
<header className="mb-8 space-y-2">
|
|
26
|
+
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold leading-tight text-[var(--color-text)]">
|
|
27
|
+
Nostr Identity
|
|
28
|
+
</h1>
|
|
29
|
+
<p className="max-w-[75ch] text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
30
|
+
Connect with NIP-07 and sign events with your Nostr key. Inside Fedi,{' '}
|
|
31
|
+
<code className="font-mono text-xs">window.nostr</code> is injected automatically. Your
|
|
32
|
+
private key never leaves the app.
|
|
33
|
+
</p>
|
|
34
|
+
</header>
|
|
35
|
+
|
|
36
|
+
<div className="space-y-8">
|
|
37
|
+
<section className="space-y-4">
|
|
38
|
+
<div className="max-w-[75ch] space-y-1.5">
|
|
39
|
+
<h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
|
|
40
|
+
Identity badge
|
|
41
|
+
</h2>
|
|
42
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
43
|
+
Shows your connected pubkey as a colored avatar and truncated npub. The color is
|
|
44
|
+
derived deterministically from your key. The same pubkey always gets the same color.
|
|
45
|
+
</p>
|
|
46
|
+
</div>
|
|
47
|
+
<IdentityBadge />
|
|
48
|
+
</section>
|
|
49
|
+
|
|
50
|
+
<section className="space-y-4">
|
|
51
|
+
<div className="max-w-[75ch] space-y-1.5">
|
|
52
|
+
<h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
|
|
53
|
+
Login flow
|
|
54
|
+
</h2>
|
|
55
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
56
|
+
Drop-in auth component for any page. Calls{' '}
|
|
57
|
+
<code className="font-mono text-xs">getPublicKey()</code>. No username, no password,
|
|
58
|
+
no account signup. Your pubkey <em>is</em> your account.
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
<NostrLogin />
|
|
62
|
+
</section>
|
|
63
|
+
|
|
64
|
+
<section className="space-y-4">
|
|
65
|
+
<div className="max-w-[75ch] space-y-1.5">
|
|
66
|
+
<h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
|
|
67
|
+
Signed message
|
|
68
|
+
</h2>
|
|
69
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
70
|
+
Signs a kind-1 text note via{' '}
|
|
71
|
+
<code className="font-mono text-xs">signEvent()</code>. The returned JSON includes a
|
|
72
|
+
Schnorr signature anyone can verify.
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
<SignedMessage />
|
|
76
|
+
</section>
|
|
77
|
+
|
|
78
|
+
<section className="space-y-3">
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={() => setHowItWorksOpen((open) => !open)}
|
|
82
|
+
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"
|
|
83
|
+
style={{
|
|
84
|
+
background: 'var(--color-surface-1)',
|
|
85
|
+
border: '1px solid var(--color-border)',
|
|
86
|
+
color: 'var(--color-text)',
|
|
87
|
+
borderRadius: 'var(--radius-lg)',
|
|
88
|
+
}}
|
|
89
|
+
aria-expanded={howItWorksOpen}
|
|
90
|
+
aria-controls="nostr-how-it-works"
|
|
91
|
+
>
|
|
92
|
+
How Nostr identity works
|
|
93
|
+
<span aria-hidden>{howItWorksOpen ? '−' : '+'}</span>
|
|
94
|
+
</button>
|
|
95
|
+
|
|
96
|
+
{howItWorksOpen && (
|
|
97
|
+
<div
|
|
98
|
+
id="nostr-how-it-works"
|
|
99
|
+
className="space-y-3 rounded-lg px-4 py-3 text-sm leading-[1.65]"
|
|
100
|
+
style={{
|
|
101
|
+
background: 'var(--color-surface-1)',
|
|
102
|
+
border: '1px solid var(--color-border)',
|
|
103
|
+
color: 'var(--color-text-muted)',
|
|
104
|
+
borderRadius: 'var(--radius-lg)',
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
<p>
|
|
108
|
+
<strong className="text-[var(--color-text)]">Nostr</strong> is a decentralized
|
|
109
|
+
protocol where identity is a cryptographic keypair. Your public key (shown as{' '}
|
|
110
|
+
<code className="font-mono text-xs">npub1…</code>) is a stable, unique identifier.
|
|
111
|
+
</p>
|
|
112
|
+
<p>
|
|
113
|
+
Fedi injects a NIP-07 provider at{' '}
|
|
114
|
+
<code className="font-mono text-xs">window.nostr</code> before your page loads. The
|
|
115
|
+
mini app can read your pubkey and request signatures, but never sees your private
|
|
116
|
+
key.
|
|
117
|
+
</p>
|
|
118
|
+
<p>
|
|
119
|
+
<code className="font-mono text-xs">getPublicKey()</code> is safe for login flows.
|
|
120
|
+
It only reads your identity. To prove you control a key, call{' '}
|
|
121
|
+
<code className="font-mono text-xs">signEvent()</code> and verify the signature
|
|
122
|
+
server-side (NIP-98 HTTP auth uses kind 27235 for this).
|
|
123
|
+
</p>
|
|
124
|
+
<p>
|
|
125
|
+
Outside Fedi, <code className="font-mono text-xs">window.nostr</code> is undefined.
|
|
126
|
+
In development, this project uses a mock provider with a well-known test keypair so
|
|
127
|
+
you can build and test locally.
|
|
128
|
+
</p>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
</section>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useIdentity } from '../../lib/nostr';
|
|
5
|
+
import { pubkeyToHsl, pubkeyToNpub, truncateNpub } from '../../lib/nostr-utils';
|
|
6
|
+
|
|
7
|
+
export function IdentityBadge() {
|
|
8
|
+
const { pubkey, npub, displayNpub, getPublicKey, isConnecting } = useIdentity();
|
|
9
|
+
const [localPubkey, setLocalPubkey] = useState<string | null>(null);
|
|
10
|
+
const [isConnectingLocal, setIsConnectingLocal] = useState(false);
|
|
11
|
+
const [copied, setCopied] = useState(false);
|
|
12
|
+
|
|
13
|
+
const activePubkey = pubkey ?? localPubkey;
|
|
14
|
+
const activeNpub = npub ?? (localPubkey ? pubkeyToNpub(localPubkey) : null);
|
|
15
|
+
const activeDisplayNpub = displayNpub ?? (activeNpub ? truncateNpub(activeNpub) : null);
|
|
16
|
+
|
|
17
|
+
async function handleConnect() {
|
|
18
|
+
setIsConnectingLocal(true);
|
|
19
|
+
try {
|
|
20
|
+
const pk = await getPublicKey();
|
|
21
|
+
if (pk) setLocalPubkey(pk);
|
|
22
|
+
} finally {
|
|
23
|
+
setIsConnectingLocal(false);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function handleCopyNpub() {
|
|
28
|
+
if (!activeNpub) return;
|
|
29
|
+
await navigator.clipboard.writeText(activeNpub);
|
|
30
|
+
setCopied(true);
|
|
31
|
+
window.setTimeout(() => setCopied(false), 2000);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!activePubkey) {
|
|
35
|
+
return (
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={handleConnect}
|
|
39
|
+
disabled={isConnecting || isConnectingLocal}
|
|
40
|
+
className="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70 disabled:cursor-not-allowed disabled:opacity-40"
|
|
41
|
+
style={{
|
|
42
|
+
background: 'var(--color-accent)',
|
|
43
|
+
color: 'var(--color-primary-foreground)',
|
|
44
|
+
borderRadius: 'var(--radius-md)',
|
|
45
|
+
}}
|
|
46
|
+
aria-label="Connect Nostr identity"
|
|
47
|
+
>
|
|
48
|
+
{isConnecting || isConnectingLocal ? 'Connecting…' : 'Connect'}
|
|
49
|
+
</button>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { h, s, l } = pubkeyToHsl(activePubkey);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
className="inline-flex items-center gap-3 rounded-xl px-3 py-2"
|
|
58
|
+
style={{
|
|
59
|
+
background: 'var(--color-surface-1)',
|
|
60
|
+
border: '1px solid var(--color-border)',
|
|
61
|
+
borderRadius: 'var(--radius-lg)',
|
|
62
|
+
}}
|
|
63
|
+
aria-label={`Connected as ${activeDisplayNpub}`}
|
|
64
|
+
>
|
|
65
|
+
<span
|
|
66
|
+
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold select-none"
|
|
67
|
+
style={{
|
|
68
|
+
background: `hsl(${h}, ${s}%, ${l}%)`,
|
|
69
|
+
color: 'var(--color-primary-foreground)',
|
|
70
|
+
}}
|
|
71
|
+
aria-hidden
|
|
72
|
+
>
|
|
73
|
+
{activePubkey.slice(0, 1).toUpperCase()}
|
|
74
|
+
</span>
|
|
75
|
+
|
|
76
|
+
<div className="min-w-0 flex flex-col gap-0.5">
|
|
77
|
+
<div className="flex items-center gap-2">
|
|
78
|
+
<span className="truncate font-mono text-sm text-[var(--color-text)]">
|
|
79
|
+
{activeDisplayNpub}
|
|
80
|
+
</span>
|
|
81
|
+
<span
|
|
82
|
+
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
|
|
83
|
+
style={{
|
|
84
|
+
background: 'var(--color-accent-dim)',
|
|
85
|
+
color: 'var(--color-accent)',
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
Verified
|
|
89
|
+
</span>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
onClick={handleCopyNpub}
|
|
96
|
+
className="shrink-0 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
|
|
97
|
+
style={{
|
|
98
|
+
background: 'var(--color-surface-2, var(--color-bg))',
|
|
99
|
+
border: '1px solid var(--color-border)',
|
|
100
|
+
color: 'var(--color-text-muted)',
|
|
101
|
+
borderRadius: 'var(--radius-md)',
|
|
102
|
+
}}
|
|
103
|
+
aria-label={copied ? 'npub copied' : 'Copy npub to clipboard'}
|
|
104
|
+
>
|
|
105
|
+
{copied ? 'Copied' : 'Copy npub'}
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useIdentity } from '../../lib/nostr';
|
|
5
|
+
import { pubkeyToHsl, pubkeyToNpub, truncateNpub } from '../../lib/nostr-utils';
|
|
6
|
+
|
|
7
|
+
interface INostrLoginProps {
|
|
8
|
+
/** Called after a successful login with the user's pubkey and npub. */
|
|
9
|
+
onLogin?: (pubkey: string, npub: string) => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Drop-in Nostr login component. Calls `getPublicKey()` on user action and shows
|
|
15
|
+
* the connected identity on success. No passwords, no account creation.
|
|
16
|
+
*/
|
|
17
|
+
export function NostrLogin({ onLogin, className }: INostrLoginProps) {
|
|
18
|
+
const { pubkey, npub, getPublicKey } = useIdentity();
|
|
19
|
+
const [localPubkey, setLocalPubkey] = useState<string | null>(null);
|
|
20
|
+
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
|
21
|
+
const [loginError, setLoginError] = useState<string | null>(null);
|
|
22
|
+
|
|
23
|
+
const activePubkey = pubkey ?? localPubkey;
|
|
24
|
+
const activeNpub = npub ?? (localPubkey ? pubkeyToNpub(localPubkey) : null);
|
|
25
|
+
|
|
26
|
+
async function handleLogin() {
|
|
27
|
+
setLoginError(null);
|
|
28
|
+
setIsLoggingIn(true);
|
|
29
|
+
try {
|
|
30
|
+
const pk = await getPublicKey();
|
|
31
|
+
if (!pk) {
|
|
32
|
+
setLoginError('Nostr provider not available. Open this app inside Fedi.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
setLocalPubkey(pk);
|
|
36
|
+
const encodedNpub = pubkeyToNpub(pk);
|
|
37
|
+
onLogin?.(pk, encodedNpub);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
setLoginError(err instanceof Error ? err.message : 'Login failed');
|
|
40
|
+
} finally {
|
|
41
|
+
setIsLoggingIn(false);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (activePubkey && activeNpub) {
|
|
46
|
+
const { h, s, l } = pubkeyToHsl(activePubkey);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
className={`rounded-xl px-4 py-4 ${className ?? ''}`}
|
|
51
|
+
style={{
|
|
52
|
+
background: 'var(--color-accent-dim)',
|
|
53
|
+
border: '1px solid var(--color-border)',
|
|
54
|
+
borderRadius: 'var(--radius-lg)',
|
|
55
|
+
}}
|
|
56
|
+
role="status"
|
|
57
|
+
aria-label="Logged in with Nostr identity"
|
|
58
|
+
>
|
|
59
|
+
<p className="mb-3 text-sm font-semibold text-[var(--color-accent)]">Logged in</p>
|
|
60
|
+
<div className="flex items-center gap-3">
|
|
61
|
+
<span
|
|
62
|
+
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold"
|
|
63
|
+
style={{
|
|
64
|
+
background: `hsl(${h}, ${s}%, ${l}%)`,
|
|
65
|
+
color: 'var(--color-primary-foreground)',
|
|
66
|
+
}}
|
|
67
|
+
aria-hidden
|
|
68
|
+
>
|
|
69
|
+
{activePubkey.slice(0, 1).toUpperCase()}
|
|
70
|
+
</span>
|
|
71
|
+
<div className="min-w-0">
|
|
72
|
+
<p className="truncate font-mono text-sm text-[var(--color-text)]">
|
|
73
|
+
{truncateNpub(activeNpub)}
|
|
74
|
+
</p>
|
|
75
|
+
<p className="text-xs text-[var(--color-text-muted)]">
|
|
76
|
+
Your Fedi Nostr key, stable across every mini app.
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className={`flex flex-col gap-2 ${className ?? ''}`}>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={handleLogin}
|
|
89
|
+
disabled={isLoggingIn}
|
|
90
|
+
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-80 active:opacity-70 disabled:cursor-not-allowed disabled:opacity-40"
|
|
91
|
+
style={{
|
|
92
|
+
background: 'var(--color-accent)',
|
|
93
|
+
color: 'var(--color-primary-foreground)',
|
|
94
|
+
borderRadius: 'var(--radius-md)',
|
|
95
|
+
}}
|
|
96
|
+
aria-label="Login with Fedi Nostr identity"
|
|
97
|
+
>
|
|
98
|
+
{isLoggingIn ? 'Connecting…' : 'Login with Fedi'}
|
|
99
|
+
</button>
|
|
100
|
+
{loginError && (
|
|
101
|
+
<p className="text-xs text-[var(--color-error,#ef4444)]" role="alert">
|
|
102
|
+
{loginError}
|
|
103
|
+
</p>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useIdentityFlow } from '../../hooks/useIdentityFlow';
|
|
5
|
+
|
|
6
|
+
interface ISignedMessageProps {
|
|
7
|
+
defaultMessage?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function SignedMessage({ defaultMessage = 'Hello from create-fedi-app!' }: ISignedMessageProps) {
|
|
11
|
+
const { isConnected, isConnecting, signTextNote, lastSignedEvent, signError } = useIdentityFlow();
|
|
12
|
+
const [message, setMessage] = useState(defaultMessage);
|
|
13
|
+
const [isSigning, setIsSigning] = useState(false);
|
|
14
|
+
|
|
15
|
+
async function handleSign() {
|
|
16
|
+
if (!message.trim()) return;
|
|
17
|
+
setIsSigning(true);
|
|
18
|
+
try {
|
|
19
|
+
await signTextNote(message.trim());
|
|
20
|
+
} finally {
|
|
21
|
+
setIsSigning(false);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!isConnected) {
|
|
26
|
+
return (
|
|
27
|
+
<p className="text-sm text-[var(--color-text-subtle)]">
|
|
28
|
+
Connect your identity above to sign a message.
|
|
29
|
+
</p>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex flex-col gap-4">
|
|
35
|
+
<p className="max-w-[75ch] text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
36
|
+
Signing creates a cryptographic proof that you hold the private key for your pubkey, without
|
|
37
|
+
ever revealing the key itself. Anyone can verify the signature against your public identity.
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
<label className="flex flex-col gap-1.5">
|
|
41
|
+
<span className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-subtle)]">
|
|
42
|
+
Message to sign
|
|
43
|
+
</span>
|
|
44
|
+
<textarea
|
|
45
|
+
value={message}
|
|
46
|
+
onChange={(e) => setMessage(e.target.value)}
|
|
47
|
+
rows={3}
|
|
48
|
+
className="w-full resize-none rounded-lg px-3 py-2 text-sm"
|
|
49
|
+
style={{
|
|
50
|
+
background: 'var(--color-surface-1)',
|
|
51
|
+
border: '1px solid var(--color-border)',
|
|
52
|
+
color: 'var(--color-text)',
|
|
53
|
+
borderRadius: 'var(--radius-md)',
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
</label>
|
|
57
|
+
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
onClick={handleSign}
|
|
61
|
+
disabled={!message.trim() || isSigning || isConnecting}
|
|
62
|
+
className="self-start rounded-lg px-4 py-2 text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70 disabled:cursor-not-allowed disabled:opacity-40"
|
|
63
|
+
style={{
|
|
64
|
+
background: 'var(--color-accent)',
|
|
65
|
+
color: 'var(--color-primary-foreground)',
|
|
66
|
+
borderRadius: 'var(--radius-md)',
|
|
67
|
+
}}
|
|
68
|
+
aria-label="Sign message with Nostr key"
|
|
69
|
+
>
|
|
70
|
+
{isSigning ? 'Signing…' : 'Sign'}
|
|
71
|
+
</button>
|
|
72
|
+
|
|
73
|
+
{signError && (
|
|
74
|
+
<p className="text-xs text-[var(--color-error,#ef4444)]" role="alert">
|
|
75
|
+
{signError.message}
|
|
76
|
+
</p>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{lastSignedEvent && (
|
|
80
|
+
<div className="space-y-2">
|
|
81
|
+
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-subtle)]">
|
|
82
|
+
Signed Nostr event
|
|
83
|
+
</p>
|
|
84
|
+
<pre
|
|
85
|
+
className="overflow-auto rounded-lg p-3 font-mono text-xs leading-relaxed"
|
|
86
|
+
style={{
|
|
87
|
+
background: 'var(--color-surface-1)',
|
|
88
|
+
border: '1px solid var(--color-border)',
|
|
89
|
+
color: 'var(--color-text-muted)',
|
|
90
|
+
borderRadius: 'var(--radius-md)',
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
{JSON.stringify(lastSignedEvent, null, 2)}
|
|
94
|
+
</pre>
|
|
95
|
+
<p className="text-xs leading-[1.65] text-[var(--color-text-subtle)]">
|
|
96
|
+
The <code className="font-mono">sig</code> field is a Schnorr signature over the event
|
|
97
|
+
hash. The <code className="font-mono">pubkey</code> identifies who signed it.
|
|
98
|
+
</p>
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|