diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..402cfa5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.DS_Store +node_modules +dist +npm-debug.log* +Dockerfile +docker-compose.yml +docker-compose.onprem.yml +server/data diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..6317d2b --- /dev/null +++ b/.env.local.example @@ -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 diff --git a/.gitignore b/.gitignore index 1e5247d..684f488 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ node_modules dist .DS_Store +.env +.env.* +!.env.local.example *.local data server/data diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6b2b057 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.onprem.yml b/docker-compose.onprem.yml new file mode 100644 index 0000000..74bdbbf --- /dev/null +++ b/docker-compose.onprem.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d58b986 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/package-lock.json b/package-lock.json index b34b45a..debf920 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 47c36ca..69f6e03 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/images/entrance-door-icon.svg b/public/images/entrance-door-icon.svg new file mode 100644 index 0000000..b72be62 --- /dev/null +++ b/public/images/entrance-door-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/firepit.avif b/public/images/firepit.avif new file mode 100644 index 0000000..55ae9b5 Binary files /dev/null and b/public/images/firepit.avif differ diff --git a/public/images/gym.avif b/public/images/gym.avif new file mode 100644 index 0000000..1d42bba Binary files /dev/null and b/public/images/gym.avif differ diff --git a/public/images/plunge.avif b/public/images/plunge.avif new file mode 100644 index 0000000..9e04946 Binary files /dev/null and b/public/images/plunge.avif differ diff --git a/public/images/sauna.avif b/public/images/sauna.avif new file mode 100644 index 0000000..10ac7fb Binary files /dev/null and b/public/images/sauna.avif differ diff --git a/server/dev.js b/server/dev.js index 1861eff..5bc32c6 100644 --- a/server/dev.js +++ b/server/dev.js @@ -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, }), ] diff --git a/server/server.js b/server/server.js index 2c0e37c..be37c7c 100644 --- a/server/server.js +++ b/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`) }) diff --git a/src/App.vue b/src/App.vue index d7587bb..dac85a1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,9 +1,11 @@