feat: access extension — NFC door access via boltcards SUN + Home Assistant

Promoted from the door-portal scratch repo to its own repo for install via the
aiolabs catalog. Authenticates a tapped Bolt Card via boltcards /verify,
authorizes against per-door grants, and fires the door's local Home Assistant
webhook to unlock a Z-Wave lock. Fails closed; logs every attempt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-08-07 22:47:41 +02:00
commit 40564f06ec
22 changed files with 986 additions and 0 deletions

75
models.py Normal file
View file

@ -0,0 +1,75 @@
from datetime import datetime
from pydantic import BaseModel
class Door(BaseModel):
"""A physical door/resource that a tap can open."""
id: str
wallet: str
name: str
# Secret the reader (Pi) must present in X-Controller-Token to hit /check.
controller_token: str
# Local Home Assistant webhook that fires lock.unlock (LAN-only). When empty,
# /check still authenticates + authorizes and logs, but performs no unlock
# (useful for bring-up / bench testing before the lock is wired).
ha_webhook_url: str
# boltcards API base used to SUN-verify the tapped card, e.g.
# http://localhost:5000/boltcards/api/v1 (same on-prem LNbits). Falls back to
# the instance base URL when empty (see services.verify_card).
boltcards_base_url: str
unlock_timeout_ms: int
enabled: bool
time: datetime
class CreateDoor(BaseModel):
name: str
ha_webhook_url: str = ""
boltcards_base_url: str = ""
# Generated server-side when left blank.
controller_token: str = ""
unlock_timeout_ms: int = 2500
enabled: bool = True
class Grant(BaseModel):
"""A card's permission to open a specific door."""
id: str
door_id: str
# The Bolt Card's boltcards `external_id` (the authenticated identity).
external_id: str
label: str
enabled: bool
# Optional hard expiry; null = no expiry.
expires_at: datetime | None
time: datetime
class CreateGrant(BaseModel):
door_id: str
external_id: str
label: str = ""
expires_at: datetime | None = None
enabled: bool = True
class AccessLog(BaseModel):
id: str
door_id: str
external_id: str
decision: str # "allow" | "deny"
reason: str
ip: str
time: datetime
class CheckRequest(BaseModel):
"""What the door reader (Pi) POSTs to /access/api/v1/check."""
doorId: str
external_id: str
p: str
c: str