import crypto from 'node:crypto' import fs from 'node:fs/promises' import { existsSync, createReadStream, statSync } 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 webpush from 'web-push' 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 adminAllowedHosts = String(process.env.ADMIN_ALLOWED_HOSTS || '') .split(',') .map((value) => value.trim().toLowerCase()) .filter(Boolean) 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 homeAssistantUnlockWebhookUrl = process.env.HOME_ASSISTANT_UNLOCK_WEBHOOK_URL || '' const homeAssistantUnlockTimeoutMs = Number(process.env.HOME_ASSISTANT_UNLOCK_TIMEOUT_MS || 2500) const cleanEnvValue = (value) => String(value || '').trim().replace(/^["']|["']$/g, '') const vapidPublicKey = cleanEnvValue(process.env.VAPID_PUBLIC_KEY) const vapidPrivateKey = cleanEnvValue(process.env.VAPID_PRIVATE_KEY) const vapidSubject = cleanEnvValue(process.env.VAPID_SUBJECT) || 'mailto:admin@l484.com' const isValidVapidPublicKey = (value) => { try { const clean = cleanEnvValue(value) const padding = '='.repeat((4 - (clean.length % 4)) % 4) const key = Buffer.from((clean + padding).replace(/-/g, '+').replace(/_/g, '/'), 'base64') return key.length === 65 && key[0] === 4 } catch { return false } } const normalizeAdminPubkey = (value) => { const raw = String(value || '').trim().toLowerCase() if (/^[0-9a-f]{64}$/.test(raw)) return raw if (!raw.startsWith('npub1')) return '' try { const decoded = nip19.decode(raw) return decoded.type === 'npub' && /^[0-9a-f]{64}$/.test(decoded.data) ? decoded.data : '' } catch { return '' } } const masterAdminPubkey = normalizeAdminPubkey(process.env.MASTER_ADMIN_PUBKEY) 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'), siteContent: path.join(dataDir, 'site-content.json'), adminRequests: path.join(dataDir, 'admin-requests.json'), notificationSubscriptions: path.join(dataDir, 'notification-subscriptions.json'), membershipNotifications: path.join(dataDir, 'membership-notifications.json'), } const state = { memberships: [], payments: [], cards: [], accessLogs: [], siteContent: null, adminRequests: [], notificationSubscriptions: [], membershipNotifications: [], } let bitcoinPriceCache = null const adminEventClients = new Set() const rateBuckets = new Map() const publicApiEnabled = () => appMode !== 'admin' const adminApiEnabled = () => appMode !== 'public' const normalizeHost = (value) => { const hostValue = String(value || '').split(',')[0].trim().toLowerCase() if (hostValue.startsWith('[')) return hostValue.slice(1, hostValue.indexOf(']') > 0 ? hostValue.indexOf(']') : undefined) return hostValue.split(':')[0] } const normalizeIp = (value) => String(value || '').trim().replace(/^::ffff:/, '') const isPrivateIp = (value) => { const ip = normalizeIp(value) if (!ip) return false if (ip === '::1' || ip === '127.0.0.1' || ip.startsWith('127.')) return true const parts = ip.split('.').map((part) => Number.parseInt(part, 10)) if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false if (parts[0] === 10) return true if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true return parts[0] === 192 && parts[1] === 168 } const isLocalHost = (value) => { const hostname = normalizeHost(value) return hostname === 'localhost' || hostname.endsWith('.local') || isPrivateIp(hostname) || adminAllowedHosts.includes(hostname) } const privilegedConnectionAllowed = (req) => { const requestHost = normalizeHost(req.headers['x-forwarded-host'] || req.headers.host) if (!isLocalHost(requestHost)) return false const socketAddress = normalizeIp(req.socket.remoteAddress || '') if (!isPrivateIp(socketAddress)) return false const forwardedFor = String(req.headers['x-forwarded-for'] || '').split(',').map((value) => normalizeIp(value)).filter(Boolean) return forwardedFor.length ? forwardedFor.every(isPrivateIp) : true } if (isValidVapidPublicKey(vapidPublicKey) && vapidPrivateKey) { webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey) } const json = (res, status, body) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', }) res.end(JSON.stringify(body)) } const adminConnectionUnavailable = (res) => { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', }) res.end(` Admin Connection Required
L484 Admin

Only Available to admin connection

Use the local admin connection to manage members, payments, cards, and site settings.

`) } const rateLimit = (req, res) => { const method = String(req.method || 'GET').toUpperCase() if (!req.url?.startsWith('/api/') || method === 'GET') return false const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown').split(',')[0].trim() const pathname = new URL(req.url, `http://${req.headers.host}`).pathname const key = `${ip}:${pathname}` const now = Date.now() const windowMs = 60_000 const limit = pathname.includes('/access/check') ? 120 : 30 const bucket = rateBuckets.get(key) || { count: 0, resetAt: now + windowMs } if (now > bucket.resetAt) { bucket.count = 0 bucket.resetAt = now + windowMs } bucket.count += 1 rateBuckets.set(key, bucket) if (rateBuckets.size > 2000) { for (const [bucketKey, value] of rateBuckets) { if (now > value.resetAt) rateBuckets.delete(bucketKey) } } if (bucket.count <= limit) return false json(res, 429, { error: 'Too many requests. Try again shortly.' }) return true } const ensureStore = async () => { await fs.mkdir(dataDir, { recursive: true, mode: 0o700 }) for (const [name, file] of Object.entries(files)) { if (name === 'siteContent') continue if (!existsSync(file)) await fs.writeFile(file, '[]', { mode: 0o600 }) } if (!existsSync(files.siteContent)) { await fs.writeFile(files.siteContent, JSON.stringify(defaultSiteContent, null, 2), { 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) state.siteContent = normalizeSiteContent(await loadJson(files.siteContent).catch(() => defaultSiteContent)) state.adminRequests = await loadJson(files.adminRequests) state.notificationSubscriptions = await loadJson(files.notificationSubscriptions) state.membershipNotifications = await loadJson(files.membershipNotifications) } 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 saveSiteContent = () => saveJson(files.siteContent, state.siteContent) const saveAdminRequests = () => saveJson(files.adminRequests, state.adminRequests) const saveNotificationSubscriptions = () => saveJson(files.notificationSubscriptions, state.notificationSubscriptions) const saveMembershipNotifications = () => saveJson(files.membershipNotifications, state.membershipNotifications) 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 defaultSiteContent = { homepage: { hero: { line1: 'Decentralization', line2: 'In Motion', benefitsCue: 'Benefits', }, members: { title: 'Members Get', benefits: [ { title: 'Raw milk', description: 'Placeholder member benefit.' }, { title: 'Grass fed beef', description: 'Placeholder member benefit.' }, { title: 'Upgrade Labs', description: 'Placeholder member benefit.' }, { title: '5 meals a month', description: 'Placeholder member benefit.' }, { title: 'Free drinks', description: 'Placeholder member benefit.' }, ], }, facilities: { title: 'Facilities', items: [ { title: 'Sauna', description: '6-8 person sauna available for member use.' }, { title: 'Cold plunge', description: 'Two XL plunges filtered and cooled to your chosen temperature.' }, { title: 'Gym', description: 'Outdoor turf space set up for fitness and group workouts.' }, { title: 'Event space', description: 'Customizable indoor and outdoor space for private gatherings.' }, { title: 'Grill area / firepit', description: 'Multiple grills and a custom sandbox firepit outside.' }, ], }, events: { navLabel: 'Events', title: 'Events', description: 'Private member gatherings, workshops, and hosted sessions at L484.', enquiryTitle: 'Event Enquiry', items: [ { title: 'Member nights', description: 'Small-format gatherings for active members and invited guests.', date: 'Fridays', price: 'Members', image: '/images/firepit.avif' }, { title: 'Workshops', description: 'Hands-on sessions around food, wellness, Bitcoin, and local resilience.', date: 'Monthly', price: '$40+', image: '/images/gym.avif' }, { title: 'Private bookings', description: 'Indoor and outdoor areas available for approved member events.', date: 'By enquiry', price: 'Custom', image: '/images/bg-3.avif' }, ], }, }, } const CONTENT_LIMITS = { heroLine: 24, cue: 16, sectionTitle: 32, navLabel: 16, itemTitle: 36, itemDescription: 90, eventTitle: 48, eventDescription: 140, eventImage: 240, eventDate: 32, eventPrice: 32, maxBenefits: 5, maxFacilities: 5, maxEvents: 6, } const limitedList = (value, fallback, limit, normalize) => { const source = Array.isArray(value) && value.length ? value : fallback return source.slice(0, limit).map(normalize) } const normalizeSiteContent = (value = {}) => { const homepage = value.homepage || {} const defaults = defaultSiteContent.homepage const hero = homepage.hero || {} const members = homepage.members || {} const facilities = homepage.facilities || {} const events = homepage.events || {} return { homepage: { hero: { line1: cleanText(hero.line1, CONTENT_LIMITS.heroLine) || defaults.hero.line1, line2: cleanText(hero.line2, CONTENT_LIMITS.heroLine) || defaults.hero.line2, benefitsCue: cleanText(hero.benefitsCue, CONTENT_LIMITS.cue) || defaults.hero.benefitsCue, }, members: { title: cleanText(members.title, CONTENT_LIMITS.sectionTitle) || defaults.members.title, benefits: limitedList(members.benefits, defaults.members.benefits, CONTENT_LIMITS.maxBenefits, (item, index) => ({ title: cleanText(item?.title, CONTENT_LIMITS.itemTitle) || defaults.members.benefits[index]?.title || `Benefit ${index + 1}`, description: cleanText(item?.description, CONTENT_LIMITS.itemDescription) || defaults.members.benefits[index]?.description || '', })), }, facilities: { title: cleanText(facilities.title, CONTENT_LIMITS.sectionTitle) || defaults.facilities.title, items: limitedList(facilities.items, defaults.facilities.items, CONTENT_LIMITS.maxFacilities, (item, index) => ({ title: cleanText(item?.title, CONTENT_LIMITS.itemTitle) || defaults.facilities.items[index]?.title || `Facility ${index + 1}`, description: cleanText(item?.description, CONTENT_LIMITS.itemDescription) || defaults.facilities.items[index]?.description || '', })), }, events: { navLabel: cleanText(events.navLabel, CONTENT_LIMITS.navLabel) || defaults.events.navLabel, title: cleanText(events.title, CONTENT_LIMITS.sectionTitle) || defaults.events.title, description: cleanText(events.description, CONTENT_LIMITS.eventDescription) || defaults.events.description, enquiryTitle: cleanText(events.enquiryTitle, CONTENT_LIMITS.sectionTitle) || defaults.events.enquiryTitle, items: limitedList(events.items, defaults.events.items, CONTENT_LIMITS.maxEvents, (item, index) => ({ title: cleanText(item?.title, CONTENT_LIMITS.eventTitle) || defaults.events.items[index]?.title || `Event ${index + 1}`, description: cleanText(item?.description, CONTENT_LIMITS.eventDescription) || defaults.events.items[index]?.description || '', date: cleanText(item?.date, CONTENT_LIMITS.eventDate) || defaults.events.items[index]?.date || '', price: cleanText(item?.price, CONTENT_LIMITS.eventPrice) || defaults.events.items[index]?.price || '', image: cleanText(item?.image, CONTENT_LIMITS.eventImage) || defaults.events.items[index]?.image || '/images/bg-1.avif', })), }, }, } } 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 membershipPeriodOptions = [1, 2, 3, 6, 12] const dayMs = 24 * 60 * 60 * 1000 const normalizePaymentMonths = (value) => { const months = Number.parseInt(value, 10) if (!Number.isFinite(months)) return 1 return Math.max(1, Math.min(24, months)) } const addMembershipMonths = (date, months) => { const start = new Date(date) const day = start.getDate() const result = new Date(start) result.setDate(1) result.setMonth(result.getMonth() + normalizePaymentMonths(months)) const lastDay = new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate() result.setDate(Math.min(day, lastDay)) return result } const activePaymentFor = (membershipId) => { const now = new Date() return state.payments.find((payment) => payment.membershipId === membershipId && payment.status === 'paid' && (!payment.paidThrough || new Date(payment.paidThrough) > now) ) } const activeCardFor = (membershipId) => state.cards.find((card) => card.membershipId === membershipId && card.status === 'active') const memberHasPaidDoorAccess = (member) => { if (!member) return false if (['revoked', 'suspended'].includes(member.status)) return false if (new Date(member.expiresAt) <= new Date()) return false return Boolean(activePaymentFor(member.membershipId)) } 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 updateMemberRecord = async (membershipId, updater) => { const index = members().findIndex((member) => member.membershipId === membershipId) if (index < 0) return null const current = decryptMember(state.memberships[index]) const updated = updater({ ...current }) || current state.memberships[index] = encryptMembership(updated) await saveMemberships() return updated } 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.setMonth(expiresAt.getMonth() + 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 } if (!privilegedConnectionAllowed(req)) { json(res, 404, { error: 'Admin API is only available from the local admin connection.' }) return false } if (!isAdminPubkey(getAuthPubkey(req))) { json(res, 403, { error: 'This npub is not an admin. Please request access and we will authorise it if you are permissioned.' }) return false } return true } const getAuthPubkey = (req) => { const auth = req.headers.authorization || '' return auth.startsWith('Bearer ') ? normalizeAdminPubkey(auth.slice(7)) : '' } const isAdminPubkey = (pubkey) => { const normalized = normalizeAdminPubkey(pubkey) return Boolean(normalized) && (isMasterAdminPubkey(normalized) || state.adminRequests.some((request) => request.pubkey === normalized && request.status === 'approved' )) } const isMasterAdminPubkey = (pubkey) => { const normalized = normalizeAdminPubkey(pubkey) return Boolean(masterAdminPubkey && normalized === masterAdminPubkey) } const requireMasterAdmin = (req, res) => { if (!requireAdmin(req, res)) return false if (!isMasterAdminPubkey(getAuthPubkey(req))) { json(res, 403, { error: 'Master admin access required.' }) return false } return true } const publicAdminRequests = () => state.adminRequests.map((request) => ({ id: request.id, displayName: request.displayName, npub: request.npub, pubkey: request.pubkey, status: request.status, requestedAt: request.requestedAt, decidedAt: request.decidedAt || '', decidedBy: request.decidedBy || '', })) const notificationStats = () => ({ configured: Boolean(isValidVapidPublicKey(vapidPublicKey) && vapidPrivateKey), subscriberCount: state.notificationSubscriptions.length, publicKeyConfigured: Boolean(vapidPublicKey), publicKeyValid: isValidVapidPublicKey(vapidPublicKey), privateKeyConfigured: Boolean(vapidPrivateKey), subject: vapidSubject, }) const sendPushNotification = async ({ title, message, url = '/edit', icon = '/images/small-logo.svg', tag = 'l484-update' }) => { if (!isValidVapidPublicKey(vapidPublicKey) || !vapidPrivateKey) { return { success: false, reason: 'vapid_not_configured', sent: 0, failed: 0 } } const payload = JSON.stringify({ title: cleanText(title, 80) || 'L484', message: cleanText(message, 220) || 'New L484 update.', icon: cleanText(icon, 240) || '/images/small-logo.svg', tag: cleanText(tag, 80) || 'l484-update', data: { url: cleanText(url, 240) || '/edit' }, }) const results = await Promise.allSettled( state.notificationSubscriptions.map((subscription) => webpush.sendNotification(subscription, payload)), ) const validSubscriptions = [] let sent = 0 let failed = 0 results.forEach((result, index) => { if (result.status === 'fulfilled') { sent += 1 validSubscriptions.push(state.notificationSubscriptions[index]) return } failed += 1 const statusCode = result.reason?.statusCode if (![404, 410].includes(statusCode)) validSubscriptions.push(state.notificationSubscriptions[index]) }) if (validSubscriptions.length !== state.notificationSubscriptions.length) { state.notificationSubscriptions = validSubscriptions await saveNotificationSubscriptions() } return { success: true, sent, failed } } const sendPushNotificationToMember = async (member, payload) => { const targets = state.notificationSubscriptions.filter((subscription) => subscription.membershipId === member.membershipId || subscription.npub === member.npub || member.signerNpubs?.includes(subscription.npub) ) if (!targets.length) return { success: false, reason: 'no_member_subscriptions', sent: 0, failed: 0 } if (!isValidVapidPublicKey(vapidPublicKey) || !vapidPrivateKey) { return { success: false, reason: 'vapid_not_configured', sent: 0, failed: 0 } } const body = JSON.stringify({ title: cleanText(payload.title, 80) || 'L484', message: cleanText(payload.message, 220) || 'New L484 update.', icon: cleanText(payload.icon, 240) || '/images/small-logo.svg', tag: cleanText(payload.tag, 80) || 'l484-update', data: { url: cleanText(payload.url, 240) || '/' }, }) const targetEndpoints = new Set(targets.map((subscription) => subscription.endpoint)) const results = await Promise.allSettled(targets.map((subscription) => webpush.sendNotification(subscription, body))) const invalidEndpoints = new Set() let sent = 0 let failed = 0 results.forEach((result, index) => { if (result.status === 'fulfilled') { sent += 1 return } failed += 1 if ([404, 410].includes(result.reason?.statusCode)) invalidEndpoints.add(targets[index].endpoint) }) if (invalidEndpoints.size) { state.notificationSubscriptions = state.notificationSubscriptions.filter((subscription) => !targetEndpoints.has(subscription.endpoint) || !invalidEndpoints.has(subscription.endpoint) ) await saveNotificationSubscriptions() } return { success: true, sent, failed } } const applyPaidMembershipPeriod = async (payment) => { const member = findMember((item) => item.membershipId === payment.membershipId) if (!member) return null const now = new Date() const existingExpiry = new Date(member.expiresAt) const coversFrom = Number.isNaN(existingExpiry.getTime()) || existingExpiry < now ? now : existingExpiry const months = normalizePaymentMonths(payment.months) const paidThrough = addMembershipMonths(coversFrom, months) payment.months = months payment.periodLabel = months === 1 ? '1 month' : `${months} months` payment.amountUsd = payment.provider === 'comp' ? 0 : membershipMonthlyUsd * months payment.coversFrom = coversFrom.toISOString() payment.paidThrough = paidThrough.toISOString() const updated = await updateMemberRecord(payment.membershipId, (current) => ({ ...current, expiresAt: paidThrough.toISOString(), status: activeCardFor(payment.membershipId) ? 'active' : 'pending_payment', })) await savePayments() return updated } const reconcileMembershipCycles = async ({ notify = false } = {}) => { const now = new Date() const allMembers = members() let changed = false for (const member of allMembers) { const expiry = new Date(member.expiresAt) if (Number.isNaN(expiry.getTime())) continue if (['active', 'pending_card', 'pending_payment'].includes(member.status) && expiry <= now) { await updateMemberRecord(member.membershipId, (current) => ({ ...current, status: 'suspended' })) changed = true continue } if (!notify || !['active', 'pending_card', 'pending_payment'].includes(member.status)) continue const daysUntilExpiry = Math.ceil((expiry.getTime() - now.getTime()) / dayMs) if (daysUntilExpiry < 1 || daysUntilExpiry > 3) continue const dayKey = now.toISOString().slice(0, 10) const alreadySent = state.membershipNotifications.some((entry) => entry.membershipId === member.membershipId && entry.type === 'expiry_reminder' && entry.dayKey === dayKey ) if (alreadySent) continue const result = await sendPushNotificationToMember(member, { title: 'Membership renewal due', message: `Your L484 membership expires in ${daysUntilExpiry} day${daysUntilExpiry === 1 ? '' : 's'}.`, url: '/', tag: `l484-renewal-${member.membershipId}-${dayKey}`, }) state.membershipNotifications.unshift({ id: createId('membership-notice'), membershipId: member.membershipId, type: 'expiry_reminder', dayKey, expiresAt: expiry.toISOString(), sent: result.sent || 0, failed: result.failed || 0, createdAt: now.toISOString(), }) state.membershipNotifications = state.membershipNotifications.slice(0, 2000) await saveMembershipNotifications() } return { changed } } 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) if (['/admin', '/edit'].includes(requested) && !privilegedConnectionAllowed(req)) { adminConnectionUnavailable(res) return } 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', } const headers = { 'Content-Type': types[ext] || 'application/octet-stream' } if (path.basename(safePath) === 'sw.js') { headers['Service-Worker-Allowed'] = '/' headers['Cache-Control'] = 'no-store' } else if (ext === '.html' || ext === '.webmanifest') { headers['Cache-Control'] = 'no-store' } else if (safePath.startsWith(path.join(distDir, 'assets'))) { headers['Cache-Control'] = 'public, max-age=31536000, immutable' } else { headers['Cache-Control'] = 'public, max-age=3600' } headers['Content-Length'] = statSync(safePath).size res.writeHead(200, headers) 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) => { return updateMemberRecord(membershipId, (member) => ({ ...member, status })) } const serializeAdminMember = (member) => ({ ...member, accessStatus: memberAccessStatus(member) }) const serializeAccessLog = (log, allMembers = members()) => { const member = log.membershipId ? allMembers.find((item) => item.membershipId === log.membershipId) : null return { ...log, member: member ? { membershipId: member.membershipId, fullName: member.fullName, status: memberAccessStatus(member), } : null, } } const serializeAdmin = (pubkey = '') => { const allMembers = members() return { success: true, admin: { isMaster: isMasterAdminPubkey(pubkey), }, memberships: allMembers.map(serializeAdminMember), payments: state.payments, membershipMonthlyUsd, membershipPeriodOptions, cards: state.cards.map(({ cardSecretHash, uidHash, ...card }) => card), accessLogs: state.accessLogs.slice(0, 200).map((log) => serializeAccessLog(log, allMembers)), } } 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 triggerDoorUnlock = async ({ doorId, member, card }) => { if (!homeAssistantUnlockWebhookUrl) { return { attempted: false, ok: false, reason: 'unlock_webhook_not_configured' } } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), homeAssistantUnlockTimeoutMs) try { const response = await fetch(homeAssistantUnlockWebhookUrl, { method: 'POST', signal: controller.signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ doorId, membershipId: member.membershipId, fullName: member.fullName, cardId: card?.id || '', cardPublicId: card?.cardPublicId || '', requestedAt: new Date().toISOString(), }), }) return { attempted: true, ok: response.ok, status: response.status, reason: response.ok ? 'unlock_sent' : 'unlock_webhook_failed', } } catch (error) { return { attempted: true, ok: false, reason: error?.name === 'AbortError' ? 'unlock_webhook_timeout' : 'unlock_webhook_error', } } finally { clearTimeout(timeout) } } 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', months: 1, periodLabel: '1 month', 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, coversFrom: member.createdAt, paidThrough: member.expiresAt, })), ...pendingMembers.map((member, index) => ({ id: `payment-dev-pending-${member.membershipId.slice(-6).toLowerCase()}`, membershipId: member.membershipId, provider: 'btcpay', months: 1, periodLabel: '1 month', 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, months = 1) => { if (!btcpayConfigured()) throw new Error('BTCPay credentials are not configured.') const normalizedMonths = normalizePaymentMonths(months) const amountUsd = membershipMonthlyUsd * normalizedMonths const response = await fetch(`${btcpayServerUrl}/api/v1/stores/${btcpayStoreId}/invoices`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `token ${btcpayApiKey}`, }, body: JSON.stringify({ amount: amountUsd, currency: 'USD', metadata: { orderId: member.membershipId, membershipMonths: normalizedMonths, itemDesc: normalizedMonths === 1 ? 'L484 monthly membership' : `L484 ${normalizedMonths} month 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 if (payment.status === 'paid') return payment payment.status = 'paid' payment.paidAt = new Date().toISOString() await applyPaidMembershipPeriod(payment) broadcastAdminEvent('payment-paid', { invoiceId, membershipId: payment.membershipId, paymentId: payment.id, paidAt: payment.paidAt, paidThrough: payment.paidThrough, }) 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, months: normalizePaymentMonths(payment.months), periodLabel: payment.periodLabel || (normalizePaymentMonths(payment.months) === 1 ? '1 month' : `${normalizePaymentMonths(payment.months)} months`), 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, paidThrough: payment.paidThrough || '', 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() && privilegedConnectionAllowed(req), accessEnabled: adminApiEnabled() && privilegedConnectionAllowed(req), btcpayEnabled: btcpayConfigured(), masterAdminConfigured: Boolean(masterAdminPubkey), }) } 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 === 'GET' && url.pathname === '/api/site-content') { return json(res, 200, { success: true, content: state.siteContent || defaultSiteContent, limits: CONTENT_LIMITS }) } if (req.method === 'GET' && url.pathname === '/api/notifications/vapid-public-key') { return json(res, 200, { success: true, publicKey: isValidVapidPublicKey(vapidPublicKey) ? vapidPublicKey : '', configured: Boolean(isValidVapidPublicKey(vapidPublicKey) && vapidPrivateKey) }) } if (req.method === 'GET' && url.pathname === '/api/notifications/test-vapid') { return json(res, 200, { success: true, ...notificationStats() }) } if (req.method === 'POST' && url.pathname === '/api/notifications/subscribe') { const body = await readBody(req) const subscription = body.subscription || body const endpoint = cleanText(subscription?.endpoint, 1000) if (!endpoint || !subscription?.keys?.p256dh || !subscription?.keys?.auth) { return json(res, 400, { error: 'Invalid push subscription.' }) } const existingIndex = state.notificationSubscriptions.findIndex((item) => item.endpoint === endpoint) const existingSubscription = existingIndex >= 0 ? state.notificationSubscriptions[existingIndex] : null const normalized = { endpoint, expirationTime: subscription.expirationTime || null, keys: { p256dh: cleanText(subscription.keys.p256dh, 500), auth: cleanText(subscription.keys.auth, 200), }, membershipId: cleanText(body.membershipId || existingSubscription?.membershipId, 32).toUpperCase(), npub: cleanText(body.npub || existingSubscription?.npub, 90), userAgent: cleanText(req.headers['user-agent'], 240), createdAt: existingSubscription?.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), } if (existingIndex >= 0) state.notificationSubscriptions[existingIndex] = normalized else state.notificationSubscriptions.unshift(normalized) await saveNotificationSubscriptions() return json(res, 201, { success: true, subscriberCount: state.notificationSubscriptions.length }) } if (req.method === 'POST' && url.pathname === '/api/admin/request-access') { if (!adminApiEnabled()) return json(res, 404, { error: 'Admin API is disabled on this deployment.' }) if (!privilegedConnectionAllowed(req)) return json(res, 404, { error: 'Admin API is only available from the local admin connection.' }) const body = await readBody(req) const pubkey = cleanText(body.pubkey, 64).toLowerCase() const npub = cleanText(body.npub, 90) const displayName = cleanText(body.displayName, 80) || 'Admin request' if (!/^[0-9a-f]{64}$/.test(pubkey)) return json(res, 400, { error: 'Invalid admin public key.' }) if (npub && !/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(npub)) return json(res, 400, { error: 'Invalid admin npub.' }) const existing = state.adminRequests.find((request) => request.pubkey === pubkey) if (existing) return json(res, 200, { success: true, request: { ...existing, existing: true } }) const request = { id: createId('admin-request'), displayName, npub, pubkey, status: isMasterAdminPubkey(pubkey) ? 'approved' : 'requested', requestedAt: new Date().toISOString(), decidedAt: isMasterAdminPubkey(pubkey) ? new Date().toISOString() : '', decidedBy: isMasterAdminPubkey(pubkey) ? 'system' : '', } state.adminRequests.unshift(request) await saveAdminRequests() return json(res, 201, { success: true, request }) } 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, }) sendPushNotification({ title: 'New member request', message: `${member.fullName} submitted a membership request.`, url: '/admin', tag: 'l484-membership-created', }).catch((error) => console.warn('Push notification failed:', error.message)) 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 === 'POST' && url.pathname === '/api/member/door/unlock') { if (!publicApiEnabled()) return json(res, 404, { error: 'Public membership access is disabled on this deployment.' }) const body = await readBody(req) const membershipId = cleanText(body.membershipId, 32).toUpperCase() const nsecHash = cleanText(body.nsecHash, 64).toLowerCase() const doorId = cleanText(body.doorId, 80) || 'front-door' const member = findMember((item) => item.membershipId === membershipId && item.nsecHash === nsecHash) const card = activeCardFor(membershipId) const status = memberAccessStatus(member) const allow = memberHasPaidDoorAccess(member) const unlock = allow ? await triggerDoorUnlock({ doorId, member, card }) : { attempted: false, ok: false, reason: member ? `member_${status}` : 'member_not_found' } const reasonMessages = { member_not_found: 'This membership could not be verified on this device.', member_requested: 'This membership has not been paid yet.', member_pending_payment: 'This membership payment has not been confirmed yet.', member_pending_card: 'This membership is paid, but door access is not active yet.', member_suspended: 'This membership is suspended until dues are paid.', member_expired: 'This membership has expired.', member_revoked: 'This membership has been revoked.', unlock_webhook_not_configured: 'Door unlock is not configured on this deployment.', unlock_webhook_failed: 'Home Assistant rejected the unlock request.', unlock_webhook_timeout: 'Home Assistant did not respond to the unlock request.', unlock_webhook_error: 'Could not reach Home Assistant for door unlock.', } const reason = allow ? unlock.reason : unlock.reason const error = reasonMessages[reason] || 'Door access denied.' await recordAccess({ membershipId: member?.membershipId || membershipId, cardId: card?.id || '', cardPublicId: card?.cardPublicId || '', doorId, decision: allow && unlock.ok ? 'allow' : 'deny', reason, unlock, }) return json(res, allow && unlock.ok ? 200 : 403, { success: allow && unlock.ok, allow, reason, error, unlock, member: member ? { membershipId: member.membershipId, fullName: member.fullName, status } : null, }) } if (req.method === 'GET' && url.pathname === '/api/admin/state') { if (!requireAdmin(req, res)) return return json(res, 200, serializeAdmin(getAuthPubkey(req))) } if (req.method === 'PUT' && url.pathname === '/api/admin/site-content') { if (!requireAdmin(req, res)) return const body = await readBody(req) state.siteContent = normalizeSiteContent(body.content || body) await saveSiteContent() return json(res, 200, { success: true, content: state.siteContent, limits: CONTENT_LIMITS }) } if (req.method === 'GET' && url.pathname === '/api/admin/access-requests') { if (!requireAdmin(req, res)) return return json(res, 200, { success: true, isMasterAdmin: isMasterAdminPubkey(getAuthPubkey(req)), requests: publicAdminRequests() }) } if (req.method === 'GET' && url.pathname === '/api/admin/notifications/stats') { if (!requireAdmin(req, res)) return return json(res, 200, { success: true, ...notificationStats() }) } if (req.method === 'POST' && url.pathname === '/api/admin/notifications/send') { if (!requireAdmin(req, res)) return const body = await readBody(req) const result = await sendPushNotification({ title: body.title || 'L484 update', message: body.message || 'There is a new L484 update.', url: body.url || '/edit', tag: body.tag || 'l484-admin-test', }) return json(res, result.success ? 200 : 400, result.success ? result : { error: result.reason, ...result }) } if (req.method === 'POST' && url.pathname === '/api/admin/access-requests/status') { if (!requireMasterAdmin(req, res)) return const body = await readBody(req) const id = cleanText(body.id, 120) const status = cleanText(body.status, 24) if (!['approved', 'rejected'].includes(status)) return json(res, 400, { error: 'Invalid request status.' }) const request = state.adminRequests.find((item) => item.id === id) if (!request) return json(res, 404, { error: 'Admin request not found.' }) request.status = status request.decidedAt = new Date().toISOString() request.decidedBy = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').slice(0, 64) await saveAdminRequests() return json(res, 200, { success: true, request }) } if (req.method === 'GET' && url.pathname === '/api/admin/events') { if (!adminApiEnabled()) return json(res, 404, { error: 'Admin API is disabled on this deployment.' }) if (!privilegedConnectionAllowed(req)) return json(res, 404, { error: 'Admin API is only available from the local admin connection.' }) const pubkey = cleanText(url.searchParams.get('pubkey'), 80).toLowerCase() if (!isAdminPubkey(pubkey)) return json(res, 403, { error: 'This npub is not an admin. Please request access and we will authorise it if you are permissioned.' }) 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(getAuthPubkey(req)).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 months = normalizePaymentMonths(body.months) const provider = cleanText(body.provider, 24) === 'comp' ? 'comp' : 'manual' const payment = { id: createId('payment'), membershipId, provider, months, periodLabel: months === 1 ? '1 month' : `${months} months`, amountUsd: provider === 'comp' ? 0 : membershipMonthlyUsd * months, amountSats: Number(body.amountSats || 0), status: 'paid', btcpayInvoiceId: cleanText(body.btcpayInvoiceId, 120), createdAt: new Date().toISOString(), paidAt: new Date().toISOString(), } state.payments.unshift(payment) const membership = await applyPaidMembershipPeriod(payment) return json(res, 201, { success: true, payment, membership: membership ? serializeAdminMember(membership) : null, }) } 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 months = normalizePaymentMonths(body.months) const invoice = await createBtcpayInvoice(member, months) const payment = { id: createId('payment'), membershipId, provider: 'btcpay', months, periodLabel: months === 1 ? '1 month' : `${months} months`, amountUsd: membershipMonthlyUsd * months, 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() if (!['active', 'pending_card'].includes(memberAccessStatus(member))) 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') { if (!privilegedConnectionAllowed(req)) return json(res, 404, { error: 'Controller access is only available from the local connection.' }) const controllerAuthed = hasControllerAuth(req) const isAdmin = (req.headers.authorization || '').startsWith('Bearer ') if (!controllerAuthed) { if (!isAdmin) return json(res, 401, { error: 'Controller token is required.' }) if (!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' const unlock = allow && controllerAuthed ? await triggerDoorUnlock({ doorId, member, card }) : { attempted: false, ok: false, reason: controllerAuthed ? 'access_denied' : 'admin_test_only' } if (card) card.lastSeenAt = new Date().toISOString() await recordAccess({ membershipId: member?.membershipId || '', cardId: card?.id || '', cardPublicId: card?.cardPublicId || '', doorId, decision: allow ? 'allow' : 'deny', reason: unlock.attempted && !unlock.ok ? `${reason}_${unlock.reason}` : reason, unlock, }) if (card) await saveCards() return json(res, 200, { allow, reason, unlock, member: member ? { membershipId: member.membershipId, fullName: member.fullName, status } : null, }) } json(res, 404, { error: 'Not found.' }) } await loadStore() await seedDevelopmentStore() await reconcileMembershipCycles({ notify: true }) setInterval(() => { reconcileMembershipCycles({ notify: true }).catch((error) => console.warn('Membership cycle reconciliation failed:', error.message)) }, 60 * 60 * 1000) http.createServer(async (req, res) => { try { if (req.url.startsWith('/api/')) { if (rateLimit(req, res)) return 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`) })