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