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
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:
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>
HOME_ASSISTANT_UNLOCK_WEBHOOK_URL=<local HA webhook URL, optional>
HOME_ASSISTANT_UNLOCK_TIMEOUT_MS=2500
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:
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 planned lock is a Kwikset Home Connect 918. It unlocks over Z-Wave, so the NFC reader does not directly control the lock. The current plan is:
PN532 -> ESP32 -> L484 backend allow/deny -> Home Assistant/Z-Wave JS -> Zooz ZST39 LR -> Kwikset 918
Full hardware and wiring plan: docs/nfc-door.md.
ESP32 firmware scaffold: firmware/esp32-pn532-door.
The controller-facing endpoint is:
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:
{
"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.
If HOME_ASSISTANT_UNLOCK_WEBHOOK_URL is configured, an approved controller request also calls that local webhook so Home Assistant/Z-Wave JS can unlock the Kwikset 918 through the Zooz stick. Admin mock scans do not trigger unlocks.
Security Notes
- The generated user
nsecis 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. Set
MASTER_ADMIN_PUBKEYto the master adminnpub; only that key can approve or reject other admin keys. - Controller APIs require
ACCESS_CONTROLLER_TOKEN. - Mutating API routes have basic rate limiting.
- Rotate any development BTCPay credentials before production.