Harden admin events and document deployment
This commit is contained in:
93
README.md
Normal file
93
README.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
18
src/App.vue
18
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) => {
|
||||
|
||||
Reference in New Issue
Block a user