1612 lines
69 KiB
JavaScript
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`)
|
|
})
|