Implement membership admin and BTCPay flow

This commit is contained in:
Dorian
2026-05-14 12:44:15 -05:00
parent c015f6b7da
commit 3d87041f2d
18 changed files with 3587 additions and 348 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
.DS_Store
node_modules
dist
npm-debug.log*
Dockerfile
docker-compose.yml
docker-compose.onprem.yml
server/data

10
.env.local.example Normal file
View File

@@ -0,0 +1,10 @@
APP_MODE=all
HOST=127.0.0.1
PORT=3001
MEMBERSHIP_ENCRYPTION_KEY=replace-with-a-64-character-hex-key
ACCESS_HMAC_KEY=replace-with-a-long-random-access-hmac-key
ACCESS_CONTROLLER_TOKEN=replace-with-a-long-random-door-controller-token
BTCPAY_SERVER_URL=https://your-btcpay.example.com
BTCPAY_STORE_ID=replace-with-store-id
BTCPAY_API_KEY=replace-with-api-token
BTCPAY_WEBHOOK_SECRET=replace-with-webhook-secret

3
.gitignore vendored
View File

@@ -1,6 +1,9 @@
node_modules
dist
.DS_Store
.env
.env.*
!.env.local.example
*.local
data
server/data

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=2354
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
COPY server ./server
RUN mkdir -p /app/server/data
EXPOSE 2354
CMD ["npm", "start"]

26
docker-compose.onprem.yml Normal file
View File

@@ -0,0 +1,26 @@
services:
l484-onprem:
build:
context: .
dockerfile: Dockerfile
container_name: l484-onprem
restart: unless-stopped
ports:
- "2354:2354"
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 2354
APP_MODE: admin
MEMBERSHIP_ENCRYPTION_KEY: ${MEMBERSHIP_ENCRYPTION_KEY:?Set a unique 64-character hex key}
ACCESS_HMAC_KEY: ${ACCESS_HMAC_KEY:?Set a unique access HMAC key}
ACCESS_CONTROLLER_TOKEN: ${ACCESS_CONTROLLER_TOKEN:?Set a unique door-controller token}
BTCPAY_SERVER_URL: ${BTCPAY_SERVER_URL:-}
BTCPAY_STORE_ID: ${BTCPAY_STORE_ID:-}
BTCPAY_API_KEY: ${BTCPAY_API_KEY:-}
BTCPAY_WEBHOOK_SECRET: ${BTCPAY_WEBHOOK_SECRET:-}
volumes:
- l484-onprem-data:/app/server/data
volumes:
l484-onprem-data:

25
docker-compose.yml Normal file
View File

@@ -0,0 +1,25 @@
services:
l484-public:
build:
context: .
dockerfile: Dockerfile
container_name: l484-public
restart: unless-stopped
ports:
- "2354:2354"
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 2354
APP_MODE: public
MEMBERSHIP_ENCRYPTION_KEY: ${MEMBERSHIP_ENCRYPTION_KEY:?Set a unique 64-character hex key}
ACCESS_HMAC_KEY: ${ACCESS_HMAC_KEY:?Set a unique access HMAC key}
BTCPAY_SERVER_URL: ${BTCPAY_SERVER_URL:-}
BTCPAY_STORE_ID: ${BTCPAY_STORE_ID:-}
BTCPAY_API_KEY: ${BTCPAY_API_KEY:-}
BTCPAY_WEBHOOK_SECRET: ${BTCPAY_WEBHOOK_SECRET:-}
volumes:
- l484-public-data:/app/server/data
volumes:
l484-public-data:

