diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6bbbd4 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# L484 / Sapien Membership App + +Vue frontend with a small Node backend for private membership signup, Nostr sign-in, admin approvals, BTCPay invoices, encrypted local member-card export/import, and NFC card access scaffolding. + +## Development + +```bash +npm install +npm run dev +``` + +The dev script runs the API and Vite with `--host`. It also loads `.env.local` and seeds development members when `DEV_SEED_MEMBERS=true`. + +## Production / Portainer + +The app listens on port `2354` in the provided compose files. + +Required production environment: + +```bash +PORT=2354 +HOST=0.0.0.0 +APP_MODE=all +MEMBERSHIP_ENCRYPTION_KEY=<32+ random bytes> +ACCESS_HMAC_KEY=<32+ random bytes> +ACCESS_CONTROLLER_TOKEN= +BTCPAY_SERVER_URL=https://your-btcpay-host +BTCPAY_STORE_ID= +BTCPAY_API_KEY= +BTCPAY_WEBHOOK_SECRET= +DEV_SEED_MEMBERS=false +``` + +Keep `server/data` on a persistent volume. Do not deploy `.env.local`. + +## BTCPay + +Create a BTCPay webhook pointing at: + +```text +https://your-domain/api/btcpay/webhook +``` + +Use the same webhook secret as `BTCPAY_WEBHOOK_SECRET`. + +The admin payment modal opens a live server-sent event stream at `/api/admin/events`. When BTCPay calls the webhook, the backend marks the invoice paid and pushes a `payment-paid` event to open admin sessions. The status endpoint remains available for explicit refresh/error recovery. + +## NFC Door Readiness + +The controller-facing endpoint is: + +```http +POST /api/access/check +X-Controller-Token: +Content-Type: application/json + +{ + "doorId": "front-door", + "cardCredential": "scanned-card-secret-or-uid" +} +``` + +The backend stores only HMACs of card credentials using `ACCESS_HMAC_KEY`. Access is allowed only when: + +- the scanned card hash matches an active card, +- the member exists, +- the member access status resolves to `active`. + +The response: + +```json +{ + "allow": true, + "reason": "active_member_card", + "member": { + "membershipId": "L484-2026-XXXXXX", + "fullName": "Member Name", + "status": "active" + } +} +``` + +Every access check is logged in `server/data/access-logs.json`. + +## Security Notes + +- The generated user `nsec` is shown once in the browser and is not sent to the backend. +- Member records are encrypted before being saved in `server/data/memberships.json`. +- NFC card credentials are never stored raw. +- Admin APIs require an authorized Nostr public key. +- Controller APIs require `ACCESS_CONTROLLER_TOKEN`. +- Mutating API routes have basic rate limiting. +- Rotate any development BTCPay credentials before production. diff --git a/server/server.js b/server/server.js index be37c7c..cb24c90 100644 --- a/server/server.js +++ b/server/server.js @@ -43,6 +43,7 @@ const state = { } let bitcoinPriceCache = null const adminEventClients = new Set() +const rateBuckets = new Map() const publicApiEnabled = () => appMode !== 'admin' const adminApiEnabled = () => appMode !== 'public' @@ -55,6 +56,32 @@ const json = (res, status, body) => { res.end(JSON.stringify(body)) } +const rateLimit = (req, res) => { + const method = String(req.method || 'GET').toUpperCase() + if (!req.url?.startsWith('/api/') || method === 'GET') return false + const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown').split(',')[0].trim() + const pathname = new URL(req.url, `http://${req.headers.host}`).pathname + const key = `${ip}:${pathname}` + const now = Date.now() + const windowMs = 60_000 + const limit = pathname.includes('/access/check') ? 120 : 30 + const bucket = rateBuckets.get(key) || { count: 0, resetAt: now + windowMs } + if (now > bucket.resetAt) { + bucket.count = 0 + bucket.resetAt = now + windowMs + } + bucket.count += 1 + rateBuckets.set(key, bucket) + if (rateBuckets.size > 2000) { + for (const [bucketKey, value] of rateBuckets) { + if (now > value.resetAt) rateBuckets.delete(bucketKey) + } + } + if (bucket.count <= limit) return false + json(res, 429, { error: 'Too many requests. Try again shortly.' }) + return true +} + const ensureStore = async () => { await fs.mkdir(dataDir, { recursive: true, mode: 0o700 }) for (const file of Object.values(files)) { @@ -846,7 +873,10 @@ await seedDevelopmentStore() http.createServer(async (req, res) => { try { - if (req.url.startsWith('/api/')) await handleApi(req, res) + if (req.url.startsWith('/api/')) { + if (rateLimit(req, res)) return + await handleApi(req, res) + } else serveStatic(req, res) } catch (error) { console.error(error) diff --git a/src/App.vue b/src/App.vue index dac85a1..c0fa2b7 100644 --- a/src/App.vue +++ b/src/App.vue @@ -130,6 +130,7 @@ let facilityBackgroundTimer let adminToastTimer let parallaxFrame = 0 let adminEvents = null +let adminEventsReconnectTimer = 0 const currentMember = computed(() => members.value.find((member) => member.membershipId === currentMemberId.value) || null, @@ -765,6 +766,9 @@ const clearSignature = () => { } const deleteMember = async (membershipId) => { + const member = members.value.find((item) => item.membershipId === membershipId) + const label = member?.fullName || membershipId + if (!window.confirm(`Delete ${label}? This removes the member, payments, and issued cards from the admin store.`)) return if (isAdminAuthenticated.value) { try { await fetchJson('/api/membership', { @@ -790,12 +794,16 @@ const refreshAdminState = async () => { } const disconnectAdminEvents = () => { + window.clearTimeout(adminEventsReconnectTimer) + adminEventsReconnectTimer = 0 if (!adminEvents) return adminEvents.close() adminEvents = null } const connectAdminEvents = () => { + window.clearTimeout(adminEventsReconnectTimer) + adminEventsReconnectTimer = 0 if (!isAdminAuthenticated.value || adminEvents || typeof EventSource === 'undefined') return adminEvents = new EventSource(`/api/admin/events?pubkey=${encodeURIComponent(adminUser.value)}`) adminEvents.addEventListener('payment-paid', async (event) => { @@ -810,7 +818,15 @@ const connectAdminEvents = () => { adminActionMessage.value = 'New member request received.' await refreshAdminState() }) - adminEvents.onerror = () => {} + adminEvents.onerror = () => { + if (adminEvents) { + adminEvents.close() + adminEvents = null + } + if (isAdminAuthenticated.value && !adminEventsReconnectTimer) { + adminEventsReconnectTimer = window.setTimeout(connectAdminEvents, 3000) + } + } } const updateMemberStatus = async (membershipId, status) => {