Implement membership admin and BTCPay flow
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal 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
10
.env.local.example
Normal 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
3
.gitignore
vendored
@@ -1,6 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
!.env.local.example
|
||||
*.local
|
||||
data
|
||||
server/data
|
||||
|
||||
29
Dockerfile
Normal file
29
Dockerfile
Normal 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
26
docker-compose.onprem.yml
Normal 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
25
docker-compose.yml
Normal 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
1
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
public/images/entrance-door-icon.svg
Normal file
1
public/images/entrance-door-icon.svg
Normal 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
BIN
public/images/firepit.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 553 KiB |
BIN
public/images/gym.avif
Normal file
BIN
public/images/gym.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 404 KiB |
BIN
public/images/plunge.avif
Normal file
BIN
public/images/plunge.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 235 KiB |
BIN
public/images/sauna.avif
Normal file
BIN
public/images/sauna.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 249 KiB |
@@ -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,
|
||||
}),
|
||||
]
|
||||
|
||||
|
||||
750
server/server.js
750
server/server.js
@@ -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`)
|
||||
})
|
||||
|
||||
1163
src/App.vue
1163
src/App.vue
File diff suppressed because it is too large
Load Diff
@@ -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.')
|
||||
|
||||
1891
src/style.css
1891
src/style.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user