Feature 16: NestQuest API Service (#165)

Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
This commit was merged in pull request #165.
This commit is contained in:
2026-09-23 04:37:26 -04:00
committed by approver_agent
parent b5c2b55d9b
commit d78ef196d0
43 changed files with 12248 additions and 369 deletions
+19
View File
@@ -0,0 +1,19 @@
"""NestQuest API service.
A standalone FastAPI deployable that owns the NestQuest database and
exposes it over HTTP to the Home Assistant integration (panel plane) and
the admin PWA (admin plane). See Feature 16 (task cd03735b) in the
project tracker for the full contract.
This package imports the NestQuest domain ``core`` WITHOUT executing
``custom_components/nestquest/__init__.py`` — that parent package's
``__init__.py`` imports ``homeassistant``, which is absent from the
API's runtime. The coupling is encapsulated in
:mod:`api.nestquest_core`, which loads ``core`` by file location with
:mod:`importlib.util` and registers it under the distinct top-level name
``nestquest_core`` (no ``sys.path`` mutation, no generic top-level
names published). It is a TRANSITIONAL coupling that Feature 18 will
revisit: once the integration becomes a thin API client and ``core``
moves out of the integration bundle, this helper goes away.
"""
from __future__ import annotations
+139
View File
@@ -0,0 +1,139 @@
"""The NestQuest FastAPI application.
This module builds the ASGI app for the NestQuest API service (Feature
16). The skeleton here wires the bundled ``core`` (Feature 15) and
exposes the two endpoints every later task depends on:
- ``GET /health`` — returns ``200`` with a JSON status body once the
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 /api/v1/admin/ping`` — the admin plane's auth probe, protected
by the Authentik OIDC JWT check (see :mod:`api.routes_admin`, whose
router carries the shared :func:`api.auth.require_admin_jwt`
dependency; the JWKS document it verifies against is cached on
``app.state.jwks_cache``).
- ``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.
The database is opened and migrated in a FastAPI lifespan context so a
startup failure (e.g. an unwritable path) surfaces as a startup error
rather than the first request. The connection is closed on shutdown.
This module exports ONLY the :func:`create_app` factory — there is no
module-level app instance, because building one at import time would
read ``NESTQUEST_DB_PATH`` at import (and fail-fast on a missing value)
before any worker is ready to report it. Serve with::
uvicorn --factory api.app:create_app
so the factory runs on each worker startup and reads the environment
then.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import FastAPI
from api.auth import JwksCache
from api.config import ApiConfig
from api.database import DatabaseState, make_database
from api.events import TransitionPublisher
from api.routes_admin import router as admin_router
from api.routes_panel import router as panel_router
from api.scheduler import MissedSweepScheduler
def _build_lifespan(db_path: str):
"""Return a lifespan that opens+migrates then closes one database.
Captured as a closure so the app factory can hand the configured
path in without threading it through FastAPI's lifespan signature.
The lifespan also installs the app's ONE in-process transition
publisher on ``app.state.publisher`` (see :mod:`api.events`) so
every route and SSE subscriber shares that single instance, and
starts the daily missed-sweep scheduler
(:class:`api.scheduler.MissedSweepScheduler`) against the same
database and publisher — cancelled before the connection closes on
shutdown.
"""
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
state = make_database(db_path)
app.state.db = state
app.state.publisher = TransitionPublisher()
scheduler = MissedSweepScheduler(
state.database, publisher=app.state.publisher
)
app.state.sweep_scheduler = scheduler
try:
await state.open_and_migrate()
scheduler.start()
yield
finally:
await scheduler.stop()
await state.close()
return lifespan
def create_app(config: ApiConfig | None = None) -> FastAPI:
"""Build the NestQuest API FastAPI app.
``config`` defaults to one built from the process environment, so
``uvicorn --factory api.app:create_app`` picks up
``NESTQUEST_DB_PATH`` (and fails fast on startup if it is missing).
Tests pass an explicit config so each test gets a hermetic temp
SQLite file.
"""
cfg = config if config is not None else ApiConfig.from_env()
app = FastAPI(
title="NestQuest API",
description=(
"Standalone HTTP service that owns the NestQuest database and "
"exposes it to the Home Assistant integration (panel plane) and "
"the admin PWA (admin plane)."
),
version="0.1.0",
lifespan=_build_lifespan(cfg.db_path),
)
# The resolved config rides on app.state so the reusable route
# dependencies (the panel service-token check, the admin JWT check)
# read the configured values from the app rather than module state.
app.state.config = cfg
# The admin plane's ONE per-app JWKS cache: fetched from the
# configured provider on first use, then reused until its TTL
# lapses (see api.auth.JwksCache). Tests replace it with a
# stub-backed cache so no request ever leaves the process.
app.state.jwks_cache = JwksCache(cfg.oidc_jwks_url)
app.include_router(panel_router)
app.include_router(admin_router)
@app.get("/health", summary="Service health")
async def health() -> dict[str, str]:
"""Return service health.
Returns ``200`` with ``{"status": "ok"}`` once the lifespan has
opened and migrated the database. A cheap ``SELECT 1`` against
the live connection confirms the database is reachable WITHOUT
leaking the filesystem path on an unauthenticated route; if the
connection is dead the query raises and the probe fails.
"""
state: DatabaseState = app.state.db
await state.database.fetch_one("SELECT 1")
return {"status": "ok"}
return app
__all__ = ["create_app"]
+490
View File
@@ -0,0 +1,490 @@
"""Admin-plane authentication for the NestQuest API service.
The admin plane (``/api/v1/admin/*``, D-012) is authenticated by
Authentik-issued OIDC access tokens: the admin PWA presents its JWT as
a Bearer credential and :func:`require_admin_jwt` — the ONE
implementation of that check — verifies it before any admin route runs.
Verification enforces, without exception:
- the signature, against the RSA signing key published in the
provider's JWKS document (fetched from the configured
``NESTQUEST_OIDC_JWKS_URL`` and cached per app — never per request);
- the issuer (``iss`` must equal the configured
``NESTQUEST_OIDC_ISSUER``);
- the audience (``aud`` must carry the configured
``NESTQUEST_OIDC_AUDIENCE``);
- expiry (``exp`` is required and enforced, and ``nbf`` is enforced
when the token carries it).
Every rejection raises the SAME :class:`~fastapi.HTTPException` with
the same detail — nothing in the response distinguishes which check
failed. The check FAILS CLOSED: a missing app config, a malformed or
missing ``Authorization`` header, a non-RS256 algorithm, an unknown
``kid``, and any PyJWT verification error all yield the one 401.
The token's header (``alg``, ``kid``) is parsed and checked BEFORE the
JWKS cache is consulted, so structurally invalid tokens can never
amplify load on the provider. An unknown ``kid`` forces ONE
TTL-bypassing refetch (throttled, see :class:`JwksCache`) so a rotated
key is picked up without waiting out the TTL, and a JWKS entry that
matches the ``kid`` but carries corrupt key material maps to the same
401. A JWKS FETCH failure is deliberately NOT mapped to 401: the
provider being unreachable is a server-side fault (an HTTP 500
surfaces) and never poisons the cache, so the plane stays closed while
the real cause is visible in the server log instead of disguised as a
bad credential; a short backoff window after a failed fetch fails fast
(re-raising the recorded fault) rather than fetching once per request.
Beyond authentication, :func:`require_admin` is the ONE dependency
every admin route uses: it REJECTS the panel service token outright —
that credential authenticates the panel plane only, so treating it as
a JWT candidate would blur two credential types into one check — and
then requires the verified JWT's ``groups`` claim to name the
``nestquest-admins`` group (authorization, 403, distinct from the
uniform authentication 401 so the two failure classes stay apart).
"""
from __future__ import annotations
import asyncio
import binascii
import json
import secrets
import time
from collections.abc import Awaitable, Callable
from typing import Annotated
import httpx
import jwt
from fastapi import Header, HTTPException, Request
#: The authorization scheme the admin plane expects. Matched
#: case-insensitively, as RFC 9110 requires for scheme names.
_BEARER_SCHEME = "bearer"
#: The ONLY JWT signing algorithm accepted. Pinned to one asymmetric
#: algorithm (Authentik's RS256 default) so neither the header's ``alg``
#: nor any JWKS key can talk the verifier into a weaker or symmetric
#: scheme; PyJWT re-enforces it at decode time.
_ALLOWED_ALGORITHM = "RS256"
#: 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 admin credentials"
#: Challenge header returned with a 401, per the Bearer auth scheme.
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
#: The authorization group a verified admin JWT must name in its
#: ``groups`` claim. A module constant (the value Authentik's
#: nestquest-admins group maps to) so it is clearly named in one place.
ADMIN_GROUP = "nestquest-admins"
#: Seconds a fetched JWKS document stays trusted before the next
#: fetch. Five minutes bounds how long a rotated-away key keeps
#: verifying while staying far above a per-request fetch.
_JWKS_TTL_SECONDS = 300.0
#: Seconds a FAILED JWKS fetch blocks further fetch attempts. Within
#: this window after a failure the cache fails fast — it re-raises the
#: recorded fault instead of touching the provider — so an outage is
#: never amplified into one fetch per request.
_JWKS_BACKOFF_SECONDS = 5.0
#: Seconds that must pass before ANOTHER forced (TTL-bypassing) refetch
#: may run. An unmatched ``kid`` forces one refetch to pick up rotated
#: keys; this interval bounds it, so a flood of unknown-``kid`` tokens
#: cannot turn each request into a provider fetch either.
_JWKS_KID_REFETCH_SECONDS = 5.0
#: Seconds before the real JWKS fetch gives up on the provider.
_JWKS_TIMEOUT_SECONDS = 5.0
#: An async callable that fetches the JWKS document from a URL. The
#: real one (:func:`_fetch_jwks`) uses httpx; tests inject a stub so
#: the suite stays hermetic (no network access).
JwksFetcher = Callable[[str], Awaitable[dict[str, object]]]
async def _fetch_jwks(url: str) -> dict[str, object]:
"""Fetch the provider's JWKS document over HTTP (the real fetch).
Uses httpx with a short timeout; a non-2xx response or a body that
is not a ``{"keys": [...]}`` object raises — the cache never stores
a failed or malformed fetch.
"""
async with httpx.AsyncClient(timeout=_JWKS_TIMEOUT_SECONDS) as client:
response = await client.get(url)
response.raise_for_status()
document = response.json()
if not isinstance(document, dict) or not isinstance(
document.get("keys"), list
):
raise ValueError("JWKS document must be an object with a keys list")
return document
class JwksCache:
"""One app's cache of the OIDC provider's JWKS document.
The document is fetched through ``fetcher`` (the real one uses
httpx against the configured JWKS URL) and held for ``ttl_seconds``
— the dependency NEVER fetches per request. A single fetch is
shared between concurrent requests via an :class:`asyncio.Lock`
(double-checked around it), and a failed fetch is never cached, so
the next request retries once the backoff window lapses rather than
being locked out until the TTL does.
Two bounds keep the provider out of an amplification loop:
- ``backoff_seconds``: after a FAILED fetch, :meth:`get` fails fast
for this long — re-raising the recorded fault WITHOUT touching
the fetcher or even the lock — instead of retrying per request.
- ``kid_refetch_seconds``: forced (TTL-bypassing) refetches — the
unmatched-``kid`` path — may run at most this often; a forced
refetch that is bounded out (or one inside the backoff window)
is served from the cached document as-is.
A successful fetch always clears the backoff; the document itself
is still trusted only for ``ttl_seconds`` — nothing is cached
indefinitely.
One instance lives on ``app.state.jwks_cache`` (installed by
:func:`api.app.create_app`); tests replace it with one built around
a stub fetcher, keeping the suite hermetic.
"""
def __init__(
self,
url: str,
*,
ttl_seconds: float = _JWKS_TTL_SECONDS,
backoff_seconds: float = _JWKS_BACKOFF_SECONDS,
kid_refetch_seconds: float = _JWKS_KID_REFETCH_SECONDS,
fetcher: JwksFetcher | None = None,
) -> None:
self._url = url
self._ttl_seconds = ttl_seconds
self._backoff_seconds = backoff_seconds
self._kid_refetch_seconds = kid_refetch_seconds
self._fetcher: JwksFetcher = (
fetcher if fetcher is not None else _fetch_jwks
)
self._document: dict[str, object] | None = None
self._fetched_at: float | None = None
self._failed_until: float | None = None
self._last_error: BaseException | None = None
self._forced_at: float | None = None
self._lock = asyncio.Lock()
async def get(self, *, force: bool = False) -> dict[str, object]:
"""Return the JWKS document, refetching it if the TTL lapsed.
With ``force=True`` the TTL is bypassed so an unmatched ``kid``
can pick up a rotated key without waiting it out; the forced
refetch is itself bounded (see the class docstring): inside the
backoff window or the ``kid_refetch_seconds`` interval the
cached document is served as-is instead. A failed fetch is
re-raised to the caller (a provider outage is a server fault,
never a 401) and starts the backoff window, during which every
caller — including forced ones with nothing cached — fails fast
on the recorded fault.
"""
if not force:
fresh = self._fresh_document()
if fresh is not None:
return fresh
error = self._backoff_error()
if error is not None:
# Fail fast OUTSIDE the lock: a request inside the backoff
# window must not queue behind an in-flight fetch.
if force:
document = self._document
if document is not None:
return document
raise error
async with self._lock:
if not force:
fresh = self._fresh_document()
if fresh is not None:
return fresh
error = self._backoff_error()
if error is not None:
if force:
document = self._document
if document is not None:
return document
raise error
if force and self._refetch_throttled():
document = self._document
if document is not None:
return document
return await self._fetch_locked(force=force)
def _fresh_document(self) -> dict[str, object] | None:
"""The cached document if one exists and is inside its TTL."""
document = self._document
fetched_at = self._fetched_at
if (
document is not None
and fetched_at is not None
and (time.monotonic() - fetched_at) < self._ttl_seconds
):
return document
return None
def _backoff_error(self) -> BaseException | None:
"""The recorded fetch fault while the backoff window is open."""
failed_until = self._failed_until
last_error = self._last_error
if (
failed_until is not None
and last_error is not None
and time.monotonic() < failed_until
):
return last_error
return None
def _refetch_throttled(self) -> bool:
"""Whether a forced refetch ran too recently to run another."""
forced_at = self._forced_at
return forced_at is not None and (
time.monotonic() - forced_at
) < self._kid_refetch_seconds
async def _fetch_locked(self, *, force: bool) -> dict[str, object]:
"""Run ONE fetch attempt (caller holds the lock) and record it.
A failed fetch starts the ``backoff_seconds`` window and is
re-raised; a successful one stores the document, restarts the
TTL and clears the backoff. A forced attempt stamps
``_forced_at`` so the ``kid_refetch_seconds`` interval bounds
how often forced refetches may run back-to-back.
"""
if force:
self._forced_at = time.monotonic()
try:
document = await self._fetcher(self._url)
except Exception as error:
self._failed_until = time.monotonic() + self._backoff_seconds
self._last_error = error
raise
self._document = document
self._fetched_at = time.monotonic()
self._failed_until = None
self._last_error = None
return document
#: 403 detail for the panel service token on an admin route. Deliberately
#: non-leaking: it names the credential TYPE, not its value or where it came
#: from. 403 (not 401) because this is a REFUSED credential type, not an
#: absent/invalid one — challenging re-authentication would suggest the same
#: credential could work with a retry, which it never can here.
_PANEL_TOKEN_REJECTION_DETAIL = "This credential type is not accepted for admin access"
#: 403 detail for a valid JWT that is not a member of the admin group.
#: Distinct from the uniform 401 on purpose: the token AUTHENTICATED, but
#: the caller is not AUTHORIZED, so the response may say so.
_FORBIDDEN_DETAIL = "Admin access requires membership in the nestquest-admins group"
def _reject() -> HTTPException:
"""Build the single 401 the dependency raises for every failure."""
return HTTPException(
status_code=401,
detail=_REJECTION_DETAIL,
headers=_WWW_AUTHENTICATE,
)
def _matching_key(jwks: dict[str, object], kid: str) -> object | None:
"""Return the RSA signing key named ``kid`` from the JWKS.
Only ``kty: RSA`` entries are considered (the pinned RS256 scheme
is asymmetric RSA); anything else — a missing ``keys`` list, no
entry with that ``kid`` — returns ``None`` rather than falling back
to "some" key. The key is built through PyJWT's JWKS decoder, and
an entry that MATCHES the ``kid`` but carries corrupt key material
(a malformed ``n``/``e`` raises ``ValueError`` — ``binascii.Error``
is its subclass — or ``TypeError`` instead of a ``PyJWTError``) is
mapped to the one 401 like every other rejection: the caller must
never see a half-built key.
"""
keys = jwks.get("keys")
if not isinstance(keys, list):
raise _reject()
for entry in keys:
if not isinstance(entry, dict):
continue
if entry.get("kid") != kid or entry.get("kty") != "RSA":
continue
try:
return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(entry))
except (
jwt.PyJWTError,
binascii.Error,
ValueError,
TypeError,
KeyError,
):
raise _reject() from None
return None
async def verify_admin_token(
request: Request, token: str
) -> dict[str, object]:
"""Verify ONE admin bearer token and return its verified claims.
Reads the OIDC settings off ``app.state.config`` and the JWKS cache
off ``app.state.jwks_cache`` (both installed by
:func:`api.app.create_app`) so the check carries no module state.
A missing config or cache, an EMPTY configured issuer/audience/JWKS
URL, a non-RS256 ``alg`` header, an absent ``kid``, an unknown
``kid``, and any PyJWT verification failure (bad signature,
expired, wrong issuer, wrong audience, not-yet-valid, missing
``exp``) raise the SAME 401 — the failure mode is never leaked.
``exp`` is REQUIRED (a token that never expires is never accepted)
and PyJWT enforces ``nbf`` when the token carries it.
The token's HEADER (``alg``, ``kid``) is parsed and enforced BEFORE
the JWKS cache is consulted, so a structurally invalid token costs
no fetch, no lock, no provider load. When the cached document has
no entry for the ``kid``, ONE forced (TTL-bypassing, throttled)
refetch is made and the match retried once — a rotated key is
picked up without waiting out the TTL — before the 401.
A JWKS fetch failure propagates (see the module docstring): the
provider being down is a server fault, not a bad credential.
"""
config = getattr(request.app.state, "config", None)
if (
config is None
or not config.oidc_issuer
or not config.oidc_audience
or not config.oidc_jwks_url
):
raise _reject()
cache = getattr(request.app.state, "jwks_cache", None)
if cache is None:
raise _reject()
try:
header = jwt.get_unverified_header(token)
except jwt.PyJWTError:
raise _reject() from None
if header.get("alg") != _ALLOWED_ALGORITHM:
raise _reject()
kid = header.get("kid")
if not isinstance(kid, str) or not kid:
raise _reject()
jwks = await cache.get()
try:
key = _matching_key(jwks, kid)
if key is None:
# The kid is not in the cached document: force ONE
# TTL-bypassing refetch (bounded by the cache) and retry
# the match once before rejecting.
jwks = await cache.get(force=True)
key = _matching_key(jwks, kid)
if key is None:
raise _reject()
claims = jwt.decode(
token,
key=key,
algorithms=[_ALLOWED_ALGORITHM],
issuer=config.oidc_issuer,
audience=config.oidc_audience,
options={"require": ["exp"]},
)
except jwt.PyJWTError:
raise _reject() from None
return claims
async def require_admin_jwt(
request: Request,
authorization: Annotated[str | None, Header()] = None,
) -> dict[str, object]:
"""Reject the request with 401 unless it carries a valid admin JWT.
Extracts the Bearer credential — a missing or malformed
``Authorization`` header (wrong scheme, empty credential) raises
the one 401 — and delegates the verification to
:func:`verify_admin_token`, returning the verified claims for the
route to use (the probe route surfaces the token's ``sub``).
The nestquest-admins GROUP check lives in :func:`require_admin`,
which wraps this dependency; ``require_admin_jwt`` remains the plain
JWT-validation contract — the 401 path stays exactly this uniform.
"""
if authorization is None:
raise _reject()
scheme, _, credential = authorization.partition(" ")
if scheme.lower() != _BEARER_SCHEME or not credential.strip():
raise _reject()
return await verify_admin_token(request, credential.strip())
async def require_admin(
request: Request,
authorization: Annotated[str | None, Header()] = None,
) -> dict[str, object]:
"""The ONE admin-plane dependency: service-token refusal + group gate.
Order matters. FIRST the Bearer credential is compared against the
configured panel service token (``app.state.config.panel_token``):
a match is refused OUTRIGHT with 403 — BEFORE any JWT parsing — so
the panel credential is never even treated as a JWT candidate, let
alone a non-admin JWT. (403 rather than 401 because this is a
refused credential TYPE, not an invalid one: challenging
re-authentication would imply retrying could ever succeed; the
detail names the type only, never the value.) A config with an
EMPTY panel token never matches anything, so it cannot lock every
Bearer request out.
Otherwise the credential goes through the existing JWT path
(:func:`require_admin_jwt` → :func:`verify_admin_token`): a missing
or malformed header and any invalid JWT still yield the SAME
uniform 401 as before. A VALID JWT is then checked for
authorization: its ``groups`` claim must be a list containing
:data:`ADMIN_GROUP` — a missing, empty, or non-list ``groups`` or
one without the group raises 403 (distinct from the 401 on
purpose: the token authenticated, the caller did not authorize).
Returns the verified claims for the route.
"""
if authorization is not None:
scheme, _, credential = authorization.partition(" ")
if scheme.lower() == _BEARER_SCHEME and credential.strip():
config = getattr(request.app.state, "config", None)
if (
config is not None
and config.panel_token
# compare_digest on bytes (as require_panel_token does)
# so the refusal does not short-circuit on the first
# mismatching byte — a constant-time comparison keeps a
# timing side channel on the credential off the table.
and secrets.compare_digest(
credential.strip().encode("utf-8"),
config.panel_token.encode("utf-8"),
)
):
raise HTTPException(
status_code=403,
detail=_PANEL_TOKEN_REJECTION_DETAIL,
)
claims = await require_admin_jwt(request, authorization)
groups = claims.get("groups")
if not isinstance(groups, list) or ADMIN_GROUP not in groups:
raise HTTPException(status_code=403, detail=_FORBIDDEN_DETAIL)
return claims
__all__ = [
"ADMIN_GROUP",
"JwksCache",
"JwksFetcher",
"require_admin",
"require_admin_jwt",
"verify_admin_token",
]
+174
View File
@@ -0,0 +1,174 @@
"""Configuration for the NestQuest API service.
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.
- ``NESTQUEST_OIDC_ISSUER`` — the Authentik OIDC issuer URL whose
``iss`` claim admin tokens must carry (D-012 admin plane).
- ``NESTQUEST_OIDC_AUDIENCE`` — the OIDC client id the admin tokens
must carry as their ``aud`` claim (the API service's client id).
- ``NESTQUEST_OIDC_JWKS_URL`` — the Authentik endpoint serving the
signing keys (the JWKS document) the admin tokens' signatures are
verified against. Cached per app (see :class:`api.auth.JwksCache`).
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, a default (or empty) service token
would either authenticate everyone or lock the panel plane out for
good, and a default (or empty) OIDC setting would silently leave the
admin plane trusting an issuer that was never configured. All five
variables are therefore REQUIRED — ``ApiConfig.from_env`` RAISES when
any of them 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
the environment on each worker startup rather than at import time.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
#: Environment variable holding the SQLite file path. The API opens
#: this file on startup (creating it if absent) and applies migrations.
#: REQUIRED — unset or empty raises :class:`ConfigError` from
#: :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"
#: Environment variable holding the Authentik OIDC issuer URL (D-012
#: admin plane): every admin JWT's ``iss`` claim must equal it. Checked
#: by :func:`api.auth.require_admin_jwt`. REQUIRED — unset or empty
#: raises :class:`ConfigError` from :meth:`ApiConfig.from_env`; a default
#: issuer would silently make the admin plane trust tokens from an
#: issuer nobody configured.
OIDC_ISSUER_ENV = "NESTQUEST_OIDC_ISSUER"
#: Environment variable holding the OIDC audience (the API service's
#: client id at Authentik): every admin JWT's ``aud`` claim must carry
#: it. Checked by :func:`api.auth.require_admin_jwt`. REQUIRED — unset
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`.
OIDC_AUDIENCE_ENV = "NESTQUEST_OIDC_AUDIENCE"
#: Environment variable holding the Authentik JWKS URL: the endpoint
#: serving the signing keys admin JWT signatures are verified against.
#: Fetched and cached by :class:`api.auth.JwksCache`. REQUIRED — unset
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`;
#: without the provider's keys no token can be verified, so a silent
#: default would only move the failure to request time.
OIDC_JWKS_URL_ENV = "NESTQUEST_OIDC_JWKS_URL"
class ConfigError(RuntimeError):
"""Raised when required API configuration is missing or invalid."""
@dataclass(frozen=True)
class ApiConfig:
"""Resolved API configuration.
Every field is required (no defaults): ``from_env`` rejects an
unset or empty value for each, and a caller building the dataclass
directly must pass explicit values — the token check and the JWT
checks themselves fail closed on empty configured values, but
expecting a caller to discover that at request time is a wiring
mistake, so every field is mandatory. Kept as a dataclass so later
tasks (SSE, settings persistence) can extend it without touching
the call sites that read ``db_path``.
"""
db_path: str
panel_token: str
#: The Authentik OIDC issuer URL admin tokens' ``iss`` must equal.
oidc_issuer: str
#: The OIDC audience (the API service's client id) admin tokens'
#: ``aud`` must carry.
oidc_audience: str
#: The Authentik JWKS URL the signing keys are fetched from.
oidc_jwks_url: str
@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig":
"""Build configuration from the process environment.
``env`` defaults to ``os.environ``; tests pass an explicit dict
so they are hermetic (no monkeypatch of the global environ).
Raises :class:`ConfigError` when ANY of the five variables is
unset or empty/whitespace-only — there is no silent default:
- ``NESTQUEST_DB_PATH``: a default in the temp directory would
silently lose data at reboot;
- ``NESTQUEST_PANEL_TOKEN``: a default token is never invented —
the panel plane is closed until a real one is configured;
- the three OIDC variables: an unconfigured issuer/audience/JWKS
URL must not silently leave the admin plane open (a default
JWKS URL or issuer would point at a provider nobody set up).
"""
source = env if env is not None else os.environ
def require(env_var: str, reason: str) -> str:
"""Return the stripped env value or raise ConfigError."""
value = source.get(env_var, "").strip()
if not value:
raise ConfigError(f"{env_var} {reason}")
return value
return cls(
db_path=require(
DB_PATH_ENV,
"must be set to a writable SQLite file path (it is unset "
"or empty — the API will not fall back to a temp directory "
"and silently lose data)",
),
panel_token=require(
PANEL_TOKEN_ENV,
"must be set to the panel service token (it is unset or "
"empty — the API will not start without one, and no "
"default token is invented)",
),
oidc_issuer=require(
OIDC_ISSUER_ENV,
"must be set to the Authentik OIDC issuer URL (it is "
"unset or empty — the admin plane will not trust tokens "
"from an issuer nobody configured)",
),
oidc_audience=require(
OIDC_AUDIENCE_ENV,
"must be set to the OIDC audience the admin tokens carry "
"(it is unset or empty — the admin plane will not accept "
"tokens without a configured audience)",
),
oidc_jwks_url=require(
OIDC_JWKS_URL_ENV,
"must be set to the Authentik JWKS URL (it is unset or "
"empty — admin token signatures cannot be verified "
"without the provider's signing keys)",
),
)
__all__ = [
"ApiConfig",
"ConfigError",
"DB_PATH_ENV",
"OIDC_AUDIENCE_ENV",
"OIDC_ISSUER_ENV",
"OIDC_JWKS_URL_ENV",
"PANEL_TOKEN_ENV",
]
+82
View File
@@ -0,0 +1,82 @@
"""Database wiring for the NestQuest API service.
The API owns the database (it is the writer of record per Feature 16's
contract). This module owns one application-lifetime
:class:`core.db.NestQuestDatabase`: it opens the SQLite file at the
configured path on startup, applies migrations (creating the schema on
a fresh file), and closes the connection on shutdown.
The executor callable passed into ``NestQuestDatabase`` mirrors the
contract of ``hass.async_add_executor_job``: schedule a synchronous
callable on a worker and await its result. HA's own version lives on
the event loop; the API is not HA, so it uses
:func:`asyncio.to_thread` for the same effect — sqlite3 calls run on a
worker thread and only plain data returns to the event loop.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from api.nestquest_core import core_db, core_migrations
LOGGER = logging.getLogger(__name__)
#: The async SQLite wrapper. Re-exported so route handlers and later
#: tasks reach it through the API package, not the bundled core.
NestQuestDatabase = core_db.NestQuestDatabase
#: The migration runner. Re-exported for the same reason.
apply_migrations = core_migrations.apply_migrations
async def _executor(fn: Any, *args: Any, **kwargs: Any) -> Any:
"""Run a synchronous callable on a worker thread and await its result.
Mirrors the contract of ``hass.async_add_executor_job`` so the
bundled ``NestQuestDatabase`` (written against that contract) works
unchanged in the API process: sqlite3 calls never block the event
loop, and only plain Python data (row tuples,
:class:`core.db.ExecutionResult`) crosses back.
"""
return await asyncio.to_thread(fn, *args, **kwargs)
class DatabaseState:
"""Holds the live database wrapper and its resolved path.
A small container so the lifespan and the route handlers share one
authoritative reference; FastAPI's lifespan stores one of these on
``app.state.db`` and the health route reads it back.
"""
__slots__ = ("database", "db_path")
def __init__(self, database: NestQuestDatabase, db_path: str) -> None:
self.database = database
self.db_path = db_path
async def open_and_migrate(self) -> None:
"""Open the connection and apply migrations to the latest version."""
LOGGER.info("Opening NestQuest database at %s", self.db_path)
await self.database.open(self.db_path)
version = await apply_migrations(self.database)
LOGGER.info("NestQuest schema at version %d", version)
async def close(self) -> None:
"""Close the connection; safe to call even if open failed."""
await self.database.close()
def make_database(db_path: str) -> DatabaseState:
"""Construct a :class:`DatabaseState` for the given path (not yet open)."""
return DatabaseState(NestQuestDatabase(_executor), db_path)
__all__ = [
"DatabaseState",
"NestQuestDatabase",
"apply_migrations",
"make_database",
]
+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"]
+78
View File
@@ -0,0 +1,78 @@
"""The ONE in-process transition-event publisher for the API service.
Route handlers call :meth:`TransitionPublisher.publish` synchronously
(no ``await``, callable from any async handler); SSE subscribers stream
those transitions through an async generator. The scope of "ONE" is
deliberate: a single :class:`TransitionPublisher` instance is created
in the app lifespan and stored on ``app.state.publisher`` — per-app,
so tests build their own hermetic app without module-level state, and
both the panel complete route and the SSE endpoint resolve the SAME
instance off ``request.app.state`` (exactly one publisher exists per
running app; nothing else may create one).
Design (small and dependency-free, asyncio only):
- Each subscriber gets its own ``asyncio.Queue(maxsize=64)``.
- ``publish`` is synchronous and non-blocking: it puts the event on
every live queue with ``put_nowait``. A slow subscriber that lets
its queue fill gets the event DROPPED (oldest overflow) — never a
blocked publisher, never unbounded growth.
- A disconnected subscriber's queue is removed in the generator's
``finally`` block, so teardown is clean and a closed consumer cannot
leak its queue.
Fan-out is fire-and-forget by design: the SSE plane mirrors transition
events for the panel UI; a dropped event costs the panel one UI
refresh, and the documented recovery path is the next snapshot poll.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
#: Per-subscriber queue capacity. Bounded so a stalled consumer can
#: never make the publisher or another subscriber wait; beyond this
#: the slow subscriber drops the event (see module docstring).
_QUEUE_MAXSIZE = 64
__all__ = ["TransitionPublisher"]
class TransitionPublisher:
"""A single in-process fan-out point for transition events."""
def __init__(self) -> None:
"""Create the publisher with no subscribers."""
self._subscribers: set[asyncio.Queue] = set()
def publish(self, event_type: str, payload: dict) -> None:
"""Fan one transition event out to every current subscriber.
Synchronous and non-blocking: each subscriber's bounded queue
is filled with ``put_nowait``, and a FULL queue silently drops
the event for that (slow) subscriber rather than blocking the
route handler or growing without bound.
"""
for queue in tuple(self._subscribers):
try:
queue.put_nowait((event_type, payload))
except asyncio.QueueFull:
# The subscriber is not keeping up; drop the event for
# it and move on (the panel recovers by polling).
pass
async def subscribe(self) -> AsyncIterator[tuple[str, dict]]:
"""Yield ``(event_type, payload)`` tuples until the consumer breaks.
Registers ONE bounded queue for the duration of the iteration
and removes it in the ``finally`` block, so a client disconnect
(or any break out of the loop) tears the subscription down
cleanly with no leaked queue.
"""
queue: asyncio.Queue = asyncio.Queue(maxsize=_QUEUE_MAXSIZE)
self._subscribers.add(queue)
try:
while True:
yield await queue.get()
finally:
self._subscribers.discard(queue)
+228
View File
@@ -0,0 +1,228 @@
"""Transitional coupling: import ``core`` without importing Home Assistant.
The NestQuest domain ``core`` package (Feature 15) is bundled INSIDE the
Home Assistant integration at ``custom_components/nestquest/core/``.
Importing it the obvious way — ``import custom_components.nestquest.core``
— would execute ``custom_components/nestquest/__init__.py``, which
imports ``homeassistant``. Home Assistant is NOT installed in the API
service's runtime, so that import raises ``ModuleNotFoundError`` and the
API cannot start.
``core`` itself is a clean, Home-Assistant-free package: its
``__init__.py`` is a bare docstring and every internal import is
relative (``from .db import ...``). It can therefore be imported by
file location as a standalone package.
This module loads ``core`` with :func:`importlib.util.spec_from_file_location`
— registering it under the distinct top-level name ``nestquest_core``
with ``submodule_search_locations`` set to the bundled ``core``
directory — so its relative imports resolve against that directory
without touching ``sys.path``. No generic top-level name (``db``,
``const``, ``schema``, ...) is published, so nothing here can silently
shadow a future dependency, and the integration's HA-coupled
``__init__.py`` is never executed: ``custom_components.nestquest`` is
never imported.
Usage::
from api.nestquest_core import core_db, core_migrations
# core_db.NestQuestDatabase, core_migrations.apply_migrations, ...
This is a TRANSITIONAL coupling. Feature 18 (task 430f7f98) converts
the integration into a thin API client and moves ``core`` out of the
integration bundle; once that lands, this bootstrap helper goes away and
the API imports ``core`` as an ordinary installed dependency.
NOTE on the pytest process: the suite's ``conftest.py`` stubs the
parent integration package and imports ``custom_components.nestquest.core.*``
BEFORE any API test runs, so two copies of the core package coexist in
the pytest process — the one registered here as ``nestquest_core.*``
(loaded by file location) and the conftest-stubbed
``custom_components.nestquest.core.*``. Later tasks must NOT compare
class or exception identity across these two copies: a
``nestquest_core.db.NestQuestDatabase`` is a DIFFERENT class object from
``custom_components.nestquest.core.db.NestQuestDatabase`` even though
they share source. Operate on a single copy within any one code path.
"""
from __future__ import annotations
import importlib
import importlib.util
import sys
from pathlib import Path
from types import ModuleType
#: The repository root (this file is ``<root>/api/nestquest_core.py``).
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent
#: The bundled ``core`` package directory, inside the HA integration.
_CORE_DIR: Path = _REPO_ROOT / "custom_components" / "nestquest" / "core"
#: The ``core`` package's ``__init__.py`` — a bare docstring, so loading
#: it by file location executes no imports of its own.
_CORE_INIT: Path = _CORE_DIR / "__init__.py"
#: The distinct top-level name under which the bundled ``core`` package
#: is registered in ``sys.modules``. A distinctive name (rather than the
#: generic ``core``) avoids publishing shadow-able top-level names and
#: keeps the bundled copy separate from the conftest-stubbed
#: ``custom_components.nestquest.core`` present in the pytest process.
_CORE_MODULE_NAME = "nestquest_core"
def _load_core() -> ModuleType:
"""Load the bundled ``core`` package by file location and return it.
Registers the package under :data:`_CORE_MODULE_NAME` in
``sys.modules`` with ``submodule_search_locations`` pointing at the
bundled directory, so its relative imports (``from .db import ...``)
resolve to ``nestquest_core.<submodule>`` against that directory
without mutating ``sys.path``. Idempotent: a second call returns
the already-registered module.
"""
existing = sys.modules.get(_CORE_MODULE_NAME)
if existing is not None:
return existing
spec = importlib.util.spec_from_file_location(
_CORE_MODULE_NAME,
_CORE_INIT,
submodule_search_locations=[str(_CORE_DIR)],
)
if spec is None or spec.loader is None:
raise ImportError(f"could not load core from {_CORE_INIT}")
module = importlib.util.module_from_spec(spec)
sys.modules[_CORE_MODULE_NAME] = module
spec.loader.exec_module(module)
return module
#: The bundled core package, loaded by file location and registered as
#: ``nestquest_core`` in ``sys.modules``. Held here so callers import the
#: submodules through it (``nestquest_core.db``, ``nestquest_core.migrations``)
#: the same relative way the integration does.
core: ModuleType = _load_core()
#: Re-exported for convenience; route handlers import these names from
#: here rather than reaching into the core package directly.
core_db = importlib.import_module(f"{_CORE_MODULE_NAME}.db")
core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.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 completion service (``core.completion.complete_instance`` /
#: ``uncomplete_instance``) the panel's write routes call.
core_completion = importlib.import_module(f"{_CORE_MODULE_NAME}.completion")
#: The read-only history layer (``core.history.query_history``,
#: ``export_history_csv``, ``rows_to_csv``) the admin plane's history
#: query and CSV export routes call.
core_history = importlib.import_module(f"{_CORE_MODULE_NAME}.history")
#: The transition-event builders (``core.events.build_quest_completed_event``
#: et al.) the panel's write routes build their SSE events with.
core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events")
#: The explicit settings object (``core.settings.NestQuestSettings``)
#: the snapshot builder's ``settings`` parameter takes.
core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
#: The durable settings store (``core.settings_store.load_settings`` /
#: ``update_settings``) the admin plane's settings routes read and
#: write — the API's OWN settings source: a validated JSON document in
#: the existing ``nestquest_meta_state`` table, resolved through the
#: same core settings validators (the integration's HA config-entry
#: options remain a temporary separate source until Feature 18 wires
#: the client to the API).
core_settings_store = importlib.import_module(
f"{_CORE_MODULE_NAME}.settings_store"
)
#: The children business layer (``core.children.create_child`` et al.)
#: the admin plane's children routes call.
core_children = importlib.import_module(f"{_CORE_MODULE_NAME}.children")
#: The quest-definition business layer (``core.quest_definitions`` —
#: ``create_quest_definition``, ``edit_quest_definition``,
#: ``set_quest_definition_active``) the admin plane's quest-definition
#: routes call.
core_quest_definitions = importlib.import_module(
f"{_CORE_MODULE_NAME}.quest_definitions"
)
#: The recurrence model (``core.recurrence.ScheduleRule`` and
#: ``RuleValidationError``) the quest-definition routes build request
#: rules with — the model validates at construction, the routes never
#: re-implement rule validation.
core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence")
#: The presence model (``core.presence.PresenceEngine``,
#: ``PresenceSchedule``, ``PresenceOverride``) the admin plane's
#: presence routes serialize and the tests resolve presence with.
core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence")
#: The presence-write business layer
#: (``core.presence_management.set_presence_schedule`` et al.) the
#: admin plane's presence routes call — the ONE implementation the HA
#: services share.
core_presence_management = importlib.import_module(
f"{_CORE_MODULE_NAME}.presence_management"
)
#: The typed presence DAOs (``core.dao_presence``) the admin-plane tests
#: read stored schedules and overrides back through.
core_dao_presence = importlib.import_module(
f"{_CORE_MODULE_NAME}.dao_presence"
)
#: The materialization walk (``core.materialize.materialize``,
#: ``regenerate_for_definition``, ``regenerate_for_child``) the admin
#: plane's regenerate route calls — the same walk the integration's
#: config/presence changes trigger.
core_materialize = importlib.import_module(f"{_CORE_MODULE_NAME}.materialize")
#: The core constants module (``core.const.DEFAULT_HORIZON_DAYS``) the
#: regenerate route sizes the household rolling horizon with.
core_const = importlib.import_module(f"{_CORE_MODULE_NAME}.const")
#: The typed child DAO (``core.dao_children.ChildrenDao.get``) the
#: regenerate route reads a child's existence with before delegating.
core_dao_children = importlib.import_module(
f"{_CORE_MODULE_NAME}.dao_children"
)
#: The typed definition DAO (``core.dao_rules.QuestDefinitionsDao.get``)
#: the regenerate route reads a definition's existence with before
#: delegating.
core_dao_rules = importlib.import_module(f"{_CORE_MODULE_NAME}.dao_rules")
#: The HA-free missed sweep (``core.sweep.run_missed_sweep`` +
#: ``SWEEP_WATERMARK_KEY``) the admin plane's trigger route calls and
#: the daily scheduler runs — the ONE sweep policy the integration's
#: bus shim shares.
core_sweep = importlib.import_module(f"{_CORE_MODULE_NAME}.sweep")
__all__ = [
"core",
"core_children",
"core_completion",
"core_const",
"core_dao_children",
"core_dao_presence",
"core_dao_rules",
"core_db",
"core_events",
"core_history",
"core_materialize",
"core_migrations",
"core_presence",
"core_presence_management",
"core_quest_definitions",
"core_recurrence",
"core_snapshot",
"core_settings",
"core_settings_store",
"core_sweep",
]
+1732
View File
File diff suppressed because it is too large Load Diff
+373
View File
@@ -0,0 +1,373 @@
"""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
import importlib
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, field_validator
from api.database import DatabaseState
from api.dependencies import require_panel_token
from api.nestquest_core import (
core_completion,
core_events,
core_settings,
core_snapshot,
)
from api import transitions as api_transitions
#: The instance DAO, imported on the app's core copy for the
#: child-mismatch guard on the complete route.
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
#: 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]
class PanelCompleteRequest(BaseModel):
"""The body of ``POST /api/v1/panel/instances/{id}/complete``.
Matches the panel's ``nestquest.complete_quest`` payload (§2 of
design/ENTITIES-AND-SERVICES.md): ``actor_child_id`` is the tapped
profile. A positive integer is required — the core completion
layer rejects non-int (and bool) ids anyway, but rejecting them
here with 422 keeps the contract visible in the OpenAPI document
and fails before any database work.
"""
actor_child_id: int
model_config = {
"json_schema_extra": {
"description": (
"The tapped child profile the completion is "
"attributed to (decision 9)."
)
}
}
@field_validator("actor_child_id")
@classmethod
def _positive(cls, value: int) -> int:
"""Reject zero and negative ids with a validation error."""
if value < 1:
raise ValueError("actor_child_id must be a positive integer")
return value
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
],
)
@router.post(
"/instances/{instance_id}/complete",
summary="Complete one quest instance",
status_code=200,
)
async def panel_complete_instance(
instance_id: int, body: PanelCompleteRequest, request: Request
) -> dict[str, str]:
"""Complete one quest instance as the tapped child profile.
Requires the panel service token (checked by the router's shared
:func:`~api.dependencies.require_panel_token` dependency — the ONE
token check; this handler performs NO auth of its own).
Thin adapter only: resolves the live database off ``app.state.db``,
reads ONE timezone-aware clock (:func:`_local_now`), and delegates
to :func:`nestquest_core.completion.complete_instance` with
``actor_source="panel"`` and the tapped ``actor_child_id`` — the
append/idempotence logic, the lock, and the actor-shape validation
all live in the core layer, never here.
Response and idempotence: ``complete_instance`` returns a
:class:`~nestquest_core.completion.CompletionResult` whose
``appended`` flag reports whether THIS call wrote an event; a
repeat call for an already-completed instance is a no-op inside
the core layer (no duplicate event) and this route still answers
``{"status": "done"}`` either way, so a retried panel tap succeeds.
Error mapping (deliberately narrow): the ONLY ``ValueError`` mapped
to 404 is the unknown-``instance_id`` one, identified by the
``instance_id:`` prefix ``complete_instance`` names the failing
field with. Every other ``ValueError`` (an invalid actor shape,
which the body model and the panel actor contract already make
unreachable) re-raises to the framework's 500 — nothing else is
turned into a 404 or a 500 of our own making.
Child mismatch: ``complete_instance`` verifies the tapped
``actor_child_id`` exists but does NOT check it is the child the
instance belongs to. The route performs that check here (one
``QuestInstancesDao.get_by_id`` read) and answers 404 when the
instance's ``child_id`` differs — completing another child's quest
from the wrong profile must not succeed, and 404 (not 403) keeps
the instance's existence from leaking to a wrong-profile tap.
"""
state: DatabaseState = request.app.state.db
database = state.database
# Child-mismatch guard (see docstring): the instance must belong to
# the tapped profile before the completion layer is entered.
instance = await _dao_instances.QuestInstancesDao(database).get_by_id(
instance_id
)
if instance is not None and instance.child_id != body.actor_child_id:
raise HTTPException(
status_code=404,
detail="Quest instance not found for this child",
)
now = _local_now()
try:
result = await core_completion.complete_instance(
database,
instance_id,
actor_source="panel",
actor_child_id=body.actor_child_id,
now=now,
)
except ValueError as error:
message = str(error)
if message.startswith("instance_id:"):
# complete_instance names the field in its ValueError:
# "instance_id: quest instance N does not exist".
raise HTTPException(
status_code=404,
detail="Quest instance not found",
) from error
raise
# The derived state is "done" in both the appended and no-op cases;
# ``result.appended`` distinguishes them for callers that care —
# the panel route returns the same success either way.
if result.appended:
# An ACTUAL transition: publish the SSE transition event. The
# payload is built by the core builder (never hand-built here)
# and fan-out is fire-and-forget (see api/events.py).
event_type, payload = await core_events.build_quest_completed_event(
database, instance_id, was_on_time=result.was_on_time
)
request.app.state.publisher.publish(event_type, payload)
# THEN evaluate the day-complete rule off the completed event's
# payload (the core builder decides when the child's whole day
# is cleared — a zero-quest day and a non-today instance never
# fire it). ``today`` comes from the route's single clock read
# so the calendar date can never disagree with the completion's.
await api_transitions.publish_child_day_complete(
database,
payload,
now.date(),
request.app.state.publisher,
)
return {"status": str(result)}
async def _sse_stream(request: Request) -> AsyncIterator[str]:
"""Yield the SSE frames for every published transition event.
One subscriber of the app's single publisher (resolved off
``app.state.publisher``); each event becomes one
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
publisher's subscription (on client disconnect) tears the
subscription down cleanly — no queue is leaked.
"""
publisher = request.app.state.publisher
async for event_type, payload in publisher.subscribe():
yield (
f"event: {event_type}\n"
f"data: {json.dumps(payload)}\n\n"
)
@router.get(
"/events",
summary="Stream panel transition events (SSE)",
)
async def panel_events(request: Request) -> StreamingResponse:
"""Stream the app's transition events to the panel (Server-Sent
Events).
Requires the panel service token (checked by the router's shared
:func:`~api.dependencies.require_panel_token` dependency — the ONE
token check; this handler performs NO auth of its own).
Returns a ``text/event-stream`` response whose body is produced by
:func:`_sse_stream` off the app's single in-process publisher
(``app.state.publisher``, installed in the lifespan). The stream
ends when the client disconnects, at which point the subscription
is torn down (its queue removed) with nothing leaked.
"""
return StreamingResponse(
_sse_stream(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache"},
)
__all__ = ["router"]
+173
View File
@@ -0,0 +1,173 @@
"""Daily missed-sweep background scheduler for the API service.
The API service has no Home Assistant, so nobody re-fires the
integration's day-rollover listener for it: this module owns the API
plane's own background job. One :class:`MissedSweepScheduler` per app
(installed on ``app.state`` by the lifespan) sleeps until the
configured ``day_rollover_time`` — read fresh from the settings store
at the top of EVERY cycle, so an admin's settings change applies on
the next scheduling decision without a restart — then runs the
HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for the
API host's local date and publishes each returned
``nestquest_quest_missed`` event on the app's ONE transition publisher
(the same SSE stream every route publishes through).
All sweep policy lives in the core (the watermark, the
``[watermark, today)`` window, the no-completion-event rule): this
scheduler only decides WHEN. A same-night rerun — e.g. two workers,
or a settings change moving the rollover past itself — fires nothing:
the core's watermark makes the run an empty no-op.
Testability: ``clock`` (an aware-now callable) and ``sleep`` (an
awaitable delay) are injectable, so tests pin the time, fast-forward
through the wait, and observe the computed delays without waiting a
real day. Cancellation: :meth:`stop` cancels the loop task and waits
for it, so the lifespan's shutdown is clean and a mid-run sweep is
interrupted before the connection closes. A failed iteration (a
database hiccup, a rejected setting) is logged and retried after a
fixed backoff rather than silently killing the job.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
from api.nestquest_core import core_settings_store, core_sweep
LOGGER = logging.getLogger(__name__)
#: Backoff after a failed scheduling cycle (settings read or sweep
#: error), so a persistent failure retries every minute instead of
#: spinning or dying.
_RETRY_BACKOFF_SECONDS = 60.0
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
"""Seconds from ``now`` until the next occurrence of ``target``.
``target`` is a plain ``HH:MM`` time of day: the next wall-clock
occurrence strictly AFTER ``now`` (an equal time schedules for
tomorrow, so the just-run sweep is never instantly re-run).
"""
scheduled = now.replace(
hour=target.hour, minute=target.minute, second=0, microsecond=0
)
if scheduled <= now:
scheduled += datetime.timedelta(days=1)
return (scheduled - now).total_seconds()
class MissedSweepScheduler:
"""Run the missed sweep once per day at ``day_rollover_time``."""
def __init__(
self,
database: object,
*,
publisher: object | None = None,
clock: object | None = None,
sleep: object | None = None,
) -> None:
"""Create the scheduler (not yet running).
``database`` is the app's live :class:`~api.database.NestQuestDatabase`.
``publisher`` is the app's ONE
:class:`~api.events.TransitionPublisher` (publishing is skipped
when ``None``). ``clock`` defaults to the API host's local
aware now (``datetime.datetime.now().astimezone()``) — the same
single-clock discipline the routes' ``_local_now`` uses;
``sleep`` defaults to :func:`asyncio.sleep`.
"""
self._database = database
self._publisher = publisher
self._clock = clock if clock is not None else (
lambda: datetime.datetime.now().astimezone()
)
self._sleep = sleep if sleep is not None else asyncio.sleep
self._task: asyncio.Task | None = None
def start(self) -> None:
"""Start the background loop (idempotent: a running one stays)."""
if self._task is None or self._task.done():
self._task = asyncio.create_task(
self._run_forever(), name="nestquest-missed-sweep"
)
async def stop(self) -> None:
"""Cancel the loop and wait for it to finish (safe if never started)."""
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
@property
def running(self) -> bool:
"""Whether the background loop task is alive."""
return self._task is not None and not self._task.done()
async def _run_forever(self) -> None:
"""Schedule → sweep → repeat, forever (until cancelled)."""
while True:
try:
settings = await core_settings_store.load_settings(
self._database
)
rollover = datetime.time.fromisoformat(
settings.day_rollover_time
)
except Exception:
# Not silent: the failure is logged and retried after
# the backoff, so one bad settings document or a
# transient database error never kills the scheduler.
LOGGER.exception(
"NestQuest missed-sweep scheduler failed to resolve "
"the day rollover time; retrying in %gs",
_RETRY_BACKOFF_SECONDS,
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
continue
delay = _delay_seconds(rollover, self._clock())
LOGGER.info(
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
settings.day_rollover_time,
delay,
)
await self._sleep(delay)
try:
await self._run_once(self._clock().date())
except Exception:
# Not silent either: the sweep error is logged and the
# loop backs off before the next cycle, whose watermark
# re-read makes an already-swept day a no-op.
LOGGER.exception(
"NestQuest scheduled missed sweep failed; retrying in "
"%gs",
_RETRY_BACKOFF_SECONDS,
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
async def _run_once(self, today: datetime.date) -> None:
"""Run ONE sweep for ``today`` and publish what it built.
A failure propagates to :meth:`_run_forever`, which logs it and
backs off before the next cycle; the watermark makes a re-run
of an already-swept day announce nothing twice.
"""
events = await core_sweep.run_missed_sweep(
self._database, today=today
)
for event_type, payload in events:
if self._publisher is not None:
self._publisher.publish(event_type, payload)
LOGGER.info(
"NestQuest scheduled missed sweep for %s published %d event(s)",
today.isoformat(),
len(events),
)
__all__ = ["MissedSweepScheduler"]
+83
View File
@@ -0,0 +1,83 @@
"""Reusable SSE transition publishers for the API service (task 44c93cfd).
Each helper wraps the ONE publisher on ``app.state.publisher`` with a
single documented transition: it builds the payload through the core
event builders (``nestquest_core.events``) — never hand-built here —
and fans it out with :meth:`~api.events.TransitionPublisher.publish`.
The uncomplete route (follow-up task da0226b3) and the API-side missed
sweep do not exist yet; these helpers are the functions they will call,
and the SSE tests exercise them directly. Fan-out stays fire-and-forget
(see api/events.py): a slow subscriber drops the event and recovers by
polling the snapshot.
"""
from __future__ import annotations
import datetime
from api.nestquest_core import core_events
async def publish_quest_uncompleted(
database: object, instance_id: int, publisher: object
) -> None:
"""Publish ONE ``nestquest_quest_uncompleted`` event for an actual
reversal of ``instance_id``.
The payload is built by the core builder
(:func:`nestquest_core.events.build_quest_uncompleted_events`);
this helper only picks the single event out of the builder's list
and publishes it.
"""
events = await core_events.build_quest_uncompleted_events(
database, instance_id
)
for event_type, payload in events:
publisher.publish(event_type, payload)
async def publish_quest_missed(
database: object, instance_id: int, publisher: object
) -> None:
"""Publish ONE ``nestquest_quest_missed`` event for ``instance_id``.
The payload is built by the core builder
(:func:`nestquest_core.events.build_quest_missed_event`), which
mirrors the documented sweep payload exactly (§3 of
design/ENTITIES-AND-SERVICES.md).
"""
event_type, payload = await core_events.build_quest_missed_event(
database, instance_id
)
publisher.publish(event_type, payload)
async def publish_child_day_complete(
database: object,
completed_payload: dict,
today: datetime.date,
publisher: object,
) -> bool:
"""Publish ``nestquest_child_day_complete`` when a completion
cleared the child's whole day; return whether one was published.
The day-complete rule lives entirely in the core builder
(:func:`nestquest_core.events.build_child_day_complete_event`):
it returns ``None`` when the day did not clear (a zero-quest day
or a non-today instance) and this helper publishes nothing then.
"""
event = await core_events.build_child_day_complete_event(
database, completed_payload, today=today
)
if event is None:
return False
event_type, payload = event
publisher.publish(event_type, payload)
return True
__all__ = [
"publish_child_day_complete",
"publish_quest_missed",
"publish_quest_uncompleted",
]