feat(board): Lightning-paid message board with LNbits and admin moderation
Add public /board flow: create invoice, webhook + confirm reconciliation, list active messages, likes (Nostr), zap fallbacks. Admin table for hide/delete. Include LNbits webhook body normalization (double-encoded JSON), POST /api/messages/confirm/:hash, and root npm db:push script. Prisma models for pending invoices and board messages. Made-with: Cursor
This commit is contained in:
113
backend/src/services/lnbits.ts
Normal file
113
backend/src/services/lnbits.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
const LNBITS_URL = (process.env.LNBITS_URL || 'https://legend.lnbits.com').replace(/\/$/, '');
|
||||
const LNBITS_API_KEY = process.env.LNBITS_API_KEY || '';
|
||||
|
||||
export interface CreateInvoiceResult {
|
||||
payment_hash: string;
|
||||
payment_request: string;
|
||||
checking_id: string;
|
||||
}
|
||||
|
||||
function pickBolt11(data: Record<string, unknown>): string {
|
||||
const pr = data.payment_request ?? data.bolt11;
|
||||
if (typeof pr === 'string' && pr.length > 0) return pr;
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function createIncomingInvoice(params: {
|
||||
amountSats: number;
|
||||
memo: string;
|
||||
webhookUrl: string;
|
||||
expirySeconds?: number;
|
||||
}): Promise<CreateInvoiceResult> {
|
||||
if (!LNBITS_API_KEY) {
|
||||
throw new Error('LNBITS_API_KEY is not configured');
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
out: false,
|
||||
amount: params.amountSats,
|
||||
unit: 'sat',
|
||||
memo: params.memo,
|
||||
expiry: params.expirySeconds ?? 3600,
|
||||
webhook: params.webhookUrl,
|
||||
};
|
||||
|
||||
const res = await fetch(`${LNBITS_URL}/api/v1/payments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': LNBITS_API_KEY,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
|
||||
if (!res.ok) {
|
||||
const detail =
|
||||
typeof data.detail === 'string'
|
||||
? data.detail
|
||||
: Array.isArray(data.detail)
|
||||
? JSON.stringify(data.detail)
|
||||
: typeof data.message === 'string'
|
||||
? data.message
|
||||
: res.statusText;
|
||||
throw new Error(`LNbits invoice failed: ${detail || res.status}`);
|
||||
}
|
||||
|
||||
const payment_hash = typeof data.payment_hash === 'string' ? data.payment_hash : '';
|
||||
const payment_request = pickBolt11(data);
|
||||
const checking_id =
|
||||
typeof data.checking_id === 'string' && data.checking_id.length > 0
|
||||
? data.checking_id
|
||||
: payment_hash;
|
||||
|
||||
if (!payment_hash || !payment_request) {
|
||||
throw new Error('LNbits returned an unexpected invoice payload');
|
||||
}
|
||||
|
||||
return { payment_hash, payment_request, checking_id };
|
||||
}
|
||||
|
||||
export interface PaymentVerifyResult {
|
||||
paid: boolean;
|
||||
amountMsat?: number;
|
||||
}
|
||||
|
||||
/** Verify invoice belongs to our wallet and is paid (requires API key). */
|
||||
export async function verifyIncomingPaymentPaid(paymentHash: string): Promise<PaymentVerifyResult> {
|
||||
if (!LNBITS_API_KEY) {
|
||||
throw new Error('LNBITS_API_KEY is not configured');
|
||||
}
|
||||
|
||||
const res = await fetch(`${LNBITS_URL}/api/v1/payments/${paymentHash}`, {
|
||||
headers: { 'X-Api-Key': LNBITS_API_KEY },
|
||||
});
|
||||
|
||||
if (res.status === 404) {
|
||||
return { paid: false };
|
||||
}
|
||||
|
||||
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
|
||||
if (!res.ok) {
|
||||
return { paid: false };
|
||||
}
|
||||
|
||||
const paid = data.paid === true;
|
||||
let amountMsat: number | undefined;
|
||||
const details = data.details as Record<string, unknown> | undefined;
|
||||
if (details && typeof details.amount === 'number') {
|
||||
amountMsat = details.amount;
|
||||
}
|
||||
|
||||
return { paid, amountMsat };
|
||||
}
|
||||
|
||||
/** Public LNbits poll (no key): { paid: boolean } */
|
||||
export async function getPublicPaymentStatus(paymentHash: string): Promise<boolean> {
|
||||
const res = await fetch(`${LNBITS_URL}/api/v1/payments/${paymentHash}`);
|
||||
if (!res.ok) return false;
|
||||
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
return data.paid === true;
|
||||
}
|
||||
Reference in New Issue
Block a user