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:
bbe
2026-04-03 18:37:52 +02:00
parent 7acff1ae38
commit 586b572f73
14 changed files with 1257 additions and 5 deletions

View 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;
}