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,195 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'crypto';
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
import {
|
|
4
|
+
createPaymentRecord,
|
|
5
|
+
getPaymentById,
|
|
6
|
+
markPaymentPaid,
|
|
7
|
+
type TPaymentRecord,
|
|
8
|
+
} from './payment-store';
|
|
9
|
+
|
|
10
|
+
export const PAYMENT_COOKIE_NAME = 'fedi-payment-token';
|
|
11
|
+
export const PAYMENT_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
|
|
12
|
+
|
|
13
|
+
export const PROTECTED_ROUTES = [
|
|
14
|
+
{ path: '/demo/payment-gated/article', contentId: 'demo-article' },
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
type TCookieReader = {
|
|
18
|
+
get: (name: string) => { value: string } | undefined;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function getSigningSecret(): string {
|
|
22
|
+
return process.env.PAYMENT_GATE_SECRET ?? 'dev-payment-gate-secret';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function signPayload(payload: string): string {
|
|
26
|
+
return createHmac('sha256', getSigningSecret()).update(payload).digest('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildToken(contentId: string, paymentId: string): string {
|
|
30
|
+
const payload = `${contentId}:${paymentId}`;
|
|
31
|
+
return `${payload}:${signPayload(payload)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseToken(token: string, contentId: string): string | null {
|
|
35
|
+
const parts = token.split(':');
|
|
36
|
+
if (parts.length < 3) return null;
|
|
37
|
+
|
|
38
|
+
const paymentId = parts[1];
|
|
39
|
+
const signature = parts.slice(2).join(':');
|
|
40
|
+
const payload = `${contentId}:${paymentId}`;
|
|
41
|
+
const expected = signPayload(payload);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const sigBuffer = Buffer.from(signature, 'hex');
|
|
45
|
+
const expectedBuffer = Buffer.from(expected, 'hex');
|
|
46
|
+
if (
|
|
47
|
+
sigBuffer.length !== expectedBuffer.length ||
|
|
48
|
+
!timingSafeEqual(sigBuffer, expectedBuffer)
|
|
49
|
+
) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return paymentId;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readPaymentToken(source: NextRequest | TCookieReader): string | null {
|
|
60
|
+
if (source instanceof NextRequest) {
|
|
61
|
+
return (
|
|
62
|
+
source.headers.get('x-payment-token') ??
|
|
63
|
+
source.cookies.get(PAYMENT_COOKIE_NAME)?.value ??
|
|
64
|
+
null
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return source.get(PAYMENT_COOKIE_NAME)?.value ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reads payment tokens from request cookies or the x-payment-token header.
|
|
73
|
+
*/
|
|
74
|
+
export function checkPaymentCookie(
|
|
75
|
+
source: NextRequest | TCookieReader,
|
|
76
|
+
contentId: string,
|
|
77
|
+
): boolean {
|
|
78
|
+
const token = readPaymentToken(source);
|
|
79
|
+
if (!token) return false;
|
|
80
|
+
|
|
81
|
+
const paymentId = parseToken(token, contentId);
|
|
82
|
+
if (!paymentId) return false;
|
|
83
|
+
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validates the token and confirms the underlying payment record is paid.
|
|
89
|
+
*/
|
|
90
|
+
export async function checkPaymentAccess(
|
|
91
|
+
source: NextRequest | TCookieReader,
|
|
92
|
+
contentId: string,
|
|
93
|
+
): Promise<boolean> {
|
|
94
|
+
const token = readPaymentToken(source);
|
|
95
|
+
if (!token) return false;
|
|
96
|
+
|
|
97
|
+
const paymentId = parseToken(token, contentId);
|
|
98
|
+
if (!paymentId) return false;
|
|
99
|
+
|
|
100
|
+
const record = await getPaymentById(paymentId);
|
|
101
|
+
return record?.contentId === contentId && record.status === 'paid';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function setPaymentCookie(
|
|
105
|
+
response: NextResponse,
|
|
106
|
+
contentId: string,
|
|
107
|
+
paymentId: string,
|
|
108
|
+
): NextResponse {
|
|
109
|
+
const token = buildToken(contentId, paymentId);
|
|
110
|
+
response.cookies.set(PAYMENT_COOKIE_NAME, token, {
|
|
111
|
+
httpOnly: true,
|
|
112
|
+
secure: process.env.NODE_ENV === 'production',
|
|
113
|
+
sameSite: 'lax',
|
|
114
|
+
path: '/',
|
|
115
|
+
maxAge: PAYMENT_COOKIE_MAX_AGE,
|
|
116
|
+
});
|
|
117
|
+
return response;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type TGenerateInvoiceResult = {
|
|
121
|
+
paymentId: string;
|
|
122
|
+
invoice: string;
|
|
123
|
+
amountSats: number;
|
|
124
|
+
memo: string;
|
|
125
|
+
/** Present in development to simulate wallet payment without WebLN */
|
|
126
|
+
devPreimage?: string;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Creates a server-side invoice and stores a pending payment record.
|
|
131
|
+
*/
|
|
132
|
+
export async function generateInvoice(options: {
|
|
133
|
+
contentId: string;
|
|
134
|
+
amountSats: number;
|
|
135
|
+
memo?: string;
|
|
136
|
+
}): Promise<TGenerateInvoiceResult> {
|
|
137
|
+
const record = await createPaymentRecord({
|
|
138
|
+
contentId: options.contentId,
|
|
139
|
+
amountSats: options.amountSats,
|
|
140
|
+
memo: options.memo ?? `Unlock ${options.contentId}`,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const result: TGenerateInvoiceResult = {
|
|
144
|
+
paymentId: record.id,
|
|
145
|
+
invoice: record.invoice,
|
|
146
|
+
amountSats: record.amountSats,
|
|
147
|
+
memo: record.memo,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (process.env.NODE_ENV === 'development') {
|
|
151
|
+
result.devPreimage = record.preimage;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type TVerifyPaymentResult =
|
|
158
|
+
| { valid: true; record: TPaymentRecord }
|
|
159
|
+
| { valid: false; reason: string };
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Verifies a Lightning payment preimage against a pending invoice record.
|
|
163
|
+
*/
|
|
164
|
+
export async function verifyPayment(
|
|
165
|
+
paymentId: string,
|
|
166
|
+
preimage: string,
|
|
167
|
+
): Promise<TVerifyPaymentResult> {
|
|
168
|
+
const record = await getPaymentById(paymentId);
|
|
169
|
+
if (!record) {
|
|
170
|
+
return { valid: false, reason: 'Payment not found' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (record.status === 'expired') {
|
|
174
|
+
return { valid: false, reason: 'Invoice expired' };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (record.status === 'paid') {
|
|
178
|
+
return { valid: true, record };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (record.preimage !== preimage) {
|
|
182
|
+
return { valid: false, reason: 'Invalid payment proof' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const paid = await markPaymentPaid(paymentId);
|
|
186
|
+
if (!paid) {
|
|
187
|
+
return { valid: false, reason: 'Unable to confirm payment' };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { valid: true, record: paid };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function findProtectedRoute(pathname: string) {
|
|
194
|
+
return PROTECTED_ROUTES.find((route) => pathname.startsWith(route.path));
|
|
195
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
|
|
3
|
+
export type TPaymentStatus = 'pending' | 'paid' | 'expired';
|
|
4
|
+
|
|
5
|
+
export type TPaymentRecord = {
|
|
6
|
+
id: string;
|
|
7
|
+
contentId: string;
|
|
8
|
+
invoice: string;
|
|
9
|
+
preimage: string;
|
|
10
|
+
amountSats: number;
|
|
11
|
+
memo: string;
|
|
12
|
+
status: TPaymentStatus;
|
|
13
|
+
createdAt: number;
|
|
14
|
+
paidAt: number | null;
|
|
15
|
+
metadata?: Record<string, string>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const payments = new Map<string, TPaymentRecord>();
|
|
19
|
+
const INVOICE_TTL_MS = 60 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
function generateId(): string {
|
|
22
|
+
return randomBytes(16).toString('hex');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function generatePreimage(): string {
|
|
26
|
+
return randomBytes(32).toString('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildMockInvoice(amountSats: number, paymentId: string): string {
|
|
30
|
+
const amountPart = amountSats.toString(16).padStart(6, '0');
|
|
31
|
+
return `lnbc${amountPart}n1p${paymentId.slice(0, 40)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a pending payment record. Uses in-memory storage by default;
|
|
36
|
+
* when DATABASE_URL is set and lib/db exists, the database module can extend this.
|
|
37
|
+
*/
|
|
38
|
+
export async function createPaymentRecord(options: {
|
|
39
|
+
contentId: string;
|
|
40
|
+
amountSats: number;
|
|
41
|
+
memo: string;
|
|
42
|
+
metadata?: Record<string, string>;
|
|
43
|
+
}): Promise<TPaymentRecord> {
|
|
44
|
+
const id = generateId();
|
|
45
|
+
const preimage = generatePreimage();
|
|
46
|
+
const record: TPaymentRecord = {
|
|
47
|
+
id,
|
|
48
|
+
contentId: options.contentId,
|
|
49
|
+
invoice: buildMockInvoice(options.amountSats, id),
|
|
50
|
+
preimage,
|
|
51
|
+
amountSats: options.amountSats,
|
|
52
|
+
memo: options.memo,
|
|
53
|
+
status: 'pending',
|
|
54
|
+
createdAt: Date.now(),
|
|
55
|
+
paidAt: null,
|
|
56
|
+
metadata: options.metadata,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
payments.set(id, record);
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function getPaymentById(id: string): Promise<TPaymentRecord | null> {
|
|
64
|
+
const record = payments.get(id);
|
|
65
|
+
if (!record) return null;
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
record.status === 'pending' &&
|
|
69
|
+
Date.now() - record.createdAt > INVOICE_TTL_MS
|
|
70
|
+
) {
|
|
71
|
+
record.status = 'expired';
|
|
72
|
+
payments.set(id, record);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return record;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function getPaymentByPreimage(
|
|
79
|
+
preimage: string,
|
|
80
|
+
): Promise<TPaymentRecord | null> {
|
|
81
|
+
for (const record of payments.values()) {
|
|
82
|
+
if (record.preimage === preimage) return record;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function markPaymentPaid(id: string): Promise<TPaymentRecord | null> {
|
|
88
|
+
const record = await getPaymentById(id);
|
|
89
|
+
if (!record || record.status !== 'pending') return null;
|
|
90
|
+
|
|
91
|
+
record.status = 'paid';
|
|
92
|
+
record.paidAt = Date.now();
|
|
93
|
+
payments.set(id, record);
|
|
94
|
+
return record;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function hasPaidForContent(contentId: string): Promise<boolean> {
|
|
98
|
+
for (const record of payments.values()) {
|
|
99
|
+
if (record.contentId === contentId && record.status === 'paid') {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "payment-gated-content",
|
|
3
|
+
"description": "Lock content behind a lightning payment gate",
|
|
4
|
+
"dependencies": [],
|
|
5
|
+
"devDependencies": [],
|
|
6
|
+
"files": [
|
|
7
|
+
{ "src": "proxy.ts", "dest": "proxy.ts", "merge": "replace" },
|
|
8
|
+
{ "src": "lib/payment-gate.ts", "dest": "lib/payment-gate.ts", "merge": "add" },
|
|
9
|
+
{ "src": "lib/payment-store.ts", "dest": "lib/payment-store.ts", "merge": "add" },
|
|
10
|
+
{ "src": "components/payment-gated/PayGate.tsx", "dest": "components/payment-gated/PayGate.tsx", "merge": "add" },
|
|
11
|
+
{ "src": "app/api/payment-gate/invoice/route.ts", "dest": "app/api/payment-gate/invoice/route.ts", "merge": "add" },
|
|
12
|
+
{ "src": "app/api/payment-gate/verify/route.ts", "dest": "app/api/payment-gate/verify/route.ts", "merge": "add" },
|
|
13
|
+
{ "src": "app/demo/payment-gated/page.tsx", "dest": "app/demo/payment-gated/page.tsx", "merge": "add" },
|
|
14
|
+
{ "src": "app/demo/payment-gated/article/page.tsx", "dest": "app/demo/payment-gated/article/page.tsx", "merge": "add" }
|
|
15
|
+
],
|
|
16
|
+
"envVars": [
|
|
17
|
+
{
|
|
18
|
+
"key": "PAYMENT_GATE_SECRET",
|
|
19
|
+
"description": "HMAC secret for signing payment access cookies",
|
|
20
|
+
"example": "replace-with-a-long-random-string",
|
|
21
|
+
"required": false
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import {
|
|
3
|
+
checkPaymentAccess,
|
|
4
|
+
findProtectedRoute,
|
|
5
|
+
} from './lib/payment-gate';
|
|
6
|
+
|
|
7
|
+
export default async function proxy(request: NextRequest) {
|
|
8
|
+
const protectedRoute = findProtectedRoute(request.nextUrl.pathname);
|
|
9
|
+
if (!protectedRoute) {
|
|
10
|
+
return NextResponse.next();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const hasAccess = await checkPaymentAccess(request, protectedRoute.contentId);
|
|
14
|
+
if (hasAccess) {
|
|
15
|
+
return NextResponse.next();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const url = request.nextUrl.clone();
|
|
19
|
+
url.pathname = '/demo/payment-gated';
|
|
20
|
+
url.searchParams.set('redirect', protectedRoute.path);
|
|
21
|
+
url.searchParams.set('contentId', protectedRoute.contentId);
|
|
22
|
+
return NextResponse.redirect(url);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const config = {
|
|
26
|
+
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/payment-gate).*)'],
|
|
27
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { useCallback, useState } from 'react';
|
|
5
|
+
import { PayButton } from '../../../components/webln/PayButton';
|
|
6
|
+
import { InvoiceCard } from '../../../components/webln/InvoiceCard';
|
|
7
|
+
import { PaymentHistory } from '../../../components/webln/PaymentHistory';
|
|
8
|
+
import { usePaymentFlow } from '../../../hooks/usePaymentFlow';
|
|
9
|
+
import { addPaymentRecord } from '../../../lib/payment-history';
|
|
10
|
+
|
|
11
|
+
const DEMO_SATS = 21;
|
|
12
|
+
const DEMO_MEMO = 'demo payment';
|
|
13
|
+
|
|
14
|
+
export default function WeblnDemoPage() {
|
|
15
|
+
const { recordReceivedPayment } = usePaymentFlow();
|
|
16
|
+
const [demoInvoice, setDemoInvoice] = useState('');
|
|
17
|
+
const [howItWorksOpen, setHowItWorksOpen] = useState(false);
|
|
18
|
+
|
|
19
|
+
const handleInvoiceReady = useCallback((invoice: string) => {
|
|
20
|
+
setDemoInvoice(invoice);
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
function handlePaySuccess(preimage: string) {
|
|
24
|
+
addPaymentRecord({
|
|
25
|
+
amountSats: DEMO_SATS,
|
|
26
|
+
memo: DEMO_MEMO,
|
|
27
|
+
timestamp: Date.now(),
|
|
28
|
+
preimage,
|
|
29
|
+
type: 'send',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function handleInvoicePaid(preimage: string) {
|
|
34
|
+
recordReceivedPayment({
|
|
35
|
+
amountSats: DEMO_SATS,
|
|
36
|
+
memo: DEMO_MEMO,
|
|
37
|
+
preimage,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="min-h-dvh bg-[var(--color-bg)] font-[family-name:var(--font-body)] text-[var(--color-text)]">
|
|
43
|
+
<div
|
|
44
|
+
className="mx-auto w-full max-w-[390px] px-4 pt-6"
|
|
45
|
+
style={{ paddingBottom: 'max(5rem, env(safe-area-inset-bottom, 20px))' }}
|
|
46
|
+
>
|
|
47
|
+
<Link
|
|
48
|
+
href="/demo"
|
|
49
|
+
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"
|
|
50
|
+
>
|
|
51
|
+
← back
|
|
52
|
+
</Link>
|
|
53
|
+
|
|
54
|
+
<header className="mb-8 space-y-2">
|
|
55
|
+
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold leading-tight text-[var(--color-text)]">
|
|
56
|
+
WebLN Payments
|
|
57
|
+
</h1>
|
|
58
|
+
<p className="max-w-[75ch] text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
59
|
+
Send and receive Lightning payments through the WebLN browser API. Inside Fedi, your
|
|
60
|
+
wallet is injected automatically. No extensions required.
|
|
61
|
+
</p>
|
|
62
|
+
</header>
|
|
63
|
+
|
|
64
|
+
<div className="space-y-8">
|
|
65
|
+
<section className="space-y-4">
|
|
66
|
+
<div className="max-w-[75ch] space-y-1.5">
|
|
67
|
+
<h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
|
|
68
|
+
Receive payment
|
|
69
|
+
</h2>
|
|
70
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
71
|
+
Creates a BOLT11 invoice via <code className="font-mono text-xs">makeInvoice()</code>.
|
|
72
|
+
Scan the QR code or copy the invoice string to pay.
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
<InvoiceCard
|
|
76
|
+
sats={DEMO_SATS}
|
|
77
|
+
memo={DEMO_MEMO}
|
|
78
|
+
onInvoice={handleInvoiceReady}
|
|
79
|
+
onPaid={handleInvoicePaid}
|
|
80
|
+
/>
|
|
81
|
+
</section>
|
|
82
|
+
|
|
83
|
+
<section className="space-y-4">
|
|
84
|
+
<div className="max-w-[75ch] space-y-1.5">
|
|
85
|
+
<h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
|
|
86
|
+
Send payment
|
|
87
|
+
</h2>
|
|
88
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
89
|
+
Pays the invoice above via <code className="font-mono text-xs">sendPayment()</code>.
|
|
90
|
+
The preimage returned is cryptographic proof that payment completed.
|
|
91
|
+
</p>
|
|
92
|
+
</div>
|
|
93
|
+
{demoInvoice ? (
|
|
94
|
+
<PayButton
|
|
95
|
+
invoice={demoInvoice}
|
|
96
|
+
amountSats={DEMO_SATS}
|
|
97
|
+
memo={DEMO_MEMO}
|
|
98
|
+
onSuccess={handlePaySuccess}
|
|
99
|
+
/>
|
|
100
|
+
) : (
|
|
101
|
+
<p className="text-sm text-[var(--color-text-subtle)]">Waiting for invoice…</p>
|
|
102
|
+
)}
|
|
103
|
+
</section>
|
|
104
|
+
|
|
105
|
+
<section className="space-y-4">
|
|
106
|
+
<div className="max-w-[75ch] space-y-1.5">
|
|
107
|
+
<h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
|
|
108
|
+
Payment history
|
|
109
|
+
</h2>
|
|
110
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
111
|
+
Recent payments stored in your browser. Useful for demos, not a substitute for
|
|
112
|
+
server-side records in production.
|
|
113
|
+
</p>
|
|
114
|
+
</div>
|
|
115
|
+
<PaymentHistory />
|
|
116
|
+
</section>
|
|
117
|
+
|
|
118
|
+
<section className="space-y-3">
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
onClick={() => setHowItWorksOpen((open) => !open)}
|
|
122
|
+
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"
|
|
123
|
+
style={{
|
|
124
|
+
background: 'var(--color-surface-1)',
|
|
125
|
+
border: '1px solid var(--color-border)',
|
|
126
|
+
color: 'var(--color-text)',
|
|
127
|
+
borderRadius: 'var(--radius-lg)',
|
|
128
|
+
}}
|
|
129
|
+
aria-expanded={howItWorksOpen}
|
|
130
|
+
aria-controls="webln-how-it-works"
|
|
131
|
+
>
|
|
132
|
+
How this works
|
|
133
|
+
<span aria-hidden>{howItWorksOpen ? '−' : '+'}</span>
|
|
134
|
+
</button>
|
|
135
|
+
|
|
136
|
+
{howItWorksOpen && (
|
|
137
|
+
<div
|
|
138
|
+
id="webln-how-it-works"
|
|
139
|
+
className="space-y-3 rounded-lg px-4 py-3 text-sm leading-[1.65]"
|
|
140
|
+
style={{
|
|
141
|
+
background: 'var(--color-surface-1)',
|
|
142
|
+
border: '1px solid var(--color-border)',
|
|
143
|
+
color: 'var(--color-text-muted)',
|
|
144
|
+
borderRadius: 'var(--radius-lg)',
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
<p>
|
|
148
|
+
<strong style={{ color: 'var(--color-text)' }}>WebLN</strong> is a JavaScript
|
|
149
|
+
standard that lets web apps talk to a Lightning wallet. Fedi injects{' '}
|
|
150
|
+
<code className="font-mono text-xs">window.webln</code> into every Mini App
|
|
151
|
+
WebView before your page loads.
|
|
152
|
+
</p>
|
|
153
|
+
<p>
|
|
154
|
+
To send sats, call <code className="font-mono text-xs">sendPayment(invoice)</code>{' '}
|
|
155
|
+
with a BOLT11 string. To receive, call{' '}
|
|
156
|
+
<code className="font-mono text-xs">makeInvoice({'{ amount, defaultMemo }'})</code>{' '}
|
|
157
|
+
and share the returned payment request.
|
|
158
|
+
</p>
|
|
159
|
+
<p>
|
|
160
|
+
On success, <code className="font-mono text-xs">sendPayment</code> returns a{' '}
|
|
161
|
+
<strong style={{ color: 'var(--color-text)' }}>preimage</strong>, a 32-byte hex
|
|
162
|
+
string that proves the payment happened. Your server can verify it before
|
|
163
|
+
unlocking content or granting access.
|
|
164
|
+
</p>
|
|
165
|
+
<p>
|
|
166
|
+
Outside Fedi, <code className="font-mono text-xs">window.webln</code> is undefined.
|
|
167
|
+
In development, this project uses a mock provider so you can test flows locally.
|
|
168
|
+
</p>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</section>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { QRCodeSVG } from 'qrcode.react';
|
|
5
|
+
import { usePayment } from '../../lib/webln';
|
|
6
|
+
import { formatSats } from '../../lib/payment-history';
|
|
7
|
+
|
|
8
|
+
interface IInvoiceCardProps {
|
|
9
|
+
sats: number;
|
|
10
|
+
memo?: string;
|
|
11
|
+
onPaid?: (preimage: string) => void;
|
|
12
|
+
onInvoice?: (invoice: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function mockPreimage(): string {
|
|
16
|
+
let result = '';
|
|
17
|
+
for (let i = 0; i < 64; i++) {
|
|
18
|
+
result += Math.floor(Math.random() * 16).toString(16);
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function InvoiceCard({ sats, memo, onPaid, onInvoice }: IInvoiceCardProps) {
|
|
24
|
+
const { makeInvoice, isCreatingInvoice, paymentError, lastInvoice } = usePayment();
|
|
25
|
+
const [copied, setCopied] = useState(false);
|
|
26
|
+
const [isPaid, setIsPaid] = useState(false);
|
|
27
|
+
const [paidPreimage, setPaidPreimage] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
makeInvoice({ amount: String(sats), defaultMemo: memo ?? '' });
|
|
31
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
32
|
+
}, [sats, memo]);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (lastInvoice) {
|
|
36
|
+
onInvoice?.(lastInvoice);
|
|
37
|
+
}
|
|
38
|
+
}, [lastInvoice, onInvoice]);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!lastInvoice || isPaid || isCreatingInvoice) return;
|
|
42
|
+
|
|
43
|
+
if (process.env.NODE_ENV === 'development') {
|
|
44
|
+
const timer = window.setTimeout(() => {
|
|
45
|
+
const preimage = mockPreimage();
|
|
46
|
+
setPaidPreimage(preimage);
|
|
47
|
+
setIsPaid(true);
|
|
48
|
+
onPaid?.(preimage);
|
|
49
|
+
}, 5000);
|
|
50
|
+
return () => window.clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
}, [lastInvoice, isPaid, isCreatingInvoice, onPaid]);
|
|
53
|
+
|
|
54
|
+
async function handleCopy() {
|
|
55
|
+
if (!lastInvoice) return;
|
|
56
|
+
await navigator.clipboard.writeText(lastInvoice);
|
|
57
|
+
setCopied(true);
|
|
58
|
+
window.setTimeout(() => setCopied(false), 2000);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
className="rounded-xl p-4 flex flex-col gap-3"
|
|
64
|
+
style={{
|
|
65
|
+
background: 'var(--color-surface-1)',
|
|
66
|
+
border: '1px solid var(--color-border)',
|
|
67
|
+
borderRadius: 'var(--radius-lg)',
|
|
68
|
+
}}
|
|
69
|
+
aria-label={`Invoice for ${formatSats(sats)}`}
|
|
70
|
+
>
|
|
71
|
+
<div className="flex items-center justify-between gap-2">
|
|
72
|
+
<span className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
|
73
|
+
{formatSats(sats)}
|
|
74
|
+
</span>
|
|
75
|
+
{memo && (
|
|
76
|
+
<span className="text-xs truncate max-w-[50%]" style={{ color: 'var(--color-text-muted)' }}>
|
|
77
|
+
{memo}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{isCreatingInvoice && (
|
|
83
|
+
<p
|
|
84
|
+
className="text-xs font-mono"
|
|
85
|
+
style={{ color: 'var(--color-text-subtle)' }}
|
|
86
|
+
aria-live="polite"
|
|
87
|
+
>
|
|
88
|
+
Generating invoice…
|
|
89
|
+
</p>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{isPaid && (
|
|
93
|
+
<div
|
|
94
|
+
className="flex items-center gap-2 rounded-lg px-3 py-2 transition-opacity duration-300 ease-[cubic-bezier(0.25,1,0.5,1)] opacity-100"
|
|
95
|
+
style={{
|
|
96
|
+
background: 'var(--color-accent-dim)',
|
|
97
|
+
borderRadius: 'var(--radius-md)',
|
|
98
|
+
}}
|
|
99
|
+
role="status"
|
|
100
|
+
aria-label={`Invoice paid: ${formatSats(sats)}`}
|
|
101
|
+
>
|
|
102
|
+
<span
|
|
103
|
+
className="flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold"
|
|
104
|
+
style={{ background: 'var(--color-accent)', color: 'var(--color-primary-foreground)' }}
|
|
105
|
+
aria-hidden
|
|
106
|
+
>
|
|
107
|
+
✓
|
|
108
|
+
</span>
|
|
109
|
+
<span className="text-sm font-semibold" style={{ color: 'var(--color-accent)' }}>
|
|
110
|
+
Paid
|
|
111
|
+
</span>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{lastInvoice && !isCreatingInvoice && !isPaid && (
|
|
116
|
+
<>
|
|
117
|
+
<div
|
|
118
|
+
className="mx-auto flex w-full max-w-[160px] items-center justify-center rounded-lg p-3"
|
|
119
|
+
style={{ background: 'var(--color-surface-2)' }}
|
|
120
|
+
aria-label="Invoice QR code"
|
|
121
|
+
>
|
|
122
|
+
<QRCodeSVG
|
|
123
|
+
value={lastInvoice}
|
|
124
|
+
size={136}
|
|
125
|
+
level="M"
|
|
126
|
+
bgColor="transparent"
|
|
127
|
+
fgColor="var(--color-text)"
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<p className="text-center text-xs" style={{ color: 'var(--color-text-subtle)' }}>
|
|
132
|
+
{process.env.NODE_ENV === 'development'
|
|
133
|
+
? 'Simulated payment in 5 seconds (dev only)'
|
|
134
|
+
: 'Waiting for payment…'}
|
|
135
|
+
</p>
|
|
136
|
+
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
onClick={handleCopy}
|
|
140
|
+
className="w-full rounded-lg px-4 py-2 text-sm font-medium transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70"
|
|
141
|
+
style={{
|
|
142
|
+
background: 'var(--color-surface-2)',
|
|
143
|
+
color: 'var(--color-text)',
|
|
144
|
+
borderRadius: 'var(--radius-md)',
|
|
145
|
+
}}
|
|
146
|
+
aria-label={copied ? 'Invoice copied to clipboard' : 'Copy invoice to clipboard'}
|
|
147
|
+
>
|
|
148
|
+
{copied ? 'Copied!' : 'Copy invoice'}
|
|
149
|
+
</button>
|
|
150
|
+
</>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{isPaid && paidPreimage && (
|
|
154
|
+
<p className="font-mono text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
|
155
|
+
{paidPreimage.slice(0, 12)}…{paidPreimage.slice(-8)}
|
|
156
|
+
</p>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{paymentError && (
|
|
160
|
+
<p
|
|
161
|
+
className="text-xs"
|
|
162
|
+
style={{ color: 'var(--color-error, #ef4444)' }}
|
|
163
|
+
role="alert"
|
|
164
|
+
>
|
|
165
|
+
{paymentError.message}
|
|
166
|
+
</p>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|