import crypto from 'node:crypto' import fs from 'node:fs/promises' import { existsSync, createReadStream } from 'node:fs' import http from 'node:http' import path from 'node:path' import { fileURLToPath } from 'node:url' import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools' import { decryptMembership, encryptMembership } from './encryption.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.resolve(__dirname, '..') const dataDir = path.join(rootDir, 'server', 'data') const distDir = path.join(rootDir, 'dist') const port = Number(process.env.PORT || 3001) const host = process.env.HOST || '127.0.0.1' const appMode = ['public', 'admin', 'all'].includes(process.env.APP_MODE) ? process.env.APP_MODE : 'all' const seedDevMembers = process.env.DEV_SEED_MEMBERS === 'true' const membershipMonthlyUsd = 350 const bitcoinFallbackUsd = 79592.095 const accessHmacKey = process.env.ACCESS_HMAC_KEY || process.env.MEMBERSHIP_ENCRYPTION_KEY || 'development-access-hmac-key' const controllerApiToken = process.env.ACCESS_CONTROLLER_TOKEN || '' const btcpayServerUrl = String(process.env.BTCPAY_SERVER_URL || '').replace(/\/+$/, '') const btcpayApiKey = process.env.BTCPAY_API_KEY || '' const btcpayStoreId = process.env.BTCPAY_STORE_ID || '' const btcpayWebhookSecret = process.env.BTCPAY_WEBHOOK_SECRET || '' const adminPubkeys = [ '7b849efa5604b58d50c419637b9873847dbf957081d526136c3a49b7357cd617', '2be35e9237eaf98fe0a7d1ce3ceb666dccde9c8cc800ff52f138a62c91d90783', ] const files = { memberships: path.join(dataDir, 'memberships.json'), payments: path.join(dataDir, 'payments.json'), cards: path.join(dataDir, 'cards.json'), accessLogs: path.join(dataDir, 'access-logs.json'), } const state = { memberships: [], payments: [], cards: [], accessLogs: [], } let bitcoinPriceCache = null const adminEventClients = new Set() const publicApiEnabled = () => appMode !== 'admin' const adminApiEnabled = () => appMode !== 'public' const json = (res, status, body) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', }) res.end(JSON.stringify(body)) } const ensureStore = async () => { await fs.mkdir(dataDir, { recursive: true, mode: 0o700 }) for (const file of Object.values(files)) { if (!existsSync(file)) await fs.writeFile(file, '[]', { mode: 0o600 }) } } const loadJson = async (file) => JSON.parse(await fs.readFile(file, 'utf8')) const saveJson = async (file, value) => fs.writeFile(file, JSON.stringify(value, null, 2), { mode: 0o600 }) const loadStore = async () => { await ensureStore() state.memberships = await loadJson(files.memberships) state.payments = await loadJson(files.payments) state.cards = await loadJson(files.cards) state.accessLogs = await loadJson(files.accessLogs) } const saveMemberships = () => saveJson(files.memberships, state.memberships) const savePayments = () => saveJson(files.payments, state.payments) const saveCards = () => saveJson(files.cards, state.cards) const saveAccessLogs = () => saveJson(files.accessLogs, state.accessLogs) const readBody = async (req) => { const raw = await readRawBody(req) return raw.length ? JSON.parse(raw.toString('utf8')) : {} } const readRawBody = async (req) => { const chunks = [] let size = 0 for await (const chunk of req) { size += chunk.length if (size > 8 * 1024 * 1024) throw new Error('Request body too large') chunks.push(chunk) } return Buffer.concat(chunks) } const cleanText = (value, max = 160) => String(value || '') .normalize('NFKC') .replace(/[\u0000-\u001f\u007f<>`{}[\]\\]/g, '') .replace(/\s+/g, ' ') .trim() .slice(0, max) const createId = (prefix) => `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}` const hmacHex = (value) => crypto.createHmac('sha256', accessHmacKey).update(String(value)).digest('hex') const decryptMember = (member) => decryptMembership(member) const encryptedMembers = () => state.memberships const members = () => state.memberships.map(decryptMember) const findMember = (predicate) => members().find(predicate) const activePaymentFor = (membershipId) => state.payments.find((payment) => payment.membershipId === membershipId && payment.status === 'paid') const activeCardFor = (membershipId) => state.cards.find((card) => card.membershipId === membershipId && card.status === 'active') const memberAccessStatus = (member) => { if (!member) return 'unknown' if (member.status === 'revoked' || member.status === 'suspended') return member.status if (new Date(member.expiresAt) <= new Date()) return 'expired' if (activePaymentFor(member.membershipId) && activeCardFor(member.membershipId)) return 'active' if (activePaymentFor(member.membershipId)) return 'pending_card' return member.status || 'requested' } const validateMember = (data) => { const signerNpubs = Array.isArray(data.signerNpubs) ? data.signerNpubs.map((item) => cleanText(item, 80)).filter(Boolean) : [] const status = ['pending_payment', 'active', 'suspended', 'expired', 'revoked'] .includes(data.status) ? data.status : 'requested' const member = { membershipId: cleanText(data.membershipId, 32).toUpperCase(), userId: cleanText(data.userId, 80), fullName: cleanText(data.fullName, 80), email: cleanText(data.email, 160).toLowerCase(), phone: cleanText(data.phone, 32), signature: String(data.signature || ''), signedDate: String(data.signedDate || ''), createdAt: String(data.createdAt || new Date().toISOString()), expiresAt: String(data.expiresAt || ''), status, npub: cleanText(data.npub, 80), signerNpubs, nsecHash: cleanText(data.nsecHash, 64).toLowerCase(), } const errors = [] if (!/^L484-\d{4}-[A-Z0-9]{6}$/.test(member.membershipId)) errors.push('Invalid membership ID.') if (member.fullName.length < 2) errors.push('Full name is required.') if (member.email && !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(member.email)) errors.push('Invalid email.') if (member.phone && !/^[0-9()+.\-\s]{7,32}$/.test(member.phone)) errors.push('Invalid phone.') if (!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(member.signature)) errors.push('Signature is required.') if (Number.isNaN(new Date(member.signedDate).getTime())) errors.push('Invalid signed date.') if (Number.isNaN(new Date(member.createdAt).getTime())) errors.push('Invalid created date.') if (member.expiresAt && Number.isNaN(new Date(member.expiresAt).getTime())) errors.push('Invalid expiry date.') if (!/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(member.npub)) errors.push('Invalid npub.') if (member.signerNpubs.some((npub) => !/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(npub))) errors.push('Invalid signer npub.') if (!/^[0-9a-f]{64}$/.test(member.nsecHash)) errors.push('Invalid nsec hash.') if (!member.expiresAt) { const expiresAt = new Date(member.createdAt) expiresAt.setFullYear(expiresAt.getFullYear() + 1) member.expiresAt = expiresAt.toISOString() } return { member, errors } } const requireAdmin = (req, res) => { if (!adminApiEnabled()) { json(res, 404, { error: 'Admin API is disabled on this deployment.' }) return false } const auth = req.headers.authorization || '' const pubkey = auth.startsWith('Bearer ') ? auth.slice(7).trim().toLowerCase() : '' if (!adminPubkeys.includes(pubkey)) { json(res, 403, { error: 'Admin access required.' }) return false } return true } const isAdminPubkey = (pubkey) => adminPubkeys.includes(String(pubkey || '').toLowerCase()) const broadcastAdminEvent = (event, payload = {}) => { const message = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n` for (const client of adminEventClients) { client.write(message) } } const hasControllerAuth = (req) => { const token = String(req.headers['x-controller-token'] || '') if (!controllerApiToken || !token) return false const provided = Buffer.from(token) const expected = Buffer.from(controllerApiToken) return provided.length === expected.length && crypto.timingSafeEqual(provided, expected) } const serveStatic = (req, res) => { const requested = decodeURIComponent(new URL(req.url, `http://${req.headers.host}`).pathname) const filePath = requested === '/' ? path.join(distDir, 'index.html') : path.join(distDir, requested) const safePath = filePath.startsWith(distDir) && existsSync(filePath) ? filePath : path.join(distDir, 'index.html') const ext = path.extname(safePath) const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.avif': 'image/avif', '.json': 'application/json', '.webmanifest': 'application/manifest+json', } res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' }) createReadStream(safePath).pipe(res) } const getBitcoinPrice = async () => { const now = Date.now() if (bitcoinPriceCache && now - bitcoinPriceCache.fetchedAt < 60_000) return bitcoinPriceCache const sources = [ { source: 'Coinbase BTC-USD spot', url: 'https://api.coinbase.com/v2/prices/BTC-USD/spot', parse: (payload) => Number(payload?.data?.amount) }, { source: 'CoinGecko BTC-USD spot', url: 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd', parse: (payload) => Number(payload?.bitcoin?.usd) }, { source: 'Binance US BTC-USD spot', url: 'https://api.binance.us/api/v3/ticker/price?symbol=BTCUSD', parse: (payload) => Number(payload?.price) }, ] const fetchSource = async ({ source, url, parse }) => { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 2500) try { const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } }) if (!response.ok) throw new Error(`${source} request failed: ${response.status}`) const usd = parse(await response.json()) if (!Number.isFinite(usd) || usd <= 0) throw new Error(`${source} returned an invalid BTC price.`) return { source, usd } } finally { clearTimeout(timeout) } } try { const { source, usd } = await Promise.any(sources.map(fetchSource)) bitcoinPriceCache = { usd, membershipUsd: membershipMonthlyUsd, membershipBtc: membershipMonthlyUsd / usd, fetchedAt: now, source } } catch { bitcoinPriceCache = { usd: bitcoinFallbackUsd, membershipUsd: membershipMonthlyUsd, membershipBtc: membershipMonthlyUsd / bitcoinFallbackUsd, fetchedAt: now, source: 'Fallback BTC-USD estimate', fallback: true, } } return bitcoinPriceCache } const upsertMember = async (member) => { const existingIndex = members().findIndex((item) => item.membershipId === member.membershipId || item.userId === member.userId || item.npub === member.npub || item.signerNpubs?.includes(member.npub) || member.signerNpubs.includes(item.npub) || member.signerNpubs.some((npub) => item.signerNpubs?.includes(npub)) || item.nsecHash === member.nsecHash ) const encrypted = encryptMembership(member) if (existingIndex >= 0) encryptedMembers()[existingIndex] = encrypted else encryptedMembers().unshift(encrypted) await saveMemberships() return existingIndex } const updateMemberStatus = async (membershipId, status) => { const index = members().findIndex((member) => member.membershipId === membershipId) if (index < 0) return null const member = { ...decryptMember(state.memberships[index]), status } state.memberships[index] = encryptMembership(member) await saveMemberships() return member } const serializeAdmin = () => { const allMembers = members() return { success: true, memberships: allMembers.map((member) => ({ ...member, accessStatus: memberAccessStatus(member) })), payments: state.payments, cards: state.cards.map(({ cardSecretHash, uidHash, ...card }) => card), accessLogs: state.accessLogs.slice(0, 200), } } const recordAccess = async (entry) => { state.accessLogs.unshift({ id: createId('access'), seenAt: new Date().toISOString(), ...entry, }) state.accessLogs = state.accessLogs.slice(0, 1000) await saveAccessLogs() } const createSeedMember = (index, status, daysAgo = index) => { const createdAt = new Date() createdAt.setDate(createdAt.getDate() - daysAgo) const expiresAt = new Date(createdAt) expiresAt.setFullYear(expiresAt.getFullYear() + 1) if (status === 'expired') expiresAt.setDate(new Date().getDate() - 2) const secretKey = generateSecretKey() const pubkey = getPublicKey(secretKey) const names = [ 'Zaza Stone', 'Mara Vale', 'Nico Ward', 'Ada Cross', 'Iris Hale', 'Theo Knox', 'Lena Frost', 'Oren Pike', 'Mina Sol', 'Jules Voss', 'Rafe Quinn', 'Sage Rowan', ] const suffix = String(index + 1).padStart(2, '0') return { membershipId: `L484-${createdAt.getFullYear()}-DEV${suffix}X`, userId: `l484-dev-user-${suffix}`, fullName: names[index], email: `member${suffix}@example.test`, phone: `+1 305 555 01${suffix}`, signature: 'data:image/png;base64,iVBORw0KGgo=', signedDate: createdAt.toISOString(), createdAt: createdAt.toISOString(), expiresAt: expiresAt.toISOString(), status, npub: nip19.npubEncode(pubkey), signerNpubs: [], nsecHash: crypto.createHash('sha256').update(nip19.nsecEncode(secretKey)).digest('hex'), } } const seedDevelopmentStore = async () => { if (!seedDevMembers) return const specs = [ ['requested', 1], ['requested', 2], ['requested', 3], ['pending_payment', 4], ['pending_payment', 5], ['pending_payment', 6], ['active', 7], ['active', 8], ['active', 9], ['suspended', 10], ['suspended', 11], ['expired', 380], ] const seedMembers = specs.map(([status, daysAgo], index) => createSeedMember(index, status, daysAgo)) const existingMembers = members() const existingMembershipIds = new Set(existingMembers.map((member) => member.membershipId)) const newSeedMembers = seedMembers.filter((member) => !existingMembershipIds.has(member.membershipId)) if (newSeedMembers.length) { state.memberships = [...state.memberships, ...newSeedMembers.map(encryptMembership)] } const existingPaymentIds = new Set(state.payments.map((payment) => payment.membershipId)) const paidMembers = seedMembers.filter((member) => ['active', 'suspended', 'expired'].includes(member.status) && !existingPaymentIds.has(member.membershipId)) const pendingMembers = seedMembers.filter((member) => member.status === 'pending_payment' && !existingPaymentIds.has(member.membershipId)) const newPayments = [ ...paidMembers.map((member, index) => ({ id: `payment-dev-paid-${member.membershipId.slice(-6).toLowerCase()}`, membershipId: member.membershipId, provider: index % 2 ? 'btcpay' : 'cash', amountUsd: membershipMonthlyUsd, amountSats: index % 2 ? 420000 : 0, status: 'paid', btcpayInvoiceId: index % 2 ? `dev-invoice-paid-${index + 1}` : '', checkoutLink: index % 2 ? `${btcpayServerUrl || 'https://shop.tx1138.com'}/i/dev-paid-${index + 1}` : '', createdAt: member.createdAt, paidAt: member.createdAt, })), ...pendingMembers.map((member, index) => ({ id: `payment-dev-pending-${member.membershipId.slice(-6).toLowerCase()}`, membershipId: member.membershipId, provider: 'btcpay', amountUsd: membershipMonthlyUsd, amountSats: 420000, status: 'pending', btcpayInvoiceId: `dev-invoice-pending-${index + 1}`, checkoutLink: `${btcpayServerUrl || 'https://shop.tx1138.com'}/i/dev-pending-${index + 1}`, createdAt: member.createdAt, paidAt: '', })), ] state.payments = [...state.payments, ...newPayments] const existingCardIds = new Set(state.cards.map((card) => card.membershipId)) const cardMembers = seedMembers.filter((member) => ['active', 'suspended'].includes(member.status) && !existingCardIds.has(member.membershipId)) const newCards = cardMembers.map((member, index) => { const credential = `dev-card-${index + 1}-${member.membershipId}` return { id: `card-dev-${member.membershipId.slice(-6).toLowerCase()}`, membershipId: member.membershipId, cardPublicId: `L484-CARD-DEV${String(index + 1).padStart(2, '0')}`, cardSecretHash: hmacHex(credential), uidHash: hmacHex(credential), status: 'active', issuedAt: member.createdAt, lastSeenAt: index < 3 ? new Date().toISOString() : '', notes: 'Development seed card', } }) state.cards = [...state.cards, ...newCards] const logCards = newCards.length ? newCards : state.cards const shouldSeedAccessLogs = newCards.length || !state.accessLogs.length const newAccessLogs = shouldSeedAccessLogs ? Array.from({ length: Math.min(18, Math.max(logCards.length * 3, 0)) }, (_, index) => { const card = logCards[index % logCards.length] const member = newSeedMembers.find((item) => item.membershipId === card?.membershipId) || existingMembers.find((item) => item.membershipId === card?.membershipId) const seenAt = new Date() seenAt.setHours(seenAt.getHours() - index * 3) const allow = member?.status === 'active' return { id: `access-dev-${Date.now()}-${index + 1}`, seenAt: seenAt.toISOString(), membershipId: member?.membershipId || '', cardId: card?.id || '', cardPublicId: card?.cardPublicId || '', doorId: index % 2 ? 'plunge-door' : 'front-door', decision: allow ? 'allow' : 'deny', reason: allow ? 'active_member_card' : `member_${member?.status || 'unknown'}`, } }) : [] state.accessLogs = [...newAccessLogs, ...state.accessLogs] if (!newSeedMembers.length && !newPayments.length && !newCards.length && !newAccessLogs.length) return await Promise.all([saveMemberships(), savePayments(), saveCards(), saveAccessLogs()]) console.log(`Seeded development fixtures: ${newSeedMembers.length} members, ${newPayments.length} payments, ${newCards.length} cards.`) } const btcpayConfigured = () => Boolean(btcpayServerUrl && btcpayApiKey && btcpayStoreId) const extractBitcoinAddress = (link) => { if (typeof link !== 'string' || !link.startsWith('bitcoin:')) return '' return link.slice('bitcoin:'.length).split('?')[0] || '' } const extractBolt11 = (link) => { if (typeof link !== 'string') return '' if (link.startsWith('lightning:')) return link.slice('lightning:'.length) return link.toLowerCase().startsWith('lnbc') ? link : '' } const normalizeBtcpayPaymentMethods = (paymentMethods = []) => { let lightning = null let bitcoin = null for (const method of paymentMethods) { const id = String(method.paymentMethodId || method.paymentMethod || method.id || '') const idUpper = id.toUpperCase() const paymentLink = String(method.paymentLink || '') const destination = method.destination || method.address || method.bolt11 || extractBolt11(paymentLink) || extractBitcoinAddress(paymentLink) || '' if (!lightning && (idUpper.includes('LN') || idUpper.includes('LIGHTNING'))) { const bolt11 = method.bolt11 || extractBolt11(paymentLink) || (String(destination).toLowerCase().startsWith('lnbc') ? destination : '') if (bolt11) lightning = { destination: bolt11, paymentLink } } if (!bitcoin && idUpper.includes('BTC') && !idUpper.includes('LN')) { const address = method.address || (String(destination).match(/^(bc1|[13])/i) ? destination : '') || extractBitcoinAddress(paymentLink) if (address) bitcoin = { destination: address, paymentLink } } if (lightning && bitcoin) break } return { lightning, bitcoin } } const fetchBtcpayPaymentMethods = async (invoiceId) => { if (!btcpayConfigured() || !invoiceId) return [] const response = await fetch(`${btcpayServerUrl}/api/v1/stores/${btcpayStoreId}/invoices/${invoiceId}/payment-methods`, { headers: { Accept: 'application/json', Authorization: `token ${btcpayApiKey}`, }, }) if (!response.ok) return [] const data = await response.json().catch(() => []) return Array.isArray(data) ? data : [] } const createBtcpayInvoice = async (member) => { if (!btcpayConfigured()) throw new Error('BTCPay credentials are not configured.') const response = await fetch(`${btcpayServerUrl}/api/v1/stores/${btcpayStoreId}/invoices`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `token ${btcpayApiKey}`, }, body: JSON.stringify({ amount: membershipMonthlyUsd, currency: 'USD', metadata: { orderId: member.membershipId, itemDesc: 'L484 monthly membership', }, }), }) const data = await response.json().catch(() => ({})) if (!response.ok) throw new Error(data.message || data.error || `BTCPay request failed: ${response.status}`) return data } const fetchBtcpayInvoice = async (invoiceId) => { if (!btcpayConfigured()) throw new Error('BTCPay credentials are not configured.') const response = await fetch(`${btcpayServerUrl}/api/v1/stores/${btcpayStoreId}/invoices/${invoiceId}`, { headers: { Accept: 'application/json', Authorization: `token ${btcpayApiKey}`, }, }) const data = await response.json().catch(() => ({})) if (!response.ok) throw new Error(data.message || data.error || `BTCPay status request failed: ${response.status}`) return data } const verifyBtcpayWebhook = (req, rawBody) => { if (!btcpayWebhookSecret) return false const header = String(req.headers['btcpay-sig'] || '') const digest = `sha256=${crypto.createHmac('sha256', btcpayWebhookSecret).update(rawBody).digest('hex')}` const headerBuffer = Buffer.from(header, 'utf8') const digestBuffer = Buffer.from(digest, 'utf8') return headerBuffer.length === digestBuffer.length && crypto.timingSafeEqual(headerBuffer, digestBuffer) } const markInvoicePaid = async (invoiceId) => { const payment = state.payments.find((item) => item.btcpayInvoiceId === invoiceId) if (!payment) return null payment.status = 'paid' payment.paidAt = new Date().toISOString() await savePayments() await updateMemberStatus(payment.membershipId, activeCardFor(payment.membershipId) ? 'active' : 'pending_payment') broadcastAdminEvent('payment-paid', { invoiceId, membershipId: payment.membershipId, paymentId: payment.id, paidAt: payment.paidAt, }) return payment } const paymentStatusPayload = async (payment) => { let invoice = null let paymentMethods = [] if (payment.provider === 'btcpay' && payment.btcpayInvoiceId && btcpayConfigured()) { invoice = await fetchBtcpayInvoice(payment.btcpayInvoiceId).catch(() => null) paymentMethods = await fetchBtcpayPaymentMethods(payment.btcpayInvoiceId).catch(() => []) const status = cleanText(invoice?.status || invoice?.invoiceStatus, 80) if (['Settled', 'Processing'].includes(status) && payment.status !== 'paid') { await markInvoicePaid(payment.btcpayInvoiceId) payment = state.payments.find((item) => item.id === payment.id) || payment } } const { lightning, bitcoin } = normalizeBtcpayPaymentMethods(paymentMethods) const checkoutLink = payment.checkoutLink || invoice?.checkoutLink || invoice?.paymentLink || '' const lightningInvoice = lightning?.destination || invoice?.lightningInvoice || invoice?.invoice || '' const paymentAddress = bitcoin?.destination || invoice?.paymentAddress || '' return { success: true, paid: payment.status === 'paid', status: invoice?.status || (payment.status === 'paid' ? 'Settled' : 'New'), invoice: { id: payment.btcpayInvoiceId || payment.id, amount: payment.amountUsd, currency: 'USD', status: invoice?.status || (payment.status === 'paid' ? 'Settled' : 'New'), paymentUrl: checkoutLink, checkoutLink, lightningInvoice, paymentAddress, qrCodeData: lightningInvoice || paymentAddress || invoice?.qrCodeData || checkoutLink, lightningPaymentLink: lightning?.paymentLink || '', bitcoinPaymentLink: bitcoin?.paymentLink || '', createdAt: payment.createdAt, expiresAt: invoice?.expirationTime ? new Date(Number(invoice.expirationTime) * 1000).toISOString() : '', }, } } const handleApi = async (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`) if (req.method === 'GET' && url.pathname === '/api/health') return json(res, 200, { ok: true, mode: appMode }) if (req.method === 'GET' && url.pathname === '/api/config') { return json(res, 200, { mode: appMode, publicMembershipEnabled: publicApiEnabled(), adminEnabled: adminApiEnabled(), accessEnabled: adminApiEnabled(), btcpayEnabled: btcpayConfigured(), }) } if (req.method === 'POST' && url.pathname === '/api/btcpay/webhook') { const rawBody = await readRawBody(req) if (!verifyBtcpayWebhook(req, rawBody)) return json(res, 401, { error: 'Invalid BTCPay signature.' }) const body = rawBody.length ? JSON.parse(rawBody.toString('utf8')) : {} const invoiceId = cleanText(body.invoiceId || body.id || body.data?.invoiceId || body.data?.id, 120) const eventType = cleanText(body.type, 80) const status = cleanText(body.status || body.invoiceStatus || body.data?.status || body.data?.invoiceStatus, 80) const paid = ['InvoiceSettled', 'InvoiceProcessing'].includes(eventType) || ['Settled', 'Processing'].includes(status) if (invoiceId && paid) await markInvoicePaid(invoiceId) return json(res, 200, { success: true }) } if (req.method === 'GET' && url.pathname === '/api/bitcoin-price') return json(res, 200, await getBitcoinPrice()) if (req.method === 'POST' && url.pathname === '/api/membership/create') { if (!publicApiEnabled()) return json(res, 404, { error: 'Public membership signup is disabled on this deployment.' }) const { member, errors } = validateMember(await readBody(req)) if (errors.length) return json(res, 400, { error: 'Validation failed.', errors }) member.status = 'requested' const existingIndex = await upsertMember(member) broadcastAdminEvent('membership-created', { membershipId: member.membershipId, fullName: member.fullName, createdAt: member.createdAt, updatedExisting: existingIndex >= 0, }) return json(res, existingIndex >= 0 ? 200 : 201, { success: true, membership: member }) } if (req.method === 'GET' && url.pathname === '/api/membership/check') { const membershipId = cleanText(url.searchParams.get('membershipId'), 32).toUpperCase() const userId = cleanText(url.searchParams.get('userId'), 80) const npub = cleanText(url.searchParams.get('npub'), 80) const found = findMember((item) => (membershipId && item.membershipId === membershipId) || (userId && item.userId === userId) || (npub && (item.npub === npub || item.signerNpubs?.includes(npub))) ) return json(res, 200, found ? { hasMembership: true, membership: found } : { hasMembership: false }) } if (req.method === 'POST' && url.pathname === '/api/membership/recover') { const body = await readBody(req) const nsecHash = cleanText(body.nsecHash, 64).toLowerCase() const found = findMember((item) => item.nsecHash === nsecHash) return json(res, found ? 200 : 404, found ? { success: true, membership: found } : { error: 'No membership found for that nsec.' }) } if (req.method === 'GET' && url.pathname === '/api/admin/state') { if (!requireAdmin(req, res)) return return json(res, 200, serializeAdmin()) } if (req.method === 'GET' && url.pathname === '/api/admin/events') { if (!adminApiEnabled()) return json(res, 404, { error: 'Admin API is disabled on this deployment.' }) const pubkey = cleanText(url.searchParams.get('pubkey'), 80).toLowerCase() if (!isAdminPubkey(pubkey)) return json(res, 403, { error: 'Admin access required.' }) res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }) res.write('event: ready\ndata: {"success":true}\n\n') adminEventClients.add(res) req.on('close', () => { adminEventClients.delete(res) }) return } if (req.method === 'GET' && url.pathname === '/api/memberships') { if (!requireAdmin(req, res)) return return json(res, 200, { success: true, memberships: serializeAdmin().memberships }) } if (req.method === 'PATCH' && url.pathname === '/api/membership/status') { if (!requireAdmin(req, res)) return const body = await readBody(req) const membershipId = cleanText(body.membershipId, 32).toUpperCase() const status = cleanText(body.status, 32) if (!['requested', 'pending_payment', 'active', 'suspended', 'expired', 'revoked'].includes(status)) { return json(res, 400, { error: 'Invalid membership status.' }) } const member = await updateMemberStatus(membershipId, status) return json(res, member ? 200 : 404, member ? { success: true, membership: member } : { error: 'Member not found.' }) } if (req.method === 'DELETE' && url.pathname === '/api/membership') { if (!requireAdmin(req, res)) return const { membershipId } = await readBody(req) const id = cleanText(membershipId, 32).toUpperCase() const before = state.memberships.length state.memberships = members().filter((member) => member.membershipId !== id).map(encryptMembership) state.cards = state.cards.filter((card) => card.membershipId !== id) state.payments = state.payments.filter((payment) => payment.membershipId !== id) await Promise.all([saveMemberships(), saveCards(), savePayments()]) return json(res, 200, { success: true, deleted: before - state.memberships.length }) } if (req.method === 'POST' && url.pathname === '/api/payment/manual') { if (!requireAdmin(req, res)) return const body = await readBody(req) const membershipId = cleanText(body.membershipId, 32).toUpperCase() const member = findMember((item) => item.membershipId === membershipId) if (!member) return json(res, 404, { error: 'Member not found.' }) const payment = { id: createId('payment'), membershipId, provider: cleanText(body.provider, 24) || 'manual', amountUsd: Number(body.amountUsd || membershipMonthlyUsd), amountSats: Number(body.amountSats || 0), status: 'paid', btcpayInvoiceId: cleanText(body.btcpayInvoiceId, 120), createdAt: new Date().toISOString(), paidAt: new Date().toISOString(), } state.payments.unshift(payment) await savePayments() await updateMemberStatus(membershipId, activeCardFor(membershipId) ? 'active' : 'pending_payment') return json(res, 201, { success: true, payment }) } if (req.method === 'POST' && url.pathname === '/api/payment/btcpay') { if (!requireAdmin(req, res)) return if (!btcpayConfigured()) return json(res, 400, { error: 'BTCPay credentials are not configured.' }) const body = await readBody(req) const membershipId = cleanText(body.membershipId, 32).toUpperCase() const member = findMember((item) => item.membershipId === membershipId) if (!member) return json(res, 404, { error: 'Member not found.' }) const invoice = await createBtcpayInvoice(member) const payment = { id: createId('payment'), membershipId, provider: 'btcpay', amountUsd: membershipMonthlyUsd, amountSats: 0, status: 'pending', btcpayInvoiceId: cleanText(invoice.id, 120), checkoutLink: cleanText(invoice.checkoutLink, 500), createdAt: new Date().toISOString(), paidAt: '', } state.payments.unshift(payment) await savePayments() await updateMemberStatus(membershipId, 'pending_payment') return json(res, 201, await paymentStatusPayload(payment)) } if (req.method === 'GET' && url.pathname.startsWith('/api/payment/status/')) { if (!requireAdmin(req, res)) return const invoiceId = cleanText(url.pathname.split('/').pop(), 120) const payment = state.payments.find((item) => item.btcpayInvoiceId === invoiceId || item.id === invoiceId) if (!payment) return json(res, 404, { error: 'Payment not found.' }) return json(res, 200, await paymentStatusPayload(payment)) } if (req.method === 'POST' && url.pathname === '/api/card/issue') { if (!requireAdmin(req, res)) return const body = await readBody(req) const membershipId = cleanText(body.membershipId, 32).toUpperCase() const member = findMember((item) => item.membershipId === membershipId) const cardCredential = cleanText(body.cardCredential, 160) if (!member) return json(res, 404, { error: 'Member not found.' }) if (cardCredential.length < 6) return json(res, 400, { error: 'Card credential is required.' }) const now = new Date().toISOString() const existing = state.cards.find((card) => card.membershipId === membershipId && card.status === 'active') if (existing) existing.status = 'revoked' const card = { id: createId('card'), membershipId, cardPublicId: cleanText(body.cardPublicId, 80) || `L484-CARD-${crypto.randomBytes(3).toString('hex').toUpperCase()}`, cardSecretHash: hmacHex(cardCredential), uidHash: hmacHex(cardCredential), status: 'active', issuedAt: now, lastSeenAt: '', notes: cleanText(body.notes, 240), } state.cards.unshift(card) await saveCards() await updateMemberStatus(membershipId, activePaymentFor(membershipId) ? 'active' : 'pending_payment') return json(res, 201, { success: true, card: { ...card, cardSecretHash: undefined, uidHash: undefined } }) } if (req.method === 'POST' && url.pathname === '/api/card/status') { if (!requireAdmin(req, res)) return const body = await readBody(req) const id = cleanText(body.id, 80) const status = cleanText(body.status, 24) const card = state.cards.find((item) => item.id === id) if (!card) return json(res, 404, { error: 'Card not found.' }) if (!['active', 'revoked', 'lost'].includes(status)) return json(res, 400, { error: 'Invalid card status.' }) card.status = status await saveCards() return json(res, 200, { success: true, card: { ...card, cardSecretHash: undefined, uidHash: undefined } }) } if (req.method === 'POST' && url.pathname === '/api/access/check') { const isAdmin = (req.headers.authorization || '').startsWith('Bearer ') if (!hasControllerAuth(req) && (!isAdmin || !requireAdmin(req, res))) return const body = await readBody(req) const doorId = cleanText(body.doorId, 80) || 'front-door' const cardCredential = cleanText(body.cardCredential || body.uid, 160) if (cardCredential.length < 1) return json(res, 400, { error: 'Card credential is required.' }) const credentialHash = hmacHex(cardCredential) const card = state.cards.find((item) => item.status === 'active' && (item.cardSecretHash === credentialHash || item.uidHash === credentialHash)) const member = card ? findMember((item) => item.membershipId === card.membershipId) : null const status = memberAccessStatus(member) const allow = Boolean(card && member && status === 'active') const reason = allow ? 'active_member_card' : card ? `member_${status}` : 'card_not_found' if (card) card.lastSeenAt = new Date().toISOString() await recordAccess({ membershipId: member?.membershipId || '', cardId: card?.id || '', cardPublicId: card?.cardPublicId || '', doorId, decision: allow ? 'allow' : 'deny', reason, }) if (card) await saveCards() return json(res, 200, { allow, reason, member: member ? { membershipId: member.membershipId, fullName: member.fullName, status } : null }) } json(res, 404, { error: 'Not found.' }) } await loadStore() await seedDevelopmentStore() http.createServer(async (req, res) => { try { if (req.url.startsWith('/api/')) await handleApi(req, res) else serveStatic(req, res) } catch (error) { console.error(error) json(res, 500, { error: error.message || 'Server error.' }) } }).listen(port, host, () => { console.log(`L484 server listening on http://${host}:${port} in ${appMode} mode`) })