Panel plane: service-token auth and snapshot route (#152)

This commit was merged in pull request #152.
This commit is contained in:
2026-09-22 22:23:16 -04:00
parent ca477125d6
commit 4da762173c
7 changed files with 880 additions and 25 deletions
+11
View File
@@ -8,6 +8,10 @@ exposes the two endpoints every later task depends on:
app has imported core, opened the database, and applied migrations;
a cheap ``SELECT 1`` confirms the connection is live without leaking
the filesystem path on an unauthenticated route.
- ``GET /api/v1/panel/snapshot`` — the panel plane's today snapshot
(service-token protected; see :mod:`api.routes_panel`, whose router
carries the shared :func:`api.dependencies.require_panel_token`
check).
- ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI);
``/openapi.json`` is the raw schema. No extra wiring is needed;
FastAPI serves both by default.
@@ -35,6 +39,7 @@ from fastapi import FastAPI
from api.config import ApiConfig
from api.database import DatabaseState, make_database
from api.routes_panel import router as panel_router
def _build_lifespan(db_path: str):
@@ -79,6 +84,12 @@ def create_app(config: ApiConfig | None = None) -> FastAPI:
lifespan=_build_lifespan(cfg.db_path),
)
# The resolved config rides on app.state so the reusable route
# dependencies (the panel service-token check) read the configured
# values from the app rather than module state.
app.state.config = cfg
app.include_router(panel_router)
@app.get("/health", summary="Service health")
async def health() -> dict[str, str]:
"""Return service health.
+49 -19
View File
@@ -1,16 +1,22 @@
"""Configuration for the NestQuest API service.
The API reads its single configuration knob — the SQLite database path —
from the ``NESTQUEST_DB_PATH`` environment variable. Unlike a dev
convenience default, a missing or empty value is a configuration ERROR,
not a silent fallback to a predictable world-writable temp path: the API
owns the database (it is the writer of record per Feature 16's
contract), and silently dropping data into ``$TMPDIR/nestquest.db``
would lose it at reboot. ``ApiConfig.from_env`` therefore RAISES when
the variable is unset or empty. This mirrors the integration's
"explicit settings object" discipline (Feature 15's
``core.settings``): the API never reads HA config; callers pass the
resolved path into the database wiring.
The API reads its configuration knobs from environment variables:
- ``NESTQUEST_DB_PATH`` — the SQLite database file. The API opens this
file on startup (creating it if absent) and applies migrations.
- ``NESTQUEST_PANEL_TOKEN`` — the static panel-plane service token
(D-012): the credential ``/api/v1/panel/*`` routes require via
:func:`api.dependencies.require_panel_token`. It is stored outside
the repo and handed to the service through the environment.
Unlike a dev convenience default, a missing or empty value is a
configuration ERROR, not a silent fallback: a default database path
would silently drop data at reboot, and a default (or empty) service
token would either authenticate everyone or lock the panel plane out
for good. ``ApiConfig.from_env`` therefore RAISES when either variable
is unset or empty. This mirrors the integration's "explicit settings
object" discipline (Feature 15's ``core.settings``): the API never
reads HA config; callers pass the resolved values into the wiring.
Serving: the app is built with the factory ``api.app:create_app`` — run
it with ``uvicorn --factory api.app:create_app`` so the factory reads
@@ -27,6 +33,14 @@ from dataclasses import dataclass
#: :meth:`ApiConfig.from_env`.
DB_PATH_ENV = "NESTQUEST_DB_PATH"
#: Environment variable holding the static panel-plane service token
#: (D-012), checked by :func:`api.dependencies.require_panel_token`.
#: REQUIRED — unset or empty raises :class:`ConfigError` from
#: :meth:`ApiConfig.from_env`; there is no silent default, because an
#: empty configured token would either authenticate every caller or
#: lock the panel plane out permanently.
PANEL_TOKEN_ENV = "NESTQUEST_PANEL_TOKEN"
class ConfigError(RuntimeError):
"""Raised when required API configuration is missing or invalid."""
@@ -36,12 +50,18 @@ class ConfigError(RuntimeError):
class ApiConfig:
"""Resolved API configuration.
Currently a single field — the SQLite path — but kept as a dataclass
so later tasks (auth, SSE, settings persistence) can extend it
without touching the call sites that read ``db_path``.
Both fields are required (no defaults): ``from_env`` rejects an
unset or empty value for either, and a caller building the dataclass
directly must pass an explicit token — the token check itself fails
closed on an empty configured token, but expecting a caller to
discover that at request time is a wiring mistake, so the field is
mandatory. Kept as a dataclass so later tasks (admin plane, SSE,
settings persistence) can extend it without touching the call sites
that read ``db_path``.
"""
db_path: str
panel_token: str
@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig":
@@ -52,17 +72,27 @@ class ApiConfig:
Raises :class:`ConfigError` when ``NESTQUEST_DB_PATH`` is unset
or empty/whitespace-only — there is no silent default, because a
default in the temp directory would silently lose data at reboot.
default in the temp directory would silently lose data at reboot
— and likewise when ``NESTQUEST_PANEL_TOKEN`` is unset or empty
(a default token is never invented: the panel plane is closed
until a real one is configured).
"""
source = env if env is not None else os.environ
raw = source.get(DB_PATH_ENV, "").strip()
if not raw:
raw_db_path = source.get(DB_PATH_ENV, "").strip()
if not raw_db_path:
raise ConfigError(
f"{DB_PATH_ENV} must be set to a writable SQLite file path "
f"(it is unset or empty — the API will not fall back to a "
f"temp directory and silently lose data)"
)
return cls(db_path=raw)
raw_panel_token = source.get(PANEL_TOKEN_ENV, "").strip()
if not raw_panel_token:
raise ConfigError(
f"{PANEL_TOKEN_ENV} must be set to the panel service token "
f"(it is unset or empty — the API will not start without "
f"one, and no default token is invented)"
)
return cls(db_path=raw_db_path, panel_token=raw_panel_token)
__all__ = ["ApiConfig", "ConfigError", "DB_PATH_ENV"]
__all__ = ["ApiConfig", "ConfigError", "DB_PATH_ENV", "PANEL_TOKEN_ENV"]
+81
View File
@@ -0,0 +1,81 @@
"""Reusable FastAPI dependencies for the NestQuest API routes.
The panel plane (``/api/v1/panel/*``) is authenticated by the static
service token configured through ``NESTQUEST_PANEL_TOKEN`` (D-012): the
HA integration presents it as a Bearer credential on every panel
request. :func:`require_panel_token` is the ONE implementation of that
check — every panel route (this task's snapshot route and each later
panel route) declares it as a dependency rather than re-implementing
token handling, so the credential, the failure behaviour, and the
401 contract stay in exactly one place.
The dependency FAILS CLOSED: a missing ``Authorization`` header, a
malformed one (wrong scheme or empty credential), a token that does not
match the configured value, and an empty configured token all yield
HTTP 401. The comparison is constant-time (:func:`secrets.compare_digest`)
so the wall-clock difference between "wrong length" and "wrong value"
cannot be used to probe the token byte by byte, and every rejection
carries the same detail — nothing in the response distinguishes which
part of the check failed.
"""
from __future__ import annotations
import secrets
from typing import Annotated
from fastapi import Header, HTTPException, Request
#: The authorization scheme the panel plane expects. Matched
#: case-insensitively, as RFC 9110 requires for scheme names.
_BEARER_SCHEME = "bearer"
#: The 401 detail shared by every rejection — deliberately uniform so
#: the response never reveals which part of the check failed.
_REJECTION_DETAIL = "Missing or invalid panel service token"
#: Challenge header returned with a 401, per the Bearer auth scheme.
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
def _reject() -> HTTPException:
"""Build the single 401 the dependency raises for every failure."""
return HTTPException(
status_code=401,
detail=_REJECTION_DETAIL,
headers=_WWW_AUTHENTICATE,
)
async def require_panel_token(
request: Request,
authorization: Annotated[str | None, Header()] = None,
) -> None:
"""Reject the request with 401 unless it carries the panel token.
Reads the configured token off ``app.state.config`` (set by
:func:`api.app.create_app`) so the dependency stays reusable across
routes and carries no module state of its own. A missing or
malformed ``Authorization`` header, a non-matching credential, and
an EMPTY configured token (a wiring mistake) all raise the same
``HTTPException(401)`` — the check is closed even when the service
is misconfigured.
Returns ``None`` on success; FastAPI discards a ``None`` return,
the route handler simply proceeds.
"""
config = getattr(request.app.state, "config", None)
expected = config.panel_token if config is not None else ""
if not expected or authorization is None:
raise _reject()
scheme, _, credential = authorization.partition(" ")
if scheme.lower() != _BEARER_SCHEME or not credential.strip():
raise _reject()
# compare_digest on bytes so a non-ASCII token raises nothing and a
# timing side channel on the str path is not available.
if not secrets.compare_digest(
credential.strip().encode("utf-8"), expected.encode("utf-8")
):
raise _reject()
__all__ = ["require_panel_token"]
+16 -1
View File
@@ -107,4 +107,19 @@ core: ModuleType = _load_core()
core_db = importlib.import_module(f"{_CORE_MODULE_NAME}.db")
core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations")
__all__ = ["core", "core_db", "core_migrations"]
#: The panel snapshot builder and its payload shaper
#: (``core.snapshot.build_snapshot`` / ``instance_payload``) — the pure
#: assembly the panel routes call.
core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot")
#: The explicit settings object (``core.settings.NestQuestSettings``)
#: the snapshot builder's ``settings`` parameter takes.
core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
__all__ = [
"core",
"core_db",
"core_migrations",
"core_snapshot",
"core_settings",
]
+183
View File
@@ -0,0 +1,183 @@
"""Panel-plane routes for the NestQuest API service.
The panel plane (``/api/v1/panel/*``, D-012) serves the wall panel's
reads. Every route on :data:`router` requires the panel service token
through the ONE reusable dependency :func:`api.dependencies.require_panel_token`
(declared once at the router level, so each later panel route added to
this router inherits the check).
The snapshot route is a thin adapter over the bundled core: it performs
NO business logic. It reads the live database off ``app.state.db``,
resolves ONE timezone-aware ``now`` (the API host's local time — the
API is the household's "local" host now that the domain no longer lives
inside Home Assistant), calls the pure
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
instances through :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=False`` (D-009: missed instances are OMITTED from the
panel payload), and returns the documented JSON shape:
.. code-block:: json
{
"today_iso": "2026-09-22",
"cycle_day": 2,
"children": [
{
"child_id": 1,
"child_name": "Ada",
"present": false,
"next_present": "2026-09-24",
"due_today": 1,
"completed_today": 0,
"remaining_today": 1,
"completion_pct": 0,
"instances": [
{"id": 7, "definition_id": 3, "child_id": 1,
"title": "Pack bag", "icon": null, "window": "morning",
"due_time": "10:00", "state": "open", "overdue": true,
"completed_at": null, "on_time": null}
]
}
]
}
``children`` follows the household's sort order (the core builder lists
active children ``ORDER BY sort_order, id``); ``state`` is the
documented panel spelling ``open`` | ``completed`` (§1 of
design/ENTITIES-AND-SERVICES.md); ``cycle_day`` is today's 1-based day
in the first scheduled child's custody cycle (0 when no child has one).
The same :class:`PanelSnapshotResponse` Pydantic model is the route's
``response_model``, so the OpenAPI document carries the shape too.
"""
from __future__ import annotations
import datetime
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from api.database import DatabaseState
from api.dependencies import require_panel_token
from api.nestquest_core import core_snapshot, core_settings
#: All panel-plane routes share this router; the service-token check is
#: attached HERE so every panel route (current and later) requires it.
router = APIRouter(
prefix="/api/v1/panel",
dependencies=[Depends(require_panel_token)],
)
class PanelInstancePayload(BaseModel):
"""One instance in the documented panel payload shape (§1).
Mirrors :func:`nestquest_core.snapshot.instance_payload`:
``state`` is ``open`` | ``completed`` (missed instances never reach
the panel payload), ``on_time`` passes the completion event's flag
through verbatim (``None`` for an open instance), and
``completed_at`` is the event's UTC timestamp when completed.
"""
id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_time: str | None
state: str
overdue: bool
completed_at: str | None
on_time: bool | None
class PanelChildPayload(BaseModel):
"""One child's day: presence, rollup counts, and today's instances."""
child_id: int
child_name: str
present: bool
#: ISO date of the child's next present day while they are away
#: (the away plate renders ``Returns <weekday>, <Mon D>`` from it);
#: ``None`` when the child is present today.
next_present: str | None
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
instances: list[PanelInstancePayload]
class PanelSnapshotResponse(BaseModel):
"""The whole household snapshot the panel renders from.
``today_iso`` anchors the payload's day; ``cycle_day`` is today's
1-based day in the first scheduled child's custody cycle (0 when no
child has a schedule); ``children`` is in the household's sort
order.
"""
today_iso: str
cycle_day: int
children: list[PanelChildPayload]
def _local_now() -> datetime.datetime:
"""Return the API host's local time as ONE timezone-aware clock read.
``now().astimezone()`` attaches the host's local timezone to a
single clock read, so the calendar date and wall-clock time the
builder derives can never disagree. Kept as a module function (not
inlined) so tests pin the instant the same way the coordinator's
``_local_now`` is pinned.
"""
return datetime.datetime.now().astimezone()
@router.get(
"/snapshot",
summary="Today's household snapshot",
response_model=PanelSnapshotResponse,
)
async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
"""Return today's full household snapshot for the panel.
Requires the panel service token (checked by the router's shared
:func:`~api.dependencies.require_panel_token` dependency). Builds
the snapshot through the pure core builder against the live
database and shapes each child's instances with
``instance_payload(include_missed=False)`` — missed instances are
OMITTED from the panel payload (D-009) — then returns the shape
documented in this module's docstring and
:class:`PanelSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
)
return PanelSnapshotResponse(
today_iso=snapshot.today_iso,
cycle_day=snapshot.cycle_day,
children=[
PanelChildPayload(
child_id=child.child_id,
child_name=child.child_name,
present=child.present,
next_present=child.next_present,
due_today=child.due_today,
completed_today=child.completed_today,
remaining_today=child.remaining_today,
completion_pct=child.completion_pct,
instances=[
PanelInstancePayload.model_validate(instance)
for instance in core_snapshot.instance_payload(
child.instances, include_missed=False
)
],
)
for child in snapshot.children
],
)
__all__ = ["router"]