Harden admin events and document deployment

This commit is contained in:
Dorian
2026-05-14 12:53:37 -05:00
parent 3d87041f2d
commit 1708bfcf99
3 changed files with 141 additions and 2 deletions

93
README.md Normal file
View File

@@ -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=<random controller token>
BTCPAY_SERVER_URL=https://your-btcpay-host
BTCPAY_STORE_ID=<store id>
BTCPAY_API_KEY=<api key>
BTCPAY_WEBHOOK_SECRET=<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: <ACCESS_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.

View File

@@ -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)

View File

@@ -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) => {