1
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "l484-vue-tailwind",
"version": "0.1.0",
"dependencies": {
"@noble/hashes": "1.3.1",
"@vitejs/plugin-vue": "^5.2.1",
"applesauce-signers": "^5.1.0",
"nostr-tools": "^2.10.4",

View File

@@ -12,6 +12,7 @@
"start": "node server/server.js"
},
"dependencies": {
"@noble/hashes": "1.3.1",
"@vitejs/plugin-vue": "^5.2.1",
"applesauce-signers": "^5.1.0",
"nostr-tools": "^2.10.4",

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 95.95 122.88" style="enable-background:new 0 0 95.95 122.88" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M82.16,0h13.33c0.25,0,0.46,0.21,0.46,0.46v121.96c0,0.25-0.21,0.46-0.46,0.46H82.16 c-0.25,0-0.46-0.21-0.46-0.46V56.08l-6.16,2.44l0,0l-0.86,0.3c-4.96,1.76-8.55,3.03-14.84,1.48c-0.05-0.01-0.09-0.03-0.13-0.05 c-4.4-1.71-6.68-4.08-8.9-6.4l-0.33-0.34l-2.81,17.91c2.51,1.58,5.02,2.75,7.39,3.86c7.2,3.36,13.12,6.12,14.39,17.43 c0.23,2.03,0.12,3.94,0.01,6.02l0,0.03c-0.02,0.3-0.03,0.63-0.07,1.4l-0.88,21.91c-0.02,0.43-0.38,0.76-0.8,0.75l-11.13,0.03 c-0.43,0-0.78-0.35-0.78-0.78c0.12-7.55,0.34-15.46,0.73-23l0.06-1.02c0.06-1.18,0.12-2.27,0.06-3.29 c-0.06-0.97-0.24-1.88-0.64-2.75l-0.11-0.23c-0.45-0.98-0.86-1.88-1.48-2.17c-1.23-0.57-3.18-1.28-5.4-2.01 c-2.54-0.84-5.38-1.68-7.88-2.39c-1.84,6.08-4.33,13.44-7,20.43c-2.39,6.24-4.92,12.21-7.27,16.74c-0.14,0.28-0.44,0.44-0.73,0.42 l-11.69,0.06c-0.43,0-0.78-0.34-0.78-0.77c0-0.1,0.02-0.2,0.06-0.29l0,0c2.34-5.75,5.1-13.22,7.75-20.81 c2.71-7.77,5.31-15.69,7.23-22.05c-1.14-1.4-2.23-2.96-2.97-4.68c-0.82-1.9-1.22-3.96-0.81-6.17l4.01-21.66l-0.23-0.01 c-1.39-0.06-2.55-0.11-3.88,0.47c-1.82,0.8-3.26,2.42-4.86,4.22c-0.65,0.73-1.32,1.49-2.09,2.27l-0.05,0.05 c-0.57,0.59-1.17,1.2-1.73,1.77l-7.08,7.15c-0.3,0.3-0.79,0.31-1.1,0l-7.72-8.1c-0.29-0.31-0.28-0.8,0.02-1.09l7.07-7.14 c0.61-0.62,1.13-1.16,1.64-1.67l0.08-0.08c3.19-3.48,5.78-6.26,9.1-8.14c3.36-1.9,7.4-2.84,13.42-2.58l0.01,0 c2.15,0.03,4.69,0.26,6.78,0.46c0.97,0.09,1.84,0.17,2.59,0.22c10.16,0.67,14.92,6.01,18.62,10.17c1.63,1.84,3.05,3.42,4.57,4.15 c0.72,0.34,1.85-0.14,3.14-0.68c0.78-0.33,1.6-0.68,2.47-0.91c0.56-0.21,0.87-0.33,1.11-0.42c0.31-0.12,0.52-0.2,0.64-0.24 l10.24-3.54V0.46C81.7,0.21,81.91,0,82.16,0L82.16,0z M41.74,6.41c3.18-1.09,6.51-0.78,9.31,0.59c2.8,1.37,5.08,3.82,6.17,7 c1.09,3.18,0.78,6.51-0.59,9.31c-1.37,2.8-3.82,5.08-7,6.17c-3.18,1.09-6.51,0.78-9.31-0.59c-2.8-1.37-5.08-3.82-6.17-7 c-1.09-3.18-0.78-6.51,0.59-9.31C36.11,9.78,38.56,7.5,41.74,6.41L41.74,6.41z M84.77,39.71h3.08v14.75h-3.08V39.71L84.77,39.71z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/images/firepit.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

BIN
public/images/gym.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

BIN
public/images/plunge.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

BIN
public/images/sauna.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

View File

@@ -1,13 +1,30 @@
import { spawn } from 'node:child_process'
import fs from 'node:fs'
const loadLocalEnv = () => {
if (!fs.existsSync('.env.local')) return {}
return Object.fromEntries(
fs.readFileSync('.env.local', 'utf8')
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#') && line.includes('='))
.map((line) => {
const index = line.indexOf('=')
return [line.slice(0, index), line.slice(index + 1).replace(/^["']|["']$/g, '')]
}),
)
}
const env = { ...process.env, ...loadLocalEnv() }
const processes = [
spawn(process.execPath, ['server/server.js'], {
stdio: 'inherit',
env: { ...process.env, PORT: process.env.PORT || '3001' },
env: { ...env, PORT: env.PORT || '3001', DEV_SEED_MEMBERS: env.DEV_SEED_MEMBERS || 'true' },
}),
spawn(process.execPath, ['node_modules/vite/bin/vite.js', '--host'], {
stdio: 'inherit',
env: process.env,
env,
}),
]

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,11 @@ export const clearSigner = () => {
localStorage.removeItem(SIGNER_SESSION_KEY)
}
export const getActiveSignerPublicKey = async () => {
if (!activeSigner) return ''
return activeSigner.getPublicKey()
}
export const loginWithExtension = async () => {
if (!window.nostr?.getPublicKey) {
throw new Error('No NIP-07 extension found. Try Alby, nos2x, or Primal extension.')

File diff suppressed because it is too large Load Diff