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:
@@ -5,10 +5,10 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:push": "dotenv -e ../.env -- prisma db push",
|
||||
"db:seed": "dotenv -e ../.env -- prisma db seed",
|
||||
"db:studio": "dotenv -e ../.env -- prisma studio",
|
||||
"migrate:deploy": "dotenv -e ../.env -- prisma migrate deploy"
|
||||
"db:push": "dotenv -e ../.env -e .env -- prisma db push",
|
||||
"db:seed": "dotenv -e ../.env -e .env -- prisma db seed",
|
||||
"db:studio": "dotenv -e ../.env -e .env -- prisma studio",
|
||||
"migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
|
||||
@@ -147,3 +147,27 @@ model Faq {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Pending Lightning invoice metadata (message is created only after payment via webhook).
|
||||
model BoardInvoicePending {
|
||||
paymentHash String @id
|
||||
content String
|
||||
guestName String?
|
||||
pubkey String?
|
||||
profilePic String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
/// Paid board message (LNbits payment confirmed).
|
||||
model BoardMessage {
|
||||
id String @id @default(uuid())
|
||||
paymentHash String @unique
|
||||
content String
|
||||
authorName String
|
||||
pubkey String?
|
||||
profilePic String?
|
||||
satsPaid Int
|
||||
status String @default("active") // active, hidden, deleted
|
||||
likeCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
68
backend/src/api/adminMessages.ts
Normal file
68
backend/src/api/adminMessages.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { prisma } from '../db/prisma';
|
||||
import { requireAuth, requireRole } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth, requireRole(['ADMIN', 'MODERATOR']));
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const messages = await prisma.boardMessage.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(messages);
|
||||
} catch (err) {
|
||||
console.error('Admin list board messages error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/** Toggle active <-> hidden */
|
||||
router.post('/:id/hide', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const rawId = req.params.id;
|
||||
const id = Array.isArray(rawId) ? rawId[0] : rawId;
|
||||
const msg = await prisma.boardMessage.findUnique({ where: { id } });
|
||||
if (!msg) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
if (msg.status === 'deleted') {
|
||||
res.status(400).json({ error: 'Message is deleted' });
|
||||
return;
|
||||
}
|
||||
const next = msg.status === 'hidden' ? 'active' : 'hidden';
|
||||
const updated = await prisma.boardMessage.update({
|
||||
where: { id: msg.id },
|
||||
data: { status: next },
|
||||
});
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
console.error('Admin hide board message error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/** Soft-delete */
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const rawId = req.params.id;
|
||||
const id = Array.isArray(rawId) ? rawId[0] : rawId;
|
||||
const msg = await prisma.boardMessage.findUnique({ where: { id } });
|
||||
if (!msg) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma.boardMessage.update({
|
||||
where: { id: msg.id },
|
||||
data: { status: 'deleted' },
|
||||
});
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
console.error('Admin delete board message error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
359
backend/src/api/messages.ts
Normal file
359
backend/src/api/messages.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
import { prisma } from '../db/prisma';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import {
|
||||
createIncomingInvoice,
|
||||
verifyIncomingPaymentPaid,
|
||||
getPublicPaymentStatus,
|
||||
} from '../services/lnbits';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const MAX_CONTENT = 300;
|
||||
const MAX_NAME = 80;
|
||||
const MESSAGE_PRICE_SATS = Math.max(1, parseInt(process.env.MESSAGE_PRICE_SATS || '1000', 10) || 1000);
|
||||
const WEBHOOK_BASE = (process.env.WEBHOOK_BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3000').replace(
|
||||
/\/$/,
|
||||
''
|
||||
);
|
||||
const WEBHOOK_SECRET = process.env.LNBITS_WEBHOOK_SECRET || '';
|
||||
|
||||
function sanitizeText(s: string, max: number): string {
|
||||
const t = s
|
||||
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.trim();
|
||||
return t.length > max ? t.slice(0, max) : t;
|
||||
}
|
||||
|
||||
/** LNbits sends `json=payment.json()` where `.json()` returns a Pydantic JSON
|
||||
* string — httpx double-encodes it, so Express may parse the body as a plain
|
||||
* string instead of an object. Unwrap up to two layers of JSON encoding. */
|
||||
function normalizeWebhookBody(raw: unknown): unknown {
|
||||
let v = raw;
|
||||
for (let i = 0; i < 2 && typeof v === 'string'; i++) {
|
||||
try { v = JSON.parse(v); } catch { break; }
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
const HEX64 = /^[a-f0-9]{64}$/i;
|
||||
|
||||
function extractPaymentHash(body: unknown): string | null {
|
||||
const o = normalizeWebhookBody(body);
|
||||
if (!o || typeof o !== 'object') return null;
|
||||
const r = o as Record<string, unknown>;
|
||||
for (const key of ['payment_hash', 'paymentHash', 'checking_id']) {
|
||||
const v = r[key];
|
||||
if (typeof v === 'string' && HEX64.test(v)) return v.toLowerCase();
|
||||
}
|
||||
const p = r.payment;
|
||||
if (p && typeof p === 'object') {
|
||||
const nested = p as Record<string, unknown>;
|
||||
for (const key of ['payment_hash', 'paymentHash', 'checking_id']) {
|
||||
const v = nested[key];
|
||||
if (typeof v === 'string' && HEX64.test(v)) return v.toLowerCase();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function verifyWebhookSecret(req: Request): boolean {
|
||||
if (!WEBHOOK_SECRET) return true;
|
||||
const h = req.headers['x-bbe-webhook-secret'];
|
||||
if (typeof h === 'string' && safeEqualStr(h, WEBHOOK_SECRET)) return true;
|
||||
const auth = req.headers.authorization;
|
||||
if (auth?.startsWith('Bearer ')) {
|
||||
const t = auth.slice(7);
|
||||
if (safeEqualStr(t, WEBHOOK_SECRET)) return true;
|
||||
}
|
||||
const q = req.query.token;
|
||||
const token = typeof q === 'string' ? q : Array.isArray(q) ? q[0] : '';
|
||||
if (typeof token === 'string' && token.length > 0 && safeEqualStr(token, WEBHOOK_SECRET)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function safeEqualStr(a: string, b: string): boolean {
|
||||
try {
|
||||
const ab = Buffer.from(a, 'utf8');
|
||||
const bb = Buffer.from(b, 'utf8');
|
||||
if (ab.length !== bb.length) return false;
|
||||
return timingSafeEqual(ab, bb);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Public: board config for the frontend */
|
||||
router.get('/config', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
priceSats: MESSAGE_PRICE_SATS,
|
||||
bbeZapPubkey: process.env.BOARD_ZAP_PUBKEY || null,
|
||||
bbeZapAddress: process.env.BOARD_ZAP_LN_ADDRESS || null,
|
||||
});
|
||||
});
|
||||
|
||||
/** Create Lightning invoice for a new message (no BoardMessage row yet). */
|
||||
router.post('/invoice', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { content, name, pubkey, profilePic, postAsAnon } = req.body as Record<string, unknown>;
|
||||
|
||||
if (typeof content !== 'string') {
|
||||
res.status(400).json({ error: 'content is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanContent = sanitizeText(content, MAX_CONTENT);
|
||||
if (!cleanContent) {
|
||||
res.status(400).json({ error: 'content is empty' });
|
||||
return;
|
||||
}
|
||||
|
||||
let guestName: string | null = null;
|
||||
let authorPubkey: string | null = null;
|
||||
let pic: string | null = null;
|
||||
|
||||
const pk = typeof pubkey === 'string' && /^[a-f0-9]{64}$/i.test(pubkey) ? pubkey.toLowerCase() : null;
|
||||
const asAnon = postAsAnon === true;
|
||||
|
||||
if (pk && !asAnon) {
|
||||
authorPubkey = pk;
|
||||
if (typeof profilePic === 'string' && profilePic.length > 0 && profilePic.length < 2048) {
|
||||
pic = profilePic.slice(0, 2048);
|
||||
}
|
||||
const n = typeof name === 'string' ? sanitizeText(name, MAX_NAME) : '';
|
||||
guestName = n || null;
|
||||
} else {
|
||||
const n = typeof name === 'string' ? sanitizeText(name, MAX_NAME) : '';
|
||||
guestName = n || null;
|
||||
}
|
||||
|
||||
const webhookPath = WEBHOOK_SECRET
|
||||
? `/api/messages/webhook?token=${encodeURIComponent(WEBHOOK_SECRET)}`
|
||||
: '/api/messages/webhook';
|
||||
const webhookUrl = `${WEBHOOK_BASE}${webhookPath}`;
|
||||
|
||||
const { payment_hash, payment_request, checking_id } = await createIncomingInvoice({
|
||||
amountSats: MESSAGE_PRICE_SATS,
|
||||
memo: 'BBE message board',
|
||||
webhookUrl,
|
||||
});
|
||||
|
||||
await prisma.boardInvoicePending.upsert({
|
||||
where: { paymentHash: payment_hash },
|
||||
create: {
|
||||
paymentHash: payment_hash,
|
||||
content: cleanContent,
|
||||
guestName,
|
||||
pubkey: authorPubkey,
|
||||
profilePic: pic,
|
||||
},
|
||||
update: {
|
||||
content: cleanContent,
|
||||
guestName,
|
||||
pubkey: authorPubkey,
|
||||
profilePic: pic,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
payment_request,
|
||||
checking_id,
|
||||
payment_hash,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Board invoice error:', err);
|
||||
const msg = err instanceof Error ? err.message : 'Internal server error';
|
||||
if (msg.includes('LNBITS_API_KEY')) {
|
||||
res.status(503).json({ error: 'Lightning payments are not configured' });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Shared logic: verify payment on LNbits, look up pending row, create BoardMessage.
|
||||
* Returns { ok, duplicate?, ignored?, error? }.
|
||||
*/
|
||||
async function promotePendingToMessage(paymentHash: string): Promise<{
|
||||
ok: boolean;
|
||||
duplicate?: boolean;
|
||||
ignored?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const existing = await prisma.boardMessage.findUnique({ where: { paymentHash } });
|
||||
if (existing) {
|
||||
await prisma.boardInvoicePending.deleteMany({ where: { paymentHash } });
|
||||
return { ok: true, duplicate: true };
|
||||
}
|
||||
|
||||
const verified = await verifyIncomingPaymentPaid(paymentHash);
|
||||
if (!verified.paid) {
|
||||
return { ok: true, ignored: 'not_paid' };
|
||||
}
|
||||
|
||||
const expectedMsat = MESSAGE_PRICE_SATS * 1000;
|
||||
if (verified.amountMsat != null && verified.amountMsat < expectedMsat) {
|
||||
console.warn('Board: amount mismatch', paymentHash, verified.amountMsat);
|
||||
return { ok: true, ignored: 'amount' };
|
||||
}
|
||||
|
||||
const pending = await prisma.boardInvoicePending.findUnique({ where: { paymentHash } });
|
||||
if (!pending) {
|
||||
return { ok: true, ignored: 'no_pending' };
|
||||
}
|
||||
|
||||
const authorName = pending.pubkey
|
||||
? pending.guestName && pending.guestName.length > 0
|
||||
? pending.guestName
|
||||
: 'Nostr user'
|
||||
: pending.guestName && pending.guestName.length > 0
|
||||
? pending.guestName
|
||||
: 'anon';
|
||||
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.boardMessage.create({
|
||||
data: {
|
||||
paymentHash,
|
||||
content: pending.content,
|
||||
authorName,
|
||||
pubkey: pending.pubkey,
|
||||
profilePic: pending.profilePic,
|
||||
satsPaid: MESSAGE_PRICE_SATS,
|
||||
status: 'active',
|
||||
},
|
||||
}),
|
||||
prisma.boardInvoicePending.delete({ where: { paymentHash } }),
|
||||
]);
|
||||
} catch (e: unknown) {
|
||||
const code = e && typeof e === 'object' && 'code' in e ? (e as { code: string }).code : '';
|
||||
if (code === 'P2002') {
|
||||
await prisma.boardInvoicePending.deleteMany({ where: { paymentHash } });
|
||||
return { ok: true, duplicate: true };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** LNbits calls this when an invoice is paid */
|
||||
router.post('/webhook', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!verifyWebhookSecret(req)) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentHash = extractPaymentHash(req.body);
|
||||
if (!paymentHash) {
|
||||
const preview = typeof req.body === 'string' ? req.body.slice(0, 200) : JSON.stringify(req.body).slice(0, 200);
|
||||
console.warn('Board webhook: could not extract payment_hash', typeof req.body, preview);
|
||||
res.status(200).json({ ok: false, error: 'missing payment_hash' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await promotePendingToMessage(paymentHash);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('Board webhook error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/** Client-side reconciliation: verify payment and create message if webhook was missed. */
|
||||
router.post('/confirm/:paymentHash', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const raw = req.params.paymentHash;
|
||||
const paymentHash = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!paymentHash || !HEX64.test(paymentHash)) {
|
||||
res.status(400).json({ error: 'invalid payment_hash' });
|
||||
return;
|
||||
}
|
||||
const result = await promotePendingToMessage(paymentHash.toLowerCase());
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('Board confirm error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/** Poll payment status (server-side LNbits public API — avoids CORS). */
|
||||
router.get('/payment/:paymentHash/status', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const raw = req.params.paymentHash;
|
||||
const paymentHash = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!paymentHash || !/^[a-f0-9]{64}$/i.test(paymentHash)) {
|
||||
res.status(400).json({ error: 'invalid payment_hash' });
|
||||
return;
|
||||
}
|
||||
const hash = paymentHash.toLowerCase();
|
||||
const paid = await getPublicPaymentStatus(hash);
|
||||
const message = await prisma.boardMessage.findUnique({
|
||||
where: { paymentHash: hash },
|
||||
select: { id: true },
|
||||
});
|
||||
res.json({ paid: paid || !!message, messageCreated: !!message });
|
||||
} catch (err) {
|
||||
console.error('Board payment status error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/** Public list: active messages only */
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const messages = await prisma.boardMessage.findMany({
|
||||
where: { status: 'active' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
paymentHash: true,
|
||||
content: true,
|
||||
authorName: true,
|
||||
pubkey: true,
|
||||
profilePic: true,
|
||||
satsPaid: true,
|
||||
likeCount: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
res.json(messages);
|
||||
} catch (err) {
|
||||
console.error('List board messages error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/** Like (Nostr-attributed messages only; logged-in users) */
|
||||
router.post('/:id/like', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const rawId = req.params.id;
|
||||
const id = Array.isArray(rawId) ? rawId[0] : rawId;
|
||||
const msg = await prisma.boardMessage.findUnique({ where: { id } });
|
||||
if (!msg || msg.status !== 'active') {
|
||||
res.status(404).json({ error: 'Message not found' });
|
||||
return;
|
||||
}
|
||||
if (!msg.pubkey) {
|
||||
res.status(400).json({ error: 'Likes are only for Nostr-attributed messages' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma.boardMessage.update({
|
||||
where: { id: msg.id },
|
||||
data: { likeCount: { increment: 1 } },
|
||||
select: { likeCount: true },
|
||||
});
|
||||
res.json({ likeCount: updated.likeCount });
|
||||
} catch (err) {
|
||||
console.error('Board like error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -21,6 +21,8 @@ import mediaRouter from './api/media';
|
||||
import faqsRouter from './api/faqs';
|
||||
import calendarRouter from './api/calendar';
|
||||
import nip05Router from './api/nip05';
|
||||
import messagesRouter from './api/messages';
|
||||
import adminMessagesRouter from './api/adminMessages';
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
|
||||
@@ -48,6 +50,8 @@ app.use('/api/media', mediaRouter);
|
||||
app.use('/api/faqs', faqsRouter);
|
||||
app.use('/api/calendar', calendarRouter);
|
||||
app.use('/api/nip05', nip05Router);
|
||||
app.use('/api/messages', messagesRouter);
|
||||
app.use('/api/admin/messages', adminMessagesRouter);
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
|
||||
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