diff --git a/.env.local.example b/.env.local.example index 6317d2b..8ebd36f 100644 --- a/.env.local.example +++ b/.env.local.example @@ -4,7 +4,13 @@ PORT=3001 MEMBERSHIP_ENCRYPTION_KEY=replace-with-a-64-character-hex-key ACCESS_HMAC_KEY=replace-with-a-long-random-access-hmac-key ACCESS_CONTROLLER_TOKEN=replace-with-a-long-random-door-controller-token +MASTER_ADMIN_PUBKEY=npub1replacewithmasteradminkey +HOME_ASSISTANT_UNLOCK_WEBHOOK_URL= +HOME_ASSISTANT_UNLOCK_TIMEOUT_MS=2500 BTCPAY_SERVER_URL=https://your-btcpay.example.com BTCPAY_STORE_ID=replace-with-store-id BTCPAY_API_KEY=replace-with-api-token BTCPAY_WEBHOOK_SECRET=replace-with-webhook-secret +VAPID_PUBLIC_KEY=replace-with-web-push-public-key +VAPID_PRIVATE_KEY=replace-with-web-push-private-key +VAPID_SUBJECT=mailto:admin@l484.com diff --git a/.gitignore b/.gitignore index 684f488..34cfd15 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ dist *.local data server/data +.venv-platformio +.platformio-core +firmware/esp32-pn532-door/.pio +firmware/esp32-pn532-door/include/l484_door_config.h diff --git a/README.md b/README.md index c6bbbd4..013754a 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ APP_MODE=all MEMBERSHIP_ENCRYPTION_KEY=<32+ random bytes> ACCESS_HMAC_KEY=<32+ random bytes> ACCESS_CONTROLLER_TOKEN= +HOME_ASSISTANT_UNLOCK_WEBHOOK_URL= +HOME_ASSISTANT_UNLOCK_TIMEOUT_MS=2500 BTCPAY_SERVER_URL=https://your-btcpay-host BTCPAY_STORE_ID= BTCPAY_API_KEY= @@ -47,6 +49,16 @@ The admin payment modal opens a live server-sent event stream at `/api/admin/eve ## 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: + +```text +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](docs/nfc-door.md). + +ESP32 firmware scaffold: [firmware/esp32-pn532-door](firmware/esp32-pn532-door). + The controller-facing endpoint is: ```http @@ -82,12 +94,14 @@ The response: 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. +- 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. diff --git a/docker-compose.onprem.yml b/docker-compose.onprem.yml index 74bdbbf..0d12687 100644 --- a/docker-compose.onprem.yml +++ b/docker-compose.onprem.yml @@ -15,6 +15,8 @@ services: MEMBERSHIP_ENCRYPTION_KEY: ${MEMBERSHIP_ENCRYPTION_KEY:?Set a unique 64-character hex key} ACCESS_HMAC_KEY: ${ACCESS_HMAC_KEY:?Set a unique access HMAC key} ACCESS_CONTROLLER_TOKEN: ${ACCESS_CONTROLLER_TOKEN:?Set a unique door-controller token} + HOME_ASSISTANT_UNLOCK_WEBHOOK_URL: ${HOME_ASSISTANT_UNLOCK_WEBHOOK_URL:-} + HOME_ASSISTANT_UNLOCK_TIMEOUT_MS: ${HOME_ASSISTANT_UNLOCK_TIMEOUT_MS:-2500} BTCPAY_SERVER_URL: ${BTCPAY_SERVER_URL:-} BTCPAY_STORE_ID: ${BTCPAY_STORE_ID:-} BTCPAY_API_KEY: ${BTCPAY_API_KEY:-} diff --git a/docker-compose.yml b/docker-compose.yml index d58b986..b850357 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,9 @@ services: APP_MODE: public MEMBERSHIP_ENCRYPTION_KEY: ${MEMBERSHIP_ENCRYPTION_KEY:?Set a unique 64-character hex key} ACCESS_HMAC_KEY: ${ACCESS_HMAC_KEY:?Set a unique access HMAC key} + ACCESS_CONTROLLER_TOKEN: ${ACCESS_CONTROLLER_TOKEN:-} + HOME_ASSISTANT_UNLOCK_WEBHOOK_URL: ${HOME_ASSISTANT_UNLOCK_WEBHOOK_URL:-} + HOME_ASSISTANT_UNLOCK_TIMEOUT_MS: ${HOME_ASSISTANT_UNLOCK_TIMEOUT_MS:-2500} BTCPAY_SERVER_URL: ${BTCPAY_SERVER_URL:-} BTCPAY_STORE_ID: ${BTCPAY_STORE_ID:-} BTCPAY_API_KEY: ${BTCPAY_API_KEY:-} diff --git a/docs/71CKqXDmOkL._AC_SL1500_.jpg b/docs/71CKqXDmOkL._AC_SL1500_.jpg new file mode 100644 index 0000000..39aef3f Binary files /dev/null and b/docs/71CKqXDmOkL._AC_SL1500_.jpg differ diff --git a/docs/PN532.jpg b/docs/PN532.jpg new file mode 100644 index 0000000..494a3aa Binary files /dev/null and b/docs/PN532.jpg differ diff --git a/docs/assets/esp32-pn532-photo-overlay.svg b/docs/assets/esp32-pn532-photo-overlay.svg new file mode 100644 index 0000000..e02078a --- /dev/null +++ b/docs/assets/esp32-pn532-photo-overlay.svg @@ -0,0 +1,91 @@ + + Photo overlay wiring guide for ESP32 DevKit and PN532 NFC module + Annotated photo overlay showing common ESP32 DevKit pins wired to PN532 I2C pins for the L484 NFC door reader. + + + + + + + + + ESP32 + PN532 PHOTO WIRING OVERLAY + SUPPLIED BOARD PHOTOS, PN532 IN I2C MODE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 3V3 -> VCC + + GND -> GND + + GPIO21 -> SDA + + GPIO22 -> SCL + + GPIO4 -> IRQ + + GPIO5 -> RSTO + + + + + VERIFY THE SILKSCREEN/PINOUT ON YOUR EXACT ESP32 BOARD BEFORE POWERING. + The ESP32 photo does not show pin labels, so the ESP32 dots follow the common 30-pin DevKit V1 pinout. The PN532 labels are visible in the supplied photo. + + diff --git a/docs/assets/esp32-pn532-wiring.svg b/docs/assets/esp32-pn532-wiring.svg new file mode 100644 index 0000000..be6ac6d --- /dev/null +++ b/docs/assets/esp32-pn532-wiring.svg @@ -0,0 +1,103 @@ + + ESP32 to PN532 I2C wiring for L484 door reader + Wiring diagram showing PN532 VCC, GND, SDA, SCL, IRQ, and reset pins connected to ESP32 3V3, GND, GPIO21, GPIO22, GPIO4, and GPIO5. + + + + + + + + + + + + + + + + + + + L484 NFC DOOR READER WIRING + ESP32 DEV BOARD + PN532 NFC V3 MODULE, I2C MODE + + + + + + USB-C + + ESP32 + DEV BOARD + + + 3V3 + GND + GPIO21 + GPIO22 + GPIO4 + GPIO5 + + + + + + + + + + PN532 + NFC V3 MODULE + + + VCC + GND + SDA + SCL + IRQ + RSTO/RST + + + + + + + 3V3 -> VCC + + + + GND -> GND + + + + GPIO21 -> SDA + + + + GPIO22 -> SCL + + + + GPIO4 -> IRQ + + + + GPIO5 -> RSTO/RST + + VERIFY THE SILKSCREEN ON YOUR EXACT BOARDS BEFORE POWERING. + Some PN532 V3 modules label reset as RSTO, RST, or RSTPDN. Set the PN532 switches/jumpers to I2C mode. + diff --git a/docs/esp32.jpg b/docs/esp32.jpg new file mode 100644 index 0000000..d50f3c5 Binary files /dev/null and b/docs/esp32.jpg differ diff --git a/docs/nfc-door.md b/docs/nfc-door.md new file mode 100644 index 0000000..d04afac --- /dev/null +++ b/docs/nfc-door.md @@ -0,0 +1,248 @@ +# NFC Door Plan: Kwikset Home Connect 918 + Zooz ZST39 LR + +This plan is for adding L484 member-card access to the existing **Kwikset Home Connect 918** lock. + +The Kwikset 918 is a Z-Wave lock. It does not read NFC cards directly, so the NFC reader is only the credential scanner. The actual unlock command must be sent through a Z-Wave controller. + +## Hardware + +- Lock: Kwikset Home Connect 918. +- NFC reader: PN532 NFC V3 Module Reader/Writer board. +- Reader controller: ESP32 dev board. +- Z-Wave coordinator: Zooz 800 Series Z-Wave Long Range S2 USB Stick ZST39 LR. +- On-prem host: a local server running the L484 backend plus either Home Assistant or Z-Wave JS. + +## Architecture + +```text +Member NFC card + -> PN532 reader + -> ESP32 over I2C/SPI + -> L484 backend /api/access/check + -> allow/deny decision and access log + -> local Z-Wave bridge + -> Zooz ZST39 LR + -> Kwikset Home Connect 918 unlock command +``` + +The ESP32 should not directly unlock the Kwikset. It should only read the card and ask the local backend whether access is allowed. + +## Local Services + +Run these on the on-prem server: + +- L484 backend and admin app. +- Z-Wave JS or Home Assistant with Z-Wave JS integration. +- Zooz ZST39 LR USB stick paired to the Kwikset 918. + +The simplest operational setup is Home Assistant: + +```text +ESP32 -> L484 API -> Home Assistant webhook/service call -> lock.unlock +``` + +Alternative setup: + +```text +ESP32 -> L484 API -> Z-Wave JS API/MQTT -> Kwikset unlock +``` + +Home Assistant is likely easier to operate and inspect. + +## Access Flow + +1. Member taps NFC card on the PN532 reader. +2. ESP32 reads the card credential. +3. ESP32 sends the credential to the local L484 backend. +4. Backend checks: + - card exists, + - card is active, + - member exists, + - member is paid/active, + - member is not suspended/revoked/expired. +5. Backend records an access log. +6. If allowed, the backend calls the local Home Assistant webhook. +7. Home Assistant/Z-Wave JS sends unlock to the Kwikset 918 through the Zooz stick. + +## Controller API + +The ESP32 calls: + +```http +POST /api/access/check +X-Controller-Token: +Content-Type: application/json + +{ + "doorId": "front-door", + "cardCredential": "" +} +``` + +Allowed response: + +```json +{ + "allow": true, + "reason": "active_member_card", + "member": { + "membershipId": "L484-2026-XXXXXX", + "fullName": "Member Name", + "status": "active" + } +} +``` + +Denied response: + +```json +{ + "allow": false, + "reason": "member_suspended", + "member": { + "membershipId": "L484-2026-XXXXXX", + "fullName": "Member Name", + "status": "suspended" + } +} +``` + +## PN532 to ESP32 + +Start with I2C. + +Photo overlay using the supplied board pictures: + +![ESP32 to PN532 photo wiring overlay](assets/esp32-pn532-photo-overlay.svg) + +Clean schematic: + +![ESP32 to PN532 wiring diagram](assets/esp32-pn532-wiring.svg) + +Typical wiring: + +- PN532 `VCC` -> ESP32 `3V3` +- PN532 `GND` -> ESP32 `GND` +- PN532 `SDA` -> ESP32 GPIO 21 +- PN532 `SCL` -> ESP32 GPIO 22 +- PN532 `IRQ` -> ESP32 GPIO 4 +- PN532 `RSTO/RST` -> ESP32 GPIO 5 + +If reads are unreliable, switch to SPI and keep the cable short. + +## Card Credential Model + +Do not rely on raw UID-only cards for production. + +Preferred first version: + +- Write a random card secret to a writable NFC card/tag. +- Register that secret in the L484 admin panel when issuing the card. +- Backend stores only `HMAC_SHA256(secret, ACCESS_HMAC_KEY)`. +- ESP32 reads and submits the secret. + +Raw UID can be retained as a fallback identifier for testing, but it is cloneable and should not be the production access credential. + +## Z-Wave / Kwikset Setup + +1. Plug Zooz ZST39 LR into the on-prem server. +2. Add it to Home Assistant or Z-Wave JS. +3. Pair the Kwikset Home Connect 918 with S2 security if supported. +4. Confirm manual `lock.unlock` works from Home Assistant/Z-Wave JS. +5. Create a local-only webhook/service path that unlocks only after L484 backend approval. +6. Set `HOME_ASSISTANT_UNLOCK_WEBHOOK_URL` in the L484 backend environment. + +The unlock call should stay on the LAN. Do not expose Home Assistant or Z-Wave JS publicly for this. + +## Home Assistant Webhook + +Use a Home Assistant automation webhook as the backend target. The webhook should call `lock.unlock` for the Kwikset 918 entity. + +Example automation shape: + +```yaml +alias: L484 approved member unlock +trigger: + - platform: webhook + webhook_id: l484-approved-unlock-random-secret + allowed_methods: + - POST + local_only: true +action: + - service: lock.unlock + target: + entity_id: lock.kwikset_918 +mode: single +``` + +Set the L484 backend value to the local webhook URL: + +```bash +HOME_ASSISTANT_UNLOCK_WEBHOOK_URL=http://homeassistant.local:8123/api/webhook/l484-approved-unlock-random-secret +``` + +The backend posts member/card metadata to the webhook body for future logging or notifications. + +## Security Rules + +- Keep ESP32, power conversion, and wiring inside. +- Mount only the PN532 reader outside, or protect the wiring if the reader must be outside. +- Use HTTPS or a trusted isolated LAN between ESP32 and backend where practical. +- Use a strong `ACCESS_CONTROLLER_TOKEN`. +- Use a strong `ACCESS_HMAC_KEY`. +- Fail locked if backend, Wi-Fi, Home Assistant, or Z-Wave is unavailable. +- Log every allow/deny attempt. +- Do not store raw card secrets in backend data. +- Do not put Nostr nsec keys on cards. + +## Failure Behavior + +- If card read fails: deny. +- If backend request times out: deny. +- If backend says deny: do not call Z-Wave. +- If Z-Wave unlock fails: log locally and deny/retry once only. +- If internet is down but LAN is up: system should still work because all services are local. + +## Development Access Tests + +When `DEV_SEED_MEMBERS=true`, the backend seeds active and suspended members with matching test card credentials. + +Example active-card request: + +```bash +curl -X POST http://localhost:3001/api/access/check \ + -H "Content-Type: application/json" \ + -H "X-Controller-Token: $ACCESS_CONTROLLER_TOKEN" \ + -d '{"doorId":"front-door","cardCredential":"dev-card-1-L484-2026-DEV07X"}' +``` + +Expected result: `allow: true`. + +Example suspended-card request: + +```bash +curl -X POST http://localhost:3001/api/access/check \ + -H "Content-Type: application/json" \ + -H "X-Controller-Token: $ACCESS_CONTROLLER_TOKEN" \ + -d '{"doorId":"front-door","cardCredential":"dev-card-4-L484-2026-DEV10X"}' +``` + +Expected result: `allow: false`. + +If `HOME_ASSISTANT_UNLOCK_WEBHOOK_URL` is configured, only the active controller request attempts the Home Assistant unlock webhook. + +## Next Build Tasks + +Completed in this repo: + +- ESP32 firmware scaffold with configurable backend URL, controller token, door ID, and PN532 mode. +- Backend approved-access hook for Home Assistant/Z-Wave unlock. +- Decision: L484 backend is responsible for the final Home Assistant unlock call. + +Remaining when hardware arrives: + +1. Copy `firmware/esp32-pn532-door/include/l484_door_config.example.h` to `l484_door_config.h` and fill in local values. +2. Flash ESP32 and confirm PN532 card reads over serial. +3. Pair Zooz ZST39 LR and Kwikset 918 in Home Assistant/Z-Wave JS. +4. Create the Home Assistant webhook automation and set `HOME_ASSISTANT_UNLOCK_WEBHOOK_URL`. +5. Test with one development member, one active card, and one suspended member. diff --git a/firmware/esp32-pn532-door/README.md b/firmware/esp32-pn532-door/README.md new file mode 100644 index 0000000..2aaba2a --- /dev/null +++ b/firmware/esp32-pn532-door/README.md @@ -0,0 +1,63 @@ +# ESP32 PN532 Door Reader + +Firmware scaffold for the L484 NFC door reader. + +## Role + +The ESP32 reads the NFC credential from the PN532 and asks the local L484 backend whether access is allowed. + +For the Kwikset Home Connect 918 path, the ESP32 does **not** unlock the door directly. The backend triggers Home Assistant/Z-Wave JS, which sends the unlock command through the Zooz ZST39 LR to the Kwikset lock. + +## Setup + +1. Install PlatformIO. +2. Copy `include/l484_door_config.example.h` to `include/l484_door_config.h`. +3. Fill in: + - Wi-Fi SSID/password. + - L484 backend access URL. + - `ACCESS_CONTROLLER_TOKEN`. + - `doorId`. + - PN532 pins and card mode. +4. Build/upload: + +```bash +pio run -t upload +pio device monitor +``` + +## Wiring + +Photo overlay using the supplied board pictures: + +![ESP32 to PN532 photo wiring overlay](../../docs/assets/esp32-pn532-photo-overlay.svg) + +Clean schematic: + +![ESP32 to PN532 wiring diagram](../../docs/assets/esp32-pn532-wiring.svg) + +Default ESP32 dev board wiring: + +- PN532 `VCC` -> ESP32 `3V3` +- PN532 `GND` -> ESP32 `GND` +- PN532 `SDA` -> ESP32 `GPIO21` +- PN532 `SCL` -> ESP32 `GPIO22` +- PN532 `IRQ` -> ESP32 `GPIO4` +- PN532 `RSTO/RST` -> ESP32 `GPIO5` + +Set the PN532 board switches to I2C mode. + +## Card Modes + +`CARD_MODE_UID` submits the card UID as hex. Use this for bench testing only. + +`CARD_MODE_MIFARE_CLASSIC_BLOCK` reads a MIFARE Classic block and submits the text secret stored there. This is the preferred first production direction because UID-only cards are cloneable. + +## Fail-Closed Behavior + +The firmware denies access if: + +- Wi-Fi is unavailable. +- PN532 read fails. +- Backend times out. +- Backend returns non-2xx. +- Backend response does not include `"allow":true`. diff --git a/firmware/esp32-pn532-door/include/l484_door_config.example.h b/firmware/esp32-pn532-door/include/l484_door_config.example.h new file mode 100644 index 0000000..a2e952a --- /dev/null +++ b/firmware/esp32-pn532-door/include/l484_door_config.example.h @@ -0,0 +1,34 @@ +#pragma once + +// Copy this file to include/l484_door_config.h and fill in local values. + +#define WIFI_SSID "your-wifi" +#define WIFI_PASSWORD "your-wifi-password" + +// Use the on-prem L484 backend reachable from the ESP32 LAN. +#define L484_ACCESS_URL "http://192.168.1.10:2354/api/access/check" +#define L484_CONTROLLER_TOKEN "replace-with-ACCESS_CONTROLLER_TOKEN" +#define L484_DOOR_ID "front-door" + +// PN532 I2C pins on a typical ESP32 dev board. +#define PN532_SDA_PIN 21 +#define PN532_SCL_PIN 22 +#define PN532_IRQ_PIN 4 +#define PN532_RESET_PIN 5 + +// Card credential modes: +// 1 = submit UID hex. Good for bench testing only. +// 2 = read a MIFARE Classic block and submit the secret stored there. +#define CARD_MODE_UID 1 +#define CARD_MODE_MIFARE_CLASSIC_BLOCK 2 +#define CARD_MODE CARD_MODE_UID + +// MIFARE Classic settings used when CARD_MODE is CARD_MODE_MIFARE_CLASSIC_BLOCK. +#define MIFARE_SECRET_BLOCK 4 +static const uint8_t MIFARE_KEY_A[6] = { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff }; + +// Backend request timeout. Fail closed if the API does not answer. +#define ACCESS_TIMEOUT_MS 2500 + +// Avoid duplicate reads while a card is held on the reader. +#define READ_COOLDOWN_MS 1800 diff --git a/firmware/esp32-pn532-door/platformio.ini b/firmware/esp32-pn532-door/platformio.ini new file mode 100644 index 0000000..61917d3 --- /dev/null +++ b/firmware/esp32-pn532-door/platformio.ini @@ -0,0 +1,7 @@ +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +lib_deps = + adafruit/Adafruit PN532@^1.3.3 diff --git a/firmware/esp32-pn532-door/src/main.cpp b/firmware/esp32-pn532-door/src/main.cpp new file mode 100644 index 0000000..6d35e80 --- /dev/null +++ b/firmware/esp32-pn532-door/src/main.cpp @@ -0,0 +1,174 @@ +#include +#include +#include +#include +#include + +#if __has_include("l484_door_config.h") + #include "l484_door_config.h" +#else + #include "l484_door_config.example.h" +#endif + +Adafruit_PN532 nfc(PN532_IRQ_PIN, PN532_RESET_PIN, &Wire); + +static unsigned long lastReadAt = 0; +static String lastCredential = ""; + +String bytesToHex(const uint8_t *data, uint8_t length) { + String out; + out.reserve(length * 2); + for (uint8_t i = 0; i < length; i++) { + if (data[i] < 0x10) out += "0"; + out += String(data[i], HEX); + } + out.toUpperCase(); + return out; +} + +String jsonEscape(const String &value) { + String out; + out.reserve(value.length() + 8); + for (size_t i = 0; i < value.length(); i++) { + char c = value[i]; + if (c == '"' || c == '\\') out += '\\'; + out += c; + } + return out; +} + +void connectWifi() { + if (WiFi.status() == WL_CONNECTED) return; + + Serial.printf("Connecting to Wi-Fi %s", WIFI_SSID); + WiFi.mode(WIFI_STA); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + + unsigned long startedAt = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - startedAt < 15000) { + Serial.print("."); + delay(350); + } + Serial.println(); + + if (WiFi.status() == WL_CONNECTED) { + Serial.print("Wi-Fi connected: "); + Serial.println(WiFi.localIP()); + } else { + Serial.println("Wi-Fi connection failed."); + } +} + +bool readCardUid(uint8_t *uid, uint8_t *uidLength) { + return nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, uidLength, 350); +} + +String readMifareSecret(uint8_t *uid, uint8_t uidLength) { + uint8_t data[16]; + uint8_t key[6]; + memcpy(key, MIFARE_KEY_A, sizeof(key)); + + bool authed = nfc.mifareclassic_AuthenticateBlock(uid, uidLength, MIFARE_SECRET_BLOCK, 0, key); + if (!authed) { + Serial.println("MIFARE auth failed."); + return ""; + } + + bool readOk = nfc.mifareclassic_ReadDataBlock(MIFARE_SECRET_BLOCK, data); + if (!readOk) { + Serial.println("MIFARE block read failed."); + return ""; + } + + String secret; + for (uint8_t i = 0; i < sizeof(data); i++) { + if (data[i] == 0x00) break; + if (data[i] >= 32 && data[i] <= 126) secret += static_cast(data[i]); + } + secret.trim(); + return secret; +} + +String readCredential() { + uint8_t uid[7] = { 0 }; + uint8_t uidLength = 0; + if (!readCardUid(uid, &uidLength)) return ""; + +#if CARD_MODE == CARD_MODE_MIFARE_CLASSIC_BLOCK + String secret = readMifareSecret(uid, uidLength); + if (secret.length() >= 6) return secret; + Serial.println("No usable card secret found."); + return ""; +#else + return bytesToHex(uid, uidLength); +#endif +} + +bool requestAccess(const String &credential) { + if (WiFi.status() != WL_CONNECTED) { + Serial.println("No Wi-Fi; deny."); + return false; + } + + HTTPClient http; + http.setTimeout(ACCESS_TIMEOUT_MS); + http.begin(L484_ACCESS_URL); + http.addHeader("Content-Type", "application/json"); + http.addHeader("X-Controller-Token", L484_CONTROLLER_TOKEN); + + String body = "{\"doorId\":\"" + jsonEscape(L484_DOOR_ID) + "\",\"cardCredential\":\"" + jsonEscape(credential) + "\"}"; + int status = http.POST(body); + String response = http.getString(); + http.end(); + + Serial.printf("Access API status: %d\n", status); + Serial.println(response); + + if (status < 200 || status >= 300) return false; + return response.indexOf("\"allow\":true") >= 0; +} + +void setup() { + Serial.begin(115200); + delay(200); + + connectWifi(); + + Wire.begin(PN532_SDA_PIN, PN532_SCL_PIN); + nfc.begin(); + uint32_t version = nfc.getFirmwareVersion(); + if (!version) { + Serial.println("PN532 not found. Check wiring and mode switches."); + while (true) delay(1000); + } + + nfc.SAMConfig(); + Serial.println("PN532 ready."); +} + +void loop() { + connectWifi(); + + String credential = readCredential(); + if (!credential.length()) { + delay(120); + return; + } + + unsigned long now = millis(); + if (credential == lastCredential && now - lastReadAt < READ_COOLDOWN_MS) { + delay(120); + return; + } + + lastCredential = credential; + lastReadAt = now; + + Serial.print("Card credential: "); + Serial.println(credential); + + bool allowed = requestAccess(credential); + Serial.println(allowed ? "ACCESS ALLOWED" : "ACCESS DENIED"); + + delay(READ_COOLDOWN_MS); +} diff --git a/index.html b/index.html index 5a69397..b6f6450 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,12 @@ + + + + + L484 diff --git a/package-lock.json b/package-lock.json index debf920..2427416 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,10 @@ "@vitejs/plugin-vue": "^5.2.1", "applesauce-signers": "^5.1.0", "nostr-tools": "^2.10.4", + "qrcode": "^1.5.4", "vite": "^6.0.5", - "vue": "^3.5.13" + "vue": "^3.5.13", + "web-push": "^3.6.7" }, "devDependencies": { "autoprefixer": "^10.4.20", @@ -1124,6 +1126,39 @@ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", "license": "MIT" }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1246,6 +1281,18 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/autoprefixer": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", @@ -1309,6 +1356,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1357,6 +1410,21 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1426,6 +1494,35 @@ "node": ">= 6" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1472,6 +1569,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1479,6 +1585,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -1486,6 +1598,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.354", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.354.tgz", @@ -1493,6 +1614,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1631,6 +1758,19 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -1669,6 +1809,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1701,6 +1850,34 @@ "node": ">= 0.4" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1740,6 +1917,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1774,6 +1960,27 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1794,6 +2001,18 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1827,6 +2046,21 @@ "node": ">=8.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1931,6 +2165,51 @@ "node": ">= 6" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1977,6 +2256,15 @@ "node": ">= 6" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -2140,6 +2428,23 @@ "dev": true, "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2184,6 +2489,21 @@ "node": ">=8.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -2294,6 +2614,38 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2303,6 +2655,32 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -2636,6 +3014,86 @@ "optional": true } } + }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/package.json b/package.json index 69f6e03..a3fec7b 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,10 @@ "@vitejs/plugin-vue": "^5.2.1", "applesauce-signers": "^5.1.0", "nostr-tools": "^2.10.4", + "qrcode": "^1.5.4", "vite": "^6.0.5", - "vue": "^3.5.13" + "vue": "^3.5.13", + "web-push": "^3.6.7" }, "devDependencies": { "autoprefixer": "^10.4.20", diff --git a/public/sw.js b/public/sw.js index 6f0192e..2dc6571 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'l484-pwa-v1' +const CACHE_NAME = 'l484-pwa-v2' const APP_SHELL = [ '/', '/manifest.webmanifest', @@ -23,6 +23,7 @@ self.addEventListener('activate', (event) => { self.addEventListener('fetch', (event) => { const url = new URL(event.request.url) + if (!['http:', 'https:'].includes(url.protocol)) return if (url.pathname.startsWith('/api/')) return event.respondWith( @@ -37,3 +38,52 @@ self.addEventListener('fetch', (event) => { ), ) }) + +self.addEventListener('pushsubscriptionchange', (event) => { + event.waitUntil((async () => { + const response = await fetch('/api/notifications/vapid-public-key', { cache: 'no-store' }) + const { publicKey } = response.ok ? await response.json() : {} + if (!publicKey) return + const padding = '='.repeat((4 - publicKey.length % 4) % 4) + const base64 = (publicKey + padding).replace(/-/g, '+').replace(/_/g, '/') + const rawData = atob(base64) + const applicationServerKey = new Uint8Array([...rawData].map((char) => char.charCodeAt(0))) + const subscription = await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }) + await fetch('/api/notifications/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subscription }), + }) + })()) +}) + +self.addEventListener('push', (event) => { + let data = {} + try { + data = event.data ? event.data.json() : {} + } catch { + data = { title: 'L484', message: event.data?.text() || 'New L484 update.' } + } + const title = data.title || 'L484' + const options = { + body: data.message || data.body || 'New L484 update.', + icon: data.icon || '/images/small-logo.svg', + badge: '/images/small-logo.svg', + tag: data.tag || 'l484-update', + data: { url: data.data?.url || '/' }, + } + event.waitUntil(self.registration.showNotification(title, options)) +}) + +self.addEventListener('notificationclick', (event) => { + event.notification.close() + const urlToOpen = event.notification.data?.url || '/' + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + const existing = clientList.find((client) => new URL(client.url).pathname === urlToOpen && 'focus' in client) + if (existing) return existing.focus() + if (clients.openWindow) return clients.openWindow(urlToOpen) + return null + }), + ) +}) diff --git a/server/server.js b/server/server.js index cb24c90..1bf82b5 100644 --- a/server/server.js +++ b/server/server.js @@ -5,6 +5,7 @@ import http from 'node:http' import path from 'node:path' import { fileURLToPath } from 'node:url' import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools' +import webpush from 'web-push' import { decryptMembership, encryptMembership } from './encryption.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -23,16 +24,32 @@ const btcpayServerUrl = String(process.env.BTCPAY_SERVER_URL || '').replace(/\/+ const btcpayApiKey = process.env.BTCPAY_API_KEY || '' const btcpayStoreId = process.env.BTCPAY_STORE_ID || '' const btcpayWebhookSecret = process.env.BTCPAY_WEBHOOK_SECRET || '' -const adminPubkeys = [ - '7b849efa5604b58d50c419637b9873847dbf957081d526136c3a49b7357cd617', - '2be35e9237eaf98fe0a7d1ce3ceb666dccde9c8cc800ff52f138a62c91d90783', -] +const homeAssistantUnlockWebhookUrl = process.env.HOME_ASSISTANT_UNLOCK_WEBHOOK_URL || '' +const homeAssistantUnlockTimeoutMs = Number(process.env.HOME_ASSISTANT_UNLOCK_TIMEOUT_MS || 2500) +const vapidPublicKey = process.env.VAPID_PUBLIC_KEY || '' +const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY || '' +const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:admin@l484.com' +const normalizeAdminPubkey = (value) => { + const raw = String(value || '').trim().toLowerCase() + if (/^[0-9a-f]{64}$/.test(raw)) return raw + if (!raw.startsWith('npub1')) return '' + try { + const decoded = nip19.decode(raw) + return decoded.type === 'npub' && /^[0-9a-f]{64}$/.test(decoded.data) ? decoded.data : '' + } catch { + return '' + } +} +const masterAdminPubkey = normalizeAdminPubkey(process.env.MASTER_ADMIN_PUBKEY) const files = { memberships: path.join(dataDir, 'memberships.json'), payments: path.join(dataDir, 'payments.json'), cards: path.join(dataDir, 'cards.json'), accessLogs: path.join(dataDir, 'access-logs.json'), + siteContent: path.join(dataDir, 'site-content.json'), + adminRequests: path.join(dataDir, 'admin-requests.json'), + notificationSubscriptions: path.join(dataDir, 'notification-subscriptions.json'), } const state = { @@ -40,6 +57,9 @@ const state = { payments: [], cards: [], accessLogs: [], + siteContent: null, + adminRequests: [], + notificationSubscriptions: [], } let bitcoinPriceCache = null const adminEventClients = new Set() @@ -48,6 +68,10 @@ const rateBuckets = new Map() const publicApiEnabled = () => appMode !== 'admin' const adminApiEnabled = () => appMode !== 'public' +if (vapidPublicKey && vapidPrivateKey) { + webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey) +} + const json = (res, status, body) => { res.writeHead(status, { 'Content-Type': 'application/json', @@ -84,9 +108,13 @@ const rateLimit = (req, res) => { const ensureStore = async () => { await fs.mkdir(dataDir, { recursive: true, mode: 0o700 }) - for (const file of Object.values(files)) { + for (const [name, file] of Object.entries(files)) { + if (name === 'siteContent') continue if (!existsSync(file)) await fs.writeFile(file, '[]', { mode: 0o600 }) } + if (!existsSync(files.siteContent)) { + await fs.writeFile(files.siteContent, JSON.stringify(defaultSiteContent, null, 2), { mode: 0o600 }) + } } const loadJson = async (file) => JSON.parse(await fs.readFile(file, 'utf8')) @@ -98,12 +126,18 @@ const loadStore = async () => { state.payments = await loadJson(files.payments) state.cards = await loadJson(files.cards) state.accessLogs = await loadJson(files.accessLogs) + state.siteContent = normalizeSiteContent(await loadJson(files.siteContent).catch(() => defaultSiteContent)) + state.adminRequests = await loadJson(files.adminRequests) + state.notificationSubscriptions = await loadJson(files.notificationSubscriptions) } const saveMemberships = () => saveJson(files.memberships, state.memberships) const savePayments = () => saveJson(files.payments, state.payments) const saveCards = () => saveJson(files.cards, state.cards) const saveAccessLogs = () => saveJson(files.accessLogs, state.accessLogs) +const saveSiteContent = () => saveJson(files.siteContent, state.siteContent) +const saveAdminRequests = () => saveJson(files.adminRequests, state.adminRequests) +const saveNotificationSubscriptions = () => saveJson(files.notificationSubscriptions, state.notificationSubscriptions) const readBody = async (req) => { const raw = await readRawBody(req) @@ -129,6 +163,115 @@ const cleanText = (value, max = 160) => .trim() .slice(0, max) +const defaultSiteContent = { + homepage: { + hero: { + line1: 'Decentralization', + line2: 'In Motion', + benefitsCue: 'Benefits', + }, + members: { + title: 'Members Get', + benefits: [ + { title: 'Raw milk', description: 'Placeholder member benefit.' }, + { title: 'Grass fed beef', description: 'Placeholder member benefit.' }, + { title: 'Upgrade Labs', description: 'Placeholder member benefit.' }, + { title: '5 meals a month', description: 'Placeholder member benefit.' }, + { title: 'Free drinks', description: 'Placeholder member benefit.' }, + ], + }, + facilities: { + title: 'Facilities', + items: [ + { title: 'Sauna', description: '6-8 person sauna available for member use.' }, + { title: 'Cold plunge', description: 'Two XL plunges filtered and cooled to your chosen temperature.' }, + { title: 'Gym', description: 'Outdoor turf space set up for fitness and group workouts.' }, + { title: 'Event space', description: 'Customizable indoor and outdoor space for private gatherings.' }, + { title: 'Grill area / firepit', description: 'Multiple grills and a custom sandbox firepit outside.' }, + ], + }, + events: { + navLabel: 'Events', + title: 'Events', + description: 'Private member gatherings, workshops, and hosted sessions at L484.', + enquiryTitle: 'Event Enquiry', + items: [ + { title: 'Member nights', description: 'Small-format gatherings for active members and invited guests.', date: 'Fridays', price: 'Members', image: '/images/firepit.avif' }, + { title: 'Workshops', description: 'Hands-on sessions around food, wellness, Bitcoin, and local resilience.', date: 'Monthly', price: '$40+', image: '/images/gym.avif' }, + { title: 'Private bookings', description: 'Indoor and outdoor areas available for approved member events.', date: 'By enquiry', price: 'Custom', image: '/images/bg-3.avif' }, + ], + }, + }, +} + +const CONTENT_LIMITS = { + heroLine: 24, + cue: 16, + sectionTitle: 32, + navLabel: 16, + itemTitle: 36, + itemDescription: 90, + eventTitle: 48, + eventDescription: 140, + eventImage: 240, + eventDate: 32, + eventPrice: 32, + maxBenefits: 5, + maxFacilities: 5, + maxEvents: 6, +} + +const limitedList = (value, fallback, limit, normalize) => { + const source = Array.isArray(value) && value.length ? value : fallback + return source.slice(0, limit).map(normalize) +} + +const normalizeSiteContent = (value = {}) => { + const homepage = value.homepage || {} + const defaults = defaultSiteContent.homepage + const hero = homepage.hero || {} + const members = homepage.members || {} + const facilities = homepage.facilities || {} + const events = homepage.events || {} + + return { + homepage: { + hero: { + line1: cleanText(hero.line1, CONTENT_LIMITS.heroLine) || defaults.hero.line1, + line2: cleanText(hero.line2, CONTENT_LIMITS.heroLine) || defaults.hero.line2, + benefitsCue: cleanText(hero.benefitsCue, CONTENT_LIMITS.cue) || defaults.hero.benefitsCue, + }, + members: { + title: cleanText(members.title, CONTENT_LIMITS.sectionTitle) || defaults.members.title, + benefits: limitedList(members.benefits, defaults.members.benefits, CONTENT_LIMITS.maxBenefits, (item, index) => ({ + title: cleanText(item?.title, CONTENT_LIMITS.itemTitle) || defaults.members.benefits[index]?.title || `Benefit ${index + 1}`, + description: cleanText(item?.description, CONTENT_LIMITS.itemDescription) || defaults.members.benefits[index]?.description || '', + })), + }, + facilities: { + title: cleanText(facilities.title, CONTENT_LIMITS.sectionTitle) || defaults.facilities.title, + items: limitedList(facilities.items, defaults.facilities.items, CONTENT_LIMITS.maxFacilities, (item, index) => ({ + title: cleanText(item?.title, CONTENT_LIMITS.itemTitle) || defaults.facilities.items[index]?.title || `Facility ${index + 1}`, + description: cleanText(item?.description, CONTENT_LIMITS.itemDescription) || defaults.facilities.items[index]?.description || '', + })), + }, + events: { + navLabel: cleanText(events.navLabel, CONTENT_LIMITS.navLabel) || defaults.events.navLabel, + title: cleanText(events.title, CONTENT_LIMITS.sectionTitle) || defaults.events.title, + description: cleanText(events.description, CONTENT_LIMITS.eventDescription) || defaults.events.description, + enquiryTitle: cleanText(events.enquiryTitle, CONTENT_LIMITS.sectionTitle) || defaults.events.enquiryTitle, + items: limitedList(events.items, defaults.events.items, CONTENT_LIMITS.maxEvents, (item, index) => ({ + title: cleanText(item?.title, CONTENT_LIMITS.eventTitle) || defaults.events.items[index]?.title || `Event ${index + 1}`, + description: cleanText(item?.description, CONTENT_LIMITS.eventDescription) || defaults.events.items[index]?.description || '', + date: cleanText(item?.date, CONTENT_LIMITS.eventDate) || defaults.events.items[index]?.date || '', + price: cleanText(item?.price, CONTENT_LIMITS.eventPrice) || defaults.events.items[index]?.price || '', + image: cleanText(item?.image, CONTENT_LIMITS.eventImage) || defaults.events.items[index]?.image || '/images/bg-1.avif', + })), + }, + }, + } +} + const createId = (prefix) => `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}` const hmacHex = (value) => crypto.createHmac('sha256', accessHmacKey).update(String(value)).digest('hex') const decryptMember = (member) => decryptMembership(member) @@ -197,16 +340,91 @@ const requireAdmin = (req, res) => { json(res, 404, { error: 'Admin API is disabled on this deployment.' }) return false } - const auth = req.headers.authorization || '' - const pubkey = auth.startsWith('Bearer ') ? auth.slice(7).trim().toLowerCase() : '' - if (!adminPubkeys.includes(pubkey)) { + if (!isAdminPubkey(getAuthPubkey(req))) { json(res, 403, { error: 'Admin access required.' }) return false } return true } -const isAdminPubkey = (pubkey) => adminPubkeys.includes(String(pubkey || '').toLowerCase()) +const getAuthPubkey = (req) => { + const auth = req.headers.authorization || '' + return auth.startsWith('Bearer ') ? normalizeAdminPubkey(auth.slice(7)) : '' +} + +const isAdminPubkey = (pubkey) => { + const normalized = normalizeAdminPubkey(pubkey) + return Boolean(normalized) && (isMasterAdminPubkey(normalized) || state.adminRequests.some((request) => + request.pubkey === normalized && request.status === 'approved' + )) +} + +const isMasterAdminPubkey = (pubkey) => { + const normalized = normalizeAdminPubkey(pubkey) + return Boolean(masterAdminPubkey && normalized === masterAdminPubkey) +} + +const requireMasterAdmin = (req, res) => { + if (!requireAdmin(req, res)) return false + if (!isMasterAdminPubkey(getAuthPubkey(req))) { + json(res, 403, { error: 'Master admin access required.' }) + return false + } + return true +} + +const publicAdminRequests = () => state.adminRequests.map((request) => ({ + id: request.id, + displayName: request.displayName, + npub: request.npub, + pubkey: request.pubkey, + status: request.status, + requestedAt: request.requestedAt, + decidedAt: request.decidedAt || '', + decidedBy: request.decidedBy || '', +})) + +const notificationStats = () => ({ + configured: Boolean(vapidPublicKey && vapidPrivateKey), + subscriberCount: state.notificationSubscriptions.length, + publicKeyConfigured: Boolean(vapidPublicKey), + privateKeyConfigured: Boolean(vapidPrivateKey), + subject: vapidSubject, +}) + +const sendPushNotification = async ({ title, message, url = '/edit', icon = '/images/small-logo.svg', tag = 'l484-update' }) => { + if (!vapidPublicKey || !vapidPrivateKey) { + return { success: false, reason: 'vapid_not_configured', sent: 0, failed: 0 } + } + const payload = JSON.stringify({ + title: cleanText(title, 80) || 'L484', + message: cleanText(message, 220) || 'New L484 update.', + icon: cleanText(icon, 240) || '/images/small-logo.svg', + tag: cleanText(tag, 80) || 'l484-update', + data: { url: cleanText(url, 240) || '/edit' }, + }) + const results = await Promise.allSettled( + state.notificationSubscriptions.map((subscription) => webpush.sendNotification(subscription, payload)), + ) + const validSubscriptions = [] + let sent = 0 + let failed = 0 + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + sent += 1 + validSubscriptions.push(state.notificationSubscriptions[index]) + return + } + failed += 1 + const statusCode = result.reason?.statusCode + if (![404, 410].includes(statusCode)) validSubscriptions.push(state.notificationSubscriptions[index]) + }) + if (validSubscriptions.length !== state.notificationSubscriptions.length) { + state.notificationSubscriptions = validSubscriptions + await saveNotificationSubscriptions() + } + return { success: true, sent, failed } +} const broadcastAdminEvent = (event, payload = {}) => { const message = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n` @@ -309,10 +527,13 @@ const updateMemberStatus = async (membershipId, status) => { return member } -const serializeAdmin = () => { +const serializeAdmin = (pubkey = '') => { const allMembers = members() return { success: true, + admin: { + isMaster: isMasterAdminPubkey(pubkey), + }, memberships: allMembers.map((member) => ({ ...member, accessStatus: memberAccessStatus(member) })), payments: state.payments, cards: state.cards.map(({ cardSecretHash, uidHash, ...card }) => card), @@ -330,6 +551,44 @@ const recordAccess = async (entry) => { await saveAccessLogs() } +const triggerDoorUnlock = async ({ doorId, member, card }) => { + if (!homeAssistantUnlockWebhookUrl) { + return { attempted: false, ok: false, reason: 'unlock_webhook_not_configured' } + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), homeAssistantUnlockTimeoutMs) + try { + const response = await fetch(homeAssistantUnlockWebhookUrl, { + method: 'POST', + signal: controller.signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + doorId, + membershipId: member.membershipId, + fullName: member.fullName, + cardId: card.id, + cardPublicId: card.cardPublicId, + requestedAt: new Date().toISOString(), + }), + }) + return { + attempted: true, + ok: response.ok, + status: response.status, + reason: response.ok ? 'unlock_sent' : 'unlock_webhook_failed', + } + } catch (error) { + return { + attempted: true, + ok: false, + reason: error?.name === 'AbortError' ? 'unlock_webhook_timeout' : 'unlock_webhook_error', + } + } finally { + clearTimeout(timeout) + } +} + const createSeedMember = (index, status, daysAgo = index) => { const createdAt = new Date() createdAt.setDate(createdAt.getDate() - daysAgo) @@ -638,6 +897,7 @@ const handleApi = async (req, res) => { adminEnabled: adminApiEnabled(), accessEnabled: adminApiEnabled(), btcpayEnabled: btcpayConfigured(), + masterAdminConfigured: Boolean(masterAdminPubkey), }) } @@ -655,6 +915,67 @@ const handleApi = async (req, res) => { if (req.method === 'GET' && url.pathname === '/api/bitcoin-price') return json(res, 200, await getBitcoinPrice()) + if (req.method === 'GET' && url.pathname === '/api/site-content') { + return json(res, 200, { success: true, content: state.siteContent || defaultSiteContent, limits: CONTENT_LIMITS }) + } + + if (req.method === 'GET' && url.pathname === '/api/notifications/vapid-public-key') { + return json(res, 200, { success: true, publicKey: vapidPublicKey, configured: Boolean(vapidPublicKey && vapidPrivateKey) }) + } + + if (req.method === 'GET' && url.pathname === '/api/notifications/test-vapid') { + return json(res, 200, { success: true, ...notificationStats() }) + } + + if (req.method === 'POST' && url.pathname === '/api/notifications/subscribe') { + const body = await readBody(req) + const subscription = body.subscription || body + const endpoint = cleanText(subscription?.endpoint, 1000) + if (!endpoint || !subscription?.keys?.p256dh || !subscription?.keys?.auth) { + return json(res, 400, { error: 'Invalid push subscription.' }) + } + const normalized = { + endpoint, + expirationTime: subscription.expirationTime || null, + keys: { + p256dh: cleanText(subscription.keys.p256dh, 500), + auth: cleanText(subscription.keys.auth, 200), + }, + userAgent: cleanText(req.headers['user-agent'], 240), + createdAt: new Date().toISOString(), + } + const existingIndex = state.notificationSubscriptions.findIndex((item) => item.endpoint === endpoint) + if (existingIndex >= 0) state.notificationSubscriptions[existingIndex] = normalized + else state.notificationSubscriptions.unshift(normalized) + await saveNotificationSubscriptions() + return json(res, 201, { success: true, subscriberCount: state.notificationSubscriptions.length }) + } + + if (req.method === 'POST' && url.pathname === '/api/admin/request-access') { + if (!adminApiEnabled()) return json(res, 404, { error: 'Admin API is disabled on this deployment.' }) + const body = await readBody(req) + const pubkey = cleanText(body.pubkey, 64).toLowerCase() + const npub = cleanText(body.npub, 90) + const displayName = cleanText(body.displayName, 80) || 'Admin request' + if (!/^[0-9a-f]{64}$/.test(pubkey)) return json(res, 400, { error: 'Invalid admin public key.' }) + if (npub && !/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(npub)) return json(res, 400, { error: 'Invalid admin npub.' }) + const existing = state.adminRequests.find((request) => request.pubkey === pubkey) + if (existing) return json(res, 200, { success: true, request: { ...existing, existing: true } }) + const request = { + id: createId('admin-request'), + displayName, + npub, + pubkey, + status: isMasterAdminPubkey(pubkey) ? 'approved' : 'requested', + requestedAt: new Date().toISOString(), + decidedAt: isMasterAdminPubkey(pubkey) ? new Date().toISOString() : '', + decidedBy: isMasterAdminPubkey(pubkey) ? 'system' : '', + } + state.adminRequests.unshift(request) + await saveAdminRequests() + return json(res, 201, { success: true, request }) + } + if (req.method === 'POST' && url.pathname === '/api/membership/create') { if (!publicApiEnabled()) return json(res, 404, { error: 'Public membership signup is disabled on this deployment.' }) const { member, errors } = validateMember(await readBody(req)) @@ -667,6 +988,12 @@ const handleApi = async (req, res) => { createdAt: member.createdAt, updatedExisting: existingIndex >= 0, }) + sendPushNotification({ + title: 'New member request', + message: `${member.fullName} submitted a membership request.`, + url: '/admin', + tag: 'l484-membership-created', + }).catch((error) => console.warn('Push notification failed:', error.message)) return json(res, existingIndex >= 0 ? 200 : 201, { success: true, membership: member }) } @@ -691,7 +1018,52 @@ const handleApi = async (req, res) => { if (req.method === 'GET' && url.pathname === '/api/admin/state') { if (!requireAdmin(req, res)) return - return json(res, 200, serializeAdmin()) + return json(res, 200, serializeAdmin(getAuthPubkey(req))) + } + + if (req.method === 'PUT' && url.pathname === '/api/admin/site-content') { + if (!requireAdmin(req, res)) return + const body = await readBody(req) + state.siteContent = normalizeSiteContent(body.content || body) + await saveSiteContent() + return json(res, 200, { success: true, content: state.siteContent, limits: CONTENT_LIMITS }) + } + + if (req.method === 'GET' && url.pathname === '/api/admin/access-requests') { + if (!requireAdmin(req, res)) return + return json(res, 200, { success: true, isMasterAdmin: isMasterAdminPubkey(getAuthPubkey(req)), requests: publicAdminRequests() }) + } + + if (req.method === 'GET' && url.pathname === '/api/admin/notifications/stats') { + if (!requireAdmin(req, res)) return + return json(res, 200, { success: true, ...notificationStats() }) + } + + if (req.method === 'POST' && url.pathname === '/api/admin/notifications/send') { + if (!requireAdmin(req, res)) return + const body = await readBody(req) + const result = await sendPushNotification({ + title: body.title || 'L484 update', + message: body.message || 'There is a new L484 update.', + url: body.url || '/edit', + tag: body.tag || 'l484-admin-test', + }) + return json(res, result.success ? 200 : 400, result.success ? result : { error: result.reason, ...result }) + } + + if (req.method === 'POST' && url.pathname === '/api/admin/access-requests/status') { + if (!requireMasterAdmin(req, res)) return + const body = await readBody(req) + const id = cleanText(body.id, 120) + const status = cleanText(body.status, 24) + if (!['approved', 'rejected'].includes(status)) return json(res, 400, { error: 'Invalid request status.' }) + const request = state.adminRequests.find((item) => item.id === id) + if (!request) return json(res, 404, { error: 'Admin request not found.' }) + request.status = status + request.decidedAt = new Date().toISOString() + request.decidedBy = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').slice(0, 64) + await saveAdminRequests() + return json(res, 200, { success: true, request }) } if (req.method === 'GET' && url.pathname === '/api/admin/events') { @@ -714,7 +1086,7 @@ const handleApi = async (req, res) => { if (req.method === 'GET' && url.pathname === '/api/memberships') { if (!requireAdmin(req, res)) return - return json(res, 200, { success: true, memberships: serializeAdmin().memberships }) + return json(res, 200, { success: true, memberships: serializeAdmin(getAuthPubkey(req)).memberships }) } if (req.method === 'PATCH' && url.pathname === '/api/membership/status') { @@ -840,8 +1212,9 @@ const handleApi = async (req, res) => { } if (req.method === 'POST' && url.pathname === '/api/access/check') { + const controllerAuthed = hasControllerAuth(req) const isAdmin = (req.headers.authorization || '').startsWith('Bearer ') - if (!hasControllerAuth(req) && (!isAdmin || !requireAdmin(req, res))) return + if (!controllerAuthed && (!isAdmin || !requireAdmin(req, res))) return const body = await readBody(req) const doorId = cleanText(body.doorId, 80) || 'front-door' const cardCredential = cleanText(body.cardCredential || body.uid, 160) @@ -852,6 +1225,9 @@ const handleApi = async (req, res) => { const status = memberAccessStatus(member) const allow = Boolean(card && member && status === 'active') const reason = allow ? 'active_member_card' : card ? `member_${status}` : 'card_not_found' + const unlock = allow && controllerAuthed + ? await triggerDoorUnlock({ doorId, member, card }) + : { attempted: false, ok: false, reason: controllerAuthed ? 'access_denied' : 'admin_test_only' } if (card) card.lastSeenAt = new Date().toISOString() await recordAccess({ membershipId: member?.membershipId || '', @@ -859,10 +1235,15 @@ const handleApi = async (req, res) => { cardPublicId: card?.cardPublicId || '', doorId, decision: allow ? 'allow' : 'deny', - reason, + reason: unlock.attempted && !unlock.ok ? `${reason}_${unlock.reason}` : reason, }) if (card) await saveCards() - return json(res, 200, { allow, reason, member: member ? { membershipId: member.membershipId, fullName: member.fullName, status } : null }) + return json(res, 200, { + allow, + reason, + unlock, + member: member ? { membershipId: member.membershipId, fullName: member.fullName, status } : null, + }) } json(res, 404, { error: 'Not found.' }) diff --git a/src/App.vue b/src/App.vue index c0fa2b7..f06f38a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,6 @@