create-fedi-app 0.1.2 → 0.1.3

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 (46) hide show
  1. package/README.md +161 -0
  2. package/dist/index.js +58 -9
  3. package/dist/templates/base/.env.example +13 -0
  4. package/dist/templates/base/app/page.tsx +97 -6
  5. package/dist/templates/base/components/InvoiceQr.tsx +31 -0
  6. package/dist/templates/base/components/PaymentCallout.tsx +55 -0
  7. package/dist/templates/base/gitignore +46 -0
  8. package/dist/templates/base/hooks/useIsMounted.ts +14 -0
  9. package/dist/templates/base/lib/__tests__/fedi.test.ts +23 -0
  10. package/dist/templates/base/lib/demo-routes.ts +1 -1
  11. package/dist/templates/base/lib/fedi-types.ts +10 -0
  12. package/dist/templates/base/lib/fedi.ts +6 -0
  13. package/dist/templates/base/lib/lightning/bolt11.ts +15 -0
  14. package/dist/templates/base/lib/lightning/lnurl-pay.ts +97 -0
  15. package/dist/templates/base/lib/lightning/preimage-verify.ts +31 -0
  16. package/dist/templates/base/lib/nostr/hooks.ts +12 -3
  17. package/dist/templates/base/lib/nostr/provider.tsx +44 -25
  18. package/dist/templates/base/lib/payment-config.ts +54 -0
  19. package/dist/templates/base/lib/webln/hooks.ts +15 -5
  20. package/dist/templates/base/lib/webln/provider.tsx +41 -17
  21. package/dist/templates/base/package.json +1 -0
  22. package/dist/templates/modules/ai-chat-gated/components/ai/PaymentGate.tsx +1 -28
  23. package/dist/templates/modules/ai-rules/rules/OVERVIEW.md +2 -1
  24. package/dist/templates/modules/ai-rules/rules/fedi-api.md +20 -0
  25. package/dist/templates/modules/ecash-balance/app/demo/ecash/page.tsx +23 -6
  26. package/dist/templates/modules/ecash-balance/components/fedi/BalanceDisplay.tsx +33 -8
  27. package/dist/templates/modules/ecash-balance/components/fedi/InstallMiniAppButton.tsx +28 -19
  28. package/dist/templates/modules/ecash-balance/components/fedi/ManageMiniAppsPermissionHint.tsx +48 -0
  29. package/dist/templates/modules/ecash-balance/hooks/useFediBalance.ts +56 -29
  30. package/dist/templates/modules/ecash-balance/module.json +1 -0
  31. package/dist/templates/modules/lnurl/app/demo/lnurl/page.tsx +17 -9
  32. package/dist/templates/modules/lnurl/components/lnurl/LnurlErrorBoundary.tsx +49 -0
  33. package/dist/templates/modules/lnurl/components/lnurl/LnurlPay.tsx +64 -13
  34. package/dist/templates/modules/lnurl/components/lnurl/LnurlQR.tsx +36 -2
  35. package/dist/templates/modules/lnurl/components/lnurl/LnurlWithdraw.tsx +2 -1
  36. package/dist/templates/modules/lnurl/module.json +1 -0
  37. package/dist/templates/modules/payment-gated-content/app/api/payment-gate/invoice/route.ts +7 -2
  38. package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/article/page.tsx +66 -16
  39. package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/page.tsx +13 -14
  40. package/dist/templates/modules/payment-gated-content/components/payment-gated/PayGate.tsx +45 -70
  41. package/dist/templates/modules/payment-gated-content/lib/payment-gate.ts +20 -15
  42. package/dist/templates/modules/payment-gated-content/lib/payment-store.ts +5 -22
  43. package/dist/templates/modules/webln-payments/app/demo/webln/page.tsx +13 -7
  44. package/dist/templates/modules/webln-payments/components/webln/InvoiceCard.tsx +29 -82
  45. package/dist/templates/modules/webln-payments/tests/e2e/webln-payment.spec.ts +2 -0
  46. package/package.json +10 -1
