Files
sapien/server/server.js
2026-05-20 14:51:32 -05:00

1612 lines
69 KiB
JavaScript

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(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin Connection Required</title>
<style>
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #080808; color: #fafafa; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top, rgba(242,169,0,0.12), transparent 34%), #080808; }
main { width: min(100%, 460px); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 28px; background: rgba(255,255,255,0.045); }
p { margin: 0 0 8px; color: rgba(255,255,255,0.62); line-height: 1.5; }
h1 { margin: 0 0 12px; font-size: clamp(28px, 8vw, 44px); line-height: 0.95; text-transform: uppercase; }
strong { color: #f2a900; font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase; }
</style>
</head>
<body>
<main>
<strong>L484 Admin</strong>
<h1>Only Available to admin connection</h1>
<p>Use the local admin connection to manage members, payments, cards, and site settings.</p>
</main>
</body>
</html>`)
}
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`)
})