Files
sapien/README.md
2026-05-19 12:13:04 -05:00

3.8 KiB

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
ADMIN_ALLOWED_HOSTS=admin.local,l484.local
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.

The admin UI, admin APIs, and controller card-scan endpoint are available only when the request comes from localhost, a private LAN IP, a .local hostname, or a hostname listed in ADMIN_ALLOWED_HOSTS. Public members can still use /api/member/door/unlock from the external site when their local membership secret verifies an active paid membership.

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 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. Set MASTER_ADMIN_PUBKEY to the master admin npub; 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.