@@ -7,6 +7,12 @@ export function getFediInternalVersion(): 0 | 1 | 2 | null {
7
7
  return window.fediInternal.version as 0 | 1 | 2;
8
8
  }
9
9
 
10
+ /** True when Fedi rejected a call due to a missing or denied mini-app permission. */
11
+ export function isFediPermissionError(err: unknown): boolean {
12
+ const message = err instanceof Error ? err.message : String(err);
13
+ return /permission denied|missing the following permissions/i.test(message);
14
+ }
15
+
10
16
  export function formatSats(sats: number): string {
11
17
  if (sats >= 100_000) return `${(sats / 100_000_000).toFixed(6)} BTC`;
12
18
  return `${sats.toLocaleString()} sats`;
@@ -0,0 +1,15 @@
1
+ import { decode } from 'light-bolt11-decoder';
2
+
3
+ /**
4
+ * Extracts the payment hash (hex) from a BOLT11 invoice string.
5
+ */
6
+ export function getPaymentHashFromInvoice(invoice: string): string {
7
+ const decoded = decode(invoice);
8
+ const section = decoded.sections.find((s) => s.name === 'payment_hash');
9
+
10
+ if (!section || typeof section.value !== 'string') {
11
+ throw new Error('Invoice missing payment_hash');
12
+ }
13
+
14
+ return section.value;
15
+ }
@@ -0,0 +1,97 @@
1
+ import { bech32 } from '@scure/base';
2
+
3
+ export interface ILnurlPayMetadata {
4
+ callback: string;
5
+ minSendable: number;
6
+ maxSendable: number;
7
+ metadata?: string;
8
+ tag?: string;
9
+ }
10
+
11
+ export interface ILnurlPayInvoiceResponse {
12
+ pr: string;
13
+ routes?: unknown[];
14
+ successAction?: { tag: string; message?: string };
15
+ }
16
+
17
+ /** Decodes a bech32 LNURL string to the underlying HTTPS endpoint. */
18
+ export function decodeLnurlPayAddress(lnurl: string): string {
19
+ const normalized = lnurl.trim().toLowerCase();
20
+ if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
21
+ return lnurl.trim();
22
+ }
23
+ const { words } = bech32.decode(normalized, 2000);
24
+ const bytes = bech32.fromWords(words);
25
+ return new TextDecoder().decode(bytes);
26
+ }
27
+
28
+ /** Resolves an LNURL-pay HTTPS endpoint from a bech32 or raw URL string. */
29
+ export function resolveLnurlPayEndpoint(address: string): string {
30
+ const trimmed = address.trim();
31
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
32
+ return trimmed;
33
+ }
34
+ return decodeLnurlPayAddress(trimmed);
35
+ }
36
+
37
+ /** Fetches LNURL-pay metadata (LUD-06). */
38
+ export async function fetchLnurlPayMetadata(endpoint: string): Promise<ILnurlPayMetadata> {
39
+ const res = await fetch(endpoint);
40
+ if (!res.ok) {
41
+ throw new Error(`LNURL metadata lookup failed (${res.status})`);
42
+ }
43
+
44
+ const data = (await res.json()) as ILnurlPayMetadata;
45
+ if (!data.callback || !data.minSendable || !data.maxSendable) {
46
+ throw new Error('Invalid LNURL-pay metadata response');
47
+ }
48
+
49
+ return data;
50
+ }
51
+
52
+ /** Requests a BOLT11 invoice from an LNURL-pay callback. */
53
+ export async function requestLnurlPayInvoice(
54
+ callback: string,
55
+ amountMsats: number,
56
+ comment?: string,
57
+ ): Promise<string> {
58
+ const url = new URL(callback);
59
+ url.searchParams.set('amount', String(amountMsats));
60
+ if (comment) {
61
+ url.searchParams.set('comment', comment);
62
+ }
63
+
64
+ const res = await fetch(url.toString());
65
+ if (!res.ok) {
66
+ throw new Error(`LNURL invoice request failed (${res.status})`);
67
+ }
68
+
69
+ const data = (await res.json()) as ILnurlPayInvoiceResponse;
70
+ if (!data.pr) {
71
+ throw new Error('LNURL response did not include an invoice');
72
+ }
73
+
74
+ return data.pr;
75
+ }
76
+
77
+ /**
78
+ * Creates a real BOLT11 invoice via an LNURL-pay address.
79
+ */
80
+ export async function createLnurlPayInvoice(
81
+ lnurlAddress: string,
82
+ amountSats: number,
83
+ comment?: string,
84
+ ): Promise<{ invoice: string; amountMsats: number }> {
85
+ const endpoint = resolveLnurlPayEndpoint(lnurlAddress);
86
+ const metadata = await fetchLnurlPayMetadata(endpoint);
87
+ const amountMsats = amountSats * 1000;
88
+
89
+ if (amountMsats < metadata.minSendable || amountMsats > metadata.maxSendable) {
90
+ throw new Error(
91
+ `Amount ${amountSats} sats is outside LNURL limits (${Math.floor(metadata.minSendable / 1000)}–${Math.floor(metadata.maxSendable / 1000)} sats)`,
92
+ );
93
+ }
94
+
95
+ const invoice = await requestLnurlPayInvoice(metadata.callback, amountMsats, comment);
96
+ return { invoice, amountMsats };
97
+ }
@@ -0,0 +1,31 @@
1
+ import { sha256 } from '@noble/hashes/sha256';
2
+
3
+ function hexToBytes(hex: string): Uint8Array {
4
+ const normalized = hex.replace(/^0x/i, '');
5
+ const bytes = new Uint8Array(normalized.length / 2);
6
+ for (let i = 0; i < normalized.length; i += 2) {
7
+ bytes[i / 2] = Number.parseInt(normalized.slice(i, i + 2), 16);
8
+ }
9
+ return bytes;
10
+ }
11
+
12
+ function bytesToHex(bytes: Uint8Array): string {
13
+ return Array.from(bytes)
14
+ .map((b) => b.toString(16).padStart(2, '0'))
15
+ .join('');
16
+ }
17
+
18
+ /**
19
+ * Verifies that a Lightning payment preimage matches the expected payment hash.
20
+ */
21
+ export function verifyPreimage(preimage: string, paymentHashHex: string): boolean {
22
+ try {
23
+ const preimageBytes = hexToBytes(preimage);
24
+ const hash = sha256(preimageBytes);
25
+ const expected = hexToBytes(paymentHashHex);
26
+ if (hash.length !== expected.length) return false;
27
+ return bytesToHex(hash) === bytesToHex(expected);
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
@@ -21,27 +21,36 @@ export function useNostr() {
21
21
  provider: ctx.provider,
22
22
  pubkey: ctx.pubkey,
23
23
  npub,
24
+ isAvailable: ctx.isAvailable,
24
25
  isLoading: ctx.isLoading,
25
26
  error: ctx.error,
26
- isConnected: ctx.provider !== null,
27
+ isConnected: ctx.pubkey !== null,
28
+ connect: ctx.connect,
27
29
  };
28
30
  }
29
31
 
30
32
  export function useIdentity() {
31
- const { provider, pubkey, npub } = useNostr();
33
+ const { provider, pubkey, npub, connect } = useNostr();
32
34
  const [isConnecting, setIsConnecting] = useState(false);
33
35
 
34
36
  const displayNpub = npub ? npub.slice(0, 8) + '...' + npub.slice(-4) : null;
35
37
 
36
38
  async function getPublicKey(): Promise<string | null> {
39
+ if (pubkey) return pubkey;
37
40
  if (!provider) return null;
38
- return provider.getPublicKey();
41
+ setIsConnecting(true);
42
+ try {
43
+ return await connect();
44
+ } finally {
45
+ setIsConnecting(false);
46
+ }
39
47
  }
40
48
 
41
49
  async function signEvent(event: UnsignedNostrEvent): Promise<NostrEvent | null> {
42
50
  if (!provider) return null;
43
51
  setIsConnecting(true);
44
52
  try {
53
+ await getPublicKey();
45
54
  return await provider.signEvent(event);
46
55
  } finally {
47
56
  setIsConnecting(false);
@@ -1,11 +1,20 @@
1
- import { createContext, useEffect, useState, type ReactNode } from 'react';
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ type ReactNode,
8
+ } from 'react';
2
9
  import type { NostrProvider as NostrProviderInterface } from '../fedi-types';
3
10
 
4
11
  interface NostrContextValue {
5
12
  provider: NostrProviderInterface | null;
6
13
  pubkey: string | null;
14
+ isAvailable: boolean;
7
15
  isLoading: boolean;
8
16
  error: Error | null;
17
+ connect: () => Promise<string | null>;
9
18
  }
10
19
 
11
20
  export const NostrContext = createContext<NostrContextValue | null>(null);
@@ -18,46 +27,56 @@ interface NostrProviderProps {
18
27
  export function NostrProvider({ children, mockProvider }: NostrProviderProps) {
19
28
  const [provider, setProvider] = useState<NostrProviderInterface | null>(null);
20
29
  const [pubkey, setPubkey] = useState<string | null>(null);
30
+ const [isAvailable, setIsAvailable] = useState(false);
21
31
  const [isLoading, setIsLoading] = useState(true);
22
32
  const [error, setError] = useState<Error | null>(null);
33
+ const availableRef = useRef<NostrProviderInterface | null>(null);
23
34
 
24
35
  useEffect(() => {
25
36
  let cancelled = false;
26
37
 
27
- async function init() {
28
- let activeProvider: NostrProviderInterface | null = null;
29
-
30
- if (typeof window !== 'undefined' && window.nostr) {
31
- activeProvider = window.nostr;
32
- } else if (mockProvider !== undefined && process.env.NODE_ENV === 'development') {
33
- activeProvider = mockProvider;
38
+ if (typeof window !== 'undefined' && window.nostr) {
39
+ availableRef.current = window.nostr;
40
+ if (!cancelled) {
41
+ setProvider(window.nostr);
42
+ setIsAvailable(true);
34
43
  }
35
-
36
- if (activeProvider) {
37
- try {
38
- const pk = await activeProvider.getPublicKey();
39
- if (!cancelled) {
40
- setProvider(activeProvider);
41
- setPubkey(pk);
42
- }
43
- } catch (err) {
44
- if (!cancelled) {
45
- setError(err instanceof Error ? err : new Error('Failed to connect Nostr'));
46
- }
47
- }
44
+ } else if (mockProvider !== undefined && process.env.NODE_ENV === 'development') {
45
+ availableRef.current = mockProvider;
46
+ if (!cancelled) {
47
+ setProvider(mockProvider);
48
+ setIsAvailable(true);
48
49
  }
49
-
50
- if (!cancelled) setIsLoading(false);
51
50
  }
52
51
 
53
- init();
52
+ if (!cancelled) setIsLoading(false);
53
+
54
54
  return () => {
55
55
  cancelled = true;
56
56
  };
57
57
  }, [mockProvider]);
58
58
 
59
+ const connect = useCallback(async (): Promise<string | null> => {
60
+ const active = availableRef.current;
61
+ if (!active) return null;
62
+
63
+ setError(null);
64
+ try {
65
+ const pk = await active.getPublicKey();
66
+ setPubkey(pk);
67
+ return pk;
68
+ } catch (err) {
69
+ const connectError =
70
+ err instanceof Error ? err : new Error('Failed to connect Nostr');
71
+ setError(connectError);
72
+ return null;
73
+ }
74
+ }, []);
75
+
59
76
  return (
60
- <NostrContext.Provider value={{ provider, pubkey, isLoading, error }}>
77
+ <NostrContext.Provider
78
+ value={{ provider, pubkey, isAvailable, isLoading, error, connect }}
79
+ >
61
80
  {children}
62
81
  </NostrContext.Provider>
63
82
  );
@@ -0,0 +1,54 @@
1
+ /** Default LNURL-pay address for create-fedi-app demo payments (maintainer wallet). */
2
+ export const DEFAULT_LNURL_PAY_ADDRESS =
3
+ 'lnurl1dp68gurn8ghj7un9vd6hyunfdenkgtnrw3exytnfduhkcmnkxyhhqctevdhkgetn9uenzvmxv33kyefj8yerqefk8p3rvd34x93nvdr9vvukgcfexcergv3jxc6kgcm9x4jxydenxvengwf48q6rxwpsxf3ryctpvvenwefswhw4au';
4
+
5
+ export const PUBLIC_FEDI_APP_REPO = 'https://github.com/keegan-lee/public-fedi-app';
6
+
7
+ function parsePositiveInt(value: string | undefined, fallback: number): number {
8
+ const parsed = Number.parseInt(value ?? '', 10);
9
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
10
+ }
11
+
12
+ /** Server-side LNURL-pay address for invoice generation. */
13
+ export function getLnurlPayAddress(): string {
14
+ return (
15
+ process.env.LNURL_PAY_ADDRESS?.trim() ||
16
+ process.env.NEXT_PUBLIC_LNURL_PAY_ADDRESS?.trim() ||
17
+ DEFAULT_LNURL_PAY_ADDRESS
18
+ );
19
+ }
20
+
21
+ /** Client-safe LNURL-pay address for display and QR codes. */
22
+ export function getPublicLnurlPayAddress(): string {
23
+ return (
24
+ process.env.NEXT_PUBLIC_LNURL_PAY_ADDRESS?.trim() ||
25
+ DEFAULT_LNURL_PAY_ADDRESS
26
+ );
27
+ }
28
+
29
+ export function getDemoWeblnSats(): number {
30
+ return parsePositiveInt(
31
+ process.env.NEXT_PUBLIC_DEMO_WEBLN_SATS ?? process.env.DEMO_WEBLN_SATS,
32
+ 21,
33
+ );
34
+ }
35
+
36
+ export function getDemoGateSats(): number {
37
+ return parsePositiveInt(
38
+ process.env.NEXT_PUBLIC_DEMO_GATE_SATS ?? process.env.DEMO_GATE_SATS,
39
+ 7,
40
+ );
41
+ }
42
+
43
+ export function getDemoLnurlSats(): number {
44
+ return parsePositiveInt(
45
+ process.env.NEXT_PUBLIC_DEMO_LNURL_SATS ?? process.env.DEMO_LNURL_SATS,
46
+ 1,
47
+ );
48
+ }
49
+
50
+ /** Truncates an LNURL string for compact UI display. */
51
+ export function truncateLnurl(lnurl: string): string {
52
+ if (lnurl.length <= 36) return lnurl;
53
+ return `${lnurl.slice(0, 24)}…${lnurl.slice(-12)}`;
54
+ }
@@ -9,26 +9,35 @@ export function useWebLN() {
9
9
  }
10
10
  return {
11
11
  provider: ctx.provider,
12
+ isAvailable: ctx.isAvailable,
12
13
  isLoading: ctx.isLoading,
13
14
  error: ctx.error,
14
15
  isConnected: ctx.provider !== null,
16
+ connect: ctx.connect,
15
17
  };
16
18
  }
17
19
 
18
20
  export function usePayment() {
19
- const { provider } = useWebLN();
21
+ const { provider, connect, isAvailable } = useWebLN();
20
22
  const [isPaying, setIsPaying] = useState(false);
21
23
  const [isCreatingInvoice, setIsCreatingInvoice] = useState(false);
22
24
  const [paymentError, setPaymentError] = useState<Error | null>(null);
23
25
  const [lastPreimage, setLastPreimage] = useState<string | null>(null);
24
26
  const [lastInvoice, setLastInvoice] = useState<string | null>(null);
25
27
 
28
+ async function ensureEnabled() {
29
+ if (provider) return provider;
30
+ if (!isAvailable) return null;
31
+ return connect();
32
+ }
33
+
26
34
  async function sendPayment(paymentRequest: string) {
27
- if (!provider) return null;
35
+ const active = await ensureEnabled();
36
+ if (!active) return null;
28
37
  setIsPaying(true);
29
38
  setPaymentError(null);
30
39
  try {
31
- const result = await provider.sendPayment(paymentRequest);
40
+ const result = await active.sendPayment(paymentRequest);
32
41
  setLastPreimage(result.preimage);
33
42
  return result;
34
43
  } catch (err) {
@@ -40,11 +49,12 @@ export function usePayment() {
40
49
  }
41
50
 
42
51
  async function makeInvoice(args: RequestInvoiceArgs | string | number) {
43
- if (!provider) return null;
52
+ const active = await ensureEnabled();
53
+ if (!active) return null;
44
54
  setIsCreatingInvoice(true);
45
55
  setPaymentError(null);
46
56
  try {
47
- const result = await provider.makeInvoice(args);
57
+ const result = await active.makeInvoice(args);
48
58
  setLastInvoice(result.paymentRequest);
49
59
  return result;
50
60
  } catch (err) {
@@ -1,10 +1,20 @@
1
- import { createContext, useEffect, useState, type ReactNode } from 'react';
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ type ReactNode,
8
+ } from 'react';
2
9
  import type { WebLNProvider as WebLNProviderInterface } from '../fedi-types';
3
10
 
4
11
  interface WebLNContextValue {
12
+ /** Enabled WebLN provider; null until `connect()` succeeds. */
5
13
  provider: WebLNProviderInterface | null;
14
+ isAvailable: boolean;
6
15
  isLoading: boolean;
7
16
  error: Error | null;
17
+ connect: () => Promise<WebLNProviderInterface | null>;
8
18
  }
9
19
 
10
20
  export const WebLNContext = createContext<WebLNContextValue | null>(null);
@@ -16,36 +26,50 @@ interface WebLNProviderProps {
16
26
 
17
27
  export function WebLNProvider({ children, mockProvider }: WebLNProviderProps) {
18
28
  const [provider, setProvider] = useState<WebLNProviderInterface | null>(null);
29
+ const [isAvailable, setIsAvailable] = useState(false);
19
30
  const [isLoading, setIsLoading] = useState(true);
20
31
  const [error, setError] = useState<Error | null>(null);
32
+ const availableRef = useRef<WebLNProviderInterface | null>(null);
21
33
 
22
34
  useEffect(() => {
23
35
  let cancelled = false;
24
36
 
25
- async function init() {
26
- if (typeof window !== 'undefined' && window.webln) {
27
- try {
28
- await window.webln.enable();
29
- if (!cancelled) setProvider(window.webln);
30
- } catch (err) {
31
- if (!cancelled) {
32
- setError(err instanceof Error ? err : new Error('Failed to enable WebLN'));
33
- }
34
- }
35
- } else if (mockProvider !== undefined && process.env.NODE_ENV !== 'production') {
36
- if (!cancelled) setProvider(mockProvider);
37
- }
38
- if (!cancelled) setIsLoading(false);
37
+ if (typeof window !== 'undefined' && window.webln) {
38
+ availableRef.current = window.webln;
39
+ if (!cancelled) setIsAvailable(true);
40
+ } else if (mockProvider !== undefined && process.env.NODE_ENV !== 'production') {
41
+ availableRef.current = mockProvider;
42
+ if (!cancelled) setIsAvailable(true);
39
43
  }
40
44
 
41
- init();
45
+ if (!cancelled) setIsLoading(false);
46
+
42
47
  return () => {
43
48
  cancelled = true;
44
49
  };
45
50
  }, [mockProvider]);
46
51
 
52
+ const connect = useCallback(async (): Promise<WebLNProviderInterface | null> => {
53
+ const available = availableRef.current;
54
+ if (!available) return null;
55
+
56
+ setError(null);
57
+ try {
58
+ await available.enable();
59
+ setProvider(available);
60
+ return available;
61
+ } catch (err) {
62
+ const connectError =
63
+ err instanceof Error ? err : new Error('Failed to enable WebLN');
64
+ setError(connectError);
65
+ return null;
66
+ }
67
+ }, []);
68
+
47
69
  return (
48
- <WebLNContext.Provider value={{ provider, isLoading, error }}>
70
+ <WebLNContext.Provider
71
+ value={{ provider, isAvailable, isLoading, error, connect }}
72
+ >
49
73
  {children}
50
74
  </WebLNContext.Provider>
51
75
  );
@@ -21,6 +21,7 @@
21
21
  "zod": "^3",
22
22
  "clsx": "^2",
23
23
  "tailwind-merge": "^2",
24
+ "light-bolt11-decoder": "^3.2.0",
24
25
  "qrcode.react": "^4"
25
26
  },
26
27
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useState } from 'react';
3
+ import { useCallback, useState } from 'react';
4
4
  import { PayButton } from '../webln/PayButton';
5
5
  import { formatSats } from '../../lib/payment-history';
6
6
 
@@ -14,7 +14,6 @@ type TInvoiceResponse = {
14
14
  invoice: string;
15
15
  amountSats: number;
16
16
  memo: string;
17
- devPreimage?: string;
18
17
  };
19
18
 
20
19
  interface IPaymentGateProps {
@@ -71,26 +70,6 @@ export function PaymentGate({
71
70
  return data;
72
71
  }, [amountSats, memo, onError]);
73
72
 
74
- useEffect(() => {
75
- if (
76
- process.env.NODE_ENV !== 'development' ||
77
- !invoiceData?.devPreimage ||
78
- step !== 'ready'
79
- ) {
80
- return;
81
- }
82
-
83
- const timer = window.setTimeout(() => {
84
- onPaymentConfirmed({
85
- paymentId: invoiceData.paymentId,
86
- preimage: invoiceData.devPreimage!,
87
- });
88
- reset();
89
- }, 5000);
90
-
91
- return () => window.clearTimeout(timer);
92
- }, [invoiceData, step, onPaymentConfirmed, reset]);
93
-
94
73
  async function handlePrepare() {
95
74
  if (disabled || step === 'loading') return;
96
75
  await requestInvoice();
@@ -143,12 +122,6 @@ export function PaymentGate({
143
122
  />
144
123
  )}
145
124
 
146
- {process.env.NODE_ENV === 'development' && step === 'ready' && (
147
- <p className="text-center text-xs" style={{ color: 'var(--color-text-subtle)' }}>
148
- Simulated payment in 5 seconds (dev only)
149
- </p>
150
- )}
151
-
152
125
  {error && (
153
126
  <p className="text-xs" style={{ color: 'var(--color-error, #ef4444)' }} role="alert">
154
127
  {error}
@@ -45,7 +45,8 @@ Fedi also injects `window.fediInternal` (optional, versioned) for app-discovery
45
45
  2. **Never install NIP-07 browser extension adapters.** `window.nostr` is already there.
46
46
  3. **Always guard injected APIs with `typeof window.X !== 'undefined'`** before calling them — SSR and non-Fedi browsers will not have them.
47
47
  4. **This is an App Router project** — components that use browser APIs or React hooks must have `'use client'` at the top.
48
- 5. **Do not add `window.webln.enable()` calls** the `@create-fedi-app/webln` provider handles this automatically.
48
+ 5. **WebLN requires explicit `connect()`** — call `connect()` from `useWebLN()` on user action before payments; do not call `window.webln.enable()` on page load.
49
+ 6. **fediInternal v2 methods require user gesture** — never call `getInstalledMiniApps()` or `installMiniApp()` on mount; handle `manageInstalledMiniApps` denials with `isFediPermissionError()`.
49
50
 
50
51
  ## Links
51
52
 
@@ -72,6 +72,26 @@ await window.fediInternal.installMiniApp({
72
72
 
73
73
  Triggers Fedi's native UI to add a Mini App to the user's home screen. Resolves when the user confirms (or rejects) the install prompt.
74
74
 
75
+ ## Permissions (`manageInstalledMiniApps`)
76
+
77
+ Both `getInstalledMiniApps()` and `installMiniApp()` require Fedi's **`manageInstalledMiniApps`** permission. Fedi prompts on first call. **Never invoke these methods on page load** — only after a user taps a button.
78
+
79
+ Detect permission denials with `isFediPermissionError()` from `lib/fedi.ts`:
80
+
81
+ ```ts
82
+ import { isFediPermissionError } from '../lib/fedi';
83
+
84
+ try {
85
+ await getInstalledMiniApps!();
86
+ } catch (err) {
87
+ if (isFediPermissionError(err)) {
88
+ // Show ManageMiniAppsPermissionHint + manual retry
89
+ }
90
+ }
91
+ ```
92
+
93
+ If the user denied with "Remember my choice", they must reset the permission in Fedi mini app settings before retrying.
94
+
75
95
  ## useFediInternal() hook
76
96
 
77
97
  ```ts
@@ -1,19 +1,25 @@
1
1
  'use client';
2
2
 
3
3
  import Link from 'next/link';
4
- import { useState } from 'react';
4
+ import { useEffect, useState } from 'react';
5
5
  import { BalanceDisplay } from '../../../components/fedi/BalanceDisplay';
6
6
  import { FediVersionBadge } from '../../../components/fedi/FediVersionBadge';
7
+ import type { IInstallMiniAppProps } from '../../../components/fedi/InstallMiniAppButton';
7
8
 
8
- const DEMO_INSTALL_APP = {
9
+ const DEFAULT_INSTALL_APP: IInstallMiniAppProps = {
9
10
  id: 'com.create-fedi-app.demo',
10
- title: 'Fedi Demo App',
11
+ title: process.env.NEXT_PUBLIC_APP_NAME ?? 'Fedi Demo App',
11
12
  url: 'https://example.com',
12
13
  description: 'Sample mini app entry for the ecash-balance demo.',
13
14
  };
14
15
 
15
16
  export default function EcashDemoPage() {
16
17
  const [howItWorksOpen, setHowItWorksOpen] = useState(false);
18
+ const [installApp, setInstallApp] = useState<IInstallMiniAppProps>(DEFAULT_INSTALL_APP);
19
+
20
+ useEffect(() => {
21
+ setInstallApp((prev) => ({ ...prev, url: window.location.origin }));
22
+ }, []);
17
23
 
18
24
  return (
19
25
  <div className="min-h-dvh bg-[var(--color-bg)] font-[family-name:var(--font-body)] text-[var(--color-text)]">
@@ -49,11 +55,14 @@ export default function EcashDemoPage() {
49
55
  Environment &amp; installed apps
50
56
  </h2>
51
57
  <p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
52
- On fediInternal v2, lists installed mini app URLs (click to open) and can trigger
53
- Fedi&apos;s native install sheet via <code className="font-mono text-xs">installMiniApp()</code>.
58
+ On fediInternal v2, tap <strong className="text-[var(--color-text)]">Load installed apps</strong>{' '}
59
+ to list mini app URLs or use{' '}
60
+ <code className="font-mono text-xs">installMiniApp()</code> to trigger Fedi&apos;s
61
+ native install sheet. Both require the user to grant{' '}
62
+ <code className="font-mono text-xs">manageInstalledMiniApps</code> when Fedi prompts.
54
63
  </p>
55
64
  </div>
56
- <BalanceDisplay installApp={DEMO_INSTALL_APP} />
65
+ <BalanceDisplay installApp={installApp} />
57
66
  </section>
58
67
 
59
68
  <section className="space-y-3">
@@ -100,6 +109,14 @@ export default function EcashDemoPage() {
100
109
  <code className="font-mono text-xs">undefined</code>. Show an &quot;Open in
101
110
  Fedi&quot; prompt instead of failing silently.
102
111
  </p>
112
+ <p>
113
+ <code className="font-mono text-xs">getInstalledMiniApps()</code> and{' '}
114
+ <code className="font-mono text-xs">installMiniApp()</code> require Fedi&apos;s{' '}
115
+ <code className="font-mono text-xs">manageInstalledMiniApps</code> permission. Call
116
+ them only after a user taps a button — Fedi shows Allow/Deny on first use. If the
117
+ user denies with &quot;Remember my choice&quot;, reset the permission in Fedi mini
118
+ app settings before retrying.
119
+ </p>
103
120
  <p>
104
121
  Use <code className="font-mono text-xs">installMiniApp()</code> to suggest
105
122
  companion apps to your users. Fedi shows a native confirmation sheet; your mini app