diff --git a/.github/workflows/ci_check.yml b/.github/workflows/ci_check.yml index fb81c17..b22ec1f 100644 --- a/.github/workflows/ci_check.yml +++ b/.github/workflows/ci_check.yml @@ -41,6 +41,33 @@ jobs: if-no-files-found: error retention-days: 7 + backend: + name: Backend application + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + with: + version: "0.11.29" + enable-cache: false + - run: uv sync --frozen + - run: uv run mypy backend/src + - name: Test every authored backend module with separate coverage + run: uv run pytest tests/backend --cov=domus --cov-branch --cov-report=term-missing --cov-report=json:coverage-backend.json --cov-report=xml:coverage-backend.xml + - name: Retain backend coverage + if: ${{ always() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: backend-coverage-${{ github.sha }} + path: | + coverage-backend.json + coverage-backend.xml + if-no-files-found: error + retention-days: 7 + node: name: Node architecture policy runs-on: ubuntu-latest @@ -83,6 +110,7 @@ jobs: enable-cache: false - run: uv sync --frozen - run: uv run lint-imports --config architecture/python-tooling.ini --no-cache + - run: uv run lint-imports --config architecture/python-application.ini --no-cache - run: uv run pytest tests/architecture documentation: @@ -100,10 +128,46 @@ jobs: - run: uv sync --frozen --no-dev - run: uv run --no-dev domus-check docs + containers: + name: App container (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + arch: amd64 + - runner: ubuntu-24.04-arm + arch: aarch64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + with: + version: "0.11.29" + enable-cache: false + - run: uv sync --frozen + - name: Build the exact Supervisor app folder + run: docker build --build-arg BUILD_ARCH=${{ matrix.arch }} -t domus-app-ci addon-edge + - name: Build the immutable previous image for upgrade and downgrade checks + run: >- + docker build --build-arg BUILD_ARCH=${{ matrix.arch }} + --build-arg BUILD_VERSION=0.0.0-edge.2 + --build-arg DOMUS_SOURCE_COMMIT=d013cf2c8b932d0e00f5858053cbc0e663d5eee1 + --build-arg DOMUS_SOURCE_SHA256=fa58d457bef8a863a1943b4e6ba5c9513da8d3baa8b0a033bdcbe7bf11b09c6c + -t domus-app-previous addon-edge + - name: Verify the real image, listener and cleanup + env: + DOMUS_TEST_IMAGE: domus-app-ci + DOMUS_PREVIOUS_IMAGE: domus-app-previous + run: uv run pytest tests/containers --no-cov + aggregate: name: CI Check if: ${{ always() }} - needs: [python, node, architecture, documentation] + needs: [python, backend, node, architecture, documentation, containers] runs-on: ubuntu-latest timeout-minutes: 10 env: diff --git a/README.md b/README.md index 8599f18..2983aca 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,13 @@ into one responsive interface. Normal operation does not require AI or cloud ser ## Project status -Domus has started its development-tooling foundation. No household application is implemented or published yet. +Domus has tested backend storage, identity policy and a standalone authentication API/CLI in development. +A stateless Edge HA app preview now uses native Ingress authentication without another sign-in. +The household product, product UI, production packages and release artifacts remain incomplete. The [documentation foundation issue](https://github.com/vypdev/domus/issues/2) tracks eleven Draft SDDs, 82 normative requirements and 353 planned acceptance cases. These cases are verification plans, not executed application tests. +The M1 identity/storage companion adds its own 61-case plan; partial backend evidence does not complete it. The first usable release covers all six domains, Supervisor App/Ingress and standalone Container, and isolated Stable and experimental Edge channels. @@ -38,12 +41,16 @@ in the application, with the chosen policy and actual awards preserved in the le - [Acceptance, coverage and CI Check plan](docs/acceptance-plan.md) - [Specification validation evidence and limitations](docs/sdd-validation.md) - [Development setup, tests and CI Check](docs/development.md) +- [Home Assistant app installation and development preview](docs/installation-home-assistant.md) +- [Implemented authentication journey and limits](docs/identity-household.md) +- [Authentication server configuration](docs/configuration.md) +- [Identity/storage operations and pending commands](docs/operations-identity-storage.md) ## Quality and contribution Clean Architecture is mandatory. Meaningful tests accompany every implementation from its first change, with 100% supported first-party coverage metrics as the -required implementation gate. A future project-owned workflow named **CI Check** runs +required implementation gate. The project-owned workflow named **CI Check** runs the full applicable suite for every PR update. English is used for issues, documentation, source identifiers and comments; the product supports English and Spanish. @@ -60,13 +67,15 @@ preparation when necessary. Merge, release and deployment remain separately auth ## Readiness limitations All SDDs remain Draft while material decisions are reviewed. Their original Copilot -format and consistency checks pass. The proposed SDK catalog still fails the unchanged -upstream evidence validator for the ten capabilities without implementation. Delivery-quality -registers only its real partial tooling/CI evidence; no fake paths or gate-readiness claims are used. See the +format and consistency checks pass. The corrected upstream catalog validator accepts truthful +proposed capabilities with empty implementation arrays. Platform, identity and delivery-quality +register only their real partial evidence; no fake paths or gate-readiness claims are used. See the [adoption note](docs/copilot-adoption.md). The supplied brief ends at the beginning of section 10.2. The owner completed the barcode scope on 2026-10-29: camera/manual entry, local matching and optional Internet product lookup. Provider feasibility and device evidence still require review. The initial tooling/tests and a separate CI Check workflow are now implemented; see the development guide. -Household application, UI and HA runtime tests remain future implementation work. +Complete household commands, UI, console recovery, snapshots, Supervisor session/listener wiring +and live/package acceptance remain pending. The authentication checkpoint uses real adapters and +offline provider/loopback fixtures; it is not a completed or published household release. diff --git a/addon-edge/CHANGELOG.md b/addon-edge/CHANGELOG.md new file mode 100644 index 0000000..6ec5b2d --- /dev/null +++ b/addon-edge/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +## 0.0.0-edge.3 + +- Preserve complete original operation history separately from restored household state in SQLite schema 4. +- Add console-only restore preparation, verification, activation, status and interruption recovery. +- Retain a verified pre-activation source checkpoint; rollback also preserves newer replay evidence. +- Migrate only a verified target during restoration and retain original source data. +- Prevent reusing consumed or canceled recovery approval after restoring an older checkpoint. +- Mark the schema/history boundary as breaking: an older image must not open schema 4 or selected generations. +- Keep the complete product UI, live restore and production acceptance pending. + +## 0.0.0-edge.2 + +- Add private persistent installation identity and SQLite household/member/policy/recovery services. +- Prepare only the app's own private data child, then run the server without root privileges. +- Require one to four fixed Home Assistant browser origins in the app configuration. +- Preserve data and original-operation receipts on restart; keep sessions in memory. +- Include manual snapshot creation/inspection and console-only recovery approval; restore remains pending. +- Keep the full household UI and production acceptance pending. + +## 0.0.0-edge.1 + +- Add the native Home Assistant authentication preview without an additional sign-in. +- Bind the locally built app to reviewed runtime source and locked dependencies. +- Keep household commands, product UI and production acceptance explicitly pending. diff --git a/addon-edge/DOCS.md b/addon-edge/DOCS.md new file mode 100644 index 0000000..3618e74 --- /dev/null +++ b/addon-edge/DOCS.md @@ -0,0 +1,43 @@ +# Installation and operation + +Install Domus Edge from the reviewed Domus development repository. In Configuration, set +public_origins to the URLs used to open HA, for example http://homeassistant.local:8123. Enter one to +four distinct origins without paths, credentials, queries or fragments, then start and select Open Web UI. +No password, copied token, OAuth callback or host port configuration is needed. The first installation +builds the reviewed runtime from an immutable source commit and frozen dependencies; allow the build +to finish before opening the UI. + +The preview shows whether the current human can be verified through HA. It creates one private +installation identity and SQLite schema, retaining both across restart/update. It never implicitly +creates a household or member. The household/member/policy APIs and console-approved recovery are +included; the product UI is still a connection page. Existing sessions have bounded local read access +during HA outages; writes require fresh proof. Failure never grants default administrator access. + +The container requests Core API access to verify individual HA users. HA grants this technical +connection administrator privileges, which the app never treats as the human's permissions. +It requests no Supervisor management API, host port, host network, privileged capabilities, devices +or host-directory mounts. A short container-root launcher prepares only /data/domus and then clears +supplementary groups and executes the server as UID/GID 65532. Existing data must already have the +correct private ownership/mode; there is no recursive repair or root server fallback. Domus reads the Supervisor-provided +credential into private runtime memory and does not write it into its web page, logs or business +storage. HA/Supervisor owns provisioning and the credential's platform lifecycle. + +Stop the app to close its local sessions and release storage locks. Household records and original +operation receipts remain in /data/domus. Do not remove the installation marker, copy SQLite alone +or share this data with Stable. Edge.3 includes console-only restoration to a new verified generation; +it preserves later operation evidence without reapplying rolled-away effects. Stop the app before +restore preparation/activation. Inspect and resume the same activation ID after an uncertain result. +The retained source-checkpoint supports deliberate rollback reconciled against the latest history. +Do not switch root pointers or run an older image manually. See the +[restoration guide](https://github.com/vypdev/domus/blob/feature/6-implement-home-assistant-identity-and-sqlite-platform-persistence/docs/restoration.md) +for the exact console commands. Restore is not available from the connection page. +App removal with data +deletion removes the app's private records; use deliberate operator backup procedures before removal. +Changing public_origins requires a restart. Edge.2 and Edge.3 are marked as breaking versions so an +unattended update cannot cross their configuration and schema/history boundaries. Schema 4 refuses +downgrade by Edge.2; recovery requires a compatible image/data checkpoint pair. + +Stopping does not sign out your HA frontend. There is no Domus OAuth +grant to revoke for this native mode. Remove the development app/repository after its owned test; +do not migrate production household data into this preview. A stable package will be published +separately when its acceptance gates pass. diff --git a/addon-edge/Dockerfile b/addon-edge/Dockerfile new file mode 100644 index 0000000..83dadac --- /dev/null +++ b/addon-edge/Dockerfile @@ -0,0 +1,30 @@ +# The Supervisor builds this folder. Fetch only the reviewed immutable runtime. +FROM ghcr.io/astral-sh/uv:0.11.29@sha256:eb2843a1e56fd9e30c7276ce1a52cba86e64c7b385f5e3279a0e08e02dd058fc AS uv +FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 AS builder +COPY --from=uv /uv /usr/local/bin/uv +ARG DOMUS_SOURCE_COMMIT=faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5 +ARG DOMUS_SOURCE_SHA256=15bcd701696204b3362cebe5901feb3d0072c9bac1db2ef2cbc5007e9ed73717 +ADD https://codeload.github.com/vypdev/domus/tar.gz/${DOMUS_SOURCE_COMMIT} /tmp/source.tar.gz +RUN printf '%s /tmp/source.tar.gz\n' "$DOMUS_SOURCE_SHA256" | sha256sum --check - \ + && mkdir /app \ + && tar --extract --gzip --file=/tmp/source.tar.gz --directory=/app --strip-components=1 +WORKDIR /app +ENV UV_PYTHON_DOWNLOADS=never UV_NO_CACHE=1 +RUN uv sync --frozen --no-dev --package domus-server --no-editable + +FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 +ARG BUILD_VERSION=0.0.0-edge.3 +ARG BUILD_ARCH +ARG DOMUS_SOURCE_COMMIT=faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5 +LABEL io.hass.type="app" \ + io.hass.version="${BUILD_VERSION}" \ + io.hass.arch="${BUILD_ARCH}" \ + org.opencontainers.image.source="https://github.com/vypdev/domus" \ + org.opencontainers.image.revision="${DOMUS_SOURCE_COMMIT}" +COPY --from=builder /app/.venv /app/.venv +ENV PATH="/app/.venv/bin:${PATH}" PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 +# Only the fixed-path launcher provisions /data/domus as root; the server execs as 65532. +USER 0:0 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8099/health', timeout=3)" +ENTRYPOINT ["/app/.venv/bin/domus-ha-app", "--channel", "edge"] diff --git a/addon-edge/README.md b/addon-edge/README.md new file mode 100644 index 0000000..d43186a --- /dev/null +++ b/addon-edge/README.md @@ -0,0 +1,11 @@ +# Domus Edge + +Development preview of Domus installed as a Home Assistant app. Open it through HA's Open Web UI; +it uses your existing HA sign-in without another login. + +Edge 0.0.0-edge.3 adds retained operation history and console-only restoration with interrupted +activation recovery. Private persistent identity, household APIs, bounded sessions and administrator +recovery remain included. The complete product UI remains under development. Set your Home Assistant browser +origins in the app's Configuration tab before starting. It remains experimental, with no stable release. + +See [installation and permissions](DOCS.md). diff --git a/addon-edge/config.yaml b/addon-edge/config.yaml new file mode 100644 index 0000000..0c62b4e --- /dev/null +++ b/addon-edge/config.yaml @@ -0,0 +1,30 @@ +name: Domus Edge +version: "0.0.0-edge.3" +slug: domus_edge +description: Development preview of the local Domus household application +url: https://github.com/vypdev/domus +arch: + - amd64 + - aarch64 +startup: application +boot: manual +stage: experimental +breaking_versions: + - "0.0.0-edge.2" + - "0.0.0-edge.3" +init: true +timeout: 30 +ingress: true +ingress_port: 8099 +panel_icon: mdi:home-heart +panel_title: Domus Edge +panel_admin: false +homeassistant_api: true +hassio_api: false +auth_api: false +ports: {} +options: + public_origins: [] +schema: + public_origins: + - url diff --git a/addon-edge/translations/en.yaml b/addon-edge/translations/en.yaml new file mode 100644 index 0000000..ad4152a --- /dev/null +++ b/addon-edge/translations/en.yaml @@ -0,0 +1,7 @@ +configuration: + public_origins: + name: Home Assistant browser origins + description: >- + The URLs used to open Home Assistant, including scheme and port when present, + for example http://homeassistant.local:8123. Enter one to four distinct origins + without paths. Restart Domus after changing them. Your existing HA sign-in is used. diff --git a/architecture/python-application.ini b/architecture/python-application.ini index 00c2f54..80ae696 100644 --- a/architecture/python-application.ini +++ b/architecture/python-application.ini @@ -21,10 +21,21 @@ forbidden_modules = fastapi homeassistant httpx + httpx2 + websockets + uvicorn + starlette + pydantic requests sqlite3 sqlalchemy subprocess + asyncio + time + secrets + pathlib + os + fcntl [importlinter:contract:no-cycles] name = Application modules remain acyclic diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..89b4d85 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "domus-server" +version = "0.0.0" +description = "Local household application backend for Domus" +requires-python = ">=3.14,<3.15" +dependencies = [ + "fastapi==0.162.0", + "httpx==0.28.1", + "uvicorn==0.54.0", + "websockets==17.4", +] + +[project.scripts] +domus-server = "domus.composition.server:main" +domus-ingress = "domus.composition.ingress:main" +domus-admin = "domus.composition.admin:main" +domus-ha-app = "domus.composition.ha_app:main" + +[build-system] +requires = ["hatchling==1.33.0"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/domus"] diff --git a/backend/src/domus/__init__.py b/backend/src/domus/__init__.py new file mode 100644 index 0000000..74ce1a8 --- /dev/null +++ b/backend/src/domus/__init__.py @@ -0,0 +1 @@ +"""Domus household application, independent of its deployment adapters.""" diff --git a/backend/src/domus/application/__init__.py b/backend/src/domus/application/__init__.py new file mode 100644 index 0000000..8c177f0 --- /dev/null +++ b/backend/src/domus/application/__init__.py @@ -0,0 +1 @@ +"""Use cases and semantic ports that depend only inward on domain contracts.""" diff --git a/backend/src/domus/application/activation.py b/backend/src/domus/application/activation.py new file mode 100644 index 0000000..d801bc7 --- /dev/null +++ b/backend/src/domus/application/activation.py @@ -0,0 +1,55 @@ +"""Semantic activation commands bind results to the requested installation and preparation.""" + +from dataclasses import dataclass +from typing import Protocol + +from domus.domain.activation import ActivationStatus +from domus.domain.errors import InvalidInput, StorageFailure +from domus.domain.restoration import PreparedRestoration +from domus.domain.storage import Installation, validate_identifier + + +class Activations(Protocol): + def activate(self) -> ActivationStatus: ... + def resume(self) -> ActivationStatus: ... + def inspect(self) -> ActivationStatus: ... + + +@dataclass +class ActivationCommands: + activations: Activations + installation: Installation + prepared: PreparedRestoration | None = None + restore_id: str | None = None + + def _checked(self, result: ActivationStatus, *, complete: bool) -> ActivationStatus: + if ( + result.record.prepared.installation != self.installation + or (self.prepared is not None and result.record.prepared != self.prepared) + or ( + self.restore_id is not None and result.record.prepared.restore_id != self.restore_id + ) + or (complete and result.state == "pending") + ): + raise StorageFailure("invalid_activation_result") + return result + + def _selection(self) -> None: + if self.prepared is None and self.restore_id is None: + raise InvalidInput("activation_selection_required") + if self.prepared is not None and self.prepared.installation != self.installation: + raise InvalidInput("activation_selection_required") + if self.restore_id is not None: + validate_identifier(self.restore_id) + + def activate(self) -> ActivationStatus: + self._selection() + return self._checked(self.activations.activate(), complete=True) + + def resume(self) -> ActivationStatus: + self._selection() + return self._checked(self.activations.resume(), complete=True) + + def inspect(self) -> ActivationStatus: + self._selection() + return self._checked(self.activations.inspect(), complete=False) diff --git a/backend/src/domus/application/administrator_proof.py b/backend/src/domus/application/administrator_proof.py new file mode 100644 index 0000000..7b6643d --- /dev/null +++ b/backend/src/domus/application/administrator_proof.py @@ -0,0 +1,64 @@ +"""Prepare eligible administrator facts through ports before entering a writer transaction.""" + +from domus.application.identity_ports import HumanDirectory +from domus.domain.errors import Forbidden +from domus.domain.identity import ( + HouseholdPolicy, + HumanIdentity, + MappingMode, + Member, + MemberStatus, + Role, + effective_role, +) + + +def exact_human(human: HumanIdentity, user_id: str) -> HumanIdentity: + if not isinstance(human, HumanIdentity) or human.ha_user_id != user_id: + raise Forbidden("identity_mismatch") + return human + + +def remaining_humans( + policy: HouseholdPolicy, + members: tuple[Member, ...], + known: tuple[HumanIdentity, ...], + directory: HumanDirectory, +) -> tuple[tuple[HumanIdentity, ...], bool]: + """An omitted eligible candidate is a negative fact, never a transport fallback. + + Return whether external proof was needed so callers reprove their actor. + The transition must still check these facts and local inventory atomically. + """ + humans = {human.ha_user_id: human for human in known} + if any( + member.ha_user_id in humans + and effective_role(humans[member.ha_user_id], member, policy) is Role.ADMINISTRATOR + for member in members + ): + return known, False + candidates = tuple( + member.ha_user_id + for member in members + if member.status is MemberStatus.ACTIVE + and member.ha_user_id not in humans + and ( + member.role is Role.ADMINISTRATOR + or policy.mapping.mode is MappingMode.ALL + or policy.mapping.target_ha_user_id == member.ha_user_id + ) + ) + if not candidates: + return known, False + resolved = directory.eligible(candidates) + if type(resolved) is not tuple or len(resolved) > len(candidates): + raise Forbidden("identity_mismatch") + for candidate in resolved: + if ( + not isinstance(candidate, HumanIdentity) + or candidate.ha_user_id not in candidates + or candidate.ha_user_id in humans + ): + raise Forbidden("identity_mismatch") + humans[candidate.ha_user_id] = candidate + return tuple(humans.values()), True diff --git a/backend/src/domus/application/authentication.py b/backend/src/domus/application/authentication.py new file mode 100644 index 0000000..3929a84 --- /dev/null +++ b/backend/src/domus/application/authentication.py @@ -0,0 +1,78 @@ +"""Browser-bound single-use login orchestration does not own HA tokens or URLs.""" + +import base64 +import hashlib +from dataclasses import dataclass, field + +from domus.application.authentication_values import LoginAttempt, validate_login_material +from domus.application.identity_ports import LeaseClock, LoginAttempts, LoginEntropy, LoginProvider +from domus.application.session_values import SessionLease, validate_session_lifetime +from domus.domain.errors import IdentityRejected, InvalidInput +from domus.domain.storage import validate_identifier + + +def _digest(value: str) -> str: + return hashlib.sha256(value.encode("ascii")).hexdigest() + + +@dataclass(frozen=True, slots=True) +class LoginStart: + authorization_url: str = field(repr=False) + browser_token: str = field(repr=False) + + +@dataclass +class Authentication: + attempts: LoginAttempts + provider: LoginProvider = field(repr=False) + clock: LeaseClock + entropy: LoginEntropy + lifetime: int = 3_600_000 + + def __post_init__(self) -> None: + validate_session_lifetime(self.lifetime) + + def _material(self) -> str: + value = self.entropy.new() + validate_login_material(value) + return value + + def start(self) -> LoginStart: + state, verifier, browser = self._material(), self._material(), self._material() + now = self.clock.now() + attempt = LoginAttempt(_digest(state), _digest(browser), verifier, now, now + 300_000) + self.attempts.add(attempt, now) + challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()) + .rstrip(b"=") + .decode("ascii") + ) + return LoginStart(self.provider.authorization_url(state, challenge), browser) + + def _take(self, state: str, browser: str) -> LoginAttempt: + try: + validate_login_material(state) + validate_login_material(browser) + except InvalidInput: + raise IdentityRejected("invalid_login_attempt") from None + return self.attempts.take(_digest(state), _digest(browser), self.clock.now()) + + def cancel(self, state: str, browser: str) -> None: + self._take(state, browser) + + def complete(self, code: str, state: str, browser: str) -> SessionLease: + attempt = self._take(state, browser) + session_id = "s_" + self._material() + validate_identifier(session_id) + now = self.clock.now() + deadline = now + self.lifetime + lease = self.provider.sign_in(code, attempt.verifier, session_id, now, deadline) + if ( + lease.session_id != session_id + or lease.started_at != now + or lease.expires_at != deadline + or not lease.valid_at(self.clock.now()) + ): + self.provider.sign_out(session_id) + raise IdentityRejected("invalid_login_result") + return lease diff --git a/backend/src/domus/application/authentication_values.py b/backend/src/domus/application/authentication_values.py new file mode 100644 index 0000000..ad75921 --- /dev/null +++ b/backend/src/domus/application/authentication_values.py @@ -0,0 +1,40 @@ +"""Ephemeral login contracts stay outside household domain entities and business storage.""" + +import hmac +import re +from dataclasses import dataclass, field + +from domus.domain.errors import InvalidInput + + +def validate_login_material(value: str) -> None: + if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9._~-]{43,128}", value): + raise InvalidInput("invalid_login_material") + + +@dataclass(frozen=True, slots=True) +class LoginAttempt: + state_digest: str + browser_digest: str + verifier: str = field(repr=False) + issued_at: int + expires_at: int + + def __post_init__(self) -> None: + for digest in (self.state_digest, self.browser_digest): + if not isinstance(digest, str) or not re.fullmatch(r"[a-f0-9]{64}", digest): + raise InvalidInput("invalid_login_attempt") + validate_login_material(self.verifier) + if ( + type(self.issued_at) is not int + or type(self.expires_at) is not int + or not 0 <= self.issued_at < self.expires_at <= 2**63 - 1 + or self.expires_at - self.issued_at > 300_000 + ): + raise InvalidInput("invalid_login_attempt") + + def valid_at(self, now: int) -> bool: + return self.issued_at <= now < self.expires_at + + def matches_browser(self, browser_digest: str) -> bool: + return hmac.compare_digest(self.browser_digest, browser_digest) diff --git a/backend/src/domus/application/authorization.py b/backend/src/domus/application/authorization.py new file mode 100644 index 0000000..71ce393 --- /dev/null +++ b/backend/src/domus/application/authorization.py @@ -0,0 +1,69 @@ +"""Current local authority is checked inside each read or command transaction.""" + +from dataclasses import dataclass + +from domus.application.ports import UnitOfWork +from domus.domain.errors import Forbidden +from domus.domain.identity import HumanIdentity, Member, Permission, allows +from domus.domain.storage import CommandKey + + +@dataclass(frozen=True, slots=True) +class HouseholdAccess: + """The identity must come from the server proof boundary, never a request DTO. + + Mutating use cases must obtain fresh HA proof before entering SQLite. Reads + may use an unexpired session's outage lease. Both reread current local facts; + an HA proof and a local commit cannot be atomic across the two systems. + """ + + human: HumanIdentity + + def require( + self, + unit: UnitOfWork, + household_id: str, + permission: Permission, + *, + subject_member_id: str | None = None, + assigned_to: tuple[str, ...] = (), + ) -> Member: + member = unit.members.by_user(household_id, self.human.ha_user_id) + policy = unit.policies.get(household_id) + if ( + member is None + or policy is None + or not allows( + self.human, + member, + policy, + permission, + household_id, + subject_member_id=subject_member_id, + assigned_to=assigned_to, + ) + ): + raise Forbidden("permission_denied") + return member + + +@dataclass(frozen=True, slots=True) +class MembershipAuthorization: + """Bind a server-selected command action/permission to its proven actor.""" + + access: HouseholdAccess + action: str + permission: Permission + subject_member_id: str | None = None + assigned_to: tuple[str, ...] = () + + def require(self, command: CommandKey, unit: UnitOfWork) -> None: + if command.actor_id != self.access.human.ha_user_id or command.action != self.action: + raise Forbidden("permission_denied") + self.access.require( + unit, + command.household_id, + self.permission, + subject_member_id=self.subject_member_id, + assigned_to=self.assigned_to, + ) diff --git a/backend/src/domus/application/commands.py b/backend/src/domus/application/commands.py new file mode 100644 index 0000000..70471d1 --- /dev/null +++ b/backend/src/domus/application/commands.py @@ -0,0 +1,71 @@ +"""One authorized durable command commits business facts and replay evidence together.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from domus.application.ports import ( + Clock, + CommandAuthorization, + Identifiers, + Transactions, + UnitOfWork, +) +from domus.domain.errors import Conflict +from domus.domain.storage import AuditEntry, CommandKey, JsonDocument, OutboxEvent, Receipt + + +@dataclass(frozen=True, slots=True) +class Change: + result: JsonDocument + audit_detail: JsonDocument + event_payload: JsonDocument + + +@dataclass +class AtomicCommands: + transactions: Transactions + authorization: CommandAuthorization + clock: Clock + identifiers: Identifiers + + def replay(self, command: CommandKey, payload: JsonDocument) -> Receipt | None: + """Authorize a read-only retry before preparing any external proof. + + A new effect still enters execute(), which checks current authority and + replay again in the writer transaction. No provider I/O holds its lock. + """ + with self.transactions.read() as unit: + self.authorization.require(command, unit) + previous = unit.receipts.get(command) + if previous is not None and previous.payload_fingerprint != payload.fingerprint: + raise Conflict("idempotency_conflict") + return previous + + def execute( + self, + command: CommandKey, + payload: JsonDocument, + apply: Callable[[UnitOfWork], Change], + ) -> Receipt: + with self.transactions.write() as unit: + self.authorization.require(command, unit) + previous = unit.receipts.get(command) + if previous is not None: + if previous.payload_fingerprint != payload.fingerprint: + raise Conflict("idempotency_conflict") + return previous + change = apply(unit) + operation_id, created_at = self.identifiers.new(), self.clock.now() + receipt = Receipt(command, payload.fingerprint, operation_id, change.result, created_at) + unit.receipts.add(receipt) + unit.audit.append(AuditEntry(operation_id, command, change.audit_detail, created_at)) + unit.outbox.append( + OutboxEvent( + operation_id, + command.household_id, + command.action, + change.event_payload, + created_at, + ) + ) + return receipt diff --git a/backend/src/domus/application/household_commands.py b/backend/src/domus/application/household_commands.py new file mode 100644 index 0000000..9178cf2 --- /dev/null +++ b/backend/src/domus/application/household_commands.py @@ -0,0 +1,253 @@ +"""Explicit household setup and own enrollment commit through the durable command boundary.""" + +from dataclasses import dataclass +from typing import cast + +from domus.application.authorization import HouseholdAccess, MembershipAuthorization +from domus.application.commands import AtomicCommands, Change +from domus.application.identity_ports import CurrentHuman, HumanDirectory +from domus.application.ports import Clock, Identifiers, TimeZones, Transactions, UnitOfWork +from domus.domain.errors import Conflict, Forbidden, InvalidInput +from domus.domain.identity import ( + AdministratorMapping, + HouseholdPolicy, + HumanIdentity, + MappingMode, + Member, + MemberStatus, + Permission, + Role, + validate_revision, +) +from domus.domain.storage import CommandKey, Household, JsonDocument, Receipt, validate_identifier + + +def _initial_revision(value: int) -> None: + if type(value) is not int or value != 0: + raise InvalidInput("invalid_revision") + + +@dataclass(frozen=True, slots=True) +class SetupHousehold: + name: str + time_zone: str + mapping_mode: MappingMode = MappingMode.ALL + selected_ha_user_id: str | None = None + expected_revision: int = 0 + + def __post_init__(self) -> None: + _initial_revision(self.expected_revision) + home = Household("home", self.name, self.time_zone, 1) + object.__setattr__(self, "name", home.name) + if not isinstance(self.mapping_mode, MappingMode) or ( + self.mapping_mode is MappingMode.SELECTED + ) != (self.selected_ha_user_id is not None): + raise InvalidInput("invalid_administrator_mapping") + if self.selected_ha_user_id is not None: + validate_identifier(self.selected_ha_user_id) + + def document(self) -> JsonDocument: + return JsonDocument.from_value( + { + "name": self.name, + "time_zone": self.time_zone, + "mapping_mode": self.mapping_mode.value, + "selected_ha_user_id": self.selected_ha_user_id, + "expected_revision": self.expected_revision, + } + ) + + +@dataclass(frozen=True, slots=True) +class _InitialAuthority: + human: HumanIdentity + action: str + + def require(self, command: CommandKey, unit: UnitOfWork) -> None: + if ( + self.action not in ("household.setup", "member.enroll") + or command.actor_id != self.human.ha_user_id + or command.action != self.action + ): + raise Forbidden("permission_denied") + home = unit.households.current() + if self.action == "household.setup" and home is None: + if not self.human.is_administrator: + raise Forbidden("permission_denied") + return + if home is None or home.household_id != command.household_id: + raise Forbidden("permission_denied") + if self.action == "household.setup": + HouseholdAccess(self.human).require( + unit, home.household_id, Permission.HOUSEHOLD_MANAGE + ) + return + member = unit.members.by_user(home.household_id, self.human.ha_user_id) + if unit.policies.get(home.household_id) is None or ( + member is not None and member.status not in (MemberStatus.PENDING, MemberStatus.ACTIVE) + ): + raise Forbidden("permission_denied") + + +@dataclass +class HouseholdCommands: + transactions: Transactions + actor: CurrentHuman + directory: HumanDirectory + time_zones: TimeZones + clock: Clock + identifiers: Identifiers + household_id: str = "home" + + def __post_init__(self) -> None: + validate_identifier(self.household_id) + + def _initial(self, human: HumanIdentity, action: str) -> AtomicCommands: + return AtomicCommands( + self.transactions, _InitialAuthority(human, action), self.clock, self.identifiers + ) + + def setup(self, key: str, request: SetupHousehold) -> Receipt: + human = self.actor.current() + command = CommandKey(self.household_id, human.ha_user_id, "household.setup", key) + payload = request.document() + commands = self._initial(human, command.action) + previous = commands.replay(command, payload) + if previous is not None: + return previous + self.time_zones.require(request.time_zone) + target = human + if request.mapping_mode is MappingMode.SELECTED: + user_id = cast(str, request.selected_ha_user_id) + target = human if user_id == human.ha_user_id else self.directory.get(user_id) + if target.ha_user_id != user_id: + raise Forbidden("identity_mismatch") + mapping = AdministratorMapping( + request.mapping_mode, + None if request.mapping_mode is MappingMode.ALL else target.ha_user_id, + ) + + def apply(unit: UnitOfWork) -> Change: + if unit.households.current() is not None: + raise Conflict("household_exists") + unit.households.save( + Household(self.household_id, request.name, request.time_zone, 1), 0 + ) + unit.policies.save(HouseholdPolicy(self.household_id, mapping, 1), 0) + member_id = self.identifiers.new() + unit.members.save( + Member( + member_id, + self.household_id, + human.ha_user_id, + Role.VIEWER, + MemberStatus.ACTIVE, + 1, + ), + 0, + ) + administrator_id = member_id + if target.ha_user_id != human.ha_user_id: + administrator_id = self.identifiers.new() + unit.members.save( + Member( + administrator_id, + self.household_id, + target.ha_user_id, + Role.VIEWER, + MemberStatus.ACTIVE, + 1, + ), + 0, + ) + result = JsonDocument.from_value( + { + "state": "configured", + "household_id": self.household_id, + "revision": 1, + "policy_revision": 1, + "member_id": member_id, + "administrator_member_id": administrator_id, + "mapping_mode": mapping.mode.value, + } + ) + detail = JsonDocument.from_value( + { + "transition": "household.configured", + "mapping_mode": mapping.mode.value, + "administrator_member_id": administrator_id, + } + ) + event = JsonDocument.from_value({"household_id": self.household_id, "revision": 1}) + return Change(result, detail, event) + + return commands.execute(command, payload, apply) + + def enroll(self, key: str, expected_revision: int = 0) -> Receipt: + _initial_revision(expected_revision) + human = self.actor.current() + command = CommandKey(self.household_id, human.ha_user_id, "member.enroll", key) + payload = JsonDocument.from_value({"expected_revision": expected_revision}) + + def apply(unit: UnitOfWork) -> Change: + if unit.members.by_user(self.household_id, human.ha_user_id) is not None: + raise Conflict("member_exists") + if len(unit.members.list(self.household_id)) >= 256: + raise Conflict("member_capacity") + policy = cast(HouseholdPolicy, unit.policies.get(self.household_id)) + status = ( + MemberStatus.ACTIVE + if policy.mapping.mode is MappingMode.ALL and human.is_administrator + else MemberStatus.PENDING + ) + member_id = self.identifiers.new() + member = Member(member_id, self.household_id, human.ha_user_id, Role.VIEWER, status, 1) + unit.members.save(member, 0) + result = JsonDocument.from_value( + { + "member_id": member_id, + "status": status.value, + "role": member.role.value, + "revision": 1, + "policy_revision": policy.revision, + } + ) + detail = JsonDocument.from_value( + {"transition": "member.enrolled", "member_id": member_id, "status": status.value} + ) + return Change(result, detail, detail) + + return self._initial(human, command.action).execute(command, payload, apply) + + def update(self, key: str, name: str, time_zone: str, expected_revision: int) -> Receipt: + validate_revision(expected_revision) + proposed = Household(self.household_id, name, time_zone, expected_revision + 1) + human = self.actor.current() + command = CommandKey(self.household_id, human.ha_user_id, "household.update", key) + payload = JsonDocument.from_value( + { + "name": proposed.name, + "time_zone": proposed.time_zone, + "expected_revision": expected_revision, + } + ) + authority = MembershipAuthorization( + HouseholdAccess(human), command.action, Permission.HOUSEHOLD_MANAGE + ) + commands = AtomicCommands(self.transactions, authority, self.clock, self.identifiers) + previous = commands.replay(command, payload) + if previous is not None: + return previous + self.time_zones.require(proposed.time_zone) + + def apply(unit: UnitOfWork) -> Change: + unit.households.save(proposed, expected_revision) + result = JsonDocument.from_value( + {"household_id": proposed.household_id, "revision": proposed.revision} + ) + detail = JsonDocument.from_value( + {"transition": "household.updated", "revision": proposed.revision} + ) + return Change(result, detail, result) + + return commands.execute(command, payload, apply) diff --git a/backend/src/domus/application/household_queries.py b/backend/src/domus/application/household_queries.py new file mode 100644 index 0000000..3b90bf6 --- /dev/null +++ b/backend/src/domus/application/household_queries.py @@ -0,0 +1,187 @@ +"""Local reads use a server-proven human and reread current policy before every disclosure.""" + +from dataclasses import dataclass +from typing import Literal, cast + +from domus.application.authorization import HouseholdAccess +from domus.application.ports import Transactions, UnitOfWork +from domus.domain.errors import Conflict, Forbidden, NotFound, StorageFailure +from domus.domain.identity import ( + HouseholdPolicy, + HumanIdentity, + Member, + MemberStatus, + Permission, + PermissionRule, + Role, + allows, + effective_role, +) +from domus.domain.storage import Household, Receipt, validate_identifier + +_ADMINISTRATION_SCOPE = ( + Permission.HOUSEHOLD_READ, + Permission.HOUSEHOLD_MANAGE, + Permission.MEMBERS_READ, + Permission.MEMBERS_MANAGE, + Permission.POLICY_MANAGE, + Permission.PREFERENCES_READ, + Permission.PREFERENCES_WRITE, + Permission.OPERATIONS_READ, +) +_ORIGINAL_PERMISSION = { + "household.setup": Permission.HOUSEHOLD_MANAGE, + "household.update": Permission.HOUSEHOLD_MANAGE, + "member.update": Permission.MEMBERS_MANAGE, + "policy.update": Permission.POLICY_MANAGE, + "recovery.complete": Permission.POLICY_MANAGE, +} + + +@dataclass(frozen=True, slots=True) +class MemberView: + member_id: str + role: Role + status: MemberStatus + revision: int + ha_user_id: str | None + exceptions: tuple[PermissionRule, ...] | None + + +type AccessState = Literal[ + "unconfigured", "not_enrolled", "pending", "active", "suspended", "archived" +] + + +@dataclass(frozen=True, slots=True) +class AccessView: + state: AccessState + setup_allowed: bool = False + enrollment_allowed: bool = False + member: MemberView | None = None + policy_revision: int | None = None + effective_role: Role | None = None + permission_scope: Literal["household_administration"] = "household_administration" + permissions: tuple[Permission, ...] = () + + +@dataclass(frozen=True, slots=True) +class OperationView: + receipt: Receipt + delivered: bool + + +@dataclass(frozen=True, slots=True) +class HouseholdQueries: + """The composition supplies human proof or a valid outage lease, never an HTTP actor ID.""" + + transactions: Transactions + human: HumanIdentity + household_id: str = "home" + + def __post_init__(self) -> None: + validate_identifier(self.household_id) + + def _view(self, member: Member, *, privileged: bool = False) -> MemberView: + private = privileged or member.ha_user_id == self.human.ha_user_id + return MemberView( + member.member_id, + member.role, + member.status, + member.revision, + member.ha_user_id if private else None, + member.exceptions if private else None, + ) + + def _require(self, unit: UnitOfWork, permission: Permission) -> Member: + return HouseholdAccess(self.human).require(unit, self.household_id, permission) + + def access(self) -> AccessView: + with self.transactions.read() as unit: + home = unit.households.current() + if home is None: + return AccessView("unconfigured", setup_allowed=self.human.is_administrator) + if home.household_id != self.household_id: + raise Forbidden("permission_denied") + policy = unit.policies.get(self.household_id) + if policy is None: + raise Conflict("household_configuration_required") + member = unit.members.by_user(self.household_id, self.human.ha_user_id) + if member is None: + return AccessView("not_enrolled", enrollment_allowed=True) + view = self._view(member) + if member.status is not MemberStatus.ACTIVE: + return AccessView(cast(AccessState, member.status.value), member=view) + permissions = tuple( + permission + for permission in _ADMINISTRATION_SCOPE + if allows( + self.human, + member, + policy, + permission, + self.household_id, + subject_member_id=member.member_id, + ) + ) + return AccessView( + "active", + member=view, + policy_revision=policy.revision, + effective_role=effective_role(self.human, member, policy), + permissions=permissions, + ) + + def household(self) -> Household: + with self.transactions.read() as unit: + self._require(unit, Permission.HOUSEHOLD_READ) + home = unit.households.get(self.household_id) + if home is None: + raise StorageFailure("invalid_identity_record") + return home + + def members(self) -> tuple[MemberView, ...]: + with self.transactions.read() as unit: + actor = self._require(unit, Permission.MEMBERS_READ) + policy = cast(HouseholdPolicy, unit.policies.get(self.household_id)) + privileged = allows( + self.human, actor, policy, Permission.MEMBERS_MANAGE, self.household_id + ) + members = unit.members.list(self.household_id) + if len(members) > 256: + raise StorageFailure("invalid_identity_record") + return tuple(self._view(member, privileged=privileged) for member in members) + + def policy(self) -> HouseholdPolicy: + with self.transactions.read() as unit: + self._require(unit, Permission.POLICY_MANAGE) + return cast(HouseholdPolicy, unit.policies.get(self.household_id)) + + def operation(self, operation_id: str) -> OperationView: + validate_identifier(operation_id) + with self.transactions.read() as unit: + member = unit.members.by_user(self.household_id, self.human.ha_user_id) + HouseholdAccess(self.human).require( + unit, + self.household_id, + Permission.OPERATIONS_READ, + subject_member_id=member.member_id if member is not None else None, + ) + receipt = unit.receipts.get_operation( + self.household_id, self.human.ha_user_id, operation_id + ) + if receipt is None: + raise NotFound("operation_missing") + if receipt.command.action not in ( + "member.enroll", + "recovery.request", + "recovery.cancel", + ): + original = _ORIGINAL_PERMISSION.get(receipt.command.action) + if original is None: + raise Forbidden("permission_denied") + self._require(unit, original) + delivered = unit.outbox.delivered(receipt.operation_id) + if delivered is None: + raise StorageFailure("invalid_operation_record") + return OperationView(receipt, delivered) diff --git a/backend/src/domus/application/identity_ports.py b/backend/src/domus/application/identity_ports.py new file mode 100644 index 0000000..a5f1cf6 --- /dev/null +++ b/backend/src/domus/application/identity_ports.py @@ -0,0 +1,73 @@ +"""Session and live-proof contracts contain neither HA transport DTOs nor credentials.""" + +from typing import Protocol + +from domus.application.authentication_values import LoginAttempt +from domus.application.session_values import SessionLease +from domus.domain.identity import HumanIdentity + + +class CurrentHuman(Protocol): + """Obtain fresh server-side human proof for a mutation; never a submitted DTO.""" + + def current(self) -> HumanIdentity: ... + + +class HumanDirectory(Protocol): + """Resolve one exact, currently eligible human through a configured authority.""" + + def get(self, ha_user_id: str) -> HumanIdentity: ... + + def eligible(self, ha_user_ids: tuple[str, ...]) -> tuple[HumanIdentity, ...]: + """Return only confirmed eligible requested humans from one bounded lookup. + + Omission means confirmed absence/ineligibility. Transport, authorization + or malformed-source failures must raise, never become partial success. + """ + ... + + +class LeaseClock(Protocol): + """Process-local monotonic milliseconds; never persisted across a restart.""" + + def now(self) -> int: ... + + +class LiveIdentity(Protocol): + """Reprove through private server credentials bound to the opaque session ID. + + Explicit rejection or malformed proof raises IdentityRejected. Only a true + bounded availability failure raises IdentityUnavailable, permitting read grace. + """ + + def prove(self, session_id: str) -> HumanIdentity: ... + + +class SessionAccess(Protocol): + def get(self, session_id: str) -> SessionLease | None: ... + def refresh(self, expected: SessionLease, human: HumanIdentity) -> bool: ... + def remove(self, session_id: str) -> None: ... + + +class SessionLeases(SessionAccess, Protocol): + def add(self, lease: SessionLease) -> None: ... + def discard_expired(self, now: int) -> int: ... + + +class LoginAttempts(Protocol): + def add(self, attempt: LoginAttempt, now: int) -> None: ... + def take(self, state_digest: str, browser_digest: str, now: int) -> LoginAttempt: ... + + +class LoginProvider(Protocol): + """Own provider credentials, exact endpoint and cleanup outside business storage.""" + + def authorization_url(self, state: str, challenge: str) -> str: ... + def sign_in( + self, code: str, verifier: str, session_id: str, issued_at: int, expires_at: int + ) -> SessionLease: ... + def sign_out(self, session_id: str) -> bool | None: ... + + +class LoginEntropy(Protocol): + def new(self) -> str: ... diff --git a/backend/src/domus/application/member_commands.py b/backend/src/domus/application/member_commands.py new file mode 100644 index 0000000..c468149 --- /dev/null +++ b/backend/src/domus/application/member_commands.py @@ -0,0 +1,180 @@ +"""Current administrator edits preserve eligible human linkage and a remaining administrator.""" + +from dataclasses import dataclass, replace +from typing import cast + +from domus.application.administrator_proof import exact_human, remaining_humans +from domus.application.authorization import HouseholdAccess, MembershipAuthorization +from domus.application.commands import AtomicCommands, Change +from domus.application.identity_ports import CurrentHuman, HumanDirectory +from domus.application.ports import Clock, Identifiers, Transactions, UnitOfWork +from domus.domain.errors import Conflict, StorageFailure +from domus.domain.identity import ( + HouseholdPolicy, + HumanIdentity, + Member, + MemberStatus, + Permission, + PermissionRule, + Role, + ensure_administrator, +) +from domus.domain.storage import CommandKey, JsonDocument, Receipt, validate_identifier + + +@dataclass(frozen=True, slots=True) +class UpdateMember: + member_id: str + expected_revision: int + role: Role + status: MemberStatus + exceptions: tuple[PermissionRule, ...] = () + + def __post_init__(self) -> None: + Member( + self.member_id, + "home", + "subject", + self.role, + self.status, + self.expected_revision, + self.exceptions, + ) + object.__setattr__( + self, + "exceptions", + tuple(sorted(self.exceptions, key=lambda rule: rule.permission.value)), + ) + + def document(self) -> JsonDocument: + return JsonDocument.from_value( + { + "member_id": self.member_id, + "expected_revision": self.expected_revision, + "role": self.role.value, + "status": self.status.value, + "exceptions": [ + {"permission": rule.permission.value, "allow": rule.allow} + for rule in self.exceptions + ], + } + ) + + +@dataclass(frozen=True, slots=True) +class _Snapshot: + policy: HouseholdPolicy + members: tuple[Member, ...] + + +@dataclass +class MemberCommands: + transactions: Transactions + actor: CurrentHuman + directory: HumanDirectory + clock: Clock + identifiers: Identifiers + household_id: str = "home" + + def __post_init__(self) -> None: + validate_identifier(self.household_id) + + def _commands(self, human: HumanIdentity) -> AtomicCommands: + return AtomicCommands( + self.transactions, + MembershipAuthorization( + HouseholdAccess(human), "member.update", Permission.MEMBERS_MANAGE + ), + self.clock, + self.identifiers, + ) + + def _snapshot(self, human: HumanIdentity) -> _Snapshot: + with self.transactions.read() as unit: + HouseholdAccess(human).require(unit, self.household_id, Permission.MEMBERS_MANAGE) + members = unit.members.list(self.household_id) + if len(members) > 256: + raise StorageFailure("invalid_identity_record") + return _Snapshot(cast(HouseholdPolicy, unit.policies.get(self.household_id)), members) + + def _proofs( + self, human: HumanIdentity, snapshot: _Snapshot, proposed: Member + ) -> tuple[HumanIdentity, ...]: + humans = {human.ha_user_id: human} + external = False + if proposed.status is MemberStatus.ACTIVE and proposed.ha_user_id != human.ha_user_id: + humans[proposed.ha_user_id] = exact_human( + self.directory.get(proposed.ha_user_id), proposed.ha_user_id + ) + external = True + members = tuple( + proposed if member.member_id == proposed.member_id else member + for member in snapshot.members + ) + resolved, batch = remaining_humans( + snapshot.policy, members, tuple(humans.values()), self.directory + ) + humans = {person.ha_user_id: person for person in resolved} + external = external or batch + if external: + humans[human.ha_user_id] = exact_human(self.actor.current(), human.ha_user_id) + return tuple(humans.values()) + + def update(self, key: str, request: UpdateMember) -> Receipt: + human = self.actor.current() + command = CommandKey(self.household_id, human.ha_user_id, "member.update", key) + payload = request.document() + previous = self._commands(human).replay(command, payload) + if previous is not None: + return previous + snapshot = self._snapshot(human) + current = next( + (member for member in snapshot.members if member.member_id == request.member_id), None + ) + if current is None: + raise Conflict("member_missing") + if current.revision != request.expected_revision: + raise Conflict("revision_conflict") + proposed = replace( + current, + role=request.role, + status=request.status, + exceptions=request.exceptions, + revision=current.revision + 1, + ) + humans = self._proofs(human, snapshot, proposed) + actor = next(person for person in humans if person.ha_user_id == human.ha_user_id) + + def apply(unit: UnitOfWork) -> Change: + if ( + unit.policies.get(self.household_id) != snapshot.policy + or unit.members.list(self.household_id) != snapshot.members + ): + raise Conflict("identity_state_changed") + members = tuple( + proposed if member.member_id == proposed.member_id else member + for member in snapshot.members + ) + ensure_administrator(snapshot.policy, members, humans) + unit.members.save(proposed, request.expected_revision) + result = JsonDocument.from_value( + { + "member_id": proposed.member_id, + "revision": proposed.revision, + "status": proposed.status.value, + "role": proposed.role.value, + } + ) + detail = JsonDocument.from_value( + { + "transition": "member.updated", + "previous_revision": current.revision, + "previous_role": current.role.value, + "previous_status": current.status.value, + "member": result.decode(), + "exceptions": request.document().decode()["exceptions"], + } + ) + return Change(result, detail, result) + + return self._commands(actor).execute(command, payload, apply) diff --git a/backend/src/domus/application/policy_commands.py b/backend/src/domus/application/policy_commands.py new file mode 100644 index 0000000..2b5dcfc --- /dev/null +++ b/backend/src/domus/application/policy_commands.py @@ -0,0 +1,152 @@ +"""Explicit policy changes preserve administrator provenance and current human authority.""" + +from dataclasses import dataclass +from typing import cast + +from domus.application.administrator_proof import exact_human, remaining_humans +from domus.application.authorization import HouseholdAccess, MembershipAuthorization +from domus.application.commands import AtomicCommands, Change +from domus.application.identity_ports import CurrentHuman, HumanDirectory +from domus.application.ports import Clock, Identifiers, Transactions, UnitOfWork +from domus.domain.errors import Conflict, Forbidden, InvalidInput, StorageFailure +from domus.domain.identity import ( + AdministratorMapping, + HouseholdPolicy, + HumanIdentity, + MappingMode, + MemberStatus, + Permission, + ProfileRule, + ensure_administrator, +) +from domus.domain.storage import CommandKey, JsonDocument, Receipt, validate_identifier + + +@dataclass(frozen=True, slots=True) +class UpdatePolicy: + expected_revision: int + mapping: AdministratorMapping + adjustments: tuple[ProfileRule, ...] = () + + def __post_init__(self) -> None: + HouseholdPolicy("home", self.mapping, self.expected_revision, self.adjustments) + object.__setattr__( + self, + "adjustments", + tuple( + sorted(self.adjustments, key=lambda rule: (rule.role.value, rule.permission.value)) + ), + ) + + def document(self) -> JsonDocument: + return JsonDocument.from_value( + { + "expected_revision": self.expected_revision, + "mapping_mode": self.mapping.mode.value, + "target_ha_user_id": self.mapping.target_ha_user_id, + "adjustments": [ + { + "role": rule.role.value, + "permission": rule.permission.value, + "allow": rule.allow, + } + for rule in self.adjustments + ], + } + ) + + +@dataclass +class PolicyCommands: + transactions: Transactions + actor: CurrentHuman + directory: HumanDirectory + clock: Clock + identifiers: Identifiers + household_id: str = "home" + + def __post_init__(self) -> None: + validate_identifier(self.household_id) + + def _commands(self, human: HumanIdentity) -> AtomicCommands: + return AtomicCommands( + self.transactions, + MembershipAuthorization( + HouseholdAccess(human), "policy.update", Permission.POLICY_MANAGE + ), + self.clock, + self.identifiers, + ) + + def update(self, key: str, request: UpdatePolicy) -> Receipt: + human = self.actor.current() + command = CommandKey(self.household_id, human.ha_user_id, "policy.update", key) + payload = request.document() + previous = self._commands(human).replay(command, payload) + if previous is not None: + return previous + with self.transactions.read() as unit: + HouseholdAccess(human).require(unit, self.household_id, Permission.POLICY_MANAGE) + current = cast(HouseholdPolicy, unit.policies.get(self.household_id)) + if current.revision != request.expected_revision: + raise Conflict("revision_conflict") + members = unit.members.list(self.household_id) + if len(members) > 256: + raise StorageFailure("invalid_identity_record") + if request.mapping.mode is MappingMode.SETUP: + founder = unit.audit.setup_actor(self.household_id) + if founder is None: + raise Conflict("setup_provenance_required") + if founder != request.mapping.target_ha_user_id: + raise InvalidInput("setup_administrator_mismatch") + proposed = HouseholdPolicy( + self.household_id, request.mapping, current.revision + 1, request.adjustments + ) + humans = {human.ha_user_id: human} + external = False + target = request.mapping.target_ha_user_id + if target is not None: + member = next((person for person in members if person.ha_user_id == target), None) + if member is None or member.status is not MemberStatus.ACTIVE: + raise Conflict("mapping_target_inactive") + target_human = human + if target != human.ha_user_id: + target_human = exact_human(self.directory.get(target), target) + external = True + if request.mapping.mode is MappingMode.SETUP and not target_human.is_administrator: + raise Forbidden("ha_administrator_required") + humans[target] = target_human + resolved, batch = remaining_humans( + proposed, members, tuple(humans.values()), self.directory + ) + humans = {person.ha_user_id: person for person in resolved} + if external or batch: + humans[human.ha_user_id] = exact_human(self.actor.current(), human.ha_user_id) + actor = humans[human.ha_user_id] + + def apply(unit: UnitOfWork) -> Change: + if ( + unit.policies.get(self.household_id) != current + or unit.members.list(self.household_id) != members + ): + raise Conflict("identity_state_changed") + ensure_administrator(proposed, members, tuple(humans.values())) + unit.policies.save(proposed, request.expected_revision) + result = JsonDocument.from_value( + { + "household_id": self.household_id, + "revision": proposed.revision, + "mapping_mode": proposed.mapping.mode.value, + } + ) + detail = JsonDocument.from_value( + { + "transition": "policy.updated", + "previous_revision": current.revision, + "previous_mapping_mode": current.mapping.mode.value, + "policy": request.document().decode(), + } + ) + return Change(result, detail, result) + + return self._commands(actor).execute(command, payload, apply) diff --git a/backend/src/domus/application/ports.py b/backend/src/domus/application/ports.py new file mode 100644 index 0000000..7cf6cc6 --- /dev/null +++ b/backend/src/domus/application/ports.py @@ -0,0 +1,100 @@ +"""Storage contracts name household behavior rather than SQL or HTTP mechanisms.""" + +from contextlib import AbstractContextManager +from typing import Protocol + +from domus.domain.identity import HouseholdPolicy, Member +from domus.domain.recovery import RecoveryRequest +from domus.domain.storage import AuditEntry, CommandKey, Household, OutboxEvent, Receipt + + +class Households(Protocol): + def get(self, household_id: str) -> Household | None: ... + def current(self) -> Household | None: ... + def save(self, household: Household, expected_revision: int) -> None: ... + + +class Receipts(Protocol): + def get(self, command: CommandKey) -> Receipt | None: ... + def get_operation( + self, household_id: str, actor_id: str, operation_id: str + ) -> Receipt | None: ... + def add(self, receipt: Receipt) -> None: ... + + +class Members(Protocol): + def get(self, household_id: str, member_id: str) -> Member | None: ... + def by_user(self, household_id: str, ha_user_id: str) -> Member | None: ... + def list(self, household_id: str) -> tuple[Member, ...]: ... + def save(self, member: Member, expected_revision: int) -> None: ... + + +class Policies(Protocol): + def get(self, household_id: str) -> HouseholdPolicy | None: ... + def save(self, policy: HouseholdPolicy, expected_revision: int) -> None: ... + + +class Recoveries(Protocol): + def closed_before_restoration(self, household_id: str, request_id: str) -> bool: ... + def get(self, household_id: str, request_id: str) -> RecoveryRequest | None: ... + def pending( + self, household_id: str, now: int, *, limit: int = 257 + ) -> tuple[RecoveryRequest, ...]: ... + def save(self, request: RecoveryRequest, expected_revision: int) -> None: ... + + +class Audit(Protocol): + def append(self, entry: AuditEntry) -> None: ... + def entries(self, household_id: str) -> tuple[AuditEntry, ...]: ... + def setup_actor(self, household_id: str) -> str | None: + """The unique committed setup actor; missing is not permission to retarget.""" + ... + + +class Outbox(Protocol): + def append(self, event: OutboxEvent) -> None: ... + def pending(self, limit: int = 50) -> tuple[OutboxEvent, ...]: ... + def acknowledge(self, event_id: str) -> bool: ... + def delivered(self, event_id: str) -> bool | None: ... + + +class UnitOfWork(Protocol): + @property + def households(self) -> Households: ... + @property + def receipts(self) -> Receipts: ... + @property + def audit(self) -> Audit: ... + @property + def outbox(self) -> Outbox: ... + @property + def members(self) -> Members: ... + @property + def policies(self) -> Policies: ... + @property + def recoveries(self) -> Recoveries: ... + + +class Transactions(Protocol): + def read(self) -> AbstractContextManager[UnitOfWork]: ... + def write(self) -> AbstractContextManager[UnitOfWork]: ... + + +class CommandAuthorization(Protocol): + def require(self, command: CommandKey, unit: UnitOfWork) -> None: ... + + +class Clock(Protocol): + """UTC Unix milliseconds; domain transitions never read a global clock.""" + + def now(self) -> int: ... + + +class Identifiers(Protocol): + def new(self) -> str: ... + + +class TimeZones(Protocol): + """Validate an exact configured zone against the installation's timezone catalog.""" + + def require(self, time_zone: str) -> None: ... diff --git a/backend/src/domus/application/recovery_commands.py b/backend/src/domus/application/recovery_commands.py new file mode 100644 index 0000000..2a63fea --- /dev/null +++ b/backend/src/domus/application/recovery_commands.py @@ -0,0 +1,383 @@ +"""Two-authority recovery keeps terminal approval separate from human HTTP commands.""" + +from dataclasses import dataclass, replace +from typing import Protocol, cast + +from domus.application.authorization import HouseholdAccess +from domus.application.commands import AtomicCommands, Change +from domus.application.identity_ports import CurrentHuman +from domus.application.ports import Clock, Identifiers, Transactions, UnitOfWork +from domus.domain.errors import Conflict, Forbidden, InvalidInput, NotFound +from domus.domain.identity import ( + HouseholdPolicy, + HumanIdentity, + Member, + MemberStatus, + Permission, + Role, + recovery_allowed, + validate_revision, +) +from domus.domain.recovery import RECOVERY_LIFETIME, RecoveryRequest, RecoveryState +from domus.domain.storage import ( + CommandKey, + Installation, + JsonDocument, + Receipt, + validate_identifier, +) + + +class RecoveryConfirmation(Protocol): + """Only the installation-console composition supplies this port, never an HTTP DTO.""" + + def confirm(self, request: RecoveryRequest, member: Member | None) -> bool: ... + + +def _policy(unit: UnitOfWork, household_id: str) -> HouseholdPolicy: + home = unit.households.current() + policy = unit.policies.get(household_id) + if home is None or home.household_id != household_id or policy is None: + raise Forbidden("recovery_unavailable") + return policy + + +def _request(unit: UnitOfWork, household_id: str, request_id: str) -> RecoveryRequest: + validate_identifier(request_id) + value = unit.recoveries.get(household_id, request_id) + if value is None: + raise NotFound("recovery_missing") + return value + + +def _own( + unit: UnitOfWork, + household_id: str, + request_id: str, + human: HumanIdentity, + installation: Installation, +) -> RecoveryRequest: + value = _request(unit, household_id, request_id) + if value.target_ha_user_id != human.ha_user_id: + raise NotFound("recovery_missing") + if value.installation != installation: + raise Conflict("recovery_binding_changed") + return value + + +def _require_open(unit: UnitOfWork, household_id: str, request_id: str) -> None: + if unit.recoveries.closed_before_restoration(household_id, request_id): + raise Conflict("recovery_closed") + + +def _view(value: RecoveryRequest, now: int) -> JsonDocument: + state = value.visible_state(now) + return JsonDocument.from_value( + { + "request_id": value.request_id, + "state": state, + "revision": value.revision, + "target_ha_user_id": value.target_ha_user_id, + "mapping_mode": value.mapping.mode.value, + "policy_revision": value.policy_revision, + "member_id": value.member_id, + "member_revision": value.member_revision, + "created_at": value.created_at, + "expires_at": value.expires_at, + "approved_at": value.approved_at, + "closed_at": value.closed_at, + } + ) + + +@dataclass(frozen=True, slots=True) +class RecoveryQueries: + transactions: Transactions + human: HumanIdentity + installation: Installation + clock: Clock + household_id: str = "home" + + def get(self, request_id: str) -> JsonDocument: + with self.transactions.read() as unit: + value = _own(unit, self.household_id, request_id, self.human, self.installation) + _require_open(unit, self.household_id, request_id) + return _view(value, self.clock.now()) + + +@dataclass(frozen=True, slots=True) +class _HumanAuthority: + human: HumanIdentity + installation: Installation + action: str + request_id: str | None = None + + def require(self, command: CommandKey, unit: UnitOfWork) -> None: + if command.actor_id != self.human.ha_user_id or command.action != self.action: + raise Forbidden("permission_denied") + policy = _policy(unit, command.household_id) + if self.action in ("recovery.complete", "recovery.cancel"): + value = _own( + unit, + command.household_id, + cast(str, self.request_id), + self.human, + self.installation, + ) + if self.action == "recovery.cancel": + return + if value.state is RecoveryState.CONSUMED: + HouseholdAccess(self.human).require( + unit, command.household_id, Permission.POLICY_MANAGE + ) + return + elif self.action != "recovery.request": + raise Forbidden("permission_denied") + if not recovery_allowed(self.human, policy): + raise Forbidden("recovery_unavailable") + + +@dataclass +class RecoveryCommands: + transactions: Transactions + actor: CurrentHuman + installation: Installation + clock: Clock + identifiers: Identifiers + household_id: str = "home" + + def _commands( + self, human: HumanIdentity, action: str, request_id: str | None = None + ) -> AtomicCommands: + return AtomicCommands( + self.transactions, + _HumanAuthority(human, self.installation, action, request_id), + self.clock, + self.identifiers, + ) + + def start(self, key: str, expected_revision: int = 0) -> Receipt: + if type(expected_revision) is not int or expected_revision != 0: + raise InvalidInput("invalid_revision") + human = self.actor.current() + command = CommandKey(self.household_id, human.ha_user_id, "recovery.request", key) + payload = JsonDocument.from_value({"expected_revision": expected_revision}) + + def apply(unit: UnitOfWork) -> Change: + now, policy = self.clock.now(), _policy(unit, self.household_id) + pending = unit.recoveries.pending(self.household_id, now) + if len(pending) >= 256: + raise Conflict("recovery_capacity") + if any(value.target_ha_user_id == human.ha_user_id for value in pending): + raise Conflict("recovery_pending") + member = unit.members.by_user(self.household_id, human.ha_user_id) + value = RecoveryRequest( + self.identifiers.new(), + self.household_id, + human.ha_user_id, + self.installation, + policy.mapping, + policy.revision, + member.member_id if member is not None else None, + member.revision if member is not None else 0, + now, + now + RECOVERY_LIFETIME, + ) + unit.recoveries.save(value, 0) + result = _view(value, now) + detail = JsonDocument.from_value( + {"transition": "recovery.requested", "request_id": value.request_id} + ) + return Change(result, detail, detail) + + return self._commands(human, command.action).execute(command, payload, apply) + + def status(self, request_id: str) -> JsonDocument: + return RecoveryQueries( + self.transactions, + self.actor.current(), + self.installation, + self.clock, + self.household_id, + ).get(request_id) + + def complete(self, key: str, request_id: str, expected_revision: int) -> Receipt: + validate_revision(expected_revision) + validate_identifier(request_id) + human = self.actor.current() + command = CommandKey(self.household_id, human.ha_user_id, "recovery.complete", key) + payload = JsonDocument.from_value( + {"request_id": request_id, "expected_revision": expected_revision} + ) + + def apply(unit: UnitOfWork) -> Change: + value = _own(unit, self.household_id, request_id, human, self.installation) + _require_open(unit, self.household_id, request_id) + if value.revision != expected_revision: + raise Conflict("revision_conflict") + current = unit.members.by_user(self.household_id, human.ha_user_id) + value.require_current(self.installation, _policy(unit, self.household_id), current) + consumed = value.consume(self.clock.now()) + if current is None: + if len(unit.members.list(self.household_id)) >= 256: + raise Conflict("member_capacity") + member = Member( + self.identifiers.new(), + self.household_id, + human.ha_user_id, + Role.ADMINISTRATOR, + MemberStatus.ACTIVE, + 1, + ) + else: + member = replace( + current, + role=Role.ADMINISTRATOR, + status=MemberStatus.ACTIVE, + revision=current.revision + 1, + ) + unit.members.save(member, 0 if current is None else current.revision) + unit.recoveries.save(consumed, expected_revision) + result = JsonDocument.from_value( + { + "state": "recovered", + "request_id": request_id, + "request_revision": consumed.revision, + "member_id": member.member_id, + "member_revision": member.revision, + "role": member.role.value, + "policy_revision": value.policy_revision, + } + ) + detail = JsonDocument.from_value( + { + "transition": "administrator.recovered", + "grant": result.decode(), + "previous_role": current.role.value if current is not None else None, + "previous_status": current.status.value if current is not None else None, + } + ) + return Change(result, detail, result) + + return self._commands(human, command.action, request_id).execute(command, payload, apply) + + def cancel(self, key: str, request_id: str, expected_revision: int) -> Receipt: + validate_revision(expected_revision) + validate_identifier(request_id) + human = self.actor.current() + command = CommandKey(self.household_id, human.ha_user_id, "recovery.cancel", key) + payload = JsonDocument.from_value( + {"request_id": request_id, "expected_revision": expected_revision} + ) + + def apply(unit: UnitOfWork) -> Change: + current = _own(unit, self.household_id, request_id, human, self.installation) + _require_open(unit, self.household_id, request_id) + if current.revision != expected_revision: + raise Conflict("revision_conflict") + value = current.cancel(self.clock.now()) + unit.recoveries.save(value, expected_revision) + result = _view(value, self.clock.now()) + return Change( + result, + result, + JsonDocument.from_value({"request_id": request_id, "state": "canceled"}), + ) + + return self._commands(human, command.action, request_id).execute(command, payload, apply) + + +def _console_request( + command: CommandKey, unit: UnitOfWork, installation: Installation, request_id: str +) -> RecoveryRequest: + if ( + command.actor_id != "installation-console" + or command.action != "recovery.approve" + or command.key != request_id + ): + raise Forbidden("permission_denied") + value = _request(unit, command.household_id, request_id) + _require_open(unit, command.household_id, request_id) + if value.installation != installation: + raise Conflict("recovery_binding_changed") + return value + + +@dataclass(frozen=True, slots=True) +class _ConsoleReceiptAuthority: + installation: Installation + request_id: str + + def require(self, command: CommandKey, unit: UnitOfWork) -> None: + # Only read-only receipt lookup uses this authority; no effect executor receives it. + _console_request(command, unit, self.installation, self.request_id) + + +@dataclass(frozen=True, slots=True) +class _ConsoleAuthority: + installation: Installation + clock: Clock + request_id: str + + def require(self, command: CommandKey, unit: UnitOfWork) -> None: + value = _console_request(command, unit, self.installation, self.request_id) + value.require_current( + self.installation, + _policy(unit, command.household_id), + unit.members.by_user(command.household_id, value.target_ha_user_id), + ) + if value.visible_state(self.clock.now()) == "expired": + raise Conflict("recovery_expired") + if value.state not in (RecoveryState.PENDING, RecoveryState.APPROVED): + raise Conflict("recovery_closed") + + +@dataclass +class RecoveryConsole: + transactions: Transactions + installation: Installation + clock: Clock + identifiers: Identifiers + confirmation: RecoveryConfirmation + household_id: str = "home" + + def approve(self, request_id: str) -> Receipt: + command = CommandKey( + self.household_id, "installation-console", "recovery.approve", request_id + ) + authority = _ConsoleAuthority(self.installation, self.clock, request_id) + commands = AtomicCommands(self.transactions, authority, self.clock, self.identifiers) + payload = JsonDocument.from_value({"request_id": request_id, "expected_revision": 1}) + previous = AtomicCommands( + self.transactions, + _ConsoleReceiptAuthority(self.installation, request_id), + self.clock, + self.identifiers, + ).replay(command, payload) + if previous is not None: + return previous + with self.transactions.read() as unit: + authority.require(command, unit) + snapshot = _request(unit, self.household_id, request_id) + member = unit.members.by_user(self.household_id, snapshot.target_ha_user_id) + if self.confirmation.confirm(snapshot, member) is not True: + raise Forbidden("console_confirmation_required") + + def apply(unit: UnitOfWork) -> Change: + value = snapshot.approve(self.clock.now()) + unit.recoveries.save(value, snapshot.revision) + result = _view(value, self.clock.now()) + detail = JsonDocument.from_value( + { + "transition": "recovery.console_approved", + "authority": "installation_console", + "binding": value.document().decode(), + } + ) + return Change( + result, + detail, + JsonDocument.from_value({"request_id": request_id, "state": "approved"}), + ) + + return commands.execute(command, payload, apply) diff --git a/backend/src/domus/application/restoration.py b/backend/src/domus/application/restoration.py new file mode 100644 index 0000000..1975024 --- /dev/null +++ b/backend/src/domus/application/restoration.py @@ -0,0 +1,42 @@ +"""A preparation result must match its requested checkpoint and installation.""" + +from dataclasses import dataclass +from typing import Protocol + +from domus.application.ports import Identifiers +from domus.domain.errors import InvalidInput, StorageFailure +from domus.domain.restoration import PreparedRestoration +from domus.domain.snapshots import Snapshot +from domus.domain.storage import Installation + + +class RestorationArchive(Protocol): + def prepare(self, restore_id: str) -> PreparedRestoration: ... + def inspect(self) -> PreparedRestoration: ... + + +@dataclass +class RestorationCommands: + archive: RestorationArchive + installation: Installation + identifiers: Identifiers + checkpoint: Snapshot | None = None + + def _checked(self, result: PreparedRestoration) -> PreparedRestoration: + if result.installation != self.installation or ( + self.checkpoint is not None and result.checkpoint != self.checkpoint + ): + raise StorageFailure("invalid_restore_result") + return result + + def prepare(self) -> PreparedRestoration: + if self.checkpoint is None or self.checkpoint.installation != self.installation: + raise InvalidInput("restore_checkpoint_required") + restore_id = self.identifiers.new() + result = self._checked(self.archive.prepare(restore_id)) + if result.restore_id != restore_id: + raise StorageFailure("invalid_restore_result") + return result + + def inspect(self) -> PreparedRestoration: + return self._checked(self.archive.inspect()) diff --git a/backend/src/domus/application/session_values.py b/backend/src/domus/application/session_values.py new file mode 100644 index 0000000..f14ac15 --- /dev/null +++ b/backend/src/domus/application/session_values.py @@ -0,0 +1,34 @@ +"""Private application session metadata uses monotonic deadlines, never browser claims.""" + +from dataclasses import dataclass, field + +from domus.domain.errors import InvalidInput +from domus.domain.identity import HumanIdentity +from domus.domain.storage import validate_identifier + + +def validate_session_lifetime(lifetime: int) -> None: + if type(lifetime) is not int or not 300_000 <= lifetime <= 28_800_000: + raise InvalidInput("invalid_session_lifetime") + + +@dataclass(frozen=True, slots=True) +class SessionLease: + session_id: str = field(repr=False) + human: HumanIdentity + started_at: int + expires_at: int + + def __post_init__(self) -> None: + validate_identifier(self.session_id) + if ( + not isinstance(self.human, HumanIdentity) + or type(self.started_at) is not int + or type(self.expires_at) is not int + or not 0 <= self.started_at < self.expires_at <= 2**63 - 1 + or self.expires_at - self.started_at > 28_800_000 + ): + raise InvalidInput("invalid_session_lease") + + def valid_at(self, now: int) -> bool: + return self.started_at <= now < self.expires_at diff --git a/backend/src/domus/application/sessions.py b/backend/src/domus/application/sessions.py new file mode 100644 index 0000000..eebf7f1 --- /dev/null +++ b/backend/src/domus/application/sessions.py @@ -0,0 +1,60 @@ +"""Fresh proof for writes; bounded read grace only for an original live lease.""" + +from dataclasses import dataclass + +from domus.application.identity_ports import LeaseClock, LiveIdentity, SessionAccess +from domus.application.session_values import SessionLease +from domus.domain.errors import IdentityRejected, IdentityUnavailable, Unauthenticated +from domus.domain.identity import HumanIdentity + + +@dataclass(frozen=True, slots=True) +class SessionStatus: + human: HumanIdentity + fresh: bool + expires_at: int + + +@dataclass +class SessionGuard: + sessions: SessionAccess + identity: LiveIdentity + clock: LeaseClock + + def _require_time(self, lease: SessionLease) -> None: + if not lease.valid_at(self.clock.now()): + self.sessions.remove(lease.session_id) + raise Unauthenticated("session_expired") + + def _prove(self, session_id: str, *, write: bool) -> SessionStatus: + lease = self.sessions.get(session_id) + if lease is None: + raise Unauthenticated("session_required") + self._require_time(lease) + fresh = True + try: + human = self.identity.prove(session_id) + except IdentityUnavailable: + if write: + raise + human = lease.human + fresh = False + except Unauthenticated: + self.sessions.remove(session_id) + raise + self._require_time(lease) + if human.ha_user_id != lease.human.ha_user_id: + self.sessions.remove(session_id) + raise IdentityRejected("identity_mismatch") + if not self.sessions.refresh(lease, human): + raise Unauthenticated("session_changed") + return SessionStatus(human, fresh, lease.expires_at) + + def read(self, session_id: str) -> HumanIdentity: + return self._prove(session_id, write=False).human + + def write(self, session_id: str) -> HumanIdentity: + return self._prove(session_id, write=True).human + + def status(self, session_id: str) -> SessionStatus: + return self._prove(session_id, write=False) diff --git a/backend/src/domus/application/snapshots.py b/backend/src/domus/application/snapshots.py new file mode 100644 index 0000000..226c051 --- /dev/null +++ b/backend/src/domus/application/snapshots.py @@ -0,0 +1,34 @@ +"""Snapshot orchestration consumes semantic archive ports rather than filesystem paths.""" + +from dataclasses import dataclass +from typing import Protocol + +from domus.application.ports import Identifiers +from domus.domain.errors import StorageFailure +from domus.domain.snapshots import Snapshot +from domus.domain.storage import Installation + + +class SnapshotArchive(Protocol): + def create(self, snapshot_id: str) -> Snapshot: ... + def inspect(self) -> Snapshot: ... + + +@dataclass +class SnapshotCommands: + archive: SnapshotArchive + installation: Installation + identifiers: Identifiers + + def create(self) -> Snapshot: + snapshot_id = self.identifiers.new() + result = self.archive.create(snapshot_id) + if result.snapshot_id != snapshot_id or result.installation != self.installation: + raise StorageFailure("invalid_snapshot_result") + return result + + def inspect(self) -> Snapshot: + result = self.archive.inspect() + if result.installation != self.installation: + raise StorageFailure("invalid_snapshot_result") + return result diff --git a/backend/src/domus/composition/__init__.py b/backend/src/domus/composition/__init__.py new file mode 100644 index 0000000..25d4902 --- /dev/null +++ b/backend/src/domus/composition/__init__.py @@ -0,0 +1 @@ +"""Deployment wiring belongs here; no product policy is evaluated by composition.""" diff --git a/backend/src/domus/composition/admin.py b/backend/src/domus/composition/admin.py new file mode 100644 index 0000000..b6238a2 --- /dev/null +++ b/backend/src/domus/composition/admin.py @@ -0,0 +1,166 @@ +"""Installation-console composition can approve recovery; it cannot complete human proof.""" + +import argparse +import json +import sys +from pathlib import Path +from typing import TextIO + +from domus.application.recovery_commands import RecoveryConsole +from domus.application.snapshots import SnapshotCommands +from domus.composition.restore import execute as restore +from domus.domain.errors import DomusError, InvalidInput +from domus.domain.storage import Installation +from domus.entrypoints.console_recovery import TerminalRecoveryConfirmation +from domus.infrastructure.application_runtime import SecureIdentifiers, SystemClock +from domus.infrastructure.owned_storage import OwnedStorage, attach_database, attach_storage +from domus.infrastructure.sqlite import SQLiteDatabase +from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + + +def _snapshot( + args: argparse.Namespace, + database: SQLiteDatabase | None, + installation: Installation, + outgoing: TextIO, +) -> int: + archive = SQLiteSnapshots( + database, args.location, installation, SystemClock(), timeout=args.timeout + ) + commands = SnapshotCommands(archive, installation, SecureIdentifiers()) + result = commands.create() if args.operation == "create" else commands.inspect() + outgoing.write( + json.dumps( + { + "state": "created" if args.operation == "create" else "verified", + "snapshot": result.document().decode(), + "message": "The private SQLite snapshot and its checkpoint metadata are verified.", + "action_required": ( + "Keep the bundle private. Inspect it again before preparing a restore." + ), + }, + ensure_ascii=True, + ) + + "\n" + ) + outgoing.flush() + return 0 + + +def main( + argv: list[str] | None = None, + *, + input_stream: TextIO | None = None, + output_stream: TextIO | None = None, + error_stream: TextIO | None = None, +) -> int: + parser = argparse.ArgumentParser( + description="Manage the exact Domus installation from its console." + ) + parser.add_argument("--channel", choices=("stable", "edge"), required=True) + source = parser.add_mutually_exclusive_group() + source.add_argument("--data-directory", type=Path) + source.add_argument("--database", type=Path) + parser.add_argument("--instance-id") + subcommands = parser.add_subparsers(dest="command", required=True) + recovery = subcommands.add_parser("recovery") + approval = recovery.add_subparsers(dest="operation", required=True).add_parser("approve") + approval.add_argument("request_id") + snapshots = subcommands.add_parser("snapshot") + snapshots.add_argument("--timeout", type=float, default=30) + operations = snapshots.add_subparsers(dest="operation", required=True) + for operation in ("create", "inspect"): + operations.add_parser(operation).add_argument("location", type=Path) + restoration = subcommands.add_parser("restore") + restoration.add_argument("--timeout", type=float, default=30) + restore_operations = restoration.add_subparsers(dest="operation", required=True) + prepare = restore_operations.add_parser("prepare") + prepare.add_argument("snapshot", type=Path) + prepare.add_argument("location", type=Path) + for operation in ("inspect", "activate"): + restore_operations.add_parser(operation).add_argument("location", type=Path) + for operation in ("resume", "status"): + restore_operations.add_parser(operation).add_argument("restore_id") + args = parser.parse_args(argv) + incoming = sys.stdin if input_stream is None else input_stream + outgoing = sys.stdout if output_stream is None else output_stream + errors = sys.stderr if error_stream is None else error_stream + owned: OwnedStorage | None = None + try: + if args.command == "restore": + return restore(args, outgoing, SystemClock()) + if args.command == "snapshot" and args.operation == "inspect": + if ( + args.data_directory is not None + or args.database is not None + or args.instance_id is None + ): + raise InvalidInput("invalid_persistent_configuration") + return _snapshot(args, None, Installation(args.instance_id, args.channel), outgoing) + if args.data_directory is not None: + if args.instance_id is not None: + raise InvalidInput("invalid_persistent_configuration") + owned = attach_storage(args.data_directory, args.channel) + database, installation = owned.database, owned.installation + else: + if args.database is None or args.instance_id is None: + raise InvalidInput("invalid_persistent_configuration") + installation = Installation(args.instance_id, args.channel) + database = attach_database(args.database, installation) + if args.command == "snapshot": + return _snapshot(args, database, installation, outgoing) + commands = RecoveryConsole( + database, + installation, + SystemClock(), + SecureIdentifiers(), + TerminalRecoveryConfirmation(incoming, outgoing), + ) + receipt = commands.approve(args.request_id) + outgoing.write( + json.dumps( + { + "state": "historical" if receipt.historical else "committed", + "archived_by": receipt.archived_by, + "operation_id": receipt.operation_id, + "result": receipt.result.decode(), + "message": ( + "This approval is historical and has no effect in current state." + if receipt.historical + else "Console approval was saved by this operation. Check the request's " + "current status before attempting completion, which still requires " + "fresh HA administrator proof." + ), + }, + ensure_ascii=True, + ) + + "\n" + ) + outgoing.flush() + return 0 + except DomusError: + errors.write( + "Domus could not complete or confirm the console operation. Inspect the same recovery " + "request, snapshot candidate or activation before retrying. " + "Existing records are retained.\n" + ) + return 2 + except OSError: + errors.write( + "Domus could not confirm the command result. Inspect the same request or candidate; " + "do not replace existing data. Existing records are retained.\n" + ) + return 1 + except KeyboardInterrupt: + errors.write( + "Console operation was interrupted. " + "Inspect the same request or candidate before retrying.\n" + ) + return 130 + finally: + if owned is not None: + owned.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/src/domus/composition/ha_app.py b/backend/src/domus/composition/ha_app.py new file mode 100644 index 0000000..1dee619 --- /dev/null +++ b/backend/src/domus/composition/ha_app.py @@ -0,0 +1,134 @@ +"""Provision only the packaged app's private child, drop privilege, then exec Ingress.""" + +import argparse +import os +import stat +import sys +from pathlib import Path +from typing import cast + +from domus.domain.errors import DomusError, InvalidInput +from domus.domain.storage import JsonDocument +from domus.infrastructure.ha_config import PublicOrigins +from domus.infrastructure.ha_http import _secret + +_DATA_ROOT = Path("/data") +_ROOT_UID = 0 +_RUNTIME_UID = _RUNTIME_GID = 65532 +_ENTRYPOINT = "/app/.venv/bin/domus-ingress" +_DIRECTORY_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + + +def _options(directory: int) -> tuple[str, ...]: + descriptor = os.open( + "options.json", + os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, + dir_fd=directory, + ) + try: + info = os.fstat(descriptor) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_nlink != 1 + or info.st_uid != _ROOT_UID + or stat.S_IMODE(info.st_mode) & 0o022 + ): + raise InvalidInput("unsafe_app_options") + content = os.read(descriptor, 16_385) + finally: + os.close(descriptor) + if len(content) > 16_384: + raise InvalidInput("invalid_app_options") + value = JsonDocument(content.decode("utf-8")).decode() + if set(value) != {"public_origins"} or type(value["public_origins"]) is not list: + raise InvalidInput("invalid_app_options") + return PublicOrigins(tuple(cast(list[str], value["public_origins"]))).values + + +def _provision(directory: int) -> None: + created = False + try: + os.mkdir("domus", mode=0o700, dir_fd=directory) + created = True + except FileExistsError: + pass + descriptor = os.open("domus", _DIRECTORY_FLAGS, dir_fd=directory) + try: + if created: + os.fchown(descriptor, _RUNTIME_UID, _RUNTIME_GID) + os.fsync(descriptor) + os.fsync(directory) + info = os.fstat(descriptor) + if ( + info.st_uid != _RUNTIME_UID + or info.st_gid != _RUNTIME_GID + or stat.S_IMODE(info.st_mode) != 0o700 + ): + raise InvalidInput("unsafe_app_data") + finally: + os.close(descriptor) + + +def _drop_privileges() -> None: + os.setgroups([]) + os.setgid(_RUNTIME_GID) + os.setuid(_RUNTIME_UID) + if (os.getuid(), os.geteuid(), os.getgid(), os.getegid()) != ( + _RUNTIME_UID, + _RUNTIME_UID, + _RUNTIME_GID, + _RUNTIME_GID, + ) or os.getgroups(): + raise InvalidInput("runtime_identity_failure") + + +def _run(channel: str) -> None: + if (os.getuid(), os.geteuid()) != (_ROOT_UID, _ROOT_UID): + raise InvalidInput("packaged_app_required") + token = os.environ.get("SUPERVISOR_TOKEN", "") + _secret(token) + os.umask(0o077) + directory = os.open(_DATA_ROOT, _DIRECTORY_FLAGS) + try: + info = os.fstat(directory) + if info.st_uid != _ROOT_UID or stat.S_IMODE(info.st_mode) & 0o022: + raise InvalidInput("unsafe_app_data_parent") + origins = _options(directory) + _provision(directory) + finally: + os.close(directory) + _drop_privileges() + arguments = [_ENTRYPOINT, "--channel", channel, "--data-directory", str(_DATA_ROOT / "domus")] + for origin in origins: + arguments.extend(("--public-origin", origin)) + os.execve( + _ENTRYPOINT, + arguments, + { + "PATH": "/app/.venv/bin:/usr/local/bin:/usr/bin:/bin", + "PYTHONUNBUFFERED": "1", + "PYTHONDONTWRITEBYTECODE": "1", + "LANG": "C.UTF-8", + "SUPERVISOR_TOKEN": token, + }, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Start the packaged Domus Home Assistant app.") + parser.add_argument("--channel", required=True, choices=("stable", "edge")) + args = parser.parse_args(argv) + try: + _run(args.channel) + except DomusError, OSError, UnicodeError: + print( + "Domus could not start. Review the app's public_origins and private data ownership. " + "Existing household records are retained.", + file=sys.stderr, + ) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/src/domus/composition/household.py b/backend/src/domus/composition/household.py new file mode 100644 index 0000000..afc9f93 --- /dev/null +++ b/backend/src/domus/composition/household.py @@ -0,0 +1,38 @@ +"""Wire the same application services to current standalone session proof and private SQLite.""" + +from collections.abc import Callable + +from domus.application.household_commands import HouseholdCommands +from domus.application.household_queries import HouseholdQueries +from domus.application.member_commands import MemberCommands +from domus.application.policy_commands import PolicyCommands +from domus.application.ports import Transactions +from domus.application.recovery_commands import RecoveryCommands, RecoveryQueries +from domus.application.sessions import SessionGuard +from domus.domain.storage import Installation +from domus.entrypoints.household_http import HouseholdHTTPContext +from domus.infrastructure.application_runtime import SecureIdentifiers, SystemClock, TimeZoneCatalog +from domus.infrastructure.session_access import SessionActor, SessionDirectory +from domus.infrastructure.standalone_sessions import StandaloneIdentity + + +def standalone_context( + store: Transactions, + guard: SessionGuard, + identity: StandaloneIdentity, + installation: Installation, +) -> Callable[[str], HouseholdHTTPContext]: + clock, identifiers, zones = SystemClock(), SecureIdentifiers(), TimeZoneCatalog() + + def context(session_id: str) -> HouseholdHTTPContext: + actor, directory = SessionActor(guard, session_id), SessionDirectory(identity, session_id) + return HouseholdHTTPContext( + lambda: HouseholdQueries(store, actor.read()), + HouseholdCommands(store, actor, directory, zones, clock, identifiers), + MemberCommands(store, actor, directory, clock, identifiers), + PolicyCommands(store, actor, directory, clock, identifiers), + RecoveryCommands(store, actor, installation, clock, identifiers), + lambda: RecoveryQueries(store, actor.read(), installation, clock), + ) + + return context diff --git a/backend/src/domus/composition/ingress.py b/backend/src/domus/composition/ingress.py new file mode 100644 index 0000000..b9efabb --- /dev/null +++ b/backend/src/domus/composition/ingress.py @@ -0,0 +1,184 @@ +"""Run the native HA authentication preview on a Supervisor-only private listener.""" + +import argparse +import os +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import cast + +import uvicorn +from fastapi import FastAPI, Request + +from domus.application.household_commands import HouseholdCommands +from domus.application.household_queries import HouseholdQueries +from domus.application.member_commands import MemberCommands +from domus.application.policy_commands import PolicyCommands +from domus.application.recovery_commands import RecoveryCommands, RecoveryQueries +from domus.domain.errors import DomusError, InvalidInput +from domus.domain.storage import Installation +from domus.entrypoints.household_http import HouseholdHTTPContext +from domus.entrypoints.ingress_http import create_ingress_app +from domus.entrypoints.native_http import NativeHTTPContext, create_native_app +from domus.infrastructure.application_runtime import SecureIdentifiers, SystemClock, TimeZoneCatalog +from domus.infrastructure.auth_runtime import MonotonicClock, SecureEntropy +from domus.infrastructure.ha_config import HAEndpoint, PublicOrigins +from domus.infrastructure.ha_directory import HomeAssistantDirectory +from domus.infrastructure.ha_ingress import SupervisorIdentity +from domus.infrastructure.ha_websocket import HomeAssistantWebSocket +from domus.infrastructure.ingress_sessions import IngressSessions +from domus.infrastructure.owned_storage import OwnedStorage, open_storage, reject_owned_database +from domus.infrastructure.sqlite import SQLiteDatabase + + +@dataclass +class NativeRuntime: + app: FastAPI + sessions: IngressSessions = field(repr=False) + storage: OwnedStorage | None = field(default=None, repr=False) + + def close(self) -> None: + try: + self.sessions.close() + finally: + if self.storage is not None: + self.storage.close() + + +def create_owned_runtime( + token: str, channel: str, data_directory: Path, public_origins: tuple[str, ...] +) -> NativeRuntime: + origins = PublicOrigins(public_origins).values + identity = SupervisorIdentity(HomeAssistantWebSocket(HAEndpoint.supervisor()), token) + storage = open_storage(data_directory, channel) + try: + runtime = _compose_business(identity, storage.database, origins) + runtime.storage = storage + return runtime + except BaseException: + storage.close() + raise + + +def create_business_runtime( + token: str, + channel: str, + database: Path, + instance_id: str, + public_origins: tuple[str, ...], +) -> NativeRuntime: + installation = Installation(instance_id, channel) + origins = PublicOrigins(public_origins).values + identity = SupervisorIdentity(HomeAssistantWebSocket(HAEndpoint.supervisor()), token) + reject_owned_database(database) + store = SQLiteDatabase(database, installation) + store.initialize() + return _compose_business(identity, store, origins) + + +def _compose_business( + identity: SupervisorIdentity, store: SQLiteDatabase, origins: tuple[str, ...] +) -> NativeRuntime: + clock, entropy = MonotonicClock(), SecureEntropy() + sessions = IngressSessions( + identity, store.installation, clock, entropy, entropy.new().encode("ascii") + ) + directory = HomeAssistantDirectory(identity.lookup, identity.transport_token) + utc, identifiers, zones = SystemClock(), SecureIdentifiers(), TimeZoneCatalog() + + def context(request: Request) -> NativeHTTPContext: + peer = cast(tuple[str, int], request.scope["client"]) + actor = sessions.request( + peer[0], + tuple( + (key.decode("latin1"), value.decode("latin1")) + for key, value in request.scope["headers"] + ), + ) + services = HouseholdHTTPContext( + lambda: HouseholdQueries(store, actor.read()), + HouseholdCommands(store, actor, directory, zones, utc, identifiers), + MemberCommands(store, actor, directory, utc, identifiers), + PolicyCommands(store, actor, directory, utc, identifiers), + RecoveryCommands(store, actor, store.installation, utc, identifiers), + lambda: RecoveryQueries(store, actor.read(), store.installation, utc), + ) + return NativeHTTPContext(services, actor.status, actor.csrf, actor.verify_csrf) + + app = create_native_app( + context, + origins, + store.installation.channel, + clock, + correlation=lambda: "cor_" + entropy.new(), + ) + return NativeRuntime(app, sessions) + + +def create_runtime(token: str, channel: str) -> FastAPI: + identity = SupervisorIdentity(HomeAssistantWebSocket(HAEndpoint.supervisor()), token) + entropy = SecureEntropy() + return create_ingress_app(identity.prove, channel, correlation=lambda: "cor_" + entropy.new()) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Run Domus behind private Home Assistant Ingress.") + parser.add_argument("--channel", required=True, choices=("stable", "edge")) + data = parser.add_mutually_exclusive_group() + data.add_argument("--database", type=Path) + data.add_argument("--data-directory", type=Path) + parser.add_argument("--instance-id") + parser.add_argument("--public-origin", action="append", default=[]) + args = parser.parse_args(argv) + token = os.environ.pop("SUPERVISOR_TOKEN", "") + runtime = None + try: + if args.data_directory is not None: + if args.instance_id or not args.public_origin: + raise InvalidInput("invalid_persistent_configuration") + runtime = create_owned_runtime( + token, args.channel, args.data_directory, tuple(args.public_origin) + ) + app = runtime.app + elif args.database is not None: + if not args.instance_id or not args.public_origin: + raise InvalidInput("invalid_persistent_configuration") + runtime = create_business_runtime( + token, args.channel, args.database, args.instance_id, tuple(args.public_origin) + ) + app = runtime.app + else: + if args.instance_id or args.public_origin: + raise InvalidInput("invalid_persistent_configuration") + app = create_runtime(token, args.channel) + except DomusError: + print( + "Start Domus as a Home Assistant app with valid configuration and owned data. " + "Existing household records are retained.", + file=sys.stderr, + ) + return 2 + try: + uvicorn.run( + app, + host="0.0.0.0", + port=8099, + workers=1, + proxy_headers=False, + forwarded_allow_ips="", + access_log=False, + server_header=False, + log_level="warning", + limit_concurrency=64, + timeout_keep_alive=5, + timeout_graceful_shutdown=10, + h11_max_incomplete_event_size=65_536, + ) + finally: + if runtime is not None: + runtime.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/src/domus/composition/restore.py b/backend/src/domus/composition/restore.py new file mode 100644 index 0000000..70f75e1 --- /dev/null +++ b/backend/src/domus/composition/restore.py @@ -0,0 +1,105 @@ +"""Only the installation console selects restore paths and offline activation authority.""" + +import argparse +import json +from typing import TextIO + +from domus.application.activation import ActivationCommands +from domus.application.ports import Clock +from domus.application.restoration import RestorationCommands +from domus.domain.errors import DatabaseRejected, InvalidInput +from domus.domain.storage import Installation, JsonObject +from domus.infrastructure.application_runtime import SecureIdentifiers +from domus.infrastructure.offline_storage import offline_storage +from domus.infrastructure.owned_storage import _metadata +from domus.infrastructure.sqlite_activation import SQLiteActivation +from domus.infrastructure.sqlite_restoration import SQLiteRestorations +from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots, _directory + + +def execute(args: argparse.Namespace, outgoing: TextIO, clock: Clock) -> int: + value: JsonObject + if args.operation == "inspect": + if args.data_directory is not None or args.database is not None or args.instance_id is None: + raise InvalidInput("invalid_persistent_configuration") + installation = Installation(args.instance_id, args.channel) + archive = SQLiteRestorations( + None, None, args.location, installation, clock, timeout=args.timeout + ) + prepared = RestorationCommands(archive, installation, SecureIdentifiers()).inspect() + value = { + "state": "verified", + "prepared": prepared.document().decode(), + "message": "The private prepared restoration is verified. It is not active.", + "action_required": ( + "Stop writes and activate only this bound preparation in its managed installation." + ), + } + else: + if args.data_directory is None or args.database is not None or args.instance_id is not None: + raise InvalidInput("invalid_persistent_configuration") + with _directory(args.data_directory, private=True) as directory: + installation, state, _ = _metadata(directory) + if installation.channel != args.channel or state != "ready": + raise DatabaseRejected("installation_not_ready") + if args.operation == "prepare": + with offline_storage(args.data_directory, args.channel, timeout=args.timeout) as source: + snapshot = SQLiteSnapshots( + None, args.snapshot, installation, clock, timeout=args.timeout + ).inspect() + archive = SQLiteRestorations( + source.database, + args.snapshot, + args.location, + installation, + clock, + snapshot, + timeout=args.timeout, + ) + prepared = RestorationCommands( + archive, installation, SecureIdentifiers(), snapshot + ).prepare() + value = { + "state": "prepared", + "prepared": prepared.document().decode(), + "message": "The isolated restoration is prepared and blocked from serving.", + "action_required": "Inspect this exact preparation before offline activation.", + } + else: + location = args.location if args.operation == "activate" else None + restore_id = args.restore_id if args.operation in ("resume", "status") else None + activation_prepared = ( + SQLiteRestorations( + None, None, location, installation, clock, timeout=args.timeout + ).inspect() + if location is not None + else None + ) + adapter = SQLiteActivation( + args.data_directory, installation, clock, location, restore_id, timeout=args.timeout + ) + commands = ActivationCommands(adapter, installation, activation_prepared, restore_id) + result = ( + commands.activate() + if args.operation == "activate" + else commands.resume() + if args.operation == "resume" + else commands.inspect() + ) + value = result.document().decode() + value["message"] = { + "active": "Activation completed. Its generation is selected; later work may exist.", + "historical": ( + "This activation completed previously. " + "Another generation is selected; no data was switched." + ), + "pending": "Activation is pending or uncertain. Normal startup remains blocked.", + }[result.state] + value["action_required"] = ( + "Inspect the current state before any further restore." + if result.state != "pending" + else "Resume the same activation ID. Keep existing installation data." + ) + outgoing.write(json.dumps(value, ensure_ascii=True) + "\n") + outgoing.flush() + return 0 diff --git a/backend/src/domus/composition/server.py b/backend/src/domus/composition/server.py new file mode 100644 index 0000000..3258da9 --- /dev/null +++ b/backend/src/domus/composition/server.py @@ -0,0 +1,178 @@ +"""Compose standalone authentication and optional private SQLite household services.""" + +import argparse +import logging +import sys +from dataclasses import dataclass, field +from pathlib import Path + +import uvicorn +from fastapi import FastAPI + +from domus.application.authentication import Authentication +from domus.application.sessions import SessionGuard +from domus.composition.household import standalone_context +from domus.domain.errors import DomusError, InvalidInput +from domus.domain.storage import Installation +from domus.entrypoints.http import HTTPSettings, create_app +from domus.infrastructure.auth_runtime import MonotonicClock, SecureEntropy +from domus.infrastructure.ha_config import HAEndpoint, OAuthConfiguration +from domus.infrastructure.ha_http import HomeAssistantOAuth +from domus.infrastructure.ha_websocket import HomeAssistantWebSocket +from domus.infrastructure.memory_attempts import MemoryLoginAttempts +from domus.infrastructure.owned_storage import OwnedStorage, open_storage, reject_owned_database +from domus.infrastructure.sqlite import SQLiteDatabase +from domus.infrastructure.standalone_sessions import StandaloneIdentity + + +@dataclass +class AuthenticationRuntime: + app: FastAPI + identity: StandaloneIdentity = field(repr=False) + storage: OwnedStorage | None = field(default=None, repr=False) + + def close(self) -> tuple[int, int]: + try: + return self.identity.shutdown() + finally: + if self.storage is not None: + self.storage.close() + + +def create_owned_runtime( + ha_origin: str, + public_origin: str, + channel: str, + data_directory: Path, + *, + development_http: bool = False, +) -> AuthenticationRuntime: + config = OAuthConfiguration(HAEndpoint(ha_origin), public_origin, development_http) + storage = open_storage(data_directory, channel) + try: + runtime = _compose(config, storage.installation, storage.database) + runtime.storage = storage + return runtime + except BaseException: + storage.close() + raise + + +def create_runtime( + ha_origin: str, + public_origin: str, + instance_id: str, + channel: str, + *, + development_http: bool = False, + database: Path | None = None, +) -> AuthenticationRuntime: + installation = Installation(instance_id, channel) + config = OAuthConfiguration(HAEndpoint(ha_origin), public_origin, development_http) + store = None + if database is not None: + reject_owned_database(database) + store = SQLiteDatabase(database, installation) + store.initialize() + return _compose(config, installation, store) + + +def _compose( + config: OAuthConfiguration, installation: Installation, store: SQLiteDatabase | None +) -> AuthenticationRuntime: + clock, entropy = MonotonicClock(), SecureEntropy() + identity = StandaloneIdentity( + HomeAssistantOAuth(config), HomeAssistantWebSocket(config.ha), clock + ) + authentication = Authentication(MemoryLoginAttempts(), identity, clock, entropy) + tag = f"domus-{installation.channel}-{installation.instance_id.replace('-', '')[:12]}" + settings = HTTPSettings(config.public_origin, tag, entropy.new().encode("ascii")) + guard = SessionGuard(identity, identity, clock) + context = None + if store is not None: + context = standalone_context(store, guard, identity, installation) + app = create_app( + authentication, + guard, + identity, + settings, + correlation=lambda: "cor_" + entropy.new(), + household_context=context, + ) + return AuthenticationRuntime(app, identity) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Run Domus standalone; --data-directory manages persistent household data." + ) + parser.add_argument("--ha-url", required=True) + parser.add_argument("--public-origin", required=True) + parser.add_argument("--instance-id") + parser.add_argument("--channel", choices=("stable", "edge"), required=True) + parser.add_argument("--development-http", action="store_true") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8099) + data = parser.add_mutually_exclusive_group() + data.add_argument( + "--database", type=Path, help="Owned SQLite file; parent directory must exist." + ) + data.add_argument("--data-directory", type=Path, help="Existing private 0700 data directory.") + args = parser.parse_args(argv) + try: + if not 1 <= args.port <= 65535: + raise InvalidInput("invalid_bind_port") + if args.development_http and args.host not in ("127.0.0.1", "::1"): + raise InvalidInput("development_requires_loopback") + if args.data_directory is not None: + if args.instance_id is not None: + raise InvalidInput("invalid_persistent_configuration") + runtime = create_owned_runtime( + args.ha_url, + args.public_origin, + args.channel, + args.data_directory, + development_http=args.development_http, + ) + else: + if not args.instance_id: + raise InvalidInput("invalid_persistent_configuration") + runtime = create_runtime( + args.ha_url, + args.public_origin, + args.instance_id, + args.channel, + development_http=args.development_http, + database=args.database, + ) + except DomusError as error: + print(f"Configuration rejected: {error.code}", file=sys.stderr) + return 2 + try: + uvicorn.run( + runtime.app, + host=args.host, + port=args.port, + workers=1, + proxy_headers=False, + forwarded_allow_ips="", + access_log=False, + server_header=False, + log_level="warning", + limit_concurrency=64, + timeout_keep_alive=5, + timeout_graceful_shutdown=10, + h11_max_incomplete_event_size=65_536, + ) + finally: + revoked, unrevoked = runtime.close() + logging.getLogger("domus.auth").info( + "auth_runtime_stopped revoked=%d unconfirmed=%d", revoked, unrevoked + ) + if unrevoked: + logging.getLogger("domus.auth").warning("ha_grants_not_revoked: %d", unrevoked) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/src/domus/domain/__init__.py b/backend/src/domus/domain/__init__.py new file mode 100644 index 0000000..4d8f369 --- /dev/null +++ b/backend/src/domus/domain/__init__.py @@ -0,0 +1 @@ +"""Pure household rules and immutable value contracts.""" diff --git a/backend/src/domus/domain/activation.py b/backend/src/domus/domain/activation.py new file mode 100644 index 0000000..f0be892 --- /dev/null +++ b/backend/src/domus/domain/activation.py @@ -0,0 +1,126 @@ +"""Durable activation facts distinguish a selected pointer from completed activation.""" + +import re +from dataclasses import dataclass +from typing import Literal, cast + +from domus.domain.errors import InvalidInput +from domus.domain.restoration import PreparedRestoration +from domus.domain.snapshots import Snapshot +from domus.domain.storage import JsonDocument, validate_identifier + + +@dataclass(frozen=True, slots=True) +class ActivationRecord: + prepared: PreparedRestoration + phase: Literal["pending", "materialized", "active"] + database_sha256: str | None = None + database_bytes: int | None = None + activated_at: int | None = None + rollback_snapshot: Snapshot | None = None + + def __post_init__(self) -> None: + if ( + not isinstance(self.prepared, PreparedRestoration) + or self.prepared.source is None + or self.prepared.source.identity_sha256 is None + or self.phase not in ("pending", "materialized", "active") + ): + raise InvalidInput("invalid_activation_record") + if self.phase == "pending": + if any( + value is not None + for value in ( + self.database_sha256, + self.database_bytes, + self.activated_at, + self.rollback_snapshot, + ) + ): + raise InvalidInput("invalid_activation_record") + elif ( + type(self.database_sha256) is not str + or not re.fullmatch(r"[a-f0-9]{64}", self.database_sha256) + or type(self.database_bytes) is not int + or not 1 <= self.database_bytes <= 2**63 - 1 + or not isinstance(self.rollback_snapshot, Snapshot) + or self.rollback_snapshot.installation != self.prepared.installation + ): + raise InvalidInput("invalid_activation_record") + if self.phase == "active": + if type(self.activated_at) is not int or not 0 <= self.activated_at <= 2**63 - 1: + raise InvalidInput("invalid_activation_record") + elif self.activated_at is not None: + raise InvalidInput("invalid_activation_record") + + def document(self) -> JsonDocument: + return JsonDocument.from_value( + { + "format_version": 1, + "prepared": self.prepared.document().decode(), + "phase": self.phase, + "database_sha256": self.database_sha256, + "database_bytes": self.database_bytes, + "activated_at": self.activated_at, + "rollback_snapshot": self.rollback_snapshot.document().decode() + if self.rollback_snapshot is not None + else None, + } + ) + + @classmethod + def from_value(cls, value: object) -> ActivationRecord: + if type(value) is not dict or set(value) != { + "format_version", + "prepared", + "phase", + "database_sha256", + "database_bytes", + "activated_at", + "rollback_snapshot", + }: + raise InvalidInput("invalid_activation_record") + data = cast(dict[str, object], value) + if type(data["format_version"]) is not int or data["format_version"] != 1: + raise InvalidInput("invalid_activation_record") + return cls( + PreparedRestoration.from_value(data["prepared"]), + cast(Literal["pending", "materialized", "active"], data["phase"]), + cast(str | None, data["database_sha256"]), + cast(int | None, data["database_bytes"]), + cast(int | None, data["activated_at"]), + Snapshot.from_value(data["rollback_snapshot"]) + if data["rollback_snapshot"] is not None + else None, + ) + + +@dataclass(frozen=True, slots=True) +class ActivationStatus: + record: ActivationRecord + selected_generation: str | None + + def __post_init__(self) -> None: + if not isinstance(self.record, ActivationRecord): + raise InvalidInput("invalid_activation_status") + if self.selected_generation is not None: + validate_identifier(self.selected_generation) + + @property + def state(self) -> Literal["pending", "active", "historical"]: + if self.record.phase != "active": + return "pending" + return ( + "active" + if self.selected_generation == self.record.prepared.restore_id + else "historical" + ) + + def document(self) -> JsonDocument: + return JsonDocument.from_value( + { + "state": self.state, + "selected_generation": self.selected_generation, + "activation": self.record.document().decode(), + } + ) diff --git a/backend/src/domus/domain/errors.py b/backend/src/domus/domain/errors.py new file mode 100644 index 0000000..5091286 --- /dev/null +++ b/backend/src/domus/domain/errors.py @@ -0,0 +1,49 @@ +"""Safe application failures carry codes rather than provider exception text.""" + + +class DomusError(Exception): + """A stable, nonsecret failure that entrypoints can map to their own schemas.""" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__(code) + + +class InvalidInput(DomusError): + """An input cannot establish a valid domain value.""" + + +class Conflict(DomusError): + """Current durable state conflicts with the requested transition.""" + + +class NotFound(DomusError): + """An authorized scoped lookup has no matching record.""" + + +class Forbidden(DomusError): + """Current authority does not permit the requested action.""" + + +class Unauthenticated(DomusError): + """No current eligible identity/session can establish access.""" + + +class IdentityRejected(Unauthenticated): + """An explicit upstream rejection invalidates local session authority.""" + + +class IdentityUnavailable(DomusError): + """A bounded provider availability failure; never a mutation grant.""" + + +class StorageBusy(DomusError): + """A bounded writer slot was not obtained; identical retry is possible.""" + + +class StorageFailure(DomusError): + """Persistence failed without exposing driver or filesystem details.""" + + +class DatabaseRejected(DomusError): + """Schema or installation identity cannot be safely opened for mutation.""" diff --git a/backend/src/domus/domain/generations.py b/backend/src/domus/domain/generations.py new file mode 100644 index 0000000..771441c --- /dev/null +++ b/backend/src/domus/domain/generations.py @@ -0,0 +1,46 @@ +"""A path-free versioned identity selects exactly one ready installation generation.""" + +from dataclasses import dataclass +from typing import cast + +from domus.domain.errors import InvalidInput +from domus.domain.storage import Installation, JsonDocument, validate_identifier + + +@dataclass(frozen=True, slots=True) +class GenerationSelection: + installation: Installation + generation_id: str + + def __post_init__(self) -> None: + if not isinstance(self.installation, Installation): + raise InvalidInput("invalid_generation_selection") + validate_identifier(self.generation_id) + + def document(self) -> JsonDocument: + return JsonDocument.from_value( + { + "schema_version": 2, + "instance_id": self.installation.instance_id, + "channel": self.installation.channel, + "state": "ready", + "generation_id": self.generation_id, + } + ) + + @classmethod + def from_value(cls, value: object) -> GenerationSelection: + if type(value) is not dict: + raise InvalidInput("invalid_generation_selection") + data = cast(dict[str, object], value) + if ( + set(data) != {"schema_version", "instance_id", "channel", "state", "generation_id"} + or type(data["schema_version"]) is not int + or data["schema_version"] != 2 + or data["state"] != "ready" + ): + raise InvalidInput("invalid_generation_selection") + return cls( + Installation(cast(str, data["instance_id"]), cast(str, data["channel"])), + cast(str, data["generation_id"]), + ) diff --git a/backend/src/domus/domain/identity.py b/backend/src/domus/domain/identity.py new file mode 100644 index 0000000..b7f418b --- /dev/null +++ b/backend/src/domus/domain/identity.py @@ -0,0 +1,265 @@ +"""Pure identity eligibility and household authority; no provider or credential state.""" + +from dataclasses import dataclass +from enum import StrEnum + +from domus.domain.errors import Conflict, InvalidInput +from domus.domain.storage import validate_identifier + + +class Role(StrEnum): + ADMINISTRATOR = "administrator" + ADULT = "adult" + RESTRICTED = "restricted" + VIEWER = "viewer" + + +class MemberStatus(StrEnum): + PENDING = "pending" + ACTIVE = "active" + SUSPENDED = "suspended" + ARCHIVED = "archived" + + +class MappingMode(StrEnum): + ALL = "all_ha_administrators" + SETUP = "setup_administrator" + SELECTED = "selected_ha_user" + + +class Permission(StrEnum): + HOUSEHOLD_READ = "household.read" + HOUSEHOLD_MANAGE = "household.manage" + MEMBERS_READ = "members.read" + MEMBERS_MANAGE = "members.manage" + POLICY_MANAGE = "policy.manage" + PREFERENCES_READ = "preferences.read" + PREFERENCES_WRITE = "preferences.write" + OPERATIONS_READ = "operations.read" + TASKS_CREATE = "tasks.create" + TASKS_COMPLETE = "tasks.complete" + TASKS_APPROVE = "tasks.approve" + TASKS_CONFIGURE = "tasks.configure" + SHOPPING_REQUEST = "shopping.request" + SHOPPING_MANAGE = "shopping.manage" + INVENTORY_MANAGE = "inventory.manage" + AUTOMATIONS_CONFIGURE = "automations.configure" + POINTS_MANAGE = "points.manage" + + +ADMINISTRATOR_ONLY = frozenset( + { + Permission.HOUSEHOLD_MANAGE, + Permission.MEMBERS_MANAGE, + Permission.POLICY_MANAGE, + Permission.TASKS_CONFIGURE, + Permission.AUTOMATIONS_CONFIGURE, + Permission.POINTS_MANAGE, + } +) +OWN_SCOPED = frozenset( + {Permission.PREFERENCES_READ, Permission.PREFERENCES_WRITE, Permission.OPERATIONS_READ} +) +COMMON = frozenset({Permission.HOUSEHOLD_READ, Permission.MEMBERS_READ}) | OWN_SCOPED +RESTRICTED = COMMON | {Permission.TASKS_COMPLETE, Permission.SHOPPING_REQUEST} +ADULT = RESTRICTED | { + Permission.TASKS_CREATE, + Permission.TASKS_APPROVE, + Permission.SHOPPING_MANAGE, + Permission.INVENTORY_MANAGE, +} +DEFAULTS = { + Role.ADMINISTRATOR: frozenset(Permission), + Role.ADULT: ADULT, + Role.RESTRICTED: RESTRICTED, + Role.VIEWER: COMMON, +} + + +def validate_revision(revision: int) -> None: + if type(revision) is not int or not 1 <= revision <= 2**63 - 1: + raise InvalidInput("invalid_revision") + + +@dataclass(frozen=True, slots=True) +class HumanIdentity: + """An adapter-proven active human; construction cannot authenticate a request.""" + + ha_user_id: str + is_administrator: bool + is_active: bool + system_generated: bool + + def __post_init__(self) -> None: + validate_identifier(self.ha_user_id) + if ( + type(self.is_administrator) is not bool + or self.is_active is not True + or self.system_generated is not False + ): + raise InvalidInput("invalid_human_identity") + + +@dataclass(frozen=True, slots=True) +class AdministratorMapping: + mode: MappingMode + target_ha_user_id: str | None = None + + def __post_init__(self) -> None: + if not isinstance(self.mode, MappingMode) or (self.mode is MappingMode.ALL) != ( + self.target_ha_user_id is None + ): + raise InvalidInput("invalid_administrator_mapping") + if self.target_ha_user_id is not None: + validate_identifier(self.target_ha_user_id) + + def grants(self, human: HumanIdentity) -> bool: + if self.mode is MappingMode.ALL: + return human.is_administrator + if self.target_ha_user_id != human.ha_user_id: + return False + return self.mode is MappingMode.SELECTED or human.is_administrator + + +@dataclass(frozen=True, slots=True) +class PermissionRule: + permission: Permission + allow: bool + + def __post_init__(self) -> None: + if not isinstance(self.permission, Permission) or type(self.allow) is not bool: + raise InvalidInput("invalid_permission_rule") + if self.permission in ADMINISTRATOR_ONLY: + raise InvalidInput("protected_permission") + + +@dataclass(frozen=True, slots=True) +class ProfileRule: + role: Role + permission: Permission + allow: bool + + def __post_init__(self) -> None: + if not isinstance(self.role, Role): + raise InvalidInput("invalid_profile_rule") + PermissionRule(self.permission, self.allow) + + +@dataclass(frozen=True, slots=True) +class Member: + member_id: str + household_id: str + ha_user_id: str + role: Role + status: MemberStatus + revision: int + exceptions: tuple[PermissionRule, ...] = () + + def __post_init__(self) -> None: + for value in (self.member_id, self.household_id, self.ha_user_id): + validate_identifier(value) + validate_revision(self.revision) + if not isinstance(self.role, Role) or not isinstance(self.status, MemberStatus): + raise InvalidInput("invalid_member") + if ( + type(self.exceptions) is not tuple + or len(self.exceptions) > 256 + or any(not isinstance(rule, PermissionRule) for rule in self.exceptions) + or len({rule.permission for rule in self.exceptions}) != len(self.exceptions) + ): + raise InvalidInput("invalid_permission_rules") + + +@dataclass(frozen=True, slots=True) +class HouseholdPolicy: + household_id: str + mapping: AdministratorMapping + revision: int + adjustments: tuple[ProfileRule, ...] = () + + def __post_init__(self) -> None: + validate_identifier(self.household_id) + validate_revision(self.revision) + if not isinstance(self.mapping, AdministratorMapping): + raise InvalidInput("invalid_policy") + if ( + type(self.adjustments) is not tuple + or len(self.adjustments) > 256 + or any(not isinstance(rule, ProfileRule) for rule in self.adjustments) + or len({(rule.role, rule.permission) for rule in self.adjustments}) + != len(self.adjustments) + ): + raise InvalidInput("invalid_profile_rules") + + +def effective_role(human: HumanIdentity, member: Member, policy: HouseholdPolicy) -> Role | None: + if ( + member.ha_user_id != human.ha_user_id + or member.household_id != policy.household_id + or member.status is not MemberStatus.ACTIVE + ): + return None + if policy.mapping.grants(human): + return Role.ADMINISTRATOR + return member.role + + +def allows( + human: HumanIdentity, + member: Member, + policy: HouseholdPolicy, + permission: Permission | str, + household_id: str, + *, + subject_member_id: str | None = None, + assigned_to: tuple[str, ...] = (), +) -> bool: + role = effective_role(human, member, policy) + if ( + role is None + or household_id != policy.household_id + or not isinstance(permission, Permission) + ): + return False + if permission in ADMINISTRATOR_ONLY: + return role is Role.ADMINISTRATOR + if permission in OWN_SCOPED and subject_member_id != member.member_id: + return False + if permission is Permission.TASKS_APPROVE and role not in (Role.ADMINISTRATOR, Role.ADULT): + return False + if ( + permission is Permission.TASKS_COMPLETE + and role is Role.RESTRICTED + and member.member_id not in assigned_to + ): + return False + for exception in member.exceptions: + if exception.permission is permission: + return exception.allow + for adjustment in policy.adjustments: + if adjustment.role is role and adjustment.permission is permission: + return adjustment.allow + return permission in DEFAULTS[role] + + +def ensure_administrator( + policy: HouseholdPolicy, members: tuple[Member, ...], humans: tuple[HumanIdentity, ...] +) -> None: + """Check a proposed local transition against supplied current identity facts. + + HA proof and a SQLite commit are separate systems. This pure check does not + promise an atomic HA directory snapshot or prevent independent HA revocation. + """ + by_user = {human.ha_user_id: human for human in humans} + for member in members: + human = by_user.get(member.ha_user_id) + if human is not None and effective_role(human, member, policy) is Role.ADMINISTRATOR: + return + raise Conflict("last_administrator") + + +def recovery_allowed(human: HumanIdentity, policy: HouseholdPolicy) -> bool: + return human.is_administrator and policy.mapping.mode in ( + MappingMode.SETUP, + MappingMode.SELECTED, + ) diff --git a/backend/src/domus/domain/recovery.py b/backend/src/domus/domain/recovery.py new file mode 100644 index 0000000..5327ce4 --- /dev/null +++ b/backend/src/domus/domain/recovery.py @@ -0,0 +1,192 @@ +"""Pure, target-bound recovery with bounded request and single-use approval windows.""" + +from dataclasses import dataclass, replace +from enum import StrEnum + +from domus.domain.errors import Conflict, InvalidInput +from domus.domain.identity import ( + AdministratorMapping, + HouseholdPolicy, + MappingMode, + Member, + validate_revision, +) +from domus.domain.storage import Installation, JsonDocument, Receipt, validate_identifier + +RECOVERY_LIFETIME = 600_000 + + +def closed_recovery(receipt: Receipt) -> str | None: + """Original terminal facts identify a resource that restoration must never rearm.""" + expected = {"recovery.complete": "recovered", "recovery.cancel": "canceled"}.get( + receipt.command.action + ) + if expected is None: + return None + result = receipt.result.decode() + request_id = result.get("request_id") + if not isinstance(request_id, str) or result.get("state") != expected: + raise InvalidInput("invalid_recovery_history") + validate_identifier(request_id) + return request_id + + +class RecoveryState(StrEnum): + PENDING = "pending" + APPROVED = "approved" + CONSUMED = "consumed" + CANCELED = "canceled" + + +def validate_recovery_time(value: int) -> None: + if type(value) is not int or not 0 <= value <= 2**63 - 1 - RECOVERY_LIFETIME: + raise InvalidInput("invalid_recovery_time") + + +@dataclass(frozen=True, slots=True) +class RecoveryRequest: + request_id: str + household_id: str + target_ha_user_id: str + installation: Installation + mapping: AdministratorMapping + policy_revision: int + member_id: str | None + member_revision: int + created_at: int + expires_at: int + state: RecoveryState = RecoveryState.PENDING + revision: int = 1 + approved_at: int | None = None + closed_at: int | None = None + + def __post_init__(self) -> None: + for value in (self.request_id, self.household_id, self.target_ha_user_id): + validate_identifier(value) + validate_revision(self.policy_revision) + validate_recovery_time(self.created_at) + if ( + not isinstance(self.installation, Installation) + or not isinstance(self.mapping, AdministratorMapping) + or self.mapping.mode not in (MappingMode.SETUP, MappingMode.SELECTED) + or type(self.expires_at) is not int + or not isinstance(self.state, RecoveryState) + ): + raise InvalidInput("invalid_recovery_request") + if self.member_id is None: + if type(self.member_revision) is not int or self.member_revision != 0: + raise InvalidInput("invalid_recovery_member") + else: + validate_identifier(self.member_id) + validate_revision(self.member_revision) + if self.approved_at is not None: + validate_recovery_time(self.approved_at) + if not self.created_at <= self.approved_at < self.created_at + RECOVERY_LIFETIME: + raise InvalidInput("invalid_recovery_request") + origin = self.created_at if self.approved_at is None else self.approved_at + if self.expires_at != origin + RECOVERY_LIFETIME: + raise InvalidInput("invalid_recovery_request") + if self.closed_at is not None: + validate_recovery_time(self.closed_at) + earliest = self.created_at if self.approved_at is None else self.approved_at + if self.closed_at < earliest: + raise InvalidInput("invalid_recovery_request") + expected = { + RecoveryState.PENDING: (1, False, False), + RecoveryState.APPROVED: (2, True, False), + RecoveryState.CONSUMED: (3, True, True), + RecoveryState.CANCELED: (2 if self.approved_at is None else 3, False, True), + }[self.state] + if ( + type(self.revision) is not int + or self.revision != expected[0] + or (self.state is not RecoveryState.CANCELED) + and (self.approved_at is not None) != expected[1] + or (self.closed_at is not None) != expected[2] + or self.state is RecoveryState.CONSUMED + and self.closed_at is not None + and self.closed_at >= self.expires_at + ): + raise InvalidInput("invalid_recovery_request") + + def document(self) -> JsonDocument: + return JsonDocument.from_value( + { + "request_id": self.request_id, + "household_id": self.household_id, + "target_ha_user_id": self.target_ha_user_id, + "instance_id": self.installation.instance_id, + "channel": self.installation.channel, + "mapping_mode": self.mapping.mode.value, + "mapping_target": self.mapping.target_ha_user_id, + "policy_revision": self.policy_revision, + "member_id": self.member_id, + "member_revision": self.member_revision, + "created_at": self.created_at, + "expires_at": self.expires_at, + "state": self.state.value, + "revision": self.revision, + "approved_at": self.approved_at, + "closed_at": self.closed_at, + } + ) + + def require_current( + self, installation: Installation, policy: HouseholdPolicy | None, member: Member | None + ) -> None: + if ( + self.installation != installation + or policy is None + or policy.household_id != self.household_id + or policy.mapping != self.mapping + or policy.revision != self.policy_revision + or (None if member is None else member.member_id) != self.member_id + or (0 if member is None else member.revision) != self.member_revision + or member is not None + and ( + member.household_id != self.household_id + or member.ha_user_id != self.target_ha_user_id + ) + ): + raise Conflict("recovery_binding_changed") + + def visible_state(self, now: int) -> str: + validate_recovery_time(now) + origin = self.created_at if self.approved_at is None else self.approved_at + if self.state in (RecoveryState.PENDING, RecoveryState.APPROVED) and not ( + origin <= now < self.expires_at + ): + return "expired" + return self.state.value + + def _live(self, now: int) -> None: + if self.visible_state(now) == "expired": + raise Conflict("recovery_expired") + + def approve(self, now: int) -> RecoveryRequest: + self._live(now) + if self.state is not RecoveryState.PENDING: + raise Conflict("recovery_not_pending") + return replace( + self, + state=RecoveryState.APPROVED, + revision=2, + approved_at=now, + expires_at=now + RECOVERY_LIFETIME, + ) + + def consume(self, now: int) -> RecoveryRequest: + self._live(now) + if self.state is not RecoveryState.APPROVED: + raise Conflict("recovery_not_approved") + return replace(self, state=RecoveryState.CONSUMED, revision=3, closed_at=now) + + def cancel(self, now: int) -> RecoveryRequest: + validate_recovery_time(now) + if self.state not in (RecoveryState.PENDING, RecoveryState.APPROVED): + raise Conflict("recovery_closed") + if now < self.created_at: + raise Conflict("recovery_expired") + return replace( + self, state=RecoveryState.CANCELED, revision=self.revision + 1, closed_at=now + ) diff --git a/backend/src/domus/domain/restoration.py b/backend/src/domus/domain/restoration.py new file mode 100644 index 0000000..99af677 --- /dev/null +++ b/backend/src/domus/domain/restoration.py @@ -0,0 +1,372 @@ +"""Pure complete-operation evidence and streaming checkpoint/history reconciliation.""" + +import re +from collections.abc import Iterable, Iterator +from dataclasses import dataclass, replace +from typing import cast + +from domus.domain.errors import Conflict, InvalidInput +from domus.domain.snapshots import Snapshot +from domus.domain.storage import ( + AuditEntry, + CommandKey, + Installation, + JsonDocument, + JsonObject, + OutboxEvent, + Receipt, + validate_identifier, +) + + +@dataclass(frozen=True, slots=True) +class OperationEvidence: + receipt: Receipt + audit: AuditEntry + event: OutboxEvent + position: int + delivered: bool + + def __post_init__(self) -> None: + r, a, e = self.receipt, self.audit, self.event + if ( + not isinstance(r, Receipt) + or not isinstance(a, AuditEntry) + or not isinstance(e, OutboxEvent) + or not isinstance(r.command, CommandKey) + or r.restoration is not None + or type(self.position) is not int + or not 1 <= self.position <= 2**63 - 1 + or type(self.delivered) is not bool + or not isinstance(r.payload_fingerprint, str) + or not re.fullmatch(r"[a-f0-9]{64}", r.payload_fingerprint) + or type(r.created_at) is not int + or not 0 <= r.created_at <= 2**63 - 1 + or type(a.created_at) is not int + or type(e.created_at) is not int + or a.operation_id != r.operation_id + or e.event_id != r.operation_id + or a.command != r.command + or e.household_id != r.command.household_id + or e.event_type != r.command.action + or a.created_at != r.created_at + or e.created_at != r.created_at + or not isinstance(r.result, JsonDocument) + or not isinstance(a.detail, JsonDocument) + or not isinstance(e.payload, JsonDocument) + ): + raise InvalidInput("invalid_restore_evidence") + validate_identifier(r.operation_id) + + @property + def command_scope(self) -> tuple[str, str, str, str]: + command = self.receipt.command + return command.household_id, command.actor_id, command.action, command.key + + +@dataclass(frozen=True, slots=True) +class RetainedOperation: + evidence: OperationEvidence + current: bool + archived_by: str | None = None + + def __post_init__(self) -> None: + if ( + not isinstance(self.evidence, OperationEvidence) + or type(self.current) is not bool + or (not self.current and self.archived_by is None) + ): + raise InvalidInput("invalid_retained_operation") + if self.archived_by is not None: + validate_identifier(self.archived_by) + + def value(self) -> JsonObject: + r, a, e = self.evidence.receipt, self.evidence.audit, self.evidence.event + return { + "household_id": r.command.household_id, + "actor_id": r.command.actor_id, + "action": r.command.action, + "command_key": r.command.key, + "payload_fingerprint": r.payload_fingerprint, + "operation_id": r.operation_id, + "result": r.result.decode(), + "created_at": r.created_at, + "audit_detail": a.detail.decode(), + "event_payload": e.payload.decode(), + "position": self.evidence.position, + "delivered": self.evidence.delivered, + "current": self.current, + "archived_by": self.archived_by, + } + + @classmethod + def from_value(cls, value: object) -> RetainedOperation: + fields = { + "household_id", + "actor_id", + "action", + "command_key", + "payload_fingerprint", + "operation_id", + "result", + "created_at", + "audit_detail", + "event_payload", + "position", + "delivered", + "current", + "archived_by", + } + if type(value) is not dict or set(value) != fields: + raise InvalidInput("invalid_retained_operation") + data = cast(dict[str, object], value) + key = CommandKey( + *( + cast(str, data[name]) + for name in ("household_id", "actor_id", "action", "command_key") + ) + ) + operation_id, timestamp = cast(str, data["operation_id"]), cast(int, data["created_at"]) + return cls( + OperationEvidence( + Receipt( + key, + cast(str, data["payload_fingerprint"]), + operation_id, + JsonDocument.from_value(data["result"]), + timestamp, + ), + AuditEntry( + operation_id, key, JsonDocument.from_value(data["audit_detail"]), timestamp + ), + OutboxEvent( + operation_id, + key.household_id, + key.action, + JsonDocument.from_value(data["event_payload"]), + timestamp, + ), + cast(int, data["position"]), + cast(bool, data["delivered"]), + ), + cast(bool, data["current"]), + cast(str | None, data["archived_by"]), + ) + + +def _ordered(values: Iterable[RetainedOperation]) -> Iterator[RetainedOperation]: + previous: tuple[str, str, str, str] | None = None + identities, positions = set[str](), set[int]() + for value in values: + if not isinstance(value, RetainedOperation): + raise InvalidInput("invalid_retained_operation") + scope, identifier = value.evidence.command_scope, value.evidence.receipt.operation_id + if previous is not None and scope <= previous: + raise InvalidInput("restore_evidence_order") + if identifier in identities or value.evidence.position in positions: + raise InvalidInput("restore_operation_collision") + previous = scope + identities.add(identifier) + positions.add(value.evidence.position) + yield value + + +def reconcile_operations( + checkpoint: Iterable[RetainedOperation], current: Iterable[RetainedOperation], restore_id: str +) -> Iterator[RetainedOperation]: + """Consume sorted complete frontiers; publish no result until this stream finishes. + + Only identity/position sets grow with history; payloads are processed one record at a time. + A checkpoint is a complete prefix of retained operation positions, independent of wall clocks. + Acknowledgements are monotonic knowledge, never a fabricated delivery of rolled-away work. + """ + validate_identifier(restore_id) + selected = iter(_ordered(checkpoint)) + expected = next(selected, None) + checkpoint_highwater, first_extra = 0, None + for source in _ordered(current): + scope = source.evidence.command_scope + if expected is not None and scope > expected.evidence.command_scope: + raise Conflict("restore_checkpoint_unexplained") + if expected is not None and scope == expected.evidence.command_scope: + if replace(source.evidence, delivered=expected.evidence.delivered) != expected.evidence: + raise Conflict("restore_evidence_conflict") + if expected.evidence.delivered and not source.evidence.delivered: + raise Conflict("restore_delivery_regressed") + if expected.archived_by is not None and expected.archived_by != source.archived_by: + raise Conflict("restore_history_conflict") + checkpoint_highwater = max(checkpoint_highwater, expected.evidence.position) + result = replace( + source, + current=expected.current, + archived_by=source.archived_by or (None if expected.current else restore_id), + ) + expected = next(selected, None) + else: + position = source.evidence.position + first_extra = position if first_extra is None else min(first_extra, position) + result = replace(source, current=False, archived_by=source.archived_by or restore_id) + yield result + if expected is not None: + raise Conflict("restore_checkpoint_unexplained") + if first_extra is not None and first_extra <= checkpoint_highwater: + raise Conflict("restore_checkpoint_incomplete") + + +@dataclass(frozen=True, slots=True) +class RestoreSource: + installation: Installation + schema_version: int + generation_id: str | None + identity_sha256: str | None + + def __post_init__(self) -> None: + if ( + not isinstance(self.installation, Installation) + or type(self.schema_version) is not int + or not 1 <= self.schema_version <= 2**31 - 1 + or (self.generation_id is not None and self.identity_sha256 is None) + ): + raise InvalidInput("invalid_restore_source") + if self.generation_id is not None: + validate_identifier(self.generation_id) + if self.identity_sha256 is not None and ( + type(self.identity_sha256) is not str + or not re.fullmatch(r"[a-f0-9]{64}", self.identity_sha256) + ): + raise InvalidInput("invalid_restore_source") + + def document(self) -> JsonDocument: + return JsonDocument.from_value( + { + "instance_id": self.installation.instance_id, + "channel": self.installation.channel, + "schema_version": self.schema_version, + "generation_id": self.generation_id, + "identity_sha256": self.identity_sha256, + } + ) + + @classmethod + def from_value(cls, value: object) -> RestoreSource: + if type(value) is not dict or set(value) != { + "instance_id", + "channel", + "schema_version", + "generation_id", + "identity_sha256", + }: + raise InvalidInput("invalid_restore_source") + data = cast(dict[str, object], value) + return cls( + Installation(cast(str, data["instance_id"]), cast(str, data["channel"])), + cast(int, data["schema_version"]), + cast(str | None, data["generation_id"]), + cast(str | None, data["identity_sha256"]), + ) + + +@dataclass(frozen=True, slots=True) +class PreparedRestoration: + restore_id: str + checkpoint: Snapshot + source_frontier_sha256: str + operations_sha256: str + identity_sha256: str + operation_count: int + current_operations: int + highwater: int + prepared_at: int + source: RestoreSource | None = None + + def __post_init__(self) -> None: + validate_identifier(self.restore_id) + if ( + not isinstance(self.checkpoint, Snapshot) + or ( + self.source is not None + and ( + not isinstance(self.source, RestoreSource) + or self.source.installation != self.installation + ) + ) + or any( + type(value) is not int or not 0 <= value <= 2**63 - 1 + for value in ( + self.operation_count, + self.current_operations, + self.highwater, + self.prepared_at, + ) + ) + or self.current_operations > self.operation_count + or self.highwater < self.operation_count + or (self.operation_count == 0) != (self.highwater == 0) + ): + raise InvalidInput("invalid_restore_preparation") + for digest in (self.source_frontier_sha256, self.operations_sha256, self.identity_sha256): + if type(digest) is not str or not re.fullmatch(r"[a-f0-9]{64}", digest): + raise InvalidInput("invalid_restore_digest") + + @property + def installation(self) -> Installation: + return self.checkpoint.installation + + def document(self) -> JsonDocument: + value: JsonObject = { + "format_version": 2 if self.source is not None else 1, + "state": "prepared", + "restore_id": self.restore_id, + "checkpoint": self.checkpoint.document().decode(), + "source_frontier_sha256": self.source_frontier_sha256, + "operations_sha256": self.operations_sha256, + "identity_sha256": self.identity_sha256, + "operation_count": self.operation_count, + "current_operations": self.current_operations, + "highwater": self.highwater, + "prepared_at": self.prepared_at, + } + if self.source is not None: + value["source"] = self.source.document().decode() + return JsonDocument.from_value(value) + + @classmethod + def from_value(cls, value: object) -> PreparedRestoration: + if type(value) is not dict: + raise InvalidInput("invalid_restore_preparation") + data = cast(dict[str, object], value) + try: + if type(data["format_version"]) is not int or data["format_version"] not in (1, 2): + raise InvalidInput("invalid_restore_preparation") + snapshot = data["checkpoint"] + if type(snapshot) is not dict: + raise InvalidInput("invalid_restore_preparation") + item = cast(dict[str, object], snapshot) + checkpoint = Snapshot( + cast(str, item["snapshot_id"]), + Installation(cast(str, item["instance_id"]), cast(str, item["channel"])), + cast(int, item["schema_version"]), + cast(int, item["captured_at"]), + cast(int | None, item["last_committed_at"]), + cast(str, item["database_sha256"]), + cast(int, item["database_bytes"]), + cast(str, item["identity_sha256"]), + cast(int, item["format_version"]), + ) + result = cls( + cast(str, data["restore_id"]), + checkpoint, + cast(str, data["source_frontier_sha256"]), + cast(str, data["operations_sha256"]), + cast(str, data["identity_sha256"]), + cast(int, data["operation_count"]), + cast(int, data["current_operations"]), + cast(int, data["highwater"]), + cast(int, data["prepared_at"]), + RestoreSource.from_value(data["source"]) if data["format_version"] == 2 else None, + ) + if result.document().decode() != data: + raise InvalidInput("invalid_restore_preparation") + return result + except KeyError: + raise InvalidInput("invalid_restore_preparation") from None diff --git a/backend/src/domus/domain/snapshots.py b/backend/src/domus/domain/snapshots.py new file mode 100644 index 0000000..e6a121d --- /dev/null +++ b/backend/src/domus/domain/snapshots.py @@ -0,0 +1,84 @@ +"""Immutable snapshot metadata identifies one checkpoint without local paths or credentials.""" + +import re +from dataclasses import dataclass +from typing import cast + +from domus.domain.errors import InvalidInput +from domus.domain.storage import Installation, JsonDocument, validate_identifier + + +def _timestamp(value: int) -> None: + if type(value) is not int or not 0 <= value <= 2**63 - 1: + raise InvalidInput("invalid_snapshot_timestamp") + + +@dataclass(frozen=True, slots=True) +class Snapshot: + snapshot_id: str + installation: Installation + schema_version: int + captured_at: int + last_committed_at: int | None + database_sha256: str + database_bytes: int + identity_sha256: str + format_version: int = 1 + + def __post_init__(self) -> None: + validate_identifier(self.snapshot_id) + _timestamp(self.captured_at) + if self.last_committed_at is not None: + _timestamp(self.last_committed_at) + if ( + not isinstance(self.installation, Installation) + or type(self.format_version) is not int + or self.format_version != 1 + or type(self.schema_version) is not int + or not 1 <= self.schema_version <= 2**31 - 1 + or type(self.database_bytes) is not int + or not 1 <= self.database_bytes <= 2**63 - 1 + ): + raise InvalidInput("invalid_snapshot") + for digest in (self.database_sha256, self.identity_sha256): + if type(digest) is not str or not re.fullmatch(r"[a-f0-9]{64}", digest): + raise InvalidInput("invalid_snapshot_digest") + + def document(self) -> JsonDocument: + return JsonDocument.from_value( + { + "format_version": self.format_version, + "snapshot_id": self.snapshot_id, + "instance_id": self.installation.instance_id, + "channel": self.installation.channel, + "schema_version": self.schema_version, + "captured_at": self.captured_at, + "last_committed_at": self.last_committed_at, + "database_sha256": self.database_sha256, + "database_bytes": self.database_bytes, + "identity_sha256": self.identity_sha256, + } + ) + + @classmethod + def from_value(cls, value: object) -> Snapshot: + if type(value) is not dict: + raise InvalidInput("invalid_snapshot") + data = cast(dict[str, object], value) + try: + result = cls( + cast(str, data["snapshot_id"]), + Installation(cast(str, data["instance_id"]), cast(str, data["channel"])), + cast(int, data["schema_version"]), + cast(int, data["captured_at"]), + cast(int | None, data["last_committed_at"]), + cast(str, data["database_sha256"]), + cast(int, data["database_bytes"]), + cast(str, data["identity_sha256"]), + cast(int, data["format_version"]), + ) + if result.document().decode() != data: + raise InvalidInput("invalid_snapshot") + return result + except KeyError: + raise InvalidInput("invalid_snapshot") from None diff --git a/backend/src/domus/domain/storage.py b/backend/src/domus/domain/storage.py new file mode 100644 index 0000000..fd97bbb --- /dev/null +++ b/backend/src/domus/domain/storage.py @@ -0,0 +1,210 @@ +"""Immutable identities, canonical payloads and durable household facts.""" + +import hashlib +import json +import math +import re +from dataclasses import dataclass +from typing import Self, cast +from uuid import UUID + +from domus.domain.errors import InvalidInput + +type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue] +type JsonObject = dict[str, JsonValue] + + +def validate_identifier(value: str) -> None: + if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}", value): + raise InvalidInput("invalid_identifier") + + +def _json_value(value: object, depth: int = 0) -> None: + if depth > 16: + raise InvalidInput("invalid_document") + if type(value) in (type(None), bool, int, str): + return + if type(value) is float and math.isfinite(value): + return + if type(value) is list: + for item in value: + _json_value(item, depth + 1) + return + if type(value) is dict: + for key, item in value.items(): + if not isinstance(key, str): + raise InvalidInput("invalid_document") + _json_value(item, depth + 1) + return + raise InvalidInput("invalid_document") + + +def _unique_object(pairs: list[tuple[str, JsonValue]]) -> JsonObject: + result: JsonObject = {} + for key, value in pairs: + if key in result: + raise InvalidInput("invalid_document") + result[key] = value + return result + + +@dataclass(frozen=True, slots=True) +class JsonDocument: + """A bounded canonical object whose decoded copies cannot mutate its digest.""" + + encoded: str + + def __post_init__(self) -> None: + try: + if not isinstance(self.encoded, str) or len(self.encoded.encode("utf-8")) > 65_536: + raise InvalidInput("invalid_document") + value = json.loads(self.encoded, object_pairs_hook=_unique_object) + if not isinstance(value, dict): + raise InvalidInput("invalid_document") + _json_value(value) + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + if len(encoded.encode("utf-8")) > 65_536: + raise InvalidInput("invalid_document") + except TypeError, ValueError, UnicodeError, RecursionError: + raise InvalidInput("invalid_document") from None + object.__setattr__(self, "encoded", encoded) + + @classmethod + def from_value(cls, value: object) -> Self: + _json_value(value) + try: + return cls(json.dumps(value, ensure_ascii=False)) + except TypeError, ValueError: + raise InvalidInput("invalid_document") from None + + def decode(self) -> JsonObject: + return cast(JsonObject, json.loads(self.encoded)) + + @property + def fingerprint(self) -> str: + return hashlib.sha256(self.encoded.encode("utf-8")).hexdigest() + + +@dataclass(frozen=True, slots=True) +class Installation: + """Database ownership is explicit and cannot cross Stable and Edge.""" + + instance_id: str + channel: str + + def __post_init__(self) -> None: + try: + identity = UUID(self.instance_id) + except ValueError, TypeError, AttributeError: + raise InvalidInput("invalid_installation") from None + if ( + str(identity) != self.instance_id + or not identity.int + or self.channel not in ("stable", "edge") + ): + raise InvalidInput("invalid_installation") + + +@dataclass(frozen=True, slots=True) +class CommandKey: + """Possession of a replay key never replaces current actor authorization.""" + + household_id: str + actor_id: str + action: str + key: str + + def __post_init__(self) -> None: + for value in (self.household_id, self.actor_id, self.action, self.key): + validate_identifier(value) + + +@dataclass(frozen=True, slots=True) +class Household: + """The minimal durable household record; timezone catalogs belong to ports.""" + + household_id: str + name: str + time_zone: str + revision: int + + def __post_init__(self) -> None: + validate_identifier(self.household_id) + if ( + not isinstance(self.name, str) + or not isinstance(self.time_zone, str) + or not self.name.strip() + or len(self.name) > 160 + or any(ord(character) < 32 for character in self.name) + or not self.time_zone + or len(self.time_zone) > 128 + or any(ord(character) < 32 for character in self.time_zone) + or type(self.revision) is not int + or self.revision < 1 + or self.revision > 2**63 - 1 + ): + raise InvalidInput("invalid_household") + try: + self.name.encode("utf-8") + self.time_zone.encode("utf-8") + except UnicodeError: + raise InvalidInput("invalid_household") from None + object.__setattr__(self, "name", self.name.strip()) + + +@dataclass(frozen=True, slots=True) +class RestorationProvenance: + """First archival provenance and whether the original effect is in current state.""" + + archived_by: str + current: bool + + def __post_init__(self) -> None: + validate_identifier(self.archived_by) + if type(self.current) is not bool: + raise InvalidInput("invalid_restoration_provenance") + + +@dataclass(frozen=True, slots=True) +class Receipt: + """The original committed result, scoped independently of its public identifier.""" + + command: CommandKey + payload_fingerprint: str + operation_id: str + result: JsonDocument + created_at: int + restoration: RestorationProvenance | None = None + + def __post_init__(self) -> None: + if self.restoration is not None and not isinstance(self.restoration, RestorationProvenance): + raise InvalidInput("invalid_receipt_provenance") + + @property + def historical(self) -> bool: + return self.restoration is not None and not self.restoration.current + + @property + def archived_by(self) -> str | None: + return self.restoration.archived_by if self.restoration is not None else None + + +@dataclass(frozen=True, slots=True) +class AuditEntry: + """An immutable business transition with its authenticated attribution.""" + + operation_id: str + command: CommandKey + detail: JsonDocument + created_at: int + + +@dataclass(frozen=True, slots=True) +class OutboxEvent: + """A committed fact awaiting independent, duplicate-safe projection.""" + + event_id: str + household_id: str + event_type: str + payload: JsonDocument + created_at: int diff --git a/backend/src/domus/entrypoints/__init__.py b/backend/src/domus/entrypoints/__init__.py new file mode 100644 index 0000000..213839d --- /dev/null +++ b/backend/src/domus/entrypoints/__init__.py @@ -0,0 +1 @@ +"""Transport-specific input and output adaptation belongs outside use cases.""" diff --git a/backend/src/domus/entrypoints/bounded_http.py b/backend/src/domus/entrypoints/bounded_http.py new file mode 100644 index 0000000..9bfa750 --- /dev/null +++ b/backend/src/domus/entrypoints/bounded_http.py @@ -0,0 +1,86 @@ +"""Bounded ASGI input and response protections shared by HTTP deployment adapters.""" + +from asyncio import timeout +from collections.abc import Callable, Mapping + +from starlette.responses import Response +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from domus.domain.errors import DomusError, InvalidInput + + +class BoundedHTTP: + def __init__( + self, + app: ASGIApp, + validate: Callable[[Scope], None], + correlation: Callable[[], str], + error: Callable[[int, str, str], Response], + headers: Mapping[str, str], + ) -> None: + self.app, self.validate, self.correlation = app, validate, correlation + self.error, self.headers = error, headers + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + correlation = self.correlation() + scope.setdefault("state", {})["correlation_id"] = correlation + try: + self.validate(scope) + except DomusError as error: + status = 422 if isinstance(error, InvalidInput) else 403 + await self.error( + status, "invalid_request" if status == 422 else "request_forbidden", correlation + )(scope, receive, send) + return + body = bytearray() + try: + async with timeout(5): + while True: + message = await receive() + if message["type"] == "http.disconnect": + return + data = message.get("body", b"") + if len(body) + len(data) > 65_536: + await self.error(413, "request_too_large", correlation)( + scope, receive, send + ) + return + body.extend(data) + if not message.get("more_body", False): + break + except TimeoutError: + await self.error(408, "request_timeout", correlation)(scope, receive, send) + return + if scope["method"] in ("GET", "HEAD") and body: + await self.error(422, "invalid_request", correlation)(scope, receive, send) + return + first = True + + async def buffered() -> Message: + nonlocal first + if first: + first = False + return {"type": "http.request", "body": bytes(body), "more_body": False} + return await receive() + + async def secured(message: Message) -> None: + if message["type"] == "http.response.start": + protected = {key.lower().encode("ascii") for key in self.headers} + message = { + **message, + "headers": [ + (key, value) + for key, value in message.get("headers", []) + if key.lower() not in protected + ] + + [ + (key.lower().encode("ascii"), value.encode("ascii")) + for key, value in self.headers.items() + ], + } + await send(message) + + await self.app(scope, buffered, secured) diff --git a/backend/src/domus/entrypoints/console_recovery.py b/backend/src/domus/entrypoints/console_recovery.py new file mode 100644 index 0000000..5c52b86 --- /dev/null +++ b/backend/src/domus/entrypoints/console_recovery.py @@ -0,0 +1,32 @@ +"""A real terminal displays the exact recovery binding before accepting explicit approval.""" + +import json +from dataclasses import dataclass +from typing import TextIO + +from domus.domain.errors import Forbidden +from domus.domain.identity import Member +from domus.domain.recovery import RecoveryRequest + + +@dataclass +class TerminalRecoveryConfirmation: + incoming: TextIO + outgoing: TextIO + + def confirm(self, request: RecoveryRequest, member: Member | None) -> bool: + if not self.incoming.isatty() or not self.outgoing.isatty(): + raise Forbidden("console_tty_required") + details = request.document().decode() + details["previous_role"] = member.role.value if member is not None else None + details["previous_status"] = member.status.value if member is not None else None + details["grant"] = ( + "Explicit Domus administrator; active membership. " + "Existing linkage and exceptions are retained." + ) + self.outgoing.write("Review the exact recovery target and installation:\n") + self.outgoing.write(json.dumps(details, ensure_ascii=True, indent=2) + "\n") + challenge = f"APPROVE {request.request_id} {request.target_ha_user_id}" + self.outgoing.write("To approve this target, type exactly: " + challenge + "\n> ") + self.outgoing.flush() + return self.incoming.readline(1024).rstrip("\r\n") == challenge diff --git a/backend/src/domus/entrypoints/household_http.py b/backend/src/domus/entrypoints/household_http.py new file mode 100644 index 0000000..4e436f5 --- /dev/null +++ b/backend/src/domus/entrypoints/household_http.py @@ -0,0 +1,444 @@ +"""Strict household wire contracts adapt only server-bound application use cases.""" + +import re +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Annotated, Any, Literal + +from fastapi import APIRouter, Query, Request +from fastapi.params import Depends +from fastapi.routing import APIRoute +from pydantic import Field +from starlette.responses import Response + +from domus.application.household_commands import HouseholdCommands, SetupHousehold +from domus.application.household_queries import AccessState, AccessView, HouseholdQueries +from domus.application.member_commands import MemberCommands, UpdateMember +from domus.application.policy_commands import PolicyCommands, UpdatePolicy +from domus.application.recovery_commands import RecoveryCommands, RecoveryQueries +from domus.domain.errors import DomusError, InvalidInput +from domus.domain.identity import ( + AdministratorMapping, + MappingMode, + MemberStatus, + Permission, + PermissionRule, + ProfileRule, + Role, +) +from domus.domain.storage import JsonDocument, JsonValue, Receipt +from domus.entrypoints.http_contract import StrictModel + + +@dataclass(frozen=True, slots=True) +class HouseholdHTTPContext: + queries: Callable[[], HouseholdQueries] + household: HouseholdCommands + members: MemberCommands + policy: PolicyCommands + recovery: RecoveryCommands + recovery_queries: Callable[[], RecoveryQueries] + + +class _JSONRoute(APIRoute): + def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: + handler = super().get_route_handler() + + async def strict_json(request: Request) -> Response: + if request.method in ("POST", "PUT"): + media = request.headers.getlist("content-type") + if len(media) != 1 or not re.fullmatch( + r"application/json(?:\s*;\s*charset=utf-8)?", media[0], flags=re.IGNORECASE + ): + raise InvalidInput("invalid_request") + try: + JsonDocument((await request.body()).decode("utf-8")) + except DomusError, UnicodeError: + raise InvalidInput("invalid_request") from None + return await handler(request) + + return strict_json + + +class _EmptyQuery(StrictModel): + pass + + +class PermissionDecision(StrictModel): + permission: Permission = Field(strict=False) + allow: bool + + +class ProfileDecision(PermissionDecision): + role: Role = Field(strict=False) + + +class SetupRequest(StrictModel): + expected_revision: int = Field(ge=0, le=0) + name: str = Field(min_length=1, max_length=160) + time_zone: str = Field(min_length=1, max_length=128) + mapping_mode: MappingMode = Field(default=MappingMode.ALL, strict=False) + selected_ha_user_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$") + + +class HouseholdUpdateRequest(StrictModel): + expected_revision: int = Field(ge=1, le=2**63 - 1) + name: str = Field(min_length=1, max_length=160) + time_zone: str = Field(min_length=1, max_length=128) + + +class EnrollmentRequest(StrictModel): + expected_revision: int = Field(ge=0, le=0) + + +class RecoveryTransitionRequest(StrictModel): + expected_revision: int = Field(ge=1, le=2**63 - 1) + + +class RecoveryResponse(StrictModel): + request_id: str + state: Literal["pending", "approved", "consumed", "canceled", "expired"] + revision: int + target_ha_user_id: str + mapping_mode: Literal["setup_administrator", "selected_ha_user"] + policy_revision: int + member_id: str | None + member_revision: int + created_at: int + expires_at: int + approved_at: int | None + closed_at: int | None + + +class MemberUpdateRequest(StrictModel): + expected_revision: int = Field(ge=1, le=2**63 - 1) + role: Role = Field(strict=False) + status: MemberStatus = Field(strict=False) + exceptions: list[PermissionDecision] = Field(default_factory=list, max_length=256) + + +class PolicyUpdateRequest(StrictModel): + expected_revision: int = Field(ge=1, le=2**63 - 1) + mapping_mode: MappingMode = Field(strict=False) + target_ha_user_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$") + adjustments: list[ProfileDecision] = Field(default_factory=list, max_length=256) + + +class HouseholdResponse(StrictModel): + household_id: str + name: str + time_zone: str + revision: int + + +class MemberResponse(StrictModel): + member_id: str + role: Role + status: MemberStatus + revision: int + ha_user_id: str | None = None + exceptions: tuple[PermissionDecision, ...] | None = None + + +class MembersResponse(StrictModel): + members: tuple[MemberResponse, ...] + + +class MappingResponse(StrictModel): + mode: MappingMode + target_ha_user_id: str | None + + +class PolicyResponse(StrictModel): + household_id: str + mapping: MappingResponse + revision: int + adjustments: tuple[ProfileDecision, ...] + + +class AccessResponse(StrictModel): + state: AccessState + setup_allowed: bool + enrollment_allowed: bool + member: MemberResponse | None + policy_revision: int | None + effective_role: Role | None + permission_scope: Literal["household_administration"] + permissions: tuple[Permission, ...] + message: str + action_required: str + + +class ReceiptResponse(StrictModel): + state: Literal["committed", "historical"] = "committed" + archived_by: str | None = None + operation_id: str + result: dict[str, JsonValue] + created_at: int + message: str = "The household change was saved. Event delivery is tracked separately." + action_required: str = ( + "Keep the operation ID. If the response was lost, retry the same payload and key." + ) + + +class OperationResponse(ReceiptResponse): + delivery: Literal["pending", "delivered", "suppressed"] + + +def _access(view: AccessView) -> AccessResponse: + message, action = { + "unconfigured": ( + "The household is not configured. Signing in did not create it.", + "Set up the household explicitly with fresh Home Assistant administrator proof." + if view.setup_allowed + else "Ask a Home Assistant administrator to set up the household.", + ), + "not_enrolled": ( + "This Home Assistant account has no membership in this household.", + "Request account linkage explicitly. Household access begins after activation.", + ), + "pending": ( + "Your verified Home Assistant account was linked. " + "The membership is pending and household access is not active.", + "Ask a household administrator to review and activate your membership.", + ), + "active": ( + "Your household membership is active. Current permissions apply to each request.", + "No membership action is required. Changes also require fresh Home Assistant proof.", + ), + "suspended": ( + "Your membership is suspended and household access is blocked.", + "Ask a household administrator to review the suspension.", + ), + "archived": ( + "Your membership is archived and household access is blocked.", + "Ask a household administrator to review and reactivate your existing membership.", + ), + }[view.state] + return AccessResponse( + state=view.state, + setup_allowed=view.setup_allowed, + enrollment_allowed=view.enrollment_allowed, + member=MemberResponse.model_validate(view.member) if view.member is not None else None, + policy_revision=view.policy_revision, + effective_role=view.effective_role, + permission_scope=view.permission_scope, + permissions=view.permissions, + message=message, + action_required=action, + ) + + +def _receipt(receipt: Receipt) -> ReceiptResponse: + if receipt.historical: + return ReceiptResponse( + state="historical", + archived_by=receipt.archived_by, + operation_id=receipt.operation_id, + result=receipt.result.decode(), + created_at=receipt.created_at, + message=( + "This operation was processed before restoration. " + "Its effect is absent from current state." + ), + action_required=( + "Review current state before requesting new work. " + "Keep the original key and operation ID." + ), + ) + response = ReceiptResponse( + archived_by=receipt.archived_by, + operation_id=receipt.operation_id, + result=receipt.result.decode(), + created_at=receipt.created_at, + ) + if receipt.command.action == "member.enroll": + pending = response.result["status"] == "pending" + response.message = ( + "This operation verified and linked your Home Assistant account and saved the " + "membership as pending. It did not activate household access." + if pending + else "This operation linked your verified Home Assistant account and activated " + "its household membership." + ) + response.action_required = ( + "Check your current membership status. If still pending, ask a household " + "administrator to review it. Keep the original operation ID and retry only " + "the same payload and key." + if pending + else "Check your current membership status and permissions before requesting work. " + "Keep the original operation ID and retry only the same payload and key." + ) + return response + + +def _key(request: Request) -> str: + values = request.headers.getlist("idempotency-key") + if len(values) != 1 or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}", values[0]): + raise InvalidInput("invalid_idempotency_key") + return values[0] + + +def create_household_router( + context: Callable[[Request], HouseholdHTTPContext], *, security: Depends +) -> APIRouter: + router = APIRouter(prefix="/api/v1", dependencies=[security], route_class=_JSONRoute) + write_contract = { + "parameters": [ + {"name": name, "in": "header", "required": True, "schema": {"type": "string"}} + for name in ("Idempotency-Key", "Origin", "X-CSRF-Token") + ] + } + + @router.get("/authorization", response_model=AccessResponse, response_model_exclude_none=True) + def access(request: Request, query: Annotated[_EmptyQuery, Query()]) -> AccessResponse: + return _access(context(request).queries().access()) + + @router.get("/household", response_model=HouseholdResponse) + def household(request: Request, query: Annotated[_EmptyQuery, Query()]) -> HouseholdResponse: + return HouseholdResponse.model_validate(context(request).queries().household()) + + @router.get("/members", response_model=MembersResponse, response_model_exclude_none=True) + def members(request: Request, query: Annotated[_EmptyQuery, Query()]) -> MembersResponse: + return MembersResponse( + members=tuple( + MemberResponse.model_validate(member) + for member in context(request).queries().members() + ) + ) + + @router.get("/policy", response_model=PolicyResponse) + def policy(request: Request, query: Annotated[_EmptyQuery, Query()]) -> PolicyResponse: + return PolicyResponse.model_validate(context(request).queries().policy()) + + @router.get("/operations/{operation_id}", response_model=OperationResponse) + def operation( + operation_id: str, request: Request, query: Annotated[_EmptyQuery, Query()] + ) -> OperationResponse: + result = context(request).queries().operation(operation_id) + return OperationResponse( + **_receipt(result.receipt).model_dump(), + delivery=( + "delivered" + if result.delivered + else "suppressed" + if result.receipt.historical + else "pending" + ), + ) + + @router.post("/household", response_model=ReceiptResponse, openapi_extra=write_contract) + def setup( + request: Request, body: SetupRequest, query: Annotated[_EmptyQuery, Query()] + ) -> ReceiptResponse: + result = context(request).household.setup( + _key(request), + SetupHousehold( + body.name, + body.time_zone, + body.mapping_mode, + body.selected_ha_user_id, + body.expected_revision, + ), + ) + return _receipt(result) + + @router.put("/household", response_model=ReceiptResponse, openapi_extra=write_contract) + def update_household( + request: Request, body: HouseholdUpdateRequest, query: Annotated[_EmptyQuery, Query()] + ) -> ReceiptResponse: + result = context(request).household.update( + _key(request), body.name, body.time_zone, body.expected_revision + ) + return _receipt(result) + + @router.post( + "/members/enrollment", response_model=ReceiptResponse, openapi_extra=write_contract + ) + def enroll( + request: Request, body: EnrollmentRequest, query: Annotated[_EmptyQuery, Query()] + ) -> ReceiptResponse: + return _receipt(context(request).household.enroll(_key(request), body.expected_revision)) + + @router.put( + "/members/{member_id}", response_model=ReceiptResponse, openapi_extra=write_contract + ) + def update_member( + member_id: str, + request: Request, + body: MemberUpdateRequest, + query: Annotated[_EmptyQuery, Query()], + ) -> ReceiptResponse: + result = context(request).members.update( + _key(request), + UpdateMember( + member_id, + body.expected_revision, + body.role, + body.status, + tuple(PermissionRule(rule.permission, rule.allow) for rule in body.exceptions), + ), + ) + return _receipt(result) + + @router.put("/policy", response_model=ReceiptResponse, openapi_extra=write_contract) + def update_policy( + request: Request, body: PolicyUpdateRequest, query: Annotated[_EmptyQuery, Query()] + ) -> ReceiptResponse: + result = context(request).policy.update( + _key(request), + UpdatePolicy( + body.expected_revision, + AdministratorMapping(body.mapping_mode, body.target_ha_user_id), + tuple( + ProfileRule(rule.role, rule.permission, rule.allow) for rule in body.adjustments + ), + ), + ) + return _receipt(result) + + @router.post("/recovery/requests", response_model=ReceiptResponse, openapi_extra=write_contract) + def start_recovery( + request: Request, body: EnrollmentRequest, query: Annotated[_EmptyQuery, Query()] + ) -> ReceiptResponse: + return _receipt(context(request).recovery.start(_key(request), body.expected_revision)) + + @router.get("/recovery/requests/{request_id}", response_model=RecoveryResponse) + def recovery_status( + request_id: str, request: Request, query: Annotated[_EmptyQuery, Query()] + ) -> RecoveryResponse: + return RecoveryResponse.model_validate( + context(request).recovery_queries().get(request_id).decode() + ) + + @router.post( + "/recovery/requests/{request_id}/complete", + response_model=ReceiptResponse, + openapi_extra=write_contract, + ) + def complete_recovery( + request_id: str, + request: Request, + body: RecoveryTransitionRequest, + query: Annotated[_EmptyQuery, Query()], + ) -> ReceiptResponse: + return _receipt( + context(request).recovery.complete(_key(request), request_id, body.expected_revision) + ) + + @router.post( + "/recovery/requests/{request_id}/cancel", + response_model=ReceiptResponse, + openapi_extra=write_contract, + ) + def cancel_recovery( + request_id: str, + request: Request, + body: RecoveryTransitionRequest, + query: Annotated[_EmptyQuery, Query()], + ) -> ReceiptResponse: + return _receipt( + context(request).recovery.cancel(_key(request), request_id, body.expected_revision) + ) + + return router diff --git a/backend/src/domus/entrypoints/http.py b/backend/src/domus/entrypoints/http.py new file mode 100644 index 0000000..163ff97 --- /dev/null +++ b/backend/src/domus/entrypoints/http.py @@ -0,0 +1,391 @@ +"""Strict authentication DTOs and HTTP cookie/origin boundaries; no business storage.""" + +import hashlib +import hmac +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Annotated, Literal, Self, cast +from urllib.parse import parse_qsl, urlsplit + +from fastapi import FastAPI, Query, Request, Security +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, RedirectResponse, Response +from fastapi.security import APIKeyCookie +from pydantic import Field, model_validator +from starlette.exceptions import HTTPException +from starlette.types import ASGIApp, Scope + +from domus.application.authentication import Authentication +from domus.application.identity_ports import LoginProvider +from domus.application.sessions import SessionGuard +from domus.domain.errors import ( + DomusError, + Forbidden, + IdentityRejected, + InvalidInput, + Unauthenticated, +) +from domus.entrypoints.bounded_http import BoundedHTTP +from domus.entrypoints.household_http import HouseholdHTTPContext, create_household_router +from domus.entrypoints.http_contract import ErrorResponse, StrictModel +from domus.entrypoints.http_errors import classify + + +@dataclass(frozen=True, slots=True) +class HTTPSettings: + origin: str + cookie_tag: str + csrf_secret: bytes = field(repr=False) + + def __post_init__(self) -> None: + if ( + not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", self.cookie_tag) + or type(self.csrf_secret) is not bytes + or len(self.csrf_secret) < 32 + ): + raise InvalidInput("invalid_http_settings") + + @property + def secure(self) -> bool: + return self.origin.startswith("https://") + + @property + def session_cookie(self) -> str: + return ("__Host-" if self.secure else "") + self.cookie_tag + "-session" + + @property + def browser_cookie(self) -> str: + return ("__Host-" if self.secure else "") + self.cookie_tag + "-browser" + + def csrf(self, session_id: str) -> str: + return hmac.new(self.csrf_secret, session_id.encode("utf-8"), hashlib.sha256).hexdigest() + + +def _cookie(scope: Scope, name: str, *, required: bool = True) -> str | None: + candidates: list[str] = [] + for key, value in scope["headers"]: + if key.lower() == b"cookie": + for pair in value.decode("latin1").split(";"): + cookie_name, separator, content = pair.strip().partition("=") + if cookie_name == name and separator: + candidates.append(content) + if not candidates and not required: + return None + if len(candidates) != 1 or not re.fullmatch(r"[A-Za-z0-9._~-]{1,128}", candidates[0]): + raise Unauthenticated("session_required") + return candidates[0] + + +_SECURITY_HEADERS = { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", +} + + +def _error(status: int, code: str, correlation: str) -> JSONResponse: + restart_login = code in ("invalid_login_attempt", "login_attempt_expired") + return JSONResponse( + { + "state": "blocked", + "code": code, + "message": ( + "The sign-in attempt is missing, expired or no longer valid." + if restart_login + else ( + "Domus could not confirm the request result." + if status == 500 + else "The request was not accepted. No household change was made." + ) + ), + "action_required": ( + "Start a new sign-in at /api/v1/auth/start and complete it within five minutes. " + "Existing household records are retained." + if restart_login + else ( + "For a household change, retry the same payload and Idempotency-Key or check " + "its operation ID. Do not start a replacement command." + if status == 500 + else ( + "Review the request or sign in again. " + "Existing household records are retained." + ) + ) + ), + "correlation_id": correlation, + }, + status_code=status, + headers={**_SECURITY_HEADERS, **({"Retry-After": "1"} if code == "storage_busy" else {})}, + ) + + +class _HTTPBoundary(BoundedHTTP): + def __init__( + self, app: ASGIApp, settings: HTTPSettings, correlation: Callable[[], str] + ) -> None: + self.settings = settings + super().__init__(app, self._allowed, correlation, _error, _SECURITY_HEADERS) + + def _allowed(self, scope: Scope) -> None: + headers = scope["headers"] + if len(headers) > 100 or sum(len(key) + len(value) for key, value in headers) > 16_384: + raise InvalidInput("invalid_request") + + def values(name: bytes) -> list[bytes]: + return [value for key, value in headers if key.lower() == name] + + hosts = values(b"host") + expected = urlsplit(self.settings.origin) + if len(hosts) != 1 or any( + key.lower().startswith(b"x-remote-user-") or key.lower() == b"x-ingress-path" + for key, _ in headers + ): + raise Forbidden("request_forbidden") + try: + host = hosts[0].decode("ascii") + if any(character.isspace() or character in "/\\?#%" for character in host): + raise ValueError("invalid host") + actual = urlsplit(expected.scheme + "://" + host) + default = 443 if expected.scheme == "https" else 80 + actual_port = actual.port if actual.port is not None else default + expected_port = expected.port if expected.port is not None else default + if ( + actual.hostname != expected.hostname + or actual.username is not None + or actual_port != expected_port + ): + raise ValueError("invalid host") + except ValueError, UnicodeError: + raise Forbidden("request_forbidden") from None + query = scope.get("query_string", b"") + if len(query) > 8192: + raise InvalidInput("invalid_request") + pairs = parse_qsl(query.decode("latin1"), keep_blank_values=True) + if len(pairs) != len({key for key, _ in pairs}): + raise InvalidInput("invalid_request") + if scope["method"] in ("POST", "PUT", "PATCH", "DELETE"): + origins, csrf = values(b"origin"), values(b"x-csrf-token") + session_id = _cookie(scope, self.settings.session_cookie) + if ( + len(origins) != 1 + or origins[0] != self.settings.origin.encode("ascii") + or len(csrf) != 1 + or not hmac.compare_digest( + csrf[0], self.settings.csrf(cast(str, session_id)).encode("ascii") + ) + ): + raise Forbidden("request_forbidden") + + +_Strict = StrictModel + + +class EmptyRequest(_Strict): + pass + + +class CallbackQuery(_Strict): + state: str = Field(min_length=43, max_length=128, pattern=r"^[A-Za-z0-9._~-]+$") + code: str | None = Field( + default=None, min_length=1, max_length=4096, pattern=r"^[A-Za-z0-9._~-]+$" + ) + error: str | None = Field(default=None, min_length=1, max_length=64, pattern=r"^[a-z_]+$") + + @model_validator(mode="after") + def one_result(self) -> Self: + if (self.code is None) == (self.error is None): + raise ValueError("provide_one_callback_result") + return self + + +class SessionResponse(_Strict): + state: Literal["authenticated"] = "authenticated" + ha_user_id: str + ha_administrator: bool + access: Literal["full", "read_only"] + expires_in: int + message: str + action_required: str + + +class CSRFResponse(_Strict): + csrf_token: str + + +class LogoutResponse(_Strict): + state: Literal["logged_out"] = "logged_out" + upstream_revoked: bool | None + message: Literal["You are signed out of Domus."] = "You are signed out of Domus." + action_required: str + + +def create_app( + authentication: Authentication, + guard: SessionGuard, + provider: LoginProvider, + settings: HTTPSettings, + *, + correlation: Callable[[], str], + household_context: Callable[[str], HouseholdHTTPContext] | None = None, +) -> FastAPI: + app = FastAPI( + title="Domus", + version="0.0.0", + docs_url=None, + redoc_url=None, + openapi_url="/api/v1/openapi.json", + redirect_slashes=False, + responses={ + status: {"model": ErrorResponse} + for status in (400, 401, 403, 404, 405, 408, 409, 413, 422, 500, 503) + }, + ) + app.add_middleware(_HTTPBoundary, settings=settings, correlation=correlation) + session_security = Security( + APIKeyCookie(name=settings.session_cookie, scheme_name="DomusSession", auto_error=False) + ) + + async def domain_error(request: Request, error: Exception) -> Response: + status, code = classify(cast(DomusError, error)) + return _error(status, code, request.state.correlation_id) + + async def invalid_request(request: Request, error: Exception) -> Response: + return _error(422, "invalid_request", request.state.correlation_id) + + async def missing_route(request: Request, error: Exception) -> Response: + return _error( + cast(HTTPException, error).status_code, + "resource_unavailable", + request.state.correlation_id, + ) + + async def unexpected(request: Request, error: Exception) -> Response: + return _error(500, "internal_error", request.state.correlation_id) + + app.add_exception_handler(DomusError, domain_error) + app.add_exception_handler(RequestValidationError, invalid_request) + app.add_exception_handler(HTTPException, missing_route) + app.add_exception_handler(Exception, unexpected) + + def cookie(response: Response, name: str, value: str, age: int) -> None: + response.set_cookie( + name, + value, + max_age=age, + secure=settings.secure, + httponly=True, + samesite="lax", + path="/", + ) + + @app.get("/api/v1/auth/start", status_code=303, response_class=RedirectResponse) + def start(parameters: Annotated[EmptyRequest, Query()]) -> Response: + result = authentication.start() + response = RedirectResponse(result.authorization_url, status_code=303) + cookie(response, settings.browser_cookie, result.browser_token, 300) + return response + + @app.get( + "/api/v1/auth/callback", + status_code=303, + response_class=RedirectResponse, + description=( + "Exactly one of code or error is required, together with state and the bound browser " + "cookie. Providing both outcomes or neither returns 422 without consuming the attempt. " + "A code completes authorization; an error records a declined authorization." + ), + ) + def callback(request: Request, parameters: Annotated[CallbackQuery, Query()]) -> Response: + try: + browser = cast(str, _cookie(request.scope, settings.browser_cookie)) + except Unauthenticated: + raise IdentityRejected("invalid_login_attempt") from None + if parameters.error is not None: + authentication.cancel(parameters.state, browser) + response: Response = _error(401, "sign_in_declined", request.state.correlation_id) + else: + previous = _cookie(request.scope, settings.session_cookie, required=False) + lease = authentication.complete(cast(str, parameters.code), parameters.state, browser) + if previous is not None: + provider.sign_out(previous) + response = RedirectResponse("/api/v1/session", status_code=303) + cookie( + response, + settings.session_cookie, + lease.session_id, + max(0, (lease.expires_at - authentication.clock.now()) // 1000), + ) + response.delete_cookie( + settings.browser_cookie, path="/", secure=settings.secure, httponly=True, samesite="lax" + ) + return response + + @app.get("/api/v1/session", response_model=SessionResponse, dependencies=[session_security]) + def session(request: Request, parameters: Annotated[EmptyRequest, Query()]) -> SessionResponse: + status = guard.status(cast(str, _cookie(request.scope, settings.session_cookie))) + return SessionResponse( + ha_user_id=status.human.ha_user_id, + ha_administrator=status.human.is_administrator, + access="full" if status.fresh else "read_only", + expires_in=max(0, (status.expires_at - guard.clock.now()) // 1000), + message=( + "Your Home Assistant identity is verified." + if status.fresh + else "Home Assistant is unavailable. Your existing session has read-only access." + ), + action_required=( + "No action required." + if status.fresh + else "Reconnect to Home Assistant before making changes." + ), + ) + + @app.get("/api/v1/auth/csrf", response_model=CSRFResponse, dependencies=[session_security]) + def csrf(request: Request, parameters: Annotated[EmptyRequest, Query()]) -> CSRFResponse: + session_id = cast(str, _cookie(request.scope, settings.session_cookie)) + guard.read(session_id) + return CSRFResponse(csrf_token=settings.csrf(session_id)) + + @app.post( + "/api/v1/auth/logout", + response_model=LogoutResponse, + dependencies=[session_security], + openapi_extra={ + "parameters": [ + {"name": "Origin", "in": "header", "required": True, "schema": {"type": "string"}}, + { + "name": "X-CSRF-Token", + "in": "header", + "required": True, + "schema": {"type": "string", "minLength": 64, "maxLength": 64}, + }, + ] + }, + ) + def logout(request: Request, response: Response, body: EmptyRequest) -> LogoutResponse: + session_id = cast(str, _cookie(request.scope, settings.session_cookie)) + revoked = provider.sign_out(session_id) + response.delete_cookie( + settings.session_cookie, path="/", secure=settings.secure, httponly=True, samesite="lax" + ) + return LogoutResponse( + upstream_revoked=revoked, + action_required=( + "No action required." + if revoked is True + else ( + "Review the Domus authorization in your Home Assistant profile; " + "revocation was not confirmed." + ) + ), + ) + + if household_context is not None: + factory = household_context + + def context(request: Request) -> HouseholdHTTPContext: + return factory(cast(str, _cookie(request.scope, settings.session_cookie))) + + app.include_router(create_household_router(context, security=session_security)) + return app diff --git a/backend/src/domus/entrypoints/http_contract.py b/backend/src/domus/entrypoints/http_contract.py new file mode 100644 index 0000000..60d4004 --- /dev/null +++ b/backend/src/domus/entrypoints/http_contract.py @@ -0,0 +1,17 @@ +"""Common strict wire models are separate from application and domain records.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, from_attributes=True) + + +class ErrorResponse(StrictModel): + state: Literal["blocked"] + code: str + message: str + action_required: str + correlation_id: str diff --git a/backend/src/domus/entrypoints/http_errors.py b/backend/src/domus/entrypoints/http_errors.py new file mode 100644 index 0000000..4331ac7 --- /dev/null +++ b/backend/src/domus/entrypoints/http_errors.py @@ -0,0 +1,98 @@ +"""Allowlisted domain failure classification is shared by HTTP deployment adapters.""" + +from domus.domain.errors import ( + Conflict, + DomusError, + Forbidden, + IdentityUnavailable, + InvalidInput, + NotFound, + StorageBusy, + Unauthenticated, +) + +PUBLIC_CODES = frozenset( + { + "session_required", + "session_expired", + "session_changed", + "identity_mismatch", + "ha_unavailable", + "ha_rejected", + "invalid_ha_protocol", + "ha_subject_rejected", + "ha_directory_forbidden", + "invalid_login_material", + "invalid_login_attempt", + "login_attempt_expired", + "login_attempt_exists", + "login_capacity", + "invalid_login_result", + "session_exists", + "session_capacity", + "invalid_auth_value", + "invalid_session_lease", + "invalid_identifier", + "invalid_session_lifetime", + "permission_denied", + "revision_conflict", + "idempotency_conflict", + "invalid_request", + "sign_in_declined", + "request_forbidden", + "invalid_household", + "invalid_time_zone", + "invalid_revision", + "invalid_administrator_mapping", + "invalid_policy", + "invalid_permission_rule", + "protected_permission", + "invalid_profile_rule", + "invalid_member", + "invalid_permission_rules", + "invalid_profile_rules", + "invalid_idempotency_key", + "household_exists", + "member_exists", + "member_missing", + "member_capacity", + "mapping_target_inactive", + "setup_provenance_required", + "setup_administrator_mismatch", + "ha_administrator_required", + "identity_state_changed", + "last_administrator", + "household_configuration_required", + "operation_missing", + "storage_busy", + "invalid_ingress_subject", + "recovery_unavailable", + "recovery_missing", + "recovery_expired", + "recovery_not_pending", + "recovery_not_approved", + "recovery_closed", + "recovery_binding_changed", + "recovery_capacity", + "recovery_pending", + "recovery_exists", + "invalid_recovery_time", + } +) + + +def classify(error: DomusError) -> tuple[int, str]: + if error.code not in PUBLIC_CODES or type(error) is DomusError: + return 500, "internal_error" + for kind, status in ( + (Unauthenticated, 401), + (Forbidden, 403), + (InvalidInput, 422), + (Conflict, 409), + (IdentityUnavailable, 503), + (StorageBusy, 503), + (NotFound, 404), + ): + if isinstance(error, kind): + return status, error.code + return 500, "internal_error" diff --git a/backend/src/domus/entrypoints/ingress_boundary.py b/backend/src/domus/entrypoints/ingress_boundary.py new file mode 100644 index 0000000..6a195ea --- /dev/null +++ b/backend/src/domus/entrypoints/ingress_boundary.py @@ -0,0 +1,32 @@ +"""Shared native input bounds and actual Supervisor peer/base-path policy.""" + +import re + +from starlette.types import Scope + +from domus.domain.errors import Forbidden, InvalidInput + + +def require_ingress_source(scope: Scope) -> None: + headers = scope["headers"] + if ( + len(headers) > 100 + or sum(len(key) + len(value) for key, value in headers) > 16_384 + or scope.get("query_string", b"") + ): + raise InvalidInput("invalid_request") + peer = scope.get("client") + if ( + scope["path"] == "/health" + and scope["method"] in ("GET", "HEAD") + and peer + and peer[0] in ("127.0.0.1", "::1", "172.30.32.2") + ): + return + if peer is None or peer[0] != "172.30.32.2": + raise Forbidden("ingress_access_required") + prefixes = [value for key, value in headers if key.lower() == b"x-ingress-path"] + if len(prefixes) != 1 or not re.fullmatch( + rb"/api/hassio_ingress/[A-Za-z0-9_-]{16,256}", prefixes[0] + ): + raise Forbidden("ingress_access_required") diff --git a/backend/src/domus/entrypoints/ingress_http.py b/backend/src/domus/entrypoints/ingress_http.py new file mode 100644 index 0000000..6d3ffcc --- /dev/null +++ b/backend/src/domus/entrypoints/ingress_http.py @@ -0,0 +1,155 @@ +"""Native HA authentication preview; no OAuth, personal business data or mutations.""" + +import base64 +import hashlib +from collections.abc import Callable +from typing import cast + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse, Response +from pydantic import BaseModel, ConfigDict +from starlette.exceptions import HTTPException +from starlette.types import ASGIApp, Scope + +from domus.domain.errors import ( + DomusError, + Forbidden, + IdentityUnavailable, + InvalidInput, + Unauthenticated, +) +from domus.domain.identity import HumanIdentity +from domus.entrypoints.bounded_http import BoundedHTTP +from domus.entrypoints.http_contract import ErrorResponse +from domus.entrypoints.ingress_boundary import require_ingress_source + +IdentityProof = Callable[[str, tuple[tuple[str, str], ...]], HumanIdentity] +_STYLE = ( + "body{font:1rem system-ui;margin:3rem auto;padding:1rem;" + "max-width:44rem;color-scheme:light dark}" +) +_STYLE_HASH = base64.b64encode(hashlib.sha256(_STYLE.encode()).digest()).decode() +_HEADERS = { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": ( + f"default-src 'none'; style-src 'sha256-{_STYLE_HASH}'; base-uri 'none'; form-action 'none'" + ), +} + + +class IngressIdentityResponse(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + state: str = "authenticated" + source: str = "home_assistant_ingress" + ha_user_id: str + ha_administrator: bool + message: str = "Your existing Home Assistant session is verified." + + +def _blocked(status: int, code: str, correlation: str) -> Response: + return JSONResponse( + { + "state": "blocked", + "code": code, + "message": ( + "Domus could not verify access through Home Assistant. " + "No household change was made." + ), + "action_required": ( + "Open Domus from its Home Assistant app page. If already open there, " + "check the app's HA connection. No separate Domus login is required." + ), + "correlation_id": correlation, + }, + status_code=status, + headers=_HEADERS, + ) + + +class _IngressBoundary(BoundedHTTP): + def __init__(self, app: ASGIApp, correlation: Callable[[], str]) -> None: + super().__init__(app, self._allowed, correlation, _blocked, _HEADERS) + + def _allowed(self, scope: Scope) -> None: + require_ingress_source(scope) + if scope["method"] not in ("GET", "HEAD"): + raise Forbidden("request_forbidden") + + +def create_ingress_app( + identity: IdentityProof, channel: str, *, correlation: Callable[[], str] +) -> FastAPI: + if channel not in ("stable", "edge"): + raise InvalidInput("invalid_channel") + app = FastAPI( + title="Domus native HA preview", + version="0.0.0", + docs_url=None, + redoc_url=None, + openapi_url=None, + redirect_slashes=False, + responses={status: {"model": ErrorResponse} for status in (401, 403, 404, 422, 500, 503)}, + ) + app.add_middleware(_IngressBoundary, correlation=correlation) + + async def failure(request: Request, error: Exception) -> Response: + status, code = 500, "internal_error" + if isinstance(error, IdentityUnavailable): + status, code = 503, "ha_unavailable" + elif isinstance(error, Unauthenticated): + status, code = 401, "ha_rejected" + elif isinstance(error, Forbidden): + status, code = ( + 403, + ( + "ha_directory_forbidden" + if error.code == "ha_directory_forbidden" + else "request_forbidden" + ), + ) + elif isinstance(error, HTTPException): + status, code = error.status_code, "resource_unavailable" + return _blocked(status, code, request.state.correlation_id) + + for kind in (DomusError, HTTPException, Exception): + app.add_exception_handler(kind, failure) + + def prove(request: Request) -> HumanIdentity: + peer = cast(tuple[str, int], request.scope["client"]) + return identity( + peer[0], + tuple( + (key.decode("latin1"), value.decode("latin1")) + for key, value in request.scope["headers"] + ), + ) + + @app.get("/", response_class=HTMLResponse) + def home(request: Request) -> Response: + prove(request) + return HTMLResponse( + '' + '' + f"Domus {channel.title()}
" + f"

Domus {channel.title()}

Development preview

" + "

Connected to Home Assistant

" + "

Your existing Home Assistant sign-in is being used. " + "No additional login is required.

" + "

Household setup is not available in this build.

" + '

Refresh connection

' + ) + + @app.get("/api/v1/identity", response_model=IngressIdentityResponse) + def own_identity(request: Request) -> IngressIdentityResponse: + human = prove(request) + return IngressIdentityResponse( + ha_user_id=human.ha_user_id, ha_administrator=human.is_administrator + ) + + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "ready", "scope": "process"} + + return app diff --git a/backend/src/domus/entrypoints/native_http.py b/backend/src/domus/entrypoints/native_http.py new file mode 100644 index 0000000..84ee3e5 --- /dev/null +++ b/backend/src/domus/entrypoints/native_http.py @@ -0,0 +1,200 @@ +"""Native HTTP binds HA Ingress to local sessions and the common household API.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Annotated, Literal, cast + +from fastapi import Depends, FastAPI, Query, Request, Security +from fastapi.exceptions import RequestValidationError +from fastapi.responses import HTMLResponse, JSONResponse, Response +from fastapi.security import APIKeyCookie +from starlette.exceptions import HTTPException +from starlette.types import ASGIApp, Scope + +from domus.application.identity_ports import LeaseClock +from domus.application.sessions import SessionStatus +from domus.domain.errors import DomusError, Forbidden +from domus.entrypoints.bounded_http import BoundedHTTP +from domus.entrypoints.household_http import HouseholdHTTPContext, create_household_router +from domus.entrypoints.http import CSRFResponse, SessionResponse +from domus.entrypoints.http_contract import ErrorResponse, StrictModel +from domus.entrypoints.http_errors import classify +from domus.entrypoints.ingress_boundary import require_ingress_source + +_HEADERS = { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": ( + "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'" + ), +} + + +@dataclass(frozen=True, slots=True) +class NativeHTTPContext: + household: HouseholdHTTPContext + status: Callable[[], SessionStatus] + csrf: Callable[[], str] + verify_csrf: Callable[[str], None] + + +class NativeSessionResponse(SessionResponse): + source: Literal["home_assistant_ingress"] = "home_assistant_ingress" + + +class _Empty(StrictModel): + pass + + +def _error(status: int, code: str, correlation: str) -> Response: + uncertain = status == 500 + return JSONResponse( + { + "state": "blocked", + "code": code, + "message": ( + "Domus could not confirm the request result." + if uncertain + else "The request was not accepted. No household change was made." + ), + "action_required": ( + "Retry the same payload and Idempotency-Key or inspect the operation ID. " + "Do not start a replacement command." + if uncertain + else "Open or refresh Domus through Home Assistant and review current access. " + "No separate Domus login is required. Existing household records are retained." + ), + "correlation_id": correlation, + }, + status_code=status, + headers={**_HEADERS, **({"Retry-After": "1"} if code == "storage_busy" else {})}, + ) + + +class _NativeBoundary(BoundedHTTP): + def __init__( + self, app: ASGIApp, origins: tuple[str, ...], correlation: Callable[[], str] + ) -> None: + self.origins = origins + super().__init__(app, self._allowed, correlation, _error, _HEADERS) + + def _allowed(self, scope: Scope) -> None: + require_ingress_source(scope) + if scope["method"] not in ("GET", "HEAD", "POST", "PUT"): + raise Forbidden("request_forbidden") + if scope["method"] in ("POST", "PUT"): + origins = [value for name, value in scope["headers"] if name.lower() == b"origin"] + if len(origins) != 1 or origins[0].decode("latin1") not in self.origins: + raise Forbidden("request_forbidden") + if scope["path"] == "/health": + raise Forbidden("request_forbidden") + + +def create_native_app( + context: Callable[[Request], NativeHTTPContext], + origins: tuple[str, ...], + channel: str, + clock: LeaseClock, + *, + correlation: Callable[[], str], +) -> FastAPI: + app = FastAPI( + title="Domus native Home Assistant application", + version="0.0.0", + docs_url=None, + redoc_url=None, + openapi_url=None, + redirect_slashes=False, + responses={ + status: {"model": ErrorResponse} + for status in (400, 401, 403, 404, 405, 408, 409, 413, 422, 500, 503) + }, + ) + app.add_middleware(_NativeBoundary, origins=origins, correlation=correlation) + + async def domain_error(request: Request, error: Exception) -> Response: + status, code = classify(cast(DomusError, error)) + return _error(status, code, request.state.correlation_id) + + async def invalid(request: Request, error: Exception) -> Response: + return _error(422, "invalid_request", request.state.correlation_id) + + async def missing(request: Request, error: Exception) -> Response: + return _error( + cast(HTTPException, error).status_code, + "resource_unavailable", + request.state.correlation_id, + ) + + async def unexpected(request: Request, error: Exception) -> Response: + return _error(500, "internal_error", request.state.correlation_id) + + app.add_exception_handler(DomusError, domain_error) + app.add_exception_handler(RequestValidationError, invalid) + app.add_exception_handler(HTTPException, missing) + app.add_exception_handler(Exception, unexpected) + + cookie_scheme = APIKeyCookie( + name="ingress_session", scheme_name="HomeAssistantIngress", auto_error=False + ) + + def security(request: Request, _cookie: Annotated[str | None, Security(cookie_scheme)]) -> None: + access = context(request) + if request.method in ("POST", "PUT"): + tokens = request.headers.getlist("x-csrf-token") + if len(tokens) != 1: + raise Forbidden("request_forbidden") + access.verify_csrf(tokens[0]) + + @app.get( + "/api/v1/session", response_model=NativeSessionResponse, dependencies=[Depends(security)] + ) + def session(request: Request, query: Annotated[_Empty, Query()]) -> NativeSessionResponse: + status = context(request).status() + return NativeSessionResponse( + ha_user_id=status.human.ha_user_id, + ha_administrator=status.human.is_administrator, + access="full" if status.fresh else "read_only", + expires_in=max(0, (status.expires_at - clock.now()) // 1000), + message="Your existing Home Assistant session is verified." + if status.fresh + else "Home Assistant is unavailable. Your existing session has read-only access.", + action_required="No additional login is required." + if status.fresh + else "Reconnect to Home Assistant before making changes.", + ) + + @app.get("/api/v1/auth/csrf", response_model=CSRFResponse, dependencies=[Depends(security)]) + def csrf(request: Request, query: Annotated[_Empty, Query()]) -> CSRFResponse: + return CSRFResponse(csrf_token=context(request).csrf()) + + @app.get("/api/v1/openapi.json", dependencies=[Depends(security)]) + def schema(request: Request, query: Annotated[_Empty, Query()]) -> dict[str, object]: + context(request).status() + return app.openapi() + + @app.get("/", response_class=HTMLResponse, dependencies=[Depends(security)]) + def home(request: Request) -> Response: + context(request).status() + return HTMLResponse( + '' + '' + f"Domus {channel.title()}
" + f"

Domus {channel.title()}

Development preview

" + "

Connected to Home Assistant

" + "

Your existing Home Assistant sign-in is being used. " + "No additional login is required.

The household interface is being prepared.

" + '

Refresh connection

' + ) + + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "ready", "scope": "process"} + + app.include_router( + create_household_router( + lambda request: context(request).household, security=Depends(security) + ) + ) + return app diff --git a/backend/src/domus/infrastructure/__init__.py b/backend/src/domus/infrastructure/__init__.py new file mode 100644 index 0000000..dff7ed9 --- /dev/null +++ b/backend/src/domus/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Concrete persistence and provider adapters implement application ports.""" diff --git a/backend/src/domus/infrastructure/application_runtime.py b/backend/src/domus/infrastructure/application_runtime.py new file mode 100644 index 0000000..ad17f81 --- /dev/null +++ b/backend/src/domus/infrastructure/application_runtime.py @@ -0,0 +1,25 @@ +"""System UTC, collision-resistant record identifiers and IANA timezone validation.""" + +import secrets +import time +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from domus.domain.errors import InvalidInput + + +class SystemClock: + def now(self) -> int: + return time.time_ns() // 1_000_000 + + +class SecureIdentifiers: + def new(self) -> str: + return "domus_" + secrets.token_urlsafe(24) + + +class TimeZoneCatalog: + def require(self, time_zone: str) -> None: + try: + ZoneInfo(time_zone) + except ZoneInfoNotFoundError, ValueError, TypeError, UnicodeError: + raise InvalidInput("invalid_time_zone") from None diff --git a/backend/src/domus/infrastructure/auth_runtime.py b/backend/src/domus/infrastructure/auth_runtime.py new file mode 100644 index 0000000..69f56b1 --- /dev/null +++ b/backend/src/domus/infrastructure/auth_runtime.py @@ -0,0 +1,14 @@ +"""OS-backed entropy and process-local monotonic time for ephemeral authentication.""" + +import secrets +import time + + +class MonotonicClock: + def now(self) -> int: + return time.monotonic_ns() // 1_000_000 + + +class SecureEntropy: + def new(self) -> str: + return secrets.token_urlsafe(32) diff --git a/backend/src/domus/infrastructure/ha_config.py b/backend/src/domus/infrastructure/ha_config.py new file mode 100644 index 0000000..7ac9c73 --- /dev/null +++ b/backend/src/domus/infrastructure/ha_config.py @@ -0,0 +1,115 @@ +"""Operator-owned endpoints cannot be selected by request hosts or forwarding headers.""" + +import ipaddress +import re +from dataclasses import dataclass +from urllib.parse import urlsplit + +from domus.domain.errors import InvalidInput + + +def _origin(value: str) -> str: + if ( + not isinstance(value, str) + or len(value) > 2048 + or any(character.isspace() or ord(character) < 32 for character in value) + or any(character in value for character in ("?", "#", "\\", "%")) + ): + raise InvalidInput("invalid_origin") + try: + url = urlsplit(value) + host, port = url.hostname, url.port + if ( + url.scheme not in ("http", "https") + or not host + or url.username is not None + or url.password is not None + or url.path not in ("", "/") + or (port is not None and not 1 <= port <= 65535) + ): + raise ValueError("invalid origin") + try: + host = ipaddress.ip_address(host).compressed + except ValueError: + label = r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?" + if len(host) > 253 or not re.fullmatch(rf"{label}(?:\.{label})*", host): + raise ValueError("invalid host") from None + authority = f"[{host}]" if ":" in host else host + if port is not None and port != (443 if url.scheme == "https" else 80): + authority += f":{port}" + return f"{url.scheme}://{authority}" + except ValueError: + raise InvalidInput("invalid_origin") from None + + +@dataclass(frozen=True, slots=True) +class HAEndpoint: + origin: str + supervised: bool = False + + def __post_init__(self) -> None: + object.__setattr__(self, "origin", _origin(self.origin)) + if type(self.supervised) is not bool or ( + self.supervised and self.origin != "http://supervisor" + ): + raise InvalidInput("invalid_ha_endpoint") + + @classmethod + def supervisor(cls) -> HAEndpoint: + return cls("http://supervisor", supervised=True) + + @property + def host(self) -> str: + return str(urlsplit(self.origin).hostname) + + @property + def port(self) -> int: + return urlsplit(self.origin).port or (443 if self.origin.startswith("https:") else 80) + + @property + def websocket_url(self) -> str: + if self.supervised: + return "ws://supervisor/core/websocket" + scheme = "wss" if self.origin.startswith("https:") else "ws" + return f"{scheme}://{urlsplit(self.origin).netloc}/api/websocket" + + +@dataclass(frozen=True, slots=True) +class PublicOrigins: + values: tuple[str, ...] + + def __post_init__(self) -> None: + if type(self.values) is not tuple or not 1 <= len(self.values) <= 4: + raise InvalidInput("invalid_public_origins") + normalized = tuple(_origin(value) for value in self.values) + if len(set(normalized)) != len(normalized): + raise InvalidInput("invalid_public_origins") + object.__setattr__(self, "values", normalized) + + +@dataclass(frozen=True, slots=True) +class OAuthConfiguration: + ha: HAEndpoint + public_origin: str + development_http: bool = False + + def __post_init__(self) -> None: + if self.ha.supervised: + raise InvalidInput("supervisor_uses_ingress") + origin = _origin(self.public_origin) + host = str(urlsplit(origin).hostname) + try: + loopback = ipaddress.ip_address(host).is_loopback + except ValueError: + loopback = host == "localhost" + if not origin.startswith("https:") and not (self.development_http is True and loopback): + raise InvalidInput("insecure_public_origin") + object.__setattr__(self, "public_origin", origin) + + @property + def client_id(self) -> str: + return self.public_origin + "/" + + @property + def callback_url(self) -> str: + return self.public_origin + "/api/v1/auth/callback" diff --git a/backend/src/domus/infrastructure/ha_directory.py b/backend/src/domus/infrastructure/ha_directory.py new file mode 100644 index 0000000..47ccd5d --- /dev/null +++ b/backend/src/domus/infrastructure/ha_directory.py @@ -0,0 +1,22 @@ +"""A fixed HA directory adapter supplies human facts, never household privileges.""" + +from dataclasses import dataclass, field + +from domus.domain.identity import HumanIdentity +from domus.infrastructure.ha_http import _secret +from domus.infrastructure.ha_websocket import HomeAssistantWebSocket + + +@dataclass(frozen=True, slots=True) +class HomeAssistantDirectory: + lookup: HomeAssistantWebSocket + transport_token: str = field(repr=False) + + def __post_init__(self) -> None: + _secret(self.transport_token) + + def get(self, ha_user_id: str) -> HumanIdentity: + return self.lookup.directory_user(self.transport_token, ha_user_id) + + def eligible(self, ha_user_ids: tuple[str, ...]) -> tuple[HumanIdentity, ...]: + return self.lookup.directory_users(self.transport_token, ha_user_ids) diff --git a/backend/src/domus/infrastructure/ha_http.py b/backend/src/domus/infrastructure/ha_http.py new file mode 100644 index 0000000..0501aac --- /dev/null +++ b/backend/src/domus/infrastructure/ha_http.py @@ -0,0 +1,143 @@ +"""Bounded server-side HA OAuth form exchange with no redirects or environment proxies.""" + +import asyncio +import re +from asyncio import timeout +from dataclasses import dataclass, field +from typing import cast + +import httpx + +from domus.domain.errors import DomusError, IdentityRejected, IdentityUnavailable, InvalidInput +from domus.domain.storage import JsonDocument, JsonObject +from domus.infrastructure.ha_config import OAuthConfiguration + + +def _secret(value: str, *, minimum: int = 1, maximum: int = 8192) -> None: + if ( + not isinstance(value, str) + or not minimum <= len(value) <= maximum + or not re.fullmatch(r"[A-Za-z0-9._~-]+", value) + ): + raise InvalidInput("invalid_auth_value") + + +@dataclass(frozen=True, slots=True) +class OAuthTokens: + """Private, process-local transport state; never a DTO or business record.""" + + access_token: str = field(repr=False) + refresh_token: str = field(repr=False) + expires_in: int + provider: str + + def __post_init__(self) -> None: + _secret(self.access_token) + _secret(self.refresh_token) + if ( + type(self.expires_in) is not int + or not 1 <= self.expires_in <= 86_400 + or not isinstance(self.provider, str) + or not re.fullmatch(r"[a-z0-9_-]{1,128}", self.provider) + ): + raise InvalidInput("invalid_auth_value") + + +@dataclass +class HomeAssistantOAuth: + configuration: OAuthConfiguration + transport: httpx.AsyncBaseTransport | None = field(default=None, repr=False) + + def _post(self, path: str, values: dict[str, str], *, document: bool = True) -> JsonObject: + return asyncio.run(self._post_bounded(path, values, document=document)) + + async def _post_bounded( + self, path: str, values: dict[str, str], *, document: bool + ) -> JsonObject: + try: + async with ( + timeout(5), + httpx.AsyncClient( + trust_env=False, + follow_redirects=False, + timeout=httpx.Timeout(3, connect=2), + transport=self.transport, + headers={"Accept-Encoding": "identity"}, + ) as client, + ): + async with client.stream( + "POST", self.configuration.ha.origin + path, data=values + ) as response: + if response.status_code == 429 or response.status_code >= 500: + raise IdentityUnavailable("ha_unavailable") + if response.status_code in (400, 401, 403): + raise IdentityRejected("ha_rejected") + if ( + response.status_code != 200 + or response.headers.get("content-encoding", "identity") != "identity" + ): + raise IdentityRejected("invalid_ha_protocol") + payload = bytearray() + async for chunk in response.aiter_bytes(): + payload.extend(chunk) + if len(payload) > 65_536: + raise IdentityRejected("invalid_ha_protocol") + if not document: + if payload: + raise IdentityRejected("invalid_ha_protocol") + return {} + return JsonDocument(payload.decode("utf-8")).decode() + except httpx.RequestError, TimeoutError: + raise IdentityUnavailable("ha_unavailable") from None + except InvalidInput, UnicodeError: + raise IdentityRejected("invalid_ha_protocol") from None + + @staticmethod + def _tokens( + payload: JsonObject, *, refresh: str | None = None, provider: str | None = None + ) -> OAuthTokens: + try: + if payload["token_type"] != "Bearer": + raise InvalidInput("invalid_auth_value") + return OAuthTokens( + cast(str, payload["access_token"]), + refresh if refresh is not None else cast(str, payload["refresh_token"]), + cast(int, payload["expires_in"]), + provider if provider is not None else cast(str, payload["ha_auth_provider"]), + ) + except DomusError, KeyError: + raise IdentityRejected("invalid_ha_protocol") from None + + def exchange(self, code: str, verifier: str) -> OAuthTokens: + _secret(code, maximum=4096) + _secret(verifier, minimum=43, maximum=128) + return self._tokens( + self._post( + "/auth/token", + { + "grant_type": "authorization_code", + "code": code, + "client_id": self.configuration.client_id, + "code_verifier": verifier, + }, + ) + ) + + def refresh(self, refresh_token: str, provider: str) -> OAuthTokens: + _secret(refresh_token) + return self._tokens( + self._post( + "/auth/token", + { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": self.configuration.client_id, + }, + ), + refresh=refresh_token, + provider=provider, + ) + + def revoke(self, refresh_token: str) -> None: + _secret(refresh_token) + self._post("/auth/revoke", {"token": refresh_token}, document=False) diff --git a/backend/src/domus/infrastructure/ha_ingress.py b/backend/src/domus/infrastructure/ha_ingress.py new file mode 100644 index 0000000..8a99521 --- /dev/null +++ b/backend/src/domus/infrastructure/ha_ingress.py @@ -0,0 +1,42 @@ +"""Supervisor peer + stripped subject header + fresh Core proof establish identity.""" + +from dataclasses import dataclass, field + +from domus.domain.errors import IdentityRejected, InvalidInput +from domus.domain.identity import HumanIdentity +from domus.infrastructure.ha_http import _secret +from domus.infrastructure.ha_websocket import HomeAssistantWebSocket, _user_id + + +@dataclass(frozen=True, slots=True) +class SupervisorIdentity: + lookup: HomeAssistantWebSocket + transport_token: str = field(repr=False) + + def __post_init__(self) -> None: + if not self.lookup.endpoint.supervised: + raise InvalidInput("ingress_requires_supervisor") + _secret(self.transport_token) + + def subject(self, peer: str, headers: tuple[tuple[str, str], ...]) -> str: + """Return only a trusted candidate, never human or administrator proof. + + peer must be the socket peer with proxy rewriting disabled by wiring. + Forwarded headers and names cannot supply it. Duplicate subjects fail. + """ + if peer != "172.30.32.2": + raise IdentityRejected("untrusted_ingress_peer") + candidates = [value for name, value in headers if name.lower() == "x-remote-user-id"] + try: + if len(candidates) != 1: + raise InvalidInput("invalid_ha_user_id") + return _user_id(candidates[0]) + except InvalidInput: + raise IdentityRejected("invalid_ingress_subject") from None + + def prove(self, peer: str, headers: tuple[tuple[str, str], ...]) -> HumanIdentity: + subject = self.subject(peer, headers) + human = self.lookup.directory_user(self.transport_token, subject) + if human.ha_user_id != subject: + raise IdentityRejected("identity_mismatch") + return human diff --git a/backend/src/domus/infrastructure/ha_websocket.py b/backend/src/domus/infrastructure/ha_websocket.py new file mode 100644 index 0000000..9d1ae78 --- /dev/null +++ b/backend/src/domus/infrastructure/ha_websocket.py @@ -0,0 +1,196 @@ +"""Correlated HA proof over a fixed, bounded and nonredirecting WebSocket endpoint.""" + +import asyncio +import json +import logging +import re +from asyncio import timeout +from dataclasses import dataclass +from typing import cast + +from websockets.asyncio.client import ClientConnection, connect +from websockets.exceptions import ConnectionClosed, InvalidStatus, WebSocketException + +from domus.domain.errors import ( + DomusError, + Forbidden, + IdentityRejected, + IdentityUnavailable, + InvalidInput, +) +from domus.domain.identity import HumanIdentity +from domus.domain.storage import JsonDocument, JsonObject, JsonValue +from domus.infrastructure.ha_config import HAEndpoint +from domus.infrastructure.ha_http import OAuthTokens, _secret + +# A private logger prevents library wire-debug settings from recording auth frames. +_WIRE_LOGGER = logging.Logger("domus.ha.private-wire") +_WIRE_LOGGER.disabled = True + + +class _FixedEndpoint(connect): + """Pinned websockets 17.4 hook; real redirect fixtures guard upgrades.""" + + def process_redirect(self, exc: Exception) -> Exception | str: + return exc + + +def _user_id(value: object) -> str: + if not isinstance(value, str) or not re.fullmatch(r"[a-f0-9]{32}", value): + raise InvalidInput("invalid_ha_user_id") + return value + + +async def _message(connection: ClientConnection) -> JsonObject: + message = await connection.recv() + if not isinstance(message, str): + raise IdentityRejected("invalid_ha_protocol") + return JsonDocument(message).decode() + + +@dataclass(frozen=True, slots=True) +class HomeAssistantWebSocket: + endpoint: HAEndpoint + + async def _exchange(self, token: str, operation: str) -> JsonValue: + try: + async with ( + timeout(5), + _FixedEndpoint( + self.endpoint.websocket_url, + proxy=None, + compression=None, + open_timeout=2, + close_timeout=0.25, + max_size=65_536, + max_queue=4, + ping_interval=None, + logger=_WIRE_LOGGER, + ) as connection, + ): + if (await _message(connection)).get("type") != "auth_required": + raise IdentityRejected("invalid_ha_protocol") + await connection.send(json.dumps({"type": "auth", "access_token": token})) + authentication = await _message(connection) + if authentication.get("type") == "auth_invalid": + raise IdentityRejected("ha_rejected") + if authentication.get("type") != "auth_ok": + raise IdentityRejected("invalid_ha_protocol") + await connection.send(json.dumps({"id": 1, "type": operation})) + response = await _message(connection) + if ( + response.get("type") != "result" + or type(response.get("id")) is not int + or response["id"] != 1 + ): + raise IdentityRejected("invalid_ha_protocol") + if response.get("success") is False: + if operation == "config/auth/list": + raise Forbidden("ha_directory_forbidden") + raise IdentityRejected("ha_rejected") + if response.get("success") is not True or "result" not in response: + raise IdentityRejected("invalid_ha_protocol") + return response["result"] + except InvalidStatus as error: + status = error.response.status_code + if status == 429 or status >= 500: + raise IdentityUnavailable("ha_unavailable") from None + raise IdentityRejected("invalid_ha_protocol") from None + except ConnectionClosed as error: + if (error.sent is not None and error.sent.code == 1009) or ( + error.rcvd is not None and error.rcvd.code == 1009 + ): + raise IdentityRejected("invalid_ha_protocol") from None + raise IdentityUnavailable("ha_unavailable") from None + except TimeoutError, OSError: + raise IdentityUnavailable("ha_unavailable") from None + except WebSocketException, InvalidInput, ValueError: + raise IdentityRejected("invalid_ha_protocol") from None + + def _request(self, token: str, operation: str) -> JsonValue: + _secret(token) + return asyncio.run(self._exchange(token, operation)) + + def current_user(self, grant: OAuthTokens) -> HumanIdentity: + """Use only a server-exchanged client grant, never an incoming bearer token. + + HA 2026.10.4 rejects system users when creating a client-bound refresh + token and enforces active users during authentication. current_user alone + lacks system/active flags; that OAuth provenance is required here. + """ + result = self._request(grant.access_token, "auth/current_user") + try: + if not isinstance(result, dict): + raise InvalidInput("invalid_ha_identity") + user_id = _user_id(result["id"]) + credentials = result["credentials"] + if ( + not isinstance(credentials, list) + or not 1 <= len(credentials) <= 32 + or any(not isinstance(item, dict) for item in credentials) + or not any( + cast(JsonObject, item).get("auth_provider_type") == grant.provider + for item in credentials + ) + ): + raise InvalidInput("invalid_ha_identity") + return HumanIdentity(user_id, cast(bool, result["is_admin"]), True, False) + except DomusError, KeyError: + raise IdentityRejected("invalid_ha_protocol") from None + + def directory_user(self, transport_token: str, ha_user_id: str) -> HumanIdentity: + """Supervisor's credential only transports the exact subject lookup.""" + humans = self.directory_users(transport_token, (ha_user_id,)) + if not humans: + raise IdentityRejected("ha_subject_rejected") + return humans[0] + + def directory_users( + self, transport_token: str, ha_user_ids: tuple[str, ...] + ) -> tuple[HumanIdentity, ...]: + """One current directory response; errors cannot masquerade as absent humans.""" + if type(ha_user_ids) is not tuple or len(ha_user_ids) > 256: + raise InvalidInput("invalid_ha_directory") + for user_id in ha_user_ids: + _user_id(user_id) + requested = set(ha_user_ids) + if len(requested) != len(ha_user_ids): + raise InvalidInput("invalid_ha_directory") + if not requested: + return () + records = self._request(transport_token, "config/auth/list") + try: + if not isinstance(records, list) or len(records) > 256: + raise InvalidInput("invalid_ha_directory") + seen: set[str] = set() + humans: dict[str, HumanIdentity] = {} + for record in records: + if not isinstance(record, dict): + raise InvalidInput("invalid_ha_directory") + identity = _user_id(record["id"]) + if identity in seen: + raise InvalidInput("invalid_ha_directory") + seen.add(identity) + if identity not in requested: + continue + owner, active, system = ( + record["is_owner"], + record["is_active"], + record["system_generated"], + ) + if any(type(value) is not bool for value in (owner, active, system)): + raise InvalidInput("invalid_ha_directory") + groups = record["group_ids"] + if ( + not isinstance(groups, list) + or len(groups) > 32 + or any(not isinstance(group, str) for group in groups) + ): + raise InvalidInput("invalid_ha_directory") + if active and not system: + humans[identity] = HumanIdentity( + identity, bool(owner or "system-admin" in groups), True, False + ) + return tuple(humans[user_id] for user_id in ha_user_ids if user_id in humans) + except DomusError, KeyError: + raise IdentityRejected("ha_subject_rejected") from None diff --git a/backend/src/domus/infrastructure/ingress_sessions.py b/backend/src/domus/infrastructure/ingress_sessions.py new file mode 100644 index 0000000..11f28a2 --- /dev/null +++ b/backend/src/domus/infrastructure/ingress_sessions.py @@ -0,0 +1,170 @@ +"""Bind an existing Supervisor session to bounded local leases without another browser cookie.""" + +import hashlib +import hmac +import re +from dataclasses import dataclass, field +from threading import RLock + +from domus.application.authentication_values import validate_login_material +from domus.application.identity_ports import LeaseClock, LoginEntropy +from domus.application.session_values import SessionLease, validate_session_lifetime +from domus.application.sessions import SessionGuard, SessionStatus +from domus.domain.errors import Forbidden, IdentityRejected, InvalidInput, Unauthenticated +from domus.domain.identity import HumanIdentity +from domus.domain.storage import Installation +from domus.infrastructure.ha_ingress import SupervisorIdentity +from domus.infrastructure.memory_sessions import MemorySessions + + +@dataclass(frozen=True, slots=True) +class _RequestProof: + identity: SupervisorIdentity = field(repr=False) + peer: str + headers: tuple[tuple[str, str], ...] = field(repr=False) + + def prove(self, _session_id: str) -> HumanIdentity: + return self.identity.prove(self.peer, self.headers) + + +class IngressSessions: + def __init__( + self, + identity: SupervisorIdentity, + installation: Installation, + clock: LeaseClock, + entropy: LoginEntropy, + key: bytes, + *, + lifetime: int = 3_600_000, + capacity: int = 256, + ) -> None: + validate_session_lifetime(lifetime) + if type(key) is not bytes or len(key) < 32: + raise InvalidInput("invalid_ingress_session_key") + self.identity, self.installation = identity, installation + self.clock, self.entropy = clock, entropy + self.leases = MemorySessions(max_sessions=capacity) + self._key, self._lifetime = key, lifetime + self._bindings: dict[str, str] = {} + self._lock = RLock() + self._closed = False + + def request(self, peer: str, headers: tuple[tuple[str, str], ...]) -> IngressRequest: + subject = self.identity.subject(peer, headers) + prefixes = [value for name, value in headers if name.lower() == "x-ingress-path"] + if len(prefixes) != 1 or not re.fullmatch( + r"/api/hassio_ingress/[A-Za-z0-9_-]{16,256}", prefixes[0] + ): + raise IdentityRejected("invalid_ingress_subject") + cookies = [] + for name, value in headers: + if name.lower() == "cookie": + for pair in value.split(";"): + cookie_name, separator, content = pair.strip().partition("=") + if cookie_name == "ingress_session" and separator: + cookies.append(content) + if len(cookies) != 1 or not re.fullmatch(r"[a-f0-9]{128}", cookies[0]): + raise Unauthenticated("session_required") + context = "\0".join( + ( + self.installation.instance_id, + self.installation.channel, + subject, + prefixes[0], + cookies[0], + ) + ) + binding = hmac.new( + self._key, b"binding\0" + context.encode("ascii"), hashlib.sha256 + ).hexdigest() + return IngressRequest(self, binding, _RequestProof(self.identity, peer, headers)) + + def _open(self) -> None: + if self._closed: + raise IdentityRejected("session_required") + + def _prune(self) -> None: + self.leases.discard_expired(self.clock.now()) + for binding, session_id in tuple(self._bindings.items()): + if self.leases.get(session_id) is None: + del self._bindings[binding] + + def _existing(self, binding: str) -> str: + with self._lock: + self._open() + session_id = self._bindings.get(binding) + lease = self.leases.get(session_id) if session_id is not None else None + if lease is None: + self._bindings.pop(binding, None) + raise Unauthenticated("session_required") + if not lease.valid_at(self.clock.now()): + self.leases.remove(lease.session_id) + del self._bindings[binding] + raise Unauthenticated("session_expired") + return lease.session_id + + def _status(self, binding: str, proof: _RequestProof) -> SessionStatus: + with self._lock: + self._open() + self._prune() + session_id = self._bindings.get(binding) + if session_id is not None: + return SessionGuard(self.leases, proof, self.clock).status(session_id) + human = proof.prove("") + material = self.entropy.new() + validate_login_material(material) + now = self.clock.now() + candidate = SessionLease("n_" + material, human, now, now + self._lifetime) + with self._lock: + self._open() + self._prune() + concurrent_id = self._bindings.get(binding) + concurrent = self.leases.get(concurrent_id) if concurrent_id is not None else None + if concurrent is not None: + if not self.leases.refresh(concurrent, human): + raise IdentityRejected("session_changed") + return SessionStatus(human, True, concurrent.expires_at) + self.leases.add(candidate) + self._bindings[binding] = candidate.session_id + return SessionStatus(human, True, candidate.expires_at) + + def _csrf(self, binding: str) -> str: + session_id = self._existing(binding) + context = (binding + "\0" + session_id).encode("ascii") + return hmac.new(self._key, b"csrf\0" + context, hashlib.sha256).hexdigest() + + def close(self) -> None: + with self._lock: + self._closed = True + for session_id in self._bindings.values(): + self.leases.remove(session_id) + self._bindings.clear() + + +@dataclass(frozen=True, slots=True) +class IngressRequest: + _sessions: IngressSessions = field(repr=False) + _binding: str = field(repr=False) + _proof: _RequestProof = field(repr=False) + + def status(self) -> SessionStatus: + return self._sessions._status(self._binding, self._proof) + + def read(self) -> HumanIdentity: + return self.status().human + + def current(self) -> HumanIdentity: + session_id = self._sessions._existing(self._binding) + return SessionGuard(self._sessions.leases, self._proof, self._sessions.clock).write( + session_id + ) + + def csrf(self) -> str: + self.status() + return self._sessions._csrf(self._binding) + + def verify_csrf(self, value: str) -> None: + expected = self._sessions._csrf(self._binding) + if not re.fullmatch(r"[a-f0-9]{64}", value) or not hmac.compare_digest(expected, value): + raise Forbidden("request_forbidden") diff --git a/backend/src/domus/infrastructure/memory_attempts.py b/backend/src/domus/infrastructure/memory_attempts.py new file mode 100644 index 0000000..c6a225a --- /dev/null +++ b/backend/src/domus/infrastructure/memory_attempts.py @@ -0,0 +1,39 @@ +"""Bounded private attempt storage atomically checks browser binding before consumption.""" + +from threading import Lock + +from domus.application.authentication_values import LoginAttempt +from domus.domain.errors import Conflict, IdentityRejected, IdentityUnavailable, InvalidInput + + +class MemoryLoginAttempts: + def __init__(self, *, max_attempts: int = 128) -> None: + if type(max_attempts) is not int or not 1 <= max_attempts <= 1024: + raise InvalidInput("invalid_login_capacity") + self._maximum = max_attempts + self._attempts: dict[str, LoginAttempt] = {} + self._lock = Lock() + + def add(self, attempt: LoginAttempt, now: int) -> None: + with self._lock: + expired = [key for key, record in self._attempts.items() if not record.valid_at(now)] + for key in expired: + del self._attempts[key] + if attempt.state_digest in self._attempts: + raise Conflict("login_attempt_exists") + if len(self._attempts) >= self._maximum: + raise IdentityUnavailable("login_capacity") + self._attempts[attempt.state_digest] = attempt + + def take(self, state_digest: str, browser_digest: str, now: int) -> LoginAttempt: + with self._lock: + attempt = self._attempts.get(state_digest) + if attempt is None: + raise IdentityRejected("invalid_login_attempt") + if not attempt.valid_at(now): + del self._attempts[state_digest] + raise IdentityRejected("login_attempt_expired") + if not attempt.matches_browser(browser_digest): + raise IdentityRejected("invalid_login_attempt") + del self._attempts[state_digest] + return attempt diff --git a/backend/src/domus/infrastructure/memory_sessions.py b/backend/src/domus/infrastructure/memory_sessions.py new file mode 100644 index 0000000..0fde5c1 --- /dev/null +++ b/backend/src/domus/infrastructure/memory_sessions.py @@ -0,0 +1,49 @@ +"""Bounded process-local leases; restart requires authentication, not database secrets.""" + +from dataclasses import replace +from threading import Lock + +from domus.application.session_values import SessionLease +from domus.domain.errors import Conflict, IdentityUnavailable, InvalidInput +from domus.domain.identity import HumanIdentity + + +class MemorySessions: + def __init__(self, *, max_sessions: int = 256) -> None: + if type(max_sessions) is not int or not 1 <= max_sessions <= 1024: + raise InvalidInput("invalid_session_capacity") + self._maximum = max_sessions + self._leases: dict[str, SessionLease] = {} + self._lock = Lock() + + def get(self, session_id: str) -> SessionLease | None: + with self._lock: + return self._leases.get(session_id) + + def add(self, lease: SessionLease) -> None: + with self._lock: + if lease.session_id in self._leases: + raise Conflict("session_exists") + if len(self._leases) >= self._maximum: + raise IdentityUnavailable("session_capacity") + self._leases[lease.session_id] = lease + + def refresh(self, expected: SessionLease, human: HumanIdentity) -> bool: + if human.ha_user_id != expected.human.ha_user_id: + raise InvalidInput("identity_mismatch") + with self._lock: + if self._leases.get(expected.session_id) != expected: + return False + self._leases[expected.session_id] = replace(expected, human=human) + return True + + def remove(self, session_id: str) -> None: + with self._lock: + self._leases.pop(session_id, None) + + def discard_expired(self, now: int) -> int: + with self._lock: + expired = [key for key, lease in self._leases.items() if not lease.valid_at(now)] + for key in expired: + del self._leases[key] + return len(expired) diff --git a/backend/src/domus/infrastructure/offline_storage.py b/backend/src/domus/infrastructure/offline_storage.py new file mode 100644 index 0000000..e4f4b97 --- /dev/null +++ b/backend/src/domus/infrastructure/offline_storage.py @@ -0,0 +1,128 @@ +"""Exclusive offline capability validates existing managed data without initializing the source.""" + +import fcntl +import math +import os +import stat +import time +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path + +from domus.domain.errors import DatabaseRejected, InvalidInput, StorageFailure +from domus.domain.restoration import RestoreSource +from domus.domain.storage import Installation, JsonDocument +from domus.infrastructure.owned_storage import ( + _ALLOWED, + ACTIVATION_MANIFEST, + ACTIVATION_NEXT, + GENERATION_MARKER, + RESTORE_MANIFEST, + RESTORE_OPERATIONS, + SNAPSHOT_MANIFEST, + _acquire, + _close, + _document, + _file, + _generation_database, + _metadata, + _private_directory, +) +from domus.infrastructure.restore_source import source_binding +from domus.infrastructure.sqlite import SQLiteDatabase +from domus.infrastructure.sqlite_snapshots import _Budget, _connection, _verify_database + + +@dataclass(frozen=True) +class OfflineStorage: + installation: Installation + database: SQLiteDatabase + source: RestoreSource + root_identity: JsonDocument + directory: int = field(repr=False) + + +def _existing_root(directory: int, channel: str) -> None: + info = os.fstat(directory) + if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700: + raise DatabaseRejected("unsafe_installation_directory") + names = set(os.listdir(directory)) + if {GENERATION_MARKER, RESTORE_MANIFEST, RESTORE_OPERATIONS, SNAPSHOT_MANIFEST} & names: + raise DatabaseRejected("restore_not_active") + installation, state, _ = _metadata(directory) + if installation.channel != channel or state != "ready": + raise DatabaseRejected("installation_not_ready") + + +@contextmanager +def offline_storage( + root: Path, + channel: str, + *, + timeout: float = 30, + monotonic: Callable[[], float] = time.monotonic, +) -> Iterator[OfflineStorage]: + if channel not in ("stable", "edge"): + raise InvalidInput("invalid_installation_channel") + if type(timeout) not in (int, float) or not math.isfinite(timeout) or not 0 < timeout <= 3600: + raise InvalidInput("invalid_restore_timeout") + directory = runtime = maintenance = None + try: + directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) + _existing_root(directory, channel) + runtime = _acquire(directory, "runtime.lock", fcntl.LOCK_EX) + maintenance = _acquire(directory, "maintenance.lock", fcntl.LOCK_EX) + _existing_root(directory, channel) + installation, _, selection = _metadata(directory) + names = set(os.listdir(directory)) + subdirectories = ( + {"generations", "archives", "activations"} + if selection is not None or ACTIVATION_MANIFEST in names + else set() + ) + allowed = _ALLOWED | {ACTIVATION_MANIFEST, ACTIVATION_NEXT} | subdirectories + if names - allowed: + raise DatabaseRejected("unexpected_installation_artifact") + for name in names: + if name in subdirectories: + with _private_directory(directory, name): + pass + else: + os.close(_file(directory, name)) + path = ( + _generation_database(root, directory, selection) + if selection is not None + else root / "domus.sqlite3" + ) + if selection is None: + os.close(_file(directory, "domus.sqlite3")) + database = SQLiteDatabase(path, installation) + budget = _Budget(monotonic() + timeout, monotonic) + with _connection(path, budget, readonly=True) as connection: + connection.execute("BEGIN") + version = _verify_database(connection, database, previous=True) + with _private_source(path.parent) as source_dir: + source = source_binding(source_dir, installation, version) + budget.check() + capability = OfflineStorage( + installation, database, source, _document(directory, "installation.json"), directory + ) + except BaseException as error: + _close(maintenance, runtime, directory) + if isinstance(error, OSError): + raise StorageFailure("storage_failure") from None + raise + try: + yield capability + finally: + _close(maintenance, runtime, directory) + + +@contextmanager +def _private_source(path: Path) -> Iterator[int]: + directory = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) + try: + yield directory + finally: + os.close(directory) diff --git a/backend/src/domus/infrastructure/owned_storage.py b/backend/src/domus/infrastructure/owned_storage.py new file mode 100644 index 0000000..10543a5 --- /dev/null +++ b/backend/src/domus/infrastructure/owned_storage.py @@ -0,0 +1,351 @@ +"""Own one private data directory, a stable installation identity and its process lease.""" + +import fcntl +import os +import stat +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import cast +from uuid import uuid4 + +from domus.domain.errors import DatabaseRejected, InvalidInput, StorageBusy, StorageFailure +from domus.domain.generations import GenerationSelection +from domus.domain.storage import Installation, JsonDocument +from domus.infrastructure.sqlite import SQLiteDatabase + +_MARKER = "installation.json" +_NEXT = ".installation.next" +_DATABASE = "domus.sqlite3" +_LOCK = "runtime.lock" +_MAINTENANCE = "maintenance.lock" +RESTORE_MANIFEST = "restore.json" +RESTORE_OPERATIONS = "operations.jsonl" +GENERATION_MARKER = "generation.json" +ACTIVATION_MANIFEST = "activation.json" +ACTIVATION_NEXT = "activation.json.next" +SNAPSHOT_MANIFEST = "manifest.json" +_GENERATIONS = "generations" +_ARCHIVES = "archives" +_ALLOWED = { + _MARKER, + _NEXT, + _DATABASE, + _LOCK, + _MAINTENANCE, + *(_DATABASE + suffix for suffix in ("-journal", "-wal", "-shm")), +} + + +@dataclass +class OwnedStorage: + installation: Installation + database: SQLiteDatabase + _directory: int | None = field(repr=False) + _lock: int | None = field(repr=False) + _maintenance: int | None = field(default=None, repr=False) + generation_id: str | None = None + + def close(self) -> None: + descriptors = self._maintenance, self._lock, self._directory + self._maintenance = self._lock = self._directory = None + _close(*descriptors) + + +def _close(*descriptors: int | None) -> None: + for descriptor in descriptors: + if descriptor is not None: + os.close(descriptor) + + +def _file(directory: int, name: str, flags: int = os.O_RDONLY) -> int: + descriptor = os.open( + name, flags | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK, 0o600, dir_fd=directory + ) + try: + info = os.fstat(descriptor) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_nlink != 1 + or info.st_uid != os.getuid() + or stat.S_IMODE(info.st_mode) != 0o600 + ): + raise DatabaseRejected("unsafe_installation_file") + except BaseException: + os.close(descriptor) + raise + return descriptor + + +def _document(directory: int, name: str) -> JsonDocument: + descriptor = _file(directory, name) + try: + content = os.read(descriptor, 1025) + finally: + os.close(descriptor) + try: + if len(content) > 1024: + raise InvalidInput("invalid_document") + return JsonDocument(content.decode("utf-8")) + except InvalidInput, UnicodeError: + raise DatabaseRejected("invalid_installation_identity") from None + + +def _metadata(directory: int) -> tuple[Installation, str, GenerationSelection | None]: + try: + value = _document(directory, _MARKER).decode() + if value.get("schema_version") == 2: + selection = GenerationSelection.from_value(value) + return selection.installation, "ready", selection + if ( + set(value) != {"schema_version", "instance_id", "channel", "state"} + or type(value["schema_version"]) is not int + or value["schema_version"] != 1 + or value["state"] not in ("initializing", "ready") + ): + raise InvalidInput("invalid_document") + installation = Installation(cast(str, value["instance_id"]), cast(str, value["channel"])) + return installation, value["state"], None + except InvalidInput: + raise DatabaseRejected("invalid_installation_identity") from None + + +def _identity(directory: int) -> tuple[Installation, str]: + installation, state, _ = _metadata(directory) + return installation, state + + +@contextmanager +def _private_directory(parent: int, name: str) -> Iterator[int]: + descriptor = os.open( + name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent + ) + try: + info = os.fstat(descriptor) + if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700: + raise DatabaseRejected("unsafe_installation_directory") + yield descriptor + finally: + os.close(descriptor) + + +def _generation_database(root: Path, directory: int, selection: GenerationSelection) -> Path: + with _private_directory(directory, _GENERATIONS) as generations: + with _private_directory(generations, selection.generation_id) as slot: + names = set(os.listdir(slot)) + required = {_MARKER, _DATABASE, GENERATION_MARKER} + allowed = required | {_DATABASE + suffix for suffix in ("-journal", "-wal", "-shm")} + if not required <= names or names - allowed: + raise DatabaseRejected("invalid_generation_artifacts") + for name in names: + os.close(_file(slot, name)) + installation, state, nested = _metadata(slot) + if ( + installation != selection.installation + or state != "ready" + or nested is not None + or _document(slot, GENERATION_MARKER) != selection.document() + ): + raise DatabaseRejected("invalid_generation_identity") + return root / _GENERATIONS / selection.generation_id / _DATABASE + + +def _write_identity( + directory: int, installation: Installation, state: str, *, create: bool = False +) -> None: + name = _MARKER if create else _NEXT + if not create and _NEXT in os.listdir(directory): + os.unlink(_NEXT, dir_fd=directory) + descriptor = _file(directory, name, os.O_WRONLY | os.O_CREAT | os.O_EXCL) + with os.fdopen(descriptor, "w", encoding="utf-8") as target: + target.write( + JsonDocument.from_value( + { + "schema_version": 1, + "instance_id": installation.instance_id, + "channel": installation.channel, + "state": state, + } + ).encoded + ) + target.flush() + os.fsync(target.fileno()) + if not create: + os.replace(_NEXT, _MARKER, src_dir_fd=directory, dst_dir_fd=directory) + os.fsync(directory) + + +def _acquire(directory: int, name: str, mode: int) -> int: + descriptor = _file(directory, name, os.O_RDWR | os.O_CREAT) + try: + fcntl.flock(descriptor, mode | fcntl.LOCK_NB) + return descriptor + except BaseException as error: + os.close(descriptor) + if isinstance(error, BlockingIOError): + raise StorageBusy("installation_in_use") from None + raise + + +def _open(root: Path, channel: str, *, attach: bool) -> OwnedStorage: + directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) + lock = maintenance = None + try: + info = os.fstat(directory) + if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700: + raise DatabaseRejected("unsafe_installation_directory") + present = set(os.listdir(directory)) + if { + RESTORE_MANIFEST, + RESTORE_OPERATIONS, + GENERATION_MARKER, + ACTIVATION_MANIFEST, + ACTIVATION_NEXT, + SNAPSHOT_MANIFEST, + } & present: + raise DatabaseRejected("restore_not_active") + if _MARKER in present: + _metadata(directory) + if not attach: + lock = _acquire(directory, _LOCK, fcntl.LOCK_EX) + maintenance = _acquire(directory, _MAINTENANCE, fcntl.LOCK_SH if attach else fcntl.LOCK_EX) + names = set(os.listdir(directory)) + metadata = _metadata(directory) if _MARKER in names else None + selection = metadata[2] if metadata is not None else None + subdirectories = ( + {_GENERATIONS, _ARCHIVES, "activations"} if selection is not None else set() + ) + if names - _ALLOWED - subdirectories: + raise DatabaseRejected("unexpected_installation_artifact") + for name in names: + if name in subdirectories: + with _private_directory(directory, name): + pass + else: + os.close(_file(directory, name)) + if _MARKER not in names: + if attach or names - {_LOCK, _MAINTENANCE}: + raise DatabaseRejected("missing_installation_identity") + installation, state = Installation(str(uuid4()), channel), "initializing" + _write_identity(directory, installation, state, create=True) + else: + installation, state, _ = cast( + tuple[Installation, str, GenerationSelection | None], metadata + ) + if installation.channel != channel: + raise DatabaseRejected("installation_channel_mismatch") + ready = state == "ready" + if ready and selection is None and _DATABASE not in names: + raise DatabaseRejected("missing_installation_database") + path = ( + _generation_database(root, directory, selection) + if selection is not None + else root / _DATABASE + ) + database = SQLiteDatabase(path, installation) + if attach: + if not ready: + raise DatabaseRejected("installation_not_ready") + with database.read(): + pass + else: + database.initialize(require_existing=ready) + if not ready: + _write_identity(directory, installation, "ready") + elif _NEXT in names: + os.unlink(_NEXT, dir_fd=directory) + os.fsync(directory) + fcntl.flock(maintenance, fcntl.LOCK_SH) + return OwnedStorage( + installation, + database, + directory, + lock, + maintenance, + selection.generation_id if selection is not None else None, + ) + except BaseException: + _close(maintenance, lock, directory) + raise + + +def open_storage(root: Path, channel: str) -> OwnedStorage: + """Open a trusted operator-owned existing 0700 directory, holding its lock until close. + + No listener may start until this succeeds. A ready marker never permits an empty or + missing database; interrupted first initialization resumes only its persisted UUID. + The private directory is an operator trust boundary, not protection from its owner. + """ + return _storage(root, channel, attach=False) + + +def attach_storage(root: Path, channel: str) -> OwnedStorage: + """Attach an installation-console operation without migration or UUID/schema creation. + + A shared maintenance guard coexists with the one runtime while excluding startup + migrations/offline maintenance. The actual approval still requires explicit console input. + """ + return _storage(root, channel, attach=True) + + +def attach_database(path: Path, installation: Installation) -> SQLiteDatabase: + """Inspect an explicit standalone file without creation/migration or protocol bypass.""" + try: + directory = os.open( + path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + ) + try: + info = os.fstat(directory) + if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o022: + raise DatabaseRejected("unsafe_installation_directory") + _no_owned_marker(directory) + os.close(_file(directory, path.name)) + database = SQLiteDatabase(path, installation) + with database.read(): + pass + return database + finally: + os.close(directory) + except OSError: + raise StorageFailure("storage_failure") from None + + +def _no_owned_marker(directory: int) -> None: + for name in ( + _MARKER, + GENERATION_MARKER, + RESTORE_MANIFEST, + RESTORE_OPERATIONS, + ACTIVATION_MANIFEST, + ACTIVATION_NEXT, + SNAPSHOT_MANIFEST, + ): + try: + os.stat(name, dir_fd=directory, follow_symlinks=False) + except FileNotFoundError: + continue + raise InvalidInput("owned_directory_required") + + +def reject_owned_database(path: Path) -> None: + """Explicit runtime files cannot bypass owned-directory startup/lifecycle rules.""" + try: + directory = os.open( + path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + ) + try: + _no_owned_marker(directory) + finally: + os.close(directory) + except OSError: + raise StorageFailure("storage_failure") from None + + +def _storage(root: Path, channel: str, *, attach: bool) -> OwnedStorage: + if channel not in ("stable", "edge"): + raise InvalidInput("invalid_installation_channel") + try: + return _open(root, channel, attach=attach) + except OSError: + raise StorageFailure("storage_failure") from None diff --git a/backend/src/domus/infrastructure/restore_source.py b/backend/src/domus/infrastructure/restore_source.py new file mode 100644 index 0000000..005a40c --- /dev/null +++ b/backend/src/domus/infrastructure/restore_source.py @@ -0,0 +1,50 @@ +"""Bind a coherent operation frontier to its managed identity and generation.""" + +import hashlib +import os +import sqlite3 +from collections.abc import Callable, Iterator + +from domus.domain.errors import DatabaseRejected +from domus.domain.generations import GenerationSelection +from domus.domain.restoration import RestoreSource, RetainedOperation +from domus.domain.storage import Installation +from domus.infrastructure.owned_storage import GENERATION_MARKER, _document, _metadata +from domus.infrastructure.sqlite_history import encode_operation, iter_operations + + +def source_binding(directory: int, installation: Installation, version: int) -> RestoreSource: + names = set(os.listdir(directory)) + if "installation.json" not in names: + if GENERATION_MARKER in names: + raise DatabaseRejected("invalid_restore_source") + return RestoreSource(installation, version, None, None) + identity, state, nested = _metadata(directory) + if identity != installation or state != "ready" or nested is not None: + raise DatabaseRejected("invalid_restore_source") + if GENERATION_MARKER in names: + selection = GenerationSelection.from_value(_document(directory, GENERATION_MARKER).decode()) + if selection.installation != installation: + raise DatabaseRejected("invalid_restore_source") + return RestoreSource( + installation, version, selection.generation_id, selection.document().fingerprint + ) + return RestoreSource( + installation, version, None, _document(directory, "installation.json").fingerprint + ) + + +class BoundFrontier: + def __init__( + self, connection: sqlite3.Connection, source: RestoreSource, check: Callable[[], None] + ): + self.connection, self.check = connection, check + self.digest = hashlib.sha256(source.document().encoded.encode("utf-8")) + + def __iter__(self) -> Iterator[RetainedOperation]: + for value in iter_operations(self.connection, self.check): + self.digest.update((encode_operation(value) + "\n").encode("utf-8")) + yield value + + def fingerprint(self) -> str: + return self.digest.hexdigest() diff --git a/backend/src/domus/infrastructure/session_access.py b/backend/src/domus/infrastructure/session_access.py new file mode 100644 index 0000000..26e1944 --- /dev/null +++ b/backend/src/domus/infrastructure/session_access.py @@ -0,0 +1,35 @@ +"""Opaque server-session adapters keep private transport material outside request DTOs.""" + +from dataclasses import dataclass, field + +from domus.application.sessions import SessionGuard +from domus.domain.errors import IdentityRejected +from domus.domain.identity import HumanIdentity +from domus.infrastructure.standalone_sessions import StandaloneIdentity + + +@dataclass(frozen=True, slots=True) +class SessionActor: + guard: SessionGuard = field(repr=False) + session_id: str = field(repr=False) + + def read(self) -> HumanIdentity: + return self.guard.read(self.session_id) + + def current(self) -> HumanIdentity: + return self.guard.write(self.session_id) + + +@dataclass(frozen=True, slots=True) +class SessionDirectory: + identity: StandaloneIdentity = field(repr=False) + session_id: str = field(repr=False) + + def get(self, ha_user_id: str) -> HumanIdentity: + humans = self.eligible((ha_user_id,)) + if not humans: + raise IdentityRejected("ha_subject_rejected") + return humans[0] + + def eligible(self, ha_user_ids: tuple[str, ...]) -> tuple[HumanIdentity, ...]: + return self.identity.directory(self.session_id, ha_user_ids) diff --git a/backend/src/domus/infrastructure/sqlite.py b/backend/src/domus/infrastructure/sqlite.py new file mode 100644 index 0000000..009bbe0 --- /dev/null +++ b/backend/src/domus/infrastructure/sqlite.py @@ -0,0 +1,450 @@ +"""Owned SQLite files with explicit, bounded and atomic transaction boundaries.""" + +import math +import os +import sqlite3 +from collections.abc import Iterator +from contextlib import AbstractContextManager, contextmanager +from dataclasses import dataclass +from pathlib import Path + +from domus.application.ports import UnitOfWork +from domus.domain.errors import ( + Conflict, + DatabaseRejected, + DomusError, + InvalidInput, + StorageBusy, + StorageFailure, +) +from domus.domain.storage import ( + AuditEntry, + CommandKey, + Household, + Installation, + JsonDocument, + OutboxEvent, + Receipt, + validate_identifier, +) +from domus.infrastructure.sqlite_history import HISTORY_STATEMENTS, SQLiteHistory +from domus.infrastructure.sqlite_identity import SQLiteMembers, SQLitePolicies +from domus.infrastructure.sqlite_recovery import SQLiteRecoveries + +SCHEMA_VERSION = 4 +APPLICATION_ID = 0x444F4D55 +BASE_STATEMENTS = ( + "CREATE TABLE installation(singleton INTEGER PRIMARY KEY CHECK(singleton=1), " + "instance_id TEXT NOT NULL, channel TEXT NOT NULL CHECK(channel IN ('stable','edge')))", + "CREATE TABLE households(household_id TEXT PRIMARY KEY, singleton INTEGER NOT NULL " + "DEFAULT 1 UNIQUE CHECK(singleton=1), name TEXT NOT NULL, time_zone TEXT NOT NULL, " + "revision INTEGER NOT NULL CHECK(revision>=1))", + "CREATE TABLE receipts(household_id TEXT NOT NULL REFERENCES households(household_id), " + "actor_id TEXT NOT NULL, action TEXT NOT NULL, command_key TEXT NOT NULL, " + "payload_fingerprint TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE, " + "result TEXT NOT NULL, created_at INTEGER NOT NULL, " + "PRIMARY KEY(household_id,actor_id,action,command_key))", + "CREATE TABLE audit(operation_id TEXT PRIMARY KEY, " + "household_id TEXT NOT NULL REFERENCES households(household_id), " + "actor_id TEXT NOT NULL, action TEXT NOT NULL, command_key TEXT NOT NULL, " + "detail TEXT NOT NULL, created_at INTEGER NOT NULL)", + "CREATE TABLE outbox(position INTEGER PRIMARY KEY AUTOINCREMENT, " + "event_id TEXT NOT NULL UNIQUE, " + "household_id TEXT NOT NULL REFERENCES households(household_id), " + "event_type TEXT NOT NULL, payload TEXT NOT NULL, created_at INTEGER NOT NULL, " + "delivered INTEGER NOT NULL DEFAULT 0 CHECK(delivered IN (0,1)))", + "CREATE INDEX outbox_pending ON outbox(delivered,position)", +) +IDENTITY_STATEMENTS = ( + "CREATE TABLE members(member_id TEXT PRIMARY KEY, " + "household_id TEXT NOT NULL REFERENCES households(household_id), ha_user_id TEXT NOT NULL, " + "role TEXT NOT NULL CHECK(role IN ('administrator','adult','restricted','viewer')), " + "status TEXT NOT NULL CHECK(status IN ('pending','active','suspended','archived')), " + "revision INTEGER NOT NULL CHECK(revision>=1), exceptions TEXT NOT NULL, " + "UNIQUE(household_id,ha_user_id))", + "CREATE TABLE household_policy(household_id TEXT PRIMARY KEY " + "REFERENCES households(household_id), mapping_mode TEXT NOT NULL, target_ha_user_id TEXT, " + "revision INTEGER NOT NULL CHECK(revision>=1), adjustments TEXT NOT NULL, " + "CHECK((mapping_mode='all_ha_administrators' AND target_ha_user_id IS NULL) " + "OR (mapping_mode IN ('setup_administrator','selected_ha_user') " + "AND target_ha_user_id IS NOT NULL)))", +) +RECOVERY_STATEMENTS = ( + "CREATE TABLE recovery_requests(request_id TEXT PRIMARY KEY, " + "household_id TEXT NOT NULL REFERENCES households(household_id), " + "target_ha_user_id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>=1), " + "state TEXT NOT NULL CHECK(state IN ('pending','approved','consumed','canceled')), " + "created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, detail TEXT NOT NULL)", + "CREATE INDEX recovery_pending ON recovery_requests(household_id,state,expires_at)", +) +SCHEMA_STATEMENTS = ( + *BASE_STATEMENTS, + *IDENTITY_STATEMENTS, + *RECOVERY_STATEMENTS, + *HISTORY_STATEMENTS, +) +BASE_TABLES = {"installation", "households", "receipts", "audit", "outbox"} +IDENTITY_TABLES = BASE_TABLES | {"members", "household_policy"} +RECOVERY_TABLES = IDENTITY_TABLES | {"recovery_requests"} +TABLES = RECOVERY_TABLES | {"retained_operations"} + + +def _scope(command: CommandKey) -> tuple[str, str, str, str]: + return command.household_id, command.actor_id, command.action, command.key + + +def _command(row: sqlite3.Row) -> CommandKey: + return CommandKey(row["household_id"], row["actor_id"], row["action"], row["command_key"]) + + +def _receipt(row: sqlite3.Row | None) -> Receipt | None: + if row is None: + return None + return Receipt( + _command(row), + row["payload_fingerprint"], + row["operation_id"], + JsonDocument(row["result"]), + row["created_at"], + ) + + +def _storage_error(error: sqlite3.Error) -> DomusError: + if getattr(error, "sqlite_errorcode", 0) & 0xFF in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED): + return StorageBusy("storage_busy") + return StorageFailure("storage_failure") + + +@dataclass +class SQLiteHouseholds: + connection: sqlite3.Connection + + def get(self, household_id: str) -> Household | None: + row = self.connection.execute( + "SELECT * FROM households WHERE household_id=?", (household_id,) + ).fetchone() + return self._record(row) + + def current(self) -> Household | None: + return self._record(self.connection.execute("SELECT * FROM households").fetchone()) + + @staticmethod + def _record(row: sqlite3.Row | None) -> Household | None: + if row is None: + return None + return Household(row["household_id"], row["name"], row["time_zone"], row["revision"]) + + def save(self, household: Household, expected_revision: int) -> None: + if household.revision != expected_revision + 1: + raise Conflict("revision_conflict") + if expected_revision == 0: + try: + self.connection.execute( + "INSERT INTO households(household_id,name,time_zone,revision) VALUES (?,?,?,?)", + ( + household.household_id, + household.name, + household.time_zone, + household.revision, + ), + ) + except sqlite3.IntegrityError: + raise Conflict("household_exists") from None + else: + updated = self.connection.execute( + "UPDATE households SET name=?,time_zone=?,revision=? " + "WHERE household_id=? AND revision=?", + ( + household.name, + household.time_zone, + household.revision, + household.household_id, + expected_revision, + ), + ) + if updated.rowcount != 1: + raise Conflict("revision_conflict") + + +@dataclass +class SQLiteReceipts: + connection: sqlite3.Connection + + def get(self, command: CommandKey) -> Receipt | None: + original = _receipt( + self.connection.execute( + "SELECT * FROM receipts WHERE household_id=? AND actor_id=? " + "AND action=? AND command_key=?", + _scope(command), + ).fetchone() + ) + return SQLiteHistory(self.connection).receipt(command, original) + + def get_operation(self, household_id: str, actor_id: str, operation_id: str) -> Receipt | None: + original = _receipt( + self.connection.execute( + "SELECT * FROM receipts WHERE household_id=? AND actor_id=? AND operation_id=?", + (household_id, actor_id, operation_id), + ).fetchone() + ) + history = SQLiteHistory(self.connection) + if original is not None: + return history.receipt(original.command, original) + return history.operation(household_id, actor_id, operation_id) + + def add(self, receipt: Receipt) -> None: + if receipt.restoration is not None: + raise InvalidInput("receipt_projection_not_storable") + if ( + self.connection.execute( + "SELECT 1 FROM retained_operations WHERE operation_id=? OR " + "(household_id=? AND actor_id=? AND action=? AND command_key=?) LIMIT 1", + (receipt.operation_id, *_scope(receipt.command)), + ).fetchone() + is not None + ): + raise Conflict("operation_collision") + self.connection.execute( + "INSERT INTO receipts VALUES (?,?,?,?,?,?,?,?)", + ( + *_scope(receipt.command), + receipt.payload_fingerprint, + receipt.operation_id, + receipt.result.encoded, + receipt.created_at, + ), + ) + + +@dataclass +class SQLiteAudit: + connection: sqlite3.Connection + + def append(self, entry: AuditEntry) -> None: + self.connection.execute( + "INSERT INTO audit VALUES (?,?,?,?,?,?,?)", + (entry.operation_id, *_scope(entry.command), entry.detail.encoded, entry.created_at), + ) + + def entries(self, household_id: str) -> tuple[AuditEntry, ...]: + return tuple( + AuditEntry( + row["operation_id"], _command(row), JsonDocument(row["detail"]), row["created_at"] + ) + for row in self.connection.execute( + "SELECT * FROM audit WHERE household_id=? ORDER BY rowid", (household_id,) + ) + ) + + def setup_actor(self, household_id: str) -> str | None: + rows = self.connection.execute( + "SELECT actor_id FROM audit WHERE household_id=? AND action='household.setup' LIMIT 2", + (household_id,), + ).fetchall() + if not rows: + return None + if len(rows) != 1: + raise StorageFailure("invalid_setup_provenance") + try: + actor_id: str = rows[0]["actor_id"] + validate_identifier(actor_id) + return actor_id + except InvalidInput: + raise StorageFailure("invalid_setup_provenance") from None + + +@dataclass +class SQLiteOutbox: + connection: sqlite3.Connection + + def append(self, event: OutboxEvent) -> None: + self.connection.execute( + "INSERT INTO outbox(event_id,household_id,event_type,payload,created_at) " + "VALUES (?,?,?,?,?)", + ( + event.event_id, + event.household_id, + event.event_type, + event.payload.encoded, + event.created_at, + ), + ) + + def pending(self, limit: int = 50) -> tuple[OutboxEvent, ...]: + if type(limit) is not int or not 1 <= limit <= 200: + raise InvalidInput("invalid_page_size") + return tuple( + OutboxEvent( + row["event_id"], + row["household_id"], + row["event_type"], + JsonDocument(row["payload"]), + row["created_at"], + ) + for row in self.connection.execute( + "SELECT * FROM outbox WHERE delivered=0 ORDER BY position LIMIT ?", (limit,) + ) + ) + + def acknowledge(self, event_id: str) -> bool: + return ( + self.connection.execute( + "UPDATE outbox SET delivered=1 WHERE event_id=? AND delivered=0", (event_id,) + ).rowcount + == 1 + ) + + def delivered(self, event_id: str) -> bool | None: + row = self.connection.execute( + "SELECT delivered FROM outbox WHERE event_id=?", (event_id,) + ).fetchone() + if row is None: + return SQLiteHistory(self.connection).delivery(event_id) + value = row["delivered"] + if type(value) is not int or value not in (0, 1): + raise StorageFailure("invalid_operation_record") + return value == 1 + + +class SQLiteUnit: + def __init__(self, connection: sqlite3.Connection) -> None: + self.households = SQLiteHouseholds(connection) + self.receipts = SQLiteReceipts(connection) + self.audit = SQLiteAudit(connection) + self.outbox = SQLiteOutbox(connection) + self.members = SQLiteMembers(connection) + self.policies = SQLitePolicies(connection) + self.recoveries = SQLiteRecoveries(connection) + + +class SQLiteDatabase: + def __init__( + self, path: Path, installation: Installation, *, busy_timeout: float = 0.25 + ) -> None: + if not math.isfinite(busy_timeout) or not 0 < busy_timeout <= 5: + raise InvalidInput("invalid_storage_timeout") + self.path = path.resolve() + self.installation = installation + self.busy_timeout = busy_timeout + + @contextmanager + def _connection(self) -> Iterator[sqlite3.Connection]: + try: + connection = sqlite3.connect( + self.path.as_uri() + "?mode=rw", + uri=True, + timeout=self.busy_timeout, + autocommit=True, + ) + except sqlite3.Error as error: + raise _storage_error(error) from None + connection.row_factory = sqlite3.Row + try: + yield connection + finally: + connection.close() + + def _inspect( + self, + connection: sqlite3.Connection, + *, + allow_empty: bool = False, + allow_previous: bool = False, + ) -> int: + try: + version = connection.execute("PRAGMA user_version").fetchone()[0] + application_id = connection.execute("PRAGMA application_id").fetchone()[0] + objects = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type IN ('table','view','trigger') " + "AND name NOT LIKE 'sqlite_%'" + ) + } + if version == 0 and application_id == 0 and not objects and allow_empty: + return 0 + if version != SCHEMA_VERSION and not (allow_previous and version in (1, 2, 3)): + raise DatabaseRejected("unsupported_schema") + expected_tables = {1: BASE_TABLES, 2: IDENTITY_TABLES, 3: RECOVERY_TABLES}.get( + version, TABLES + ) + if application_id != APPLICATION_ID or objects != expected_tables: + raise DatabaseRejected("unrelated_database") + identity = connection.execute("SELECT instance_id,channel FROM installation").fetchone() + if identity is None or tuple(identity) != ( + self.installation.instance_id, + self.installation.channel, + ): + raise DatabaseRejected("installation_mismatch") + except sqlite3.Error: + raise DatabaseRejected("invalid_database") from None + return int(version) + + def initialize(self, *, require_existing: bool = False) -> None: + if not require_existing: + try: + descriptor = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError: + pass + except OSError: + raise StorageFailure("storage_failure") from None + else: + os.close(descriptor) + with self._connection() as connection: + self._inspect(connection, allow_empty=not require_existing, allow_previous=True) + connection.execute("PRAGMA foreign_keys=ON") + try: + connection.execute("BEGIN IMMEDIATE") + version = self._inspect( + connection, allow_empty=not require_existing, allow_previous=True + ) + if version == 0: + for statement in SCHEMA_STATEMENTS: + connection.execute(statement) + connection.execute( + "INSERT INTO installation VALUES (1,?,?)", + (self.installation.instance_id, self.installation.channel), + ) + connection.execute(f"PRAGMA application_id={APPLICATION_ID}") + connection.execute(f"PRAGMA user_version={SCHEMA_VERSION}") + elif version in (1, 2, 3): + migration = ( + *(IDENTITY_STATEMENTS if version == 1 else ()), + *(RECOVERY_STATEMENTS if version < 3 else ()), + *HISTORY_STATEMENTS, + ) + for statement in migration: + connection.execute(statement) + connection.execute(f"PRAGMA user_version={SCHEMA_VERSION}") + connection.execute("COMMIT") + except BaseException as error: + if connection.in_transaction: + connection.execute("ROLLBACK") + if isinstance(error, sqlite3.Error): + raise _storage_error(error) from None + raise + + @contextmanager + def _transaction(self, *, write: bool) -> Iterator[UnitOfWork]: + with self._connection() as connection: + self._inspect(connection) + connection.execute("PRAGMA foreign_keys=ON") + if not write: + connection.execute("PRAGMA query_only=ON") + try: + connection.execute("BEGIN IMMEDIATE" if write else "BEGIN") + self._inspect(connection) + yield SQLiteUnit(connection) + connection.execute("COMMIT") + except BaseException as error: + if connection.in_transaction: + connection.execute("ROLLBACK") + if isinstance(error, sqlite3.Error): + raise _storage_error(error) from None + raise + + def read(self) -> AbstractContextManager[UnitOfWork]: + return self._transaction(write=False) + + def write(self) -> AbstractContextManager[UnitOfWork]: + return self._transaction(write=True) diff --git a/backend/src/domus/infrastructure/sqlite_activation.py b/backend/src/domus/infrastructure/sqlite_activation.py new file mode 100644 index 0000000..dd72c3a --- /dev/null +++ b/backend/src/domus/infrastructure/sqlite_activation.py @@ -0,0 +1,544 @@ +"""Publish one verified offline generation and resume its durable transition without replay.""" + +import math +import os +import time +from collections.abc import Callable +from dataclasses import dataclass, replace +from pathlib import Path +from typing import cast + +from domus.application.ports import Clock +from domus.domain.activation import ActivationRecord, ActivationStatus +from domus.domain.errors import ( + Conflict, + DatabaseRejected, + DomusError, + InvalidInput, + NotFound, + StorageFailure, +) +from domus.domain.generations import GenerationSelection +from domus.domain.restoration import PreparedRestoration, RestoreSource +from domus.domain.snapshots import Snapshot +from domus.domain.storage import Installation, JsonDocument, validate_identifier +from domus.infrastructure.offline_storage import OfflineStorage, offline_storage +from domus.infrastructure.owned_storage import ( + ACTIVATION_MANIFEST, + ACTIVATION_NEXT, + GENERATION_MARKER, + _document, + _file, + _generation_database, + _metadata, + _private_directory, + _write_identity, + attach_storage, +) +from domus.infrastructure.restore_source import BoundFrontier +from domus.infrastructure.sqlite import SQLiteDatabase +from domus.infrastructure.sqlite_history import SQLiteHistory, last_committed_at +from domus.infrastructure.sqlite_restoration import ( + SQLiteRestorations, + _read_document, + _read_operations, +) +from domus.infrastructure.sqlite_snapshots import ( + SQLiteSnapshots, + _Budget, + _connection, + _directory, + _hash, + _verify_database, +) +from domus.infrastructure.sqlite_snapshots import _copy as _backup + +_INPUT_FILES = {"domus.sqlite3", "installation.json", "operations.jsonl", "restore.json"} +_SLOT_FILES = { + "domus.sqlite3", + "installation.json", + GENERATION_MARKER, + ".installation.next", + GENERATION_MARKER + ".next", + *("domus.sqlite3" + suffix for suffix in ("-journal", "-wal", "-shm")), +} + + +def _record(directory: int, name: str) -> ActivationRecord: + try: + return ActivationRecord.from_value(_read_document(directory, name).decode()) + except InvalidInput: + raise DatabaseRejected("invalid_activation_record") from None + + +def _atomic(directory: int, name: str, document: JsonDocument, stage: str | None = None) -> None: + temporary = stage or name + ".next" + descriptor = _file(directory, temporary, os.O_RDWR | os.O_CREAT) + with os.fdopen(descriptor, "wb") as stream: + stream.truncate(0) + stream.write(document.encoded.encode("utf-8")) + stream.flush() + os.fsync(stream.fileno()) + if name in os.listdir(directory): + os.close(_file(directory, name)) + os.replace(temporary, name, src_dir_fd=directory, dst_dir_fd=directory) + os.fsync(directory) + + +def _child(directory: int, name: str) -> None: + try: + os.mkdir(name, 0o700, dir_fd=directory) + except FileExistsError: + pass + with _private_directory(directory, name): + pass + + +def _copy(source: int, target: int, name: str, budget: _Budget) -> None: + incoming = _file(source, name) + with os.fdopen(incoming, "rb") as stream: + outgoing = _file(target, name, os.O_RDWR | os.O_CREAT) + with os.fdopen(outgoing, "wb") as destination: + destination.truncate(0) + while chunk := stream.read(1_048_576): + budget.check() + destination.write(chunk) + destination.flush() + os.fsync(destination.fileno()) + + +@dataclass +class SQLiteActivation: + root: Path + installation: Installation + clock: Clock + prepared_location: Path | None = None + restore_id: str | None = None + timeout: float = 30 + monotonic: Callable[[], float] = time.monotonic + + def __post_init__(self) -> None: + if ( + type(self.timeout) not in (int, float) + or not math.isfinite(self.timeout) + or not 0 < self.timeout <= 3600 + ): + raise InvalidInput("invalid_restore_timeout") + if self.restore_id is not None: + validate_identifier(self.restore_id) + + def _inspect_input(self, location: Path) -> PreparedRestoration: + return SQLiteRestorations( + None, + None, + location, + self.installation, + self.clock, + timeout=self.timeout, + monotonic=self.monotonic, + ).inspect() + + def _pending(self, directory: int) -> ActivationRecord | None: + names = set(os.listdir(directory)) + if ACTIVATION_MANIFEST not in names and ACTIVATION_NEXT not in names: + return None + result = _record( + directory, ACTIVATION_MANIFEST if ACTIVATION_MANIFEST in names else ACTIVATION_NEXT + ) + if result.phase == "active" or result.prepared.installation != self.installation: + raise DatabaseRejected("invalid_activation_record") + if ACTIVATION_MANIFEST not in names and result.phase != "pending": + raise DatabaseRejected("invalid_activation_record") + return result + + def _selected_id(self, pending: ActivationRecord | None) -> str: + if self.restore_id is not None: + return self.restore_id + if self.prepared_location is not None: + return self._inspect_input(self.prepared_location).restore_id + if pending is not None: + return pending.prepared.restore_id + raise NotFound("activation_missing") + + def _completed(self, directory: int, identifier: str) -> ActivationRecord | None: + if "activations" not in os.listdir(directory): + return None + with _private_directory(directory, "activations") as activations: + if identifier not in os.listdir(activations): + return None + with _private_directory(activations, identifier) as evidence: + if ACTIVATION_MANIFEST not in os.listdir(evidence): + return None + value = _record(evidence, ACTIVATION_MANIFEST) + if ( + value.phase != "active" + or value.prepared.restore_id != identifier + or value.prepared.installation != self.installation + ): + raise DatabaseRejected("invalid_activation_record") + return value + + def _check_source( + self, storage: OfflineStorage, prepared: PreparedRestoration, budget: _Budget + ) -> None: + if prepared.source is None or prepared.source.identity_sha256 is None: + raise InvalidInput("managed_restore_binding_required") + if prepared.source != storage.source: + raise Conflict("restore_source_changed") + with _connection(storage.database.path, budget, readonly=True) as connection: + connection.execute("BEGIN") + frontier = BoundFrontier(connection, storage.source, budget.check) + for _ in frontier: + pass + if frontier.fingerprint() != prepared.source_frontier_sha256: + raise Conflict("restore_source_changed") + budget.check() + + def _sealed_input( + self, storage: OfflineStorage, record: ActivationRecord, budget: _Budget + ) -> Path: + identifier = record.prepared.restore_id + _child(storage.directory, "activations") + with _private_directory(storage.directory, "activations") as activations: + _child(activations, identifier) + with _private_directory(activations, identifier) as evidence: + _child(evidence, "input") + with _private_directory(evidence, "input") as target: + path = self.root / "activations" / identifier / "input" + if set(os.listdir(target)) == _INPUT_FILES: + try: + if self._inspect_input(path) == record.prepared: + return path + except DomusError: + pass + if ( + self.prepared_location is None + or self._inspect_input(self.prepared_location) != record.prepared + ): + raise DatabaseRejected("activation_input_required") + if set(os.listdir(target)) - _INPUT_FILES: + raise DatabaseRejected("invalid_activation_artifacts") + with _directory(self.prepared_location, private=True) as source: + for name in ( + "installation.json", + "domus.sqlite3", + "operations.jsonl", + "restore.json", + ): + _copy(source, target, name, budget) + os.fsync(target) + os.fsync(evidence) + os.fsync(activations) + os.fsync(storage.directory) + if self._inspect_input(path) != record.prepared: + raise DatabaseRejected("activation_input_changed") + return path + + def _verify_target( + self, storage: OfflineStorage, record: ActivationRecord, budget: _Budget + ) -> None: + selection = GenerationSelection(self.installation, record.prepared.restore_id) + path = _generation_database(self.root, storage.directory, selection) + with _directory(path.parent, private=True) as slot: + digest, size = _hash(slot, "domus.sqlite3", budget) + if (digest, size) != (record.database_sha256, record.database_bytes): + raise DatabaseRejected("activation_target_changed") + database = SQLiteDatabase(path, self.installation) + with _connection(path, budget, readonly=True) as connection: + _verify_database(connection, database, previous=False) + budget.check() + + def _materialize( + self, storage: OfflineStorage, record: ActivationRecord, location: Path, budget: _Budget + ) -> ActivationRecord: + if record.phase == "materialized": + self._verify_target(storage, record, budget) + return record + identifier = record.prepared.restore_id + rollback = self._source_checkpoint(storage, record, budget) + _child(storage.directory, "generations") + with _private_directory(storage.directory, "generations") as generations: + _child(generations, identifier) + with _private_directory(generations, identifier) as slot: + names = set(os.listdir(slot)) + if names - _SLOT_FILES: + raise DatabaseRejected("invalid_activation_artifacts") + for name in names: + os.close(_file(slot, name)) + _write_identity( + slot, + self.installation, + "restore_pending", + create="installation.json" not in names, + ) + for name in names - {"installation.json"}: + os.unlink(name, dir_fd=slot) + with _directory(location, private=True) as input_dir: + _copy(input_dir, slot, "domus.sqlite3", budget) + path = self.root / "generations" / identifier / "domus.sqlite3" + database = SQLiteDatabase(path, self.installation) + database.initialize(require_existing=True) + with _connection(path, budget, readonly=False) as connection: + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("BEGIN IMMEDIATE") + try: + with _directory(location, private=True) as input_dir: + SQLiteHistory(connection).install(_read_operations(input_dir, budget)) + connection.execute("COMMIT") + except BaseException: + connection.execute("ROLLBACK") + raise + with _connection(path, budget, readonly=True) as connection: + _verify_database(connection, database, previous=False) + descriptor = _file(slot, "domus.sqlite3") + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + digest, size = _hash(slot, "domus.sqlite3", budget) + _atomic( + slot, + GENERATION_MARKER, + GenerationSelection(self.installation, identifier).document(), + ) + _write_identity(slot, self.installation, "ready") + os.fsync(slot) + os.fsync(generations) + os.fsync(storage.directory) + result = replace( + record, + phase="materialized", + database_sha256=digest, + database_bytes=size, + rollback_snapshot=rollback, + ) + self._verify_target(storage, result, budget) + _atomic(storage.directory, ACTIVATION_MANIFEST, result.document()) + return result + + def _source_checkpoint( + self, storage: OfflineStorage, record: ActivationRecord, budget: _Budget + ) -> Snapshot: + identifier = record.prepared.restore_id + path = self.root / "activations" / identifier / "source-checkpoint" + with _private_directory(storage.directory, "activations") as activations: + with _private_directory(activations, identifier) as evidence: + _child(evidence, "source-checkpoint") + with _private_directory(evidence, "source-checkpoint") as target: + names = set(os.listdir(target)) + if "manifest.json" in names and GENERATION_MARKER not in names: + snapshot = SQLiteSnapshots( + None, path, self.installation, self.clock, timeout=self.timeout + ).inspect() + self._check_checkpoint_frontier(path, record, budget) + return snapshot + allowed = { + "domus.sqlite3", + "installation.json", + "manifest.json", + "manifest.json.next", + GENERATION_MARKER, + GENERATION_MARKER + ".next", + ".installation.next", + } + if names - allowed: + raise DatabaseRejected("invalid_activation_artifacts") + for name in names: + os.close(_file(target, name)) + _atomic( + target, + GENERATION_MARKER, + GenerationSelection(self.installation, identifier).document(), + ) + for name in names - {GENERATION_MARKER}: + os.unlink(name, dir_fd=target) + _write_identity(target, self.installation, "restore_pending", create=True) + descriptor = _file( + target, "domus.sqlite3", os.O_CREAT | os.O_EXCL | os.O_WRONLY + ) + os.close(descriptor) + with _connection(storage.database.path, budget, readonly=True) as source: + source.execute("BEGIN") + with _connection( + path / "domus.sqlite3", budget, readonly=False + ) as outgoing: + _backup(source, outgoing, budget) + database = SQLiteDatabase(path / "domus.sqlite3", self.installation) + with _connection(database.path, budget, readonly=True) as source: + version = _verify_database(source, database, previous=True) + committed = last_committed_at(source) + _write_identity(target, self.installation, "ready") + descriptor = _file(target, "domus.sqlite3") + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + digest, size = _hash(target, "domus.sqlite3", budget) + identity_hash, _ = _hash(target, "installation.json", budget) + snapshot = Snapshot( + identifier, + self.installation, + version, + self.clock.now(), + committed, + digest, + size, + identity_hash, + ) + _atomic(target, "manifest.json", snapshot.document()) + os.unlink(GENERATION_MARKER, dir_fd=target) + os.fsync(target) + os.fsync(evidence) + self._check_checkpoint_frontier(path, record, budget) + return SQLiteSnapshots( + None, path, self.installation, self.clock, timeout=self.timeout + ).inspect() + + def _check_checkpoint_frontier( + self, path: Path, record: ActivationRecord, budget: _Budget + ) -> None: + with _connection(path / "domus.sqlite3", budget, readonly=True) as connection: + frontier = BoundFrontier( + connection, cast(RestoreSource, record.prepared.source), budget.check + ) + for _ in frontier: + pass + if frontier.fingerprint() != record.prepared.source_frontier_sha256: + raise DatabaseRejected("activation_rollback_changed") + + def _finish( + self, storage: OfflineStorage, record: ActivationRecord, budget: _Budget + ) -> ActivationStatus: + self._verify_target(storage, record, budget) + identifier = record.prepared.restore_id + rollback_path = self.root / "activations" / identifier / "source-checkpoint" + if ( + SQLiteSnapshots( + None, rollback_path, self.installation, self.clock, timeout=self.timeout + ).inspect() + != record.rollback_snapshot + ): + raise DatabaseRejected("activation_rollback_changed") + _, _, selected = _metadata(storage.directory) + if selected != GenerationSelection(self.installation, identifier): + raise DatabaseRejected("activation_selection_changed") + completed = self._completed(storage.directory, identifier) + if completed is not None: + if replace(completed, phase="materialized", activated_at=None) != record: + raise Conflict("activation_record_conflict") + else: + completed = replace(record, phase="active", activated_at=self.clock.now()) + with _private_directory(storage.directory, "activations") as activations: + with _private_directory(activations, identifier) as evidence: + _atomic(evidence, ACTIVATION_MANIFEST, completed.document()) + os.fsync(activations) + budget.check() + os.unlink(ACTIVATION_MANIFEST, dir_fd=storage.directory) + os.fsync(storage.directory) + budget.check() + return ActivationStatus(completed, identifier) + + def activate(self) -> ActivationStatus: + budget = _Budget(self.monotonic() + self.timeout, self.monotonic) + try: + with offline_storage( + self.root, self.installation.channel, timeout=self.timeout, monotonic=self.monotonic + ) as storage: + if storage.installation != self.installation: + raise DatabaseRejected("installation_mismatch") + pending = self._pending(storage.directory) + identifier = self._selected_id(pending) + external = ( + self._inspect_input(self.prepared_location) + if self.prepared_location is not None + else None + ) + if external is not None and external.restore_id != identifier: + raise Conflict("activation_record_conflict") + if pending is not None and ( + pending.prepared.restore_id != identifier + or (external is not None and external != pending.prepared) + ): + raise Conflict("activation_in_progress") + if pending is None: + completed = self._completed(storage.directory, identifier) + if completed is not None: + if external is not None and completed.prepared != external: + raise Conflict("activation_record_conflict") + budget.check() + return ActivationStatus(completed, storage.source.generation_id) + if external is None: + raise NotFound("activation_missing") + self._check_source(storage, external, budget) + if "generations" in os.listdir(storage.directory): + with _private_directory(storage.directory, "generations") as generations: + if identifier in os.listdir(generations): + raise Conflict("activation_destination_exists") + if "activations" in os.listdir(storage.directory): + with _private_directory(storage.directory, "activations") as activations: + if identifier in os.listdir(activations): + raise Conflict("activation_destination_exists") + pending = ActivationRecord(external, "pending") + _atomic(storage.directory, ACTIVATION_MANIFEST, pending.document()) + elif ACTIVATION_MANIFEST not in os.listdir(storage.directory): + self._check_source(storage, pending.prepared, budget) + _atomic(storage.directory, ACTIVATION_MANIFEST, pending.document()) + if storage.source.generation_id == identifier: + if pending.phase != "materialized": + raise DatabaseRejected("invalid_activation_record") + return self._finish(storage, pending, budget) + self._check_source(storage, pending.prepared, budget) + location = self._sealed_input(storage, pending, budget) + materialized = self._materialize(storage, pending, location, budget) + budget.check() + if _document(storage.directory, "installation.json") != storage.root_identity: + raise Conflict("restore_source_changed") + _atomic( + storage.directory, + "installation.json", + GenerationSelection(self.installation, identifier).document(), + ".installation.next", + ) + return self._finish(storage, materialized, budget) + except OSError: + raise StorageFailure("activation_uncertain") from None + + def resume(self) -> ActivationStatus: + return self.activate() + + def inspect(self) -> ActivationStatus: + try: + return self._inspect() + except OSError: + raise StorageFailure("activation_inspection_failed") from None + + def _inspect(self) -> ActivationStatus: + budget = _Budget(self.monotonic() + self.timeout, self.monotonic) + # A pending transition blocks serving; completed records permit shared inspection. + if any((self.root / name).exists() for name in (ACTIVATION_MANIFEST, ACTIVATION_NEXT)): + with offline_storage( + self.root, self.installation.channel, timeout=self.timeout, monotonic=self.monotonic + ) as storage: + if storage.installation != self.installation: + raise DatabaseRejected("installation_mismatch") + pending = self._pending(storage.directory) + identifier = self._selected_id(pending) + if pending is not None and pending.prepared.restore_id == identifier: + budget.check() + return ActivationStatus(pending, storage.source.generation_id) + completed = self._completed(storage.directory, identifier) + if completed is None: + raise NotFound("activation_missing") + budget.check() + return ActivationStatus(completed, storage.source.generation_id) + attached = attach_storage(self.root, self.installation.channel) + try: + if attached.installation != self.installation: + raise DatabaseRejected("installation_mismatch") + completed = self._completed(cast(int, attached._directory), self._selected_id(None)) + if completed is None: + raise NotFound("activation_missing") + budget.check() + return ActivationStatus(completed, attached.generation_id) + finally: + attached.close() diff --git a/backend/src/domus/infrastructure/sqlite_history.py b/backend/src/domus/infrastructure/sqlite_history.py new file mode 100644 index 0000000..8c799e8 --- /dev/null +++ b/backend/src/domus/infrastructure/sqlite_history.py @@ -0,0 +1,340 @@ +"""Retained facts are independent of current household rows and never become pending delivery.""" + +import heapq +import json +import sqlite3 +from collections.abc import Callable, Iterable, Iterator +from dataclasses import dataclass, replace +from typing import cast + +from domus.domain.errors import Conflict, DomusError, InvalidInput, StorageFailure +from domus.domain.recovery import closed_recovery +from domus.domain.restoration import OperationEvidence, RetainedOperation +from domus.domain.storage import ( + AuditEntry, + CommandKey, + JsonDocument, + OutboxEvent, + Receipt, + RestorationProvenance, + _unique_object, +) + +HISTORY_STATEMENTS = ( + "CREATE TABLE retained_operations(household_id TEXT NOT NULL, actor_id TEXT NOT NULL, " + "action TEXT NOT NULL, command_key TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE, " + "position INTEGER NOT NULL UNIQUE CHECK(position>=1), " + "current INTEGER NOT NULL CHECK(current IN (0,1)), archived_by TEXT, document TEXT NOT NULL, " + "closed_recovery_id TEXT, " + "PRIMARY KEY(household_id,actor_id,action,command_key))", + "CREATE INDEX retained_recovery ON retained_operations(household_id,closed_recovery_id) " + "WHERE current=0 AND closed_recovery_id IS NOT NULL", +) +_CURRENT = ( + "SELECT r.*,a.detail AS audit_detail,o.payload AS event_payload,o.position,o.delivered " + "FROM receipts r JOIN audit a ON a.operation_id=r.operation_id " + "JOIN outbox o ON o.event_id=r.operation_id " +) + + +def _scope(command: CommandKey) -> tuple[str, str, str, str]: + return command.household_id, command.actor_id, command.action, command.key + + +def encode_operation(value: RetainedOperation) -> str: + return json.dumps(value.value(), sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def _record(row: sqlite3.Row) -> RetainedOperation: + try: + document = row["document"] + if not isinstance(document, str) or len(document.encode("utf-8")) > 262_144: + raise InvalidInput("invalid_retained_operation") + result = RetainedOperation.from_value( + json.loads(document, object_pairs_hook=_unique_object) + ) + if tuple( + row[name] + for name in ( + "household_id", + "actor_id", + "action", + "command_key", + "operation_id", + "position", + "current", + "archived_by", + "closed_recovery_id", + ) + ) != ( + *result.evidence.command_scope, + result.evidence.receipt.operation_id, + result.evidence.position, + int(result.current), + result.archived_by, + closed_recovery(result.evidence.receipt) if not result.current else None, + ): + raise InvalidInput("invalid_retained_operation") + return result + except DomusError, ValueError, UnicodeError, RecursionError: + raise StorageFailure("invalid_retained_operation") from None + + +def _evidence(row: sqlite3.Row) -> OperationEvidence: + command = CommandKey( + *(row[name] for name in ("household_id", "actor_id", "action", "command_key")) + ) + if row["delivered"] not in (0, 1): + raise StorageFailure("invalid_operation_record") + return OperationEvidence( + Receipt( + command, + row["payload_fingerprint"], + row["operation_id"], + JsonDocument(row["result"]), + row["created_at"], + ), + AuditEntry( + row["operation_id"], command, JsonDocument(row["audit_detail"]), row["created_at"] + ), + OutboxEvent( + row["operation_id"], + command.household_id, + command.action, + JsonDocument(row["event_payload"]), + row["created_at"], + ), + row["position"], + bool(row["delivered"]), + ) + + +def _current(connection: sqlite3.Connection, command: CommandKey) -> OperationEvidence | None: + row = connection.execute( + _CURRENT + "WHERE r.household_id=? AND r.actor_id=? AND r.action=? AND r.command_key=?", + _scope(command), + ).fetchone() + return _evidence(row) if row is not None else None + + +def _same(original: OperationEvidence, retained: OperationEvidence) -> bool: + return replace(original, delivered=retained.delivered) == retained and ( + not original.delivered or retained.delivered + ) + + +def iter_operations( + connection: sqlite3.Connection, check: Callable[[], None] = lambda: None +) -> Iterator[RetainedOperation]: + """Merge sorted current and historical frontiers while streaming payloads.""" + modern = connection.execute("PRAGMA user_version").fetchone()[0] >= 4 + + def current() -> Iterator[RetainedOperation]: + for row in connection.execute( + _CURRENT + "ORDER BY r.household_id,r.actor_id,r.action,r.command_key" + ): + check() + evidence = _evidence(row) + retained = SQLiteHistory(connection).get(evidence.receipt.command) if modern else None + yield RetainedOperation( + evidence, True, retained.archived_by if retained is not None else None + ) + + def history() -> Iterator[RetainedOperation]: + if modern: + for row in connection.execute( + "SELECT * FROM retained_operations WHERE current=0 " + "ORDER BY household_id,actor_id,action,command_key" + ): + check() + yield _record(row) + + yield from heapq.merge(current(), history(), key=lambda value: value.evidence.command_scope) + + +def verify_history(connection: sqlite3.Connection) -> None: + """Verify current projection, complete facts, unique identities and sequence continuity.""" + highwater = 0 + for row in connection.execute("SELECT * FROM retained_operations"): + retained = _record(row) + highwater = max(highwater, retained.evidence.position) + original = _current(connection, retained.evidence.receipt.command) + if retained.current: + if original is None or not _same(retained.evidence, original): + raise StorageFailure("invalid_retained_projection") + elif ( + connection.execute( + "SELECT 1 FROM receipts r JOIN outbox o ON o.event_id=r.operation_id " + "WHERE (r.household_id=? AND r.actor_id=? AND r.action=? AND r.command_key=?) " + "OR r.operation_id=? OR o.position=? LIMIT 1", + ( + *retained.evidence.command_scope, + retained.evidence.receipt.operation_id, + retained.evidence.position, + ), + ).fetchone() + is not None + ): + raise StorageFailure("invalid_retained_projection") + # Table uniqueness plus the cross-current checks above already prove a unique frontier. + for row in connection.execute(_CURRENT): + highwater = max(highwater, _evidence(row).position) + rows = connection.execute("SELECT seq FROM sqlite_sequence WHERE name='outbox'").fetchall() + sequence = rows[0][0] if rows else 0 + if len(rows) > 1 or type(sequence) is not int or not highwater <= sequence <= 2**63 - 1: + raise StorageFailure("invalid_retained_sequence") + + +def last_committed_at(connection: sqlite3.Connection) -> int | None: + if connection.execute("PRAGMA user_version").fetchone()[0] < 4: + return cast( + int | None, connection.execute("SELECT max(created_at) FROM receipts").fetchone()[0] + ) + return max( + (value.evidence.receipt.created_at for value in iter_operations(connection)), default=None + ) + + +@dataclass +class SQLiteHistory: + connection: sqlite3.Connection + + def closed_recovery(self, household_id: str, request_id: str) -> bool: + row = self.connection.execute( + "SELECT * FROM retained_operations WHERE household_id=? " + "AND closed_recovery_id=? AND current=0 LIMIT 1", + (household_id, request_id), + ).fetchone() + if row is None: + return False + _record(row) + return True + + def get(self, command: CommandKey) -> RetainedOperation | None: + row = self.connection.execute( + "SELECT * FROM retained_operations WHERE household_id=? AND actor_id=? " + "AND action=? AND command_key=?", + _scope(command), + ).fetchone() + return _record(row) if row is not None else None + + @staticmethod + def projected(value: RetainedOperation) -> Receipt: + provenance = ( + RestorationProvenance(value.archived_by, value.current) + if value.archived_by is not None + else None + ) + return replace(value.evidence.receipt, restoration=provenance) + + def receipt(self, command: CommandKey, original: Receipt | None) -> Receipt | None: + retained = self.get(command) + if retained is None: + return original + if retained.current != (original is not None) or ( + original is not None and original != retained.evidence.receipt + ): + raise StorageFailure("invalid_retained_projection") + return self.projected(retained) + + def operation(self, household_id: str, actor_id: str, operation_id: str) -> Receipt | None: + row = self.connection.execute( + "SELECT * FROM retained_operations WHERE household_id=? " + "AND actor_id=? AND operation_id=?", + (household_id, actor_id, operation_id), + ).fetchone() + if row is None: + return None + retained = _record(row) + if retained.current: + raise StorageFailure("invalid_retained_projection") + return self.projected(retained) + + def delivery(self, event_id: str) -> bool | None: + row = self.connection.execute( + "SELECT * FROM retained_operations WHERE operation_id=?", (event_id,) + ).fetchone() + if row is None: + return None + value = _record(row) + if value.current: + raise StorageFailure("invalid_retained_projection") + return value.evidence.delivered + + def install(self, operations: Iterable[RetainedOperation]) -> None: + """Only verified offline composition calls this; no commit or activation is implicit.""" + if not self.connection.in_transaction: + raise StorageFailure("restore_import_transaction_required") + self.connection.execute("SAVEPOINT restore_history") + try: + self._install(operations) + verify_history(self.connection) + except BaseException as error: + self.connection.execute("ROLLBACK TO restore_history") + self.connection.execute("RELEASE restore_history") + if isinstance(error, sqlite3.IntegrityError): + raise Conflict("restore_history_conflict") from None + raise + self.connection.execute("RELEASE restore_history") + + def _install(self, operations: Iterable[RetainedOperation]) -> None: + count = highwater = 0 + previous = None + for value in operations: + evidence = value.evidence + if previous is not None and evidence.command_scope <= previous: + raise Conflict("restore_history_conflict") + previous = evidence.command_scope + original = _current(self.connection, evidence.receipt.command) + if value.current != (original is not None) or ( + original is not None and not _same(original, evidence) + ): + raise Conflict("restore_history_conflict") + retained = self.get(evidence.receipt.command) + if retained is not None and ( + not _same(retained.evidence, evidence) + or (retained.archived_by is not None and retained.archived_by != value.archived_by) + ): + raise Conflict("restore_history_conflict") + self.connection.execute( + "INSERT INTO retained_operations VALUES (?,?,?,?,?,?,?,?,?,?) " + "ON CONFLICT(household_id,actor_id,action,command_key) DO UPDATE SET " + "operation_id=excluded.operation_id,position=excluded.position,current=excluded.current," + "archived_by=excluded.archived_by,document=excluded.document," + "closed_recovery_id=excluded.closed_recovery_id", + ( + *evidence.command_scope, + evidence.receipt.operation_id, + evidence.position, + int(value.current), + value.archived_by, + encode_operation(value), + closed_recovery(evidence.receipt) if not value.current else None, + ), + ) + if value.current and evidence.delivered: + self.connection.execute( + "UPDATE outbox SET delivered=1 WHERE event_id=?", + (evidence.receipt.operation_id,), + ) + count += 1 + highwater = max(highwater, evidence.position) + if ( + self.connection.execute("SELECT count(*) FROM retained_operations").fetchone()[0] + != count + or self.connection.execute( + "SELECT 1 FROM receipts r LEFT JOIN retained_operations h ON " + "r.operation_id=h.operation_id WHERE h.operation_id IS NULL LIMIT 1" + ).fetchone() + is not None + ): + raise Conflict("restore_history_incomplete") + if ( + self.connection.execute( + "UPDATE sqlite_sequence SET seq=max(seq,?) WHERE name='outbox'", (highwater,) + ).rowcount + == 0 + ): + self.connection.execute( + "INSERT INTO sqlite_sequence(name,seq) VALUES ('outbox',?)", (highwater,) + ) diff --git a/backend/src/domus/infrastructure/sqlite_identity.py b/backend/src/domus/infrastructure/sqlite_identity.py new file mode 100644 index 0000000..19d0d12 --- /dev/null +++ b/backend/src/domus/infrastructure/sqlite_identity.py @@ -0,0 +1,184 @@ +"""Parameterized identity repositories; policy decisions remain in the domain.""" + +import sqlite3 +from dataclasses import dataclass +from typing import cast + +from domus.domain.errors import Conflict, DomusError, StorageFailure +from domus.domain.identity import ( + AdministratorMapping, + HouseholdPolicy, + MappingMode, + Member, + MemberStatus, + Permission, + PermissionRule, + ProfileRule, + Role, +) +from domus.domain.storage import JsonDocument, JsonObject + + +def _rules(encoded: str) -> list[JsonObject]: + rules = JsonDocument(encoded).decode()["rules"] + if type(rules) is not list or any(type(rule) is not dict for rule in rules): + raise ValueError("invalid stored rule shape") + return cast(list[JsonObject], rules) + + +def _member(row: sqlite3.Row | None) -> Member | None: + if row is None: + return None + try: + return Member( + row["member_id"], + row["household_id"], + row["ha_user_id"], + Role(row["role"]), + MemberStatus(row["status"]), + row["revision"], + tuple( + PermissionRule(Permission(cast(str, rule["permission"])), cast(bool, rule["allow"])) + for rule in _rules(row["exceptions"]) + ), + ) + except DomusError, KeyError, TypeError, ValueError: + raise StorageFailure("invalid_identity_record") from None + + +@dataclass +class SQLiteMembers: + connection: sqlite3.Connection + + def get(self, household_id: str, member_id: str) -> Member | None: + return _member( + self.connection.execute( + "SELECT * FROM members WHERE household_id=? AND member_id=?", + (household_id, member_id), + ).fetchone() + ) + + def by_user(self, household_id: str, ha_user_id: str) -> Member | None: + return _member( + self.connection.execute( + "SELECT * FROM members WHERE household_id=? AND ha_user_id=?", + (household_id, ha_user_id), + ).fetchone() + ) + + def list(self, household_id: str) -> tuple[Member, ...]: + rows = self.connection.execute( + "SELECT * FROM members WHERE household_id=? ORDER BY member_id", + (household_id,), + ) + return tuple(cast(Member, _member(row)) for row in rows) + + def save(self, member: Member, expected_revision: int) -> None: + if member.revision != expected_revision + 1: + raise Conflict("revision_conflict") + exceptions = JsonDocument.from_value( + { + "rules": [ + {"permission": rule.permission.value, "allow": rule.allow} + for rule in member.exceptions + ] + } + ).encoded + values = (member.role.value, member.status.value, member.revision, exceptions) + if expected_revision == 0: + try: + self.connection.execute( + "INSERT INTO members(role,status,revision,exceptions,member_id," + "household_id,ha_user_id) " + "VALUES (?,?,?,?,?,?,?)", + (*values, member.member_id, member.household_id, member.ha_user_id), + ) + except sqlite3.IntegrityError: + raise Conflict("member_exists") from None + elif ( + self.connection.execute( + "UPDATE members SET role=?,status=?,revision=?,exceptions=? " + "WHERE member_id=? AND household_id=? AND ha_user_id=? AND revision=?", + ( + *values, + member.member_id, + member.household_id, + member.ha_user_id, + expected_revision, + ), + ).rowcount + != 1 + ): + raise Conflict("revision_conflict") + + +@dataclass +class SQLitePolicies: + connection: sqlite3.Connection + + def get(self, household_id: str) -> HouseholdPolicy | None: + row = self.connection.execute( + "SELECT * FROM household_policy WHERE household_id=?", + (household_id,), + ).fetchone() + if row is None: + return None + try: + return HouseholdPolicy( + row["household_id"], + AdministratorMapping(MappingMode(row["mapping_mode"]), row["target_ha_user_id"]), + row["revision"], + tuple( + ProfileRule( + Role(cast(str, rule["role"])), + Permission(cast(str, rule["permission"])), + cast(bool, rule["allow"]), + ) + for rule in _rules(row["adjustments"]) + ), + ) + except DomusError, KeyError, TypeError, ValueError: + raise StorageFailure("invalid_identity_record") from None + + def save(self, policy: HouseholdPolicy, expected_revision: int) -> None: + if policy.revision != expected_revision + 1: + raise Conflict("revision_conflict") + adjustments = JsonDocument.from_value( + { + "rules": [ + { + "role": rule.role.value, + "permission": rule.permission.value, + "allow": rule.allow, + } + for rule in policy.adjustments + ] + } + ).encoded + values = ( + policy.mapping.mode.value, + policy.mapping.target_ha_user_id, + policy.revision, + adjustments, + policy.household_id, + ) + if expected_revision == 0: + try: + self.connection.execute( + "INSERT INTO household_policy(mapping_mode,target_ha_user_id," + "revision,adjustments,household_id) " + "VALUES (?,?,?,?,?)", + values, + ) + except sqlite3.IntegrityError: + raise Conflict("policy_exists") from None + elif ( + self.connection.execute( + "UPDATE household_policy SET mapping_mode=?,target_ha_user_id=?," + "revision=?,adjustments=? " + "WHERE household_id=? AND revision=?", + (*values, expected_revision), + ).rowcount + != 1 + ): + raise Conflict("revision_conflict") diff --git a/backend/src/domus/infrastructure/sqlite_recovery.py b/backend/src/domus/infrastructure/sqlite_recovery.py new file mode 100644 index 0000000..3f46fbf --- /dev/null +++ b/backend/src/domus/infrastructure/sqlite_recovery.py @@ -0,0 +1,148 @@ +"""Scoped recovery repository keeps immutable target bindings and optimistic transitions.""" + +import sqlite3 +from dataclasses import dataclass, replace +from typing import cast + +from domus.domain.errors import Conflict, DomusError, InvalidInput, StorageFailure +from domus.domain.identity import AdministratorMapping, MappingMode +from domus.domain.recovery import RecoveryRequest, RecoveryState, validate_recovery_time +from domus.domain.storage import Installation, JsonDocument +from domus.infrastructure.sqlite_history import SQLiteHistory + +_COLUMNS = ( + "request_id", + "household_id", + "target_ha_user_id", + "revision", + "state", + "created_at", + "expires_at", +) + + +def _indexed(request: RecoveryRequest) -> tuple[object, ...]: + return ( + request.request_id, + request.household_id, + request.target_ha_user_id, + request.revision, + request.state.value, + request.created_at, + request.expires_at, + ) + + +def _record(row: sqlite3.Row | None) -> RecoveryRequest | None: + if row is None: + return None + try: + document = JsonDocument(row["detail"]) + value = document.decode() + request = RecoveryRequest( + cast(str, value["request_id"]), + cast(str, value["household_id"]), + cast(str, value["target_ha_user_id"]), + Installation(cast(str, value["instance_id"]), cast(str, value["channel"])), + AdministratorMapping( + MappingMode(cast(str, value["mapping_mode"])), + cast(str | None, value["mapping_target"]), + ), + cast(int, value["policy_revision"]), + cast(str | None, value["member_id"]), + cast(int, value["member_revision"]), + cast(int, value["created_at"]), + cast(int, value["expires_at"]), + RecoveryState(cast(str, value["state"])), + cast(int, value["revision"]), + cast(int | None, value["approved_at"]), + cast(int | None, value["closed_at"]), + ) + if request.document() != document or tuple(row[name] for name in _COLUMNS) != _indexed( + request + ): + raise ValueError("inconsistent recovery record") + return request + except DomusError, KeyError, TypeError, ValueError: + raise StorageFailure("invalid_recovery_record") from None + + +@dataclass +class SQLiteRecoveries: + connection: sqlite3.Connection + + def closed_before_restoration(self, household_id: str, request_id: str) -> bool: + return SQLiteHistory(self.connection).closed_recovery(household_id, request_id) + + def get(self, household_id: str, request_id: str) -> RecoveryRequest | None: + return _record( + self.connection.execute( + "SELECT * FROM recovery_requests WHERE household_id=? AND request_id=?", + (household_id, request_id), + ).fetchone() + ) + + def pending( + self, household_id: str, now: int, *, limit: int = 257 + ) -> tuple[RecoveryRequest, ...]: + validate_recovery_time(now) + if type(limit) is not int or not 1 <= limit <= 257: + raise InvalidInput("invalid_page_size") + return tuple( + cast(RecoveryRequest, _record(row)) + for row in self.connection.execute( + "SELECT r.* FROM recovery_requests r WHERE r.household_id=? " + "AND r.state IN ('pending','approved') AND r.created_at<=? AND r.expires_at>? " + "AND NOT EXISTS (SELECT 1 FROM retained_operations h WHERE " + "h.household_id=r.household_id AND h.closed_recovery_id=r.request_id " + "AND h.current=0) ORDER BY r.request_id LIMIT ?", + (household_id, now, now, limit), + ) + ) + + def save(self, request: RecoveryRequest, expected_revision: int) -> None: + if request.revision != expected_revision + 1: + raise Conflict("revision_conflict") + values = (*_indexed(request), request.document().encoded) + if expected_revision == 0: + try: + self.connection.execute( + "INSERT INTO recovery_requests VALUES (?,?,?,?,?,?,?,?)", values + ) + except sqlite3.IntegrityError: + raise Conflict("recovery_exists") from None + else: + current = self.get(request.household_id, request.request_id) + if current is None: + raise Conflict("revision_conflict") + if current.approved_at is not None and request.approved_at != current.approved_at: + raise Conflict("recovery_binding_changed") + if ( + replace( + current, + state=request.state, + revision=request.revision, + approved_at=request.approved_at, + expires_at=request.expires_at, + closed_at=request.closed_at, + ) + != request + ): + raise Conflict("recovery_binding_changed") + if ( + self.connection.execute( + "UPDATE recovery_requests SET revision=?,state=?,expires_at=?,detail=? " + "WHERE household_id=? AND request_id=? AND revision=?", + ( + request.revision, + request.state.value, + request.expires_at, + request.document().encoded, + request.household_id, + request.request_id, + expected_revision, + ), + ).rowcount + != 1 + ): + raise Conflict("revision_conflict") diff --git a/backend/src/domus/infrastructure/sqlite_restoration.py b/backend/src/domus/infrastructure/sqlite_restoration.py new file mode 100644 index 0000000..2362f80 --- /dev/null +++ b/backend/src/domus/infrastructure/sqlite_restoration.py @@ -0,0 +1,299 @@ +"""Prepare and inspect a private blocked checkpoint plus complete processed-operation plan.""" + +import hashlib +import itertools +import json +import math +import os +import sqlite3 +import time +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field +from pathlib import Path +from typing import cast + +from domus.application.ports import Clock +from domus.domain.errors import Conflict, DatabaseRejected, DomusError, InvalidInput, StorageFailure +from domus.domain.restoration import ( + PreparedRestoration, + RetainedOperation, + reconcile_operations, +) +from domus.domain.snapshots import Snapshot +from domus.domain.storage import ( + Installation, + JsonDocument, + _unique_object, + validate_identifier, +) +from domus.infrastructure.owned_storage import ( + RESTORE_MANIFEST, + RESTORE_OPERATIONS, + _file, + _identity, + _write_identity, +) +from domus.infrastructure.restore_source import BoundFrontier, source_binding +from domus.infrastructure.sqlite import SQLiteDatabase +from domus.infrastructure.sqlite_history import encode_operation, iter_operations, last_committed_at +from domus.infrastructure.sqlite_snapshots import ( + SQLiteSnapshots, + _Budget, + _connection, + _directory, + _hash, + _verify_database, +) + +_DATABASE, _IDENTITY, _OPERATIONS = "domus.sqlite3", "installation.json", RESTORE_OPERATIONS +_FILES = {_DATABASE, _IDENTITY, _OPERATIONS, RESTORE_MANIFEST} +_ROW_LIMIT = 262_144 + + +def _encoded(value: RetainedOperation) -> bytes: + return (encode_operation(value) + "\n").encode("utf-8") + + +def _operations(connection: sqlite3.Connection, budget: _Budget) -> Iterator[RetainedOperation]: + return iter_operations(connection, budget.check) + + +def _read_operations(directory: int, budget: _Budget) -> Iterator[RetainedOperation]: + descriptor = _file(directory, _OPERATIONS) + with os.fdopen(descriptor, "rb") as stream: + while line := stream.readline(_ROW_LIMIT + 1): + budget.check() + if len(line) > _ROW_LIMIT or not line.endswith(b"\n"): + raise DatabaseRejected("invalid_restore_operations") + try: + yield RetainedOperation.from_value( + json.loads(line.decode("utf-8"), object_pairs_hook=_unique_object) + ) + except DomusError, ValueError, UnicodeError, RecursionError: + raise DatabaseRejected("invalid_restore_operations") from None + + +def _pending(installation: Installation) -> JsonDocument: + return JsonDocument.from_value( + { + "schema_version": 1, + "instance_id": installation.instance_id, + "channel": installation.channel, + "state": "restore_pending", + } + ) + + +def _read_document(directory: int, name: str) -> JsonDocument: + descriptor = _file(directory, name) + try: + content = os.read(descriptor, 16_385) + finally: + os.close(descriptor) + try: + if len(content) > 16_384: + raise InvalidInput("invalid_document") + return JsonDocument(content.decode("utf-8")) + except DomusError, UnicodeError: + raise DatabaseRejected("invalid_restore_manifest") from None + + +@dataclass +class SQLiteRestorations: + database: SQLiteDatabase | None + snapshot_location: Path | None + location: Path + installation: Installation + clock: Clock + checkpoint: Snapshot | None = None + timeout: float = 30 + monotonic: Callable[[], float] = field(default=time.monotonic, repr=False) + + def __post_init__(self) -> None: + if ( + type(self.timeout) not in (int, float) + or not math.isfinite(self.timeout) + or not 0 < self.timeout <= 3600 + ): + raise InvalidInput("invalid_restore_timeout") + + def prepare(self, restore_id: str) -> PreparedRestoration: + validate_identifier(restore_id) + if self.database is None or self.snapshot_location is None or self.checkpoint is None: + raise InvalidInput("restore_sources_required") + if ( + self.database.installation != self.installation + or self.checkpoint.installation != self.installation + ): + raise DatabaseRejected("installation_mismatch") + budget = _Budget(self.monotonic() + self.timeout, self.monotonic) + inspected = SQLiteSnapshots( + None, + self.snapshot_location, + self.installation, + self.clock, + self.timeout, + self.monotonic, + ).inspect() + budget.check() + if inspected != self.checkpoint: + raise DatabaseRejected("restore_checkpoint_changed") + if self.location.resolve().is_relative_to(self.snapshot_location.resolve()): + raise InvalidInput("restore_destination_inside_source") + try: + with _directory(self.database.path.parent, private=False) as source_dir: + os.close(_file(source_dir, self.database.path.name)) + if _IDENTITY in os.listdir(source_dir): + installation, state = _identity(source_dir) + if installation != self.installation or state != "ready": + raise DatabaseRejected("installation_mismatch") + if self.location.resolve().is_relative_to(self.database.path.parent): + raise InvalidInput("restore_destination_inside_source") + with _connection(self.database.path, budget, readonly=True) as current: + current.execute("BEGIN") + source_version = _verify_database(current, self.database, previous=True) + with _directory(self.location.parent, private=False) as parent: + try: + os.mkdir(self.location.name, 0o700, dir_fd=parent) + except FileExistsError: + raise Conflict("restore_destination_exists") from None + with _directory(self.location, private=True) as target: + return self._prepare( + current, source_version, target, parent, restore_id, budget + ) + except OSError: + raise StorageFailure("restore_storage_failure") from None + + def _prepare( + self, + current: sqlite3.Connection, + source_version: int, + target: int, + parent: int, + restore_id: str, + budget: _Budget, + ) -> PreparedRestoration: + checkpoint = cast(Snapshot, self.checkpoint) + _write_identity(target, self.installation, "restore_pending", create=True) + with _directory(cast(Path, self.snapshot_location), private=True) as archive: + source_fd = _file(archive, _DATABASE) + with os.fdopen(source_fd, "rb") as source: + descriptor = _file(target, _DATABASE, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + with os.fdopen(descriptor, "wb") as outgoing: + while chunk := source.read(1_048_576): + budget.check() + outgoing.write(chunk) + outgoing.flush() + os.fsync(outgoing.fileno()) + digest, size = _hash(target, _DATABASE, budget) + if (digest, size) != (checkpoint.database_sha256, checkpoint.database_bytes): + raise DatabaseRejected("snapshot_checksum_mismatch") + with _directory( + cast(SQLiteDatabase, self.database).path.parent, private=False + ) as source_dir: + binding = source_binding(source_dir, self.installation, source_version) + frontier = BoundFrontier(current, binding, budget.check) + + journal_hash = hashlib.sha256() + count = active = highwater = 0 + database = SQLiteDatabase(self.location / _DATABASE, self.installation) + with _connection(database.path, budget, readonly=True) as selected: + _verify_database(selected, database, previous=True) + descriptor = _file(target, _OPERATIONS, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + with os.fdopen(descriptor, "wb") as output: + for item in reconcile_operations( + _operations(selected, budget), frontier, restore_id + ): + budget.check() + encoded = _encoded(item) + output.write(encoded) + journal_hash.update(encoded) + count += 1 + active += int(item.current) + highwater = max(highwater, item.evidence.position) + output.flush() + os.fsync(output.fileno()) + identity_hash, _ = _hash(target, _IDENTITY, budget) + result = PreparedRestoration( + restore_id, + checkpoint, + frontier.fingerprint(), + journal_hash.hexdigest(), + identity_hash, + count, + active, + highwater, + self.clock.now(), + binding, + ) + budget.check() + descriptor = _file(target, RESTORE_MANIFEST, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + with os.fdopen(descriptor, "w", encoding="utf-8") as manifest: + manifest.write(result.document().encoded) + manifest.flush() + os.fsync(manifest.fileno()) + os.fsync(target) + os.fsync(parent) + budget.check() + return result + + def inspect(self) -> PreparedRestoration: + budget = _Budget(self.monotonic() + self.timeout, self.monotonic) + try: + with _directory(self.location, private=True) as directory: + if set(os.listdir(directory)) != _FILES: + raise DatabaseRejected("incomplete_restore_preparation") + try: + result = PreparedRestoration.from_value( + _read_document(directory, RESTORE_MANIFEST).decode() + ) + except InvalidInput: + raise DatabaseRejected("invalid_restore_manifest") from None + if result.installation != self.installation: + raise DatabaseRejected("installation_mismatch") + checkpoint = result.checkpoint + for name, expected in ( + (_DATABASE, checkpoint.database_sha256), + (_IDENTITY, result.identity_sha256), + (_OPERATIONS, result.operations_sha256), + ): + digest, size = _hash(directory, name, budget) + if digest != expected or ( + name == _DATABASE and size != checkpoint.database_bytes + ): + raise DatabaseRejected("restore_checksum_mismatch") + if _read_document(directory, _IDENTITY) != _pending(self.installation): + raise DatabaseRejected("invalid_restore_identity") + database = SQLiteDatabase(self.location / _DATABASE, self.installation) + count = active = highwater = 0 + with _connection(database.path, budget, readonly=True) as selected: + version = _verify_database(selected, database, previous=True) + committed = last_committed_at(selected) + if ( + version != checkpoint.schema_version + or committed != checkpoint.last_committed_at + ): + raise DatabaseRejected("restore_checkpoint_changed") + left, right = itertools.tee(_read_operations(directory, budget)) + for planned, actual in zip( + reconcile_operations( + _operations(selected, budget), left, result.restore_id + ), + right, + strict=True, + ): + if planned != actual: + raise DatabaseRejected("invalid_restore_projection") + count += 1 + active += int(actual.current) + highwater = max(highwater, actual.evidence.position) + if (count, active, highwater) != ( + result.operation_count, + result.current_operations, + result.highwater, + ): + raise DatabaseRejected("invalid_restore_counts") + budget.check() + return result + except OSError: + raise StorageFailure("restore_storage_failure") from None diff --git a/backend/src/domus/infrastructure/sqlite_snapshots.py b/backend/src/domus/infrastructure/sqlite_snapshots.py new file mode 100644 index 0000000..b266966 --- /dev/null +++ b/backend/src/domus/infrastructure/sqlite_snapshots.py @@ -0,0 +1,360 @@ +"""Private, checksummed SQLite checkpoints use the database backup API and strict inspection.""" + +import hashlib +import math +import os +import re +import sqlite3 +import time +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import cast + +from domus.application.ports import Clock +from domus.domain.errors import ( + Conflict, + DatabaseRejected, + DomusError, + InvalidInput, + StorageFailure, +) +from domus.domain.recovery import RecoveryRequest +from domus.domain.snapshots import Snapshot +from domus.domain.storage import CommandKey, Installation, JsonDocument, validate_identifier +from domus.infrastructure.owned_storage import _file, _identity, _write_identity +from domus.infrastructure.sqlite import ( + BASE_STATEMENTS, + IDENTITY_STATEMENTS, + RECOVERY_STATEMENTS, + SQLiteDatabase, + SQLiteUnit, + _storage_error, +) +from domus.infrastructure.sqlite_history import ( + HISTORY_STATEMENTS, + last_committed_at, + verify_history, +) +from domus.infrastructure.sqlite_recovery import _record as _recovery + +_DATABASE = "domus.sqlite3" +_IDENTITY = "installation.json" +_MANIFEST = "manifest.json" +_FILES = {_DATABASE, _IDENTITY, _MANIFEST} + + +@dataclass +class _Budget: + deadline: float + now: Callable[[], float] + + def check(self) -> None: + if self.now() >= self.deadline: + raise StorageFailure("snapshot_timeout") + + def progress(self, _status: int, _remaining: int, _total: int) -> None: + self.check() + + def sql(self) -> int: + return int(self.now() >= self.deadline) + + +@contextmanager +def _directory(path: Path, *, private: bool) -> Iterator[int]: + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) + try: + info = os.fstat(descriptor) + mode = info.st_mode & 0o7777 + if info.st_uid != os.getuid() or (mode != 0o700 if private else bool(mode & 0o022)): + raise DatabaseRejected("unsafe_snapshot_directory") + yield descriptor + finally: + os.close(descriptor) + + +@contextmanager +def _connection(path: Path, budget: _Budget, *, readonly: bool) -> Iterator[sqlite3.Connection]: + try: + connection = sqlite3.connect( + path.absolute().as_uri() + ("?mode=ro" if readonly else "?mode=rw"), + uri=True, + autocommit=True, + timeout=0.25, + ) + try: + connection.row_factory = sqlite3.Row + connection.set_progress_handler(budget.sql, 1000) + connection.execute("PRAGMA trusted_schema=OFF") + if readonly: + connection.execute("PRAGMA query_only=ON") + yield connection + finally: + connection.close() + except sqlite3.Error as error: + budget.check() + raise _storage_error(error) from None + + +def _schema(connection: sqlite3.Connection) -> tuple[tuple[str, str], ...]: + return tuple( + (row[0], re.sub(r"\s+", " ", row[1])) + for row in connection.execute( + "SELECT name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY name" + ) + ) + + +def _expected_schema(version: int) -> tuple[tuple[str, str], ...]: + connection = sqlite3.connect(":memory:", autocommit=True) + try: + statements = ( + *BASE_STATEMENTS, + *(IDENTITY_STATEMENTS if version >= 2 else ()), + *(RECOVERY_STATEMENTS if version >= 3 else ()), + *(HISTORY_STATEMENTS if version >= 4 else ()), + ) + for statement in statements: + connection.execute(statement) + return _schema(connection) + finally: + connection.close() + + +def _records(connection: sqlite3.Connection, version: int, installation: Installation) -> None: + rows = connection.execute("PRAGMA integrity_check(1)").fetchall() + if len(rows) != 1 or tuple(rows[0]) != ("ok",): + raise DatabaseRejected("invalid_snapshot_database") + if connection.execute("PRAGMA foreign_key_check").fetchone() is not None: + raise DatabaseRejected("invalid_snapshot_database") + identities = connection.execute("SELECT singleton FROM installation").fetchall() + households = connection.execute("SELECT household_id,singleton FROM households").fetchall() + if ( + len(identities) != 1 + or identities[0][0] != 1 + or len(households) > 1 + or any(row[1] != 1 for row in households) + ): + raise DatabaseRejected("invalid_snapshot_database") + for query in ( + "SELECT 1 FROM receipts r LEFT JOIN audit a ON a.operation_id=r.operation_id " + "LEFT JOIN outbox o ON o.event_id=r.operation_id WHERE a.operation_id IS NULL " + "OR o.event_id IS NULL OR a.household_id!=r.household_id OR a.actor_id!=r.actor_id " + "OR a.action!=r.action OR a.command_key!=r.command_key OR o.household_id!=r.household_id " + "OR o.event_type!=r.action OR a.created_at!=r.created_at " + "OR o.created_at!=r.created_at LIMIT 1", + "SELECT 1 FROM audit a LEFT JOIN receipts r ON r.operation_id=a.operation_id " + "WHERE r.operation_id IS NULL LIMIT 1", + "SELECT 1 FROM outbox o LEFT JOIN receipts r ON r.operation_id=o.event_id " + "WHERE r.operation_id IS NULL LIMIT 1", + ): + if connection.execute(query).fetchone() is not None: + raise DatabaseRejected("incomplete_snapshot_evidence") + try: + for table, field in (("receipts", "result"), ("audit", "detail"), ("outbox", "payload")): + for row in connection.execute(f"SELECT * FROM {table}"): + JsonDocument(row[field]) + timestamp = row["created_at"] + if type(timestamp) is not int or not 0 <= timestamp <= 2**63 - 1: + raise InvalidInput("invalid_snapshot_timestamp") + if table == "outbox" and row["delivered"] not in (0, 1): + raise InvalidInput("invalid_snapshot_delivery") + identifier = row["event_id"] if table == "outbox" else row["operation_id"] + validate_identifier(identifier) + if table != "outbox": + CommandKey( + row["household_id"], row["actor_id"], row["action"], row["command_key"] + ) + unit = SQLiteUnit(connection) + for row in households: + unit.households.get(row[0]) + if version >= 2: + unit.members.list(row[0]) + unit.policies.get(row[0]) + if version >= 3: + for row in connection.execute("SELECT * FROM recovery_requests"): + value = cast(RecoveryRequest, _recovery(row)) + if value.installation != installation: + raise DatabaseRejected("invalid_snapshot_database") + if version >= 4: + verify_history(connection) + except DomusError as error: + raise DatabaseRejected("invalid_snapshot_records") from error + + +def _verify_database( + connection: sqlite3.Connection, database: SQLiteDatabase, *, previous: bool +) -> int: + version = database._inspect(connection, allow_previous=previous) + if _schema(connection) != _expected_schema(version): + raise DatabaseRejected("unrelated_database") + _records(connection, version, database.installation) + return version + + +def _copy(source: sqlite3.Connection, target: sqlite3.Connection, budget: _Budget) -> None: + source.backup(target, pages=128, progress=budget.progress, sleep=0.01) + target.execute("PRAGMA journal_mode=DELETE") + + +def _hash(directory: int, name: str, budget: _Budget) -> tuple[str, int]: + descriptor = _file(directory, name) + digest, size = hashlib.sha256(), 0 + with os.fdopen(descriptor, "rb") as source: + while chunk := source.read(1_048_576): + budget.check() + digest.update(chunk) + size += len(chunk) + budget.check() + return digest.hexdigest(), size + + +def _manifest(directory: int) -> Snapshot: + descriptor = _file(directory, _MANIFEST) + try: + content = os.read(descriptor, 16_385) + finally: + os.close(descriptor) + try: + if len(content) > 16_384: + raise InvalidInput("invalid_document") + document = JsonDocument(content.decode("utf-8")) + value = document.decode() + result = Snapshot( + cast(str, value["snapshot_id"]), + Installation(cast(str, value["instance_id"]), cast(str, value["channel"])), + cast(int, value["schema_version"]), + cast(int, value["captured_at"]), + cast(int | None, value["last_committed_at"]), + cast(str, value["database_sha256"]), + cast(int, value["database_bytes"]), + cast(str, value["identity_sha256"]), + cast(int, value["format_version"]), + ) + if result.document() != document: + raise InvalidInput("invalid_document") + return result + except DomusError, KeyError, UnicodeError: + raise DatabaseRejected("invalid_snapshot_manifest") from None + + +@dataclass +class SQLiteSnapshots: + database: SQLiteDatabase | None + location: Path + installation: Installation + clock: Clock + timeout: float = 30 + monotonic: Callable[[], float] = field(default=time.monotonic, repr=False) + + def __post_init__(self) -> None: + if ( + type(self.timeout) not in (int, float) + or not math.isfinite(self.timeout) + or not 0 < self.timeout <= 3600 + ): + raise InvalidInput("invalid_snapshot_timeout") + + def create(self, snapshot_id: str) -> Snapshot: + validate_identifier(snapshot_id) + if self.database is None: + raise InvalidInput("snapshot_source_required") + if self.database.installation != self.installation: + raise DatabaseRejected("installation_mismatch") + budget = _Budget(self.monotonic() + self.timeout, self.monotonic) + try: + with _directory(self.database.path.parent, private=False) as source_directory: + os.close(_file(source_directory, self.database.path.name)) + if _IDENTITY in os.listdir(source_directory): + identity, state = _identity(source_directory) + if identity != self.installation or state != "ready": + raise DatabaseRejected("installation_mismatch") + if self.location.resolve().is_relative_to(self.database.path.parent): + raise InvalidInput("snapshot_destination_inside_source") + with _connection(self.database.path, budget, readonly=True) as source: + source.execute("BEGIN") + version = _verify_database(source, self.database, previous=False) + committed = last_committed_at(source) + captured = self.clock.now() + with _directory(self.location.parent, private=False) as parent: + try: + os.mkdir(self.location.name, 0o700, dir_fd=parent) + except FileExistsError: + raise Conflict("snapshot_destination_exists") from None + with _directory(self.location, private=True) as target_directory: + descriptor = _file( + target_directory, _DATABASE, os.O_CREAT | os.O_EXCL | os.O_RDWR + ) + os.close(descriptor) + with _connection( + self.location / _DATABASE, budget, readonly=False + ) as target: + _copy(source, target, budget) + _verify_database( + target, + SQLiteDatabase(self.location / _DATABASE, self.installation), + previous=False, + ) + _write_identity( + target_directory, self.installation, "ready", create=True + ) + database_hash, size = _hash(target_directory, _DATABASE, budget) + identity_hash, _ = _hash(target_directory, _IDENTITY, budget) + result = Snapshot( + snapshot_id, + self.installation, + version, + captured, + committed, + database_hash, + size, + identity_hash, + ) + descriptor = _file(target_directory, _DATABASE, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + descriptor = _file( + target_directory, _MANIFEST, os.O_CREAT | os.O_EXCL | os.O_WRONLY + ) + with os.fdopen(descriptor, "w", encoding="utf-8") as manifest: + manifest.write(result.document().encoded) + manifest.flush() + os.fsync(manifest.fileno()) + os.fsync(target_directory) + os.fsync(parent) + return result + except OSError: + raise StorageFailure("snapshot_storage_failure") from None + + def inspect(self) -> Snapshot: + budget = _Budget(self.monotonic() + self.timeout, self.monotonic) + try: + with _directory(self.location, private=True) as directory: + if set(os.listdir(directory)) != _FILES: + raise DatabaseRejected("incomplete_snapshot") + result = _manifest(directory) + if result.installation != self.installation: + raise DatabaseRejected("installation_mismatch") + digest, size = _hash(directory, _DATABASE, budget) + identity_hash, _ = _hash(directory, _IDENTITY, budget) + if ( + digest != result.database_sha256 + or size != result.database_bytes + or identity_hash != result.identity_sha256 + ): + raise DatabaseRejected("snapshot_checksum_mismatch") + identity, state = _identity(directory) + if identity != self.installation or state != "ready": + raise DatabaseRejected("installation_mismatch") + database = SQLiteDatabase(self.location / _DATABASE, self.installation) + with _connection(database.path, budget, readonly=True) as connection: + version = _verify_database(connection, database, previous=True) + committed = last_committed_at(connection) + if version != result.schema_version or committed != result.last_committed_at: + raise DatabaseRejected("snapshot_checkpoint_mismatch") + return result + except OSError: + raise StorageFailure("snapshot_storage_failure") from None diff --git a/backend/src/domus/infrastructure/standalone_sessions.py b/backend/src/domus/infrastructure/standalone_sessions.py new file mode 100644 index 0000000..631f0b0 --- /dev/null +++ b/backend/src/domus/infrastructure/standalone_sessions.py @@ -0,0 +1,175 @@ +"""Private in-memory OAuth credentials bind to server-owned, nonextendable leases.""" + +import logging +from dataclasses import dataclass, field +from threading import Lock, RLock +from urllib.parse import urlencode + +from domus.application.authentication_values import validate_login_material +from domus.application.identity_ports import LeaseClock +from domus.application.session_values import SessionLease +from domus.application.sessions import SessionGuard +from domus.domain.errors import Conflict, DomusError, IdentityRejected, InvalidInput +from domus.domain.identity import HumanIdentity +from domus.infrastructure.ha_http import HomeAssistantOAuth, OAuthTokens +from domus.infrastructure.ha_websocket import HomeAssistantWebSocket +from domus.infrastructure.memory_sessions import MemorySessions + +_LOGGER = logging.getLogger("domus.auth") + + +@dataclass(frozen=True, slots=True) +class _Credentials: + tokens: OAuthTokens = field(repr=False) + renew_at: int + gate: Lock = field(default_factory=Lock, repr=False, compare=False) + + +@dataclass +class StandaloneIdentity: + oauth: HomeAssistantOAuth = field(repr=False) + socket: HomeAssistantWebSocket = field(repr=False) + clock: LeaseClock + leases: MemorySessions = field(default_factory=MemorySessions, repr=False) + _credentials: dict[str, _Credentials] = field(default_factory=dict, init=False, repr=False) + _lock: RLock = field(default_factory=RLock, init=False, repr=False) + _closed: bool = field(default=False, init=False, repr=False) + + def __post_init__(self) -> None: + if self.oauth.configuration.ha != self.socket.endpoint: + raise InvalidInput("identity_endpoint_mismatch") + + def authorization_url(self, state: str, challenge: str) -> str: + validate_login_material(state) + validate_login_material(challenge) + config = self.oauth.configuration + return ( + config.ha.origin + + "/auth/authorize?" + + urlencode( + { + "client_id": config.client_id, + "redirect_uri": config.callback_url, + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + } + ) + ) + + def _record(self, tokens: OAuthTokens, gate: Lock | None = None) -> _Credentials: + return _Credentials( + tokens, + self.clock.now() + max(0, tokens.expires_in * 1000 - 30_000), + gate if gate is not None else Lock(), + ) + + def _revoke(self, tokens: OAuthTokens) -> bool: + try: + self.oauth.revoke(tokens.refresh_token) + except DomusError: + _LOGGER.warning("ha_grant_cleanup_unavailable") + return False + return True + + def sign_in( + self, code: str, verifier: str, session_id: str, issued_at: int, expires_at: int + ) -> SessionLease: + with self._lock: + if self._closed: + raise IdentityRejected("server_closed") + if self.leases.get(session_id) is not None: + raise Conflict("session_exists") + tokens = self.oauth.exchange(code, verifier) + try: + human = self.socket.current_user(tokens) + lease = SessionLease(session_id, human, issued_at, expires_at) + if not lease.valid_at(self.clock.now()): + raise IdentityRejected("session_expired") + with self._lock: + if self._closed: + raise IdentityRejected("server_closed") + self.leases.discard_expired(self.clock.now()) + for key in tuple(self._credentials): + if self.leases.get(key) is None: + del self._credentials[key] + self.leases.add(lease) + self._credentials[session_id] = self._record(tokens) + return lease + except BaseException: + self._revoke(tokens) + raise + + def get(self, session_id: str) -> SessionLease | None: + with self._lock: + return self.leases.get(session_id) + + def refresh(self, expected: SessionLease, human: HumanIdentity) -> bool: + with self._lock: + return self.leases.refresh(expected, human) + + def remove(self, session_id: str) -> None: + """Erase local credentials; this does not promise upstream grant revocation.""" + with self._lock: + self.leases.remove(session_id) + self._credentials.pop(session_id, None) + + def prove(self, session_id: str) -> HumanIdentity: + with self._lock: + selected = self._credentials.get(session_id) + if selected is None: + raise IdentityRejected("session_required") + with selected.gate: + with self._lock: + selected = self._credentials.get(session_id) + if selected is None: + raise IdentityRejected("session_required") + tokens = selected.tokens + if self.clock.now() >= selected.renew_at: + tokens = self.oauth.refresh(tokens.refresh_token, tokens.provider) + human = self.socket.current_user(tokens) + with self._lock: + if self._credentials.get(session_id) is not selected: + raise IdentityRejected("session_changed") + if tokens is not selected.tokens: + self._credentials[session_id] = self._record(tokens, selected.gate) + return human + + def sign_out(self, session_id: str) -> bool | None: + with self._lock: + credentials = self._credentials.get(session_id) + self.remove(session_id) + return None if credentials is None else self._revoke(credentials.tokens) + + def directory(self, session_id: str, ha_user_ids: tuple[str, ...]) -> tuple[HumanIdentity, ...]: + if not ha_user_ids: + return () + SessionGuard(self, self, self.clock).write(session_id) + with self._lock: + selected = self._credentials.get(session_id) + if selected is None: + raise IdentityRejected("session_changed") + with selected.gate: + with self._lock: + if self._credentials.get(session_id) is not selected: + raise IdentityRejected("session_changed") + humans = self.socket.directory_users(selected.tokens.access_token, ha_user_ids) + with self._lock: + lease = self.leases.get(session_id) + if self._credentials.get(session_id) is not selected or lease is None: + raise IdentityRejected("session_changed") + if not lease.valid_at(self.clock.now()): + self.remove(session_id) + raise IdentityRejected("session_expired") + return humans + + def shutdown(self) -> tuple[int, int]: + """Erase every local credential; at most two bounded remote revoke calls.""" + with self._lock: + self._closed = True + records = tuple(self._credentials.items()) + self._credentials.clear() + for session_id, _ in records: + self.leases.remove(session_id) + revoked = sum(self._revoke(record.tokens) for _, record in records[:2]) + return revoked, len(records) - revoked diff --git a/docs/administrator-recovery.md b/docs/administrator-recovery.md new file mode 100644 index 0000000..463171e --- /dev/null +++ b/docs/administrator-recovery.md @@ -0,0 +1,88 @@ +# Administrator access recovery + +Status: the target-bound recovery model, SQLite schema 4, human routes in both native/standalone +compositions and installation-console command are implemented. Tests include actual ASGI/SQLite +journeys and a separate process using a real terminal. Edge.2 packages these services; the owner has +verified its connection page and restart, without exercising live recovery. Edge.3 adds retained +closure guards and real-image restoration tests. Full live recovery acceptance remains pending. + +## Recover access + +A current active human HA administrator can request recovery in setup_administrator or +selected_ha_user mode. The target always comes from that person's server-proven session. Opening +Domus or knowing a request ID cannot create a grant. The all_ha_administrators mode uses its ordinary +current-source enrollment path and does not enable this recovery flow. + +1. Sign in through HA and POST /api/v1/recovery/requests with `{"expected_revision":0}`, a new + Idempotency-Key, exact configured Origin and the own session's X-CSRF-Token. Keep the returned + request ID and original operation ID. Retrying the same body/key returns the original result. +2. An installation operator reviews that exact request from the local host/container console: + + ```sh + domus-admin --channel edge --data-directory /data/domus \ + recovery approve + ``` + + The process must run as the runtime UID that owns the data. For an independently configured + standalone file, use the matching explicit installation configuration instead: + + ```sh + domus-admin --channel edge --database /owned/data/domus.sqlite3 \ + --instance-id recovery approve + ``` + +3. The terminal displays installation UUID/channel, exact HA target, mapping and policy revision, + existing member linkage/revision/profile/status, pending-request expiry and the intended explicit + administrator/active grant. Both input and output must be terminals for a new approval. Type the + displayed `APPROVE ` line exactly. This is local confirmation, not + a shared password/PIN or a secret that a browser can submit. There is no --yes bypass. +4. The same human POSTs /api/v1/recovery/requests//complete with + `{"expected_revision":2}`, current session CSRF/Origin and its own Idempotency-Key. Fresh exact HA + administrator proof is required again. A changed mapping/policy revision or member linkage/revision + invalidates the prepared approval. HA unavailability blocks completion. +5. Completion grants an independent explicit administrator profile and active membership, preserving + existing member ID, HA linkage and permission exceptions. A previously pending/suspended/archived + target is explicitly reactivated only through this reviewed recovery. The configured mapping is + retained. Consumption, member change, receipt, audit and outbox commit together. Event delivery + remains separate from that durable result. + +A pending request expires ten minutes after creation. Confirming that live request creates one +approval valid for ten full minutes from confirmation. Replaying approval retains its original fixed +deadline. Only one live +request per target is admitted, with a bounded household inventory. Expired/canceled/consumed approvals +cannot grant another effect. Concurrent completion has one result; identical same-key retry recovers +it, while another key cannot consume the approval again. A committed completion's original result is +available only under current Domus administrator authority; suspension or permission loss blocks it. + +GET /api/v1/recovery/requests/ returns only the requesting human's own state and binding +summary. Unknown or another person's request is unavailable. Existing local read-grace sessions may +view that status until their original session deadline during a bounded HA outage. They cannot write. +The owner may POST ...//cancel using the current request revision (1 pending, 2 approved) +and the same write protections. Cancellation grants no privilege and also works for an otherwise +eligible HA human who has lost HA administrator status. + +## Console and data boundaries + +Approval is a console-only application port; neither HTTP deployment composition exposes it. The CLI +uses existing ready data, never generates installation identity and never creates or migrates a +schema. It needs no HA transport token. A native console attachment holds a shared maintenance guard +while the runtime retains its exclusive process lease. Startup migrations require exclusive +maintenance access. The explicit-file CLI rejects an owned-directory marker, requiring --data-directory +instead of bypassing that protocol. All normal SQLite writer/revision guards still apply. + +A canceled/interrupted terminal does not approve. If output fails after commit, the CLI reports that +it could not confirm the result and instructs inspection or retry of the same request; it does not +claim no effect. Repeating the identical approval returns its original receipt even after normal +consumption, cancellation, expiry or policy changes. This is a read-only lookup by the bound local +installation console; it never approves again, renews a deadline or grants access. New approvals +still require a live unchanged binding and exact terminal confirmation; human completion still +requires fresh current HA proof. Restoration closure guards continue to block old approvals. +The returned result describes the original operation, so inspect current request status before acting. +Existing business and replay records are retained on rejected or uncertain commands. + +See [source-identified evidence](evidence/m1-administrator-recovery.json), +[migration behavior](migrations-identity-storage.md), [owned data](owned-data-directory.md), +[HTTP contracts](household-api.md) and [remaining operational work](operations-identity-storage.md). +Consistent snapshot/restore and packaged directory provisioning have separate real-file/image proof; +owner HA recovery/restore and complete deployment acceptance remain pending. No automatic backup +frequency or retention is introduced. diff --git a/docs/architecture-identity-storage.md b/docs/architecture-identity-storage.md new file mode 100644 index 0000000..5cf61cf --- /dev/null +++ b/docs/architecture-identity-storage.md @@ -0,0 +1,174 @@ +# Identity and storage implementation boundaries + +Status: the M1 storage kernel, pure identity policy, transaction-bound local authorization and +session-lease core, HA adapters, standalone authentication API/CLI and application household/member/policy +commands are implemented on the Action-managed issue #6 branch. Edge.3 passes bounded owner +update/session/reload/restart acceptance. Both persistent HTTP compositions, console-approved recovery, +snapshots and offline restoration/activation have real-file and process tests. Native images also +verify interruption/resume and previous-image compatibility. The [acceptance audit](m1-acceptance-audit.md) +traces the normative cases and retains current-head review, live household/recovery/restore, +product UI and complete deployment acceptance as pending. The +[owning companion](../specs/platform-foundation-identity-sqlite.md) retains the complete increment +contract; this foundation does not complete M1 or the product. + +## Implemented dependency direction + +`domus.domain.storage` defines immutable installation identity, household records, scoped command +keys and canonical JSON documents. It imports no framework, database, network or global clock. +Decoded documents are detached copies, and invalid/ambiguous JSON cannot acquire a replay digest. + +`domus.domain.identity` defines eligible human facts, all three administrator mappings, immutable +member/profile rules and deterministic permission precedence. A selected active human need not be +an HA administrator. A mapping grant is derived from current proof, separately from the member's +explicit role, so a source change removes inherited authority without deleting an independent grant. +Inactive local state, scope, private self-only access and required adult approval take precedence. +Ordinary overrides cannot configure protected administrator controls. The administrator-retention +policy evaluates supplied current identity facts; it cannot make an HA directory and SQLite atomic. + +`domus.application.ports` defines semantic household, member/policy, receipt, audit, outbox and +transaction ports. +`AtomicCommands` requires an injected authorization policy; it has no permissive production default. +It checks that policy inside the write transaction before examining a previous receipt. Identical +authorized replay returns the original result; changed payload conflicts. A new effect, receipt, +audit fact and outbox event commit together. `HouseholdAccess` and `MembershipAuthorization` now +resolve current membership/policy inside that transaction and bind the action to the proven actor. +They do not authenticate a submitted identity; only provider proof supplies it. The standalone +[household HTTP API](household-api.md) now adapts server-bound application services. +[The application commands](household-setup.md) explicitly initialize, +enroll, edit members and replace policy. External proof preparation holds no writer lock. Before +save, administrator transitions compare the prepared inventory, recheck current local authority and +preserve a proven eligible administrator. Shared application proof preparation imports only ports +and domain values. Setup-only mapping uses the unique durable setup audit actor; a missing or +ambiguous record cannot establish a new founder. + +`domus.infrastructure.sqlite` implements those ports with separate connections and explicit SQL +transactions. Reads are query-only. Writers use `BEGIN IMMEDIATE` and a bounded wait, with no hidden +new-key retry. Schema/application/installation/channel checks precede mutations and repeat after +the writer lock. Initial schema creation uses transactional DDL, without `executescript`. +Unknown/future schemas and wrong ownership are refused. Receipts are retained and are never pruned +with activity; a future retention feature must preserve replay protection. + +`domus.infrastructure.owned_storage` owns POSIX directory/file validation, lifecycle locking and +durable initialization/ready metadata. It generates a UUID only for an empty private directory and +passes an existing-only mode to SQLite after readiness, preventing silent empty replacement. The +native and standalone compositions initialize storage once before starting HTTP and release ownership +at shutdown, including listener failures. Standalone credential shutdown cannot prevent lease release. +Both explicit-file runtime factories reject an owned marker before SQLite initialization, even when +the owned runtime is stopped. Console attachment applies the same boundary. +All filesystem/locking primitives remain outside pure inner layers. See [data lifecycle](owned-data-directory.md). + +The path-free GenerationSelection value adds strict version-2 root metadata. Managed startup +selects only its private bound slot after rereading selection under the root leases; legacy flat +data stays readable but cannot override a selected generation. Slots reject direct lifecycle and +explicit-file bypass. Blocked restore identity now precedes database copying, and every managed +identity/journal signal blocks direct startup. The offline console now publishes only a bound, +verified target under exclusive root leases and resumes its durable transition. See +[generation evidence](evidence/m1-generation-selection.json). + +Schema 2 added uniquely linked members and revisioned household policy. Its explicit additive +upgrade accepts the owned schema 1 checkpoint and preserves household data, receipts, audit and +outbox. Failure rolls back the entire DDL migration. The frozen old-schema fixture is independent +of current schema constants. See [migration limits](migrations-identity-storage.md). + +Schema 3 adds recovery requests. The pure model binds target, installation/channel, policy/mapping +and member revisions to fixed ten-minute request and approval windows. Human commands require fresh provider proof; +the separate console use case obtains an explicit terminal decision outside the writer lock. The +transaction rechecks binding and commits approval or consumption/grant with receipt/audit/outbox. +Both HTTP compositions expose human routes while only domus-admin wires the console confirmation port. + +Snapshot metadata remains pure and path-free. Application use cases consume a semantic archive port; +the SQLite adapter owns filesystem checks, read transactions, backup, hashes and integrity inspection. +Only the installation-console composition selects paths. [Manual snapshot evidence](evidence/m1-snapshots.json) +does not establish restore activation or newer-history reconciliation. + +Restoration now adds pure complete-operation evidence and streaming reconciliation. Its semantic +application port binds preparation to an explicit checkpoint and installation. The SQLite adapter +seals the exact old database plus later facts in a new blocked directory, then independently verifies +hashes, projection and counts. It never replaces active data or starts a runtime. Schema 4 now retains +original complete facts separately from checkpoint rows and provides history-aware receipt/delivery +lookup. An infrastructure-only transactional import preserves acknowledgements, sequence high-water +and first archive provenance. Current authority still precedes replay/disclosure, and indexed recovery +closure guards prevent old approvals from granting access again. Both APIs distinguish historical +results and suppressed delivery; current setup provenance reads only current audit. Verified offline +activation now has real console, fault, frozen-schema and both-composition tests. The Edge.3 +image additionally passes previous-image upgrade/refusal and restore on both CPU families. Its +[owner update/session/restart acceptance](evidence/m1-edge3-ha-acceptance.json) is bounded; complete +deployment and live household/recovery/restore acceptance remain pending. See +[restoration boundaries](restoration.md) and the [complete SDD](../specs/platform-restoration.md). + +One household per installation is reinforced by a singleton constraint. Household writes compare +the expected revision, and other input values use parameterized SQL. Outbox acknowledgements are +durable and duplicate-safe; delivering those events to HA is future work. + +`SessionGuard` distinguishes bounded provider unavailability from explicit rejection. It requires +fresh proof for writes, while a read may use the latest known identity until the unchanged absolute +monotonic deadline. Rejection, expiry and identity mismatch deny access. A conditional in-memory +refresh cannot resurrect logout or overwrite a concurrently changed identity. Leases never contain +HA tokens or enter SQLite; their private session handles are omitted from diagnostic representations. +Restart requires authentication. This is tested with semantic +provider fixtures, not a claim of authentication on the owner's live HA. + +The [HA adapters](ha-identity-protocol.md) now implement fixed operator endpoints, private OAuth +form exchange and correlated WebSocket proof, with explicit active/non-system provenance and +administrator/owner semantics. Supervisor's actual peer and a single candidate subject header precede +fresh Core lookup. The real pinned WebSocket client is exercised against disposable loopback servers, +including a negative cross-origin redirect fixture. No request may authenticate by constructing +an adapter value. The [native persistent runtime](native-household-api.md) now shares the household +routes, bounded local leases and fixed-origin CSRF. Edge.3 packages schema 4 and restoration and +passes both native CPU-family image checks plus bounded owner update/reload/restart acceptance. + +`domus.application.authentication_values` and `session_values` own ephemeral login/lease contracts; +private browser/session material stays outside household domain entities. Application orchestration +atomically consumes browser-bound attempts before exchange and checks the returned target/deadline. +Concurrent callbacks cannot exchange twice. + +`domus.infrastructure.standalone_sessions` privately binds HA credentials to leases, serializes fresh +proof/refresh per session and cannot restore a concurrently removed record. Unknown upstream +revocation is null, never fabricated success. Credentials remain bounded memory only. + +`domus.entrypoints.http` owns strict DTOs, cookie/origin/CSRF checks, HTTP bounds, safe errors and actual +OpenAPI. `domus.entrypoints.household_http` owns strict business DTOs and uses a common HTTP contract +module, separate from domain records. `domus.composition.server` wires the standalone CLI with one +worker, disabled proxy rewriting and no access/query logging. Its optional --database enables +composition.household, private session actors/directory and local query services. Sign-in creates +no household. See [configuration](configuration.md), +[the user journey](identity-household.md) and [operations](operations-identity-storage.md). + +`domus.composition.ingress` wires both native runtime forms. Installed Edge.3 uses +`domus.composition.ha_app` to provision its private child and exec the persistent runtime without +root privileges. [Original preview acceptance](evidence/m1-native-ha-acceptance.json) is distinct from +[persistent owner acceptance](evidence/m1-persistent-ha-acceptance.json). Neither establishes complete +product, live household-command or standalone-browser acceptance. + +Its optional persistent composition uses the same household router and services as standalone. +Infrastructure binds local leases to keyed digests of the existing Supervisor session and installation +context; no raw cookie is persisted. The native entrypoint verifies configured origins and CSRF, +while the application still requires fresh human proof and current transaction-time Domus authority. +Technical Core directory access never sets the actor's role. See the +[native runtime evidence](evidence/m1-native-household-api.json) for exact tests and remaining packaging. + +## Verification scope + +The separate backend CI job measures every authored `domus` module at 100% supported line/statement +and branch coverage. Tooling coverage remains independent. The architecture job checks the actual +backend graph as well as the existing forbidden-import/cycle fixtures. The aggregate requires every +suite on every PR update, including documentation changes. + +Real-file tests cover rollback at every durable stage, process death before commit, reopened state, +two-process writer contention/stale revisions, overlapping identical commands, current-policy replay +denial, schema/ownership refusal without changing existing file bytes, and initial migration rollback. +Additional tests cover policy precedence/source revocation, concrete authorization before replay, +schema 1 upgrades, duplicate HA linkage, scoped optimistic writes, and session outage/expiry/races. +These fixtures alone do not establish live HA or complete packaged acceptance. The separate +[member](evidence/m1-member-administration.json) and [policy](evidence/m1-policy-administration.json) +checkpoints add real-file authority races, canonical retries and provenance checks. The +[HTTP checkpoint](evidence/m1-household-api.json) adds production standalone composition, strict JSON, +read-grace/authority boundaries and schema tests. Native persistent packaging, restore and full M1 +acceptance remain outstanding. + +See [development checks](development.md), [owner decisions](decisions.md), +[the companion](../specs/platform-foundation-identity-sqlite.md), and +[initial storage evidence](evidence/m1-storage-kernel.json) and +[identity-policy checkpoint evidence](evidence/m1-identity-policy.json). +The subsequent [protocol evidence](evidence/m1-ha-protocol.json) identifies the adapter sources, +reviewed Core blobs, fictitious provider fixtures and outstanding live/package acceptance. diff --git a/docs/architecture.md b/docs/architecture.md index 8c6a6f5..5cc23f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,6 +84,13 @@ Supervisor mode uses App/Ingress packaging and persistent App storage. Standalon binding, an owned data volume, Core APIs and authenticated access; it assumes no Supervisor or Ingress. Both map verified individual HA users to Domus membership and roles. Ingress headers are accepted only behind a verified trust boundary; standalone never trusts a browser-supplied identity header. + +The [native persistent runtime](native-household-api.md) binds bounded in-memory leases to the +Supervisor session, exact human, installation/channel and Ingress path. It shares the standalone +household DTOs and application use cases; fresh Core proof and transaction-time Domus authorization +remain distinct. Configured public origins and scoped CSRF protect writes independently of forwarded +metadata. Installed Edge.3 packages private persistence and restoration; both CPU-family image suites pass. +The complete UI and full deployment acceptance remain pending. HA OAuth or an equivalent supported HA authentication flow is a required proof step before implementation readiness. No child PIN, shared adult session or parallel Domus password store is proposed. diff --git a/docs/compatibility-home-assistant.md b/docs/compatibility-home-assistant.md new file mode 100644 index 0000000..a4d33e3 --- /dev/null +++ b/docs/compatibility-home-assistant.md @@ -0,0 +1,53 @@ +# Home Assistant reference and acceptance matrix + +Status: bounded native Edge.3 update/session/reload/restart accepted on the exact owner target below on 2026-10-31. +Complete M1, standalone browser return and production compatibility remain pending. + +| Target | Core | Supervisor | HA OS | Frontend | Domus acceptance | +|---|---|---|---|---|---| +| Standalone reference | 2026.10.4 | Not applicable | Not applicable | Not a server dependency | Offline fixtures pass; live browser return failed; owned OAuth grant revoked; acceptance pending | +| Owner Supervisor/Ingress reference | 2026.10.4 | 2026.10.5 | 18.3 | 20260930.5 | Edge 0.0.0-edge.3 update, configured origin, session, Chrome reload and app restart pass; live household/recovery/restore pending | + +The separate read-only Chrome About observation in the [acceptance audit](evidence/m1-acceptance-audit.json) +confirmed these platform versions on 2026-10-31. It does not retroactively add observations to older +immutable receipts. The current Supervisor observation supersedes the historical 2026.10.3 value in the clarification +history. That earlier version has not passed Domus acceptance. Every other combination is unverified +until tested. Both deployment targets remain required for the first usable release. + +The authorized browser entry is `/home/overview`; version observations used `/config/info`. +The earlier [read-only observation](evidence/home-assistant-reference.json) remains historical. +The owner then authorized adding the development repository and installing/starting its Edge app. +No entities, devices, automations, household records or users were changed. No credentials, private +frontend state, session cookies or HA tokens were extracted into evidence. The app remains running +for development with automatic update and start on boot disabled. + +[Original native preview evidence](evidence/m1-native-ha-acceptance.json) identifies its package/runtime +commits, checksum, safe outer browser URL, exact versions, assertions and retained app state. +The initial iframe request stalled while Chrome's task debugger was attached. Detaching that +debugger resumed loading; normal native Chrome reload also passed. No application authentication, +peer or permission boundary was weakened. That original Edge.1 preview had no household database or commands. + +[Edge.3 owner evidence](evidence/m1-edge3-ha-acceptance.json) records the later persistent package +update and unchanged public origin. The installed runtime pin is +`faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5`; new source response explanations in the acceptance audit +are not part of that immutable package. Actual owner SQLite contents were not queried. Separate +[real-image evidence](evidence/m1-restoration-app-package.json) covers synthetic household/history, +restoration, interruption and previous-image compatibility, not live owner data or general support. + +## Required evidence before support claims + +Record the exact Domus source/artifact, channel, Core/Supervisor versions, applicable protocol and +package cases, browser path, owned test records and cleanup. Keep a row pending until its cases +execute. Offline adapters and high unit coverage cannot establish Ingress cookie/peer behavior or +real deployment compatibility. A stale/missing/rejected identity must never become administrator +authority through version detection or a fallback. + +See [the companion contract](../specs/platform-foundation-identity-sqlite.md) and +[implementation boundaries](architecture-identity-storage.md). + +The [protocol checkpoint](ha-identity-protocol.md) has provider/loopback fixtures against the reviewed +Core schema. Those fixtures alone cannot promote a matrix row. The +[standalone authentication checkpoint](identity-household.md) adds browser-bound login and +cookie/session HTTP fixtures. A live test exchanged one delegated grant, but did not establish a +working browser session return. Shutdown confirmed revocation of that owned grant. Native preview +acceptance does not establish standalone acceptance or general support for these versions. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..dcf83a9 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,100 @@ +# Authentication checkpoint configuration + +For the native HA app, follow [app installation](installation-home-assistant.md). It uses Ingress +and requires no second login. The [native persistent development runtime](native-household-api.md) +also has an explicit database/UUID/fixed-origin development contract. Installed Edge.3 uses the +managed-directory launcher described below. +The options below apply to the independent standalone runtime. + +Trusted origins omit scheme-default ports after validation: `https://domus.example:443` becomes +`https://domus.example`, and HTTP port 80 is likewise omitted. Non-default ports are retained. +Native Origin/CSRF checks remain exact against the canonical configured origins; equivalent duplicate +entries are rejected. Standalone OAuth client and callback URLs use the same canonical public origin. + +Both CLIs support [--data-directory](owned-data-directory.md) for an existing private +0700 directory owned by its runtime UID. It generates/preserves installation identity, checks files +and holds the process lock until shutdown. Use it with --public-origin; do not also supply +--database or --instance-id. Installed Edge.3 wires this lifecycle through its fixed launcher. +Both explicit-file runtime modes reject a parent installation.json marker before SQLite initialization. +Use the owned-directory entrypoint for managed data; an explicit file cannot bypass its locks. + +[domus-admin recovery approve](administrator-recovery.md) uses --data-directory and --channel to +attach to ready managed data in either mode, or --database plus --instance-id and --channel for an explicit standalone +file. It does not create/migrate storage and needs no HA token. New approval requires real terminal +input/output and the exact displayed request/target confirmation. HTTP cannot supply console authority. + +[domus-admin snapshot](snapshots.md) provides explicit create/inspect operations. Creation uses the +same source selection as approval and requires a new private destination. Inspection instead requires +only --instance-id and --channel, without an active --database/--data-directory. Its --timeout defaults +to 30 seconds and accepts finite positive values up to 3600. It does not add a schedule or retention. + +Status: these options run the standalone API. `--data-directory` or `--database` additionally wires the +persistent [household API](household-api.md); omitting both keeps the authentication preview. This is not a deployment +package or complete M1 acceptance. Both installation modes and Stable/Edge +remain first-release requirements. Production channels/artifacts have not been published. + +| Option | Required/default | Contract | +|---|---|---| +| --ha-url | Required | One fixed HTTP(S) origin; no userinfo, query, fragment, arbitrary path or request override | +| --public-origin | Required | Exact Domus website/client/callback origin; HTTPS except explicitly enabled loopback development | +| --instance-id | Required without --data-directory; forbidden with it | Canonical nonzero UUID supplied by the installation for preview/explicit files; managed data generates it only on a valid empty start | +| --channel | Required | stable or edge; keep installation IDs, data, credentials and endpoint bindings separate | +| --development-http | Disabled | Permits a loopback public HTTP origin; development listener must bind loopback | +| --host | 127.0.0.1 | Listener binding; development accepts only 127.0.0.1 or ::1 | +| --port | 8099 | Integer 1–65535; no fallback to a different port or origin | +| --database | Omitted: authentication preview | Owned SQLite file with an existing parent directory; validates the configured UUID/channel and keeps state across restart | +| --data-directory | Omitted; mutually exclusive with --database | Existing runtime-owned 0700 directory; automatically preserves UUID/channel and holds runtime/maintenance guards until shutdown | + +Example using the nonsecret UUID fixture from the tests; use the installation's own persistent UUID +for an actual installation: + +```sh +uv run --frozen domus-server \ + --ha-url http://homeassistant.local:8123 \ + --public-origin http://127.0.0.1:8099 \ + --instance-id 047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6 \ + --channel edge \ + --development-http +``` + +The example omits both storage selections and opens no business database. With either selection, schema initialization +still creates no household or member: explicit authorized API commands do that. Identity +proof and session cookies are scoped to the configured installation/channel; that is not package or +Stable/Edge coexistence acceptance. Production HTTPS termination and private deployment wiring +remain deployment gates. The server uses one worker because authentication state is process-local. + +## Fixed bounds + +| Resource | Current bound | +|---|---| +| Login attempts | 128; five-minute absolute lifetime; expired attempts reclaimed | +| Active sessions | 256; one-hour absolute lifetime; no extension on proof/refresh/outage | +| Session policy constructor | Five minutes to eight hours; no lifetime CLI option yet | +| HTTP request body | 65,536 bytes, including chunked input; five-second receiving deadline | +| HTTP request headers | 100 headers / 16,384 aggregate bytes | +| Raw query | 8,192 bytes before parsing; duplicate fields rejected | +| Server concurrency / keep-alive | 64 / five seconds | +| Graceful server shutdown | Ten-second worker grace, then bounded owned-grant cleanup | +| Remote shutdown cleanup | At most two five-second revoke operations; discard all local credentials | + +These are implementation bounds subject to the outstanding package/operations review, not a claimed +recovery-time SLA. [Protocol I/O bounds](ha-identity-protocol.md) are separately documented. + +Origin/Host validation uses fixed configuration. Cookies are HttpOnly, SameSite=Lax and host-only; +HTTPS uses the __Host- prefix. Names include the installation/channel namespace. Development HTTP +has explicitly distinct cookie names. CSRF keys are private process memory and regenerate at restart. +Request forwarding/Ingress headers cannot change identity, callback, public origin or HA destination. + +The command disables proxy-header rewriting and access logs, so OAuth callback queries are not logged. +Private HA wire logging is disabled. Logs contain allowlisted codes and cleanup counts, never raw +credentials or provider payloads. No credential is supplied as a CLI argument or stored in SQLite. + +## Native package launcher + +The installed Edge.3 domus-ha-app --channel edge entrypoint has fixed /data paths and UID/GID 65532. +Its only options.json field is public_origins: +an array of one to four distinct origins used to open HA, such as http://homeassistant.local:8123. +Scheme/host/port must be explicit and no path, query, credentials or fragment is accepted. Values +normalize through the same PublicOrigins adapter as the native CLI. The file is bounded to 16,384 +bytes; unknown/duplicate fields and unsafe ownership/mode are rejected. It supplies no UUID: owned +storage generates one only on a valid empty first start. See [provisioning](owned-data-directory.md). diff --git a/docs/contracts/auth-api.openapi.json b/docs/contracts/auth-api.openapi.json new file mode 100644 index 0000000..280381c --- /dev/null +++ b/docs/contracts/auth-api.openapi.json @@ -0,0 +1,886 @@ +{ + "components": { + "schemas": { + "CSRFResponse": { + "additionalProperties": false, + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "CSRFResponse", + "type": "object" + }, + "EmptyRequest": { + "additionalProperties": false, + "properties": {}, + "title": "EmptyRequest", + "type": "object" + }, + "ErrorResponse": { + "additionalProperties": false, + "properties": { + "action_required": { + "title": "Action Required", + "type": "string" + }, + "code": { + "title": "Code", + "type": "string" + }, + "correlation_id": { + "title": "Correlation Id", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "state": { + "const": "blocked", + "title": "State", + "type": "string" + } + }, + "required": [ + "state", + "code", + "message", + "action_required", + "correlation_id" + ], + "title": "ErrorResponse", + "type": "object" + }, + "LogoutResponse": { + "additionalProperties": false, + "properties": { + "action_required": { + "title": "Action Required", + "type": "string" + }, + "message": { + "const": "You are signed out of Domus.", + "default": "You are signed out of Domus.", + "title": "Message", + "type": "string" + }, + "state": { + "const": "logged_out", + "default": "logged_out", + "title": "State", + "type": "string" + }, + "upstream_revoked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Upstream Revoked" + } + }, + "required": [ + "upstream_revoked", + "action_required" + ], + "title": "LogoutResponse", + "type": "object" + }, + "SessionResponse": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "full", + "read_only" + ], + "title": "Access", + "type": "string" + }, + "action_required": { + "title": "Action Required", + "type": "string" + }, + "expires_in": { + "title": "Expires In", + "type": "integer" + }, + "ha_administrator": { + "title": "Ha Administrator", + "type": "boolean" + }, + "ha_user_id": { + "title": "Ha User Id", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "state": { + "const": "authenticated", + "default": "authenticated", + "title": "State", + "type": "string" + } + }, + "required": [ + "ha_user_id", + "ha_administrator", + "access", + "expires_in", + "message", + "action_required" + ], + "title": "SessionResponse", + "type": "object" + } + }, + "securitySchemes": { + "DomusSession": { + "in": "cookie", + "name": "__Host-domus-edge-session", + "type": "apiKey" + } + } + }, + "info": { + "title": "Domus", + "version": "0.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/v1/auth/callback": { + "get": { + "description": "Exactly one of code or error is required, together with state and the bound browser cookie. Providing both outcomes or neither returns 422 without consuming the attempt. A code completes authorization; an error records a declined authorization.", + "operationId": "callback_api_v1_auth_callback_get", + "parameters": [ + { + "in": "query", + "name": "state", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 43, + "pattern": "^[A-Za-z0-9._~-]+$", + "title": "State", + "type": "string" + } + }, + { + "in": "query", + "name": "code", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 4096, + "minLength": 1, + "pattern": "^[A-Za-z0-9._~-]+$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + } + }, + { + "in": "query", + "name": "error", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z_]+$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + } + } + ], + "responses": { + "303": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "summary": "Callback" + } + }, + "/api/v1/auth/csrf": { + "get": { + "operationId": "csrf_api_v1_auth_csrf_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CSRFResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Csrf" + } + }, + "/api/v1/auth/logout": { + "post": { + "operationId": "logout_api_v1_auth_logout_post", + "parameters": [ + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 64, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmptyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Logout" + } + }, + "/api/v1/auth/start": { + "get": { + "operationId": "start_api_v1_auth_start_get", + "responses": { + "303": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "summary": "Start" + } + }, + "/api/v1/session": { + "get": { + "operationId": "session_api_v1_session_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Session" + } + } + } +} diff --git a/docs/contracts/household-api.openapi.json b/docs/contracts/household-api.openapi.json new file mode 100644 index 0000000..78429ca --- /dev/null +++ b/docs/contracts/household-api.openapi.json @@ -0,0 +1,3853 @@ +{ + "components": { + "schemas": { + "AccessResponse": { + "additionalProperties": false, + "properties": { + "action_required": { + "title": "Action Required", + "type": "string" + }, + "effective_role": { + "anyOf": [ + { + "$ref": "#/components/schemas/Role" + }, + { + "type": "null" + } + ] + }, + "enrollment_allowed": { + "title": "Enrollment Allowed", + "type": "boolean" + }, + "member": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemberResponse" + }, + { + "type": "null" + } + ] + }, + "message": { + "title": "Message", + "type": "string" + }, + "permission_scope": { + "const": "household_administration", + "title": "Permission Scope", + "type": "string" + }, + "permissions": { + "items": { + "$ref": "#/components/schemas/Permission" + }, + "title": "Permissions", + "type": "array" + }, + "policy_revision": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Policy Revision" + }, + "setup_allowed": { + "title": "Setup Allowed", + "type": "boolean" + }, + "state": { + "$ref": "#/components/schemas/AccessState" + } + }, + "required": [ + "state", + "setup_allowed", + "enrollment_allowed", + "member", + "policy_revision", + "effective_role", + "permission_scope", + "permissions", + "message", + "action_required" + ], + "title": "AccessResponse", + "type": "object" + }, + "AccessState": { + "enum": [ + "unconfigured", + "not_enrolled", + "pending", + "active", + "suspended", + "archived" + ], + "type": "string" + }, + "CSRFResponse": { + "additionalProperties": false, + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "CSRFResponse", + "type": "object" + }, + "EmptyRequest": { + "additionalProperties": false, + "properties": {}, + "title": "EmptyRequest", + "type": "object" + }, + "EnrollmentRequest": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "maximum": 0.0, + "minimum": 0.0, + "title": "Expected Revision", + "type": "integer" + } + }, + "required": [ + "expected_revision" + ], + "title": "EnrollmentRequest", + "type": "object" + }, + "ErrorResponse": { + "additionalProperties": false, + "properties": { + "action_required": { + "title": "Action Required", + "type": "string" + }, + "code": { + "title": "Code", + "type": "string" + }, + "correlation_id": { + "title": "Correlation Id", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "state": { + "const": "blocked", + "title": "State", + "type": "string" + } + }, + "required": [ + "state", + "code", + "message", + "action_required", + "correlation_id" + ], + "title": "ErrorResponse", + "type": "object" + }, + "HouseholdResponse": { + "additionalProperties": false, + "properties": { + "household_id": { + "title": "Household Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "revision": { + "title": "Revision", + "type": "integer" + }, + "time_zone": { + "title": "Time Zone", + "type": "string" + } + }, + "required": [ + "household_id", + "name", + "time_zone", + "revision" + ], + "title": "HouseholdResponse", + "type": "object" + }, + "HouseholdUpdateRequest": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "maximum": 9.223372036854776e+18, + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" + }, + "name": { + "maxLength": 160, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "time_zone": { + "maxLength": 128, + "minLength": 1, + "title": "Time Zone", + "type": "string" + } + }, + "required": [ + "expected_revision", + "name", + "time_zone" + ], + "title": "HouseholdUpdateRequest", + "type": "object" + }, + "JsonValue": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "items": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "array" + }, + { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "LogoutResponse": { + "additionalProperties": false, + "properties": { + "action_required": { + "title": "Action Required", + "type": "string" + }, + "message": { + "const": "You are signed out of Domus.", + "default": "You are signed out of Domus.", + "title": "Message", + "type": "string" + }, + "state": { + "const": "logged_out", + "default": "logged_out", + "title": "State", + "type": "string" + }, + "upstream_revoked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Upstream Revoked" + } + }, + "required": [ + "upstream_revoked", + "action_required" + ], + "title": "LogoutResponse", + "type": "object" + }, + "MappingMode": { + "enum": [ + "all_ha_administrators", + "setup_administrator", + "selected_ha_user" + ], + "title": "MappingMode", + "type": "string" + }, + "MappingResponse": { + "additionalProperties": false, + "properties": { + "mode": { + "$ref": "#/components/schemas/MappingMode" + }, + "target_ha_user_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Ha User Id" + } + }, + "required": [ + "mode", + "target_ha_user_id" + ], + "title": "MappingResponse", + "type": "object" + }, + "MemberResponse": { + "additionalProperties": false, + "properties": { + "exceptions": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/PermissionDecision" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Exceptions" + }, + "ha_user_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ha User Id" + }, + "member_id": { + "title": "Member Id", + "type": "string" + }, + "revision": { + "title": "Revision", + "type": "integer" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "status": { + "$ref": "#/components/schemas/MemberStatus" + } + }, + "required": [ + "member_id", + "role", + "status", + "revision" + ], + "title": "MemberResponse", + "type": "object" + }, + "MemberStatus": { + "enum": [ + "pending", + "active", + "suspended", + "archived" + ], + "title": "MemberStatus", + "type": "string" + }, + "MemberUpdateRequest": { + "additionalProperties": false, + "properties": { + "exceptions": { + "items": { + "$ref": "#/components/schemas/PermissionDecision" + }, + "maxItems": 256, + "title": "Exceptions", + "type": "array" + }, + "expected_revision": { + "maximum": 9.223372036854776e+18, + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "status": { + "$ref": "#/components/schemas/MemberStatus" + } + }, + "required": [ + "expected_revision", + "role", + "status" + ], + "title": "MemberUpdateRequest", + "type": "object" + }, + "MembersResponse": { + "additionalProperties": false, + "properties": { + "members": { + "items": { + "$ref": "#/components/schemas/MemberResponse" + }, + "title": "Members", + "type": "array" + } + }, + "required": [ + "members" + ], + "title": "MembersResponse", + "type": "object" + }, + "OperationResponse": { + "additionalProperties": false, + "properties": { + "action_required": { + "default": "Keep the operation ID. If the response was lost, retry the same payload and key.", + "title": "Action Required", + "type": "string" + }, + "archived_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Archived By" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "delivery": { + "enum": [ + "pending", + "delivered", + "suppressed" + ], + "title": "Delivery", + "type": "string" + }, + "message": { + "default": "The household change was saved. Event delivery is tracked separately.", + "title": "Message", + "type": "string" + }, + "operation_id": { + "title": "Operation Id", + "type": "string" + }, + "result": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Result", + "type": "object" + }, + "state": { + "default": "committed", + "enum": [ + "committed", + "historical" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "operation_id", + "result", + "created_at", + "delivery" + ], + "title": "OperationResponse", + "type": "object" + }, + "Permission": { + "enum": [ + "household.read", + "household.manage", + "members.read", + "members.manage", + "policy.manage", + "preferences.read", + "preferences.write", + "operations.read", + "tasks.create", + "tasks.complete", + "tasks.approve", + "tasks.configure", + "shopping.request", + "shopping.manage", + "inventory.manage", + "automations.configure", + "points.manage" + ], + "title": "Permission", + "type": "string" + }, + "PermissionDecision": { + "additionalProperties": false, + "properties": { + "allow": { + "title": "Allow", + "type": "boolean" + }, + "permission": { + "$ref": "#/components/schemas/Permission" + } + }, + "required": [ + "permission", + "allow" + ], + "title": "PermissionDecision", + "type": "object" + }, + "PolicyResponse": { + "additionalProperties": false, + "properties": { + "adjustments": { + "items": { + "$ref": "#/components/schemas/ProfileDecision" + }, + "title": "Adjustments", + "type": "array" + }, + "household_id": { + "title": "Household Id", + "type": "string" + }, + "mapping": { + "$ref": "#/components/schemas/MappingResponse" + }, + "revision": { + "title": "Revision", + "type": "integer" + } + }, + "required": [ + "household_id", + "mapping", + "revision", + "adjustments" + ], + "title": "PolicyResponse", + "type": "object" + }, + "PolicyUpdateRequest": { + "additionalProperties": false, + "properties": { + "adjustments": { + "items": { + "$ref": "#/components/schemas/ProfileDecision" + }, + "maxItems": 256, + "title": "Adjustments", + "type": "array" + }, + "expected_revision": { + "maximum": 9.223372036854776e+18, + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" + }, + "mapping_mode": { + "$ref": "#/components/schemas/MappingMode" + }, + "target_ha_user_id": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{32}$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Ha User Id" + } + }, + "required": [ + "expected_revision", + "mapping_mode" + ], + "title": "PolicyUpdateRequest", + "type": "object" + }, + "ProfileDecision": { + "additionalProperties": false, + "properties": { + "allow": { + "title": "Allow", + "type": "boolean" + }, + "permission": { + "$ref": "#/components/schemas/Permission" + }, + "role": { + "$ref": "#/components/schemas/Role" + } + }, + "required": [ + "permission", + "allow", + "role" + ], + "title": "ProfileDecision", + "type": "object" + }, + "ReceiptResponse": { + "additionalProperties": false, + "properties": { + "action_required": { + "default": "Keep the operation ID. If the response was lost, retry the same payload and key.", + "title": "Action Required", + "type": "string" + }, + "archived_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Archived By" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "message": { + "default": "The household change was saved. Event delivery is tracked separately.", + "title": "Message", + "type": "string" + }, + "operation_id": { + "title": "Operation Id", + "type": "string" + }, + "result": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Result", + "type": "object" + }, + "state": { + "default": "committed", + "enum": [ + "committed", + "historical" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "operation_id", + "result", + "created_at" + ], + "title": "ReceiptResponse", + "type": "object" + }, + "RecoveryResponse": { + "additionalProperties": false, + "properties": { + "approved_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Approved At" + }, + "closed_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Closed At" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "expires_at": { + "title": "Expires At", + "type": "integer" + }, + "mapping_mode": { + "enum": [ + "setup_administrator", + "selected_ha_user" + ], + "title": "Mapping Mode", + "type": "string" + }, + "member_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Member Id" + }, + "member_revision": { + "title": "Member Revision", + "type": "integer" + }, + "policy_revision": { + "title": "Policy Revision", + "type": "integer" + }, + "request_id": { + "title": "Request Id", + "type": "string" + }, + "revision": { + "title": "Revision", + "type": "integer" + }, + "state": { + "enum": [ + "pending", + "approved", + "consumed", + "canceled", + "expired" + ], + "title": "State", + "type": "string" + }, + "target_ha_user_id": { + "title": "Target Ha User Id", + "type": "string" + } + }, + "required": [ + "request_id", + "state", + "revision", + "target_ha_user_id", + "mapping_mode", + "policy_revision", + "member_id", + "member_revision", + "created_at", + "expires_at", + "approved_at", + "closed_at" + ], + "title": "RecoveryResponse", + "type": "object" + }, + "RecoveryTransitionRequest": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "maximum": 9.223372036854776e+18, + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" + } + }, + "required": [ + "expected_revision" + ], + "title": "RecoveryTransitionRequest", + "type": "object" + }, + "Role": { + "enum": [ + "administrator", + "adult", + "restricted", + "viewer" + ], + "title": "Role", + "type": "string" + }, + "SessionResponse": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "full", + "read_only" + ], + "title": "Access", + "type": "string" + }, + "action_required": { + "title": "Action Required", + "type": "string" + }, + "expires_in": { + "title": "Expires In", + "type": "integer" + }, + "ha_administrator": { + "title": "Ha Administrator", + "type": "boolean" + }, + "ha_user_id": { + "title": "Ha User Id", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "state": { + "const": "authenticated", + "default": "authenticated", + "title": "State", + "type": "string" + } + }, + "required": [ + "ha_user_id", + "ha_administrator", + "access", + "expires_in", + "message", + "action_required" + ], + "title": "SessionResponse", + "type": "object" + }, + "SetupRequest": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "maximum": 0.0, + "minimum": 0.0, + "title": "Expected Revision", + "type": "integer" + }, + "mapping_mode": { + "$ref": "#/components/schemas/MappingMode", + "default": "all_ha_administrators" + }, + "name": { + "maxLength": 160, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "selected_ha_user_id": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{32}$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Selected Ha User Id" + }, + "time_zone": { + "maxLength": 128, + "minLength": 1, + "title": "Time Zone", + "type": "string" + } + }, + "required": [ + "expected_revision", + "name", + "time_zone" + ], + "title": "SetupRequest", + "type": "object" + } + }, + "securitySchemes": { + "DomusSession": { + "in": "cookie", + "name": "__Host-domus-edge-session", + "type": "apiKey" + } + } + }, + "info": { + "title": "Domus", + "version": "0.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/v1/auth/callback": { + "get": { + "description": "Exactly one of code or error is required, together with state and the bound browser cookie. Providing both outcomes or neither returns 422 without consuming the attempt. A code completes authorization; an error records a declined authorization.", + "operationId": "callback_api_v1_auth_callback_get", + "parameters": [ + { + "in": "query", + "name": "state", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 43, + "pattern": "^[A-Za-z0-9._~-]+$", + "title": "State", + "type": "string" + } + }, + { + "in": "query", + "name": "code", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 4096, + "minLength": 1, + "pattern": "^[A-Za-z0-9._~-]+$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + } + }, + { + "in": "query", + "name": "error", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z_]+$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + } + } + ], + "responses": { + "303": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "summary": "Callback" + } + }, + "/api/v1/auth/csrf": { + "get": { + "operationId": "csrf_api_v1_auth_csrf_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CSRFResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Csrf" + } + }, + "/api/v1/auth/logout": { + "post": { + "operationId": "logout_api_v1_auth_logout_post", + "parameters": [ + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 64, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmptyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Logout" + } + }, + "/api/v1/auth/start": { + "get": { + "operationId": "start_api_v1_auth_start_get", + "responses": { + "303": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "summary": "Start" + } + }, + "/api/v1/authorization": { + "get": { + "operationId": "access_api_v1_authorization_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Access" + } + }, + "/api/v1/household": { + "get": { + "operationId": "household_api_v1_household_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HouseholdResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Household" + }, + "post": { + "operationId": "setup_api_v1_household_post", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Setup" + }, + "put": { + "operationId": "update_household_api_v1_household_put", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HouseholdUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Update Household" + } + }, + "/api/v1/members": { + "get": { + "operationId": "members_api_v1_members_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembersResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Members" + } + }, + "/api/v1/members/enrollment": { + "post": { + "operationId": "enroll_api_v1_members_enrollment_post", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrollmentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Enroll" + } + }, + "/api/v1/members/{member_id}": { + "put": { + "operationId": "update_member_api_v1_members__member_id__put", + "parameters": [ + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "title": "Member Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Update Member" + } + }, + "/api/v1/operations/{operation_id}": { + "get": { + "operationId": "operation_api_v1_operations__operation_id__get", + "parameters": [ + { + "in": "path", + "name": "operation_id", + "required": true, + "schema": { + "title": "Operation Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Operation" + } + }, + "/api/v1/policy": { + "get": { + "operationId": "policy_api_v1_policy_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Policy" + }, + "put": { + "operationId": "update_policy_api_v1_policy_put", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Update Policy" + } + }, + "/api/v1/recovery/requests": { + "post": { + "operationId": "start_recovery_api_v1_recovery_requests_post", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrollmentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Start Recovery" + } + }, + "/api/v1/recovery/requests/{request_id}": { + "get": { + "operationId": "recovery_status_api_v1_recovery_requests__request_id__get", + "parameters": [ + { + "in": "path", + "name": "request_id", + "required": true, + "schema": { + "title": "Request Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoveryResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Recovery Status" + } + }, + "/api/v1/recovery/requests/{request_id}/cancel": { + "post": { + "operationId": "cancel_recovery_api_v1_recovery_requests__request_id__cancel_post", + "parameters": [ + { + "in": "path", + "name": "request_id", + "required": true, + "schema": { + "title": "Request Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoveryTransitionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Cancel Recovery" + } + }, + "/api/v1/recovery/requests/{request_id}/complete": { + "post": { + "operationId": "complete_recovery_api_v1_recovery_requests__request_id__complete_post", + "parameters": [ + { + "in": "path", + "name": "request_id", + "required": true, + "schema": { + "title": "Request Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoveryTransitionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Complete Recovery" + } + }, + "/api/v1/session": { + "get": { + "operationId": "session_api_v1_session_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Session" + } + } + } +} diff --git a/docs/contracts/native-household-api.openapi.json b/docs/contracts/native-household-api.openapi.json new file mode 100644 index 0000000..d2800fb --- /dev/null +++ b/docs/contracts/native-household-api.openapi.json @@ -0,0 +1,3757 @@ +{ + "components": { + "schemas": { + "AccessResponse": { + "additionalProperties": false, + "properties": { + "action_required": { + "title": "Action Required", + "type": "string" + }, + "effective_role": { + "anyOf": [ + { + "$ref": "#/components/schemas/Role" + }, + { + "type": "null" + } + ] + }, + "enrollment_allowed": { + "title": "Enrollment Allowed", + "type": "boolean" + }, + "member": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemberResponse" + }, + { + "type": "null" + } + ] + }, + "message": { + "title": "Message", + "type": "string" + }, + "permission_scope": { + "const": "household_administration", + "title": "Permission Scope", + "type": "string" + }, + "permissions": { + "items": { + "$ref": "#/components/schemas/Permission" + }, + "title": "Permissions", + "type": "array" + }, + "policy_revision": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Policy Revision" + }, + "setup_allowed": { + "title": "Setup Allowed", + "type": "boolean" + }, + "state": { + "$ref": "#/components/schemas/AccessState" + } + }, + "required": [ + "state", + "setup_allowed", + "enrollment_allowed", + "member", + "policy_revision", + "effective_role", + "permission_scope", + "permissions", + "message", + "action_required" + ], + "title": "AccessResponse", + "type": "object" + }, + "AccessState": { + "enum": [ + "unconfigured", + "not_enrolled", + "pending", + "active", + "suspended", + "archived" + ], + "type": "string" + }, + "CSRFResponse": { + "additionalProperties": false, + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "CSRFResponse", + "type": "object" + }, + "EnrollmentRequest": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "maximum": 0.0, + "minimum": 0.0, + "title": "Expected Revision", + "type": "integer" + } + }, + "required": [ + "expected_revision" + ], + "title": "EnrollmentRequest", + "type": "object" + }, + "ErrorResponse": { + "additionalProperties": false, + "properties": { + "action_required": { + "title": "Action Required", + "type": "string" + }, + "code": { + "title": "Code", + "type": "string" + }, + "correlation_id": { + "title": "Correlation Id", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "state": { + "const": "blocked", + "title": "State", + "type": "string" + } + }, + "required": [ + "state", + "code", + "message", + "action_required", + "correlation_id" + ], + "title": "ErrorResponse", + "type": "object" + }, + "HouseholdResponse": { + "additionalProperties": false, + "properties": { + "household_id": { + "title": "Household Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "revision": { + "title": "Revision", + "type": "integer" + }, + "time_zone": { + "title": "Time Zone", + "type": "string" + } + }, + "required": [ + "household_id", + "name", + "time_zone", + "revision" + ], + "title": "HouseholdResponse", + "type": "object" + }, + "HouseholdUpdateRequest": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "maximum": 9.223372036854776e+18, + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" + }, + "name": { + "maxLength": 160, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "time_zone": { + "maxLength": 128, + "minLength": 1, + "title": "Time Zone", + "type": "string" + } + }, + "required": [ + "expected_revision", + "name", + "time_zone" + ], + "title": "HouseholdUpdateRequest", + "type": "object" + }, + "JsonValue": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "items": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "array" + }, + { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "MappingMode": { + "enum": [ + "all_ha_administrators", + "setup_administrator", + "selected_ha_user" + ], + "title": "MappingMode", + "type": "string" + }, + "MappingResponse": { + "additionalProperties": false, + "properties": { + "mode": { + "$ref": "#/components/schemas/MappingMode" + }, + "target_ha_user_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Ha User Id" + } + }, + "required": [ + "mode", + "target_ha_user_id" + ], + "title": "MappingResponse", + "type": "object" + }, + "MemberResponse": { + "additionalProperties": false, + "properties": { + "exceptions": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/PermissionDecision" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Exceptions" + }, + "ha_user_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ha User Id" + }, + "member_id": { + "title": "Member Id", + "type": "string" + }, + "revision": { + "title": "Revision", + "type": "integer" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "status": { + "$ref": "#/components/schemas/MemberStatus" + } + }, + "required": [ + "member_id", + "role", + "status", + "revision" + ], + "title": "MemberResponse", + "type": "object" + }, + "MemberStatus": { + "enum": [ + "pending", + "active", + "suspended", + "archived" + ], + "title": "MemberStatus", + "type": "string" + }, + "MemberUpdateRequest": { + "additionalProperties": false, + "properties": { + "exceptions": { + "items": { + "$ref": "#/components/schemas/PermissionDecision" + }, + "maxItems": 256, + "title": "Exceptions", + "type": "array" + }, + "expected_revision": { + "maximum": 9.223372036854776e+18, + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "status": { + "$ref": "#/components/schemas/MemberStatus" + } + }, + "required": [ + "expected_revision", + "role", + "status" + ], + "title": "MemberUpdateRequest", + "type": "object" + }, + "MembersResponse": { + "additionalProperties": false, + "properties": { + "members": { + "items": { + "$ref": "#/components/schemas/MemberResponse" + }, + "title": "Members", + "type": "array" + } + }, + "required": [ + "members" + ], + "title": "MembersResponse", + "type": "object" + }, + "NativeSessionResponse": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "full", + "read_only" + ], + "title": "Access", + "type": "string" + }, + "action_required": { + "title": "Action Required", + "type": "string" + }, + "expires_in": { + "title": "Expires In", + "type": "integer" + }, + "ha_administrator": { + "title": "Ha Administrator", + "type": "boolean" + }, + "ha_user_id": { + "title": "Ha User Id", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "source": { + "const": "home_assistant_ingress", + "default": "home_assistant_ingress", + "title": "Source", + "type": "string" + }, + "state": { + "const": "authenticated", + "default": "authenticated", + "title": "State", + "type": "string" + } + }, + "required": [ + "ha_user_id", + "ha_administrator", + "access", + "expires_in", + "message", + "action_required" + ], + "title": "NativeSessionResponse", + "type": "object" + }, + "OperationResponse": { + "additionalProperties": false, + "properties": { + "action_required": { + "default": "Keep the operation ID. If the response was lost, retry the same payload and key.", + "title": "Action Required", + "type": "string" + }, + "archived_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Archived By" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "delivery": { + "enum": [ + "pending", + "delivered", + "suppressed" + ], + "title": "Delivery", + "type": "string" + }, + "message": { + "default": "The household change was saved. Event delivery is tracked separately.", + "title": "Message", + "type": "string" + }, + "operation_id": { + "title": "Operation Id", + "type": "string" + }, + "result": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Result", + "type": "object" + }, + "state": { + "default": "committed", + "enum": [ + "committed", + "historical" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "operation_id", + "result", + "created_at", + "delivery" + ], + "title": "OperationResponse", + "type": "object" + }, + "Permission": { + "enum": [ + "household.read", + "household.manage", + "members.read", + "members.manage", + "policy.manage", + "preferences.read", + "preferences.write", + "operations.read", + "tasks.create", + "tasks.complete", + "tasks.approve", + "tasks.configure", + "shopping.request", + "shopping.manage", + "inventory.manage", + "automations.configure", + "points.manage" + ], + "title": "Permission", + "type": "string" + }, + "PermissionDecision": { + "additionalProperties": false, + "properties": { + "allow": { + "title": "Allow", + "type": "boolean" + }, + "permission": { + "$ref": "#/components/schemas/Permission" + } + }, + "required": [ + "permission", + "allow" + ], + "title": "PermissionDecision", + "type": "object" + }, + "PolicyResponse": { + "additionalProperties": false, + "properties": { + "adjustments": { + "items": { + "$ref": "#/components/schemas/ProfileDecision" + }, + "title": "Adjustments", + "type": "array" + }, + "household_id": { + "title": "Household Id", + "type": "string" + }, + "mapping": { + "$ref": "#/components/schemas/MappingResponse" + }, + "revision": { + "title": "Revision", + "type": "integer" + } + }, + "required": [ + "household_id", + "mapping", + "revision", + "adjustments" + ], + "title": "PolicyResponse", + "type": "object" + }, + "PolicyUpdateRequest": { + "additionalProperties": false, + "properties": { + "adjustments": { + "items": { + "$ref": "#/components/schemas/ProfileDecision" + }, + "maxItems": 256, + "title": "Adjustments", + "type": "array" + }, + "expected_revision": { + "maximum": 9.223372036854776e+18, + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" + }, + "mapping_mode": { + "$ref": "#/components/schemas/MappingMode" + }, + "target_ha_user_id": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{32}$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Ha User Id" + } + }, + "required": [ + "expected_revision", + "mapping_mode" + ], + "title": "PolicyUpdateRequest", + "type": "object" + }, + "ProfileDecision": { + "additionalProperties": false, + "properties": { + "allow": { + "title": "Allow", + "type": "boolean" + }, + "permission": { + "$ref": "#/components/schemas/Permission" + }, + "role": { + "$ref": "#/components/schemas/Role" + } + }, + "required": [ + "permission", + "allow", + "role" + ], + "title": "ProfileDecision", + "type": "object" + }, + "ReceiptResponse": { + "additionalProperties": false, + "properties": { + "action_required": { + "default": "Keep the operation ID. If the response was lost, retry the same payload and key.", + "title": "Action Required", + "type": "string" + }, + "archived_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Archived By" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "message": { + "default": "The household change was saved. Event delivery is tracked separately.", + "title": "Message", + "type": "string" + }, + "operation_id": { + "title": "Operation Id", + "type": "string" + }, + "result": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Result", + "type": "object" + }, + "state": { + "default": "committed", + "enum": [ + "committed", + "historical" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "operation_id", + "result", + "created_at" + ], + "title": "ReceiptResponse", + "type": "object" + }, + "RecoveryResponse": { + "additionalProperties": false, + "properties": { + "approved_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Approved At" + }, + "closed_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Closed At" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "expires_at": { + "title": "Expires At", + "type": "integer" + }, + "mapping_mode": { + "enum": [ + "setup_administrator", + "selected_ha_user" + ], + "title": "Mapping Mode", + "type": "string" + }, + "member_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Member Id" + }, + "member_revision": { + "title": "Member Revision", + "type": "integer" + }, + "policy_revision": { + "title": "Policy Revision", + "type": "integer" + }, + "request_id": { + "title": "Request Id", + "type": "string" + }, + "revision": { + "title": "Revision", + "type": "integer" + }, + "state": { + "enum": [ + "pending", + "approved", + "consumed", + "canceled", + "expired" + ], + "title": "State", + "type": "string" + }, + "target_ha_user_id": { + "title": "Target Ha User Id", + "type": "string" + } + }, + "required": [ + "request_id", + "state", + "revision", + "target_ha_user_id", + "mapping_mode", + "policy_revision", + "member_id", + "member_revision", + "created_at", + "expires_at", + "approved_at", + "closed_at" + ], + "title": "RecoveryResponse", + "type": "object" + }, + "RecoveryTransitionRequest": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "maximum": 9.223372036854776e+18, + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" + } + }, + "required": [ + "expected_revision" + ], + "title": "RecoveryTransitionRequest", + "type": "object" + }, + "Role": { + "enum": [ + "administrator", + "adult", + "restricted", + "viewer" + ], + "title": "Role", + "type": "string" + }, + "SetupRequest": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "maximum": 0.0, + "minimum": 0.0, + "title": "Expected Revision", + "type": "integer" + }, + "mapping_mode": { + "$ref": "#/components/schemas/MappingMode", + "default": "all_ha_administrators" + }, + "name": { + "maxLength": 160, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "selected_ha_user_id": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{32}$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Selected Ha User Id" + }, + "time_zone": { + "maxLength": 128, + "minLength": 1, + "title": "Time Zone", + "type": "string" + } + }, + "required": [ + "expected_revision", + "name", + "time_zone" + ], + "title": "SetupRequest", + "type": "object" + } + }, + "securitySchemes": { + "HomeAssistantIngress": { + "in": "cookie", + "name": "ingress_session", + "type": "apiKey" + } + } + }, + "info": { + "title": "Domus native Home Assistant application", + "version": "0.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/": { + "get": { + "operationId": "home__get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "text/html": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "text/html": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "text/html": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "text/html": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "text/html": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "text/html": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "text/html": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "text/html": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "text/html": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "text/html": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "text/html": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Home" + } + }, + "/api/v1/auth/csrf": { + "get": { + "operationId": "csrf_api_v1_auth_csrf_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CSRFResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Csrf" + } + }, + "/api/v1/authorization": { + "get": { + "operationId": "access_api_v1_authorization_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Access" + } + }, + "/api/v1/household": { + "get": { + "operationId": "household_api_v1_household_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HouseholdResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Household" + }, + "post": { + "operationId": "setup_api_v1_household_post", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Setup" + }, + "put": { + "operationId": "update_household_api_v1_household_put", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HouseholdUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Update Household" + } + }, + "/api/v1/members": { + "get": { + "operationId": "members_api_v1_members_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembersResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Members" + } + }, + "/api/v1/members/enrollment": { + "post": { + "operationId": "enroll_api_v1_members_enrollment_post", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrollmentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Enroll" + } + }, + "/api/v1/members/{member_id}": { + "put": { + "operationId": "update_member_api_v1_members__member_id__put", + "parameters": [ + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "title": "Member Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Update Member" + } + }, + "/api/v1/openapi.json": { + "get": { + "operationId": "schema_api_v1_openapi_json_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Schema Api V1 Openapi Json Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Schema" + } + }, + "/api/v1/operations/{operation_id}": { + "get": { + "operationId": "operation_api_v1_operations__operation_id__get", + "parameters": [ + { + "in": "path", + "name": "operation_id", + "required": true, + "schema": { + "title": "Operation Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Operation" + } + }, + "/api/v1/policy": { + "get": { + "operationId": "policy_api_v1_policy_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Policy" + }, + "put": { + "operationId": "update_policy_api_v1_policy_put", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Update Policy" + } + }, + "/api/v1/recovery/requests": { + "post": { + "operationId": "start_recovery_api_v1_recovery_requests_post", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrollmentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Start Recovery" + } + }, + "/api/v1/recovery/requests/{request_id}": { + "get": { + "operationId": "recovery_status_api_v1_recovery_requests__request_id__get", + "parameters": [ + { + "in": "path", + "name": "request_id", + "required": true, + "schema": { + "title": "Request Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoveryResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Recovery Status" + } + }, + "/api/v1/recovery/requests/{request_id}/cancel": { + "post": { + "operationId": "cancel_recovery_api_v1_recovery_requests__request_id__cancel_post", + "parameters": [ + { + "in": "path", + "name": "request_id", + "required": true, + "schema": { + "title": "Request Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoveryTransitionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Cancel Recovery" + } + }, + "/api/v1/recovery/requests/{request_id}/complete": { + "post": { + "operationId": "complete_recovery_api_v1_recovery_requests__request_id__complete_post", + "parameters": [ + { + "in": "path", + "name": "request_id", + "required": true, + "schema": { + "title": "Request Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoveryTransitionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Complete Recovery" + } + }, + "/api/v1/session": { + "get": { + "operationId": "session_api_v1_session_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NativeSessionResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Session" + } + }, + "/health": { + "get": { + "operationId": "health_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Health Health Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "summary": "Health" + } + } + } +} diff --git a/docs/copilot-adoption.md b/docs/copilot-adoption.md index 8c74c55..22cb910 100644 --- a/docs/copilot-adoption.md +++ b/docs/copilot-adoption.md @@ -22,8 +22,8 @@ Every case is planned acceptance work, not a claim that tests exist. ## Catalog limitation -The audited SDK version-1 validator requires nonempty entrypoints, code and tests arrays for every capability, -including status proposed. Domus has no implementation files yet. The proposed catalog therefore preserves +The initially audited SDK version-1 validator required nonempty entrypoints, code and tests arrays for every capability, +including status proposed. At that foundation snapshot Domus had no implementation files. The proposed catalog preserved empty arrays and an explicit readiness blocker instead of referencing fake source or calling documents code. The original validator is run unchanged against the package; failure is recorded in the validation evidence in docs/sdd-validation.md. A passing draft-format check cannot establish gate readiness. @@ -45,4 +45,10 @@ A linked PR carries validation and unresolved decisions; maintainers own merge a The specification foundation had no runtime package, pnpm task or project CI. Issue #4 now adds a development-tooling package, locked dependencies, tests and a separate project-owned CI Check workflow, documented in [development](development.md). Household application/runtime capabilities remain prospective. Commands in the unchanged upstream standard are not Domus commands unless the development guide verifies them. The original draft-check runner outside this repository remains a review aid and does not replace the Action's bundled authority. -The current catalog registers only real delivery-quality tooling/CI evidence. Its proposed/Draft status remains unchanged, and the other ten capabilities retain absent implementation evidence. The pinned original validator therefore reports 30 remaining missing-evidence errors; the foundation's original 33-error result is preserved separately. This is ordinary evidence registration after implementation, not a schema/status bypass or a Copilot change. +The tooling checkpoint registered real delivery-quality evidence and reduced the original pinned validator's +errors from 33 to 30; those reports remain historical. On 2026-10-31, reviewed v3 revision +3e7ceea08e6131360e026937d1c0cc1b120472e0 accepted the truthful proposed catalog with zero errors. +The Action then published the issue #6 companion and verified its managed branch at +3fdf4f841e7e2f6d37f8ebfd4b8d80f1ce90090f. No manual branch or validation bypass was used. +The current storage checkpoint adds only actual partial platform evidence; statuses remain proposed/Draft, +missing entrypoints remain empty and HA/runtime acceptance is not inferred. Copilot stays unchanged. diff --git a/docs/decisions.md b/docs/decisions.md index 2e0c22e..3efd85f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -30,14 +30,18 @@ Confirmed owner requirements are recorded in [product vision](product-vision.md) | A24 | Accepted: an approved unfulfilled reward without sufficient backing enters a funding hold until credit recovers or an explicit authorized resolution occurs; delivery remains blocked while unfunded | Preserve approval/reservation without automatic cancellation, delivery or fabricated credit | Owner confirmed 2026-10-29; reversal/fulfillment/cancellation races require tests | | A25 | Accepted: all_ha_administrators is the default administrator mapping; setup_administrator and selected_ha_user are supported configuration alternatives | The owner explicitly requested all three modes and selected the all-HA-administrators mode as default | Owner confirmed 2026-10-29; authoritative HA proof, source revocation and authenticated recovery require evidence | | A26 | Accepted: predefined permission profiles are adjustable per household with explicit per-member exceptions | Households can tailor grants without replacing verified identity or domain invariants | Owner confirmed 2026-10-29; detailed permission matrix, deterministic precedence and cache/revocation tests remain required | +| A27 | Accepted: setup-only/selected-user administrator recovery requires a current HA administrator plus explicit installation-console approval | Bind both authorities to the same target and installation; preserve the mapping and history | Owner confirmed 2026-10-29; runtime expiry, single-use and race proof pending | +| A28 | Accepted: HA outage permits existing-session local queries only until original expiry; all changes remain blocked | Known revocation still applies and availability failure cannot renew authority | Owner confirmed 2026-10-29; concrete identity/session adapter proof pending | +| A29 | Accepted: administrator manages configuration/members/privileged corrections; adult handles daily operations and restricted-work approval; restricted completes assigned work and adds own shopping requests; viewer queries; all edit own preferences | Provide usable defaults within the already accepted household/member customization model | Owner confirmed 2026-10-31; concrete permission policy and API tests pending | +| A30 | Accepted: restoring an older checkpoint restores its state while retaining readable later committed receipts and history as already processed | Prevent duplicate execution without silently reapplying later business changes | Owner confirmed 2026-10-31; consistent evidence reconciliation and activation tests remain required | -All eleven SDDs remain Draft. Review resolves choices; it does not claim implementation or live acceptance. +All eleven owning SDDs and the M1 companion remain Draft. Review resolves choices; it does not claim implementation or live acceptance. The Copilot limitation is an observed constraint, not permission to change its implementation, installed files, statuses, or checks. Documentation can be reviewed through its existing documentation workflow. ## Review status -The latest owner-question batches are answered and recorded in A6 and A22–A26. Administrator mapping supports all three requested modes; the default is all_ha_administrators, superseding the installer's-only recommendation from the question. +The latest owner-question batches are answered and recorded in A6 and A22–A29. Administrator mapping supports all three requested modes; the default is all_ha_administrators, superseding the installer's-only recommendation from the question. All SDDs remain Draft. Authentication/administrator proof and recovery, the detailed permission matrix, time-zone/library interoperability, adapter feasibility and numeric resource bounds still need explicit engineering/security review and executable evidence. These gates do not reopen the accepted architecture, product policies or launch scope. @@ -65,3 +69,18 @@ All SDDs remain Draft. Authentication/administrator proof and recovery, the deta - Administrator mapping is configurable: all HA administrators by default, only the verified setup administrator, or an explicitly selected HA user. Prove identity and the relevant HA privilege; retain each grant's source and enforce revocation. - Predefined administrator/adult/restricted/viewer profiles support household adjustments and per-member exceptions. Backend authorization, scope, ledger integrity and required adult verification remain enforced. - Repeated autumn wall times execute once at the first appearance by default, with a household option for the second. Preserve nominal identity and disclose the resolved instant. + +## Backup sequencing for M1 + +The owner has not selected a new automatic backup frequency or retention policy. This increment +implements the consistent snapshot/restore primitive; Supervisor packaging will participate in HA +backups, and standalone snapshots remain explicit local operations. Scheduling/retention and +package-specific recovery objectives remain deployment/operations review items. No daily/weekly +rotation or recovery-time SLA is recorded as owner-approved. + +On 2026-10-31 the owner accepted A30: if later operations remain readable in current storage, +restore the selected checkpoint's business state and retain those later receipts/history as processed +operations. Do not execute their effects again or silently advance the restored state. Conflicting +or incomplete evidence must be handled explicitly before activation. This choice is separate from +backup scheduling/retention, which remains unselected. The implementation and restore acceptance +are still pending. diff --git a/docs/development.md b/docs/development.md index 8d0051d..b1ed92a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,7 +2,13 @@ The documentation foundation was merged through [PR #3](https://github.com/vypdev/domus/pull/3). [Issue #4](https://github.com/vypdev/domus/issues/4) starts M1 with real development tooling, tests and CI. -The household application, HA authentication, product UI, deployment packages and channels remain unimplemented. +The issue #6 checkpoints introduce storage, pure identity policy, local authorization and session leases. +HA protocol adapters have provider/loopback tests and the standalone authentication API/CLI is wired. +Explicit setup, own enrollment and household edits now have application-layer commands. The native +Ingress connection preview and immutable-source Edge package are installed and tested on the owner's HA. +Native persistent API wiring, console-approved recovery and manual snapshots are tested locally. +App packaging, restore activation, product UI and complete +Stable/Edge release acceptance remain pending. ## Reproduce the environment @@ -15,7 +21,9 @@ uv sync --frozen pnpm install --frozen-lockfile ``` -The Python tooling distribution is domus-tooling, not the application server. The frontend workspace +The Python tooling distribution is domus-tooling. The separate domus-server workspace distribution +contains the actual backend modules under backend/src/domus and has its own coverage gate. +The frontend workspace currently declares its package boundary only; Lit/Vite application source and its own coverage gate belong to the first frontend implementation. No empty application package is used as implementation evidence. @@ -25,10 +33,13 @@ belong to the first frontend implementation. No empty application package is use uv run ruff check . uv run ruff format --check . uv run mypy tooling +uv run mypy backend/src uv run pytest tests/python --cov=domus_tooling --cov-branch --cov-report=term-missing +uv run pytest tests/backend --cov=domus --cov-branch --cov-report=term-missing pnpm check pnpm test uv run lint-imports --config architecture/python-tooling.ini --no-cache +uv run lint-imports --config architecture/python-application.ini --no-cache uv run pytest tests/architecture uv run domus-check docs ``` @@ -45,7 +56,7 @@ file until its own applicable coverage/architecture gate is registered. - architecture/python-tooling.ini checks the actual tooling package: pure CI policy cannot import its CLI/filesystem adapters, concrete I/O or framework modules, and modules remain acyclic. -- architecture/python-application.ini is exercised against disposable fixture packages. Composition +- architecture/python-application.ini checks the actual backend and disposable negative fixture packages. Composition wires adapters; entrypoints and infrastructure are independent; application depends inward on domain. Inner layers cannot import HA, HTTP, SQL or concrete frameworks. Recursive cycle checks have an explicit depth budget of 100; the normal package layout is shallow. A deeper layout needs review. @@ -54,8 +65,8 @@ file until its own applicable coverage/architecture gate is registered. type-only imports, cycles and unresolved imports are checked. Fixture packages are owned test data, not shipped application modules or product implementation evidence. -When the real domus or HA integration package is introduced, wire its real graph into CI alongside its -own tests. The inventory guard prevents that source from silently inheriting a tooling-only green result. +The real domus graph is now wired into CI alongside its own tests. A future HA integration must add +its own scope. The inventory guard prevents runtime source from inheriting a tooling-only green result. The selected TypeScript 6.0.3 is inside dependency-cruiser 18.7.0's inspected supported range, >=2.0.0 and <7.0.0. The executable parser check fails if that support is lost during a dependency update. @@ -66,13 +77,16 @@ See the primary [dependency-cruiser API](https://github.com/sverweij/dependency- ## CI Check -The project-owned .github/workflows/ci_check.yml runs Python tooling, Node policy, Python architecture +The project-owned .github/workflows/ci_check.yml runs Python tooling, the separate backend suite, +Node policy, Python architecture contracts and local documentation links for every PR update, including drafts and documentation changes. It also runs on develop/master pushes and merge-queue validation. There are no path filters or job conditions hiding applicable suites. Actions are pinned to immutable commits and installs use lockfiles. -The final CI Check job always evaluates all four required job results. Missing, malformed, unexpected, -failed, skipped, cancelled or pending results prevent success. Only four explicit successful results pass. +The final CI Check job always evaluates all six required job results, including the container matrix. +Missing, malformed, unexpected, failed, skipped, cancelled or pending results prevent success. +Every required job must explicitly succeed. The container job builds and tests the exact Supervisor +app folder on native amd64 and aarch64 runners; it never publishes images or installs into live HA. The workflow uses read-only repository permissions, does not persist checkout credentials and does not publish artifacts to a release or deploy. Coverage reports are retained for seven days under the checked SHA. @@ -89,8 +103,112 @@ tests. These 85 executed tests are separate from the 353 planned product accepta [Tooling evidence](evidence/development-tooling.json) records exact scopes and metrics. The first implementation commit, 8a1c8fbdc50960a124fc76bc16702638845a0606, passed all five jobs in [CI Check run 40800913976](https://github.com/vypdev/domus/actions/runs/40800913976). Every later head must pass independently; this recorded result does not transfer to new code. -The delivery-quality catalog entry registers only existing tooling/CI evidence and stays proposed/Draft. -Other capabilities retain empty implementation evidence. The original SDK validator still reports the -remaining missing evidence; this is not a workaround for [Copilot issue #777](https://github.com/vypdev/copilot/issues/777). -The entire delivery-quality capability still needs application coverage, real deployment/HA adapter +Delivery-quality and platform-foundation register only their real partial evidence and stay proposed/Draft. +The identity capability now registers its partial pure/local evidence; other product domains retain +empty implementation evidence. Reviewed Copilot v3 revision +3e7ceea08e6131360e026937d1c0cc1b120472e0 resolves +[issue #777](https://github.com/vypdev/copilot/issues/777): truthful proposed capabilities may retain +empty implementation arrays. The current original validator accepts the catalog without changing +Copilot or manufacturing paths. Historical failure reports remain historical evidence. +The entire delivery-quality capability still needs complete application/UI coverage, real deployment/HA contracts, Stable/Edge isolation and controlled Chrome evidence before acceptance. + +The storage checkpoint has its own [architecture and limits](architecture-identity-storage.md), +[source-identified evidence](evidence/m1-storage-kernel.json) and +[current HA reference matrix](compatibility-home-assistant.md). It does not implement a human +authentication entrypoint, snapshot command or HTTP server yet. + +The subsequent [identity-policy evidence](evidence/m1-identity-policy.json) records pure mappings, +permission precedence, transaction-bound authorization, schema 2 migration and bounded session leases. +Provider fixtures are not live HA acceptance. The [migration guide](migrations-identity-storage.md) +identifies the frozen schema 1 input and the fail-closed unconfigured-household state. + +The [HA protocol adapters](ha-identity-protocol.md) use HTTP provider fixtures and ephemeral loopback +WebSocket servers. Local sandboxes that forbid listening sockets need permission for the backend +suite; no test contacts the owner's HA. [Protocol evidence](evidence/m1-ha-protocol.json) identifies +the reviewed Core blobs, fixed dependency versions and outstanding live/package journeys. + +The [standalone authentication checkpoint](identity-household.md) now has strict API/CLI wiring, +single-use browser-bound attempts, private token refresh, safe logout and shutdown-race tests. +Use [configuration](configuration.md) and [operations](operations-identity-storage.md) for this current +surface. The import contract rejects both inner layers importing every declared concrete SDK, +filesystem/process I/O or global time/entropy source. Its expanded negative matrix has 39 fixtures. +FastAPI/Uvicorn are fixed runtime dependencies. httpx2 is a separate fixed development dependency +required by Starlette's current TestClient; the HA transport retains its independently tested HTTPX +version. No deprecated TestClient fallback or Copilot modification is used. + +The [native HA preview](installation-home-assistant.md) composes existing Ingress/Core proof on a +private listener and serves an operational connection page without OAuth or its own browser cookie. +Its source-bound Edge package has a separate [package receipt](evidence/m1-app-package.json). +The owner selected native HA installation as the next dogfooding path after the independent browser +session return failed. The test OAuth grant was revoked successfully; this does not establish native +Ingress compatibility by itself. The subsequent +[native acceptance receipt](evidence/m1-native-ha-acceptance.json) records successful installation, +start, Open Web UI and normal reload of the exact Edge preview on the identified HA target. +Both deployment targets remain required for the first usable release. + +The [household command checkpoint](household-setup.md) uses application ports for current human proof, +directory lookup, UTC clock, secure identifiers and IANA timezone validation. External preparation is +outside SQLite writer transactions. Its real-file tests cover one complete concurrent setup winner, +rollback, restart, current-authorization-before-replay and pending enrollment. The bounded HA batch +directory distinguishes confirmed ineligibility from source failure; malformed or duplicate records +cannot become partial success. [Source-identified evidence](evidence/m1-household-commands.json) +records the implemented scope and coverage. These commands are not in the installed preview image. + +The next [member-administration checkpoint](evidence/m1-member-administration.json) adds explicit +activation, profiles/status/exception edits and last-administrator checks against current human facts. +It tests real SQLite interleavings, fresh actor reproof, local authority revocation, archived-linkage +preservation, rollback-free conflicts and same-key concurrent receipt reuse. Coverage includes the +complete authored backend; neither these use cases nor their later source are in the installed image. + +The [policy-administration checkpoint](evidence/m1-policy-administration.json) adds revisioned +mapping/profile changes with preserved setup provenance and independent grants. A shared application +helper prepares bounded human facts for member and policy safeguards, with no framework imports. +Current local authority and inventory remain transactional. Source/audit corruption, lost authority, +source outage, stale state, rollback-free conflicts and restart/replay behavior have real-file tests. + +The [household HTTP checkpoint](household-api.md) exposes local authorization/roster/policy/operation +queries and strict write DTOs. The standalone --database option wires the real session, private +directory, use cases and SQLite adapter. Tests include actual composed-runtime restart, channel +refusal, CSRF/origin/idempotency, ambiguous JSON refusal, HA outage read grace and credential +logout/expiry races. [Its evidence](evidence/m1-household-api.json) and +[published OpenAPI](contracts/household-api.openapi.json) identify the implemented surface. +Recovery and native persistent packaging/UI remained outstanding at that checkpoint. + +The [native household API checkpoint](native-household-api.md) now wires the same services to real +SQLite through private Supervisor Ingress. Tests bind local leases and CSRF to installation/channel, +exact human, app path and the existing upstream session. They cover original-deadline outage reads, +fresh writes, rejection, restart, concurrent bootstrap and a selected ordinary HA user's independent +Domus administrator grant. Fixed configured origins prevent forwarding headers from selecting trust. +[Its evidence](evidence/m1-native-household-api.json) and +[published OpenAPI](contracts/native-household-api.openapi.json) record the current runtime. +Owned identity, recovery/snapshots and restoration are now packaged in Edge.3. The complete UI, +standalone companion and full deployment/live household acceptance remain pending; +[current owner evidence](evidence/m1-edge3-ha-acceptance.json) covers connection/reload/restart. + +The [owned data checkpoint](owned-data-directory.md) now persists initialization/ready metadata and +automatic UUID/channel identity inside an existing private directory. Native --data-directory holds +its POSIX process lock through the listener lifetime. Real-file/process tests reject missing or empty +ready data, cross-channel adoption, unsafe files and interrupted metadata; a compatible v1 migration +preserves history. The package provisions only its private child before dropping privileges; +Edge.3 update/reload/restart now has bounded owner evidence. See [evidence](evidence/m1-owned-storage.json). + +The [administrator recovery checkpoint](administrator-recovery.md) adds the pure bound lifecycle, +schema 3 migration, same-target fresh completion proof and a console-only approval command. Both +production HTTP compositions share the human routes. Tests exercise real SQLite races/rollback, +terminal confirmation in a separate PTY process, approval output uncertainty and native/standalone +request-to-completion journeys. [Evidence](evidence/m1-administrator-recovery.json) identifies source +and remaining live work. Recovery is packaged in installed Edge.3; live recovery was not exercised. + +The [snapshot checkpoint](snapshots.md) adds pure metadata, semantic application ports and a private +SQLite backup/inspection adapter. Tests use real files, concurrent WAL changes, physical index damage, +corrupt records/manifests, incomplete publication, deadlines and both console source selections. +[Evidence](evidence/m1-snapshots.json) records the exact coverage and boundaries. Restore activation +and its treatment of newer replay history remain pending; the installed HA app is unchanged. + +The standalone CLI now shares --data-directory and its durable UUID/channel lifecycle with the +native runtime. It validates provider/public origins before storage, initializes exactly once, +retains the lock through the listener and releases it even if credential cleanup fails. Tests verify +real household/original-operation restart, lost sessions, channel/cookie separation and console +attachment. [Evidence](evidence/m1-standalone-owned-storage.json) records this local composition +checkpoint; complete standalone deployment and owner browser acceptance remain pending. diff --git a/docs/evidence/home-assistant-reference.json b/docs/evidence/home-assistant-reference.json new file mode 100644 index 0000000..5b0a087 --- /dev/null +++ b/docs/evidence/home-assistant-reference.json @@ -0,0 +1,16 @@ +{ + "observedOn": "2026-10-31", + "method": "Read-only Google Chrome About screen on the owner-authorized local HA", + "entryPath": "/home/overview", + "aboutPath": "/config/info", + "installationMethod": "Home Assistant OS", + "versions": { + "core": "2026.10.4", + "supervisor": "2026.10.5", + "operatingSystem": "18.3", + "frontend": "20260930.5" + }, + "limitations": "Version observation only; no verified API principal or Domus runtime acceptance", + "credentialsExtracted": false, + "householdConfigurationChanged": false +} diff --git a/docs/evidence/issue6-human-clarifications.json b/docs/evidence/issue6-human-clarifications.json new file mode 100644 index 0000000..2ff1844 --- /dev/null +++ b/docs/evidence/issue6-human-clarifications.json @@ -0,0 +1,64 @@ +{ + "observedOn": "2026-10-31", + "repository": "vypdev/domus", + "issue": 6, + "sourceHead": "ead7dde44f57d32d5a42797a811e798d92b36a89", + "purpose": "Preserve exact original public maintainer clarification text before removing redundant restatements from the live review conversation. Product decisions, requirements and review gates are unchanged. Original source text remains available to complete code/diff review; compact live comments link here.", + "scope": "Nine maintainer-authored issue comments only. Bot comments, original issue requirements and generated configuration are untouched. Historical implementation/acceptance claims remain historical, not current claims.", + "comments": [ + { + "id": 6486136335, + "url": "https://github.com/vypdev/domus/issues/6#issuecomment-6486136335", + "author": "efraespada", + "body": "## Owner decisions and M1 preparation retained while branch admission is blocked\n\nPR #5 has been merged into `develop` at `0012a172492515e282be09c37921f4014a451e64`. The next increment remains this feature issue; it has not been rerouted through a chore/documentation issue to bypass its prerequisite.\n\nTwo owner decisions are now confirmed:\n\n1. **Administrator recovery:** in `setup_administrator` / `selected_ha_user` modes, require a currently proven HA administrator **and explicit installation-console confirmation**. The two authorities must be bound to the same identity/installation. No first-browser fallback or silent switch to `all_ha_administrators`.\n2. **Home Assistant outage:** an already valid session may query locally retained data until its original absolute expiry. **Mutations and administrative actions remain blocked.** Recheck current local membership/policy for every read; known suspension/unlinking applies immediately. Do not renew/create a session while HA is unavailable, and distinguish explicit credential rejection from availability failure.\n\nThe planned recovery contract uses an expiring identity/installation/channel/mapping-bound request, console approval outside the browser surface, fresh HA proof on completion, and one atomic grant/consumption/receipt/audit/outbox transaction. Tests must cover both missing authorities, target substitution, expiry, stale mapping, suspended target, replay, concurrent completion and rollback. This is prospective design, not an implemented recovery CLI/API.\n\nPreparation outside the checkout updates the **existing** `household-identity` and `platform-foundation` owners in the installed twenty-section SDD format. It adds 22 identity cases (52 total) and 14 platform cases (34 total), with reciprocal requirement/case tables and updated numeric budgets. The new cases cover authentication/protocol rejection, bounded offline-read leases and local revocation, dual-authority recovery, real-file migration/rollback/races, scoped replay, restart, backup and channel/instance refusal. These are **planned cases; zero new application tests have executed**.\n\nBoth prospective SDDs pass unchanged pinned `validateSddMarkdown`, `validateSddDraftConsistency` and exact canonical-heading checks against the merged PR #5 file inventory. The seven upstream source copies were verified against their Git blob IDs first. A five-file prospective documentation/catalog patch passes `git apply --check` against that exact base. Capability statuses/evidence arrays remain unchanged; no absent application evidence is disguised as tooling.\n\nImplementation after managed branch readiness will introduce the real backend with pure domain policies and semantic application ports, separate HA/SQLite/HTTP adapters, a distinct 100% supported backend coverage gate, and import checks against actual product modules. Existing tooling/Node/documentation checks and the strict CI aggregate remain required. No runtime source or application test has been authored before the branch gate.\n\nThe actual prerequisite failure is now linked to the existing upstream report: https://github.com/vypdev/copilot/issues/777#issuecomment-6486050904. The unchanged reader reproduces `owner-paths-invalid`; the unchanged catalog validator returns exactly thirty absent-evidence errors on ten unimplemented proposed capabilities. [The failed Action](https://github.com/vypdev/domus/actions/runs/40804494274) published no branch or expected ref suitable for exceptional recovery. Do not retry unchanged input, create a substitute branch, fabricate evidence or modify Copilot to bypass this.\n\nAll twenty-nine protected installed-resource hashes still match, and the repository checkout remains clean at the merged base. The read-only Chrome HA version check did not extract credentials or modify household configuration/entities. Live identity, Ingress and actual deployment acceptance remain unproven.\n" + }, + { + "id": 6537127683, + "url": "https://github.com/vypdev/domus/issues/6#issuecomment-6537127683", + "author": "efraespada", + "body": "## Resuming after the reviewed Copilot bootstrap fix\n\nThe owner asked to continue now that Copilot's reported failures are fixed. Upstream #777 and #774 are closed. Current `vypdev/copilot@v3` resolves to `3e7ceea08e6131360e026937d1c0cc1b120472e0`.\n\nUsing unchanged upstream catalog-validator/path/publication policy snapshots verified against their Git blob IDs, the existing Domus catalog at `0012a172492515e282be09c37921f4014a451e64` now returns **zero catalog errors** for all eleven proposed capabilities. Previously it returned thirty absent-implementation errors. No Domus status, evidence path, source file or installed Copilot resource was changed to obtain this result. Catalog validity does not establish runtime acceptance or SDD publication readiness.\n\nThe failed workflow `40838301124` has been retried through the normal workflow route. Continue this same feature issue and let the Action publish its exact managed branch/SDD. The earlier manual-recovery proposal was not executed and is unnecessary if normal publication succeeds.\n\nThe confirmed recovery/outage decisions in the preceding owner-decision comment remain applicable: current HA administrator plus installation-console confirmation for recovery; existing-session local queries only until original expiry during HA outage, with all mutations blocked and current local revocation enforced. Both deployment targets, all first-release domains, isolated Stable/Edge channels, Clean Architecture, full supported coverage from the first source, existing gateway scope and English repository content remain unchanged. The owner additionally requires no unresolved Bugbot findings and full coverage before any merge.\n" + }, + { + "id": 6537148471, + "url": "https://github.com/vypdev/domus/issues/6#issuecomment-6537148471", + "author": "efraespada", + "body": "The catalog/bootstrap blocker is resolved. These maintainer answers use the owner's confirmed architecture and the supported HA protocols. The immutable feature base `0012a172492515e282be09c37921f4014a451e64` does contain `specs/` and both registered owners. Preserve those contracts. A focused implementation companion under `platform-foundation` is acceptable; it must reference `household-identity` rather than create another owner of member/authorization state. Do not infer missing repository evidence from the workflow's unrelated default-branch checkout.\n\nSDD Q1: Supervisor uses the fixed internal Core proxy `http://supervisor/core/api/` and WebSocket `ws://supervisor/core/websocket`, with `homeassistant_api: true` and the runtime-provided SUPERVISOR_TOKEN kept out of logs/database/browser. This credential authenticates adapter transport only, never the human caller. Standalone requires an operator-controlled DOMUS_HA_URL in installation configuration, validated as an explicit HTTP(S) origin without userinfo, query or fragment; LAN HTTP is supported for the owner's local HA, HTTPS verifies certificates. Build the known Core paths from that origin, reject request/body/forwarded-header endpoint overrides, disable automatic redirects and environment-proxy inheritance, and apply bounded timeouts/message sizes. Missing/invalid configuration fails startup readiness without exposing protected APIs. An unavailable configured HA permits only the owner's existing-session bounded local-read policy; writes/setup/recovery fail closed. Endpoint changes require deliberate operator reconfiguration/restart, invalidate sessions and unfinished authorization/recovery flows, and preserve household/business history; connecting a different HA instance must not silently relink identities or widen grants.\n\nSDD Q2: Use deployment-specific supported HA identity adapters behind the same semantic PrincipalVerifier port. Standalone is an HA public OAuth client: no central registration or client secret; client_id is the exact operator-configured Domus public origin URL, with a fixed callback on that same host/port (`/api/v1/auth/callback`). Require state plus S256 PKCE, exact callback/client binding, bounded single-use pending attempts, HttpOnly/SameSite session cookies, Origin/CSRF protection for cookie-authenticated mutations, server-side token exchange/current-user proof and no token persistence. Never construct origins from Host, forwarded or Ingress headers. Supervisor's normal Ingress profile uses HA's existing native login, with no second OAuth login or public callback: accept identity metadata only on the dedicated Ingress listener from the actual Supervisor peer 172.30.32.2, with proxy-header address rewriting disabled and no direct published port. Missing X-Remote-User-Id fails authentication; use its ID only as the candidate subject, then authoritatively recheck that subject's current active human account and HA administrator source through Core over the fixed private adapter. The installation token's own administrator privilege must never be assigned to that subject. Treat names as display text, not proof. Standalone never accepts these headers. X-Ingress-Path may supply a validated UI base path only after that transport boundary is verified; it never supplies an OAuth origin or administrator grant. Core protocol/schema and actual Ingress/browser acceptance need tests; a fabricated header fixture cannot count as live proof. This is the equivalent supported HA authentication path already allowed by the owning architecture, not a replacement password store, gateway or second MCP.\n\nSDD Q3: Provide a local `domus-admin recovery approve ` console entrypoint, available to the installation operator inside the owned container/host console (for standalone, docker exec; for Supervisor, the installation's controlled App/container console). It is not an HTTP approval endpoint or an HA option that silently approves. Read the pending request from the installation's SQLite database, display its exact target HA user ID plus safely rendered verified name, instance ID, Stable/Edge channel, mapping/revision, creation and expiry, and require an explicit confirmation of that exact target ID. Requests expire after 10 minutes, are identity/installation/channel/mapping-bound and single-use. Completion still requires fresh proof of the same current HA administrator; console possession alone grants nothing. Atomically record the explicit recovered administrator grant, consume approval and commit receipt/audit/outbox while preserving the configured mapping/history. Wrong target, missing authority, expiry, suspended target, stale mapping, replay and concurrent completion must not duplicate or widen grants. An identical currently authorized retry resolves its original receipt; revoked access cannot read that receipt. The command is a proposed entrypoint to implement and test, not an existing binary or a claim of live acceptance.\n\nPrimary protocol references:\n- https://contributors.home-assistant.io/apps/guides/communication/\n- https://contributors.home-assistant.io/apps/guides/ingress/\n- https://contributors.home-assistant.io/core/internals/auth-api/\n- https://github.com/home-assistant/core/blob/2026.10.4/homeassistant/components/config/auth.py\n\nKeep all repository prose, code, comments, tests and generated issue/PR content English. Maintain the separate 100% supported backend coverage gate and real application import graph from the first source file. Do not weaken current authorization, idempotency, channel isolation, supported first-release scope or the no-Copilot-change rule.\n" + }, + { + "id": 6537170316, + "url": "https://github.com/vypdev/domus/issues/6#issuecomment-6537170316", + "author": "efraespada", + "body": "## Exact SDD ownership/path clarification for normal analysis recovery\n\nThe `companion-path-registered` check correctly prevents treating an existing owning document as a new companion. Use these exact intended ownership facts for the next analysis:\n\n- **Action:** `companion`.\n- **Existing catalog capability ID:** `platform-foundation`.\n- **New companion path:** `specs/platform-foundation-identity-sqlite.md`.\n- **Registered original owner:** `specs/platform-foundation.md` (preserve verbatim).\n- **Related identity owner:** capability `household-identity`, path `specs/household-identity.md` (reference its identity/permission invariants; preserve ownership).\n- **Immutable base:** `0012a172492515e282be09c37921f4014a451e64`.\n\nThe exact proposed companion path is absent from that base's Git tree and from every entry in its catalog. The two original owner paths exist and are registered. Do not select `specs/platform-foundation.md` or `specs/household-identity.md` with action `companion`; those are existing documents. Do not create another capability ID or reinterpret unrelated default-branch checkout contents as this source's inventory.\n\nThe round-1 answers remain in https://github.com/vypdev/domus/issues/6#issuecomment-6537148471. They answer all three numbered questions and do not change the feature scope. Preserve them and the owner decisions, then retry analysis/drafting on this same issue. This comment clarifies the intended path; it does not fake a publication, branch or readiness receipt.\n" + }, + { + "id": 6537210338, + "url": "https://github.com/vypdev/domus/issues/6#issuecomment-6537210338", + "author": "efraespada", + "body": "SDD Q1: The issue author explicitly confirmed these configurable defaults today. For this M1 surface use the following permission-by-action matrix (A=administrator, D=adult, R=restricted, V=viewer; all require current active membership unless the bootstrap/recovery rule explicitly applies): household/member public-summary read=A,D,R,V; read/update own locale/notification preferences=A,D,R,V, scoped to the caller; read another member's private preferences=denied by default; request own pending enrollment=any currently verified unlinked HA human, with no active access or role grant; activate/link/suspend/archive members or assign explicit roles=A; change household profiles/member exceptions, administrator mapping or protected settings=A; read an operation receipt=its authenticated actor with the operation's current permission and household scope, never key possession alone. Initial household setup requires an explicit action by a currently proven HA administrator. Recovery is a separate dual-authority flow requiring the same currently proven HA administrator and local-console confirmation, not an ordinary configurable permission. For subsequent domain increments retain the confirmed defaults: adults operate ordinary household work/supplies and may approve restricted performers' completions; restricted members may complete assigned tasks and add their own shopping requests; viewers query authorized data only; all edit their own preferences. Administrator-only actions include point/verification/automation configuration and privileged corrections. These statements do not claim those later domain APIs are implemented by this increment. Precedence for supported configurable actions is hard identity/scope/domain constraints, then explicit member exception, then household profile adjustment, then predefined role default, then deny if undefined. Member exceptions may allow or deny a supported ordinary action, so they can expand a household profile denial as already owner-approved; they cannot grant administrator identity, bypass suspension/unlinking, cross scope, bypass restricted-performer adult verification or violate a domain invariant. Conflicting allow/deny entries at the same level are rejected while preserving prior policy. Only a currently authorized administrator changes profiles/exceptions; ordinary members never edit their own authority. Current policy is checked inside command transactions and before receipt lookup, with expected revisions and cache invalidation. Source-derived administration in all_ha_administrators cannot be silently demoted by an ordinary profile edit: change its authorized mapping or live HA source, preserving independent explicit grants and last-administrator safety. HA outage never extends cached authority or permits mutation.\n\nThis is the new answer for clarification round 2, not a replacement of round 1. Retain all previously confirmed endpoint, per-target authentication and console-recovery answers. The owner keeps all product domains/both targets in the first release; this increment establishes their shared identity/persistence foundation.\n" + }, + { + "id": 6537316477, + "url": "https://github.com/vypdev/domus/issues/6#issuecomment-6537316477", + "author": "efraespada", + "body": "These answers restate the confirmed decisions for the new issue fingerprint after selecting the complete companion. Use action `companion`, capability `platform-foundation`, NEW path `specs/platform-foundation-identity-sqlite.md`; preserve the original platform/identity owners and use distinct new case IDs. The companion is explicitly approved instead of an 18,000-character update appendix. Keep all behavior and risk coverage; do not shorten the product scope to fit an appendix.\n\nSDD Q1: Standalone uses HA authorization code with state and S256 PKCE: fixed operator-configured HA origin and exact Domus public client/callback origin, server-side form token exchange, bounded single-use attempts and server-owned sessions, no central client registration/client secret. Current-user WebSocket proof must authenticate the exchanged user's token and obtain its exact ID plus Boolean administrator flag; reject malformed/correlated-to-another-request, inactive/rejected/expired proof and technical credentials presented to the human API. Supervisor instead uses native HA Ingress login on a private listener accepting only actual peer 172.30.32.2, proxy-header rewriting disabled, and requires remote user ID plus a fresh Core lookup of that active non-system human and its actual administrator source. Fixed supervisor Core proxy/token is transport authority only, never the human role. Missing identity fails closed; standalone never accepts Ingress identity headers. Core 2026.10.4 is the observed reference baseline; declare only independently tested Core/Supervisor protocol versions supported, retain a versioned contract matrix, and test the owner's current version before claiming live acceptance. Do not promise untested versions. Fixed upstream/configured origin, strict URL validation, no request-chosen hosts/redirects/proxies, bounded I/O and redacted errors apply. Existing valid sessions may query local data during HA outage only until original absolute expiry, rechecking current local policy/known revocation; no session extension or mutation while unavailable, and explicit HA rejection invalidates access rather than becoming an offline lease.\n\nSDD Q2: Modes are all_ha_administrators (default, live inherited grant for each proven current HA admin), setup_administrator (designated proven setup HA admin), selected_ha_user (explicit active human selected by proven HA admin; selected human need not be an HA admin). Initial setup is explicit; first browser arrival never creates authority. Keep source provenance and independent explicit grants; mapping/source changes revoke inherited grants, never override suspension or technical-principal boundaries, and Domus edits cannot remove its last active effective administrator. The owner confirmed configurable defaults: administrator manages members, policy/settings and privileged corrections; adult handles ordinary daily work/supplies and may approve restricted performers; restricted completes assigned work and creates own shopping requests; viewer queries authorized data only; everyone edits own preferences. M1 matrix: public household/member-summary read=A,D,R,V; own preference read/update=A,D,R,V; pending self-enrollment=verified unlinked human without active access; member activation/linking/status/explicit role and profile/override/mapping edits=A; receipt lookup=original principal plus current operation permission/scope. Private preferences of another member are denied by default. Hard identity/scope/domain constraints outrank explicit member override, then household profile adjustment, then predefined defaults, then deny undefined actions. Supported ordinary exceptions may allow or deny and expand a profile denial; they cannot create administrator identity, bypass restricted-performer approval, cross scope or weaken invariants. Reject same-level contradictions, preserve prior policy, require administrator edits, optimistic revisions and transaction-time current-policy checks/cache invalidation. Configuration of point values, verification, automation and privileged corrections remains administrator-only.\n\nSDD Q3: Current HA administrator requests recovery; local installation operator approves with proposed `domus-admin recovery approve ` inside the owned host/container console (docker exec for standalone; controlled App/container console for Supervisor). No HTTP approval route, shared PIN or implicit HA-options approval. Display request ID, exact verified target HA ID plus safely rendered display name, installation UUID, channel, mapping/revision, creation and expiry; require explicit confirmation of that exact target. Lifetime is ten minutes; bind approval to the same identity/installation/channel/mapping. Completion requires fresh proof that the same target is a current HA administrator and not suspended, then atomically commits an explicit recovery grant, consumed approval, receipt, audit and outbox while preserving mapping/history. Wrong identity/channel, missing authority, expiry, stale mapping or concurrency cannot widen/duplicate grants. Authorized identical replay returns the retained receipt; a later revoked actor cannot read it. Rollback leaves both request and grants unchanged. Console possession without fresh HA proof grants nothing.\n\nSDD Q4: Database metadata contains a Domus application marker, schema version, persistent installation UUID and channel enum stable/edge. Match the configured installation/channel before any mutation; never silently regenerate identity for an existing database, adopt an unrelated database or open Stable data as Edge. Inspect future/unrelated schemas before journal/schema writes; migrations are explicit atomic SQL transactions and failures preserve recoverable prior state. Domain changes, scoped command receipt, audit and outbox commit together. Backup uses a consistent SQLite snapshot including identity/schema, business state, receipts/replay guards, audit and outbox; integrity/schema/identity verification precedes restore activation. A corrupt/future/wrong-instance-or-channel candidate cannot replace the active database. Preserve command key scope, canonical payload digest and durable result across restart, retention and restore so old retries cannot create effects. Activity cleanup must not delete replay protection. Production-to-Edge copying needs an explicit import/sanitization path, not automatic identity adoption. Credentials and HA tokens never enter these business tables or evidence.\n\nSDD Q5: Use separate per-unit-of-work SQLite connections and BEGIN IMMEDIATE to serialize writers, with an initial 250 ms busy budget. Return bounded storage_busy/503 with retryable=true and Retry-After guidance when no writer slot is obtained; no partial effects. Check current local permissions inside the transaction before receipt lookup or new effects. Identical scoped key/payload returns its original result before treating its old expected revision as a new-write conflict; changed payload for the same key returns idempotency_conflict/409. New writes compare expected aggregate/policy revision and return revision_conflict/409 instead of overwriting. Do not automatically retry mutations with a new key, silently rebase stale edits or replay side effects. A client may deliberately retry the same request/key after temporary unavailability or a lost response, still subject to current authorization; stale-revision resolution requires explicit review/resubmission. Use deterministic clocks/failure injection and real-file connection/process/concurrency fixtures, without sleeps as business tests.\n\nSDD Q6: Initial routes: GET /api/v1/session, GET /api/v1/household, POST /api/v1/household/setup, GET /api/v1/members, POST /api/v1/members/enroll, PATCH /api/v1/members/{member_id}, PATCH /api/v1/members/me/preferences, GET/PUT /api/v1/authorization, GET /api/v1/operations/{operation_id}, POST /api/v1/recovery/requests, POST /api/v1/recovery/{request_id}/complete; standalone GET /api/v1/auth/start and /api/v1/auth/callback plus POST /api/v1/auth/logout. No HTTP recovery approval. Strict typed request schemas forbid unknown fields, validate bounded IDs/text/timezone/enums, and require expected revisions plus Idempotency-Key for mutations; authenticated actor never comes from a submitted member ID. Return stable business IDs, state/revisions and operation ID; authorized replay returns original durable result. Safe envelope is {error:{code,message,correlation_id,retryable,details}}, with only allowlisted nonsecret details. Use 401 invalid/missing identity, 403 denied action, 404 absent/invisible scoped record, 409 revision/idempotency/last-admin conflict, 422 invalid input and 503 unavailable/busy; invalid/denied requests leave no business/receipt/audit/outbox effect. Cookie mutations require Origin/CSRF checks. Document exact DTOs/OpenAPI and response examples in the implementation; API schemas remain entrypoint DTOs, not domain models. No unrelated household-domain endpoints, product UI or publication are claimed by these routes.\n\nSDD Q7: All authored backend modules, including unimported runtime files, have an independent coverage scope enforced at 100% executable statements/lines and branches as exposed by coverage.py/pytest-cov. No broad exclusions or test-only production branches; any genuine generated/third-party/non-executable declaration distinction must be explicit and cannot hide logic. Function metrics are enforced when supported, not invented where unavailable. Keep tooling, frontend and future HA integration scopes independent. Wire the actual domus package graph into the existing application import-linter contract: composition wires entrypoints/infrastructure; those adapters are independent; application depends only inward on domain/semantic ports; neither inner layer imports FastAPI, HA, HTTP/SQL clients or concrete I/O. Reject outward edges and cycles in actual modules as well as policy fixtures. Extend CI Check with the backend suite/coverage artifacts, frozen installs and strict aggregate needs; every PR update, including drafts/docs-only changes, must run every applicable suite. Existing installed Copilot resource hashes remain unchanged. Merge requires complete coverage and a current-head complete Bugbot review with no unresolved findings; skipped/neutral/incomplete review is not acceptance.\n\nSDD Q8: This increment must prove the common use-case/API contracts under explicit Supervisor and standalone wiring with real SQLite and controlled protocol fixtures: fixed endpoint/config failures, identity spoofing/missing metadata, current HA source revocation, session/CSRF/origin behavior, per-mode role defaults, offline-read lease, recovery, restart/rollback/races and channel isolation. Supervisor fixtures must test the actual socket peer boundary and prefixed routing, not trust arbitrary forwarding headers. Standalone fixtures must prove OAuth state/PKCE/callback/current-user proof and rejection of Ingress/transport credentials as identity. Actual package installation, full UI/HA surface and Stable/Edge release acceptance remain tracked separately and are still mandatory for the first usable product. Use authorized Chrome on the owner's HA only with an identifiable experimental artifact, owned test data and cleanup; record exact versions/artifact/scenarios and honestly mark every unexecuted target/live case pending. Protocol fixtures or coverage alone cannot claim real HA/Ingress deployment acceptance. Keep the existing gateway and MCP unchanged.\n" + }, + { + "id": 6537358069, + "url": "https://github.com/vypdev/domus/issues/6#issuecomment-6537358069", + "author": "efraespada", + "body": "SDD Q1: Use an explicit initial acceptance target matrix: standalone = Core 2026.10.4 (no Supervisor); Supervisor/Ingress = Core 2026.10.4, Supervisor 2026.10.3, HA OS 18.3 and Frontend 20260930.5. These are reference versions from the owner's installation observations (Core/frontend on 2026-10-29; Supervisor/OS recorded in the same installation's 2026-10-25 preflight), not a claim that Domus already passed runtime acceptance there. Mark every matrix row pending until its exact protocol, packaging and live/browser cases execute; only passed exact artifact/version combinations may be described as supported. Other versions remain unverified, with compatibility determined by strict supported protocol/schema checks rather than a fabricated all-versions promise. Add the owner's actual current versions to the matrix when connectivity allows and test them before claiming support. Today's read-only Chrome attempt at the authorized homeassistant.local URL failed with name-resolution failure, so no fresh installed version or live HA proof is claimed; offline protocol fixtures can proceed and live cases remain pending. Preserve both deployment targets in the first usable release and do not interpret missing current network access as permission to drop Ingress/standalone acceptance.\n\nQ2's backup-frequency/retention default is currently being confirmed directly with the owner. All earlier endpoint, role, recovery and transaction decisions remain in the retained answers.\n" + }, + { + "id": 6537371545, + "url": "https://github.com/vypdev/domus/issues/6#issuecomment-6537371545", + "author": "efraespada", + "body": "SDD Q2: Limit this M1 increment to the consistent backup/restore primitive and its real-file tests, not a new automatic backup scheduler/rotation product. The owner asked what these backups referred to; no daily/weekly frequency or retention option was approved. This implementation boundary is a technical sequencing decision, not a new owner-confirmed backup policy. In Supervisor, deployment packaging should include Domus state in the operator's normal HA backup mechanism using a consistent snapshot/appropriate hooks; HA owns its configured schedule and retention. For standalone in this increment, provide explicit local snapshot creation through the installation console and retain successful snapshots until the operator deliberately removes them; no automatic pruning. A pre-upgrade recovery snapshot and its compatible application/configuration pairing must be retained before destructive migration. Exact packaged schedule/retention and target-specific recovery-time acceptance are explicitly deferred to the deployment/operations gate, not dropped from first-release review. Concrete required restore target now: a new owned data directory for the SAME installation UUID and SAME stable/edge channel, with writes stopped, verified schema/application marker, integrity, manifest/checksum and identity before activation. A corrupt, future, wrong-instance or wrong-channel candidate cannot replace active data. Restore must reproduce all committed business records together with audit/outbox and command receipts/replay guards; authorized retry of an old key after restore cannot create another effect. The recovery point is the selected successful snapshot's committed checkpoint, visibly timestamped; there is no unproven 24-hour RPO or restoration-time SLA. Use a paired compatible image/schema for rollback. HA credentials and browser session tokens are excluded from business-state snapshots, so restored users reauthenticate normally. Packaging/live HA backup participation and operational retention/time objectives remain documented pending acceptance items; a SQLite fixture cannot claim them passed.\n" + }, + { + "id": 6537433320, + "url": "https://github.com/vypdev/domus/issues/6#issuecomment-6537433320", + "author": "efraespada", + "body": "## Current read-only owner-installation version evidence\n\nThe owner supplied the local HA `/home/overview` entry again. Chrome can now reach it. Its About screen currently reports **Core 2026.10.4, Supervisor 2026.10.5, HA OS 18.3, Frontend 20260930.5**, observed on 2026-10-31.\n\nUse this exact combination as the owner-installation acceptance target. The earlier Supervisor 2026.10.3 value was explicitly historical evidence and must not be described as today's installed version or as a passed Domus target. Other combinations remain unverified until tested. This refines the live target evidence without changing the agreed authentication, persistence, permission or recovery contracts.\n\nThe browser check was read-only. No credential/token/private frontend state was extracted and no entity, device, automation or HA configuration was changed. Version visibility does not prove a Domus API principal, packaged Ingress flow or runtime acceptance; those still require the implemented artifact and actual cases.\n" + } + ] +} diff --git a/docs/evidence/m1-acceptance-audit.json b/docs/evidence/m1-acceptance-audit.json new file mode 100644 index 0000000..7b3479c --- /dev/null +++ b/docs/evidence/m1-acceptance-audit.json @@ -0,0 +1,1032 @@ +{ + "observedOn": "2026-10-31", + "sourceBase": "8b59a0435fbb11f069298d23cc5e0735c9547bb1", + "issue": 6, + "pullRequest": 7, + "scope": "In-scope identity, authorization, persistence and offline restoration foundation; not complete M1 or product acceptance", + "backend": { + "tests": 1387, + "statements": 4692, + "branches": 1304, + "missingStatements": 0, + "missingBranches": 0, + "percent": 100, + "python": "3.14.6", + "sqlite": "3.53.3", + "command": "pytest tests/backend -q -W error::ResourceWarning --cov=domus --cov-branch" + }, + "caseCounts": { + "identity": 61, + "restoration": 18, + "total": 79 + }, + "cases": [ + { + "case": "platform-foundation-identity-sqlite-C01", + "requirements": [ + "ID-01" + ], + "scenario": "Reject an inactive, system, or malformed HA human proof.", + "selectors": [ + "tests/backend/test_identity_policy.py::test_only_exact_boolean_active_nonsystem_human_is_eligible", + "tests/backend/test_ha_websocket.py::test_malformed_or_nonhuman_current_user_cannot_become_identity" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C02", + "requirements": [ + "ID-01" + ], + "scenario": "Reject mismatched request correlation and expired proof.", + "selectors": [ + "tests/backend/test_ha_websocket.py::test_response_correlation_and_success_are_exact", + "tests/backend/test_session_leases.py::test_missing_expired_and_backwards_clock_leases_cannot_invoke_provider" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C03", + "requirements": [ + "ID-02" + ], + "scenario": "Default mapping grants each currently proven HA administrator and no nonadministrator.", + "selectors": [ + "tests/backend/test_identity_policy.py::test_all_administrators_mapping_uses_current_proof_and_retains_explicit_grants" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C04", + "requirements": [ + "ID-02" + ], + "scenario": "Setup-administrator mode grants only designated proven setup administrator.", + "selectors": [ + "tests/backend/test_identity_policy.py::test_setup_mapping_requires_the_designated_current_ha_administrator" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C05", + "requirements": [ + "ID-02" + ], + "scenario": "Selected-user mode permits an explicitly selected active human who is not an HA administrator.", + "selectors": [ + "tests/backend/test_identity_policy.py::test_selected_mapping_does_not_require_selected_human_to_be_ha_administrator" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C06", + "requirements": [ + "ID-02" + ], + "scenario": "Mapping/source change revokes inherited grant but preserves independent explicit grant.", + "selectors": [ + "tests/backend/test_policy_commands.py::test_selected_mapping_grants_an_ordinary_active_human_and_revokes_only_inheritance", + "tests/backend/test_policy_commands.py::test_mapping_change_preserves_independently_granted_administrator_profile" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C07", + "requirements": [ + "ID-02" + ], + "scenario": "Verify administrator, adult, restricted and viewer default permission matrix.", + "selectors": [ + "tests/backend/test_identity_policy.py::test_default_profile_matrix" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C08", + "requirements": [ + "ID-02" + ], + "scenario": "Verify exception/profile/default/deny precedence and reject same-level contradiction.", + "selectors": [ + "tests/backend/test_identity_policy.py::test_member_then_profile_then_default_precedence_and_undefined_denial", + "tests/backend/test_identity_policy.py::test_invalid_enums_rules_mutable_lists_and_same_level_duplicates_are_rejected" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C09", + "requirements": [ + "ID-02" + ], + "scenario": "Enforce hard scope, suspension, restricted approval and last-administrator constraints over overrides.", + "selectors": [ + "tests/backend/test_identity_policy.py::test_restricted_work_is_assigned_to_actual_member_and_cannot_skip_adult_approval", + "tests/backend/test_identity_policy.py::test_own_scoped_actions_require_explicit_self_even_for_administrator", + "tests/backend/test_member_commands.py::test_concurrent_self_suspension_cannot_remove_both_remaining_administrators" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C10", + "requirements": [ + "ID-03" + ], + "scenario": "Recovery eligibility requires current HA administrator and exact selected/setup-only mode.", + "selectors": [ + "tests/backend/test_identity_policy.py::test_recovery_is_narrowly_available_only_to_current_ha_administrator_in_alternate_modes" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C11", + "requirements": [ + "ID-04" + ], + "scenario": "Canonical payload digest and receipt scope distinguish identical payload from changed payload.", + "selectors": [ + "tests/backend/test_storage_values.py::test_canonical_payload_is_detached_from_callers_and_ignores_object_key_order", + "tests/backend/test_storage_values.py::test_command_key_scopes_receipts_by_household_actor_action_and_key" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C12", + "requirements": [ + "ID-04" + ], + "scenario": "Validate installation UUID/channel/schema marker and reject future or unrelated schema before writes.", + "selectors": [ + "tests/backend/test_sqlite_transactions.py::test_schema_and_ownership_rejection_happens_before_file_writes" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C13", + "requirements": [ + "ID-05" + ], + "scenario": "Strict DTO rejects unknown, unbounded, malformed and cross-field-invalid values.", + "selectors": [ + "tests/backend/test_household_http.py::test_invalid_or_forged_setup_is_safe_and_leaves_no_records", + "tests/backend/test_household_http.py::test_ambiguous_malformed_or_nonobject_json_never_reaches_a_command" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C14", + "requirements": [ + "ID-06" + ], + "scenario": "Pure policy decision branches cover allow, deny, stale, suspension and undefined action.", + "selectors": [ + "tests/backend/test_identity_policy.py::test_member_then_profile_then_default_precedence_and_undefined_denial", + "tests/backend/test_identity_policy.py::test_inactive_local_state_denies_even_independent_and_inherited_administration", + "tests/backend/test_member_commands.py::test_invalid_and_stale_member_changes_never_request_target_proof" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C15", + "requirements": [ + "ID-02" + ], + "scenario": "First browser arrival cannot create household or administrator authority.", + "selectors": [ + "tests/backend/test_household_commands.py::test_first_arriving_ordinary_user_never_initializes_the_household", + "tests/backend/test_household_runtime.py::test_actual_runtime_wires_all_household_services_without_implicit_setup" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C16", + "requirements": [ + "ID-02" + ], + "scenario": "Verified self-enrollment remains pending until authorized activation.", + "selectors": [ + "tests/backend/test_household_commands.py::test_own_enrollment_is_pending_and_a_retry_has_one_stable_member", + "tests/backend/test_household_commands.py::test_ha_admin_activation_exception_applies_only_to_explicit_enrollment_in_default_mapping" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C17", + "requirements": [ + "ID-02" + ], + "scenario": "Administrator edits use optimistic revision and transaction-time current policy.", + "selectors": [ + "tests/backend/test_member_commands.py::test_external_proof_cannot_commit_against_changed_local_authority_state", + "tests/backend/test_policy_commands.py::test_policy_changed_while_resolving_target_is_not_overwritten" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C18", + "requirements": [ + "ID-02" + ], + "scenario": "Suspension and revocation deny protected action and private preferences remain private.", + "selectors": [ + "tests/backend/test_member_commands.py::test_suspension_takes_effect_on_existing_authenticated_authority_immediately", + "tests/backend/test_identity_policy.py::test_own_scoped_actions_require_explicit_self_even_for_administrator" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C19", + "requirements": [ + "ID-03" + ], + "scenario": "Console approval is exact-target bound, expires at ten minutes and cannot be retargeted.", + "selectors": [ + "tests/backend/test_recovery_console.py::test_explicit_console_approves_exact_displayed_target_and_cannot_complete_human_step", + "tests/backend/test_recovery_commands.py::test_approval_has_ten_full_minutes_from_confirmation_and_replay_never_extends_it" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C20", + "requirements": [ + "ID-03" + ], + "scenario": "Recovery completion requires fresh same-target administrator proof and is single use.", + "selectors": [ + "tests/backend/test_recovery_commands.py::test_recovery_grants_only_explicit_administration_after_console_and_fresh_proof", + "tests/backend/test_recovery_commands.py::test_concurrent_completion_has_one_grant_and_one_consumed_request" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C21", + "requirements": [ + "ID-03" + ], + "scenario": "Wrong identity, channel, mapping revision, expiry or concurrent completion creates no grant.", + "selectors": [ + "tests/backend/test_recovery_commands.py::test_approval_cannot_be_retargeted_or_used_after_source_or_binding_change", + "tests/backend/test_recovery_commands.py::test_policy_change_during_terminal_confirmation_is_detected_before_approval", + "tests/backend/test_recovery_commands.py::test_expiration_does_not_extend_on_approval_and_grants_nothing" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C22", + "requirements": [ + "ID-04" + ], + "scenario": "Identical authorized retry after restart returns original result before stale-write revision handling.", + "selectors": [ + "tests/backend/test_atomic_commands.py::test_restart_replay_returns_original_result_without_fresh_effect", + "tests/backend/test_household_commands.py::test_authorized_setup_retry_reuses_receipt_after_restart_and_payload_conflicts" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C23", + "requirements": [ + "ID-04" + ], + "scenario": "Same scoped key with changed payload returns conflict without new effect.", + "selectors": [ + "tests/backend/test_atomic_commands.py::test_changed_payload_conflicts_and_revoked_permission_hides_even_old_result" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C24", + "requirements": [ + "ID-04" + ], + "scenario": "Current permissions govern receipt lookup after actor revocation.", + "selectors": [ + "tests/backend/test_membership_authorization.py::test_fresh_source_revocation_denies_original_receipt_before_result_disclosure", + "tests/backend/test_household_commands.py::test_setup_retry_after_actor_loses_administration_cannot_disclose_the_old_receipt" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C25", + "requirements": [ + "ID-04" + ], + "scenario": "Competing writers serialize and produce bounded busy or revision conflict without partial effects.", + "selectors": [ + "tests/backend/test_sqlite_transactions.py::test_reader_cannot_write_and_an_external_writer_returns_bounded_busy", + "tests/backend/test_sqlite_transactions.py::test_expected_revision_prevents_lost_updates_and_second_household" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C26", + "requirements": [ + "ID-04" + ], + "scenario": "Injected failure rolls back domain state, receipt, audit and outbox atomically.", + "selectors": [ + "tests/backend/test_sqlite_transactions.py::test_business_exception_rolls_back_every_fact", + "tests/backend/test_recovery_commands.py::test_late_outbox_failure_rolls_back_consumption_grant_and_all_evidence" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C27", + "requirements": [ + "ID-04" + ], + "scenario": "Cleanup preserves replay protection and authorized old retry cannot duplicate effect.", + "selectors": [ + "tests/backend/test_household_runtime.py::test_owned_standalone_restart_preserves_identity_household_and_replay_not_session", + "tests/backend/test_sqlite_transactions.py::test_outbox_acknowledgement_is_durable_and_duplicate_safe" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C28", + "requirements": [ + "ID-04" + ], + "scenario": "Restore preserves receipt/replay evidence and identical authorized retry remains idempotent.", + "selectors": [ + "tests/backend/test_sqlite_activation.py::test_actual_compositions_reauthenticate_and_replay_historical_work_after_activation", + "tests/backend/test_restored_commands.py::test_historical_http_replay_is_truthful_and_requires_current_authority" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C29", + "requirements": [ + "ID-01" + ], + "scenario": "Standalone OAuth uses fixed origin, state, S256 PKCE, bounded single-use attempt and server form exchange.", + "selectors": [ + "tests/backend/test_authentication_flow.py::test_start_uses_pkce_s256_and_completion_consumes_browser_bound_attempt", + "tests/backend/test_ha_http.py::test_code_exchange_is_exact_server_side_form_and_token_repr_is_redacted" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C30", + "requirements": [ + "ID-01" + ], + "scenario": "Standalone rejects request-chosen host/redirect, Ingress headers and technical transport credential as human identity.", + "selectors": [ + "tests/backend/test_auth_http.py::test_standalone_rejects_wrong_host_and_ingress_identity_headers", + "tests/backend/test_ha_http.py::test_provider_status_is_allowlisted_and_redirects_are_never_followed", + "tests/backend/test_ha_http.py::test_invalid_operator_origin_is_rejected_before_io" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C31", + "requirements": [ + "ID-01" + ], + "scenario": "HA adapter handles timeout, unavailable, revoked token, malformed/oversized response and cleanup without secret leakage.", + "selectors": [ + "tests/backend/test_ha_http.py::test_network_errors_are_availability_without_transport_detail", + "tests/backend/test_ha_http.py::test_invalid_duplicate_oversized_and_non_utf8_response_fails_closed", + "tests/backend/test_auth_http.py::test_outage_status_is_read_only_and_logout_still_clears_local_access" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C32", + "requirements": [ + "ID-01" + ], + "scenario": "Supervisor adapter accepts only actual peer 172.30.32.2 and requires fresh Core lookup of active non-system human and actual admin source.", + "selectors": [ + "tests/backend/test_ha_ingress.py::test_actual_supervisor_peer_is_required_before_any_lookup_or_forwarded_header", + "tests/backend/test_ha_websocket.py::test_directory_rejects_missing_inactive_technical_malformed_or_duplicate_subject" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C33", + "requirements": [ + "ID-01" + ], + "scenario": "Supervisor arbitrary forwarding headers and transport token alone cannot assert user or role.", + "selectors": [ + "tests/backend/test_ha_ingress.py::test_core_proof_overrules_header_name_and_transport_privilege", + "tests/backend/test_ha_ingress.py::test_lookup_must_match_candidate_and_failures_never_fall_back_to_headers" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C34", + "requirements": [ + "ID-01" + ], + "scenario": "HA outage permits bounded read-only access until original expiry but no mutation or session extension.", + "selectors": [ + "tests/backend/test_session_leases.py::test_outage_read_uses_latest_proof_but_write_is_blocked_without_extension", + "tests/backend/test_native_http.py::test_native_outage_reads_have_original_expiry_and_writes_remain_blocked" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C35", + "requirements": [ + "ID-05" + ], + "scenario": "Stable API errors expose allowlisted details/correlation and map 401, 403, 404, 409, 422 and 503 safely.", + "selectors": [ + "tests/backend/test_auth_http.py::test_error_envelope_maps_only_known_failures_and_never_echoes_provider_detail", + "tests/backend/test_household_http.py::test_missing_operation_returns_safe_scoped_404_and_openapi_declares_all_routes", + "tests/backend/test_household_http.py::test_busy_writer_returns_retry_guidance_without_new_key_or_partial_effect" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C36", + "requirements": [ + "ID-05" + ], + "scenario": "Cookie mutation requires valid Origin/CSRF checks and actor is never taken from submitted member ID.", + "selectors": [ + "tests/backend/test_household_http.py::test_writes_require_origin_csrf_unique_idempotency_and_strict_empty_query", + "tests/backend/test_household_http.py::test_invalid_or_forged_setup_is_safe_and_leaves_no_records", + "tests/backend/test_native_http.py::test_native_write_boundary_cannot_be_replaced_by_forwarding_or_forged_csrf" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C37", + "requirements": [ + "ID-04" + ], + "scenario": "SQLite uses parameterized operations, separate unit-of-work connections and bounded BEGIN IMMEDIATE writer wait.", + "selectors": [ + "tests/backend/test_sqlite_transactions.py::test_display_text_is_bound_as_data_instead_of_executed_as_sql", + "tests/backend/test_sqlite_transactions.py::test_reader_cannot_write_and_an_external_writer_returns_bounded_busy" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C38", + "requirements": [ + "ID-04" + ], + "scenario": "Atomic migration failure leaves recoverable prior database and performs no partial schema change.", + "selectors": [ + "tests/backend/test_sqlite_identity.py::test_failed_v1_upgrade_rolls_back_new_tables_and_keeps_old_state", + "tests/backend/test_sqlite_history_integrity.py::test_interrupted_schema_three_upgrade_rolls_back_the_new_history_table" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C39", + "requirements": [ + "ID-04" + ], + "scenario": "Real database file restart preserves household, policy, receipt, audit and outbox.", + "selectors": [ + "tests/backend/test_household_runtime.py::test_owned_standalone_restart_preserves_identity_household_and_replay_not_session", + "tests/backend/test_policy_commands.py::test_restart_preserves_policy_revision_provenance_and_authorized_replay" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C40", + "requirements": [ + "ID-04" + ], + "scenario": "Consistent snapshot verifies integrity/manifest/identity and restores to new same-UUID/channel directory.", + "selectors": [ + "tests/backend/test_sqlite_snapshots.py::test_snapshot_contains_verified_private_identity_checkpoint_and_all_atomic_records", + "tests/backend/test_sqlite_activation.py::test_real_activation_and_restart_keep_checkpoint_state_plus_later_receipts" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C41", + "requirements": [ + "ID-04" + ], + "scenario": "Corrupt, future, unrelated, wrong-instance or wrong-channel restore candidate cannot replace active DB.", + "selectors": [ + "tests/backend/test_sqlite_snapshots.py::test_changed_files_never_verify_and_do_not_change_the_source", + "tests/backend/test_sqlite_restoration.py::test_scope_changes_and_snapshot_replacement_fail_before_any_output", + "tests/backend/test_activation_failures.py::test_unbound_or_stale_preparation_is_inspectable_but_not_activatable" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C42", + "requirements": [ + "ID-05" + ], + "scenario": "OpenAPI and route contract includes specified session, household, member, authorization, operation and recovery routes.", + "selectors": [ + "tests/backend/test_household_http.py::test_published_household_openapi_matches_the_composed_server", + "tests/backend/test_native_http.py::test_published_native_openapi_matches_actual_private_session_and_household_contract", + "tests/backend/test_recovery_http.py::test_strict_recovery_routes_reject_forged_target_and_allow_owner_cancellation" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C43", + "requirements": [ + "ID-05" + ], + "scenario": "Standalone auth start/callback/logout routes and Supervisor-prefixed routing obey common server contract.", + "selectors": [ + "tests/backend/test_auth_http.py::test_happy_path_sets_secure_host_only_cookies_and_identity_view_has_no_credentials", + "tests/backend/test_auth_http.py::test_successful_logout_uses_original_cookie_and_empty_strict_body", + "tests/backend/test_native_http.py::test_native_root_schema_and_method_bounds_are_verified_without_leaking_cookies" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C44", + "requirements": [ + "ID-05" + ], + "scenario": "Recovery approval has console command only and no HTTP approval route.", + "selectors": [ + "tests/backend/test_auth_http.py::test_openapi_contract_and_no_http_console_approval", + "tests/backend/test_recovery_console.py::test_actual_console_process_requires_and_accepts_a_real_pty" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C45", + "requirements": [ + "ID-06" + ], + "scenario": "Every PR head including docs-only runs tooling, frontend, documentation and backend suites in fail-closed aggregate.", + "selectors": [ + "tests/python/test_ci_contract.py::test_every_pr_update_and_merge_queue_run_without_path_filters", + "tests/python/test_ci_contract.py::test_required_jobs_always_reach_a_fail_closed_aggregate" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C46", + "requirements": [ + "ID-06" + ], + "scenario": "Backend coverage scope includes authored unimported runtime modules and enforces supported statements/lines and branches at 100%.", + "selectors": [ + "tests/python/test_ci_contract.py::test_new_runtime_sources_require_an_explicit_coverage_scope", + "tests/python/test_ci_contract.py::test_backend_cannot_inherit_the_tooling_coverage_result" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C47", + "requirements": [ + "ID-06" + ], + "scenario": "Actual product import graph rejects outward imports and cycles; no broad coverage exclusions hide logic.", + "selectors": [ + "tests/architecture/test_python_architecture.py::test_dependencies_point_inward", + "tests/architecture/test_python_architecture.py::test_inner_framework_or_io_dependency_is_rejected", + "tests/architecture/test_python_architecture.py::test_cycle_inside_domain_is_rejected" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C48", + "requirements": [ + "ID-05" + ], + "scenario": "Pending enrollment response states current status, completed proof, next action and no active access.", + "selectors": [ + "tests/backend/test_response_readability.py::test_authorization_explains_current_membership_and_next_action_without_implicit_access", + "tests/backend/test_response_readability.py::test_enrollment_receipt_describes_original_effect_even_after_current_status_changes" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C49", + "requirements": [ + "ID-05" + ], + "scenario": "Blocked pre-commit error names impact, cause, action and retained state without claiming a commit.", + "selectors": [ + "tests/backend/test_household_http.py::test_invalid_or_forged_setup_is_safe_and_leaves_no_records", + "tests/backend/test_auth_http.py::test_error_envelope_maps_only_known_failures_and_never_echoes_provider_detail" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C50", + "requirements": [ + "ID-05" + ], + "scenario": "Committed effect with pending outbox is presented as committed and inspectable by operation ID.", + "selectors": [ + "tests/backend/test_household_http.py::test_explicit_setup_authorization_queries_and_original_receipt", + "tests/backend/test_household_http.py::test_delivery_state_is_observed_and_reads_hide_other_member_private_fields" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C51", + "requirements": [ + "ID-05" + ], + "scenario": "Error/console rendering sanitizes untrusted Markdown, HTML, mentions, commands, markers and URLs.", + "selectors": [ + "tests/backend/test_response_readability.py::test_console_rejects_untrusted_request_text_without_echo_prompt_or_saved_effect", + "tests/backend/test_response_readability.py::test_missing_operation_is_readable_english_json_without_untrusted_diagnostic_rendering" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C52", + "requirements": [ + "ID-05" + ], + "scenario": "API codes remain stable, user text has English fallback, narrow layout and text states remain understandable without color.", + "selectors": [ + "tests/backend/test_response_readability.py::test_missing_operation_is_readable_english_json_without_untrusted_diagnostic_rendering", + "tests/backend/test_response_readability.py::test_authorization_explains_current_membership_and_next_action_without_implicit_access" + ], + "evidenceKind": "automated fixture", + "limitation": "API/console text only; assistant inspection is not independent human UX approval. Product UI/narrow-screen browser acceptance is outside issue #6 and remains pending." + }, + { + "case": "platform-foundation-identity-sqlite-C53", + "requirements": [ + "ID-01", + "ID-05" + ], + "scenario": "Standalone protocol fixtures prove OAuth callback/current-user proof and reject invalid state, PKCE, correlation and credentials.", + "selectors": [ + "tests/backend/test_authentication_flow.py::test_wrong_browser_binding_cannot_consume_legitimate_attempt", + "tests/backend/test_authentication_flow.py::test_competing_callbacks_only_one_can_take_the_attempt", + "tests/backend/test_ha_websocket.py::test_response_correlation_and_success_are_exact", + "tests/backend/test_ha_http.py::test_invalid_code_or_pkce_verifier_is_not_transmitted" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C54", + "requirements": [ + "ID-01", + "ID-05" + ], + "scenario": "Supervisor fixtures prove socket peer boundary, fresh identity lookup and prefixed routing without trusting forwarded headers.", + "selectors": [ + "tests/backend/test_native_http.py::test_native_private_listener_rejects_direct_requests_even_with_copied_headers", + "tests/backend/test_ha_ingress.py::test_core_proof_overrules_header_name_and_transport_privilege", + "tests/backend/test_native_http.py::test_native_root_schema_and_method_bounds_are_verified_without_leaking_cookies" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C55", + "requirements": [ + "ID-02", + "ID-04" + ], + "scenario": "Real-file integration proves atomic commit and rollback of state, receipt, audit and outbox across process restart.", + "selectors": [ + "tests/backend/test_sqlite_transactions.py::test_process_death_cannot_commit_partial_business_or_replay_facts", + "tests/backend/test_sqlite_transactions.py::test_committed_household_receipt_audit_and_outbox_survive_reopen", + "tests/backend/test_sqlite_identity.py::test_membership_and_policy_failure_rolls_back_together" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C56", + "requirements": [ + "ID-03", + "ID-04" + ], + "scenario": "Recovery race and replay fixtures prove both authorities and atomic single-use grant/evidence.", + "selectors": [ + "tests/backend/test_recovery_http.py::test_actual_http_and_terminal_journey_needs_both_authorities_and_preserves_mapping", + "tests/backend/test_recovery_commands.py::test_concurrent_completion_has_one_grant_and_one_consumed_request", + "tests/backend/test_recovery_commands.py::test_late_outbox_failure_rolls_back_consumption_grant_and_all_evidence" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C57", + "requirements": [ + "ID-04" + ], + "scenario": "Real-file concurrent processes verify serialized writers, bounded busy result, stale revision conflict and no duplicate effects.", + "selectors": [ + "tests/backend/test_sqlite_transactions.py::test_separate_process_observes_bounded_busy_then_committed_revision", + "tests/backend/test_atomic_commands.py::test_overlapping_identical_commands_commit_once_and_retry_the_winning_receipt", + "tests/backend/test_member_commands.py::test_two_identical_concurrent_activations_have_one_effect_and_the_same_receipt" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C58", + "requirements": [ + "ID-04" + ], + "scenario": "Backup/reopen verifies checkpoint, identity/channel and audit/outbox; restoring older state retains readable later processed evidence without executing effects again.", + "selectors": [ + "tests/backend/test_sqlite_activation.py::test_real_activation_and_restart_keep_checkpoint_state_plus_later_receipts", + "tests/backend/test_sqlite_activation.py::test_supported_rollback_checkpoint_reconciles_newer_work_and_never_reactivates_an_old_key", + "tests/backend/test_restoration_values.py::test_later_acknowledgement_is_preserved_for_current_and_historical_events" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C59", + "requirements": [ + "ID-06" + ], + "scenario": "CI coverage and architecture commands evaluate actual backend modules and fail when threshold or import contract is violated.", + "selectors": [ + "tests/python/test_ci_contract.py::test_backend_cannot_inherit_the_tooling_coverage_result", + "tests/architecture/test_python_architecture.py::test_inner_layer_cannot_import_infrastructure", + "tests/architecture/test_python_architecture.py::test_entrypoint_cannot_import_storage_adapter" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-foundation-identity-sqlite-C60", + "requirements": [ + "ID-07" + ], + "scenario": "Exact reference target matrix records protocol, package and live/browser evidence as pending until run on identified artifact/version.", + "selectors": [], + "evidenceKind": "bounded artifact/browser observation", + "documents": [ + "docs/compatibility-home-assistant.md", + "docs/evidence/m1-edge3-ha-acceptance.json" + ] + }, + { + "case": "platform-foundation-identity-sqlite-C61", + "requirements": [ + "ID-07" + ], + "scenario": "Authorized local HA/Chrome proof, if run, records exact artifact/version, owned test data and cleanup; absent live proof is explicitly pending.", + "selectors": [], + "evidenceKind": "bounded artifact/browser observation", + "documents": [ + "docs/compatibility-home-assistant.md", + "docs/evidence/m1-edge3-ha-acceptance.json" + ] + }, + { + "case": "platform-restoration-C01", + "requirements": [ + "REST-01" + ], + "scenario": "Reject invalid/incomplete evidence values.", + "selectors": [ + "tests/backend/test_restoration_values.py::test_incomplete_or_inconsistent_atomic_facts_are_rejected", + "tests/backend/test_restoration_values.py::test_record_parser_rejects_unknown_missing_and_invalid_fields" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C02", + "requirements": [ + "REST-01" + ], + "scenario": "Reject key or operation identity collisions.", + "selectors": [ + "tests/backend/test_restoration_values.py::test_same_command_with_different_committed_facts_is_never_silently_selected", + "tests/backend/test_restoration_values.py::test_frontiers_must_have_unique_sorted_scopes_and_operation_identities" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C03", + "requirements": [ + "REST-01" + ], + "scenario": "Checkpoint facts must be explained by current retained frontier, independent of clocks.", + "selectors": [ + "tests/backend/test_restoration_values.py::test_a_checkpoint_cannot_omit_an_earlier_retained_operation", + "tests/backend/test_restoration_values.py::test_wall_clock_regression_does_not_change_the_operation_frontier" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C04", + "requirements": [ + "REST-02" + ], + "scenario": "Later facts become historical without changing checkpoint state.", + "selectors": [ + "tests/backend/test_restoration_values.py::test_later_operations_are_historical_and_not_selected_business_state", + "tests/backend/test_sqlite_activation.py::test_real_activation_and_restart_keep_checkpoint_state_plus_later_receipts" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C05", + "requirements": [ + "REST-03" + ], + "scenario": "Preserve real acknowledgement; do not enqueue rolled-away effects.", + "selectors": [ + "tests/backend/test_restoration_values.py::test_later_acknowledgement_is_preserved_for_current_and_historical_events", + "tests/backend/test_sqlite_history.py::test_historical_lookup_retains_original_result_and_never_enters_pending_delivery" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C06", + "requirements": [ + "REST-02" + ], + "scenario": "Empty and repeated restorations retain scope/history and sequence high-water marks.", + "selectors": [ + "tests/backend/test_restoration_values.py::test_empty_checkpoint_keeps_complete_history_without_recreating_a_household", + "tests/backend/test_restoration_values.py::test_repeated_restore_keeps_first_history_and_can_select_a_known_effect_again", + "tests/backend/test_sqlite_history.py::test_current_import_keeps_provenance_and_acknowledgement_and_advances_next_position" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C07", + "requirements": [ + "REST-04" + ], + "scenario": "Reject a result bound to another selection/installation.", + "selectors": [ + "tests/backend/test_restoration_commands.py::test_application_rejects_misdirected_results", + "tests/backend/test_restoration_commands.py::test_preparation_requires_an_explicit_matching_checkpoint_before_port_io" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C08", + "requirements": [ + "REST-05" + ], + "scenario": "Changed source cannot activate a stale candidate.", + "selectors": [ + "tests/backend/test_activation_failures.py::test_unbound_or_stale_preparation_is_inspectable_but_not_activatable", + "tests/backend/test_sqlite_activation.py::test_activation_requires_stopped_runtime_and_current_source_before_any_transition" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C09", + "requirements": [ + "REST-06" + ], + "scenario": "Same-key historical replay never executes a second effect.", + "selectors": [ + "tests/backend/test_restored_commands.py::test_historical_http_replay_is_truthful_and_requires_current_authority", + "tests/backend/test_sqlite_activation.py::test_actual_compositions_reauthenticate_and_replay_historical_work_after_activation" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C10", + "requirements": [ + "REST-02" + ], + "scenario": "Real history rows retain original receipt/audit/event facts and current authorization.", + "selectors": [ + "tests/backend/test_sqlite_history.py::test_historical_lookup_retains_original_result_and_never_enters_pending_delivery", + "tests/backend/test_sqlite_history_integrity.py::test_conflicting_import_is_atomic_even_if_the_caller_commits_after_catching_it", + "tests/backend/test_restored_commands.py::test_historical_http_replay_is_truthful_and_requires_current_authority" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C11", + "requirements": [ + "REST-04" + ], + "scenario": "Private copy verifies hashes/schema/integrity without changing source.", + "selectors": [ + "tests/backend/test_sqlite_restoration.py::test_preparation_seals_checkpoint_bytes_and_complete_later_history_without_source_mutation", + "tests/backend/test_sqlite_restoration.py::test_current_history_is_one_coherent_read_view_during_a_concurrent_wal_commit" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C12", + "requirements": [ + "REST-04" + ], + "scenario": "Prepared/incomplete candidate cannot start through any runtime path.", + "selectors": [ + "tests/backend/test_sqlite_restoration.py::test_prepared_copy_is_rejected_without_mutation_by_every_managed_and_explicit_runtime", + "tests/backend/test_sqlite_restoration.py::test_a_partial_restore_signal_blocks_direct_paths_even_if_identity_is_missing" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C13", + "requirements": [ + "REST-02" + ], + "scenario": "Frozen historical schemas migrate atomically with retained records.", + "selectors": [ + "tests/backend/test_sqlite_history.py::test_frozen_old_schema_migrates_without_losing_existing_evidence", + "tests/backend/test_sqlite_history_integrity.py::test_interrupted_schema_three_upgrade_rolls_back_the_new_history_table", + "tests/backend/test_activation_compatibility.py::test_real_activation_upgrades_only_target_from_frozen_source_and_checkpoint" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C14", + "requirements": [ + "REST-05" + ], + "scenario": "An unaware older image refuses new metadata/schema without data loss.", + "selectors": [ + "tests/containers/test_app_compatibility.py::test_actual_older_image_refuses_selected_generation_without_changing_any_file", + "tests/containers/test_app_compatibility.py::test_real_schema_three_image_upgrades_replays_and_restores_its_pre_upgrade_snapshot" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C15", + "requirements": [ + "REST-06" + ], + "scenario": "Historical response distinguishes rolled-away effect from current committed state.", + "selectors": [ + "tests/backend/test_restored_commands.py::test_historical_http_replay_is_truthful_and_requires_current_authority", + "tests/backend/test_response_readability.py::test_historical_enrollment_does_not_claim_membership_survived_restoration", + "tests/backend/test_restored_commands.py::test_console_replay_cannot_claim_a_rolled_away_approval_is_current" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C16", + "requirements": [ + "REST-04" + ], + "scenario": "Post-publication output failure preserves uncertainty without secrets.", + "selectors": [ + "tests/backend/test_sqlite_restoration.py::test_output_flush_failure_preserves_uncertainty_and_an_independently_inspectable_bundle", + "tests/backend/test_sqlite_restoration.py::test_deadline_expiring_after_manifest_flush_reports_uncertainty" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C17", + "requirements": [ + "REST-06" + ], + "scenario": "Consumed/canceled recovery cannot be rearmed by an old checkpoint/new key.", + "selectors": [ + "tests/backend/test_activation_compatibility.py::test_actual_activation_of_old_approval_cannot_rearm_closed_recovery", + "tests/backend/test_restored_commands.py::test_restoring_an_approval_cannot_rearm_closed_recovery_with_another_key" + ], + "evidenceKind": "automated fixture" + }, + { + "case": "platform-restoration-C18", + "requirements": [ + "REST-05" + ], + "scenario": "Actual interrupted activation/restart preserves old data and starts only verified state.", + "selectors": [ + "tests/backend/test_sqlite_activation.py::test_faulted_transition_preserves_source_and_resumes_exact_input_without_second_effect", + "tests/backend/test_sqlite_activation.py::test_process_death_after_pointer_rename_releases_leases_and_resumes_without_external_input", + "tests/containers/test_app_image.py::test_packaged_process_death_blocks_start_and_resumes_the_exact_transition" + ], + "evidenceKind": "automated fixture" + } + ], + "inspection": "Assistant inspected API/console semantic assertions and static English text. No independent human approval or product UI browser readability acceptance is claimed.", + "ownerVersionObservation": { + "method": "Read-only Google Chrome /config/info on owner installation", + "core": "2026.10.4", + "supervisor": "2026.10.5", + "operatingSystem": "18.3", + "frontend": "20260930.5", + "dataImpact": "No configuration, household, HA entity, user or recovery changes" + }, + "review": { + "status": "pending", + "reason": "Published PR is draft and Bugbot skips drafts. Complete review of the eventual ready/current head with no unresolved findings is required before merge." + }, + "packageBoundary": "Edge.3 remains pinned to faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5. The new source response explanations are not installed on the owner target. Existing package/browser evidence remains limited to its exact immutable pin.", + "pending": [ + "Current-head CI and complete Bugbot review", + "Independent human API/console readability review", + "Standalone live browser return and companion authority", + "Live owner household/recovery/restore commands", + "Product UI, HA entities/services, remaining household domains, complete Stable/Edge distribution and release" + ], + "sourceSha256": { + "backend/src/domus/entrypoints/household_http.py": "f4856a94fa4979b0cc4dd6b5670f6741371efadc794d69e6e186b07493549cab", + "tests/backend/test_identity_policy.py": "2e4e35737f813df2d5eec44874d84b4ce63ff5c061ee040a58d42f423bff72fd", + "tests/backend/test_response_readability.py": "2cbc02a1823bc001f4a0dae42d8f37d4922d01fb2b822710ceafbc889e845731", + "docs/contracts/household-api.openapi.json": "908ec8ba85db2beb0586261ee1731721dbfae3412fea2f9e6ea92d406d36ecce", + "docs/contracts/native-household-api.openapi.json": "e6e4d5236e869e9b58c7f5551f1cdbd9dc95123681e124b93c474c796bace664" + }, + "historicalEvidence": { + "verifiedSourceHead": "ead7dde44f57d32d5a42797a811e798d92b36a89", + "meaning": "All measurements, schema numbers, assertions and sourceSha256 values describe this exact historical source tree, not a later reviewed head. The pre-change sourceBase remains provenance of the preparation baseline.", + "verification": "Every recorded sourceSha256 was checked against git show :; all match. Original measurements and digests are retained unchanged.", + "currentEvidence": "docs/evidence/m1-review-fixes.json", + "currentGuide": "docs/m1-acceptance-audit.md" + } +} diff --git a/docs/evidence/m1-administrator-recovery.json b/docs/evidence/m1-administrator-recovery.json new file mode 100644 index 0000000..4cf1708 --- /dev/null +++ b/docs/evidence/m1-administrator-recovery.json @@ -0,0 +1,99 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "ddc90faa5964509ed154090f8c8070f743e02370", + "scope": "Target-bound administrator recovery, schema 3 migrations, both persistent HTTP compositions and actual installation-console command; no package or owner HA changes", + "backendTests": 808, + "backendCoverage": { + "covered_lines": 3022, + "num_statements": 3022, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 118, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 772, + "num_partial_branches": 0, + "covered_branches": 772, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "command": "COVERAGE_FILE=/private/tmp/domus-recovery-backend.coverage .venv/bin/pytest tests/backend -q -W error::ResourceWarning --cov=domus --cov-branch --cov-report=term-missing", + "coverageScope": "All authored domus modules only. Final report asserts every path belongs to backend/src/domus. A prior concurrent local run mixed tooling data and was replaced with independent backend/tooling coverage files; CI already runs these scopes in separate jobs.", + "testFirst": "Pure values and application/console suites first failed import because their modules did not exist; repository tests failed because no recoveries port/table existed; actual composed HTTP journeys first failed with 404 before the routes were implemented.", + "assertions": [ + "Current active HA administrator can request only in setup/selected mapping modes; server proof supplies the target, never a submitted HA ID", + "The request binds exact target, installation UUID/channel, household, mapping/policy revision and existing member ID/revision. Pending requests expire after ten minutes; one live console confirmation establishes ten full minutes of approval, with an immutable approval timestamp/deadline on retries and consumption.", + "Console approval displays the bound target and intended grant, requires real terminal input/output and exact typed confirmation, and never completes the human proof step", + "The real domus-admin module runs in a separate process using a real PTY; the registered project script help is also executed after a frozen uv reinstall", + "No HTTP approval route exists in either deployment; actual ASGI/SQLite request-console-completion journeys verify both authorities and own-only request disclosure", + "Fresh same-target HA administrator proof is required for new completion; expiry, source loss, wrong target/channel, changed policy/member or terminal-review races grant nothing", + "Completion restores or creates only the exact human membership with an independent explicit administrator role; existing linkage/exceptions and configured mapping are preserved", + "Consumption, grant, receipt, audit and outbox commit atomically; injected late outbox failure rolls every stage back; concurrent same/different-key completions have one grant", + "Same-key completion receipt replay still requires current local administrator authority; cancellation is own-target, revision-checked and grants no privilege", + "Bounded HA outage permits own local request-status reads under the existing original session deadline, never writes", + "Console output failure after commit reports uncertainty and same-request retry, without claiming no effect or exposing provider/error text", + "Native console attachments share a maintenance guard with one runtime and cannot initialize/migrate; startup requires exclusive maintenance. Explicit-file console validates private existing data and refuses owned-directory bypass", + "Frozen v1/v2 inputs migrate atomically to schema 3 while retaining identity, household, receipts, audit and outbox; failed migration leaves prior version/data intact", + "Published standalone/native OpenAPI paths/components match their actual runtime and include exactly the four human recovery routes, excluding approval" + ], + "architecture": { + "files": 86, + "dependencies": 368, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 53 backend source files", + "negativeFixtures": 41 + }, + "tooling": { + "tests": 66, + "statements": 73, + "branches": 34, + "coveragePercent": 100, + "protectedCopilotResourcesUnchanged": 29 + }, + "schema": "Business schema 3; additive recovery table/index; initialization metadata remains version 1. Recovery records are not credentials. HA tokens, cookies and local leases remain outside business storage.", + "installation": "Edge 0.0.0-edge.1 / immutable runtime 98bd12f1bf141ba64053730b2686bd848aee1b1c remains the installed stateless preview. No recovery, member or other household records were created on the owner HA.", + "providers": "Fictitious HA provider facts with real ASGI, SQLite, process/terminal and lifecycle primitives; not live owner recovery acceptance", + "remaining": [ + "Consistent snapshot/restore and verified activation", + "Private app data provisioning and updated immutable native package", + "Complete standalone companion authority and browser acceptance", + "Product UI and both deployment/channel acceptance; full product domains remain required" + ], + "priorHeadCI": { + "head": "ddc90faa5964509ed154090f8c8070f743e02370", + "url": "https://github.com/vypdev/domus/actions/runs/41197009648", + "result": "PASS: six required project groups; not evidence for this later source" + }, + "copilot": "Unchanged. Prior scope-phase failures tracked in vypdev/copilot#828; distinct assessment admission failure in #829. Full current-head Bugbot and complete coverage remain required before merge.", + "sourceSha256": { + "backend/pyproject.toml": "7366b90eb147b5d4a41e88f074d4809db907c6c22bb4ad4ec5c92f0eecd8f138", + "backend/src/domus/domain/recovery.py": "e12c80ebc185392ab2a724ba8795e7dc19bd17f67e79b3c869d281aa2ec544e7", + "backend/src/domus/application/ports.py": "4d7ba6b0850764ad9b9ab8fcc5b095faa1dd715d5a70e9639642fb53d764fdd6", + "backend/src/domus/application/recovery_commands.py": "83bf68d1449629d1fe18f7a00a8370b296862999dc0a8f82188e0948f12cfe66", + "backend/src/domus/application/household_queries.py": "20ababafc0b848c179e5334ec8cd2654de5e45e401f9368674cf52fbafe7bd84", + "backend/src/domus/composition/admin.py": "e62ec55bea3bbb08f5a146451bbe641d84b50adb322e1accae1ace437c7d83ce", + "backend/src/domus/composition/household.py": "835e256f8b5b8e60adeef0d5292cb115ce05e085bbd27d0a3a0bdea46fe4ede8", + "backend/src/domus/composition/server.py": "402866058a7bd36a0a7f27afe52a9256670f68bc0dac8888822c9ac17f50bb67", + "backend/src/domus/composition/ingress.py": "7361f12e7d9510e26188bebc2ca995b175e89bf68a01c288f818206953722251", + "backend/src/domus/entrypoints/console_recovery.py": "854a3f8d7a2e74f301ac5bd4b54682e409e2669a3bb7e05546e479c113c09026", + "backend/src/domus/entrypoints/household_http.py": "562a9f976244c29c070664367363c59d190f458d6485e892a7a8bf3ff4f0e6e2", + "backend/src/domus/entrypoints/http_errors.py": "10e85816bdf1256abd9408ea7fb3488555906b93fa1449e037ab50b6d81339df", + "backend/src/domus/infrastructure/owned_storage.py": "5bfdce4ebf5b334f45322039149b708d7652a095ca51979535b3adf036a2e2ee", + "backend/src/domus/infrastructure/sqlite.py": "aade37646492577f4e7baa51aed9d0b39fd625858f5ab68fe0f45305f7d9ab13", + "backend/src/domus/infrastructure/sqlite_recovery.py": "7ae5351ce3549ddaf112b3da293dc0fc4dbe8893631600e7698cff037e319767", + "tests/backend/test_recovery_values.py": "ca2a09748ff46fda79384415fabd74d4260f31de6a5609924627fd4c51c8450f", + "tests/backend/test_sqlite_recovery.py": "aabade1aefdbb45884d1014f81311c2f4bd8b5c25a3e188a12cea37e0411a1a6", + "tests/backend/test_recovery_commands.py": "273a4e4dbd027a7fc85bf5e31de863c467315c0b5bc0f2694e17f88c7b9cc38b", + "tests/backend/test_recovery_console.py": "1552e1b92b1d7cde958092251650de45e6042876818e6777c8ba9650855a90f7", + "tests/backend/test_recovery_http.py": "1c07bed4b90ec78d56aa8eb239c5c520e1aa9ccf203a383d807c31a237b10237", + "tests/backend/test_household_http.py": "af32c2eeff649abe1c3bab8cf1686bc3c22d0abb26da04dca81945895d635804", + "tests/backend/test_owned_storage.py": "24b288e0ed53aaee3cbd3b6b7e6a473084670217cd49a095d93023173d8d302d", + "tests/backend/test_sqlite_identity.py": "8d49f20ef689d7293ef7d8cb8bac81cdedc3b9b411a7bef518475c725c3bfe13", + "tests/fixtures/sqlite/v2.sql": "b213b97d4f0eba8018b5c2a8536ad8b821e6a79b977538d497f4985039d1f11a", + "docs/contracts/household-api.openapi.json": "d9ef083b6c046bbddabe68836be54a77ceea700965e0d976ccac36db9696a617", + "docs/contracts/native-household-api.openapi.json": "ac19b68a97f66030f83acba69e64eaaad98056af69f6b48a00467d5ac3362bc4" + } +} diff --git a/docs/evidence/m1-app-package.json b/docs/evidence/m1-app-package.json new file mode 100644 index 0000000..e99953c --- /dev/null +++ b/docs/evidence/m1-app-package.json @@ -0,0 +1,67 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "98bd12f1bf141ba64053730b2686bd848aee1b1c", + "runtimeSource": "98bd12f1bf141ba64053730b2686bd848aee1b1c", + "runtimeMatchesCurrentBackend": true, + "sourceArchiveSha256": "4531e8b013e288bcc717c72452c57018a1158f4aa6054f277b19b05d6baaa63f", + "appVersion": "0.0.0-edge.1", + "channel": "edge", + "scope": "Stateless native authentication preview; no household setup or production release", + "localContainer": { + "architecture": "arm64", + "imageId": "sha256:ab1979eb1e00cf9aaf40dae3acb448bf87199fe0fff095429cdfcd6477fade62", + "tests": "PASS: 3 actual image/listener/lifecycle cases", + "network": "none; fictional token only", + "cleanup": "Owned test containers removed; reviewed image retained" + }, + "localTests": { + "backend": 415, + "tooling": 66, + "architectureFixtures": 39, + "nodePolicy": 11, + "nativeContainer": 3, + "totalExecuted": 534 + }, + "backendCoverage": { + "covered_lines": 1525, + "num_statements": 1525, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 89, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 402, + "num_partial_branches": 0, + "covered_branches": 402, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "ciContract": "Every PR head runs all six required jobs; containers matrix builds/tests on native amd64 and aarch64; aggregate fails closed", + "ciNativeArchitectures": "PENDING: exact pushed package head", + "liveHAInstallation": "PENDING: not installed; no native Ingress compatibility claim", + "stableProduction": "PENDING: no stable package or release published", + "permissions": { + "coreApi": true, + "supervisorManagementApi": false, + "hostPorts": {}, + "hostMounts": [], + "privilegedCapabilities": [], + "runtimeUidGid": "65532:65532", + "humanIdentity": "Fresh exact user; no technical-account promotion or second sign-in" + }, + "sourceSha256": { + "repository.yaml": "a3667e020e7ac2bf404b906fdaba221b47fd6d42160b73616067c10d3ed817f6", + "addon-edge/config.yaml": "687efe603dc18a669b38a187c7af4b5ae0f4a4c9e6188441c6462198ccf6e1d1", + "addon-edge/Dockerfile": "dfe8b2515b171ce1fcf8b6f16278e92bb46323611e6553f1d21f0e42d78f8981", + "addon-edge/README.md": "38ba9af604de7c964799452ca7841ec3765e5081150b59c994d461feca8cc8c9", + "addon-edge/DOCS.md": "d953dbddee2e4260aa72247f230b8d8509b696a9985e0a696dc16af35bfd2de3", + "addon-edge/CHANGELOG.md": "751bae503ccef45b538a4bc26153b382d8f711be846237ae1b03d99141493ca9", + "tests/backend/test_app_package.py": "a85c51ab7a1d06fff6d9253a43998cb56ecdf040047bce28ab11192a51fd051b", + "tests/containers/test_app_image.py": "7cdad0f0e22cc0df2b957e78027682ffcd0e7928a0570b63d5a97f1ed6b9c865", + ".github/workflows/ci_check.yml": "f86ebbc6e9167c01776f3fe27706431a690370a61fe4141cc5f2f90abda3b37c", + "tooling/domus_tooling/ci_policy.py": "3ff09d02aff0aa0403a79d22b9ad7f690a55cb7ecff0375dd780eb8c158fa9e8", + "tests/python/test_ci_contract.py": "194a06fd741a7fd12a4d0119d9d44c9b4f39886e6e5444b8bf87b5ab4ac613fa" + } +} diff --git a/docs/evidence/m1-edge3-ha-acceptance.json b/docs/evidence/m1-edge3-ha-acceptance.json new file mode 100644 index 0000000..c788157 --- /dev/null +++ b/docs/evidence/m1-edge3-ha-acceptance.json @@ -0,0 +1,69 @@ +{ + "observedOn": "2026-10-31", + "completedAtUtc": "2026-10-31T17:16:59Z", + "sourceBase": "0484ba7cf3786fffac9c678032790e0690710fcb", + "packageVersion": "0.0.0-edge.3", + "runtimeSourcePin": "faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5", + "runtimeArchiveSha256": "15bcd701696204b3362cebe5901feb3d0072c9bac1db2ef2cbc5007e9ed73717", + "target": { + "origin": "http://homeassistant.local:8123", + "appId": "62fdc661_domus_edge", + "entry": "http://homeassistant.local:8123/app/62fdc661_domus_edge", + "haPlatformVersionReference": "Earlier m1-native-ha-acceptance.json About observation; platform versions were not reread during this bounded app update" + }, + "observations": [ + "Before the update, the existing Domus app was running at Edge.2; its four operational switches were off", + "Refresh of the existing development repository offered Edge.3 and displayed the expected English release notes; no new repository or permission was added", + "The retain-previous-version backup option was explicitly enabled before confirming update; the resulting backup artifact was not inspected", + "Supervisor completed update and reported installed/latest version Edge.3 and the app running", + "The sole persisted public origin remained http://homeassistant.local:8123; no configuration save was needed", + "Open Web UI followed by a real Chrome toolbar reload showed Connected to Home Assistant and No additional login is required using the existing HA session", + "Domus-only restart completed; another real Chrome toolbar reload showed the same connected page through existing HA authentication", + "Start-on-boot, watchdog, automatic-update and sidebar switches remained off; no host port, mount, API role or protection-mode change was requested" + ], + "browserEvidence": { + "method": "Google Chrome visible UI, accessibility and screenshot observations in the task", + "caveat": "Some automation snapshots cached previous HA SPA state. Actual Chrome tab selection, removal of the debugging banner and fresh native-toolbar reload established current rendered version and connection state; cached observations were not used as acceptance.", + "publicEntryOnly": true, + "screenshotsExportedToRepository": false + }, + "dataImpact": "Updated/restarted only the existing Domus development app through Supervisor and requested its normal previous-version backup. The packaged startup lifecycle handles its additive schema4 upgrade. No household/member, recovery approval/grant, points, automation, authored HA entity/device/user change or Domus manual snapshot/restore was created on the owner installation.", + "runtimeDataInspection": { + "expectedBusinessSchemaFromReviewedPin": 4, + "directlyQueriedOwnerSQLite": false, + "ownerUUIDHistoryOrCapabilityBytesExtracted": false + }, + "separateImageEvidence": { + "head": "0484ba7cf3786fffac9c678032790e0690710fcb", + "run": "https://github.com/vypdev/domus/actions/runs/41222154726", + "amd64Job": 124598791305, + "aarch64Job": 124598791230, + "casesPerArchitecture": 15, + "result": "PASS on both native CPU families; current default Edge.3 and immutable previous Edge.2 are built and exercised", + "backendTests": 1299, + "statements": 4684, + "branches": 1302, + "coveragePercent": 100, + "localImage": "sha256:9ff296232909a4042c7b33c1787c5674f2f0017ff23a08eca43c30e968f50d15", + "python": "3.14.6", + "sqlite": "3.46.1" + }, + "limits": [ + "Bounded native app update/configuration/session/reload/restart acceptance, not complete M1, household UI or product acceptance", + "Owner filesystem identity/history and process capabilities were not extracted; those assertions come from separately identified real-image fixtures", + "No live owner restore, household command, outage/revocation or console recovery was exercised", + "Selecting a previous-version backup does not establish that its artifact is restorable; no owner backup restore was attempted", + "Complete standalone companion/browser, Stable/Edge distribution and all remaining functional domains/release gates remain pending", + "Merge still requires complete current-head Bugbot review with no unresolved findings and full supported coverage; skipped/neutral review is insufficient" + ], + "privacy": "This receipt retains only the public app entry and minimal version/status evidence. Opaque Ingress routes, session cookies, Supervisor tokens, human HA IDs and credentials are omitted. No owner screenshot was uploaded to GitHub.", + "sourceSha256": { + "addon-edge/Dockerfile": "0b7c878e9af9bfa36e72fd9de3fe97c115e088cbe55a9680106701352d4afac8", + "addon-edge/config.yaml": "b20cbe1daa7e163b668ec100ee1b19864414767401761a7c8dbfd42d312e2e10", + "backend/src/domus/composition/ha_app.py": "e0ffe36478881ff11367c7ba5467438ebef54af7d4bfb00bf14350893b15e1b3", + "backend/src/domus/composition/ingress.py": "7447608e616cd5a70652b1c02c5ddab81ea42fc8c6228cb1a0eecd60e2d029a9", + "backend/src/domus/entrypoints/native_http.py": "59dce651bbab80a36a0bec435c581b247ad9b8aac6b53406bd577cbd0a7b15c7", + "backend/src/domus/infrastructure/owned_storage.py": "c994557ba584bc0292c8fef432afe8725b532a5976c91faa39ebdbe70b4c4192", + "backend/src/domus/infrastructure/sqlite_activation.py": "6191814bfcb73ac35613f6592fd27f74cbf88baf81394091caecbdae3e29ab8a" + } +} diff --git a/docs/evidence/m1-explicit-storage-boundary.json b/docs/evidence/m1-explicit-storage-boundary.json new file mode 100644 index 0000000..d8c5a94 --- /dev/null +++ b/docs/evidence/m1-explicit-storage-boundary.json @@ -0,0 +1,52 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "f9220511448d87bf8766ead2d677b5cd88c8e91b", + "scope": "Prevent native and standalone explicit-file runtimes from bypassing the managed-directory lifecycle", + "backendTests": 897, + "backendCoverage": { + "covered_lines": 3325, + "num_statements": 3325, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 122, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 860, + "num_partial_branches": 0, + "covered_branches": 860, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "testFirst": "The four active/closed owned-directory factory cases first failed because both factories accepted the managed database; the guard test first failed because its helper did not exist.", + "assertions": [ + "Native and standalone explicit-file runtime factories reject owned parent metadata before SQLite initialization, for both active and stopped managed installations", + "Rejection preserves database bytes and leaves the original runtime lease effective", + "Broken marker symlinks still identify managed storage; missing parent errors are sanitized", + "The console attachment and runtime factories share one marker refusal rule" + ], + "architecture": { + "files": 89, + "dependencies": 401, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 56 backend source files" + }, + "limits": [ + "Trusted local operator paths remain a boundary; this does not protect against an owner replacing metadata or paths", + "The installed stateless Edge preview and its package are unchanged", + "Standalone managed-directory startup and restore activation are not implemented by this correction" + ], + "priorHeadCI": { + "head": "f9220511448d87bf8766ead2d677b5cd88c8e91b", + "url": "https://github.com/vypdev/domus/actions/runs/41202996001", + "result": "PASS: all six required project groups; not acceptance of later changes" + }, + "sourceSha256": { + "backend/src/domus/composition/ingress.py": "7447608e616cd5a70652b1c02c5ddab81ea42fc8c6228cb1a0eecd60e2d029a9", + "backend/src/domus/composition/server.py": "58c83542654a5cd1360297557c513285277d1093cf5f3b6de27e79cc79ac35cb", + "backend/src/domus/infrastructure/owned_storage.py": "8f19e7c60d28edd20ca335d50198e59204f5be27ea1d32b8a048494264326266", + "tests/backend/test_owned_storage.py": "907aa96886a1eb9f7fa40b3e1bd97796c2798592343420deddf840129a4cdd80" + } +} diff --git a/docs/evidence/m1-generation-selection.json b/docs/evidence/m1-generation-selection.json new file mode 100644 index 0000000..4ef84a1 --- /dev/null +++ b/docs/evidence/m1-generation-selection.json @@ -0,0 +1,108 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "5ac4da7658647bc2ea93a0e85974565c8e379ee9", + "scope": "Path-free generation selection, strict legacy/selected managed startup, root-lease reread, direct-slot/partial-restore bypass refusal and identity-before-copy; verified publication/activation remains pending", + "backendTests": 1184, + "newExecutedCases": 44, + "backendCoverage": { + "covered_lines": 4031, + "num_statements": 4031, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 127, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 1090, + "num_partial_branches": 0, + "covered_branches": 1090, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "command": "COVERAGE_FILE=/private/tmp/domus-generations-backend.coverage .venv/bin/pytest tests/backend -q -W error::ResourceWarning --tb=short --cov=domus --cov-branch --cov-report=json:/private/tmp/domus-generations-coverage.json --cov-report=term:skip-covered", + "assertions": [ + "GenerationSelection is immutable, exact/versioned, bound to installation/channel and contains only a validated ID rather than a path", + "Legacy flat format 1 remains unchanged; format-2 root selection chooses only generations//domus.sqlite3 and never falls back to retained flat data", + "Private regular single-link files, exact selected-slot inventory, format-1 ready identity and matching generation marker validate before SQLite initialization; missing/empty/foreign/pending/corrupt/nested/unsafe selections do not rewrite source or identity", + "Root runtime/maintenance leases still cover the whole installation; console attaches to the same selected database without a second runtime", + "Selection is reread after acquiring leases; a real metadata change before lease acquisition cannot leave startup using a cached earlier generation", + "Both actual composed APIs retain the selected household and original receipt across shutdown/restart while retained flat bytes remain unchanged", + "Real snapshot create/independent inspect and blocked restore prepare/inspect work in private root archives outside the selected source slot", + "Symlinked generation container, slot and archives directories are refused without following them; missing retained flat data is not recreated", + "Direct managed startup, console attachment and explicit file helpers refuse a slot even if its normal identity is missing", + "Restore preparation persists blocked identity before any database bytes; an injected copy interruption verifies that managed and both actual explicit runtime factories refuse startup without adding locks or files", + "A restore manifest or operations journal alone blocks direct explicit paths even if installation identity is missing" + ], + "testFirst": "Generation tests first failed import before the model existed. The lease-reread regression initially selected generation-1 after metadata had advanced to generation-2; it passed after rereading under the leases.", + "formats": { + "legacyRoot": 1, + "selectedRoot": 2, + "slotIdentity": 1, + "slotGenerationMarker": 2, + "businessSchema": 4, + "automaticLegacyConversion": false, + "activationCommand": false, + "newPermissions": false + }, + "architecture": { + "files": 97, + "dependencies": 465, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 62 backend source files", + "negativeFixtures": 41 + }, + "tooling": { + "tests": 66, + "statements": 73, + "branches": 34, + "coveragePercent": 100, + "protectedCopilotResourcesUnchanged": 29, + "ruff": "PASS", + "localDocumentation": "PASS" + }, + "runtime": { + "python": "3.14.6", + "sqlite": "3.53.3", + "scope": "Real local files and synthetic prepared/generation layouts, both composed APIs; not a production restore or owner HA acceptance" + }, + "installedRuntime": "Owner Edge.2 / d013cf2c8b932d0e00f5858053cbc0e663d5eee1 / schema 3 unchanged. No live restore, household, recovery grant, device or user changes.", + "priorHeadCI": { + "head": "5ac4da7658647bc2ea93a0e85974565c8e379ee9", + "url": "https://github.com/vypdev/domus/actions/runs/41213120152", + "result": "PASS: all project groups; image tests validate the pinned existing package, not unpackaged generation/history sources" + }, + "remaining": [ + "Exclusive offline guard and unchanged source/generation binding", + "Verified candidate migration/history import and durable pointer publication", + "Transition inspection, output uncertainty and interruption recovery with rollback data retained", + "Reviewed restore console, actual-image and both deployment/owner acceptance", + "Product UI, standalone companion and complete functional domains/channels/releases" + ], + "copilot": "All installed resources untouched. Current-head full no-findings Bugbot review and full supported coverage remain required; a draft/skipped review is not acceptance.", + "sourceSha256": { + "backend/src/domus/domain/generations.py": "bd8d665c7b6e832f881784c07475d2b8911c08d0894cf8e125681a9f27e1ba97", + "backend/src/domus/infrastructure/owned_storage.py": "da3afd4f52ee55ff258fa44e180964beddbe5c9b6a5b216f349f93f55bffa069", + "backend/src/domus/infrastructure/sqlite_restoration.py": "8637c6bba89a720aa2ccaac44a6cc0292cd66b36f578307732f3f4139c5f9f39", + "tests/backend/test_generation_values.py": "6e7f9a8853e0297e02ba5a94e7871ace8628291e759423edbfbb34a34a869054", + "tests/backend/test_owned_generations.py": "5d46b3bb21534c9794ad0f2fbae10757b8ab5a6826f8bba8971b65c3be49248a", + "tests/backend/test_sqlite_restoration.py": "b29ee50f1bebf869a11ac36babe663b93bcaa599a2fddb43fe2dcda380825a30" + }, + "sddValidation": { + "restoration": { + "format": "PASS", + "consistency": "PASS", + "canonicalSections": 20, + "characters": 27917 + }, + "primaryCompanion": { + "format": "PASS", + "consistency": "PASS", + "characters": 69863, + "scope": "Unchanged original requirement/case inventory" + }, + "catalog": "PASS: 11 capabilities; generated rendering current", + "validator": "Byte-verified unchanged upstream policies" + } +} diff --git a/docs/evidence/m1-ha-app-bootstrap.json b/docs/evidence/m1-ha-app-bootstrap.json new file mode 100644 index 0000000..68849d7 --- /dev/null +++ b/docs/evidence/m1-ha-app-bootstrap.json @@ -0,0 +1,60 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "78766a05e96a34ce4fe54df54af27aac688a1aa9", + "scope": "Source preparation for a fixed-path HA app launcher; not yet the installed or candidate image entrypoint", + "backendTests": 953, + "backendCoverage": { + "covered_lines": 3434, + "num_statements": 3434, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 122, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 888, + "num_partial_branches": 0, + "covered_branches": 888, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "testFirst": "Launcher tests first failed import because the composition module did not exist.", + "assertions": [ + "Bounded strict options and validated transport precede any private child provisioning", + "Only fixed /data/domus is provisioned; existing data receives no recursive ownership/permission repair", + "Private child ownership/group/mode, trusted root parent/options and no-follow single-link regular options are checked", + "Configuration rejects malformed/duplicate/unknown fields, invalid origins, oversized payloads, unsafe files, symlinks, FIFOs and competing ownership without launching", + "Privilege-drop orchestration clears supplementary groups, sets GID then UID and verifies every real/effective ID before fixed exec", + "The child environment excludes Python injection/proxy/unrelated secrets and passes only fixed runtime values plus private Supervisor transport", + "Ownership/drop/exec failures never invoke a root server fallback and emit sanitized guidance" + ], + "architecture": { + "files": 90, + "dependencies": 411, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 57 backend source files" + }, + "limits": [ + "Local unit tests perform real filesystem operations under the invoking UID; privilege syscalls and exec are controlled doubles", + "Actual Linux kernel UID/capability behavior, immutable package pin/build and native amd64/aarch64 acceptance are the next required steps", + "Installed Edge 0.0.0-edge.1 and its permissions remain unchanged; no owner HA data or settings were changed", + "An interrupted new-directory ownership step fails closed and may require explicit operator investigation; existing data is never repaired or adopted" + ], + "priorHeadCI": { + "head": "78766a05e96a34ce4fe54df54af27aac688a1aa9", + "url": "https://github.com/vypdev/domus/actions/runs/41203889095", + "result": "PASS: all project groups; image cases cover the old immutable preview" + }, + "primaryReferences": [ + "https://contributors.home-assistant.io/apps/guides/options/", + "https://contributors.home-assistant.io/apps/reference/configuration/", + "https://contributors.home-assistant.io/apps/guides/security/" + ], + "sourceSha256": { + "backend/src/domus/composition/ha_app.py": "e0ffe36478881ff11367c7ba5467438ebef54af7d4bfb00bf14350893b15e1b3", + "backend/pyproject.toml": "f8411bc202e8573063500f8e7ef51da37ae2f84b8000a0babd0d1e6d2345b8db", + "tests/backend/test_ha_app_launcher.py": "1e12080d7c4aa4e90e65d3184f9a83c8b95ea5520d542fefea24c705f40aa7f5" + } +} diff --git a/docs/evidence/m1-ha-protocol.json b/docs/evidence/m1-ha-protocol.json new file mode 100644 index 0000000..e5ac7cc --- /dev/null +++ b/docs/evidence/m1-ha-protocol.json @@ -0,0 +1,132 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "53cbd4d116718069e04a710e339a333338407ab5", + "issue": "https://github.com/vypdev/domus/issues/6", + "pullRequest": "https://github.com/vypdev/domus/pull/7", + "scope": "Working-tree HA protocol adapter checkpoint identified by source hashes; offline fixtures only", + "tests": { + "backend": 289, + "tooling": 65, + "architectureFixtures": 10, + "nodePolicy": 11, + "totalExecuted": 375 + }, + "backendCoverage": { + "covered_lines": 899, + "num_statements": 899, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 72, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 264, + "num_partial_branches": 0, + "covered_branches": 264, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "implementation": "Fixed operator endpoints; bounded nonredirecting OAuth transport; correlated WebSocket current-user/directory proof; strict actual-peer Supervisor subject boundary", + "acceptanceLimits": "HTTP provider fixtures and disposable loopback WebSocket servers use fictional users/tokens. No owner HA login, credential collection, device/configuration modification, installed package or live authentication is claimed. This is partial evidence for the 61-case companion.", + "coverageDeclarationNote": "No custom coverage omissions/exclusions; default non-executable Protocol/ellipsis declarations remain separately reported. All supported executable statements and branches are covered.", + "dependencies": { + "anyio": "4.15.1", + "certifi": "2026.10.23", + "h11": "0.16.0", + "httpcore": "1.0.9", + "httpx": "0.28.1", + "idna": "3.20", + "websockets": "17.4" + }, + "existingDependencyVersionsUnchanged": true, + "reviewedCoreVersion": "2026.10.4", + "primarySourceBlobs": { + "homeassistant/components/auth/__init__.py": "a314a64adf0cde1a11ae56fdbba7846b644a99ad", + "homeassistant/auth/__init__.py": "0314c4b749e7722cbc97ae9e6b7a46d3a151f576", + "homeassistant/components/http/auth_util.py": "4eed144347b036d85dc0a74b61b9769c6121e3e6", + "homeassistant/components/config/auth.py": "75b0c88b916dbc8970afbf4041e1e463690029a8", + "homeassistant/auth/models.py": "7342e9d4b8e9aee392cf112f59f1b718db3feb83" + }, + "checks": { + "backend": "PASS: 289 tests, 899 statements and 264 branches at 100%", + "fullLocalCampaign": "PASS: 375 tests across four independently identified suites", + "currentHeadGitHub": "PENDING: commit/push follows local verification", + "currentHeadBugbot": "PENDING: incomplete draft; neutral/skipped review is insufficient", + "ruff": "PASS", + "mypy": "PASS: 26 source files", + "actualApplicationImportGraph": "PASS: 42 files, 90 dependencies, 3 contracts", + "actualToolingImportGraph": "PASS: 2 contracts", + "architectureFixtures": "PASS: 10 cases", + "nodeTypecheckAndPolicyCoverage": "PASS: 11 tests and one policy configuration statement; not UI coverage", + "localLinks": "PASS", + "gitDiffCheck": "PASS", + "installedCopilotResources": "PASS: all 29 SHA-256 fixtures unchanged", + "owningCompanionFormatConsistencyAndCanonicalHeadings": "PASS against immutable implementation parent 0012a172492515e282be09c37921f4014a451e64", + "catalog": "PASS with unchanged Copilot v3 validator 3e7ceea08e6131360e026937d1c0cc1b120472e0" + }, + "pending": [ + "Browser-bound single-use OAuth state/PKCE attempts and session credential wiring", + "Strict HTTP/console entrypoints and private Supervisor listener/proxy configuration", + "Complete household/member commands and console-approved recovery", + "Consistent snapshot/restore", + "Exact source/artifact live HA and both package/channel acceptances", + "Complete current-head Bugbot review" + ], + "sourceSha256": { + "backend/src/domus/__init__.py": "7182c88b92234b4328f94497f4e70222d4390d8093e4b62893dfa78ac5530ac6", + "backend/src/domus/application/__init__.py": "1e8c0c87bf2a8ca947112543070583c0cc55d9d40b56eab0d86ed59a4ec1e82c", + "backend/src/domus/application/authorization.py": "4ccffade393fc2bac2192fee8909f28f290dae0c6fd3913c87433338101bc52a", + "backend/src/domus/application/commands.py": "9b3d25f161cc0a2946b268d37c325eae8e980aa9254325ccb9260af4da98a8c8", + "backend/src/domus/application/identity_ports.py": "2c23514226cf05f4a75923de5f31ecfc0cc55cc187279d94cc4281112fcb9e59", + "backend/src/domus/application/ports.py": "3ffa5afaa9c59e8c67d231f9b4521984916d18bfc5ab1fb12066d3242c90795e", + "backend/src/domus/application/sessions.py": "124a302ad6d9a1556ea35373e2d0ccd10a3990633603e44838a55e3d0369a954", + "backend/src/domus/composition/__init__.py": "a902d8faf7dd19da6829fb730d81e32d34ec38ee6cfafd676966a95d0ad31366", + "backend/src/domus/domain/__init__.py": "0aac8a2ba41471d7a174ef7baca6073c95009a9db96df3e032392aa4f80bd079", + "backend/src/domus/domain/errors.py": "0768709e9c0443eee15c9d0fdc61128d5db441824785f06e13bc1e481cabb6a4", + "backend/src/domus/domain/identity.py": "61ecb5024213921b02d6c4c6a2d338c7c79fb9e8129b417f11a9f40887b13122", + "backend/src/domus/domain/sessions.py": "d5828286ee7349d55e2f972a3197e73fea458ce4e125c08c9856ca974d05cfeb", + "backend/src/domus/domain/storage.py": "56e1800464ce380f6b7a6b3ded4d9123eb85639e28e3dc171e89b1865a42be14", + "backend/src/domus/entrypoints/__init__.py": "d46875cbfef7220c7244bf1b89112ece8122ecb0d66cd908f08f098f0f3c419f", + "backend/src/domus/infrastructure/__init__.py": "ec6cb2d48fb3178de856dcbeefb7b14bc193fe78ac68a53f9afcf2bd9cac0699", + "backend/src/domus/infrastructure/ha_config.py": "ef6029bba313eda6191a18751cf283ef96faf241b9d4cb2dc781f0f2d5be9db5", + "backend/src/domus/infrastructure/ha_http.py": "638cc3d918689d1392adb57332c2136160e9745a29b7c84d8fe99363fb358673", + "backend/src/domus/infrastructure/ha_ingress.py": "081038945db0fea51b0fa45889c2a2ac9a1aa45b67ae0898be20fb1f7861b609", + "backend/src/domus/infrastructure/ha_websocket.py": "3286011f6a25f557f119ad277f4ba9dea850ae7cac189e6867032cec06b16334", + "backend/src/domus/infrastructure/memory_sessions.py": "1ef6b1b8eae384058e70b5a2fdaf702617b04c55669697eba8dc758007380816", + "backend/src/domus/infrastructure/sqlite.py": "45e9669c61dc2d8cfc89f3f02b296fb17b5d32ba4486bdce0d14457de2d89da7", + "backend/src/domus/infrastructure/sqlite_identity.py": "091994c671189707e5299ef093887d68307ca4e829e53a629569b593527bc082", + "tests/backend/test_atomic_commands.py": "a17ed9172736e0f7fb0dfbb8d9186fb45ba9a922fa3c26648bff7266ce7a4042", + "tests/backend/test_ha_http.py": "2525675d56c2314c30984d502f2e6578b076947b33589201749e5c691c7405d2", + "tests/backend/test_ha_ingress.py": "f3828d00a9e8466bc7ff0ea814ea9975a263af8463360c0bc83090657d13b0aa", + "tests/backend/test_ha_websocket.py": "99eccc06b1fa048a47838e30e20f41dbda18b09c48ca6b508a1244aaf03151f2", + "tests/backend/test_identity_policy.py": "67be51abb19f40a227c05c491aba48cf4ee411cb0095f09c3cfaafa65992c566", + "tests/backend/test_membership_authorization.py": "72d4323b4c7adf94deda6fc43f737b0dfdd369c8e9331c4af2403c6f7f37df43", + "tests/backend/test_session_leases.py": "6047cbd569aac6baf24b7f7d692f7f895a7ad8dfba8bfd1b2293cb15a3e814fa", + "tests/backend/test_sqlite_identity.py": "8b1e6ab2693ec4023a4b0b52df213204e763d27b4c89ca8346d6f063b5e3a59a", + "tests/backend/test_sqlite_transactions.py": "af6c25f8f093d564c500aaaffbdc5bccb5a1d30172788ad7d02ab877c44cba78", + "tests/backend/test_storage_values.py": "d72420955965e48be0c089339273a6a162a00bb66f9997376871304713fdd668", + "backend/pyproject.toml": "8e1d01bf253c2d3041084330b531b5b9335d6d69eb48ff48136a314a9e7f431b", + "pyproject.toml": "1260684a3a713b533700f2d2076868560cf07465d360833b2e2935128f4131d5", + "uv.lock": "3b93f72c9279ac9771493076407da889f713d1251012e78e6fd11600ed3a5108", + ".github/workflows/ci_check.yml": "d68cfaaae3cbf1ef67bfffdfc37c5f1b4f60c3bd887a8d5b4030e758e3e935ea", + "architecture/python-application.ini": "191fcdfb38bbe233172e04057763f0d7e1587549e473ad8db6a4cadcb00ddd01", + "tests/fixtures/sqlite/v1.sql": "fc9a90d8294d844ac534ae97ca1150a273f12a60a879910dcd095e65a36e3f67" + }, + "toolingCoverage": { + "covered_lines": 73, + "num_statements": 73, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 0, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 34, + "num_partial_branches": 0, + "covered_branches": 34, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + } +} diff --git a/docs/evidence/m1-household-api.json b/docs/evidence/m1-household-api.json new file mode 100644 index 0000000..5187aaf --- /dev/null +++ b/docs/evidence/m1-household-api.json @@ -0,0 +1,83 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "16f28de5bcff87aba9aa0da4422042e2bb05b0cb", + "scope": "Strict household HTTP routes and local queries, production standalone session/SQLite composition and published OpenAPI; native installation unchanged", + "backendTests": 597, + "backendCoverage": { + "covered_lines": 2184, + "num_statements": 2184, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 105, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 552, + "num_partial_branches": 0, + "covered_branches": 552, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "command": ".venv/bin/pytest tests/backend -q -W error::ResourceWarning --cov=domus --cov-branch --cov-report=term-missing", + "testFirst": "Query, HTTP and session-adapter behavioral suites first failed at import because their modules did not exist; composed-runtime tests also verify the production wiring", + "assertions": [ + "Explicit setup/enrollment, household/member/policy edits and local access/roster/policy/operation reads exercise real ASGI sessions and SQLite", + "Standalone --database initializes only schema, persists state and receipts across restart and rejects cross-channel adoption without changing existing bytes", + "No request actor or household selector is accepted; current local authority precedes every disclosure and privileged receipt lookup", + "Pending/unlinked people see only own access state; ordinary roster omits other humans HA linkage/exceptions", + "Writes require exact Origin, session CSRF, one bounded idempotency key and expected revision", + "Duplicate/malformed/nonobject/nonfinite JSON, ambiguous media, forged fields and protected overrides fail before effects", + "Busy writer returns storage_busy/503 and Retry-After: 1; identical request succeeds after release with one atomic effect", + "Outbox status is observed by exact event ID; missing/corrupt atomic records are not reported as success", + "Original unexpired HA-outage lease permits local reads, never writes; expiry/logout/source mismatch invalidate access", + "Private directory resolution uses refreshed delegated material, rejects logout/expiry races and never exposes credentials", + "The published OpenAPI paths/components equal the actual composed server; old auth-only schema remains independently checked", + "An injected response failure after commit reports uncertainty; retrying the same payload/key recovers one original effect instead of duplicating it", + "Own effective role is separate from the explicit member profile, preserving current mapped administrator semantics" + ], + "architecture": { + "files": 74, + "dependencies": 265, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 43 backend source files", + "negativeFixtures": 39 + }, + "schema": "Schema 2 unchanged; exact outbox delivery lookup is a read of existing data", + "installedPreview": "Still Edge 0.0.0-edge.1 / runtime 98bd12f1bf141ba64053730b2686bd848aee1b1c; none of these HTTP services are installed in the owner HA", + "standaloneDirectoryLimitation": "Current directory lookup uses the actors delegated HA grant; ordinary HA accounts may be denied lookup of other humans. Complete first-release companion/installation authority remains required, without borrowing technical privilege as the human role.", + "liveStandalone": "No new owner HA OAuth session requested; earlier browser return remains unaccepted and its grant was revoked", + "remaining": [ + "Native persistent/session/CSRF wiring and setup UI", + "Console-approved recovery", + "Snapshot/restore", + "Complete standalone companion authority", + "Stable/Edge and both deployment-target acceptance" + ], + "priorHeadCI": { + "head": "16f28de5bcff87aba9aa0da4422042e2bb05b0cb", + "url": "https://github.com/vypdev/domus/actions/runs/41190401094", + "result": "PASS: six required project groups; not evidence for later code" + }, + "copilot": "Unchanged; assessment-text-limit on the preceding head reported separately in https://github.com/vypdev/copilot/issues/829; scope-phase failures remain in #828", + "sourceSha256": { + "backend/src/domus/application/household_queries.py": "538c7cb118e62b613ceba15190c818b62e388cd5c09d6f1dc09a08a707958c17", + "backend/src/domus/application/ports.py": "75bacb56266f0ddd959c35735b448d7143be6e037ac53bd82ae9ba4255e9066f", + "backend/src/domus/domain/errors.py": "cb225b0e35434526fbad3603843a061fe6894001fc13e019eab7e44642196cd4", + "backend/src/domus/entrypoints/household_http.py": "c9e89371b30f21abfcb020c95c05755263aefc2d8a4af38b685920e316a8224e", + "backend/src/domus/entrypoints/http_contract.py": "13ef85b4ce8f439f5da5ba88b97651501ac8d6b673b2fa09f24d5772a1f71cba", + "backend/src/domus/entrypoints/http.py": "3aaa471e92d998db4e95656e4f08338abd8fa924505c4939687ee50a35655215", + "backend/src/domus/entrypoints/ingress_http.py": "8bae77cabbd5794f608bb0af12a2a8d63462e88cb75410d0411ba2ca44409af4", + "backend/src/domus/composition/household.py": "1a31a2de029870c5efa3d8c8c7ac2368bbe6069068424e3c67b55b5a6fc93f53", + "backend/src/domus/composition/server.py": "57e575090ed3f2e1b33fde2204eedd5dd3e53b3f8375740b39262c4d8d1976b1", + "backend/src/domus/infrastructure/session_access.py": "4389717f7a2cc9992f9cd24961294c2e2ace9c5950f7cceddc721e592a5c7220", + "backend/src/domus/infrastructure/standalone_sessions.py": "144d691be8589c9f9ecd539717e6497cdb718c28033fd39992271ac674b32252", + "backend/src/domus/infrastructure/sqlite.py": "2a97ed3eb70cd3a216440f6bf8ec3ad749bdb764c4c0ebafb488a37fae0445db", + "tests/backend/test_household_queries.py": "77c0209abda26319ae0f1e1ade640c98f777772812ca4c20612398df8d91c348", + "tests/backend/test_household_http.py": "63eae3829a488aba02db1e7d93faca0f3747a28b10c481620eb03ab588b20b28", + "tests/backend/test_household_runtime.py": "0e8f3f40e4ccb5182775daa98c074a7915431add72a642b061162aca64a43f76", + "tests/backend/test_session_directory.py": "906110b82cb3a91594d02c15dad5c2eb850e45443e36a4bb9e13bb3744739423", + "docs/contracts/household-api.openapi.json": "0018cd9f58d8c02f4cbf18291724880b25858d26f0147e2e7478a125590a4f20" + } +} diff --git a/docs/evidence/m1-household-commands.json b/docs/evidence/m1-household-commands.json new file mode 100644 index 0000000..d6c4bec --- /dev/null +++ b/docs/evidence/m1-household-commands.json @@ -0,0 +1,60 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "650f1517f0e15664ea455628e63154dada0414f2", + "scope": "Application-layer explicit household setup, own enrollment and household edits; no public command routes or installed runtime change", + "backendTests": 482, + "backendCoverage": { + "covered_lines": 1701, + "num_statements": 1701, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 103, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 450, + "num_partial_branches": 0, + "covered_branches": 450, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "command": ".venv/bin/pytest tests/backend -q -W error::ResourceWarning --cov=domus --cov-branch --cov-report=term-missing", + "assertions": [ + "Real SQLite atomic home/policy/members/receipt/audit/outbox setup and all three administrator mappings", + "Fresh current actor; no first-arrival authority; selected ordinary human must be independently verified", + "Inherited administrator source is separate from an explicit profile grant", + "Own pending enrollment; only all-HA-admin mapping activates a current admin on explicit enrollment", + "Suspended and archived members cannot reenroll or replay; capacity preserves linked actor history", + "Authorized replay before external preparation and revision checks; current authorization still precedes receipt disclosure", + "No external target/timezone lookup while holding SQLite writer lock", + "Real-file concurrent setup has one complete winner; injected linkage conflict rolls back all records; clean retry works", + "Restart preserves results; changed idempotency payload and stale revisions conflict without effects", + "HA batch lookup distinguishes confirmed negative eligibility from malformed, duplicate, forbidden or unavailable source", + "Native directory permission denial maps to safe HTTP 403 instead of unexpected 500" + ], + "architecture": "Actual backend import graph: 66 files, 196 dependencies; all 3 contracts kept; mypy backend/src passes", + "installedPreview": "Unchanged 0.0.0-edge.1 runtime 98bd12f1bf141ba64053730b2686bd848aee1b1c; none of these new application commands are installed", + "remaining": [ + "Member activation and profile/policy administration", + "Last-administrator command safeguards", + "Public command/query API and setup UI", + "Recovery and snapshot/restore", + "Native persistent installation and read-grace sessions", + "Complete deployment/channel acceptance" + ], + "sourceSha256": { + "backend/src/domus/application/household_commands.py": "878d5138026593e47f7f444bd4095d0eb7c0edcc43ebedccc18e9ba41ad28673", + "backend/src/domus/application/commands.py": "f6c3d600be23aceb0a3e2a1e3dce8f67ed1e3d7ee4010e4868a75098091739d2", + "backend/src/domus/application/identity_ports.py": "45d04bd3abe05d81d6e24f55e645e52ff9ddea341bdf0ee648ea59e68d713c0d", + "backend/src/domus/application/ports.py": "61ece246e74cc846d4503bab3aa2de0eb04024e8b31a25b5d3bedd42b14aff48", + "backend/src/domus/infrastructure/application_runtime.py": "30ed2ddfd31dd7dbc6d65f8330555e1b13fa390fb45a9a9027ff732e4113bbe5", + "backend/src/domus/infrastructure/ha_directory.py": "4e81ad4b5c67b412f83a1436b693918310f6f26a9871b83f225eaeb9d65e1f05", + "backend/src/domus/infrastructure/ha_websocket.py": "39bf7cddc367086c7d9ce600858664ffb505d77faa6f7ce623f21a0d0f58eaaf", + "backend/src/domus/entrypoints/ingress_http.py": "a21ac3facf4e319c3bb1f90acee223b9ee4b2c128fbcbf503cc69df7b70a6c6f", + "tests/backend/test_household_commands.py": "7824162bb1b67fcf1e1dfed8bf9c80b4768ea72e7d5cad96797d8429ad4d43d1", + "tests/backend/test_application_runtime.py": "58cb8226a9e679aa2646b95954d089a4cb2d05ea1a3e83af85a23fcb075f8256", + "tests/backend/test_ha_directory.py": "844e5bc36cde54b266aab3e8ba57ef14fcac3ccfab1c948b782bc136dc773385", + "tests/backend/test_ingress_http.py": "414f88ccc2a1218b23db6e29f9f8efbd2d10c42988055abf0d6e04bf5a43c93b" + } +} diff --git a/docs/evidence/m1-identity-policy.json b/docs/evidence/m1-identity-policy.json new file mode 100644 index 0000000..154a6b5 --- /dev/null +++ b/docs/evidence/m1-identity-policy.json @@ -0,0 +1,114 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "95458b84b6521b203fab3a257137f24978a03e82", + "issue": "https://github.com/vypdev/domus/issues/6", + "pullRequest": "https://github.com/vypdev/domus/pull/7", + "scope": "Working-tree pure identity/local authorization, schema 2 and session-lease checkpoint identified by source hashes", + "tests": { + "backend": 190, + "tooling": 65, + "architectureFixtures": 10, + "nodePolicy": 11, + "totalExecuted": 276 + }, + "backendCoverage": { + "covered_lines": 632, + "num_statements": 632, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 72, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 180, + "num_partial_branches": 0, + "covered_branches": 180, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "coverageDeclarationNote": "No custom omissions or exclusions. Coverage.py default non-executable Protocol/ellipsis declarations are reported separately as excluded lines; all supported executable statements and branches are covered.", + "implemented": [ + "Pure eligible-human and administrator mapping policies; explicit grants remain distinct from inherited grants", + "Default profile matrix, member/profile/default precedence, hard scope/suspension/approval constraints", + "Current local authority before protected reads and command replay", + "Unique stable HA-member linkage and revisioned member/policy repositories", + "Explicit atomic additive schema 1-to-2 migration with frozen prior schema fixture and rollback evidence", + "Monotonic absolute session leases; latest-proof outage reads, fresh proof required for writes, rejection/expiry/logout race denial" + ], + "acceptanceLimits": "Partial evidence for the 61-case companion. Provider fixtures do not implement or prove live HA authentication. Administrator-retention policy is pure; complete command/race wiring remains pending.", + "pending": [ + "HA protocol proof/OAuth/Ingress adapters", + "Household setup/enrollment/member/policy use cases and API", + "Console-approved recovery", + "Consistent snapshot/restore", + "Both package/channel/live acceptances", + "Complete exact-head Bugbot review", + "Exact-head GitHub CI" + ], + "checks": { + "backend": "PASS: real-file migration/authorization and deterministic session/provider fixtures", + "tooling": "PASS: includes all 29 immutable installed Copilot resource hashes", + "ruff": "PASS", + "mypy": "PASS", + "actualApplicationImportGraph": "PASS: 32 files, 60 dependencies, 3 contracts", + "actualToolingImportGraph": "PASS: 2 contracts", + "architectureFixtures": "PASS: 10 cases", + "nodeTypecheckAndPolicyCoverage": "PASS: 11 tests, one configuration statement; not UI acceptance", + "localLinks": "PASS", + "gitDiffCheck": "PASS", + "owningCompanionFormatConsistencyAndCanonicalHeadings": "PASS: immutable implementation parent 0012a172492515e282be09c37921f4014a451e64", + "catalog": "PASS: unchanged current Copilot v3 validator 3e7ceea08e6131360e026937d1c0cc1b120472e0", + "currentHeadGitHub": "PENDING: local source hashes identify this checkpoint; commit/push follows local validation", + "currentHeadBugbot": "PENDING: draft remains incomplete; neutral/skipped review is not acceptance" + }, + "sourceSha256": { + "backend/src/domus/__init__.py": "7182c88b92234b4328f94497f4e70222d4390d8093e4b62893dfa78ac5530ac6", + "backend/src/domus/application/__init__.py": "1e8c0c87bf2a8ca947112543070583c0cc55d9d40b56eab0d86ed59a4ec1e82c", + "backend/src/domus/application/authorization.py": "4ccffade393fc2bac2192fee8909f28f290dae0c6fd3913c87433338101bc52a", + "backend/src/domus/application/commands.py": "9b3d25f161cc0a2946b268d37c325eae8e980aa9254325ccb9260af4da98a8c8", + "backend/src/domus/application/identity_ports.py": "2c23514226cf05f4a75923de5f31ecfc0cc55cc187279d94cc4281112fcb9e59", + "backend/src/domus/application/ports.py": "3ffa5afaa9c59e8c67d231f9b4521984916d18bfc5ab1fb12066d3242c90795e", + "backend/src/domus/application/sessions.py": "124a302ad6d9a1556ea35373e2d0ccd10a3990633603e44838a55e3d0369a954", + "backend/src/domus/composition/__init__.py": "a902d8faf7dd19da6829fb730d81e32d34ec38ee6cfafd676966a95d0ad31366", + "backend/src/domus/domain/__init__.py": "0aac8a2ba41471d7a174ef7baca6073c95009a9db96df3e032392aa4f80bd079", + "backend/src/domus/domain/errors.py": "0768709e9c0443eee15c9d0fdc61128d5db441824785f06e13bc1e481cabb6a4", + "backend/src/domus/domain/identity.py": "61ecb5024213921b02d6c4c6a2d338c7c79fb9e8129b417f11a9f40887b13122", + "backend/src/domus/domain/sessions.py": "d5828286ee7349d55e2f972a3197e73fea458ce4e125c08c9856ca974d05cfeb", + "backend/src/domus/domain/storage.py": "56e1800464ce380f6b7a6b3ded4d9123eb85639e28e3dc171e89b1865a42be14", + "backend/src/domus/entrypoints/__init__.py": "d46875cbfef7220c7244bf1b89112ece8122ecb0d66cd908f08f098f0f3c419f", + "backend/src/domus/infrastructure/__init__.py": "ec6cb2d48fb3178de856dcbeefb7b14bc193fe78ac68a53f9afcf2bd9cac0699", + "backend/src/domus/infrastructure/memory_sessions.py": "1ef6b1b8eae384058e70b5a2fdaf702617b04c55669697eba8dc758007380816", + "backend/src/domus/infrastructure/sqlite.py": "45e9669c61dc2d8cfc89f3f02b296fb17b5d32ba4486bdce0d14457de2d89da7", + "backend/src/domus/infrastructure/sqlite_identity.py": "091994c671189707e5299ef093887d68307ca4e829e53a629569b593527bc082", + "tests/backend/test_atomic_commands.py": "a17ed9172736e0f7fb0dfbb8d9186fb45ba9a922fa3c26648bff7266ce7a4042", + "tests/backend/test_identity_policy.py": "67be51abb19f40a227c05c491aba48cf4ee411cb0095f09c3cfaafa65992c566", + "tests/backend/test_membership_authorization.py": "72d4323b4c7adf94deda6fc43f737b0dfdd369c8e9331c4af2403c6f7f37df43", + "tests/backend/test_session_leases.py": "6047cbd569aac6baf24b7f7d692f7f895a7ad8dfba8bfd1b2293cb15a3e814fa", + "tests/backend/test_sqlite_identity.py": "8b1e6ab2693ec4023a4b0b52df213204e763d27b4c89ca8346d6f063b5e3a59a", + "tests/backend/test_sqlite_transactions.py": "af6c25f8f093d564c500aaaffbdc5bccb5a1d30172788ad7d02ab877c44cba78", + "tests/backend/test_storage_values.py": "d72420955965e48be0c089339273a6a162a00bb66f9997376871304713fdd668", + "tests/fixtures/sqlite/v1.sql": "fc9a90d8294d844ac534ae97ca1150a273f12a60a879910dcd095e65a36e3f67", + "backend/pyproject.toml": "e73ad4db318ce5511b1b0acc7b4fd63cb43fd2a097cbd19fedc439c7576dd3a6", + "pyproject.toml": "1260684a3a713b533700f2d2076868560cf07465d360833b2e2935128f4131d5", + "uv.lock": "1136b61bae45b7e38e118833316c068741d36deb2d827bb81d76517d7e73f948", + ".github/workflows/ci_check.yml": "d68cfaaae3cbf1ef67bfffdfc37c5f1b4f60c3bd887a8d5b4030e758e3e935ea", + "architecture/python-application.ini": "191fcdfb38bbe233172e04057763f0d7e1587549e473ad8db6a4cadcb00ddd01" + }, + "toolingCoverage": { + "covered_lines": 73, + "num_statements": 73, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 0, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 34, + "num_partial_branches": 0, + "covered_branches": 34, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + } +} diff --git a/docs/evidence/m1-ingress-preview.json b/docs/evidence/m1-ingress-preview.json new file mode 100644 index 0000000..58e0ed5 --- /dev/null +++ b/docs/evidence/m1-ingress-preview.json @@ -0,0 +1,129 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "780281e842142ec16c3c214cc7236582e59aa0e5", + "scope": "Native HA authentication preview identified by source hashes; package and live Ingress pending", + "tests": { + "backend": 412, + "tooling": 65, + "architectureFixtures": 39, + "nodePolicy": 11, + "totalExecuted": 527 + }, + "backendCoverage": { + "covered_lines": 1525, + "num_statements": 1525, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 89, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 402, + "num_partial_branches": 0, + "covered_branches": 402, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "checks": { + "backend": "PASS", + "ruff": "PASS", + "mypy": "PASS: 36 source files", + "actualImportGraph": "PASS: 62 files, 179 dependencies, 3 contracts", + "originalSdd": "PASS: 66,150 characters, original format/consistency and canonical headings", + "packaging": "PENDING", + "exactHeadCI": "PENDING: commit/push follows local verification" + }, + "nativePreview": { + "authentication": "Fresh exact-user Core proof through Supervisor; no OAuth or own browser cookie", + "boundaries": "Actual peer, header/base path validation, no proxy rewriting, bounded HTTP, no protected direct port", + "routes": [ + "GET /", + "GET /api/v1/identity", + "GET /health" + ], + "health": "Nonpersonal process readiness only; Supervisor/container loopback", + "pending": [ + "Business commands and database wiring", + "Local outage-grace sessions", + "Source-bound app package, installed Ingress and complete channel acceptance" + ] + }, + "standaloneOwnerHAObservation": { + "sourceHead": "780281e842142ec16c3c214cc7236582e59aa0e5", + "coreVersion": "2026.10.4", + "browser": "Google Chrome", + "result": "NOT ACCEPTED: browser session return failed; no successful end-to-end session claim", + "firstCallback": "session_required before code exchange when browser binding was absent; expiry suspected, not proven as root cause", + "laterServerObservation": "One HA token exchange returned HTTP 200 and one private credential session existed at shutdown", + "cleanup": { + "haRevokeStatus": 200, + "revoked": 1, + "unconfirmed": 0, + "serverExitCode": 0 + }, + "nextAction": "Owner selected native app installation; no more standalone sign-in requested", + "privacy": "No credentials, codes, state, cookies, HA user IDs or tokens included" + }, + "copilot": { + "unchangedInstalledResources": 29, + "upstreamScopeFailure": "https://github.com/vypdev/copilot/issues/828", + "fullCurrentHeadBugbot": "PENDING; draft remains incomplete" + }, + "sourceSha256": { + "backend/src/domus/__init__.py": "7182c88b92234b4328f94497f4e70222d4390d8093e4b62893dfa78ac5530ac6", + "backend/src/domus/application/__init__.py": "1e8c0c87bf2a8ca947112543070583c0cc55d9d40b56eab0d86ed59a4ec1e82c", + "backend/src/domus/application/authentication.py": "321f9e0ba1d97dc84aac3dd016652d92ec9ff4912a6092d6e8cefb9d61e29998", + "backend/src/domus/application/authentication_values.py": "e83491791e6d323e1fb12aaafb11fc39d2f89a12d10c9ae415c055a1e33aa314", + "backend/src/domus/application/authorization.py": "4ccffade393fc2bac2192fee8909f28f290dae0c6fd3913c87433338101bc52a", + "backend/src/domus/application/commands.py": "9b3d25f161cc0a2946b268d37c325eae8e980aa9254325ccb9260af4da98a8c8", + "backend/src/domus/application/identity_ports.py": "8221e819ba4e9f77908d9a8b9d675e5ebb9ffc772f00948dcde4604ff7ef5808", + "backend/src/domus/application/ports.py": "3ffa5afaa9c59e8c67d231f9b4521984916d18bfc5ab1fb12066d3242c90795e", + "backend/src/domus/application/session_values.py": "f4d7f824267757b2f3b8b6c8e6ea2715ec4a36838533de914e7feb2cdefa8fd2", + "backend/src/domus/application/sessions.py": "4e21d9b3bd917a18b8b996cc43757cd1a341a0a53d4ed2b3db77688f0fbf5a8c", + "backend/src/domus/composition/__init__.py": "a902d8faf7dd19da6829fb730d81e32d34ec38ee6cfafd676966a95d0ad31366", + "backend/src/domus/composition/ingress.py": "7c2af9fe6b1abfe5ce0c62310805d5e2205d60f420583186ee99b900c5399e5a", + "backend/src/domus/composition/server.py": "dd201bd8779ad8e768019910a036db414d5eca494a5f7e73beec5098377e6cfe", + "backend/src/domus/domain/__init__.py": "0aac8a2ba41471d7a174ef7baca6073c95009a9db96df3e032392aa4f80bd079", + "backend/src/domus/domain/errors.py": "0768709e9c0443eee15c9d0fdc61128d5db441824785f06e13bc1e481cabb6a4", + "backend/src/domus/domain/identity.py": "61ecb5024213921b02d6c4c6a2d338c7c79fb9e8129b417f11a9f40887b13122", + "backend/src/domus/domain/storage.py": "56e1800464ce380f6b7a6b3ded4d9123eb85639e28e3dc171e89b1865a42be14", + "backend/src/domus/entrypoints/__init__.py": "d46875cbfef7220c7244bf1b89112ece8122ecb0d66cd908f08f098f0f3c419f", + "backend/src/domus/entrypoints/bounded_http.py": "d8047746eddb7193f1aa9f16007958c79cc6ae7ac1c30ab93909883e3aa97b54", + "backend/src/domus/entrypoints/http.py": "ac7ee03e0c18931d59c6bfdbd96f723f940579bc20bbb0e2aea8e92e9a82393d", + "backend/src/domus/entrypoints/ingress_http.py": "f16255cb53b7244aa3ccbf6e462ea57e6d733659bcd59d9fa8783105569cacc1", + "backend/src/domus/infrastructure/__init__.py": "ec6cb2d48fb3178de856dcbeefb7b14bc193fe78ac68a53f9afcf2bd9cac0699", + "backend/src/domus/infrastructure/auth_runtime.py": "414ac5ab890db991272a2fe9d7817ad1995052d2d09e1e9dd07d2631ef92de65", + "backend/src/domus/infrastructure/ha_config.py": "ef6029bba313eda6191a18751cf283ef96faf241b9d4cb2dc781f0f2d5be9db5", + "backend/src/domus/infrastructure/ha_http.py": "638cc3d918689d1392adb57332c2136160e9745a29b7c84d8fe99363fb358673", + "backend/src/domus/infrastructure/ha_ingress.py": "081038945db0fea51b0fa45889c2a2ac9a1aa45b67ae0898be20fb1f7861b609", + "backend/src/domus/infrastructure/ha_websocket.py": "3286011f6a25f557f119ad277f4ba9dea850ae7cac189e6867032cec06b16334", + "backend/src/domus/infrastructure/memory_attempts.py": "b1f739ddbbdb072a4961410b9775ae191581edf6135d0ae359bf421cc8357dfc", + "backend/src/domus/infrastructure/memory_sessions.py": "a1725225ca45e9610cb158b2a628cfd0e368fdfc17791aec18dec2983fb4d063", + "backend/src/domus/infrastructure/sqlite.py": "45e9669c61dc2d8cfc89f3f02b296fb17b5d32ba4486bdce0d14457de2d89da7", + "backend/src/domus/infrastructure/sqlite_identity.py": "091994c671189707e5299ef093887d68307ca4e829e53a629569b593527bc082", + "backend/src/domus/infrastructure/standalone_sessions.py": "7caf36974dd77db77847534041aac25f835decb5e95ed054be82078d2b0e8fc4", + "tests/backend/test_atomic_commands.py": "a17ed9172736e0f7fb0dfbb8d9186fb45ba9a922fa3c26648bff7266ce7a4042", + "tests/backend/test_auth_http.py": "69e30432529884be157b270ccf15fe1cc74b6ec6fe465763001fa1e5818ade8e", + "tests/backend/test_auth_runtime.py": "749e92af108ac84908c36408bddb93aaad55aa15d62282bdc8a66cc30983d157", + "tests/backend/test_auth_server.py": "748f6a89f80dff6336e3a9c5f04cb6098d9666c40db5be5f6c8bb8c598db7109", + "tests/backend/test_authentication_flow.py": "df4fd1e0af08bb15c7ce7f027acef50ee0f40dd3e37d7ef9ef485b09d958f36d", + "tests/backend/test_ha_http.py": "2525675d56c2314c30984d502f2e6578b076947b33589201749e5c691c7405d2", + "tests/backend/test_ha_ingress.py": "f3828d00a9e8466bc7ff0ea814ea9975a263af8463360c0bc83090657d13b0aa", + "tests/backend/test_ha_websocket.py": "99eccc06b1fa048a47838e30e20f41dbda18b09c48ca6b508a1244aaf03151f2", + "tests/backend/test_identity_policy.py": "67be51abb19f40a227c05c491aba48cf4ee411cb0095f09c3cfaafa65992c566", + "tests/backend/test_ingress_http.py": "f62b95340ebf7a3ba7b8d61da000821ab146856c14df11002b401eb45a752855", + "tests/backend/test_ingress_server.py": "57f977e8ae6bbcd5120e4e7dd1f8dc322cc15228b9141e043b870dcfb00fbecd", + "tests/backend/test_membership_authorization.py": "72d4323b4c7adf94deda6fc43f737b0dfdd369c8e9331c4af2403c6f7f37df43", + "tests/backend/test_session_leases.py": "218bb4a72444ce64fcf4f47dc563c75018a17602d63c2cdc9b937166edbe3a14", + "tests/backend/test_sqlite_identity.py": "8b1e6ab2693ec4023a4b0b52df213204e763d27b4c89ca8346d6f063b5e3a59a", + "tests/backend/test_sqlite_transactions.py": "af6c25f8f093d564c500aaaffbdc5bccb5a1d30172788ad7d02ab877c44cba78", + "tests/backend/test_standalone_sessions.py": "af1be05e6f987794bb154ecb9bd1c5cba7614077b4b90abde27d07b70bc4cf78", + "tests/backend/test_storage_values.py": "d72420955965e48be0c089339273a6a162a00bb66f9997376871304713fdd668", + "backend/pyproject.toml": "794872a1e3147a83e45801c6e63741fff3d13b58a4b878acb13b18b95f5eb23b", + "pyproject.toml": "c91ec95a30232ae4a63009c7323050f6778e0cf799a067d8fda9d7a6f85c9ace", + "uv.lock": "c31aed816ac0694704236f529434f003f4c706ccf84ffdce6a59da537cc913b5", + "architecture/python-application.ini": "32656da26ff928c71ea06a3f4764cfbeec6127c4198cded2f9ba84e4fea6d569", + ".github/workflows/ci_check.yml": "d68cfaaae3cbf1ef67bfffdfc37c5f1b4f60c3bd887a8d5b4030e758e3e935ea" + } +} diff --git a/docs/evidence/m1-member-administration.json b/docs/evidence/m1-member-administration.json new file mode 100644 index 0000000..2e0ea23 --- /dev/null +++ b/docs/evidence/m1-member-administration.json @@ -0,0 +1,61 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "146b984a71a2c7f753e442a544f6b0f87a6801ae", + "scope": "Application-layer member activation/profile/status/exceptions with last-administrator safeguards; no HTTP or installed image change", + "testFirst": "New behavioral tests initially failed at import because the use case did not exist, then passed with its implementation", + "backendTests": 522, + "memberTests": 40, + "backendCoverage": { + "covered_lines": 1785, + "num_statements": 1785, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 103, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 476, + "num_partial_branches": 0, + "covered_branches": 476, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "command": ".venv/bin/pytest tests/backend -q -W error::ResourceWarning --cov=domus --cov-branch --cov-report=term-missing", + "assertions": [ + "Only a current administrator may replace profile/status/exceptions; immutable HA linkage and actor scope are preserved", + "Active target requires exact fresh eligible human; unavailable deactivation needs a proven remaining administrator", + "Fresh actor reproof after external lookup blocks known revocation, identity mismatch or source outage", + "Snapshot/CAS and last eligible administrator are checked before save inside the same SQLite writer transaction", + "Real-file simultaneous self-suspension leaves one administrator and only one new receipt/audit/outbox effect", + "Real-file identical concurrent activations reuse one receipt with one effect", + "Current local suspension immediately blocks privilege and receipt replay; ordinary members cannot promote themselves", + "Archived reactivation is explicit and preserves linkage/history; malformed batches cannot invent remaining authority", + "Policy/member state changing during external lookup yields conflict without partial command evidence", + "Restart preserves authorized receipts; reordered equivalent permission rules have the same canonical payload" + ], + "architecture": { + "files": 67, + "dependencies": 205, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 36 backend source files" + }, + "priorHeadCI": { + "head": "146b984a71a2c7f753e442a544f6b0f87a6801ae", + "url": "https://github.com/vypdev/domus/actions/runs/41189342234", + "result": "PASS: six project-required groups including both native container architectures; not acceptance of later source" + }, + "installedPreview": "Still stateless 0.0.0-edge.1, immutable runtime 98bd12f1bf141ba64053730b2686bd848aee1b1c; none of these commands installed", + "remaining": [ + "Policy edit commands and their last-administrator safeguards", + "Public command/query API and UI", + "Recovery, snapshots and restore", + "Native installation persistence and local read-grace sessions", + "Complete standalone/channel acceptance" + ], + "sourceSha256": { + "backend/src/domus/application/member_commands.py": "e7668c1f0044b005881be2ae1d1354131859b8c8ccc1084a905b3b044898b7ac", + "tests/backend/test_member_commands.py": "4daf5f8efefab0c9580027a4ae770997ed76b315d5295f6f7bd1e7cb155f53cd" + } +} diff --git a/docs/evidence/m1-native-ha-acceptance.json b/docs/evidence/m1-native-ha-acceptance.json new file mode 100644 index 0000000..b6d2d6b --- /dev/null +++ b/docs/evidence/m1-native-ha-acceptance.json @@ -0,0 +1,73 @@ +{ + "verifiedOn": "2026-10-31", + "scope": "Owner-authorized native HA connection preview, not complete M1 or production support", + "packageCommit": "650f1517f0e15664ea455628e63154dada0414f2", + "runtimeCommit": "98bd12f1bf141ba64053730b2686bd848aee1b1c", + "runtimeArchiveSha256": "4531e8b013e288bcc717c72452c57018a1158f4aa6054f277b19b05d6baaa63f", + "sourceBinding": "Manifest version and immutable Dockerfile source/checksum; the HA-built image digest was not independently read", + "app": { + "id": "62fdc661_domus_edge", + "version": "0.0.0-edge.1", + "channel": "edge", + "repository": "https://github.com/vypdev/domus#feature/6-implement-home-assistant-identity-and-sqlite-platform-persistence", + "safeBrowserUrl": "http://homeassistant.local:8123/app/62fdc661_domus_edge", + "state": "Installed and running; retained for continued authorized development", + "startOnBoot": false, + "watchdog": false, + "automaticUpdates": false, + "sidebarEntry": false + }, + "homeAssistant": { + "core": "2026.10.4", + "supervisor": "2026.10.5", + "os": "18.3", + "frontend": "20260930.5" + }, + "browser": "Google Chrome, existing owner Home Assistant session", + "assertions": { + "repositoryAddition": "PASS", + "installation": "PASS", + "start": "PASS", + "openWebUi": "PASS: Domus Edge / Development preview / Connected to Home Assistant", + "existingHaSession": "PASS: No additional login required", + "normalNativeChromeReload": "PASS: connection page restored", + "householdSetupUnavailable": "PASS: displayed explicitly; no business records created" + }, + "instrumentationObservation": { + "initialSymptom": "HA iframe request remained stalled before sending while the task debugger was attached", + "isolation": "The same frame destination rendered as a top-level page; no application log error explained the parent stall", + "resolutionObserved": "Canceling only the task-owned Chrome debugger attachment immediately let the existing iframe load; normal native Chrome reload also passed", + "boundaryChanges": "None: peer, headers, credentials, permissions and app source were not relaxed", + "limitation": "Observed browser instrumentation interaction, not a proven general browser or application defect" + }, + "ci": { + "head": "650f1517f0e15664ea455628e63154dada0414f2", + "url": "https://github.com/vypdev/domus/actions/runs/41184982619", + "result": "PASS: all six project-required job groups, including actual native amd64 and aarch64 container builds/tests", + "doesNotTransferToLaterHeads": true + }, + "permissions": { + "coreApiTechnicalAdministrator": true, + "supervisorManagementApi": false, + "hostPorts": {}, + "hostMounts": [], + "privilegedCapabilities": [], + "humanPrincipal": "Fresh exact HA user; transport administrator access is not a household grant", + "credentialLifecycle": "HA/Supervisor owns provisioning and platform lifecycle; Domus keeps its private runtime copy outside business storage and responses" + }, + "cleanup": { + "retained": "Only the authorized repository/app and its native app tab for ongoing testing", + "closed": "Owned diagnostic log and About tabs; Chrome DevTools", + "debugger": "Detached from the owned app tab before final normal reload", + "businessData": "No household, member, entity, device, automation or user changes", + "secrets": "No credentials, session cookies, OAuth codes or opaque Ingress URLs retained in this receipt or repository", + "previousStandaloneGrant": "Earlier owned OAuth test revoked on shutdown: revoked=1, unconfirmed=0; that browser return remains unaccepted" + }, + "remaining": [ + "Public household commands and product UI", + "Native persistence and local read-grace sessions", + "Recovery and snapshot/restore", + "Stable/Edge data isolation acceptance and stable release", + "Standalone packaged browser acceptance" + ] +} diff --git a/docs/evidence/m1-native-household-api.json b/docs/evidence/m1-native-household-api.json new file mode 100644 index 0000000..ade00b5 --- /dev/null +++ b/docs/evidence/m1-native-household-api.json @@ -0,0 +1,123 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "4646ee174ab6f2b044ca323176fab3bed475c513", + "scope": "Native persistent ASGI/SQLite composition, bounded Ingress leases, fixed-origin CSRF and shared household services; package and owner installation unchanged", + "backendTests": 642, + "backendCoverage": { + "covered_lines": 2464, + "num_statements": 2464, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 105, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 610, + "num_partial_branches": 0, + "covered_branches": 610, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "command": ".venv/bin/pytest tests/backend -q -W error::ResourceWarning --cov=domus --cov-branch --cov-report=term-missing", + "environment": "Python 3.14.6; loopback protocol fixtures require local socket permission. The restricted-sandbox attempt was not accepted as a pass; the complete socket-enabled run passed.", + "assertions": [ + "Only the actual Supervisor peer, exactly one subject/base path and one valid upstream session cookie reach protected routes; no client metadata alone creates human proof", + "Keyed bindings include installation UUID, channel, human and Ingress path; raw cookies do not enter persistent data or diagnostic representations", + "Safe reads establish a lease only after exact fresh HA proof; existing fresh reads retain the original absolute deadline", + "Bounded HA availability failure permits existing local reads only until that deadline; writes require fresh proof and cannot bootstrap from CSRF", + "Explicit rejection invalidates local access; later outage cannot resurrect it; shutdown blocks late bootstrap and clears bindings", + "Concurrent bootstrap preserves one original lease and failed conditional refresh denies access", + "Fixed configured origins are validated before SQLite; supplied forwarding headers cannot change accepted origins", + "CSRF is bound to the local lease epoch and exact installation/channel/session/human/path; restart and changed upstream session cannot reuse it", + "Shared household setup/enrollment/member/policy/query services preserve transactional authority, idempotency and receipt/audit/outbox behavior", + "Selected ordinary HA human can administer Domus through the configured mapping while remaining non-administrator in HA; Core directory privilege is transport only", + "Injected response failure after commit preserves uncertainty and identical retry recovers exactly one result", + "Actual native OpenAPI paths/components match the published contract; private process health and preview behavior remain separate" + ], + "architecture": { + "files": 78, + "dependencies": 312, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 47 backend source files", + "negativeFixtures": 39 + }, + "schema": "Business schema 2 unchanged; no upstream cookie, technical token, local session lease or CSRF is persisted", + "installedPreview": "Still Edge 0.0.0-edge.1 / runtime 98bd12f1bf141ba64053730b2686bd848aee1b1c. These native household services have not been installed on the owner HA.", + "upstreamSource": [ + { + "repository": "home-assistant/core", + "ref": "2026.10.4", + "path": "homeassistant/components/hassio/ingress.py", + "gitBlob": "d2ca7316fa8a0d39d923fcac1e8fa7348c64c02c", + "url": "https://github.com/home-assistant/core/blob/2026.10.4/homeassistant/components/hassio/ingress.py" + }, + { + "repository": "home-assistant/supervisor", + "ref": "2026.10.5", + "path": "supervisor/api/ingress.py", + "gitBlob": "2e3559531112c59070dca7c7aa5b7a972e129cbf", + "url": "https://github.com/home-assistant/supervisor/blob/2026.10.5/supervisor/api/ingress.py" + }, + { + "repository": "home-assistant/supervisor", + "ref": "2026.10.5", + "path": "supervisor/api/const.py", + "gitBlob": "92d88ba18ff2358205c48dbb35b58e0c2e034e0d", + "url": "https://github.com/home-assistant/supervisor/blob/2026.10.5/supervisor/api/const.py" + }, + { + "repository": "home-assistant/supervisor", + "ref": "2026.10.5", + "path": "supervisor/ingress.py", + "gitBlob": "df50996b8a71cedfb92e858b25bb1634adba0b79", + "url": "https://github.com/home-assistant/supervisor/blob/2026.10.5/supervisor/ingress.py" + }, + { + "repository": "home-assistant/supervisor", + "ref": "2026.10.5", + "path": "supervisor/docker/app.py", + "gitBlob": "4dcb3c53feeddc6f433e458db44db7ca9134d6b6", + "url": "https://github.com/home-assistant/supervisor/blob/2026.10.5/supervisor/docker/app.py" + }, + { + "repository": "home-assistant/supervisor", + "ref": "2026.10.5", + "path": "supervisor/apps/app.py", + "gitBlob": "312f0d4ff48b6a322f4f52fe836bfc0ff51cb716", + "url": "https://github.com/home-assistant/supervisor/blob/2026.10.5/supervisor/apps/app.py" + } + ], + "dataMountBoundary": "Supervisor supplies the own /data mount. Packaged UID write permission has not been measured on the owner target and is not inferred from source mkdir behavior.", + "remaining": [ + "Owned data-directory initialization and automatic persisted installation UUID", + "Updated native app packaging and live acceptance", + "Lit product UI with Storybook/Playwright", + "Console-approved recovery and snapshot/restore", + "Complete standalone companion authority and browser acceptance", + "Stable/Edge and both deployment-target acceptance" + ], + "priorHeadCI": { + "head": "4646ee174ab6f2b044ca323176fab3bed475c513", + "url": "https://github.com/vypdev/domus/actions/runs/41192762015", + "result": "PASS: all six required project groups; not evidence for later source" + }, + "copilot": "Unchanged; scope and assessment admission failures are tracked in vypdev/copilot issues #828 and #829. No checks or merge gates relaxed.", + "sourceSha256": { + "backend/src/domus/application/authentication.py": "3ab940f76455365c4d40d40645c5f9ba5a65754e382665a0266057baaae04bf8", + "backend/src/domus/application/session_values.py": "f124c2405362cbd782394e69759ac2f88041910e771447eaf40c82ad3d993e50", + "backend/src/domus/composition/ingress.py": "a0048823c2cd79b650454487c013390f791c27c06c6206f3bd63bfbe6cb879ee", + "backend/src/domus/entrypoints/http.py": "94f72ea399337ef9c51656d28a790aba04ea75240a9858ac00bab4a6a25cb32c", + "backend/src/domus/entrypoints/http_errors.py": "8f28f35510770fe8786d4a5dfd0f845d579c3e734426be16b0344472fb4f5d02", + "backend/src/domus/entrypoints/ingress_boundary.py": "1aa1d684b8625464b5d36d272f71d94b4cd32b84819ff8765a821c9a722f8f28", + "backend/src/domus/entrypoints/ingress_http.py": "f6557eb5481d132131f66ad7691e30c223c63a66a6f0ae5754c9fba3bc7f3626", + "backend/src/domus/entrypoints/native_http.py": "59dce651bbab80a36a0bec435c581b247ad9b8aac6b53406bd577cbd0a7b15c7", + "backend/src/domus/infrastructure/ha_config.py": "42d0631cc584369a743027e90e275a17c0ec935ac0d14b8e0ad4c10b488aa1cf", + "backend/src/domus/infrastructure/ha_ingress.py": "e1da730ab4092afbf9630ff3a8d5c31285d7a8b99e1acc7080082ffdf4023dc7", + "backend/src/domus/infrastructure/ingress_sessions.py": "9f95f69f124fca64b9420458580bbad9907f027754c3413284ce7513a3d9907a", + "tests/backend/test_ingress_sessions.py": "c1196ebf2f9d2ab265fd14d93166147e0aa71b02e94bd9d406dde2ae6f739d60", + "tests/backend/test_native_http.py": "39d8c6f52992212463934a972c4954ee4a620b48996906987d2dac7f89390538", + "docs/contracts/native-household-api.openapi.json": "97f93a1f6bce3397a081644466ffd2be73d7c32f53b30554183c1352b00e530a" + } +} diff --git a/docs/evidence/m1-offline-activation.json b/docs/evidence/m1-offline-activation.json new file mode 100644 index 0000000..1cd15f7 --- /dev/null +++ b/docs/evidence/m1-offline-activation.json @@ -0,0 +1,121 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "0e86193b69371e5c27b23923ea740f34d7ef49e8", + "scope": "Actual offline activation/resume/status and rollback with managed source binding, retained history, target-only migration and both composed APIs; actual package/deployment acceptance remains pending", + "backendTests": 1299, + "newExecutedCases": 115, + "backendCoverage": { + "covered_lines": 4684, + "num_statements": 4684, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 132, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 1302, + "num_partial_branches": 0, + "covered_branches": 1302, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "command": "COVERAGE_FILE=/private/tmp/domus-activation-backend.coverage .venv/bin/pytest tests/backend -q -W error::ResourceWarning --tb=short --cov=domus --cov-branch --cov-report=json:/private/tmp/domus-activation-coverage.json --cov-report=term:skip-covered", + "assertions": [ + "New format-2 preparation binds UUID/channel/schema/current generation or flat managed identity plus the complete operation frontier; legacy format 1 remains inspectable but cannot activate", + "Exclusive runtime and maintenance leases precede read-only source validation; live runtime/shared console/second activator cannot overlap; original source is not migrated", + "A durable pending marker and orphan intent stage block normal and explicit-file startup; exact sealed private input and a verified standard source-checkpoint precede target publication", + "Only a new blocked target is migrated/imported; all files and directories are verified/flushed before one atomic root pointer replacement; source bytes remain intact", + "Ten durable-boundary faults plus a real process exit after root pointer rename retain source and resume the same transition without a second business effect", + "Status distinguishes pending, selected active and historical completed activations; completed inspection can share live runtime; retry of an old activation never switches back", + "Partial owned targets/checkpoints can be rebuilt only with unchanged source and exact input; changed source, conflicting receipts, altered target/checkpoint, unknown/unsafe files and foreign installations fail closed", + "Actual standalone and native HTTP compositions reauthenticate after activation, retain checkpoint state and replay later commands as historical with original operation ID and suppressed event delivery", + "Frozen schemas 1/2/3 activate through the real protocol into schema 4 while source/checkpoint bytes remain unchanged", + "Actual restoration of an old approved recovery cannot rearm consumed/canceled recovery under new keys or grant the historical target membership", + "The pre-activation source-checkpoint is independently inspectable and supports a subsequent rollback preparation reconciled with newer work; historical receipts and sequence high-water survive", + "Actual console prepare/inspect/activate/resume/status commands validate exact managed configuration and return safe truthful state; no HTTP restore endpoint or new human authorization flow" + ], + "testFirst": "Initial activation tests and negative contracts were added while the protocol was developed; intermediate complete-suite coverage failed at 97.84% and 99.72% before missing real error/identity cases were supplied. Final suite passes without exclusions added.", + "formats": { + "businessSchema": 4, + "preparedManifest": 2, + "legacyPreparedInspectable": 1, + "rootSelection": 2, + "activationRecord": 1, + "slotIdentity": 1, + "automaticBackupSchedule": false, + "pruning": false + }, + "architecture": { + "files": 103, + "dependencies": 542, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 68 backend source files", + "negativeFixtures": 41 + }, + "runtime": { + "python": "3.14.6", + "sqlite": "3.53.3", + "scope": "Local real SQLite files, process termination and actual ASGI compositions/console; not owner HA restore or new package execution" + }, + "installedRuntime": "Owner Edge.2 / d013cf2c8b932d0e00f5858053cbc0e663d5eee1 / schema 3 unchanged. No live restore, snapshot, household, recovery grant, device or user changes.", + "priorHeadCI": { + "head": "0e86193b69371e5c27b23923ea740f34d7ef49e8", + "url": "https://github.com/vypdev/domus/actions/runs/41215442133", + "result": "PASS: all project groups; image tests cover the pinned existing package rather than these new sources" + }, + "remaining": [ + "Review complete current-head Bugbot findings and full supported CI", + "Build and execute restore in actual supported images and both deployment modes; packaged SQLite may differ", + "Product UI, live household/recovery and full standalone companion/browser acceptance", + "All remaining functional domains/channels/releases; M1 and full product are not complete" + ], + "copilot": "All installed resources untouched. A draft/skipped/neutral Bugbot review is not no-findings acceptance.", + "sourceSha256": { + "backend/src/domus/domain/activation.py": "fcb2e47e939127d5e254e2136fb7f1459b40fa8c8561100579fced49666ad0fe", + "backend/src/domus/application/activation.py": "c0c51350b074424837251068ec416f9be1e23ec1a9a4bfdcd19c3724ae736f08", + "backend/src/domus/composition/restore.py": "fe4f5040a99db1404938e402eefa54fc93139055a5bc92abdfb7bf35d0c87297", + "backend/src/domus/infrastructure/offline_storage.py": "16795918af0b4b21bc17aa6a52706fb92aa4d40850c489587bcffa7511b0fc30", + "backend/src/domus/infrastructure/restore_source.py": "71917a2db52f5f6794504fd8caa7fd23b0732e8328760c728ea3f082acd2d203", + "backend/src/domus/infrastructure/sqlite_activation.py": "6191814bfcb73ac35613f6592fd27f74cbf88baf81394091caecbdae3e29ab8a", + "backend/src/domus/domain/restoration.py": "4bb02d6633a0c81c1ce0cb58ae73dd300813b7ed40f495de521a0290d7b2ce0f", + "backend/src/domus/domain/snapshots.py": "81060c12e0ef06c7329bc3be780d321f40426d36e87e42fea5e85ad075bf7f19", + "backend/src/domus/composition/admin.py": "042c66929a738b97fe7a1dcc3ccda7cc2ccdccdbc2b48cc36065c45989c30aae", + "backend/src/domus/infrastructure/owned_storage.py": "c994557ba584bc0292c8fef432afe8725b532a5976c91faa39ebdbe70b4c4192", + "backend/src/domus/infrastructure/sqlite_restoration.py": "2f28207974082bdee48d72afdca08956e077b957377b1a2894b3eb6f4e6c224a", + "tests/backend/test_activation_values.py": "3b0a226c2803df8f35f65c756ff3d7ce883848fded656a038b6fb02bebdde85f", + "tests/backend/test_activation_console.py": "e4c9d5d68a06b7b7ee8c890a0d43b3e811a04fba8a08774bcb6db6ccf8d5b7e7", + "tests/backend/test_restore_source.py": "090492ebd9bca26ee08715377af793abd9cbe9dbdb6ee7f77fc560b48aeb0353", + "tests/backend/test_offline_storage.py": "19b09e0ebf62654175387c44194b2beb76b05f1d3133f178bf7156d5fa12453f", + "tests/backend/test_sqlite_activation.py": "d4f13336143c11904236031d7caa3b3c1629db1c16e5cdbe05daf8716a94651c", + "tests/backend/test_activation_failures.py": "0fde2358c8d90fe988d772c72aa4d89ed2e5d20d082e9bfa27fa962bc4eef6f4", + "tests/backend/test_activation_compatibility.py": "f4e0e0b42da5c592390ab738ebe6f4a5dc95fd1c7c3ae4ca3959bac79b969a31" + }, + "tooling": { + "tests": 66, + "statements": 73, + "branches": 34, + "coveragePercent": 100, + "protectedCopilotResourcesUnchanged": 29, + "ruff": "PASS", + "localDocumentation": "PASS", + "mypy": "PASS: 4 tooling modules" + }, + "sddValidation": { + "restoration": { + "format": "PASS", + "consistency": "PASS", + "canonicalSections": 20, + "characters": 31099 + }, + "primaryCompanion": { + "format": "PASS", + "consistency": "PASS", + "characters": 69863, + "scope": "Unchanged full requirement/case inventory" + }, + "catalog": "PASS: 11 capabilities, generated rendering current", + "validator": "Byte-verified unchanged upstream policies" + } +} diff --git a/docs/evidence/m1-owned-storage.json b/docs/evidence/m1-owned-storage.json new file mode 100644 index 0000000..a857f3e --- /dev/null +++ b/docs/evidence/m1-owned-storage.json @@ -0,0 +1,69 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "d1571d0531f11122cead73ef31ebf96a69681e2c", + "scope": "POSIX owned-directory lifecycle, automatic persisted UUID/channel identity and native CLI composition; no image or live HA update", + "backendTests": 698, + "backendCoverage": { + "covered_lines": 2599, + "num_statements": 2599, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 105, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 654, + "num_partial_branches": 0, + "covered_branches": 654, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "command": ".venv/bin/pytest tests/backend -q -W error::ResourceWarning --cov=domus --cov-branch --cov-report=term-missing", + "testFirst": "The owned-storage behavioral suite first failed collection because its module did not exist; native owned-runtime tests first failed because the factory and CLI option did not exist.", + "assertions": [ + "An empty private directory receives one initializing UUID/channel marker, real SQLite schema and durable ready marker before HTTP; no household or membership is implicitly created", + "Restart preserves UUID, household and same-key original command result; native composition initializes storage exactly once, then uses existing-only mode on restart", + "A lifecycle lock excludes a concurrent process; close and abrupt process death release ownership", + "Actual child-process death before and after schema commit resumes only the durable initializing UUID", + "Failed ready-marker atomic replacement leaves a resumable original identity; owned abandoned staging is cleaned under lock", + "Ready metadata refuses missing, empty, corrupt or foreign SQLite without rewriting the original marker or database", + "Missing marker never adopts a preexisting database, sidecar or staged metadata; channel mismatch and invalid exact-field metadata fail closed", + "Private ownership, exact file/directory modes, no-follow opens, regular files and single links reject symlinks, hardlinks, FIFOs and shared files without altering external targets", + "Existing-only initialization still performs the explicit v1-to-v2 migration while preserving household, audit and outbox history", + "Configuration fails before persistent records; failed later runtime composition releases the directory lock; partial and competing CLI identity options are rejected", + "POSIX locking remains infrastructure-only and negative architecture fixtures reject fcntl in both domain and application" + ], + "architecture": { + "files": 81, + "dependencies": 323, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 48 backend source files", + "negativeFixtures": 41 + }, + "schema": "Business schema 2 unchanged; separate version-1 initialization metadata contains only UUID/channel/state and no credentials", + "trustBoundary": "POSIX filesystem, trusted operator-owned private directory and parents. Every process accessing these files must honor the protocol. No protection from the local owner or unauthorized manual path/metadata replacement is claimed.", + "installedPreview": "Edge 0.0.0-edge.1 is unchanged, stateless and does not enable owned storage. Supervisor data-directory provisioning for the packaged runtime UID is not yet accepted.", + "remaining": [ + "Provision private /data child ownership in the HA package without running the server with excess privilege", + "Updated immutable app package and CPU-family/container/live acceptance", + "Product UI, console-approved recovery, consistent snapshot/restore and full M1", + "Complete standalone companion authority and both deployment/channel acceptance" + ], + "priorHeadCI": { + "head": "d1571d0531f11122cead73ef31ebf96a69681e2c", + "url": "https://github.com/vypdev/domus/actions/runs/41196324078", + "result": "PASS: six required project groups; does not establish later source acceptance" + }, + "copilot": "Unchanged; same prior-head scope-clause-limit reported with exact reference 84adb289-e365-444d-9b90-6fb006e71b9c in vypdev/copilot#828 comment 6542747656", + "sourceSha256": { + "backend/src/domus/infrastructure/owned_storage.py": "129c408aeb31fc60d750fa200c18b6b253953f9b6402b048597a39c068824a25", + "backend/src/domus/infrastructure/sqlite.py": "5250ae3afb977e38e59b6c1f61eea99b2db63ca4a9294821b85d0157f5eeaa43", + "backend/src/domus/composition/ingress.py": "83f41e9a27d5f92753250dc0182ee99b4a99147bf3fdf4ac561e6e17711b0b05", + "tests/backend/test_owned_storage.py": "5de672ee9452d8820fdbf5cea49c410ec0f0d34101ebb5198a02ac4aea5e4285", + "tests/backend/test_native_http.py": "821080e530ea71a4a9d365a733ad02bf0617abd1cc284e246beda6bcab3cfe73", + "architecture/python-application.ini": "4a9671bb02806eba3c614936079cbd8cb014f8b6851d01566770987fdeee6384", + "tests/architecture/test_python_architecture.py": "c6128140c8233497474092e1b78fadcc5ed087ad7e20167cfb8dd0c45e366b16" + } +} diff --git a/docs/evidence/m1-persistent-app-package.json b/docs/evidence/m1-persistent-app-package.json new file mode 100644 index 0000000..d6bb1cc --- /dev/null +++ b/docs/evidence/m1-persistent-app-package.json @@ -0,0 +1,75 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "d013cf2c8b932d0e00f5858053cbc0e663d5eee1", + "packageVersion": "0.0.0-edge.2", + "runtimeCommit": "d013cf2c8b932d0e00f5858053cbc0e663d5eee1", + "runtimeArchiveSha256": "fa58d457bef8a863a1943b4e6ba5c9513da8d3baa8b0a033bdcbe7bf11b09c6c", + "localImage": { + "tag": "domus-edge-review:0.0.0-edge.2", + "id": "sha256:5d3c87fd549d40c82ec3e60d6b3c4a778579dea1cafceddedecd865de13ce32d", + "architecture": "arm64", + "python": "3.14.6", + "sqlite": "3.46.1" + }, + "backendTests": 954, + "backendCoverage": { + "covered_lines": 3434, + "num_statements": 3434, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 122, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 888, + "num_partial_branches": 0, + "covered_branches": 888, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "containerTests": 4, + "containerCommand": "DOMUS_TEST_IMAGE=domus-edge-review:0.0.0-edge.2 .venv/bin/pytest tests/containers -q --no-cov", + "assertions": [ + "Image builds the immutable checked archive with frozen dependencies and the registered launcher entrypoint", + "A root-owned named-volume fixture is provisioned only inside the app data mount, with no network or host ports", + "The real server process has four UID/GID values of 65532, no supplementary groups and zero permitted/effective/ambient capabilities", + "The runtime identity cannot regain root or write root-owned options; private directory/files have correct ownership and 0700/0600 modes", + "A real listener serves process health but rejects forged direct Ingress before any HA connection", + "A second runtime is excluded while console attachment can run concurrently", + "A canonical fictitious AtomicCommands fixture preserves household, original receipt, audit and pending outbox exactly once across stop/start; UUID/channel marker is unchanged", + "Missing transport or invalid public_origins refuses startup without a root server or new private data", + "Owned temporary containers and volumes are removed; the two review/rollback images are retained" + ], + "architecture": { + "files": 90, + "dependencies": 411, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 57 backend source files" + }, + "limits": [ + "Local native aarch64 image evidence; exact-head CI must separately build/exercise amd64 and aarch64", + "Storage fixtures use a fictitious authority and do not establish real HA human acceptance", + "The owner still runs Edge 0.0.0-edge.1 until a verified manual update; no live app setting or household record changed in this increment", + "Full product UI, restore activation/newer replay-history policy, companion authority and complete deployment/channel acceptance remain pending", + "Snapshot output parents remain explicit operator provisioning; no backup scheduler or pruning was added" + ], + "configuration": { + "public_origins": "one to four fixed HA browser origins; default empty refuses startup", + "breakingVersion": "0.0.0-edge.2", + "bootstrap": "container root provisions /data/domus then execs server as 65532; no host privilege, mount, API role or exposed port added" + }, + "runtimeSourceCI": { + "head": "d013cf2c8b932d0e00f5858053cbc0e663d5eee1", + "url": "https://github.com/vypdev/domus/actions/runs/41204679962", + "result": "PASS: six project groups; the source-head image cases still use the previous immutable preview, not this new package" + }, + "sourceSha256": { + "addon-edge/Dockerfile": "72364890d6295530fdf74afef786f53804ca39cc6c6379b817a25f3f31e65d75", + "addon-edge/config.yaml": "54f147389ce93031eff7be484446dddffb9d2c144dc6406c0b1498bfe9337af4", + "addon-edge/translations/en.yaml": "898d1c9a7e67eea8e1d72e4c2305131655a65dee78c03ce6af9074cb41067347", + "tests/backend/test_app_package.py": "ff31a0e314104a352bbc178fd386dca95320f4fda221da6bc1c8a706974e121b", + "tests/containers/test_app_image.py": "14474f5b9a5c198e884198a56ba429e50e73b03db1cb1601ee7a68a1bc4b74ba" + } +} diff --git a/docs/evidence/m1-persistent-ha-acceptance.json b/docs/evidence/m1-persistent-ha-acceptance.json new file mode 100644 index 0000000..9fb0245 --- /dev/null +++ b/docs/evidence/m1-persistent-ha-acceptance.json @@ -0,0 +1,52 @@ +{ + "observedOn": "2026-10-31", + "completedAtUtc": "2026-10-31T13:43:31Z", + "sourceBase": "fcbf78635a5ed4df77714b12e41d9bd8da3e1fe2", + "packageVersion": "0.0.0-edge.2", + "runtimeSourcePin": "d013cf2c8b932d0e00f5858053cbc0e663d5eee1", + "runtimeArchiveSha256": "fa58d457bef8a863a1943b4e6ba5c9513da8d3baa8b0a033bdcbe7bf11b09c6c", + "target": { + "origin": "http://homeassistant.local:8123", + "appId": "62fdc661_domus_edge", + "entry": "http://homeassistant.local:8123/app/62fdc661_domus_edge", + "haPlatformVersionReference": "m1-native-ha-acceptance.json; prior About observation, not re-read during this bounded update" + }, + "observations": [ + "Supervisor offered Edge.2 from the existing Action-managed development repository and displayed the installed version as Edge.2 after update", + "Retain-previous-version backup option was explicitly selected; the resulting backup artifact was not inspected", + "Empty prior app options refused startup without a server; the new English-labelled public_origins form appeared after reloading HA configuration", + "The sole configured origin http://homeassistant.local:8123 was saved, then Supervisor reported the app running", + "Open Web UI and a normal Chrome toolbar reload showed the new persistent runtime text: Connected to Home Assistant; No additional login is required; The household interface is being prepared", + "A restart of Domus only completed and another normal reload showed the same new connected page through existing HA authentication", + "Start on boot, watchdog, automatic updates and sidebar switches remained off; no host port, mount, API role or protection-mode change was made" + ], + "dataImpact": "Created the app private directory/installation UUID/schema through its packaged lifecycle and saved its one browser origin. No household/member, recovery approval/grant, points, automation, HA entity/device/user change or Domus snapshot was created on the owner installation.", + "separateContainerEvidence": { + "localImage": "sha256:5d3c87fd549d40c82ec3e60d6b3c4a778579dea1cafceddedecd865de13ce32d", + "architecture": "arm64", + "python": "3.14.6", + "sqlite": "3.46.1", + "tests": 4, + "extendedAssertions": "Real packaged domus-admin snapshot create and independent inspect agree on exact manifest/checkpoint after fixture restart/replay; output parent provisioned explicitly by the fixture operator", + "priorPackageCI": "https://github.com/vypdev/domus/actions/runs/41205183680", + "amd64Job": 124548701006, + "aarch64Job": 124548700851, + "result": "All six project groups pass; four real-image cases pass on both native CPU families before the additional packaged snapshot assertion" + }, + "limits": [ + "Owner browser evidence proves bounded update/configuration/start/session access/reload/restart, not full M1 or product acceptance", + "Actual owner UID/capability/UUID/history bytes were not extracted; kernel and replay assertions are from the separate real-image fixture tests", + "Live household commands, outage/revocation, console recovery and restore were not exercised on the owner installation", + "Standalone companion/browser acceptance, product UI and complete Stable/Edge deployment remain pending" + ], + "restoreDecision": "Owner confirmed A30: restore checkpoint state while retaining readable later receipts/history as processed; implementation remains pending", + "privacy": "No raw Ingress path/session cookie, Supervisor token, human HA ID or credential is retained in this receipt. All opaque browser observations were redacted.", + "sourceSha256": { + "addon-edge/Dockerfile": "72364890d6295530fdf74afef786f53804ca39cc6c6379b817a25f3f31e65d75", + "addon-edge/config.yaml": "54f147389ce93031eff7be484446dddffb9d2c144dc6406c0b1498bfe9337af4", + "backend/src/domus/composition/ha_app.py": "e0ffe36478881ff11367c7ba5467438ebef54af7d4bfb00bf14350893b15e1b3", + "backend/src/domus/composition/ingress.py": "7447608e616cd5a70652b1c02c5ddab81ea42fc8c6228cb1a0eecd60e2d029a9", + "backend/src/domus/entrypoints/native_http.py": "59dce651bbab80a36a0bec435c581b247ad9b8aac6b53406bd577cbd0a7b15c7", + "tests/containers/test_app_image.py": "dd59fde501cdef7cc7d15d43468fecdbb750f161eb861ac038a34b4485a6705a" + } +} diff --git a/docs/evidence/m1-policy-administration.json b/docs/evidence/m1-policy-administration.json new file mode 100644 index 0000000..e392023 --- /dev/null +++ b/docs/evidence/m1-policy-administration.json @@ -0,0 +1,68 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "508d4143ea6b8c813c729a2f0d597a9b178e9226", + "scope": "Application-layer household policy edits and shared administrator proof preparation; no public HTTP or installed image change", + "testFirst": "Policy behavior tests initially failed because policy_commands did not exist, then passed after implementation", + "backendTests": 545, + "policyTests": 23, + "backendCoverage": { + "covered_lines": 1884, + "num_statements": 1884, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 104, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 504, + "num_partial_branches": 0, + "covered_branches": 504, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "command": ".venv/bin/pytest tests/backend -q -W error::ResourceWarning --cov=domus --cov-branch --cov-report=term-missing", + "assertions": [ + "Revisioned profile/mapping changes commit policy, receipt, audit and outbox together without rewriting members", + "Selected eligible ordinary HA users can hold deliberate Domus administrator authority and edit policy", + "Changing mapping removes inherited grants and preserves independent explicit administrator profiles", + "Setup-only mode binds the unique committed setup actor; missing, malformed or ambiguous audit provenance fails closed", + "Selected/setup targets must be active linked eligible humans; setup target must remain a current HA administrator", + "No external lookup holds the SQLite writer lock; fresh actor reproof follows external preparation", + "Current authority, unchanged member/policy snapshot and a remaining eligible administrator are checked in the writer transaction", + "Source outage or confirmed lack of another eligible administrator leaves the existing policy unchanged", + "An interleaved member/policy change is a conflict instead of silently overwriting current authority", + "Canonical rule order, authorization before receipt replay, stale revision conflicts and restart durability are verified", + "All previously passing member concurrency and same-key activation cases remain passing after shared proof extraction" + ], + "architecture": { + "files": 69, + "dependencies": 219, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 38 backend source files" + }, + "schema": "Still schema 2; bounded setup-provenance read uses existing immutable audit without a data migration", + "priorHeadCI": { + "head": "508d4143ea6b8c813c729a2f0d597a9b178e9226", + "url": "https://github.com/vypdev/domus/actions/runs/41189807524", + "result": "PASS: all project-required groups including native amd64/aarch64 containers; not acceptance of later source" + }, + "installedPreview": "Still stateless 0.0.0-edge.1, immutable runtime 98bd12f1bf141ba64053730b2686bd848aee1b1c; these commands are not installed", + "remaining": [ + "Public command/query API and UI", + "Console-approved recovery", + "Snapshot/restore", + "Native installation persistence and local read-grace sessions", + "Complete standalone/channel acceptance" + ], + "sourceSha256": { + "backend/src/domus/application/administrator_proof.py": "c4735b83498970eddbde8a69c9e653829545b0589737df25ce76d2b536b0adfc", + "backend/src/domus/application/policy_commands.py": "bc588b5b5feb72fd5cb57d7b4a10ef5a05ae48da5ec042fd569142ce50fedeb6", + "backend/src/domus/application/member_commands.py": "d3e93982d2e42d9a57771269ec0ccd4692fcd196189922bb09e7b0398b924649", + "backend/src/domus/application/ports.py": "c10bf976c39def1dd163c5d21b04135bce75631d33e99ad9735e8f1a12fcf38c", + "backend/src/domus/infrastructure/sqlite.py": "fe6e04aedcf70f25218038497b139c14a4c96398e0f8f6bb924660ccaf94110e", + "tests/backend/test_policy_commands.py": "e0e69dab7d2942838fe05136c6c5ffdb7e027dcc52f8c9df78f07fd3e9b1728e", + "tests/backend/test_member_commands.py": "4daf5f8efefab0c9580027a4ae770997ed76b315d5295f6f7bd1e7cb155f53cd" + } +} diff --git a/docs/evidence/m1-restoration-app-package.json b/docs/evidence/m1-restoration-app-package.json new file mode 100644 index 0000000..019353f --- /dev/null +++ b/docs/evidence/m1-restoration-app-package.json @@ -0,0 +1,118 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5", + "packageVersion": "0.0.0-edge.3", + "runtimeCommit": "faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5", + "runtimeArchiveSha256": "15bcd701696204b3362cebe5901feb3d0072c9bac1db2ef2cbc5007e9ed73717", + "sourceArchiveVerification": { + "commit": "faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5", + "archiveSha256": "15bcd701696204b3362cebe5901feb3d0072c9bac1db2ef2cbc5007e9ed73717", + "matchedTrackedFiles": 272, + "comparison": "Every archive regular file matched its exact git-tree blob; no extraction or execution", + "sourceByteIdentical": true + }, + "localImage": { + "tag": "domus-edge-review:0.0.0-edge.3", + "id": "sha256:9ff296232909a4042c7b33c1787c5674f2f0017ff23a08eca43c30e968f50d15", + "architecture": "arm64", + "python": "3.14.6", + "sqlite": "3.46.1", + "buildCommand": "docker build --build-arg BUILD_ARCH=aarch64 -t domus-edge-review:0.0.0-edge.3 addon-edge", + "runtimePinOverridden": false + }, + "previousImage": { + "tag": "domus-edge-review:0.0.0-edge.2", + "id": "sha256:5d3c87fd549d40c82ec3e60d6b3c4a778579dea1cafceddedecd865de13ce32d", + "runtimeCommit": "d013cf2c8b932d0e00f5858053cbc0e663d5eee1", + "schema": 3 + }, + "containerTests": 15, + "containerCommand": "DOMUS_TEST_IMAGE=domus-edge-review:0.0.0-edge.3 DOMUS_PREVIOUS_IMAGE=domus-edge-review:0.0.0-edge.2 .venv/bin/pytest tests/containers -q --no-cov --tb=short", + "containerSeconds": 126.61, + "backendTests": 1299, + "backendCoverage": { + "covered_lines": 4684, + "num_statements": 4684, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 132, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 1302, + "num_partial_branches": 0, + "covered_branches": 1302, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "assertions": [ + "The exact default Supervisor folder builds reviewed source/checksum/frozen dependencies and reports the correct immutable runtime/image version", + "Actual packaged Python3.14.6 and SQLite3.46.1 execute native and standalone production listeners as the runtime UID; unauthenticated access does not obtain a session", + "Real console snapshot/prepare/independent inspect/activate/status/resume commands run as UID65532 with no network or host port; runtime and offline leases exclude concurrent activation", + "Both compositions restart on verified selected state; later commands replay as historical with unchanged facts, real acknowledgement or suppressed delivery, and correct private ownership/modes", + "Source-checkpoint rollback reconciles newer work, preserves three original operations and event sequence, and retrying an old activation reports historical without switching back", + "Real process termination after durable intent and after root pointer replacement blocks both runtime starts; same-input activation or same-ID sealed-input resume recovers exactly once", + "Actual Edge.2/schema-3 image data migrates to4 preserving UUID and original one-effect command replay; its pre-upgrade snapshot is inspectable and restores through target-only migration", + "The actual older image refuses flat future schema and selected generations, preserving the hash and inventory of every installation file; the new image can restart afterwards", + "CI builds both the current default package and separately pinned immutable previous image on native amd64/aarch64 before every container suite", + "Every owned container and named volume is removed by its fixture; current and rollback images remain retained" + ], + "testFirst": "Package tests first failed expected new version/source pins before changing the package. Initial image cases caught a missing configured Host in the standalone test probe; the probe was corrected without weakening production host/origin checks. Final15 pass.", + "tooling": { + "tests": 66, + "statements": 73, + "branches": 34, + "coveragePercent": 100, + "protectedCopilotResourcesUnchanged": 29, + "ruff": "PASS", + "mypy": "PASS: 68 backend + 4 tooling modules", + "architectureNegativeFixtures": 41, + "applicationContractsKept": 3, + "applicationContractsBroken": 0, + "graphFiles": 103, + "graphDependencies": 542 + }, + "runtimeSourceCI": { + "head": "faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5", + "url": "https://github.com/vypdev/domus/actions/runs/41220481891", + "result": "PASS: all project groups; those image cases still used Edge.2, separate from this new package proof" + }, + "installedTarget": "Owner Edge.2 remains installed on schema3; no update, owner snapshot, restore, household/member/recovery grant, device or user changes in this increment.", + "limits": [ + "Pre-publication local arm64 image evidence; exact package-head native amd64/aarch64 CI is a separate required gate", + "Disposable fixtures use fictitious human/command authority and no HA network; not live HA sign-in, household or restore acceptance", + "Both production entrypoints are exercised from the image, but full standalone distribution/companion/browser and all channel/release acceptance remain pending", + "No automatic snapshot schedule, retention/pruning or root safety-metadata editing", + "Complete current-head Bugbot review with no unresolved findings and full supported coverage remain merge conditions; draft/skipped/neutral review is insufficient" + ], + "sourceSha256": { + "addon-edge/Dockerfile": "0b7c878e9af9bfa36e72fd9de3fe97c115e088cbe55a9680106701352d4afac8", + "addon-edge/config.yaml": "b20cbe1daa7e163b668ec100ee1b19864414767401761a7c8dbfd42d312e2e10", + "addon-edge/README.md": "dea41515d94d2017c6b3ac2413ef4ed58579e6a038cfc58841e58b1138527d14", + "addon-edge/DOCS.md": "fadbd32c89704e485ece3f51aa8af20a44bfba337a29a66bf2ff5daaf9d1512e", + "addon-edge/CHANGELOG.md": "a293b9707ecdb50a8c450e950b8b8631567131831651962ebcd84a7ae704da03", + "tests/backend/test_app_package.py": "77d6ebd8e1295a3edb17b7fb9e260766a34d72903897feb7f3019d7d9a47abbc", + "tests/containers/test_app_image.py": "7d7679d9b8520e591e7fa10f7dd2cfc115ee9f93061b565728ae6a9a739199bd", + "tests/containers/test_app_compatibility.py": "ae4d567df8662e7b5c2afd7c1c97fb7c4906a5f60450d5174cd5b98c90b7185e", + "tests/containers/restore_fixture.py": "45bc4b9603102fc5af2e23efc23f1e2c4e318093dab665616b42fdd62f3de756", + ".github/workflows/ci_check.yml": "6af0f129d2f778e4c5233dad6a38bbb53e09536e55ed68b87c7a930120423e0a", + "tests/python/test_ci_contract.py": "ef522bbe01f5954f8fb93e12605163b7ba855c6c06bd5ba20157a3cf393bbb13" + }, + "sddValidation": { + "restoration": { + "format": "PASS", + "consistency": "PASS", + "canonicalSections": 20, + "characters": 31520 + }, + "primaryCompanion": { + "format": "PASS", + "consistency": "PASS", + "characters": 69863 + }, + "catalog": "PASS: 11 capabilities, generated rendering current", + "validator": "Byte-verified unchanged upstream policies" + }, + "localDocumentation": "PASS" +} diff --git a/docs/evidence/m1-restoration-planning.json b/docs/evidence/m1-restoration-planning.json new file mode 100644 index 0000000..9e974b6 --- /dev/null +++ b/docs/evidence/m1-restoration-planning.json @@ -0,0 +1,127 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "03e5e8265b96f4407aeaff3d2b946db923cba771", + "scope": "A30 pure complete-operation reconciliation, semantic preparation/inspection and real private blocked SQLite candidate; runtime history integration and offline activation remain pending", + "backendTests": 1088, + "newRestorationTests": 134, + "backendCoverage": { + "covered_lines": 3761, + "num_statements": 3761, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 126, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 992, + "num_partial_branches": 0, + "covered_branches": 992, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "command": "COVERAGE_FILE=/private/tmp/domus-restoration-backend.coverage .venv/bin/pytest tests/backend -q -W error::ResourceWarning --tb=short --cov=domus --cov-branch --cov-report=json:/private/tmp/domus-restoration-coverage.json --cov-report=term:skip-covered", + "testFirst": "Pure evidence/reconciliation tests, then application/prepared-value tests, then real SQLite preparation tests each initially failed import before their corresponding module existed. Negative and concurrent cases followed as the adapter was implemented. A final-inspection deadline test first failed with DID NOT RAISE StorageFailure, then passed after adding the completion deadline check.", + "assertions": [ + "Complete immutable receipt/audit/event facts, command scope, ID/position uniqueness and monotonic acknowledgement are validated; clock order is not ancestry", + "Streaming reconciliation preserves first archive provenance, later historical facts and forward reselection without another execution; incomplete checkpoint prefixes conflict", + "A real coherent current read transaction remains fixed across a concurrent WAL commit; later activation must reject its stale binding", + "Preparation independently verifies the selected checkpoint, copies exact bytes without migration, streams complete operation history and publishes a bound manifest last", + "Empty checkpoint does not implicitly recreate a later household; compatible schema 1/2 checkpoint bytes remain unchanged", + "Both frozen schema fixtures remain unmodified; legacy preparation inputs replace only their intentionally dummy digest in a disposable database with a real 64-character digest", + "Managed startup/attachment and native/standalone explicit-file factories reject both complete and journal-bearing partial candidates without changing their files", + "Original source and snapshot bytes remain unchanged; targets never overwrite an existing complete or partial candidate", + "Independent inspection validates exact private inventory, hashes/size, blocked identity, schema/records, checkpoint projection/counts and position high-water", + "Rehashed malformed/deep/oversized journal or manifest, wrong identity/schema/last commit/size/current flags/counts, source conflicts and changed snapshot bytes fail", + "Output flush failure or deadline after manifest publication preserves uncertainty and leaves the same independently inspectable candidate", + "Independent inspection rechecks its deadline after the complete journal projection and cannot publish success if the final validation exhausts its budget" + ], + "format": { + "version": 1, + "files": [ + "domus.sqlite3", + "installation.json", + "operations.jsonl", + "restore.json" + ], + "identityState": "restore_pending", + "operationLineBytesMax": 262144, + "documentBytesMax": 65536, + "manifestBytesMax": 16384, + "directoryMode": "0700", + "fileMode": "0600", + "sourceSchemas": [ + 1, + 2, + 3 + ], + "schemaMigration": false + }, + "architecture": { + "files": 94, + "dependencies": 444, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 60 backend source files", + "negativeFixtures": 41 + }, + "tooling": { + "tests": 66, + "statements": 73, + "branches": 34, + "coveragePercent": 100, + "protectedCopilotResourcesUnchanged": 29, + "nodeTests": 11, + "typescript": "PASS", + "ruff": "PASS", + "localDocumentation": "PASS", + "toolingArchitectureContractsKept": 2 + }, + "runtime": { + "python": "3.14.6", + "sqlite": "3.53.3", + "scope": "Local real-file tests, not packaged restoration or owner HA acceptance" + }, + "installedRuntime": "Edge 0.0.0-edge.2 / d013cf2c8b932d0e00f5858053cbc0e663d5eee1 remains unchanged. No owner household, restore, recovery grant or device changes in this increment.", + "remaining": [ + "Import retained operations and later acknowledgements into runtime history storage without recreating checkpoint business rows", + "Authorize current actor before historical replay/disclosure and distinguish historical result from current committed state", + "Suppress historical outbox delivery while preserving actual acknowledgement and global sequence high-water", + "Prevent restoring consumed/canceled recovery approval under another command key", + "Stop writes, verify unchanged source binding, activate offline with durable transition/rollback and recover interruption", + "Expose the reviewed console flow, update migrations/contracts and verify both deployment modes and packaged SQLite", + "Product UI, standalone companion and complete domain/channel/release acceptance" + ], + "priorHeadCI": { + "head": "03e5e8265b96f4407aeaff3d2b946db923cba771", + "url": "https://github.com/vypdev/domus/actions/runs/41210424715", + "result": "PASS: all project groups, including both pinned real-image architectures; the existing package does not include or validate restoration sources" + }, + "review": "Draft PR remains unmerged; a skipped/neutral Bugbot check is not full current-head review acceptance", + "copilot": "Unchanged; terminal scope/assessment admission bugs remain tracked in vypdev/copilot#828 and #829 without check relaxation", + "sourceSha256": { + "backend/src/domus/domain/restoration.py": "adff09c838c534f28cd4daee817c9386dfb11f00dd52650d4deaf57ed208bad3", + "backend/src/domus/application/restoration.py": "32a301e9d733f453526dbba70dbe99c036d6485a4c9bc8483d4f52456534a6eb", + "backend/src/domus/infrastructure/sqlite_restoration.py": "da94614422789dc73663f7685bbaa6d75073668e3e350509b4c394b1108f6604", + "backend/src/domus/infrastructure/owned_storage.py": "9067ec47b84ce87d37b48f2883903510a2ad7fe731ac4c7f58857ee57a913414", + "tests/backend/test_restoration_values.py": "ec520c3caeaa5446fab493c8ab583c73dab71908223522adac9c9ff074dd39b4", + "tests/backend/test_restoration_commands.py": "faa0150e8c1fd78f6fb328dd4cdbbda7c80d97fab606ff14cb85ba8c76d10a99", + "tests/backend/test_sqlite_restoration.py": "5088d4a8b762e8932bcff4e243d6e7978802a78b70a3a9137624f37315cb5116" + }, + "sddValidation": { + "restoration": { + "canonicalSections": 20, + "characters": 22895, + "format": "PASS", + "consistency": "PASS", + "completeRestoreDone": false + }, + "primaryCompanion": { + "characters": 69851, + "format": "PASS", + "consistency": "PASS" + }, + "catalog": "PASS: 11 capabilities; generated rendering current", + "validator": "Byte-verified unchanged upstream policies; new companion checked against sourceBase inventory" + } +} diff --git a/docs/evidence/m1-review-fixes.json b/docs/evidence/m1-review-fixes.json new file mode 100644 index 0000000..a0518b4 --- /dev/null +++ b/docs/evidence/m1-review-fixes.json @@ -0,0 +1,74 @@ +{ + "observedOn": "2026-10-31", + "sourceBase": "d67475a92792a109eef67df1c1e98039a6bcd43f", + "pullRequest": 7, + "backend": { + "tests": 1407, + "statements": 4704, + "branches": 1308, + "missingStatements": 0, + "missingBranches": 0, + "percent": 100, + "python": "3.14.6", + "sqlite": "3.53.3" + }, + "testFirst": { + "newDistinctCases": 20, + "initialRun": "15 failed / 4 passed before fixes; the later real-console rendering regression brings the new total to 20", + "fullRun": "1407 passed; all authored backend modules covered without new exclusions" + }, + "fixes": [ + { + "finding": "default-port-origin-normalization", + "behavior": "Canonical configured origins omit scheme-default ports; native browser-origin writes succeed and unrelated ports remain denied; equivalent duplicate configurations fail closed." + }, + { + "finding": "console-approval-replay-after-consumption", + "behavior": "Read-only same-command console receipt lookup requires exact installation/actor/action/request binding and retains restoration closure guards. It may recover the original receipt after normal consumption/cancellation/expiry/policy change, without new confirmation, state change, grant, audit/event or deadline extension. Fresh approval/completion controls are unchanged." + }, + { + "finding": "callback-result-xor", + "behavior": "The generated actual callback operation documents code/error XOR; both invalid combinations return 422 without consuming the browser-bound attempt. Auth and composed household OpenAPI contracts are regenerated." + }, + { + "finding": "stale-operator-documentation", + "behavior": "Current schema4, migration1/2/3-to4, implemented native read grace and offline restore are distinguished from bounded package and pending live acceptance." + } + ], + "contractClarifications": [ + "Current published household routes are authoritative; historical proposed spellings remain archived rather than becoming a compatibility promise.", + "Partial snapshot destinations intentionally remain inspectable and are never silently overwritten or deleted; the explicit operator cleanup rule and real-file failure tests remain unchanged." + ], + "humanContentReview": { + "state": "confirmed", + "source": "Direct owner reply in this task on 2026-10-31", + "scope": "Pending/historical API explanations and exact target-bound console approval steps", + "doesNotEstablish": "Product UI/mobile acceptance, complete Bugbot review or release readiness" + }, + "review": "All current-head CI groups and complete Bugbot review without unresolved findings remain required before merge. Earlier partial reviews are not acceptance.", + "packageBoundary": "These source review fixes are not in immutable installed Edge.3 pin faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5. No owner deployment/configuration/data changed.", + "sourceSha256": { + "backend/src/domus/infrastructure/ha_config.py": "693cdca3fee12c5def5393e3f7d4eff16fe539e44619cc346f600bfbddbfc1cf", + "backend/src/domus/application/recovery_commands.py": "2b8c198cde97f0213ec1612ea32bce5e00637aab99862fe08b59418d3c64af49", + "backend/src/domus/composition/admin.py": "020abd88d8b9f26a429169bd1a51dd7b7ae960f0a96b53e2099bd2321debbff5", + "backend/src/domus/entrypoints/http.py": "5d194cef73aa9fa7edea2a1e292c09e6345ecc9f7002540bce4c8b85c4158cf7", + "tests/backend/test_ha_http.py": "dbd88c7f9281997c80f419619afa533f384f0584c228065c3774642a02118c41", + "tests/backend/test_review_regressions.py": "4f2891f6985969883ef34a05405527a5500987b9883dc5e1cb1a695ee6a41bf4", + "docs/contracts/auth-api.openapi.json": "3559a1548e4920ab38d3b5b25c5b1b62d93e52fb0e993543fa2af407b1aa0d0f", + "docs/contracts/household-api.openapi.json": "c757e63fed28cf46247b8379fa9faee23a5b694f5841967d781a09d8febff44f" + }, + "canonicalIPFollowup": { + "sourceBase": "e26a1e6390c7a5136d5d195af37f1d6f52c00372", + "behavior": "Expanded IPv6 literals serialize to their canonical compressed address as browsers do; default ports are omitted and non-default ports retained. Exact Origin denial remains in force.", + "testFirst": "Four additional unit/native composed regressions failed before IP canonicalization; then the complete backend suite passed.", + "reviewRegressionCases": 24, + "backendTests": 1411, + "statements": 4704, + "branches": 1308, + "percent": 100.0, + "sourceSha256": { + "backend/src/domus/infrastructure/ha_config.py": "e9a33ffe459b45d9b7efe946e8991c4c7363fb6d4d83b0d8d3923deb214c095f", + "tests/backend/test_review_regressions.py": "6d4279ad5ad54e359fd3e3182d0c27a3c6146742230a0c81e11566e27db855cb" + } + } +} diff --git a/docs/evidence/m1-runtime-history.json b/docs/evidence/m1-runtime-history.json new file mode 100644 index 0000000..baef2af --- /dev/null +++ b/docs/evidence/m1-runtime-history.json @@ -0,0 +1,117 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "002a982ee60b8aa22a707de402ab1e8fc0306be5", + "scope": "Schema-4 history-aware runtime receipt/delivery lookup, transactional retained-fact import, sequence continuity, truthful responses in both APIs and guards against rearming consumed/canceled recovery; verified offline activation remains pending", + "backendTests": 1140, + "backendCoverage": { + "covered_lines": 3959, + "num_statements": 3959, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 127, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 1068, + "num_partial_branches": 0, + "covered_branches": 1068, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "command": "COVERAGE_FILE=/private/tmp/domus-history-backend.coverage .venv/bin/pytest tests/backend -q -W error::ResourceWarning --tb=short --cov=domus --cov-branch --cov-report=json:/private/tmp/domus-history-coverage.json --cov-report=term:skip-covered", + "newExecutedCases": 52, + "assertions": [ + "Retained complete original receipt/audit/event facts have no current-household foreign key and do not recreate checkpoint business rows", + "Caller-owned transaction plus import savepoint preserve earlier facts even when a caller catches a conflict and commits; duplicate positions/IDs, missing prior history/current facts, changed immutable facts, provenance and acknowledgement regression reject atomically", + "Current receipt lookup falls back to retained history; current authority precedes replay and own-operation disclosure; both standalone and native composed APIs report historical state and suppressed delivery truthfully", + "Historical undelivered events remain absent from pending delivery and reject acknowledgement; actual prior acknowledgements remain delivered; current events may advance acknowledgement", + "New event positions stay above retained high-water; current setup provenance reads only current audit and historical setup does not create a household", + "Recovery terminal guards derive and cross-check an indexed request ID from original results; new-key completion/cancellation and console approval cannot reuse a restored old approval", + "Original-key recovery replay may return a historical result after existing current authority checks; another human receives scoped NotFound before closure disclosure; guarded requests do not consume pending capacity", + "Console replay of a rolled-away approval reports historical/no-current-effect rather than claiming a current approval or requesting another terminal confirmation", + "Independent frozen v1/v2/v3 fixtures upgrade additively to schema 4 without losing receipt/audit/outbox data; an injected history-index failure rolls back the schema-3 upgrade and retains exact original bytes", + "New snapshots and repeated preparation preserve prior history, first archive and actual delivery; checkpoint last_committed_at summarizes all retained receipts without serving as ancestry proof", + "Corrupt/mismatched history documents or indexed fields, current projection, sequence type/duplicates/regression and invalid delivery cannot verify", + "Three independently valid 60,000-character result/audit/event documents survive real SQLite import and lookup without an incorrect aggregate 65,536-byte limit" + ], + "testFirst": "New history tests first failed import for missing RestorationProvenance. Historical HTTP tests first returned committed and recovery-rearm tests DID NOT RAISE Conflict. Retained-history snapshot first returned null last_committed_at instead of the original receipt time. Each corresponding behavior was then implemented and reverified.", + "architecture": { + "files": 96, + "dependencies": 458, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 61 backend source files", + "negativeFixtures": 41 + }, + "tooling": { + "tests": 66, + "statements": 73, + "branches": 34, + "coveragePercent": 100, + "protectedCopilotResourcesUnchanged": 29, + "nodeTests": 11, + "typescript": "PASS", + "ruff": "PASS", + "localDocumentation": "PASS" + }, + "frozenSchema3": { + "source": "002a982ee60b8aa22a707de402ab1e8fc0306be5", + "path": "tests/fixtures/sqlite/v3.sql", + "capture": "Captured real schema/data and version/application markers before editing schema constants; never regenerated from schema 4" + }, + "runtime": { + "python": "3.14.6", + "sqlite": "3.53.3", + "scope": "Local/CI real SQLite and composed ASGI fixtures; synthetic offline substitution is not the production activation protocol" + }, + "installedRuntime": "Owner Edge.2 / d013cf2c8b932d0e00f5858053cbc0e663d5eee1 / schema 3 remains unchanged. No owner household, recovery grant, restore, device or user change.", + "priorHeadCI": { + "head": "002a982ee60b8aa22a707de402ab1e8fc0306be5", + "url": "https://github.com/vypdev/domus/actions/runs/41210642698", + "result": "PASS for all project groups; pinned real-image checks do not validate unpackaged schema-4 history sources" + }, + "remaining": [ + "Stopped-write source revalidation and verified migration/import on a blocked candidate", + "Durable offline activation with retained rollback and interruption recovery in both modes", + "Reviewed restore console and complete actual-image/owner restoration acceptance", + "Product UI, standalone companion and full deployment/channel/domain/release acceptance" + ], + "copilot": "Untouched. Existing scope/assessment admission errors reported in vypdev/copilot#828/#829. Draft/skipped Bugbot does not satisfy the complete current-head no-findings merge gate.", + "sourceSha256": { + "backend/src/domus/domain/storage.py": "9c72aedf54c63d6fa0d85f0e8c044d56c7ceefc23eea123b45d096da96a6c75d", + "backend/src/domus/domain/recovery.py": "0bbe731961d4481e8002382ab0c9948fd415534b0cbbc03bdf0a0e35676f0cb0", + "backend/src/domus/domain/restoration.py": "ae49272c569367f3838e4c620c3663e09915c0e13d8367ab78eb8a6510d34a0b", + "backend/src/domus/application/ports.py": "9a69ed47ea778d4cc0879b14aa8a6a495c2b5ac773d4571e882e2e24c60ac94d", + "backend/src/domus/application/recovery_commands.py": "db557340004facafcf8ba1f8556f5930444cdd03087a912526e11d13295fccd4", + "backend/src/domus/infrastructure/sqlite.py": "6141041285514d019a1c4522b7679328ba585dba0eff9e91ccc83bd969e34516", + "backend/src/domus/infrastructure/sqlite_history.py": "e7f8d4f2b79aed851a4056364f67d1c7806c52d011b46c597968bea86dd1d144", + "backend/src/domus/infrastructure/sqlite_recovery.py": "01fa6b8896674ce8203073c468eccb7033a9824052d7db3a853c80a2df6e2650", + "backend/src/domus/infrastructure/sqlite_snapshots.py": "6fda2192a3c9bb17559e2402071288ae7a5cfeb36539eba325831b51c3314f2b", + "backend/src/domus/infrastructure/sqlite_restoration.py": "9fee481e4d4a912f2918758e6310b477e5d70268a95c9406171bd26156eb49a6", + "backend/src/domus/entrypoints/household_http.py": "dd320215ad4adaa2f4e6acc5654ab28ab9cde3582aa220641167d48f71e92ea4", + "backend/src/domus/composition/admin.py": "4cee63ea117b272ee9d9b1dbd07fc08ce059465f3f173eb48f392a0ea1ab9319", + "tests/backend/test_sqlite_history.py": "aea9143e2eab56315212d6420f46f2ae36d6b7fc6da0291eb735bf8c705b45ec", + "tests/backend/test_sqlite_history_integrity.py": "32412cb036e9e06a7bfc0c8e993737db936b83644c5a1021238efcf8e4a74fa7", + "tests/backend/test_restored_commands.py": "655d0f66efe32388c0c553acdd9bc60bf74bc3b2b58af0a699b7ed6335a8be10", + "tests/fixtures/sqlite/v3.sql": "6da0e3610518a9f6ecc20a93347feca423b9408fde7216c43ace657cad654cb0", + "docs/contracts/household-api.openapi.json": "fb7bb13acc1d1c3f0c7b01f157b9edb9c104087758e556434085bffbd8c7d1ed", + "docs/contracts/native-household-api.openapi.json": "f838eafa44157752a6538f4ae61f4cb29963a10e85bb5da224dd5b1c0fc443dd" + }, + "sddValidation": { + "restoration": { + "canonicalSections": 20, + "characters": 26223, + "format": "PASS", + "consistency": "PASS" + }, + "primaryCompanion": { + "characters": 69863, + "format": "PASS", + "consistency": "PASS", + "scope": "Original requirements and all planned cases retained; only checkpoint wording changed" + }, + "catalog": "PASS: 11 capabilities; full M1 remains draft", + "validator": "Byte-verified unchanged upstream policies; no Copilot changes" + } +} diff --git a/docs/evidence/m1-snapshots.json b/docs/evidence/m1-snapshots.json new file mode 100644 index 0000000..8bc8272 --- /dev/null +++ b/docs/evidence/m1-snapshots.json @@ -0,0 +1,94 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "58d88faf1026d31007a805ebde8290ab12af2cab", + "scope": "Manual private SQLite snapshot creation and inspection, pure manifest/application contracts and console composition; restore activation and newer-history policy remain pending", + "backendTests": 892, + "backendCoverage": { + "covered_lines": 3309, + "num_statements": 3309, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 122, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 862, + "num_partial_branches": 0, + "covered_branches": 862, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "command": "COVERAGE_FILE=/private/tmp/domus-snapshot-backend.coverage .venv/bin/pytest tests/backend -q -W error::ResourceWarning --cov=domus --cov-branch --cov-report=term-missing", + "testFirst": "Manifest, archive and application tests first failed import because their modules did not exist; console tests first failed because snapshot was not an admitted subcommand.", + "assertions": [ + "One pinned read transaction and the SQLite backup API produce a coherent committed database including receipt/audit/outbox and recovery records", + "A real concurrent WAL change commits to the source while the archive preserves the earlier coherent household and evidence view", + "Private destination is created exclusively, never replacing an existing complete or partial output; manifest publication occurs only after verification/hash/flush steps", + "Output failure after successful publication reports uncertainty; the intact result remains independently inspectable", + "Inspection requires only the expected installation UUID/channel, without access to an active source; it never migrates an archive", + "Exact private file inventory, regular single-link files, owner/mode/no-follow checks and bounded strict metadata reject incomplete/unsafe bundles", + "Hashes/size, matching ready identity, exact compatible schema, physical integrity, foreign keys, singleton/delivery constraints, domain record parsing and atomic evidence linkage are cross-checked", + "Real physical index damage, missing evidence, unknown columns, corrupt JSON, mismatched checkpoint/schema/identity and altered bytes are rejected", + "Logical deadline checks bound backup/hash steps and interrupt SQLite query work; failures retain private diagnostics without a success manifest", + "Historical v1/v2 fixtures inspect without rewriting/migrating their files; new capture requires initialized current schema 3", + "Application ports reject a result for another request or installation; domain/application remain free of concrete SQL/filesystem/clock dependencies", + "Both native owned-data attachment and explicit standalone source selection work through the registered domus-admin entrypoint; inspection has no HA token or HTTP route" + ], + "architecture": { + "files": 89, + "dependencies": 400, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 56 backend source files", + "negativeFixtures": 41 + }, + "tooling": { + "tests": 66, + "statements": 73, + "branches": 34, + "coveragePercent": 100, + "protectedCopilotResourcesUnchanged": 29 + }, + "runtime": { + "python": "3.14.6", + "sqlite": "3.53.3", + "scope": "local backend/real-file tests; not owner native package acceptance" + }, + "format": "Version 1 private directory with domus.sqlite3, ready installation.json and manifest.json. Business schema remains 3. Auth credentials/local leases are not persisted in business schema or collected from other sources.", + "checkpoint": "captured_at is the local UTC reading within the selected view; last_committed_at is the greatest retained receipt timestamp or null before any command; hashes/ID identify the exact artifact", + "remaining": [ + "Restore preparation/activation into a new same-installation/channel directory with writes stopped", + "Owner choice on newer replay history when restoring an earlier checkpoint", + "Native private-directory provisioning and updated package/live acceptance", + "Standalone companion authority, product UI and full deployment/channel/domain acceptance" + ], + "installedPreview": "Edge 0.0.0-edge.1 / runtime 98bd12f1bf141ba64053730b2686bd848aee1b1c is unchanged. No owner HA backup, recovery grant or household data was created in this increment.", + "priorHeadCI": { + "head": "58d88faf1026d31007a805ebde8290ab12af2cab", + "url": "https://github.com/vypdev/domus/actions/runs/41199857142", + "result": "PASS: all six required project groups; not proof for later source" + }, + "primaryReferences": [ + "https://docs.python.org/3.14/library/sqlite3.html#sqlite3.Connection.backup", + "https://www.sqlite.org/backup.html" + ], + "copilot": "Unchanged. Scope admission failures tracked in vypdev/copilot#828; assessment admission failure in #829. No checks or merge review gates were relaxed.", + "sourceSha256": { + "backend/src/domus/domain/snapshots.py": "5d2dd581122a0d720e36d06196461878c5e8ecd5e49d5e924deceb95a4dfd36a", + "backend/src/domus/application/snapshots.py": "c4f0862f590d222711018de3b23a7155f5702375ce6ece7dd0e2170c86a22ca4", + "backend/src/domus/infrastructure/sqlite_snapshots.py": "156491e1146df811b9ea89ecff43f14de12a1be25a949cb7c3394740ea223e07", + "backend/src/domus/composition/admin.py": "51837e1adcfe15377cf46efe42f398f34d09413ed07f7e610e9dd50be42a5a3b", + "tests/backend/test_snapshot_values.py": "3ab87ef3b77e19fec92b2ecf0336a24596c542e25f6d6aeb243ebab2ac6c2df8", + "tests/backend/test_snapshot_commands.py": "1b4a41f183cb687fc9c43d605eed3abe75afa1a5d454bc458620035afdada988", + "tests/backend/test_sqlite_snapshots.py": "e1b2d89dbfa47986c2c71c3c270232e66fc06e0f549f295311993c2434b20a04", + "tests/backend/test_snapshot_console.py": "60dcbd33dc5e60037b6e4c74f9985b538be3ca3f877a34c2be4561ee5baf95ea" + }, + "historicalEvidence": { + "verifiedSourceHead": "f9220511448d87bf8766ead2d677b5cd88c8e91b", + "meaning": "All measurements, schema numbers, assertions and sourceSha256 values describe this exact historical source tree, not a later reviewed head. The pre-change sourceBase remains provenance of the preparation baseline.", + "verification": "Every recorded sourceSha256 was checked against git show :; all match. Original measurements and digests are retained unchanged.", + "currentEvidence": "docs/evidence/m1-review-fixes.json", + "currentGuide": "docs/snapshots.md" + } +} diff --git a/docs/evidence/m1-standalone-auth.json b/docs/evidence/m1-standalone-auth.json new file mode 100644 index 0000000..620e2b8 --- /dev/null +++ b/docs/evidence/m1-standalone-auth.json @@ -0,0 +1,106 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "16999cd3635a8f475c04187e7e1e3c7caec83e86", + "issue": "https://github.com/vypdev/domus/issues/6", + "pullRequest": "https://github.com/vypdev/domus/pull/7", + "scope": "Standalone authentication API/CLI checkpoint identified by source hashes", + "tests": { + "backend": 375, + "tooling": 65, + "architectureFixtures": 39, + "nodePolicy": 11, + "totalExecuted": 490 + }, + "backendCoverage": { + "covered_lines": 1409, + "num_statements": 1409, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 89, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 380, + "num_partial_branches": 0, + "covered_branches": 380, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "implementation": "Browser-bound single-use state/PKCE, private credential sessions, strict cookie/origin/CSRF/HTTP boundaries, actual OpenAPI and standalone authentication CLI; no household is created on sign-in", + "liveEvidence": "PENDING: no owner HA authorization or login has been performed by this checkpoint", + "acceptanceLimits": "Offline fixtures and a prepared auth-only runtime do not complete the 61-case companion, both deployment targets or first-release household scope.", + "coverageDeclarationNote": "No custom omissions/exclusions; default non-executable Protocol/ellipsis declarations are separately reported. Supported executable statements and branches are measured independently from tooling.", + "existingDependencyVersionsUnchanged": true, + "dependencyNotes": "FastAPI 0.162.0 and Uvicorn 0.54.0 are pinned runtime dependencies. httpx2 2.16.0 is pinned development support for the current Starlette TestClient; HA transport remains independently tested HTTPX 0.28.1.", + "checks": { + "backend": "PASS", + "ruff": "PASS", + "mypy": "PASS", + "architectureFixtures": "PASS: 39 cases", + "actualApplicationImportGraph": "PASS: 59 files, 152 dependencies, 3 contracts", + "fullLocalCampaign": "PASS: 490 tests, backend/tooling/Node coverage, both import graphs, type checks, formatting, documentation and unchanged original SDD/catalog validators", + "currentHeadGitHub": "PENDING: commit/push follows local verification", + "currentHeadBugbot": "PENDING: draft remains incomplete; neutral/skipped/incomplete review is insufficient" + }, + "pending": [ + "Complete household/member/policy/operation/recovery API and commands", + "Console-bound recovery approval and consistent snapshot/restore", + "Supervisor session/private listener, prefixed routing and package acceptance", + "Exact live HA and Stable/Edge artifact evidence", + "Complete current-head Bugbot review" + ], + "sourceSha256": { + "backend/src/domus/__init__.py": "7182c88b92234b4328f94497f4e70222d4390d8093e4b62893dfa78ac5530ac6", + "backend/src/domus/application/__init__.py": "1e8c0c87bf2a8ca947112543070583c0cc55d9d40b56eab0d86ed59a4ec1e82c", + "backend/src/domus/application/authentication.py": "321f9e0ba1d97dc84aac3dd016652d92ec9ff4912a6092d6e8cefb9d61e29998", + "backend/src/domus/application/authentication_values.py": "e83491791e6d323e1fb12aaafb11fc39d2f89a12d10c9ae415c055a1e33aa314", + "backend/src/domus/application/authorization.py": "4ccffade393fc2bac2192fee8909f28f290dae0c6fd3913c87433338101bc52a", + "backend/src/domus/application/commands.py": "9b3d25f161cc0a2946b268d37c325eae8e980aa9254325ccb9260af4da98a8c8", + "backend/src/domus/application/identity_ports.py": "8221e819ba4e9f77908d9a8b9d675e5ebb9ffc772f00948dcde4604ff7ef5808", + "backend/src/domus/application/ports.py": "3ffa5afaa9c59e8c67d231f9b4521984916d18bfc5ab1fb12066d3242c90795e", + "backend/src/domus/application/session_values.py": "f4d7f824267757b2f3b8b6c8e6ea2715ec4a36838533de914e7feb2cdefa8fd2", + "backend/src/domus/application/sessions.py": "4e21d9b3bd917a18b8b996cc43757cd1a341a0a53d4ed2b3db77688f0fbf5a8c", + "backend/src/domus/composition/__init__.py": "a902d8faf7dd19da6829fb730d81e32d34ec38ee6cfafd676966a95d0ad31366", + "backend/src/domus/composition/server.py": "dd201bd8779ad8e768019910a036db414d5eca494a5f7e73beec5098377e6cfe", + "backend/src/domus/domain/__init__.py": "0aac8a2ba41471d7a174ef7baca6073c95009a9db96df3e032392aa4f80bd079", + "backend/src/domus/domain/errors.py": "0768709e9c0443eee15c9d0fdc61128d5db441824785f06e13bc1e481cabb6a4", + "backend/src/domus/domain/identity.py": "61ecb5024213921b02d6c4c6a2d338c7c79fb9e8129b417f11a9f40887b13122", + "backend/src/domus/domain/storage.py": "56e1800464ce380f6b7a6b3ded4d9123eb85639e28e3dc171e89b1865a42be14", + "backend/src/domus/entrypoints/__init__.py": "d46875cbfef7220c7244bf1b89112ece8122ecb0d66cd908f08f098f0f3c419f", + "backend/src/domus/entrypoints/http.py": "a02a7e37cdc066668d670041b7d9d2721d26707ac97e5fa6dd6614e025134cc0", + "backend/src/domus/infrastructure/__init__.py": "ec6cb2d48fb3178de856dcbeefb7b14bc193fe78ac68a53f9afcf2bd9cac0699", + "backend/src/domus/infrastructure/auth_runtime.py": "414ac5ab890db991272a2fe9d7817ad1995052d2d09e1e9dd07d2631ef92de65", + "backend/src/domus/infrastructure/ha_config.py": "ef6029bba313eda6191a18751cf283ef96faf241b9d4cb2dc781f0f2d5be9db5", + "backend/src/domus/infrastructure/ha_http.py": "638cc3d918689d1392adb57332c2136160e9745a29b7c84d8fe99363fb358673", + "backend/src/domus/infrastructure/ha_ingress.py": "081038945db0fea51b0fa45889c2a2ac9a1aa45b67ae0898be20fb1f7861b609", + "backend/src/domus/infrastructure/ha_websocket.py": "3286011f6a25f557f119ad277f4ba9dea850ae7cac189e6867032cec06b16334", + "backend/src/domus/infrastructure/memory_attempts.py": "b1f739ddbbdb072a4961410b9775ae191581edf6135d0ae359bf421cc8357dfc", + "backend/src/domus/infrastructure/memory_sessions.py": "a1725225ca45e9610cb158b2a628cfd0e368fdfc17791aec18dec2983fb4d063", + "backend/src/domus/infrastructure/sqlite.py": "45e9669c61dc2d8cfc89f3f02b296fb17b5d32ba4486bdce0d14457de2d89da7", + "backend/src/domus/infrastructure/sqlite_identity.py": "091994c671189707e5299ef093887d68307ca4e829e53a629569b593527bc082", + "backend/src/domus/infrastructure/standalone_sessions.py": "7caf36974dd77db77847534041aac25f835decb5e95ed054be82078d2b0e8fc4", + "tests/backend/test_atomic_commands.py": "a17ed9172736e0f7fb0dfbb8d9186fb45ba9a922fa3c26648bff7266ce7a4042", + "tests/backend/test_auth_http.py": "d60f5003b90b866dfc3dd91f919e8327054625cf8e0cee64ae6de52ce7144edf", + "tests/backend/test_auth_runtime.py": "749e92af108ac84908c36408bddb93aaad55aa15d62282bdc8a66cc30983d157", + "tests/backend/test_auth_server.py": "748f6a89f80dff6336e3a9c5f04cb6098d9666c40db5be5f6c8bb8c598db7109", + "tests/backend/test_authentication_flow.py": "df4fd1e0af08bb15c7ce7f027acef50ee0f40dd3e37d7ef9ef485b09d958f36d", + "tests/backend/test_ha_http.py": "2525675d56c2314c30984d502f2e6578b076947b33589201749e5c691c7405d2", + "tests/backend/test_ha_ingress.py": "f3828d00a9e8466bc7ff0ea814ea9975a263af8463360c0bc83090657d13b0aa", + "tests/backend/test_ha_websocket.py": "99eccc06b1fa048a47838e30e20f41dbda18b09c48ca6b508a1244aaf03151f2", + "tests/backend/test_identity_policy.py": "67be51abb19f40a227c05c491aba48cf4ee411cb0095f09c3cfaafa65992c566", + "tests/backend/test_membership_authorization.py": "72d4323b4c7adf94deda6fc43f737b0dfdd369c8e9331c4af2403c6f7f37df43", + "tests/backend/test_session_leases.py": "218bb4a72444ce64fcf4f47dc563c75018a17602d63c2cdc9b937166edbe3a14", + "tests/backend/test_sqlite_identity.py": "8b1e6ab2693ec4023a4b0b52df213204e763d27b4c89ca8346d6f063b5e3a59a", + "tests/backend/test_sqlite_transactions.py": "af6c25f8f093d564c500aaaffbdc5bccb5a1d30172788ad7d02ab877c44cba78", + "tests/backend/test_standalone_sessions.py": "af1be05e6f987794bb154ecb9bd1c5cba7614077b4b90abde27d07b70bc4cf78", + "tests/backend/test_storage_values.py": "d72420955965e48be0c089339273a6a162a00bb66f9997376871304713fdd668", + "backend/pyproject.toml": "cf082fb163b67f77f758131507a6065e36244a40eef3d68cac48844b10d1b2c2", + "pyproject.toml": "c91ec95a30232ae4a63009c7323050f6778e0cf799a067d8fda9d7a6f85c9ace", + "uv.lock": "c31aed816ac0694704236f529434f003f4c706ccf84ffdce6a59da537cc913b5", + "architecture/python-application.ini": "32656da26ff928c71ea06a3f4764cfbeec6127c4198cded2f9ba84e4fea6d569", + "tests/architecture/test_python_architecture.py": "b316237117d4791360529f504f1ea7b50cfc8cc710df4ba079c88eda0031b41e", + ".github/workflows/ci_check.yml": "d68cfaaae3cbf1ef67bfffdfc37c5f1b4f60c3bd887a8d5b4030e758e3e935ea", + "docs/contracts/auth-api.openapi.json": "8e21d9c9371bf1c43420e53ad9d10bdba03c79d32e142c4eca211d1d22829820" + } +} diff --git a/docs/evidence/m1-standalone-owned-storage.json b/docs/evidence/m1-standalone-owned-storage.json new file mode 100644 index 0000000..505a32d --- /dev/null +++ b/docs/evidence/m1-standalone-owned-storage.json @@ -0,0 +1,64 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "48c06bf4c89fbb16fcd6807e5357208b6eb245f3", + "scope": "Standalone managed-directory runtime/CLI reuses private storage lifecycle without manually supplied installation UUID", + "backendTests": 913, + "backendCoverage": { + "covered_lines": 3351, + "num_statements": 3351, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 122, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 868, + "num_partial_branches": 0, + "covered_branches": 868, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "testFirst": "New factory and failure-cleanup tests first failed because create_owned_runtime/_compose did not exist; managed CLI tests failed because --data-directory was unavailable and the parser required a manually supplied UUID.", + "assertions": [ + "Provider/public configuration is validated before persistent writes; invalid channel also leaves empty data untouched", + "One real managed directory preserves generated UUID/channel, household state and exact original command result through authenticated composed-runtime restarts", + "Server restart discards sessions; no token or lease is restored from SQLite", + "Owned storage initializes once per startup and reopens ready data in existing-only mode", + "Stable/Edge use separate generated identities, cookie namespaces and directories; cross-channel reuse is rejected without changing database bytes", + "A competing managed runtime is rejected while shared console attachment remains possible", + "Composition failures including KeyboardInterrupt release storage; credential shutdown failure cannot retain the lease", + "The real CLI holds its lease through the listener and releases it on normal return and listener failure", + "Managed data cannot be combined with explicit UUID/file identity; missing explicit identity never starts a listener or creates data" + ], + "architecture": { + "files": 89, + "dependencies": 401, + "contractsKept": 3, + "contractsBroken": 0, + "mypy": "PASS: 56 backend source files" + }, + "businessSchema": 3, + "metadataFormat": 1, + "limits": [ + "Local real-file/provider-fixture composition only; not complete standalone Docker/browser/companion acceptance", + "The installed stateless Edge preview is unchanged; no owner HA requests or live business records were created", + "Restore activation/newer-history choice, private native package provisioning and product UI remain pending" + ], + "priorHeadCI": { + "head": "48c06bf4c89fbb16fcd6807e5357208b6eb245f3", + "url": "https://github.com/vypdev/domus/actions/runs/41203516820", + "result": "PASS: all six required project groups; not acceptance of later code" + }, + "sourceSha256": { + "backend/src/domus/composition/server.py": "676e4f0cd295229c587bf35de2f1ef6023e101913b3407476f4c495817855cb4", + "tests/backend/test_household_runtime.py": "ba21e9bd708991954c7ab246970a8b8ecd2c3d38de25453abd0ed8c3925b9d69" + }, + "tooling": { + "tests": 66, + "statements": 73, + "branches": 34, + "coveragePercent": 100, + "protectedCopilotResourcesUnchanged": 29 + } +} diff --git a/docs/evidence/m1-storage-kernel.json b/docs/evidence/m1-storage-kernel.json new file mode 100644 index 0000000..1948ffb --- /dev/null +++ b/docs/evidence/m1-storage-kernel.json @@ -0,0 +1,91 @@ +{ + "verifiedOn": "2026-10-31", + "sourceBase": "3fdf4f841e7e2f6d37f8ebfd4b8d80f1ce90090f", + "issue": "https://github.com/vypdev/domus/issues/6", + "scope": "Working-tree storage kernel identified by source hashes; exact-head GitHub results remain independent", + "tests": { + "backend": 87, + "tooling": 65, + "architectureFixtures": 10, + "nodePolicy": 11, + "totalExecuted": 173 + }, + "backendCoverage": { + "covered_lines": 305, + "num_statements": 305, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 44, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 76, + "num_partial_branches": 0, + "covered_branches": 76, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "implementation": "Immutable values, semantic ports, authorization-before-replay orchestration and real-file SQLite transactions only", + "pending": [ + "HA identity/session adapters", + "concrete membership authorization policy", + "recovery", + "consistent snapshot/restore", + "HTTP and console entrypoints", + "full target/channel/package acceptance", + "exact-head complete Bugbot review", + "exact-head GitHub CI" + ], + "copilotRevisionForCatalogValidation": "3e7ceea08e6131360e026937d1c0cc1b120472e0", + "copilotResourcesUnchanged": 29, + "sourceSha256": { + "backend/pyproject.toml": "e73ad4db318ce5511b1b0acc7b4fd63cb43fd2a097cbd19fedc439c7576dd3a6", + "backend/src/domus/__init__.py": "7182c88b92234b4328f94497f4e70222d4390d8093e4b62893dfa78ac5530ac6", + "backend/src/domus/application/__init__.py": "1e8c0c87bf2a8ca947112543070583c0cc55d9d40b56eab0d86ed59a4ec1e82c", + "backend/src/domus/application/commands.py": "9b3d25f161cc0a2946b268d37c325eae8e980aa9254325ccb9260af4da98a8c8", + "backend/src/domus/application/ports.py": "2cfa8a5ce761ba140128bd686f1641ef65ee69afba76b07d5553b08dc5969c8b", + "backend/src/domus/composition/__init__.py": "a902d8faf7dd19da6829fb730d81e32d34ec38ee6cfafd676966a95d0ad31366", + "backend/src/domus/domain/__init__.py": "0aac8a2ba41471d7a174ef7baca6073c95009a9db96df3e032392aa4f80bd079", + "backend/src/domus/domain/errors.py": "cb8ed3dd6aecf2cbd0afe763ff485097c0ea89ef5e876c744f5dd5cb422586c4", + "backend/src/domus/domain/storage.py": "67ce019a495272a5e3a921d6132416b5c003f1122c947a81601206b0c0c1c81c", + "backend/src/domus/entrypoints/__init__.py": "d46875cbfef7220c7244bf1b89112ece8122ecb0d66cd908f08f098f0f3c419f", + "backend/src/domus/infrastructure/__init__.py": "ec6cb2d48fb3178de856dcbeefb7b14bc193fe78ac68a53f9afcf2bd9cac0699", + "backend/src/domus/infrastructure/sqlite.py": "35b9c95c57f1c116b2cdc57a08b5018603c1f61ac3260b79ce55c10543378f49", + "tests/backend/test_atomic_commands.py": "a17ed9172736e0f7fb0dfbb8d9186fb45ba9a922fa3c26648bff7266ce7a4042", + "tests/backend/test_sqlite_transactions.py": "76e7ad8c3ebcfe3542806a4c060809af546073d9df7ae732b16d8b16a12ea6b7", + "tests/backend/test_storage_values.py": "d72420955965e48be0c089339273a6a162a00bb66f9997376871304713fdd668", + "pyproject.toml": "1260684a3a713b533700f2d2076868560cf07465d360833b2e2935128f4131d5", + "uv.lock": "1136b61bae45b7e38e118833316c068741d36deb2d827bb81d76517d7e73f948", + ".github/workflows/ci_check.yml": "d68cfaaae3cbf1ef67bfffdfc37c5f1b4f60c3bd887a8d5b4030e758e3e935ea", + "tooling/domus_tooling/ci_policy.py": "c5220f6e6f4fc6f8ca0aa6a554f62a7793401dacc425caad5c70f0d90442c20e", + "tests/python/test_ci_contract.py": "8a3cac52173791199bef995ed7f601ae70aa9f4996c2256f9b47b6bc25b970fe" + }, + "checks": { + "ruff": "PASS", + "mypy": "PASS", + "actualApplicationImportGraph": "PASS", + "architectureFixtures": "PASS", + "nodeTypecheckAndPolicyCoverage": "PASS", + "localLinks": "PASS", + "currentHeadGitHub": "PENDING: commit/PR publication follows local verification", + "owningCompanionFormatConsistencyAndCanonicalHeadings": "PASS against immutable implementation parent; original validator modules match current v3 blobs", + "catalog": "PASS with unchanged current v3 validator" + }, + "toolingCoverage": { + "covered_lines": 73, + "num_statements": 73, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 0, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 34, + "num_partial_branches": 0, + "covered_branches": 34, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + } +} diff --git a/docs/ha-identity-protocol.md b/docs/ha-identity-protocol.md new file mode 100644 index 0000000..bc07c51 --- /dev/null +++ b/docs/ha-identity-protocol.md @@ -0,0 +1,100 @@ +# HA identity protocol adapters + +Status: fixed endpoint configuration, OAuth form transport, correlated WebSocket proof and the +Supervisor identity boundary are implemented as adapters. Standalone authentication now has bounded +browser attempts, private session wiring and strict API/CLI entrypoints. Both persistent household +compositions and Supervisor local read-grace sessions are implemented. Native access uses a private +listener and fresh per-request identity checks without OAuth. Complete deployment and live +outage/revocation/household/recovery/restore acceptance remain pending. +Do not expose these private adapters directly as endpoints that accept a submitted token or actor. + +## Standalone + +Only operator configuration supplies the HA origin and Domus public origin. Origins reject paths, +userinfo, query/fragment delimiters, whitespace, invalid hosts/ports and ambiguous escape syntax. +The public Domus origin requires HTTPS; explicitly enabled development HTTP is limited to loopback. +The owner's configured LAN HA HTTP origin is supported as a configured upstream. Request Host, +forwarding metadata and callback parameters cannot replace either origin. + +The client ID is the fixed Domus origin with a trailing slash; the callback is that origin plus +`/api/v1/auth/callback`. Code exchange sends an URL-encoded authorization-code grant with the exact +client ID and S256 verifier. Refresh uses the same client ID; revoke uses `/auth/revoke`. The HTTP +adapter disables redirects and environment proxies, retains TLS verification, refuses unsolicited +content encoding, bounds decoded responses and redacts transport errors/token representations. + +The browser/application boundary atomically consumes state, PKCE and a browser-bound attempt, +performs Origin/CSRF validation, retains credentials privately in memory and issues a bounded +server-owned session. There is no caller-supplied bearer-token login route. See the implemented +[identity journey](identity-household.md) and [operator contract](operations-identity-storage.md). + +`auth/current_user` proves the exact user ID and exact boolean administrator flag. Its schema does +not contain active/system-generated flags. The adapter therefore requires a server-exchanged +client-bound OAuth grant and a matching nonempty credential provider list. Core 2026.10.4 enforces +active users during authentication and refuses system users when creating client-bound refresh +tokens. A raw Supervisor or long-lived transport token is not a substitute for this provenance. +Constructing the internal `OAuthTokens` value does not authenticate a browser. + +## Supervisor / Ingress + +The fixed WebSocket URL is `ws://supervisor/core/websocket`. The Supervisor token is a private +transport credential; its own administrator status never identifies the requesting person. +`SupervisorIdentity` requires the actual socket peer `172.30.32.2`, exactly one case-insensitive +`X-Remote-User-Id` and a fresh Core directory lookup of that exact ID. Names, display names, +forwarding headers and X-Ingress-Path cannot establish identity or a role. Duplicate, malformed, +missing and mismatched subjects fail closed before protected effects. + +The Core `config/auth/list` schema supplies active/system-generated flags, `is_owner` and group IDs. +The exact target must be active and non-system. An exact boolean owner or membership in +`system-admin` establishes administrator status; another row's privilege never transfers to the target. +Directory permission denial is distinct from rejecting the requesting user's own authentication. + +Native preview fixtures exercise the private listener policy, disabled proxy-header rewriting +and validated base path. The immutable-source Edge app publishes no host port and its +[installed connection preview](evidence/m1-native-ha-acceptance.json) passed on the exact owner target. +Bounded local read-grace session binding is implemented and packaged. Complete production and +live outage/revocation acceptance remain pending. Ingress uses +HA's native authentication rather than adding a second OAuth login. Core logout, Ingress cookie +expiry and Domus session expiry are separate contracts; no instant global logout claim is made here. + +## Resource and implementation contract + +| Boundary | Bound | +|---|---| +| HTTP or WebSocket operation | Five-second async deadline, including protocol I/O | +| HTTP connect / read inactivity | Two / three seconds | +| WebSocket opening / closing | Two seconds / 250 ms | +| Protocol document or frame | 65,536 bytes | +| Token text / authorization code | 8,192 / 4,096 ASCII characters | +| PKCE verifier | 43–128 RFC-compatible characters | +| Credential rows / directory rows / groups | 32 / 256 / 32 | +| HA user ID | Exactly 32 lowercase hexadecimal characters | + +Adapters expose synchronous facades for synchronous application workers; each owns its async I/O +lifetime. Future ASGI wiring must use those workers rather than nesting `asyncio.run` in a running +event loop. Business policy and transactions remain independent of HTTP, WebSocket and these limits. + +HTTPX 0.28.1 and websockets 17.4 are locked with their transitive dependencies. Existing dependency +versions are retained; the resolver also refreshed available wheel metadata. A pinned WebSocket +redirect hook returns the original handshake failure. Real loopback redirect fixtures prove that +the second origin is never contacted. The client uses an isolated disabled wire logger so debug +logging cannot record authentication frames. Library upgrades must retain these negative contracts. + +## Evidence and limitations + +Tests use fictional tokens/users, HTTPX provider fixtures and disposable WebSocket servers listening +only on loopback. They cover correlation/type checks, admin/owner distinction, technical and inactive +subjects, source mismatch, malformed/oversized content, denial versus unavailability, deadline +cancellation, cleanup, redirect refusal and token redaction. The deadline fixtures expire immediately +without business sleeps. No fixture logs into or modifies the owner's HA. + +See [source-identified protocol evidence](evidence/m1-ha-protocol.json), +[the HA reference matrix](compatibility-home-assistant.md) and +[the full M1 contract](../specs/platform-foundation-identity-sqlite.md). + +Primary references: [HA authentication API](https://contributors.home-assistant.io/core/internals/auth-api/), +[Core 2026.10.4 OAuth/current-user implementation](https://github.com/home-assistant/core/blob/2026.10.4/homeassistant/components/auth/__init__.py), +[client-bound token restrictions](https://github.com/home-assistant/core/blob/2026.10.4/homeassistant/auth/__init__.py), +[directory schema](https://github.com/home-assistant/core/blob/2026.10.4/homeassistant/components/config/auth.py), +[administrator semantics](https://github.com/home-assistant/core/blob/2026.10.4/homeassistant/auth/models.py), +[Supervisor communication](https://contributors.home-assistant.io/apps/guides/communication/) and +[Ingress boundary](https://contributors.home-assistant.io/apps/guides/ingress/). diff --git a/docs/household-api.md b/docs/household-api.md new file mode 100644 index 0000000..77de5ff --- /dev/null +++ b/docs/household-api.md @@ -0,0 +1,119 @@ +# Household API checkpoint + +Status: strict HTTP routes, local queries, standalone SQLite composition, manual snapshots and the +[native persistent runtime](native-household-api.md) are implemented. The earlier Edge.2 packages +the persistent runtime. Edge.3 now includes schema 4/history and verified offline restoration, with +real-image proof on both CPU families and bounded owner update/session/reload/restart acceptance. +The product UI and complete deployment/live household/recovery/restore acceptance remain pending. [Console-approved recovery](administrator-recovery.md) shares the authenticated boundary. + +## Run with persistent standalone data + +Add `--database /owned/data/domus.sqlite3` to the [standalone command](configuration.md). The parent +directory must exist and be owned by the installation operator. Keep the same explicit installation +UUID and channel when restarting. SQLite initialization validates ownership/schema/channel before +opening or migrating the database; another channel cannot adopt it. Stable and Edge require separate +files and installation identities. No HA credential or session token enters these business tables. + +Without this option the executable retains its authentication-preview behavior and opens no business +database. This is a development checkpoint, not the final deployment package. HA app installation +continues to use existing Ingress authentication without requiring standalone OAuth as a prerequisite. + +Signing in, opening the authorization endpoint or starting with a new database does not initialize a +household or enroll anyone. Setup and enrollment are explicit commands. See +[the application rules](household-setup.md) and [the actual OpenAPI contract](contracts/household-api.openapi.json). +The live `/api/v1/openapi.json` includes the installation's own cookie namespace; the checked reference +uses the fixture namespace and contains schemas only, with no live credentials or account records. + +## Routes and authority + +All routes use the validated server-owned session. Forwarding/Ingress identity headers are rejected +by the standalone listener. These routes do not accept an actor, installation ID or household selector. + +| Route | Meaning | Current local authority | +|---|---|---| +| GET /api/v1/authorization | Own setup/enrollment/member state and M1 permission view | Verified human or an original unexpired read-grace session | +| GET /api/v1/household | Household name, timezone and revision | Household read | +| GET /api/v1/members | Bounded roster, at most 256 records | Member read; other people's HA linkage/exceptions need member-management permission | +| GET /api/v1/policy | Current mapping, adjustments and revision | Policy management; this is a read and may use valid outage grace | +| GET /api/v1/operations/{operation_id} | Own original committed result and observed event-delivery state | Own operation read plus the original command permission | +| POST /api/v1/household | Explicit setup, expected revision zero | Fresh HA administrator before setup; current household administrator for privileged replay | +| PUT /api/v1/household | Name/timezone edit | Fresh human and current household management | +| POST /api/v1/members/enrollment | Explicit own enrollment, expected revision zero | Fresh eligible human and current enrollment rules | +| PUT /api/v1/members/{member_id} | Profile/status/ordinary exception replacement | Fresh human and current member management; last-administrator safeguard | +| PUT /api/v1/policy | Mapping/profile-adjustment replacement | Fresh human and current policy management; provenance and last-administrator safeguards | + +The authorization permission list is explicitly scoped to `household_administration`. It is not a +global authorization answer for tasks, inventory or another member's preferences. Later domain APIs +must derive assignment/ownership facts on the server. A member's roster profile is the explicit base +profile, not a claim of another person's currently inherited HA administrator source. The own access +view separately reports effective_role from that person's verified source and the current mapping. + +Source authorization responses also include `message` and `action_required`: pending membership +states that the verified account was linked, access is not active, and an administrator must review +it. Enrollment receipts describe what the original operation did; replay after later activation +does not describe the old pending state as current. Historical receipts retain their absent-current-effect +warning. These source contract explanations are tested in both HTTP compositions and are not in the +immutable installed Edge.3 runtime pin. See the [acceptance audit](m1-acceptance-audit.md). + +Pending/unlinked people can see their own access state without gaining household reads. Suspended +or archived members cannot self-reactivate. Active members may see their own linkage/exceptions; +ordinary roster reads omit those fields for everyone else. Policy and operation queries reread local +authority in the same read transaction; an old service instance is not a permission cache. + +## Write and retry contract + +Every write requires one `Origin` matching the configured public origin, one session-bound +`X-CSRF-Token`, and one `Idempotency-Key`. Obtain CSRF from `/api/v1/auth/csrf`. Keys are 1–128 +characters, start with an ASCII letter/digit and otherwise use letters/digits, underscore, dot, +colon or hyphen. Missing, duplicate and malformed keys are rejected. Every body includes the expected +revision. Invalid types, unknown fields and protected permission overrides are rejected. + +Bodies must be UTF-8 JSON objects with `application/json` (optionally `charset=utf-8`). Duplicate JSON +keys, nonfinite values, malformed encoding and excessive nesting cannot be silently normalized into a +different command. Query parameters are empty for this checkpoint, including writes. Shared transport +bounds remain 65,536 body bytes, 100 headers/16,384 bytes and a five-second body-receiving deadline. + +Current authorization precedes receipt disclosure. The same key and canonical payload return the +original result even after restart, before stale-revision handling. A changed payload returns 409; +a stale new write or inventory changed during proof also returns 409. There is no automatic rebase +or retry under a new key. A busy writer returns `storage_busy`/503 with `Retry-After: 1`; retry only +the identical request/key. Rejected transitions never append partial state, receipt, audit or outbox +evidence. An unexpected 500 cannot prove that a change was not committed: its message preserves that +uncertainty and instructs the client to retry the original payload/key or inspect the operation. +An injected failure after commit verifies that this retry returns one original effect and receipt. + +A new successful write returns `state=committed`, operation ID, original result and UTC creation time. +It does not claim that outbox delivery has completed. The operation query observes pending/delivered +from the exact event record, without guessing from a limited queue page. Missing/corrupt atomic +records fail safely. Another actor's operation returns a scoped 404 after current authorization; +unlinked or suspended actors cannot probe its existence. Privilege loss also blocks disclosure of +old privileged results. HTTP has no recovery-approval endpoint. + +After a verified history import, replay of rolled-away work instead returns state=historical and +archived_by with the original result/time. Its message explicitly says the effect is absent from +current state; it does not execute the change again. Current authority and payload equality still +apply. Operation queries report delivery=suppressed for historical undelivered events, or delivered +if acknowledgement genuinely occurred. Current operations retain pending/delivered and may include +first archive provenance when selected again. Historical setup does not create a household, and +consumed/canceled recovery cannot reuse an old approval under a new key. These are exercised with +both synthetic fixtures and real offline activations in both HTTP compositions. The installation +console exposes restore preparation/activation/resume; local Edge.3 image cases pass. Complete +deployment and live acceptance remain pending. + +## HA availability and remaining deployment work + +Reads may use the original unexpired lease during a bounded HA availability failure; current local +policy still applies. Writes always reprove the exact human. Rejection, identity mismatch, expiry +or logout cannot become read grace or revive an old credential. Directory lookup serializes use of +private delegated material, checks the session before and after I/O and preserves the absolute lease +deadline. Source/directory permission failure never promotes a transport account or permits a guessed identity. + +The standalone directory currently uses the requesting person's delegated HA grant. HA may deny +directory access to an ordinary HA account even when that person has a deliberate Domus administrator +profile. Changes requiring other people's source proof then fail closed. Complete first-release +standalone authority needs the planned companion/installation wiring; this checkpoint does not claim +that capability complete. Native Ingress will use its existing private Core transport authority while +preserving the separate exact-human command actor. The existing gateway is unchanged. + +See [checkpoint evidence](evidence/m1-household-api.json), [identity](identity-household.md), +[operations](operations-identity-storage.md) and [native installation](installation-home-assistant.md). diff --git a/docs/household-setup.md b/docs/household-setup.md new file mode 100644 index 0000000..f230cfe --- /dev/null +++ b/docs/household-setup.md @@ -0,0 +1,108 @@ +# Household setup command checkpoint + +Status: application household/member/policy use cases, standalone HTTP wiring and real SQLite tests +are implemented in both native and standalone compositions and packaged in Edge.3. The current +[owner acceptance](evidence/m1-edge3-ha-acceptance.json) covers its connection page/restart only; +product UI and live household setup remain pending. Startup never implicitly creates a household. + +## Explicit setup + +The first browser arrival grants no authority. Setup is an explicit command from a currently proven +active human HA administrator. It creates one household, its timezone, administrator mapping and +linked active member records in one transaction. A server-owned household ID prevents client scope +selection. Name whitespace is normalized, the IANA timezone must exist and initial revision must be +the integer zero. There is no silent timezone fallback. + +| Mapping | Initial administrator source | +|---|---| +| All HA administrators, default | Current HA administrator proof and an active linked membership | +| Setup administrator only | The setup actor while that account remains a current HA administrator | +| Selected user | The exact eligible selected HA human, who may be an ordinary HA user | + +Selected setup verifies the target before opening the writer transaction. It creates the target's +active linkage if different from the actor. Base profiles are viewers; inherited administration +comes from the mapping and current facts. It is not accidentally persisted as an independent grant. +Changing the mapping later must preserve any deliberately granted independent administrator profile. + +## Joining and editing + +An eligible human explicitly enrolls their own HA identity. The command cannot submit a different +actor, profile or active status. New ordinary members are pending viewers. A current HA administrator +becomes active on enrollment only under the all-administrators mapping. Existing suspended or +archived membership blocks enrollment and replay; account linkage and history are retained. +Activation and profile assignment are explicit administrator commands. Capacity is 256 total member +records, including archived history; saturation rejects +new enrollment without removing an actor or appending partial evidence. + +An active member with current household-management permission may edit the home name and timezone. +Updates use the expected current revision. A stale revision is a conflict, with no automatic retry +under a new command key. An HA outage blocks setup, enrollment and edits before mutation. + +## Durable results and source boundaries + +State, scoped idempotency receipt, audit and outbox commit together. The same key and canonical +payload return the original receipt after current authorization, including after restart. A changed +payload conflicts. A revoked or suspended actor cannot use an old receipt to regain access. An +authorized replay does not depend on recontacting an unrelated selected target or timezone provider. +Every new effect rechecks local authority and replay inside the serialized writer transaction. +No external directory or timezone preparation holds that writer lock. + +## Administrator member changes + +The member-update command replaces the target's profile, status and ordinary permission exceptions +at an expected revision. It cannot replace the member ID, HA linkage or household. Activating or +editing an active target requires fresh exact eligible-human proof; deactivating an unavailable target +is possible when the freshly proven actor still guarantees a remaining administrator. Administrator +permission is checked again inside the writer transaction. After any external lookup the actor is +reproved, so a known source rejection or identity change blocks the command. + +Before saving, the command checks that the full policy/member snapshot used to prepare proof is still +current and that a currently eligible administrator remains in the proposed state. Relevant alternative +administrators are verified in one bounded batch when required; missing/ineligible users cannot satisfy +the safeguard. A source outage blocks instead of becoming a claim of absence. Two concurrent removals +cannot both commit from the same old administrator inventory. No proof or directory lookup holds the +SQLite writer lock. HA proof and SQL still cannot form a globally atomic snapshot across systems. + +Suspension applies immediately to existing local authorization and old-receipt access. An authorized +administrator can deliberately reactivate an archived member after fresh HA proof, preserving the same +linkage and history. Identical concurrent updates share one effect and receipt. Rule order is normalized +for idempotency. Member transitions, audit, receipt and outbox share one commit; a stale inventory or +revision returns a conflict without automatic rebase. See +[the member checkpoint evidence](evidence/m1-member-administration.json). + +## Household policy changes + +Current household administrators may explicitly replace the mapping and household profile adjustments +at an expected policy revision. Adjustments cannot grant administrator-only permissions or weaken +domain constraints. An active selected ordinary HA human can administer the household, including +changing policy, through that deliberate Domus grant. Technical directory privilege supplies human +facts; it never replaces the command actor's own permission. + +Selected/setup targets must already have an active linked membership and fresh eligible-human proof. +The setup-only mode is tied to the unique committed setup actor in durable audit; the request cannot +designate a different founder. The designated founder must still be a current HA administrator. +Missing or ambiguous setup provenance blocks this mapping instead of inventing a new installer. +The bounded audit lookup returns at most two candidates; no complete audit is materialized. + +A mapping change recalculates inherited authority and preserves independent administrator profiles. +The proposed policy must retain a freshly eligible administrator. An alternate administrator lookup +is bounded; unavailable or rejected proof cannot turn into a guessed grant. After external proof the +actor is reproved. Inside the writer transaction the actor must still have current policy-management +permission, the member/policy inventory must still match, and the proposed state must retain an +administrator. No member row is rewritten merely to change household policy. + +Policy, receipt, audit and outbox commit together. Reordered equivalent adjustments share a canonical +payload. Authorized replay survives restart and occurs before stale-revision checks, but a mapping +change that removes the actor's permission also removes their ability to disclose an old privileged +receipt. See [policy evidence](evidence/m1-policy-administration.json). + +The HA directory adapter performs at most one bounded lookup for a batch of up to 256 exact user IDs. +Confirmed missing, inactive or system users are omitted. Malformed or duplicate directory records, +transport failure and authorization denial raise instead of returning a misleading partial result. +Technical transport privilege supplies facts and never becomes the human's household grant. + +The [standalone household API](household-api.md) now exposes these commands and current local queries. +Native persistent wiring, console recovery, snapshot/restore, native local sessions and +production installation persistence remain outstanding. See +[the governing companion](../specs/platform-foundation-identity-sqlite.md), +[identity behavior](identity-household.md) and [evidence](evidence/m1-household-commands.json). diff --git a/docs/identity-household.md b/docs/identity-household.md new file mode 100644 index 0000000..a5e0d51 --- /dev/null +++ b/docs/identity-household.md @@ -0,0 +1,74 @@ +# Identity and household access + +Status: standalone authentication, the installed native HA authentication preview and household +setup, own enrollment, household/member/policy edits and their HTTP routes are implemented. The +[persistent native runtime](native-household-api.md) now shares those routes with bounded local +read-grace sessions and CSRF. [Administrator recovery](administrator-recovery.md) now combines current +HA authority and explicit local console approval. Edge.2 packages these services; Edge.3 adds +retained-history restoration. Product UI and complete live/deployment acceptance remain pending. The installed +Edge 0.0.0-edge.1 still contains only the stateless connection preview. + +## Home Assistant app + +Open the installed app through HA's Open Web UI or sidebar entry. Ingress uses the existing HA +session; no additional Domus login is required. See [app installation](installation-home-assistant.md) +for the accepted connection preview and its exact runtime/package boundaries. + +## Explicit household setup and enrollment + +The [household command checkpoint](household-setup.md) implements the use cases against real SQLite. +Opening the installed preview does not call them or create records. A future setup form must invoke +explicit setup with current human HA administrator proof. All three accepted mappings are implemented; +all current HA administrators is the default. Selected-user mapping also permits a verified ordinary +HA human. Inherited administration is separate from an independently granted administrator profile. + +An eligible human explicitly joining the configured home becomes pending with the viewer profile. +The exception is a current HA administrator joining under the default all-administrators mapping: +that member becomes active. Suspended or archived members cannot reactivate themselves by joining +again. Only the server chooses the command actor. Household edits require current household-management +permission. Current authority is checked before any old receipt is disclosed, including after restart. + +## Independent Docker sign-in + +Open the configured Domus `/api/v1/auth/start` URL. Home Assistant authenticates your own HA account +and authorizes the local Domus client. Domus receives a delegated HA credential with your HA account's +permissions; this checkpoint uses it to verify identity and to refresh/revoke its own grant. Passwords +are entered only into HA. No local password, shared PIN, person entity or submitted member ID signs in. + +The browser returns to the fixed callback. A five-minute server-side attempt checks state, S256 PKCE +and a private browser binding before its single use. Invalid, expired, duplicate or wrong-browser +attempts cannot exchange a code. A declined authorization consumes the bound attempt without access. +If the browser binding is missing, invalid or expired, the error explains how to restart at +`/api/v1/auth/start` and finish within five minutes. It never exchanges an unbound callback code. +The callback redirects to `/api/v1/session`, removing code/state from the final URL. That response +contains verified identity and the current access state; it contains no HA token or session cookie. + +`ha_administrator` describes HA proof. It is not a Domus household grant. Arriving first or signing in +does not create a household, enroll a member or become its administrator. The explicit setup/member +journeys must still apply the [accepted mappings and permissions](../specs/household-identity.md). + +## Current state and next action + +| State | Meaning | Next action | +|---|---|---| +| Authenticated, full | Fresh HA proof and a valid original session | Use permitted functions; household policy still governs each protected action | +| Authenticated, read_only | HA is unavailable; the original session has not expired | Query permitted local data; reconnect to HA before changes | +| Blocked / sign-in required | Session absent/expired, identity rejected or browser binding invalid | Start a new sign-in if eligible | +| Logged out | Local cookie, lease and private credential have been removed | No action if remote revocation is confirmed; review HA authorization if unconfirmed | + +Current local membership, suspension and policy remain authoritative for protected household reads +and writes. Fresh HA proof is required for mutations; an outage cannot renew a session or grant new +rights. Explicit rejection destroys local access rather than becoming outage grace. Restart requires +sign-in again; household records and actor history remain separate durable data. + +## Sign out + +The application first obtains a session-bound nonce from `/api/v1/auth/csrf`, then posts an empty JSON +object to `/api/v1/auth/logout` with that X-CSRF-Token and the exact configured Origin. Local logout +works during an HA outage. Its response distinguishes confirmed, failed and unknown upstream +revocation; unknown is never reported as success. It does not sign out the parent HA frontend. + +HA credentials/session material live only in bounded process memory, are omitted from diagnostic +representations and never enter business SQLite tables, audit, outbox or snapshots. See the +[configuration](configuration.md), [operator guide](operations-identity-storage.md), +[protocol contract](ha-identity-protocol.md) and [reference matrix](compatibility-home-assistant.md). diff --git a/docs/installation-home-assistant.md b/docs/installation-home-assistant.md new file mode 100644 index 0000000..443698a --- /dev/null +++ b/docs/installation-home-assistant.md @@ -0,0 +1,129 @@ +# Home Assistant app installation + +Status: Edge 0.0.0-edge.3 is installed and running on the owner's HA. Its update from Edge.2, +preserved origin, existing-session access, normal Chrome reload and Domus-only restart passed on +2026-10-31. Package head 0484ba7 passes every CI group, including all 15 image cases on native amd64 +and aarch64. The complete UI, live household/recovery/restore and production release remain pending. +[Edge.3 owner acceptance](evidence/m1-edge3-ha-acceptance.json) is separate from the earlier +[persistent Edge.2 receipt](evidence/m1-persistent-ha-acceptance.json) and Edge.1 preview. + +## Intended user journey + +1. Add the published Domus app repository from Settings > Apps > Install app > Repositories. +2. Install Domus Edge for development, or the separately published stable app for production. +3. Configure the HA browser origins, then start and select Open Web UI. Optionally enable its sidebar entry. +4. Use the existing Home Assistant session. There is no second Domus sign-in or password. + +The independent Docker installation has a separate OAuth journey because it runs outside the +Supervisor. That journey is not an installation prerequisite for the HA app. + +## Reviewed development repository + +For the issue #6 preview, copy this exact branch URL into the HA repository dialog: + +```text +https://github.com/vypdev/domus#feature/6-implement-home-assistant-identity-and-sqlite-platform-persistence +``` + +This is a disposable development repository, not the permanent production channel. The Action owns +the branch lifecycle. The owner has retained its app for continued development. Remove the owned +test app/repository when that testing ends; production installation +must use the eventual published stable repository. Do not add the default repository URL yet: +the default branch does not contain this preview package. + +The development package is Domus Edge 0.0.0-edge.3, now installed after a deliberate owner-target +update from Edge.2. Supervisor builds the app locally and verifies its immutable runtime +archive before extracting it; the exact pins are recorded below. The original Edge.1 used runtime +98bd12f1bf141ba64053730b2686bd848aee1b1c. +Python/uv image digests and the dependency lock are fixed. No registry login is needed. + +The installed app is `62fdc661_domus_edge`. Open it at +[Domus Edge in the owner's HA](http://homeassistant.local:8123/app/62fdc661_domus_edge). +The connection preview, including a normal reload, passed on Core 2026.10.4, Supervisor 2026.10.5, +HA OS 18.3 and Frontend 20260930.5. Start on boot, watchdog and automatic updates remain disabled. +[The native acceptance receipt](evidence/m1-native-ha-acceptance.json) records the bounded scope. +It does not claim that later source changes are in this immutable-source image. + +## Original Edge.1 preview + +The preview runs with `domus-ingress --channel edge` inside the Supervisor container. Configuration +supplies SUPERVISOR_TOKEN privately; no human enters or copies a token. The private listener accepts +protected requests only from the actual Supervisor peer, validates the Ingress base path and exact +subject header, and checks that human in Core. Forwarding headers, names and the transport account's +administrator privilege cannot grant the requesting person access. + +The root page shows connection status, uses relative navigation and requires no browser cookie or +OAuth callback. The identity endpoint exposes only the requesting human's proof. Process health +is available to Supervisor and container loopback and is not a claim of HA availability. There are +no household commands, local read-grace sessions or business database in this preview. A failed fresh +proof blocks access. It is not complete M1, the product UI or a production compatibility claim. + +The Edge manifest enables Ingress and Core API access, publishes no host port and requests no +Supervisor management API or host mounts/privileges. Stable and Edge must remain distinct apps with +separate data when Stable is published. Core API access gives the container a technical HA +administrator credential; it must stay private and must never substitute for the human principal. + +The first preview is deliberately stateless and runs as UID/GID 65532. It does not establish persisted +installation identity, household setup, backup behavior or production acceptance. Container tests use +fictional tokens with networking disabled, exercise the real listener and remove only their own +temporary containers. The original source remains identified in its historical receipt; current +review/rollback images are Edge.3 and Edge.2. + +The later [persistent native API](native-household-api.md) is a tested development CLI with SQLite, +bounded Ingress sessions and the shared household routes. Edge.2 now packages it with private data and automatic identity. Product UI and live household/recovery +acceptance remain pending; the earlier preview evidence does not establish those later services. + +## Persistent Edge.2 + +Edge 0.0.0-edge.2 now packages immutable runtime d013cf2c8b932d0e00f5858053cbc0e663d5eee1, +archive SHA-256 fa58d457bef8a863a1943b4e6ba5c9513da8d3baa8b0a033bdcbe7bf11b09c6c. +The owner update and configured startup are now verified; source preparation and image checks remain +separate from the bounded [live receipt](evidence/m1-persistent-ha-acceptance.json). + +In the app Configuration tab, set public_origins to one to four exact URLs used to open HA, including +scheme and port when present, for example http://homeassistant.local:8123. No paths or credentials. +The default empty list refuses startup; there is no permissive fallback or request-derived origin. +This is a one-time configuration, not another sign-in. Restart after changing it. The breaking-version +marker prevents an automatic update from crossing this new configuration boundary. + +The container starts a fixed-path launcher as root to provision only /data/domus, then clears groups +and execs the server as UID/GID 65532. Existing private data is validated without recursive repair. +No host port, mount, capability, management API or protection-mode change is requested. A new empty +installation persists its UUID/channel and SQLite schema before listening, without creating a home. +Identity, household records and operation receipts survive restart; sessions are memory-only. The +initial page is still a connection preview. Household/member/policy/recovery APIs are now packaged, +while the complete interface, restore activation and live household/recovery operations remain pending. + +Local isolated arm64 tests verify process IDs/groups/capabilities, private ownership, inability to +regain root, forged-direct-request rejection, shared console attachment, an excluded second runtime +and one-effect original-command replay after restart. Storage records use a fictitious test authority; +these tests do not prove a live human sign-in. Both native CPU-family CI checks also pass. Owner +Chrome/Ingress access, normal reload and app restart pass separately; household commands, outage +behavior and recovery were not exercised on the owner's HA. See [package evidence](evidence/m1-persistent-app-package.json) +and [private data lifecycle](owned-data-directory.md). + +Official references: [Ingress authentication and relative paths](https://contributors.home-assistant.io/apps/guides/ingress/), +[repository installation and branch selection](https://contributors.home-assistant.io/apps/guides/repository/), +[Core API proxy permissions](https://contributors.home-assistant.io/apps/guides/communication/). + +## Restoration-capable Edge.3 + +Edge 0.0.0-edge.3 pins runtime faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5 and archive SHA-256 +15bcd701696204b3362cebe5901feb3d0072c9bac1db2ef2cbc5007e9ed73717. Its source archive was compared +byte for byte against all 272 tracked files of that commit. The default Supervisor folder builds +the pin with frozen dependencies; no test-only runtime override is needed. + +It adds schema-4 retained history and the [console restoration protocol](restoration.md). Local real +arm64-image tests use SQLite 3.46.1, exercise both runtime compositions, snapshot/prepare/inspect/ +activate/status/resume, later-history rollback, process death and startup refusal while pending. +The actual previous Edge.2 image upgrades through schema 3 to 4 while keeping UUID and original +operation replay. Its pre-upgrade snapshot remains independently inspectable and restorable into +a migrated new generation. Edge.2 refuses both a flat schema 4 and selected generations without +changing any file. An older image alone is not a valid rollback for newer data. + +The every-PR CI builds both the current and immutable previous image on native amd64 and aarch64 +and runs the same 15 container cases. Current package-head CI is separate evidence from the +pre-publication local run. [Package evidence](evidence/m1-restoration-app-package.json) records exact +sources and assertions. A subsequent [owner update](evidence/m1-edge3-ha-acceptance.json) verifies +Edge.3 installed/running, preserved origin and existing-session access after reload/restart. No live +restoration was performed; complete restore acceptance is separate from this connection-page proof. diff --git a/docs/m1-acceptance-audit.md b/docs/m1-acceptance-audit.md new file mode 100644 index 0000000..996fa4f --- /dev/null +++ b/docs/m1-acceptance-audit.md @@ -0,0 +1,101 @@ +# Identity, persistence and restoration acceptance audit + +Status: review candidate for issue #6 / PR #7, audited on 2026-10-31. This foundation increment +does not complete M1 or the first usable product. The [identity companion](../specs/platform-foundation-identity-sqlite.md) +and [restoration companion](../specs/platform-restoration.md) retain their normative inventories. + +The [machine-readable audit](evidence/m1-acceptance-audit.json) maps all 61 identity cases and +18 restoration cases to 171 exact semantic test selectors or explicitly bounded observation +documents. Selectors were resolved against Python AST definitions; an existing name is only a +traceability check. Assertions, real-file/process behavior and the separate compatibility evidence +establish the stated behavior. Case counts are planned acceptance cases, not pytest execution totals. + +## Verification and changes + +The initial audit at `ead7dde` passed **1,387 tests**, with **4,692 statements and 1,304 branches at +100% supported coverage**, including every authored backend module. No coverage omission or custom +exclusion was added. The independent default-permission matrix now checks every role/permission pair. +New composed tests exercise both transports, every membership state, unsupported locale fallback, +safe console diagnostics, original enrollment replay after activation and historical enrollment after +restoration. The tests failed on the missing explanation before the presenter change. + +`GET /api/v1/authorization` now supplies English `message` and `action_required` fields explaining +current membership and the next action. Enrollment receipts describe the original operation in the +past tense: pending enrollment verified and linked an account but did not activate access. Replaying +that original operation after activation preserves the receipt; current authorization reports the +new state. Historical receipts still say their effect is absent from current state. No permission, +authentication, persistence or recovery rule changed. Both published OpenAPI contracts reflect the +actual composed servers. + +API/console content was inspected by the assistant as plain text with explicit status, impact, +retained state and next action. Unsupported locales retain stable codes and English text; untrusted +diagnostics do not become HTML, Markdown, mentions, commands or URLs. On 2026-10-31 the owner explicitly +confirmed that the pending/historical explanations and exact console approval steps are clear. +This human content review does not establish product UI or mobile browser layout acceptance. + +## Exact deployment boundaries + +The installed Edge.3 package remains pinned to runtime +`faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5`. These new source explanations are not installed there. +The [real-image evidence](evidence/m1-restoration-app-package.json) proves packaged restore, +interruption/resume, previous-image upgrade and old-image refusal on the identified images. The +[owner acceptance](evidence/m1-edge3-ha-acceptance.json) proves only update, configuration, session, +reload and restart. It does not prove a live household mutation, recovery or restore. + +A separate read-only Chrome About observation on 2026-10-31 confirmed Core **2026.10.4**, Supervisor +**2026.10.5**, HA OS **18.3**, Frontend **20260930.5**. No owner data or configuration changed. +The [compatibility matrix](compatibility-home-assistant.md) keeps standalone browser return and +other unexecuted target combinations pending. Earlier immutable receipts retain their original +observations and limits. + +## Review gate and subsequent scope + +The draft PR's Bugbot check was skipped because draft reviews are disabled; that is not a clean +review. Merge requires all current-head CI groups, full supported coverage and a complete current-head +Bugbot review without unresolved findings. The audit records this as pending rather than claiming +the gate passed. Copilot implementation and installed resources remain untouched. + +The first ready review completed every one of its 30 code partitions, covering all 204 changed +files, but reported partial context because three older human comments did not fit its conversation +budget. The [exact original maintainer clarifications](evidence/issue6-human-clarifications.json) +preserve all nine public source comments before redundant restatements are compacted in the live +conversation. Their substantive decisions and the normative SDD requirements are unchanged; bot +comments, original issue requirements and generated configuration are untouched. This preserves +reviewable original evidence rather than treating omitted discussion as complete. + +That review found one valid stale schema number in the snapshot guide, corrected to schema 4. +Two claimed syntax errors are false positives for the required Python 3.14 runtime: +[PEP 758](https://peps.python.org/pep-0758/) permits the exact exception handlers without parentheses. +Both exact modules compile and their focused suite passes (134 tests including snapshot checks). +The false positives are [reported upstream](https://github.com/vypdev/copilot/issues/831) and require +evidence-based review reconciliation; partial review still cannot authorize merge. + +Issue #6 excludes the product UI, companion HA entity/service integration and complete release/channel +distribution. They remain part of the full product goal and require their own Action-admitted work. +The next interface increment must consume these application contracts, preserve Clean Architecture, +and start with component, browser, accessibility and coverage checks. + +## Review corrections + +The [follow-up evidence](evidence/m1-review-fixes.json) adds 20 distinct regression cases and a +complete **1,407-test** run at **100%** of **4,704 statements / 1,308 branches**. Default HTTP/HTTPS +ports now serialize consistently with browser origins, preserving exact Origin/CSRF checks and +non-default port isolation. The real native composed setup succeeds under that configuration. + +A bound installation console can recover its original approval receipt after normal consumption, +cancellation, expiry or policy changes. This path only reads the receipt; new approval still requires +the live unchanged request and exact terminal confirmation, and completion still needs fresh HA +administrator proof. Restoration closure guards remain in force. The CLI describes the original +operation and directs inspection of current status, without announcing a new usable approval. + +The actual OpenAPI callback operation now documents that exactly one of code or error is required; +invalid combinations preserve the bound attempt. Current operator guides distinguish schema 4 and +implemented native sessions/offline restoration from unexecuted live acceptance. Historical route +proposals are archived; the published household API is the current wire contract. Failed partial +snapshots retain diagnostic evidence and cannot be overwritten, as required by the explicit operator +contract and real-file failure tests. These source corrections are outside the installed Edge.3 pin. + +Four further unit/native cases exposed the same origin mismatch for expanded IPv6 literals. The +parser now uses canonical compressed IP addresses as well as normalized default ports. The follow-up +in the same evidence record preserves the earlier run and reports **1,411 tests**, **24 review +regressions** and the unchanged **4,704 statements / 1,308 branches at 100%**. diff --git a/docs/migrations-identity-storage.md b/docs/migrations-identity-storage.md new file mode 100644 index 0000000..9e583c6 --- /dev/null +++ b/docs/migrations-identity-storage.md @@ -0,0 +1,50 @@ +# Identity and storage migrations + +Status: schema 1/2/3 to schema 4 is an implemented additive upgrade within the M1 development branch. +This does not establish a released package upgrade, restore command or HA authentication journey. + +| Version | Introduced state | Upgrade behavior | +|---|---|---| +| 1 | Installation/channel marker, one household, durable command receipts, audit, outbox | Frozen development checkpoint at 95458b84b6521b203fab3a257137f24978a03e82 | +| 2 | Member linkage, explicit profile/exceptions, household administrator mapping/profile adjustments | Add tables atomically; retain every version 1 record | +| 3 | Target-bound administrator recovery requests and optimistic approval/consumption state | Add recovery table/index atomically; preserve version 1/2 identity and replay records | +| 4 | Retained complete operation evidence, current/historical placement, first archive provenance and indexed recovery closure | Add history table/index atomically; preserve all earlier business and replay records | + +Initialization inspects schema, application marker, installation UUID and channel before mutation, +then repeats inspection after obtaining the bounded writer lock. Only an empty owned destination or +the explicitly supported prior versions can be initialized/upgraded. Normal transactions require the +current version. An unknown future version, unrelated database or mismatched ownership is refused; +Domus never generates a different identity to adopt an existing file. + +The additive migration uses individual DDL statements inside `BEGIN IMMEDIATE`, with the version +advance in that same transaction. Failure or cancellation rolls back all new tables and the version. +Read-only frozen SQL fixtures from the version 1, version 2 and version 3 checkpoints exercise preservation of +household revision, receipt result, audit detail and pending outbox identity. Mid-migration failure leaves +the original file unchanged and a subsequent deliberate initialization succeeds. + +No policy or administrator is invented for a version 1 household that has none. Protected access +fails closed until a separately verified setup/recovery path is implemented; it must not be resolved +by granting administration to the next browser. Schema 3 adds recovery for an already configured +policy through [current HA authority and the installation console](administrator-recovery.md). +It does not invent policy for an old unconfigured household. Do not use direct SQL to bypass setup. + +Member IDs and HA linkage remain stable even after archival. A second link to the same HA user is +rejected; ordinary updates cannot relink history. Member and policy writes compare expected revisions. +Administrator grants derived from a mapping remain distinct from independent explicit member roles. +An already committed command receipt is retained across upgrade; returning it still requires current +authorization. Recovery approval consumption and the explicit grant commit with their new receipt, +audit and outbox. Credentials and session leases are absent from every business schema version. + +Schema 4 permits historical facts to outlive a current household without recreating business rows. +Its separate infrastructure-only import preserves current acknowledgements and the global outbox +sequence, validates complete retained history and rolls back its savepoint on failure. New snapshots +include that history. [Runtime lookup and recovery guards](restoration.md) are tested through both +APIs and actual offline activations. The local Edge.3 image also upgrades actual Edge.2 data and +restores its schema-3 snapshot; complete deployment acceptance remains pending. The frozen schema-3 fixture was captured from +002a982ee60b8aa22a707de402ab1e8fc0306be5 before changing the schema, independently of new constants. + +Rollback needs a compatible application/database pair. Never run an older application on schema 4 +or decrement `user_version` manually. [Snapshot creation/inspection](snapshots.md) is implemented; +offline restoration to a verified same-installation/channel generation is implemented locally. +Cross-architecture/live restore, destructive-upgrade backups, package hooks and operational +recovery/retention acceptance remain pending in the [M1 companion](../specs/platform-foundation-identity-sqlite.md). diff --git a/docs/native-household-api.md b/docs/native-household-api.md new file mode 100644 index 0000000..6aaa1a0 --- /dev/null +++ b/docs/native-household-api.md @@ -0,0 +1,100 @@ +# Native Home Assistant household API + +Status: persistent native runtime, local session leases and common household routes are implemented +and tested with real ASGI/SQLite composition. Edge 0.0.0-edge.3 now packages this runtime and passes +owner update, preserved-origin configuration, existing-session access, normal reload and restart. The product UI +is still a connection page. Live household/recovery commands are not inferred from that acceptance. +Automatic identity and private data use the [owned lifecycle](owned-data-directory.md). + +The Edge.3 pin includes schema 4, historical replay, suppressed delivery and recovery closure through +this same composition. Real-image restoration/upgrade cases pass on both native CPU families. +[Owner connection acceptance](evidence/m1-edge3-ha-acceptance.json) proves a narrower live scope; +complete deployment and live household/recovery/restore acceptance remain pending. See [history boundaries](restoration.md). + +## Authentication and session binding + +Open Domus through its HA app page. Supervisor authenticates the existing Ingress session and strips +client-supplied human identity headers before setting the associated user. Domus accepts protected +requests only from the actual Supervisor socket peer with proxy rewriting disabled, a valid Ingress +base path, one exact subject and one valid existing `ingress_session` cookie. + +Domus never sets another browser cookie or starts another OAuth sign-in. A keyed hash binds the +upstream session to installation UUID, channel, subject and app path. Only that digest and bounded +local lease records are retained in process memory; the HA cookie is never stored in business SQLite, +logs, audit, outbox or evidence. Request metadata is not human proof: creating a local lease and every +write require a fresh exact active non-system human lookup through the private Core transport. +Its technical administrator privilege never becomes the person's Domus role. + +The default local lease has a one-hour absolute deadline; the shared policy permits five minutes to +eight hours. Fresh reads do not extend it. A bounded HA availability failure permits only local reads +until that original deadline, under current local membership/policy. Writes fail. Explicit rejection +or identity mismatch removes the lease; a later outage cannot revive it. An expired lease needs fresh +HA proof before a new safe read can create a new lease. Writes and supplied CSRF never bootstrap one. +A new lease has a new CSRF epoch. Shutdown clears local bindings and blocks late authentication. +Supervisor owns the upstream cookie and transport credential lifecycle; Domus does not revoke them. +No instant global HA-logout or cross-application session-revocation claim is made. + +## Fixed origins and CSRF + +The operator supplies one to four exact public HA origins, including internal/external origins as +needed. Values are validated and duplicates rejected before SQLite initialization. Request hosts, +forwarding headers, names and display labels cannot select the HA destination, accepted origins, +requesting user or administrator source. The Core destination remains the fixed Supervisor proxy. + +GET `/api/v1/auth/csrf` verifies the existing native session or freshly establishes one through HA. +It returns a process-private-key MAC scoped to that exact binding and local lease epoch. Every write +requires one X-CSRF-Token and one exact allowed Origin, plus the shared Idempotency-Key/revision +contract. Copying a token to another cookie, subject, path, installation or channel cannot grant access. +No cross-origin CORS permission is emitted. Origin validation alone is not authentication. + +## API and data + +GET `/api/v1/session` reports the exact human, source, full/read_only access and remaining lifetime. +GET `/api/v1/openapi.json` exposes the [actual native schema](contracts/native-household-api.openapi.json) +through the same native boundary. The [household routes](household-api.md) share the same DTOs, +application use cases, privacy filtering, revision checks, last-administrator safeguards and atomic +receipt/audit/outbox semantics as standalone. Unknown input and ambiguous JSON remain rejected. +Unexpected response failure after commit preserves uncertainty and same-key recovery. + +A selected ordinary HA human can manage Domus through the configured mapping. The native directory +uses private Core transport to verify other people without granting the actor HA administrator +status. Current Domus permission is still checked in the transaction. Source changes and local +suspension apply to existing leases and old privileged receipts. + +The CLI development contract is: + +```sh +domus-ingress --channel edge \ + --database /owned/app-data/domus.sqlite3 \ + --instance-id 047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6 \ + --public-origin http://homeassistant.local:8123 +``` + +The UUID is the public fixture, not an identifier to reuse across real installations. The parent +must exist and be writable by the runtime. The operator preserves the actual installation UUID and +channel across restarts. Repeat --public-origin for additional configured HA origins. Partial option +sets are rejected. Omitting all persistence options retains the original connection preview. +SUPERVISOR_TOKEN is provisioned privately by HA and must never be copied into a command or document. + +This listener is exclusively for the Supervisor private container network, binds no public app port +in the reviewed manifest and disables proxy-header rewriting/access logs. GET /health is nonpersonal +process readiness for Supervisor/loopback, not an HA availability or authenticated-user claim. +Packaged ownership/startup and both native CPU-family image checks pass. The complete UI, live +household/recovery operations and live owner restore remain pending. Packaged snapshot create/inspect commands +also pass with the image's SQLite 3.46.1 in isolated fixtures, without creating an owner Domus snapshot. + +[Administrator recovery](administrator-recovery.md) now shares this boundary and the standalone +application services. Human request/status/completion/cancellation are authenticated routes; approval +is available exclusively from the installation console. Schema 4 migration and local fixtures do not +establish live recovery acceptance in the installed package. + +## Reviewed primary sources + +- [Core 2026.10.4 Ingress proxy](https://github.com/home-assistant/core/blob/2026.10.4/homeassistant/components/hassio/ingress.py): generated base path and forwarding behavior. +- [Supervisor 2026.10.5 Ingress](https://github.com/home-assistant/supervisor/blob/2026.10.5/supervisor/api/ingress.py): validates cookie, resolves user and replaces human headers; cookies are forwarded. +- [Supervisor cookie name](https://github.com/home-assistant/supervisor/blob/2026.10.5/supervisor/api/const.py) and [session generation](https://github.com/home-assistant/supervisor/blob/2026.10.5/supervisor/ingress.py): ingress_session and 128 lowercase hexadecimal characters. +- [Supervisor app data mount](https://github.com/home-assistant/supervisor/blob/2026.10.5/supervisor/docker/app.py) and [directory lifecycle](https://github.com/home-assistant/supervisor/blob/2026.10.5/supervisor/apps/app.py): own /data is supplied by the platform; Domus provisions only its private child. + +See [native checkpoint evidence](evidence/m1-native-household-api.json) and +[original preview evidence](evidence/m1-native-ha-acceptance.json) and +[persistent owner acceptance](evidence/m1-persistent-ha-acceptance.json). These describe different artifacts/scopes. diff --git a/docs/operations-identity-storage.md b/docs/operations-identity-storage.md new file mode 100644 index 0000000..e5b132a --- /dev/null +++ b/docs/operations-identity-storage.md @@ -0,0 +1,79 @@ +# Identity and storage operations + +Status: both persistent household APIs, bounded sessions, transactional SQLite schema 4 and +[console-approved administrator recovery](administrator-recovery.md) are implemented. Manual +[snapshot creation/inspection](snapshots.md) and verified offline [restoration](restoration.md) are +also available. Edge.3 passes bounded owner update, existing-session access, reload and restart. +Product UI, live household/recovery/restore and complete deployment acceptance remain pending. Do not use SQL +edits to bypass setup. + +## Authentication API + +| Route | Purpose | Required boundary | +|---|---|---| +| GET /api/v1/auth/start | Create one browser-bound attempt and continue to fixed HA authorization | Strict empty query, valid configured host | +| GET /api/v1/auth/callback | Consume matching attempt, exchange/prove and issue rotated session | Exactly one code or decline result, state, private browser cookie | +| GET /api/v1/session | Current own verified identity and full/read_only state | Opaque server cookie and current proof/bounded grace | +| GET /api/v1/auth/csrf | Obtain a nonce for that own session | Same session boundary; no credentials in the response | +| POST /api/v1/auth/logout | Clear local access and attempt only its own grant revocation | Exact Origin, session-bound X-CSRF-Token and strict empty JSON object | +| GET /api/v1/openapi.json | Inspect actual versioned schemas and declared cookie/error contracts | Configured host; no private records | + +All API responses disable caching and referrer forwarding. Errors use a stable code, state, impact, +next action and server-created correlation; input/provider details are not reflected. Framework +validation cannot echo an authorization code. Unknown paths and methods do not redirect. There is no +HTTP recovery-approval route. The standalone [household/member/policy/operation routes](household-api.md) +are enabled by --data-directory or --database. Both persistent compositions also expose own recovery request/status, +completion and cancellation, with approval available only through domus-admin in a real console. + +## Retained state and recovery + +| Event | Retained / removed | Operator action | +|---|---|---| +| HA temporarily unavailable | Existing bounded session may query; writes denied | Reconnect; never extend lease or invent proof | +| HA explicitly rejects identity | Local lease and credential removed immediately | New eligible sign-in; no outage fallback | +| Login code exchange/proof fails | Attempt consumed, no browser session issued; owned received grants get bounded cleanup | Start a deliberate new attempt; inspect HA authorization if cleanup is unconfirmed | +| Local sign-out / response loss | Local access is removed; upstream result may be true, false or null/unknown | Retrying local sign-out is safe; never infer remote success from absent private material | +| Process stop / restart | Every local credential/lease disappears; business SQLite remains separate | Reauthenticate; review reported unconfirmed HA grants as needed | +| Schema/identity/channel mismatch | Existing database is not adopted or rewritten | Use the matching installation/image; follow the migration guide | + +Normal sign-out attempts the exact delegated grant. Expiry/known rejection erases local material and +does not promise HA-side revocation. Shutdown clears all local leases first and attempts at most two +bounded remote revocations. Its counters describe owned records handled during that shutdown, not +an enumeration of every authorization retained by HA. A failed or unknown remote cleanup never +restores Domus access; HA profile authorization management remains available to the account owner. + +The CLI disables access/query logging. With normal info logging enabled, auth_runtime_stopped reports +only revoked/unconfirmed counts. No token, browser nonce, actor name or callback code appears there. +Controlled live tests must identify source/channel/versions, obtain the required HA consent and +record cleanup without retaining credentials or unnecessary personal data. + +## Durable data and future operator commands + +SQLite commits household state, policy, scoped receipts, audit and outbox atomically. Outbox delivery +failure does not reverse a committed business result. Retain replay evidence across restart, migration, +cleanup and restore. [Schema 1/2/3-to-4 migration](migrations-identity-storage.md) is additive and +atomic; old households without policy fail closed rather than promoting a browser. + +[Administrator recovery](administrator-recovery.md) now requires fresh current HA administrator proof, +exact target/request/UUID/channel/mapping/revision binding, ten-minute expiry and atomic single use. +Snapshot creation/inspection now verifies checkpoint/schema/identity/integrity/checksum through the +installation console. Offline restore preparation, activation/resume and rollback implement the +accepted [A30](decisions.md) policy for retaining later processed history. Real-file/process and +packaged-image evidence is separate from unexecuted live owner restore acceptance. +No automatic backup schedule/retention is approved. + +The [native persistent runtime](native-household-api.md) clears only its local bindings on shutdown; +Supervisor owns its upstream cookie and transport grant. Reopening through HA freshly proves the +human, while the matching SQLite records and receipts survive. Its fixed-origin configuration and +original lease deadline apply independently of forwarding headers. This runtime is not yet the +original Edge.1 preview. Edge.3 now packages it and passes owner update/session/reload/restart; complete UI and +live household/recovery/restore acceptance remain pending. + +The [owned data lifecycle](owned-data-directory.md) now persists UUID/channel initialization state +and holds a process lock for either listener. A ready installation refuses missing/empty SQLite +instead of recreating it. Interrupted first initialization resumes only the stored UUID. Metadata, +database and replay records must remain together; deleting identity metadata is not a recovery step. + +See [configuration](configuration.md), [user identity journey](identity-household.md), +[implementation boundaries](architecture-identity-storage.md), [the M1 companion](../specs/platform-foundation-identity-sqlite.md) +and [compatibility gates](compatibility-home-assistant.md). diff --git a/docs/owned-data-directory.md b/docs/owned-data-directory.md new file mode 100644 index 0000000..8488f69 --- /dev/null +++ b/docs/owned-data-directory.md @@ -0,0 +1,151 @@ +# Owned data directory + +Status: the private-directory lifecycle and both persistent CLIs are implemented and tested on +POSIX filesystems. Edge 0.0.0-edge.3 is installed on the owner's Supervisor and passes update, +existing-session access, normal reload and restart with its origin preserved. +[Current owner evidence](evidence/m1-edge3-ha-acceptance.json) +records that bounded scope; real-image storage/replay evidence is separate from live household commands. + +## Runtime commands + +An operator first provisions one existing directory owned by the runtime user with mode 0700. Run: + +```sh +domus-ingress --channel edge \ + --data-directory /owned/app-data/domus \ + --public-origin http://homeassistant.local:8123 +``` + +The independent runtime uses the same storage protocol without a manually supplied UUID: + +```sh +domus-server --ha-url http://homeassistant.local:8123 \ + --public-origin https://domus.example --channel edge \ + --data-directory /owned/app-data/domus +``` + +For loopback development, use --public-origin http://127.0.0.1:8099 and --development-http. That +option requires a loopback listener. The standalone server still authenticates each user through HA; +persisting installation identity does not persist authorization tokens or sessions. Native Ingress +continues using the existing HA session without another login. Both compositions share the same +household use cases; these commands do not implicitly create a household or member. + +The real path is operator configuration, never a browser field. Keep Stable and Edge in separate +private directories. This option generates the installation UUID only on the first valid empty +start and preserves it across restarts. It cannot be combined with --database or --instance-id. +Repeat --public-origin for configured HA origins; its existing validation still precedes data writes. +The explicit database/UUID development contract remains available for independent files. Both native +and standalone explicit-file runtimes reject installation, generation, snapshot, restore or activation +metadata and operations.jsonl entries in the parent directory, including broken symlinks, before SQLite. +They cannot bypass owned-directory or partial-restore lifecycle guards. + +The runtime must have exclusive access to this directory. A nonblocking POSIX process lock is held +until shutdown, including the listener's lifetime. Another process gets installation_in_use rather +than racing migrations or silently starting a second writer. Closing or process death releases the +lock. All entrypoints that access these files must honor the same ownership protocol. + +Startup holds maintenance.lock exclusively during initialization/migration, then retains a shared +guard while serving. [Console approval](administrator-recovery.md) attaches to ready matching data +with another shared guard, without migration, UUID generation or a second server lease. It can run +alongside the one server. An active console attachment prevents startup migration until it closes. +The explicit-file console applies the same owned-marker refusal. + +## Durable states + +| State | Startup behavior | +|---|---| +| Empty private directory | Persist a new UUID/channel marker in initializing state, then initialize schema and publish ready before starting HTTP. No household/member is created. | +| Initializing marker | Resume only that persisted UUID. An interrupted schema transaction or committed empty schema can complete initialization; a conflicting/corrupt database is rejected. | +| Ready marker and matching database | Validate existing schema/installation before mutation, permitting the explicit compatible versioned migration. Reuse identity and durable business/replay records. | +| Ready marker with missing/empty/replaced database | Stop. Never regenerate a UUID or create a replacement empty database. | +| Database, sidecar or staged identity without marker | Stop. Never adopt it as a new installation. | +| Wrong channel, invalid metadata or unexpected artifact | Stop and retain existing records for deliberate operator investigation. | + +The identity marker is bounded strict JSON with exact fields and a version. Its first write and +subsequent atomic replacement are flushed and directory-synced. A failed first marker write may +require operator recovery; Domus never treats partial metadata as permission to invent another UUID. +A fully owned abandoned staging file can be discarded under the process lock. Existing UUID/channel +metadata remains authoritative. No HTTP listener is started until initialization returns ready. + +Files must be regular, singly linked, owned by the runtime UID and mode 0600. Directory/file opens +reject symlinks; FIFOs and other special files are rejected without waiting for a writer. No recursive +permission repair, traversal of arbitrary files or adoption of group/world-readable data occurs. +The directory and its parents remain a trusted local-operator boundary; this does not protect against +that operator replacing paths or editing metadata. The protocol does not authorize manual repairs. + +SQLite, installation metadata and replay evidence form one operational unit. Do not delete the marker +to resolve a startup failure, copy a database alone into an empty directory, or run a standalone +explicit-database process concurrently against these files. [Manual snapshots](snapshots.md) can be +created and inspected. Offline console restoration is implemented locally; actual package/deployment +acceptance remains pending. No backup schedule or pruning is introduced. + +## Selected generations + +The new source runtime also reads a strict format-2 root identity selecting one generation ID under +the fixed generations directory. This is filesystem identity format 2, independent of business +SQLite schema 4. Existing flat format-1 installations retain their normal behavior and are not +silently converted by startup. The offline activator publishes a verified selection only after +exclusive source validation and target preparation. Do not construct or edit these files manually. + +The selected private slot contains domus.sqlite3, a ready format-1 installation.json and a +generation.json matching the root selection. UUID/channel and exact selection must agree. Root-level +runtime/maintenance guards still protect the entire installation. Startup rereads selection after +acquiring the guards, so an earlier observation cannot select stale data. A missing, empty, foreign, +pending or unsafe slot fails; retained flat data is never a fallback or a source for empty replacement. +Managed startup, console attachment and explicit-file factories refuse direct slot bypass, including +a generation marker whose ordinary identity is missing. + +An optional private archives directory is recognized beside generations, outside the selected +database's directory. Real-file tests create and inspect snapshots and blocked restore preparations +there while console attachment shares the runtime's maintenance guard. Symlinked generation/slot/ +archive directories fail. Both composed APIs retain the selected household and original receipt +across restart, leaving retained flat bytes unchanged. A prepared restore publishes its blocked +identity before copying database bytes; rejected startup does not add lock files to it. + +The console activator holds both root leases exclusively, revalidates bound source history, captures +a source-checkpoint and imports/migrates only a blocked new slot before publishing the root selection. +Pending activation metadata blocks all startup; resume finishes the same durable transition. +The activations directory retains sealed inputs, rollback snapshots and immutable completion receipts. +Actual-image/deployment restore acceptance remains pending. See +[offline activation](restoration.md), [activation evidence](evidence/m1-offline-activation.json), +[generation evidence](evidence/m1-generation-selection.json) and the +[complete restoration contract](../specs/platform-restoration.md). The installed Edge.3 has bounded connection/restart acceptance; live restore is separate. + +## Verification and packaging boundary + +[Evidence](evidence/m1-owned-storage.json) covers real files, another process, process death before and +after schema commit, interrupted marker replacement, restart/replay, v1 migration, strict metadata, +private permissions and refusal of missing/corrupt/foreign data. The architecture policy keeps fcntl +and other concrete I/O outside domain/application. +The [explicit-file boundary evidence](evidence/m1-explicit-storage-boundary.json) additionally covers +both runtime factories against active and closed owned directories without changing database bytes. +The [standalone composition evidence](evidence/m1-standalone-owned-storage.json) covers automatic +identity, original-operation replay, session loss on restart, channel separation, one initialization +per startup and lease release after composition/listener/shutdown failures. Snapshot and recovery +console attachment uses --data-directory for either mode. + +Supervisor supplies an app's own /data mount. The final package must provision its private child +directory for the runtime UID, preserve it on upgrade and prove that behavior on both CPU families. +The original nonroot Edge.1 preview did not do this; Edge.2 now packages the launcher and passed +both native CPU-family container checks and owner startup/restart. See [native API](native-household-api.md), +[installed preview](installation-home-assistant.md) and [operator limits](operations-identity-storage.md). + +### Packaged launcher preparation + +domus-ha-app --channel edge prepares the Supervisor package, using only the fixed /data path. +It validates the Supervisor token and bounded /data/options.json before creating anything. That file +contains only public_origins: one to four distinct HA browser origins. It never accepts a data path, +command, UID or credential from a browser. Existing native sessions still supply human authentication. + +The launcher starts as container root only to create /data/domus for UID/GID 65532. An existing child +must already have that owner/group and mode 0700. It never recursively changes ownership or repairs +existing data. An interrupted ownership step may require explicit operator investigation; no existing +files are adopted. The root-owned /data parent and options must not be writable by group/others. +Symlinks, special options files and multiply linked options fail. + +Before executing the fixed Ingress entrypoint, it clears supplementary groups, sets GID/UID 65532, +checks all real/effective IDs and passes only a fixed environment with the private Supervisor token. +There is no root server fallback. The owned-directory protocol then governs SQLite. The installed +Edge.2 image uses this launcher. [Unit evidence](evidence/m1-ha-app-bootstrap.json) is distinct from +the [real-image kernel checks](evidence/m1-persistent-app-package.json) on both CPU families and the +bounded owner acceptance. Live household/recovery/restore acceptance is not inferred from startup. diff --git a/docs/restoration.md b/docs/restoration.md new file mode 100644 index 0000000..eefe82a --- /dev/null +++ b/docs/restoration.md @@ -0,0 +1,169 @@ +# Checkpoint restoration and retained history + +Status: preparation, offline activation, interruption recovery, inspection and console commands are +implemented and tested locally on issue #6. Real activations exercise both HTTP compositions, +frozen schemas 1/2/3, retained history and closed recovery guards. Edge.3 now packages this source; +15 local real-image cases include both compositions, actual previous-image upgrade/refusal and +process-death recovery with SQLite 3.46.1. **Complete deployment and live restore acceptance remain +pending.** Installed Edge.3 passes bounded connection/reload/restart acceptance. This work does not complete M1 or validate +a restoration on the owner's Home Assistant. Restore authority belongs to the installation console; +there is no restore HTTP endpoint. + +The owner accepted [A30](decisions.md): select the checkpoint's business state while preserving +readable later receipts and history as already processed. Retaining history must prevent duplicate +effects without silently restoring newer business changes. + +## Implemented preparation + +The application uses a semantic preparation/inspection port. Domain rules contain no SQL, paths, +frameworks or clocks. The SQLite adapter independently verifies the selected manual snapshot and +reads current complete operation evidence in one committed transaction. A concurrent WAL writer +does not change that selected source view. Preparing while writes continue does not authorize later +activation: activation must stop writes and prove the source binding is still current. + +The output must be a new private destination with an existing trusted parent. Preparation never +overwrites complete or partial output, replaces the active source or migrates the checkpoint. It +copies exact snapshot database bytes and seals four files: + +| File | Meaning | +|---|---| +| domus.sqlite3 | Original checkpoint database, with its verified schema and checksum | +| installation.json | Same installation UUID/channel with state restore_pending | +| operations.jsonl | Complete selected/current operation projection, including retained later facts | +| restore.json | Format-2 prepared manifest with managed source binding; written last after data verification and flush | + +Each directory is mode 0700; files are owner-only regular single-link mode 0600. No-follow, bounded +strict parsing, exact inventory, logical deadline and checksum checks apply. Checksums detect changed +bytes; they do not replace the local owner's trust boundary. The manifest binds the original +checkpoint, source-frontier digest, journal/identity hashes, total/current counts and position high-water. +The source binding includes UUID/channel, source schema, selected generation or flat identity and +canonical identity digest. Legacy format-1 preparations remain inspectable; prepare them again before +activation. A bare explicit development database cannot provide managed activation authority. + +Managed startup and console attachment reject complete or journal-bearing partial candidates before +creating runtime lock files. The blocked identity is written before database bytes, and both +explicit-file runtime factories reject every managed identity/journal/generation signal. +An incomplete candidate never becomes an implicit empty installation. Removing metadata manually +is unsupported and would discard its safety protocol. + +## Reconciliation rules + +Every retained operation includes its original command scope, canonical payload fingerprint, result, +operation ID/time, audit detail, event payload, position and actual acknowledgement. Conflicting facts +for one scope, operation ID or position fail. The checkpoint must be fully explained by current +evidence and must not omit an earlier retained position. Wall-clock timestamps do not prove ancestry. + +Checkpoint operations remain current. Source-only operations become historical and retain their +first archival provenance. Selecting a known historical operation again can make it current without +another execution. Acknowledgements can progress from false to true; they cannot regress. Historical +suppression does not fabricate a delivery acknowledgement. + +The journal is ordered by household/actor/action/command key. Payloads are streamed; uniqueness +sets for operation IDs and positions grow with the history size. Each line is bounded to 262,144 +bytes, accommodating three independently bounded 65,536-byte JSON objects plus fixed metadata. +Parsing rejects duplicate keys, invalid UTF-8, oversized/deep input and malformed evidence. + +An independent inspector requires only the output and expected UUID/channel. It validates exact +files/hashes/size, blocked identity, compatible schema/integrity/atomic records, checkpoint projection, +counts and high-water. It does not need the live source or imply that source is still unchanged. + +## Failure and unfinished work + +Conflicts or copy/validation failures leave source data intact and retain private partial output. +An error or deadline after manifest publication reports uncertainty; inspect that same target rather +than retrying into it. Never claim that an output error proves no artifact was written. + +Preparation keeps checkpoint schema 1, 2, 3 or 4 unchanged. Later acknowledgements and rolled-away +facts remain sealed in the journal until activation imports them. Preparation alone does not migrate +or activate the candidate. + +## Console activation and interruption recovery + +Stop the Domus runtime before preparation or activation. Run as its owning runtime UID. The console +acquires both root leases exclusively, rereads the existing managed identity and validates the +source read-only without migrating it. The snapshot and output parent must already be trusted and +private. These commands work with the same managed-root protocol in native and standalone modes: + +```sh +domus-admin --channel edge --data-directory /owned/domus \ + restore prepare /owned/backups/manual-001 /owned/recovery/prepared-001 +domus-admin --channel edge --instance-id \ + restore inspect /owned/recovery/prepared-001 +domus-admin --channel edge --data-directory /owned/domus \ + restore activate /owned/recovery/prepared-001 +``` + +Keep the returned restore ID. Activation rechecks the complete source frontier and generation before +persisting a pending marker. That marker blocks all normal startup, including explicit-file bypass. +Domus seals the input under activations//input, captures a verified pre-activation source snapshot +under activations//source-checkpoint, and copies the checkpoint into generations/. Migration +and transactional history import affect only that new blocked target. Original source bytes remain +unchanged. After verification and flushes, one atomic root installation.json replacement selects the +new generation. A durable completion receipt precedes removal of the pending marker. + +```sh +domus-admin --channel edge --data-directory /owned/domus restore status +domus-admin --channel edge --data-directory /owned/domus restore resume +``` + +Status reports pending, active or historical. Active means this completed activation's generation is +selected; subsequent business writes may exist. Historical means another generation is selected and +retrying the old activation will not switch back. Completed status can be inspected alongside the +runtime; pending inspection and all activation/resume operations require exclusive offline leases. + +An interrupted first input copy may require the exact original preparation: run activate with that +same path again. Once the sealed input verifies, resume needs only its ID. Before selection it may +rebuild a validated owned partial target; after selection it verifies and finishes that exact target. +Unknown artifacts, changed source/bindings, conflicting receipts or altered materialized data block +resume and retain evidence. An error after publication means uncertain completion, not proof that +the pointer stayed unchanged. Inspect the same ID before retrying. Do not remove safety metadata. + +The retained source-checkpoint is a standard independently inspectable snapshot and a supported input +for a new preparation. To reverse a restore, prepare from that checkpoint against the latest selected +source, then activate the new restore ID. This reconciles operations performed since activation. +Never reverse the root pointer manually: doing so would omit later receipts and recovery guards. + +## Implemented runtime history + +Schema 4 adds retained_operations without a current-household foreign key. An infrastructure-only +import validates complete original facts, selected current projection, prior history, first archive +provenance and sequence continuity. It requires an existing caller-owned transaction and uses a +savepoint, so an error cannot leave partial changes even if the caller subsequently commits. Normal +application ports cannot select an archive or invoke import. Migration from frozen schemas 1/2/3 +is additive and transactional; failure retains the original schema and bytes. + +Current receipt lookup falls back to retained history. It still checks current authorization before +replay/disclosure, returns the original result and conflicts on changed payload. Projection metadata +does not enter original complete evidence. Both standalone and native APIs return state=historical +and archived_by for rolled-away work, with an explicit message that its effect is absent from current +state. A historical event has delivery=suppressed unless it was actually acknowledged, in which case +delivery=delivered preserves that fact. It never enters the pending queue or accepts a fabricated +acknowledgement. Current operations may retain first archive provenance if selected again. + +New event positions remain above every retained position. New snapshots and repeated preparation +retain historical facts and their delivery/provenance; last_committed_at includes historical receipts +and remains a timestamp summary, not ancestry proof. Historical setup cannot create a household or +grant founder provenance. Restored consumed/canceled recovery has a separate indexed guard: an old +approval cannot grant access under a new key, pending lists exclude it and target ownership precedes +closure disclosure. An authorized original-key retry may still return its historical result. + +The real offline activator now applies these rules, including repeated restoration and rollback +using the latest history. Process-death and durable-boundary fault tests verify retained source, +blocked partial startup and same-ID recovery. Both deployment packages and the actual packaged SQLite +still require complete deployment acceptance. The local Edge.3 image now passes the packaged protocol; +its [separate receipt](evidence/m1-restoration-app-package.json) identifies exact scope. The owner's +Edge.3 now passes [bounded update/session/restart acceptance](evidence/m1-edge3-ha-acceptance.json). +The owner's actual UUID/history bytes were not extracted and no live restore was executed. +No automatic snapshot frequency or retention is selected. See +[runtime history evidence](evidence/m1-runtime-history.json). + +Managed startup now also understands an explicit format-2 root generation selection while retaining +flat format-1 compatibility. Each selected slot has bound identity and a marker that blocks direct +slot startup; root leases still apply. Both composed APIs exercise actual activation and subsequent +historical replay. See [data layout](owned-data-directory.md), +[generation evidence](evidence/m1-generation-selection.json) and +[offline activation evidence](evidence/m1-offline-activation.json). + +See the [complete restoration SDD](../specs/platform-restoration.md), +[source-identified preparation evidence](evidence/m1-restoration-planning.json), +[manual snapshots](snapshots.md) and [architecture](architecture-identity-storage.md). diff --git a/docs/roadmap.md b/docs/roadmap.md index 20573bd..3e57c26 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,7 +16,19 @@ The product is not considered first-release complete at M2 or M3. Test, user documentation and failure recovery travel with each change. PR #3 merged the documentation foundation into develop. Issue #4 begins M1 with locked development toolchains, tested quality policies and the project CI Check workflow. This is partial M1 evidence; -identity, persistence, real application/HA coverage and channel acceptance remain pending. +the complete identity/persistence journeys, real HA coverage and channel acceptance remain pending. No implementation begins on an invented branch: maintainers and the Action retain their installed lifecycle. +Issue #6 now has its Action-published companion and verified branch. Its first source checkpoint adds +the tested SQLite transaction kernel, semantic ports, separate backend coverage and actual import graph. +The next checkpoint adds pure identity/permission rules, current local authorization, uniquely linked +member/policy storage, schema 2 migration and bounded session leases. HA protocol adapters now have +provider/loopback evidence. Standalone browser/session authentication and its HTTP/CLI entrypoints +now have executable offline coverage. Membership/policy and recovery use cases, manual snapshots, +business HTTP/console entrypoints and verified offline restore/resume/rollback are implemented and +tested locally and in the real Edge.3 arm64 image, including actual Edge.2 upgrade/refusal. +Edge.3 image CI passes on both CPU families and its owner update/session/reload/restart acceptance is bounded. Complete deployment restore, +product UI, live household/recovery, standalone companion/browser and full +channel/release acceptance remain M1 work. These source checkpoints do not complete M1 or reduce +first-release scope. Release, merge and deployment require the existing maintainer flow and explicit authorization. Future optional depth includes predictive consumption, workload recommendations, rich rewards and external calendars. diff --git a/docs/sdd-validation.md b/docs/sdd-validation.md index 084d9c7..9573532 100644 --- a/docs/sdd-validation.md +++ b/docs/sdd-validation.md @@ -72,3 +72,21 @@ The current local document validation does not make either historical run green ## Tooling increment after the foundation merge Issue #4 registers real tooling/CI entrypoints, source and tests for delivery-quality only, while the capability remains proposed/Draft. The unchanged pinned SDK validator now reports 30 missing-evidence errors for the other ten capabilities. The earlier 33-error foundation result above is retained as historical evidence, not relabeled as passing. The updated owning SDD passes original format/consistency checks against its actual merged base at 0833f630aeff1b48558e0192e19ef3a42c328986. See [development](development.md) for executed tooling tests and remaining product acceptance. + +## Reviewed bootstrap fix and M1 publication — 2026-10-31 + +Current reviewed Copilot v3 revision 3e7ceea08e6131360e026937d1c0cc1b120472e0 accepts +explicitly empty implementation arrays for proposed capabilities. The original current catalog validator +returns zero errors for Domus. The 33/30-error results above describe their exact older snapshots and +remain historical evidence. + +The normal issue #6 workflow published the [companion](../specs/platform-foundation-identity-sqlite.md) +at commit 3fdf4f841e7e2f6d37f8ebfd4b8d80f1ce90090f, verified the exact remote branch and applied +branched. The original platform and identity owners were preserved. Current platform evidence describes +the partial storage, pure policy, local authorization, session-lease and HA protocol checkpoints. +Standalone authentication now has HTTP/CLI wiring and offline browser/session fixtures; complete +membership commands, business API, recovery and target acceptance remain pending. +See [storage evidence](evidence/m1-storage-kernel.json) and +[identity-policy evidence](evidence/m1-identity-policy.json) and +[standalone authentication evidence](evidence/m1-standalone-auth.json). Copilot and its installed resources remain +unchanged, and no manual branch exception was executed. diff --git a/docs/snapshots.md b/docs/snapshots.md new file mode 100644 index 0000000..862de72 --- /dev/null +++ b/docs/snapshots.md @@ -0,0 +1,97 @@ +# Manual SQLite snapshots + +Status: local snapshot creation and inspection are implemented, with real SQLite/ASGI-era business +records, private filesystem checks and a console entrypoint. Pure restore reconciliation and private +blocked preparation/inspection, runtime history/replay, recovery guards and offline activation/resume +are now tested in the local Edge.3 image with SQLite 3.46.1, including restoration of an actual +Edge.2/schema-3 snapshot. Complete deployment acceptance remains pending. The owner's installed +persistent Edge.3 now has bounded update/session/restart acceptance; no owner Domus snapshot or restore +was executed. See [live scope](evidence/m1-edge3-ha-acceptance.json). +No automatic backup frequency, rotation or retention is introduced. + +## Create a snapshot + +Run the console as the runtime UID that owns the data. For a ready managed data directory in either mode: + +```sh +domus-admin --channel edge --data-directory /data/domus \ + snapshot create /data/snapshots/manual-001 +``` + +The destination parent must already exist, be owned by that UID and permit its writes. Provisioning +those package directories remains separate work. The destination must be new and outside the owned +source directory. For an explicit standalone installation: + +```sh +domus-admin --channel edge --database /owned/data/domus.sqlite3 \ + --instance-id \ + snapshot create /owned/backups/manual-001 +``` + +The command creates one private directory containing exactly: + +| File | Contents | +|---|---| +| domus.sqlite3 | Consistent committed business schema, identity, household/member/policy state, receipts, audit, outbox and recovery records | +| installation.json | Matching ready installation UUID/channel metadata | +| manifest.json | Format/schema versions, snapshot ID, capture/last-command timestamps, database size and SHA-256 hashes | + +The manifest is written last after database validation, hashing and file flushes; success also waits +for directory flushes. Existing destinations are never overwritten, including incomplete earlier +attempts. An interrupted/failed creation may leave private diagnostic files without a valid manifest. +Inspect before retrying or deliberately cleaning only that owned failed destination. Output failure +after publication reports uncertainty rather than claiming the snapshot was not created. + +The source is read through SQLite's backup API within a pinned read transaction. Real-file tests +show that a concurrent WAL writer can commit a later revision while the archive retains the earlier +coherent view. A long read can delay writers in rollback-journal mode; ordinary bounded busy/retry +rules still apply. No source business change or new household is made by snapshot creation. + +## Inspect independently of the active database + +Use the expected persisted installation UUID and channel, even if the active database is unavailable: + +```sh +domus-admin --channel edge --instance-id \ + snapshot inspect /owned/backups/manual-001 +``` + +Inspection does not require or open an active source selection. It verifies exact bundle membership, +private ownership/modes, regular singly linked files, strict bounded manifest/identity metadata, +hashes/size, compatible schema definition, physical integrity, foreign keys, business record shapes +and atomic receipt/audit/outbox linkage. Wrong installation/channel, changed bytes, incomplete files, +special files and corrupt records are rejected. Inspection never migrates the archive. Compatible +historical schema 1/2/3 fixtures are inspected in place; capture currently requires the initialized +current schema 4. Credentials and session leases are not stored in those business schemas or collected +from environment, browser, app configuration or other directories. + +The capture timestamp is the local UTC reading at the selected database view. last_committed_at is +the greatest retained receipt timestamp, or null before any command. Both are Unix milliseconds. +The snapshot ID and database hash identify the exact captured artifact. A verified archive is not an +activated restore, a package compatibility claim or evidence of writes after that checkpoint. + +The default logical deadline is 30 seconds; --timeout accepts a finite positive value up to 3600: + +```sh +domus-admin --channel edge --instance-id \ + snapshot --timeout 60 inspect /owned/backups/manual-001 +``` + +Deadline checks run between copy/hash steps and through SQLite's query progress handler. Checksums +detect changed bytes; the local operator and private directory remain the trust boundary. Keep the +whole bundle private and intact. Managed-directory creation attaches through the shared maintenance guard; +inspection needs no HA token or additional HA authorization. No HTTP backup endpoint is exposed. + +See [source-identified evidence](evidence/m1-snapshots.json), [owned data](owned-data-directory.md), +[migrations](migrations-identity-storage.md) and [operator limits](operations-identity-storage.md). +The implemented offline restore flow preserves the selected checkpoint and replay safeguards before +activation in a new matching generation, with writes stopped. The owner accepted [A30](decisions.md): restore the +checkpoint's state while retaining readable later receipts/history as already processed, without +executing their effects again. [Preparation](restoration.md) seals those facts beside the original +checkpoint. A separate verified offline activation imports history into the new generation before +atomic selection; same-ID resume handles interruption. See [the complete flow](restoration.md). +Real-file and image proof does not establish live owner restore or complete deployment acceptance. + +Implementation references: [Python sqlite3 backup interface](https://docs.python.org/3.14/library/sqlite3.html#sqlite3.Connection.backup) +and [SQLite backup semantics](https://www.sqlite.org/backup.html). The inspected local runtime is +Python 3.14.6 with SQLite 3.53.3; current Python 3.14 documentation is independently versioned. diff --git a/pyproject.toml b/pyproject.toml index 3a3bb75..2c4dc22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ version = "0.0.0" description = "Development quality gates for Domus" requires-python = ">=3.14,<3.15" dependencies = [ + "domus-server==0.0.0", "markdown-it-py==4.2.0", ] @@ -17,8 +18,14 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["tooling/domus_tooling"] +[tool.uv.workspace] +members = ["backend"] + +[tool.uv.sources] +domus-server = { workspace = true } + [tool.pytest.ini_options] -testpaths = ["tests/python"] +testpaths = ["tests/python", "tests/backend"] addopts = "-ra --strict-config --strict-markers" [tool.coverage.run] @@ -38,7 +45,7 @@ target-version = "py314" select = ["E", "F", "I", "UP", "B"] [tool.ruff.lint.isort] -known-first-party = ["domus_tooling"] +known-first-party = ["domus", "domus_tooling"] [tool.mypy] python_version = "3.14" @@ -48,6 +55,7 @@ files = ["tooling"] [dependency-groups] dev = [ "hatchling==1.33.0", + "httpx2==2.16.0", "import-linter==2.15", "mypy==2.4.1", "pytest==9.2.0", diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 0000000..4af8183 --- /dev/null +++ b/repository.yaml @@ -0,0 +1,3 @@ +name: Domus Apps +url: https://github.com/vypdev/domus +maintainer: vypdev diff --git a/specs/CATALOG.md b/specs/CATALOG.md index f7885ad..8843403 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -10,51 +10,51 @@ debt or convert unknown historic intent into a design decision. | Capability ID | Status | Scope | Primary SDD | Evidence | |---|---|---|---|---| -| `platform-foundation` | Proposed | One local modular application serves the complete household product in both a Supervisor-managed App and standalone Docker from the first release. Home Assistant owns user authentication; Domus owns its business records. | [Local application platform and persistence](./platform-foundation.md) | 5 paths · 2026-10-29 | -| `household-identity` | Proposed | Each participating person uses their own Home Assistant account. Domus associates that verified user with one household member and applies explicit backend permissions independently of an optional person entity. | [Household members, Home Assistant identity and permissions](./household-identity.md) | 5 paths · 2026-10-29 | -| `household-tasks` | Proposed | Members create and perform household responsibilities with explicit assignments, actual performers and history. A recurring definition survives completion of each individual occurrence. | [Task definitions, occurrences and household execution](./household-tasks.md) | 5 paths · 2026-10-29 | +| `platform-foundation` | Proposed | One local modular application serves the complete household product in both a Supervisor-managed App and standalone Docker from the first release. Home Assistant owns user authentication; Domus owns its business records. | [Local application platform and persistence](./platform-foundation.md) + 2 companion | 187 paths · 2026-10-31 | +| `household-identity` | Proposed | Each participating person uses their own Home Assistant account. Domus associates that verified user with one household member and applies explicit backend permissions independently of an optional person entity. | [Household members, Home Assistant identity and permissions](./household-identity.md) | 164 paths · 2026-10-31 | +| `household-tasks` | Proposed | Members create and perform household responsibilities with explicit assignments, actual performers and history. A recurring definition survives completion of each individual occurrence. | [Task definitions, occurrences and household execution](./household-tasks.md) | 5 paths · 2026-10-29 | | `recurrence-calendar` | Proposed | A bounded scheduler turns task definitions into stable occurrences and a family calendar distinguishes planned start, deadline and actual completion. | [Recurrence engine and family calendar](./recurrence-calendar.md) | 5 paths · 2026-10-29 | -| `points-rewards` | Proposed | Task completion produces auditable point entries. Household-configurable collaboration policies determine each share and the actual award total; corrections preserve history and optional rewards never control basic participation. | [Auditable points, participation and optional rewards](./points-rewards.md) | 5 paths · 2026-10-29 | -| `shopping-management` | Proposed | Shopping combines manual intent, replenishment and task materials without losing provenance. Buying an item and physically receiving it are separate recorded facts. | [Shared shopping demand, purchases and receipt](./shopping-management.md) | 5 paths · 2026-10-29 | -| `household-inventory` | Proposed | The household knows what is available, where it is stored and what needs replenishment through a local product catalog and an immutable stock movement ledger. | [Product catalog, pantry stock and replenishment](./household-inventory.md) | 5 paths · 2026-10-29 | -| `home-assistant-surface` | Proposed | Domus exposes its household capabilities as native Home Assistant entities, services and events. The already integrated HA MCP consumes these surfaces; no gateway work or second MCP is included. | [Home Assistant entities, services and integration](./home-assistant-surface.md) | 5 paths · 2026-10-29 | -| `application-experience` | Proposed | A complete household interface serves all six domains from the first release with fast daily actions, responsive navigation and clear operation results, independently of entity dashboards and AI. | [Responsive application, household dashboard and navigation](./application-experience.md) | 5 paths · 2026-10-29 | -| `delivery-quality` | Proposed | Every implemented capability starts with meaningful tests and enforced architecture boundaries. A CI Check workflow validates each PR update, and both deployment targets ship isolated experimental and production channels. | [Clean Architecture, CI Check and isolated Stable and Edge channels](./delivery-quality.md) | 19 paths · 2026-10-29 | -| `household-automation` | Proposed | Simple/default and optional advanced editors share one durable declarative automation engine in the first release, invoking authorized household commands with replay-safe execution and visible traces. | [Household automation engine and simple/advanced rule editors](./household-automation.md) | 5 paths · 2026-10-29 | +| `points-rewards` | Proposed | Task completion produces auditable point entries. Household-configurable collaboration policies determine each share and the actual award total; corrections preserve history and optional rewards never control basic participation. | [Auditable points, participation and optional rewards](./points-rewards.md) | 5 paths · 2026-10-29 | +| `shopping-management` | Proposed | Shopping combines manual intent, replenishment and task materials without losing provenance. Buying an item and physically receiving it are separate recorded facts. | [Shared shopping demand, purchases and receipt](./shopping-management.md) | 5 paths · 2026-10-29 | +| `household-inventory` | Proposed | The household knows what is available, where it is stored and what needs replenishment through a local product catalog and an immutable stock movement ledger. | [Product catalog, pantry stock and replenishment](./household-inventory.md) | 5 paths · 2026-10-29 | +| `home-assistant-surface` | Proposed | Domus exposes its household capabilities as native Home Assistant entities, services and events. The already integrated HA MCP consumes these surfaces; no gateway work or second MCP is included. | [Home Assistant entities, services and integration](./home-assistant-surface.md) | 5 paths · 2026-10-29 | +| `application-experience` | Proposed | A complete household interface serves all six domains from the first release with fast daily actions, responsive navigation and clear operation results, independently of entity dashboards and AI. | [Responsive application, household dashboard and navigation](./application-experience.md) | 5 paths · 2026-10-29 | +| `delivery-quality` | Proposed | Every implemented capability starts with meaningful tests and enforced architecture boundaries. A CI Check workflow validates each PR update, and both deployment targets ship isolated experimental and production channels. | [Clean Architecture, CI Check and isolated Stable and Edge channels](./delivery-quality.md) | 20 paths · 2026-10-31 | +| `household-automation` | Proposed | Simple/default and optional advanced editors share one durable declarative automation engine in the first release, invoking authorized household commands with replay-safe execution and visible traces. | [Household automation engine and simple/advanced rule editors](./household-automation.md) | 5 paths · 2026-10-29 | ## Evidence map ### `platform-foundation` — Local application platform and persistence - Owner: Domus maintainers (review assignment pending) -- Last verified: 2026-10-29 -- Specifications: [`specs/platform-foundation.md`](./platform-foundation.md) -- Workflows: Not applicable for this capability. -- Entrypoints: Not applicable for this capability. -- Core code: Not applicable for this capability. -- Tests: Not applicable for this capability. -- User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) +- Last verified: 2026-10-31 +- Specifications: [`specs/platform-foundation.md`](./platform-foundation.md) · [`specs/platform-foundation-identity-sqlite.md`](./platform-foundation-identity-sqlite.md) · [`specs/platform-restoration.md`](./platform-restoration.md) +- Workflows: [`.github/workflows/ci_check.yml`](../.github/workflows/ci_check.yml) +- Entrypoints: [`backend/src/domus/composition/server.py`](../backend/src/domus/composition/server.py) · [`backend/src/domus/entrypoints/http.py`](../backend/src/domus/entrypoints/http.py) · [`backend/src/domus/composition/ingress.py`](../backend/src/domus/composition/ingress.py) · [`backend/src/domus/entrypoints/ingress_http.py`](../backend/src/domus/entrypoints/ingress_http.py) · [`repository.yaml`](../repository.yaml) · [`addon-edge/config.yaml`](../addon-edge/config.yaml) · [`addon-edge/Dockerfile`](../addon-edge/Dockerfile) · [`backend/src/domus/entrypoints/household_http.py`](../backend/src/domus/entrypoints/household_http.py) · [`backend/src/domus/composition/household.py`](../backend/src/domus/composition/household.py) · [`backend/src/domus/entrypoints/native_http.py`](../backend/src/domus/entrypoints/native_http.py) · [`backend/src/domus/composition/admin.py`](../backend/src/domus/composition/admin.py) · [`backend/src/domus/entrypoints/console_recovery.py`](../backend/src/domus/entrypoints/console_recovery.py) · [`backend/src/domus/composition/ha_app.py`](../backend/src/domus/composition/ha_app.py) +- Core code: [`backend/src/domus/domain/errors.py`](../backend/src/domus/domain/errors.py) · [`backend/src/domus/domain/storage.py`](../backend/src/domus/domain/storage.py) · [`backend/src/domus/application/ports.py`](../backend/src/domus/application/ports.py) · [`backend/src/domus/application/commands.py`](../backend/src/domus/application/commands.py) · [`backend/src/domus/infrastructure/sqlite.py`](../backend/src/domus/infrastructure/sqlite.py) · [`backend/src/domus/domain/identity.py`](../backend/src/domus/domain/identity.py) · [`backend/src/domus/application/authorization.py`](../backend/src/domus/application/authorization.py) · [`backend/src/domus/application/identity_ports.py`](../backend/src/domus/application/identity_ports.py) · [`backend/src/domus/application/sessions.py`](../backend/src/domus/application/sessions.py) · [`backend/src/domus/infrastructure/sqlite_identity.py`](../backend/src/domus/infrastructure/sqlite_identity.py) · [`backend/src/domus/infrastructure/memory_sessions.py`](../backend/src/domus/infrastructure/memory_sessions.py) · [`backend/src/domus/infrastructure/ha_config.py`](../backend/src/domus/infrastructure/ha_config.py) · [`backend/src/domus/infrastructure/ha_http.py`](../backend/src/domus/infrastructure/ha_http.py) · [`backend/src/domus/infrastructure/ha_websocket.py`](../backend/src/domus/infrastructure/ha_websocket.py) · [`backend/src/domus/infrastructure/ha_ingress.py`](../backend/src/domus/infrastructure/ha_ingress.py) · [`backend/src/domus/application/authentication.py`](../backend/src/domus/application/authentication.py) · [`backend/src/domus/application/authentication_values.py`](../backend/src/domus/application/authentication_values.py) · [`backend/src/domus/composition/server.py`](../backend/src/domus/composition/server.py) · [`backend/src/domus/entrypoints/http.py`](../backend/src/domus/entrypoints/http.py) · [`backend/src/domus/infrastructure/auth_runtime.py`](../backend/src/domus/infrastructure/auth_runtime.py) · [`backend/src/domus/infrastructure/memory_attempts.py`](../backend/src/domus/infrastructure/memory_attempts.py) · [`backend/src/domus/infrastructure/standalone_sessions.py`](../backend/src/domus/infrastructure/standalone_sessions.py) · [`backend/src/domus/application/session_values.py`](../backend/src/domus/application/session_values.py) · [`backend/src/domus/composition/ingress.py`](../backend/src/domus/composition/ingress.py) · [`backend/src/domus/entrypoints/ingress_http.py`](../backend/src/domus/entrypoints/ingress_http.py) · [`backend/src/domus/entrypoints/bounded_http.py`](../backend/src/domus/entrypoints/bounded_http.py) · [`backend/src/domus/application/household_commands.py`](../backend/src/domus/application/household_commands.py) · [`backend/src/domus/infrastructure/application_runtime.py`](../backend/src/domus/infrastructure/application_runtime.py) · [`backend/src/domus/infrastructure/ha_directory.py`](../backend/src/domus/infrastructure/ha_directory.py) · [`backend/src/domus/application/member_commands.py`](../backend/src/domus/application/member_commands.py) · [`backend/src/domus/application/administrator_proof.py`](../backend/src/domus/application/administrator_proof.py) · [`backend/src/domus/application/policy_commands.py`](../backend/src/domus/application/policy_commands.py) · [`backend/src/domus/application/household_queries.py`](../backend/src/domus/application/household_queries.py) · [`backend/src/domus/entrypoints/household_http.py`](../backend/src/domus/entrypoints/household_http.py) · [`backend/src/domus/entrypoints/http_contract.py`](../backend/src/domus/entrypoints/http_contract.py) · [`backend/src/domus/composition/household.py`](../backend/src/domus/composition/household.py) · [`backend/src/domus/infrastructure/session_access.py`](../backend/src/domus/infrastructure/session_access.py) · [`backend/src/domus/entrypoints/native_http.py`](../backend/src/domus/entrypoints/native_http.py) · [`backend/src/domus/entrypoints/http_errors.py`](../backend/src/domus/entrypoints/http_errors.py) · [`backend/src/domus/entrypoints/ingress_boundary.py`](../backend/src/domus/entrypoints/ingress_boundary.py) · [`backend/src/domus/infrastructure/ingress_sessions.py`](../backend/src/domus/infrastructure/ingress_sessions.py) · [`backend/src/domus/infrastructure/owned_storage.py`](../backend/src/domus/infrastructure/owned_storage.py) · [`backend/src/domus/domain/recovery.py`](../backend/src/domus/domain/recovery.py) · [`backend/src/domus/application/recovery_commands.py`](../backend/src/domus/application/recovery_commands.py) · [`backend/src/domus/infrastructure/sqlite_recovery.py`](../backend/src/domus/infrastructure/sqlite_recovery.py) · [`backend/src/domus/composition/admin.py`](../backend/src/domus/composition/admin.py) · [`backend/src/domus/entrypoints/console_recovery.py`](../backend/src/domus/entrypoints/console_recovery.py) · [`backend/src/domus/domain/snapshots.py`](../backend/src/domus/domain/snapshots.py) · [`backend/src/domus/application/snapshots.py`](../backend/src/domus/application/snapshots.py) · [`backend/src/domus/infrastructure/sqlite_snapshots.py`](../backend/src/domus/infrastructure/sqlite_snapshots.py) · [`backend/src/domus/composition/ha_app.py`](../backend/src/domus/composition/ha_app.py) · [`backend/src/domus/domain/restoration.py`](../backend/src/domus/domain/restoration.py) · [`backend/src/domus/application/restoration.py`](../backend/src/domus/application/restoration.py) · [`backend/src/domus/infrastructure/sqlite_restoration.py`](../backend/src/domus/infrastructure/sqlite_restoration.py) · [`backend/src/domus/infrastructure/sqlite_history.py`](../backend/src/domus/infrastructure/sqlite_history.py) · [`backend/src/domus/domain/generations.py`](../backend/src/domus/domain/generations.py) · [`backend/src/domus/domain/activation.py`](../backend/src/domus/domain/activation.py) · [`backend/src/domus/application/activation.py`](../backend/src/domus/application/activation.py) · [`backend/src/domus/composition/restore.py`](../backend/src/domus/composition/restore.py) · [`backend/src/domus/infrastructure/offline_storage.py`](../backend/src/domus/infrastructure/offline_storage.py) · [`backend/src/domus/infrastructure/restore_source.py`](../backend/src/domus/infrastructure/restore_source.py) · [`backend/src/domus/infrastructure/sqlite_activation.py`](../backend/src/domus/infrastructure/sqlite_activation.py) +- Tests: [`tests/backend/test_storage_values.py`](../tests/backend/test_storage_values.py) · [`tests/backend/test_sqlite_transactions.py`](../tests/backend/test_sqlite_transactions.py) · [`tests/backend/test_atomic_commands.py`](../tests/backend/test_atomic_commands.py) · [`tests/backend/test_identity_policy.py`](../tests/backend/test_identity_policy.py) · [`tests/backend/test_membership_authorization.py`](../tests/backend/test_membership_authorization.py) · [`tests/backend/test_session_leases.py`](../tests/backend/test_session_leases.py) · [`tests/backend/test_sqlite_identity.py`](../tests/backend/test_sqlite_identity.py) · [`tests/backend/test_ha_http.py`](../tests/backend/test_ha_http.py) · [`tests/backend/test_ha_websocket.py`](../tests/backend/test_ha_websocket.py) · [`tests/backend/test_ha_ingress.py`](../tests/backend/test_ha_ingress.py) · [`tests/backend/test_authentication_flow.py`](../tests/backend/test_authentication_flow.py) · [`tests/backend/test_standalone_sessions.py`](../tests/backend/test_standalone_sessions.py) · [`tests/backend/test_auth_http.py`](../tests/backend/test_auth_http.py) · [`tests/backend/test_auth_runtime.py`](../tests/backend/test_auth_runtime.py) · [`tests/backend/test_auth_server.py`](../tests/backend/test_auth_server.py) · [`tests/backend/test_ingress_http.py`](../tests/backend/test_ingress_http.py) · [`tests/backend/test_ingress_server.py`](../tests/backend/test_ingress_server.py) · [`tests/backend/test_app_package.py`](../tests/backend/test_app_package.py) · [`tests/containers/test_app_image.py`](../tests/containers/test_app_image.py) · [`tests/backend/test_household_commands.py`](../tests/backend/test_household_commands.py) · [`tests/backend/test_application_runtime.py`](../tests/backend/test_application_runtime.py) · [`tests/backend/test_ha_directory.py`](../tests/backend/test_ha_directory.py) · [`tests/backend/test_member_commands.py`](../tests/backend/test_member_commands.py) · [`tests/backend/test_policy_commands.py`](../tests/backend/test_policy_commands.py) · [`tests/backend/test_household_queries.py`](../tests/backend/test_household_queries.py) · [`tests/backend/test_household_http.py`](../tests/backend/test_household_http.py) · [`tests/backend/test_household_runtime.py`](../tests/backend/test_household_runtime.py) · [`tests/backend/test_session_directory.py`](../tests/backend/test_session_directory.py) · [`tests/backend/test_ingress_sessions.py`](../tests/backend/test_ingress_sessions.py) · [`tests/backend/test_native_http.py`](../tests/backend/test_native_http.py) · [`tests/backend/test_owned_storage.py`](../tests/backend/test_owned_storage.py) · [`tests/backend/test_recovery_values.py`](../tests/backend/test_recovery_values.py) · [`tests/backend/test_sqlite_recovery.py`](../tests/backend/test_sqlite_recovery.py) · [`tests/backend/test_recovery_commands.py`](../tests/backend/test_recovery_commands.py) · [`tests/backend/test_recovery_console.py`](../tests/backend/test_recovery_console.py) · [`tests/backend/test_recovery_http.py`](../tests/backend/test_recovery_http.py) · [`tests/fixtures/sqlite/v2.sql`](../tests/fixtures/sqlite/v2.sql) · [`tests/backend/test_snapshot_values.py`](../tests/backend/test_snapshot_values.py) · [`tests/backend/test_snapshot_commands.py`](../tests/backend/test_snapshot_commands.py) · [`tests/backend/test_sqlite_snapshots.py`](../tests/backend/test_sqlite_snapshots.py) · [`tests/backend/test_snapshot_console.py`](../tests/backend/test_snapshot_console.py) · [`tests/backend/test_ha_app_launcher.py`](../tests/backend/test_ha_app_launcher.py) · [`tests/backend/test_restoration_values.py`](../tests/backend/test_restoration_values.py) · [`tests/backend/test_restoration_commands.py`](../tests/backend/test_restoration_commands.py) · [`tests/backend/test_sqlite_restoration.py`](../tests/backend/test_sqlite_restoration.py) · [`tests/backend/test_sqlite_history.py`](../tests/backend/test_sqlite_history.py) · [`tests/backend/test_sqlite_history_integrity.py`](../tests/backend/test_sqlite_history_integrity.py) · [`tests/backend/test_restored_commands.py`](../tests/backend/test_restored_commands.py) · [`tests/fixtures/sqlite/v3.sql`](../tests/fixtures/sqlite/v3.sql) · [`tests/backend/test_generation_values.py`](../tests/backend/test_generation_values.py) · [`tests/backend/test_owned_generations.py`](../tests/backend/test_owned_generations.py) · [`tests/backend/test_activation_values.py`](../tests/backend/test_activation_values.py) · [`tests/backend/test_activation_console.py`](../tests/backend/test_activation_console.py) · [`tests/backend/test_restore_source.py`](../tests/backend/test_restore_source.py) · [`tests/backend/test_offline_storage.py`](../tests/backend/test_offline_storage.py) · [`tests/backend/test_sqlite_activation.py`](../tests/backend/test_sqlite_activation.py) · [`tests/backend/test_activation_failures.py`](../tests/backend/test_activation_failures.py) · [`tests/backend/test_activation_compatibility.py`](../tests/backend/test_activation_compatibility.py) · [`tests/containers/test_app_compatibility.py`](../tests/containers/test_app_compatibility.py) · [`tests/containers/restore_fixture.py`](../tests/containers/restore_fixture.py) · [`tests/backend/test_response_readability.py`](../tests/backend/test_response_readability.py) · [`tests/backend/test_review_regressions.py`](../tests/backend/test_review_regressions.py) +- User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) · [`docs/architecture-identity-storage.md`](../docs/architecture-identity-storage.md) · [`docs/development.md`](../docs/development.md) · [`docs/compatibility-home-assistant.md`](../docs/compatibility-home-assistant.md) · [`docs/migrations-identity-storage.md`](../docs/migrations-identity-storage.md) · [`docs/ha-identity-protocol.md`](../docs/ha-identity-protocol.md) · [`docs/identity-household.md`](../docs/identity-household.md) · [`docs/configuration.md`](../docs/configuration.md) · [`docs/operations-identity-storage.md`](../docs/operations-identity-storage.md) · [`docs/contracts/auth-api.openapi.json`](../docs/contracts/auth-api.openapi.json) · [`docs/installation-home-assistant.md`](../docs/installation-home-assistant.md) · [`docs/household-setup.md`](../docs/household-setup.md) · [`docs/evidence/m1-household-commands.json`](../docs/evidence/m1-household-commands.json) · [`docs/evidence/m1-native-ha-acceptance.json`](../docs/evidence/m1-native-ha-acceptance.json) · [`docs/evidence/m1-member-administration.json`](../docs/evidence/m1-member-administration.json) · [`docs/evidence/m1-policy-administration.json`](../docs/evidence/m1-policy-administration.json) · [`docs/household-api.md`](../docs/household-api.md) · [`docs/contracts/household-api.openapi.json`](../docs/contracts/household-api.openapi.json) · [`docs/evidence/m1-household-api.json`](../docs/evidence/m1-household-api.json) · [`docs/native-household-api.md`](../docs/native-household-api.md) · [`docs/contracts/native-household-api.openapi.json`](../docs/contracts/native-household-api.openapi.json) · [`docs/evidence/m1-native-household-api.json`](../docs/evidence/m1-native-household-api.json) · [`docs/owned-data-directory.md`](../docs/owned-data-directory.md) · [`docs/evidence/m1-owned-storage.json`](../docs/evidence/m1-owned-storage.json) · [`docs/administrator-recovery.md`](../docs/administrator-recovery.md) · [`docs/evidence/m1-administrator-recovery.json`](../docs/evidence/m1-administrator-recovery.json) · [`docs/snapshots.md`](../docs/snapshots.md) · [`docs/evidence/m1-snapshots.json`](../docs/evidence/m1-snapshots.json) · [`docs/evidence/m1-explicit-storage-boundary.json`](../docs/evidence/m1-explicit-storage-boundary.json) · [`docs/evidence/m1-standalone-owned-storage.json`](../docs/evidence/m1-standalone-owned-storage.json) · [`docs/evidence/m1-ha-app-bootstrap.json`](../docs/evidence/m1-ha-app-bootstrap.json) · [`addon-edge/translations/en.yaml`](../addon-edge/translations/en.yaml) · [`docs/evidence/m1-persistent-app-package.json`](../docs/evidence/m1-persistent-app-package.json) · [`docs/evidence/m1-persistent-ha-acceptance.json`](../docs/evidence/m1-persistent-ha-acceptance.json) · [`docs/restoration.md`](../docs/restoration.md) · [`docs/evidence/m1-restoration-planning.json`](../docs/evidence/m1-restoration-planning.json) · [`docs/evidence/m1-runtime-history.json`](../docs/evidence/m1-runtime-history.json) · [`docs/evidence/m1-generation-selection.json`](../docs/evidence/m1-generation-selection.json) · [`docs/evidence/m1-offline-activation.json`](../docs/evidence/m1-offline-activation.json) · [`docs/evidence/m1-restoration-app-package.json`](../docs/evidence/m1-restoration-app-package.json) · [`docs/evidence/m1-edge3-ha-acceptance.json`](../docs/evidence/m1-edge3-ha-acceptance.json) · [`docs/m1-acceptance-audit.md`](../docs/m1-acceptance-audit.md) · [`docs/evidence/m1-acceptance-audit.json`](../docs/evidence/m1-acceptance-audit.json) · [`docs/evidence/issue6-human-clarifications.json`](../docs/evidence/issue6-human-clarifications.json) · [`docs/evidence/m1-review-fixes.json`](../docs/evidence/m1-review-fixes.json) -### `household-identity` — Household members, Home Assistant identity and permissions +### `household-identity` — Household members, Home Assistant identity and permissions - Owner: Domus maintainers (review assignment pending) -- Last verified: 2026-10-29 +- Last verified: 2026-10-31 - Specifications: [`specs/household-identity.md`](./household-identity.md) - Workflows: Not applicable for this capability. -- Entrypoints: Not applicable for this capability. -- Core code: Not applicable for this capability. -- Tests: Not applicable for this capability. -- User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) +- Entrypoints: [`backend/src/domus/composition/server.py`](../backend/src/domus/composition/server.py) · [`backend/src/domus/entrypoints/http.py`](../backend/src/domus/entrypoints/http.py) · [`backend/src/domus/composition/ingress.py`](../backend/src/domus/composition/ingress.py) · [`backend/src/domus/entrypoints/ingress_http.py`](../backend/src/domus/entrypoints/ingress_http.py) · [`backend/src/domus/entrypoints/household_http.py`](../backend/src/domus/entrypoints/household_http.py) · [`backend/src/domus/composition/household.py`](../backend/src/domus/composition/household.py) · [`backend/src/domus/entrypoints/native_http.py`](../backend/src/domus/entrypoints/native_http.py) · [`backend/src/domus/composition/admin.py`](../backend/src/domus/composition/admin.py) · [`backend/src/domus/entrypoints/console_recovery.py`](../backend/src/domus/entrypoints/console_recovery.py) · [`backend/src/domus/composition/ha_app.py`](../backend/src/domus/composition/ha_app.py) +- Core code: [`backend/src/domus/domain/identity.py`](../backend/src/domus/domain/identity.py) · [`backend/src/domus/application/authorization.py`](../backend/src/domus/application/authorization.py) · [`backend/src/domus/application/identity_ports.py`](../backend/src/domus/application/identity_ports.py) · [`backend/src/domus/application/sessions.py`](../backend/src/domus/application/sessions.py) · [`backend/src/domus/infrastructure/sqlite_identity.py`](../backend/src/domus/infrastructure/sqlite_identity.py) · [`backend/src/domus/infrastructure/memory_sessions.py`](../backend/src/domus/infrastructure/memory_sessions.py) · [`backend/src/domus/infrastructure/ha_config.py`](../backend/src/domus/infrastructure/ha_config.py) · [`backend/src/domus/infrastructure/ha_http.py`](../backend/src/domus/infrastructure/ha_http.py) · [`backend/src/domus/infrastructure/ha_websocket.py`](../backend/src/domus/infrastructure/ha_websocket.py) · [`backend/src/domus/infrastructure/ha_ingress.py`](../backend/src/domus/infrastructure/ha_ingress.py) · [`backend/src/domus/application/authentication.py`](../backend/src/domus/application/authentication.py) · [`backend/src/domus/application/authentication_values.py`](../backend/src/domus/application/authentication_values.py) · [`backend/src/domus/composition/server.py`](../backend/src/domus/composition/server.py) · [`backend/src/domus/entrypoints/http.py`](../backend/src/domus/entrypoints/http.py) · [`backend/src/domus/infrastructure/auth_runtime.py`](../backend/src/domus/infrastructure/auth_runtime.py) · [`backend/src/domus/infrastructure/memory_attempts.py`](../backend/src/domus/infrastructure/memory_attempts.py) · [`backend/src/domus/infrastructure/standalone_sessions.py`](../backend/src/domus/infrastructure/standalone_sessions.py) · [`backend/src/domus/application/session_values.py`](../backend/src/domus/application/session_values.py) · [`backend/src/domus/composition/ingress.py`](../backend/src/domus/composition/ingress.py) · [`backend/src/domus/entrypoints/ingress_http.py`](../backend/src/domus/entrypoints/ingress_http.py) · [`backend/src/domus/entrypoints/bounded_http.py`](../backend/src/domus/entrypoints/bounded_http.py) · [`backend/src/domus/application/household_commands.py`](../backend/src/domus/application/household_commands.py) · [`backend/src/domus/infrastructure/application_runtime.py`](../backend/src/domus/infrastructure/application_runtime.py) · [`backend/src/domus/infrastructure/ha_directory.py`](../backend/src/domus/infrastructure/ha_directory.py) · [`backend/src/domus/application/member_commands.py`](../backend/src/domus/application/member_commands.py) · [`backend/src/domus/application/administrator_proof.py`](../backend/src/domus/application/administrator_proof.py) · [`backend/src/domus/application/policy_commands.py`](../backend/src/domus/application/policy_commands.py) · [`backend/src/domus/application/household_queries.py`](../backend/src/domus/application/household_queries.py) · [`backend/src/domus/entrypoints/household_http.py`](../backend/src/domus/entrypoints/household_http.py) · [`backend/src/domus/entrypoints/http_contract.py`](../backend/src/domus/entrypoints/http_contract.py) · [`backend/src/domus/composition/household.py`](../backend/src/domus/composition/household.py) · [`backend/src/domus/infrastructure/session_access.py`](../backend/src/domus/infrastructure/session_access.py) · [`backend/src/domus/entrypoints/native_http.py`](../backend/src/domus/entrypoints/native_http.py) · [`backend/src/domus/entrypoints/http_errors.py`](../backend/src/domus/entrypoints/http_errors.py) · [`backend/src/domus/entrypoints/ingress_boundary.py`](../backend/src/domus/entrypoints/ingress_boundary.py) · [`backend/src/domus/infrastructure/ingress_sessions.py`](../backend/src/domus/infrastructure/ingress_sessions.py) · [`backend/src/domus/infrastructure/owned_storage.py`](../backend/src/domus/infrastructure/owned_storage.py) · [`backend/src/domus/domain/recovery.py`](../backend/src/domus/domain/recovery.py) · [`backend/src/domus/application/recovery_commands.py`](../backend/src/domus/application/recovery_commands.py) · [`backend/src/domus/infrastructure/sqlite_recovery.py`](../backend/src/domus/infrastructure/sqlite_recovery.py) · [`backend/src/domus/composition/admin.py`](../backend/src/domus/composition/admin.py) · [`backend/src/domus/entrypoints/console_recovery.py`](../backend/src/domus/entrypoints/console_recovery.py) · [`backend/src/domus/domain/snapshots.py`](../backend/src/domus/domain/snapshots.py) · [`backend/src/domus/application/snapshots.py`](../backend/src/domus/application/snapshots.py) · [`backend/src/domus/infrastructure/sqlite_snapshots.py`](../backend/src/domus/infrastructure/sqlite_snapshots.py) · [`backend/src/domus/composition/ha_app.py`](../backend/src/domus/composition/ha_app.py) · [`backend/src/domus/infrastructure/sqlite_history.py`](../backend/src/domus/infrastructure/sqlite_history.py) · [`backend/src/domus/domain/generations.py`](../backend/src/domus/domain/generations.py) · [`backend/src/domus/domain/activation.py`](../backend/src/domus/domain/activation.py) · [`backend/src/domus/application/activation.py`](../backend/src/domus/application/activation.py) · [`backend/src/domus/composition/restore.py`](../backend/src/domus/composition/restore.py) · [`backend/src/domus/infrastructure/offline_storage.py`](../backend/src/domus/infrastructure/offline_storage.py) · [`backend/src/domus/infrastructure/restore_source.py`](../backend/src/domus/infrastructure/restore_source.py) · [`backend/src/domus/infrastructure/sqlite_activation.py`](../backend/src/domus/infrastructure/sqlite_activation.py) +- Tests: [`tests/backend/test_identity_policy.py`](../tests/backend/test_identity_policy.py) · [`tests/backend/test_membership_authorization.py`](../tests/backend/test_membership_authorization.py) · [`tests/backend/test_session_leases.py`](../tests/backend/test_session_leases.py) · [`tests/backend/test_sqlite_identity.py`](../tests/backend/test_sqlite_identity.py) · [`tests/backend/test_ha_http.py`](../tests/backend/test_ha_http.py) · [`tests/backend/test_ha_websocket.py`](../tests/backend/test_ha_websocket.py) · [`tests/backend/test_ha_ingress.py`](../tests/backend/test_ha_ingress.py) · [`tests/backend/test_authentication_flow.py`](../tests/backend/test_authentication_flow.py) · [`tests/backend/test_standalone_sessions.py`](../tests/backend/test_standalone_sessions.py) · [`tests/backend/test_auth_http.py`](../tests/backend/test_auth_http.py) · [`tests/backend/test_auth_runtime.py`](../tests/backend/test_auth_runtime.py) · [`tests/backend/test_auth_server.py`](../tests/backend/test_auth_server.py) · [`tests/backend/test_ingress_http.py`](../tests/backend/test_ingress_http.py) · [`tests/backend/test_ingress_server.py`](../tests/backend/test_ingress_server.py) · [`tests/backend/test_household_commands.py`](../tests/backend/test_household_commands.py) · [`tests/backend/test_application_runtime.py`](../tests/backend/test_application_runtime.py) · [`tests/backend/test_ha_directory.py`](../tests/backend/test_ha_directory.py) · [`tests/backend/test_member_commands.py`](../tests/backend/test_member_commands.py) · [`tests/backend/test_policy_commands.py`](../tests/backend/test_policy_commands.py) · [`tests/backend/test_household_queries.py`](../tests/backend/test_household_queries.py) · [`tests/backend/test_household_http.py`](../tests/backend/test_household_http.py) · [`tests/backend/test_household_runtime.py`](../tests/backend/test_household_runtime.py) · [`tests/backend/test_session_directory.py`](../tests/backend/test_session_directory.py) · [`tests/backend/test_ingress_sessions.py`](../tests/backend/test_ingress_sessions.py) · [`tests/backend/test_native_http.py`](../tests/backend/test_native_http.py) · [`tests/backend/test_owned_storage.py`](../tests/backend/test_owned_storage.py) · [`tests/backend/test_recovery_values.py`](../tests/backend/test_recovery_values.py) · [`tests/backend/test_sqlite_recovery.py`](../tests/backend/test_sqlite_recovery.py) · [`tests/backend/test_recovery_commands.py`](../tests/backend/test_recovery_commands.py) · [`tests/backend/test_recovery_console.py`](../tests/backend/test_recovery_console.py) · [`tests/backend/test_recovery_http.py`](../tests/backend/test_recovery_http.py) · [`tests/fixtures/sqlite/v2.sql`](../tests/fixtures/sqlite/v2.sql) · [`tests/backend/test_snapshot_values.py`](../tests/backend/test_snapshot_values.py) · [`tests/backend/test_snapshot_commands.py`](../tests/backend/test_snapshot_commands.py) · [`tests/backend/test_sqlite_snapshots.py`](../tests/backend/test_sqlite_snapshots.py) · [`tests/backend/test_snapshot_console.py`](../tests/backend/test_snapshot_console.py) · [`tests/backend/test_ha_app_launcher.py`](../tests/backend/test_ha_app_launcher.py) · [`tests/backend/test_sqlite_history.py`](../tests/backend/test_sqlite_history.py) · [`tests/backend/test_sqlite_history_integrity.py`](../tests/backend/test_sqlite_history_integrity.py) · [`tests/backend/test_restored_commands.py`](../tests/backend/test_restored_commands.py) · [`tests/fixtures/sqlite/v3.sql`](../tests/fixtures/sqlite/v3.sql) · [`tests/backend/test_generation_values.py`](../tests/backend/test_generation_values.py) · [`tests/backend/test_owned_generations.py`](../tests/backend/test_owned_generations.py) · [`tests/backend/test_activation_values.py`](../tests/backend/test_activation_values.py) · [`tests/backend/test_activation_console.py`](../tests/backend/test_activation_console.py) · [`tests/backend/test_restore_source.py`](../tests/backend/test_restore_source.py) · [`tests/backend/test_offline_storage.py`](../tests/backend/test_offline_storage.py) · [`tests/backend/test_sqlite_activation.py`](../tests/backend/test_sqlite_activation.py) · [`tests/backend/test_activation_failures.py`](../tests/backend/test_activation_failures.py) · [`tests/backend/test_activation_compatibility.py`](../tests/backend/test_activation_compatibility.py) · [`tests/containers/test_app_image.py`](../tests/containers/test_app_image.py) · [`tests/containers/test_app_compatibility.py`](../tests/containers/test_app_compatibility.py) · [`tests/containers/restore_fixture.py`](../tests/containers/restore_fixture.py) · [`tests/backend/test_response_readability.py`](../tests/backend/test_response_readability.py) · [`tests/backend/test_review_regressions.py`](../tests/backend/test_review_regressions.py) +- User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) · [`docs/architecture-identity-storage.md`](../docs/architecture-identity-storage.md) · [`docs/migrations-identity-storage.md`](../docs/migrations-identity-storage.md) · [`docs/ha-identity-protocol.md`](../docs/ha-identity-protocol.md) · [`docs/identity-household.md`](../docs/identity-household.md) · [`docs/configuration.md`](../docs/configuration.md) · [`docs/operations-identity-storage.md`](../docs/operations-identity-storage.md) · [`docs/contracts/auth-api.openapi.json`](../docs/contracts/auth-api.openapi.json) · [`docs/installation-home-assistant.md`](../docs/installation-home-assistant.md) · [`docs/household-setup.md`](../docs/household-setup.md) · [`docs/evidence/m1-household-commands.json`](../docs/evidence/m1-household-commands.json) · [`docs/evidence/m1-native-ha-acceptance.json`](../docs/evidence/m1-native-ha-acceptance.json) · [`docs/evidence/m1-member-administration.json`](../docs/evidence/m1-member-administration.json) · [`docs/evidence/m1-policy-administration.json`](../docs/evidence/m1-policy-administration.json) · [`docs/household-api.md`](../docs/household-api.md) · [`docs/contracts/household-api.openapi.json`](../docs/contracts/household-api.openapi.json) · [`docs/evidence/m1-household-api.json`](../docs/evidence/m1-household-api.json) · [`docs/native-household-api.md`](../docs/native-household-api.md) · [`docs/contracts/native-household-api.openapi.json`](../docs/contracts/native-household-api.openapi.json) · [`docs/evidence/m1-native-household-api.json`](../docs/evidence/m1-native-household-api.json) · [`docs/owned-data-directory.md`](../docs/owned-data-directory.md) · [`docs/evidence/m1-owned-storage.json`](../docs/evidence/m1-owned-storage.json) · [`docs/administrator-recovery.md`](../docs/administrator-recovery.md) · [`docs/evidence/m1-administrator-recovery.json`](../docs/evidence/m1-administrator-recovery.json) · [`docs/snapshots.md`](../docs/snapshots.md) · [`docs/evidence/m1-snapshots.json`](../docs/evidence/m1-snapshots.json) · [`docs/evidence/m1-explicit-storage-boundary.json`](../docs/evidence/m1-explicit-storage-boundary.json) · [`docs/evidence/m1-standalone-owned-storage.json`](../docs/evidence/m1-standalone-owned-storage.json) · [`docs/evidence/m1-ha-app-bootstrap.json`](../docs/evidence/m1-ha-app-bootstrap.json) · [`addon-edge/translations/en.yaml`](../addon-edge/translations/en.yaml) · [`docs/evidence/m1-persistent-app-package.json`](../docs/evidence/m1-persistent-app-package.json) · [`docs/evidence/m1-persistent-ha-acceptance.json`](../docs/evidence/m1-persistent-ha-acceptance.json) · [`docs/evidence/m1-runtime-history.json`](../docs/evidence/m1-runtime-history.json) · [`docs/evidence/m1-generation-selection.json`](../docs/evidence/m1-generation-selection.json) · [`docs/evidence/m1-offline-activation.json`](../docs/evidence/m1-offline-activation.json) · [`docs/evidence/m1-restoration-app-package.json`](../docs/evidence/m1-restoration-app-package.json) · [`docs/evidence/m1-edge3-ha-acceptance.json`](../docs/evidence/m1-edge3-ha-acceptance.json) · [`docs/m1-acceptance-audit.md`](../docs/m1-acceptance-audit.md) · [`docs/evidence/m1-acceptance-audit.json`](../docs/evidence/m1-acceptance-audit.json) · [`docs/evidence/issue6-human-clarifications.json`](../docs/evidence/issue6-human-clarifications.json) · [`docs/evidence/m1-review-fixes.json`](../docs/evidence/m1-review-fixes.json) -### `household-tasks` — Task definitions, occurrences and household execution +### `household-tasks` — Task definitions, occurrences and household execution - Owner: Domus maintainers (review assignment pending) - Last verified: 2026-10-29 - Specifications: [`specs/household-tasks.md`](./household-tasks.md) - Workflows: Not applicable for this capability. -- Entrypoints: Not applicable for this capability. -- Core code: Not applicable for this capability. -- Tests: Not applicable for this capability. +- Entrypoints: Not yet implemented (proposed capability). +- Core code: Not yet implemented (proposed capability). +- Tests: Not yet implemented (proposed capability). - User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) ### `recurrence-calendar` — Recurrence engine and family calendar @@ -63,86 +63,86 @@ debt or convert unknown historic intent into a design decision. - Last verified: 2026-10-29 - Specifications: [`specs/recurrence-calendar.md`](./recurrence-calendar.md) - Workflows: Not applicable for this capability. -- Entrypoints: Not applicable for this capability. -- Core code: Not applicable for this capability. -- Tests: Not applicable for this capability. +- Entrypoints: Not yet implemented (proposed capability). +- Core code: Not yet implemented (proposed capability). +- Tests: Not yet implemented (proposed capability). - User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) -### `points-rewards` — Auditable points, participation and optional rewards +### `points-rewards` — Auditable points, participation and optional rewards - Owner: Domus maintainers (review assignment pending) - Last verified: 2026-10-29 - Specifications: [`specs/points-rewards.md`](./points-rewards.md) - Workflows: Not applicable for this capability. -- Entrypoints: Not applicable for this capability. -- Core code: Not applicable for this capability. -- Tests: Not applicable for this capability. +- Entrypoints: Not yet implemented (proposed capability). +- Core code: Not yet implemented (proposed capability). +- Tests: Not yet implemented (proposed capability). - User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) -### `shopping-management` — Shared shopping demand, purchases and receipt +### `shopping-management` — Shared shopping demand, purchases and receipt - Owner: Domus maintainers (review assignment pending) - Last verified: 2026-10-29 - Specifications: [`specs/shopping-management.md`](./shopping-management.md) - Workflows: Not applicable for this capability. -- Entrypoints: Not applicable for this capability. -- Core code: Not applicable for this capability. -- Tests: Not applicable for this capability. +- Entrypoints: Not yet implemented (proposed capability). +- Core code: Not yet implemented (proposed capability). +- Tests: Not yet implemented (proposed capability). - User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) -### `household-inventory` — Product catalog, pantry stock and replenishment +### `household-inventory` — Product catalog, pantry stock and replenishment - Owner: Domus maintainers (review assignment pending) - Last verified: 2026-10-29 - Specifications: [`specs/household-inventory.md`](./household-inventory.md) - Workflows: Not applicable for this capability. -- Entrypoints: Not applicable for this capability. -- Core code: Not applicable for this capability. -- Tests: Not applicable for this capability. +- Entrypoints: Not yet implemented (proposed capability). +- Core code: Not yet implemented (proposed capability). +- Tests: Not yet implemented (proposed capability). - User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) -### `home-assistant-surface` — Home Assistant entities, services and integration +### `home-assistant-surface` — Home Assistant entities, services and integration - Owner: Domus maintainers (review assignment pending) - Last verified: 2026-10-29 - Specifications: [`specs/home-assistant-surface.md`](./home-assistant-surface.md) - Workflows: Not applicable for this capability. -- Entrypoints: Not applicable for this capability. -- Core code: Not applicable for this capability. -- Tests: Not applicable for this capability. +- Entrypoints: Not yet implemented (proposed capability). +- Core code: Not yet implemented (proposed capability). +- Tests: Not yet implemented (proposed capability). - User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) -### `application-experience` — Responsive application, household dashboard and navigation +### `application-experience` — Responsive application, household dashboard and navigation - Owner: Domus maintainers (review assignment pending) - Last verified: 2026-10-29 - Specifications: [`specs/application-experience.md`](./application-experience.md) - Workflows: Not applicable for this capability. -- Entrypoints: Not applicable for this capability. -- Core code: Not applicable for this capability. -- Tests: Not applicable for this capability. +- Entrypoints: Not yet implemented (proposed capability). +- Core code: Not yet implemented (proposed capability). +- Tests: Not yet implemented (proposed capability). - User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) -### `delivery-quality` — Clean Architecture, CI Check and isolated Stable and Edge channels +### `delivery-quality` — Clean Architecture, CI Check and isolated Stable and Edge channels - Owner: Domus maintainers (review assignment pending) -- Last verified: 2026-10-29 +- Last verified: 2026-10-31 - Specifications: [`specs/delivery-quality.md`](./delivery-quality.md) - Workflows: [`.github/workflows/ci_check.yml`](../.github/workflows/ci_check.yml) - Entrypoints: [`.github/workflows/ci_check.yml`](../.github/workflows/ci_check.yml) · [`tooling/domus_tooling/cli.py`](../tooling/domus_tooling/cli.py) - Core code: [`tooling/domus_tooling/ci_policy.py`](../tooling/domus_tooling/ci_policy.py) · [`tooling/domus_tooling/docs.py`](../tooling/domus_tooling/docs.py) · [`tooling/domus_tooling/cli.py`](../tooling/domus_tooling/cli.py) · [`architecture/frontend.mjs`](../architecture/frontend.mjs) -- Tests: [`tests/python/test_ci_policy.py`](../tests/python/test_ci_policy.py) · [`tests/python/test_cli.py`](../tests/python/test_cli.py) · [`tests/python/test_docs.py`](../tests/python/test_docs.py) · [`tests/python/test_ci_contract.py`](../tests/python/test_ci_contract.py) · [`tests/architecture/test_python_architecture.py`](../tests/architecture/test_python_architecture.py) · [`tests/node/architecture.test.mjs`](../tests/node/architecture.test.mjs) +- Tests: [`tests/python/test_ci_policy.py`](../tests/python/test_ci_policy.py) · [`tests/python/test_cli.py`](../tests/python/test_cli.py) · [`tests/python/test_docs.py`](../tests/python/test_docs.py) · [`tests/python/test_ci_contract.py`](../tests/python/test_ci_contract.py) · [`tests/architecture/test_python_architecture.py`](../tests/architecture/test_python_architecture.py) · [`tests/node/architecture.test.mjs`](../tests/node/architecture.test.mjs) · [`tests/containers/test_app_image.py`](../tests/containers/test_app_image.py) - User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) · [`docs/development.md`](../docs/development.md) -### `household-automation` — Household automation engine and simple/advanced rule editors +### `household-automation` — Household automation engine and simple/advanced rule editors - Owner: Domus maintainers (review assignment pending) - Last verified: 2026-10-29 - Specifications: [`specs/household-automation.md`](./household-automation.md) - Workflows: Not applicable for this capability. -- Entrypoints: Not applicable for this capability. -- Core code: Not applicable for this capability. -- Tests: Not applicable for this capability. +- Entrypoints: Not yet implemented (proposed capability). +- Core code: Not yet implemented (proposed capability). +- Tests: Not yet implemented (proposed capability). - User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) ## Maintenance contract diff --git a/specs/catalog.json b/specs/catalog.json index 29fd53e..46932db 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -7,25 +7,213 @@ "status": "proposed", "scope": "One local modular application serves the complete household product in both a Supervisor-managed App and standalone Docker from the first release. Home Assistant owns user authentication; Domus owns its business records.", "owner": "Domus maintainers (review assignment pending)", - "lastVerified": "2026-10-29", + "lastVerified": "2026-10-31", "specs": [ - "specs/platform-foundation.md" + "specs/platform-foundation.md", + "specs/platform-foundation-identity-sqlite.md", + "specs/platform-restoration.md" + ], + "workflows": [ + ".github/workflows/ci_check.yml" + ], + "entrypoints": [ + "backend/src/domus/composition/server.py", + "backend/src/domus/entrypoints/http.py", + "backend/src/domus/composition/ingress.py", + "backend/src/domus/entrypoints/ingress_http.py", + "repository.yaml", + "addon-edge/config.yaml", + "addon-edge/Dockerfile", + "backend/src/domus/entrypoints/household_http.py", + "backend/src/domus/composition/household.py", + "backend/src/domus/entrypoints/native_http.py", + "backend/src/domus/composition/admin.py", + "backend/src/domus/entrypoints/console_recovery.py", + "backend/src/domus/composition/ha_app.py" + ], + "code": [ + "backend/src/domus/domain/errors.py", + "backend/src/domus/domain/storage.py", + "backend/src/domus/application/ports.py", + "backend/src/domus/application/commands.py", + "backend/src/domus/infrastructure/sqlite.py", + "backend/src/domus/domain/identity.py", + "backend/src/domus/application/authorization.py", + "backend/src/domus/application/identity_ports.py", + "backend/src/domus/application/sessions.py", + "backend/src/domus/infrastructure/sqlite_identity.py", + "backend/src/domus/infrastructure/memory_sessions.py", + "backend/src/domus/infrastructure/ha_config.py", + "backend/src/domus/infrastructure/ha_http.py", + "backend/src/domus/infrastructure/ha_websocket.py", + "backend/src/domus/infrastructure/ha_ingress.py", + "backend/src/domus/application/authentication.py", + "backend/src/domus/application/authentication_values.py", + "backend/src/domus/composition/server.py", + "backend/src/domus/entrypoints/http.py", + "backend/src/domus/infrastructure/auth_runtime.py", + "backend/src/domus/infrastructure/memory_attempts.py", + "backend/src/domus/infrastructure/standalone_sessions.py", + "backend/src/domus/application/session_values.py", + "backend/src/domus/composition/ingress.py", + "backend/src/domus/entrypoints/ingress_http.py", + "backend/src/domus/entrypoints/bounded_http.py", + "backend/src/domus/application/household_commands.py", + "backend/src/domus/infrastructure/application_runtime.py", + "backend/src/domus/infrastructure/ha_directory.py", + "backend/src/domus/application/member_commands.py", + "backend/src/domus/application/administrator_proof.py", + "backend/src/domus/application/policy_commands.py", + "backend/src/domus/application/household_queries.py", + "backend/src/domus/entrypoints/household_http.py", + "backend/src/domus/entrypoints/http_contract.py", + "backend/src/domus/composition/household.py", + "backend/src/domus/infrastructure/session_access.py", + "backend/src/domus/entrypoints/native_http.py", + "backend/src/domus/entrypoints/http_errors.py", + "backend/src/domus/entrypoints/ingress_boundary.py", + "backend/src/domus/infrastructure/ingress_sessions.py", + "backend/src/domus/infrastructure/owned_storage.py", + "backend/src/domus/domain/recovery.py", + "backend/src/domus/application/recovery_commands.py", + "backend/src/domus/infrastructure/sqlite_recovery.py", + "backend/src/domus/composition/admin.py", + "backend/src/domus/entrypoints/console_recovery.py", + "backend/src/domus/domain/snapshots.py", + "backend/src/domus/application/snapshots.py", + "backend/src/domus/infrastructure/sqlite_snapshots.py", + "backend/src/domus/composition/ha_app.py", + "backend/src/domus/domain/restoration.py", + "backend/src/domus/application/restoration.py", + "backend/src/domus/infrastructure/sqlite_restoration.py", + "backend/src/domus/infrastructure/sqlite_history.py", + "backend/src/domus/domain/generations.py", + "backend/src/domus/domain/activation.py", + "backend/src/domus/application/activation.py", + "backend/src/domus/composition/restore.py", + "backend/src/domus/infrastructure/offline_storage.py", + "backend/src/domus/infrastructure/restore_source.py", + "backend/src/domus/infrastructure/sqlite_activation.py" + ], + "tests": [ + "tests/backend/test_storage_values.py", + "tests/backend/test_sqlite_transactions.py", + "tests/backend/test_atomic_commands.py", + "tests/backend/test_identity_policy.py", + "tests/backend/test_membership_authorization.py", + "tests/backend/test_session_leases.py", + "tests/backend/test_sqlite_identity.py", + "tests/backend/test_ha_http.py", + "tests/backend/test_ha_websocket.py", + "tests/backend/test_ha_ingress.py", + "tests/backend/test_authentication_flow.py", + "tests/backend/test_standalone_sessions.py", + "tests/backend/test_auth_http.py", + "tests/backend/test_auth_runtime.py", + "tests/backend/test_auth_server.py", + "tests/backend/test_ingress_http.py", + "tests/backend/test_ingress_server.py", + "tests/backend/test_app_package.py", + "tests/containers/test_app_image.py", + "tests/backend/test_household_commands.py", + "tests/backend/test_application_runtime.py", + "tests/backend/test_ha_directory.py", + "tests/backend/test_member_commands.py", + "tests/backend/test_policy_commands.py", + "tests/backend/test_household_queries.py", + "tests/backend/test_household_http.py", + "tests/backend/test_household_runtime.py", + "tests/backend/test_session_directory.py", + "tests/backend/test_ingress_sessions.py", + "tests/backend/test_native_http.py", + "tests/backend/test_owned_storage.py", + "tests/backend/test_recovery_values.py", + "tests/backend/test_sqlite_recovery.py", + "tests/backend/test_recovery_commands.py", + "tests/backend/test_recovery_console.py", + "tests/backend/test_recovery_http.py", + "tests/fixtures/sqlite/v2.sql", + "tests/backend/test_snapshot_values.py", + "tests/backend/test_snapshot_commands.py", + "tests/backend/test_sqlite_snapshots.py", + "tests/backend/test_snapshot_console.py", + "tests/backend/test_ha_app_launcher.py", + "tests/backend/test_restoration_values.py", + "tests/backend/test_restoration_commands.py", + "tests/backend/test_sqlite_restoration.py", + "tests/backend/test_sqlite_history.py", + "tests/backend/test_sqlite_history_integrity.py", + "tests/backend/test_restored_commands.py", + "tests/fixtures/sqlite/v3.sql", + "tests/backend/test_generation_values.py", + "tests/backend/test_owned_generations.py", + "tests/backend/test_activation_values.py", + "tests/backend/test_activation_console.py", + "tests/backend/test_restore_source.py", + "tests/backend/test_offline_storage.py", + "tests/backend/test_sqlite_activation.py", + "tests/backend/test_activation_failures.py", + "tests/backend/test_activation_compatibility.py", + "tests/containers/test_app_compatibility.py", + "tests/containers/restore_fixture.py", + "tests/backend/test_response_readability.py", + "tests/backend/test_review_regressions.py" ], - "workflows": [], - "entrypoints": [], - "code": [], - "tests": [], "documentation": [ "docs/product-vision.md", "docs/architecture.md", "docs/operations.md", "docs/user-guide.md", - "docs/contracts/home-assistant.md" + "docs/contracts/home-assistant.md", + "docs/architecture-identity-storage.md", + "docs/development.md", + "docs/compatibility-home-assistant.md", + "docs/migrations-identity-storage.md", + "docs/ha-identity-protocol.md", + "docs/identity-household.md", + "docs/configuration.md", + "docs/operations-identity-storage.md", + "docs/contracts/auth-api.openapi.json", + "docs/installation-home-assistant.md", + "docs/household-setup.md", + "docs/evidence/m1-household-commands.json", + "docs/evidence/m1-native-ha-acceptance.json", + "docs/evidence/m1-member-administration.json", + "docs/evidence/m1-policy-administration.json", + "docs/household-api.md", + "docs/contracts/household-api.openapi.json", + "docs/evidence/m1-household-api.json", + "docs/native-household-api.md", + "docs/contracts/native-household-api.openapi.json", + "docs/evidence/m1-native-household-api.json", + "docs/owned-data-directory.md", + "docs/evidence/m1-owned-storage.json", + "docs/administrator-recovery.md", + "docs/evidence/m1-administrator-recovery.json", + "docs/snapshots.md", + "docs/evidence/m1-snapshots.json", + "docs/evidence/m1-explicit-storage-boundary.json", + "docs/evidence/m1-standalone-owned-storage.json", + "docs/evidence/m1-ha-app-bootstrap.json", + "addon-edge/translations/en.yaml", + "docs/evidence/m1-persistent-app-package.json", + "docs/evidence/m1-persistent-ha-acceptance.json", + "docs/restoration.md", + "docs/evidence/m1-restoration-planning.json", + "docs/evidence/m1-runtime-history.json", + "docs/evidence/m1-generation-selection.json", + "docs/evidence/m1-offline-activation.json", + "docs/evidence/m1-restoration-app-package.json", + "docs/evidence/m1-edge3-ha-acceptance.json", + "docs/m1-acceptance-audit.md", + "docs/evidence/m1-acceptance-audit.json", + "docs/evidence/issue6-human-clarifications.json", + "docs/evidence/m1-review-fixes.json" ], "readiness": { "state": "draft", - "implementationEvidence": "absent", - "blockingDecisions": "Storage concurrency verification; supported Home Assistant release matrix; backup retention and restore targets. The owner selected Python/FastAPI, SQLite, one household per installation and a thin HA integration on 2026-10-29.", + "implementationEvidence": "partial: identity/storage, both APIs, snapshots and verified offline restoration; Edge.3 image suites pass on native amd64/aarch64 with actual previous-image upgrade/refusal; owner Edge.3 update/session/reload/restart accepted; complete M1 pending; 79 normative cases traced in acceptance audit; source membership explanations tested in both compositions", + "blockingDecisions": "Complete deployment/live household/recovery/restore, product UI, standalone companion/browser and full channel/release acceptance. No unanswered A30 product choice. Complete current-head Bugbot review remains required.", "copilotChangesAllowed": false } }, @@ -35,25 +223,187 @@ "status": "proposed", "scope": "Each participating person uses their own Home Assistant account. Domus associates that verified user with one household member and applies explicit backend permissions independently of an optional person entity.", "owner": "Domus maintainers (review assignment pending)", - "lastVerified": "2026-10-29", + "lastVerified": "2026-10-31", "specs": [ "specs/household-identity.md" ], "workflows": [], - "entrypoints": [], - "code": [], - "tests": [], + "entrypoints": [ + "backend/src/domus/composition/server.py", + "backend/src/domus/entrypoints/http.py", + "backend/src/domus/composition/ingress.py", + "backend/src/domus/entrypoints/ingress_http.py", + "backend/src/domus/entrypoints/household_http.py", + "backend/src/domus/composition/household.py", + "backend/src/domus/entrypoints/native_http.py", + "backend/src/domus/composition/admin.py", + "backend/src/domus/entrypoints/console_recovery.py", + "backend/src/domus/composition/ha_app.py" + ], + "code": [ + "backend/src/domus/domain/identity.py", + "backend/src/domus/application/authorization.py", + "backend/src/domus/application/identity_ports.py", + "backend/src/domus/application/sessions.py", + "backend/src/domus/infrastructure/sqlite_identity.py", + "backend/src/domus/infrastructure/memory_sessions.py", + "backend/src/domus/infrastructure/ha_config.py", + "backend/src/domus/infrastructure/ha_http.py", + "backend/src/domus/infrastructure/ha_websocket.py", + "backend/src/domus/infrastructure/ha_ingress.py", + "backend/src/domus/application/authentication.py", + "backend/src/domus/application/authentication_values.py", + "backend/src/domus/composition/server.py", + "backend/src/domus/entrypoints/http.py", + "backend/src/domus/infrastructure/auth_runtime.py", + "backend/src/domus/infrastructure/memory_attempts.py", + "backend/src/domus/infrastructure/standalone_sessions.py", + "backend/src/domus/application/session_values.py", + "backend/src/domus/composition/ingress.py", + "backend/src/domus/entrypoints/ingress_http.py", + "backend/src/domus/entrypoints/bounded_http.py", + "backend/src/domus/application/household_commands.py", + "backend/src/domus/infrastructure/application_runtime.py", + "backend/src/domus/infrastructure/ha_directory.py", + "backend/src/domus/application/member_commands.py", + "backend/src/domus/application/administrator_proof.py", + "backend/src/domus/application/policy_commands.py", + "backend/src/domus/application/household_queries.py", + "backend/src/domus/entrypoints/household_http.py", + "backend/src/domus/entrypoints/http_contract.py", + "backend/src/domus/composition/household.py", + "backend/src/domus/infrastructure/session_access.py", + "backend/src/domus/entrypoints/native_http.py", + "backend/src/domus/entrypoints/http_errors.py", + "backend/src/domus/entrypoints/ingress_boundary.py", + "backend/src/domus/infrastructure/ingress_sessions.py", + "backend/src/domus/infrastructure/owned_storage.py", + "backend/src/domus/domain/recovery.py", + "backend/src/domus/application/recovery_commands.py", + "backend/src/domus/infrastructure/sqlite_recovery.py", + "backend/src/domus/composition/admin.py", + "backend/src/domus/entrypoints/console_recovery.py", + "backend/src/domus/domain/snapshots.py", + "backend/src/domus/application/snapshots.py", + "backend/src/domus/infrastructure/sqlite_snapshots.py", + "backend/src/domus/composition/ha_app.py", + "backend/src/domus/infrastructure/sqlite_history.py", + "backend/src/domus/domain/generations.py", + "backend/src/domus/domain/activation.py", + "backend/src/domus/application/activation.py", + "backend/src/domus/composition/restore.py", + "backend/src/domus/infrastructure/offline_storage.py", + "backend/src/domus/infrastructure/restore_source.py", + "backend/src/domus/infrastructure/sqlite_activation.py" + ], + "tests": [ + "tests/backend/test_identity_policy.py", + "tests/backend/test_membership_authorization.py", + "tests/backend/test_session_leases.py", + "tests/backend/test_sqlite_identity.py", + "tests/backend/test_ha_http.py", + "tests/backend/test_ha_websocket.py", + "tests/backend/test_ha_ingress.py", + "tests/backend/test_authentication_flow.py", + "tests/backend/test_standalone_sessions.py", + "tests/backend/test_auth_http.py", + "tests/backend/test_auth_runtime.py", + "tests/backend/test_auth_server.py", + "tests/backend/test_ingress_http.py", + "tests/backend/test_ingress_server.py", + "tests/backend/test_household_commands.py", + "tests/backend/test_application_runtime.py", + "tests/backend/test_ha_directory.py", + "tests/backend/test_member_commands.py", + "tests/backend/test_policy_commands.py", + "tests/backend/test_household_queries.py", + "tests/backend/test_household_http.py", + "tests/backend/test_household_runtime.py", + "tests/backend/test_session_directory.py", + "tests/backend/test_ingress_sessions.py", + "tests/backend/test_native_http.py", + "tests/backend/test_owned_storage.py", + "tests/backend/test_recovery_values.py", + "tests/backend/test_sqlite_recovery.py", + "tests/backend/test_recovery_commands.py", + "tests/backend/test_recovery_console.py", + "tests/backend/test_recovery_http.py", + "tests/fixtures/sqlite/v2.sql", + "tests/backend/test_snapshot_values.py", + "tests/backend/test_snapshot_commands.py", + "tests/backend/test_sqlite_snapshots.py", + "tests/backend/test_snapshot_console.py", + "tests/backend/test_ha_app_launcher.py", + "tests/backend/test_sqlite_history.py", + "tests/backend/test_sqlite_history_integrity.py", + "tests/backend/test_restored_commands.py", + "tests/fixtures/sqlite/v3.sql", + "tests/backend/test_generation_values.py", + "tests/backend/test_owned_generations.py", + "tests/backend/test_activation_values.py", + "tests/backend/test_activation_console.py", + "tests/backend/test_restore_source.py", + "tests/backend/test_offline_storage.py", + "tests/backend/test_sqlite_activation.py", + "tests/backend/test_activation_failures.py", + "tests/backend/test_activation_compatibility.py", + "tests/containers/test_app_image.py", + "tests/containers/test_app_compatibility.py", + "tests/containers/restore_fixture.py", + "tests/backend/test_response_readability.py", + "tests/backend/test_review_regressions.py" + ], "documentation": [ "docs/product-vision.md", "docs/architecture.md", "docs/operations.md", "docs/user-guide.md", - "docs/contracts/home-assistant.md" + "docs/contracts/home-assistant.md", + "docs/architecture-identity-storage.md", + "docs/migrations-identity-storage.md", + "docs/ha-identity-protocol.md", + "docs/identity-household.md", + "docs/configuration.md", + "docs/operations-identity-storage.md", + "docs/contracts/auth-api.openapi.json", + "docs/installation-home-assistant.md", + "docs/household-setup.md", + "docs/evidence/m1-household-commands.json", + "docs/evidence/m1-native-ha-acceptance.json", + "docs/evidence/m1-member-administration.json", + "docs/evidence/m1-policy-administration.json", + "docs/household-api.md", + "docs/contracts/household-api.openapi.json", + "docs/evidence/m1-household-api.json", + "docs/native-household-api.md", + "docs/contracts/native-household-api.openapi.json", + "docs/evidence/m1-native-household-api.json", + "docs/owned-data-directory.md", + "docs/evidence/m1-owned-storage.json", + "docs/administrator-recovery.md", + "docs/evidence/m1-administrator-recovery.json", + "docs/snapshots.md", + "docs/evidence/m1-snapshots.json", + "docs/evidence/m1-explicit-storage-boundary.json", + "docs/evidence/m1-standalone-owned-storage.json", + "docs/evidence/m1-ha-app-bootstrap.json", + "addon-edge/translations/en.yaml", + "docs/evidence/m1-persistent-app-package.json", + "docs/evidence/m1-persistent-ha-acceptance.json", + "docs/evidence/m1-runtime-history.json", + "docs/evidence/m1-generation-selection.json", + "docs/evidence/m1-offline-activation.json", + "docs/evidence/m1-restoration-app-package.json", + "docs/evidence/m1-edge3-ha-acceptance.json", + "docs/m1-acceptance-audit.md", + "docs/evidence/m1-acceptance-audit.json", + "docs/evidence/issue6-human-clarifications.json", + "docs/evidence/m1-review-fixes.json" ], "readiness": { "state": "draft", - "implementationEvidence": "absent", - "blockingDecisions": "Detailed permission matrix/override review; authenticated recovery after external HA account loss; proof of HA identity/administrator status in both deployment targets; technical service principal policy. The owner confirmed three administrator-mapping modes with all HA administrators as the default, plus configurable household profiles and per-member permission exceptions.", + "implementationEvidence": "partial: identity/storage, both APIs, snapshots and verified offline restoration; Edge.3 image suites pass on native amd64/aarch64 with actual previous-image upgrade/refusal; owner Edge.3 update/session/reload/restart accepted; complete M1 pending; 79 normative cases traced in acceptance audit; source membership explanations tested in both compositions", + "blockingDecisions": "Complete deployment/live household/recovery/restore, product UI, standalone companion/browser and full channel/release acceptance. No unanswered A30 product choice. Complete current-head Bugbot review remains required.", "copilotChangesAllowed": false } }, @@ -259,7 +609,7 @@ "status": "proposed", "scope": "Every implemented capability starts with meaningful tests and enforced architecture boundaries. A CI Check workflow validates each PR update, and both deployment targets ship isolated experimental and production channels.", "owner": "Domus maintainers (review assignment pending)", - "lastVerified": "2026-10-29", + "lastVerified": "2026-10-31", "specs": [ "specs/delivery-quality.md" ], @@ -282,7 +632,8 @@ "tests/python/test_docs.py", "tests/python/test_ci_contract.py", "tests/architecture/test_python_architecture.py", - "tests/node/architecture.test.mjs" + "tests/node/architecture.test.mjs", + "tests/containers/test_app_image.py" ], "documentation": [ "docs/product-vision.md", @@ -294,7 +645,7 @@ ], "readiness": { "state": "draft", - "implementationEvidence": "partial: tested development tooling and CI; application/HA/channel acceptance pending", + "implementationEvidence": "partial: tooling plus separately tested storage kernel/backend CI and actual import graph; UI/HA/channel acceptance pending", "blockingDecisions": "Current-head CI proof and required-check configuration by maintainer; real application/HA coverage and adapter contracts; channel IDs/integration coexistence; release publication authorization. Tooling versions and initial checks are implemented under issue #4.", "copilotChangesAllowed": false } diff --git a/specs/household-identity.md b/specs/household-identity.md index 6051099..bb8c916 100644 --- a/specs/household-identity.md +++ b/specs/household-identity.md @@ -222,7 +222,7 @@ Minimum: 30 distinct cases, derived from the explicit risk inventory below. Each | household-identity-C29 | Domain and configuration | household-identity-R2 | Given a household profile denial for a supported ordinary action and an authorized explicit member allowance, the backend permits only that action within scope while unrelated denied actions remain denied. | | household-identity-C30 | Domain and configuration | household-identity-R2 | Given contradictory allow/deny entries for the same permission at the same precedence level, saving rejects the policy with prior effective permissions retained instead of resolving it to an accidental grant. | -Every implemented first-party module starts with meaningful behavior tests. Target and proposed merge gate: 100% lines/statements/functions/branches where the selected tools expose those metrics, enforced separately across backend, frontend and integration code. Third-party/generated artifacts are distinguished from authored source; broad exclusions cannot hide untested logic. No application coverage currently exists. +Every implemented first-party module starts with meaningful behavior tests. Target and proposed merge gate: 100% lines/statements/functions/branches where the selected tools expose those metrics, enforced separately across backend, frontend and integration code. Third-party/generated artifacts are distinguished from authored source; broad exclusions cannot hide untested logic. The partial M1 backend now has independent statement/branch coverage; full identity journeys, UI and HA integration remain pending. Inject deterministic clocks, IDs, provider fakes and transaction failures. Use semantic assertions alongside golden content, real adapter contracts and disposable HA integration environments. Add Google Chrome evidence from the owner's authorized local HA for real UX; it does not replace automated tests or unit coverage. CI Check runs the complete applicable suite for every PR update. @@ -312,3 +312,7 @@ These are internal implementation steps within the first integrated release; the Primary sources: [Copilot standard](https://github.com/vypdev/copilot/blob/f5e5a4cf9a80248994469cd88ad55eecab5b95ca/specs/README.md), [original template](https://github.com/vypdev/copilot/blob/f5e5a4cf9a80248994469cd88ad55eecab5b95ca/specs/_template.md), and the linked [reference investigation](../docs/reference-investigation.md). Confirmed owner choices and unresolved proposals are separated in [decisions](../docs/decisions.md). [Issue #2](https://github.com/vypdev/domus/issues/2) tracks the documentation foundation. The [adoption note](../docs/copilot-adoption.md) records the current catalog evidence limitation. Optional future depth remains explicitly deferred in the product vision; the gateway's installation and MCP internals are outside Domus scope. + +### Partial M1 identity checkpoint — 2026-10-31 + +The [implementation companion](platform-foundation-identity-sqlite.md) records pure identity/mapping/permission rules, transaction-bound local authority, member/policy persistence and the session-lease core. [Source-identified evidence](../docs/evidence/m1-identity-policy.json) distinguishes executable policy/storage/provider-fixture tests from pending HA protocol, complete member commands, UI, recovery and target acceptance. This specification remains Draft; no planned acceptance inventory is declared complete by the partial checkpoint. diff --git a/specs/platform-foundation-identity-sqlite.md b/specs/platform-foundation-identity-sqlite.md new file mode 100644 index 0000000..9c2fbfe --- /dev/null +++ b/specs/platform-foundation-identity-sqlite.md @@ -0,0 +1,624 @@ +# Home Assistant Identity and SQLite Persistence + +- Status: Draft +- Date: 2026-10-31 +- Catalog capability ID: `platform-foundation` +- Last verified: 2026-10-31; source fixtures and bounded Edge.3 acceptance; complete review pending +- Owners: Domus maintainers (review assignment pending) +- Scope: Specify the next M1 increment: verified human Home Assistant identity, household authorization, and durable SQLite persistence in the existing `platform-foundation` capability. +- Related issues/PRs: [Issue #6](https://github.com/vypdev/domus/issues/6) · [PR #7](https://github.com/vypdev/domus/pull/7) +- Required review gates: product UX, architecture, testing, documentation, security/operations +- Open decisions blocking readiness: none; review gates and target-specific acceptance remain required before readiness. + +This companion preserves the ownership and invariants of `specs/platform-foundation.md` and `specs/household-identity.md`; it does not create a competing owner. At the original baseline `0012a172492515e282be09c37921f4014a451e64`, PR #5 supplied tooling rather than this runtime. The source implementation and bounded package/browser evidence now appear in section 20 and the acceptance audit. Draft status remains until the required review gates pass; neither this foundation increment nor its fixtures establish complete M1 or product acceptance. + +## 1. Executive summary + +A human signs in through the configured Home Assistant (HA) path, and Domus binds that session to a current, server-proven HA identity. An authorized administrator explicitly sets up one household, selects the configured administrator mapping, and activates verified members. Household permissions and business records survive restart through SQLite. Every protected action rechecks current HA proof when available and current Domus authorization; unverified identity, stale grants, and duplicate commands cannot create authority or effects. + +```text +Prove human identity with HA -> evaluate current household policy -> commit state, receipt, audit and outbox together -> return the authorized result +``` + +## 2. Problem, current behavior, and evidence + +### 2.1 Problem + +The household product needs individual HA identities, explicit membership and permission setup, and durable business state. Without those runtime boundaries, a first browser arrival, spoofed identity header, stale grant, process restart, or replay could establish authority incorrectly or lose/duplicate effects. + +### 2.2 Current behavior + +1. At the immutable base, PR #5 is merged to `develop` and provides reproducible developer tooling and 85 tooling/architecture tests. +2. The household runtime, HA human authentication, SQLite persistence, HTTP domain API, and backend coverage scope are absent from supplied catalog evidence. +3. Existing identity and platform specifications are Draft; their authentication, concurrency, and recovery proof remains outstanding. + +These are the supplied baseline facts. No runtime acceptance is inferred from them. + +### 2.3 Evidence + +- Code/workflow/documentation references: immutable-base catalog evidence lists `docs/product-vision.md`, `docs/architecture.md`, `docs/operations.md`, `docs/user-guide.md`, and `docs/contracts/home-assistant.md`; implementation and test arrays for this capability are empty. +- Incidents or concrete examples: no incident is supplied. Current absence of runtime evidence is the concrete limitation. +- External primary sources: [HA WebSocket API](https://developers.home-assistant.io/docs/api/websocket/) · [HA authorization API](https://developers.home-assistant.io/docs/auth_api/). +- Unknowns: the owner HA About screen was inspected read-only on 2026-10-31; this is version evidence only, not runtime acceptance. The observed reference matrix is standalone Core 2026.10.4; Supervisor target Core 2026.10.4, Supervisor 2026.10.5, HA OS 18.3, Frontend 20260930.5. Every row is pending until the exact protocol, package, and live/browser cases execute. Other versions are unverified. + +### 2.4 Retrospective classification (as-built baselines only) + +Not applicable: this prospective companion does not reconstruct an implemented runtime baseline. + +## 3. Actors, surfaces, and terminology + +| Actor | Goal | Entry point | Visible surfaces | +|---|---|---|---| +| HA human | Authenticate and use permitted household functions | HA authorization flow or native Supervisor Ingress | Session, household, member and policy API results | +| Household administrator | Set up household and manage membership/policy | Authenticated versioned API | Revisions, member state, safe errors | +| Installation operator | Approve a requested recovery locally | Owned host/container console | Target-bound request details and approval result | +| Domus operator/contributor | Diagnose, restore and maintain installation | Console, documentation and CI | Sanitized logs, snapshots, checks and contract docs | + +A **human identity** is the active, non-system HA user proven by the server. A **household** is the one household per installation whose business records Domus owns. An **installation** has a persistent UUID and a `stable` or `edge` channel. An **administrator mapping** determines how proven HA administrators receive Domus administrator grants. HA authenticates people; Domus owns household records and authorization policy. Identity and persistence invariants remain governed by `specs/household-identity.md` and `specs/platform-foundation.md`. + +## 4. Goals, non-goals, and fixed invariants + +### 4.1 Goals + +| Requirement | Behavior | +|---|---| +| ID-01 | Authenticate a human through a supported HA flow and bind the session only to fresh server-side proof of the exact active, non-system HA user and actual administrator source. | +| ID-02 | Support explicit household setup, verified membership linkage, all three administrator mappings, predefined profiles, household adjustments, member exceptions, deterministic precedence, pending self-enrollment, and last-administrator safety. | +| ID-03 | In selected-user and setup-only modes, recover only with a current proven HA administrator plus explicit installation-console approval bound to a short-lived target-specific request and fresh completion proof. | +| ID-04 | Persist household state, policy/revisions, scoped command receipts, audit and outbox through SQLite adapters, with atomic writes, durable replay behavior, migrations, concurrency limits, backup and restore. | +| ID-05 | Provide a small strict versioned FastAPI identity/household/member/policy surface with safe errors and common Supervisor/Ingress and standalone wiring contracts. | +| ID-06 | Enforce Clean Architecture on real product imports and a separately measured, 100% supported first-party backend coverage scope in every-PR CI. | +| ID-07 | Document user, setup, operator, contributor, migration, recovery and compatibility journeys; report unproven target/live acceptance as pending. | + +### 4.2 Non-goals + +1. Other household domains, product UI, HA entity/service companion integration, full packaging/channel acceptance, release publication, and automatic backup scheduling/rotation are outside this increment. Packaging and operational retention/recovery objectives remain mandatory first-release review items. +2. Copilot, its installed configuration/resources, and the existing gateway are unchanged. No absent capability is declared implemented. +3. No arbitrary-browser recovery, shared password/PIN, automatic retry under a new key, or silent widening to all HA administrators is provided. + +### 4.3 Fixed product/safety invariants + +1. Identity, administrator status, and actor never come from submitted IDs, person entities, untrusted forwarding/Ingress headers, or installation transport credentials. Standalone never accepts Ingress identity headers. Supervisor trusts only the actual peer `172.30.32.2` on a private listener with proxy-header rewriting disabled; it requires remote user ID and fresh Core proof. Its fixed Core proxy/token is transport authority only. +2. Standalone uses a fixed operator-configured HA origin and exact Domus public client/callback origin, OAuth state and S256 PKCE, server-side form token exchange, bounded single-use attempts, and server-owned bounded sessions; no central client registration or client secret. Validate URLs and bound I/O; never accept request-chosen hosts, redirects, or proxies. +3. Current HA identity/administrator source and current Domus authorization govern protected actions. HA outage permits existing sessions to query local data only until original absolute expiry, with current local policy and known revocation checks; no extension or mutation. Explicit HA rejection invalidates access. +4. Policy precedence is hard identity/scope/domain constraints, member exception, household profile adjustment, predefined profile, then deny. Same-level contradiction is rejected. Exceptions cannot create administrator identity, bypass restricted-performer approval, cross scope, or weaken invariants. Private preferences of another member are denied by default. +5. Schema/installation/channel identity is checked before mutation. State, receipt, audit and outbox commit atomically. Credentials and session tokens never enter business tables or snapshots. + +## 5. Current versus proposed product journey + +| Stage | Current | Proposed | User/operator effect | +|---|---|---|---| +| Sign in | No runtime evidence | HA proves exact active human; Domus issues bounded session | User sees identity-bound session or safe denial | +| Initialize/join | No household runtime evidence | Administrator explicitly sets up; verified self-enrollment remains pending until activation | No first-arrival privilege | +| Authorize action | No runtime policy evidence | Current HA source and local policy/revision are checked | Revoked, suspended or stale authority is denied | +| Commit/restart | No durable runtime evidence | SQLite commits effects and replay evidence atomically; restart restores them | Authorized retry returns original result | +| Recover | No recovery runtime proof | Current HA administrator initiates; local console approves; target freshly reproves | Two authorities grant a narrow explicit recovery grant | + +```mermaid +flowchart LR + A[HA human or explicit pending enrollment] --> B[Server proves identity] + B --> C[Current Domus policy and scope] + C --> D[Atomic SQLite state, receipt, audit, outbox] + D --> E[Authorized result and durable restart state] + B --> F[Identity unavailable or rejected] + F --> G[Bounded read-only lease or denial] +``` + +Text equivalent: HA entry or pending enrollment -> server identity proof -> current policy check -> atomic durable commit -> authorized result; outage allows only an unexpired read-only session, while explicit rejection denies access. + +## 6. Functional behavior and state model + +### 6.1 Happy path + +1. Standalone completes state/PKCE authorization and server-side token exchange, then current-user WebSocket proof; Supervisor validates actual peer and Ingress identity, then performs fresh Core lookup. Adapters reject inactive, system, malformed, oversized, expired, rejected, or mismatched/correlation-invalid proof. +2. An eligible administrator explicitly creates the household and selects an administrator mapping. The default is `all_ha_administrators`. An ordinary proven, unlinked human may enroll but remains pending until authorized activation. In all_ha_administrators mode, an explicitly enrolling current HA administrator may activate through the verified mapping exception; page arrival never initializes the household or grants an ordinary user access. +3. Each protected use case obtains fresh HA status/source before entering SQLite and checks current local policy inside its transaction. An HA proof and a local commit cannot be atomic across systems; a concurrent independent HA revocation must not be represented as an atomic directory snapshot. Mutations require expected revisions and `Idempotency-Key`. +4. SQLite commits domain state, scoped receipt, audit and outbox in one transaction. Identical authorized replay returns the retained result; different payload conflicts. Restart and verified restore preserve replay guards. + +### 6.2 Alternative paths + +- Mapping modes: `all_ha_administrators` (default, live inherited grant for every proven current HA administrator); `setup_administrator` (designated proven setup HA administrator); `selected_ha_user` (explicit active human selected by proven HA administrator; selected human need not be HA administrator). +- Configurable default roles: administrator manages members, policy/settings and privileged corrections; adult handles ordinary daily work/supplies and may approve restricted performers; restricted completes assigned work and creates own shopping requests; viewer queries authorized data only; everyone edits own preferences. Configuration of points, verification, automation and privileged corrections is administrator-only. +- A mapping/source change revokes inherited grants, preserves source provenance and independent explicit grants, and never overrides suspension or technical-principal boundaries. Administrator edits require optimistic revisions, transaction-time policy checks and cache invalidation. +- Selected-user/setup-only recovery: a current proven HA administrator requests recovery; the operator runs `domus-admin recovery approve ` in the owned console and explicitly confirms the exact safely rendered target. The ten-minute approval binds request, target identity, installation UUID, channel, mapping/revision. Fresh proof that the same target remains a current HA administrator is required to complete. Approval and consumed request, explicit grant, receipt, audit and outbox commit atomically. No HTTP approval endpoint. +- During HA outage, a still-valid session may read local data only until its original expiry. It cannot mutate or extend; explicit HA rejection invalidates it. + +### 6.3 State machine + +| State | Entered when | User-visible meaning | Allowed next states | Recovery/owner | +|---|---|---|---|---| +| Unauthenticated | No valid session | Sign-in required | Proving, denied | Human uses configured HA flow | +| Proving | Bounded auth/proof attempt starts | Identity is being checked | Authenticated, denied, expired | Auth adapter cleans up attempt | +| Authenticated | Fresh human proof and session established | Current bounded access | Pending member, active member, read-only outage, denied, expired | Application rechecks current policy | +| Pending member | Verified human enrolls without activation | Request awaits administrator | Active, suspended, denied | Administrator activates or rejects | +| Active member | Verified linkage and permitted status | Profile/overrides apply within invariants | Suspended, revoked, pending | Administrator changes with revision | +| Recovery pending | Eligible admin requests recovery | Console approval needed before expiry | Approved, expired, canceled | Operator approves exact target locally | +| Recovery approved | Bound console approval awaits atomic completion | Target must freshly reprove; approval is consumed only with the grant | Recovered, expired, denied | HA proof and one atomic transaction | +| Read-only outage | HA unavailable but original session unexpired | Local queries only | Authenticated after fresh proof, expired, denied | No extension; explicit rejection denies | +| Denied/expired | Proof, scope or lifetime fails | Action unavailable | New authentication where allowed | No partial mutation; safe error | + +Duplicate identical commands return original authorized results. Changed-payload key reuse conflicts. New writes with stale revisions conflict and are not rebased. Competing writers receive bounded busy/conflict results. Cancellation/rollback leaves state and associated evidence unchanged. Expired recovery approval cannot be revived or retargeted. + +## 7. User-facing configuration + +| Input | Type | Recommended default | Allowed values/range | Scope/persistence | +|---|---|---|---|---| +| HA origin | Operator URL | Fixed configured HA origin | Strictly validated origin; no request-selected URL | Installation config; never operation supplied | +| Administrator mapping | Enum | `all_ha_administrators` | `all_ha_administrators`, `setup_administrator`, `selected_ha_user` | Household policy with revision and provenance | +| Household profile | Enum | Administrator-selected predefined profile | Administrator, adult, restricted, viewer | Household/member policy; revisioned | +| Member exception | Bounded action decision | No exception | Allow/deny supported ordinary permissions; constraints always deny | Member policy; administrator-only revisioned edit | +| Session lifetime | Bounded duration | Operator/product bounded value | Fixed maximum; absolute expiry, no outage extension | Server session state, not business DB | +| SQLite writer wait | Duration | 250 ms | Fixed bounded budget | Adapter policy; busy returns retryable 503 | +| Pending recovery request lifetime | Duration | 10 minutes | Fixed | Must be approved while live | +| Recovery approval lifetime | Duration | 10 minutes | Fixed, not configurable | Target/request-bound, single use | +| Installation identity | UUID + channel | Generated once at installation | `stable` or `edge`; exact configured match | Persistent DB metadata; mismatch forbids mutation | + +Reject unknown fields, invalid enums, out-of-bound text/IDs/timezone, invalid cross-field mappings, arbitrary URLs, and same-level policy contradictions. Existing valid sessions reread current local permission and known revocation on protected requests; writes check current authorization in-transaction. Migrations are explicit and atomic. Missing metadata is handled only by a documented compatible migration; future or unrelated schemas fail before writes. Never silently regenerate installation identity, adopt another database, or open Stable data as Edge. Recommended configuration is the default mapping and an administrator role for the setup administrator; a meaningful alternative is `selected_ha_user` with the same explicit setup and recovery safeguards. Safety constraints are not configurable. + +## 8. Clean Architecture design + +### 8.1 Responsibilities and dependency direction + +| Layer/boundary | Owns | Must not own/import | +|---|---|---| +| Domain/pure policy | Identity references, household/member state, permission precedence, revisions and invariants | FastAPI, HA, HTTP, SQL, credentials | +| Application | Setup, enrollment, authorization, recovery, mutation/replay use cases and immutable contracts | Concrete SDK, process, SQL or transport concerns | +| Adapters/data | HA protocol mapping, SQLite repositories, transaction/error mapping and snapshots | Product authorization decisions | +| Infrastructure/composition | Concrete adapters, channel/instance configuration and wiring | Duplicated business policy | +| Entrypoints | FastAPI DTO validation, cookie/CSRF/origin and Supervisor/standalone input adaptation | Domain entities or duplicated orchestration | +| Presentation | Stable response/error view models and operator/user text | Mutations or policy transitions | + +```mermaid +flowchart LR + E[FastAPI and console entrypoints] --> A[Application use cases] + A --> D[Domain policy] + A --> P[Semantic ports] + H[HA identity adapter] --> P + S[SQLite and snapshot adapters] --> P + I[Composition and channel wiring] --> H + I --> S + A --> V[Presentation models] +``` + +Text equivalent: entrypoints call application use cases; application depends inward on pure domain and semantic ports; HA/SQLite adapters implement ports; composition wires adapters; presentation maps results without owning mutations. + +### 8.2 Contracts, state, and trust boundaries + +- Pure decisions: identity eligibility, mapping grants, permission precedence, scope, last-admin safety, revisions and recovery eligibility. +- Application contracts: immutable use-case input/output; actor derives only from proven session; mutations carry expected revision and idempotency key. +- Normative wire contracts are the published standalone/native household OpenAPI documents and their route guide. Setup is POST /api/v1/household; enrollment is POST /api/v1/members/enrollment; member replacement is PUT /api/v1/members/{member_id}. These supersede historical pre-implementation route examples, whose original text remains archived. Both compositions share the same household router; no earlier public client compatibility is claimed. +- Operator origins omit explicit scheme-default ports before exact browser-origin checks. A bound installation console may read its original approval receipt after normal closure/expiry without granting or renewing approval; new effects retain every live-binding/confirmation check and restoration closure guards still apply. +- Semantic ports: identity proof/current source, unit-of-work/repository, clock/IDs, snapshot/restore and audit/outbox capabilities without provider DTOs. +- Durable state/schema ownership: SQLite adapter owns versioned schema, application marker, installation UUID, channel, business records, policy revisions, receipts, audit and outbox. Credentials/tokens are excluded. +- Concurrency/idempotency strategy: separate per-unit-of-work connections and `BEGIN IMMEDIATE`; 250 ms initial busy budget; transaction-time authorization. Busy is retryable `storage_busy`/503 with `Retry-After` guidance. Identical scoped key/digest returns original result before old expected-revision evaluation; changed payload is `idempotency_conflict`/409; stale new write is `revision_conflict`/409. No automatic new-key retries or silent rebase. +- Trusted and untrusted inputs: only configured endpoint, actual Supervisor peer and fresh server-side HA proof are trusted as described in section 4. Submitted IDs, headers, cookies without validation, forwarding metadata and all request DTOs are untrusted. +- Provider error mapping: bounded timeout/unavailability and malformed/oversized/correlation-invalid responses fail closed; errors expose allowlisted details and correlation ID, never credentials or diagnostic leakage. + +### 8.3 Executable architecture constraints + +CI must run import-linter against actual `domus` product modules, including unimported runtime files, and reject outward dependencies and cycles. Entrypoints/composition may depend inward; adapters are independent; application depends only on domain and semantic ports; inner layers cannot import FastAPI, HA, HTTP or SQL clients. Add separate backend coverage collection for all authored backend modules and enforce 100% supported executable statements/lines and branches as exposed by coverage.py/pytest-cov. Do not use broad exclusions or test-only production branches. Explicitly identify any genuine generated, third-party or non-executable declaration distinction. Enforce function metrics only where supported. Keep tooling, frontend and future HA integration scopes independent. Every PR head, including docs-only, runs every applicable suite through the fail-closed CI Check aggregate. Installed Copilot resource hashes remain unchanged. + +## 9. UI/UX and content contract + +The API and console are the applicable surfaces; this increment does not add product UI or GitHub issue/PR automation. Responses lead with current state and next action, then safe details. Errors use `impact -> cause -> action -> retained state` and never imply a committed effect failed. + +### 9.1 Information hierarchy + +1. Current authentication, membership, policy or operation state. +2. Completed identity proof or durable effect. +3. Next transition and required actor. +4. Impact and retained revision/receipt. +5. Correlation and inspectable operation ID. +6. Technical detail only when allowlisted and sanitized. + +### 9.2 Representative primary view + +```json +{ + "state": "pending_activation", + "message": "Your verified request to join this household is waiting for an administrator.", + "action_required": "Ask a household administrator to review your membership request.", + "member_id": "mem_01J...", + "correlation_id": "cor_01J..." +} +``` + +Pending/no action: `state=active`, “Your household access is active,” “No action required.” Action required: pending enrollment instructs the member to contact an administrator. Blocked before effects: “The household change was not saved. The supplied revision is no longer current. Review the latest policy and submit a new request. No change was committed.” Partial success: “The command was committed, but delivery is pending. The saved result is available by operation ID; retry with the same key to retrieve it.” Completed: “The member was activated,” with member ID and resulting revision. These are semantic examples, not claims of implemented UI. + +### 9.3 Issue, PR, and comment behavior + +No issue, PR, label, check, comment, or notification automation is introduced. The implementation PR must have a deterministic purpose/title and link Issue #6, this companion, both owning specifications, and inspectable CI/artifact evidence. Merge requires complete coverage and a current-head complete Bugbot review with no unresolved findings; skipped, neutral, or incomplete review is not acceptance. No implementation PR merge, release, or deployment is authorized by this specification. + +### 9.4 Accessibility, localization, and responsive behavior + +English is the language for repository issues, documentation, source identifiers, comments and tests; conversation with the owner may be Spanish. API enum/error codes are stable and language-neutral; user text uses documented English fallback. Use textual state and action, never color alone. Keep errors and tables usable on narrow screens; ensure semantic headings and descriptive links. Sanitize untrusted Markdown, HTML, mentions, commands, markers, and URLs in any rendered operator text. Diagrams have adjacent textual equivalents. No product UI is in scope; manual API/console content review is required. + +## 10. Failure, recovery, and cleanup + +| Failure/partial state | User impact | Retained facts | Automatic retry | Required action | Cleanup | +|---|---|---|---|---|---| +| HA timeout/unavailable | New proof and mutations unavailable; valid session may read until absolute expiry | Existing session expiry and local state | No mutation retry; bounded read-only only | Retry sign-in/proof when HA returns | Expire attempt/session; remove transient tokens | +| Explicit HA rejection/revocation | Access denied | Audit-safe outcome only | No | Reauthenticate if eligible | Invalidate session | +| Invalid/mismatched proof or spoofed identity | Denied | Correlation only | No | Correct HA setup/client flow | Clear bounded attempt; no secret logs | +| SQLite busy | Mutation not committed | No partial state; safe correlation | Client may retry same request/key | Retry after bounded guidance | Close unit-of-work connection | +| Stale revision / changed-payload replay | Conflict, no new effect | Prior state/receipt retained | No automatic rebase; identical authorized replay only | Review latest state or use correct same key | Retain replay evidence | +| Migration failure/future/unrelated schema | Startup/mutation blocked safely | Prior recoverable DB unchanged | No blind retry | Restore compatible paired image/schema or operator repair | Preserve source DB and diagnostics without secrets | +| Recovery expires/wrong target/channel | No recovery grant | Request and audit outcome | No | Start new request with current proof | Expire request/approval; never retarget | +| Outbox delivery fails after commit | Business result remains committed | State, receipt, audit and pending outbox | Bounded delivery retry, same event identity | Operator inspects pending operation if prolonged | Mark delivery outcome; do not roll back committed effect | +| Restore candidate corrupt/wrong identity/channel | Active data remains in place | Candidate validation evidence | No activation retry without correction | Choose verified same-installation/channel snapshot | Preserve candidate for inspection; never overwrite active DB | + +Snapshots are consistent SQLite snapshots including identity/schema, business state, receipts/replay guards, audit and outbox. Standalone creates snapshots explicitly through installation console and retains successful snapshots until operator removal; no automatic pruning. Supervisor packaging should participate in the operator's normal HA backup using suitable hooks; actual packaging and schedule/retention acceptance remain pending. Restore targets a new owned data directory for the same installation UUID and channel, with writes stopped; verify marker, schema, integrity, manifest/checksum and identity before activation. The visible recovery point is the selected snapshot's committed checkpoint timestamp. No 24-hour RPO or recovery-time SLA is claimed. Pair compatible application/configuration for rollback. Credentials and browser tokens are excluded; users reauthenticate. Cleanup must never remove replay protection while a retry could recur. + +Owner decision A30: when later committed evidence remains readable in current data, restore the selected business checkpoint and retain later receipts/history as already processed. Do not reapply their business changes or redeliver processed effects. Conflicting/incomplete evidence must be resolved before activation; never silently discard it. + +## 11. Security, permissions, and privacy + +1. Require current proven human identity and current Domus policy for protected actions. The three mapping modes and precedence are fixed in section 4; pending users have no active access. Suspension/revocation, scope, stale revisions and last-active-administrator safety are enforced transactionally. +2. OAuth state/PKCE, exact configured origins, bounded single-use attempts/sessions, Origin/CSRF checks for cookie mutations, strict DTOs, actual Supervisor peer validation, and fixed HA endpoint prevent forgery and request-directed network access. +3. Never persist HA tokens or browser session credentials in business tables, audit, outbox, logs or snapshots. Bound and redact diagnostics; use allowlisted error details and correlation IDs. +4. Scope idempotency keys and receipts; authorize receipt lookup under current permissions. Retain durable replay evidence across restart, cleanup, backup and restore. Recovery requires both authorities, exact target binding, ten-minute expiry, fresh proof and atomic single-use consumption. +5. An installation/channel mismatch, future/unrelated schema, corrupt candidate, malformed provider response, identity-source mismatch or HA rejection fails closed before unauthorized effects. + +## 12. Observability and operational UX + +- User-facing state: stable session/member/policy/operation state, revision, action, safe error code and correlation ID. +- Operator evidence: installation UUID, channel, schema version, snapshot checkpoint, target binding, migration result, pending outbox and sanitized correlation. Never emit tokens or secrets. +- Logs, metrics, and correlation: structured bounded events for auth outcome, storage busy/conflict, migration/restore and outbox outcome; avoid identity data beyond necessary stable internal references. Correlate requests and receipts without logging credentials. +- Pending external dependency versus workflow failure: HA unavailability is a bounded dependency state; explicit HA rejection is denial. A committed command with pending outbox delivery remains committed and visibly pending. +- Rate-limit/retry behavior: bound protocol I/O and auth attempts; storage wait is 250 ms initially. Give retry guidance only for temporary busy/unavailability; clients retry mutations only with the identical key and payload. +- Noise/comment budget: no automated comments or notifications; one structured operation result per request and no repeated console approval prompts for an already consumed request. + +## 13. Compatibility, migration, rollout, and rollback + +- Existing in-flight operations/data: no runtime baseline is evidenced. Preserve owning contracts and do not claim compatibility with absent implementations. +- Schema/configuration migration: versioned atomic SQL migrations; inspect marker/schema before journal or schema writes. Reject future/unrelated schemas. Failed migration preserves recoverable prior state. Installation UUID and channel must match before mutation; never silently adopt or regenerate. +- Deprecated behavior and compatibility window: none established. Support only exact independently tested Core/Supervisor/protocol combinations; retain a versioned contract matrix. Other versions remain unverified. +- Rollout stages/feature flag if needed: land contracts/architecture checks, pure policies, use cases, adapters, endpoint wiring, then real-file/security and target acceptance. No feature flag may weaken identity or policy invariants. +- Rollback and irreversible effects: take pre-upgrade recovery snapshot before destructive migration, paired with compatible application/configuration. Atomic transactions prevent partial domain effects. Restore verified same-installation/channel snapshot to a new directory; do not activate a corrupt/future/wrong-identity candidate. Preserve receipts across rollback/restore. +- Compatibility matrix: standalone Core 2026.10.4; Supervisor/Ingress Core 2026.10.4, Supervisor 2026.10.5, HA OS 18.3, Frontend 20260930.5. All pending. Only exact passed artifact/version combinations may be called supported. Actual current versions must be added and tested when connectivity allows. Both deployment targets remain required for first usable release. + +## 14. Testing strategy and numeric budget + +Budgets are minimum distinct cases; inventory rows define the cases and the publication process derives the budget table and reciprocal traceability. No inventory row represents an executed test. Existing owning baseline case IDs `platform-foundation-C01` through `platform-foundation-C20` are reserved and are not reused or counted here. + +| Area | Minimum distinct cases | Behaviors/risks covered | +|---|---:|---| +| Domain/configuration/pure planning | 14 | Observable scenarios in the case inventory. | +| State/application/idempotency/races | 14 | Observable scenarios in the case inventory. | +| Adapters/provider contracts | 13 | Observable scenarios in the case inventory. | +| Workflows/setup/schema | 6 | Observable scenarios in the case inventory. | +| UI/UX/localization/sanitization | 5 | Observable scenarios in the case inventory. | +| Integration/security/migration | 9 | Observable scenarios in the case inventory. | +| Total | 61 | Distinct inventory cases; repeated executions do not add cases. | + +| Case ID | Area | Requirements | Scenario | +|---|---|---|---| +| platform-foundation-identity-sqlite-C01 | Domain/configuration/pure planning | ID-01 | Reject an inactive, system, or malformed HA human proof. | +| platform-foundation-identity-sqlite-C02 | Domain/configuration/pure planning | ID-01 | Reject mismatched request correlation and expired proof. | +| platform-foundation-identity-sqlite-C03 | Domain/configuration/pure planning | ID-02 | Default mapping grants each currently proven HA administrator and no nonadministrator. | +| platform-foundation-identity-sqlite-C04 | Domain/configuration/pure planning | ID-02 | Setup-administrator mode grants only designated proven setup administrator. | +| platform-foundation-identity-sqlite-C05 | Domain/configuration/pure planning | ID-02 | Selected-user mode permits an explicitly selected active human who is not an HA administrator. | +| platform-foundation-identity-sqlite-C06 | Domain/configuration/pure planning | ID-02 | Mapping/source change revokes inherited grant but preserves independent explicit grant. | +| platform-foundation-identity-sqlite-C07 | Domain/configuration/pure planning | ID-02 | Verify administrator, adult, restricted and viewer default permission matrix. | +| platform-foundation-identity-sqlite-C08 | Domain/configuration/pure planning | ID-02 | Verify exception/profile/default/deny precedence and reject same-level contradiction. | +| platform-foundation-identity-sqlite-C09 | Domain/configuration/pure planning | ID-02 | Enforce hard scope, suspension, restricted approval and last-administrator constraints over overrides. | +| platform-foundation-identity-sqlite-C10 | Domain/configuration/pure planning | ID-03 | Recovery eligibility requires current HA administrator and exact selected/setup-only mode. | +| platform-foundation-identity-sqlite-C11 | Domain/configuration/pure planning | ID-04 | Canonical payload digest and receipt scope distinguish identical payload from changed payload. | +| platform-foundation-identity-sqlite-C12 | Domain/configuration/pure planning | ID-04 | Validate installation UUID/channel/schema marker and reject future or unrelated schema before writes. | +| platform-foundation-identity-sqlite-C13 | Domain/configuration/pure planning | ID-05 | Strict DTO rejects unknown, unbounded, malformed and cross-field-invalid values. | +| platform-foundation-identity-sqlite-C14 | Domain/configuration/pure planning | ID-06 | Pure policy decision branches cover allow, deny, stale, suspension and undefined action. | +| platform-foundation-identity-sqlite-C15 | State/application/idempotency/races | ID-02 | First browser arrival cannot create household or administrator authority. | +| platform-foundation-identity-sqlite-C16 | State/application/idempotency/races | ID-02 | Verified self-enrollment remains pending until authorized activation. | +| platform-foundation-identity-sqlite-C17 | State/application/idempotency/races | ID-02 | Administrator edits use optimistic revision and transaction-time current policy. | +| platform-foundation-identity-sqlite-C18 | State/application/idempotency/races | ID-02 | Suspension and revocation deny protected action and private preferences remain private. | +| platform-foundation-identity-sqlite-C19 | State/application/idempotency/races | ID-03 | Console approval is exact-target bound, expires at ten minutes and cannot be retargeted. | +| platform-foundation-identity-sqlite-C20 | State/application/idempotency/races | ID-03 | Recovery completion requires fresh same-target administrator proof and is single use. | +| platform-foundation-identity-sqlite-C21 | State/application/idempotency/races | ID-03 | Wrong identity, channel, mapping revision, expiry or concurrent completion creates no grant. | +| platform-foundation-identity-sqlite-C22 | State/application/idempotency/races | ID-04 | Identical authorized retry after restart returns original result before stale-write revision handling. | +| platform-foundation-identity-sqlite-C23 | State/application/idempotency/races | ID-04 | Same scoped key with changed payload returns conflict without new effect. | +| platform-foundation-identity-sqlite-C24 | State/application/idempotency/races | ID-04 | Current permissions govern receipt lookup after actor revocation. | +| platform-foundation-identity-sqlite-C25 | State/application/idempotency/races | ID-04 | Competing writers serialize and produce bounded busy or revision conflict without partial effects. | +| platform-foundation-identity-sqlite-C26 | State/application/idempotency/races | ID-04 | Injected failure rolls back domain state, receipt, audit and outbox atomically. | +| platform-foundation-identity-sqlite-C27 | State/application/idempotency/races | ID-04 | Cleanup preserves replay protection and authorized old retry cannot duplicate effect. | +| platform-foundation-identity-sqlite-C28 | State/application/idempotency/races | ID-04 | Restore preserves receipt/replay evidence and identical authorized retry remains idempotent. | +| platform-foundation-identity-sqlite-C29 | Adapters/provider contracts | ID-01 | Standalone OAuth uses fixed origin, state, S256 PKCE, bounded single-use attempt and server form exchange. | +| platform-foundation-identity-sqlite-C30 | Adapters/provider contracts | ID-01 | Standalone rejects request-chosen host/redirect, Ingress headers and technical transport credential as human identity. | +| platform-foundation-identity-sqlite-C31 | Adapters/provider contracts | ID-01 | HA adapter handles timeout, unavailable, revoked token, malformed/oversized response and cleanup without secret leakage. | +| platform-foundation-identity-sqlite-C32 | Adapters/provider contracts | ID-01 | Supervisor adapter accepts only actual peer 172.30.32.2 and requires fresh Core lookup of active non-system human and actual admin source. | +| platform-foundation-identity-sqlite-C33 | Adapters/provider contracts | ID-01 | Supervisor arbitrary forwarding headers and transport token alone cannot assert user or role. | +| platform-foundation-identity-sqlite-C34 | Adapters/provider contracts | ID-01 | HA outage permits bounded read-only access until original expiry but no mutation or session extension. | +| platform-foundation-identity-sqlite-C35 | Adapters/provider contracts | ID-05 | Stable API errors expose allowlisted details/correlation and map 401, 403, 404, 409, 422 and 503 safely. | +| platform-foundation-identity-sqlite-C36 | Adapters/provider contracts | ID-05 | Cookie mutation requires valid Origin/CSRF checks and actor is never taken from submitted member ID. | +| platform-foundation-identity-sqlite-C37 | Adapters/provider contracts | ID-04 | SQLite uses parameterized operations, separate unit-of-work connections and bounded BEGIN IMMEDIATE writer wait. | +| platform-foundation-identity-sqlite-C38 | Adapters/provider contracts | ID-04 | Atomic migration failure leaves recoverable prior database and performs no partial schema change. | +| platform-foundation-identity-sqlite-C39 | Adapters/provider contracts | ID-04 | Real database file restart preserves household, policy, receipt, audit and outbox. | +| platform-foundation-identity-sqlite-C40 | Adapters/provider contracts | ID-04 | Consistent snapshot verifies integrity/manifest/identity and restores to new same-UUID/channel directory. | +| platform-foundation-identity-sqlite-C41 | Adapters/provider contracts | ID-04 | Corrupt, future, unrelated, wrong-instance or wrong-channel restore candidate cannot replace active DB. | +| platform-foundation-identity-sqlite-C42 | Workflows/setup/schema | ID-05 | OpenAPI and route contract includes specified session, household, member, authorization, operation and recovery routes. | +| platform-foundation-identity-sqlite-C43 | Workflows/setup/schema | ID-05 | Standalone auth start/callback/logout routes and Supervisor-prefixed routing obey common server contract. | +| platform-foundation-identity-sqlite-C44 | Workflows/setup/schema | ID-05 | Recovery approval has console command only and no HTTP approval route. | +| platform-foundation-identity-sqlite-C45 | Workflows/setup/schema | ID-06 | Every PR head including docs-only runs tooling, frontend, documentation and backend suites in fail-closed aggregate. | +| platform-foundation-identity-sqlite-C46 | Workflows/setup/schema | ID-06 | Backend coverage scope includes authored unimported runtime modules and enforces supported statements/lines and branches at 100%. | +| platform-foundation-identity-sqlite-C47 | Workflows/setup/schema | ID-06 | Actual product import graph rejects outward imports and cycles; no broad coverage exclusions hide logic. | +| platform-foundation-identity-sqlite-C48 | UI/UX/localization/sanitization | ID-05 | Pending enrollment response states current status, completed proof, next action and no active access. | +| platform-foundation-identity-sqlite-C49 | UI/UX/localization/sanitization | ID-05 | Blocked pre-commit error names impact, cause, action and retained state without claiming a commit. | +| platform-foundation-identity-sqlite-C50 | UI/UX/localization/sanitization | ID-05 | Committed effect with pending outbox is presented as committed and inspectable by operation ID. | +| platform-foundation-identity-sqlite-C51 | UI/UX/localization/sanitization | ID-05 | Error/console rendering sanitizes untrusted Markdown, HTML, mentions, commands, markers and URLs. | +| platform-foundation-identity-sqlite-C52 | UI/UX/localization/sanitization | ID-05 | API codes remain stable, user text has English fallback, narrow layout and text states remain understandable without color. | +| platform-foundation-identity-sqlite-C53 | Integration/security/migration | ID-01, ID-05 | Standalone protocol fixtures prove OAuth callback/current-user proof and reject invalid state, PKCE, correlation and credentials. | +| platform-foundation-identity-sqlite-C54 | Integration/security/migration | ID-01, ID-05 | Supervisor fixtures prove socket peer boundary, fresh identity lookup and prefixed routing without trusting forwarded headers. | +| platform-foundation-identity-sqlite-C55 | Integration/security/migration | ID-02, ID-04 | Real-file integration proves atomic commit and rollback of state, receipt, audit and outbox across process restart. | +| platform-foundation-identity-sqlite-C56 | Integration/security/migration | ID-03, ID-04 | Recovery race and replay fixtures prove both authorities and atomic single-use grant/evidence. | +| platform-foundation-identity-sqlite-C57 | Integration/security/migration | ID-04 | Real-file concurrent processes verify serialized writers, bounded busy result, stale revision conflict and no duplicate effects. | +| platform-foundation-identity-sqlite-C58 | Integration/security/migration | ID-04 | Backup/reopen verifies checkpoint, identity/channel and audit/outbox; restoring older state retains readable later processed evidence without executing effects again. | +| platform-foundation-identity-sqlite-C59 | Integration/security/migration | ID-06 | CI coverage and architecture commands evaluate actual backend modules and fail when threshold or import contract is violated. | +| platform-foundation-identity-sqlite-C60 | Integration/security/migration | ID-07 | Exact reference target matrix records protocol, package and live/browser evidence as pending until run on identified artifact/version. | +| platform-foundation-identity-sqlite-C61 | Integration/security/migration | ID-07 | Authorized local HA/Chrome proof, if run, records exact artifact/version, owned test data and cleanup; absent live proof is explicitly pending. | + +Define changed-module backend coverage as 100% of supported executable statements/lines and branches; repository backend scope has the same 100% threshold. Pure policy branches require complete branch coverage. Use deterministic clocks, IDs, fakes, failure injection and real-file/process fixtures; no sleeps as business tests and no live services in automated tests. Parameter rows count separately only when distinct behavior. Contract tests parse routes, schemas, workflow structure and import graph rather than grep prose. Include semantic assertions alongside golden response fixtures. Human review must inspect API/console readability and exact live target/browser evidence; protocol fixtures and coverage alone do not establish deployment acceptance. Do not claim any listed case executed until evidence exists. + +## 15. Documentation and discoverability + +| Path | Change | +|---|---| +| `specs/platform-foundation-identity-sqlite.md` | create | +| `specs/platform-foundation.md` | reference | +| `specs/household-identity.md` | reference | +| `specs/delivery-quality.md` | reference | +| `docs/product-vision.md` | reference | +| `docs/architecture.md` | reference | +| `docs/operations.md` | reference | +| `docs/user-guide.md` | reference | +| `docs/contracts/home-assistant.md` | reference | +| `docs/identity-household.md` | create | +| `docs/configuration.md` | create | +| `docs/operations-identity-storage.md` | create | +| `docs/architecture-identity-storage.md` | create | +| `docs/migrations-identity-storage.md` | create | +| `docs/compatibility-home-assistant.md` | create | +| `docs/ha-identity-protocol.md` | create | +| `backend/pyproject.toml` | create | +| `backend/src/domus/__init__.py` | create | +| `backend/src/domus/application/__init__.py` | create | +| `backend/src/domus/application/commands.py` | create | +| `backend/src/domus/application/authorization.py` | create | +| `backend/src/domus/application/identity_ports.py` | create | +| `backend/src/domus/application/sessions.py` | create | +| `backend/src/domus/application/ports.py` | create | +| `backend/src/domus/composition/__init__.py` | create | +| `backend/src/domus/domain/__init__.py` | create | +| `backend/src/domus/domain/errors.py` | create | +| `backend/src/domus/domain/identity.py` | create | +| `backend/src/domus/application/session_values.py` | create | +| `backend/src/domus/domain/storage.py` | create | +| `backend/src/domus/entrypoints/__init__.py` | create | +| `backend/src/domus/infrastructure/__init__.py` | create | +| `backend/src/domus/infrastructure/sqlite.py` | create | +| `backend/src/domus/infrastructure/sqlite_identity.py` | create | +| `backend/src/domus/infrastructure/memory_sessions.py` | create | +| `backend/src/domus/infrastructure/ha_config.py` | create | +| `backend/src/domus/infrastructure/ha_http.py` | create | +| `backend/src/domus/infrastructure/ha_websocket.py` | create | +| `backend/src/domus/infrastructure/ha_ingress.py` | create | +| `tests/backend/test_atomic_commands.py` | create | +| `tests/backend/test_sqlite_transactions.py` | create | +| `tests/backend/test_storage_values.py` | create | +| `tests/backend/test_identity_policy.py` | create | +| `tests/backend/test_membership_authorization.py` | create | +| `tests/backend/test_sqlite_identity.py` | create | +| `tests/backend/test_session_leases.py` | create | +| `tests/backend/test_ha_http.py` | create | +| `tests/backend/test_ha_websocket.py` | create | +| `tests/backend/test_ha_ingress.py` | create | +| `tests/fixtures/sqlite/v1.sql` | create | +| `docs/evidence/m1-storage-kernel.json` | create | +| `docs/evidence/m1-identity-policy.json` | create | +| `docs/evidence/m1-ha-protocol.json` | create | +| `docs/evidence/home-assistant-reference.json` | create | +| `pyproject.toml` | update | +| `uv.lock` | update | +| `.github/workflows/ci_check.yml` | update | +| `tooling/domus_tooling/ci_policy.py` | update | +| `tests/python/test_ci_contract.py` | update | +| `docs/development.md` | update | +| `docs/decisions.md` | update | +| `docs/copilot-adoption.md` | update | +| `docs/roadmap.md` | update | +| `docs/sdd-validation.md` | update | +| `specs/catalog.json` | update | +| `specs/CATALOG.md` | update | +| `backend/src/domus/application/authentication.py` | create | +| `backend/src/domus/application/authentication_values.py` | create | +| `backend/src/domus/composition/server.py` | create | +| `backend/src/domus/entrypoints/http.py` | create | +| `backend/src/domus/infrastructure/auth_runtime.py` | create | +| `backend/src/domus/infrastructure/memory_attempts.py` | create | +| `backend/src/domus/infrastructure/standalone_sessions.py` | create | +| `tests/backend/test_authentication_flow.py` | create | +| `tests/backend/test_standalone_sessions.py` | create | +| `tests/backend/test_auth_http.py` | create | +| `tests/backend/test_auth_runtime.py` | create | +| `tests/backend/test_auth_server.py` | create | +| `docs/contracts/auth-api.openapi.json` | create | +| `docs/evidence/m1-standalone-auth.json` | create | +| `architecture/python-application.ini` | update | +| `tests/architecture/test_python_architecture.py` | update | +| `backend/src/domus/entrypoints/bounded_http.py` | create | +| `backend/src/domus/entrypoints/ingress_http.py` | create | +| `backend/src/domus/composition/ingress.py` | create | +| `tests/backend/test_ingress_http.py` | create | +| `tests/backend/test_ingress_server.py` | create | +| `docs/installation-home-assistant.md` | create | +| `docs/evidence/m1-ingress-preview.json` | create | +| `repository.yaml` | create | +| `addon-edge/config.yaml` | create | +| `addon-edge/Dockerfile` | create | +| `addon-edge/README.md` | create | +| `addon-edge/DOCS.md` | create | +| `addon-edge/CHANGELOG.md` | create | +| `tests/backend/test_app_package.py` | create | +| `tests/containers/test_app_image.py` | create | +| `docs/evidence/m1-app-package.json` | create | +| `README.md` | update | +| `backend/src/domus/application/household_queries.py` | create | +| `backend/src/domus/entrypoints/household_http.py` | create | +| `backend/src/domus/entrypoints/http_contract.py` | create | +| `backend/src/domus/composition/household.py` | create | +| `backend/src/domus/infrastructure/session_access.py` | create | +| `tests/backend/test_household_queries.py` | create | +| `tests/backend/test_household_http.py` | create | +| `tests/backend/test_household_runtime.py` | create | +| `tests/backend/test_session_directory.py` | create | +| `docs/household-api.md` | create | +| `docs/contracts/household-api.openapi.json` | create | +| `docs/evidence/m1-household-api.json` | create | +| `backend/src/domus/application/administrator_proof.py` | create | +| `backend/src/domus/application/policy_commands.py` | create | +| `tests/backend/test_policy_commands.py` | create | +| `docs/evidence/m1-policy-administration.json` | create | +| `backend/src/domus/application/member_commands.py` | create | +| `tests/backend/test_member_commands.py` | create | +| `docs/evidence/m1-member-administration.json` | create | +| `backend/src/domus/application/household_commands.py` | create | +| `backend/src/domus/infrastructure/application_runtime.py` | create | +| `backend/src/domus/infrastructure/ha_directory.py` | create | +| `tests/backend/test_household_commands.py` | create | +| `tests/backend/test_application_runtime.py` | create | +| `tests/backend/test_ha_directory.py` | create | +| `docs/household-setup.md` | create | +| `docs/evidence/m1-household-commands.json` | create | +| `docs/evidence/m1-native-ha-acceptance.json` | create | +| `backend/src/domus/entrypoints/native_http.py` | create | +| `backend/src/domus/entrypoints/http_errors.py` | create | +| `backend/src/domus/entrypoints/ingress_boundary.py` | create | +| `backend/src/domus/infrastructure/ingress_sessions.py` | create | +| `tests/backend/test_ingress_sessions.py` | create | +| `tests/backend/test_native_http.py` | create | +| `docs/native-household-api.md` | create | +| `docs/contracts/native-household-api.openapi.json` | create | +| `docs/evidence/m1-native-household-api.json` | create | +| `backend/src/domus/infrastructure/owned_storage.py` | create | +| `tests/backend/test_owned_storage.py` | create | +| `docs/owned-data-directory.md` | create | +| `docs/evidence/m1-owned-storage.json` | create | +| `backend/src/domus/domain/recovery.py` | create | +| `backend/src/domus/application/recovery_commands.py` | create | +| `backend/src/domus/infrastructure/sqlite_recovery.py` | create | +| `backend/src/domus/composition/admin.py` | create | +| `backend/src/domus/entrypoints/console_recovery.py` | create | +| `tests/backend/test_recovery_values.py` | create | +| `tests/backend/test_sqlite_recovery.py` | create | +| `tests/backend/test_recovery_commands.py` | create | +| `tests/backend/test_recovery_console.py` | create | +| `tests/backend/test_recovery_http.py` | create | +| `tests/fixtures/sqlite/v2.sql` | create | +| `docs/administrator-recovery.md` | create | +| `docs/evidence/m1-administrator-recovery.json` | create | +| `backend/src/domus/domain/snapshots.py` | create | +| `backend/src/domus/application/snapshots.py` | create | +| `backend/src/domus/infrastructure/sqlite_snapshots.py` | create | +| `tests/backend/test_snapshot_values.py` | create | +| `tests/backend/test_snapshot_commands.py` | create | +| `tests/backend/test_sqlite_snapshots.py` | create | +| `tests/backend/test_snapshot_console.py` | create | +| `docs/snapshots.md` | create | +| `docs/evidence/m1-snapshots.json` | create | +| `docs/evidence/m1-explicit-storage-boundary.json` | create | +| `docs/evidence/m1-standalone-owned-storage.json` | create | +| `backend/src/domus/composition/ha_app.py` | create | +| `tests/backend/test_ha_app_launcher.py` | create | +| `docs/evidence/m1-ha-app-bootstrap.json` | create | +| `addon-edge/translations/en.yaml` | create | +| `docs/evidence/m1-persistent-app-package.json` | create | +| `docs/evidence/m1-persistent-ha-acceptance.json` | create | +| `tests/backend/test_response_readability.py` | create | +| `tests/backend/test_review_regressions.py` | create | +| `docs/evidence/m1-review-fixes.json` | create | +| `docs/m1-acceptance-audit.md` | create | +| `docs/evidence/m1-acceptance-audit.json` | create | +| `docs/evidence/issue6-human-clarifications.json` | create | +| `docs/evidence/m1-restoration-app-package.json` | create | +| `docs/evidence/m1-edge3-ha-acceptance.json` | create | + +The new user guide must explain normal sign-in, explicit setup, pending enrollment, defaults and examples. Setup documentation covers HA prerequisites, origin, both deployment paths, permissions, and credential handling. Configuration reference documents every bounded value and precedence. Operator guidance covers state, outage, recovery command, errors, snapshots, restore, replay and cleanup. Migration/compatibility docs explain atomic versioned migration, supported exact matrix, paired rollback, and pending deployment acceptance. Contributor architecture docs show ports, trust boundaries, actual import checks, tests and route contracts. Link and validate examples against fixtures; update catalog evidence only when implementation actually exists. Document standalone explicit snapshots and Supervisor's intended HA backup participation without claiming schedule/retention acceptance. + +## 16. Acceptance scenarios + +These are executable acceptance requirements, not reports of tests already run. + +1. Given a valid standalone authorization, when the server completes state/PKCE exchange and correlated current-user proof, then the session is bound to that exact active human; invalid/revoked/malformed proof yields a safe denial with no secret leakage. +2. Given Supervisor mode, when a request arrives, then only the actual configured peer and fresh Core identity lookup establish user/admin status; forwarding headers or transport credential alone never establish a human. +3. Given each of the three mapping modes, when users and profile/member exceptions are evaluated, then only the specified grants apply, pending users remain inactive, precedence is deterministic, revocation/suspension applies, and the last administrator cannot be removed. +4. Given selected-user or setup-only mode, when recovery is requested, console-approved for the exact target, and completed with fresh proof, then a current HA administrator and operator approval are both required and one atomic single-use grant results; expired, wrong-target, stale or replayed requests grant nothing. +5. Given a mutation with current permission and revision, when SQLite commits, then domain state, receipt, audit and outbox are durable together; injected failure leaves all unchanged and process restart preserves committed state. +6. Given the same authorized key and payload after restart or restore, when retried, then the original result is returned; changed payload conflicts, old permissions do not expose receipts, and cleanup cannot erase replay protection. +7. Given competing writers or stale revisions, when the bounded writer slot or revision check fails, then the caller receives retryable busy/503 or conflict/409 without partial effects or silent rebase. +8. Given a future/unrelated schema or wrong installation/channel database, when opened for mutation, then validation fails before writes; valid migration is atomic and preserves recoverable prior state on failure. +9. Given a consistent snapshot, when restored to a new owned directory for the same installation UUID/channel, then marker, schema, integrity, manifest and identity are verified before activation and receipts/audit/outbox survive. Wrong/corrupt candidates cannot replace active data. +10. Given an HA outage with a previously valid session, when a read occurs before original absolute expiry, then local read is allowed subject to current policy; mutation and extension are denied, and explicit HA rejection invalidates access. +11. Given an API request with unknown fields, forged actor/member ID, invalid Origin/CSRF, or excessive values, when validated, then it receives the specified safe envelope and no business, receipt, audit or outbox effect. +12. Given CI on any PR head including docs-only, when CI Check runs, then all applicable suites and actual import/cycle checks run and supported backend metrics enforce 100%; incomplete review is not acceptance. +13. Given the reference compatibility matrix, when protocol fixtures or controlled live/browser proof have not run for an exact artifact/version, then that row remains pending and is not described as supported. Any authorized live proof records exact versions/artifact, owned test data and cleanup. +14. Given an operator or user reading a response on a narrow screen, when pending, action-required, blocked, partially committed, or complete states render, then status, impact, next action and retained state are understandable as text without color or unsafe untrusted rendering. + +## 17. Requirements traceability + +| Requirement | Cases | +|---|---| +| ID-01 | platform-foundation-identity-sqlite-C01, platform-foundation-identity-sqlite-C02, platform-foundation-identity-sqlite-C29, platform-foundation-identity-sqlite-C30, platform-foundation-identity-sqlite-C31, platform-foundation-identity-sqlite-C32, platform-foundation-identity-sqlite-C33, platform-foundation-identity-sqlite-C34, platform-foundation-identity-sqlite-C53, platform-foundation-identity-sqlite-C54 | +| ID-02 | platform-foundation-identity-sqlite-C03, platform-foundation-identity-sqlite-C04, platform-foundation-identity-sqlite-C05, platform-foundation-identity-sqlite-C06, platform-foundation-identity-sqlite-C07, platform-foundation-identity-sqlite-C08, platform-foundation-identity-sqlite-C09, platform-foundation-identity-sqlite-C15, platform-foundation-identity-sqlite-C16, platform-foundation-identity-sqlite-C17, platform-foundation-identity-sqlite-C18, platform-foundation-identity-sqlite-C55 | +| ID-03 | platform-foundation-identity-sqlite-C10, platform-foundation-identity-sqlite-C19, platform-foundation-identity-sqlite-C20, platform-foundation-identity-sqlite-C21, platform-foundation-identity-sqlite-C56 | +| ID-04 | platform-foundation-identity-sqlite-C11, platform-foundation-identity-sqlite-C12, platform-foundation-identity-sqlite-C22, platform-foundation-identity-sqlite-C23, platform-foundation-identity-sqlite-C24, platform-foundation-identity-sqlite-C25, platform-foundation-identity-sqlite-C26, platform-foundation-identity-sqlite-C27, platform-foundation-identity-sqlite-C28, platform-foundation-identity-sqlite-C37, platform-foundation-identity-sqlite-C38, platform-foundation-identity-sqlite-C39, platform-foundation-identity-sqlite-C40, platform-foundation-identity-sqlite-C41, platform-foundation-identity-sqlite-C55, platform-foundation-identity-sqlite-C56, platform-foundation-identity-sqlite-C57, platform-foundation-identity-sqlite-C58 | +| ID-05 | platform-foundation-identity-sqlite-C13, platform-foundation-identity-sqlite-C35, platform-foundation-identity-sqlite-C36, platform-foundation-identity-sqlite-C42, platform-foundation-identity-sqlite-C43, platform-foundation-identity-sqlite-C44, platform-foundation-identity-sqlite-C48, platform-foundation-identity-sqlite-C49, platform-foundation-identity-sqlite-C50, platform-foundation-identity-sqlite-C51, platform-foundation-identity-sqlite-C52, platform-foundation-identity-sqlite-C53, platform-foundation-identity-sqlite-C54 | +| ID-06 | platform-foundation-identity-sqlite-C14, platform-foundation-identity-sqlite-C45, platform-foundation-identity-sqlite-C46, platform-foundation-identity-sqlite-C47, platform-foundation-identity-sqlite-C59 | +| ID-07 | platform-foundation-identity-sqlite-C60, platform-foundation-identity-sqlite-C61 | + +## 18. Implementation sequence + +1. Preserve the two owning contracts; add companion/API/port contracts, versioned compatibility matrix and executable architecture/coverage CI design. +2. Implement pure identity eligibility, mappings, profile/exception precedence, recovery and revision policies with deterministic tests. +3. Implement use cases, semantic ports, authorization-before-receipt lookup and atomic unit-of-work orchestration. +4. Implement HA standalone and Supervisor adapters with bounded protocol fixtures; implement SQLite schema, migrations, receipts, audit/outbox and snapshot/restore adapters with real-file tests. +5. Add FastAPI DTOs/routes, CSRF/origin enforcement, safe errors, console recovery command, and representative response/content fixtures. +6. Deliver user/setup/configuration/operator/contributor/migration/compatibility documentation alongside behavior; update catalog evidence only for implemented paths. +7. Run full every-PR CI, 100% backend coverage, real import/cycle checks, security and real-file acceptance; separately obtain exact package and live/browser evidence before marking target rows passed. + +## 19. Definition of Done + +- [ ] Every normative requirement has an executable acceptance scenario and reciprocal case traceability. +- [ ] Existing household identity and platform ownership/invariants are preserved; no competing owner is introduced. +- [ ] Architecture boundaries are implemented and enforced against actual product imports and cycles. +- [ ] Numeric test inventory and budget agree; backend supported statements/lines and branches reach 100% without broad exclusions. +- [ ] Identity proof, all mapping defaults, profiles/exceptions, recovery, stale revisions, suspension and last-administrator safety pass. +- [ ] SQLite migration, atomic rollback, restart, concurrency, replay, backup and restore pass on real files. +- [ ] API validation, session/CSRF/origin, safe errors and both wiring contracts pass; deployment/package claims remain limited to exact proven targets. +- [x] Primary response states, sanitization, accessibility, localization fallback, and manual API/console readability evidence are reviewed (owner confirmation on 2026-10-31; product UI remains outside this increment). +- [ ] User/setup/operator/contributor, configuration, migration, compatibility and recovery documentation is complete and discoverable. +- [ ] Every-PR CI runs all applicable suites, including docs-only updates, with fail-closed aggregate; merge review gate is complete and current-head. +- [ ] `specs/catalog.json` and generated `specs/CATALOG.md` accurately reflect evidence and specification validation passes. +- [ ] No readiness-blocking decision remains unresolved; unexecuted live/package acceptance remains explicitly pending. + +## 20. References and decisions + +- Primary sources: [HA authorization API](https://developers.home-assistant.io/docs/auth_api/) · [HA WebSocket API](https://developers.home-assistant.io/docs/api/websocket/) +- Related specs/ADRs/issues/PRs: `specs/household-identity.md`; `specs/platform-foundation.md`; `specs/delivery-quality.md`; [Issue #6](https://github.com/vypdev/domus/issues/6); [PR #5](https://github.com/vypdev/domus/pull/5); [Copilot upstream report #774](https://github.com/vypdev/copilot/issues/774); [Copilot upstream report #777](https://github.com/vypdev/copilot/issues/777). +- Decisions and rejected alternatives: retain HA auth and HTTP/SQLite as replaceable adapters around pure policies and use cases; server-side HA proof is required; use explicit transaction boundaries and parameterized SQLite operations; API schemas are entrypoint DTOs, not domain entities. Recovery requires current HA administrator proof plus installation-console approval; no shared secret or HTTP approval. Backup scope is explicit consistent snapshot/restore primitive, not an automatic scheduler. No untested version is promised. +- Follow-up work explicitly outside this spec: other household domains, product UI, HA entity/service integration, complete package and Stable/Edge deployment acceptance, Supervisor backup hooks and operational retention/recovery objectives, release publication. Both deployment targets remain required for first usable release. Merge/release/deployment is not authorized by this draft. +### Implementation evidence — 2026-10-31 + +The [acceptance audit](../docs/m1-acceptance-audit.md) maps all 61 identity and 18 restoration +cases to exact semantic tests or bounded observations. Its [receipt](../docs/evidence/m1-acceptance-audit.json) +records 1,387 backend tests, 4,692 statements and 1,304 branches at 100% supported coverage. +The [review corrections](../docs/evidence/m1-review-fixes.json) add 20 regression cases, 1,407 tests +and 100% coverage. The owner confirmed API/console text clarity; complete current-head Bugbot review +remains pending. Skipped draft review is not acceptance. Specifications remain Draft. + +Earlier immutable receipts remain indexed by the catalog and implementation/user guides. The +[real-image restoration receipt](../docs/evidence/m1-restoration-app-package.json) adds Edge.3 +activation/resume and previous-image upgrade/refusal on native amd64/aarch64. The +[owner receipt](../docs/evidence/m1-edge3-ha-acceptance.json) proves bounded update/configuration/ +session/reload/restart only. New source response explanations are outside that immutable pin. +No credentials enter household storage, responses, snapshots or evidence. Native authentication +is independent of standalone OAuth. Standalone browser return is unaccepted; its owned grant +was revoked (1 confirmed, 0 unknown). Live household/recovery/restore, product UI, standalone +companion authority, full deployment/channel and release acceptance remain pending. diff --git a/specs/platform-restoration.md b/specs/platform-restoration.md new file mode 100644 index 0000000..70da749 --- /dev/null +++ b/specs/platform-restoration.md @@ -0,0 +1,505 @@ +# Checkpoint restoration and retained processed history + +- Status: Draft +- Date: 2026-10-31 +- Catalog capability ID: platform-foundation +- Last verified: 2026-10-31; real arm64 Edge.3 restore/upgrade/refusal; full deployment acceptance pending +- Owners: Domus maintainers +- Scope: complete M1 snapshot restoration with retained replay evidence, explicit preparation and verified offline activation. +- Related issues/PRs: [Issue #6](https://github.com/vypdev/domus/issues/6), [PR #7](https://github.com/vypdev/domus/pull/7) +- Required review gates: product UX, architecture, testing, documentation, security/operations +- Open decisions blocking readiness: none for A30; complete current-head review and target-specific acceptance remain required. + +## 1. Executive summary + +Restore the selected checkpoint's business state while keeping readable later operations as already +processed history. An old retry must never execute its effect again. History is distinct from current +state and from a pending external delivery. Preparation never starts a server or replaces active data. + +```text +Stop writes -> inspect checkpoint/current evidence -> prepare isolated candidate -> verify -> activate -> reauthenticate +``` + +## 2. Problem, current behavior, and evidence + +### 2.1 Problem + +Copying an old database alone loses later receipts, can duplicate external effects and can rearm a +consumed recovery approval. Keeping later business state instead would violate the selected checkpoint. + +### 2.2 Current behavior + +1. Edge.3 packages schema 4, retained history and verified offline restoration; Edge.2 is the previous-image compatibility fixture. +2. Both managed runtimes persist identity and refuse explicit-file bypass. +3. Owner Edge.3 update/session/reload/restart passed. Reconciliation, blocked preparation, history + guards and offline activation have real-file/process and native amd64/aarch64 image evidence. +4. A30 explicitly selects checkpoint state plus retained later processed history. + +### 2.3 Evidence + +- [M1 companion](platform-foundation-identity-sqlite.md), [A30](../docs/decisions.md), + [snapshot guide](../docs/snapshots.md), [owner acceptance](../docs/evidence/m1-persistent-ha-acceptance.json). +- Example: revision 2 is checkpointed, revision 3 commits, restore returns revision 2; retrying revision + 3's original key yields a historical result without rerunning the update. +- Unknowns: live owner restore and complete deployment/channel acceptance remain pending. No restore SLA is promised. + +### 2.4 Retrospective classification (as-built baselines only) + +Not applicable: this is a prospective refinement of M1 ID-04, not an as-built completion claim. + +## 3. Actors, surfaces, and terminology + +| Actor | Goal | Entry point | Visible surfaces | +|---|---|---|---| +| Installation operator | Restore deliberately with writes stopped | Installation console | Inspection, prepared binding and activation result | +| Proven HA human | Resume permitted work after reauthentication | Existing native/standalone APIs | Current state and distinctly historical operation results | + +A checkpoint is a sealed snapshot. Current evidence is the readable operation frontier, including +already archived facts. A historical operation retains its original result/audit/event but does not +assert that its business change exists in restored state. Delivered means actually acknowledged; +suppression by restore must not fabricate delivery. + +## 4. Goals, non-goals, and fixed invariants + +### 4.1 Goals + +| Requirement | Behavior | +|---|---| +| REST-01 | Validate immutable complete operation evidence and reject incompatible histories. | +| REST-02 | Preserve later receipt/audit/event facts separately from checkpoint business state. | +| REST-03 | Suppress rolled-away event delivery and preserve actual delivery knowledge. | +| REST-04 | Prepare/verify a private same-installation/channel candidate without replacing active data. | +| REST-05 | Activate only verified offline data, retain rollback evidence and recover interrupted transitions. | +| REST-06 | Enforce current authority before historical disclosure/replay; prevent reusing consumed recovery. | + +### 4.2 Non-goals + +No automatic backup schedule, pruning, cross-installation import, HA gateway change, new HTTP restore +endpoint, production release or implicit household/admin creation. These do not defer the complete +restore/activation scope described here. + +### 4.3 Fixed product/safety invariants + +Preserve UUID/channel, original command scope/fingerprint/result and historical facts. Different +facts for one command key or operation ID are conflicts. Checkpoint evidence must be explainable by +the retained current frontier. Timestamps alone cannot prove ancestry. Never invent an acknowledgement. +Unknown or conflicting later evidence cannot be silently discarded to activate a candidate. + +## 5. Current versus proposed product journey + +| Stage | Current | Proposed | User/operator effect | +|---|---|---|---| +| Inspect | Verifies snapshot only | Also explains replay frontier | Know the selected state and retained history | +| Prepare | Unavailable | New private blocked candidate | Source remains untouched | +| Activate | Unavailable | Verified offline transition | Only chosen state becomes current | +| Retry | No restore semantics | Historical result distinguished | No duplicate effect or misleading current-state success | + +```mermaid +flowchart LR + S[Stopped writes] --> V[Verified evidence] --> P[Prepared blocked candidate] --> A[Verified activation] + A --> H[Reauthenticated access] +``` + +Text equivalent: stop writes, verify both inputs, prepare without serving, activate after verification, +then authenticate again. A failed stage retains the source and candidate for inspection. + +## 6. Functional behavior and state model + +### 6.1 Happy path + +1. Verify snapshot manifest, schema, identity, integrity and exact private files. +2. Read current committed evidence under the appropriate maintenance guard. +3. Reconcile by command key and operation identity, including prior historical facts. +4. Keep checkpoint business rows. Preserve later operations as historical records and keep their + original delivery status; they never enter the pending delivery queue. +5. For checkpoint events, retain later acknowledgements to avoid redelivery. Preserve sequence + high-water marks and complete history through repeated restorations. +6. Prepare in a new private directory with a blocked marker; verify every output before activation. +7. Activate only with stopped writes and an unchanged verified source binding. Preserve the previous + recoverable installation; interrupted activation cannot start an incomplete database. +8. Drop process-local sessions. Require new proof and current restored policy for subsequent access. + +### 6.2 Alternative paths + +Empty checkpoints remain valid: later history cannot require a current household foreign key or +silently recreate its household. Re-restoring a checkpoint can make a known operation current again; +retain its historical provenance without treating it as another execution. Conflicts, unknown source +history, changed source bindings and wrong installation/channel block activation with retained evidence. + +### 6.3 State machine + +| State | Entered when | Meaning | Next states | Recovery | +|---|---|---|---|---| +| inspected | Inputs validate | No change made | prepared, rejected | Operator chooses | +| prepared | Complete candidate published | Not serving or active | verified, rejected | Inspect same candidate | +| verified | All hashes/bindings agree | Eligible for offline activation | activating, rejected | Revalidate source | +| activating | Durable transition begins | Server remains blocked | active, interrupted | Resume exact transition | +| active | Verified transition committed | Chosen state with retained history | normal operation | Reauthenticate | +| interrupted/rejected | A stage cannot be proven | No success claim | inspection/recovery | Preserve both copies | + +Duplicates identify the same candidate/transition. Reusing a key with changed input conflicts. A +post-publication output failure reports uncertainty and points to inspection, never replacement. + +## 7. User-facing configuration + +| Input | Type | Recommended default | Allowed values | Persistence | +|---|---|---|---|---| +| Installation/channel | Exact binding | Existing metadata | Same UUID and stable/edge | Candidate and source | +| Checkpoint/current source/target | Operator selection | Explicit private locations | Existing trusted sources; new target | Prepared manifest | +| A30 policy | Fixed policy | Retain later processed history | No discard/overwrite alternative | Restore evidence | + +Reject unknown fields, ambiguous identity, reused nonmatching targets and unsafe paths. Browser inputs +cannot select maintenance paths. Example: restore an older same-installation checkpoint while retaining +its later receipts. Alternative: inspect/reject the candidate and keep the existing state unchanged. + +## 8. Clean Architecture design + +### 8.1 Responsibilities and dependency direction + +| Layer | Owns | Excludes | +|---|---|---| +| Domain | Immutable evidence, conflict and reconciliation rules | SQL/filesystem/clock/frameworks | +| Application | Preparation/activation orchestration and semantic ports | Concrete paths, processes and SQL | +| Infrastructure/composition | SQLite, private files, locks, hashes and durable switching | Hidden business policy | +| Entrypoint/presentation | Strict console selection and truthful status | Granting browser maintenance authority | + +```mermaid +flowchart LR + E[Console] --> U[Use case] --> D[Pure reconciliation] + I[SQLite/filesystem adapter] --> P[Semantic port] + U --> P +``` + +### 8.2 Contracts, state, and trust boundaries + +The pure policy compares complete immutable operation facts, not filesystem timestamps. Adapters +map joined receipt/audit/outbox rows into those values. Current authority precedes replay/disclosure. +Schema migration retains existing data. Archive records may outlive a current household and must +not grant setup provenance or membership. Resource-specific guards prevent a restored old approval +from reusing an already-consumed recovery. Paths and local ownership remain an operator trust boundary. + +Preparation reads a coherent committed source view and copies exact selected checkpoint bytes. +Format 1 contains only domus.sqlite3, installation.json, operations.jsonl and restore.json. The identity +is restore_pending; the success manifest is written last. It binds the complete checkpoint, source +frontier digest, journal/identity hashes, operation/current counts and retained position high-water. +The journal is sorted by complete command scope and streams complete original facts, capped at +262,144 bytes per line. Each result/audit/event object independently remains bounded to 65,536 bytes; +do not impose that single-object limit on their aggregate. Identity/position uniqueness sets grow +with history; payloads are streamed. A checkpoint must contain every known position through its +high-water; missing earlier facts are rejected independently of wall-clock ordering. + +Current schema 1/2/3/4 inspection does not import the journal into runtime tables or make the candidate +servable. Independent preparation inspection verifies projection against checkpoint facts, counts, +hashes and private inventory. Activation must later revalidate the source digest under stopped writes; +the digest is a binding, not authentication or proof of unchanged live data by itself. + +### Runtime history design + +Schema 4 adds retained_operations without current-household foreign keys. Complete immutable evidence +is stored separately from the selected business rows, with current/historical placement and first +archive provenance. Current records may advance acknowledgement; historical suppression never marks +delivery. The outbox sequence remains above every retained position. Frozen v3 input joins v1/v2 +migration fixtures; older runtimes reject schema 4. + +Runtime receipt lookup checks current records and then retained history. A receipt may carry explicit +restoration provenance for presentation; complete evidence always contains original facts, without +that projection metadata. Current authorization remains before both replay and disclosure. Historical +responses say the original effect is absent from current state and expose suppressed delivery +separately from real prior acknowledgement. Current setup provenance never reads historical audit. +A resource guard rejects an old consumed/canceled recovery even under a new idempotency key. +Its indexed closed_recovery_id is derived from original terminal results and cross-checked against +those facts. Pending recovery queries exclude guarded resources without rewriting checkpoint state. +Original-key replay can return historical recovery results after current authority checks; a new +effect cannot reuse the old approval. Other humans cannot probe its closure before target ownership. + +History import is an infrastructure-only operation on a verified candidate inside a caller-owned +transaction, with its own savepoint. It preserves exact original facts, checks the selected current +projection, retains all prior history and updates acknowledgement/sequence monotonically. It never +grants activation authority or writes into an active owner installation. + +### Managed generation selection + +Keep existing format-1 flat installations readable. A format-2 root identity selects one validated +generation ID under a fixed private generations directory, preserving UUID/channel. Root-level +runtime/maintenance leases continue to protect the whole installation. A selected slot has its own +ready format-1 identity and a generation marker that binds the root selection; direct managed or +explicit-file startup of a slot is refused. Missing, foreign, pending or unsafe slots never create +an empty database or another identity. Old flat data may remain as retained rollback evidence but +cannot override a format-2 selection. + +The offline activator materializes and verifies a new private slot inside the existing owned root, +flushes it and atomically publishes one root pointer last. The native runtime need not rename +root-owned /data siblings or gain privileges. Source binding, exclusive stopped-write verification, +durable transition status and interruption recovery now have executable console/local evidence. +Actual package and complete deployment acceptance remain required. + +Prepared restore identity must be written before database bytes. All managed signals, including a +journal without the final manifest or a generation marker without the normal identity, block explicit +file bypass. A rejected start must not add lock files to a blocked candidate. + +### Offline activation and recovery protocol + +New preparations use format 2 with an explicit source binding: installation/channel, source schema, +selected generation or flat state and canonical managed identity digest. The complete operation +frontier digest includes this binding. Legacy format-1 preparations remain inspectable but lack the +binding required for publication; re-prepare them. Bare explicit development files remain usable for +preparation/inspection; production activation requires an existing managed root in either mode. + +An offline capability acquires both the installation runtime lease and maintenance lease exclusively, +then rereads existing ready identity/selection and validates the source read-only without migration. +Neither ordinary shared console attachment nor a caller-supplied boolean grants this capability. + +Activation validates the selected bound preparation and unchanged source before publishing a durable +pending transition. This marker blocks all normal startup. Retain a sealed input in private root +activation storage, capture a verified pre-activation source snapshot, and materialize a new blocked +target slot; migrate/import only that target. The source snapshot is a supported rollback checkpoint, +still reconciled against latest history before any later activation. Verify final +identity, schema, history, counts and hashes, then flush all files/directories. Publish the one root +selection atomically last, persist the completion receipt and retire the pending marker only after +the selected result is proven. Old source data remains recoverable, including flat source files. + +Resume uses the same bound transition and sealed input. Before pointer publication it rechecks source +and may rebuild only a validated, owned, unselected partial target from the sealed input. After pointer +publication it verifies the selected complete target and finishes that exact transition; it cannot +silently choose the old source, another input or another generation. Errors after publication preserve +uncertainty. Inspect distinguishes pending, selected active and previously activated historical state. +Repeated activation never switches back after a newer transition. Rollback also reconciles the latest +history; retaining old files is not permission to drop guards or run an incompatible older image. + +### 8.3 Executable architecture constraints + +Existing import-linter direction/purity/acyclic contracts and negative fixtures apply. Every new +authored module belongs to backend coverage; 100% supported statement/branch coverage is required. + +## 9. UI/UX and content contract + +### 9.1 Information hierarchy + +Show current versus historical state, checkpoint, retained operation count, next action and safe +inspection reference before optional technical details. + +### 9.2 Representative primary view + +```text +Prepared; not active. Checkpoint state and later processed history are verified. +Action: inspect this candidate, stop writes, then activate the same verified binding. +Existing installation data is retained. +``` + +Variants: pending verification makes no activation claim; rejected data retains both copies; an +uncertain completed transition requires inspection; active state requests reauthentication. Historical +API results say their effect is absent from current state and must not be retried as a replacement. + +### 9.3 Issue, PR, and comment behavior + +Use issue #6/PR #7, exact source/check evidence and the existing managed branch. Repeated diagnostics +update the existing upstream issue. No automatic merge, lifecycle-label change or check relaxation. + +### 9.4 Accessibility, localization, and responsive behavior + +All artifacts/content are English; owner conversation is Spanish. Console status uses text, not color +alone. No raw terminal payload, token, HA cookie, opaque Ingress path or untrusted Markdown is echoed. +The console flow has no responsive layout; API/presentation history distinctions remain accessible. + +## 10. Failure, recovery, and cleanup + +| Failure | Impact | Retained facts | Retry/action | Cleanup | +|---|---|---|---|---| +| Conflict/incomplete evidence | No activation | Both sources | Resolve exact evidence | Never discard guards | +| Copy/hash/flush failure | Candidate not successful | Private partial output, source | Inspect same target | Deliberate operator cleanup | +| Source changed | Preparation stale | Bound manifest and source | Re-prepare after stopping writes | No overwrite | +| Interrupted activation | Startup blocked | Durable transition and rollback | Resume/inspect exact transition | No premature deletion | +| Output failure after publication | Result uncertain | Published artifact | Inspect it | Never claim no effect | + +## 11. Security, permissions, and privacy + +Console maintenance is installation authority, not a browser permission. Do not borrow HA technical +privilege as a human role. Preserve current authorization before archived results and ownership before +file access. No credentials/sessions enter snapshots/history. Historical approval/consumption evidence +cannot recreate grants. Private/no-follow/single-link rules and bounded parsing/deadlines apply. + +## 12. Observability and operational UX + +Report checkpoint, installation/channel, source frontier and candidate identity, counts, current state, +and next safe action. Sanitized errors preserve uncertainty. No scheduled polling or comment flood is +introduced; record actual failures in the existing Copilot reports without waiting for fixes. + +## 13. Compatibility, migration, rollout, and rollback + +Support verified compatible historical snapshots through explicit migrations on the candidate only. +Edge.3 now packages schema 4 and passes both CPU-family image suites. Its bounded owner +update/session/reload/restart acceptance is separate from live restore acceptance. New metadata must block older/unaware runtime paths. Pair image/configuration/schema for +rollback; never strip history to make an older image start. Both deployment modes remain required. + +## 14. Testing strategy and numeric budget + +Minimum distinct planned cases, not executed-test claims: + +| Area | Minimum distinct cases | Behaviors/risks covered | +|---|---:|---| +| Domain/configuration/pure planning | 6 | Evidence, ancestry, conflicts, delivery and repeated restoration | +| State/application/idempotency/races | 3 | Preparation identity, stale source, one-effect retry | +| Adapters/provider contracts | 3 | Real schema/history, backup copy, blocked candidate | +| Workflows/setup/schema | 2 | Migration preservation, old-runtime rejection | +| UI/UX/localization/sanitization | 2 | Historical status, uncertain-output guidance | +| Integration/security/migration | 2 | Recovery rearm prevention, interrupted activation/restart | +| Total | 18 | No double counting | + +| Case ID | Area | Requirements | Scenario | +|---|---|---|---| +| platform-restoration-C01 | Domain/configuration/pure planning | REST-01 | Reject invalid/incomplete evidence values. | +| platform-restoration-C02 | Domain/configuration/pure planning | REST-01 | Reject key or operation identity collisions. | +| platform-restoration-C03 | Domain/configuration/pure planning | REST-01 | Checkpoint facts must be explained by current retained frontier, independent of clocks. | +| platform-restoration-C04 | Domain/configuration/pure planning | REST-02 | Later facts become historical without changing checkpoint state. | +| platform-restoration-C05 | Domain/configuration/pure planning | REST-03 | Preserve real acknowledgement; do not enqueue rolled-away effects. | +| platform-restoration-C06 | Domain/configuration/pure planning | REST-02 | Empty and repeated restorations retain scope/history and sequence high-water marks. | +| platform-restoration-C07 | State/application/idempotency/races | REST-04 | Reject a result bound to another selection/installation. | +| platform-restoration-C08 | State/application/idempotency/races | REST-05 | Changed source cannot activate a stale candidate. | +| platform-restoration-C09 | State/application/idempotency/races | REST-06 | Same-key historical replay never executes a second effect. | +| platform-restoration-C10 | Adapters/provider contracts | REST-02 | Real history rows retain original receipt/audit/event facts and current authorization. | +| platform-restoration-C11 | Adapters/provider contracts | REST-04 | Private copy verifies hashes/schema/integrity without changing source. | +| platform-restoration-C12 | Adapters/provider contracts | REST-04 | Prepared/incomplete candidate cannot start through any runtime path. | +| platform-restoration-C13 | Workflows/setup/schema | REST-02 | Frozen historical schemas migrate atomically with retained records. | +| platform-restoration-C14 | Workflows/setup/schema | REST-05 | An unaware older image refuses new metadata/schema without data loss. | +| platform-restoration-C15 | UI/UX/localization/sanitization | REST-06 | Historical response distinguishes rolled-away effect from current committed state. | +| platform-restoration-C16 | UI/UX/localization/sanitization | REST-04 | Post-publication output failure preserves uncertainty without secrets. | +| platform-restoration-C17 | Integration/security/migration | REST-06 | Consumed/canceled recovery cannot be rearmed by an old checkpoint/new key. | +| platform-restoration-C18 | Integration/security/migration | REST-05 | Actual interrupted activation/restart preserves old data and starts only verified state. | + +Use real SQLite/files and process interruptions, deterministic clocks/IDs and meaningful negative +fixtures. Run complete supported coverage and exact-head CI, including packaged SQLite behavior. + +## 15. Documentation and discoverability + +| Audience | Artifact | Content | Validation | +|---|---|---|---| +| Operator | docs/snapshots.md | Preparation, verification, activation, failure and rollback | Executable CLI examples | +| Contributor | docs/architecture-identity-storage.md | Policy/port/adapter boundaries and history schema | Import/schema checks | +| Reviewer | docs/m1-acceptance-audit.md | Exact case selectors, source/package boundary and pending review | docs/evidence/m1-acceptance-audit.json | +| Owner | docs/decisions.md | A30 and unchanged scheduling choices | Explicit accepted policy | + +Planned file footprint (implementation is staged; presence is not completion): + +| Path | Change | +|---|---| +| `specs/platform-restoration.md` | create | +| `specs/catalog.json` | update | +| `specs/CATALOG.md` | update | +| `specs/platform-foundation-identity-sqlite.md` | update | +| `docs/m1-acceptance-audit.md` | create | +| `docs/evidence/m1-acceptance-audit.json` | create | +| `docs/evidence/issue6-human-clarifications.json` | create | +| `docs/evidence/m1-review-fixes.json` | create | +| `backend/src/domus/domain/restoration.py` | create | +| `backend/src/domus/application/restoration.py` | create | +| `backend/src/domus/infrastructure/sqlite_restoration.py` | create | +| `backend/src/domus/infrastructure/owned_storage.py` | update | +| `docs/owned-data-directory.md` | update | +| `backend/src/domus/domain/generations.py` | create | +| `backend/src/domus/domain/activation.py` | create | +| `backend/src/domus/domain/snapshots.py` | update | +| `backend/src/domus/application/activation.py` | create | +| `backend/src/domus/composition/restore.py` | create | +| `backend/src/domus/infrastructure/restore_source.py` | create | +| `backend/src/domus/infrastructure/offline_storage.py` | create | +| `backend/src/domus/infrastructure/sqlite_activation.py` | create | +| `tests/backend/test_restore_source.py` | create | +| `tests/backend/test_offline_storage.py` | create | +| `tests/backend/test_activation_values.py` | create | +| `tests/backend/test_sqlite_activation.py` | create | +| `tests/backend/test_activation_failures.py` | create | +| `tests/backend/test_activation_compatibility.py` | create | +| `tests/backend/test_activation_console.py` | create | +| `docs/evidence/m1-offline-activation.json` | create | +| `tests/containers/test_app_image.py` | update | +| `tests/containers/restore_fixture.py` | create | +| `tests/containers/test_app_compatibility.py` | create | +| `.github/workflows/ci_check.yml` | update | +| `docs/evidence/m1-restoration-app-package.json` | create | +| `docs/evidence/m1-edge3-ha-acceptance.json` | create | +| `tests/backend/test_generation_values.py` | create | +| `tests/backend/test_owned_generations.py` | create | +| `docs/evidence/m1-generation-selection.json` | create | +| `tests/backend/test_restoration_values.py` | create | +| `tests/backend/test_restoration_commands.py` | create | +| `tests/backend/test_sqlite_restoration.py` | create | +| `docs/restoration.md` | create | +| `docs/evidence/m1-restoration-planning.json` | create | +| `docs/snapshots.md` | update | +| `docs/architecture-identity-storage.md` | update | +| `docs/decisions.md` | reference | +| `backend/src/domus/domain/storage.py` | update | +| `backend/src/domus/infrastructure/sqlite.py` | update | +| `backend/src/domus/infrastructure/sqlite_history.py` | create | +| `backend/src/domus/infrastructure/sqlite_snapshots.py` | update | +| `backend/src/domus/application/ports.py` | update | +| `backend/src/domus/application/recovery_commands.py` | update | +| `backend/src/domus/infrastructure/sqlite_recovery.py` | update | +| `backend/src/domus/entrypoints/household_http.py` | update | +| `backend/src/domus/composition/admin.py` | update | +| `tests/backend/test_sqlite_history.py` | create | +| `tests/backend/test_sqlite_history_integrity.py` | create | +| `backend/src/domus/domain/recovery.py` | update | +| `tests/backend/test_restored_commands.py` | create | +| `tests/backend/test_storage_values.py` | update | +| `tests/backend/test_sqlite_identity.py` | update | +| `tests/backend/test_sqlite_recovery.py` | update | +| `tests/backend/test_sqlite_snapshots.py` | update | +| `tests/backend/test_household_http.py` | update | +| `tests/backend/test_native_http.py` | update | +| `tests/backend/test_recovery_commands.py` | update | +| `tests/fixtures/sqlite/v3.sql` | create | +| `docs/contracts/household-api.openapi.json` | update | +| `docs/contracts/native-household-api.openapi.json` | update | +| `docs/migrations-identity-storage.md` | update | +| `docs/household-api.md` | update | +| `docs/native-household-api.md` | update | +| `docs/evidence/m1-runtime-history.json` | create | + +## 16. Acceptance scenarios + +1. Given later work, restore only checkpoint state and preserve later original results as historical. +2. Given a same-key retry, authorize currently and return historical outcome without rerunning work. +3. Given a delivered event, restoration never forgets its acknowledgement or emits another copy. +4. Given inconsistent source facts, preparation/activation fails with original data retained. +5. Given interruption or output failure, inspect/resume the same artifact without inventing success. +6. Given a consumed recovery, an older approval and a new key cannot grant it again. +7. Given an empty checkpoint, later history does not implicitly recreate a household or administrator. +8. Given both deployment modes, safe stopped-write activation and paired rollback preserve identities. + +## 17. Requirements traceability + +| Requirement | Cases | +|---|---| +| REST-01 | platform-restoration-C01, platform-restoration-C02, platform-restoration-C03 | +| REST-02 | platform-restoration-C04, platform-restoration-C06, platform-restoration-C10, platform-restoration-C13 | +| REST-03 | platform-restoration-C05 | +| REST-04 | platform-restoration-C07, platform-restoration-C11, platform-restoration-C12, platform-restoration-C16 | +| REST-05 | platform-restoration-C08, platform-restoration-C14, platform-restoration-C18 | +| REST-06 | platform-restoration-C09, platform-restoration-C15, platform-restoration-C17 | + +## 18. Implementation sequence + +Pure values/reconciliation and tests first; semantic ports and real SQLite preparation next; historical +runtime replay/disclosure, migration and recovery guards; verified offline activation/interruption +recovery; console/docs/contracts; complete coverage and both real-image/deployment acceptance. + +## 19. Definition of Done + +- [ ] REST-01 through REST-06 and all 18 cases have implemented evidence. +- [ ] Direction/purity/acyclic checks and 100% supported coverage pass. +- [ ] Both deployment modes activate safely and recover interruption without losing guards. +- [ ] Historical API/console states, current authority and recovery safety are verified. +- [ ] Schema/configuration/CLI/migration/docs/generated catalog agree. +- [ ] Current-head CI and complete Bugbot review pass without unresolved findings. +- [ ] No pending preparation/activation work is described as complete restoration. + +## 20. References and decisions + +A30 is accepted on 2026-10-31. [Parent M1 contract](platform-foundation-identity-sqlite.md) ID-04 remains +in force. [Snapshot evidence](../docs/evidence/m1-snapshots.json) and +[persistent package evidence](../docs/evidence/m1-persistent-app-package.json) prove earlier primitives, +not restoration. [Real-image restore evidence](../docs/evidence/m1-restoration-app-package.json) +adds local packaged activation, interruption and previous-image compatibility. Other functional +domains/product UI, standalone companion authority, complete channel +acceptance and release remain in the full product scope, outside this focused restore specification. diff --git a/tests/architecture/test_python_architecture.py b/tests/architecture/test_python_architecture.py index 22a2b17..09783c3 100644 --- a/tests/architecture/test_python_architecture.py +++ b/tests/architecture/test_python_architecture.py @@ -60,10 +60,35 @@ def test_inner_layer_cannot_import_infrastructure(tmp_path: Path, layer: str) -> @pytest.mark.parametrize( - "dependency", ["fastapi", "homeassistant", "httpx", "sqlite3", "sqlalchemy"] + "dependency", + [ + "fastapi", + "homeassistant", + "httpx", + "httpx2", + "websockets", + "uvicorn", + "starlette", + "pydantic", + "requests", + "sqlite3", + "sqlalchemy", + "subprocess", + "asyncio", + "time", + "secrets", + "pathlib", + "os", + "fcntl", + ], ) -def test_inner_framework_or_io_dependency_is_rejected(tmp_path: Path, dependency: str) -> None: - result = check(tmp_path, {"domain/invalid.py": f"import {dependency}\n"}) +@pytest.mark.parametrize("layer", ["domain", "application"]) +def test_inner_framework_or_io_dependency_is_rejected( + tmp_path: Path, + dependency: str, + layer: str, +) -> None: + result = check(tmp_path, {f"{layer}/invalid.py": f"import {dependency}\n"}) assert result.returncode != 0 assert "Domain and application exclude concrete frameworks and I/O BROKEN" in result.stdout diff --git a/tests/backend/test_activation_compatibility.py b/tests/backend/test_activation_compatibility.py new file mode 100644 index 0000000..95be14d --- /dev/null +++ b/tests/backend/test_activation_compatibility.py @@ -0,0 +1,138 @@ +"""Real offline activation migrates frozen inputs and preserves closed recovery guards.""" + +import hashlib +import json +import sqlite3 +from contextlib import closing +from dataclasses import replace +from pathlib import Path + +import pytest +from test_recovery_commands import configured, start +from test_snapshot_values import INSTALLATION +from test_sqlite_snapshots import Clock + +from domus.domain.errors import Conflict +from domus.domain.snapshots import Snapshot +from domus.infrastructure.owned_storage import open_storage +from domus.infrastructure.sqlite import SQLiteDatabase +from domus.infrastructure.sqlite_activation import SQLiteActivation +from domus.infrastructure.sqlite_restoration import SQLiteRestorations +from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + + +def identity_at(root, installation): + path = root / "installation.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "instance_id": installation.instance_id, + "channel": installation.channel, + "state": "ready", + } + ) + ) + path.chmod(0o600) + return path + + +@pytest.mark.parametrize("version", [1, 2, 3]) +def test_real_activation_upgrades_only_target_from_frozen_source_and_checkpoint(tmp_path, version): + archive, root = tmp_path / "snapshot", tmp_path / "root" + archive.mkdir(mode=0o700) + root.mkdir(mode=0o700) + database = archive / "domus.sqlite3" + frozen = Path(__file__).resolve().parents[1] / f"fixtures/sqlite/v{version}.sql" + with closing(sqlite3.connect(database, autocommit=True)) as connection: + connection.executescript(frozen.read_text()) + # Frozen v1/v2 contain a dummy fingerprint; use a canonical digest for replay evidence. + connection.execute("UPDATE receipts SET payload_fingerprint=?", ("a" * 64,)) + database.chmod(0o600) + identity = identity_at(archive, INSTALLATION) + checkpoint = Snapshot( + "old", + INSTALLATION, + version, + 2000, + 100, + hashlib.sha256(database.read_bytes()).hexdigest(), + database.stat().st_size, + hashlib.sha256(identity.read_bytes()).hexdigest(), + ) + (archive / "manifest.json").write_text(checkpoint.document().encoded) + (archive / "manifest.json").chmod(0o600) + source = root / "domus.sqlite3" + source.write_bytes(database.read_bytes()) + source.chmod(0o600) + identity_at(root, INSTALLATION) + original = source.read_bytes() + target = tmp_path / "prepared" + prepared = SQLiteRestorations( + SQLiteDatabase(source, INSTALLATION), archive, target, INSTALLATION, Clock(), checkpoint + ).prepare("restore-old") + assert prepared.source.schema_version == version + result = SQLiteActivation(root, INSTALLATION, Clock(), target).activate() + assert result.state == "active" and result.record.rollback_snapshot.schema_version == version + assert source.read_bytes() == original == database.read_bytes() + selected = open_storage(root, "edge") + try: + with selected.database._connection() as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == 4 + assert connection.execute("SELECT COUNT(*) FROM retained_operations").fetchone()[0] == 1 + with selected.database.read() as unit: + assert unit.households.current().revision == (1 if version == 3 else 3) + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 1 + finally: + selected.close() + + +@pytest.mark.parametrize("terminal", ["consume", "cancel"]) +def test_actual_activation_of_old_approval_cannot_rearm_closed_recovery(tmp_path, terminal): + commands, approval, original, actor, clock, console = configured(tmp_path) + root = tmp_path / "root" + root.mkdir(mode=0o700) + path = root / "domus.sqlite3" + path.write_bytes(original.path.read_bytes()) + path.chmod(0o600) + identity_at(root, original.installation) + database = SQLiteDatabase(path, original.installation) + commands = replace(commands, transactions=database) + approval = replace(approval, transactions=database) + request_id = start(commands) + approval.approve(request_id) + archive = tmp_path / "snapshot" + snapshot = SQLiteSnapshots(database, archive, original.installation, Clock()).create("approved") + if terminal == "consume": + commands.complete("terminal", request_id, 2) + else: + commands.cancel("terminal", request_id, 2) + target = tmp_path / "prepared" + SQLiteRestorations(database, archive, target, original.installation, Clock(), snapshot).prepare( + "restore-approved" + ) + SQLiteActivation(root, original.installation, Clock(), target).activate() + selected = open_storage(root, "edge") + commands = replace(commands, transactions=selected.database) + approval = replace(approval, transactions=selected.database) + try: + replay = ( + commands.complete("terminal", request_id, 2) + if terminal == "consume" + else commands.cancel("terminal", request_id, 2) + ) + assert replay.historical + for attempt in ( + lambda: commands.complete("new-key", request_id, 2), + lambda: commands.cancel("new-key", request_id, 2), + lambda: approval.approve(request_id), + lambda: commands.status(request_id), + ): + with pytest.raises(Conflict, match="recovery_closed"): + attempt() + with selected.database.read() as unit: + assert unit.members.by_user("home", "ha_new") is None + assert unit.recoveries.pending("home", clock.now()) == () + assert len(console.reviews) == 1 + finally: + selected.close() diff --git a/tests/backend/test_activation_console.py b/tests/backend/test_activation_console.py new file mode 100644 index 0000000..50fa811 --- /dev/null +++ b/tests/backend/test_activation_console.py @@ -0,0 +1,87 @@ +"""Installation-console restoration uses bound data, never a human HTTP grant.""" + +import io +import json + +import pytest +from test_sqlite_activation import fixture + +from domus.composition import admin +from domus.infrastructure.owned_storage import open_storage + + +def arguments(root): + return ["--channel", "edge", "--data-directory", str(root), "restore"] + + +def run(args): + output, error = io.StringIO(), io.StringIO() + code = admin.main(args, input_stream=io.StringIO(), output_stream=output, error_stream=error) + return code, output.getvalue(), error.getvalue() + + +def test_actual_console_activation_and_live_shared_status_are_truthful(tmp_path, monkeypatch): + from test_sqlite_snapshots import Clock + + adapter, root, installation, prepared = fixture(tmp_path) + monkeypatch.setattr(admin, "SystemClock", Clock) + code, output, error = run([*arguments(root), "activate", str(adapter.prepared_location)]) + assert code == 0 and error == "", error + assert json.loads(output)["state"] == "active" + runtime = open_storage(root, "edge") + try: + code, output, error = run([*arguments(root), "status", prepared.restore_id]) + assert code == 0 and json.loads(output)["state"] == "active", error + finally: + runtime.close() + code, output, error = run([*arguments(root), "resume", prepared.restore_id]) + assert code == 0 and json.loads(output)["state"] == "active", error + + +def test_console_prepare_and_independent_inspection_retain_exact_source_binding(tmp_path): + adapter, root, installation, prepared = fixture(tmp_path) + destination = tmp_path / "console-prepared" + code, output, error = run( + [*arguments(root), "prepare", str(tmp_path / "snapshot"), str(destination)] + ) + assert code == 0, error + value = json.loads(output) + assert ( + value["state"] == "prepared" and value["prepared"]["source"]["identity_sha256"] is not None + ) + code, output, error = run( + [ + "--channel", + "edge", + "--instance-id", + installation.instance_id, + "restore", + "inspect", + str(destination), + ] + ) + assert code == 0 and json.loads(output)["state"] == "verified", error + + +@pytest.mark.parametrize( + "operation", ["inspect_with_root", "activate_without_root", "wrong_channel"] +) +def test_console_requires_the_exact_owned_configuration(tmp_path, operation): + adapter, root, _, _ = fixture(tmp_path) + if operation == "inspect_with_root": + args = [*arguments(root), "inspect", str(adapter.prepared_location)] + elif operation == "activate_without_root": + args = ["--channel", "edge", "restore", "activate", str(adapter.prepared_location)] + else: + args = [ + "--channel", + "stable", + "--data-directory", + str(root), + "restore", + "activate", + str(adapter.prepared_location), + ] + code, output, error = run(args) + assert code == 2 and output == "" and "Existing records are retained" in error + assert not (root / "activation.json").exists() diff --git a/tests/backend/test_activation_failures.py b/tests/backend/test_activation_failures.py new file mode 100644 index 0000000..09a9671 --- /dev/null +++ b/tests/backend/test_activation_failures.py @@ -0,0 +1,424 @@ +"""Unsafe restore artifacts fail closed; interrupted owned work remains resumable.""" + +import json +import os +from dataclasses import replace + +import pytest +from test_sqlite_activation import fixture +from test_sqlite_snapshots import Clock + +from domus.domain.activation import ActivationRecord +from domus.domain.errors import Conflict, DatabaseRejected, DomusError, NotFound, StorageFailure +from domus.domain.storage import Installation +from domus.infrastructure import sqlite_activation +from domus.infrastructure.owned_storage import open_storage +from domus.infrastructure.sqlite_activation import SQLiteActivation +from domus.infrastructure.sqlite_restoration import SQLiteRestorations + + +def document(path, value): + path.write_text(value.document().encoded) + path.chmod(0o600) + + +def directory(path): + if not path.parent.exists(): + directory(path.parent) + path.mkdir(mode=0o700, exist_ok=True) + return path + + +def pause(adapter, root, monkeypatch, point): + original = sqlite_activation.os.replace + inode = root.stat().st_ino + + def interrupt(source, target, **kwargs): + result = original(source, target, **kwargs) + at_root = os.fstat(kwargs["dst_dir_fd"]).st_ino == inode + if at_root and target == "activation.json": + if json.loads((root / target).read_text())["phase"] == point: + raise OSError("interrupted after durable intent") + if at_root and target == "installation.json" and point == "selected": + raise OSError("interrupted after durable selection") + if not at_root and target == "activation.json" and point == "receipt": + raise OSError("interrupted after completion receipt") + return result + + monkeypatch.setattr(sqlite_activation.os, "replace", interrupt) + with pytest.raises(StorageFailure): + adapter.activate() + monkeypatch.undo() + + +@pytest.mark.parametrize("timeout", [0, -1, True, float("nan"), float("inf"), 3601]) +def test_invalid_activation_timeout_has_no_filesystem_effect(tmp_path, timeout): + from domus.domain.errors import InvalidInput + + with pytest.raises(InvalidInput): + SQLiteActivation( + tmp_path / "absent", + Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge"), + Clock(), + timeout=timeout, + ) + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize("damage", ["invalid", "active", "staged_materialized", "foreign"]) +def test_invalid_pending_records_do_not_authorize_a_transition(tmp_path, monkeypatch, damage): + adapter, root, identity, prepared = fixture(tmp_path) + pause(adapter, root, monkeypatch, "pending") + path = root / "activation.json" + if damage == "invalid": + path.write_text("{}") + elif damage in ("active", "staged_materialized"): + value = ActivationRecord(prepared, "materialized", "a" * 64, 1, None, prepared.checkpoint) + if damage == "active": + value = replace(value, phase="active", activated_at=2000) + document(path, value) + if damage == "staged_materialized": + path.rename(root / "activation.json.next") + else: + other = Installation("b08026cc-6ded-4c68-9c7d-722b81a19dca", "edge") + value = replace( + prepared, + checkpoint=replace(prepared.checkpoint, installation=other), + source=replace(prepared.source, installation=other), + ) + document(path, ActivationRecord(value, "pending")) + with pytest.raises(DatabaseRejected): + adapter.resume() + assert not (root / "generations").exists() + + +def test_missing_selection_and_foreign_installation_cannot_gain_offline_authority(tmp_path): + _, root, identity, _ = fixture(tmp_path) + missing = SQLiteActivation(root, identity, Clock()) + for attempt in (missing.activate, missing.inspect): + with pytest.raises(NotFound): + attempt() + foreign = SQLiteActivation( + root, + Installation("b08026cc-6ded-4c68-9c7d-722b81a19dca", "edge"), + Clock(), + restore_id="restore-1", + ) + for attempt in (foreign.activate, foreign.inspect): + with pytest.raises(DatabaseRejected, match="installation_mismatch"): + attempt() + assert not (root / "activation.json").exists() + + +@pytest.mark.parametrize("binding", ["legacy", "changed"]) +def test_unbound_or_stale_preparation_is_inspectable_but_not_activatable(tmp_path, binding): + adapter, root, _, prepared = fixture(tmp_path) + value = ( + replace(prepared, source=None) + if binding == "legacy" + else replace(prepared, source=replace(prepared.source, schema_version=3)) + ) + document(adapter.prepared_location / "restore.json", value) + assert adapter._inspect_input(adapter.prepared_location) == value + with pytest.raises(DomusError, match="managed_restore_binding_required|restore_source_changed"): + adapter.activate() + assert not (root / "activation.json").exists() + + +def test_conflicting_selectors_and_pending_external_input_do_not_replace_an_intent( + tmp_path, monkeypatch +): + adapter, root, identity, prepared = fixture(tmp_path) + with pytest.raises(Conflict, match="activation_record_conflict"): + replace(adapter, restore_id="other").activate() + pause(adapter, root, monkeypatch, "pending") + original = (root / "activation.json").read_bytes() + with pytest.raises(Conflict, match="activation_in_progress"): + SQLiteActivation(root, identity, Clock(), restore_id="other").resume() + document( + adapter.prepared_location / "restore.json", + replace(prepared, prepared_at=prepared.prepared_at + 1), + ) + with pytest.raises(Conflict, match="activation_in_progress"): + adapter.resume() + assert (root / "activation.json").read_bytes() == original + + +def test_missing_and_invalid_completion_evidence_never_creates_a_new_activation(tmp_path): + adapter, root, identity, prepared = fixture(tmp_path) + adapter.activate() + unknown = SQLiteActivation(root, identity, Clock(), restore_id="unknown") + for attempt in (unknown.activate, unknown.inspect): + with pytest.raises(NotFound): + attempt() + receipt = root / "activations" / prepared.restore_id / "activation.json" + document(receipt, ActivationRecord(prepared, "pending")) + with pytest.raises(DatabaseRejected, match="invalid_activation_record"): + adapter.activate() + + +def test_completed_id_cannot_be_reused_with_a_different_preparation(tmp_path): + adapter, root, _, prepared = fixture(tmp_path) + adapter.activate() + document( + adapter.prepared_location / "restore.json", + replace(prepared, prepared_at=prepared.prepared_at + 1), + ) + with pytest.raises(Conflict, match="activation_record_conflict"): + adapter.activate() + assert not (root / "activation.json").exists() + + +@pytest.mark.parametrize("damage", ["invalid", "changed"]) +def test_sealed_input_recovers_corruption_but_requires_exact_original_for_partial_copy( + tmp_path, monkeypatch, damage +): + adapter, root, identity, prepared = fixture(tmp_path) + pause(adapter, root, monkeypatch, "materialized") + sealed = root / "activations" / prepared.restore_id / "input" + if damage == "invalid": + (sealed / "operations.jsonl").write_text("corrupted") + else: + document(sealed / "restore.json", replace(prepared, prepared_at=prepared.prepared_at + 1)) + with pytest.raises(DatabaseRejected, match="activation_input_required"): + SQLiteActivation(root, identity, Clock(), restore_id=prepared.restore_id).resume() + assert adapter.resume().state == "active" + + +@pytest.mark.parametrize("area", ["input", "slot", "checkpoint"]) +def test_unknown_artifacts_are_retained_and_block_resume(tmp_path, monkeypatch, area): + adapter, root, _, prepared = fixture(tmp_path) + pause(adapter, root, monkeypatch, "pending") + evidence = directory(root / "activations" / prepared.restore_id) + target = ( + directory(root / "generations" / prepared.restore_id) + if area == "slot" + else directory(evidence / ("input" if area == "input" else "source-checkpoint")) + ) + foreign = target / "unrecognized" + foreign.write_text("retain this evidence") + foreign.chmod(0o600) + with pytest.raises(DatabaseRejected, match="invalid_activation_artifacts"): + adapter.resume() + assert foreign.read_text() == "retain this evidence" + + +def test_partial_source_checkpoint_is_safely_recopied_before_publication(tmp_path, monkeypatch): + adapter, root, identity, prepared = fixture(tmp_path) + pause(adapter, root, monkeypatch, "pending") + target = directory(root / "activations" / prepared.restore_id / "source-checkpoint") + (target / "domus.sqlite3").write_bytes(b"partial copy") + (target / "domus.sqlite3").chmod(0o600) + assert adapter.resume().state == "active" + + +def test_input_replaced_during_sealing_is_not_selected(tmp_path, monkeypatch): + adapter, root, _, prepared = fixture(tmp_path) + original = sqlite_activation._copy + + def changed(source, target, name, budget): + original(source, target, name, budget) + if name == "restore.json": + fd = os.open(name, os.O_WRONLY | os.O_TRUNC, dir_fd=target) + with os.fdopen(fd, "w") as stream: + stream.write( + replace(prepared, prepared_at=prepared.prepared_at + 1).document().encoded + ) + + monkeypatch.setattr(sqlite_activation, "_copy", changed) + with pytest.raises(DatabaseRejected, match="activation_input_changed"): + adapter.activate() + assert "generation_id" not in json.loads((root / "installation.json").read_text()) + + +def test_history_import_failure_rolls_back_target_and_resume_reimports_once(tmp_path, monkeypatch): + adapter, root, _, prepared = fixture(tmp_path) + original = sqlite_activation.SQLiteHistory.install + + def fail(history, records): + original(history, records) + raise OSError("write failed before commit") + + monkeypatch.setattr(sqlite_activation.SQLiteHistory, "install", fail) + with pytest.raises(StorageFailure): + adapter.activate() + import sqlite3 + from contextlib import closing + + with closing( + sqlite3.connect(root / "generations" / prepared.restore_id / "domus.sqlite3") + ) as connection: + assert connection.execute("SELECT COUNT(*) FROM retained_operations").fetchone()[0] == 0 + monkeypatch.undo() + assert adapter.resume().state == "active" + + +def test_materialized_target_modified_before_selection_is_rejected(tmp_path, monkeypatch): + adapter, root, _, prepared = fixture(tmp_path) + pause(adapter, root, monkeypatch, "materialized") + path = root / "generations" / prepared.restore_id / "domus.sqlite3" + with path.open("ab") as stream: + stream.write(b"unexpected bytes") + with pytest.raises(DatabaseRejected, match="activation_target_changed"): + adapter.resume() + + +@pytest.mark.parametrize("area", ["generations", "activations"]) +def test_new_activation_does_not_adopt_a_preexisting_destination(tmp_path, area): + adapter, root, identity, prepared = fixture(tmp_path) + adapter.activate() + storage = open_storage(root, "edge") + target = tmp_path / "second-prepared" + try: + SQLiteRestorations( + storage.database, tmp_path / "snapshot", target, identity, Clock(), prepared.checkpoint + ).prepare("restore-2") + finally: + storage.close() + collision = directory(root / area / "restore-2") + with pytest.raises(Conflict, match="activation_destination_exists"): + SQLiteActivation(root, identity, Clock(), target).activate() + assert list(collision.iterdir()) == [] + + +def test_selected_generation_cannot_claim_a_merely_pending_record(tmp_path, monkeypatch): + adapter, root, _, prepared = fixture(tmp_path) + pause(adapter, root, monkeypatch, "selected") + document(root / "activation.json", ActivationRecord(prepared, "pending")) + with pytest.raises(DatabaseRejected, match="invalid_activation_record"): + adapter.resume() + + +def test_pending_status_requires_same_installation_and_can_report_a_prior_activation( + tmp_path, monkeypatch +): + adapter, root, identity, prepared = fixture(tmp_path) + adapter.activate() + storage = open_storage(root, "edge") + target = tmp_path / "second-prepared" + try: + second = SQLiteRestorations( + storage.database, tmp_path / "snapshot", target, identity, Clock(), prepared.checkpoint + ).prepare("restore-2") + finally: + storage.close() + other = SQLiteActivation(root, identity, Clock(), target) + pause(other, root, monkeypatch, "pending") + assert adapter.inspect().state == "active" + assert SQLiteActivation(root, identity, Clock()).inspect().record.prepared == second + with pytest.raises(NotFound): + SQLiteActivation(root, identity, Clock(), restore_id="unknown").inspect() + foreign = replace( + adapter, installation=Installation("b08026cc-6ded-4c68-9c7d-722b81a19dca", "edge") + ) + with pytest.raises(DatabaseRejected, match="installation_mismatch"): + foreign.inspect() + assert other.resume().state == "active" + + +def test_live_status_missing_receipt_is_explicit_and_inspection_io_is_sanitized( + tmp_path, monkeypatch +): + adapter, root, identity, _ = fixture(tmp_path) + with pytest.raises(NotFound): + SQLiteActivation(root, identity, Clock(), restore_id="unknown").inspect() + adapter.activate() + original = sqlite_activation.os.listdir + + def fail(directory): + if ( + type(directory) is int + and os.fstat(directory).st_ino == (root / "activations").stat().st_ino + ): + raise OSError("private inspection failure") + return original(directory) + + monkeypatch.setattr(sqlite_activation.os, "listdir", fail) + with pytest.raises(StorageFailure, match="activation_inspection_failed"): + adapter.inspect() + + +def test_valid_but_changed_rollback_history_is_not_reused(tmp_path, monkeypatch): + import hashlib + import sqlite3 + from contextlib import closing + + adapter, root, _, prepared = fixture(tmp_path) + original = sqlite_activation._copy + slot = root / "generations" / prepared.restore_id + + def fail_before_target_copy(source, target, name, budget): + if slot.exists() and os.fstat(target).st_ino == slot.stat().st_ino: + raise OSError("interrupted before copying the target") + original(source, target, name, budget) + + monkeypatch.setattr(sqlite_activation, "_copy", fail_before_target_copy) + with pytest.raises(StorageFailure): + adapter.activate() + monkeypatch.undo() + checkpoint = root / "activations" / prepared.restore_id / "source-checkpoint" + path = checkpoint / "domus.sqlite3" + with closing(sqlite3.connect(path, autocommit=True)) as connection: + connection.execute("UPDATE outbox SET delivered=1") + manifest = checkpoint / "manifest.json" + value = json.loads(manifest.read_text()) + value["database_sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() + value["database_bytes"] = path.stat().st_size + manifest.write_text(json.dumps(value)) + with pytest.raises(DatabaseRejected, match="activation_rollback_changed"): + adapter.resume() + + +def test_rollback_checkpoint_identity_must_match_the_materialization_receipt(tmp_path, monkeypatch): + adapter, root, _, prepared = fixture(tmp_path) + pause(adapter, root, monkeypatch, "materialized") + manifest = root / "activations" / prepared.restore_id / "source-checkpoint" / "manifest.json" + value = json.loads(manifest.read_text()) + value["captured_at"] += 1 + manifest.write_text(json.dumps(value)) + with pytest.raises(DatabaseRejected, match="activation_rollback_changed"): + adapter.resume() + + +@pytest.mark.parametrize("point", ["materialized", "selected"]) +def test_out_of_band_root_identity_change_is_never_silently_overwritten( + tmp_path, monkeypatch, point +): + adapter, root, _, _ = fixture(tmp_path) + original = sqlite_activation._atomic + inode = root.stat().st_ino + + def external_change(directory, name, value, stage=None): + original(directory, name, value, stage) + if os.fstat(directory).st_ino != inode: + return + if ( + point == "materialized" + and name == "activation.json" + and value.decode()["phase"] == "materialized" + ) or (point == "selected" and name == "installation.json"): + path = root / "installation.json" + identity = json.loads(path.read_text()) + if point == "materialized": + identity["state"] = "initializing" + else: + identity["generation_id"] = "another-selection" + path.write_text(json.dumps(identity)) + + monkeypatch.setattr(sqlite_activation, "_atomic", external_change) + with pytest.raises(DomusError, match="restore_source_changed|activation_selection_changed"): + adapter.activate() + assert (root / "activation.json").exists() + + +def test_conflicting_durable_completion_receipt_cannot_be_overwritten(tmp_path, monkeypatch): + adapter, root, _, prepared = fixture(tmp_path) + pause(adapter, root, monkeypatch, "receipt") + path = root / "activations" / prepared.restore_id / "activation.json" + value = json.loads(path.read_text()) + value["database_sha256"] = "a" * 64 + path.write_text(json.dumps(value)) + before = path.read_bytes() + with pytest.raises(Conflict, match="activation_record_conflict"): + adapter.resume() + assert path.read_bytes() == before and (root / "activation.json").exists() diff --git a/tests/backend/test_activation_values.py b/tests/backend/test_activation_values.py new file mode 100644 index 0000000..7fe314e --- /dev/null +++ b/tests/backend/test_activation_values.py @@ -0,0 +1,163 @@ +"""A durable activation record binds one prepared input and truthful publication state.""" + +from dataclasses import replace + +import pytest +from test_restoration_commands import prepared + +from domus.domain.activation import ActivationRecord, ActivationStatus +from domus.domain.errors import InvalidInput +from domus.domain.restoration import RestoreSource + + +def record(): + value = prepared() + value = replace(value, source=RestoreSource(value.installation, 4, None, "a" * 64)) + return ActivationRecord(value, "pending") + + +def test_transition_roundtrips_and_status_never_confuses_selection_with_completion(): + initial = record() + assert ActivationRecord.from_value(initial.document().decode()) == initial + materialized = replace( + initial, + phase="materialized", + database_sha256="b" * 64, + database_bytes=4096, + rollback_snapshot=initial.prepared.checkpoint, + ) + assert ActivationStatus(materialized, initial.prepared.restore_id).state == "pending" + active = replace(materialized, phase="active", activated_at=100) + assert ActivationStatus(active, initial.prepared.restore_id).state == "active" + assert ActivationStatus(active, "newer").state == "historical" + assert ActivationRecord.from_value(active.document().decode()) == active + + +@pytest.mark.parametrize( + "change", + [ + {"phase": "wrong"}, + {"prepared": None}, + {"prepared": prepared()}, + {"database_sha256": "bad"}, + {"phase": "materialized"}, + {"activated_at": 1}, + {"database_bytes": True}, + { + "phase": "active", + "database_sha256": "b" * 64, + "database_bytes": 4096, + "activated_at": -1, + }, + ], +) +def test_invalid_or_unbound_transition_cannot_be_published(change): + with pytest.raises(InvalidInput): + replace(record(), **change) + + +@pytest.mark.parametrize("kind", ["list", "missing", "extra", "version", "boolean"]) +def test_activation_parser_is_exact_and_versioned(kind): + value = record().document().decode() + if kind == "list": + value = [] + elif kind == "missing": + del value["phase"] + elif kind == "extra": + value["path"] = "untrusted" + elif kind == "version": + value["format_version"] = 2 + else: + value["format_version"] = True + with pytest.raises(InvalidInput): + ActivationRecord.from_value(value) + + +@pytest.mark.parametrize( + "field,value", [("activated_at", -1), ("activated_at", True), ("activated_at", None)] +) +def test_complete_record_requires_a_real_bounded_completion_timestamp(field, value): + active = replace( + record(), + phase="active", + database_sha256="b" * 64, + database_bytes=4096, + rollback_snapshot=record().prepared.checkpoint, + activated_at=100, + ) + with pytest.raises(InvalidInput): + replace(active, **{field: value}) + with pytest.raises(InvalidInput): + replace(active, phase="materialized") + with pytest.raises(InvalidInput): + ActivationStatus(None, None) + + +@pytest.mark.parametrize("kind", ["list", "missing", "extra"]) +def test_snapshot_record_parser_is_exact(kind): + from domus.domain.snapshots import Snapshot + + value = record().prepared.checkpoint.document().decode() + if kind == "list": + value = [] + elif kind == "missing": + del value["snapshot_id"] + else: + value["extra"] = True + with pytest.raises(InvalidInput): + Snapshot.from_value(value) + + +def test_application_port_validates_selection_and_complete_results(): + from domus.application.activation import ActivationCommands + from domus.domain.errors import StorageFailure + + pending = ActivationStatus(record(), None) + + class Port: + value = pending + + def activate(self): + return self.value + + def resume(self): + return self.value + + def inspect(self): + return self.value + + port = Port() + installation = record().prepared.installation + with pytest.raises(InvalidInput): + ActivationCommands(port, installation).activate() + with pytest.raises(InvalidInput): + ActivationCommands( + port, replace(installation, channel="stable"), record().prepared + ).activate() + commands = ActivationCommands(port, installation, record().prepared) + assert commands.inspect() == pending + for action in (commands.activate, commands.resume): + with pytest.raises(StorageFailure): + action() + port.value = ActivationStatus( + replace( + record(), + phase="active", + database_sha256="b" * 64, + database_bytes=4096, + rollback_snapshot=record().prepared.checkpoint, + activated_at=100, + ), + "restore-1", + ) + assert commands.activate() == commands.resume() == commands.inspect() == port.value + with pytest.raises(StorageFailure): + ActivationCommands(port, installation, restore_id="different").inspect() + with pytest.raises(StorageFailure): + ActivationCommands( + port, installation, replace(record().prepared, restore_id="other") + ).inspect() + with pytest.raises(StorageFailure): + ActivationCommands( + port, replace(installation, channel="stable"), restore_id="restore-1" + ).inspect() diff --git a/tests/backend/test_app_package.py b/tests/backend/test_app_package.py new file mode 100644 index 0000000..46bd9e0 --- /dev/null +++ b/tests/backend/test_app_package.py @@ -0,0 +1,61 @@ +"""App manifest and build contract prevent new exposure or mutable runtime sources.""" + +import re +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] + + +def test_repository_and_edge_manifest_offer_native_ha_entry_without_user_credentials() -> None: + repository = yaml.safe_load((ROOT / "repository.yaml").read_text()) + manifest = yaml.safe_load((ROOT / "addon-edge/config.yaml").read_text()) + assert repository["url"] == "https://github.com/vypdev/domus" + assert manifest["slug"] == "domus_edge" and manifest["stage"] == "experimental" + assert manifest["boot"] == "manual" and manifest["version"] == "0.0.0-edge.3" + assert manifest["ingress"] is True and manifest["ingress_port"] == 8099 + assert manifest["panel_admin"] is False and manifest["homeassistant_api"] is True + assert manifest["options"] == {"public_origins": []} + assert manifest["schema"] == {"public_origins": ["url"]} + assert manifest["breaking_versions"] == ["0.0.0-edge.2", "0.0.0-edge.3"] + assert set(manifest["arch"]) == {"amd64", "aarch64"} + + +def test_manifest_has_no_published_port_or_unneeded_host_privileges() -> None: + manifest = yaml.safe_load((ROOT / "addon-edge/config.yaml").read_text()) + assert manifest["ports"] == {} + for key in ("hassio_api", "auth_api", "host_network", "host_pid", "host_dbus", "full_access"): + assert not manifest.get(key, False), key + for key in ("privileged", "devices", "map", "docker_api", "image"): + assert key not in manifest, key + assert manifest["init"] is True and manifest["timeout"] >= 20 + + +def test_build_pins_source_checksum_bases_lock_and_nonroot_native_entrypoint() -> None: + source = (ROOT / "addon-edge/Dockerfile").read_text() + bases = re.findall(r"^FROM (\S+)", source, re.MULTILINE) + assert len(bases) == 3 and all(re.search(r"@sha256:[a-f0-9]{64}$", b) for b in bases) + commits = re.findall(r"^ARG DOMUS_SOURCE_COMMIT=(\w+)$", source, re.MULTILINE) + assert len(commits) == 2 and len(set(commits)) == 1 + assert re.fullmatch(r"[a-f0-9]{40}", commits[0]) + assert commits[0] == "faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5" + assert ( + "DOMUS_SOURCE_SHA256=15bcd701696204b3362cebe5901feb3d0072c9bac1db2ef2cbc5007e9ed73717" + in source + ) + assert "ARG BUILD_VERSION=0.0.0-edge.3" in source + assert re.search(r"^ARG DOMUS_SOURCE_SHA256=[a-f0-9]{64}$", source, re.MULTILINE) + assert "sha256sum --check" in source + assert "https://codeload.github.com/vypdev/domus/tar.gz/${DOMUS_SOURCE_COMMIT}" in source + assert "uv sync --frozen --no-dev --package domus-server --no-editable" in source + assert "USER 0:0" in source and "UV_PYTHON_DOWNLOADS=never" in source + assert 'ENTRYPOINT ["/app/.venv/bin/domus-ha-app", "--channel", "edge"]' in source + assert "EXPOSE" not in source and "SUPERVISOR_TOKEN" not in source + + +def test_native_options_explain_the_one_time_origin_configuration_without_a_new_login() -> None: + text = yaml.safe_load((ROOT / "addon-edge/translations/en.yaml").read_text()) + field = text["configuration"]["public_origins"] + assert field["name"] == "Home Assistant browser origins" + assert "one to four" in field["description"] and "existing HA sign-in" in field["description"] diff --git a/tests/backend/test_application_runtime.py b/tests/backend/test_application_runtime.py new file mode 100644 index 0000000..057cef5 --- /dev/null +++ b/tests/backend/test_application_runtime.py @@ -0,0 +1,38 @@ +"""System adapters preserve UTC units, safe identifiers and the configured zone boundary.""" + +import pytest + +from domus.domain.errors import InvalidInput +from domus.domain.storage import validate_identifier +from domus.infrastructure import application_runtime + + +def test_wall_clock_is_utc_unix_milliseconds_without_float_rounding( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(application_runtime.time, "time_ns", lambda: 123_456_789_123_456_789) + assert application_runtime.SystemClock().now() == 123_456_789_123 + + +def test_record_identifiers_use_192_bits_and_remain_valid_with_leading_underscore_entropy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def entropy(bytes_requested: int) -> str: + assert bytes_requested == 24 + return "_fixture_material" + + monkeypatch.setattr(application_runtime.secrets, "token_urlsafe", entropy) + value = application_runtime.SecureIdentifiers().new() + validate_identifier(value) + assert value == "domus__fixture_material" + + +@pytest.mark.parametrize("zone", ["UTC", "Europe/Madrid", "America/New_York"]) +def test_supported_zones_are_validated_by_the_actual_system_catalog(zone: str) -> None: + application_runtime.TimeZoneCatalog().require(zone) + + +@pytest.mark.parametrize("zone", ["Not/AZone", "../UTC", "/etc/passwd", "UTC\x00", None]) +def test_unknown_or_path_malformed_zone_never_falls_back_to_a_different_zone(zone: str) -> None: + with pytest.raises(InvalidInput, match="invalid_time_zone"): + application_runtime.TimeZoneCatalog().require(zone) diff --git a/tests/backend/test_atomic_commands.py b/tests/backend/test_atomic_commands.py new file mode 100644 index 0000000..78cbf46 --- /dev/null +++ b/tests/backend/test_atomic_commands.py @@ -0,0 +1,151 @@ +"""Use cases authorize before replay and commit one durable transition per command.""" + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from threading import Event + +import pytest + +from domus.application.commands import AtomicCommands, Change +from domus.application.ports import UnitOfWork +from domus.domain.errors import Conflict, Forbidden, StorageBusy +from domus.domain.storage import CommandKey, Household, Installation, JsonDocument +from domus.infrastructure.sqlite import SQLiteDatabase + + +@dataclass +class Authorization: + allowed: bool = True + checks: int = 0 + + def require(self, command: CommandKey, unit: UnitOfWork) -> None: + self.checks += 1 + if not self.allowed: + raise Forbidden("permission_denied") + + +class Clock: + def now(self) -> int: + return 100 + + +class Identifiers: + def __init__(self) -> None: + self.calls = 0 + + def new(self) -> str: + self.calls += 1 + return f"operation-{self.calls}" + + +def store_at(path: Path) -> SQLiteDatabase: + store = SQLiteDatabase(path, Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge")) + store.initialize() + return store + + +def create_home(unit: UnitOfWork) -> Change: + unit.households.save(Household("home", "Home", "UTC", 1), 0) + result = JsonDocument.from_value({"household_id": "home", "revision": 1}) + return Change(result, JsonDocument.from_value({"transition": "created"}), result) + + +def test_identical_retry_does_not_rerun_effect_or_allocate_identifiers(tmp_path: Path) -> None: + store = store_at(tmp_path / "database") + policy, identifiers = Authorization(), Identifiers() + commands = AtomicCommands(store, policy, Clock(), identifiers) + key = CommandKey("home", "ha_1", "household.create", "request") + payload = JsonDocument.from_value({"name": "Home", "expected_revision": 0}) + first = commands.execute(key, payload, create_home) + + def forbidden_second_effect(unit: UnitOfWork) -> Change: + pytest.fail("A receipt replay must not run a new effect") + + assert commands.execute(key, payload, forbidden_second_effect) == first + assert identifiers.calls == 1 + assert policy.checks == 2 + with store.read() as unit: + assert unit.receipts.get(key) == first + assert len(unit.audit.entries("home")) == 1 + assert len(unit.outbox.pending()) == 1 + assert unit.audit.entries("home")[0].detail.decode() == {"transition": "created"} + + +def test_changed_payload_conflicts_and_revoked_permission_hides_even_old_result( + tmp_path: Path, +) -> None: + store = store_at(tmp_path / "database") + policy = Authorization() + commands = AtomicCommands(store, policy, Clock(), Identifiers()) + key = CommandKey("home", "ha_1", "household.create", "request") + commands.execute(key, JsonDocument.from_value({"name": "Home"}), create_home) + with pytest.raises(Conflict, match="idempotency_conflict"): + commands.execute(key, JsonDocument.from_value({"name": "Changed"}), create_home) + policy.allowed = False + with pytest.raises(Forbidden, match="permission_denied"): + commands.execute(key, JsonDocument.from_value({"name": "Home"}), create_home) + with store.read() as unit: + assert unit.households.get("home").revision == 1 + assert len(unit.audit.entries("home")) == 1 + + +def test_restart_replay_returns_original_result_without_fresh_effect(tmp_path: Path) -> None: + path = tmp_path / "database" + first = AtomicCommands(store_at(path), Authorization(), Clock(), Identifiers()) + key = CommandKey("home", "ha_1", "household.create", "request") + payload = JsonDocument.from_value({"name": "Home"}) + result = first.execute(key, payload, create_home) + restarted_ids = Identifiers() + restarted = AtomicCommands(store_at(path), Authorization(), Clock(), restarted_ids) + assert restarted.execute(key, payload, create_home) == result + assert restarted_ids.calls == 0 + + +def test_failed_effect_preserves_preexisting_state_and_creates_no_receipt(tmp_path: Path) -> None: + store = store_at(tmp_path / "database") + commands = AtomicCommands(store, Authorization(), Clock(), Identifiers()) + key = CommandKey("home", "ha_1", "household.create", "request") + + def fails_after_change(unit: UnitOfWork) -> Change: + create_home(unit) + raise RuntimeError("external effect failed") + + with pytest.raises(RuntimeError): + commands.execute(key, JsonDocument.from_value({}), fails_after_change) + with store.read() as unit: + assert unit.households.current() is None + assert unit.receipts.get(key) is None + assert unit.audit.entries("home") == () + assert unit.outbox.pending() == () + + +def test_overlapping_identical_commands_commit_once_and_retry_the_winning_receipt( + tmp_path: Path, +) -> None: + store = store_at(tmp_path / "database") + commands = AtomicCommands(store, Authorization(), Clock(), Identifiers()) + key = CommandKey("home", "ha_1", "household.create", "request") + payload = JsonDocument.from_value({"expected_revision": 0}) + entered, release = Event(), Event() + + def held_effect(unit: UnitOfWork) -> Change: + result = create_home(unit) + entered.set() + assert release.wait(timeout=5), "Test coordinator did not release the first writer" + return result + + with ThreadPoolExecutor(max_workers=1) as executor: + first = executor.submit(commands.execute, key, payload, held_effect) + try: + assert entered.wait(timeout=5), "First writer never reached its transition" + with pytest.raises(StorageBusy): + commands.execute(key, payload, create_home) + finally: + release.set() + result = first.result(timeout=5) + assert commands.execute(key, payload, create_home) == result + with store.read() as unit: + assert len(unit.audit.entries("home")) == 1 + assert len(unit.outbox.pending()) == 1 + assert unit.households.get("home").revision == 1 diff --git a/tests/backend/test_auth_http.py b/tests/backend/test_auth_http.py new file mode 100644 index 0000000..af6d439 --- /dev/null +++ b/tests/backend/test_auth_http.py @@ -0,0 +1,498 @@ +"""Strict authentication HTTP contracts never trust request-chosen identity or origins.""" + +import asyncio +import json +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +import pytest +from fastapi.testclient import TestClient +from test_standalone_sessions import fixture as identity_fixture + +from domus.application.authentication import Authentication +from domus.application.sessions import SessionGuard +from domus.domain.errors import ( + Conflict, + DomusError, + Forbidden, + IdentityUnavailable, + InvalidInput, + StorageFailure, + Unauthenticated, +) +from domus.entrypoints.http import HTTPSettings, _HTTPBoundary, create_app +from domus.infrastructure.memory_attempts import MemoryLoginAttempts + + +class Entropy: + def __init__(self) -> None: + self.values = iter( + ("s" * 43, "v" * 43, "b" * 43, "x" * 43, "t" * 43, "v" * 43, "c" * 43, "y" * 43) + ) + + def new(self) -> str: + return next(self.values) + + +def fixture() -> tuple: + identity, oauth, socket, clock = identity_fixture() + flow = Authentication(MemoryLoginAttempts(), identity, clock, Entropy()) + settings = HTTPSettings("https://domus.example", "domus-edge", b"k" * 32) + app = create_app( + flow, + SessionGuard(identity, identity, clock), + identity, + settings, + correlation=lambda: "cor_test", + ) + client = TestClient( + app, base_url=settings.origin, follow_redirects=False, raise_server_exceptions=False + ) + return client, identity, oauth, socket, clock, settings + + +def login(client: TestClient) -> None: + start = client.get("/api/v1/auth/start") + assert start.status_code == 303 + state = parse_qs(urlsplit(start.headers["location"]).query)["state"][0] + callback = client.get("/api/v1/auth/callback", params={"state": state, "code": "code"}) + assert callback.status_code == 303 + assert callback.headers["location"] == "/api/v1/session" + + +def test_happy_path_sets_secure_host_only_cookies_and_identity_view_has_no_credentials() -> None: + client, _, _, _, _, settings = fixture() + before = client.get("/api/v1/session") + assert before.status_code == 401 and before.json()["code"] == "session_required" + start = client.get("/api/v1/auth/start") + cookie = start.headers["set-cookie"] + assert "HttpOnly" in cookie and "Secure" in cookie and "SameSite=lax" in cookie + assert "Domain=" not in cookie and "Path=/" in cookie + assert settings.browser_cookie in cookie and "__Host-" in cookie + state = parse_qs(urlsplit(start.headers["location"]).query)["state"][0] + callback = client.get("/api/v1/auth/callback", params={"code": "code", "state": state}) + assert callback.status_code == 303 + assert settings.session_cookie in callback.headers["set-cookie"] + result = client.get("/api/v1/session") + assert result.status_code == 200 + assert result.json()["ha_user_id"] == "a" * 32 + assert result.json()["access"] == "full" + assert result.json()["ha_administrator"] is False + for sensitive in ("private-access", "private-refresh", "csrf_token", "session_id"): + assert sensitive not in result.text + assert result.headers["cache-control"] == "no-store" + assert result.headers["referrer-policy"] == "no-referrer" + + +def test_outage_status_is_read_only_and_logout_still_clears_local_access() -> None: + client, identity, oauth, socket, _, settings = fixture() + login(client) + session_id = client.cookies.get(settings.session_cookie) + socket.error = IdentityUnavailable("ha_unavailable") + assert client.get("/api/v1/session").json()["access"] == "read_only" + csrf = client.get("/api/v1/auth/csrf").json()["csrf_token"] + oauth.revoke_error = IdentityUnavailable("ha_unavailable") + response = client.post( + "/api/v1/auth/logout", json={}, headers={"Origin": settings.origin, "X-CSRF-Token": csrf} + ) + assert response.status_code == 200 + assert response.json()["state"] == "logged_out" + assert response.json()["upstream_revoked"] is False + assert "not confirmed" in response.json()["action_required"] + assert identity.get(session_id) is None + assert client.cookies.get(settings.session_cookie) is None + assert client.get("/api/v1/session").status_code == 401 + + +def test_lost_private_credential_is_unknown_upstream_revocation_not_a_fabricated_success() -> None: + client, identity, _, _, _, settings = fixture() + login(client) + session_id = client.cookies.get(settings.session_cookie) + csrf = client.get("/api/v1/auth/csrf").json()["csrf_token"] + identity.remove(session_id) + response = client.post( + "/api/v1/auth/logout", json={}, headers={"Origin": settings.origin, "X-CSRF-Token": csrf} + ) + assert response.status_code == 200 and response.json()["state"] == "logged_out" + assert response.json()["upstream_revoked"] is None + assert "not confirmed" in response.json()["action_required"] + + +@pytest.mark.parametrize( + "headers", + [ + {}, + {"Origin": "https://evil.example"}, + {"Origin": "https://domus.example"}, + {"Origin": "https://domus.example", "X-CSRF-Token": "wrong"}, + ], +) +def test_cookie_mutations_require_exact_origin_and_session_bound_csrf(headers: dict) -> None: + client, _, oauth, _, _, _ = fixture() + login(client) + response = client.post("/api/v1/auth/logout", json={}, headers=headers) + assert response.status_code == 403 and response.json()["code"] == "request_forbidden" + assert oauth.revoked == [] + assert client.get("/api/v1/session").status_code == 200 + + +def test_successful_logout_uses_original_cookie_and_empty_strict_body() -> None: + client, identity, oauth, _, _, settings = fixture() + login(client) + old = client.cookies.get(settings.session_cookie) + csrf = client.get("/api/v1/auth/csrf").json()["csrf_token"] + headers = {"Origin": settings.origin, "X-CSRF-Token": csrf} + rejected = client.post( + "/api/v1/auth/logout", json={"member_id": "another", "secret": "private"}, headers=headers + ) + assert rejected.status_code == 422 and "private" not in rejected.text + assert identity.get(old) is not None + response = client.post("/api/v1/auth/logout", json={}, headers=headers) + assert response.status_code == 200 and response.json()["upstream_revoked"] is True + assert identity.get(old) is None and oauth.revoked == ["private-refresh"] + + +@pytest.mark.parametrize( + "path", + [ + "/api/v1/session?member_id=someone", + "/api/v1/auth/start?ha_url=https://evil.example", + "/api/v1/auth/callback?code=private-code&state=bad", + "/api/v1/auth/callback/", + ], +) +def test_unknown_unbounded_or_route_mismatched_inputs_never_establish_session(path: str) -> None: + client, _, oauth, _, _, _ = fixture() + response = client.get(path) + assert response.status_code in (404, 422) + assert "private-code" not in response.text + assert oauth.revoked == [] + + +@pytest.mark.parametrize( + "headers", + [ + {"Host": "evil.example"}, + {"Host": "user@domus.example"}, + {"X-Remote-User-Id": "a" * 32}, + {"X-Ingress-Path": "/forged"}, + ], +) +def test_standalone_rejects_wrong_host_and_ingress_identity_headers(headers: dict) -> None: + client, _, _, _, _, _ = fixture() + response = client.get("/api/v1/auth/start", headers=headers) + assert response.status_code == 403 + assert "location" not in response.headers and "set-cookie" not in response.headers + + +def test_duplicate_callback_fields_or_browser_cookie_cannot_consume_attempt() -> None: + client, _, _, _, _, settings = fixture() + start = client.get("/api/v1/auth/start") + state = parse_qs(urlsplit(start.headers["location"]).query)["state"][0] + duplicate = client.get( + "/api/v1/auth/callback", params=[("state", state), ("state", state), ("code", "code")] + ) + assert duplicate.status_code == 422 + token = client.cookies.get(settings.browser_cookie) + response = client.get( + "/api/v1/auth/callback", + params={"code": "code", "state": state}, + headers={"Cookie": f"{settings.browser_cookie}={token}; {settings.browser_cookie}={token}"}, + ) + assert response.status_code == 401 + assert ( + client.get("/api/v1/auth/callback", params={"code": "code", "state": state}).status_code + == 303 + ) + + +@pytest.mark.parametrize("binding", ["missing", "malformed", "wrong", "expired"]) +def test_invalid_login_binding_explains_restart_without_exchanging_code( + binding: str, monkeypatch: pytest.MonkeyPatch +) -> None: + client, _, oauth, socket, clock, settings = fixture() + start = client.get("/api/v1/auth/start") + state = parse_qs(urlsplit(start.headers["location"]).query)["state"][0] + if binding == "missing": + client.cookies.clear() + elif binding == "malformed": + client.cookies.clear() + client.cookies.set(settings.browser_cookie, "invalid!binding") + elif binding == "wrong": + client.cookies.clear() + client.cookies.set(settings.browser_cookie, "w" * 43) + else: + clock.value += 300_000 + + def forbidden_exchange(*args: object) -> None: + pytest.fail("An invalid browser attempt must not exchange an HA code") + + monkeypatch.setattr(oauth, "exchange", forbidden_exchange) + response = client.get("/api/v1/auth/callback", params={"state": state, "code": "private-code"}) + assert response.status_code == 401 + assert response.json()["code"] in ("invalid_login_attempt", "login_attempt_expired") + assert "sign-in attempt" in response.json()["message"] + assert "/api/v1/auth/start" in response.json()["action_required"] + assert "five minutes" in response.json()["action_required"] + assert "private-code" not in response.text and state not in response.text + assert client.cookies.get(settings.session_cookie) is None + assert socket.tokens == [] and oauth.revoked == [] + + +def test_missing_browser_binding_can_recover_through_a_new_bound_attempt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + values = iter(value * 43 for value in ("s", "v", "b", "t", "v", "c", "x")) + monkeypatch.setattr(Entropy, "new", lambda self: next(values)) + client, _, _, _, _, _ = fixture() + start = client.get("/api/v1/auth/start") + state = parse_qs(urlsplit(start.headers["location"]).query)["state"][0] + client.cookies.clear() + rejected = client.get("/api/v1/auth/callback", params={"state": state, "code": "code"}) + assert rejected.status_code == 401 + login(client) + assert client.get("/api/v1/session").json()["access"] == "full" + + +def test_reauthentication_rotates_cookie_and_removes_previous_browser_session() -> None: + client, identity, _, _, _, settings = fixture() + login(client) + old = client.cookies.get(settings.session_cookie) + login(client) + assert client.cookies.get(settings.session_cookie) != old + assert identity.get(old) is None + + +def test_openapi_contract_and_no_http_console_approval() -> None: + client, _, _, _, _, _ = fixture() + paths = client.get("/api/v1/openapi.json").json()["paths"] + assert set(paths) == { + "/api/v1/auth/start", + "/api/v1/auth/callback", + "/api/v1/auth/logout", + "/api/v1/auth/csrf", + "/api/v1/session", + } + assert client.post("/api/v1/recovery/approve").status_code == 403 + assert client.get("/docs").status_code == 404 + + +def test_published_openapi_schema_and_security_match_actual_routes() -> None: + client, _, _, _, _, settings = fixture() + actual = client.get("/api/v1/openapi.json").json() + reference = json.loads( + (Path(__file__).resolve().parents[2] / "docs/contracts/auth-api.openapi.json").read_text() + ) + assert actual["paths"] == reference["paths"] + assert actual["components"]["schemas"] == reference["components"]["schemas"] + scheme = actual["components"]["securitySchemes"]["DomusSession"] + assert scheme == {"type": "apiKey", "in": "cookie", "name": settings.session_cookie} + operation = actual["paths"]["/api/v1/auth/logout"]["post"] + assert operation["security"] == [{"DomusSession": []}] + assert {item["name"] for item in operation["parameters"]} == {"Origin", "X-CSRF-Token"} + assert operation["responses"]["422"]["content"]["application/json"]["schema"]["$ref"].endswith( + "/ErrorResponse" + ) + + +def test_bearer_transport_credential_cannot_replace_a_domus_cookie() -> None: + client, _, _, _, _, _ = fixture() + response = client.get( + "/api/v1/session", headers={"Authorization": "Bearer fixture-transport-token"} + ) + assert response.status_code == 401 and response.json()["code"] == "session_required" + + +def test_declined_login_consumes_only_the_bound_attempt_and_clears_browser_cookie() -> None: + client, _, oauth, _, _, settings = fixture() + start = client.get("/api/v1/auth/start") + state = parse_qs(urlsplit(start.headers["location"]).query)["state"][0] + invalid = client.get( + "/api/v1/auth/callback", params={"state": state, "code": "code", "error": "access_denied"} + ) + assert invalid.status_code == 422 + result = client.get("/api/v1/auth/callback", params={"state": state, "error": "access_denied"}) + assert result.status_code == 401 and result.json()["code"] == "sign_in_declined" + assert client.cookies.get(settings.browser_cookie) is None + assert oauth.revoked == [] + + +@pytest.mark.parametrize( + "failure,status,code", + [ + (Unauthenticated("session_required"), 401, "session_required"), + (Forbidden("permission_denied"), 403, "permission_denied"), + (InvalidInput("invalid_request"), 422, "invalid_request"), + (Conflict("login_attempt_exists"), 409, "login_attempt_exists"), + (IdentityUnavailable("ha_unavailable"), 503, "ha_unavailable"), + (DomusError("ha_unavailable"), 500, "internal_error"), + (StorageFailure("ha_unavailable"), 500, "internal_error"), + (InvalidInput("private-provider-payload"), 500, "internal_error"), + (RuntimeError("private-provider-payload"), 500, "internal_error"), + ], +) +def test_error_envelope_maps_only_known_failures_and_never_echoes_provider_detail( + monkeypatch: pytest.MonkeyPatch, + failure: Exception, + status: int, + code: str, +) -> None: + client, identity, _, _, _, _ = fixture() + + def fail(*arguments: object) -> str: + raise failure + + monkeypatch.setattr(identity, "authorization_url", fail) + response = client.get("/api/v1/auth/start", headers={"X-Correlation-Id": "untrusted-input"}) + assert response.status_code == status and response.json()["code"] == code + assert response.json()["correlation_id"] == "cor_test" + assert ( + "private-provider-payload" not in response.text and "untrusted-input" not in response.text + ) + + +def test_http_bounds_and_malformed_host_fail_before_any_protected_action() -> None: + client, _, oauth, _, _, settings = fixture() + for host in ( + "domus.example:bad", + "domus.example/path", + "domus.example:8443", + "domus.example:0", + ): + assert client.get("/api/v1/auth/start", headers={"Host": host}).status_code == 403 + assert client.get("/api/v1/auth/start?x=" + "x" * 8193).status_code == 422 + assert client.request("GET", "/api/v1/auth/start", json={"actor": "forged"}).status_code == 422 + login(client) + csrf = client.get("/api/v1/auth/csrf").json()["csrf_token"] + result = client.post( + "/api/v1/auth/logout", + json={"private": "x" * 70_000}, + headers={"Origin": settings.origin, "X-CSRF-Token": csrf}, + ) + assert result.status_code == 413 and result.json()["code"] == "request_too_large" + assert oauth.revoked == [] + assert ( + client.post( + "/api/v1/recovery/approve", + json={}, + headers={"Origin": settings.origin, "X-CSRF-Token": csrf}, + ).status_code + == 404 + ) + + +def test_http_settings_are_bounded_and_explicit_loopback_development_has_distinct_cookies() -> None: + with pytest.raises(InvalidInput): + HTTPSettings("https://domus.example", "invalid cookie", b"k" * 32) + with pytest.raises(InvalidInput): + HTTPSettings("https://domus.example", "domus-edge", b"short") + settings = HTTPSettings("http://127.0.0.1:8099", "domus-edge", b"k" * 32) + assert not settings.secure and settings.session_cookie == "domus-edge-session" + assert settings.browser_cookie == "domus-edge-browser" + + +def test_header_count_and_size_are_bounded_before_login_state_is_allocated() -> None: + client, _, _, _, _, _ = fixture() + assert client.get("/api/v1/auth/start", headers={"X-Padding": "x" * 17_000}).status_code == 422 + assert ( + client.get( + "/api/v1/auth/start", headers={f"X-{index}": "x" for index in range(101)} + ).status_code + == 422 + ) + assert client.get("/api/v1/auth/start").status_code == 303 + + +def test_lifespan_and_method_mismatch_use_framework_contract_without_redirects() -> None: + client, _, _, _, _, _ = fixture() + with client: + assert client.get("/api/v1/openapi.json").status_code == 200 + assert client.get("/api/v1/auth/logout").status_code == 405 + + +def test_fragmented_body_and_late_disconnect_are_preserved_and_cache_headers_are_overridden() -> ( + None +): + settings = HTTPSettings("https://domus.example", "domus-edge", b"k" * 32) + scope = { + "type": "http", + "method": "POST", + "query_string": b"", + "headers": [ + (b"host", b"domus.example"), + (b"origin", b"https://domus.example"), + (b"cookie", f"{settings.session_cookie}=fixture".encode()), + (b"x-csrf-token", settings.csrf("fixture").encode()), + ], + } + incoming = iter( + [ + {"type": "http.request", "body": b"a", "more_body": True}, + {"type": "http.request", "body": b"b", "more_body": False}, + {"type": "http.disconnect"}, + ] + ) + received, sent = [], [] + + async def receive() -> dict: + return next(incoming) + + async def send(message: dict) -> None: + sent.append(message) + + async def app(scope: dict, receive: object, send: object) -> None: + received.append(await receive()) + received.append(await receive()) + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"cache-control", b"public")], + } + ) + await send({"type": "http.response.body", "body": b"ok"}) + + asyncio.run(_HTTPBoundary(app, settings, lambda: "cor_test")(scope, receive, send)) + assert received == [ + {"type": "http.request", "body": b"ab", "more_body": False}, + {"type": "http.disconnect"}, + ] + assert dict(sent[0]["headers"])[b"cache-control"] == b"no-store" + + +@pytest.mark.parametrize("disconnect", [False, True]) +def test_body_deadline_and_disconnect_never_call_inner_application( + monkeypatch: pytest.MonkeyPatch, + disconnect: bool, +) -> None: + from domus.entrypoints import bounded_http + + settings = HTTPSettings("https://domus.example", "domus-edge", b"k" * 32) + scope = { + "type": "http", + "method": "GET", + "query_string": b"", + "headers": [(b"host", b"domus.example")], + } + real_timeout = asyncio.timeout + sent = [] + + def immediate_deadline(seconds: float) -> asyncio.Timeout: + assert seconds == 5 + return real_timeout(0) + + async def receive() -> dict: + if disconnect: + return {"type": "http.disconnect"} + await asyncio.Event().wait() + pytest.fail("A blocked request body must be cancelled") + + async def send(message: dict) -> None: + sent.append(message) + + async def forbidden(*arguments: object) -> None: + pytest.fail("No application action is allowed before receiving a valid body") + + monkeypatch.setattr(bounded_http, "timeout", immediate_deadline) + asyncio.run(_HTTPBoundary(forbidden, settings, lambda: "cor_test")(scope, receive, send)) + assert sent == [] if disconnect else sent[0]["status"] == 408 diff --git a/tests/backend/test_auth_runtime.py b/tests/backend/test_auth_runtime.py new file mode 100644 index 0000000..6a6a249 --- /dev/null +++ b/tests/backend/test_auth_runtime.py @@ -0,0 +1,19 @@ +"""Process-local authentication sources use the correct clock and cryptographic entropy.""" + +import pytest + +from domus.infrastructure.auth_runtime import MonotonicClock, SecureEntropy + + +def test_monotonic_clock_uses_integer_milliseconds(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("domus.infrastructure.auth_runtime.time.monotonic_ns", lambda: 123_456_789) + assert MonotonicClock().now() == 123 + + +def test_entropy_uses_32_private_random_bytes(monkeypatch: pytest.MonkeyPatch) -> None: + def token(size: int) -> str: + assert size == 32 + return "_" + "r" * 42 + + monkeypatch.setattr("domus.infrastructure.auth_runtime.secrets.token_urlsafe", token) + assert SecureEntropy().new() == "_" + "r" * 42 diff --git a/tests/backend/test_auth_server.py b/tests/backend/test_auth_server.py new file mode 100644 index 0000000..edf1310 --- /dev/null +++ b/tests/backend/test_auth_server.py @@ -0,0 +1,168 @@ +"""The local authentication composition fixes listener, proxy and credential cleanup policy.""" + +import runpy +from dataclasses import dataclass +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from test_standalone_sessions import TOKENS, sign_in +from test_standalone_sessions import fixture as identity_fixture + +from domus.composition import server +from domus.domain.errors import IdentityRejected + +INSTANCE = "047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6" + + +def arguments() -> list[str]: + return [ + "--ha-url", + "http://homeassistant.local:8123", + "--public-origin", + "http://127.0.0.1:8099", + "--instance-id", + INSTANCE, + "--channel", + "edge", + "--development-http", + ] + + +def test_composition_builds_fixed_edge_authentication_without_creating_household_data() -> None: + runtime = server.create_runtime( + "http://homeassistant.local:8123", + "http://127.0.0.1:8099", + INSTANCE, + "edge", + development_http=True, + ) + with TestClient( + runtime.app, base_url="http://127.0.0.1:8099", follow_redirects=False + ) as client: + assert client.get("/api/v1/session").status_code == 401 + response = client.get("/api/v1/auth/start") + assert response.status_code == 303 + assert response.headers["location"].startswith( + "http://homeassistant.local:8123/auth/authorize?" + ) + assert "Secure" not in response.headers["set-cookie"] + assert "domus-edge-047cc6fbdfb0" in response.headers["set-cookie"] + assert runtime.close() == (0, 0) + + +def test_server_uses_one_worker_no_access_query_logging_and_no_proxy_rewriting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events = [] + + @dataclass + class Runtime: + app: object = None + + def close(self) -> tuple[int, int]: + events.append("closed") + return 0, 0 + + def create(*args: object, **kwargs: object) -> Runtime: + return Runtime() + + def run(app: object, **kwargs: object) -> None: + assert kwargs == { + "host": "127.0.0.1", + "port": 8099, + "workers": 1, + "proxy_headers": False, + "forwarded_allow_ips": "", + "access_log": False, + "server_header": False, + "log_level": "warning", + "limit_concurrency": 64, + "timeout_keep_alive": 5, + "timeout_graceful_shutdown": 10, + "h11_max_incomplete_event_size": 65_536, + } + events.append("ran") + + monkeypatch.setattr(server, "create_runtime", create) + monkeypatch.setattr(server.uvicorn, "run", run) + assert server.main(arguments()) == 0 + assert events == ["ran", "closed"] + + +def test_startup_failure_still_cleans_owned_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + runtime = server.create_runtime("http://ha.local", "https://domus.example", INSTANCE, "edge") + closed = [] + monkeypatch.setattr(server, "create_runtime", lambda *args, **kwargs: runtime) + + def cleanup() -> tuple[int, int]: + closed.append(True) + return 0, 0 + + monkeypatch.setattr(runtime, "close", cleanup) + + def failed(*args: object, **kwargs: object) -> None: + raise RuntimeError("listener failed") + + monkeypatch.setattr(server.uvicorn, "run", failed) + with pytest.raises(RuntimeError): + server.main(arguments()) + assert closed == [True] + + +def test_unrevoked_shutdown_count_is_reported_without_credentials( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + runtime = server.create_runtime("http://ha.local", "https://domus.example", INSTANCE, "edge") + monkeypatch.setattr(server, "create_runtime", lambda *args, **kwargs: runtime) + monkeypatch.setattr(runtime, "close", lambda: (0, 1)) + monkeypatch.setattr(server.uvicorn, "run", lambda *args, **kwargs: None) + assert server.main(arguments()) == 0 + assert "ha_grants_not_revoked: 1" in caplog.text + assert "private-access" not in caplog.text + + +@pytest.mark.parametrize( + "extra", + [["--port", "0"], ["--port", "65536"], ["--host", "0.0.0.0"], ["--instance-id", "not-a-uuid"]], +) +def test_bad_configuration_does_not_start_a_listener( + extra: list[str], capsys: pytest.CaptureFixture +) -> None: + assert server.main(arguments() + extra) == 2 + assert "Configuration rejected:" in capsys.readouterr().err + + +def test_console_help_is_a_real_module_entrypoint(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.argv", ["domus-server", "--help"]) + with pytest.raises(SystemExit) as result: + runpy.run_path(str(Path(server.__file__)), run_name="__main__") + assert result.value.code == 0 + + +def test_shutdown_clears_all_local_leases_and_bounds_remote_cleanup() -> None: + identity, oauth, _, _ = identity_fixture() + for index in range(3): + from dataclasses import replace + + oauth.issued = replace( + TOKENS, access_token=f"private-access-{index}", refresh_token=f"private-refresh-{index}" + ) + sign_in(identity, f"session_{index}") + assert identity.shutdown() == (2, 1) + assert len(oauth.revoked) == 2 + for index in range(3): + assert identity.get(f"session_{index}") is None + with pytest.raises(IdentityRejected, match="server_closed"): + sign_in(identity, "later_session") + assert identity.shutdown() == (0, 0) + + +def test_shutdown_during_initial_proof_cannot_install_a_late_session() -> None: + identity, oauth, socket, _ = identity_fixture() + socket.callback = lambda: identity.shutdown() + with pytest.raises(IdentityRejected, match="server_closed"): + sign_in(identity) + assert identity.get("session_1") is None + assert oauth.revoked == ["private-refresh"] diff --git a/tests/backend/test_authentication_flow.py b/tests/backend/test_authentication_flow.py new file mode 100644 index 0000000..26af036 --- /dev/null +++ b/tests/backend/test_authentication_flow.py @@ -0,0 +1,212 @@ +"""Browser-bound, single-use login attempts precede all HA exchanges and session issuance.""" + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from threading import Barrier + +import pytest + +from domus.application.authentication import Authentication +from domus.application.authentication_values import LoginAttempt +from domus.application.session_values import SessionLease +from domus.domain.errors import Conflict, IdentityRejected, IdentityUnavailable, InvalidInput +from domus.domain.identity import HumanIdentity +from domus.infrastructure.memory_attempts import MemoryLoginAttempts + +STATE, VERIFIER, BROWSER = "s" * 43, "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", "b" * 43 +SESSION = "s_" + "x" * 43 +HUMAN = HumanIdentity("ha_1", False, True, False) + + +class Clock: + value = 100 + + def now(self) -> int: + return self.value + + +class Entropy: + def __init__(self) -> None: + self.values = iter((STATE, VERIFIER, BROWSER, "x" * 43)) + + def new(self) -> str: + return next(self.values) + + +class Provider: + def __init__(self) -> None: + self.exchanges = [] + self.signed_out = [] + self.error = None + self.change = lambda lease: lease + self.challenge = None + + def authorization_url(self, state: str, challenge: str) -> str: + assert state == STATE + self.challenge = challenge + return "https://ha.example/auth/authorize?state=" + state + + def sign_in( + self, code: str, verifier: str, session_id: str, issued_at: int, expires_at: int + ) -> SessionLease: + self.exchanges.append((code, verifier)) + if self.error: + raise self.error + return self.change(SessionLease(session_id, HUMAN, issued_at, expires_at)) + + def sign_out(self, session_id: str) -> bool: + self.signed_out.append(session_id) + return True + + +def fixture() -> tuple[Authentication, MemoryLoginAttempts, Provider, Clock]: + attempts, provider, clock = MemoryLoginAttempts(), Provider(), Clock() + return Authentication(attempts, provider, clock, Entropy()), attempts, provider, clock + + +def test_start_uses_pkce_s256_and_completion_consumes_browser_bound_attempt() -> None: + flow, _, provider, clock = fixture() + start = flow.start() + assert start.browser_token == BROWSER + assert start.authorization_url == "https://ha.example/auth/authorize?state=" + STATE + assert provider.challenge == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + clock.value = 200 + lease = flow.complete("code", STATE, BROWSER) + assert lease == SessionLease(SESSION, HUMAN, 200, 3_600_200) + assert provider.exchanges == [("code", VERIFIER)] + with pytest.raises(IdentityRejected, match="invalid_login_attempt"): + flow.complete("code", STATE, BROWSER) + assert len(provider.exchanges) == 1 + assert BROWSER not in repr(start) + + +def test_wrong_browser_binding_cannot_consume_legitimate_attempt() -> None: + flow, _, provider, _ = fixture() + flow.start() + with pytest.raises(IdentityRejected, match="invalid_login_attempt"): + flow.complete("code", STATE, "w" * 43) + assert provider.exchanges == [] + assert flow.complete("code", STATE, BROWSER).human == HUMAN + + +@pytest.mark.parametrize("state,browser", [("wrong", BROWSER), (STATE, ""), ("z" * 43, BROWSER)]) +def test_invalid_or_unknown_attempt_is_rejected_before_provider(state: str, browser: str) -> None: + flow, _, provider, _ = fixture() + flow.start() + with pytest.raises(IdentityRejected): + flow.complete("code", state, browser) + assert provider.exchanges == [] + + +def test_upstream_failure_consumes_valid_attempt_without_a_second_exchange() -> None: + flow, _, provider, _ = fixture() + flow.start() + provider.error = IdentityUnavailable("ha_unavailable") + with pytest.raises(IdentityUnavailable): + flow.complete("code", STATE, BROWSER) + with pytest.raises(IdentityRejected): + flow.complete("code", STATE, BROWSER) + assert len(provider.exchanges) == 1 + + +@pytest.mark.parametrize("time", [99, 300_100]) +def test_expired_or_backwards_clock_attempt_cannot_exchange(time: int) -> None: + flow, _, provider, clock = fixture() + flow.start() + clock.value = time + with pytest.raises(IdentityRejected, match="login_attempt_expired"): + flow.complete("code", STATE, BROWSER) + assert provider.exchanges == [] + + +@pytest.mark.parametrize( + "change", + [ + lambda lease: replace(lease, session_id="someone_else"), + lambda lease: replace(lease, expires_at=lease.expires_at + 1), + ], +) +def test_provider_cannot_retarget_or_extend_server_owned_session(change: object) -> None: + flow, _, provider, _ = fixture() + flow.start() + provider.change = change + with pytest.raises(IdentityRejected, match="invalid_login_result"): + flow.complete("code", STATE, BROWSER) + assert provider.signed_out == [SESSION] + + +def test_session_expiry_during_exchange_cleans_only_allocated_session() -> None: + flow, _, provider, clock = fixture() + flow.start() + + def expire(lease: SessionLease) -> SessionLease: + clock.value = lease.expires_at + return lease + + provider.change = expire + with pytest.raises(IdentityRejected, match="invalid_login_result"): + flow.complete("code", STATE, BROWSER) + assert provider.signed_out == [SESSION] + + +def attempt() -> LoginAttempt: + return LoginAttempt("a" * 64, "b" * 64, VERIFIER, 100, 300_100) + + +def test_attempt_repr_capacity_duplicate_and_expired_cleanup() -> None: + attempts = MemoryLoginAttempts(max_attempts=1) + record = attempt() + attempts.add(record, 100) + assert VERIFIER not in repr(record) + with pytest.raises(Conflict, match="login_attempt_exists"): + attempts.add(record, 100) + with pytest.raises(IdentityUnavailable, match="login_capacity"): + attempts.add(replace(record, state_digest="c" * 64), 100) + attempts.add( + replace(record, state_digest="c" * 64, issued_at=300_100, expires_at=600_100), 300_100 + ) + with pytest.raises(IdentityRejected, match="invalid_login_attempt"): + attempts.take(record.state_digest, record.browser_digest, 300_100) + + +def test_competing_callbacks_only_one_can_take_the_attempt() -> None: + attempts = MemoryLoginAttempts() + record = attempt() + attempts.add(record, 100) + ready = Barrier(2) + + def claim() -> object: + ready.wait(timeout=3) + try: + return attempts.take(record.state_digest, record.browser_digest, 101) + except IdentityRejected as error: + return error.code + + with ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(lambda _: claim(), range(2))) + assert results.count(record) == 1 and results.count("invalid_login_attempt") == 1 + + +@pytest.mark.parametrize("capacity", [0, -1, True, 1025]) +def test_attempt_capacity_is_bounded(capacity: object) -> None: + with pytest.raises(InvalidInput): + MemoryLoginAttempts(max_attempts=capacity) + + +@pytest.mark.parametrize("lifetime", [0, True, 299_999, 28_800_001]) +def test_session_lifetime_is_explicit_and_bounded(lifetime: object) -> None: + with pytest.raises(InvalidInput): + Authentication(MemoryLoginAttempts(), Provider(), Clock(), Entropy(), lifetime=lifetime) + + +def test_attempt_value_rejects_unbounded_verifier_digest_and_lifetime() -> None: + for changes in ( + {"state_digest": "bad"}, + {"browser_digest": "bad"}, + {"verifier": "bad"}, + {"issued_at": True}, + {"expires_at": 100}, + {"expires_at": 300_101}, + ): + with pytest.raises(InvalidInput): + replace(attempt(), **changes) diff --git a/tests/backend/test_generation_values.py b/tests/backend/test_generation_values.py new file mode 100644 index 0000000..7b0ea3e --- /dev/null +++ b/tests/backend/test_generation_values.py @@ -0,0 +1,62 @@ +"""An active generation is an exact installation selection, never an arbitrary path.""" + +from dataclasses import FrozenInstanceError, replace + +import pytest + +from domus.domain.errors import InvalidInput +from domus.domain.generations import GenerationSelection +from domus.domain.storage import Installation + +INSTALLATION = Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge") + + +def test_selection_roundtrips_without_paths_or_mutable_identity(): + value = GenerationSelection(INSTALLATION, "generation-1") + assert value.document().decode() == { + "schema_version": 2, + "instance_id": INSTALLATION.instance_id, + "channel": "edge", + "state": "ready", + "generation_id": "generation-1", + } + assert GenerationSelection.from_value(value.document().decode()) == value + assert replace(value, installation=Installation(INSTALLATION.instance_id, "stable")) != value + with pytest.raises(FrozenInstanceError): + value.generation_id = "other" + + +@pytest.mark.parametrize( + "generation", ["", ".", "..", "../other", "/tmp/db", "with space", "x" * 129, None] +) +def test_generation_cannot_escape_its_fixed_parent(generation): + with pytest.raises(InvalidInput): + GenerationSelection(INSTALLATION, generation) + + +def test_selection_requires_an_explicit_installation(): + with pytest.raises(InvalidInput): + GenerationSelection(None, "valid") + + +@pytest.mark.parametrize( + "change", ["list", "extra", "missing", "version", "boolean", "state", "channel"] +) +def test_root_document_is_exact_and_versioned(change): + value = GenerationSelection(INSTALLATION, "generation-1").document().decode() + if change == "list": + value = [] + elif change == "extra": + value["database_path"] = "elsewhere" + elif change == "missing": + del value["generation_id"] + elif change == "version": + value["schema_version"] = 3 + elif change == "boolean": + value["schema_version"] = True + elif change == "state": + value["state"] = "preparing" + else: + value["channel"] = "wrong" + with pytest.raises(InvalidInput): + GenerationSelection.from_value(value) diff --git a/tests/backend/test_ha_app_launcher.py b/tests/backend/test_ha_app_launcher.py new file mode 100644 index 0000000..0cb79ea --- /dev/null +++ b/tests/backend/test_ha_app_launcher.py @@ -0,0 +1,255 @@ +"""Private real-file provisioning and launch ordering; kernel UID proof is container-only.""" + +import json +import os +import runpy +from pathlib import Path + +import pytest + +from domus.composition import ha_app +from domus.domain.errors import InvalidInput + +ORIGIN = "http://homeassistant.local:8123" + + +@pytest.fixture +def launch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + root = tmp_path / "data" + root.mkdir(mode=0o755) + (root / "options.json").write_text(json.dumps({"public_origins": [ORIGIN]})) + monkeypatch.setattr(ha_app, "_DATA_ROOT", root) + monkeypatch.setattr(ha_app, "_ROOT_UID", os.getuid()) + monkeypatch.setattr(ha_app, "_RUNTIME_UID", os.getuid()) + monkeypatch.setattr(ha_app, "_RUNTIME_GID", os.getgid()) + monkeypatch.setenv("SUPERVISOR_TOKEN", "fixture-supervisor-token") + events = [] + monkeypatch.setattr(ha_app, "_drop_privileges", lambda: events.append("drop")) + monkeypatch.setattr(ha_app.os, "execve", lambda *args: events.append(args)) + previous = os.umask(0o077) + try: + yield root, events + finally: + os.umask(previous) + + +def test_launcher_creates_only_private_child_drops_before_exec_and_limits_environment( + launch, monkeypatch: pytest.MonkeyPatch +) -> None: + root, events = launch + monkeypatch.setenv("PYTHONPATH", "/untrusted") + monkeypatch.setenv("UNRELATED_PRIVATE_VALUE", "must-not-inherit") + (root / "options.json").write_text( + json.dumps({"public_origins": [ORIGIN, "https://HA.EXAMPLE/"]}) + ) + assert ha_app.main(["--channel", "edge"]) == 0 + assert set(p.name for p in root.iterdir()) == {"options.json", "domus"} + assert (root / "domus").stat().st_mode & 0o777 == 0o700 + assert list((root / "domus").iterdir()) == [] + assert events[0] == "drop" + executable, arguments, environment = events[1] + assert executable == "/app/.venv/bin/domus-ingress" + assert arguments == [ + executable, + "--channel", + "edge", + "--data-directory", + str(root / "domus"), + "--public-origin", + ORIGIN, + "--public-origin", + "https://ha.example", + ] + assert environment == { + "PATH": "/app/.venv/bin:/usr/local/bin:/usr/bin:/bin", + "PYTHONUNBUFFERED": "1", + "PYTHONDONTWRITEBYTECODE": "1", + "LANG": "C.UTF-8", + "SUPERVISOR_TOKEN": "fixture-supervisor-token", + } + + +def test_existing_private_child_is_validated_without_chown_or_changes( + launch, monkeypatch: pytest.MonkeyPatch +) -> None: + root, events = launch + target = root / "domus" + target.mkdir(mode=0o700) + record = target / "existing-record" + record.write_bytes(b"existing-private-data") + before = record.stat() + monkeypatch.setattr(ha_app.os, "fchown", lambda *args: pytest.fail("existing chown")) + assert ha_app.main(["--channel", "stable"]) == 0 + assert events[1][1][2] == "stable" + assert record.stat() == before + assert record.read_bytes() == b"existing-private-data" + + +@pytest.mark.parametrize( + "content", + [ + b"", + b"not json", + b"\xff", + b" " * 16385, + b"[]", + b"{}", + b'{"public_origins":null}', + b'{"public_origins":"http://ha.local"}', + b'{"public_origins":[],"extra":true}', + b'{"public_origins":[],"public_origins":["http://ha.local"]}', + b'{"public_origins":["http://ha.local/path"]}', + b'{"public_origins":["http://user:password@ha.local"]}', + b'{"public_origins":[42]}', + b'{"public_origins":["http://ha.local","http://ha.local/"]}', + json.dumps({"public_origins": [f"http://ha{i}.local" for i in range(5)]}).encode(), + ], +) +def test_invalid_bounded_options_never_provision_or_launch( + launch, content: bytes, capsys: pytest.CaptureFixture +) -> None: + root, events = launch + (root / "options.json").write_bytes(content) + assert ha_app.main(["--channel", "edge"]) == 2 + assert events == [] and not (root / "domus").exists() + error = capsys.readouterr().err + assert "Existing household records are retained" in error + assert "fixture-supervisor-token" not in error and str(root) not in error + + +@pytest.mark.parametrize("kind", ["missing", "symlink", "hardlink", "fifo", "writable"]) +def test_options_are_one_regular_nonwritable_operator_file(launch, kind: str) -> None: + root, events = launch + options = root / "options.json" + if kind == "writable": + options.chmod(0o666) + elif kind == "hardlink": + os.link(options, root / "other") + else: + content = options.read_bytes() + options.unlink() + if kind == "symlink": + other = root / "other" + other.write_bytes(content) + options.symlink_to(other) + elif kind == "fifo": + os.mkfifo(options) + assert ha_app.main(["--channel", "edge"]) == 2 + assert events == [] and not (root / "domus").exists() + + +@pytest.mark.parametrize("kind", ["shared_parent", "symlink_parent", "file", "symlink", "mode"]) +def test_launcher_rejects_unsafe_parent_or_child_without_repair(launch, kind: str) -> None: + root, events = launch + target = root / "domus" + if kind == "shared_parent": + root.chmod(0o777) + elif kind == "symlink_parent": + actual = root.with_name("actual") + root.rename(actual) + root.symlink_to(actual, target_is_directory=True) + elif kind == "file": + target.write_bytes(b"retained") + elif kind == "symlink": + target.symlink_to(root / "missing", target_is_directory=True) + else: + target.mkdir(mode=0o755) + target.chmod(0o755) + assert ha_app.main(["--channel", "edge"]) == 2 + assert events == [] + if kind == "mode": + assert target.stat().st_mode & 0o777 == 0o755 + elif kind == "file": + assert target.read_bytes() == b"retained" + + +@pytest.mark.parametrize("field", ["st_uid", "st_gid"]) +def test_existing_child_with_another_owner_is_never_adopted( + launch, monkeypatch: pytest.MonkeyPatch, field: str +) -> None: + root, events = launch + target = root / "domus" + target.mkdir(mode=0o700) + monkeypatch.setattr(ha_app, "_RUNTIME_UID" if field == "st_uid" else "_RUNTIME_GID", 123456) + assert ha_app.main(["--channel", "edge"]) == 2 + assert events == [] + assert target.stat().st_uid == os.getuid() and target.stat().st_gid == os.getgid() + + +def test_launcher_requires_root_and_valid_transport_before_any_files( + launch, monkeypatch: pytest.MonkeyPatch +) -> None: + root, events = launch + monkeypatch.setattr(ha_app, "_ROOT_UID", os.getuid() + 1) + assert ha_app.main(["--channel", "edge"]) == 2 + monkeypatch.setattr(ha_app, "_ROOT_UID", os.getuid()) + monkeypatch.delenv("SUPERVISOR_TOKEN") + assert ha_app.main(["--channel", "edge"]) == 2 + assert events == [] and not (root / "domus").exists() + + +@pytest.mark.parametrize("phase", ["ownership", "drop", "exec"]) +def test_launch_failure_never_runs_a_root_server_and_preserves_ready_data( + launch, monkeypatch: pytest.MonkeyPatch, phase: str, capsys: pytest.CaptureFixture +) -> None: + root, events = launch + + def fail(*args): + raise PermissionError("private details must stay private") + + if phase == "ownership": + monkeypatch.setattr(ha_app.os, "fchown", fail) + elif phase == "drop": + monkeypatch.setattr(ha_app, "_drop_privileges", fail) + else: + monkeypatch.setattr(ha_app.os, "execve", fail) + assert ha_app.main(["--channel", "edge"]) == 2 + assert events == (["drop"] if phase == "exec" else []) + assert "private details" not in capsys.readouterr().err + + +@pytest.mark.parametrize("invalid", [None, "uid", "euid", "gid", "egid", "groups"]) +def test_privilege_drop_clears_groups_and_verifies_all_process_ids( + monkeypatch: pytest.MonkeyPatch, invalid: str | None +) -> None: + state = {"uid": 0, "euid": 0, "gid": 0, "egid": 0, "groups": [0, 7]} + events = [] + + def groups(value): + events.append("groups") + state["groups"] = value + + def gid(value): + events.append("gid") + state["gid"] = state["egid"] = value + + def uid(value): + events.append("uid") + state["uid"] = state["euid"] = value + if invalid is not None: + state[invalid] = [0] if invalid == "groups" else 0 + + for name in ("uid", "euid", "gid", "egid", "groups"): + monkeypatch.setattr(ha_app.os, "get" + name, lambda name=name: state[name]) + monkeypatch.setattr(ha_app.os, "setgroups", groups) + monkeypatch.setattr(ha_app.os, "setgid", gid) + monkeypatch.setattr(ha_app.os, "setuid", uid) + if invalid is not None: + with pytest.raises(InvalidInput, match="runtime_identity_failure"): + ha_app._drop_privileges() + else: + ha_app._drop_privileges() + assert state == {"uid": 65532, "euid": 65532, "gid": 65532, "egid": 65532, "groups": []} + assert events == ["groups", "gid", "uid"] + + +def test_launcher_help_has_no_operator_path_command_or_uid_override( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture +) -> None: + monkeypatch.setattr("sys.argv", ["domus-ha-app", "--help"]) + with pytest.raises(SystemExit) as result: + runpy.run_path(str(Path(ha_app.__file__)), run_name="__main__") + assert result.value.code == 0 + help_text = capsys.readouterr().out + assert "--channel" in help_text + assert "--data-directory" not in help_text and "--uid" not in help_text diff --git a/tests/backend/test_ha_directory.py b/tests/backend/test_ha_directory.py new file mode 100644 index 0000000..f87cc9b --- /dev/null +++ b/tests/backend/test_ha_directory.py @@ -0,0 +1,107 @@ +"""One bounded real protocol lookup distinguishes negative human facts from source failure.""" + +import pytest +from test_ha_websocket import USER, provider, scenario + +from domus.domain.errors import Forbidden, IdentityRejected, InvalidInput +from domus.infrastructure.ha_config import HAEndpoint +from domus.infrastructure.ha_directory import HomeAssistantDirectory +from domus.infrastructure.ha_websocket import HomeAssistantWebSocket + +OTHER, INACTIVE, SYSTEM, MISSING = (value * 32 for value in ("b", "c", "d", "e")) + + +def person(user_id: str, *, active: bool = True, system: bool = False, owner: bool = False) -> dict: + return { + "id": user_id, + "is_owner": owner, + "is_active": active, + "system_generated": system, + "group_ids": ["system-users"], + } + + +def test_one_lookup_returns_only_exact_eligible_humans_in_requested_order() -> None: + records = [ + person(USER, owner=True), + person(OTHER), + person(INACTIVE, active=False), + person(SYSTEM, system=True), + ] + with provider(scenario(records, operation="config/auth/list")) as endpoint: + adapter = HomeAssistantDirectory(HomeAssistantWebSocket(endpoint), "fixture-access") + humans = adapter.eligible((OTHER, MISSING, USER, INACTIVE, SYSTEM)) + assert tuple(human.ha_user_id for human in humans) == (OTHER, USER) + assert not humans[0].is_administrator and humans[1].is_administrator + assert "fixture-access" not in repr(adapter) + + +def test_single_human_port_uses_the_same_proven_directory_contract() -> None: + with provider(scenario([person(USER)], operation="config/auth/list")) as endpoint: + adapter = HomeAssistantDirectory(HomeAssistantWebSocket(endpoint), "fixture-access") + assert adapter.get(USER).ha_user_id == USER + with pytest.raises(InvalidInput): + HomeAssistantDirectory(HomeAssistantWebSocket(endpoint), "invalid token") + + +@pytest.mark.parametrize( + "requested", [[USER], ("invalid",), (USER, USER), tuple("a" * 32 for _ in range(257))] +) +def test_unbounded_malformed_or_duplicate_requests_fail_before_contacting_ha( + requested: tuple, +) -> None: + with pytest.raises(InvalidInput): + HomeAssistantWebSocket(HAEndpoint("http://unreachable.invalid")).directory_users( + "fixture-access", requested + ) + + +def test_empty_candidate_set_requires_no_source_request() -> None: + assert ( + HomeAssistantWebSocket(HAEndpoint("http://unreachable.invalid")).directory_users( + "fixture-access", () + ) + == () + ) + + +@pytest.mark.parametrize( + "broken", + [{"is_active": 1}, {"system_generated": "false"}, {"is_owner": 1}, {"group_ids": [None]}], +) +def test_malformed_candidate_never_becomes_a_silent_partial_success(broken: dict) -> None: + records = [person(USER, owner=True), {**person(OTHER), **broken}] + with provider(scenario(records, operation="config/auth/list")) as endpoint: + with pytest.raises(IdentityRejected): + HomeAssistantDirectory(HomeAssistantWebSocket(endpoint), "fixture-access").eligible( + (USER, OTHER) + ) + + +def test_directory_permission_denial_is_not_an_empty_eligible_set() -> None: + with provider(scenario(None, operation="config/auth/list", success=False)) as endpoint: + with pytest.raises(Forbidden, match="ha_directory_forbidden"): + HomeAssistantDirectory(HomeAssistantWebSocket(endpoint), "fixture-access").eligible( + (USER,) + ) + + +def test_unrequested_human_cannot_influence_a_requested_identity() -> None: + records = [person(OTHER, owner=True), person(USER)] + with provider(scenario(records, operation="config/auth/list")) as endpoint: + humans = HomeAssistantDirectory( + HomeAssistantWebSocket(endpoint), "fixture-access" + ).eligible((USER,)) + assert len(humans) == 1 + assert humans[0].ha_user_id == USER + assert not humans[0].is_administrator + + +@pytest.mark.parametrize("duplicate", [USER, OTHER]) +def test_ambiguous_directory_rejects_duplicate_ids_even_outside_candidates(duplicate: str) -> None: + records = [person(USER), person(OTHER), person(duplicate, owner=True)] + with provider(scenario(records, operation="config/auth/list")) as endpoint: + with pytest.raises(IdentityRejected, match="ha_subject_rejected"): + HomeAssistantDirectory(HomeAssistantWebSocket(endpoint), "fixture-access").eligible( + (USER,) + ) diff --git a/tests/backend/test_ha_http.py b/tests/backend/test_ha_http.py new file mode 100644 index 0000000..b57aa42 --- /dev/null +++ b/tests/backend/test_ha_http.py @@ -0,0 +1,279 @@ +"""HA OAuth transport uses fixed validated origins and never exposes token payloads.""" + +import asyncio +from urllib.parse import parse_qs + +import httpx +import pytest + +from domus.domain.errors import IdentityRejected, IdentityUnavailable, InvalidInput +from domus.infrastructure.ha_config import HAEndpoint, OAuthConfiguration +from domus.infrastructure.ha_http import HomeAssistantOAuth + +VERIFIER = "v" * 43 +TOKEN = { + "access_token": "private-access", + "refresh_token": "private-refresh", + "expires_in": 1800, + "token_type": "Bearer", + "ha_auth_provider": "homeassistant", +} + + +def configuration() -> OAuthConfiguration: + return OAuthConfiguration( + HAEndpoint("http://homeassistant.local:8123"), "https://domus.example" + ) + + +def test_configuration_normalizes_fixed_origins_and_supervisor_socket_path() -> None: + config = OAuthConfiguration(HAEndpoint("https://HA.EXAMPLE:443/"), "https://DOMUS.EXAMPLE/") + assert config.ha.origin == "https://ha.example" + assert config.ha.websocket_url == "wss://ha.example/api/websocket" + assert config.client_id == "https://domus.example/" + assert config.callback_url == "https://domus.example/api/v1/auth/callback" + assert ( + HAEndpoint("http://homeassistant.local:8123").websocket_url + == "ws://homeassistant.local:8123/api/websocket" + ) + supervisor = HAEndpoint.supervisor() + assert supervisor.websocket_url == "ws://supervisor/core/websocket" + assert supervisor.host == "supervisor" and supervisor.port == 80 + with pytest.raises(InvalidInput, match="invalid_ha_endpoint"): + HAEndpoint("http://other", supervised=True) + with pytest.raises(InvalidInput, match="supervisor_uses_ingress"): + OAuthConfiguration(supervisor, "https://domus.example") + + +@pytest.mark.parametrize( + "url", + [ + "", + "ftp://host", + "https://user:password@host", + "https://host/path", + "https://host?x=1", + "https://host#fragment", + "https://host?", + "https://host#", + "https://host:0", + "https://host:65536", + "https://host:bad", + "https://host\\evil", + "https://host/\n", + "https://%68ost", + "https://", + "https://[invalid]", + "https://[fe80::1%25eth0]", + "https://a..example", + 123, + "https://" + "x" * 2049, + ], +) +def test_invalid_operator_origin_is_rejected_before_io(url: object) -> None: + with pytest.raises(InvalidInput, match="invalid_origin"): + HAEndpoint(url) + + +def test_public_http_requires_explicit_loopback_development_and_ipv6_is_preserved() -> None: + for url in ("http://localhost:8099", "http://127.0.0.1:8099", "http://[::1]:8099"): + with pytest.raises(InvalidInput, match="insecure_public_origin"): + OAuthConfiguration(HAEndpoint("http://ha.local"), url) + assert ( + OAuthConfiguration( + HAEndpoint("http://ha.local"), url, development_http=True + ).public_origin + == url + ) + with pytest.raises(InvalidInput, match="insecure_public_origin"): + OAuthConfiguration( + HAEndpoint("http://ha.local"), "http://localhost.evil", development_http=True + ) + assert HAEndpoint("https://[::1]:8123").host == "::1" + assert HAEndpoint("http://127.0.0.1").port == 80 + assert HAEndpoint("https://ha.local").port == 443 + + +def test_code_exchange_is_exact_server_side_form_and_token_repr_is_redacted() -> None: + requests = [] + + def provider(request: httpx.Request) -> httpx.Response: + requests.append(request) + assert str(request.url) == "http://homeassistant.local:8123/auth/token" + assert request.headers["content-type"].startswith("application/x-www-form-urlencoded") + assert parse_qs(request.content.decode()) == { + "grant_type": ["authorization_code"], + "code": ["approved-code"], + "client_id": ["https://domus.example/"], + "code_verifier": [VERIFIER], + } + return httpx.Response(200, json=TOKEN) + + tokens = HomeAssistantOAuth(configuration(), transport=httpx.MockTransport(provider)).exchange( + "approved-code", VERIFIER + ) + assert ( + tokens.access_token == TOKEN["access_token"] + and tokens.refresh_token == TOKEN["refresh_token"] + ) + assert tokens.expires_in == 1800 and tokens.provider == "homeassistant" + assert "private-access" not in repr(tokens) and "private-refresh" not in repr(tokens) + assert len(requests) == 1 + + +def test_refresh_and_revoke_use_fixed_client_and_do_not_change_refresh_identity() -> None: + calls = [] + + def provider(request: httpx.Request) -> httpx.Response: + calls.append(parse_qs(request.content.decode())) + if request.url.path == "/auth/revoke": + return httpx.Response(200) + return httpx.Response( + 200, json={"access_token": "new-access", "expires_in": 1800, "token_type": "Bearer"} + ) + + client = HomeAssistantOAuth(configuration(), transport=httpx.MockTransport(provider)) + renewed = client.refresh("private-refresh", "homeassistant") + assert renewed.refresh_token == "private-refresh" and renewed.access_token == "new-access" + assert renewed.provider == "homeassistant" + client.revoke("private-refresh") + assert calls == [ + { + "grant_type": ["refresh_token"], + "refresh_token": ["private-refresh"], + "client_id": ["https://domus.example/"], + }, + {"token": ["private-refresh"]}, + ] + + +@pytest.mark.parametrize( + "status,exception", + [ + (400, IdentityRejected), + (401, IdentityRejected), + (403, IdentityRejected), + (302, IdentityRejected), + (429, IdentityUnavailable), + (500, IdentityUnavailable), + (204, IdentityRejected), + ], +) +def test_provider_status_is_allowlisted_and_redirects_are_never_followed( + status: int, exception: type[Exception] +) -> None: + calls = [] + + def provider(request: httpx.Request) -> httpx.Response: + calls.append(str(request.url)) + return httpx.Response( + status, + headers={"location": "https://attacker.example/private"}, + text="private-provider-details", + ) + + client = HomeAssistantOAuth(configuration(), transport=httpx.MockTransport(provider)) + with pytest.raises(exception) as failure: + client.exchange("code", VERIFIER) + assert "private" not in str(failure.value) + assert calls == ["http://homeassistant.local:8123/auth/token"] + + +@pytest.mark.parametrize( + "change", ["access", "refresh", "expiry", "bool_expiry", "type", "provider"] +) +def test_malformed_token_result_cannot_become_a_grant(change: str) -> None: + payload = dict(TOKEN) + if change == "access": + payload["access_token"] = "" + elif change == "refresh": + payload.pop("refresh_token") + elif change == "expiry": + payload["expires_in"] = 0 + elif change == "bool_expiry": + payload["expires_in"] = True + elif change == "type": + payload["token_type"] = "Other" + else: + payload["ha_auth_provider"] = "unsafe provider" + client = HomeAssistantOAuth( + configuration(), transport=httpx.MockTransport(lambda _: httpx.Response(200, json=payload)) + ) + with pytest.raises(IdentityRejected, match="invalid_ha_protocol"): + client.exchange("code", VERIFIER) + + +@pytest.mark.parametrize("content", [b"not json", b'{"a":1,"a":2}', b"[]", b"\xff", b" " * 65_537]) +def test_invalid_duplicate_oversized_and_non_utf8_response_fails_closed(content: bytes) -> None: + client = HomeAssistantOAuth( + configuration(), + transport=httpx.MockTransport(lambda _: httpx.Response(200, content=content)), + ) + with pytest.raises(IdentityRejected, match="invalid_ha_protocol"): + client.exchange("code", VERIFIER) + + +@pytest.mark.parametrize( + "error", [httpx.ConnectError("private-address"), httpx.ReadTimeout("private-token")] +) +def test_network_errors_are_availability_without_transport_detail( + error: httpx.RequestError, +) -> None: + def provider(_: httpx.Request) -> httpx.Response: + raise error + + client = HomeAssistantOAuth(configuration(), transport=httpx.MockTransport(provider)) + with pytest.raises(IdentityUnavailable, match="ha_unavailable") as failure: + client.exchange("code", VERIFIER) + assert "private" not in str(failure.value) + + +@pytest.mark.parametrize( + "code,verifier", [("", VERIFIER), ("code", "short"), ("bad code", VERIFIER)] +) +def test_invalid_code_or_pkce_verifier_is_not_transmitted(code: str, verifier: str) -> None: + def forbidden_io(_: httpx.Request) -> httpx.Response: + pytest.fail("Invalid input must not contact HA") + + with pytest.raises(InvalidInput): + HomeAssistantOAuth(configuration(), transport=httpx.MockTransport(forbidden_io)).exchange( + code, verifier + ) + + +def test_nonempty_revocation_response_and_unsolicited_encoding_are_rejected() -> None: + client = HomeAssistantOAuth( + configuration(), + transport=httpx.MockTransport(lambda _: httpx.Response(200, content=b"unexpected body")), + ) + with pytest.raises(IdentityRejected, match="invalid_ha_protocol"): + client.revoke("private-refresh") + client = HomeAssistantOAuth( + configuration(), + transport=httpx.MockTransport( + lambda _: httpx.Response(200, headers={"content-encoding": "unsupported"}) + ), + ) + with pytest.raises(IdentityRejected, match="invalid_ha_protocol"): + client.exchange("code", VERIFIER) + + +def test_absolute_budget_cancels_a_blocked_provider_without_real_waits( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from domus.infrastructure import ha_http + + real_timeout = asyncio.timeout + + def immediate_deadline(seconds: float) -> asyncio.Timeout: + assert seconds == 5 + return real_timeout(0) + + async def blocked(request: httpx.Request) -> httpx.Response: + await asyncio.Event().wait() + pytest.fail("An indefinitely blocked provider must be cancelled") + + monkeypatch.setattr(ha_http, "timeout", immediate_deadline) + client = HomeAssistantOAuth(configuration(), transport=httpx.MockTransport(blocked)) + with pytest.raises(IdentityUnavailable, match="ha_unavailable"): + client.exchange("code", VERIFIER) diff --git a/tests/backend/test_ha_ingress.py b/tests/backend/test_ha_ingress.py new file mode 100644 index 0000000..7368cad --- /dev/null +++ b/tests/backend/test_ha_ingress.py @@ -0,0 +1,87 @@ +"""Only Supervisor's actual socket peer may supply a candidate human subject.""" + +from dataclasses import dataclass + +import pytest + +from domus.domain.errors import IdentityRejected, IdentityUnavailable, InvalidInput +from domus.domain.identity import HumanIdentity +from domus.infrastructure.ha_config import HAEndpoint +from domus.infrastructure.ha_ingress import SupervisorIdentity + +USER = "a" * 32 +HEADERS = ( + ("X-Remote-User-Id", USER), + ("X-Remote-User-Name", "Administrator"), + ("X-Forwarded-For", "172.30.32.2"), +) + + +@dataclass +class Lookup: + endpoint: HAEndpoint = HAEndpoint.supervisor() + calls: int = 0 + identity: HumanIdentity = HumanIdentity(USER, False, True, False) + error: Exception | None = None + + def directory_user(self, token: str, user_id: str) -> HumanIdentity: + assert token == "private-supervisor-token" and user_id == USER + self.calls += 1 + if self.error: + raise self.error + return self.identity + + +def test_actual_supervisor_peer_is_required_before_any_lookup_or_forwarded_header() -> None: + lookup = Lookup() + verifier = SupervisorIdentity(lookup, "private-supervisor-token") + for peer in ("127.0.0.1", "192.168.1.10", "::ffff:172.30.32.2", ""): + with pytest.raises(IdentityRejected, match="untrusted_ingress_peer"): + verifier.prove(peer, HEADERS) + assert lookup.calls == 0 + + +def test_core_proof_overrules_header_name_and_transport_privilege() -> None: + lookup = Lookup() + verifier = SupervisorIdentity(lookup, "private-supervisor-token") + assert not verifier.prove("172.30.32.2", HEADERS).is_administrator + lookup.identity = HumanIdentity(USER, True, True, False) + assert verifier.prove("172.30.32.2", HEADERS).is_administrator + assert lookup.calls == 2 + assert "private-supervisor-token" not in repr(verifier) + + +@pytest.mark.parametrize( + "headers", + [ + (), + (("X-Remote-User-Name", "Owner"),), + (("X-Remote-User-Id", "bad-id"),), + (("X-Remote-User-Id", USER), ("x-remote-user-id", USER)), + (("X-Remote-User-Id", USER + ", " + USER),), + ], +) +def test_missing_malformed_or_duplicate_header_does_not_establish_human(headers: tuple) -> None: + lookup = Lookup() + with pytest.raises(IdentityRejected, match="invalid_ingress_subject"): + SupervisorIdentity(lookup, "private-supervisor-token").prove("172.30.32.2", headers) + assert lookup.calls == 0 + + +def test_lookup_must_match_candidate_and_failures_never_fall_back_to_headers() -> None: + lookup = Lookup(identity=HumanIdentity("b" * 32, True, True, False)) + verifier = SupervisorIdentity(lookup, "private-supervisor-token") + with pytest.raises(IdentityRejected, match="identity_mismatch"): + verifier.prove("172.30.32.2", HEADERS) + lookup.error = IdentityUnavailable("ha_unavailable") + with pytest.raises(IdentityUnavailable): + verifier.prove("172.30.32.2", HEADERS) + lookup.error = IdentityRejected("ha_subject_rejected") + with pytest.raises(IdentityRejected): + verifier.prove("172.30.32.2", HEADERS) + + +def test_standalone_endpoint_cannot_be_repurposed_for_ingress_identity() -> None: + lookup = Lookup(endpoint=HAEndpoint("http://ha.local")) + with pytest.raises(InvalidInput, match="ingress_requires_supervisor"): + SupervisorIdentity(lookup, "private-supervisor-token") diff --git a/tests/backend/test_ha_websocket.py b/tests/backend/test_ha_websocket.py new file mode 100644 index 0000000..93ee32a --- /dev/null +++ b/tests/backend/test_ha_websocket.py @@ -0,0 +1,340 @@ +"""Disposable loopback HA protocol fixtures exercise the pinned real WebSocket client.""" + +import asyncio +import json +import logging +import socket +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from threading import Thread + +import pytest +from websockets.http11 import Response +from websockets.sync.server import ServerConnection, serve + +from domus.domain.errors import Forbidden, IdentityRejected, IdentityUnavailable, InvalidInput +from domus.domain.identity import HumanIdentity +from domus.infrastructure.ha_config import HAEndpoint +from domus.infrastructure.ha_http import OAuthTokens +from domus.infrastructure.ha_websocket import HomeAssistantWebSocket + +USER = "a" * 32 +GRANT = OAuthTokens("fixture-access", "fixture-refresh", 1800, "homeassistant") +CURRENT = { + "id": USER, + "is_admin": True, + "credentials": [{"auth_provider_type": "homeassistant", "auth_provider_id": None}], +} + + +@contextmanager +def provider(handler: Callable[[ServerConnection], None], **kwargs: object) -> Iterator[HAEndpoint]: + with serve(handler, "127.0.0.1", 0, ping_interval=None, close_timeout=0.2, **kwargs) as server: + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield HAEndpoint(f"http://127.0.0.1:{server.socket.getsockname()[1]}") + finally: + server.shutdown() + thread.join(timeout=3) + assert not thread.is_alive() + + +def scenario( + result: object, + *, + operation: str = "auth/current_user", + response_id: object = 1, + success: object = True, +) -> Callable[[ServerConnection], None]: + def handle(connection: ServerConnection) -> None: + connection.send(json.dumps({"type": "auth_required", "ha_version": "2026.10.4"})) + assert json.loads(connection.recv()) == {"type": "auth", "access_token": "fixture-access"} + connection.send(json.dumps({"type": "auth_ok", "ha_version": "2026.10.4"})) + assert json.loads(connection.recv()) == {"type": operation, "id": 1} + connection.send( + json.dumps({"type": "result", "id": response_id, "success": success, "result": result}) + ) + + return handle + + +def test_oauth_current_user_proves_exact_human_and_boolean_admin() -> None: + with provider(scenario(CURRENT)) as endpoint: + assert HomeAssistantWebSocket(endpoint).current_user(GRANT) == HumanIdentity( + USER, True, True, False + ) + with provider(scenario({**CURRENT, "is_admin": False})) as endpoint: + assert not HomeAssistantWebSocket(endpoint).current_user(GRANT).is_administrator + + +@pytest.mark.parametrize( + "result", + [ + {**CURRENT, "id": "person.someone"}, + {**CURRENT, "is_admin": 1}, + {**CURRENT, "credentials": []}, + {**CURRENT, "credentials": "wrong"}, + {**CURRENT, "credentials": [None]}, + {**CURRENT, "credentials": [{"auth_provider_type": "other"}]}, + [], + {}, + ], +) +def test_malformed_or_nonhuman_current_user_cannot_become_identity(result: object) -> None: + with provider(scenario(result)) as endpoint: + with pytest.raises(IdentityRejected, match="invalid_ha_protocol"): + HomeAssistantWebSocket(endpoint).current_user(GRANT) + + +@pytest.mark.parametrize("response_id,success", [(2, True), (True, True), (1, 1), (1, False)]) +def test_response_correlation_and_success_are_exact(response_id: object, success: object) -> None: + with provider(scenario(CURRENT, response_id=response_id, success=success)) as endpoint: + with pytest.raises(IdentityRejected): + HomeAssistantWebSocket(endpoint).current_user(GRANT) + + +def test_directory_uses_group_source_and_explicit_system_active_flags() -> None: + records = [ + { + "id": USER, + "is_owner": False, + "is_active": True, + "system_generated": False, + "group_ids": ["system-admin"], + } + ] + with provider(scenario(records, operation="config/auth/list")) as endpoint: + assert ( + HomeAssistantWebSocket(endpoint).directory_user("fixture-access", USER).is_administrator + ) + records[0]["group_ids"] = ["system-users"] + with provider(scenario(records, operation="config/auth/list")) as endpoint: + assert ( + not HomeAssistantWebSocket(endpoint) + .directory_user("fixture-access", USER) + .is_administrator + ) + + +@pytest.mark.parametrize( + "records", + [ + [], + [ + { + "id": USER, + "is_owner": False, + "is_active": False, + "system_generated": False, + "group_ids": [], + } + ], + [ + { + "id": USER, + "is_owner": True, + "is_active": True, + "system_generated": True, + "group_ids": [], + } + ], + [ + { + "id": USER, + "is_owner": False, + "is_active": 1, + "system_generated": False, + "group_ids": [], + } + ], + [ + { + "id": USER, + "is_owner": False, + "is_active": True, + "system_generated": False, + "group_ids": "system-admin", + } + ], + [ + { + "id": USER, + "is_owner": False, + "is_active": True, + "system_generated": False, + "group_ids": [1], + } + ], + [ + { + "id": USER, + "is_owner": 1, + "is_active": True, + "system_generated": False, + "group_ids": [], + } + ], + [None], + {}, + [{"id": "bad"}], + [{"id": USER}, {"id": USER}], + ], +) +def test_directory_rejects_missing_inactive_technical_malformed_or_duplicate_subject( + records: object, +) -> None: + with provider(scenario(records, operation="config/auth/list")) as endpoint: + with pytest.raises(IdentityRejected): + HomeAssistantWebSocket(endpoint).directory_user("fixture-access", USER) + + +def test_directory_permission_denial_is_distinct_from_own_identity_rejection() -> None: + with provider(scenario(None, operation="config/auth/list", success=False)) as endpoint: + with pytest.raises(Forbidden, match="ha_directory_forbidden"): + HomeAssistantWebSocket(endpoint).directory_user("fixture-access", USER) + + +@pytest.mark.parametrize( + "message", + [b"binary", "not-json", '{"type":"a","type":"b"}', '{"type":"unexpected"}', "x" * 70_000], +) +def test_invalid_or_oversized_handshake_is_not_outage_grace(message: str | bytes) -> None: + def handle(connection: ServerConnection) -> None: + connection.send(message) + + with provider(handle) as endpoint: + with pytest.raises(IdentityRejected): + HomeAssistantWebSocket(endpoint).current_user(GRANT) + + +def test_explicit_auth_rejection_does_not_grant_outage_access() -> None: + def handle(connection: ServerConnection) -> None: + connection.send('{"type":"auth_required"}') + connection.recv() + connection.send('{"type":"auth_invalid","message":"private-provider-detail"}') + + with provider(handle) as endpoint: + with pytest.raises(IdentityRejected, match="ha_rejected") as failure: + HomeAssistantWebSocket(endpoint).current_user(GRANT) + assert "private" not in str(failure.value) + + +def test_early_disconnect_is_unavailable_and_pinned_redirect_hook_never_contacts_other_origin() -> ( + None +): + with provider(lambda connection: connection.close()) as endpoint: + with pytest.raises(IdentityUnavailable): + HomeAssistantWebSocket(endpoint).current_user(GRANT) + visited = [] + with provider(lambda connection: visited.append(True)) as other: + + def redirect(connection: ServerConnection, request: object) -> Response: + response = connection.respond(302, "Moved") + response.headers["Location"] = other.websocket_url + return response + + with provider( + lambda _: pytest.fail("Redirect must not open a protocol"), process_request=redirect + ) as endpoint: + with pytest.raises(IdentityRejected): + HomeAssistantWebSocket(endpoint).current_user(GRANT) + assert visited == [] + + +def test_invalid_directory_subject_is_rejected_before_connect() -> None: + with pytest.raises(InvalidInput): + HomeAssistantWebSocket(HAEndpoint("http://ha.invalid")).directory_user( + "fixture-access", "bad-id" + ) + + +def test_wrong_auth_acknowledgement_is_a_protocol_rejection() -> None: + def handle(connection: ServerConnection) -> None: + connection.send('{"type":"auth_required"}') + connection.recv() + connection.send('{"type":"unexpected"}') + + with provider(handle) as endpoint: + with pytest.raises(IdentityRejected, match="invalid_ha_protocol"): + HomeAssistantWebSocket(endpoint).current_user(GRANT) + + +@pytest.mark.parametrize("status", [429, 503]) +def test_temporary_handshake_failure_is_availability(status: int) -> None: + def unavailable(connection: ServerConnection, request: object) -> Response: + return connection.respond(status, "private failure detail") + + with provider(lambda _: None, process_request=unavailable) as endpoint: + with pytest.raises(IdentityUnavailable, match="ha_unavailable"): + HomeAssistantWebSocket(endpoint).current_user(GRANT) + + +def test_connection_refusal_is_safe_unavailability() -> None: + with socket.socket() as unlistening: + unlistening.bind(("127.0.0.1", 0)) + endpoint = HAEndpoint(f"http://127.0.0.1:{unlistening.getsockname()[1]}") + with pytest.raises(IdentityUnavailable, match="ha_unavailable"): + HomeAssistantWebSocket(endpoint).current_user(GRANT) + + +def test_directory_ignores_another_users_admin_flag_and_wire_logging_is_private( + caplog: pytest.LogCaptureFixture, +) -> None: + records = [ + { + "id": "b" * 32, + "is_owner": False, + "is_active": True, + "system_generated": False, + "group_ids": ["system-admin"], + }, + { + "id": USER, + "is_owner": False, + "is_active": True, + "system_generated": False, + "group_ids": [], + }, + ] + with caplog.at_level(logging.DEBUG, logger="websockets.client"): + with provider(scenario(records, operation="config/auth/list")) as endpoint: + assert ( + not HomeAssistantWebSocket(endpoint) + .directory_user("fixture-access", USER) + .is_administrator + ) + assert "fixture-access" not in caplog.text + + +def test_ha_owner_remains_administrator_without_administrator_group() -> None: + records = [ + { + "id": USER, + "is_owner": True, + "is_active": True, + "system_generated": False, + "group_ids": [], + } + ] + with provider(scenario(records, operation="config/auth/list")) as endpoint: + assert ( + HomeAssistantWebSocket(endpoint).directory_user("fixture-access", USER).is_administrator + ) + + +def test_websocket_absolute_deadline_is_enforced_without_business_sleeps( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from domus.infrastructure import ha_websocket + + real_timeout = asyncio.timeout + + def immediate_deadline(seconds: float) -> asyncio.Timeout: + assert seconds == 5 + return real_timeout(0) + + monkeypatch.setattr(ha_websocket, "timeout", immediate_deadline) + with provider(lambda _: None) as endpoint: + with pytest.raises(IdentityUnavailable, match="ha_unavailable"): + HomeAssistantWebSocket(endpoint).current_user(GRANT) diff --git a/tests/backend/test_household_commands.py b/tests/backend/test_household_commands.py new file mode 100644 index 0000000..9511416 --- /dev/null +++ b/tests/backend/test_household_commands.py @@ -0,0 +1,458 @@ +"""Explicit household setup and own enrollment preserve authorization and durable replay.""" + +from concurrent.futures import ThreadPoolExecutor +from contextlib import closing +from dataclasses import replace +from pathlib import Path +from threading import Barrier + +import pytest + +from domus.application.commands import AtomicCommands +from domus.application.household_commands import ( + HouseholdCommands, + SetupHousehold, + _InitialAuthority, +) +from domus.domain.errors import Conflict, Forbidden, IdentityUnavailable, InvalidInput, StorageBusy +from domus.domain.identity import HumanIdentity, MappingMode, Member, MemberStatus, Role +from domus.domain.storage import CommandKey, Installation, JsonDocument +from domus.infrastructure.sqlite import SQLiteDatabase + + +class Actor: + human = HumanIdentity("ha_admin", True, True, False) + error = None + calls = 0 + + def current(self) -> HumanIdentity: + self.calls += 1 + if self.error: + raise self.error + return self.human + + +class Directory: + def __init__(self) -> None: + self.people = {"ha_selected": HumanIdentity("ha_selected", False, True, False)} + self.calls = [] + self.error = None + + def get(self, user_id: str) -> HumanIdentity: + self.calls.append(user_id) + if self.error: + raise self.error + return self.people[user_id] + + +class Zones: + def require(self, time_zone: str) -> None: + if time_zone not in ("UTC", "Europe/Madrid"): + raise InvalidInput("invalid_time_zone") + + +class Clock: + def now(self) -> int: + return 100 + + +class Identifiers: + def __init__(self) -> None: + self.count = 0 + + def new(self) -> str: + self.count += 1 + return f"identity-{self.count}" + + +def fixture(path: Path) -> tuple: + store = SQLiteDatabase(path, Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge")) + store.initialize() + actor, directory, identifiers = Actor(), Directory(), Identifiers() + return ( + HouseholdCommands(store, actor, directory, Zones(), Clock(), identifiers), + store, + actor, + directory, + identifiers, + ) + + +def test_setup_is_explicit_and_commits_household_mapping_member_and_evidence( + tmp_path: Path, +) -> None: + commands, store, actor, directory, _ = fixture(tmp_path / "database") + with store.read() as unit: + assert unit.households.current() is None + result = commands.setup("create", SetupHousehold(" Our home ", "Europe/Madrid")) + assert actor.calls == 1 and directory.calls == [] + assert result.command.actor_id == "ha_admin" and result.created_at == 100 + with store.read() as unit: + assert unit.households.current().name == "Our home" + assert unit.households.current().time_zone == "Europe/Madrid" + assert unit.policies.get("home").mapping.mode is MappingMode.ALL + member = unit.members.by_user("home", "ha_admin") + assert member.status is MemberStatus.ACTIVE + assert member.role is Role.VIEWER # Administration is inherited, not an independent grant. + assert result.result.decode()["member_id"] == member.member_id + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 1 + assert unit.receipts.get(result.command) == result + + +@pytest.mark.parametrize("mode", [MappingMode.ALL, MappingMode.SETUP, MappingMode.SELECTED]) +def test_each_mapping_is_explicit_and_selected_ordinary_human_is_verified( + tmp_path: Path, + mode: MappingMode, +) -> None: + commands, store, _, directory, _ = fixture(tmp_path / "database") + selected = "ha_selected" if mode is MappingMode.SELECTED else None + result = commands.setup("create", SetupHousehold("Home", "UTC", mode, selected)) + with store.read() as unit: + policy = unit.policies.get("home") + assert policy.mapping.mode is mode + expected = None if mode is MappingMode.ALL else (selected or "ha_admin") + assert policy.mapping.target_ha_user_id == expected + if selected: + member = unit.members.by_user("home", selected) + assert member.status is MemberStatus.ACTIVE and member.role is Role.VIEWER + assert result.result.decode()["administrator_member_id"] == member.member_id + assert directory.calls == ([selected] if selected else []) + + +def test_selected_self_uses_fresh_actor_proof_without_an_unrelated_directory_read( + tmp_path: Path, +) -> None: + commands, _, _, directory, _ = fixture(tmp_path / "database") + commands.setup("create", SetupHousehold("Home", "UTC", MappingMode.SELECTED, "ha_admin")) + assert directory.calls == [] + + +def test_first_arriving_ordinary_user_never_initializes_the_household(tmp_path: Path) -> None: + commands, store, actor, _, _ = fixture(tmp_path / "database") + actor.human = HumanIdentity("ha_ordinary", False, True, False) + with pytest.raises(Forbidden): + commands.setup("create", SetupHousehold("Home", "UTC")) + with store.read() as unit: + assert unit.households.current() is None + assert unit.outbox.pending() == () + + +@pytest.mark.parametrize("failure", ["unavailable", "mismatched"]) +def test_unverified_selected_user_cannot_leave_partial_setup(tmp_path: Path, failure: str) -> None: + commands, store, _, directory, _ = fixture(tmp_path / "database") + if failure == "unavailable": + directory.error = IdentityUnavailable("ha_unavailable") + else: + directory.people["ha_selected"] = HumanIdentity("other", True, True, False) + with pytest.raises((IdentityUnavailable, Forbidden)): + commands.setup("create", SetupHousehold("Home", "UTC", MappingMode.SELECTED, "ha_selected")) + with store.read() as unit: + assert unit.households.current() is None and unit.outbox.pending() == () + + +def test_authorized_setup_retry_reuses_receipt_after_restart_and_payload_conflicts( + tmp_path: Path, +) -> None: + path = tmp_path / "database" + commands, _, _, _, ids = fixture(path) + request = SetupHousehold("Home", "UTC") + original = commands.setup("create", request) + assert commands.setup("create", request) == original + assert ids.count == 2 + restarted, _, _, _, fresh_ids = fixture(path) + assert restarted.setup("create", request) == original and fresh_ids.count == 0 + with pytest.raises(Conflict, match="idempotency_conflict"): + restarted.setup("create", SetupHousehold("Changed", "UTC")) + with pytest.raises(Conflict, match="household_exists"): + restarted.setup("another-key", request) + + +def test_selected_setup_retry_does_not_require_fresh_target_proof_when_actor_remains_admin( + tmp_path: Path, +) -> None: + commands, _, _, directory, _ = fixture(tmp_path / "database") + request = SetupHousehold("Home", "UTC", MappingMode.SELECTED, "ha_admin") + first = commands.setup("create", request) + directory.error = IdentityUnavailable("ha_unavailable") + assert commands.setup("create", request) == first + + +def test_setup_retry_after_actor_loses_administration_cannot_disclose_the_old_receipt( + tmp_path: Path, +) -> None: + commands, _, actor, _, _ = fixture(tmp_path / "database") + request = SetupHousehold("Home", "UTC") + commands.setup("create", request) + actor.human = replace(actor.human, is_administrator=False) + with pytest.raises(Forbidden): + commands.setup("create", request) + + +def test_own_enrollment_is_pending_and_a_retry_has_one_stable_member(tmp_path: Path) -> None: + commands, store, actor, _, ids = fixture(tmp_path / "database") + commands.setup("create", SetupHousehold("Home", "UTC")) + actor.human = HumanIdentity("ha_ordinary", False, True, False) + first = commands.enroll("join") + count = ids.count + assert commands.enroll("join") == first and ids.count == count + assert first.command.actor_id == "ha_ordinary" + assert first.result.decode()["status"] == "pending" + with store.read() as unit: + member = unit.members.by_user("home", "ha_ordinary") + assert member.role is Role.VIEWER and member.status is MemberStatus.PENDING + assert len(unit.members.list("home")) == 2 + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 2 + with pytest.raises(Conflict, match="member_exists"): + commands.enroll("different-key") + + +@pytest.mark.parametrize( + "mode,expected", [(MappingMode.ALL, "active"), (MappingMode.SETUP, "pending")] +) +def test_ha_admin_activation_exception_applies_only_to_explicit_enrollment_in_default_mapping( + tmp_path: Path, + mode: MappingMode, + expected: str, +) -> None: + commands, store, actor, _, _ = fixture(tmp_path / "database") + commands.setup("create", SetupHousehold("Home", "UTC", mode)) + actor.human = HumanIdentity("ha_other_admin", True, True, False) + with store.read() as unit: + assert unit.members.by_user("home", "ha_other_admin") is None + assert commands.enroll("join").result.decode()["status"] == expected + + +@pytest.mark.parametrize("status", [MemberStatus.SUSPENDED, MemberStatus.ARCHIVED]) +def test_inactive_members_cannot_reenroll_or_replay_an_earlier_request( + tmp_path: Path, status: MemberStatus +) -> None: + commands, store, actor, _, _ = fixture(tmp_path / "database") + commands.setup("create", SetupHousehold("Home", "UTC")) + actor.human = HumanIdentity("ha_ordinary", False, True, False) + commands.enroll("join") + with store.write() as unit: + member = unit.members.by_user("home", "ha_ordinary") + unit.members.save(replace(member, status=status, revision=2), 1) + for key in ("join", "new-join"): + with pytest.raises(Forbidden): + commands.enroll(key) + with store.read() as unit: + assert len(unit.members.list("home")) == 2 + + +def test_enrollment_without_a_configured_household_fails_closed(tmp_path: Path) -> None: + commands, store, _, _, _ = fixture(tmp_path / "database") + with pytest.raises(Forbidden): + commands.enroll("join") + with store.read() as unit: + assert unit.households.current() is None + + +def test_rename_checks_current_authority_and_replays_before_stale_revision(tmp_path: Path) -> None: + commands, store, actor, _, _ = fixture(tmp_path / "database") + commands.setup("create", SetupHousehold("Home", "UTC")) + changed = commands.update("rename", "New name", "Europe/Madrid", 1) + assert commands.update("rename", "New name", "Europe/Madrid", 1) == changed + with pytest.raises(Conflict, match="revision_conflict"): + commands.update("other", "Different", "UTC", 1) + actor.human = replace(actor.human, is_administrator=False) + with pytest.raises(Forbidden): + commands.update("rename", "New name", "Europe/Madrid", 1) + with store.read() as unit: + assert unit.households.current().revision == 2 + assert unit.households.current().name == "New name" + + +def test_fresh_actor_outage_and_invalid_timezone_do_not_mutate_records(tmp_path: Path) -> None: + commands, store, actor, _, _ = fixture(tmp_path / "database") + with pytest.raises(InvalidInput, match="invalid_time_zone"): + commands.setup("create", SetupHousehold("Home", "Not/AZone")) + actor.error = IdentityUnavailable("ha_unavailable") + with pytest.raises(IdentityUnavailable): + commands.setup("create", SetupHousehold("Home", "UTC")) + with store.read() as unit: + assert unit.households.current() is None + + +@pytest.mark.parametrize( + "values", + [ + {"mapping_mode": "all_ha_administrators"}, + {"mapping_mode": MappingMode.ALL, "selected_ha_user_id": "ha_selected"}, + {"mapping_mode": MappingMode.SELECTED}, + {"mapping_mode": MappingMode.SELECTED, "selected_ha_user_id": "invalid/id"}, + {"mapping_mode": MappingMode.SETUP, "selected_ha_user_id": "ha_selected"}, + {"expected_revision": True}, + {"expected_revision": -1}, + {"expected_revision": 1}, + ], +) +def test_initial_configuration_rejects_ambiguous_mappings_and_nonzero_revisions( + values: dict, +) -> None: + with pytest.raises(InvalidInput): + SetupHousehold("Home", "UTC", **values) + + +@pytest.mark.parametrize("value", [True, -1, 1]) +def test_enrollment_requires_a_deliberate_initial_revision(tmp_path: Path, value: int) -> None: + commands, _, _, _, _ = fixture(tmp_path / "database") + with pytest.raises(InvalidInput, match="invalid_revision"): + commands.enroll("join", value) + + +@pytest.mark.parametrize( + "actor_id,action,authority_action", + [ + ("outsider", "household.setup", "household.setup"), + ("ha_admin", "member.enroll", "household.setup"), + ("ha_admin", "unknown", "unknown"), + ], +) +def test_setup_authority_cannot_be_reused_for_a_forged_actor_or_action( + tmp_path: Path, + actor_id: str, + action: str, + authority_action: str, +) -> None: + _, store, actor, _, identifiers = fixture(tmp_path / "database") + commands = AtomicCommands( + store, _InitialAuthority(actor.human, authority_action), Clock(), identifiers + ) + + def forbidden_effect(unit: object) -> None: + pytest.fail("Forged command context must not reach any effect") + + with pytest.raises(Forbidden): + commands.execute( + CommandKey("home", actor_id, action, "forged"), + JsonDocument.from_value({}), + forbidden_effect, + ) + with store.read() as unit: + assert unit.households.current() is None and unit.outbox.pending() == () + + +def test_other_household_scope_and_missing_policy_cannot_admit_enrollment(tmp_path: Path) -> None: + commands, store, actor, directory, identifiers = fixture(tmp_path / "database") + commands.setup("create", SetupHousehold("Home", "UTC")) + cross_scope = HouseholdCommands(store, actor, directory, Zones(), Clock(), identifiers, "other") + with pytest.raises(Forbidden): + cross_scope.enroll("join") + import sqlite3 + + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute("DELETE FROM household_policy") + with pytest.raises(Forbidden): + commands.enroll("join") + + +def test_preparing_a_selected_human_never_holds_the_sqlite_writer_lock( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + commands, store, _, directory, _ = fixture(tmp_path / "database") + original = directory.get + + def prove(user_id: str) -> HumanIdentity: + with store.write() as unit: + assert unit.households.current() is None + return original(user_id) + + monkeypatch.setattr(directory, "get", prove) + commands.setup("create", SetupHousehold("Home", "UTC", MappingMode.SELECTED, "ha_selected")) + + +def test_authorized_selected_setup_replay_does_not_depend_on_target_directory_availability( + tmp_path: Path, +) -> None: + commands, store, _, directory, _ = fixture(tmp_path / "database") + request = SetupHousehold("Home", "UTC", MappingMode.SELECTED, "ha_selected") + first = commands.setup("create", request) + # Fixture an independent explicit grant, separate from the selected-user mapping. + with store.write() as unit: + member = unit.members.by_user("home", "ha_admin") + unit.members.save(replace(member, role=Role.ADMINISTRATOR, revision=2), 1) + directory.error = IdentityUnavailable("ha_unavailable") + assert commands.setup("create", request) == first + assert directory.calls == ["ha_selected"] + + +def test_selected_member_storage_conflict_rolls_back_every_setup_record(tmp_path: Path) -> None: + commands, store, _, _, _ = fixture(tmp_path / "database") + + class CollidingIdentifiers: + def new(self) -> str: + return "collision" + + commands.identifiers = CollidingIdentifiers() + request = SetupHousehold("Home", "UTC", MappingMode.SELECTED, "ha_selected") + with pytest.raises(Conflict, match="member_exists"): + commands.setup("create", request) + with store.read() as unit: + assert unit.households.current() is None + assert unit.members.list("home") == () and unit.policies.get("home") is None + assert unit.audit.entries("home") == () and unit.outbox.pending() == () + commands.identifiers = Identifiers() + assert commands.setup("create", request).result.decode()["state"] == "configured" + + +def test_concurrent_explicit_setup_has_one_complete_winner(tmp_path: Path) -> None: + _, store, _, _, _ = fixture(tmp_path / "database") + gate = Barrier(2) + + class ConcurrentZones(Zones): + def require(self, time_zone: str) -> None: + super().require(time_zone) + gate.wait(timeout=3) + + def create(key: str) -> str: + command = HouseholdCommands( + store, Actor(), Directory(), ConcurrentZones(), Clock(), Identifiers() + ) + try: + command.setup(key, SetupHousehold("Home", "UTC")) + except Conflict, StorageBusy: + return "conflict" + return "configured" + + with ThreadPoolExecutor(max_workers=2) as workers: + assert sorted(workers.map(create, ("first", "second"))) == ["configured", "conflict"] + with store.read() as unit: + assert unit.households.current().revision == 1 + assert len(unit.members.list("home")) == 1 + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 1 + + +def test_enrollment_capacity_preserves_all_existing_actors_without_partial_evidence( + tmp_path: Path, +) -> None: + commands, store, actor, _, _ = fixture(tmp_path / "database") + commands.setup("create", SetupHousehold("Home", "UTC")) + with store.write() as unit: + for number in range(255): + unit.members.save( + Member(f"m{number}", "home", f"ha_{number}", Role.VIEWER, MemberStatus.PENDING, 1), + 0, + ) + actor.human = HumanIdentity("ha_new", False, True, False) + with pytest.raises(Conflict, match="member_capacity"): + commands.enroll("join") + with store.read() as unit: + assert len(unit.members.list("home")) == 256 + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 1 + + +def test_existing_active_member_enrollment_replay_and_invalid_updated_zone_are_safe( + tmp_path: Path, +) -> None: + commands, store, actor, _, _ = fixture(tmp_path / "database") + commands.setup("create", SetupHousehold("Home", "UTC")) + with pytest.raises(InvalidInput): + commands.update("invalid", "Home", "Not/AZone", 1) + actor.human = HumanIdentity("ha_ordinary", False, True, False) + enrolled = commands.enroll("join") + with store.write() as unit: + member = unit.members.by_user("home", "ha_ordinary") + unit.members.save(replace(member, status=MemberStatus.ACTIVE, revision=2), 1) + assert commands.enroll("join") == enrolled diff --git a/tests/backend/test_household_http.py b/tests/backend/test_household_http.py new file mode 100644 index 0000000..8105c8f --- /dev/null +++ b/tests/backend/test_household_http.py @@ -0,0 +1,448 @@ +"""Real ASGI, sessions and SQLite exercise command DTOs, idempotency and disclosure boundaries.""" + +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient +from test_auth_http import login +from test_household_commands import Directory, Identifiers, Zones +from test_standalone_sessions import fixture as identity_fixture + +from domus.application.authentication import Authentication +from domus.application.household_commands import HouseholdCommands +from domus.application.household_queries import HouseholdQueries +from domus.application.member_commands import MemberCommands +from domus.application.policy_commands import PolicyCommands +from domus.application.recovery_commands import RecoveryCommands, RecoveryQueries +from domus.application.sessions import SessionGuard +from domus.domain.errors import IdentityUnavailable +from domus.domain.identity import HumanIdentity +from domus.domain.storage import Installation +from domus.entrypoints.household_http import HouseholdHTTPContext +from domus.entrypoints.http import HTTPSettings, create_app +from domus.infrastructure.memory_attempts import MemoryLoginAttempts +from domus.infrastructure.sqlite import SQLiteDatabase + + +class Entropy: + count = 0 + + def new(self) -> str: + self.count += 1 + return "v" * 43 if self.count % 4 == 2 else f"{self.count:043d}" + + +def fixture(tmp_path: Path) -> tuple: + identity, oauth, socket, clock = identity_fixture() + socket.human = replace(socket.human, is_administrator=True) + flow = Authentication(MemoryLoginAttempts(), identity, clock, Entropy()) + settings = HTTPSettings("https://domus.example", "domus-edge", b"k" * 32) + guard = SessionGuard(identity, identity, clock) + store = SQLiteDatabase( + tmp_path / "database", Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge") + ) + store.initialize() + directory, identifiers = Directory(), Identifiers() + directory.people["b" * 32] = HumanIdentity("b" * 32, False, True, False) + + def context(session_id: str) -> HouseholdHTTPContext: + actor = SimpleNamespace(current=lambda: guard.write(session_id)) + return HouseholdHTTPContext( + lambda: HouseholdQueries(store, guard.read(session_id)), + HouseholdCommands(store, actor, directory, Zones(), clock, identifiers), + MemberCommands(store, actor, directory, clock, identifiers), + PolicyCommands(store, actor, directory, clock, identifiers), + RecoveryCommands(store, actor, store.installation, clock, identifiers), + lambda: RecoveryQueries(store, guard.read(session_id), store.installation, clock), + ) + + app = create_app( + flow, guard, identity, settings, correlation=lambda: "cor_api", household_context=context + ) + client = TestClient( + app, base_url=settings.origin, follow_redirects=False, raise_server_exceptions=False + ) + return client, store, socket, settings, directory, clock + + +def write_headers(client: TestClient, key: str = "command") -> dict[str, str]: + return { + "Origin": "https://domus.example", + "X-CSRF-Token": client.get("/api/v1/auth/csrf").json()["csrf_token"], + "Idempotency-Key": key, + } + + +def setup(client: TestClient) -> dict: + response = client.post( + "/api/v1/household", + json={"name": "Home", "time_zone": "UTC", "expected_revision": 0}, + headers=write_headers(client, "setup"), + ) + assert response.status_code == 200, response.text + return response.json() + + +def test_explicit_setup_authorization_queries_and_original_receipt(tmp_path: Path) -> None: + client, store, _, _, _, _ = fixture(tmp_path) + assert client.get("/api/v1/authorization").status_code == 401 + login(client) + assert client.get("/api/v1/authorization").json()["state"] == "unconfigured" + with store.read() as unit: + assert unit.households.current() is None + created = setup(client) + assert created["state"] == "committed" + assert created["result"]["state"] == "configured" + assert client.get("/api/v1/household").json()["name"] == "Home" + access = client.get("/api/v1/authorization").json() + assert access["state"] == "active" and access["effective_role"] == "administrator" + operation = client.get("/api/v1/operations/" + created["operation_id"]) + assert operation.status_code == 200 and operation.json()["delivery"] == "pending" + assert operation.json()["result"] == created["result"] + assert setup(client) == created + assert client.get("/api/v1/policy").json()["revision"] == 1 + assert len(client.get("/api/v1/members").json()["members"]) == 1 + + +def test_household_and_policy_edits_use_revisions_and_durable_idempotency(tmp_path: Path) -> None: + client, _, _, _, _, _ = fixture(tmp_path) + login(client) + setup(client) + headers = write_headers(client, "rename") + payload = {"name": "Renamed", "time_zone": "Europe/Madrid", "expected_revision": 1} + result = client.put("/api/v1/household", json=payload, headers=headers) + assert result.status_code == 200 + assert client.put("/api/v1/household", json=payload, headers=headers).json() == result.json() + assert ( + client.put( + "/api/v1/household", json=payload, headers=write_headers(client, "stale") + ).status_code + == 409 + ) + policy = client.put( + "/api/v1/policy", + json={ + "expected_revision": 1, + "mapping_mode": "setup_administrator", + "target_ha_user_id": "a" * 32, + "adjustments": [{"role": "viewer", "permission": "shopping.request", "allow": True}], + }, + headers=write_headers(client, "policy"), + ) + assert policy.status_code == 200, policy.text + assert client.get("/api/v1/policy").json()["mapping"]["mode"] == "setup_administrator" + + +def test_pending_enrollment_and_admin_activation_preserve_bound_actor(tmp_path: Path) -> None: + client, _, socket, _, _, _ = fixture(tmp_path) + login(client) + setup(client) + admin_human = socket.human + socket.human = HumanIdentity("b" * 32, False, True, False) + socket.human = admin_human + csrf = write_headers(client) + assert client.post("/api/v1/auth/logout", json={}, headers=csrf).status_code == 200 + socket.human = HumanIdentity("b" * 32, False, True, False) + login(client) + joined = client.post( + "/api/v1/members/enrollment", + json={"expected_revision": 0}, + headers=write_headers(client, "join"), + ) + assert joined.status_code == 200 and joined.json()["result"]["status"] == "pending" + member_id = joined.json()["result"]["member_id"] + assert client.get("/api/v1/household").status_code == 403 + assert client.get("/api/v1/authorization").json()["state"] == "pending" + csrf = write_headers(client) + assert client.post("/api/v1/auth/logout", json={}, headers=csrf).status_code == 200 + socket.human = admin_human + login(client) + changed = client.put( + "/api/v1/members/" + member_id, + json={ + "expected_revision": 1, + "role": "restricted", + "status": "active", + "exceptions": [{"permission": "shopping.request", "allow": False}], + }, + headers=write_headers(client, "activate"), + ) + assert changed.status_code == 200 and changed.json()["result"]["status"] == "active" + + +@pytest.mark.parametrize( + "body", + [ + {"name": "Home", "time_zone": "UTC", "expected_revision": 0, "actor_id": "private-forgery"}, + {"name": "Home", "time_zone": "UTC", "expected_revision": True}, + {"name": "Home", "time_zone": "Not/AZone", "expected_revision": 0}, + ], +) +def test_invalid_or_forged_setup_is_safe_and_leaves_no_records(tmp_path: Path, body: dict) -> None: + client, store, _, _, _, _ = fixture(tmp_path) + login(client) + response = client.post("/api/v1/household", json=body, headers=write_headers(client)) + assert response.status_code == 422 + assert "private-forgery" not in response.text + with store.read() as unit: + assert unit.households.current() is None and unit.outbox.pending() == () + + +def test_writes_require_origin_csrf_unique_idempotency_and_strict_empty_query( + tmp_path: Path, +) -> None: + client, store, _, _, _, _ = fixture(tmp_path) + login(client) + payload = {"name": "Home", "time_zone": "UTC", "expected_revision": 0} + assert client.post("/api/v1/household", json=payload).status_code == 403 + headers = write_headers(client) + assert ( + client.post( + "/api/v1/household", + json=payload, + headers={**headers, "Origin": "https://foreign.invalid"}, + ).status_code + == 403 + ) + assert ( + client.post( + "/api/v1/household", + json=payload, + headers={key: value for key, value in headers.items() if key != "Idempotency-Key"}, + ).status_code + == 422 + ) + duplicate = list(headers.items()) + [("Idempotency-Key", "different")] + assert client.post("/api/v1/household", json=payload, headers=duplicate).status_code == 422 + assert ( + client.post("/api/v1/household?actor=forged", json=payload, headers=headers).status_code + == 422 + ) + with store.read() as unit: + assert unit.households.current() is None + + +def test_ha_outage_allows_existing_local_read_until_expiry_but_blocks_writes( + tmp_path: Path, +) -> None: + client, _, socket, _, _, clock = fixture(tmp_path) + login(client) + setup(client) + headers = write_headers(client, "rename") + socket.error = IdentityUnavailable("ha_unavailable") + assert client.get("/api/v1/household").status_code == 200 + assert ( + client.put( + "/api/v1/household", + json={"name": "Changed", "time_zone": "UTC", "expected_revision": 1}, + headers=headers, + ).status_code + == 503 + ) + clock.value += 3_600_001 + assert client.get("/api/v1/household").status_code == 401 + + +def test_missing_operation_returns_safe_scoped_404_and_openapi_declares_all_routes( + tmp_path: Path, +) -> None: + client, _, _, settings, _, _ = fixture(tmp_path) + login(client) + setup(client) + assert client.get("/api/v1/operations/missing").status_code == 404 + schema = client.get("/api/v1/openapi.json").json() + assert { + "/api/v1/authorization", + "/api/v1/household", + "/api/v1/members", + "/api/v1/members/enrollment", + "/api/v1/members/{member_id}", + "/api/v1/policy", + "/api/v1/operations/{operation_id}", + } <= schema["paths"].keys() + for path in ("/api/v1/household", "/api/v1/members/{member_id}", "/api/v1/policy"): + for method, operation in schema["paths"][path].items(): + assert operation["security"] == [{"DomusSession": []}] + if method in ("post", "put"): + assert {"Idempotency-Key", "Origin", "X-CSRF-Token"} <= { + item["name"] for item in operation["parameters"] + } + assert ( + schema["components"]["securitySchemes"]["DomusSession"]["name"] == settings.session_cookie + ) + + +@pytest.mark.parametrize( + "body", + [ + b'{"name":"Home","name":"Changed","time_zone":"UTC","expected_revision":0}', + b'{"name":"Home","time_zone":"UTC","expected_revision":0,"invalid":NaN}', + b"[]", + b"{", + b'{"name":"\xff","time_zone":"UTC","expected_revision":0}', + ], +) +def test_ambiguous_malformed_or_nonobject_json_never_reaches_a_command( + tmp_path: Path, body: bytes +) -> None: + client, store, _, _, _, _ = fixture(tmp_path) + login(client) + response = client.post( + "/api/v1/household", + content=body, + headers={**write_headers(client), "Content-Type": "application/json"}, + ) + assert response.status_code == 422 and response.json()["code"] == "invalid_request" + with store.read() as unit: + assert unit.households.current() is None and unit.outbox.pending() == () + + +@pytest.mark.parametrize("media", ["text/plain", "application/json; charset=latin-1", "duplicate"]) +def test_unsupported_or_duplicate_media_type_cannot_bypass_json_contract( + tmp_path: Path, media: str +) -> None: + client, store, _, _, _, _ = fixture(tmp_path) + login(client) + headers = list(write_headers(client).items()) + [ + ("Content-Type", "application/json" if media == "duplicate" else media) + ] + if media == "duplicate": + headers.append(("Content-Type", "text/plain")) + response = client.post( + "/api/v1/household", + content='{"name":"Home","time_zone":"UTC","expected_revision":0}', + headers=headers, + ) + assert response.status_code == 422 + with store.read() as unit: + assert unit.households.current() is None + + +def test_busy_writer_returns_retry_guidance_without_new_key_or_partial_effect( + tmp_path: Path, +) -> None: + client, store, _, _, _, _ = fixture(tmp_path) + login(client) + headers = write_headers(client, "setup") + payload = {"name": "Home", "time_zone": "UTC", "expected_revision": 0} + with store.write(): + result = client.post("/api/v1/household", json=payload, headers=headers) + assert result.status_code == 503 and result.json()["code"] == "storage_busy" + assert result.headers["retry-after"] == "1" + assert client.post("/api/v1/household", json=payload, headers=headers).status_code == 200 + with store.read() as unit: + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 1 + + +def test_delivery_state_is_observed_and_reads_hide_other_member_private_fields( + tmp_path: Path, +) -> None: + client, store, socket, _, _, _ = fixture(tmp_path) + login(client) + created = setup(client) + with store.write() as unit: + unit.outbox.acknowledge(created["operation_id"]) + result = client.get("/api/v1/operations/" + created["operation_id"]) + assert result.status_code == 200 and result.json()["delivery"] == "delivered" + assert "private-access" not in result.text and "private-refresh" not in result.text + # Authenticated reentry through HA binds the new principal to a new local lease. + socket.human = HumanIdentity("b" * 32, False, True, False) + login(client) + joined = client.post( + "/api/v1/members/enrollment", + json={"expected_revision": 0}, + headers=write_headers(client, "join"), + ) + member_id = joined.json()["result"]["member_id"] + from dataclasses import replace + + from domus.domain.identity import MemberStatus + + with store.write() as unit: + member = unit.members.get("home", member_id) + unit.members.save(replace(member, status=MemberStatus.ACTIVE, revision=2), 1) + roster = client.get("/api/v1/members") + assert roster.status_code == 200 + other = next(member for member in roster.json()["members"] if member["member_id"] != member_id) + assert "ha_user_id" not in other and "exceptions" not in other + + +def test_last_administrator_cannot_be_removed_through_the_public_api(tmp_path: Path) -> None: + client, store, _, _, _, _ = fixture(tmp_path) + login(client) + created = setup(client) + member_id = created["result"]["member_id"] + response = client.put( + "/api/v1/members/" + member_id, + json={"expected_revision": 1, "role": "viewer", "status": "suspended"}, + headers=write_headers(client, "suspend"), + ) + assert response.status_code == 409 and response.json()["code"] == "last_administrator" + assert client.get("/api/v1/authorization").json()["state"] == "active" + with store.read() as unit: + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 1 + + +def test_ordinary_rule_cannot_grant_a_protected_administrator_permission(tmp_path: Path) -> None: + client, store, _, _, _, _ = fixture(tmp_path) + login(client) + created = setup(client) + member_id = created["result"]["member_id"] + response = client.put( + "/api/v1/members/" + member_id, + json={ + "expected_revision": 1, + "role": "viewer", + "status": "active", + "exceptions": [{"permission": "household.manage", "allow": True}], + }, + headers=write_headers(client, "exception"), + ) + assert response.status_code == 422 and response.json()["code"] == "protected_permission" + with store.read() as unit: + assert unit.members.get("home", member_id).revision == 1 + assert len(unit.audit.entries("home")) == 1 + + +def test_published_household_openapi_matches_the_composed_server(tmp_path: Path) -> None: + import json + + client, _, _, _, _, _ = fixture(tmp_path) + actual = client.get("/api/v1/openapi.json").json() + reference = json.loads( + ( + Path(__file__).resolve().parents[2] / "docs/contracts/household-api.openapi.json" + ).read_text() + ) + assert actual["paths"] == reference["paths"] + assert actual["components"] == reference["components"] + + +def test_response_failure_after_commit_preserves_uncertainty_and_same_key_recovery( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from domus.entrypoints import household_http + + client, store, _, _, _, _ = fixture(tmp_path) + login(client) + headers = write_headers(client, "setup") + payload = {"name": "Home", "time_zone": "UTC", "expected_revision": 0} + original = household_http._receipt + + def fail_after_commit(*_: object) -> None: + raise RuntimeError("private-serialization-detail") + + monkeypatch.setattr(household_http, "_receipt", fail_after_commit) + response = client.post("/api/v1/household", json=payload, headers=headers) + assert response.status_code == 500 and response.json()["code"] == "internal_error" + assert "could not confirm" in response.json()["message"] + assert "No household change was made" not in response.text + assert "same payload and Idempotency-Key" in response.json()["action_required"] + assert "private-serialization-detail" not in response.text + monkeypatch.setattr(household_http, "_receipt", original) + recovered = client.post("/api/v1/household", json=payload, headers=headers) + assert recovered.status_code == 200 and recovered.json()["result"]["state"] == "configured" + with store.read() as unit: + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 1 diff --git a/tests/backend/test_household_queries.py b/tests/backend/test_household_queries.py new file mode 100644 index 0000000..bdb7f2f --- /dev/null +++ b/tests/backend/test_household_queries.py @@ -0,0 +1,225 @@ +"""Current local authorization controls every read and discloses no unrelated private linkage.""" + +from dataclasses import replace +from pathlib import Path + +import pytest +from test_household_commands import fixture +from test_member_commands import configured + +from domus.application.household_queries import HouseholdQueries +from domus.domain.errors import Conflict, Forbidden, NotFound +from domus.domain.identity import HumanIdentity, MemberStatus, Permission, PermissionRule, Role + + +def test_unconfigured_access_never_initializes_or_enrolls_anyone(tmp_path: Path) -> None: + _, store, actor, _, _ = fixture(tmp_path / "database") + result = HouseholdQueries(store, actor.human).access() + assert result.state == "unconfigured" and result.setup_allowed + assert not result.enrollment_allowed and result.member is None + ordinary = HumanIdentity("ordinary", False, True, False) + assert not HouseholdQueries(store, ordinary).access().setup_allowed + with store.read() as unit: + assert unit.households.current() is None + assert unit.audit.entries("home") == unit.outbox.pending() == () + + +def test_pending_and_unlinked_people_can_read_only_their_own_access_state(tmp_path: Path) -> None: + _, store, _, directory, member_id = configured(tmp_path) + pending = HouseholdQueries(store, directory.people["ha_joiner"]) + result = pending.access() + assert result.state == "pending" and not result.enrollment_allowed + assert result.member.member_id == member_id + assert result.permissions == () + unlinked = HouseholdQueries(store, HumanIdentity("unlinked", False, True, False)).access() + assert unlinked.state == "not_enrolled" and unlinked.enrollment_allowed + assert unlinked.member is None and unlinked.policy_revision is None + for read in (pending.household, pending.members, pending.policy): + with pytest.raises(Forbidden): + read() + + +def test_admin_can_read_household_member_linkage_and_policy(tmp_path: Path) -> None: + _, store, actor, _, member_id = configured(tmp_path) + queries = HouseholdQueries(store, actor.human) + assert queries.household().name == "Home" + assert queries.policy().revision == 1 + result = queries.access() + assert result.state == "active" and not result.setup_allowed + assert result.member.role is Role.VIEWER and result.effective_role is Role.ADMINISTRATOR + assert Permission.MEMBERS_MANAGE in result.permissions + assert Permission.OPERATIONS_READ in result.permissions + assert ( + Permission.TASKS_COMPLETE not in result.permissions + ) # This view is the M1 administration scope. + assert ( + next(member for member in queries.members() if member.member_id == member_id).ha_user_id + == "ha_joiner" + ) + + +def test_viewer_roster_hides_others_ha_linkage_and_exceptions(tmp_path: Path) -> None: + commands, store, _, directory, member_id = configured(tmp_path) + with store.write() as unit: + pending = unit.members.get("home", member_id) + unit.members.save(replace(pending, status=MemberStatus.ACTIVE, revision=2), 1) + admin = unit.members.by_user("home", "ha_admin") + unit.members.save( + replace( + admin, exceptions=(PermissionRule(Permission.SHOPPING_REQUEST, False),), revision=2 + ), + 1, + ) + queries = HouseholdQueries(store, directory.people["ha_joiner"]) + roster = queries.members() + own = next(member for member in roster if member.member_id == member_id) + other = next(member for member in roster if member.member_id != member_id) + assert own.ha_user_id == "ha_joiner" and own.exceptions == () + assert other.ha_user_id is None and other.exceptions is None + assert queries.household().household_id == "home" + with pytest.raises(Forbidden): + queries.policy() + assert commands.household_id == "home" + + +def test_suspension_and_permission_revocation_apply_without_rebuilding_query_service( + tmp_path: Path, +) -> None: + _, store, actor, _, _ = configured(tmp_path) + queries = HouseholdQueries(store, actor.human) + assert queries.household().name == "Home" + with store.write() as unit: + member = unit.members.by_user("home", "ha_admin") + unit.members.save( + replace( + member, exceptions=(PermissionRule(Permission.HOUSEHOLD_READ, False),), revision=2 + ), + 1, + ) + with pytest.raises(Forbidden): + queries.household() + with store.write() as unit: + member = unit.members.by_user("home", "ha_admin") + unit.members.save(replace(member, status=MemberStatus.SUSPENDED, revision=3), 2) + assert queries.access().state == "suspended" + with pytest.raises(Forbidden): + queries.members() + + +def test_operation_query_requires_current_original_permission_and_own_actor(tmp_path: Path) -> None: + _, store, actor, _, member_id = configured(tmp_path) + queries = HouseholdQueries(store, actor.human) + with store.read() as unit: + setup = unit.audit.entries("home")[0] + receipt = queries.operation(setup.operation_id) + assert receipt.receipt.command.action == "household.setup" and not receipt.delivered + with store.write() as unit: + unit.outbox.acknowledge(setup.operation_id) + assert queries.operation(setup.operation_id).delivered + ordinary = HouseholdQueries(store, HumanIdentity("ha_joiner", False, True, False)) + with pytest.raises(Forbidden): + ordinary.operation(setup.operation_id) + with store.write() as unit: + member = unit.members.get("home", member_id) + unit.members.save(replace(member, status=MemberStatus.ACTIVE, revision=2), 1) + with pytest.raises(NotFound): + ordinary.operation(setup.operation_id) + actor.human = replace(actor.human, is_administrator=False) + with pytest.raises(Forbidden): + HouseholdQueries(store, actor.human).operation(setup.operation_id) + + +def test_configured_household_without_policy_fails_closed(tmp_path: Path) -> None: + import sqlite3 + from contextlib import closing + + _, store, actor, _, _ = configured(tmp_path) + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute("DELETE FROM household_policy") + with pytest.raises(Conflict, match="household_configuration_required"): + HouseholdQueries(store, actor.human).access() + + +def test_own_enrollment_result_remains_original_after_activation(tmp_path: Path) -> None: + _, store, _, directory, member_id = configured(tmp_path) + with store.read() as unit: + enrollment = unit.audit.entries("home")[1] + with store.write() as unit: + member = unit.members.get("home", member_id) + unit.members.save(replace(member, status=MemberStatus.ACTIVE, revision=2), 1) + queries = HouseholdQueries(store, directory.people["ha_joiner"]) + result = queries.operation(enrollment.operation_id) + assert result.receipt.result.decode()["status"] == "pending" + assert queries.access().state == "active" + + +def test_cross_household_access_fails_without_disclosing_state(tmp_path: Path) -> None: + _, store, actor, _, _ = configured(tmp_path) + with pytest.raises(Forbidden): + HouseholdQueries(store, actor.human, "other").access() + + +def test_corrupt_overflow_roster_never_leaks_a_partial_list(tmp_path: Path) -> None: + from domus.domain.errors import StorageFailure + + _, store, actor, _, member_id = configured(tmp_path) + with store.write() as unit: + member = unit.members.get("home", member_id) + for number in range(255): + unit.members.save( + replace(member, member_id=f"overflow{number}", ha_user_id=f"ha_{number}"), 0 + ) + with pytest.raises(StorageFailure): + HouseholdQueries(store, actor.human).members() + + +def test_operation_query_does_not_authorize_unregistered_command_actions(tmp_path: Path) -> None: + import sqlite3 + from contextlib import closing + + _, store, actor, _, _ = configured(tmp_path) + with store.read() as unit: + operation = unit.audit.entries("home")[0].operation_id + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute( + "UPDATE receipts SET action='unknown.command' WHERE operation_id=?", (operation,) + ) + with pytest.raises(Forbidden): + HouseholdQueries(store, actor.human).operation(operation) + + +@pytest.mark.parametrize("damage", ["missing-event", "invalid-delivery", "missing-household"]) +def test_missing_or_corrupt_atomic_records_are_not_reported_as_success( + tmp_path: Path, damage: str +) -> None: + import sqlite3 + from contextlib import closing + + from domus.domain.errors import StorageFailure + + _, store, actor, _, _ = configured(tmp_path) + with store.read() as unit: + operation = unit.audit.entries("home")[0].operation_id + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + if damage == "missing-event": + connection.execute("DELETE FROM outbox WHERE event_id=?", (operation,)) + elif damage == "invalid-delivery": + connection.execute("PRAGMA ignore_check_constraints=ON") + connection.execute( + "UPDATE outbox SET delivered='invalid' WHERE event_id=?", (operation,) + ) + else: + connection.execute("DELETE FROM households") + queries = HouseholdQueries(store, actor.human) + with pytest.raises(StorageFailure): + if damage == "missing-household": + queries.household() + else: + queries.operation(operation) + + +def test_an_unlinked_human_cannot_probe_operation_existence(tmp_path: Path) -> None: + _, store, _, _, _ = configured(tmp_path) + queries = HouseholdQueries(store, HumanIdentity("unlinked", False, True, False)) + with pytest.raises(Forbidden): + queries.operation("unknown-operation") diff --git a/tests/backend/test_household_runtime.py b/tests/backend/test_household_runtime.py new file mode 100644 index 0000000..5496ba0 --- /dev/null +++ b/tests/backend/test_household_runtime.py @@ -0,0 +1,345 @@ +"""Standalone composition opens owned SQLite and retains household state across restart.""" + +from contextlib import closing +from dataclasses import replace +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from test_auth_server import INSTANCE +from test_standalone_sessions import fixture as identity_fixture + +from domus.composition import server +from domus.domain.errors import DatabaseRejected, InvalidInput, StorageBusy +from domus.infrastructure.owned_storage import attach_storage, open_storage + + +def runtime_at( + path: Path, monkeypatch: pytest.MonkeyPatch, *, channel: str = "edge", owned: bool = False +) -> tuple: + _, oauth, socket, clock = identity_fixture() + socket.human = replace(socket.human, is_administrator=True) + monkeypatch.setattr(server, "HomeAssistantOAuth", lambda _: oauth) + monkeypatch.setattr(server, "HomeAssistantWebSocket", lambda _: socket) + monkeypatch.setattr(server, "MonotonicClock", lambda: clock) + if owned: + runtime = server.create_owned_runtime( + "http://ha.local", "https://domus.example", channel, path + ) + else: + runtime = server.create_runtime( + "http://ha.local", "https://domus.example", INSTANCE, channel, database=path + ) + cookie = runtime.app.openapi()["components"]["securitySchemes"]["DomusSession"]["name"] + runtime.identity.sign_in("code", "v" * 43, "test-session", 100, 3_600_100) + client = TestClient(runtime.app, base_url="https://domus.example", follow_redirects=False) + client.cookies.set(cookie, "test-session") + return runtime, client + + +def headers(client: TestClient, key: str) -> dict[str, str]: + return { + "Origin": "https://domus.example", + "X-CSRF-Token": client.get("/api/v1/auth/csrf").json()["csrf_token"], + "Idempotency-Key": key, + } + + +def test_actual_runtime_wires_all_household_services_without_implicit_setup( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime, client = runtime_at(tmp_path / "domus.sqlite3", monkeypatch) + try: + assert client.get("/api/v1/authorization").json()["state"] == "unconfigured" + assert ( + client.post( + "/api/v1/household", + json={"name": "Home", "time_zone": "UTC", "expected_revision": 0}, + headers=headers(client, "setup"), + ).status_code + == 200 + ) + assert ( + client.put( + "/api/v1/household", + json={"name": "Updated", "time_zone": "Europe/Madrid", "expected_revision": 1}, + headers=headers(client, "rename"), + ).status_code + == 200 + ) + member = client.get("/api/v1/members").json()["members"][0] + assert ( + client.put( + "/api/v1/members/" + member["member_id"], + json={"expected_revision": 1, "role": "viewer", "status": "active"}, + headers=headers(client, "member"), + ).status_code + == 200 + ) + assert ( + client.put( + "/api/v1/policy", + json={ + "expected_revision": 1, + "mapping_mode": "setup_administrator", + "target_ha_user_id": "a" * 32, + }, + headers=headers(client, "policy"), + ).status_code + == 200 + ) + assert client.get("/api/v1/household").json()["name"] == "Updated" + finally: + assert runtime.close() == (1, 0) + + +def test_restart_reopens_same_household_and_scoped_receipt( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "domus.sqlite3" + runtime, client = runtime_at(path, monkeypatch) + payload = {"name": "Home", "time_zone": "UTC", "expected_revision": 0} + try: + original = client.post( + "/api/v1/household", json=payload, headers=headers(client, "setup") + ).json() + finally: + runtime.close() + restarted, client = runtime_at(path, monkeypatch) + try: + assert client.get("/api/v1/authorization").json()["state"] == "active" + assert ( + client.post("/api/v1/household", json=payload, headers=headers(client, "setup")).json() + == original + ) + finally: + restarted.close() + + +def test_same_database_cannot_be_adopted_by_the_other_channel( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "domus.sqlite3" + runtime, _ = runtime_at(path, monkeypatch) + runtime.close() + before = path.read_bytes() + with pytest.raises(DatabaseRejected): + runtime_at(path, monkeypatch, channel="stable") + assert path.read_bytes() == before + + +def test_cli_database_option_activates_household_routes_without_starting_an_unreviewed_port( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from test_auth_server import arguments + + captured = [] + + def run(app: object, **options: object) -> None: + captured.append(app.openapi()) + assert options["proxy_headers"] is False and options["host"] == "127.0.0.1" + + monkeypatch.setattr(server.uvicorn, "run", run) + assert server.main(arguments() + ["--database", str(tmp_path / "domus.sqlite3")]) == 0 + assert "/api/v1/household" in captured[0]["paths"] + assert (tmp_path / "domus.sqlite3").exists() + + +def test_owned_standalone_restart_preserves_identity_household_and_replay_not_session( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "owned" + root.mkdir(mode=0o700) + first, client = runtime_at(root, monkeypatch, owned=True) + identity = first.storage.installation + payload = {"name": "Home", "time_zone": "UTC", "expected_revision": 0} + try: + assert client.get("/api/v1/authorization").json()["state"] == "unconfigured" + original = client.post( + "/api/v1/household", json=payload, headers=headers(client, "setup") + ).json() + with pytest.raises(StorageBusy): + server.create_owned_runtime("http://ha.local", "https://domus.example", "edge", root) + with closing(attach_storage(root, "edge")) as attached: + assert attached.installation == identity + finally: + assert first.close() == (1, 0) + second = server.create_owned_runtime("http://ha.local", "https://domus.example", "edge", root) + try: + assert second.storage.installation == identity + assert ( + TestClient(second.app, base_url="https://domus.example") + .get("/api/v1/session") + .status_code + == 401 + ) + finally: + second.close() + third, client = runtime_at(root, monkeypatch, owned=True) + try: + assert third.storage.installation == identity + assert client.get("/api/v1/household").json()["name"] == "Home" + assert ( + client.post("/api/v1/household", json=payload, headers=headers(client, "setup")).json() + == original + ) + finally: + third.close() + + +@pytest.mark.parametrize( + "ha,public,channel", + [ + ("http://ha.local/path", "https://domus.example", "edge"), + ("http://ha.local", "https://domus.example/path", "edge"), + ("http://ha.local", "http://127.0.0.1:8099", "edge"), + ("http://ha.local", "https://domus.example", "other"), + ], +) +def test_owned_standalone_rejects_configuration_before_storage_mutation( + tmp_path: Path, ha: str, public: str, channel: str +) -> None: + root = tmp_path / "owned" + root.mkdir(mode=0o700) + with pytest.raises(InvalidInput): + server.create_owned_runtime(ha, public, channel, root) + assert list(root.iterdir()) == [] + + +@pytest.mark.parametrize("error", [RuntimeError, KeyboardInterrupt]) +def test_owned_standalone_releases_storage_on_late_composition_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, error: type[BaseException] +) -> None: + root = tmp_path / "owned" + root.mkdir(mode=0o700) + + def fail(*args): + raise error("injected composition failure") + + monkeypatch.setattr(server, "_compose", fail) + with pytest.raises(error, match="injected composition failure"): + server.create_owned_runtime("http://ha.local", "https://domus.example", "edge", root) + open_storage(root, "edge").close() + + +def test_owned_standalone_releases_lease_even_when_credential_shutdown_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "owned" + root.mkdir(mode=0o700) + runtime = server.create_owned_runtime("http://ha.local", "https://domus.example", "edge", root) + + def fail(): + raise RuntimeError("injected shutdown failure") + + monkeypatch.setattr(runtime.identity, "shutdown", fail) + with pytest.raises(RuntimeError, match="injected shutdown failure"): + runtime.close() + open_storage(root, "edge").close() + + +def test_owned_standalone_initializes_once_and_keeps_channel_and_cookie_names_separate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from domus.infrastructure.sqlite import SQLiteDatabase + + initialize = SQLiteDatabase.initialize + modes = [] + + def capture(self, *, require_existing=False): + modes.append(require_existing) + return initialize(self, require_existing=require_existing) + + monkeypatch.setattr(SQLiteDatabase, "initialize", capture) + identities, names = [], [] + for channel in ("edge", "stable"): + root = tmp_path / channel + root.mkdir(mode=0o700) + for _ in range(2): + runtime = server.create_owned_runtime( + "http://ha.local", "https://domus.example", channel, root + ) + identities.append(runtime.storage.installation) + names.append( + runtime.app.openapi()["components"]["securitySchemes"]["DomusSession"]["name"] + ) + runtime.close() + assert modes == [False, True, False, True] + assert identities[0] == identities[1] and identities[2] == identities[3] + assert identities[0].instance_id != identities[2].instance_id + assert names[0] == names[1] and names[2] == names[3] and names[0] != names[2] + before = (tmp_path / "edge" / "domus.sqlite3").read_bytes() + with pytest.raises(DatabaseRejected, match="installation_channel_mismatch"): + server.create_owned_runtime( + "http://ha.local", "https://domus.example", "stable", tmp_path / "edge" + ) + assert (tmp_path / "edge" / "domus.sqlite3").read_bytes() == before + + +def owned_arguments(root: Path) -> list[str]: + return [ + "--ha-url", + "http://ha.local", + "--public-origin", + "http://127.0.0.1:8099", + "--channel", + "edge", + "--development-http", + "--data-directory", + str(root), + ] + + +@pytest.mark.parametrize("failure", [False, True]) +def test_owned_standalone_cli_holds_storage_through_listener_and_releases_on_exit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: bool +) -> None: + root = tmp_path / "owned" + root.mkdir(mode=0o700) + ran = [] + + def run(app, **options): + with pytest.raises(StorageBusy): + open_storage(root, "edge") + assert "/api/v1/household" in app.openapi()["paths"] + assert options["host"] == "127.0.0.1" and options["proxy_headers"] is False + ran.append(True) + if failure: + raise RuntimeError("injected listener failure") + + monkeypatch.setattr(server.uvicorn, "run", run) + if failure: + with pytest.raises(RuntimeError, match="injected listener failure"): + server.main(owned_arguments(root)) + else: + assert server.main(owned_arguments(root)) == 0 + assert ran == [True] + open_storage(root, "edge").close() + + +@pytest.mark.parametrize( + "mode", ["preview_missing_id", "file_missing_id", "owned_with_id", "owned_with_empty_id"] +) +def test_standalone_cli_refuses_missing_or_competing_identity_before_storage( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mode: str +) -> None: + root = tmp_path / "owned" + root.mkdir(mode=0o700) + options = owned_arguments(root) + if mode.startswith("owned_with"): + options += ["--instance-id", "" if mode == "owned_with_empty_id" else INSTANCE] + else: + options = options[:-2] + if mode == "file_missing_id": + options += ["--database", str(root / "domus.sqlite3")] + monkeypatch.setattr( + server.uvicorn, "run", lambda *args, **kwargs: pytest.fail("listener started") + ) + assert server.main(options) == 2 + assert list(root.iterdir()) == [] + + +def test_standalone_cli_rejects_both_data_selections_at_parser_boundary(tmp_path: Path) -> None: + with pytest.raises(SystemExit) as error: + server.main(owned_arguments(tmp_path) + ["--database", str(tmp_path / "domus.sqlite3")]) + assert error.value.code == 2 diff --git a/tests/backend/test_identity_policy.py b/tests/backend/test_identity_policy.py new file mode 100644 index 0000000..65181af --- /dev/null +++ b/tests/backend/test_identity_policy.py @@ -0,0 +1,301 @@ +"""Identity policy enforces current sources, explicit scope and permission precedence.""" + +from dataclasses import FrozenInstanceError, replace + +import pytest + +from domus.domain.errors import Conflict, InvalidInput +from domus.domain.identity import ( + AdministratorMapping, + HouseholdPolicy, + HumanIdentity, + MappingMode, + Member, + MemberStatus, + Permission, + PermissionRule, + ProfileRule, + Role, + allows, + effective_role, + ensure_administrator, + recovery_allowed, +) + + +def human(user_id: str = "ha_1", *, administrator: bool = False) -> HumanIdentity: + return HumanIdentity(user_id, administrator, True, False) + + +def member(role: Role = Role.VIEWER, **changes: object) -> Member: + return replace(Member("member_1", "home", "ha_1", role, MemberStatus.ACTIVE, 1), **changes) + + +def policy(**changes: object) -> HouseholdPolicy: + return replace(HouseholdPolicy("home", AdministratorMapping(MappingMode.ALL), 1), **changes) + + +@pytest.mark.parametrize( + "flags", + [ + (False, False, False), + (False, True, True), + (1, True, False), + (False, "yes", False), + (False, True, 0), + ], +) +def test_only_exact_boolean_active_nonsystem_human_is_eligible(flags: tuple[object, ...]) -> None: + with pytest.raises(InvalidInput, match="invalid_human_identity"): + HumanIdentity("ha_1", *flags) + + +def test_identity_is_exact_stable_identifier_and_immutable() -> None: + first = human() + assert first.ha_user_id == "ha_1" + with pytest.raises(FrozenInstanceError): + first.is_administrator = True + with pytest.raises(InvalidInput, match="invalid_identifier"): + human("invalid user") + + +@pytest.mark.parametrize("mode", [MappingMode.SETUP, MappingMode.SELECTED]) +def test_designated_modes_require_an_explicit_target(mode: MappingMode) -> None: + with pytest.raises(InvalidInput, match="invalid_administrator_mapping"): + AdministratorMapping(mode) + assert AdministratorMapping(mode, "ha_1").target_ha_user_id == "ha_1" + + +@pytest.mark.parametrize( + "mapping", + [(MappingMode.ALL, "ha_1"), ("all_ha_administrators", None), (MappingMode.SELECTED, "")], +) +def test_invalid_mapping_cannot_silently_widen(mapping: tuple[object, object]) -> None: + with pytest.raises(InvalidInput): + AdministratorMapping(*mapping) + + +def test_all_administrators_mapping_uses_current_proof_and_retains_explicit_grants() -> None: + assert effective_role(human(administrator=True), member(), policy()) == Role.ADMINISTRATOR + assert effective_role(human(), member(), policy()) == Role.VIEWER + assert effective_role(human(), member(Role.ADMINISTRATOR), policy()) == Role.ADMINISTRATOR + selected_elsewhere = policy(mapping=AdministratorMapping(MappingMode.SELECTED, "ha_2")) + assert effective_role(human(administrator=True), member(), selected_elsewhere) == Role.VIEWER + assert ( + effective_role(human(), member(Role.ADMINISTRATOR), selected_elsewhere) + == Role.ADMINISTRATOR + ) + + +def test_setup_mapping_requires_the_designated_current_ha_administrator() -> None: + selected = policy(mapping=AdministratorMapping(MappingMode.SETUP, "ha_1")) + assert effective_role(human(administrator=True), member(), selected) == Role.ADMINISTRATOR + assert effective_role(human(), member(), selected) == Role.VIEWER + other = member(ha_user_id="ha_2") + assert effective_role(human("ha_2", administrator=True), other, selected) == Role.VIEWER + + +def test_selected_mapping_does_not_require_selected_human_to_be_ha_administrator() -> None: + selected = policy(mapping=AdministratorMapping(MappingMode.SELECTED, "ha_1")) + assert effective_role(human(), member(), selected) == Role.ADMINISTRATOR + assert effective_role(human(), member(), policy()) == Role.VIEWER + + +@pytest.mark.parametrize( + "state", [MemberStatus.PENDING, MemberStatus.SUSPENDED, MemberStatus.ARCHIVED] +) +def test_inactive_local_state_denies_even_independent_and_inherited_administration( + state: MemberStatus, +) -> None: + actor = member(Role.ADMINISTRATOR, status=state) + assert effective_role(human(administrator=True), actor, policy()) is None + assert not allows(human(administrator=True), actor, policy(), Permission.HOUSEHOLD_READ, "home") + + +def test_wrong_identity_and_cross_household_never_resolve_a_role() -> None: + assert effective_role(human("ha_2"), member(Role.ADMINISTRATOR), policy()) is None + assert effective_role(human(), member(Role.ADMINISTRATOR), policy(household_id="other")) is None + assert not allows( + human(), member(Role.ADMINISTRATOR), policy(), Permission.MEMBERS_MANAGE, "other" + ) + + +@pytest.mark.parametrize("role", list(Role)) +@pytest.mark.parametrize("permission", list(Permission)) +def test_default_profile_matrix(role: Role, permission: Permission) -> None: + # Independent owner-approved baseline, not the production DEFAULTS table. + expected = { + Role.ADMINISTRATOR: { + "household.read", + "household.manage", + "members.read", + "members.manage", + "policy.manage", + "preferences.read", + "preferences.write", + "operations.read", + "tasks.create", + "tasks.complete", + "tasks.approve", + "tasks.configure", + "shopping.request", + "shopping.manage", + "inventory.manage", + "automations.configure", + "points.manage", + }, + Role.ADULT: { + "household.read", + "members.read", + "preferences.read", + "preferences.write", + "operations.read", + "tasks.create", + "tasks.complete", + "tasks.approve", + "shopping.request", + "shopping.manage", + "inventory.manage", + }, + Role.RESTRICTED: { + "household.read", + "members.read", + "preferences.read", + "preferences.write", + "operations.read", + "tasks.complete", + "shopping.request", + }, + Role.VIEWER: { + "household.read", + "members.read", + "preferences.read", + "preferences.write", + "operations.read", + }, + } + assert allows( + human(), + member(role), + policy(), + permission, + "home", + subject_member_id="member_1", + assigned_to=("member_1",), + ) is (permission.value in expected[role]) + + +def test_restricted_work_is_assigned_to_actual_member_and_cannot_skip_adult_approval() -> None: + actor = member(Role.RESTRICTED, exceptions=(PermissionRule(Permission.TASKS_APPROVE, True),)) + assert not allows(human(), actor, policy(), Permission.TASKS_COMPLETE, "home") + assert allows( + human(), actor, policy(), Permission.TASKS_COMPLETE, "home", assigned_to=("member_1",) + ) + assert not allows(human(), actor, policy(), Permission.TASKS_APPROVE, "home") + viewer = member(exceptions=(PermissionRule(Permission.TASKS_APPROVE, True),)) + assert not allows(human(), viewer, policy(), Permission.TASKS_APPROVE, "home") + + +@pytest.mark.parametrize("role", list(Role)) +@pytest.mark.parametrize( + "permission", + [Permission.PREFERENCES_READ, Permission.PREFERENCES_WRITE, Permission.OPERATIONS_READ], +) +def test_own_scoped_actions_require_explicit_self_even_for_administrator( + role: Role, + permission: Permission, +) -> None: + actor = member(role) + assert allows(human(), actor, policy(), permission, "home", subject_member_id="member_1") + assert not allows(human(), actor, policy(), permission, "home", subject_member_id="member_2") + assert not allows(human(), actor, policy(), permission, "home") + + +def test_member_then_profile_then_default_precedence_and_undefined_denial() -> None: + denied_profile = policy(adjustments=(ProfileRule(Role.ADULT, Permission.TASKS_CREATE, False),)) + ordinary = member(Role.ADULT) + assert not allows(human(), ordinary, denied_profile, Permission.TASKS_CREATE, "home") + allowed_member = replace(ordinary, exceptions=(PermissionRule(Permission.TASKS_CREATE, True),)) + assert allows(human(), allowed_member, denied_profile, Permission.TASKS_CREATE, "home") + allowed_profile = policy(adjustments=(ProfileRule(Role.VIEWER, Permission.TASKS_CREATE, True),)) + denied_member = member(exceptions=(PermissionRule(Permission.TASKS_CREATE, False),)) + assert allows(human(), member(), allowed_profile, Permission.TASKS_CREATE, "home") + assert not allows(human(), denied_member, allowed_profile, Permission.TASKS_CREATE, "home") + assert not allows(human(), member(Role.ADMINISTRATOR), policy(), "unknown.action", "home") + unrelated = policy( + adjustments=( + ProfileRule(Role.VIEWER, Permission.TASKS_CREATE, True), + ProfileRule(Role.ADULT, Permission.TASKS_APPROVE, False), + ) + ) + assert allows(human(), member(Role.ADULT), unrelated, Permission.TASKS_CREATE, "home") + + +def test_profile_overrides_do_not_create_administrator_authority() -> None: + with pytest.raises(InvalidInput, match="protected_permission"): + PermissionRule(Permission.MEMBERS_MANAGE, True) + with pytest.raises(InvalidInput, match="protected_permission"): + ProfileRule(Role.VIEWER, Permission.POLICY_MANAGE, True) + denied_admin = member( + Role.ADMINISTRATOR, exceptions=(PermissionRule(Permission.TASKS_CREATE, False),) + ) + assert not allows(human(), denied_admin, policy(), Permission.TASKS_CREATE, "home") + + +@pytest.mark.parametrize("revision", [0, -1, True, 2**63]) +def test_invalid_revisions_are_rejected(revision: object) -> None: + with pytest.raises(InvalidInput, match="invalid_revision"): + member(revision=revision) + with pytest.raises(InvalidInput, match="invalid_revision"): + policy(revision=revision) + + +def test_invalid_enums_rules_mutable_lists_and_same_level_duplicates_are_rejected() -> None: + with pytest.raises(InvalidInput, match="invalid_member"): + member(role="viewer") + with pytest.raises(InvalidInput, match="invalid_member"): + member(status="active") + with pytest.raises(InvalidInput, match="invalid_permission_rule"): + PermissionRule("tasks.create", True) + with pytest.raises(InvalidInput, match="invalid_permission_rule"): + PermissionRule(Permission.TASKS_CREATE, 1) + with pytest.raises(InvalidInput, match="invalid_profile_rule"): + ProfileRule("adult", Permission.TASKS_CREATE, True) + with pytest.raises(InvalidInput, match="invalid_policy"): + policy(mapping="all") + allow, deny = ( + PermissionRule(Permission.TASKS_CREATE, True), + PermissionRule(Permission.TASKS_CREATE, False), + ) + for rules in ([allow], ("wrong",), (allow, deny), (allow, allow), (allow,) * 257): + with pytest.raises(InvalidInput, match="invalid_permission_rules"): + member(exceptions=rules) + rule = ProfileRule(Role.ADULT, Permission.TASKS_CREATE, True) + for rules in ([rule], ("wrong",), (rule, rule), (rule,) * 257): + with pytest.raises(InvalidInput, match="invalid_profile_rules"): + policy(adjustments=rules) + + +def test_last_administrator_requires_an_active_member_with_current_identity_evidence() -> None: + admin = member(Role.ADMINISTRATOR) + ensure_administrator(policy(), (admin,), (human(),)) + inherited = member() + ensure_administrator(policy(), (inherited,), (human(administrator=True),)) + for records, identities in [ + ((inherited,), (human(),)), + ((admin,), ()), + ((replace(admin, status=MemberStatus.SUSPENDED),), (human(),)), + ((), (human(administrator=True),)), + ]: + with pytest.raises(Conflict, match="last_administrator"): + ensure_administrator(policy(), records, identities) + + +def test_recovery_is_narrowly_available_only_to_current_ha_administrator_in_alternate_modes() -> ( + None +): + assert not recovery_allowed(human(administrator=True), policy()) + for mode in (MappingMode.SETUP, MappingMode.SELECTED): + alternate = policy(mapping=AdministratorMapping(mode, "ha_2")) + assert recovery_allowed(human(administrator=True), alternate) + assert not recovery_allowed(human(), alternate) diff --git a/tests/backend/test_ingress_http.py b/tests/backend/test_ingress_http.py new file mode 100644 index 0000000..a116977 --- /dev/null +++ b/tests/backend/test_ingress_http.py @@ -0,0 +1,179 @@ +"""Native HA entry proves the actual peer and exact human without another sign-in.""" + +import pytest +from fastapi.testclient import TestClient + +from domus.domain.errors import ( + DomusError, + Forbidden, + IdentityRejected, + IdentityUnavailable, + InvalidInput, +) +from domus.domain.identity import HumanIdentity +from domus.entrypoints.ingress_http import create_ingress_app +from domus.infrastructure.ha_config import HAEndpoint +from domus.infrastructure.ha_ingress import SupervisorIdentity + +SUBJECT = "a" * 32 +PREFIX = "/api/hassio_ingress/" + "b" * 32 + + +class Lookup: + endpoint = HAEndpoint.supervisor() + + def __init__(self) -> None: + self.calls = [] + self.error = None + + def directory_user(self, token: str, subject: str) -> HumanIdentity: + assert token == "private-supervisor-token" + self.calls.append(subject) + if self.error: + raise self.error + return HumanIdentity(subject, False, True, False) + + +def fixture(peer: str | None = "172.30.32.2", channel: str = "edge") -> tuple: + lookup = Lookup() + identity = SupervisorIdentity(lookup, "private-supervisor-token") + app = create_ingress_app(identity.prove, channel, correlation=lambda: "cor_fixture") + return ( + TestClient( + app, + client=(peer, 12000) if peer is not None else None, + headers={"X-Ingress-Path": PREFIX, "X-Remote-User-Id": SUBJECT}, + raise_server_exceptions=False, + ), + lookup, + ) + + +@pytest.mark.parametrize("channel", ["stable", "edge"]) +def test_native_home_uses_ha_identity_without_oauth_cookie_or_external_assets(channel: str) -> None: + client, lookup = fixture(channel=channel) + response = client.get("/") + assert response.status_code == 200 + assert f"Domus {channel.title()}" in response.text + assert "No additional login is required" in response.text + assert "Household setup is not available" in response.text + assert 'href="./"' in response.text and '' in response.text + assert SUBJECT not in response.text and "private-supervisor-token" not in response.text + assert "set-cookie" not in response.headers and client.cookies == {} + assert response.headers["cache-control"] == "no-store" + assert response.headers["referrer-policy"] == "no-referrer" + assert "default-src 'none'" in response.headers["content-security-policy"] + assert lookup.calls == [SUBJECT] + + +def test_identity_endpoint_requires_fresh_lookup_each_time_without_technical_user_promotion() -> ( + None +): + client, lookup = fixture() + for _ in range(2): + response = client.get("/api/v1/identity") + assert response.status_code == 200 + assert response.json()["ha_user_id"] == SUBJECT + assert response.json()["ha_administrator"] is False + assert response.json()["source"] == "home_assistant_ingress" + assert "token" not in response.text and "session_id" not in response.text + assert lookup.calls == [SUBJECT, SUBJECT] + + +@pytest.mark.parametrize("peer", ["127.0.0.1", "172.30.33.4", "192.168.1.2"]) +def test_forged_source_headers_do_not_override_actual_peer(peer: str) -> None: + client, lookup = fixture(peer) + response = client.get("/", headers={"X-Forwarded-For": "172.30.32.2"}) + assert response.status_code == 403 + assert "Home Assistant app page" in response.json()["action_required"] + assert lookup.calls == [] + + +@pytest.mark.parametrize( + "prefixes", [[], ["/wrong"], [PREFIX + "/../"], [PREFIX + "?query"], [PREFIX, PREFIX]] +) +def test_untrusted_or_ambiguous_base_paths_are_rejected_before_ha_lookup(prefixes: list) -> None: + client, lookup = fixture() + client.headers.clear() + response = client.get( + "/", headers=[("X-Remote-User-Id", SUBJECT)] + [("X-Ingress-Path", p) for p in prefixes] + ) + assert response.status_code == 403 and lookup.calls == [] + + +@pytest.mark.parametrize("subjects", [[], ["invalid"], [SUBJECT, SUBJECT]]) +def test_missing_malformed_or_duplicate_identity_is_never_a_default_user(subjects: list) -> None: + client, lookup = fixture() + client.headers.clear() + response = client.get( + "/", headers=[("X-Ingress-Path", PREFIX)] + [("X-Remote-User-Id", s) for s in subjects] + ) + assert response.status_code == 401 and lookup.calls == [] + + +@pytest.mark.parametrize( + "error,status", + [ + (IdentityUnavailable("private-upstream-detail"), 503), + (IdentityRejected("private-upstream-detail"), 401), + (Forbidden("private-upstream-detail"), 403), + (Forbidden("ha_directory_forbidden"), 403), + (DomusError("private-upstream-detail"), 500), + (RuntimeError("private-upstream-detail"), 500), + ], +) +def test_provider_failure_is_safe_and_never_starts_an_oauth_flow( + error: Exception, status: int +) -> None: + client, lookup = fixture() + lookup.error = error + response = client.get("/api/v1/identity") + assert response.status_code == status + assert "private-upstream-detail" not in response.text + assert "No separate Domus login is required" in response.json()["action_required"] + assert "location" not in response.headers and "set-cookie" not in response.headers + + +@pytest.mark.parametrize("peer", ["127.0.0.1", "::1", "172.30.32.2"]) +def test_process_health_is_nonpersonal_and_does_not_claim_ha_availability(peer: str) -> None: + client, lookup = fixture(peer) + client.headers.clear() + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ready", "scope": "process"} + assert lookup.calls == [] + + +@pytest.mark.parametrize("path", ["/api/v1/auth/start", "/docs", "/api/v1/recovery/approve"]) +def test_preview_exposes_no_standalone_oauth_or_business_command_route(path: str) -> None: + client, lookup = fixture() + assert client.get(path).status_code == 404 + assert lookup.calls == [] + + +def test_invalid_channel_oversized_headers_queries_bodies_and_mutations_are_rejected() -> None: + with pytest.raises(InvalidInput): + fixture(channel="unknown") + client, lookup = fixture() + assert client.get("/?member_id=another").status_code == 422 + assert client.get("/", headers={"X-Large": "x" * 16_384}).status_code == 422 + assert client.get("/", headers={f"X-{i}": "x" for i in range(101)}).status_code == 422 + assert client.request("GET", "/", content="body").status_code == 422 + assert client.post("/api/v1/identity", json={}).status_code == 403 + assert client.delete("/").status_code == 403 + assert lookup.calls == [] + + +def test_missing_socket_peer_and_unknown_health_peer_fail_closed() -> None: + client, lookup = fixture("172.30.33.4") + assert client.get("/health").status_code == 403 + client, lookup = fixture(None) + assert client.get("/").status_code == 403 + assert lookup.calls == [] + + +def test_head_is_not_a_hidden_identity_or_mutation_handler() -> None: + client, lookup = fixture() + # HEAD is admitted by the boundary but has no mutation or identity handler. + assert client.head("/").status_code == 405 + assert lookup.calls == [] diff --git a/tests/backend/test_ingress_server.py b/tests/backend/test_ingress_server.py new file mode 100644 index 0000000..ff3248e --- /dev/null +++ b/tests/backend/test_ingress_server.py @@ -0,0 +1,61 @@ +"""Supervisor composition owns its private endpoint and never asks for a human token.""" + +import runpy +import sys + +import pytest +from fastapi.testclient import TestClient + +from domus.composition import ingress + + +def test_composition_exposes_only_loopback_process_health_without_upstream_calls() -> None: + app = ingress.create_runtime("private-supervisor-token", "edge") + client = TestClient(app, client=("127.0.0.1", 1234)) + assert client.get("/health").json() == {"status": "ready", "scope": "process"} + assert client.get("/").status_code == 403 + + +def test_cli_keeps_real_peer_and_private_listener_security(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SUPERVISOR_TOKEN", "private-supervisor-token") + calls = [] + monkeypatch.setattr(ingress.uvicorn, "run", lambda app, **kwargs: calls.append(kwargs)) + assert ingress.main(["--channel", "edge"]) == 0 + assert "SUPERVISOR_TOKEN" not in ingress.os.environ + assert calls == [ + { + "host": "0.0.0.0", + "port": 8099, + "workers": 1, + "proxy_headers": False, + "forwarded_allow_ips": "", + "access_log": False, + "server_header": False, + "log_level": "warning", + "limit_concurrency": 64, + "timeout_keep_alive": 5, + "timeout_graceful_shutdown": 10, + "h11_max_incomplete_event_size": 65_536, + } + ] + + +@pytest.mark.parametrize("token", [None, "invalid token"]) +def test_cli_missing_or_malformed_supervisor_configuration_fails_without_secret_output( + token: str | None, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture +) -> None: + monkeypatch.delenv("SUPERVISOR_TOKEN", raising=False) + if token is not None: + monkeypatch.setenv("SUPERVISOR_TOKEN", token) + assert ingress.main(["--channel", "edge"]) == 2 + output = capsys.readouterr() + assert "Home Assistant app" in output.err + assert "invalid token" not in output.err + + +def test_module_command_help_requires_no_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delitem(sys.modules, "domus.composition.ingress", raising=False) + monkeypatch.setattr(sys, "argv", ["domus-ingress", "--help"]) + with pytest.raises(SystemExit) as stopped: + runpy.run_module("domus.composition.ingress", run_name="__main__") + assert stopped.value.code == 0 diff --git a/tests/backend/test_ingress_sessions.py b/tests/backend/test_ingress_sessions.py new file mode 100644 index 0000000..f357a9b --- /dev/null +++ b/tests/backend/test_ingress_sessions.py @@ -0,0 +1,303 @@ +"""Native leases bind the existing trusted HA session without persisting or exposing its cookie.""" + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from threading import Barrier + +import pytest +from test_ha_ingress import USER, Lookup + +from domus.domain.errors import Forbidden, IdentityRejected, IdentityUnavailable, Unauthenticated +from domus.domain.storage import Installation +from domus.infrastructure.ha_ingress import SupervisorIdentity +from domus.infrastructure.ingress_sessions import IngressSessions + +PREFIX = "/api/hassio_ingress/" + "b" * 43 +COOKIE = "c" * 128 +HEADERS = ( + ("X-Remote-User-Id", USER), + ("X-Ingress-Path", PREFIX), + ("Cookie", "other=ignored; ingress_session=" + COOKIE), +) + + +class Clock: + value = 100 + + def now(self) -> int: + return self.value + + +class Entropy: + count = 0 + + def new(self) -> str: + self.count += 1 + return f"{self.count:043d}" + + +def fixture(*, capacity: int = 256) -> tuple: + lookup, clock = Lookup(), Clock() + sessions = IngressSessions( + SupervisorIdentity(lookup, "private-supervisor-token"), + Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge"), + clock, + Entropy(), + b"k" * 32, + capacity=capacity, + ) + return sessions, lookup, clock + + +def test_native_bootstrap_is_fresh_and_updates_preserve_original_deadline() -> None: + sessions, lookup, clock = fixture() + request = sessions.request("172.30.32.2", HEADERS) + first = request.status() + assert first.fresh and first.expires_at == 3_600_100 + csrf = request.csrf() + clock.value += 1000 + lookup.identity = replace(lookup.identity, is_administrator=True) + assert request.current().is_administrator + assert request.status().expires_at == first.expires_at + assert request.csrf() == csrf + assert COOKIE not in repr(sessions) and COOKIE not in repr(request) + assert COOKIE not in repr(sessions.leases) + assert "private-supervisor-token" not in repr(sessions) + + +def test_outage_allows_only_original_read_grace_without_renewal() -> None: + sessions, lookup, clock = fixture() + request = sessions.request("172.30.32.2", HEADERS) + first = request.status() + csrf = request.csrf() + lookup.error = IdentityUnavailable("ha_unavailable") + assert not request.status().fresh and request.read() == first.human + request.verify_csrf(csrf) + with pytest.raises(IdentityUnavailable): + request.current() + assert request.status().expires_at == first.expires_at + clock.value = first.expires_at + with pytest.raises(Unauthenticated): + request.verify_csrf(csrf) + with pytest.raises(IdentityUnavailable): + request.status() + + +def test_expiry_requires_new_fresh_proof_and_changes_csrf_epoch() -> None: + sessions, lookup, clock = fixture() + request = sessions.request("172.30.32.2", HEADERS) + first = request.status() + csrf = request.csrf() + clock.value = first.expires_at + with pytest.raises(Unauthenticated, match="session_expired"): + request.current() + renewed = request.status() + assert renewed.fresh and renewed.expires_at == clock.value + 3_600_000 + assert request.csrf() != csrf + with pytest.raises(Forbidden, match="request_forbidden"): + request.verify_csrf(csrf) + assert lookup.calls >= 2 + + +def test_explicit_rejection_removes_read_grace_even_if_next_proof_is_unavailable() -> None: + sessions, lookup, _ = fixture() + request = sessions.request("172.30.32.2", HEADERS) + request.status() + lookup.error = IdentityRejected("ha_subject_rejected") + with pytest.raises(IdentityRejected): + request.read() + lookup.error = IdentityUnavailable("ha_unavailable") + with pytest.raises(IdentityUnavailable): + request.read() + + +@pytest.mark.parametrize( + "changes", + [ + (("Cookie", "ingress_session=" + "d" * 128),), + (("X-Ingress-Path", "/api/hassio_ingress/" + "e" * 43),), + ], +) +def test_cookie_or_app_path_change_cannot_borrow_an_existing_lease(changes: tuple) -> None: + sessions, lookup, _ = fixture() + original = sessions.request("172.30.32.2", HEADERS) + original.status() + csrf = original.csrf() + replaced = ( + tuple((name, value) for name, value in HEADERS if name not in {name for name, _ in changes}) + + changes + ) + other = sessions.request("172.30.32.2", replaced) + with pytest.raises(Unauthenticated): + other.current() + lookup.error = IdentityUnavailable("ha_unavailable") + with pytest.raises(IdentityUnavailable): + other.status() + with pytest.raises(Unauthenticated): + other.verify_csrf(csrf) + + +@pytest.mark.parametrize( + "headers", + [ + HEADERS[:-1], + HEADERS[:-1] + (("Cookie", "ingress_session=bad"),), + HEADERS + (("cookie", "ingress_session=" + COOKIE),), + HEADERS[:-1] + (("Cookie", "ingress_session=" + COOKIE + "; ingress_session=" + COOKIE),), + tuple((name, value) for name, value in HEADERS if name != "X-Ingress-Path"), + HEADERS + (("x-ingress-path", PREFIX),), + ], +) +def test_missing_ambiguous_or_malformed_metadata_fails_before_core(headers: tuple) -> None: + sessions, lookup, _ = fixture() + with pytest.raises((IdentityRejected, Unauthenticated)): + sessions.request("172.30.32.2", headers).status() + assert lookup.calls == 0 + + +def test_untrusted_peer_cannot_use_even_a_real_cookie_and_subject() -> None: + sessions, lookup, _ = fixture() + with pytest.raises(IdentityRejected): + sessions.request("127.0.0.1", HEADERS).status() + assert lookup.calls == 0 + + +def test_writes_and_csrf_never_bootstrap_a_missing_local_lease() -> None: + sessions, lookup, _ = fixture() + request = sessions.request("172.30.32.2", HEADERS) + with pytest.raises(Unauthenticated): + request.current() + with pytest.raises(Unauthenticated): + request.verify_csrf("guess") + assert lookup.calls == 0 + + +def test_shutdown_discards_leases_and_cannot_reauthenticate_the_same_runtime() -> None: + sessions, lookup, _ = fixture() + request = sessions.request("172.30.32.2", HEADERS) + request.status() + calls = lookup.calls + sessions.close() + with pytest.raises(Unauthenticated): + request.status() + assert lookup.calls == calls + + +def test_simultaneous_first_requests_share_one_lease_and_original_expiry() -> None: + sessions, lookup, _ = fixture(capacity=1) + gate = Barrier(2) + original = lookup.directory_user + + def concurrent(token: str, subject: str): + human = original(token, subject) + gate.wait(timeout=3) + return human + + lookup.directory_user = concurrent + + def read(_: int): + return sessions.request("172.30.32.2", HEADERS).status() + + with ThreadPoolExecutor(max_workers=2) as workers: + first, second = workers.map(read, (1, 2)) + assert first == second + + +@pytest.mark.parametrize("key", [b"short", "not-bytes"]) +def test_native_session_key_must_be_private_bounded_entropy(key: bytes) -> None: + from domus.domain.errors import InvalidInput + + with pytest.raises(InvalidInput, match="invalid_ingress_session_key"): + IngressSessions( + SupervisorIdentity(Lookup(), "private-supervisor-token"), + Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge"), + Clock(), + Entropy(), + key, + ) + + +def test_capacity_cannot_evict_valid_other_leases_and_expired_bindings_are_reclaimed() -> None: + sessions, _, clock = fixture(capacity=1) + first = sessions.request("172.30.32.2", HEADERS) + first.status() + original = first.csrf() + changed = HEADERS[:-1] + (("Cookie", "ingress_session=" + "d" * 128),) + second = sessions.request("172.30.32.2", changed) + with pytest.raises(IdentityUnavailable, match="session_capacity"): + second.status() + assert first.csrf() == original and len(sessions._bindings) == 1 + clock.value += 3_600_000 + assert second.status().fresh and len(sessions._bindings) == 1 + with pytest.raises(Unauthenticated): + first.verify_csrf(original) + + +def test_shutdown_during_fresh_proof_cannot_publish_a_new_lease() -> None: + sessions, lookup, _ = fixture() + original = lookup.directory_user + + def closed(token: str, subject: str): + human = original(token, subject) + sessions.close() + return human + + lookup.directory_user = closed + with pytest.raises(Unauthenticated): + sessions.request("172.30.32.2", HEADERS).status() + assert sessions._bindings == {} + + +def test_concurrent_bootstrap_losing_lease_compare_and_swap_does_not_override_current_proof( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sessions, lookup, _ = fixture() + gate = Barrier(2) + original = lookup.directory_user + + def concurrent(token: str, subject: str): + human = original(token, subject) + gate.wait(timeout=3) + return human + + lookup.directory_user = concurrent + monkeypatch.setattr(sessions.leases, "refresh", lambda *_: False) + + def attempt(_: int) -> str: + try: + sessions.request("172.30.32.2", HEADERS).status() + except IdentityRejected as error: + assert error.code == "session_changed" + return "changed" + return "created" + + with ThreadPoolExecutor(max_workers=2) as workers: + assert sorted(workers.map(attempt, (1, 2))) == ["changed", "created"] + assert len(sessions._bindings) == 1 + + +@pytest.mark.parametrize( + "channel,instance", + [ + ("stable", "047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6"), + ("edge", "d94fba1b-7e76-47c4-a6b0-a88ade28363e"), + ], +) +def test_csrf_namespace_cannot_cross_installation_or_channel_even_with_test_key_reuse( + channel: str, instance: str +) -> None: + first, _, _ = fixture() + left = first.request("172.30.32.2", HEADERS) + left.status() + right_sessions = IngressSessions( + SupervisorIdentity(Lookup(), "private-supervisor-token"), + Installation(instance, channel), + Clock(), + Entropy(), + b"k" * 32, + ) + right = right_sessions.request("172.30.32.2", HEADERS) + right.status() + assert left.csrf() != right.csrf() + with pytest.raises(Forbidden): + right.verify_csrf(left.csrf()) diff --git a/tests/backend/test_member_commands.py b/tests/backend/test_member_commands.py new file mode 100644 index 0000000..3161871 --- /dev/null +++ b/tests/backend/test_member_commands.py @@ -0,0 +1,479 @@ +"""Real-file administrator transitions cannot bypass eligibility, replay or last-admin safety.""" + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from pathlib import Path +from threading import Barrier +from types import SimpleNamespace + +import pytest +from test_household_commands import fixture + +from domus.application.household_commands import SetupHousehold +from domus.application.member_commands import MemberCommands, UpdateMember +from domus.domain.errors import ( + Conflict, + Forbidden, + IdentityRejected, + IdentityUnavailable, + InvalidInput, + StorageBusy, + StorageFailure, +) +from domus.domain.identity import ( + HumanIdentity, + MappingMode, + MemberStatus, + Permission, + PermissionRule, + Role, + allows, +) +from domus.infrastructure.sqlite import SQLiteDatabase + + +def configured(tmp_path: Path, mode: MappingMode = MappingMode.ALL) -> tuple: + household, store, actor, directory, identifiers = fixture(tmp_path / "database") + household.setup("setup", SetupHousehold("Home", "UTC", mode)) + actor.human = HumanIdentity("ha_joiner", False, True, False) + result = household.enroll("enroll") + member_id = result.result.decode()["member_id"] + actor.human = HumanIdentity("ha_admin", True, True, False) + directory.people["ha_admin"] = actor.human + directory.people["ha_joiner"] = HumanIdentity("ha_joiner", False, True, False) + directory.batch_calls = [] + + def eligible(user_ids: tuple[str, ...]) -> tuple[HumanIdentity, ...]: + directory.batch_calls.append(user_ids) + if directory.error: + raise directory.error + return tuple( + directory.people[user_id] for user_id in user_ids if user_id in directory.people + ) + + directory.eligible = eligible + commands = MemberCommands(store, actor, directory, household.clock, identifiers) + return commands, store, actor, directory, member_id + + +def test_activation_commits_verified_linkage_role_exceptions_and_evidence(tmp_path: Path) -> None: + commands, store, _, directory, member_id = configured(tmp_path) + rule = PermissionRule(Permission.SHOPPING_REQUEST, False) + request = UpdateMember(member_id, 1, Role.RESTRICTED, MemberStatus.ACTIVE, (rule,)) + receipt = commands.update("activate", request) + with store.read() as unit: + member = unit.members.get("home", member_id) + assert member.ha_user_id == "ha_joiner" + assert member.role is Role.RESTRICTED and member.status is MemberStatus.ACTIVE + assert member.revision == 2 and member.exceptions == (rule,) + policy = unit.policies.get("home") + assert not allows( + directory.people["ha_joiner"], member, policy, Permission.SHOPPING_REQUEST, "home" + ) + assert unit.receipts.get(receipt.command) == receipt + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 3 + assert receipt.command.actor_id == "ha_admin" + assert receipt.result.decode() == { + "member_id": member_id, + "revision": 2, + "status": "active", + "role": "restricted", + } + + +@pytest.mark.parametrize("failure", ["unavailable", "rejected", "mismatch"]) +def test_activation_requires_exact_fresh_target_without_partial_effects( + tmp_path: Path, failure: str +) -> None: + commands, store, _, directory, member_id = configured(tmp_path) + if failure == "mismatch": + directory.people["ha_joiner"] = HumanIdentity("another", False, True, False) + else: + directory.error = ( + IdentityUnavailable("ha_unavailable") + if failure == "unavailable" + else IdentityRejected("ha_subject_rejected") + ) + with pytest.raises((IdentityUnavailable, IdentityRejected, Forbidden)): + commands.update("activate", UpdateMember(member_id, 1, Role.ADULT, MemberStatus.ACTIVE)) + with store.read() as unit: + assert unit.members.get("home", member_id).status is MemberStatus.PENDING + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 2 + + +def test_current_administrator_may_archive_an_ineligible_other_user_without_contacting_them( + tmp_path: Path, +) -> None: + commands, store, _, directory, member_id = configured(tmp_path) + directory.error = IdentityUnavailable("ha_unavailable") + commands.update("archive", UpdateMember(member_id, 1, Role.VIEWER, MemberStatus.ARCHIVED)) + assert directory.calls == directory.batch_calls == [] + with store.read() as unit: + member = unit.members.get("home", member_id) + assert member.status is MemberStatus.ARCHIVED and member.ha_user_id == "ha_joiner" + + +@pytest.mark.parametrize( + "status", [MemberStatus.PENDING, MemberStatus.SUSPENDED, MemberStatus.ARCHIVED] +) +def test_last_administrator_cannot_deactivate_itself(tmp_path: Path, status: MemberStatus) -> None: + commands, store, _, _, _ = configured(tmp_path) + with store.read() as unit: + administrator = unit.members.by_user("home", "ha_admin") + with pytest.raises(Conflict, match="last_administrator"): + commands.update( + "remove-self", UpdateMember(administrator.member_id, 1, Role.VIEWER, status) + ) + with store.read() as unit: + assert unit.members.by_user("home", "ha_admin").status is MemberStatus.ACTIVE + assert len(unit.audit.entries("home")) == 2 + + +def test_explicit_handover_preserves_ordinary_admin_and_then_allows_self_suspension( + tmp_path: Path, +) -> None: + commands, store, _, directory, member_id = configured(tmp_path, MappingMode.SETUP) + commands.update("promote", UpdateMember(member_id, 1, Role.ADMINISTRATOR, MemberStatus.ACTIVE)) + with store.read() as unit: + administrator = unit.members.by_user("home", "ha_admin") + commands.update( + "suspend-self", + UpdateMember(administrator.member_id, 1, Role.VIEWER, MemberStatus.SUSPENDED), + ) + assert directory.batch_calls == [("ha_joiner",)] + with store.read() as unit: + member = unit.members.get("home", member_id) + assert allows( + directory.people["ha_joiner"], + member, + unit.policies.get("home"), + Permission.MEMBERS_MANAGE, + "home", + ) + with pytest.raises(Forbidden): + commands.update( + "suspend-self", + UpdateMember(administrator.member_id, 1, Role.VIEWER, MemberStatus.SUSPENDED), + ) + + +def test_ha_revoked_remaining_administrator_cannot_satisfy_last_admin_check(tmp_path: Path) -> None: + commands, store, _, directory, member_id = configured(tmp_path) + commands.update("promote", UpdateMember(member_id, 1, Role.ADMINISTRATOR, MemberStatus.ACTIVE)) + del directory.people["ha_joiner"] + with store.read() as unit: + administrator = unit.members.by_user("home", "ha_admin") + with pytest.raises(Conflict, match="last_administrator"): + commands.update( + "suspend", UpdateMember(administrator.member_id, 1, Role.VIEWER, MemberStatus.SUSPENDED) + ) + + +def test_current_authority_precedes_old_receipt_and_target_availability(tmp_path: Path) -> None: + commands, store, actor, directory, member_id = configured(tmp_path) + request = UpdateMember(member_id, 1, Role.ADULT, MemberStatus.ACTIVE) + result = commands.update("activate", request) + directory.error = IdentityUnavailable("ha_unavailable") + assert commands.update("activate", request) == result + with pytest.raises(Conflict, match="idempotency_conflict"): + commands.update("activate", replace(request, role=Role.VIEWER)) + actor.human = replace(actor.human, is_administrator=False) + with pytest.raises(Forbidden): + commands.update("activate", request) + with store.read() as unit: + assert unit.members.get("home", member_id).revision == 2 + + +def test_invalid_and_stale_member_changes_never_request_target_proof(tmp_path: Path) -> None: + commands, _, _, directory, member_id = configured(tmp_path) + for request, code in ( + (UpdateMember("missing", 1, Role.ADULT, MemberStatus.ACTIVE), "member_missing"), + (UpdateMember(member_id, 2, Role.ADULT, MemberStatus.ACTIVE), "revision_conflict"), + ): + with pytest.raises(Conflict, match=code): + commands.update("update", request) + assert directory.calls == directory.batch_calls == [] + + +@pytest.mark.parametrize( + "overrides", + [ + {"expected_revision": True}, + {"expected_revision": 0}, + {"member_id": "bad/id"}, + {"role": "administrator"}, + {"status": "active"}, + {"exceptions": []}, + {"exceptions": (PermissionRule(Permission.HOUSEHOLD_READ, True),) * 2}, + ], +) +def test_change_contract_rejects_ambiguous_revisions_enums_and_rules(overrides: dict) -> None: + values = { + "member_id": "member", + "expected_revision": 1, + "role": Role.VIEWER, + "status": MemberStatus.ACTIVE, + **overrides, + } + with pytest.raises(InvalidInput): + UpdateMember(**values) + + +def test_rule_order_does_not_change_the_canonical_retry_payload(tmp_path: Path) -> None: + commands, _, _, _, member_id = configured(tmp_path) + rules = ( + PermissionRule(Permission.TASKS_COMPLETE, True), + PermissionRule(Permission.SHOPPING_REQUEST, False), + ) + request = UpdateMember(member_id, 1, Role.ADULT, MemberStatus.ACTIVE, rules) + result = commands.update("activate", request) + assert ( + commands.update("activate", replace(request, exceptions=tuple(reversed(rules)))) == result + ) + + +@pytest.mark.parametrize("changed", ["policy", "member"]) +def test_external_proof_cannot_commit_against_changed_local_authority_state( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, changed: str +) -> None: + commands, store, _, directory, member_id = configured(tmp_path) + original = directory.get + + def lookup(user_id: str) -> HumanIdentity: + # This also proves external I/O never holds the writer lock. + with store.write() as unit: + if changed == "policy": + policy = unit.policies.get("home") + unit.policies.save(replace(policy, revision=2), 1) + else: + member = unit.members.get("home", member_id) + unit.members.save(replace(member, role=Role.RESTRICTED, revision=2), 1) + return original(user_id) + + monkeypatch.setattr(directory, "get", lookup) + with pytest.raises(Conflict, match="identity_state_changed"): + commands.update("activate", UpdateMember(member_id, 1, Role.ADULT, MemberStatus.ACTIVE)) + with store.read() as unit: + assert unit.members.get("home", member_id).status is MemberStatus.PENDING + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 2 + + +@pytest.mark.parametrize("change", ["revoked", "mismatch", "unavailable"]) +def test_actor_is_reproved_after_external_lookup_before_commit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, change: str +) -> None: + commands, store, actor, directory, member_id = configured(tmp_path) + original = directory.get + + def lookup(user_id: str) -> HumanIdentity: + if change == "unavailable": + actor.error = IdentityUnavailable("ha_unavailable") + else: + actor.human = HumanIdentity( + "someone_else" if change == "mismatch" else "ha_admin", False, True, False + ) + return original(user_id) + + monkeypatch.setattr(directory, "get", lookup) + with pytest.raises((Forbidden, IdentityUnavailable)): + commands.update("activate", UpdateMember(member_id, 1, Role.ADULT, MemberStatus.ACTIVE)) + with store.read() as unit: + assert unit.members.get("home", member_id).status is MemberStatus.PENDING + assert len(unit.audit.entries("home")) == 2 + + +@pytest.mark.parametrize("result", ["list", "too_many", "unknown", "duplicate", "invalid"]) +def test_untrusted_batch_shape_or_identity_cannot_create_a_remaining_administrator( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, result: str +) -> None: + commands, store, _, directory, member_id = configured(tmp_path, MappingMode.SETUP) + commands.update("promote", UpdateMember(member_id, 1, Role.ADMINISTRATOR, MemberStatus.ACTIVE)) + with store.read() as unit: + administrator = unit.members.by_user("home", "ha_admin") + extra = unit.members.get("home", member_id) + # A second candidate makes the duplicate-identity guard independent of the size bound. + with store.write() as unit: + unit.members.save(replace(extra, member_id="extra", ha_user_id="ha_extra", revision=1), 0) + human = directory.people["ha_joiner"] + results = { + "list": [human], + "too_many": (human,) * 3, + "unknown": (HumanIdentity("unknown", True, True, False),), + "duplicate": (human, human), + "invalid": (None,), + } + monkeypatch.setattr(directory, "eligible", lambda _: results[result]) + with pytest.raises(Forbidden, match="identity_mismatch"): + commands.update( + "suspend", UpdateMember(administrator.member_id, 1, Role.VIEWER, MemberStatus.SUSPENDED) + ) + with store.read() as unit: + assert unit.members.by_user("home", "ha_admin").status is MemberStatus.ACTIVE + assert len(unit.audit.entries("home")) == 3 + + +def test_directory_outage_never_becomes_confirmed_absence_or_a_partial_transition( + tmp_path: Path, +) -> None: + commands, store, _, directory, member_id = configured(tmp_path, MappingMode.SETUP) + commands.update("promote", UpdateMember(member_id, 1, Role.ADMINISTRATOR, MemberStatus.ACTIVE)) + with store.read() as unit: + administrator = unit.members.by_user("home", "ha_admin") + directory.error = IdentityUnavailable("ha_unavailable") + with pytest.raises(IdentityUnavailable): + commands.update( + "suspend", UpdateMember(administrator.member_id, 1, Role.VIEWER, MemberStatus.SUSPENDED) + ) + with store.read() as unit: + assert unit.members.by_user("home", "ha_admin").revision == 1 + assert len(unit.audit.entries("home")) == 3 + + +def test_unbounded_corrupt_member_inventory_fails_before_lookup(tmp_path: Path) -> None: + commands, store, _, directory, member_id = configured(tmp_path) + with store.write() as unit: + member = unit.members.get("home", member_id) + for number in range(255): + unit.members.save( + replace(member, member_id=f"overflow{number}", ha_user_id=f"human{number}"), 0 + ) + with pytest.raises(StorageFailure, match="invalid_identity_record"): + commands.update("activate", UpdateMember(member_id, 1, Role.ADULT, MemberStatus.ACTIVE)) + assert directory.calls == directory.batch_calls == [] + + +def test_suspension_takes_effect_on_existing_authenticated_authority_immediately( + tmp_path: Path, +) -> None: + commands, store, actor, directory, member_id = configured(tmp_path) + commands.update("promote", UpdateMember(member_id, 1, Role.ADMINISTRATOR, MemberStatus.ACTIVE)) + actor.human = directory.people["ha_joiner"] + commands.update("edit-own", UpdateMember(member_id, 2, Role.ADMINISTRATOR, MemberStatus.ACTIVE)) + actor.human = directory.people["ha_admin"] + commands.update( + "suspend", UpdateMember(member_id, 3, Role.ADMINISTRATOR, MemberStatus.SUSPENDED) + ) + actor.human = directory.people["ha_joiner"] + with pytest.raises(Forbidden): + commands.update( + "edit-own", UpdateMember(member_id, 2, Role.ADMINISTRATOR, MemberStatus.ACTIVE) + ) + with pytest.raises(Forbidden): + commands.update( + "self-reactivate", UpdateMember(member_id, 4, Role.ADMINISTRATOR, MemberStatus.ACTIVE) + ) + + +def test_restart_preserves_authorized_member_command_replay(tmp_path: Path) -> None: + commands, store, actor, directory, member_id = configured(tmp_path) + request = UpdateMember(member_id, 1, Role.ADULT, MemberStatus.ACTIVE) + receipt = commands.update("activate", request) + restarted = MemberCommands( + SQLiteDatabase(store.path, store.installation), + actor, + directory, + commands.clock, + commands.identifiers, + ) + directory.error = IdentityUnavailable("ha_unavailable") + assert restarted.update("activate", request) == receipt + + +def test_concurrent_self_suspension_cannot_remove_both_remaining_administrators( + tmp_path: Path, +) -> None: + commands, store, _, directory, member_id = configured(tmp_path, MappingMode.SETUP) + commands.update("promote", UpdateMember(member_id, 1, Role.ADMINISTRATOR, MemberStatus.ACTIVE)) + with store.read() as unit: + administrator = unit.members.by_user("home", "ha_admin") + gate = Barrier(2) + + class ConcurrentDirectory: + def eligible(self, user_ids: tuple[str, ...]) -> tuple[HumanIdentity, ...]: + result = tuple(directory.people[user_id] for user_id in user_ids) + gate.wait(timeout=3) + return result + + def suspend(user_id: str) -> str: + actor = SimpleNamespace(current=lambda: directory.people[user_id]) + service = MemberCommands( + store, actor, ConcurrentDirectory(), commands.clock, commands.identifiers + ) + target, revision = (administrator.member_id, 1) if user_id == "ha_admin" else (member_id, 2) + try: + service.update( + "suspend", UpdateMember(target, revision, Role.VIEWER, MemberStatus.SUSPENDED) + ) + except Conflict, StorageBusy: + return "conflict" + return "committed" + + with ThreadPoolExecutor(max_workers=2) as workers: + assert sorted(workers.map(suspend, ("ha_admin", "ha_joiner"))) == ["committed", "conflict"] + with store.read() as unit: + assert ( + sum(member.status is MemberStatus.ACTIVE for member in unit.members.list("home")) == 1 + ) + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 4 + + +@pytest.mark.parametrize("role", [Role.ADULT, Role.RESTRICTED, Role.VIEWER]) +def test_ordinary_member_cannot_assign_itself_privileges_or_change_another_member( + tmp_path: Path, role: Role +) -> None: + commands, store, actor, directory, member_id = configured(tmp_path) + commands.update("activate", UpdateMember(member_id, 1, role, MemberStatus.ACTIVE)) + actor.human = directory.people["ha_joiner"] + calls = tuple(directory.calls) + with pytest.raises(Forbidden): + commands.update( + "promote-self", UpdateMember(member_id, 2, Role.ADMINISTRATOR, MemberStatus.ACTIVE) + ) + assert tuple(directory.calls) == calls + with store.read() as unit: + assert unit.members.get("home", member_id).role is role + assert len(unit.audit.entries("home")) == 3 + + +def test_archived_member_reactivation_is_explicit_and_preserves_ha_linkage_and_history( + tmp_path: Path, +) -> None: + commands, store, _, directory, member_id = configured(tmp_path) + commands.update("archive", UpdateMember(member_id, 1, Role.VIEWER, MemberStatus.ARCHIVED)) + commands.update("reactivate", UpdateMember(member_id, 2, Role.RESTRICTED, MemberStatus.ACTIVE)) + assert directory.calls == ["ha_joiner"] + with store.read() as unit: + member = unit.members.by_user("home", "ha_joiner") + assert member.member_id == member_id and member.revision == 3 + assert member.status is MemberStatus.ACTIVE + assert len(unit.members.list("home")) == 2 + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 4 + + +def test_two_identical_concurrent_activations_have_one_effect_and_the_same_receipt( + tmp_path: Path, +) -> None: + commands, store, _, directory, member_id = configured(tmp_path) + gate = Barrier(2) + + class ConcurrentDirectory: + def get(self, user_id: str) -> HumanIdentity: + human = directory.people[user_id] + gate.wait(timeout=3) + return human + + def activate(_: int) -> object: + actor = SimpleNamespace(current=lambda: directory.people["ha_admin"]) + service = MemberCommands( + store, actor, ConcurrentDirectory(), commands.clock, commands.identifiers + ) + return service.update( + "activate", UpdateMember(member_id, 1, Role.ADULT, MemberStatus.ACTIVE) + ) + + with ThreadPoolExecutor(max_workers=2) as workers: + results = list(workers.map(activate, (1, 2))) + assert results[0] == results[1] + with store.read() as unit: + assert unit.members.get("home", member_id).revision == 2 + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 3 diff --git a/tests/backend/test_membership_authorization.py b/tests/backend/test_membership_authorization.py new file mode 100644 index 0000000..868cf5f --- /dev/null +++ b/tests/backend/test_membership_authorization.py @@ -0,0 +1,185 @@ +"""Concrete local authority is reread before an old receipt or a new mutation.""" + +import sqlite3 +from contextlib import closing +from dataclasses import replace +from pathlib import Path + +import pytest + +from domus.application.authorization import HouseholdAccess, MembershipAuthorization +from domus.application.commands import AtomicCommands, Change +from domus.application.ports import UnitOfWork +from domus.domain.errors import Forbidden +from domus.domain.identity import ( + AdministratorMapping, + HouseholdPolicy, + HumanIdentity, + MappingMode, + Member, + MemberStatus, + Permission, + Role, +) +from domus.domain.storage import CommandKey, Household, Installation, JsonDocument +from domus.infrastructure.sqlite import SQLiteDatabase + + +class Clock: + def now(self) -> int: + return 100 + + +class Identifiers: + def new(self) -> str: + return "rename-operation" + + +def store_at(path: Path, *, explicit_admin: bool = False) -> SQLiteDatabase: + store = SQLiteDatabase(path, Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge")) + store.initialize() + with store.write() as unit: + unit.households.save(Household("home", "Before", "UTC", 1), 0) + unit.policies.save(HouseholdPolicy("home", AdministratorMapping(MappingMode.ALL), 1), 0) + unit.members.save( + Member( + "member_1", + "home", + "ha_1", + Role.ADMINISTRATOR if explicit_admin else Role.VIEWER, + MemberStatus.ACTIVE, + 1, + ), + 0, + ) + return store + + +def authority(*, ha_admin: bool = True) -> MembershipAuthorization: + return MembershipAuthorization( + HouseholdAccess(HumanIdentity("ha_1", ha_admin, True, False)), + "household.rename", + Permission.HOUSEHOLD_MANAGE, + ) + + +def rename(unit: UnitOfWork) -> Change: + unit.households.save(Household("home", "After", "UTC", 2), 1) + result = JsonDocument.from_value({"revision": 2}) + return Change(result, result, result) + + +def test_fresh_source_revocation_denies_original_receipt_before_result_disclosure( + tmp_path: Path, +) -> None: + store = store_at(tmp_path / "database") + key = CommandKey("home", "ha_1", "household.rename", "request") + payload = JsonDocument.from_value({"name": "After"}) + original = AtomicCommands(store, authority(), Clock(), Identifiers()).execute( + key, payload, rename + ) + revoked = AtomicCommands(store, authority(ha_admin=False), Clock(), Identifiers()) + with pytest.raises(Forbidden, match="permission_denied"): + revoked.execute(key, payload, rename) + with store.read() as unit: + assert unit.receipts.get(key) == original + assert unit.households.current().revision == 2 + assert len(unit.audit.entries("home")) == 1 + assert len(unit.outbox.pending()) == 1 + + +def test_independent_explicit_administrator_survives_ha_admin_source_revocation( + tmp_path: Path, +) -> None: + store = store_at(tmp_path / "database", explicit_admin=True) + key = CommandKey("home", "ha_1", "household.rename", "request") + payload = JsonDocument.from_value({}) + commands = AtomicCommands(store, authority(), Clock(), Identifiers()) + first = commands.execute(key, payload, rename) + assert ( + AtomicCommands(store, authority(ha_admin=False), Clock(), Identifiers()).execute( + key, payload, rename + ) + == first + ) + + +@pytest.mark.parametrize( + "status", [MemberStatus.PENDING, MemberStatus.SUSPENDED, MemberStatus.ARCHIVED] +) +def test_current_member_state_overrides_existing_authority_and_receipt( + tmp_path: Path, + status: MemberStatus, +) -> None: + store = store_at(tmp_path / "database") + key = CommandKey("home", "ha_1", "household.rename", "request") + commands = AtomicCommands(store, authority(), Clock(), Identifiers()) + commands.execute(key, JsonDocument.from_value({}), rename) + with store.write() as unit: + unit.members.save( + replace(unit.members.get("home", "member_1"), status=status, revision=2), 1 + ) + with pytest.raises(Forbidden): + commands.execute(key, JsonDocument.from_value({}), rename) + with store.read() as unit: + with pytest.raises(Forbidden): + authority().access.require(unit, "home", Permission.HOUSEHOLD_READ) + + +def test_mapping_revision_is_not_cached_across_commands(tmp_path: Path) -> None: + store = store_at(tmp_path / "database") + auth = authority() + with store.write() as unit: + auth.require(CommandKey("home", "ha_1", "household.rename", "before"), unit) + unit.policies.save( + HouseholdPolicy("home", AdministratorMapping(MappingMode.SELECTED, "ha_2"), 2), 1 + ) + with store.write() as unit: + with pytest.raises(Forbidden): + auth.require(CommandKey("home", "ha_1", "household.rename", "after"), unit) + + +@pytest.mark.parametrize("actor,action", [("ha_2", "household.rename"), ("ha_1", "members.read")]) +def test_command_identity_and_action_are_bound_by_server_authority( + tmp_path: Path, + actor: str, + action: str, +) -> None: + store = store_at(tmp_path / "database") + with store.write() as unit: + with pytest.raises(Forbidden): + authority().require(CommandKey("home", actor, action, "request"), unit) + with store.read() as unit: + assert unit.households.current().revision == 1 + + +def test_unlinked_user_missing_policy_and_cross_scope_fail_closed(tmp_path: Path) -> None: + store = store_at(tmp_path / "database") + unlinked = HouseholdAccess(HumanIdentity("ha_2", True, True, False)) + with store.read() as unit: + with pytest.raises(Forbidden): + unlinked.require(unit, "home", Permission.HOUSEHOLD_READ) + with pytest.raises(Forbidden): + authority().access.require(unit, "other", Permission.HOUSEHOLD_READ) + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute("DELETE FROM household_policy") + with store.read() as unit: + with pytest.raises(Forbidden): + authority().access.require(unit, "home", Permission.HOUSEHOLD_READ) + + +def test_authorized_reads_return_resolved_member_and_enforce_private_self_scope( + tmp_path: Path, +) -> None: + store = store_at(tmp_path / "database") + access = authority().access + with store.read() as unit: + assert access.require(unit, "home", Permission.HOUSEHOLD_READ).member_id == "member_1" + assert ( + access.require( + unit, "home", Permission.PREFERENCES_READ, subject_member_id="member_1" + ).ha_user_id + == "ha_1" + ) + with pytest.raises(Forbidden): + access.require(unit, "home", Permission.PREFERENCES_READ, subject_member_id="member_2") diff --git a/tests/backend/test_native_http.py b/tests/backend/test_native_http.py new file mode 100644 index 0000000..e181652 --- /dev/null +++ b/tests/backend/test_native_http.py @@ -0,0 +1,493 @@ +"""The native composed API uses HA Ingress identity, local lease grace and fixed-origin CSRF.""" + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from test_ingress_sessions import HEADERS, USER, Clock + +from domus.composition import ingress +from domus.domain.errors import IdentityRejected, IdentityUnavailable, InvalidInput +from domus.domain.identity import HumanIdentity +from domus.infrastructure.ha_config import HAEndpoint + +INSTANCE = "047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6" +ORIGIN = "http://homeassistant.local:8123" + + +class Lookup: + endpoint = HAEndpoint.supervisor() + + def __init__(self) -> None: + self.people = {USER: HumanIdentity(USER, True, True, False)} + self.error = None + + def directory_user(self, token: str, user_id: str) -> HumanIdentity: + assert token == "private-supervisor-token" + if self.error: + raise self.error + if user_id not in self.people: + raise IdentityRejected("ha_subject_rejected") + return self.people[user_id] + + def directory_users(self, token: str, user_ids: tuple[str, ...]) -> tuple[HumanIdentity, ...]: + return tuple( + self.directory_user(token, user_id) for user_id in user_ids if user_id in self.people + ) + + +def fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple: + lookup, clock = Lookup(), Clock() + monkeypatch.setattr(ingress, "HomeAssistantWebSocket", lambda _: lookup) + monkeypatch.setattr(ingress, "MonotonicClock", lambda: clock) + runtime = ingress.create_business_runtime( + "private-supervisor-token", "edge", tmp_path / "domus.sqlite3", INSTANCE, (ORIGIN,) + ) + client = TestClient( + runtime.app, + base_url=ORIGIN, + client=("172.30.32.2", 12000), + headers=dict(HEADERS), + raise_server_exceptions=False, + ) + return runtime, client, lookup, clock + + +def headers(client: TestClient, key: str) -> dict[str, str]: + response = client.get("/api/v1/auth/csrf") + assert response.status_code == 200, response.text + return {"Origin": ORIGIN, "X-CSRF-Token": response.json()["csrf_token"], "Idempotency-Key": key} + + +def setup(client: TestClient) -> dict: + response = client.post( + "/api/v1/household", + json={"name": "Home", "time_zone": "UTC", "expected_revision": 0}, + headers=headers(client, "setup"), + ) + assert response.status_code == 200, response.text + return response.json() + + +def test_native_session_and_setup_require_no_new_cookie_login_or_submitted_actor( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime, client, _, _ = fixture(tmp_path, monkeypatch) + session = client.get("/api/v1/session") + assert session.status_code == 200 and session.json()["ha_user_id"] == USER + assert ( + session.json()["access"] == "full" and session.json()["source"] == "home_assistant_ingress" + ) + assert "set-cookie" not in session.headers and client.cookies == {} + assert client.get("/api/v1/authorization").json()["state"] == "unconfigured" + created = setup(client) + assert created["result"]["state"] == "configured" + assert client.get("/api/v1/household").json()["name"] == "Home" + assert client.get("/api/v1/operations/" + created["operation_id"]).status_code == 200 + runtime.close() + + +def test_native_outage_reads_have_original_expiry_and_writes_remain_blocked( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime, client, lookup, clock = fixture(tmp_path, monkeypatch) + setup(client) + write = headers(client, "rename") + lookup.error = IdentityUnavailable("ha_unavailable") + assert client.get("/api/v1/household").status_code == 200 + assert client.get("/api/v1/session").json()["access"] == "read_only" + result = client.put( + "/api/v1/household", + json={"name": "Changed", "time_zone": "UTC", "expected_revision": 1}, + headers=write, + ) + assert result.status_code == 503 + clock.value += 3_600_000 + assert client.get("/api/v1/household").status_code == 503 + runtime.close() + + +@pytest.mark.parametrize("change", ["origin", "forwarded", "csrf", "duplicate_csrf"]) +def test_native_write_boundary_cannot_be_replaced_by_forwarding_or_forged_csrf( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, change: str +) -> None: + runtime, client, _, _ = fixture(tmp_path, monkeypatch) + write = headers(client, "setup") + if change in ("origin", "forwarded"): + write["Origin"] = "https://foreign.invalid" + if change == "forwarded": + write.update({"X-Forwarded-Host": "foreign.invalid", "X-Forwarded-Proto": "https"}) + if change == "csrf": + write["X-CSRF-Token"] = "f" * 64 + final = list(write.items()) + if change == "duplicate_csrf": + final.append(("X-CSRF-Token", write["X-CSRF-Token"])) + result = client.post( + "/api/v1/household", + json={"name": "Home", "time_zone": "UTC", "expected_revision": 0}, + headers=final, + ) + assert result.status_code == 403 + assert client.get("/api/v1/authorization").json()["state"] == "unconfigured" + runtime.close() + + +def test_native_rejection_does_not_turn_into_outage_grace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime, client, lookup, _ = fixture(tmp_path, monkeypatch) + setup(client) + lookup.error = IdentityRejected("ha_subject_rejected") + assert client.get("/api/v1/household").status_code == 401 + lookup.error = IdentityUnavailable("ha_unavailable") + assert client.get("/api/v1/household").status_code == 503 + runtime.close() + + +def test_native_private_listener_rejects_direct_requests_even_with_copied_headers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime, _, _, _ = fixture(tmp_path, monkeypatch) + direct = TestClient( + runtime.app, base_url=ORIGIN, client=("127.0.0.1", 12000), headers=dict(HEADERS) + ) + assert direct.get("/api/v1/household").status_code == 403 + assert direct.get("/health").json() == {"status": "ready", "scope": "process"} + runtime.close() + + +def test_native_restart_keeps_business_state_but_requires_new_fresh_local_lease( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + first, client, _, _ = fixture(tmp_path, monkeypatch) + created = setup(client) + nonce = headers(client, "setup") + first.close() + restarted, client, _, _ = fixture(tmp_path, monkeypatch) + # Old CSRF cannot bootstrap a new local session after restart. + response = client.post( + "/api/v1/household", + json={"name": "Home", "time_zone": "UTC", "expected_revision": 0}, + headers=nonce, + ) + assert response.status_code == 401 + assert client.get("/api/v1/authorization").json()["state"] == "active" + assert setup(client) == created + restarted.close() + + +@pytest.mark.parametrize( + "origins", + [ + (), + (ORIGIN, ORIGIN), + ("https://bad.example/path",), + tuple(f"https://ha{n}.example" for n in range(5)), + ], +) +def test_bad_native_origins_fail_before_creating_business_database( + tmp_path: Path, origins: tuple +) -> None: + path = tmp_path / "domus.sqlite3" + with pytest.raises(InvalidInput): + ingress.create_business_runtime("private-supervisor-token", "edge", path, INSTANCE, origins) + assert not path.exists() + + +def test_native_root_schema_and_method_bounds_are_verified_without_leaking_cookies( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime, client, _, _ = fixture(tmp_path, monkeypatch) + root = client.get("/") + assert root.status_code == 200 and "No additional login is required" in root.text + assert "set-cookie" not in root.headers and "private-supervisor-token" not in root.text + schema = client.get("/api/v1/openapi.json") + assert schema.status_code == 200 + assert "/api/v1/household" in schema.json()["paths"] + assert ( + schema.json()["components"]["securitySchemes"]["HomeAssistantIngress"]["name"] + == "ingress_session" + ) + assert client.delete("/api/v1/household").status_code == 403 + assert client.post("/health", headers={"Origin": ORIGIN}).status_code == 403 + assert client.get("/missing").status_code == 404 + assert client.get("/api/v1/session?actor=forged").status_code == 422 + runtime.close() + + +def test_native_missing_csrf_and_bad_request_body_never_create_a_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime, client, _, _ = fixture(tmp_path, monkeypatch) + client.get("/api/v1/session") + payload = {"name": "Home", "time_zone": "UTC", "expected_revision": 0} + assert ( + client.post( + "/api/v1/household", + json=payload, + headers={"Origin": ORIGIN, "Idempotency-Key": "setup"}, + ).status_code + == 403 + ) + assert ( + client.post( + "/api/v1/household", + json={**payload, "actor_id": "private-forgery"}, + headers=headers(client, "setup"), + ).status_code + == 422 + ) + assert client.get("/api/v1/authorization").json()["state"] == "unconfigured" + runtime.close() + + +def test_native_response_failure_after_commit_preserves_uncertainty_and_safe_retry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from domus.entrypoints import household_http + + runtime, client, _, _ = fixture(tmp_path, monkeypatch) + original = household_http._receipt + + def fail(*_: object): + raise RuntimeError("private-native-response-detail") + + write = headers(client, "setup") + monkeypatch.setattr(household_http, "_receipt", fail) + payload = {"name": "Home", "time_zone": "UTC", "expected_revision": 0} + response = client.post("/api/v1/household", json=payload, headers=write) + assert response.status_code == 500 and "could not confirm" in response.json()["message"] + assert "private-native-response-detail" not in response.text + assert "No household change was made" not in response.text + monkeypatch.setattr(household_http, "_receipt", original) + assert client.post("/api/v1/household", json=payload, headers=write).status_code == 200 + runtime.close() + + +@pytest.mark.parametrize( + "options", [["--database", "unused"], ["--instance-id", INSTANCE], ["--public-origin", ORIGIN]] +) +def test_partial_native_persistent_configuration_is_rejected_before_listening( + monkeypatch: pytest.MonkeyPatch, options: list[str] +) -> None: + monkeypatch.setenv("SUPERVISOR_TOKEN", "private-supervisor-token") + assert ingress.main(["--channel", "edge"] + options) == 2 + + +def test_native_cli_wires_persistent_runtime_and_always_closes_it( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("SUPERVISOR_TOKEN", "private-supervisor-token") + captured = [] + original = ingress.create_business_runtime + + def create(*args, **kwargs): + runtime = original(*args, **kwargs) + captured.append(runtime) + return runtime + + def run(app, **options): + assert options["proxy_headers"] is False and options["access_log"] is False + assert "/api/v1/household" in app.openapi()["paths"] + raise RuntimeError("listener stopped") + + monkeypatch.setattr(ingress, "create_business_runtime", create) + monkeypatch.setattr(ingress.uvicorn, "run", run) + with pytest.raises(RuntimeError, match="listener stopped"): + ingress.main( + [ + "--channel", + "edge", + "--database", + str(tmp_path / "domus.sqlite3"), + "--instance-id", + INSTANCE, + "--public-origin", + ORIGIN, + ] + ) + assert captured[0].sessions._closed + + +def test_selected_ordinary_ha_administrator_can_manage_members_using_only_own_domus_grant( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime, client, lookup, _ = fixture(tmp_path, monkeypatch) + created = setup(client) + founder = created["result"]["member_id"] + participant = "b" * 32 + lookup.people[participant] = HumanIdentity(participant, False, True, False) + + def switch(user_id: str, cookie_value: str) -> None: + client.headers["X-Remote-User-Id"] = user_id + client.headers["Cookie"] = "ingress_session=" + cookie_value * 128 + + switch(participant, "d") + joined = client.post( + "/api/v1/members/enrollment", json={"expected_revision": 0}, headers=headers(client, "join") + ) + assert joined.status_code == 200 and joined.json()["result"]["status"] == "pending" + member_id = joined.json()["result"]["member_id"] + assert client.get("/api/v1/household").status_code == 403 + switch(USER, "c") + activated = client.put( + "/api/v1/members/" + member_id, + json={"expected_revision": 1, "role": "viewer", "status": "active"}, + headers=headers(client, "activate"), + ) + assert activated.status_code == 200 + selected = client.put( + "/api/v1/policy", + json={ + "expected_revision": 1, + "mapping_mode": "selected_ha_user", + "target_ha_user_id": participant, + }, + headers=headers(client, "select"), + ) + assert selected.status_code == 200 + assert client.get("/api/v1/authorization").json()["effective_role"] == "viewer" + switch(participant, "d") + assert client.get("/api/v1/session").json()["ha_administrator"] is False + assert client.get("/api/v1/authorization").json()["effective_role"] == "administrator" + changed = client.put( + "/api/v1/members/" + founder, + json={"expected_revision": 1, "role": "adult", "status": "active"}, + headers=headers(client, "edit-founder"), + ) + assert changed.status_code == 200 and changed.json()["result"]["role"] == "adult" + runtime.close() + + +def test_published_native_openapi_matches_actual_private_session_and_household_contract( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import json + + runtime, client, _, _ = fixture(tmp_path, monkeypatch) + actual = client.get("/api/v1/openapi.json").json() + reference = json.loads( + ( + Path(__file__).resolve().parents[2] / "docs/contracts/native-household-api.openapi.json" + ).read_text() + ) + assert actual["paths"] == reference["paths"] + assert actual["components"] == reference["components"] + runtime.close() + + +def test_owned_native_runtime_restarts_with_same_uuid_household_and_original_operation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "owned" + root.mkdir(mode=0o700) + lookup = Lookup() + monkeypatch.setattr(ingress, "HomeAssistantWebSocket", lambda _: lookup) + + def start(): + runtime = ingress.create_owned_runtime("private-supervisor-token", "edge", root, (ORIGIN,)) + client = TestClient( + runtime.app, base_url=ORIGIN, client=("172.30.32.2", 12000), headers=dict(HEADERS) + ) + return runtime, client + + first, client = start() + original = setup(client) + identity = first.storage.installation + first.close() + second, client = start() + assert second.storage.installation == identity + assert client.get("/api/v1/household").json()["name"] == "Home" + assert setup(client) == original + second.close() + + +def test_owned_runtime_closes_directory_lease_if_later_composition_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from domus.domain.errors import StorageFailure + from domus.infrastructure.owned_storage import open_storage + + root = tmp_path / "owned" + root.mkdir(mode=0o700) + + def fail(*args): + raise StorageFailure("injected_composition_failure") + + monkeypatch.setattr(ingress, "_compose_business", fail) + with pytest.raises(StorageFailure, match="injected_composition_failure"): + ingress.create_owned_runtime("private-supervisor-token", "edge", root, (ORIGIN,)) + open_storage(root, "edge").close() + + +@pytest.mark.parametrize( + "token,origins", + [("bad token", (ORIGIN,)), ("private-supervisor-token", ("http://invalid/path",))], +) +def test_owned_runtime_rejects_configuration_before_creating_persistent_records( + tmp_path: Path, token: str, origins: tuple[str, ...] +) -> None: + root = tmp_path / "owned" + root.mkdir(mode=0o700) + with pytest.raises(InvalidInput): + ingress.create_owned_runtime(token, "edge", root, origins) + assert list(root.iterdir()) == [] + + +def test_owned_native_cli_keeps_directory_locked_until_listener_stops( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from domus.domain.errors import StorageBusy + from domus.infrastructure.owned_storage import open_storage + + root = tmp_path / "owned" + root.mkdir(mode=0o700) + monkeypatch.setenv("SUPERVISOR_TOKEN", "private-supervisor-token") + + def run(app, **kwargs): + with pytest.raises(StorageBusy, match="installation_in_use"): + open_storage(root, "edge") + assert "/api/v1/household" in app.openapi()["paths"] + + monkeypatch.setattr(ingress.uvicorn, "run", run) + assert ( + ingress.main( + ["--channel", "edge", "--data-directory", str(root), "--public-origin", ORIGIN] + ) + == 0 + ) + open_storage(root, "edge").close() + + +@pytest.mark.parametrize( + "options", + [[], ["--instance-id", INSTANCE], ["--instance-id", INSTANCE, "--public-origin", ORIGIN]], +) +def test_owned_cli_rejects_incomplete_or_competing_identity_options( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, options: list[str] +) -> None: + root = tmp_path / "owned" + root.mkdir(mode=0o700) + monkeypatch.setenv("SUPERVISOR_TOKEN", "private-supervisor-token") + assert ingress.main(["--channel", "edge", "--data-directory", str(root), *options]) == 2 + assert list(root.iterdir()) == [] + + +def test_owned_runtime_initializes_sqlite_once_and_restarts_in_existing_only_mode( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from domus.infrastructure.sqlite import SQLiteDatabase + + root = tmp_path / "owned" + root.mkdir(mode=0o700) + original = SQLiteDatabase.initialize + modes = [] + + def initialize(self, *, require_existing=False): + modes.append(require_existing) + return original(self, require_existing=require_existing) + + monkeypatch.setattr(SQLiteDatabase, "initialize", initialize) + ingress.create_owned_runtime("private-supervisor-token", "edge", root, (ORIGIN,)).close() + ingress.create_owned_runtime("private-supervisor-token", "edge", root, (ORIGIN,)).close() + assert modes == [False, True] diff --git a/tests/backend/test_offline_storage.py b/tests/backend/test_offline_storage.py new file mode 100644 index 0000000..8bbbd40 --- /dev/null +++ b/tests/backend/test_offline_storage.py @@ -0,0 +1,160 @@ +"""Offline restoration requires both root leases and never initializes or migrates source data.""" + +import sqlite3 +from contextlib import closing +from pathlib import Path + +import pytest +from test_owned_generations import fixture as generation_fixture + +from domus.domain.errors import DomusError, InvalidInput, StorageBusy +from domus.domain.storage import Installation +from domus.infrastructure.offline_storage import offline_storage +from domus.infrastructure.owned_storage import attach_storage, open_storage + + +def flat(tmp_path): + root = tmp_path / "root" + root.mkdir(mode=0o700) + storage = open_storage(root, "edge") + identity = storage.installation + storage.close() + return root, identity + + +def test_both_leases_exclude_server_and_console_until_offline_capability_exits(tmp_path): + root, identity = flat(tmp_path) + original = (root / "domus.sqlite3").read_bytes() + with offline_storage(root, "edge") as storage: + assert storage.installation == identity and storage.source.generation_id is None + assert storage.source.identity_sha256 == storage.root_identity.fingerprint + for attempt in ( + lambda: open_storage(root, "edge"), + lambda: attach_storage(root, "edge"), + lambda: offline_storage(root, "edge").__enter__(), + ): + with pytest.raises(StorageBusy): + attempt() + assert (root / "domus.sqlite3").read_bytes() == original + open_storage(root, "edge").close() + + +@pytest.mark.parametrize("serving", [True, False]) +def test_live_runtime_or_shared_console_refuses_offline_work_and_releases_partial_leases( + tmp_path, serving +): + root, _ = flat(tmp_path) + active = open_storage(root, "edge") if serving else attach_storage(root, "edge") + with pytest.raises(StorageBusy): + with offline_storage(root, "edge"): + pytest.fail("unexpected offline capability") + active.close() + with offline_storage(root, "edge"): + pass + + +def test_selected_generation_is_bound_under_exclusive_root_leases(tmp_path): + root, slot, selection = generation_fixture(tmp_path) + with offline_storage(root, "edge") as storage: + assert storage.database.path == slot / "domus.sqlite3" + assert storage.source.generation_id == selection.generation_id + assert storage.root_identity == selection.document() + assert storage.source.identity_sha256 == selection.document().fingerprint + + +def test_frozen_schema_three_is_inspected_without_migration_or_source_rewrite(tmp_path): + import json + + root = tmp_path / "root" + root.mkdir(mode=0o700) + identity = Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge") + database = root / "domus.sqlite3" + frozen = Path(__file__).resolve().parents[1] / "fixtures/sqlite/v3.sql" + with closing(sqlite3.connect(database, autocommit=True)) as connection: + connection.executescript(frozen.read_text()) + database.chmod(0o600) + marker = root / "installation.json" + marker.write_text( + json.dumps( + { + "schema_version": 1, + "instance_id": identity.instance_id, + "channel": "edge", + "state": "ready", + } + ) + ) + marker.chmod(0o600) + before = database.read_bytes() + with offline_storage(root, "edge") as storage: + assert storage.source.schema_version == 3 and storage.installation == identity + assert before == database.read_bytes() + + +@pytest.mark.parametrize( + "damage", + [ + "missing_identity", + "empty_database", + "missing_database", + "initializing", + "partial", + "slot_bypass", + "unknown", + ], +) +def test_existing_source_is_required_and_partial_or_unrelated_state_is_not_adopted( + tmp_path, damage +): + import json + + if damage == "slot_bypass": + _, root, _ = generation_fixture(tmp_path) + else: + root, _ = flat(tmp_path) + if damage == "missing_identity": + (root / "installation.json").unlink() + elif damage == "empty_database": + (root / "domus.sqlite3").write_bytes(b"") + elif damage == "missing_database": + (root / "domus.sqlite3").unlink() + elif damage == "initializing": + value = json.loads((root / "installation.json").read_text()) + value["state"] = "initializing" + (root / "installation.json").write_text(json.dumps(value)) + elif damage == "partial": + (root / "operations.jsonl").write_text("retained") + elif damage == "unknown": + (root / "foreign").write_text("retained") + before = {p.name: p.read_bytes() for p in root.iterdir() if p.is_file()} + with pytest.raises(DomusError): + with offline_storage(root, "edge"): + pytest.fail("adopted unsafe source") + for name, value in before.items(): + assert (root / name).read_bytes() == value + + +@pytest.mark.parametrize("timeout", [0, -1, True, float("nan"), float("inf"), 3601]) +def test_invalid_maintenance_budget_never_opens_a_directory(tmp_path, timeout): + with pytest.raises(InvalidInput): + with offline_storage(tmp_path / "absent", "edge", timeout=timeout): + pass + + +def test_unsafe_owner_channel_and_missing_root_do_not_acquire_offline_authority(tmp_path): + from domus.domain.errors import StorageFailure + + root, _ = flat(tmp_path) + with pytest.raises(InvalidInput): + with offline_storage(root, "wrong"): + pass + with pytest.raises(DomusError): + with offline_storage(root, "stable"): + pass + root.chmod(0o755) + with pytest.raises(DomusError): + with offline_storage(root, "edge"): + pass + with pytest.raises(StorageFailure): + with offline_storage(tmp_path / "absent", "edge"): + pass diff --git a/tests/backend/test_owned_generations.py b/tests/backend/test_owned_generations.py new file mode 100644 index 0000000..0ac1e98 --- /dev/null +++ b/tests/backend/test_owned_generations.py @@ -0,0 +1,294 @@ +"""Only the selected private generation can run under the installation-wide lease.""" + +import json +from dataclasses import replace + +import pytest + +from domus.domain.errors import DomusError, StorageBusy +from domus.domain.generations import GenerationSelection +from domus.infrastructure.owned_storage import ( + attach_database, + attach_storage, + open_storage, + reject_owned_database, +) +from domus.infrastructure.sqlite import SQLiteDatabase + + +def fixture(tmp_path): + root = tmp_path / "owned" + root.mkdir(mode=0o700) + initial = open_storage(root, "edge") + installation = initial.installation + initial.close() + generations = root / "generations" + generations.mkdir(mode=0o700) + selection = GenerationSelection(installation, "generation-1") + slot = generations / selection.generation_id + slot.mkdir(mode=0o700) + database = slot / "domus.sqlite3" + database.write_bytes((root / "domus.sqlite3").read_bytes()) + database.chmod(0o600) + marker = { + "schema_version": 1, + "instance_id": installation.instance_id, + "channel": "edge", + "state": "ready", + } + (slot / "installation.json").write_text(json.dumps(marker)) + (slot / "installation.json").chmod(0o600) + (slot / "generation.json").write_text(selection.document().encoded) + (slot / "generation.json").chmod(0o600) + (root / "installation.json").write_text(selection.document().encoded) + return root, slot, selection + + +def test_runtime_and_console_share_only_the_selected_generation_and_keep_root_leases(tmp_path): + root, slot, selection = fixture(tmp_path) + legacy = (root / "domus.sqlite3").read_bytes() + storage = open_storage(root, "edge") + assert ( + storage.installation == selection.installation + and storage.generation_id == selection.generation_id + ) + assert storage.database.path == slot / "domus.sqlite3" + with storage.database.read() as unit: + assert unit.households.current() is None + attached = attach_storage(root, "edge") + assert ( + attached.database.path == storage.database.path + and attached.generation_id == storage.generation_id + ) + attached.close() + with pytest.raises(StorageBusy): + open_storage(root, "edge") + storage.close() + restarted = open_storage(root, "edge") + assert restarted.database.path == slot / "domus.sqlite3" + restarted.close() + assert (root / "domus.sqlite3").read_bytes() == legacy + + +@pytest.mark.parametrize("missing_identity", [False, True]) +def test_slot_cannot_bypass_root_selection_by_any_direct_storage_path(tmp_path, missing_identity): + root, slot, selection = fixture(tmp_path) + if missing_identity: + (slot / "installation.json").unlink() + before = {p.name: p.read_bytes() for p in slot.iterdir()} + for attempt in ( + lambda: open_storage(slot, "edge"), + lambda: attach_storage(slot, "edge"), + lambda: reject_owned_database(slot / "domus.sqlite3"), + lambda: attach_database(slot / "domus.sqlite3", selection.installation), + ): + with pytest.raises(DomusError): + attempt() + assert before == {p.name: p.read_bytes() for p in slot.iterdir()} + + +@pytest.mark.parametrize( + "damage", + [ + "missing_database", + "empty_database", + "foreign_database", + "missing_slot", + "wrong_marker", + "missing_marker", + "pending_identity", + "extra", + "unsafe_slot", + "unsafe_parent", + "foreign_identity", + "nested_identity", + "corrupt_marker", + ], +) +def test_invalid_selection_never_falls_back_to_legacy_or_initializes_empty_data(tmp_path, damage): + root, slot, selection = fixture(tmp_path) + database = slot / "domus.sqlite3" + if damage == "missing_database": + database.unlink() + elif damage == "empty_database": + database.write_bytes(b"") + elif damage == "foreign_database": + other = tmp_path / "other.sqlite3" + SQLiteDatabase(other, replace(selection.installation, channel="stable")).initialize() + database.write_bytes(other.read_bytes()) + elif damage == "missing_slot": + slot.rename(slot.with_name("other")) + elif damage == "wrong_marker": + (slot / "generation.json").write_text( + replace(selection, generation_id="other").document().encoded + ) + elif damage == "missing_marker": + (slot / "generation.json").unlink() + elif damage == "pending_identity": + value = json.loads((slot / "installation.json").read_text()) + value["state"] = "restore_pending" + (slot / "installation.json").write_text(json.dumps(value)) + elif damage == "extra": + (slot / "unknown").write_text("retained") + elif damage == "unsafe_slot": + slot.chmod(0o755) + elif damage == "unsafe_parent": + slot.parent.chmod(0o755) + elif damage == "foreign_identity": + value = json.loads((slot / "installation.json").read_text()) + value["instance_id"] = "047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b7" + (slot / "installation.json").write_text(json.dumps(value)) + elif damage == "nested_identity": + (slot / "installation.json").write_text(selection.document().encoded) + else: + (slot / "generation.json").write_text("not JSON") + marker = (root / "installation.json").read_bytes() + legacy = (root / "domus.sqlite3").read_bytes() + before = database.read_bytes() if database.exists() else None + with pytest.raises(DomusError): + open_storage(root, "edge") + assert ( + marker == (root / "installation.json").read_bytes() + and legacy == (root / "domus.sqlite3").read_bytes() + ) + assert (database.read_bytes() if database.exists() else None) == before + + +def test_selection_is_reread_after_the_installation_lease_not_cached_before_it( + tmp_path, monkeypatch +): + from domus.infrastructure import owned_storage + + root, slot, selection = fixture(tmp_path) + later = replace(selection, generation_id="generation-2") + second = slot.with_name(later.generation_id) + second.mkdir(mode=0o700) + for name in ("domus.sqlite3", "installation.json"): + (second / name).write_bytes((slot / name).read_bytes()) + (second / name).chmod(0o600) + (second / "generation.json").write_text(later.document().encoded) + (second / "generation.json").chmod(0o600) + original = owned_storage._acquire + changed = False + + def publish_before_acquire(directory, name, mode): + nonlocal changed + if not changed: + (root / "installation.json").write_text(later.document().encoded) + changed = True + return original(directory, name, mode) + + monkeypatch.setattr(owned_storage, "_acquire", publish_before_acquire) + storage = open_storage(root, "edge") + try: + assert storage.generation_id == later.generation_id + assert storage.database.path == second / "domus.sqlite3" + finally: + storage.close() + + +@pytest.mark.parametrize("mode", ["standalone", "native"]) +def test_both_composed_apis_use_the_selected_slot_across_restart(tmp_path, monkeypatch, mode): + from fastapi.testclient import TestClient + from test_household_runtime import headers as standalone_headers + from test_household_runtime import runtime_at + from test_native_http import HEADERS, ORIGIN, Lookup + from test_native_http import headers as native_headers + + from domus.composition import ingress + + root, slot, selection = fixture(tmp_path) + old = (root / "domus.sqlite3").read_bytes() + + def start(): + if mode == "standalone": + runtime, client = runtime_at(root, monkeypatch, owned=True) + return runtime, client, standalone_headers + monkeypatch.setattr(ingress, "HomeAssistantWebSocket", lambda _: Lookup()) + runtime = ingress.create_owned_runtime("private-supervisor-token", "edge", root, (ORIGIN,)) + client = TestClient( + runtime.app, base_url=ORIGIN, client=("172.30.32.2", 12000), headers=dict(HEADERS) + ) + return runtime, client, native_headers + + payload = {"name": "Selected household", "time_zone": "UTC", "expected_revision": 0} + runtime, client, headers = start() + try: + result = client.post("/api/v1/household", json=payload, headers=headers(client, "setup")) + assert result.status_code == 200, result.text + original = result.json() + assert runtime.storage.database.path == slot / "domus.sqlite3" + finally: + runtime.close() + runtime, client, headers = start() + try: + assert client.get("/api/v1/household").json()["name"] == "Selected household" + assert ( + client.post("/api/v1/household", json=payload, headers=headers(client, "setup")).json() + == original + ) + assert runtime.storage.generation_id == selection.generation_id + finally: + runtime.close() + assert (root / "domus.sqlite3").read_bytes() == old + + +def test_snapshot_and_blocked_preparation_work_in_private_root_archives(tmp_path): + from test_sqlite_snapshots import Clock + + from domus.infrastructure.sqlite_restoration import SQLiteRestorations + from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + + root, _, selection = fixture(tmp_path) + archives = root / "archives" + archives.mkdir(mode=0o700) + runtime = open_storage(root, "edge") + attached = attach_storage(root, "edge") + try: + archive = archives / "manual-1" + snapshots = SQLiteSnapshots(attached.database, archive, selection.installation, Clock()) + checkpoint = snapshots.create("snapshot-1") + assert snapshots.inspect() == checkpoint + candidate = archives / "prepared-1" + restorations = SQLiteRestorations( + attached.database, archive, candidate, selection.installation, Clock(), checkpoint + ) + prepared = restorations.prepare("restore-1") + assert restorations.inspect() == prepared + finally: + attached.close() + runtime.close() + restarted = open_storage(root, "edge") + assert restarted.generation_id == selection.generation_id + restarted.close() + + +def test_selected_generation_does_not_require_or_recreate_retained_flat_database(tmp_path): + root, slot, _ = fixture(tmp_path) + (root / "domus.sqlite3").unlink() + storage = open_storage(root, "edge") + try: + assert storage.database.path == slot / "domus.sqlite3" + finally: + storage.close() + assert not (root / "domus.sqlite3").exists() + + +@pytest.mark.parametrize("name", ["generations", "slot", "archives"]) +def test_symlinked_generation_or_archive_directories_are_not_followed(tmp_path, name): + root, slot, _ = fixture(tmp_path) + outside = tmp_path / "outside" + outside.mkdir(mode=0o700) + if name == "generations": + original = root / "generations" + original.rename(outside / "data") + original.symlink_to(outside / "data", target_is_directory=True) + elif name == "slot": + slot.rename(outside / "data") + slot.symlink_to(outside / "data", target_is_directory=True) + else: + (root / "archives").symlink_to(outside, target_is_directory=True) + before = (root / "installation.json").read_bytes() + with pytest.raises(DomusError): + open_storage(root, "edge") + assert before == (root / "installation.json").read_bytes() diff --git a/tests/backend/test_owned_storage.py b/tests/backend/test_owned_storage.py new file mode 100644 index 0000000..40c268a --- /dev/null +++ b/tests/backend/test_owned_storage.py @@ -0,0 +1,550 @@ +"""Owned initialization preserves identity and never replaces lost data with an empty home.""" + +import json +import os +import sqlite3 +import subprocess +import sys +from contextlib import closing +from pathlib import Path + +import pytest + +from domus.domain.errors import DatabaseRejected, InvalidInput, StorageBusy, StorageFailure +from domus.domain.storage import Household +from domus.infrastructure.owned_storage import open_storage +from domus.infrastructure.sqlite import SQLiteDatabase + + +def directory(tmp_path: Path) -> Path: + root = tmp_path / "domus" + root.mkdir(mode=0o700) + return root + + +def marker(root: Path) -> dict: + return json.loads((root / "installation.json").read_text()) + + +def test_first_start_creates_private_identity_and_empty_schema_then_reuses_both( + tmp_path: Path, +) -> None: + root = directory(tmp_path) + first = open_storage(root, "edge") + identity = first.installation + assert marker(root) == { + "schema_version": 1, + "instance_id": identity.instance_id, + "channel": "edge", + "state": "ready", + } + assert first.database.path == root / "domus.sqlite3" + with first.database.write() as unit: + unit.households.save(Household("home", "Preserved home", "UTC", 1), 0) + for name in ("installation.json", "runtime.lock", "domus.sqlite3"): + assert (root / name).stat().st_mode & 0o777 == 0o600 + first.close() + first.close() + second = open_storage(root, "edge") + assert second.installation == identity + with second.database.read() as unit: + assert unit.households.current().name == "Preserved home" + second.close() + + +def test_channel_mismatch_does_not_rewrite_identity_or_database(tmp_path: Path) -> None: + root = directory(tmp_path) + owned = open_storage(root, "edge") + owned.close() + before = {name: (root / name).read_bytes() for name in ("installation.json", "domus.sqlite3")} + with pytest.raises(DatabaseRejected, match="installation_channel_mismatch"): + open_storage(root, "stable") + assert before == {name: (root / name).read_bytes() for name in before} + assert open_storage(root, "edge").close() is None + + +@pytest.mark.parametrize("artifact", ["domus.sqlite3", "domus.sqlite3-wal", ".installation.next"]) +def test_missing_identity_never_adopts_existing_artifact(tmp_path: Path, artifact: str) -> None: + root = directory(tmp_path) + (root / artifact).write_bytes(b"preexisting") + (root / artifact).chmod(0o600) + with pytest.raises(DatabaseRejected, match="missing_installation_identity"): + open_storage(root, "edge") + assert (root / artifact).read_bytes() == b"preexisting" + assert not (root / "installation.json").exists() + + +@pytest.mark.parametrize("loss", ["missing", "empty", "invalid", "foreign"]) +def test_ready_identity_cannot_recreate_or_adopt_lost_or_replaced_database( + tmp_path: Path, loss: str +) -> None: + root = directory(tmp_path) + owned = open_storage(root, "edge") + owned.close() + original = (root / "installation.json").read_bytes() + database = root / "domus.sqlite3" + if loss == "missing": + database.unlink() + elif loss == "empty": + database.write_bytes(b"") + elif loss == "invalid": + database.write_bytes(b"not sqlite") + else: + with closing(sqlite3.connect(database, autocommit=True)) as connection: + connection.execute( + "UPDATE installation SET instance_id='047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6'" + ) + before = database.read_bytes() if database.exists() else None + with pytest.raises((DatabaseRejected, StorageFailure)): + open_storage(root, "edge") + assert (root / "installation.json").read_bytes() == original + assert (database.read_bytes() if database.exists() else None) == before + + +@pytest.mark.parametrize("after_commit", [False, True]) +def test_interrupted_schema_initialization_resumes_only_the_persisted_uuid( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, after_commit: bool +) -> None: + root = directory(tmp_path) + original = SQLiteDatabase.initialize + + def interrupted(self, **kwargs): + if after_commit: + original(self, **kwargs) + raise StorageFailure("simulated_interruption") + + monkeypatch.setattr(SQLiteDatabase, "initialize", interrupted) + with pytest.raises(StorageFailure, match="simulated_interruption"): + open_storage(root, "edge") + pending = marker(root) + assert pending["state"] == "initializing" + monkeypatch.setattr(SQLiteDatabase, "initialize", original) + resumed = open_storage(root, "edge") + assert resumed.installation.instance_id == pending["instance_id"] + assert marker(root)["state"] == "ready" + with resumed.database.read() as unit: + assert unit.households.current() is None + resumed.close() + + +def test_same_directory_is_exclusively_owned_until_close_even_in_another_process( + tmp_path: Path, +) -> None: + root = directory(tmp_path) + owned = open_storage(root, "edge") + with pytest.raises(StorageBusy, match="installation_in_use"): + open_storage(root, "edge") + code = """from pathlib import Path +from domus.infrastructure.owned_storage import open_storage +from domus.domain.errors import StorageBusy +import sys +try: + open_storage(Path(sys.argv[1]), 'edge') +except StorageBusy as error: + assert error.code == 'installation_in_use' +else: + raise AssertionError('A second process obtained the owned directory') +""" + result = subprocess.run( + [sys.executable, "-c", code, str(root)], text=True, capture_output=True, timeout=10 + ) + assert result.returncode == 0, result.stderr + owned.close() + open_storage(root, "edge").close() + + +@pytest.mark.parametrize( + "content", + ["{}", "[]", "not JSON", '{"schema_version":1,"schema_version":1}', "\ud800", "x" * 1025], +) +def test_invalid_identity_is_preserved_and_never_replaced(tmp_path: Path, content: str) -> None: + root = directory(tmp_path) + file = root / "installation.json" + file.write_bytes(content.encode("utf-8", errors="surrogatepass")) + file.chmod(0o600) + original = file.read_bytes() + with pytest.raises(DatabaseRejected, match="invalid_installation_identity"): + open_storage(root, "edge") + assert file.read_bytes() == original + assert not (root / "domus.sqlite3").exists() + + +@pytest.mark.parametrize( + "field,value", + [ + ("schema_version", True), + ("schema_version", 2), + ("instance_id", "not-uuid"), + ("state", "unknown"), + ("channel", "preview"), + ], +) +def test_invalid_identity_fields_cannot_initialize_sqlite( + tmp_path: Path, field: str, value: object +) -> None: + root = directory(tmp_path) + value_object = { + "schema_version": 1, + "instance_id": "047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", + "channel": "edge", + "state": "ready", + } + value_object[field] = value + file = root / "installation.json" + file.write_text(json.dumps(value_object)) + file.chmod(0o600) + with pytest.raises(DatabaseRejected, match="invalid_installation_identity"): + open_storage(root, "edge") + assert not (root / "domus.sqlite3").exists() + + +def test_unknown_files_and_unsafe_root_do_not_receive_a_new_identity(tmp_path: Path) -> None: + root = directory(tmp_path) + (root / "unexpected").touch() + with pytest.raises(DatabaseRejected, match="unexpected_installation_artifact"): + open_storage(root, "edge") + assert not (root / "installation.json").exists() + (root / "unexpected").unlink() + root.chmod(0o755) + with pytest.raises(DatabaseRejected, match="unsafe_installation_directory"): + open_storage(root, "edge") + + +@pytest.mark.parametrize( + "name", ["runtime.lock", "installation.json", "domus.sqlite3", ".installation.next"] +) +@pytest.mark.parametrize("kind", ["symlink", "hardlink", "fifo", "shared_mode"]) +def test_special_linked_or_shared_files_are_not_opened_as_owned_state( + tmp_path: Path, name: str, kind: str +) -> None: + root = directory(tmp_path) + if name != "runtime.lock": + open_storage(root, "edge").close() + file = root / name + file.unlink(missing_ok=True) + outside = tmp_path / "outside" + outside.write_bytes(b"untouched") + outside.chmod(0o600) + if kind == "symlink": + file.symlink_to(outside) + elif kind == "hardlink": + os.link(outside, file) + elif kind == "fifo": + os.mkfifo(file, 0o600) + else: + file.write_bytes(b"unsafe") + file.chmod(0o644) + with pytest.raises((DatabaseRejected, StorageFailure)): + open_storage(root, "edge") + assert outside.read_bytes() == b"untouched" + + +def test_invalid_channel_and_missing_or_symlink_root_fail_before_initialization( + tmp_path: Path, +) -> None: + root = tmp_path / "missing" + with pytest.raises(InvalidInput, match="invalid_installation_channel"): + open_storage(root, "preview") + with pytest.raises(StorageFailure): + open_storage(root, "edge") + private = directory(tmp_path) + root.symlink_to(private, target_is_directory=True) + with pytest.raises(StorageFailure): + open_storage(root, "edge") + assert list(private.iterdir()) == [] + + +def test_failed_ready_marker_replacement_resumes_committed_schema_with_same_uuid( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = directory(tmp_path) + original = os.replace + + def fail(*args, **kwargs): + raise OSError("injected device error") + + monkeypatch.setattr(os, "replace", fail) + with pytest.raises(StorageFailure, match="storage_failure"): + open_storage(root, "edge") + pending = marker(root) + assert pending["state"] == "initializing" + assert (root / ".installation.next").is_file() + monkeypatch.setattr(os, "replace", original) + owned = open_storage(root, "edge") + assert owned.installation.instance_id == pending["instance_id"] + assert marker(root)["state"] == "ready" + assert not (root / ".installation.next").exists() + owned.close() + + +def test_ready_storage_discards_only_owned_staging_file_without_changing_identity( + tmp_path: Path, +) -> None: + root = directory(tmp_path) + owned = open_storage(root, "edge") + identity = owned.installation + owned.close() + staged = root / ".installation.next" + staged.write_text("interrupted staging") + staged.chmod(0o600) + restarted = open_storage(root, "edge") + assert restarted.installation == identity and not staged.exists() + restarted.close() + + +@pytest.mark.parametrize("stage", ["before_schema", "after_schema"]) +def test_process_death_releases_lock_and_resumes_only_durable_installation( + tmp_path: Path, stage: str +) -> None: + root = directory(tmp_path) + code = """from pathlib import Path +import os, sys +from domus.infrastructure.owned_storage import open_storage +from domus.infrastructure.sqlite import SQLiteDatabase +original = SQLiteDatabase.initialize +def die(self, **kwargs): + if sys.argv[2] == 'after_schema': + original(self, **kwargs) + os._exit(61) +SQLiteDatabase.initialize = die +open_storage(Path(sys.argv[1]), 'edge') +""" + stopped = subprocess.run( + [sys.executable, "-c", code, str(root), stage], capture_output=True, timeout=10 + ) + assert stopped.returncode == 61 + before = marker(root) + assert before["state"] == "initializing" + resumed = open_storage(root, "edge") + assert resumed.installation.instance_id == before["instance_id"] + assert marker(root)["state"] == "ready" + resumed.close() + + +def test_wrong_directory_or_file_owner_is_rejected_without_changing_os_identity( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = directory(tmp_path) + uid = os.getuid() + monkeypatch.setattr(os, "getuid", lambda: uid + 1) + with pytest.raises(DatabaseRejected, match="unsafe_installation_directory"): + open_storage(root, "edge") + calls = iter((uid, uid + 1)) + monkeypatch.setattr(os, "getuid", lambda: next(calls)) + with pytest.raises(DatabaseRejected, match="unsafe_installation_file"): + open_storage(root, "edge") + assert not (root / "installation.json").exists() + + +def test_ready_owned_directory_permits_explicit_v1_migration_without_recreating_state( + tmp_path: Path, +) -> None: + from test_sqlite_identity import V1 + + root = directory(tmp_path) + open_storage(root, "edge").close() + value = marker(root) + value["instance_id"] = "047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6" + (root / "installation.json").write_text(json.dumps(value)) + database = root / "domus.sqlite3" + database.unlink() + with closing(sqlite3.connect(database, autocommit=True)) as connection: + connection.executescript(V1.read_text()) + database.chmod(0o600) + owned = open_storage(root, "edge") + with owned.database.read() as unit: + home = unit.households.current() + assert home.name == "Existing household" and home.revision == 3 + assert unit.audit.entries("home")[0].operation_id == "old-operation" + assert unit.outbox.pending()[0].event_id == "old-operation" + owned.close() + + +def test_console_attachment_coexists_with_one_runtime_without_reinitializing_schema( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from domus.infrastructure.owned_storage import attach_storage + + root = directory(tmp_path) + runtime = open_storage(root, "edge") + + def forbidden(*args, **kwargs): + raise AssertionError("Console attachment must not initialize or migrate") + + monkeypatch.setattr(SQLiteDatabase, "initialize", forbidden) + console = attach_storage(root, "edge") + assert console.installation == runtime.installation + with console.database.write() as unit: + unit.households.save(Household("home", "Console operation", "UTC", 1), 0) + with runtime.database.read() as unit: + assert unit.households.current().name == "Console operation" + with pytest.raises(StorageBusy): + open_storage(root, "edge") + console.close() + runtime.close() + + +def test_console_guard_prevents_migration_until_attachment_closes(tmp_path: Path) -> None: + from domus.infrastructure.owned_storage import attach_storage + + root = directory(tmp_path) + open_storage(root, "edge").close() + console = attach_storage(root, "edge") + with pytest.raises(StorageBusy, match="installation_in_use"): + open_storage(root, "edge") + console.close() + open_storage(root, "edge").close() + + +@pytest.mark.parametrize("state", ["empty", "initializing", "wrong_channel", "missing_database"]) +def test_console_attachment_requires_ready_owned_data_and_never_creates_uuid_or_database( + tmp_path: Path, state: str +) -> None: + from domus.infrastructure.owned_storage import attach_storage + + root = directory(tmp_path) + if state != "empty": + open_storage(root, "edge").close() + if state == "initializing": + value = marker(root) + value["state"] = "initializing" + (root / "installation.json").write_text(json.dumps(value)) + elif state == "missing_database": + (root / "domus.sqlite3").unlink() + before = {p.name: p.read_bytes() for p in root.iterdir()} + with pytest.raises(DatabaseRejected): + attach_storage(root, "stable" if state == "wrong_channel" else "edge") + for name, value in before.items(): + assert (root / name).read_bytes() == value + if state in ("empty", "missing_database"): + assert not (root / "domus.sqlite3").exists() + + +def test_console_attachment_rejects_bad_channel_missing_directory_and_exclusive_maintenance( + tmp_path: Path, +) -> None: + import fcntl + + from domus.infrastructure.owned_storage import attach_storage + + root = directory(tmp_path) + open_storage(root, "edge").close() + with pytest.raises(InvalidInput): + attach_storage(root, "invalid") + with pytest.raises(StorageFailure): + attach_storage(tmp_path / "missing", "edge") + descriptor = os.open(root / "maintenance.lock", os.O_RDWR) + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + with pytest.raises(StorageBusy): + attach_storage(root, "edge") + finally: + os.close(descriptor) + + +def test_lock_io_failure_closes_descriptors_and_does_not_claim_ownership( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import fcntl + + root = directory(tmp_path) + original = fcntl.flock + + def fail(*args): + raise OSError("injected lock device error") + + monkeypatch.setattr(fcntl, "flock", fail) + with pytest.raises(StorageFailure): + open_storage(root, "edge") + monkeypatch.setattr(fcntl, "flock", original) + open_storage(root, "edge").close() + + +def test_explicit_console_attachment_validates_existing_private_database_without_initializing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from domus.domain.storage import Installation + from domus.infrastructure.owned_storage import attach_database + + path = tmp_path / "explicit.sqlite3" + identity = Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge") + SQLiteDatabase(path, identity).initialize() + + def forbidden(*args, **kwargs): + raise AssertionError("Attachment must not initialize") + + monkeypatch.setattr(SQLiteDatabase, "initialize", forbidden) + attached = attach_database(path, identity) + with attached.read() as unit: + assert unit.households.current() is None + with pytest.raises(StorageFailure): + attach_database(tmp_path / "missing", identity) + with pytest.raises(DatabaseRejected): + attach_database(path, Installation(identity.instance_id, "stable")) + + +def test_explicit_attachment_cannot_bypass_owned_protocol_or_shared_parent_permissions( + tmp_path: Path, +) -> None: + from domus.infrastructure.owned_storage import attach_database + + root = directory(tmp_path) + owned = open_storage(root, "edge") + identity = owned.installation + owned.close() + with pytest.raises(InvalidInput, match="owned_directory_required"): + attach_database(root / "domus.sqlite3", identity) + (root / "installation.json").unlink() + root.chmod(0o777) + with pytest.raises(DatabaseRejected, match="unsafe_installation_directory"): + attach_database(root / "domus.sqlite3", identity) + + +@pytest.mark.parametrize("runtime_kind", ["native", "standalone"]) +@pytest.mark.parametrize("active", [False, True]) +def test_explicit_runtime_cannot_bypass_owned_directory_lifecycle( + tmp_path: Path, runtime_kind: str, active: bool +) -> None: + from domus.composition import ingress, server + + root = directory(tmp_path) + owned = open_storage(root, "edge") + path, identity = owned.database.path, owned.installation + if not active: + owned.close() + before = path.read_bytes() + try: + with pytest.raises(InvalidInput, match="owned_directory_required"): + if runtime_kind == "native": + ingress.create_business_runtime( + "fixture-supervisor-token", + "edge", + path, + identity.instance_id, + ("http://homeassistant.local:8123",), + ) + else: + server.create_runtime( + "http://ha.local", + "https://domus.example", + identity.instance_id, + "edge", + database=path, + ) + assert path.read_bytes() == before + if active: + with pytest.raises(StorageBusy): + open_storage(root, "edge") + finally: + owned.close() + + +def test_explicit_runtime_guard_recognizes_even_a_broken_owned_marker(tmp_path: Path) -> None: + from domus.infrastructure.owned_storage import reject_owned_database + + root = directory(tmp_path) + (root / "installation.json").symlink_to(root / "absent") + with pytest.raises(InvalidInput, match="owned_directory_required"): + reject_owned_database(root / "domus.sqlite3") + with pytest.raises(StorageFailure): + reject_owned_database(tmp_path / "absent" / "database") diff --git a/tests/backend/test_policy_commands.py b/tests/backend/test_policy_commands.py new file mode 100644 index 0000000..6326ef9 --- /dev/null +++ b/tests/backend/test_policy_commands.py @@ -0,0 +1,362 @@ +"""Policy edits retain mapping provenance, current authority and an eligible administrator.""" + +from dataclasses import replace +from pathlib import Path + +import pytest +from test_member_commands import configured + +from domus.application.member_commands import UpdateMember +from domus.application.policy_commands import PolicyCommands, UpdatePolicy +from domus.domain.errors import Conflict, Forbidden, IdentityUnavailable, InvalidInput +from domus.domain.identity import ( + AdministratorMapping, + HumanIdentity, + MappingMode, + MemberStatus, + Permission, + ProfileRule, + Role, + allows, +) + + +def policies(tmp_path: Path) -> tuple: + members, store, actor, directory, member_id = configured(tmp_path) + commands = PolicyCommands(store, actor, directory, members.clock, members.identifiers) + return commands, members, store, actor, directory, member_id + + +def test_profile_adjustments_persist_with_revision_and_one_atomic_command(tmp_path: Path) -> None: + commands, members, store, _, directory, member_id = policies(tmp_path) + members.update("activate", UpdateMember(member_id, 1, Role.ADULT, MemberStatus.ACTIVE)) + adjustment = ProfileRule(Role.ADULT, Permission.SHOPPING_MANAGE, False) + request = UpdatePolicy(1, AdministratorMapping(MappingMode.ALL), (adjustment,)) + receipt = commands.update("policy", request) + with store.read() as unit: + policy = unit.policies.get("home") + member = unit.members.get("home", member_id) + assert policy.revision == 2 and policy.adjustments == (adjustment,) + assert not allows( + directory.people["ha_joiner"], member, policy, Permission.SHOPPING_MANAGE, "home" + ) + assert member.revision == 2 # Policy changes do not rewrite member records. + assert unit.receipts.get(receipt.command) == receipt + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 4 + assert receipt.result.decode() == { + "household_id": "home", + "revision": 2, + "mapping_mode": "all_ha_administrators", + } + + +def test_selected_mapping_grants_an_ordinary_active_human_and_revokes_only_inheritance( + tmp_path: Path, +) -> None: + commands, members, store, actor, directory, member_id = policies(tmp_path) + members.update("activate", UpdateMember(member_id, 1, Role.VIEWER, MemberStatus.ACTIVE)) + request = UpdatePolicy(1, AdministratorMapping(MappingMode.SELECTED, "ha_joiner")) + commands.update("select", request) + with store.read() as unit: + policy = unit.policies.get("home") + original = unit.members.by_user("home", "ha_admin") + selected = unit.members.get("home", member_id) + assert not allows(actor.human, original, policy, Permission.POLICY_MANAGE, "home") + assert allows( + directory.people["ha_joiner"], selected, policy, Permission.POLICY_MANAGE, "home" + ) + assert original.role is selected.role is Role.VIEWER + with pytest.raises(Forbidden): + commands.update("select", request) + actor.human = directory.people["ha_joiner"] + commands.update("all", UpdatePolicy(2, AdministratorMapping(MappingMode.ALL))) + with store.read() as unit: + assert not allows( + actor.human, + unit.members.get("home", member_id), + unit.policies.get("home"), + Permission.POLICY_MANAGE, + "home", + ) + assert directory.batch_calls == [("ha_admin",)] + + +def test_mapping_change_preserves_independently_granted_administrator_profile( + tmp_path: Path, +) -> None: + commands, members, store, actor, directory, member_id = policies(tmp_path) + members.update("explicit", UpdateMember(member_id, 1, Role.ADMINISTRATOR, MemberStatus.ACTIVE)) + commands.update( + "select", UpdatePolicy(1, AdministratorMapping(MappingMode.SELECTED, "ha_joiner")) + ) + actor.human = directory.people["ha_joiner"] + directory.error = IdentityUnavailable("ha_unavailable") + commands.update("all", UpdatePolicy(2, AdministratorMapping(MappingMode.ALL))) + with store.read() as unit: + assert allows( + actor.human, + unit.members.get("home", member_id), + unit.policies.get("home"), + Permission.POLICY_MANAGE, + "home", + ) + + +def test_setup_mapping_is_bound_to_recorded_setup_actor(tmp_path: Path) -> None: + commands, members, store, _, _, member_id = policies(tmp_path) + members.update("activate", UpdateMember(member_id, 1, Role.ADULT, MemberStatus.ACTIVE)) + with pytest.raises(InvalidInput, match="setup_administrator_mismatch"): + commands.update( + "retarget", UpdatePolicy(1, AdministratorMapping(MappingMode.SETUP, "ha_joiner")) + ) + commands.update( + "setup-only", UpdatePolicy(1, AdministratorMapping(MappingMode.SETUP, "ha_admin")) + ) + with store.read() as unit: + assert unit.policies.get("home").mapping == AdministratorMapping( + MappingMode.SETUP, "ha_admin" + ) + assert unit.audit.setup_actor("home") == "ha_admin" + assert unit.audit.setup_actor("another-home") is None + + +@pytest.mark.parametrize("target", ["ha_joiner", "missing"]) +def test_selected_mapping_requires_existing_active_linkage_before_directory_lookup( + tmp_path: Path, target: str +) -> None: + commands, _, store, _, directory, _ = policies(tmp_path) + with pytest.raises(Conflict, match="mapping_target_inactive"): + commands.update( + "select", UpdatePolicy(1, AdministratorMapping(MappingMode.SELECTED, target)) + ) + assert directory.calls == directory.batch_calls == [] + with store.read() as unit: + assert unit.policies.get("home").revision == 1 + assert len(unit.audit.entries("home")) == 2 + + +def test_current_source_changes_during_target_proof_block_policy_edit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + commands, members, store, actor, directory, member_id = policies(tmp_path) + members.update("activate", UpdateMember(member_id, 1, Role.ADULT, MemberStatus.ACTIVE)) + original = directory.get + + def lookup(user_id: str) -> HumanIdentity: + with store.write() as unit: + assert unit.policies.get("home").revision == 1 + actor.human = replace(actor.human, is_administrator=False) + return original(user_id) + + monkeypatch.setattr(directory, "get", lookup) + with pytest.raises(Forbidden): + commands.update( + "select", UpdatePolicy(1, AdministratorMapping(MappingMode.SELECTED, "ha_joiner")) + ) + with store.read() as unit: + assert unit.policies.get("home").revision == 1 + assert len(unit.audit.entries("home")) == 3 + + +def test_local_inventory_changed_during_target_proof_is_a_conflict( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + commands, members, store, _, directory, member_id = policies(tmp_path) + members.update("activate", UpdateMember(member_id, 1, Role.ADULT, MemberStatus.ACTIVE)) + original = directory.get + + def lookup(user_id: str) -> HumanIdentity: + with store.write() as unit: + member = unit.members.get("home", member_id) + unit.members.save(replace(member, status=MemberStatus.SUSPENDED, revision=3), 2) + return original(user_id) + + monkeypatch.setattr(directory, "get", lookup) + with pytest.raises(Conflict, match="identity_state_changed"): + commands.update( + "select", UpdatePolicy(1, AdministratorMapping(MappingMode.SELECTED, "ha_joiner")) + ) + with store.read() as unit: + assert unit.policies.get("home").revision == 1 + + +def test_adjustment_order_is_canonical_and_authorized_replay_ignores_old_revision( + tmp_path: Path, +) -> None: + commands, _, _, _, directory, _ = policies(tmp_path) + rules = ( + ProfileRule(Role.VIEWER, Permission.SHOPPING_REQUEST, True), + ProfileRule(Role.ADULT, Permission.INVENTORY_MANAGE, False), + ) + request = UpdatePolicy(1, AdministratorMapping(MappingMode.ALL), rules) + original = commands.update("edit", request) + directory.error = IdentityUnavailable("ha_unavailable") + assert commands.update("edit", replace(request, adjustments=tuple(reversed(rules)))) == original + with pytest.raises(Conflict, match="idempotency_conflict"): + commands.update("edit", replace(request, adjustments=())) + with pytest.raises(Conflict, match="revision_conflict"): + commands.update("another", request) + + +@pytest.mark.parametrize( + "values", + [ + {"expected_revision": 0}, + {"expected_revision": True}, + {"mapping": "all_ha_administrators"}, + {"adjustments": []}, + {"adjustments": (ProfileRule(Role.ADULT, Permission.HOUSEHOLD_READ, False),) * 2}, + ], +) +def test_policy_contract_rejects_ambiguous_revision_mapping_and_rules(values: dict) -> None: + with pytest.raises(InvalidInput): + UpdatePolicy( + **{"expected_revision": 1, "mapping": AdministratorMapping(MappingMode.ALL), **values} + ) + + +def test_setup_mapping_rejects_a_founder_who_is_no_longer_ha_administrator(tmp_path: Path) -> None: + commands, members, store, actor, directory, member_id = policies(tmp_path) + members.update("promote", UpdateMember(member_id, 1, Role.ADMINISTRATOR, MemberStatus.ACTIVE)) + actor.human = directory.people["ha_joiner"] + directory.people["ha_admin"] = HumanIdentity("ha_admin", False, True, False) + with pytest.raises(Forbidden, match="ha_administrator_required"): + commands.update( + "setup-only", UpdatePolicy(1, AdministratorMapping(MappingMode.SETUP, "ha_admin")) + ) + with store.read() as unit: + assert unit.policies.get("home").revision == 1 + assert len(unit.audit.entries("home")) == 3 + + +def test_switching_away_from_selected_mapping_cannot_remove_the_last_administrator( + tmp_path: Path, +) -> None: + commands, members, store, actor, directory, member_id = policies(tmp_path) + members.update("activate", UpdateMember(member_id, 1, Role.VIEWER, MemberStatus.ACTIVE)) + commands.update( + "select", UpdatePolicy(1, AdministratorMapping(MappingMode.SELECTED, "ha_joiner")) + ) + actor.human = directory.people["ha_joiner"] + del directory.people["ha_admin"] + with pytest.raises(Conflict, match="last_administrator"): + commands.update("all", UpdatePolicy(2, AdministratorMapping(MappingMode.ALL))) + with store.read() as unit: + assert unit.policies.get("home").mapping.mode is MappingMode.SELECTED + assert unit.policies.get("home").revision == 2 + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 4 + + +def test_policy_write_blocks_on_source_outage_without_changing_current_mapping( + tmp_path: Path, +) -> None: + commands, members, store, _, directory, member_id = policies(tmp_path) + members.update("activate", UpdateMember(member_id, 1, Role.VIEWER, MemberStatus.ACTIVE)) + directory.error = IdentityUnavailable("ha_unavailable") + with pytest.raises(IdentityUnavailable): + commands.update( + "select", UpdatePolicy(1, AdministratorMapping(MappingMode.SELECTED, "ha_joiner")) + ) + with store.read() as unit: + assert unit.policies.get("home").mapping.mode is MappingMode.ALL + assert unit.policies.get("home").revision == 1 + + +@pytest.mark.parametrize("damage", ["missing", "duplicate", "malformed"]) +def test_missing_or_corrupt_setup_provenance_never_designates_a_new_founder( + tmp_path: Path, damage: str +) -> None: + import sqlite3 + from contextlib import closing + + from domus.domain.errors import StorageFailure + + commands, _, store, _, _, _ = policies(tmp_path) + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + if damage == "missing": + connection.execute("DELETE FROM audit WHERE action='household.setup'") + elif damage == "duplicate": + connection.execute( + "INSERT INTO audit SELECT 'duplicate-setup',household_id,actor_id,action," + "command_key,detail,created_at FROM audit WHERE action='household.setup'" + ) + else: + connection.execute( + "UPDATE audit SET actor_id='invalid/actor' WHERE action='household.setup'" + ) + error, code = ( + (Conflict, "setup_provenance_required") + if damage == "missing" + else (StorageFailure, "invalid_setup_provenance") + ) + with pytest.raises(error, match=code): + commands.update( + "setup-only", UpdatePolicy(1, AdministratorMapping(MappingMode.SETUP, "ha_admin")) + ) + with store.read() as unit: + assert unit.policies.get("home").revision == 1 + assert len(unit.outbox.pending()) == 2 + + +def test_unbounded_member_inventory_cannot_prepare_a_policy_change(tmp_path: Path) -> None: + from domus.domain.errors import StorageFailure + + commands, _, store, _, directory, member_id = policies(tmp_path) + with store.write() as unit: + member = unit.members.get("home", member_id) + for number in range(255): + unit.members.save( + replace(member, member_id=f"overflow{number}", ha_user_id=f"human{number}"), 0 + ) + with pytest.raises(StorageFailure, match="invalid_identity_record"): + commands.update("edit", UpdatePolicy(1, AdministratorMapping(MappingMode.ALL))) + assert directory.calls == directory.batch_calls == [] + + +def test_policy_changed_while_resolving_target_is_not_overwritten( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + commands, members, store, _, directory, member_id = policies(tmp_path) + members.update("activate", UpdateMember(member_id, 1, Role.VIEWER, MemberStatus.ACTIVE)) + original = directory.get + + def lookup(user_id: str) -> HumanIdentity: + commands.update( + "concurrent", + UpdatePolicy( + 1, + AdministratorMapping(MappingMode.ALL), + (ProfileRule(Role.VIEWER, Permission.SHOPPING_REQUEST, True),), + ), + ) + return original(user_id) + + monkeypatch.setattr(directory, "get", lookup) + with pytest.raises(Conflict, match="identity_state_changed"): + commands.update( + "select", UpdatePolicy(1, AdministratorMapping(MappingMode.SELECTED, "ha_joiner")) + ) + with store.read() as unit: + assert unit.policies.get("home").revision == 2 + assert unit.policies.get("home").mapping.mode is MappingMode.ALL + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 4 + + +def test_restart_preserves_policy_revision_provenance_and_authorized_replay(tmp_path: Path) -> None: + from domus.infrastructure.sqlite import SQLiteDatabase + + commands, _, store, actor, directory, _ = policies(tmp_path) + request = UpdatePolicy(1, AdministratorMapping(MappingMode.SETUP, "ha_admin")) + original = commands.update("setup-only", request) + restarted = PolicyCommands( + SQLiteDatabase(store.path, store.installation), + actor, + directory, + commands.clock, + commands.identifiers, + ) + directory.error = IdentityUnavailable("ha_unavailable") + assert restarted.update("setup-only", request) == original + with restarted.transactions.read() as unit: + assert unit.audit.setup_actor("home") == "ha_admin" + assert unit.policies.get("home").revision == 2 diff --git a/tests/backend/test_recovery_commands.py b/tests/backend/test_recovery_commands.py new file mode 100644 index 0000000..983ca93 --- /dev/null +++ b/tests/backend/test_recovery_commands.py @@ -0,0 +1,450 @@ +"""Recovery requires current HA proof, an exact console decision and atomic consumption.""" + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from pathlib import Path +from threading import Barrier + +import pytest +from test_household_commands import fixture + +from domus.application.household_commands import SetupHousehold +from domus.application.recovery_commands import RecoveryCommands, RecoveryConsole, RecoveryQueries +from domus.domain.errors import ( + Conflict, + Forbidden, + IdentityUnavailable, + InvalidInput, + NotFound, + StorageFailure, +) +from domus.domain.identity import HumanIdentity, MappingMode, Member, MemberStatus, Role +from domus.domain.storage import Installation +from domus.infrastructure.sqlite import SQLiteOutbox + + +class Clock: + time = 1000 + + def now(self) -> int: + return self.time + + +class Console: + accepted = True + before = None + + def __init__(self): + self.reviews = [] + + def confirm(self, request, member): + self.reviews.append((request, member)) + if self.before: + self.before() + return self.accepted + + +def configured(tmp_path: Path, mode: MappingMode = MappingMode.SETUP) -> tuple: + household, store, actor, _, identifiers = fixture(tmp_path / "database") + target = "ha_selected" if mode is MappingMode.SELECTED else None + household.setup("setup", SetupHousehold("Home", "UTC", mode, target)) + actor.human = HumanIdentity("ha_new", True, True, False) + clock, console = Clock(), Console() + commands = RecoveryCommands(store, actor, store.installation, clock, identifiers) + approval = RecoveryConsole(store, store.installation, clock, identifiers, console) + return commands, approval, store, actor, clock, console + + +def start(commands) -> str: + return commands.start("start").result.decode()["request_id"] + + +@pytest.mark.parametrize("mode", [MappingMode.SETUP, MappingMode.SELECTED]) +def test_recovery_grants_only_explicit_administration_after_console_and_fresh_proof( + tmp_path: Path, mode: MappingMode +) -> None: + commands, approval, store, actor, clock, console = configured(tmp_path, mode) + request_id = start(commands) + with store.read() as unit: + original_policy = unit.policies.get("home") + assert unit.members.by_user("home", "ha_new") is None + with pytest.raises(Conflict, match="recovery_not_approved"): + commands.complete("complete", request_id, 1) + approved = approval.approve(request_id) + assert len(console.reviews) == 1 + assert console.reviews[0][0].target_ha_user_id == "ha_new" + assert approval.approve(request_id) == approved and len(console.reviews) == 1 + finished = commands.complete("complete", request_id, 2) + result = finished.result.decode() + assert result["state"] == "recovered" and result["role"] == "administrator" + with store.read() as unit: + member = unit.members.by_user("home", "ha_new") + assert member.role is Role.ADMINISTRATOR and member.status is MemberStatus.ACTIVE + assert unit.policies.get("home") == original_policy + assert unit.recoveries.get("home", request_id).state.value == "consumed" + assert unit.receipts.get(finished.command) == finished + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 4 + clock.time = 900000 + assert commands.complete("complete", request_id, 2) == finished + with pytest.raises(Conflict, match="revision_conflict"): + commands.complete("another-key", request_id, 2) + assert ( + RecoveryQueries(store, actor.human, store.installation, clock) + .get(request_id) + .decode()["state"] + == "consumed" + ) + + +@pytest.mark.parametrize("status", list(MemberStatus)) +def test_existing_link_is_explicitly_restored_without_relinking_or_duplicate_member( + tmp_path: Path, status: MemberStatus +) -> None: + commands, approval, store, _, _, _ = configured(tmp_path) + with store.write() as unit: + unit.members.save(Member("existing", "home", "ha_new", Role.RESTRICTED, status, 1), 0) + request_id = start(commands) + approval.approve(request_id) + receipt = commands.complete("complete", request_id, 2) + with store.read() as unit: + person = unit.members.by_user("home", "ha_new") + assert person.member_id == "existing" and person.revision == 2 + assert person.role is Role.ADMINISTRATOR and person.status is MemberStatus.ACTIVE + assert len([m for m in unit.members.list("home") if m.ha_user_id == "ha_new"]) == 1 + assert receipt.result.decode()["member_id"] == "existing" + + +@pytest.mark.parametrize("failure", ["not_ha_admin", "all_mapping", "outage"]) +def test_request_requires_selected_or_setup_mode_and_current_ha_administrator( + tmp_path: Path, failure: str +) -> None: + commands, _, store, actor, _, _ = configured( + tmp_path, MappingMode.ALL if failure == "all_mapping" else MappingMode.SETUP + ) + if failure == "not_ha_admin": + actor.human = replace(actor.human, is_administrator=False) + if failure == "outage": + actor.error = IdentityUnavailable("ha_unavailable") + with pytest.raises((Forbidden, IdentityUnavailable)): + commands.start("start") + with store.read() as unit: + assert unit.recoveries.pending("home", 1000) == () + assert len(unit.audit.entries("home")) == 1 + + +def test_denied_console_confirmation_never_creates_approval_or_grant(tmp_path: Path) -> None: + commands, approval, store, _, _, console = configured(tmp_path) + request_id = start(commands) + console.accepted = False + with pytest.raises(Forbidden, match="console_confirmation_required"): + approval.approve(request_id) + with store.read() as unit: + assert unit.recoveries.get("home", request_id).state.value == "pending" + assert len(unit.audit.entries("home")) == 2 + + +@pytest.mark.parametrize("stage", ["approval", "completion"]) +def test_expiration_does_not_extend_on_approval_and_grants_nothing( + tmp_path: Path, stage: str +) -> None: + commands, approval, store, _, clock, _ = configured(tmp_path) + request_id = start(commands) + if stage == "completion": + approval.approve(request_id) + clock.time = 601000 + with pytest.raises(Conflict, match="recovery_expired"): + approval.approve(request_id) if stage == "approval" else commands.complete( + "finish", request_id, 2 + ) + with store.read() as unit: + assert unit.members.by_user("home", "ha_new") is None + assert commands.status(request_id).decode()["state"] == "expired" + + +@pytest.mark.parametrize("change", ["policy", "member", "installation", "ha_admin", "actor"]) +def test_approval_cannot_be_retargeted_or_used_after_source_or_binding_change( + tmp_path: Path, change: str +) -> None: + commands, approval, store, actor, _, _ = configured(tmp_path) + request_id = start(commands) + approval.approve(request_id) + if change == "policy": + with store.write() as unit: + policy = unit.policies.get("home") + unit.policies.save(replace(policy, revision=2), 1) + elif change == "member": + with store.write() as unit: + unit.members.save( + Member("new", "home", "ha_new", Role.VIEWER, MemberStatus.PENDING, 1), 0 + ) + elif change == "installation": + commands.installation = Installation(store.installation.instance_id, "stable") + elif change == "ha_admin": + actor.human = replace(actor.human, is_administrator=False) + else: + actor.human = HumanIdentity("other", True, True, False) + with pytest.raises((Conflict, Forbidden, NotFound)): + commands.complete("finish", request_id, 2) + with store.read() as unit: + assert unit.recoveries.get("home", request_id).state.value == "approved" + + +def test_policy_change_during_terminal_confirmation_is_detected_before_approval( + tmp_path: Path, +) -> None: + commands, approval, store, _, _, console = configured(tmp_path) + request_id = start(commands) + + def change(): + with store.write() as unit: + policy = unit.policies.get("home") + unit.policies.save(replace(policy, revision=2), 1) + + console.before = change + with pytest.raises(Conflict, match="recovery_binding_changed"): + approval.approve(request_id) + with store.read() as unit: + assert unit.recoveries.get("home", request_id).state.value == "pending" + + +def test_late_outbox_failure_rolls_back_consumption_grant_and_all_evidence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + commands, approval, store, _, _, _ = configured(tmp_path) + request_id = start(commands) + approval.approve(request_id) + original = SQLiteOutbox.append + + def fail(self, event): + original(self, event) + if event.event_type == "recovery.complete": + raise StorageFailure("injected_outbox_failure") + + monkeypatch.setattr(SQLiteOutbox, "append", fail) + with pytest.raises(StorageFailure): + commands.complete("finish", request_id, 2) + with store.read() as unit: + assert unit.recoveries.get("home", request_id).state.value == "approved" + assert unit.members.by_user("home", "ha_new") is None + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 3 + monkeypatch.setattr(SQLiteOutbox, "append", original) + commands.complete("finish", request_id, 2) + + +def test_only_request_owner_can_read_or_cancel_and_cancellation_prevents_grant( + tmp_path: Path, +) -> None: + commands, approval, store, actor, _, _ = configured(tmp_path) + request_id = start(commands) + approval.approve(request_id) + actor.human = HumanIdentity("other", True, True, False) + with pytest.raises(NotFound): + commands.status(request_id) + with pytest.raises(NotFound): + commands.cancel("cancel", request_id, 2) + actor.human = HumanIdentity("ha_new", False, True, False) + canceled = commands.cancel("cancel", request_id, 2) + assert commands.cancel("cancel", request_id, 2) == canceled + actor.human = replace(actor.human, is_administrator=True) + with pytest.raises(Conflict): + commands.complete("finish", request_id, 3) + with store.read() as unit: + assert unit.members.by_user("home", "ha_new") is None + + +@pytest.mark.parametrize("same_key", [True, False]) +def test_concurrent_completion_has_one_grant_and_one_consumed_request( + tmp_path: Path, same_key: bool +) -> None: + commands, approval, store, actor, _, _ = configured(tmp_path) + request_id = start(commands) + approval.approve(request_id) + barrier = Barrier(2) + original = actor.current + + def current(): + human = original() + barrier.wait(timeout=5) + return human + + actor.current = current + + def finish(key): + try: + return commands.complete(key, request_id, 2) + except Conflict as error: + return error.code + + with ThreadPoolExecutor(max_workers=2) as pool: + values = list(pool.map(finish, ["finish", "finish" if same_key else "other-key"])) + if same_key: + assert values[0] == values[1] + else: + assert sum(not isinstance(v, str) for v in values) == 1 + with store.read() as unit: + assert unit.recoveries.get("home", request_id).state.value == "consumed" + assert len(unit.audit.entries("home")) == 4 + + +def test_request_replay_is_stable_and_other_keys_cannot_flood_pending_requests( + tmp_path: Path, +) -> None: + commands, _, _, _, _, _ = configured(tmp_path) + first = commands.start("start") + assert commands.start("start") == first + with pytest.raises(Conflict, match="recovery_pending"): + commands.start("other-key") + with pytest.raises(InvalidInput) as rejected: + commands.start("bad", True) + assert rejected.value.code == "invalid_revision" + + +def test_missing_household_or_request_never_acquires_recovery_authority(tmp_path: Path) -> None: + household, store, actor, _, identifiers = fixture(tmp_path / "empty") + commands = RecoveryCommands(store, actor, store.installation, Clock(), identifiers) + with pytest.raises(Forbidden, match="recovery_unavailable"): + commands.start("start") + commands, approval, _, _, _, _ = configured(tmp_path) + with pytest.raises(NotFound, match="recovery_missing"): + commands.status("missing") + with pytest.raises(NotFound, match="recovery_missing"): + approval.approve("missing") + + +def test_active_request_capacity_is_bounded_per_household(tmp_path: Path) -> None: + from domus.domain.recovery import RecoveryRequest + + commands, _, store, _, _, _ = configured(tmp_path) + with store.write() as unit: + policy = unit.policies.get("home") + for i in range(256): + unit.recoveries.save( + RecoveryRequest( + f"request-{i}", + "home", + f"ha_other{i}", + store.installation, + policy.mapping, + 1, + None, + 0, + 1000, + 601000, + ), + 0, + ) + with pytest.raises(Conflict, match="recovery_capacity"): + commands.start("start") + + +def test_member_capacity_and_cancellation_revision_conflict_preserve_request( + tmp_path: Path, +) -> None: + commands, approval, store, _, _, _ = configured(tmp_path) + request_id = start(commands) + approval.approve(request_id) + with store.write() as unit: + for i in range(255): + unit.members.save( + Member(f"member-{i}", "home", f"ha_other{i}", Role.VIEWER, MemberStatus.ACTIVE, 1), + 0, + ) + with pytest.raises(Conflict, match="member_capacity"): + commands.complete("finish", request_id, 2) + with pytest.raises(Conflict, match="revision_conflict"): + commands.cancel("cancel", request_id, 1) + with store.read() as unit: + assert unit.recoveries.get("home", request_id).state.value == "approved" + + +def test_canceled_request_cannot_receive_console_approval(tmp_path: Path) -> None: + commands, approval, _, _, _, _ = configured(tmp_path) + request_id = start(commands) + commands.cancel("cancel", request_id, 1) + with pytest.raises(Conflict, match="recovery_closed"): + approval.approve(request_id) + + +@pytest.mark.parametrize("actor,action", [("other", "recovery.request"), ("ha_new", "unexpected")]) +def test_human_recovery_authority_cannot_authorize_a_forged_actor_or_action( + tmp_path: Path, actor: str, action: str +) -> None: + from domus.application.recovery_commands import _HumanAuthority + from domus.domain.storage import CommandKey + + _, _, store, human, _, _ = configured(tmp_path) + authority = _HumanAuthority(human.human, store.installation, "recovery.request") + with pytest.raises(Forbidden): + with store.read() as unit: + authority.require(CommandKey("home", actor, action, "key"), unit) + unknown = _HumanAuthority(human.human, store.installation, "unexpected") + with pytest.raises(Forbidden): + with store.read() as unit: + unknown.require(CommandKey("home", "ha_new", "unexpected", "key"), unit) + + +@pytest.mark.parametrize( + "actor,action,key", + [ + ("other", "recovery.approve", "request"), + ("installation-console", "unexpected", "request"), + ("installation-console", "recovery.approve", "other"), + ], +) +def test_console_authority_requires_its_exact_operation_identity( + tmp_path: Path, actor: str, action: str, key: str +) -> None: + from domus.application.recovery_commands import _ConsoleAuthority + from domus.domain.storage import CommandKey + + _, _, store, _, clock, _ = configured(tmp_path) + authority = _ConsoleAuthority(store.installation, clock, "request") + with pytest.raises(Forbidden): + with store.read() as unit: + authority.require(CommandKey("home", actor, action, key), unit) + + +def test_expiry_while_operator_reviews_the_terminal_never_commits_approval(tmp_path: Path) -> None: + commands, approval, store, _, clock, console = configured(tmp_path) + request_id = start(commands) + console.before = lambda: setattr(clock, "time", 601000) + with pytest.raises(Conflict, match="recovery_expired"): + approval.approve(request_id) + with store.read() as unit: + assert unit.recoveries.get("home", request_id).state.value == "pending" + assert len(unit.audit.entries("home")) == 2 + + +def test_completed_recovery_receipt_is_denied_after_current_local_administration_is_removed( + tmp_path: Path, +) -> None: + commands, approval, store, _, _, _ = configured(tmp_path) + request_id = start(commands) + approval.approve(request_id) + completed = commands.complete("finish", request_id, 2) + with store.write() as unit: + member = unit.members.by_user("home", "ha_new") + unit.members.save( + replace(member, role=Role.VIEWER, revision=member.revision + 1), member.revision + ) + with pytest.raises(Forbidden, match="permission_denied"): + commands.complete("finish", request_id, 2) + with store.read() as unit: + assert unit.receipts.get(completed.command) == completed + assert unit.recoveries.get("home", request_id).state.value == "consumed" + assert len(unit.audit.entries("home")) == 4 + + +def test_approval_has_ten_full_minutes_from_confirmation_and_replay_never_extends_it( + tmp_path: Path, +) -> None: + commands, approval, store, _, clock, _ = configured(tmp_path) + request_id = start(commands) + clock.time = 500000 + receipt = approval.approve(request_id) + assert receipt.result.decode()["expires_at"] == 1100000 + clock.time = 601000 + assert approval.approve(request_id) == receipt + assert commands.complete("finish", request_id, 2).result.decode()["state"] == "recovered" + with store.read() as unit: + assert unit.recoveries.get("home", request_id).expires_at == 1100000 diff --git a/tests/backend/test_recovery_console.py b/tests/backend/test_recovery_console.py new file mode 100644 index 0000000..fddaa1f --- /dev/null +++ b/tests/backend/test_recovery_console.py @@ -0,0 +1,277 @@ +"""Real console composition requires explicit terminal confirmation and keeps approval local.""" + +import io +import runpy +import sys +from pathlib import Path + +import pytest +from test_recovery_commands import configured, start + +from domus.composition import admin + + +class Terminal(io.StringIO): + def isatty(self) -> bool: + return True + + +def options(path: Path, identity) -> list[str]: + return [ + "--channel", + identity.channel, + "--database", + str(path), + "--instance-id", + identity.instance_id, + "recovery", + "approve", + ] + + +def test_explicit_console_approves_exact_displayed_target_and_cannot_complete_human_step( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + commands, _, store, _, clock, _ = configured(tmp_path) + request_id = start(commands) + monkeypatch.setattr(admin, "SystemClock", lambda: clock) + output, error = Terminal(), io.StringIO() + result = admin.main( + [*options(store.path, store.installation), request_id], + input_stream=Terminal(f"APPROVE {request_id} ha_new\n"), + output_stream=output, + error_stream=error, + ) + assert result == 0 and error.getvalue() == "" + assert store.installation.instance_id in output.getvalue() + assert "ha_new" in output.getvalue() and "setup_administrator" in output.getvalue() + with store.read() as unit: + assert unit.recoveries.get("home", request_id).state.value == "approved" + assert unit.members.by_user("home", "ha_new") is None + assert commands.complete("finish", request_id, 2).result.decode()["state"] == "recovered" + + +@pytest.mark.parametrize( + "answer,tty", + [ + ("yes\n", True), + ("", True), + ("APPROVE wrong wrong\n", True), + ("ignored", False), + ("x" * 2000, True), + ], +) +def test_console_refuses_wrong_empty_oversized_or_noninteractive_confirmation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, answer: str, tty: bool +) -> None: + commands, _, store, _, clock, _ = configured(tmp_path) + request_id = start(commands) + monkeypatch.setattr(admin, "SystemClock", lambda: clock) + incoming = Terminal(answer) if tty else io.StringIO(answer) + output, error = Terminal(), io.StringIO() + assert ( + admin.main( + [*options(store.path, store.installation), request_id], + input_stream=incoming, + output_stream=output, + error_stream=error, + ) + == 2 + ) + assert "Existing records are retained" in error.getvalue() + with store.read() as unit: + assert unit.recoveries.get("home", request_id).state.value == "pending" + + +def test_console_rejects_competing_configuration_without_touching_files(tmp_path: Path) -> None: + root = tmp_path / "owned" + root.mkdir(mode=0o700) + error = io.StringIO() + assert ( + admin.main( + [ + "--channel", + "edge", + "--data-directory", + str(root), + "--instance-id", + "invalid", + "recovery", + "approve", + "request", + ], + error_stream=error, + ) + == 2 + ) + assert list(root.iterdir()) == [] + assert ( + admin.main( + [ + "--channel", + "edge", + "--database", + str(root / "missing"), + "recovery", + "approve", + "request", + ], + error_stream=error, + ) + == 2 + ) + assert list(root.iterdir()) == [] + + +def test_owned_console_attaches_to_running_native_storage_and_releases_guard( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from domus.application.household_commands import HouseholdCommands, SetupHousehold + from domus.application.recovery_commands import RecoveryCommands + from domus.domain.identity import MappingMode + from domus.infrastructure.owned_storage import open_storage + + commands, _, source, actor, clock, _ = configured(tmp_path) + root = tmp_path / "owned" + root.mkdir(mode=0o700) + owned = open_storage(root, "edge") + # Set up a separate real owned installation using the existing pure provider fixtures. + from test_household_commands import Actor, Directory, Identifiers, Zones + + creator = Actor() + identifiers = Identifiers() + household = HouseholdCommands(owned.database, creator, Directory(), Zones(), clock, identifiers) + household.setup("setup", SetupHousehold("Home", "UTC", MappingMode.SETUP)) + commands = RecoveryCommands(owned.database, actor, owned.installation, clock, identifiers) + request_id = start(commands) + monkeypatch.setattr(admin, "SystemClock", lambda: clock) + assert ( + admin.main( + ["--channel", "edge", "--data-directory", str(root), "recovery", "approve", request_id], + input_stream=Terminal(f"APPROVE {request_id} ha_new\n"), + output_stream=Terminal(), + error_stream=io.StringIO(), + ) + == 0 + ) + commands.complete("finish", request_id, 2) + owned.close() + open_storage(root, "edge").close() + + +def test_output_failure_after_commit_preserves_uncertainty_and_allows_same_request_retry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + commands, _, store, _, clock, _ = configured(tmp_path) + request_id = start(commands) + monkeypatch.setattr(admin, "SystemClock", lambda: clock) + + class BrokenOutput(Terminal): + def write(self, value): + if "operation_id" in value: + raise OSError("private output failure") + return super().write(value) + + error = io.StringIO() + assert ( + admin.main( + [*options(store.path, store.installation), request_id], + input_stream=Terminal(f"APPROVE {request_id} ha_new\n"), + output_stream=BrokenOutput(), + error_stream=error, + ) + == 1 + ) + assert "private output failure" not in error.getvalue() + assert "confirm" in error.getvalue() + assert ( + admin.main( + [*options(store.path, store.installation), request_id], + input_stream=Terminal(), + output_stream=Terminal(), + error_stream=io.StringIO(), + ) + == 0 + ) + with store.read() as unit: + assert len(unit.audit.entries("home")) == 3 + + +def test_console_interrupt_does_not_approve_and_help_needs_no_identity( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + commands, _, store, _, clock, _ = configured(tmp_path) + request_id = start(commands) + monkeypatch.setattr(admin, "SystemClock", lambda: clock) + + class Interrupted(Terminal): + def readline(self, *args): + raise KeyboardInterrupt + + assert ( + admin.main( + [*options(store.path, store.installation), request_id], + input_stream=Interrupted(), + output_stream=Terminal(), + error_stream=io.StringIO(), + ) + == 130 + ) + with store.read() as unit: + assert unit.recoveries.get("home", request_id).state.value == "pending" + monkeypatch.delitem(sys.modules, "domus.composition.admin", raising=False) + monkeypatch.setattr(sys, "argv", ["domus-admin", "--help"]) + with pytest.raises(SystemExit) as stopped: + runpy.run_module("domus.composition.admin", run_name="__main__") + assert stopped.value.code == 0 + + +def test_actual_console_process_requires_and_accepts_a_real_pty(tmp_path: Path) -> None: + import os + import pty + import select + import subprocess + import time + + commands, _, store, _, clock, _ = configured(tmp_path) + clock.time = int(time.time() * 1000) + request_id = start(commands) + master, slave = pty.openpty() + process = None + captured = b"" + try: + process = subprocess.Popen( + [ + sys.executable, + "-m", + "domus.composition.admin", + *options(store.path, store.installation), + request_id, + ], + stdin=slave, + stdout=slave, + stderr=slave, + close_fds=True, + ) + os.close(slave) + slave = None + deadline = time.monotonic() + 10 + while b"\n> " not in captured: + remaining = deadline - time.monotonic() + assert remaining > 0, "Console did not display its exact confirmation prompt" + ready, _, _ = select.select([master], [], [], remaining) + assert ready, "Console prompt timed out" + captured += os.read(master, 4096) + assert len(captured) < 16384 + os.write(master, f"APPROVE {request_id} ha_new\n".encode()) + assert process.wait(timeout=10) == 0, captured.decode(errors="replace") + with store.read() as unit: + assert unit.recoveries.get("home", request_id).state.value == "approved" + assert unit.members.by_user("home", "ha_new") is None + finally: + if process is not None and process.poll() is None: + process.terminate() + process.wait(timeout=5) + if slave is not None: + os.close(slave) + os.close(master) diff --git a/tests/backend/test_recovery_http.py b/tests/backend/test_recovery_http.py new file mode 100644 index 0000000..f9f2694 --- /dev/null +++ b/tests/backend/test_recovery_http.py @@ -0,0 +1,208 @@ +"""Both deployment compositions expose human recovery; approval remains a console operation.""" + +import io +from pathlib import Path + +import pytest +from test_recovery_commands import Clock +from test_recovery_console import Terminal + +from domus.composition import admin, household, ingress +from domus.domain.identity import HumanIdentity +from domus.infrastructure.sqlite import SQLiteDatabase + +TARGET = "b" * 32 + + +def deployment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mode: str) -> tuple: + utc = Clock() + monkeypatch.setattr(admin, "SystemClock", lambda: utc) + if mode == "native": + from test_native_http import fixture, headers + + monkeypatch.setattr(ingress, "SystemClock", lambda: utc) + runtime, client, lookup, lease = fixture(tmp_path, monkeypatch) + store = SQLiteDatabase(tmp_path / "domus.sqlite3", runtime.sessions.installation) + + def switch(user_id=TARGET): + lookup.people[user_id] = HumanIdentity(user_id, True, True, False) + client.headers["X-Remote-User-Id"] = user_id + client.headers["Cookie"] = ( + "ingress_session=" + ("d" if user_id == TARGET else "e") * 128 + ) + + def unavailable(): + from domus.domain.errors import IdentityUnavailable + + lookup.error = IdentityUnavailable("ha_unavailable") + else: + from test_auth_server import INSTANCE + from test_household_runtime import headers, runtime_at + + from domus.domain.storage import Installation + + monkeypatch.setattr(household, "SystemClock", lambda: utc) + runtime, client = runtime_at(tmp_path / "domus.sqlite3", monkeypatch) + store = SQLiteDatabase(tmp_path / "domus.sqlite3", Installation(INSTANCE, "edge")) + cookie = runtime.app.openapi()["components"]["securitySchemes"]["DomusSession"]["name"] + + def switch(user_id=TARGET): + runtime.identity.socket.human = HumanIdentity(user_id, True, True, False) + runtime.identity.sign_in("code", "v" * 43, "session-" + user_id, 100, 3600100) + client.cookies.set(cookie, "session-" + user_id) + + def unavailable(): + from domus.domain.errors import IdentityUnavailable + + runtime.identity.socket.error = IdentityUnavailable("ha_unavailable") + + response = client.post( + "/api/v1/household", + json={ + "name": "Home", + "time_zone": "UTC", + "mapping_mode": "setup_administrator", + "expected_revision": 0, + }, + headers=headers(client, "setup"), + ) + assert response.status_code == 200, response.text + switch() + return runtime, client, store, headers, switch, unavailable, utc + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +def test_actual_http_and_terminal_journey_needs_both_authorities_and_preserves_mapping( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mode: str +) -> None: + runtime, client, store, headers, switch, _, _ = deployment(tmp_path, monkeypatch, mode) + try: + assert client.get("/api/v1/authorization").json()["state"] == "not_enrolled" + response = client.post( + "/api/v1/recovery/requests", + json={"expected_revision": 0}, + headers=headers(client, "request"), + ) + assert response.status_code == 200, response.text + request_id = response.json()["result"]["request_id"] + path = "/api/v1/recovery/requests/" + request_id + assert client.get(path).json()["state"] == "pending" + assert ( + client.post( + path + "/complete", + json={"expected_revision": 1}, + headers=headers(client, "complete"), + ).status_code + == 409 + ) + assert ( + client.post( + path + "/approve", + json={"expected_revision": 1}, + headers=headers(client, "forged-console"), + ).status_code + == 404 + ) + assert ( + admin.main( + [ + "--channel", + "edge", + "--database", + str(store.path), + "--instance-id", + store.installation.instance_id, + "recovery", + "approve", + request_id, + ], + input_stream=Terminal(f"APPROVE {request_id} {TARGET}\n"), + output_stream=Terminal(), + error_stream=io.StringIO(), + ) + == 0 + ) + completed = client.post( + path + "/complete", json={"expected_revision": 2}, headers=headers(client, "complete") + ) + assert completed.status_code == 200, completed.text + assert client.get("/api/v1/authorization").json()["effective_role"] == "administrator" + assert client.get("/api/v1/policy").json()["mapping"]["target_ha_user_id"] == "a" * 32 + assert client.get(path).json()["state"] == "consumed" + assert ( + client.get("/api/v1/operations/" + completed.json()["operation_id"]).status_code == 200 + ) + assert ( + client.post( + path + "/complete", + json={"expected_revision": 2}, + headers=headers(client, "complete"), + ).json() + == completed.json() + ) + switch("c" * 32) + assert client.get(path).status_code == 404 + finally: + runtime.close() + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +def test_strict_recovery_routes_reject_forged_target_and_allow_owner_cancellation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mode: str +) -> None: + runtime, client, _, headers, _, _, _ = deployment(tmp_path, monkeypatch, mode) + try: + assert ( + client.post( + "/api/v1/recovery/requests", + json={"expected_revision": 0, "target_ha_user_id": "c" * 32}, + headers=headers(client, "forged-target"), + ).status_code + == 422 + ) + first = client.post( + "/api/v1/recovery/requests", + json={"expected_revision": 0}, + headers=headers(client, "request"), + ).json() + path = "/api/v1/recovery/requests/" + first["result"]["request_id"] + assert client.get(path + "?target_ha_user_id=" + "c" * 32).status_code == 422 + canceled = client.post( + path + "/cancel", json={"expected_revision": 1}, headers=headers(client, "cancel") + ) + assert canceled.status_code == 200 and canceled.json()["result"]["state"] == "canceled" + assert ( + client.post( + path + "/complete", + json={"expected_revision": 2}, + headers=headers(client, "complete"), + ).status_code + == 409 + ) + finally: + runtime.close() + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +def test_recovery_status_has_local_outage_grace_and_writes_remain_blocked( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mode: str +) -> None: + runtime, client, _, headers, _, unavailable, _ = deployment(tmp_path, monkeypatch, mode) + try: + created = client.post( + "/api/v1/recovery/requests", + json={"expected_revision": 0}, + headers=headers(client, "request"), + ).json() + path = "/api/v1/recovery/requests/" + created["result"]["request_id"] + command_headers = headers(client, "cancel") + unavailable() + assert client.get(path).status_code == 200 + assert ( + client.post( + path + "/cancel", json={"expected_revision": 1}, headers=command_headers + ).status_code + == 503 + ) + finally: + runtime.close() diff --git a/tests/backend/test_recovery_values.py b/tests/backend/test_recovery_values.py new file mode 100644 index 0000000..344d26a --- /dev/null +++ b/tests/backend/test_recovery_values.py @@ -0,0 +1,149 @@ +"""Recovery is target-bound, short-lived and cannot reopen a completed approval.""" + +from dataclasses import replace + +import pytest + +from domus.domain.errors import Conflict, InvalidInput +from domus.domain.identity import ( + AdministratorMapping, + HouseholdPolicy, + MappingMode, + Member, + MemberStatus, + Role, +) +from domus.domain.recovery import RecoveryRequest, RecoveryState +from domus.domain.storage import Installation + +INSTALLATION = Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge") +MAPPING = AdministratorMapping(MappingMode.SETUP, "lost_admin") +POLICY = HouseholdPolicy("home", MAPPING, 2) + + +def request() -> RecoveryRequest: + return RecoveryRequest( + "request", "home", "ha_new", INSTALLATION, MAPPING, 2, None, 0, 1000, 601000 + ) + + +def test_recovery_approval_has_absolute_deadline_and_atomic_single_use_transitions() -> None: + pending = request() + assert pending.state is RecoveryState.PENDING and pending.revision == 1 + pending.require_current(INSTALLATION, POLICY, None) + approved = pending.approve(2000) + assert approved.expires_at == 602000 and approved.approved_at == 2000 + assert approved.revision == 2 and approved.visible_state(3000) == "approved" + consumed = approved.consume(3000) + assert consumed.state is RecoveryState.CONSUMED and consumed.revision == 3 + assert consumed.closed_at == 3000 and consumed.visible_state(900000) == "consumed" + with pytest.raises(Conflict, match="recovery_not_pending"): + approved.approve(3000) + with pytest.raises(Conflict, match="recovery_not_approved"): + pending.consume(3000) + with pytest.raises(Conflict, match="recovery_not_approved"): + consumed.consume(4000) + with pytest.raises(Conflict, match="recovery_closed"): + consumed.cancel(4000) + + +@pytest.mark.parametrize( + "approved,now", + [(False, 601000), (False, 700000), (False, 999), (True, 602000), (True, 700000), (True, 1999)], +) +def test_recovery_cannot_approve_or_complete_outside_its_fixed_phase_window( + approved: bool, now: int +) -> None: + value = request().approve(2000) if approved else request() + assert value.visible_state(now) == "expired" + with pytest.raises(Conflict, match="recovery_expired"): + value.consume(now) if approved else value.approve(now) + + +@pytest.mark.parametrize("approved", [False, True]) +def test_owner_can_cancel_pending_or_approved_request_even_after_expiry(approved: bool) -> None: + value = request().approve(2000) if approved else request() + canceled = value.cancel(900000) + assert canceled.visible_state(900001) == "canceled" + assert canceled.revision == value.revision + 1 + with pytest.raises(Conflict, match="recovery_closed"): + canceled.cancel(900001) + + +@pytest.mark.parametrize( + "field,value", + [ + ("request_id", "bad id"), + ("household_id", ""), + ("target_ha_user_id", "bad id"), + ("installation", None), + ("mapping", AdministratorMapping(MappingMode.ALL)), + ("policy_revision", 0), + ("member_id", "bad id"), + ("member_revision", 1), + ("created_at", True), + ("created_at", -1), + ("created_at", 2**63), + ("expires_at", 601001), + ("state", "pending"), + ("revision", 2), + ("approved_at", 2000), + ("closed_at", 2000), + ], +) +def test_invalid_recovery_value_cannot_be_persisted(field: str, value: object) -> None: + with pytest.raises(InvalidInput): + replace(request(), **{field: value}) + + +@pytest.mark.parametrize( + "change", + [ + {"approved_at": 999}, + {"approved_at": 601000}, + {"approved_at": True}, + {"closed_at": 3000}, + {"revision": 1}, + ], +) +def test_approved_record_requires_consistent_time_and_state(change: dict) -> None: + with pytest.raises(InvalidInput): + replace(request().approve(2000), **change) + + +@pytest.mark.parametrize( + "change", [{"closed_at": None}, {"closed_at": 1999}, {"closed_at": 602000}, {"revision": 2}] +) +def test_consumed_record_cannot_fabricate_completion(change: dict) -> None: + with pytest.raises(InvalidInput): + replace(request().approve(2000).consume(3000), **change) + + +def test_member_and_policy_binding_detects_retargeting_or_stale_revision() -> None: + member = Member("member", "home", "ha_new", Role.VIEWER, MemberStatus.PENDING, 1) + bound = replace(request(), member_id="member", member_revision=1) + bound.require_current(INSTALLATION, POLICY, member) + for installation, policy, person in [ + (Installation(INSTALLATION.instance_id, "stable"), POLICY, member), + (INSTALLATION, None, member), + (INSTALLATION, replace(POLICY, revision=3), member), + (INSTALLATION, replace(POLICY, household_id="other"), member), + ( + INSTALLATION, + replace(POLICY, mapping=AdministratorMapping(MappingMode.SELECTED, "other")), + member, + ), + (INSTALLATION, POLICY, None), + (INSTALLATION, POLICY, replace(member, revision=2)), + (INSTALLATION, POLICY, replace(member, ha_user_id="other")), + (INSTALLATION, POLICY, replace(member, household_id="other")), + ]: + with pytest.raises(Conflict, match="recovery_binding_changed"): + bound.require_current(installation, policy, person) + with pytest.raises(InvalidInput): + replace(request(), member_id="member", member_revision=0) + + +def test_cancellation_rejects_time_before_the_request_was_created() -> None: + with pytest.raises(Conflict, match="recovery_expired"): + request().cancel(999) diff --git a/tests/backend/test_response_readability.py b/tests/backend/test_response_readability.py new file mode 100644 index 0000000..02401d0 --- /dev/null +++ b/tests/backend/test_response_readability.py @@ -0,0 +1,248 @@ +"""Both transport compositions retain stable, safe English fallback for unsupported locales.""" + +import io +from contextlib import contextmanager +from dataclasses import replace + +import pytest +from test_auth_http import login +from test_household_http import fixture as standalone_fixture +from test_household_http import setup as standalone_setup +from test_household_http import write_headers +from test_native_http import INSTANCE +from test_native_http import fixture as native_fixture +from test_native_http import headers as native_headers +from test_native_http import setup as native_setup + +from domus.composition import admin +from domus.domain.identity import HumanIdentity, MemberStatus +from domus.domain.storage import Installation +from domus.infrastructure.sqlite import SQLiteDatabase + + +@contextmanager +def composition(tmp_path, monkeypatch, mode): + runtime = None + if mode == "standalone": + client, store, source, _, _, _ = standalone_fixture(tmp_path) + login(client) + headers, setup = write_headers, standalone_setup + + def select(human): + response = client.post("/api/v1/auth/logout", json={}, headers=headers(client)) + assert response.status_code == 200 + source.human = human + login(client) + else: + runtime, client, source, _ = native_fixture(tmp_path, monkeypatch) + store = SQLiteDatabase(tmp_path / "domus.sqlite3", Installation(INSTANCE, "edge")) + headers, setup = native_headers, native_setup + + def select(human): + source.people[human.ha_user_id] = human + client.headers["X-Remote-User-Id"] = human.ha_user_id + client.headers["Cookie"] = "ingress_session=" + human.ha_user_id * 4 + assert client.get("/api/v1/session").status_code == 200 + + try: + yield client, store, setup, headers, select + finally: + if runtime is not None: + runtime.close() + + +@pytest.mark.parametrize("mode", ["standalone", "native"]) +@pytest.mark.parametrize( + "state, expected_message, expected_action", + [ + ("unconfigured_admin", "not configured", "Set up"), + ("unconfigured_ordinary", "not configured", "Home Assistant administrator"), + ("not_enrolled", "no membership", "Request"), + ("pending", "not active", "administrator"), + ("active", "active", "No membership action"), + ("suspended", "blocked", "administrator"), + ("archived", "blocked", "existing membership"), + ], +) +def test_authorization_explains_current_membership_and_next_action_without_implicit_access( + tmp_path, monkeypatch, mode, state, expected_message, expected_action +): + with composition(tmp_path, monkeypatch, mode) as (client, store, setup, headers, select): + ordinary = HumanIdentity("b" * 32, False, True, False) + if not state.startswith("unconfigured"): + setup(client) + select(ordinary) + if state != "not_enrolled": + joined = client.post( + "/api/v1/members/enrollment", + json={"expected_revision": 0}, + headers=headers(client, "join"), + ) + assert joined.status_code == 200 + if state != "pending": + with store.write() as unit: + member = unit.members.by_user("home", ordinary.ha_user_id) + unit.members.save( + replace(member, status=MemberStatus(state), revision=2), 1 + ) + elif state == "unconfigured_ordinary": + select(ordinary) + result = client.get("/api/v1/authorization") + assert result.status_code == 200, result.text + body = result.json() + assert body["state"] == ("unconfigured" if state.startswith("unconfigured") else state) + assert expected_message in body["message"] and expected_action in body["action_required"] + assert body["setup_allowed"] is (state == "unconfigured_admin") + if state != "active": + assert body["permissions"] == [] + assert client.get("/api/v1/household").status_code == 403 + if state == "pending": + assert "linked" in body["message"] + with store.read() as unit: + assert len(unit.audit.entries("home")) == ( + 0 if state.startswith("unconfigured") else 1 if state == "not_enrolled" else 2 + ) + + +@pytest.mark.parametrize("mode", ["standalone", "native"]) +@pytest.mark.parametrize("ha_admin", [False, True]) +def test_enrollment_receipt_describes_original_effect_even_after_current_status_changes( + tmp_path, monkeypatch, mode, ha_admin +): + with composition(tmp_path, monkeypatch, mode) as (client, store, setup, headers, select): + setup(client) + human = HumanIdentity("b" * 32, ha_admin, True, False) + select(human) + + def enroll(): + return client.post( + "/api/v1/members/enrollment", + json={"expected_revision": 0}, + headers=headers(client, "join"), + ) + + first = enroll() + assert first.status_code == 200, first.text + body = first.json() + assert body["result"]["status"] == ("active" if ha_admin else "pending") + assert "This operation" in body["message"] and "linked" in body["message"] + assert "current membership status" in body["action_required"] + if not ha_admin: + assert "did not activate household access" in body["message"] + assert "administrator" in body["action_required"] + with store.write() as unit: + member = unit.members.by_user("home", human.ha_user_id) + unit.members.save(replace(member, status=MemberStatus.ACTIVE, revision=2), 1) + assert enroll().json() == body + assert client.get("/api/v1/authorization").json()["state"] == "active" + with store.read() as unit: + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 2 + + +def test_historical_enrollment_does_not_claim_membership_survived_restoration(tmp_path): + from test_restored_commands import frontier, restore_fixture + + client, store, source, _, _, _ = standalone_fixture(tmp_path) + login(client) + standalone_setup(client) + checkpoint, selected = store.path.read_bytes(), frontier(store) + client.post("/api/v1/auth/logout", json={}, headers=write_headers(client)) + source.human = HumanIdentity("b" * 32, False, True, False) + login(client) + + def enroll(): + return client.post( + "/api/v1/members/enrollment", + json={"expected_revision": 0}, + headers=write_headers(client, "join"), + ) + + first = enroll() + assert first.status_code == 200 + restore_fixture(store, checkpoint, selected) + replay = enroll() + assert replay.status_code == 200, replay.text + assert replay.json()["state"] == "historical" + assert replay.json()["operation_id"] == first.json()["operation_id"] + assert "absent from current state" in replay.json()["message"] + assert client.get("/api/v1/authorization").json()["state"] == "not_enrolled" + with store.read() as unit: + assert unit.members.by_user("home", "b" * 32) is None + + +@pytest.mark.parametrize("mode", ["standalone", "native"]) +@pytest.mark.parametrize("locale", ["en-US", "es-ES,es;q=0.9", "fr-FR"]) +def test_missing_operation_is_readable_english_json_without_untrusted_diagnostic_rendering( + tmp_path, monkeypatch, mode, locale +): + runtime = None + if mode == "standalone": + client, store, _, _, _, _ = standalone_fixture(tmp_path) + login(client) + standalone_setup(client) + else: + runtime, client, _, _ = native_fixture(tmp_path, monkeypatch) + store = SQLiteDatabase(tmp_path / "domus.sqlite3", Installation(INSTANCE, "edge")) + native_setup(client) + try: + untrusted = "", + "@everyone", + "$(command)", + "https://untrusted.invalid/", + "\x1b[31mCONTROL", + "[review](https://untrusted.invalid/)", + "", + ], +) +def test_console_rejects_untrusted_request_text_without_echo_prompt_or_saved_effect( + tmp_path, untrusted +): + from test_recovery_commands import configured + + _, _, store, _, _, _ = configured(tmp_path) + output, error = io.StringIO(), io.StringIO() + code = admin.main( + [ + "--channel", + "edge", + "--database", + str(store.path), + "--instance-id", + store.installation.instance_id, + "recovery", + "approve", + untrusted, + ], + input_stream=io.StringIO(), + output_stream=output, + error_stream=error, + ) + assert code == 2 and output.getvalue() == "" + assert untrusted not in error.getvalue() and "\x1b" not in error.getvalue() + assert "Existing records are retained" in error.getvalue() + with store.read() as unit: + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 1 diff --git a/tests/backend/test_restoration_commands.py b/tests/backend/test_restoration_commands.py new file mode 100644 index 0000000..54491f1 --- /dev/null +++ b/tests/backend/test_restoration_commands.py @@ -0,0 +1,132 @@ +"""Semantic preparation ports cannot retarget an installation or the selected checkpoint.""" + +from dataclasses import replace + +import pytest +from test_snapshot_commands import snapshot + +from domus.application.restoration import RestorationCommands +from domus.domain.errors import InvalidInput, StorageFailure +from domus.domain.restoration import PreparedRestoration +from domus.domain.storage import Installation + + +def prepared() -> PreparedRestoration: + return PreparedRestoration("restore-1", snapshot(), "a" * 64, "b" * 64, "c" * 64, 2, 1, 2, 200) + + +class Identifiers: + def new(self): + return "restore-1" + + +class Archive: + def __init__(self, value): + self.value, self.calls = value, [] + + def prepare(self, restore_id): + self.calls.append(restore_id) + return self.value + + def inspect(self): + return self.value + + +def test_prepared_binding_roundtrips_and_application_validates_both_operations() -> None: + result = prepared() + assert PreparedRestoration.from_value(result.document().decode()) == result + archive = Archive(result) + commands = RestorationCommands(archive, result.installation, Identifiers(), result.checkpoint) + assert commands.prepare() == commands.inspect() == result + assert archive.calls == ["restore-1"] + assert RestorationCommands(archive, result.installation, Identifiers()).inspect() == result + + +@pytest.mark.parametrize("change", ["id", "installation", "checkpoint"]) +def test_application_rejects_misdirected_results(change: str) -> None: + result = prepared() + altered = result + if change == "id": + altered = replace(result, restore_id="other") + elif change == "installation": + altered = replace( + result, + checkpoint=replace( + result.checkpoint, + installation=Installation(result.installation.instance_id, "stable"), + ), + ) + else: + altered = replace(result, checkpoint=replace(result.checkpoint, snapshot_id="other")) + commands = RestorationCommands( + Archive(altered), result.installation, Identifiers(), result.checkpoint + ) + with pytest.raises(StorageFailure, match="invalid_restore_result"): + commands.prepare() + if change != "id": + with pytest.raises(StorageFailure): + commands.inspect() + + +def test_preparation_requires_an_explicit_matching_checkpoint_before_port_io() -> None: + archive = Archive(prepared()) + for checkpoint in ( + None, + replace( + snapshot(), installation=Installation(snapshot().installation.instance_id, "stable") + ), + ): + with pytest.raises(InvalidInput): + RestorationCommands( + archive, snapshot().installation, Identifiers(), checkpoint + ).prepare() + assert archive.calls == [] + + +@pytest.mark.parametrize( + "change", + [ + {"restore_id": "bad id"}, + {"checkpoint": None}, + {"source_frontier_sha256": "bad"}, + {"operations_sha256": 1}, + {"identity_sha256": "g" * 64}, + {"operation_count": True}, + {"operation_count": -1}, + {"operation_count": 2**63}, + {"current_operations": -1}, + {"current_operations": 3}, + {"highwater": 0}, + {"highwater": 2**63}, + {"prepared_at": -1}, + {"prepared_at": True}, + {"operation_count": 0, "current_operations": 0, "highwater": 1}, + ], +) +def test_prepared_value_rejects_invalid_counts_timestamps_and_digests(change) -> None: + with pytest.raises(InvalidInput): + replace(prepared(), **change) + + +@pytest.mark.parametrize("kind", ["missing", "extra", "list", "checkpoint", "state", "version"]) +def test_prepared_parser_is_strict(kind) -> None: + value = prepared().document().decode() + if kind == "missing": + del value["restore_id"] + elif kind == "extra": + value["extra"] = True + elif kind == "list": + value = [] + elif kind == "checkpoint": + value["checkpoint"] = [] + elif kind == "version": + value["format_version"] = True + else: + value["state"] = "active" + with pytest.raises(InvalidInput): + PreparedRestoration.from_value(value) + + +def test_empty_preparation_counts_are_valid_without_becoming_an_active_installation() -> None: + result = replace(prepared(), operation_count=0, current_operations=0, highwater=0) + assert result.document().decode()["state"] == "prepared" diff --git a/tests/backend/test_restoration_values.py b/tests/backend/test_restoration_values.py new file mode 100644 index 0000000..a8c25d6 --- /dev/null +++ b/tests/backend/test_restoration_values.py @@ -0,0 +1,269 @@ +"""Checkpoint reconciliation retains processed truth without replaying rolled-away effects.""" + +from dataclasses import replace + +import pytest + +from domus.domain.errors import Conflict, InvalidInput +from domus.domain.restoration import OperationEvidence, RetainedOperation, reconcile_operations +from domus.domain.storage import AuditEntry, CommandKey, JsonDocument, OutboxEvent, Receipt + + +def evidence(key: str = "a", *, delivered: bool = False, position: int = 1) -> OperationEvidence: + command = CommandKey("home", "human", "household.update", key) + value = JsonDocument.from_value({"name": key, "revision": position}) + receipt = Receipt(command, value.fingerprint, "operation-" + key, value, 100) + return OperationEvidence( + receipt, + AuditEntry(receipt.operation_id, command, value, 100), + OutboxEvent(receipt.operation_id, "home", command.action, value, 100), + position, + delivered, + ) + + +def record(key: str = "a", *, current=True, archived_by=None, delivered=False, position=1): + return RetainedOperation( + evidence(key, delivered=delivered, position=position), current, archived_by + ) + + +def reconcile(checkpoint, current): + return tuple(reconcile_operations(iter(checkpoint), iter(current), "restore-1")) + + +def test_evidence_roundtrip_is_complete_canonical_and_returns_independent_values() -> None: + original = record(archived_by="restore-before") + assert RetainedOperation.from_value(original.value()) == original + value = original.value() + value["result"]["name"] = "changed" + assert original.evidence.receipt.result.decode()["name"] == "a" + assert original.evidence.command_scope == ("home", "human", "household.update", "a") + assert ( + RetainedOperation.from_value(record(current=False, archived_by="old").value()).current + is False + ) + + +@pytest.mark.parametrize( + "change", + [ + {"position": True}, + {"position": 0}, + {"position": 2**63}, + {"delivered": 1}, + {"receipt": None}, + {"audit": None}, + {"event": None}, + {"receipt": replace(evidence().receipt, payload_fingerprint="not-a-digest")}, + {"receipt": replace(evidence().receipt, operation_id="unsafe id")}, + {"receipt": replace(evidence().receipt, created_at=True)}, + {"receipt": replace(evidence().receipt, created_at=-1)}, + {"receipt": replace(evidence().receipt, created_at=2**63)}, + {"audit": replace(evidence().audit, operation_id="different")}, + { + "audit": replace( + evidence().audit, command=CommandKey("other", "human", "household.update", "a") + ) + }, + {"audit": replace(evidence().audit, created_at=101)}, + {"event": replace(evidence().event, event_id="different")}, + {"event": replace(evidence().event, household_id="other")}, + {"event": replace(evidence().event, event_type="other")}, + {"event": replace(evidence().event, created_at=101)}, + {"receipt": replace(evidence().receipt, result={})}, + {"audit": replace(evidence().audit, detail={})}, + {"event": replace(evidence().event, payload={})}, + ], +) +def test_incomplete_or_inconsistent_atomic_facts_are_rejected(change: dict) -> None: + with pytest.raises(InvalidInput): + replace(evidence(), **change) + + +@pytest.mark.parametrize("current,archived", [(1, None), (False, None), (True, "bad id")]) +def test_retained_state_cannot_invent_historical_provenance(current, archived) -> None: + with pytest.raises(InvalidInput): + RetainedOperation(evidence(), current, archived) + with pytest.raises(InvalidInput): + RetainedOperation(None, True) + + +@pytest.mark.parametrize("kind", ["list", "missing", "extra", "scope", "payload", "current"]) +def test_record_parser_rejects_unknown_missing_and_invalid_fields(kind: str) -> None: + value = record().value() + if kind == "list": + value = [] + elif kind == "missing": + del value["result"] + elif kind == "extra": + value["extra"] = True + elif kind == "scope": + value["actor_id"] = "bad actor" + elif kind == "payload": + value["event_payload"] = [] + else: + value["current"] = 1 + with pytest.raises(InvalidInput): + RetainedOperation.from_value(value) + + +def test_later_operations_are_historical_and_not_selected_business_state() -> None: + a, b, c = record(), record("b", position=2), record("c", position=3) + result = reconcile([a], [a, b, c]) + assert result == ( + a, + replace(b, current=False, archived_by="restore-1"), + replace(c, current=False, archived_by="restore-1"), + ) + assert b.current and b.archived_by is None + assert result[1].evidence.receipt == b.evidence.receipt + assert result[1].evidence.audit == b.evidence.audit + assert result[1].evidence.event == b.evidence.event + + +def test_empty_checkpoint_keeps_complete_history_without_recreating_a_household() -> None: + result = reconcile([], [record(), record("b", position=2)]) + assert len(result) == 2 and not any(item.current for item in result) + assert reconcile([], []) == () + + +def test_later_acknowledgement_is_preserved_for_current_and_historical_events() -> None: + snapshot = record() + source = record(delivered=True) + assert reconcile([snapshot], [source]) == (source,) + historical = reconcile([], [source])[0] + assert historical.evidence.delivered is True and not historical.current + undelivered = reconcile([], [snapshot])[0] + assert undelivered.evidence.delivered is False and not undelivered.current + + +def test_repeated_restore_keeps_first_history_and_can_select_a_known_effect_again() -> None: + original = record() + first = reconcile([], [original])[0] + again = reconcile([], [first])[0] + assert again == first + selected = reconcile([original], [first])[0] + assert selected.current and selected.archived_by == "restore-1" + assert selected.evidence == original.evidence + assert reconcile([first], [selected]) == (first,) + + +@pytest.mark.parametrize("change", ["id", "result", "audit", "event", "position", "fingerprint"]) +def test_same_command_with_different_committed_facts_is_never_silently_selected(change) -> None: + source = record() + fact = source.evidence + replacement = JsonDocument.from_value({"different": True}) + if change == "id": + fact = replace( + fact, + receipt=replace(fact.receipt, operation_id="other"), + audit=replace(fact.audit, operation_id="other"), + event=replace(fact.event, event_id="other"), + ) + elif change == "result": + fact = replace(fact, receipt=replace(fact.receipt, result=replacement)) + elif change == "audit": + fact = replace(fact, audit=replace(fact.audit, detail=replacement)) + elif change == "event": + fact = replace(fact, event=replace(fact.event, payload=replacement)) + elif change == "position": + fact = replace(fact, position=2) + else: + fact = replace(fact, receipt=replace(fact.receipt, payload_fingerprint="0" * 64)) + with pytest.raises(Conflict, match="restore_evidence_conflict"): + reconcile([source], [replace(source, evidence=fact)]) + + +def test_missing_checkpoint_fact_and_regressed_delivery_or_history_are_rejected() -> None: + with pytest.raises(Conflict, match="restore_checkpoint_unexplained"): + reconcile([record("a")], []) + with pytest.raises(Conflict, match="restore_checkpoint_unexplained"): + reconcile([record("a")], [record("b")]) + with pytest.raises(Conflict, match="restore_delivery_regressed"): + reconcile([record(delivered=True)], [record()]) + for prior in (None, "different-restore"): + with pytest.raises(Conflict, match="restore_history_conflict"): + reconcile([record(archived_by="old-restore")], [record(archived_by=prior)]) + + +@pytest.mark.parametrize("side", ["checkpoint", "source"]) +@pytest.mark.parametrize( + "kind", ["duplicate_scope", "unsorted", "duplicate_id", "duplicate_position", "invalid"] +) +def test_frontiers_must_have_unique_sorted_scopes_and_operation_identities(side, kind) -> None: + a, b = record(), record("b", position=2) + if kind == "duplicate_scope": + values = [a, a] + elif kind == "unsorted": + values = [b, a] + elif kind == "duplicate_id": + b = replace( + b, + evidence=replace( + b.evidence, + receipt=replace(b.evidence.receipt, operation_id="operation-a"), + audit=replace(b.evidence.audit, operation_id="operation-a"), + event=replace(b.evidence.event, event_id="operation-a"), + ), + ) + values = [a, b] + elif kind == "duplicate_position": + values = [a, replace(b, evidence=replace(b.evidence, position=1))] + else: + values = [None] + with pytest.raises(InvalidInput): + reconcile(values if side == "checkpoint" else [], values if side == "source" else [a, b]) + + +def test_invalid_restore_id_is_rejected_and_earlier_extra_source_keys_are_preserved() -> None: + with pytest.raises(InvalidInput): + tuple(reconcile_operations([], [], "bad restore")) + a, b, c = record(position=2), record("b", position=1), record("c", position=3) + result = reconcile([b], [a, b, c]) + assert [item.current for item in result] == [False, True, False] + + +def test_streaming_does_not_consume_an_entire_frontier_before_the_first_result() -> None: + def current(): + yield record() + raise RuntimeError("second row requested") + + values = reconcile_operations([], current(), "restore-1") + assert next(values).current is False + with pytest.raises(RuntimeError, match="second row requested"): + next(values) + + +def test_a_checkpoint_cannot_omit_an_earlier_retained_operation() -> None: + a, b = record(), record("b", position=2) + with pytest.raises(Conflict, match="restore_checkpoint_incomplete"): + reconcile([b], [a, b]) + + +def test_wall_clock_regression_does_not_change_the_operation_frontier() -> None: + a, b = record(), record("b", position=2) + fact = b.evidence + b = replace( + b, + evidence=replace( + fact, + receipt=replace(fact.receipt, created_at=50), + audit=replace(fact.audit, created_at=50), + event=replace(fact.event, created_at=50), + ), + ) + assert reconcile([a], [a, b])[1].evidence.receipt.created_at == 50 + + +def test_large_valid_individual_payloads_are_not_rejected_by_an_aggregate_json_limit() -> None: + large = JsonDocument.from_value({"data": "x" * 60000}) + fact = evidence() + fact = replace( + fact, + receipt=replace(fact.receipt, result=large), + audit=replace(fact.audit, detail=large), + event=replace(fact.event, payload=large), + ) + retained = RetainedOperation(fact, True) + assert RetainedOperation.from_value(retained.value()) == retained diff --git a/tests/backend/test_restore_source.py b/tests/backend/test_restore_source.py new file mode 100644 index 0000000..dffd531 --- /dev/null +++ b/tests/backend/test_restore_source.py @@ -0,0 +1,131 @@ +"""Preparation binds the source schema/identity/generation even when operation facts coincide.""" + +from dataclasses import replace + +import pytest +from test_owned_generations import fixture as generation_fixture +from test_sqlite_restoration import fixture as restoration_fixture +from test_sqlite_snapshots import Clock + +from domus.domain.errors import InvalidInput +from domus.domain.restoration import PreparedRestoration, RestoreSource +from domus.infrastructure.owned_storage import open_storage +from domus.infrastructure.sqlite_restoration import SQLiteRestorations +from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + + +def test_bare_source_and_managed_generation_source_are_explicit_and_roundtrip(tmp_path): + bare_root = tmp_path / "bare" + bare_root.mkdir(mode=0o700) + adapter, store, _, _ = restoration_fixture(bare_root) + result = adapter.prepare("restore-bare") + assert result.source == RestoreSource(store.installation, 4, None, None) + assert result.document().decode()["format_version"] == 2 + assert PreparedRestoration.from_value(result.document().decode()) == result + root, slot, selection = generation_fixture(tmp_path) + storage = open_storage(root, "edge") + try: + archive = tmp_path / "snapshot" + snapshot = SQLiteSnapshots(storage.database, archive, storage.installation, Clock()).create( + "snapshot" + ) + prepared = SQLiteRestorations( + storage.database, + archive, + tmp_path / "prepared", + storage.installation, + Clock(), + snapshot, + ).prepare("restore-generation") + assert prepared.source.generation_id == selection.generation_id + assert prepared.source.identity_sha256 == selection.document().fingerprint + assert prepared.source.schema_version == 4 + assert PreparedRestoration.from_value(prepared.document().decode()) == prepared + finally: + storage.close() + + +@pytest.mark.parametrize( + "field,value", + [ + ("schema_version", True), + ("schema_version", 0), + ("schema_version", 2**31), + ("generation_id", "../escape"), + ("identity_sha256", "wrong"), + ("identity_sha256", 1), + ], +) +def test_source_binding_rejects_invalid_shape_and_unbound_generation(tmp_path, field, value): + adapter, store, _, _ = restoration_fixture(tmp_path) + source = RestoreSource(store.installation, 4, "generation-1", "a" * 64) + with pytest.raises(InvalidInput): + replace(source, **{field: value}) + with pytest.raises(InvalidInput): + RestoreSource(store.installation, 4, "generation-1", None) + + +def test_generation_identity_changes_the_frontier_even_for_identical_operation_rows(tmp_path): + root, slot, selection = generation_fixture(tmp_path) + storage = open_storage(root, "edge") + try: + archive = tmp_path / "snapshot" + snapshot = SQLiteSnapshots(storage.database, archive, storage.installation, Clock()).create( + "snapshot" + ) + first = SQLiteRestorations( + storage.database, archive, tmp_path / "one", storage.installation, Clock(), snapshot + ).prepare("one") + finally: + storage.close() + + later = replace(selection, generation_id="generation-2") + second = slot.with_name(later.generation_id) + second.mkdir(mode=0o700) + for name in ("domus.sqlite3", "installation.json"): + (second / name).write_bytes((slot / name).read_bytes()) + (second / name).chmod(0o600) + (second / "generation.json").write_text(later.document().encoded) + (second / "generation.json").chmod(0o600) + (root / "installation.json").write_text(later.document().encoded) + storage = open_storage(root, "edge") + try: + next_result = SQLiteRestorations( + storage.database, archive, tmp_path / "two", storage.installation, Clock(), snapshot + ).prepare("two") + assert next_result.source_frontier_sha256 != first.source_frontier_sha256 + assert next_result.source.generation_id == later.generation_id + finally: + storage.close() + + +@pytest.mark.parametrize("value", [[], {}, {"extra": True}]) +def test_source_parser_rejects_incomplete_or_unknown_fields(value): + with pytest.raises(InvalidInput): + RestoreSource.from_value(value) + + +@pytest.mark.parametrize("damage", ["missing_identity", "foreign_identity", "foreign_marker"]) +def test_source_binding_rejects_invalid_managed_subject(tmp_path, damage): + import json + + from domus.domain.errors import DatabaseRejected + from domus.infrastructure.restore_source import source_binding + from domus.infrastructure.sqlite_snapshots import _directory + + root, slot, selection = generation_fixture(tmp_path) + if damage == "missing_identity": + (slot / "installation.json").unlink() + elif damage == "foreign_identity": + value = json.loads((slot / "installation.json").read_text()) + value["channel"] = "stable" + (slot / "installation.json").write_text(json.dumps(value)) + else: + (slot / "generation.json").write_text( + replace(selection, installation=replace(selection.installation, channel="stable")) + .document() + .encoded + ) + with _directory(slot, private=True) as directory: + with pytest.raises(DatabaseRejected): + source_binding(directory, selection.installation, 4) diff --git a/tests/backend/test_restored_commands.py b/tests/backend/test_restored_commands.py new file mode 100644 index 0000000..7a202d7 --- /dev/null +++ b/tests/backend/test_restored_commands.py @@ -0,0 +1,169 @@ +"""Synthetic offline fixtures exercise runtime replay, disclosure and recovery after old state.""" + +from dataclasses import replace + +import pytest +from test_auth_http import login +from test_household_http import fixture as http_fixture +from test_household_http import setup, write_headers +from test_recovery_commands import configured, start +from test_sqlite_history import install + +from domus.domain.errors import Conflict, NotFound +from domus.domain.restoration import reconcile_operations +from domus.infrastructure.sqlite_history import iter_operations + + +def frontier(store): + with store._connection() as connection: + return tuple(iter_operations(connection)) + + +def restore_fixture(store, checkpoint, selected): + """Test-only offline substitution; not the production activation protocol.""" + plan = tuple(reconcile_operations(selected, frontier(store), "restore-1")) + store.path.write_bytes(checkpoint) + store.initialize(require_existing=True) + install(store, plan) + + +@pytest.mark.parametrize("delivered", [False, True]) +def test_historical_http_replay_is_truthful_and_requires_current_authority(tmp_path, delivered): + client, store, _, _, _, _ = http_fixture(tmp_path) + login(client) + setup(client) + checkpoint, selected = store.path.read_bytes(), frontier(store) + payload = {"name": "Later", "time_zone": "UTC", "expected_revision": 1} + result = client.put("/api/v1/household", json=payload, headers=write_headers(client, "later")) + assert result.status_code == 200 + operation_id = result.json()["operation_id"] + if delivered: + with store.write() as unit: + unit.outbox.acknowledge(operation_id) + restore_fixture(store, checkpoint, selected) + replay = client.put("/api/v1/household", json=payload, headers=write_headers(client, "later")) + assert replay.status_code == 200, replay.text + assert replay.json()["state"] == "historical" + assert replay.json()["archived_by"] == "restore-1" + assert replay.json()["result"] == result.json()["result"] + assert "absent from current state" in replay.json()["message"] + operation = client.get("/api/v1/operations/" + operation_id).json() + assert operation["state"] == "historical" + assert operation["delivery"] == ("delivered" if delivered else "suppressed") + assert client.get("/api/v1/household").json()["name"] == "Home" + with store.write() as unit: + actor = unit.members.list("home")[0] + unit.members.save(replace(actor, status=type(actor.status).SUSPENDED, revision=2), 1) + assert ( + client.put( + "/api/v1/household", json=payload, headers=write_headers(client, "later") + ).status_code + == 403 + ) + assert client.get("/api/v1/operations/" + operation_id).status_code == 403 + + +@pytest.mark.parametrize("terminal", ["consume", "cancel"]) +def test_restoring_an_approval_cannot_rearm_closed_recovery_with_another_key(tmp_path, terminal): + commands, approval, store, actor, _, console = configured(tmp_path) + request_id = start(commands) + approval.approve(request_id) + checkpoint, selected = store.path.read_bytes(), frontier(store) + if terminal == "consume": + commands.complete("complete", request_id, 2) + else: + commands.cancel("cancel", request_id, 2) + restore_fixture(store, checkpoint, selected) + replay = ( + commands.complete("complete", request_id, 2) + if terminal == "consume" + else commands.cancel("cancel", request_id, 2) + ) + assert replay.historical + for attempt in ( + lambda: commands.complete("new-key", request_id, 2), + lambda: commands.cancel("new-key", request_id, 2), + lambda: approval.approve(request_id), + lambda: commands.status(request_id), + ): + with pytest.raises(Conflict, match="recovery_closed"): + attempt() + with store.read() as unit: + assert unit.members.by_user("home", "ha_new") is None + assert unit.recoveries.pending("home", 1000) == () + assert len(console.reviews) == 1 + actor.human = replace(actor.human, ha_user_id="different-human") + with pytest.raises(NotFound): + commands.status(request_id) + actor.human = replace(actor.human, ha_user_id="ha_new") + assert commands.start("fresh-request").result.decode()["request_id"] != request_id + + +def test_empty_checkpoint_does_not_claim_historical_setup_created_a_household(tmp_path): + client, store, _, _, _, _ = http_fixture(tmp_path) + login(client) + checkpoint, selected = store.path.read_bytes(), frontier(store) + original = setup(client) + restore_fixture(store, checkpoint, selected) + replay = setup(client) + assert replay["state"] == "historical" and replay["operation_id"] == original["operation_id"] + assert client.get("/api/v1/authorization").json()["state"] == "unconfigured" + with store.read() as unit: + assert unit.households.current() is None and unit.audit.setup_actor("home") is None + + +def test_native_http_also_returns_historical_state_and_suppressed_delivery(tmp_path, monkeypatch): + from test_native_http import INSTANCE, headers + from test_native_http import fixture as native_fixture + from test_native_http import setup as native_setup + + from domus.domain.storage import Installation + from domus.infrastructure.sqlite import SQLiteDatabase + + runtime, client, _, _ = native_fixture(tmp_path, monkeypatch) + try: + native_setup(client) + store = SQLiteDatabase(tmp_path / "domus.sqlite3", Installation(INSTANCE, "edge")) + checkpoint, selected = store.path.read_bytes(), frontier(store) + payload = {"name": "Later", "time_zone": "UTC", "expected_revision": 1} + result = client.put("/api/v1/household", json=payload, headers=headers(client, "later")) + assert result.status_code == 200 + restore_fixture(store, checkpoint, selected) + replay = client.put("/api/v1/household", json=payload, headers=headers(client, "later")) + assert replay.status_code == 200 and replay.json()["state"] == "historical" + operation = client.get("/api/v1/operations/" + result.json()["operation_id"]) + assert operation.json()["delivery"] == "suppressed" + assert client.get("/api/v1/household").json()["name"] == "Home" + finally: + runtime.close() + + +def test_console_replay_cannot_claim_a_rolled_away_approval_is_current(tmp_path, monkeypatch): + import io + import json + + from test_recovery_console import options + + from domus.composition import admin + + commands, approval, store, _, clock, _ = configured(tmp_path) + request_id = start(commands) + checkpoint, selected = store.path.read_bytes(), frontier(store) + approval.approve(request_id) + restore_fixture(store, checkpoint, selected) + monkeypatch.setattr(admin, "SystemClock", lambda: clock) + output, error = io.StringIO(), io.StringIO() + assert ( + admin.main( + [*options(store.path, store.installation), request_id], + input_stream=io.StringIO(), + output_stream=output, + error_stream=error, + ) + == 0 + ) + result = json.loads(output.getvalue()) + assert result["state"] == "historical" and result["archived_by"] == "restore-1" + assert "no effect in current state" in result["message"] and error.getvalue() == "" + with store.read() as unit: + assert unit.recoveries.get("home", request_id).state.value == "pending" diff --git a/tests/backend/test_review_regressions.py b/tests/backend/test_review_regressions.py new file mode 100644 index 0000000..ee3166d --- /dev/null +++ b/tests/backend/test_review_regressions.py @@ -0,0 +1,188 @@ +"""Review regressions exercise browser origins, callback contracts and inert approval replay.""" + +import io +import json +from dataclasses import replace +from urllib.parse import parse_qs, urlsplit + +import pytest +from fastapi.testclient import TestClient +from test_auth_http import fixture as auth_fixture +from test_ingress_sessions import HEADERS +from test_native_http import INSTANCE, Lookup +from test_recovery_commands import configured, start + +from domus.composition import admin, ingress +from domus.domain.errors import Conflict, InvalidInput +from domus.domain.storage import Installation +from domus.infrastructure.ha_config import HAEndpoint, OAuthConfiguration, PublicOrigins + + +@pytest.mark.parametrize( + "configured_origin, serialized", + [ + ("https://DOMUS.EXAMPLE:443/", "https://domus.example"), + ("http://domus.example:80", "http://domus.example"), + ("https://[::1]:443", "https://[::1]"), + ("http://[::1]:80", "http://[::1]"), + ("https://[0:0:0:0:0:0:0:1]:443", "https://[::1]"), + ("http://[0:0:0:0:0:0:0:1]:8123", "http://[::1]:8123"), + ("https://domus.example:8443", "https://domus.example:8443"), + ("http://domus.example:8123", "http://domus.example:8123"), + ], +) +def test_trusted_origins_use_browser_serialization_without_widening_nondefault_ports( + configured_origin, serialized +): + assert PublicOrigins((configured_origin,)).values == (serialized,) + endpoint = HAEndpoint(configured_origin) + assert endpoint.origin == serialized + assert ( + endpoint.websocket_url + == serialized.replace("https:", "wss:").replace("http:", "ws:") + "/api/websocket" + ) + + +@pytest.mark.parametrize("scheme,port", [("https", 443), ("http", 80)]) +def test_equivalent_public_origins_cannot_bypass_duplicate_configuration_check(scheme, port): + with pytest.raises(InvalidInput, match="invalid_public_origins"): + PublicOrigins((f"{scheme}://domus.example", f"{scheme}://domus.example:{port}")) + + +def test_standalone_oauth_public_client_and_callback_share_canonical_https_origin(): + config = OAuthConfiguration(HAEndpoint("https://ha.example:443"), "https://DOMUS.EXAMPLE:443") + assert config.client_id == "https://domus.example/" + assert config.callback_url == "https://domus.example/api/v1/auth/callback" + + +@pytest.mark.parametrize( + "configured_origin, origin", + [ + ("https://domus.example:443", "https://domus.example"), + ("http://domus.example:80", "http://domus.example"), + ("https://[0:0:0:0:0:0:0:1]:443", "https://[::1]"), + ("http://[0:0:0:0:0:0:0:1]:80", "http://[::1]"), + ], +) +def test_native_browser_can_setup_with_explicit_default_port_in_operator_configuration( + tmp_path, monkeypatch, configured_origin, origin +): + lookup = Lookup() + monkeypatch.setattr(ingress, "HomeAssistantWebSocket", lambda _: lookup) + runtime = ingress.create_business_runtime( + "private-supervisor-token", + "edge", + tmp_path / "domus.sqlite3", + INSTANCE, + (configured_origin,), + ) + try: + client = TestClient( + runtime.app, base_url=origin, client=("172.30.32.2", 12000), headers=dict(HEADERS) + ) + csrf = client.get("/api/v1/auth/csrf").json()["csrf_token"] + headers = {"Origin": origin, "X-CSRF-Token": csrf, "Idempotency-Key": "setup"} + body = {"name": "Home", "time_zone": "UTC", "expected_revision": 0} + denied = client.post( + "/api/v1/household", json=body, headers={**headers, "Origin": origin + ":8443"} + ) + assert denied.status_code == 403 + accepted = client.post("/api/v1/household", json=body, headers=headers) + assert accepted.status_code == 200, accepted.text + assert accepted.json()["result"]["state"] == "configured" + finally: + runtime.close() + + +@pytest.mark.parametrize("transition", ["consumed", "canceled", "expired", "policy_changed"]) +def test_original_console_approval_replays_after_closure_without_rearming_or_extending( + tmp_path, transition +): + commands, approval, store, _, clock, console = configured(tmp_path) + request_id = start(commands) + original = approval.approve(request_id) + if transition == "consumed": + commands.complete("complete", request_id, 2) + elif transition == "canceled": + commands.cancel("cancel", request_id, 2) + elif transition == "expired": + clock.time = original.result.decode()["expires_at"] + else: + with store.write() as unit: + policy = unit.policies.get("home") + unit.policies.save(replace(policy, revision=2), 1) + with store.read() as unit: + state = unit.recoveries.get("home", request_id) + members = unit.members.list("home") + audit = unit.audit.entries("home") + events = unit.outbox.pending() + assert approval.approve(request_id) == original + assert len(console.reviews) == 1 + with store.read() as unit: + assert unit.recoveries.get("home", request_id) == state + assert unit.members.list("home") == members + assert unit.audit.entries("home") == audit and unit.outbox.pending() == events + with pytest.raises(Conflict): + commands.complete("another-key", request_id, 2) + + +@pytest.mark.parametrize("channel", ["stable", "edge"]) +def test_retained_console_receipt_never_bypasses_installation_binding(tmp_path, channel): + commands, approval, store, _, _, _ = configured(tmp_path) + request_id = start(commands) + approval.approve(request_id) + approval.installation = Installation( + store.installation.instance_id + if channel == "stable" + else "ac03910c-cfb7-4f3b-b36a-86b263681251", + channel, + ) + with pytest.raises(Conflict, match="recovery_binding_changed"): + approval.approve(request_id) + + +def test_actual_console_retry_after_consumption_describes_original_operation_without_new_approval( + tmp_path, +): + from test_recovery_console import options + + commands, approval, store, _, _, console = configured(tmp_path) + request_id = start(commands) + receipt = approval.approve(request_id) + commands.complete("complete", request_id, 2) + output, error = io.StringIO(), io.StringIO() + assert ( + admin.main( + [*options(store.path, store.installation), request_id], + input_stream=io.StringIO(), + output_stream=output, + error_stream=error, + ) + == 0 + ) + body = json.loads(output.getvalue()) + assert body["operation_id"] == receipt.operation_id + assert body["result"] == receipt.result.decode() and body["state"] == "committed" + assert "by this operation" in body["message"] and "current status" in body["message"] + assert error.getvalue() == "" and len(console.reviews) == 1 + with store.read() as unit: + assert unit.recoveries.get("home", request_id).state.value == "consumed" + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 4 + + +@pytest.mark.parametrize("result", [{}, {"code": "code", "error": "access_denied"}]) +def test_callback_contract_names_exclusive_results_and_invalid_combinations_keep_bound_attempt( + result, +): + client, _, _, _, _, _ = auth_fixture() + schema = client.get("/api/v1/openapi.json").json() + operation = schema["paths"]["/api/v1/auth/callback"]["get"] + assert "Exactly one of code or error" in operation["description"] + started = client.get("/api/v1/auth/start") + state = parse_qs(urlsplit(started.headers["location"]).query)["state"][0] + rejected = client.get("/api/v1/auth/callback", params={"state": state, **result}) + assert rejected.status_code == 422 and rejected.json()["code"] == "invalid_request" + assert client.get("/api/v1/session").status_code == 401 + accepted = client.get("/api/v1/auth/callback", params={"state": state, "code": "code"}) + assert accepted.status_code == 303 + assert client.get("/api/v1/session").status_code == 200 diff --git a/tests/backend/test_session_directory.py b/tests/backend/test_session_directory.py new file mode 100644 index 0000000..bfc1ba3 --- /dev/null +++ b/tests/backend/test_session_directory.py @@ -0,0 +1,151 @@ +"""Server-owned session adapters never expose or substitute the delegated human grant.""" + +from dataclasses import replace + +import pytest +from test_standalone_sessions import fixture, sign_in + +from domus.application.sessions import SessionGuard +from domus.domain.errors import Forbidden, IdentityRejected, Unauthenticated +from domus.domain.identity import HumanIdentity +from domus.infrastructure.session_access import SessionActor, SessionDirectory + +TARGET = "b" * 32 + + +def adapters() -> tuple: + identity, oauth, socket, clock = fixture() + lease = sign_in(identity) + calls = [] + + def directory(token: str, users: tuple[str, ...]) -> tuple[HumanIdentity, ...]: + calls.append((token, users)) + return tuple(HumanIdentity(user, False, True, False) for user in users) + + socket.directory_users = directory + return identity, oauth, socket, clock, lease, calls + + +def test_private_directory_and_actor_share_exact_original_session() -> None: + identity, _, _, clock, lease, calls = adapters() + actor = SessionActor(SessionGuard(identity, identity, clock), lease.session_id) + directory = SessionDirectory(identity, lease.session_id) + assert actor.read() == actor.current() == lease.human + assert directory.get(TARGET).ha_user_id == TARGET + assert calls == [("private-access", (TARGET,))] + assert directory.eligible(()) == () + assert len(calls) == 1 + assert lease.session_id not in repr(actor) and lease.session_id not in repr(directory) + assert "private-access" not in repr(directory) + + +def test_expired_or_logged_out_session_never_performs_a_directory_lookup() -> None: + identity, _, _, clock, lease, calls = adapters() + directory = SessionDirectory(identity, lease.session_id) + clock.value = lease.expires_at + with pytest.raises(Unauthenticated): + directory.get(TARGET) + assert calls == [] + with pytest.raises(Unauthenticated): + directory.eligible((TARGET,)) + assert calls == [] + + +def test_missing_target_does_not_remove_the_requesting_human_session() -> None: + identity, _, socket, _, lease, _ = adapters() + socket.directory_users = lambda *_: () + with pytest.raises(IdentityRejected, match="ha_subject_rejected"): + SessionDirectory(identity, lease.session_id).get(TARGET) + assert identity.get(lease.session_id) is not None + + +def test_directory_uses_refreshed_private_token_without_changing_original_deadline() -> None: + identity, _, _, clock, lease, calls = adapters() + clock.value += 1_770_000 + assert SessionDirectory(identity, lease.session_id).get(TARGET).ha_user_id == TARGET + assert calls == [("renewed-access", (TARGET,))] + assert identity.get(lease.session_id).expires_at == lease.expires_at + + +def test_logout_during_directory_lookup_prevents_returning_old_proof() -> None: + identity, _, socket, _, lease, _ = adapters() + + def directory(*_: object) -> tuple[HumanIdentity, ...]: + identity.remove(lease.session_id) + return (HumanIdentity(TARGET, False, True, False),) + + socket.directory_users = directory + with pytest.raises(IdentityRejected, match="session_changed"): + SessionDirectory(identity, lease.session_id).get(TARGET) + + +def test_directory_permission_failure_is_not_a_human_role_or_session_rejection() -> None: + identity, _, socket, _, lease, _ = adapters() + + def denied(*_: object) -> None: + raise Forbidden("ha_directory_forbidden") + + socket.directory_users = denied + with pytest.raises(Forbidden): + SessionDirectory(identity, lease.session_id).eligible((TARGET,)) + assert identity.get(lease.session_id) is not None + + +def test_changed_source_identity_cannot_use_the_previous_users_private_directory() -> None: + identity, _, socket, _, lease, calls = adapters() + socket.human = replace(socket.human, ha_user_id="c" * 32) + with pytest.raises(IdentityRejected, match="identity_mismatch"): + SessionDirectory(identity, lease.session_id).get(TARGET) + assert calls == [] and identity.get(lease.session_id) is None + + +def test_logout_immediately_after_actor_proof_blocks_directory_preparation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + identity, _, _, _, lease, calls = adapters() + original = SessionGuard.write + + def proof_then_logout(guard: SessionGuard, session_id: str) -> HumanIdentity: + human = original(guard, session_id) + identity.remove(session_id) + return human + + monkeypatch.setattr(SessionGuard, "write", proof_then_logout) + with pytest.raises(IdentityRejected, match="session_changed"): + SessionDirectory(identity, lease.session_id).get(TARGET) + assert calls == [] + + +def test_logout_while_waiting_for_credential_gate_cannot_reuse_the_selected_grant() -> None: + identity, _, _, _, lease, calls = adapters() + + class InterleavedGate: + entered = 0 + + def __enter__(self) -> None: + self.entered += 1 + if self.entered == 2: + identity.remove(lease.session_id) + + def __exit__(self, *_: object) -> None: + pass + + record = identity._credentials[lease.session_id] + identity._credentials[lease.session_id] = replace(record, gate=InterleavedGate()) + with pytest.raises(IdentityRejected, match="session_changed"): + SessionDirectory(identity, lease.session_id).get(TARGET) + assert calls == [] and identity.get(lease.session_id) is None + + +def test_expiry_during_directory_lookup_erases_local_access_and_discards_the_result() -> None: + identity, _, socket, clock, lease, _ = adapters() + + def slow_lookup(*_: object) -> tuple[HumanIdentity, ...]: + clock.value = lease.expires_at + return (HumanIdentity(TARGET, False, True, False),) + + socket.directory_users = slow_lookup + with pytest.raises(IdentityRejected, match="session_expired"): + SessionDirectory(identity, lease.session_id).get(TARGET) + assert identity.get(lease.session_id) is None + assert lease.session_id not in identity._credentials diff --git a/tests/backend/test_session_leases.py b/tests/backend/test_session_leases.py new file mode 100644 index 0000000..6bfa1db --- /dev/null +++ b/tests/backend/test_session_leases.py @@ -0,0 +1,197 @@ +"""Absolute session leases never turn unavailability into authority or a renewed login.""" + +from collections.abc import Callable +from dataclasses import replace + +import pytest + +from domus.application.session_values import SessionLease +from domus.application.sessions import SessionGuard +from domus.domain.errors import ( + Conflict, + IdentityRejected, + IdentityUnavailable, + InvalidInput, + Unauthenticated, +) +from domus.domain.identity import HumanIdentity +from domus.infrastructure.memory_sessions import MemorySessions + +HUMAN = HumanIdentity("ha_1", True, True, False) +ORDINARY = replace(HUMAN, is_administrator=False) + + +class Clock: + value = 100 + + def now(self) -> int: + return self.value + + +class Verifier: + def __init__(self) -> None: + self.result = HUMAN + self.error: Exception | None = None + self.callback: Callable[[], None] = lambda: None + self.calls = 0 + + def prove(self, session_id: str) -> HumanIdentity: + self.calls += 1 + self.callback() + if self.error: + raise self.error + return self.result + + +def fixture() -> tuple[SessionGuard, MemorySessions, Verifier, Clock]: + sessions, verifier, clock = MemorySessions(), Verifier(), Clock() + sessions.add(SessionLease("session_1", HUMAN, 0, 200)) + return SessionGuard(sessions, verifier, clock), sessions, verifier, clock + + +@pytest.mark.parametrize( + "started,expires", + [(0, 0), (100, 99), (-1, 100), (True, 100), (0, True), (0, 28_800_001), (0, 2**63)], +) +def test_lease_has_strict_bounded_monotonic_lifetime(started: object, expires: object) -> None: + with pytest.raises(InvalidInput, match="invalid_session_lease"): + SessionLease("session_1", HUMAN, started, expires) + + +def test_lease_requires_eligible_identity() -> None: + with pytest.raises(InvalidInput, match="invalid_session_lease"): + SessionLease("session_1", "ha_1", 0, 100) + assert SessionLease("session_1", HUMAN, 0, 28_800_000).expires_at == 28_800_000 + + +def test_session_handle_is_omitted_from_diagnostic_representation() -> None: + assert "private-session-handle" not in repr( + SessionLease("private-session-handle", HUMAN, 0, 100) + ) + + +def test_every_fresh_read_and_write_reproves_exact_subject_without_extending_expiry() -> None: + guard, sessions, verifier, _ = fixture() + assert guard.read("session_1") == HUMAN + verifier.result = ORDINARY + assert guard.write("session_1") == ORDINARY + assert verifier.calls == 2 + assert sessions.get("session_1") == SessionLease("session_1", ORDINARY, 0, 200) + + +def test_outage_read_uses_latest_proof_but_write_is_blocked_without_extension() -> None: + guard, sessions, verifier, _ = fixture() + verifier.result = ORDINARY + assert guard.read("session_1") == ORDINARY + verifier.error = IdentityUnavailable("ha_unavailable") + assert guard.read("session_1") == ORDINARY + with pytest.raises(IdentityUnavailable, match="ha_unavailable"): + guard.write("session_1") + assert sessions.get("session_1").expires_at == 200 + + +def test_session_status_distinguishes_fresh_proof_from_bounded_read_grace() -> None: + guard, _, verifier, _ = fixture() + status = guard.status("session_1") + assert status.human == HUMAN and status.fresh and status.expires_at == 200 + verifier.error = IdentityUnavailable("ha_unavailable") + status = guard.status("session_1") + assert status.human == HUMAN and not status.fresh and status.expires_at == 200 + + +@pytest.mark.parametrize("time", [-1, 200, 201]) +def test_missing_expired_and_backwards_clock_leases_cannot_invoke_provider(time: int) -> None: + guard, sessions, verifier, clock = fixture() + clock.value = time + with pytest.raises(Unauthenticated, match="session_expired"): + guard.read("session_1") + assert sessions.get("session_1") is None + with pytest.raises(Unauthenticated, match="session_required"): + guard.write("session_1") + assert verifier.calls == 0 + + +@pytest.mark.parametrize("outage", [False, True]) +def test_expiry_during_provider_io_cannot_return_either_fresh_or_outage_access( + outage: bool, +) -> None: + guard, sessions, verifier, clock = fixture() + verifier.callback = lambda: setattr(clock, "value", 200) + if outage: + verifier.error = IdentityUnavailable("ha_unavailable") + with pytest.raises(Unauthenticated, match="session_expired"): + guard.read("session_1") + assert sessions.get("session_1") is None + + +def test_explicit_ha_rejection_revokes_session_and_cannot_fall_back_on_next_outage() -> None: + guard, sessions, verifier, _ = fixture() + verifier.error = IdentityRejected("ha_rejected") + with pytest.raises(IdentityRejected): + guard.read("session_1") + assert sessions.get("session_1") is None + verifier.error = IdentityUnavailable("ha_unavailable") + with pytest.raises(Unauthenticated, match="session_required"): + guard.read("session_1") + + +def test_mismatched_fresh_identity_is_rejected_and_destroys_original_session() -> None: + guard, sessions, verifier, _ = fixture() + verifier.result = replace(HUMAN, ha_user_id="ha_2") + with pytest.raises(IdentityRejected, match="identity_mismatch"): + guard.write("session_1") + assert sessions.get("session_1") is None + + +@pytest.mark.parametrize("outage", [False, True]) +def test_logout_during_proof_is_not_resurrected_by_late_result(outage: bool) -> None: + guard, sessions, verifier, _ = fixture() + verifier.callback = lambda: sessions.remove("session_1") + if outage: + verifier.error = IdentityUnavailable("ha_unavailable") + with pytest.raises(Unauthenticated, match="session_changed"): + guard.read("session_1") + assert sessions.get("session_1") is None + + +def test_concurrent_changed_identity_cannot_be_overwritten_by_stale_proof() -> None: + guard, sessions, verifier, _ = fixture() + original = sessions.get("session_1") + verifier.callback = lambda: sessions.refresh(original, ORDINARY) + with pytest.raises(Unauthenticated, match="session_changed"): + guard.write("session_1") + assert sessions.get("session_1").human == ORDINARY + + +def test_memory_store_capacity_duplicate_and_cleanup_keep_active_leases() -> None: + sessions = MemorySessions(max_sessions=1) + lease = SessionLease("session_1", HUMAN, 0, 100) + sessions.add(lease) + with pytest.raises(Conflict, match="session_exists"): + sessions.add(lease) + with pytest.raises(IdentityUnavailable, match="session_capacity"): + sessions.add(replace(lease, session_id="session_2")) + assert sessions.discard_expired(99) == 0 + assert sessions.discard_expired(100) == 1 + sessions.add(replace(lease, session_id="session_2", started_at=100, expires_at=200)) + assert sessions.get("session_2") is not None + + +def test_refresh_preserves_deadline_and_cannot_retarget_an_identity() -> None: + sessions = MemorySessions() + lease = SessionLease("session_1", HUMAN, 0, 100) + sessions.add(lease) + with pytest.raises(InvalidInput, match="identity_mismatch"): + sessions.refresh(lease, replace(HUMAN, ha_user_id="ha_2")) + assert sessions.get("session_1") == lease + assert sessions.refresh(lease, ORDINARY) + assert not sessions.refresh(lease, HUMAN) + sessions.remove("session_1") + assert not sessions.refresh(lease, HUMAN) + sessions.remove("session_1") + + +@pytest.mark.parametrize("capacity", [0, -1, True, 1025]) +def test_session_store_capacity_is_bounded(capacity: object) -> None: + with pytest.raises(InvalidInput, match="invalid_session_capacity"): + MemorySessions(max_sessions=capacity) diff --git a/tests/backend/test_snapshot_commands.py b/tests/backend/test_snapshot_commands.py new file mode 100644 index 0000000..5286117 --- /dev/null +++ b/tests/backend/test_snapshot_commands.py @@ -0,0 +1,49 @@ +"""Snapshot use cases cannot report a provider result for another request or installation.""" + +from dataclasses import replace + +import pytest +from test_snapshot_values import INSTALLATION, snapshot + +from domus.application.snapshots import SnapshotCommands +from domus.domain.errors import StorageFailure +from domus.domain.storage import Installation + + +class Identifiers: + def new(self): + return "snapshot-1" + + +class Archive: + value = snapshot() + + def create(self, snapshot_id): + assert snapshot_id == "snapshot-1" + return self.value + + def inspect(self): + return self.value + + +def test_snapshot_use_cases_return_only_the_configured_installation_result() -> None: + commands = SnapshotCommands(Archive(), INSTALLATION, Identifiers()) + assert commands.create() == snapshot() + assert commands.inspect() == snapshot() + + +@pytest.mark.parametrize("operation", ["create", "inspect"]) +def test_other_installation_result_is_not_published(operation: str) -> None: + archive = Archive() + archive.value = replace( + snapshot(), installation=Installation(INSTALLATION.instance_id, "stable") + ) + with pytest.raises(StorageFailure, match="invalid_snapshot_result"): + getattr(SnapshotCommands(archive, INSTALLATION, Identifiers()), operation)() + + +def test_capture_result_for_a_different_id_is_not_published() -> None: + archive = Archive() + archive.value = replace(snapshot(), snapshot_id="another") + with pytest.raises(StorageFailure, match="invalid_snapshot_result"): + SnapshotCommands(archive, INSTALLATION, Identifiers()).create() diff --git a/tests/backend/test_snapshot_console.py b/tests/backend/test_snapshot_console.py new file mode 100644 index 0000000..65becf0 --- /dev/null +++ b/tests/backend/test_snapshot_console.py @@ -0,0 +1,183 @@ +"""The installed console command creates and inspects private snapshots without an HA grant.""" + +import io +import json +from pathlib import Path + +import pytest +from test_sqlite_snapshots import configured + +from domus.composition import admin + + +def source_options(database) -> list[str]: + return [ + "--channel", + database.installation.channel, + "--database", + str(database.path), + "--instance-id", + database.installation.instance_id, + ] + + +def test_console_creates_then_inspects_snapshot_without_access_to_active_source( + tmp_path: Path, +) -> None: + _, _, store, location = configured(tmp_path) + output, error = io.StringIO(), io.StringIO() + assert ( + admin.main( + [*source_options(store), "snapshot", "create", str(location)], + output_stream=output, + error_stream=error, + ) + == 0 + ) + created = json.loads(output.getvalue()) + assert created["state"] == "created" and created["snapshot"]["channel"] == "edge" + assert created["snapshot"]["last_committed_at"] == 100 + assert error.getvalue() == "" + store.path.unlink() + inspected = io.StringIO() + assert ( + admin.main( + [ + "--channel", + "edge", + "--instance-id", + store.installation.instance_id, + "snapshot", + "inspect", + str(location), + ], + output_stream=inspected, + error_stream=error, + ) + == 0 + ) + assert json.loads(inspected.getvalue())["state"] == "verified" + assert json.loads(inspected.getvalue())["snapshot"] == created["snapshot"] + + +def test_console_uses_owned_attachment_and_releases_maintenance_guard(tmp_path: Path) -> None: + from domus.infrastructure.owned_storage import open_storage + + root = tmp_path / "owned" + root.mkdir(mode=0o700) + owned = open_storage(root, "edge") + location = tmp_path / "archive" + try: + assert ( + admin.main( + [ + "--channel", + "edge", + "--data-directory", + str(root), + "snapshot", + "create", + str(location), + ], + output_stream=io.StringIO(), + error_stream=io.StringIO(), + ) + == 0 + ) + finally: + owned.close() + open_storage(root, "edge").close() + + +@pytest.mark.parametrize( + "options", + [[], ["--database", "missing"], ["--data-directory", "missing", "--instance-id", "bad"]], +) +def test_inspection_requires_explicit_identity_and_no_active_source_selection( + tmp_path: Path, options: list[str] +) -> None: + error = io.StringIO() + assert ( + admin.main( + ["--channel", "edge", *options, "snapshot", "inspect", str(tmp_path / "missing")], + output_stream=io.StringIO(), + error_stream=error, + ) + == 2 + ) + assert "Existing records are retained" in error.getvalue() + + +def test_snapshot_creation_requires_source_and_rejects_invalid_deadline_or_existing_output( + tmp_path: Path, +) -> None: + _, _, store, location = configured(tmp_path) + error = io.StringIO() + assert ( + admin.main(["--channel", "edge", "snapshot", "create", str(location)], error_stream=error) + == 2 + ) + assert ( + admin.main( + [*source_options(store), "snapshot", "--timeout", "0", "create", str(location)], + error_stream=error, + ) + == 2 + ) + assert not location.exists() + assert ( + admin.main( + [*source_options(store), "snapshot", "create", str(location)], + output_stream=io.StringIO(), + error_stream=error, + ) + == 0 + ) + before = (location / "manifest.json").read_bytes() + assert ( + admin.main( + [*source_options(store), "snapshot", "create", str(location)], + output_stream=io.StringIO(), + error_stream=error, + ) + == 2 + ) + assert (location / "manifest.json").read_bytes() == before + + +def test_output_failure_after_snapshot_publication_preserves_inspectable_copy( + tmp_path: Path, +) -> None: + _, _, store, location = configured(tmp_path) + + class Broken(io.StringIO): + def write(self, value): + raise OSError("private output fixture detail") + + error = io.StringIO() + assert ( + admin.main( + [*source_options(store), "snapshot", "create", str(location)], + output_stream=Broken(), + error_stream=error, + ) + == 1 + ) + assert "private output fixture detail" not in error.getvalue() + assert (location / "manifest.json").exists() + assert ( + admin.main( + [ + "--channel", + "edge", + "--instance-id", + store.installation.instance_id, + "snapshot", + "inspect", + str(location), + ], + output_stream=io.StringIO(), + error_stream=error, + ) + == 0 + ) diff --git a/tests/backend/test_snapshot_values.py b/tests/backend/test_snapshot_values.py new file mode 100644 index 0000000..e4825d6 --- /dev/null +++ b/tests/backend/test_snapshot_values.py @@ -0,0 +1,65 @@ +"""Snapshot manifests describe one immutable checkpoint without filesystem or credential data.""" + +from dataclasses import replace + +import pytest + +from domus.domain.errors import InvalidInput +from domus.domain.snapshots import Snapshot +from domus.domain.storage import Installation + +INSTALLATION = Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge") + + +def snapshot() -> Snapshot: + return Snapshot("snapshot-1", INSTALLATION, 3, 2000, 1000, "a" * 64, 16384, "b" * 64) + + +def test_manifest_is_canonical_detached_and_has_no_credentials_or_local_paths() -> None: + value = snapshot() + document = value.document() + decoded = document.decode() + assert decoded == { + "format_version": 1, + "snapshot_id": "snapshot-1", + "instance_id": INSTALLATION.instance_id, + "channel": "edge", + "schema_version": 3, + "captured_at": 2000, + "last_committed_at": 1000, + "database_sha256": "a" * 64, + "database_bytes": 16384, + "identity_sha256": "b" * 64, + } + decoded["snapshot_id"] = "changed" + assert value.document() == document + assert replace(value, last_committed_at=None).document().decode()["last_committed_at"] is None + + +@pytest.mark.parametrize( + "field,value", + [ + ("snapshot_id", "bad id"), + ("installation", None), + ("format_version", True), + ("format_version", 2), + ("schema_version", True), + ("schema_version", 0), + ("schema_version", 2**31), + ("captured_at", True), + ("captured_at", -1), + ("captured_at", 2**63), + ("last_committed_at", -1), + ("last_committed_at", True), + ("last_committed_at", 2**63), + ("database_sha256", "A" * 64), + ("database_sha256", "a" * 63), + ("identity_sha256", "secret"), + ("database_bytes", True), + ("database_bytes", 0), + ("database_bytes", 2**63), + ], +) +def test_invalid_manifest_cannot_be_published_as_a_snapshot(field: str, value: object) -> None: + with pytest.raises(InvalidInput): + replace(snapshot(), **{field: value}) diff --git a/tests/backend/test_sqlite_activation.py b/tests/backend/test_sqlite_activation.py new file mode 100644 index 0000000..ddba324 --- /dev/null +++ b/tests/backend/test_sqlite_activation.py @@ -0,0 +1,356 @@ +"""Actual offline publication restores business state and retains later processed facts.""" + +import json +from dataclasses import replace + +import pytest +from test_household_commands import fixture as command_fixture +from test_sqlite_snapshots import Clock + +from domus.application.household_commands import SetupHousehold +from domus.domain.errors import Conflict, DomusError, StorageBusy +from domus.infrastructure.owned_storage import open_storage +from domus.infrastructure.sqlite_activation import SQLiteActivation +from domus.infrastructure.sqlite_restoration import SQLiteRestorations +from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + + +def fixture(tmp_path): + + root = tmp_path / "owned" + root.mkdir(mode=0o700) + storage = open_storage(root, "edge") + commands, _, actor, directory, identifiers = command_fixture(tmp_path / "unused") + commands = replace(commands, transactions=storage.database) + commands.setup("setup", SetupHousehold("Home", "UTC")) + archive = tmp_path / "snapshot" + snapshot = SQLiteSnapshots(storage.database, archive, storage.installation, Clock()).create( + "snapshot" + ) + commands.update("later", "Later state", "UTC", 1) + target = tmp_path / "prepared" + prepared = SQLiteRestorations( + storage.database, archive, target, storage.installation, Clock(), snapshot + ).prepare("restore-1") + installation = storage.installation + storage.close() + return SQLiteActivation(root, installation, Clock(), target), root, installation, prepared + + +def test_real_activation_and_restart_keep_checkpoint_state_plus_later_receipts(tmp_path): + adapter, root, installation, prepared = fixture(tmp_path) + source = (root / "domus.sqlite3").read_bytes() + result = adapter.activate() + assert result.state == "active" and result.record.prepared == prepared + assert not (root / "activation.json").exists() + assert adapter.inspect().state == "active" + assert adapter.activate() == result + storage = open_storage(root, "edge") + try: + assert storage.generation_id == prepared.restore_id + with storage.database.read() as unit: + assert unit.households.current().name == "Home" + assert len(unit.audit.entries("home")) == 1 + assert len(unit.outbox.pending()) == 1 + with storage.database._connection() as connection: + rows = connection.execute( + "SELECT current,document FROM retained_operations ORDER BY position" + ).fetchall() + assert len(rows) == 2 and not rows[1]["current"] + assert json.loads(rows[1]["document"])["archived_by"] == prepared.restore_id + finally: + storage.close() + assert (root / "domus.sqlite3").read_bytes() == source + + +def test_activation_requires_stopped_runtime_and_current_source_before_any_transition(tmp_path): + adapter, root, _, _ = fixture(tmp_path) + running = open_storage(root, "edge") + with pytest.raises(StorageBusy): + adapter.activate() + assert not (root / "activation.json").exists() + with running.database.write() as unit: + assert unit.outbox.acknowledge(unit.outbox.pending()[0].event_id) + running.close() + with pytest.raises(Conflict, match="restore_source_changed"): + adapter.activate() + assert not (root / "activation.json").exists() + + +@pytest.mark.parametrize( + "point", + [ + "intent_stage", + "intent", + "input_copy", + "target_import", + "materialized", + "pointer_before", + "pointer_after", + "receipt", + "unlink_before", + "unlink_after", + ], +) +def test_faulted_transition_preserves_source_and_resumes_exact_input_without_second_effect( + tmp_path, monkeypatch, point +): + from domus.domain.errors import StorageFailure + from domus.infrastructure import sqlite_activation + + adapter, root, installation, prepared = fixture(tmp_path) + original = (root / "domus.sqlite3").read_bytes() + root_inode = root.stat().st_ino + fired = False + real_replace = sqlite_activation.os.replace + real_copy = sqlite_activation._copy + real_verify = sqlite_activation._verify_database + real_unlink = sqlite_activation.os.unlink + + def interrupt(): + nonlocal fired + if not fired: + fired = True + raise OSError("injected durable-boundary failure") + + def replace_file(source, destination, **kwargs): + is_root = sqlite_activation.os.fstat(kwargs["dst_dir_fd"]).st_ino == root_inode + if ( + is_root + and destination == "activation.json" + and point == "intent_stage" + and not (root / "activation.json").exists() + ): + interrupt() + if is_root and destination == "installation.json" and point == "pointer_before": + interrupt() + result = real_replace(source, destination, **kwargs) + if is_root and destination == "activation.json": + phase = json.loads((root / "activation.json").read_text())["phase"] + if point == "intent" and phase == "pending": + interrupt() + if point == "materialized" and phase == "materialized": + interrupt() + if is_root and destination == "installation.json" and point == "pointer_after": + interrupt() + if not is_root and destination == "activation.json" and point == "receipt": + interrupt() + return result + + def copy_file(source, target, name, budget): + if point == "input_copy" and name == "domus.sqlite3": + interrupt() + return real_copy(source, target, name, budget) + + def verify(connection, database, *, previous): + result = real_verify(connection, database, previous=previous) + if point == "target_import" and "generations" in database.path.parts: + interrupt() + return result + + def unlink_file(name, **kwargs): + if name == "activation.json" and point == "unlink_before": + interrupt() + result = real_unlink(name, **kwargs) + if name == "activation.json" and point == "unlink_after": + interrupt() + return result + + monkeypatch.setattr(sqlite_activation.os, "replace", replace_file) + monkeypatch.setattr(sqlite_activation, "_copy", copy_file) + monkeypatch.setattr(sqlite_activation, "_verify_database", verify) + monkeypatch.setattr(sqlite_activation.os, "unlink", unlink_file) + with pytest.raises(StorageFailure): + adapter.activate() + assert fired and (root / "domus.sqlite3").read_bytes() == original + if point != "unlink_after": + with pytest.raises(DomusError): + open_storage(root, "edge") + assert adapter.inspect().state == "pending" + monkeypatch.undo() + resumed = SQLiteActivation( + root, installation, Clock(), adapter.prepared_location, prepared.restore_id + ).resume() + assert resumed.state == "active" + assert adapter.activate().record == resumed.record + restarted = open_storage(root, "edge") + try: + with restarted.database.read() as unit: + assert unit.households.current().name == "Home" + assert len(unit.audit.entries("home")) == 1 + finally: + restarted.close() + + +@pytest.mark.parametrize("mode", ["standalone", "native"]) +def test_actual_compositions_reauthenticate_and_replay_historical_work_after_activation( + tmp_path, monkeypatch, mode +): + from fastapi.testclient import TestClient + from test_household_runtime import headers as standalone_headers + from test_household_runtime import runtime_at + from test_native_http import HEADERS, ORIGIN, Lookup + from test_native_http import headers as native_headers + + from domus.composition import ingress + + root = tmp_path / "root" + root.mkdir(mode=0o700) + + def start(): + if mode == "standalone": + runtime, client = runtime_at(root, monkeypatch, owned=True) + return runtime, client, standalone_headers + monkeypatch.setattr(ingress, "HomeAssistantWebSocket", lambda _: Lookup()) + runtime = ingress.create_owned_runtime("private-supervisor-token", "edge", root, (ORIGIN,)) + return ( + runtime, + TestClient( + runtime.app, base_url=ORIGIN, client=("172.30.32.2", 12000), headers=dict(HEADERS) + ), + native_headers, + ) + + runtime, client, headers = start() + try: + created = client.post( + "/api/v1/household", + json={"name": "Home", "time_zone": "UTC", "expected_revision": 0}, + headers=headers(client, "setup"), + ) + assert created.status_code == 200, created.text + installation = runtime.storage.installation + snapshot = SQLiteSnapshots( + runtime.storage.database, tmp_path / "snapshot", installation, Clock() + ).create("snapshot") + payload = {"name": "Later", "time_zone": "UTC", "expected_revision": 1} + later = client.put("/api/v1/household", json=payload, headers=headers(client, "later")) + assert later.status_code == 200, later.text + prepared_path = tmp_path / "prepared" + SQLiteRestorations( + runtime.storage.database, + tmp_path / "snapshot", + prepared_path, + installation, + Clock(), + snapshot, + ).prepare("restore-1") + finally: + runtime.close() + activation = SQLiteActivation(root, installation, Clock(), prepared_path) + assert activation.activate().state == "active" + runtime, client, headers = start() + try: + assert client.get("/api/v1/household").json()["name"] == "Home" + replay = client.put("/api/v1/household", json=payload, headers=headers(client, "later")) + assert replay.status_code == 200 and replay.json()["state"] == "historical" + assert replay.json()["operation_id"] == later.json()["operation_id"] + assert ( + client.get("/api/v1/operations/" + later.json()["operation_id"]).json()["delivery"] + == "suppressed" + ) + assert activation.inspect().state == "active" + finally: + runtime.close() + + +def test_supported_rollback_checkpoint_reconciles_newer_work_and_never_reactivates_an_old_key( + tmp_path, +): + adapter, root, installation, prepared = fixture(tmp_path) + first = adapter.activate() + rollback = root / "activations" / prepared.restore_id / "source-checkpoint" + snapshot = SQLiteSnapshots(None, rollback, installation, Clock()).inspect() + assert snapshot == first.record.rollback_snapshot + current = open_storage(root, "edge") + try: + from test_atomic_commands import Authorization, Identifiers + from test_atomic_commands import Clock as CommandClock + + from domus.application.commands import AtomicCommands, Change + from domus.domain.storage import CommandKey, Household, JsonDocument + + # Existing commands remain current; a later operation must be retained during rollback. + authority = Authorization() + command = AtomicCommands(current.database, authority, CommandClock(), Identifiers()) + document = JsonDocument.from_value({"later": True}) + + def apply(unit): + unit.households.save(Household("home", "After activation", "UTC", 2), 1) + return Change(document, document, document) + + command.execute( + CommandKey("home", "human", "household.update", "after-activation"), document, apply + ) + second_path = tmp_path / "rollback-prepared" + SQLiteRestorations( + current.database, rollback, second_path, installation, Clock(), snapshot + ).prepare("restore-2") + finally: + current.close() + second = SQLiteActivation(root, installation, Clock(), second_path).activate() + assert second.state == "active" + assert adapter.activate().state == adapter.inspect().state == "historical" + selected = open_storage(root, "edge") + try: + assert selected.generation_id == "restore-2" + with selected.database.read() as unit: + assert unit.households.current().name == "Later state" + with selected.database._connection() as connection: + rows = connection.execute( + "SELECT position,current,archived_by FROM retained_operations ORDER BY position" + ).fetchall() + assert len(rows) == 3 and rows[-1]["current"] == 0 + assert ( + connection.execute( + "SELECT seq FROM sqlite_sequence WHERE name='outbox'" + ).fetchone()[0] + == 3 + ) + finally: + selected.close() + + +def test_process_death_after_pointer_rename_releases_leases_and_resumes_without_external_input( + tmp_path, +): + import subprocess + import sys + + adapter, root, installation, prepared = fixture(tmp_path) + script = tmp_path / "interrupt.py" + script.write_text("""from pathlib import Path +import os,sys +from domus.domain.storage import Installation +from domus.infrastructure import sqlite_activation +class Clock: + def now(self):return 2000 +root=Path(sys.argv[1]);identity=Installation(sys.argv[2],'edge') +real=os.replace;inode=root.stat().st_ino +def stop(source,target,**kwargs): + result=real(source,target,**kwargs) + if target=='installation.json' and os.fstat(kwargs['dst_dir_fd']).st_ino==inode:os._exit(37) + return result +os.replace=stop +sqlite_activation.SQLiteActivation(root,identity,Clock(),Path(sys.argv[3])).activate() +""") + result = subprocess.run( + [ + sys.executable, + str(script), + str(root), + installation.instance_id, + str(adapter.prepared_location), + ], + capture_output=True, + text=True, + timeout=20, + ) + assert result.returncode == 37, result.stderr + with pytest.raises(DomusError): + open_storage(root, "edge") + # Sealed local input is sufficient after the original external path is no longer available. + resumed = SQLiteActivation(root, installation, Clock(), restore_id=prepared.restore_id).resume() + assert resumed.state == "active" + selected = open_storage(root, "edge") + selected.close() diff --git a/tests/backend/test_sqlite_history.py b/tests/backend/test_sqlite_history.py new file mode 100644 index 0000000..5869e44 --- /dev/null +++ b/tests/backend/test_sqlite_history.py @@ -0,0 +1,185 @@ +"""Retained operation facts prevent replay while checkpoint business state remains current.""" + +import sqlite3 +from contextlib import closing +from dataclasses import replace +from pathlib import Path + +import pytest +from test_household_commands import fixture +from test_restoration_values import record + +from domus.domain.errors import Conflict, InvalidInput, StorageFailure +from domus.domain.storage import Installation, RestorationProvenance +from domus.infrastructure.sqlite import SCHEMA_VERSION, SQLiteDatabase +from domus.infrastructure.sqlite_history import SQLiteHistory, iter_operations + + +def install(store, values): + with store._connection() as connection: + connection.execute("BEGIN IMMEDIATE") + try: + SQLiteHistory(connection).install(values) + connection.execute("COMMIT") + except BaseException: + connection.execute("ROLLBACK") + raise + + +def test_historical_lookup_retains_original_result_and_never_enters_pending_delivery(tmp_path): + _, store, _, _, _ = fixture(tmp_path / "database") + original = record(current=False, archived_by="restore-1", position=8) + install(store, [original]) + with store.read() as unit: + receipt = unit.receipts.get(original.evidence.receipt.command) + assert receipt.result == original.evidence.receipt.result + assert receipt.historical and receipt.archived_by == "restore-1" + assert unit.receipts.get_operation("home", "human", receipt.operation_id) == receipt + assert unit.receipts.get_operation("home", "other", receipt.operation_id) is None + assert unit.outbox.delivered(receipt.operation_id) is False + assert unit.outbox.pending() == () and unit.audit.entries("home") == () + assert unit.audit.setup_actor("home") is None + assert unit.households.current() is None + with store.write() as unit: + assert not unit.outbox.acknowledge(receipt.operation_id) + with store._connection() as connection: + assert tuple(iter_operations(connection)) == (original,) + assert ( + connection.execute("SELECT seq FROM sqlite_sequence WHERE name='outbox'").fetchone()[0] + == 8 + ) + + +def test_import_requires_a_transaction_and_preserves_earlier_history_on_failure(tmp_path): + _, store, _, _, _ = fixture(tmp_path / "database") + a = record(current=False, archived_by="restore-1") + b = record("b", current=False, archived_by="restore-2", position=2) + with store._connection() as connection: + with pytest.raises(StorageFailure): + SQLiteHistory(connection).install([a]) + install(store, [a]) + with pytest.raises(Conflict): + install(store, [b]) + with store._connection() as connection: + assert tuple(iter_operations(connection)) == (a,) + + +@pytest.mark.parametrize("version", [1, 2, 3]) +def test_frozen_old_schema_migrates_without_losing_existing_evidence(tmp_path, version): + path = tmp_path / "old.sqlite3" + source = Path(__file__).resolve().parents[1] / f"fixtures/sqlite/v{version}.sql" + with closing(sqlite3.connect(path, autocommit=True)) as connection: + connection.executescript(source.read_text()) + before = { + table: connection.execute(f"SELECT * FROM {table}").fetchall() + for table in ("receipts", "audit", "outbox") + } + store = SQLiteDatabase(path, Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge")) + store.initialize() + with store._connection() as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION == 4 + assert connection.execute("SELECT count(*) FROM retained_operations").fetchone()[0] == 0 + for table, expected in before.items(): + assert [tuple(row) for row in connection.execute(f"SELECT * FROM {table}")] == expected + + +@pytest.mark.parametrize("current", [True, False]) +def test_receipt_provenance_distinguishes_history_without_modifying_original_facts(current): + original = record().evidence.receipt + result = replace(original, restoration=RestorationProvenance("restore-1", current)) + assert result.historical is (not current) and result.archived_by == "restore-1" + assert result.result == original.result and result.operation_id == original.operation_id + assert original.restoration is None and not original.historical and original.archived_by is None + with pytest.raises(InvalidInput): + replace(record().evidence, receipt=result) + + +@pytest.mark.parametrize("restore_id,current", [("bad id", True), ("restore-1", 1)]) +def test_invalid_provenance_is_not_an_operation_placement(restore_id, current): + with pytest.raises(InvalidInput): + RestorationProvenance(restore_id, current) + + +def test_nonmatching_placement_type_is_not_receipt_metadata(): + with pytest.raises(InvalidInput): + replace(record().evidence.receipt, restoration={}) + + +def test_new_snapshot_and_repeated_preparation_keep_historical_facts_and_last_receipt_time( + tmp_path, +): + from test_sqlite_snapshots import Clock + + from domus.infrastructure.sqlite_restoration import SQLiteRestorations + from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + + _, store, _, _, _ = fixture(tmp_path / "database") + original = record(current=False, archived_by="restore-before", position=8, delivered=True) + install(store, [original]) + archive = tmp_path / "snapshot" + snapshots = SQLiteSnapshots(store, archive, store.installation, Clock()) + checkpoint = snapshots.create("with-history") + assert checkpoint.last_committed_at == original.evidence.receipt.created_at + assert snapshots.inspect() == checkpoint + target = tmp_path / "prepared" + adapter = SQLiteRestorations(store, archive, target, store.installation, Clock(), checkpoint) + result = adapter.prepare("restore-again") + assert result.operation_count == 1 and result.current_operations == 0 and result.highwater == 8 + assert adapter.inspect() == result + + +def current_fixture(tmp_path): + from domus.application.household_commands import SetupHousehold + + commands, store, _, _, _ = fixture(tmp_path / "current.sqlite3") + created = commands.setup("setup", SetupHousehold("Home", "UTC")) + with store._connection() as connection: + current = tuple(iter_operations(connection)) + return commands, store, created, current[0] + + +def test_current_import_keeps_provenance_and_acknowledgement_and_advances_next_position(tmp_path): + commands, store, created, original = current_fixture(tmp_path) + retained = replace( + original, archived_by="first-archive", evidence=replace(original.evidence, delivered=True) + ) + extra = record("z", current=False, archived_by="restore-2", position=8) + values = sorted([retained, extra], key=lambda v: v.evidence.command_scope) + install(store, values) + with store.read() as unit: + result = unit.receipts.get(created.command) + assert not result.historical and result.archived_by == "first-archive" + assert ( + unit.receipts.get_operation("home", created.command.actor_id, created.operation_id) + == result + ) + assert unit.outbox.delivered(created.operation_id) is True + assert unit.outbox.pending() == () + install(store, values) + commands.update("new", "New state", "UTC", 1) + with store._connection() as connection: + rows = tuple(iter_operations(connection)) + assert max(row.evidence.position for row in rows) == 9 + with store.read() as unit: + assert len(unit.outbox.pending()) == 1 and len(unit.audit.entries("home")) == 2 + + +def test_large_independent_result_audit_and_event_documents_survive_real_sqlite_history(tmp_path): + from domus.domain.storage import JsonDocument + + _, store, _, _, _ = fixture(tmp_path / "database") + original = record(current=False, archived_by="restore-1") + fact = original.evidence + document = JsonDocument.from_value({"value": "x" * 60_000}) + value = replace( + original, + evidence=replace( + fact, + receipt=replace(fact.receipt, result=document), + audit=replace(fact.audit, detail=document), + event=replace(fact.event, payload=document), + ), + ) + install(store, [value]) + with store._connection() as connection: + assert tuple(iter_operations(connection)) == (value,) diff --git a/tests/backend/test_sqlite_history_integrity.py b/tests/backend/test_sqlite_history_integrity.py new file mode 100644 index 0000000..f8dc161 --- /dev/null +++ b/tests/backend/test_sqlite_history_integrity.py @@ -0,0 +1,246 @@ +"""Malformed history, incomplete imports and sequence changes fail without losing facts.""" + +from dataclasses import replace + +import pytest +from test_household_commands import fixture +from test_restoration_values import record +from test_sqlite_history import current_fixture, install + +from domus.domain.errors import Conflict, InvalidInput, StorageFailure +from domus.domain.storage import CommandKey, JsonDocument +from domus.infrastructure.sqlite_history import ( + SQLiteHistory, + encode_operation, + iter_operations, + verify_history, +) + + +@pytest.mark.parametrize( + "change", + ["facts", "acknowledgement", "archive", "order", "current", "missing", "position_collision"], +) +def test_conflicting_import_is_atomic_even_if_the_caller_commits_after_catching_it( + tmp_path, change +): + _, store, _, _ = current_fixture(tmp_path) + with store._connection() as connection: + original = tuple(iter_operations(connection)) + install(store, original) + a = record("z", current=False, archived_by="restore-1", position=8, delivered=True) + values = sorted([*original, a], key=lambda v: v.evidence.command_scope) + install(store, values) + if change == "facts": + altered = replace( + a, + evidence=replace( + a.evidence, + receipt=replace( + a.evidence.receipt, result=JsonDocument.from_value({"different": True}) + ), + ), + ) + attempted = [altered if v == a else v for v in values] + elif change == "acknowledgement": + attempted = [ + replace(a, evidence=replace(a.evidence, delivered=False)) if v == a else v + for v in values + ] + elif change == "archive": + attempted = [replace(a, archived_by="different") if v == a else v for v in values] + elif change == "order": + attempted = [*values, values[-1]] + elif change == "current": + attempted = [replace(a, current=True) if v == a else v for v in values] + elif change == "position_collision": + attempted = sorted( + [*values, record("zz", current=False, archived_by="restore-2", position=8)], + key=lambda v: v.evidence.command_scope, + ) + else: + attempted = list(original) + with store._connection() as connection: + connection.execute("BEGIN IMMEDIATE") + with pytest.raises(Conflict): + SQLiteHistory(connection).install(attempted) + connection.execute("COMMIT") + assert tuple(iter_operations(connection)) == tuple(values) + + +def test_current_projection_and_omitted_base_facts_are_not_silently_imported(tmp_path): + _, store, _, current = current_fixture(tmp_path) + for attempted in ( + [], + [replace(current, current=False, archived_by="restore-1")], + [replace(current, evidence=replace(current.evidence, delivered=True, position=2))], + ): + with pytest.raises(Conflict): + install(store, attempted) + with store._connection() as connection: + assert tuple(iter_operations(connection)) == (current,) + + +@pytest.mark.parametrize( + "kind", + [ + "json", + "utf8", + "oversized", + "columns", + "closed_binding", + "current_missing", + "current_changed", + "current_ack_regressed", + "historical_collision", + "sequence", + "sequence_type", + "sequence_duplicate", + "delivery", + ], +) +def test_corrupt_history_and_projection_never_verify(tmp_path, kind): + _, store, _, current = current_fixture(tmp_path) + install(store, [current]) + with store._connection() as connection: + if kind == "json": + connection.execute("UPDATE retained_operations SET document='{}'") + elif kind == "utf8": + connection.execute("UPDATE retained_operations SET document=x'ff'") + elif kind == "oversized": + connection.execute("UPDATE retained_operations SET document=?", ("x" * 262_145,)) + elif kind == "columns": + connection.execute("UPDATE retained_operations SET operation_id='different'") + elif kind == "closed_binding": + connection.execute("UPDATE retained_operations SET closed_recovery_id='fabricated'") + elif kind == "current_missing": + connection.execute("DELETE FROM receipts") + elif kind == "current_changed": + connection.execute("UPDATE receipts SET result='{}'") + elif kind == "current_ack_regressed": + delivered = replace(current, evidence=replace(current.evidence, delivered=True)) + connection.execute( + "UPDATE retained_operations SET document=?", (encode_operation(delivered),) + ) + elif kind == "historical_collision": + historical = replace(current, current=False, archived_by="restore-1") + connection.execute( + "UPDATE retained_operations SET current=0,archived_by='restore-1',document=?", + (encode_operation(historical),), + ) + elif kind == "sequence": + connection.execute("UPDATE sqlite_sequence SET seq=0 WHERE name='outbox'") + elif kind == "sequence_type": + connection.execute("UPDATE sqlite_sequence SET seq='wrong' WHERE name='outbox'") + elif kind == "sequence_duplicate": + connection.execute("INSERT INTO sqlite_sequence(name,seq) VALUES ('outbox',1)") + else: + connection.execute("PRAGMA ignore_check_constraints=ON") + connection.execute("UPDATE outbox SET delivered=2") + with pytest.raises(StorageFailure): + verify_history(connection) + + +@pytest.mark.parametrize("kind", ["receipt", "operation", "delivery"]) +def test_incomplete_current_projection_cannot_be_presented_as_historical(tmp_path, kind): + _, store, created, current = current_fixture(tmp_path) + install(store, [current]) + with store._connection() as connection: + if kind == "receipt": + connection.execute("UPDATE receipts SET result='{}'") + elif kind == "operation": + connection.execute("DELETE FROM receipts") + else: + connection.execute("DELETE FROM outbox") + with store.read() as unit: + with pytest.raises(StorageFailure): + if kind == "receipt": + unit.receipts.get(created.command) + elif kind == "operation": + unit.receipts.get_operation("home", created.command.actor_id, created.operation_id) + else: + unit.outbox.delivered(created.operation_id) + + +def test_duplicate_historical_id_and_projection_receipt_cannot_be_stored_as_new_current_work( + tmp_path, +): + _, store, _, _, _ = fixture(tmp_path / "database") + a = record(current=False, archived_by="restore-1") + install(store, [a]) + with store.write() as unit: + for command in ( + a.evidence.receipt.command, + replace(a.evidence.receipt.command, key="other"), + ): + with pytest.raises(Conflict): + unit.receipts.add(replace(a.evidence.receipt, command=command)) + with pytest.raises(InvalidInput): + unit.receipts.add(unit.receipts.get(a.evidence.receipt.command)) + + +@pytest.mark.parametrize( + "action,state,request_id", + [ + ("recovery.complete", "wrong", "r"), + ("recovery.cancel", "canceled", None), + ("recovery.complete", "recovered", "bad id"), + ], +) +def test_malformed_terminal_recovery_facts_do_not_create_a_guard(action, state, request_id): + from domus.domain.recovery import closed_recovery + + receipt = replace( + record().evidence.receipt, + command=CommandKey("home", "human", action, "key"), + result=JsonDocument.from_value({"state": state, "request_id": request_id}), + ) + with pytest.raises(InvalidInput): + closed_recovery(receipt) + + +def test_empty_import_does_not_require_a_household_or_fabricate_a_receipt(tmp_path): + _, store, _, _, _ = fixture(tmp_path / "database") + with store._connection() as connection: + verify_history(connection) + install(store, []) + with store.read() as unit: + assert unit.households.current() is None + + +def test_interrupted_schema_three_upgrade_rolls_back_the_new_history_table(tmp_path, monkeypatch): + import sqlite3 + from contextlib import closing + from pathlib import Path + + from domus.domain.storage import Installation + from domus.infrastructure.sqlite import SQLiteDatabase + + path = tmp_path / "old.sqlite3" + source = Path(__file__).resolve().parents[1] / "fixtures/sqlite/v3.sql" + with closing(sqlite3.connect(path, autocommit=True)) as connection: + connection.executescript(source.read_text()) + before = path.read_bytes() + original = sqlite3.connect + + class Failure(sqlite3.Connection): + def execute(self, statement, parameters=()): + if statement.startswith("CREATE INDEX retained_recovery"): + raise sqlite3.OperationalError("injected failure") + return super().execute(statement, parameters) + + monkeypatch.setattr(sqlite3, "connect", lambda *a, **kw: original(*a, **kw, factory=Failure)) + store = SQLiteDatabase(path, Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge")) + with pytest.raises(StorageFailure): + store.initialize() + monkeypatch.undo() + assert before == path.read_bytes() + with store._connection() as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == 3 + assert ( + connection.execute( + "SELECT name FROM sqlite_master WHERE name='retained_operations'" + ).fetchone() + is None + ) + store.initialize() diff --git a/tests/backend/test_sqlite_identity.py b/tests/backend/test_sqlite_identity.py new file mode 100644 index 0000000..129ded8 --- /dev/null +++ b/tests/backend/test_sqlite_identity.py @@ -0,0 +1,245 @@ +"""Real-file membership/policy contracts and upgrades preserve committed replay evidence.""" + +import sqlite3 +from contextlib import closing +from dataclasses import replace +from pathlib import Path + +import pytest + +from domus.domain.errors import Conflict, DatabaseRejected, StorageFailure +from domus.domain.identity import ( + AdministratorMapping, + HouseholdPolicy, + MappingMode, + Member, + MemberStatus, + Permission, + PermissionRule, + ProfileRule, + Role, +) +from domus.domain.storage import CommandKey, Household, Installation +from domus.infrastructure.sqlite import SQLiteDatabase + +IDENTITY = Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge") +V1 = Path(__file__).resolve().parents[1] / "fixtures/sqlite/v1.sql" + + +def old_database(path: Path) -> None: + with closing(sqlite3.connect(path, autocommit=True)) as connection: + connection.executescript(V1.read_text()) + + +def store_at(path: Path) -> SQLiteDatabase: + store = SQLiteDatabase(path, IDENTITY) + store.initialize() + with store.write() as unit: + unit.households.save(Household("home", "Home", "UTC", 1), 0) + return store + + +def member() -> Member: + return Member( + "member_1", + "home", + "ha_1", + Role.ADULT, + MemberStatus.ACTIVE, + 1, + (PermissionRule(Permission.TASKS_CREATE, False),), + ) + + +def policy() -> HouseholdPolicy: + return HouseholdPolicy( + "home", + AdministratorMapping(MappingMode.SELECTED, "ha_1"), + 1, + (ProfileRule(Role.VIEWER, Permission.TASKS_CREATE, True),), + ) + + +def test_members_and_policy_round_trip_with_no_credential_fields(tmp_path: Path) -> None: + path = tmp_path / "database" + store = store_at(path) + with store.read() as unit: + assert unit.members.get("home", "missing") is None + assert unit.members.by_user("home", "missing") is None + assert unit.members.list("home") == () + assert unit.policies.get("home") is None + with store.write() as unit: + unit.members.save(member(), 0) + unit.policies.save(policy(), 0) + restarted = SQLiteDatabase(path, IDENTITY) + restarted.initialize() + with restarted.read() as unit: + assert unit.members.get("home", "member_1") == member() + assert unit.members.by_user("home", "ha_1") == member() + assert unit.members.list("home") == (member(),) + assert unit.policies.get("home") == policy() + assert unit.members.get("other", "member_1") is None + assert unit.members.by_user("other", "ha_1") is None + assert unit.members.list("other") == () + assert unit.policies.get("other") is None + + +def test_unique_ha_link_is_retained_even_after_archival(tmp_path: Path) -> None: + store = store_at(tmp_path / "database") + with store.write() as unit: + unit.members.save(replace(member(), status=MemberStatus.ARCHIVED), 0) + for duplicate in (member(), replace(member(), member_id="member_2")): + with pytest.raises(Conflict, match="member_exists"): + with store.write() as unit: + unit.members.save(duplicate, 0) + with store.read() as unit: + assert len(unit.members.list("home")) == 1 + + +def test_member_updates_are_scoped_revisioned_and_cannot_relink_history(tmp_path: Path) -> None: + store = store_at(tmp_path / "database") + with store.write() as unit: + unit.members.save(member(), 0) + changed = replace(member(), role=Role.RESTRICTED, revision=2, exceptions=()) + with store.write() as unit: + unit.members.save(changed, 1) + invalid = [ + (member(), 1), + (changed, 1), + (replace(changed, revision=4), 2), + (replace(changed, ha_user_id="different", revision=3), 2), + (replace(changed, household_id="other", revision=3), 2), + ] + for record, revision in invalid: + with pytest.raises(Conflict, match="revision_conflict"): + with store.write() as unit: + unit.members.save(record, revision) + with store.read() as unit: + assert unit.members.get("home", "member_1") == changed + + +def test_policy_cas_duplicate_create_and_clearing_adjustments(tmp_path: Path) -> None: + store = store_at(tmp_path / "database") + with store.write() as unit: + unit.policies.save(policy(), 0) + with pytest.raises(Conflict, match="policy_exists"): + with store.write() as unit: + unit.policies.save(policy(), 0) + changed = replace( + policy(), mapping=AdministratorMapping(MappingMode.ALL), revision=2, adjustments=() + ) + with store.write() as unit: + unit.policies.save(changed, 1) + for record, revision in [ + (policy(), 1), + (changed, 1), + (replace(changed, revision=4), 2), + (replace(changed, household_id="other", revision=3), 2), + ]: + with pytest.raises(Conflict, match="revision_conflict"): + with store.write() as unit: + unit.policies.save(record, revision) + with store.read() as unit: + assert unit.policies.get("home") == changed + + +def test_membership_and_policy_failure_rolls_back_together(tmp_path: Path) -> None: + store = store_at(tmp_path / "database") + with pytest.raises(RuntimeError): + with store.write() as unit: + unit.members.save(member(), 0) + unit.policies.save(policy(), 0) + raise RuntimeError("cancel command") + with store.read() as unit: + assert unit.members.list("home") == () + assert unit.policies.get("home") is None + + +def test_v1_upgrade_preserves_household_receipts_audit_and_pending_outbox(tmp_path: Path) -> None: + path = tmp_path / "database" + old_database(path) + store = SQLiteDatabase(path, IDENTITY) + with pytest.raises(DatabaseRejected, match="unsupported_schema"): + with store.read(): + pytest.fail("Old schema must be explicitly upgraded at initialization") + store.initialize() + store.initialize() + with store.read() as unit: + assert unit.households.current() == Household("home", "Existing household", "UTC", 3) + receipt = unit.receipts.get(CommandKey("home", "ha_1", "household.rename", "old-key")) + assert receipt.operation_id == "old-operation" + assert receipt.result.decode() == {"revision": 3} + assert unit.audit.entries("home")[0].detail.decode() == {"retained": True} + assert unit.outbox.pending()[0].event_id == "old-operation" + assert unit.members.list("home") == () + assert unit.policies.get("home") is None + with store.write() as unit: + unit.members.save(member(), 0) + unit.policies.save(policy(), 0) + with closing(sqlite3.connect(path)) as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == 4 + assert connection.execute("PRAGMA foreign_key_check").fetchall() == [] + + +def test_failed_v1_upgrade_rolls_back_new_tables_and_keeps_old_state( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from domus.infrastructure import sqlite as adapter + + path = tmp_path / "database" + old_database(path) + before = path.read_bytes() + with monkeypatch.context() as patch: + patch.setattr(adapter, "IDENTITY_STATEMENTS", (*adapter.IDENTITY_STATEMENTS, "INVALID SQL")) + with pytest.raises(StorageFailure): + SQLiteDatabase(path, IDENTITY).initialize() + assert path.read_bytes() == before + with closing(sqlite3.connect(path)) as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == 1 + assert ( + connection.execute("SELECT name FROM sqlite_master WHERE name='members'").fetchone() + is None + ) + assert ( + connection.execute("SELECT operation_id FROM receipts").fetchone()[0] == "old-operation" + ) + SQLiteDatabase(path, IDENTITY).initialize() + + +def test_v1_ownership_is_checked_before_migration_writes(tmp_path: Path) -> None: + path = tmp_path / "database" + old_database(path) + before = path.read_bytes() + with pytest.raises(DatabaseRejected, match="installation_mismatch"): + SQLiteDatabase(path, replace(IDENTITY, channel="stable")).initialize() + assert path.read_bytes() == before + + +@pytest.mark.parametrize( + "table,column,value", + [ + ("members", "exceptions", '{"rules":[{"permission":"unknown","allow":true}]}'), + ("members", "exceptions", '{"rules":[{"permission":"tasks.create","allow":1}]}'), + ("members", "exceptions", '{"rules":"wrong"}'), + ("members", "exceptions", '{"rules":[null]}'), + ("household_policy", "adjustments", '{"rules":[{"role":"unknown"}]}'), + ("household_policy", "adjustments", "{}"), + ], +) +def test_invalid_persisted_permission_facts_fail_closed_without_raw_driver_details( + tmp_path: Path, + table: str, + column: str, + value: str, +) -> None: + path = tmp_path / "database" + store = store_at(path) + with store.write() as unit: + unit.members.save(member(), 0) + unit.policies.save(policy(), 0) + with closing(sqlite3.connect(path, autocommit=True)) as connection: + connection.execute(f"UPDATE {table} SET {column}=?", (value,)) + with pytest.raises(StorageFailure, match="invalid_identity_record"): + with store.read() as unit: + unit.members.list("home") if table == "members" else unit.policies.get("home") diff --git a/tests/backend/test_sqlite_recovery.py b/tests/backend/test_sqlite_recovery.py new file mode 100644 index 0000000..9b9d47a --- /dev/null +++ b/tests/backend/test_sqlite_recovery.py @@ -0,0 +1,205 @@ +"""Durable recovery state is scoped, immutable in its binding and migrated atomically.""" + +import sqlite3 +from contextlib import closing +from dataclasses import replace +from pathlib import Path + +import pytest +from test_recovery_values import INSTALLATION, request + +from domus.domain.errors import Conflict, InvalidInput, StorageFailure +from domus.domain.identity import AdministratorMapping, MappingMode +from domus.domain.storage import CommandKey, Household +from domus.infrastructure.sqlite import SCHEMA_VERSION, SQLiteDatabase + + +def storage(path: Path) -> SQLiteDatabase: + database = SQLiteDatabase(path, INSTALLATION) + database.initialize() + with database.write() as unit: + unit.households.save(Household("home", "Home", "UTC", 1), 0) + return database + + +def test_recovery_roundtrip_preserves_binding_and_optimistic_state(tmp_path: Path) -> None: + store = storage(tmp_path / "database") + value = request() + with store.write() as unit: + assert unit.recoveries.get("home", "missing") is None + unit.recoveries.save(value, 0) + with store.read() as unit: + assert unit.recoveries.get("home", value.request_id) == value + assert unit.recoveries.get("other", value.request_id) is None + approved = value.approve(2000) + with store.write() as unit: + unit.recoveries.save(approved, 1) + reopened = SQLiteDatabase(store.path, INSTALLATION) + reopened.initialize(require_existing=True) + with reopened.read() as unit: + assert unit.recoveries.get("home", value.request_id) == approved + with store.write() as unit: + unit.recoveries.save(approved.consume(3000), 2) + with store.read() as unit: + assert unit.recoveries.get("home", value.request_id).state.value == "consumed" + + +def test_stale_duplicate_retargeted_and_missing_recovery_updates_do_not_mutate( + tmp_path: Path, +) -> None: + store = storage(tmp_path / "database") + value = request() + with store.write() as unit: + unit.recoveries.save(value, 0) + changes = [ + (value, 0, "recovery_exists"), + (value.approve(2000), 2, "revision_conflict"), + (replace(value.approve(2000), request_id="missing"), 1, "revision_conflict"), + ( + replace( + value.approve(2000), mapping=AdministratorMapping(MappingMode.SELECTED, "other") + ), + 1, + "recovery_binding_changed", + ), + ] + for changed, expected, reason in changes: + with pytest.raises(Conflict, match=reason): + with store.write() as unit: + unit.recoveries.save(changed, expected) + with store.read() as unit: + assert unit.recoveries.get("home", value.request_id) == value + with store.write() as unit: + unit.recoveries.save(value.approve(2000), 1) + with pytest.raises(Conflict, match="revision_conflict"): + with store.write() as unit: + unit.recoveries.save(value.approve(2000), 1) + + +def test_pending_recovery_inventory_is_bounded_scoped_and_excludes_expired_or_terminal( + tmp_path: Path, +) -> None: + store = storage(tmp_path / "database") + a = request() + b = replace(request(), request_id="b", target_ha_user_id="other").approve(2000) + c = replace(request(), request_id="c").cancel(3000) + d = replace(request(), request_id="d").approve(2000).consume(3000) + with store.write() as unit: + for value in ( + a, + replace(b, state=a.state, revision=1, approved_at=None, expires_at=a.expires_at), + replace(c, state=a.state, revision=1, closed_at=None), + replace( + d, + state=a.state, + revision=1, + approved_at=None, + closed_at=None, + expires_at=a.expires_at, + ), + ): + unit.recoveries.save(value, 0) + unit.recoveries.save(b, 1) + unit.recoveries.save(c, 1) + unit.recoveries.save(replace(d, state=b.state, revision=2, closed_at=None), 1) + unit.recoveries.save(d, 2) + with store.read() as unit: + assert unit.recoveries.pending("home", 3000) == (b, a) + assert unit.recoveries.pending("home", 3000, limit=1) == (b,) + assert unit.recoveries.pending("other", 3000) == () + assert unit.recoveries.pending("home", 601000) == (b,) + assert unit.recoveries.pending("home", 602000) == () + assert unit.recoveries.pending("home", 999) == () + for limit in (True, 0, 258): + with pytest.raises(InvalidInput, match="invalid_page_size"): + unit.recoveries.pending("home", 3000, limit=limit) + + +@pytest.mark.parametrize("fragment", ["not JSON", "{}", "[]", '{"unexpected":true}']) +def test_invalid_stored_document_is_not_silently_skipped(tmp_path: Path, fragment: str) -> None: + store = storage(tmp_path / "database") + with store.write() as unit: + unit.recoveries.save(request(), 0) + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute("UPDATE recovery_requests SET detail=?", (fragment,)) + with pytest.raises(StorageFailure, match="invalid_recovery_record"): + with store.read() as unit: + unit.recoveries.get("home", "request") + + +def test_stored_index_and_complete_document_must_agree(tmp_path: Path) -> None: + store = storage(tmp_path / "database") + with store.write() as unit: + unit.recoveries.save(request(), 0) + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute("UPDATE recovery_requests SET target_ha_user_id='wrong'") + with pytest.raises(StorageFailure, match="invalid_recovery_record"): + with store.read() as unit: + unit.recoveries.pending("home", 3000) + + +@pytest.mark.parametrize("version", [1, 2]) +def test_compatible_migration_preserves_identity_history_and_adds_empty_recovery( + tmp_path: Path, version: int +) -> None: + path = tmp_path / "database" + fixture = Path(__file__).resolve().parents[1] / f"fixtures/sqlite/v{version}.sql" + with closing(sqlite3.connect(path, autocommit=True)) as connection: + connection.executescript(fixture.read_text()) + store = SQLiteDatabase(path, INSTALLATION) + store.initialize(require_existing=True) + with store.read() as unit: + assert unit.households.current().name == "Existing household" + assert ( + unit.receipts.get( + CommandKey("home", "ha_1", "household.rename", "old-key") + ).operation_id + == "old-operation" + ) + assert unit.audit.entries("home")[0].operation_id == "old-operation" + assert unit.outbox.pending()[0].event_id == "old-operation" + assert unit.recoveries.pending("home", 3000) == () + with closing(sqlite3.connect(path)) as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION == 4 + + +def test_failed_v2_upgrade_rolls_back_all_new_schema_and_replay_data( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from domus.infrastructure import sqlite + + path = tmp_path / "database" + fixture = Path(__file__).resolve().parents[1] / "fixtures/sqlite/v2.sql" + with closing(sqlite3.connect(path, autocommit=True)) as connection: + connection.executescript(fixture.read_text()) + monkeypatch.setattr(sqlite, "RECOVERY_STATEMENTS", (*sqlite.RECOVERY_STATEMENTS, "INVALID SQL")) + with pytest.raises(StorageFailure): + SQLiteDatabase(path, INSTALLATION).initialize(require_existing=True) + with closing(sqlite3.connect(path)) as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == 2 + assert ( + connection.execute( + "SELECT count(*) FROM sqlite_master WHERE name='recovery_requests'" + ).fetchone()[0] + == 0 + ) + assert ( + connection.execute("SELECT operation_id FROM receipts").fetchone()[0] == "old-operation" + ) + + +def test_persisted_approval_time_and_deadline_cannot_be_extended_when_consuming( + tmp_path: Path, +) -> None: + store = storage(tmp_path / "database") + value = request() + approved = value.approve(2000) + with store.write() as unit: + unit.recoveries.save(value, 0) + unit.recoveries.save(approved, 1) + forged = replace(approved.consume(4000), approved_at=3000, expires_at=603000) + with pytest.raises(Conflict, match="recovery_binding_changed"): + with store.write() as unit: + unit.recoveries.save(forged, 2) + with store.read() as unit: + assert unit.recoveries.get("home", value.request_id) == approved diff --git a/tests/backend/test_sqlite_restoration.py b/tests/backend/test_sqlite_restoration.py new file mode 100644 index 0000000..bf441d1 --- /dev/null +++ b/tests/backend/test_sqlite_restoration.py @@ -0,0 +1,590 @@ +"""A prepared restore seals old business state plus later evidence and never starts a server.""" + +import hashlib +import json +import sqlite3 +from contextlib import closing +from pathlib import Path + +import pytest +from test_sqlite_snapshots import Clock, configured + +from domus.domain.errors import Conflict, DatabaseRejected, DomusError, InvalidInput, StorageFailure +from domus.domain.storage import Installation +from domus.infrastructure import sqlite_restoration +from domus.infrastructure.owned_storage import attach_storage, open_storage +from domus.infrastructure.sqlite import SQLiteDatabase +from domus.infrastructure.sqlite_restoration import SQLiteRestorations + + +def fixture(tmp_path: Path): + source = tmp_path / "source" + source.mkdir(mode=0o700) + snapshots, commands, store, archive = configured(source) + checkpoint = snapshots.create("checkpoint-1") + commands.update("later", "Later state", "UTC", 1) + target = tmp_path / "prepared" + adapter = SQLiteRestorations(store, archive, target, store.installation, Clock(), checkpoint) + return adapter, store, archive, target + + +def inspect_at(target, installation): + return SQLiteRestorations(None, None, target, installation, Clock()).inspect() + + +def test_preparation_seals_checkpoint_bytes_and_complete_later_history_without_source_mutation( + tmp_path, +): + adapter, store, archive, target = fixture(tmp_path) + source_before = store.path.read_bytes() + archive_before = {p.name: p.read_bytes() for p in archive.iterdir()} + result = adapter.prepare("restore-1") + assert result == inspect_at(target, store.installation) + assert result.operation_count == result.highwater == 2 and result.current_operations == 1 + assert result.checkpoint == adapter.checkpoint + assert (target / "domus.sqlite3").read_bytes() == (archive / "domus.sqlite3").read_bytes() + assert source_before == store.path.read_bytes() + assert archive_before == {p.name: p.read_bytes() for p in archive.iterdir()} + assert json.loads((target / "installation.json").read_text())["state"] == "restore_pending" + rows = [json.loads(line) for line in (target / "operations.jsonl").read_text().splitlines()] + assert [row["current"] for row in rows] == [True, False] + assert rows[1]["archived_by"] == "restore-1" + with SQLiteDatabase(target / "domus.sqlite3", store.installation).read() as unit: + assert unit.households.current().name == "Home" + assert len(unit.audit.entries("home")) == 1 + assert target.stat().st_mode & 0o777 == 0o700 + assert all(p.stat().st_mode & 0o777 == 0o600 for p in target.iterdir()) + + +@pytest.mark.parametrize("complete", [True, False]) +def test_prepared_copy_is_rejected_without_mutation_by_every_managed_and_explicit_runtime( + tmp_path, complete +): + from domus.composition import ingress, server + + adapter, store, _, target = fixture(tmp_path) + adapter.prepare("restore-1") + if not complete: + (target / "restore.json").unlink() + before = {p.name: p.read_bytes() for p in target.iterdir()} + attempts = [ + lambda: open_storage(target, "edge"), + lambda: attach_storage(target, "edge"), + lambda: ingress.create_owned_runtime("fixture-token", "edge", target, ("http://ha.local",)), + lambda: server.create_owned_runtime( + "http://ha.local", "https://domus.example", "edge", target + ), + lambda: ingress.create_business_runtime( + "fixture-token", + "edge", + target / "domus.sqlite3", + store.installation.instance_id, + ("http://ha.local",), + ), + lambda: server.create_runtime( + "http://ha.local", + "https://domus.example", + store.installation.instance_id, + "edge", + database=target / "domus.sqlite3", + ), + ] + for attempt in attempts: + with pytest.raises(DomusError): + attempt() + assert before == {p.name: p.read_bytes() for p in target.iterdir()} + + +@pytest.mark.parametrize( + "name", ["restore.json", "operations.jsonl", "installation.json", "domus.sqlite3"] +) +def test_altered_files_cannot_verify(tmp_path, name): + adapter, store, _, target = fixture(tmp_path) + adapter.prepare("restore-1") + path = target / name + path.write_bytes(path.read_bytes() + b"altered") + with pytest.raises(DatabaseRejected): + inspect_at(target, store.installation) + + +def test_missing_or_conflicting_source_facts_leave_only_a_blocked_partial_target(tmp_path): + adapter, store, _, target = fixture(tmp_path) + with store._connection() as connection: + connection.execute("UPDATE receipts SET result='{}' WHERE action='household.setup'") + before = store.path.read_bytes() + with pytest.raises(Conflict, match="restore_evidence_conflict"): + adapter.prepare("restore-1") + assert not (target / "restore.json").exists() and store.path.read_bytes() == before + with pytest.raises(DatabaseRejected): + inspect_at(target, store.installation) + + +def test_target_never_overwrites_complete_or_partial_outputs_and_aliases_are_rejected(tmp_path): + adapter, store, archive, target = fixture(tmp_path) + result = adapter.prepare("restore-1") + with pytest.raises(Conflict, match="restore_destination_exists"): + adapter.prepare("other") + assert inspect_at(target, store.installation) == result + adapter.location = archive / "inside" + with pytest.raises(InvalidInput): + adapter.prepare("other") + assert not adapter.location.exists() + adapter.location = tmp_path / "new" + adapter.checkpoint = None + with pytest.raises(InvalidInput): + adapter.prepare("other") + adapter.checkpoint = result.checkpoint + adapter.database = None + with pytest.raises(InvalidInput): + adapter.prepare("other") + + +def test_scope_changes_and_snapshot_replacement_fail_before_any_output(tmp_path): + adapter, store, _, target = fixture(tmp_path) + adapter.installation = Installation(store.installation.instance_id, "stable") + with pytest.raises(DatabaseRejected): + adapter.prepare("restore-1") + assert not target.exists() + adapter.installation = store.installation + from dataclasses import replace + + adapter.checkpoint = replace(adapter.checkpoint, snapshot_id="other") + with pytest.raises(DatabaseRejected): + adapter.prepare("restore-1") + assert not target.exists() + + +def test_output_flush_failure_preserves_uncertainty_and_an_independently_inspectable_bundle( + tmp_path, monkeypatch +): + adapter, store, _, target = fixture(tmp_path) + original = sqlite_restoration.os.fsync + + def fail(fd): + if (target / "restore.json").exists(): + raise OSError("private failure") + return original(fd) + + monkeypatch.setattr(sqlite_restoration.os, "fsync", fail) + with pytest.raises(StorageFailure): + adapter.prepare("restore-1") + monkeypatch.undo() + assert inspect_at(target, store.installation).restore_id == "restore-1" + + +@pytest.mark.parametrize("timeout", [0, -1, float("inf"), float("nan"), True, 3601]) +def test_deadlines_are_fixed_and_invalid_configuration_makes_no_artifact(tmp_path, timeout): + with pytest.raises(InvalidInput): + SQLiteRestorations( + None, + None, + tmp_path / "unused", + Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge"), + Clock(), + timeout=timeout, + ) + + +def test_expired_budget_does_not_publish_success(tmp_path): + adapter, _, _, target = fixture(tmp_path) + calls = iter([0, 100]) + adapter.monotonic = lambda: next(calls, 100) + with pytest.raises(StorageFailure): + adapter.prepare("restore-1") + assert not (target / "restore.json").exists() + + +def rewrite_manifest(target, change): + path = target / "restore.json" + value = json.loads(path.read_text()) + change(value) + path.write_text(json.dumps(value)) + + +def reseal(target, name, field): + digest = hashlib.sha256((target / name).read_bytes()).hexdigest() + rewrite_manifest(target, lambda value: value.update({field: digest})) + + +@pytest.mark.parametrize( + "data", + [b"{}", b'"' + b"x" * 16_384 + b'"', b"\xff", b'{"format_version":true}'], +) +def test_invalid_or_oversized_manifest_is_sanitized(tmp_path, data): + adapter, store, _, target = fixture(tmp_path) + adapter.prepare("restore-1") + (target / "restore.json").write_bytes(data) + with pytest.raises(DatabaseRejected, match="invalid_restore_manifest"): + inspect_at(target, store.installation) + + +@pytest.mark.parametrize( + "data", + [ + b"{}\n", + b"{}", + b"\xff\n", + b'{"x":1,"x":2}\n', + b"[" * 2000 + b"]" * 2000 + b"\n", + b"x" * 262_145 + b"\n", + ], +) +def test_rehashed_journal_must_still_contain_bounded_strict_complete_operations(tmp_path, data): + adapter, store, _, target = fixture(tmp_path) + adapter.prepare("restore-1") + (target / "operations.jsonl").write_bytes(data) + reseal(target, "operations.jsonl", "operations_sha256") + with pytest.raises(DatabaseRejected, match="invalid_restore_operations"): + inspect_at(target, store.installation) + + +def test_rehashed_identity_must_remain_blocked_and_match_the_installation(tmp_path): + adapter, store, _, target = fixture(tmp_path) + adapter.prepare("restore-1") + path = target / "installation.json" + value = json.loads(path.read_text()) + value["state"] = "ready" + path.write_text(json.dumps(value)) + reseal(target, "installation.json", "identity_sha256") + with pytest.raises(DatabaseRejected, match="invalid_restore_identity"): + inspect_at(target, store.installation) + + +@pytest.mark.parametrize("field", ["schema_version", "last_committed_at", "database_bytes"]) +def test_manifest_cannot_relabel_the_selected_checkpoint(tmp_path, field): + adapter, store, _, target = fixture(tmp_path) + adapter.prepare("restore-1") + + def change(value): + value["checkpoint"][field] += 1 + + rewrite_manifest(target, change) + with pytest.raises(DatabaseRejected): + inspect_at(target, store.installation) + + +def test_independent_inspection_requires_the_same_channel(tmp_path): + adapter, store, _, target = fixture(tmp_path) + adapter.prepare("restore-1") + with pytest.raises(DatabaseRejected, match="installation_mismatch"): + inspect_at(target, Installation(store.installation.instance_id, "stable")) + + +def test_rehashed_journal_cannot_make_later_business_changes_current(tmp_path): + adapter, store, _, target = fixture(tmp_path) + adapter.prepare("restore-1") + path = target / "operations.jsonl" + rows = [json.loads(line) for line in path.read_text().splitlines()] + rows[-1]["current"] = True + path.write_text("".join(json.dumps(row) + "\n" for row in rows)) + reseal(target, "operations.jsonl", "operations_sha256") + with pytest.raises(DatabaseRejected, match="invalid_restore_projection"): + inspect_at(target, store.installation) + + +@pytest.mark.parametrize("field", ["operation_count", "current_operations", "highwater"]) +def test_manifest_counts_must_match_complete_history(tmp_path, field): + adapter, store, _, target = fixture(tmp_path) + adapter.prepare("restore-1") + rewrite_manifest(target, lambda value: value.update({field: 1})) + if field == "current_operations": + rewrite_manifest(target, lambda value: value.update({field: 0})) + with pytest.raises(DatabaseRejected): + inspect_at(target, store.installation) + + +@pytest.mark.parametrize("state", ["ready", "initializing", "wrong_channel"]) +def test_owned_source_identity_and_destination_are_checked_before_creation(tmp_path, state): + adapter, store, _, target = fixture(tmp_path) + path = store.path.parent / "installation.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "instance_id": store.installation.instance_id, + "channel": "stable" if state == "wrong_channel" else "edge", + "state": "ready" if state == "wrong_channel" else state, + } + ) + ) + path.chmod(0o600) + if state == "ready": + adapter.location = store.path.parent / "inside" + error = InvalidInput + else: + error = DatabaseRejected + with pytest.raises(error): + adapter.prepare("restore-1") + assert not target.exists() and not adapter.location.exists() + + +def test_snapshot_replacement_after_inspection_is_detected_by_copy_hash(tmp_path, monkeypatch): + adapter, _, archive, target = fixture(tmp_path) + original = sqlite_restoration.SQLiteSnapshots.inspect + + def inspect_then_change(instance): + result = original(instance) + with (archive / "domus.sqlite3").open("ab") as stream: + stream.write(b"changed") + return result + + monkeypatch.setattr(sqlite_restoration.SQLiteSnapshots, "inspect", inspect_then_change) + with pytest.raises(DatabaseRejected, match="snapshot_checksum_mismatch"): + adapter.prepare("restore-1") + assert not (target / "restore.json").exists() + + +def test_missing_paths_and_partial_targets_never_provision_or_replace_data(tmp_path): + adapter, store, _, target = fixture(tmp_path) + adapter.location = tmp_path / "missing" / "target" + with pytest.raises(StorageFailure): + adapter.prepare("restore-1") + assert not adapter.location.parent.exists() + with pytest.raises(StorageFailure): + inspect_at(adapter.location, store.installation) + adapter.location = target + target.mkdir(mode=0o700) + marker = target / "partial" + marker.write_bytes(b"retained") + with pytest.raises(Conflict): + adapter.prepare("restore-1") + assert marker.read_bytes() == b"retained" + + +def test_owned_source_outside_target_preserves_later_acknowledgements_without_inventing_them( + tmp_path, +): + adapter, store, _, target = fixture(tmp_path) + identity = store.path.parent / "installation.json" + identity.write_text( + json.dumps( + { + "schema_version": 1, + "instance_id": store.installation.instance_id, + "channel": "edge", + "state": "ready", + } + ) + ) + identity.chmod(0o600) + with store._connection() as connection: + connection.execute("UPDATE outbox SET delivered=1 WHERE event_type='household.setup'") + adapter.prepare("restore-1") + rows = [json.loads(line) for line in (target / "operations.jsonl").read_text().splitlines()] + assert rows[0]["current"] and rows[0]["delivered"] + assert not rows[1]["current"] and not rows[1]["delivered"] + assert inspect_at(target, store.installation).operation_count == 2 + + +def test_empty_checkpoint_keeps_later_setup_as_history_without_creating_a_household(tmp_path): + from test_household_commands import fixture as commands_fixture + + from domus.application.household_commands import SetupHousehold + from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + + commands, store, _, _, _ = commands_fixture(tmp_path / "current.sqlite3") + archive = tmp_path / "empty" + checkpoint = SQLiteSnapshots(store, archive, store.installation, Clock()).create("empty") + commands.setup("first", SetupHousehold("Later household", "UTC")) + target = tmp_path / "prepared" + adapter = SQLiteRestorations(store, archive, target, store.installation, Clock(), checkpoint) + result = adapter.prepare("restore-empty") + assert result.operation_count == 1 and result.current_operations == 0 + with SQLiteDatabase(target / "domus.sqlite3", store.installation).read() as unit: + assert unit.households.current() is None + assert inspect_at(target, store.installation) == result + + +@pytest.mark.parametrize("version", [1, 2, 3]) +def test_compatible_legacy_checkpoint_is_copied_without_migration(tmp_path, version): + from test_snapshot_values import INSTALLATION + + from domus.domain.snapshots import Snapshot + + archive = tmp_path / "archive" + archive.mkdir(mode=0o700) + database = archive / "domus.sqlite3" + frozen = Path(__file__).resolve().parents[1] / f"fixtures/sqlite/v{version}.sql" + with closing(sqlite3.connect(database, autocommit=True)) as connection: + connection.executescript(frozen.read_text()) + # Supply a real canonical command digest; the frozen fixture intentionally uses a dummy. + connection.execute("UPDATE receipts SET payload_fingerprint=?", ("a" * 64,)) + database.chmod(0o600) + identity = archive / "installation.json" + identity.write_text( + json.dumps( + { + "schema_version": 1, + "instance_id": INSTALLATION.instance_id, + "channel": "edge", + "state": "ready", + } + ) + ) + identity.chmod(0o600) + checkpoint = Snapshot( + "old", + INSTALLATION, + version, + 2000, + 100, + hashlib.sha256(database.read_bytes()).hexdigest(), + database.stat().st_size, + hashlib.sha256(identity.read_bytes()).hexdigest(), + ) + manifest = archive / "manifest.json" + manifest.write_text(checkpoint.document().encoded) + manifest.chmod(0o600) + source = tmp_path / "source.sqlite3" + source.write_bytes(database.read_bytes()) + source.chmod(0o600) + target = tmp_path / "prepared" + adapter = SQLiteRestorations( + SQLiteDatabase(source, INSTALLATION), archive, target, INSTALLATION, Clock(), checkpoint + ) + result = adapter.prepare("restore-old") + assert inspect_at(target, INSTALLATION) == result + assert (target / "domus.sqlite3").read_bytes() == database.read_bytes() + with closing(sqlite3.connect(target / "domus.sqlite3")) as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == version + + +def test_current_history_is_one_coherent_read_view_during_a_concurrent_wal_commit( + tmp_path, monkeypatch +): + from test_household_commands import fixture as commands_fixture + + from domus.application.household_commands import SetupHousehold + from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + + commands, store, _, _, _ = commands_fixture(tmp_path / "source.sqlite3") + commands.setup("first", SetupHousehold("Home", "UTC")) + archive = tmp_path / "archive" + checkpoint = SQLiteSnapshots(store, archive, store.installation, Clock()).create("checkpoint") + commands.update("second", "Included", "UTC", 1) + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute("PRAGMA journal_mode=WAL") + original = sqlite_restoration._verify_database + changed = False + + def verify_then_commit(connection, database, *, previous): + nonlocal changed + result = original(connection, database, previous=previous) + if database.path == store.path and not changed: + changed = True + commands.update("third", "Committed after source view", "UTC", 2) + return result + + monkeypatch.setattr(sqlite_restoration, "_verify_database", verify_then_commit) + target = tmp_path / "prepared" + adapter = SQLiteRestorations(store, archive, target, store.installation, Clock(), checkpoint) + result = adapter.prepare("restore-1") + assert result.operation_count == 2 + assert inspect_at(target, store.installation) == result + with store.read() as unit: + assert unit.households.current().revision == 3 and len(unit.audit.entries("home")) == 3 + + +def test_deadline_expiring_after_manifest_flush_reports_uncertainty(tmp_path, monkeypatch): + adapter, store, _, target = fixture(tmp_path) + now = 0 + adapter.monotonic = lambda: now + original = sqlite_restoration.os.fsync + + def flush_then_expire(fd): + nonlocal now + original(fd) + if (target / "restore.json").exists(): + now = 100 + + monkeypatch.setattr(sqlite_restoration.os, "fsync", flush_then_expire) + with pytest.raises(StorageFailure): + adapter.prepare("restore-1") + assert inspect_at(target, store.installation).restore_id == "restore-1" + + +def test_inspection_cannot_report_success_after_its_last_validation_exceeds_the_deadline( + tmp_path, monkeypatch +): + adapter, store, _, target = fixture(tmp_path) + adapter.prepare("restore-1") + now = 0 + original = sqlite_restoration._read_operations + + def read_then_expire(directory, budget): + nonlocal now + yield from original(directory, budget) + now = 100 + + monkeypatch.setattr(sqlite_restoration, "_read_operations", read_then_expire) + inspector = SQLiteRestorations( + None, None, target, store.installation, Clock(), monotonic=lambda: now + ) + with pytest.raises(StorageFailure): + inspector.inspect() + + +def test_blocked_identity_precedes_database_creation_and_prevents_partial_direct_start( + tmp_path, monkeypatch +): + from domus.composition import ingress, server + + adapter, store, _, target = fixture(tmp_path) + original = sqlite_restoration._file + observed = False + + def inspect_before_database(directory, name, flags=sqlite_restoration.os.O_RDONLY): + nonlocal observed + if name == "domus.sqlite3" and flags & sqlite_restoration.os.O_CREAT: + observed = True + assert ( + json.loads((target / "installation.json").read_text())["state"] == "restore_pending" + ) + before = {p.name: p.read_bytes() for p in target.iterdir()} + for attempt in ( + lambda: open_storage(target, "edge"), + lambda: attach_storage(target, "edge"), + lambda: ingress.create_business_runtime( + "fixture-token", + "edge", + target / "domus.sqlite3", + store.installation.instance_id, + ("http://ha.local",), + ), + lambda: server.create_runtime( + "http://ha.local", + "https://domus.example", + store.installation.instance_id, + "edge", + database=target / "domus.sqlite3", + ), + ): + with pytest.raises(DomusError): + attempt() + assert before == {p.name: p.read_bytes() for p in target.iterdir()} + raise OSError("interrupted before checkpoint bytes") + return original(directory, name, flags) + + monkeypatch.setattr(sqlite_restoration, "_file", inspect_before_database) + with pytest.raises(StorageFailure): + adapter.prepare("restore-1") + assert observed and not (target / "domus.sqlite3").exists() + + +@pytest.mark.parametrize("signal", ["restore.json", "operations.jsonl"]) +def test_a_partial_restore_signal_blocks_direct_paths_even_if_identity_is_missing(tmp_path, signal): + from domus.infrastructure.owned_storage import attach_database, reject_owned_database + + adapter, store, _, target = fixture(tmp_path) + adapter.prepare("restore-1") + for name in ("installation.json", "restore.json", "operations.jsonl"): + if name != signal: + (target / name).unlink() + before = {p.name: p.read_bytes() for p in target.iterdir()} + for attempt in ( + lambda: reject_owned_database(target / "domus.sqlite3"), + lambda: attach_database(target / "domus.sqlite3", store.installation), + ): + with pytest.raises(DomusError): + attempt() + assert before == {p.name: p.read_bytes() for p in target.iterdir()} diff --git a/tests/backend/test_sqlite_snapshots.py b/tests/backend/test_sqlite_snapshots.py new file mode 100644 index 0000000..e5c02bd --- /dev/null +++ b/tests/backend/test_sqlite_snapshots.py @@ -0,0 +1,483 @@ +"""Real SQLite snapshots preserve a committed view and reject incomplete or altered bundles.""" + +import hashlib +import json +import os +import sqlite3 +from contextlib import closing +from pathlib import Path + +import pytest +from test_household_commands import fixture + +from domus.application.household_commands import SetupHousehold +from domus.domain.errors import Conflict, DatabaseRejected, InvalidInput, StorageFailure +from domus.domain.storage import Installation +from domus.infrastructure import sqlite_snapshots +from domus.infrastructure.sqlite import SQLiteDatabase +from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + + +class Clock: + def now(self) -> int: + return 2000 + + +def configured(tmp_path: Path) -> tuple: + commands, store, _, _, _ = fixture(tmp_path / "database") + commands.setup("setup", SetupHousehold("Home", "UTC")) + location = tmp_path / "snapshot" + return SQLiteSnapshots(store, location, store.installation, Clock()), commands, store, location + + +def test_snapshot_contains_verified_private_identity_checkpoint_and_all_atomic_records( + tmp_path: Path, +) -> None: + snapshots, _, store, location = configured(tmp_path) + before = store.path.read_bytes() + result = snapshots.create("snapshot-1") + assert ( + result.schema_version == 4 + and result.captured_at == 2000 + and result.last_committed_at == 100 + ) + assert ( + result.database_sha256 + == hashlib.sha256((location / "domus.sqlite3").read_bytes()).hexdigest() + ) + assert result.database_bytes == (location / "domus.sqlite3").stat().st_size + assert ( + result.identity_sha256 + == hashlib.sha256((location / "installation.json").read_bytes()).hexdigest() + ) + assert set(p.name for p in location.iterdir()) == { + "domus.sqlite3", + "installation.json", + "manifest.json", + } + assert location.stat().st_mode & 0o777 == 0o700 + for path in location.iterdir(): + assert path.stat().st_mode & 0o777 == 0o600 + assert snapshots.inspect() == result + assert store.path.read_bytes() == before + copy = SQLiteDatabase(location / "domus.sqlite3", store.installation) + with copy.read() as unit: + assert unit.households.current().name == "Home" + assert len(unit.members.list("home")) == 1 + assert unit.policies.get("home").revision == 1 + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 1 + with closing(sqlite3.connect(copy.path)) as connection: + assert connection.execute("SELECT count(*) FROM receipts").fetchone()[0] == 1 + + +def test_snapshot_never_overwrites_existing_destination_or_creates_one_for_wrong_scope( + tmp_path: Path, +) -> None: + snapshots, _, store, location = configured(tmp_path) + result = snapshots.create("snapshot-1") + with pytest.raises(Conflict, match="snapshot_destination_exists"): + snapshots.create("another") + assert snapshots.inspect() == result + wrong = SQLiteSnapshots( + store, tmp_path / "wrong", Installation(store.installation.instance_id, "stable"), Clock() + ) + with pytest.raises(DatabaseRejected): + wrong.create("snapshot-2") + assert not (tmp_path / "wrong").exists() + with pytest.raises(DatabaseRejected): + SQLiteSnapshots(None, location, wrong.installation, Clock()).inspect() + + +@pytest.mark.parametrize("name", ["domus.sqlite3", "installation.json", "manifest.json"]) +def test_changed_files_never_verify_and_do_not_change_the_source(tmp_path: Path, name: str) -> None: + snapshots, _, store, location = configured(tmp_path) + snapshots.create("snapshot-1") + before = store.path.read_bytes() + path = location / name + content = path.read_bytes() + path.write_bytes(content + b"corruption") + with pytest.raises(DatabaseRejected): + snapshots.inspect() + assert store.path.read_bytes() == before + + +@pytest.mark.parametrize( + "artifact", + [ + "missing_manifest", + "extra_file", + "symlink", + "hardlink", + "fifo", + "shared_file", + "shared_directory", + ], +) +def test_incomplete_or_unsafe_archive_is_not_accepted(tmp_path: Path, artifact: str) -> None: + snapshots, _, _, location = configured(tmp_path) + snapshots.create("snapshot-1") + path = location / "manifest.json" + if artifact == "missing_manifest": + path.unlink() + elif artifact == "extra_file": + (location / "extra").touch() + elif artifact == "shared_directory": + location.chmod(0o755) + elif artifact == "shared_file": + path.chmod(0o644) + else: + content = path.read_bytes() + path.unlink() + outside = tmp_path / "outside" + outside.write_bytes(content) + outside.chmod(0o600) + if artifact == "symlink": + path.symlink_to(outside) + elif artifact == "hardlink": + os.link(outside, path) + else: + os.mkfifo(path, 0o600) + with pytest.raises((DatabaseRejected, StorageFailure)): + snapshots.inspect() + + +def test_failed_copy_has_no_valid_manifest_and_a_retry_cannot_replace_partial_evidence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + snapshots, _, store, location = configured(tmp_path) + + def fail(*args, **kwargs): + raise StorageFailure("injected_copy_failure") + + monkeypatch.setattr(sqlite_snapshots, "_copy", fail) + with pytest.raises(StorageFailure): + snapshots.create("snapshot-1") + assert location.is_dir() and not (location / "manifest.json").exists() + with pytest.raises(DatabaseRejected): + snapshots.inspect() + with pytest.raises(Conflict): + snapshots.create("another") + with store.read() as unit: + assert unit.households.current().name == "Home" + + +def test_read_transaction_pins_snapshot_while_wal_writer_commits_a_newer_revision( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + snapshots, commands, store, location = configured(tmp_path) + with closing(sqlite3.connect(store.path)) as connection: + assert connection.execute("PRAGMA journal_mode=WAL").fetchone()[0] == "wal" + original = sqlite_snapshots._copy + + def interleave(*args, **kwargs): + commands.update("later", "Later home", "UTC", 1) + original(*args, **kwargs) + + monkeypatch.setattr(sqlite_snapshots, "_copy", interleave) + result = snapshots.create("snapshot-1") + assert result.last_committed_at == 100 + with store.read() as unit: + assert unit.households.current().name == "Later home" + with SQLiteDatabase(location / "domus.sqlite3", store.installation).read() as unit: + assert unit.households.current().name == "Home" + assert len(unit.audit.entries("home")) == 1 + assert snapshots.inspect() == result + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), 0, 3601, True]) +def test_invalid_deadline_fails_before_any_destination(tmp_path: Path, value: float) -> None: + _, _, store, location = configured(tmp_path) + with pytest.raises(InvalidInput): + SQLiteSnapshots(store, location, store.installation, Clock(), timeout=value) + assert not location.exists() + + +def test_source_is_required_for_capture_and_unknown_schema_is_not_copied(tmp_path: Path) -> None: + snapshots, _, store, location = configured(tmp_path) + with pytest.raises(InvalidInput): + SQLiteSnapshots(None, location, store.installation, Clock()).create("snapshot-1") + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute("PRAGMA user_version=999") + with pytest.raises(DatabaseRejected): + snapshots.create("snapshot-1") + assert not location.exists() + + +@pytest.mark.parametrize( + "change", + [ + "PRAGMA ignore_check_constraints=ON; UPDATE members SET role='unknown'", + "DELETE FROM households", + "DELETE FROM audit", + "DELETE FROM outbox", + "DELETE FROM receipts", + "DELETE FROM receipts; DELETE FROM audit", + "UPDATE audit SET actor_id='other'", + "UPDATE receipts SET result='broken JSON'", + "UPDATE members SET exceptions='broken JSON'", + "ALTER TABLE members ADD COLUMN unauthorized TEXT", + "UPDATE receipts SET created_at='bad'; UPDATE audit SET created_at='bad'; " + "UPDATE outbox SET created_at='bad'", + ], +) +def test_logical_or_physical_corruption_is_rejected_before_creating_archive( + tmp_path: Path, change: str +) -> None: + snapshots, _, store, location = configured(tmp_path) + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.executescript(change) + with pytest.raises(DatabaseRejected): + snapshots.create("snapshot-1") + assert not location.exists() + + +def test_empty_initialized_database_is_a_valid_checkpoint_without_command_time( + tmp_path: Path, +) -> None: + from test_snapshot_values import INSTALLATION + + database = SQLiteDatabase(tmp_path / "database", INSTALLATION) + database.initialize() + snapshots = SQLiteSnapshots(database, tmp_path / "empty", INSTALLATION, Clock()) + result = snapshots.create("empty-1") + assert result.last_committed_at is None and snapshots.inspect() == result + + +@pytest.mark.parametrize("changed_identity", [False, True]) +def test_recovery_consumption_is_preserved_and_mismatched_installation_recovery_is_rejected( + tmp_path: Path, changed_identity: bool +) -> None: + import json + + from test_recovery_commands import configured as recovery_fixture + from test_recovery_commands import start + + commands, approval, store, _, _, _ = recovery_fixture(tmp_path) + request_id = start(commands) + approval.approve(request_id) + commands.complete("complete", request_id, 2) + if changed_identity: + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + document = json.loads( + connection.execute("SELECT detail FROM recovery_requests").fetchone()[0] + ) + document["channel"] = "stable" + connection.execute("UPDATE recovery_requests SET detail=?", (json.dumps(document),)) + snapshots = SQLiteSnapshots(store, tmp_path / "copy", store.installation, Clock()) + if changed_identity: + with pytest.raises(DatabaseRejected): + snapshots.create("snapshot-1") + else: + result = snapshots.create("snapshot-1") + with SQLiteDatabase( + snapshots.location / "domus.sqlite3", store.installation + ).read() as unit: + assert unit.recoveries.get("home", request_id).state.value == "consumed" + assert snapshots.inspect() == result + + +@pytest.mark.parametrize( + "change", ["unknown", "large", "unicode", "version", "checkpoint", "schema", "identity"] +) +def test_manifest_and_identity_are_strict_and_cross_checked_against_database( + tmp_path: Path, change: str +) -> None: + import json + + snapshots, _, _, location = configured(tmp_path) + snapshots.create("snapshot-1") + path = location / "manifest.json" + document = json.loads(path.read_text()) + if change == "unknown": + document["extra"] = True + elif change == "large": + path.write_bytes(b"x" * 16385) + elif change == "unicode": + path.write_bytes(b"\xff") + elif change == "version": + document["format_version"] = 2 + elif change == "checkpoint": + document["last_committed_at"] = 101 + elif change == "schema": + document["schema_version"] = 2 + else: + identity = location / "installation.json" + value = json.loads(identity.read_text()) + value["state"] = "initializing" + identity.write_text(json.dumps(value)) + document["identity_sha256"] = hashlib.sha256(identity.read_bytes()).hexdigest() + if change not in ("large", "unicode"): + path.write_text(json.dumps(document)) + with pytest.raises(DatabaseRejected): + snapshots.inspect() + + +def test_owned_source_metadata_and_nested_destination_are_checked_before_copy( + tmp_path: Path, +) -> None: + import json + + snapshots, _, store, location = configured(tmp_path) + marker = store.path.parent / "installation.json" + value = { + "schema_version": 1, + "instance_id": store.installation.instance_id, + "channel": "edge", + "state": "initializing", + } + marker.write_text(json.dumps(value)) + marker.chmod(0o600) + with pytest.raises(DatabaseRejected): + snapshots.create("snapshot-1") + value["state"] = "ready" + marker.write_text(json.dumps(value)) + with pytest.raises(InvalidInput, match="snapshot_destination_inside_source"): + snapshots.create("snapshot-1") + assert not location.exists() + outside = tmp_path.parent / (tmp_path.name + "-separate-snapshot") + snapshots.location = outside + try: + assert snapshots.create("snapshot-1") == snapshots.inspect() + finally: + if outside.exists(): + for child in outside.iterdir(): + child.unlink() + outside.rmdir() + + +@pytest.mark.parametrize("failure", ["deadline", "driver", "filesystem", "sql_deadline"]) +def test_copy_failure_is_bounded_sanitized_and_never_publishes_manifest( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + snapshots, _, store, location = configured(tmp_path) + + class Timer: + value = 0 + + def __call__(self): + return self.value + + timer = Timer() + snapshots.monotonic = timer + original = sqlite_snapshots._copy + + def fail(source, target, budget): + if failure in ("deadline", "sql_deadline"): + timer.value = 31 + if failure == "deadline": + original(source, target, budget) + elif failure == "driver": + raise sqlite3.OperationalError("private fixture driver detail") + elif failure == "filesystem": + raise OSError("private fixture path") + else: + source.execute( + "WITH RECURSIVE n(x) AS (VALUES(0) UNION ALL SELECT x+1 FROM n WHERE x<10000) " + "SELECT sum(x) FROM n" + ).fetchone() + + monkeypatch.setattr(sqlite_snapshots, "_copy", fail) + with pytest.raises(StorageFailure) as rejected: + snapshots.create("snapshot-1") + assert "private" not in str(rejected.value) + assert not (location / "manifest.json").exists() + with store.read() as unit: + assert unit.households.current().name == "Home" + + +def test_inspection_missing_directory_and_publication_error_do_not_touch_source( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + snapshots, _, store, location = configured(tmp_path) + with pytest.raises(StorageFailure): + snapshots.inspect() + original = sqlite_snapshots._file + + def fail(directory, name, *args): + if name == "manifest.json": + raise OSError("injected disk full") + return original(directory, name, *args) + + monkeypatch.setattr(sqlite_snapshots, "_file", fail) + with pytest.raises(StorageFailure): + snapshots.create("snapshot-1") + assert (location / "domus.sqlite3").exists() and not (location / "manifest.json").exists() + with store.read() as unit: + assert len(unit.audit.entries("home")) == 1 + + +@pytest.mark.parametrize("version", [1, 2, 3]) +def test_compatible_historical_schema_is_inspected_without_migrating_archive( + tmp_path: Path, version: int +) -> None: + from test_snapshot_values import INSTALLATION + + from domus.domain.snapshots import Snapshot + + location = tmp_path / "historical" + location.mkdir(mode=0o700) + database = location / "domus.sqlite3" + fixture = Path(__file__).resolve().parents[1] / f"fixtures/sqlite/v{version}.sql" + with closing(sqlite3.connect(database, autocommit=True)) as connection: + connection.executescript(fixture.read_text()) + database.chmod(0o600) + identity = location / "installation.json" + identity.write_text( + json.dumps( + { + "schema_version": 1, + "instance_id": INSTALLATION.instance_id, + "channel": "edge", + "state": "ready", + } + ) + ) + identity.chmod(0o600) + manifest = Snapshot( + "historical-1", + INSTALLATION, + version, + 2000, + 100, + hashlib.sha256(database.read_bytes()).hexdigest(), + database.stat().st_size, + hashlib.sha256(identity.read_bytes()).hexdigest(), + ) + (location / "manifest.json").write_text(manifest.document().encoded) + (location / "manifest.json").chmod(0o600) + before = database.read_bytes() + assert SQLiteSnapshots(None, location, INSTALLATION, Clock()).inspect() == manifest + assert database.read_bytes() == before + + +def test_physical_index_corruption_is_rejected_before_archive_publication(tmp_path: Path) -> None: + snapshots, _, store, location = configured(tmp_path) + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.executescript( + "PRAGMA writable_schema=ON; UPDATE sqlite_master SET rootpage=" + "(SELECT rootpage FROM sqlite_master WHERE name='households') " + "WHERE name='outbox_pending'; PRAGMA writable_schema=OFF" + ) + with pytest.raises(DatabaseRejected, match="invalid_snapshot_database"): + snapshots.create("snapshot-1") + assert not location.exists() + + +@pytest.mark.parametrize( + "table,field,value", + [("installation", "singleton", 2), ("households", "singleton", 2), ("outbox", "delivered", 2)], +) +def test_readonly_inspection_also_checks_singleton_and_delivery_invariants( + tmp_path: Path, table: str, field: str, value: int +) -> None: + snapshots, _, _, location = configured(tmp_path) + snapshots.create("snapshot-1") + database = location / "domus.sqlite3" + with closing(sqlite3.connect(database, autocommit=True)) as connection: + connection.execute("PRAGMA ignore_check_constraints=ON") + connection.execute(f"UPDATE {table} SET {field}=?", (value,)) + manifest = json.loads((location / "manifest.json").read_text()) + manifest["database_sha256"] = hashlib.sha256(database.read_bytes()).hexdigest() + (location / "manifest.json").write_text(json.dumps(manifest)) + with pytest.raises(DatabaseRejected): + snapshots.inspect() diff --git a/tests/backend/test_sqlite_transactions.py b/tests/backend/test_sqlite_transactions.py new file mode 100644 index 0000000..132da73 --- /dev/null +++ b/tests/backend/test_sqlite_transactions.py @@ -0,0 +1,379 @@ +"""Real files prove schema ownership, atomic transactions and durable replay facts.""" + +import hashlib +import sqlite3 +import subprocess +import sys +import textwrap +from contextlib import closing +from pathlib import Path + +import pytest + +from domus.domain.errors import ( + Conflict, + DatabaseRejected, + InvalidInput, + StorageBusy, + StorageFailure, +) +from domus.domain.storage import ( + AuditEntry, + CommandKey, + Household, + Installation, + JsonDocument, + OutboxEvent, + Receipt, +) +from domus.infrastructure.sqlite import SQLiteDatabase + +IDENTITY = Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge") +KEY = CommandKey("home", "ha_1", "household.create", "request-1") +RESULT = JsonDocument.from_value({"household_id": "home", "revision": 1}) + + +def database(path: Path) -> SQLiteDatabase: + store = SQLiteDatabase(path, IDENTITY, busy_timeout=0.05) + store.initialize() + return store + + +def receipt(key: CommandKey = KEY, operation_id: str = "operation-1") -> Receipt: + return Receipt(key, "a" * 64, operation_id, RESULT, 100) + + +def test_committed_household_receipt_audit_and_outbox_survive_reopen(tmp_path: Path) -> None: + path = tmp_path / "domus.sqlite3" + store = database(path) + with store.write() as unit: + unit.households.save(Household("home", "Family home", "Europe/Madrid", 1), 0) + unit.receipts.add(receipt()) + unit.audit.append(AuditEntry("operation-1", KEY, RESULT, 100)) + unit.outbox.append(OutboxEvent("operation-1", "home", KEY.action, RESULT, 100)) + reopened = SQLiteDatabase(path, IDENTITY) + reopened.initialize() + with reopened.read() as unit: + assert unit.households.get("home") == Household("home", "Family home", "Europe/Madrid", 1) + assert unit.households.current() == unit.households.get("home") + assert unit.receipts.get(KEY) == receipt() + assert unit.receipts.get_operation("home", "ha_1", "operation-1") == receipt() + assert unit.audit.entries("home") == (AuditEntry("operation-1", KEY, RESULT, 100),) + assert unit.outbox.pending() == ( + OutboxEvent("operation-1", "home", KEY.action, RESULT, 100), + ) + + +@pytest.mark.parametrize("after", ["household", "receipt", "audit", "outbox"]) +def test_business_exception_rolls_back_every_fact(tmp_path: Path, after: str) -> None: + store = database(tmp_path / "domus.sqlite3") + with pytest.raises(RuntimeError, match="effect failed"): + with store.write() as unit: + unit.households.save(Household("home", "Home", "UTC", 1), 0) + if after != "household": + unit.receipts.add(receipt()) + if after in ("audit", "outbox"): + unit.audit.append(AuditEntry("operation-1", KEY, RESULT, 100)) + if after == "outbox": + unit.outbox.append(OutboxEvent("operation-1", "home", KEY.action, RESULT, 100)) + raise RuntimeError("effect failed") + with store.read() as unit: + assert unit.households.current() is None + assert unit.receipts.get(KEY) is None + assert unit.audit.entries("home") == () + assert unit.outbox.pending() == () + + +def test_cancellation_rolls_back_and_releases_the_writer(tmp_path: Path) -> None: + store = database(tmp_path / "domus.sqlite3") + with pytest.raises(KeyboardInterrupt): + with store.write() as unit: + unit.households.save(Household("home", "Home", "UTC", 1), 0) + raise KeyboardInterrupt + with store.write() as unit: + assert unit.households.current() is None + unit.households.save(Household("home", "Home", "UTC", 1), 0) + + +def test_expected_revision_prevents_lost_updates_and_second_household(tmp_path: Path) -> None: + store = database(tmp_path / "domus.sqlite3") + with store.write() as unit: + unit.households.save(Household("home", "First", "UTC", 1), 0) + with store.write() as unit: + unit.households.save(Household("home", "Second", "UTC", 2), 1) + with pytest.raises(Conflict, match="revision_conflict"): + with store.write() as unit: + unit.households.save(Household("home", "Lost update", "UTC", 2), 1) + with pytest.raises(Conflict, match="household_exists"): + with store.write() as unit: + unit.households.save(Household("another", "Another", "UTC", 1), 0) + with store.read() as unit: + assert unit.households.get("home").name == "Second" + assert unit.households.get("another") is None + + +def test_receipt_scope_is_not_an_operation_key_capability(tmp_path: Path) -> None: + store = database(tmp_path / "domus.sqlite3") + with store.write() as unit: + unit.households.save(Household("home", "Home", "UTC", 1), 0) + unit.receipts.add(receipt()) + with store.read() as unit: + assert unit.receipts.get(CommandKey("other", "ha_1", KEY.action, KEY.key)) is None + assert unit.receipts.get(CommandKey("home", "ha_2", KEY.action, KEY.key)) is None + assert unit.receipts.get(CommandKey("home", "ha_1", "other.action", KEY.key)) is None + assert unit.receipts.get_operation("home", "ha_2", "operation-1") is None + assert unit.receipts.get_operation("other", "ha_1", "operation-1") is None + + +def test_constraint_failure_rolls_back_without_driver_text(tmp_path: Path) -> None: + store = database(tmp_path / "domus.sqlite3") + with pytest.raises(StorageFailure) as caught: + with store.write() as unit: + unit.households.save(Household("home", "private title", "UTC", 1), 0) + unit.receipts.add(receipt()) + unit.receipts.add(receipt()) + assert str(caught.value) == "storage_failure" + assert "private title" not in repr(caught.value) + with store.read() as unit: + assert unit.households.current() is None + assert unit.receipts.get(KEY) is None + + +def test_reader_cannot_write_and_an_external_writer_returns_bounded_busy(tmp_path: Path) -> None: + store = database(tmp_path / "domus.sqlite3") + with pytest.raises(StorageFailure): + with store.read() as unit: + unit.households.save(Household("home", "Home", "UTC", 1), 0) + with store.write(): + with pytest.raises(StorageBusy, match="storage_busy"): + with store.write(): + pytest.fail("A second independent writer was admitted") + with store.read() as unit: + assert unit.households.current() is None + + +@pytest.mark.parametrize( + "change", ["future", "unrelated", "missing_metadata", "wrong_channel", "wrong_instance"] +) +def test_schema_and_ownership_rejection_happens_before_file_writes( + tmp_path: Path, change: str +) -> None: + path = tmp_path / "domus.sqlite3" + database(path) + with closing(sqlite3.connect(path, autocommit=True)) as connection: + if change == "future": + connection.execute("PRAGMA user_version=999") + elif change == "unrelated": + connection.execute("PRAGMA application_id=1") + elif change == "missing_metadata": + connection.execute("DELETE FROM installation") + elif change == "wrong_channel": + connection.execute("UPDATE installation SET channel='stable'") + else: + connection.execute( + "UPDATE installation SET instance_id='10000000-0000-0000-0000-000000000001'" + ) + before = hashlib.sha256(path.read_bytes()).hexdigest() + with pytest.raises(DatabaseRejected): + SQLiteDatabase(path, IDENTITY).initialize() + assert hashlib.sha256(path.read_bytes()).hexdigest() == before + + +def test_unversioned_foreign_database_and_non_database_are_not_adopted(tmp_path: Path) -> None: + path = tmp_path / "foreign.sqlite3" + with closing(sqlite3.connect(path)) as connection: + connection.execute("CREATE TABLE unrelated(value TEXT)") + before = path.read_bytes() + with pytest.raises(DatabaseRejected): + SQLiteDatabase(path, IDENTITY).initialize() + assert path.read_bytes() == before + invalid = tmp_path / "not-sqlite" + invalid.write_text("unrelated private file") + with pytest.raises(DatabaseRejected): + SQLiteDatabase(invalid, IDENTITY).initialize() + assert invalid.read_text() == "unrelated private file" + + +def test_partial_initial_migration_is_rolled_back( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from domus.infrastructure import sqlite as adapter + + path = tmp_path / "domus.sqlite3" + with monkeypatch.context() as patch: + patch.setattr(adapter, "SCHEMA_STATEMENTS", (*adapter.SCHEMA_STATEMENTS, "INVALID SQL")) + with pytest.raises(StorageFailure): + SQLiteDatabase(path, IDENTITY).initialize() + with closing(sqlite3.connect(path)) as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == 0 + assert ( + connection.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() == [] + ) + database(path) + + +def test_outbox_acknowledgement_is_durable_and_duplicate_safe(tmp_path: Path) -> None: + path = tmp_path / "domus.sqlite3" + store = database(path) + with store.write() as unit: + unit.households.save(Household("home", "Home", "UTC", 1), 0) + unit.outbox.append(OutboxEvent("event-1", "home", "household.created", RESULT, 100)) + with store.write() as unit: + assert unit.outbox.acknowledge("event-1") + assert not unit.outbox.acknowledge("event-1") + assert not unit.outbox.acknowledge("unknown") + with database(path).read() as unit: + assert unit.outbox.pending() == () + + +@pytest.mark.parametrize("timeout", [0, -1, 6, float("inf"), float("nan")]) +def test_writer_wait_is_a_finite_bounded_configuration(tmp_path: Path, timeout: float) -> None: + with pytest.raises(InvalidInput, match="invalid_storage_timeout"): + SQLiteDatabase(tmp_path / "database", IDENTITY, busy_timeout=timeout) + + +def test_missing_parent_or_removed_database_is_a_safe_storage_failure(tmp_path: Path) -> None: + with pytest.raises(StorageFailure, match="storage_failure"): + SQLiteDatabase(tmp_path / "missing" / "database", IDENTITY).initialize() + path = tmp_path / "database" + store = database(path) + path.unlink() + with pytest.raises(StorageFailure, match="storage_failure"): + with store.read(): + pytest.fail("Read must not silently recreate a removed database") + assert not path.exists() + + +def test_initialization_also_obeys_the_existing_writer_budget(tmp_path: Path) -> None: + path = tmp_path / "database" + store = database(path) + with store.write(): + with pytest.raises(StorageBusy, match="storage_busy"): + SQLiteDatabase(path, IDENTITY, busy_timeout=0.01).initialize() + + +def test_invalid_revision_step_and_outbox_limits_have_no_effect(tmp_path: Path) -> None: + store = database(tmp_path / "database") + with pytest.raises(Conflict, match="revision_conflict"): + with store.write() as unit: + unit.households.save(Household("home", "Home", "UTC", 3), 0) + with store.read() as unit: + for limit in (0, -1, 201, True): + with pytest.raises(InvalidInput, match="invalid_page_size"): + unit.outbox.pending(limit) + assert unit.households.current() is None + + +def test_initialization_rechecks_ownership_after_obtaining_the_writer( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "database" + store = database(path) + inspect = store._inspect + first = True + + def change_after_preflight( + connection: sqlite3.Connection, *, allow_empty: bool = False, allow_previous: bool = False + ) -> int: + nonlocal first + result = inspect(connection, allow_empty=allow_empty, allow_previous=allow_previous) + if first: + first = False + with closing(sqlite3.connect(path, autocommit=True)) as external: + external.execute("UPDATE installation SET channel='stable'") + return result + + monkeypatch.setattr(store, "_inspect", change_after_preflight) + with pytest.raises(DatabaseRejected, match="installation_mismatch"): + store.initialize() + + +def test_display_text_is_bound_as_data_instead_of_executed_as_sql(tmp_path: Path) -> None: + store = database(tmp_path / "database") + name = "Home'); DROP TABLE outbox; --" + with store.write() as unit: + unit.households.save(Household("home", name, "UTC", 1), 0) + with store.read() as unit: + assert unit.households.get("home").name == name + assert unit.outbox.pending() == () + + +def test_separate_process_observes_bounded_busy_then_committed_revision(tmp_path: Path) -> None: + path = tmp_path / "database" + store = database(path) + with store.write() as unit: + unit.households.save(Household("home", "Initial", "UTC", 1), 0) + script = textwrap.dedent(""" + import sys + from pathlib import Path + from domus.domain.errors import DomusError + from domus.domain.storage import Household, Installation + from domus.infrastructure.sqlite import SQLiteDatabase + store = SQLiteDatabase(Path(sys.argv[1]), + Installation('047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6','edge'), busy_timeout=0.01) + try: + with store.write() as unit: + unit.households.save(Household('home','Child','UTC',2),1) + except DomusError as error: + print(error.code) + else: + print('committed') + """) + with store.write() as unit: + unit.households.save(Household("home", "Parent", "UTC", 2), 1) + busy = subprocess.run( + [sys.executable, "-c", script, str(path)], + text=True, + capture_output=True, + check=True, + timeout=5, + ) + assert busy.stdout.strip() == "storage_busy" + conflict = subprocess.run( + [sys.executable, "-c", script, str(path)], + text=True, + capture_output=True, + check=True, + timeout=5, + ) + assert conflict.stdout.strip() == "revision_conflict" + with store.read() as unit: + assert unit.households.get("home").name == "Parent" + + +def test_process_death_cannot_commit_partial_business_or_replay_facts(tmp_path: Path) -> None: + path = tmp_path / "database" + store = database(path) + with store.write() as unit: + unit.households.save(Household("home", "Before crash", "UTC", 1), 0) + script = textwrap.dedent(""" + import os, sys + from pathlib import Path + from domus.domain.storage import ( + AuditEntry, CommandKey, Household, Installation, JsonDocument, OutboxEvent, Receipt) + from domus.infrastructure.sqlite import SQLiteDatabase + store = SQLiteDatabase(Path(sys.argv[1]), + Installation('047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6','edge')) + key = CommandKey('home','ha_1','household.rename','crash') + document = JsonDocument.from_value({'revision':2}) + with store.write() as unit: + unit.households.save(Household('home','Uncommitted','UTC',2),1) + unit.receipts.add(Receipt(key,'a'*64,'crash-operation',document,100)) + unit.audit.append(AuditEntry('crash-operation',key,document,100)) + unit.outbox.append(OutboxEvent('crash-operation','home',key.action,document,100)) + os._exit(23) + """) + crashed = subprocess.run( + [sys.executable, "-c", script, str(path)], + text=True, + capture_output=True, + check=False, + timeout=5, + ) + assert crashed.returncode == 23, crashed.stderr + with database(path).read() as unit: + assert unit.households.get("home").name == "Before crash" + assert unit.households.get("home").revision == 1 + assert unit.receipts.get(CommandKey("home", "ha_1", "household.rename", "crash")) is None + assert unit.audit.entries("home") == () + assert unit.outbox.pending() == () diff --git a/tests/backend/test_standalone_sessions.py b/tests/backend/test_standalone_sessions.py new file mode 100644 index 0000000..4b8f287 --- /dev/null +++ b/tests/backend/test_standalone_sessions.py @@ -0,0 +1,254 @@ +"""Private OAuth sessions bind credentials to leases and erase/revoke only owned grants.""" + +from dataclasses import replace +from threading import Event, Lock, Thread, current_thread +from urllib.parse import parse_qs, urlsplit + +import pytest + +from domus.application.session_values import SessionLease +from domus.application.sessions import SessionGuard +from domus.domain.errors import Conflict, IdentityRejected, IdentityUnavailable, InvalidInput +from domus.domain.identity import HumanIdentity +from domus.infrastructure.ha_config import HAEndpoint, OAuthConfiguration +from domus.infrastructure.ha_http import OAuthTokens +from domus.infrastructure.memory_sessions import MemorySessions +from domus.infrastructure.standalone_sessions import StandaloneIdentity + +HUMAN = HumanIdentity("a" * 32, False, True, False) +TOKENS = OAuthTokens("private-access", "private-refresh", 1800, "homeassistant") + + +class Clock: + value = 100 + + def now(self) -> int: + return self.value + + +class OAuth: + def __init__(self) -> None: + self.configuration = OAuthConfiguration( + HAEndpoint("http://ha.local"), "https://domus.example" + ) + self.issued = TOKENS + self.refreshed = replace(TOKENS, access_token="renewed-access") + self.revoke_error = None + self.revoked = [] + self.refreshes = 0 + + def exchange(self, code: str, verifier: str) -> OAuthTokens: + assert code == "code" and verifier == "v" * 43 + return self.issued + + def refresh(self, token: str, provider: str) -> OAuthTokens: + assert token == TOKENS.refresh_token and provider == TOKENS.provider + self.refreshes += 1 + return self.refreshed + + def revoke(self, token: str) -> None: + self.revoked.append(token) + if self.revoke_error: + raise self.revoke_error + + +class Socket: + endpoint = HAEndpoint("http://ha.local") + + def __init__(self) -> None: + self.human = HUMAN + self.error = None + self.tokens = [] + self.callback = lambda: None + + def current_user(self, tokens: OAuthTokens) -> HumanIdentity: + self.callback() + self.tokens.append(tokens.access_token) + if self.error: + raise self.error + return self.human + + +def fixture(capacity: int = 256) -> tuple[StandaloneIdentity, OAuth, Socket, Clock]: + oauth, socket, clock = OAuth(), Socket(), Clock() + return ( + StandaloneIdentity(oauth, socket, clock, MemorySessions(max_sessions=capacity)), + oauth, + socket, + clock, + ) + + +def sign_in(provider: StandaloneIdentity, key: str = "session_1") -> SessionLease: + return provider.sign_in("code", "v" * 43, key, 100, 3_600_100) + + +def test_authorization_url_has_only_fixed_client_callback_state_and_s256() -> None: + provider, _, _, _ = fixture() + url = urlsplit(provider.authorization_url("s" * 43, "c" * 43)) + assert url.scheme == "http" and url.netloc == "ha.local" and url.path == "/auth/authorize" + assert parse_qs(url.query) == { + "client_id": ["https://domus.example/"], + "redirect_uri": ["https://domus.example/api/v1/auth/callback"], + "state": ["s" * 43], + "code_challenge": ["c" * 43], + "code_challenge_method": ["S256"], + } + with pytest.raises(InvalidInput): + provider.authorization_url("bad", "c" * 43) + + +def test_sign_in_reproves_each_request_and_refreshes_without_extending_lease() -> None: + provider, oauth, socket, clock = fixture() + lease = sign_in(provider) + assert provider.get("session_1") == lease + guard = SessionGuard(provider, provider, clock) + assert guard.read("session_1") == HUMAN + socket.human = replace(HUMAN, is_administrator=True) + clock.value = 1_770_100 + assert guard.write("session_1").is_administrator + assert oauth.refreshes == 1 + assert socket.tokens[-1] == "renewed-access" + assert provider.get("session_1").expires_at == lease.expires_at + assert "private-access" not in repr(provider) and "private-refresh" not in repr(provider) + + +def test_logout_clears_local_lease_and_revokes_only_that_ha_grant() -> None: + provider, oauth, _, _ = fixture() + sign_in(provider) + assert provider.sign_out("session_1") is True + assert provider.get("session_1") is None + assert oauth.revoked == ["private-refresh"] + assert provider.sign_out("session_1") is None + with pytest.raises(IdentityRejected, match="session_required"): + provider.prove("session_1") + + +def test_failed_remote_revoke_retains_local_logout_and_sanitized_diagnostic( + caplog: pytest.LogCaptureFixture, +) -> None: + provider, oauth, _, _ = fixture() + sign_in(provider) + oauth.revoke_error = IdentityUnavailable("ha_unavailable") + assert not provider.sign_out("session_1") + assert provider.get("session_1") is None + assert "ha_grant_cleanup_unavailable" in caplog.text + assert "private-refresh" not in caplog.text + + +def test_failed_proof_or_full_capacity_never_retains_new_grant() -> None: + provider, oauth, socket, _ = fixture(capacity=1) + sign_in(provider) + with pytest.raises(IdentityUnavailable, match="session_capacity"): + sign_in(provider, "session_2") + assert provider.get("session_2") is None + assert oauth.revoked == ["private-refresh"] + socket.error = IdentityRejected("ha_rejected") + with pytest.raises(IdentityRejected): + sign_in(provider, "session_3") + assert provider.get("session_3") is None + assert len(oauth.revoked) == 2 + + +def test_duplicate_session_cannot_replace_old_credentials() -> None: + provider, oauth, _, _ = fixture() + original = sign_in(provider) + with pytest.raises(Conflict, match="session_exists"): + sign_in(provider) + assert provider.get("session_1") == original + assert oauth.revoked == [] + + +def test_rejected_fresh_proof_destroys_lease_and_cannot_use_outage_later() -> None: + provider, _, socket, clock = fixture() + sign_in(provider) + guard = SessionGuard(provider, provider, clock) + socket.error = IdentityRejected("ha_rejected") + with pytest.raises(IdentityRejected): + guard.read("session_1") + assert provider.get("session_1") is None + socket.error = IdentityUnavailable("ha_unavailable") + with pytest.raises(IdentityRejected, match="session_required"): + provider.prove("session_1") + + +def test_expired_sessions_and_credentials_are_pruned_before_new_sign_in() -> None: + provider, _, _, clock = fixture(capacity=1) + sign_in(provider) + clock.value = 3_600_100 + provider.sign_in("code", "v" * 43, "session_2", clock.value, clock.value + 3_600_000) + assert provider.get("session_1") is None + assert provider.get("session_2") is not None + + +def test_lease_that_expires_during_initial_proof_is_not_installed() -> None: + provider, oauth, socket, clock = fixture() + socket.callback = lambda: setattr(clock, "value", 3_600_100) + with pytest.raises(IdentityRejected, match="session_expired"): + sign_in(provider) + assert provider.get("session_1") is None + assert oauth.revoked == ["private-refresh"] + + +def test_logout_during_proof_does_not_resurrect_private_credentials() -> None: + provider, _, socket, _ = fixture() + sign_in(provider) + socket.callback = lambda: provider.remove("session_1") + with pytest.raises(IdentityRejected, match="session_changed"): + provider.prove("session_1") + assert provider.get("session_1") is None + + +def test_already_waiting_proof_rechecks_removed_session_before_using_credentials() -> None: + provider, _, socket, _ = fixture() + sign_in(provider) + entered, waiting, release = Event(), Event(), Event() + + class NotifyingGate: + def __init__(self) -> None: + self.lock = Lock() + + def __enter__(self) -> None: + if current_thread().name == "second": + waiting.set() + self.lock.acquire() + + def __exit__(self, *exception: object) -> None: + self.lock.release() + + # Observe the waiting boundary without relying on scheduler timing or sleeps. + original = provider._credentials["session_1"] + provider._credentials["session_1"] = replace(original, gate=NotifyingGate()) + + def held_proof() -> None: + entered.set() + assert release.wait(timeout=3) + + socket.callback = held_proof + results = [] + + def prove() -> None: + try: + results.append(provider.prove("session_1")) + except IdentityRejected as error: + results.append(error.code) + + first = Thread(target=prove, name="first") + second = Thread(target=prove, name="second") + first.start() + assert entered.wait(timeout=3) + second.start() + assert waiting.wait(timeout=3) + provider.remove("session_1") + release.set() + first.join(timeout=3) + second.join(timeout=3) + assert not first.is_alive() and not second.is_alive() + assert len(results) == 2 and all(isinstance(result, str) for result in results) + + +def test_standalone_clients_must_share_the_same_operator_endpoint() -> None: + oauth, socket, clock = OAuth(), Socket(), Clock() + socket.endpoint = HAEndpoint("http://another") + with pytest.raises(InvalidInput, match="identity_endpoint_mismatch"): + StandaloneIdentity(oauth, socket, clock) diff --git a/tests/backend/test_storage_values.py b/tests/backend/test_storage_values.py new file mode 100644 index 0000000..e078aaf --- /dev/null +++ b/tests/backend/test_storage_values.py @@ -0,0 +1,147 @@ +"""Pure persistence contracts reject ambiguous identifiers and mutable replay payloads.""" + +from dataclasses import FrozenInstanceError + +import pytest + +from domus.domain.errors import InvalidInput +from domus.domain.storage import CommandKey, Household, Installation, JsonDocument + + +def test_installation_has_explicit_persistent_identity_and_channel() -> None: + identity = Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge") + assert identity.channel == "edge" + with pytest.raises(FrozenInstanceError): + identity.channel = "stable" # type: ignore[misc] + + +@pytest.mark.parametrize( + ("instance_id", "channel"), + [ + ("not-a-uuid", "stable"), + ("047CC6FB-DFB0-4FA4-B3C3-E0FE54D116B6", "stable"), + ("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "production"), + ("00000000-0000-0000-0000-000000000000", "stable"), + ], +) +def test_installation_rejects_noncanonical_identity_or_unknown_channel( + instance_id: str, channel: str +) -> None: + with pytest.raises(InvalidInput, match="invalid_installation"): + Installation(instance_id, channel) + + +def test_command_key_scopes_receipts_by_household_actor_action_and_key() -> None: + key = CommandKey("household_1", "ha_1", "household.rename", "request-1") + assert key != CommandKey("household_2", "ha_1", "household.rename", "request-1") + assert key != CommandKey("household_1", "ha_2", "household.rename", "request-1") + assert key != CommandKey("household_1", "ha_1", "household.create", "request-1") + + +@pytest.mark.parametrize("field", ["household_id", "actor_id", "action", "key"]) +@pytest.mark.parametrize("value", ["", "with space", "line\nfeed", "x" * 129]) +def test_command_key_rejects_ambiguous_or_unbounded_scope(field: str, value: str) -> None: + values = dict(household_id="home", actor_id="actor", action="rename", key="request") + values[field] = value + with pytest.raises(InvalidInput, match="invalid_identifier"): + CommandKey(**values) + + +def test_canonical_payload_is_detached_from_callers_and_ignores_object_key_order() -> None: + value = {"b": [2, {"ok": True}], "a": None} + document = JsonDocument.from_value(value) + value["b"] = [] + assert document.decode() == {"a": None, "b": [2, {"ok": True}]} + assert document == JsonDocument('{"b":[2,{"ok":true}],"a":null}') + assert document.fingerprint == JsonDocument.from_value(document.decode()).fingerprint + decoded = document.decode() + decoded["a"] = "changed" + assert document.decode()["a"] is None + + +def test_json_preserves_scalar_types_and_array_order() -> None: + value = {"items": ["é", 1, 1.25, False, None]} + assert JsonDocument.from_value(value).decode() == value + assert ( + JsonDocument.from_value({"items": [1, 2]}).fingerprint + != JsonDocument.from_value({"items": [2, 1]}).fingerprint + ) + + +@pytest.mark.parametrize( + "encoded", + [None, 123, "not json", "[]", "1", '{"a":NaN}', '{"a":Infinity}', '{"a":1,"a":2}'], +) +def test_invalid_or_ambiguous_json_does_not_establish_a_payload(encoded: str) -> None: + with pytest.raises(InvalidInput, match="invalid_document"): + JsonDocument(encoded) + + +@pytest.mark.parametrize("value", [{1: "not a string key"}, {"value": object()}, {"bad": "\ud800"}]) +def test_unsupported_objects_and_invalid_unicode_are_rejected_without_echo(value: object) -> None: + with pytest.raises(InvalidInput) as caught: + JsonDocument.from_value(value) + assert str(caught.value) == "invalid_document" + + +def test_document_size_and_depth_are_bounded() -> None: + with pytest.raises(InvalidInput): + JsonDocument.from_value({"value": "x" * 65_536}) + nested: object = None + for _ in range(20): + nested = [nested] + with pytest.raises(InvalidInput): + JsonDocument.from_value({"nested": nested}) + + +def test_integer_conversion_limits_are_safe_document_validation_failures() -> None: + with pytest.raises(InvalidInput, match="invalid_document"): + JsonDocument.from_value({"unbounded_integer": 10**5000}) + + +def test_encoded_size_and_parser_depth_are_bounded_before_interpretation() -> None: + with pytest.raises(InvalidInput, match="invalid_document"): + JsonDocument(" " * 65_537 + "{}") + with pytest.raises(InvalidInput, match="invalid_document"): + JsonDocument('{"nested":' + "[" * 2000 + "0" + "]" * 2000 + "}") + + +def test_unicode_payloads_use_the_utf8_budget_without_ascii_escape_inflation() -> None: + value = {"name": "🏠" * 10_000} + assert JsonDocument.from_value(value).decode() == value + + +def test_canonical_numeric_expansion_cannot_escape_the_document_budget() -> None: + encoded = '{"values":[' + ",".join(["1e6"] * 10_000) + "]}" + assert len(encoded) < 65_536 + with pytest.raises(InvalidInput, match="invalid_document"): + JsonDocument(encoded) + + +def test_household_record_preserves_revision_and_unicode_display_name() -> None: + record = Household("home", " Family home 🏠 ", "Europe/Madrid", 1) + assert record.name == "Family home 🏠" + assert record.revision == 1 + + +@pytest.mark.parametrize( + ("name", "time_zone", "revision"), + [ + (" ", "UTC", 1), + ("x" * 161, "UTC", 1), + ("bad\nname", "UTC", 1), + ("Home", "", 1), + ("Home", "UTC", 0), + ("Home", "UTC", True), + ("Home", "UTC", 2**63), + (None, "UTC", 1), + ("Home", None, 1), + ("\ud800", "UTC", 1), + ("Home", "\ud800", 1), + ], +) +def test_invalid_household_does_not_become_persistent_state( + name: str, time_zone: str, revision: int +) -> None: + with pytest.raises(InvalidInput, match="invalid_household"): + Household("home", name, time_zone, revision) diff --git a/tests/containers/restore_fixture.py b/tests/containers/restore_fixture.py new file mode 100644 index 0000000..06d55f4 --- /dev/null +++ b/tests/containers/restore_fixture.py @@ -0,0 +1,130 @@ +"""Disposable packaged-data fixtures; never connect to HA or modify an owner installation.""" + +import hashlib +import json +import os +import sqlite3 +import sys +from contextlib import closing +from pathlib import Path + +from domus.application.household_commands import HouseholdCommands, SetupHousehold +from domus.application.household_queries import HouseholdQueries +from domus.domain.identity import HumanIdentity +from domus.infrastructure.application_runtime import SecureIdentifiers, TimeZoneCatalog +from domus.infrastructure.owned_storage import attach_storage +from domus.infrastructure.sqlite_activation import SQLiteActivation + +ROOT = Path("/data/domus") + + +class Actor: + def current(self): + return HumanIdentity("packaged-fixture-human", True, True, False) + + def get(self, _identifier): + raise AssertionError("This fixture does not use a directory lookup") + + +class Clock: + def now(self): + return 100 + + +def commands(database): + return HouseholdCommands( + database, Actor(), Actor(), TimeZoneCatalog(), Clock(), SecureIdentifiers() + ) + + +def private_layout(): + assert os.getuid() == os.getgid() == 65532 + for folder, directories, files in os.walk(ROOT): + directory = Path(folder) + assert directory.stat().st_uid == 65532 and directory.stat().st_mode & 0o777 == 0o700 + for name in directories + files: + path = directory / name + assert not path.is_symlink() and path.stat().st_uid == 65532 + assert path.stat().st_mode & 0o777 == (0o700 if path.is_dir() else 0o600) + + +def main(): + mode = sys.argv[1] + if mode == "fault": + point = sys.argv[2] + with closing(attach_storage(ROOT, "edge")) as storage: + installation = storage.installation + original = os.replace + inode = ROOT.stat().st_ino + + def terminate(source, target, **kwargs): + result = original(source, target, **kwargs) + if os.fstat(kwargs["dst_dir_fd"]).st_ino == inode: + if target == "installation.json" and point == "selected": + os._exit(37) + if target == "activation.json" and point == "pending": + os._exit(37) + return result + + os.replace = terminate + SQLiteActivation(ROOT, installation, Clock(), Path("/data/snapshots/prepared")).activate() + raise AssertionError("The process did not terminate at its selected boundary") + with closing(attach_storage(ROOT, "edge")) as storage: + service = commands(storage.database) + if mode == "initialize": + service.setup("setup", SetupHousehold("Checkpoint home", "UTC")) + elif mode == "later": + receipt = service.update("later", "Later home", "UTC", 1) + if sys.argv[2] == "delivered": + with storage.database.write() as unit: + unit.outbox.acknowledge(receipt.operation_id) + elif mode == "after": + service.update("after", "After activation", "UTC", 1) + elif mode == "active": + replay = service.update("later", "Later home", "UTC", 1) + assert replay.historical + view = HouseholdQueries(storage.database, Actor().current()).operation( + replay.operation_id + ) + assert view.delivered == (sys.argv[2] == "delivered") + with storage.database.read() as unit: + assert unit.households.current().name == "Checkpoint home" + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 1 + elif mode == "rollback": + assert not service.update("later", "Later home", "UTC", 1).historical + assert service.update("after", "After activation", "UTC", 1).historical + with storage.database.read() as unit: + assert unit.households.current().name == "Later home" + with storage.database._connection() as connection: + assert ( + connection.execute( + "SELECT seq FROM sqlite_sequence WHERE name='outbox'" + ).fetchone()[0] + == 3 + ) + assert ( + connection.execute("SELECT COUNT(*) FROM retained_operations").fetchone()[0] + == 3 + ) + else: + raise AssertionError("Unknown fixture operation") + with storage.database._connection() as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == 4 + private_layout() + print( + json.dumps( + { + "instance_id": storage.installation.instance_id, + "generation_id": storage.generation_id, + "flat_sha256": hashlib.sha256( + (ROOT / "domus.sqlite3").read_bytes() + ).hexdigest(), + "python": sys.version.split()[0], + "sqlite": sqlite3.sqlite_version, + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/containers/test_app_compatibility.py b/tests/containers/test_app_compatibility.py new file mode 100644 index 0000000..45349d9 --- /dev/null +++ b/tests/containers/test_app_compatibility.py @@ -0,0 +1,263 @@ +"""The actual previous image upgrades safely and cannot downgrade retained current state.""" + +import json +import os +import uuid + +import pytest +from test_app_image import ( + _STORAGE_COMMAND, + console, + docker, + packaged_fixture, + ready, + stopped, + successful, +) +from test_app_image import data_volume as data_volume +from test_app_image import image as image +from test_app_image import restore_runtime as restore_runtime + + +@pytest.fixture +def previous_image(): + value = os.environ.get("DOMUS_PREVIOUS_IMAGE") + assert value, "Set DOMUS_PREVIOUS_IMAGE to the reviewed schema-3 Edge.2 image" + result = docker("image", "inspect", value) + assert result.returncode == 0, result.stderr + labels = json.loads(result.stdout)[0]["Config"]["Labels"] + assert labels["org.opencontainers.image.revision"] == "d013cf2c8b932d0e00f5858053cbc0e663d5eee1" + assert labels["io.hass.version"] == "0.0.0-edge.2" + return value + + +def inventory(image, volume): + return successful( + docker( + "run", + "--rm", + "--network", + "none", + "--user", + "65532:65532", + "--volume", + volume + ":/data", + "--entrypoint", + "/app/.venv/bin/python", + image, + "-c", + "import pathlib,hashlib,json; root=pathlib.Path('/data/domus'); " + "print(json.dumps({str(p.relative_to(root)):hashlib.sha256(p.read_bytes()).hexdigest() " + "for p in root.rglob('*') if p.is_file()}))", + ) + ) + + +def refuse_old_image(image, previous, volume): + before = inventory(image, volume) + refused = docker( + "run", + "--rm", + "--network", + "none", + "--volume", + volume + ":/data", + "--env", + "SUPERVISOR_TOKEN=fixture-supervisor-token", + previous, + ) + assert refused.returncode == 2, refused.stderr + assert "fixture-supervisor-token" not in refused.stdout + refused.stderr + assert inventory(image, volume) == before + + +@pytest.mark.parametrize("restore_runtime", ["native", "standalone"], indirect=True) +def test_actual_older_image_refuses_selected_generation_without_changing_any_file( + image, previous_image, restore_runtime +): + name, volume, _, identifier, mode = restore_runtime + successful( + console( + image, + volume, + "--data-directory", + "/data/domus", + "restore", + "activate", + "/data/snapshots/prepared", + ) + ) + refuse_old_image(image, previous_image, volume) + assert docker("start", name).returncode == 0 + ready(name, standalone=mode == "standalone") + assert ( + successful(packaged_fixture(image, volume, "active", "suppressed"))["generation_id"] + == identifier + ) + stopped(name) + + +def test_real_schema_three_image_upgrades_replays_and_restores_its_pre_upgrade_snapshot( + image, previous_image, data_volume +): + old = "domus-upgrade-old-" + uuid.uuid4().hex + new = "domus-upgrade-new-" + uuid.uuid4().hex + try: + result = docker( + "run", + "--detach", + "--init", + "--network", + "none", + "--name", + old, + "--volume", + data_volume + ":/data", + "--env", + "SUPERVISOR_TOKEN=fixture-supervisor-token", + previous_image, + ) + assert result.returncode == 0, result.stderr + ready(old) + seeded = docker( + "exec", + "--user", + "65532:65532", + old, + "/app/.venv/bin/python", + "-c", + _STORAGE_COMMAND, + "0", + ) + assert seeded.returncode == 0, seeded.stderr + result = docker( + "exec", + old, + "/app/.venv/bin/python", + "-c", + "import os; os.mkdir('/data/snapshots',0o700); os.chown('/data/snapshots',65532,65532)", + ) + assert result.returncode == 0, result.stderr + checkpoint = successful( + console( + previous_image, + data_volume, + "--data-directory", + "/data/domus", + "snapshot", + "create", + "/data/snapshots/schema3", + ) + ) + assert checkpoint["snapshot"]["schema_version"] == 3 + instance_id = checkpoint["snapshot"]["instance_id"] + stopped(old) + result = docker( + "run", + "--detach", + "--init", + "--network", + "none", + "--name", + new, + "--volume", + data_volume + ":/data", + "--env", + "SUPERVISOR_TOKEN=fixture-supervisor-token", + image, + ) + assert result.returncode == 0, result.stderr + ready(new) + replay = docker( + "exec", + "--user", + "65532:65532", + new, + "/app/.venv/bin/python", + "-c", + _STORAGE_COMMAND, + "1", + ) + assert replay.returncode == 0, replay.stderr + current = successful( + console( + image, + data_volume, + "--data-directory", + "/data/domus", + "snapshot", + "create", + "/data/snapshots/schema4", + ) + ) + assert current["snapshot"]["schema_version"] == 4 + assert current["snapshot"]["instance_id"] == instance_id + inspected = successful( + console( + image, + data_volume, + "--instance-id", + instance_id, + "snapshot", + "inspect", + "/data/snapshots/schema3", + ) + ) + assert inspected["snapshot"] == checkpoint["snapshot"] + stopped(new) + # Even without generation selection, the older image must refuse the future schema. + refuse_old_image(image, previous_image, data_volume) + successful( + console( + image, + data_volume, + "--data-directory", + "/data/domus", + "restore", + "prepare", + "/data/snapshots/schema3", + "/data/snapshots/prepared3", + ) + ) + active = successful( + console( + image, + data_volume, + "--data-directory", + "/data/domus", + "restore", + "activate", + "/data/snapshots/prepared3", + ) + ) + assert active["state"] == "active" + assert docker("start", new).returncode == 0 + ready(new) + replay = docker( + "exec", + "--user", + "65532:65532", + new, + "/app/.venv/bin/python", + "-c", + _STORAGE_COMMAND, + "1", + ) + assert replay.returncode == 0, replay.stderr + final = successful( + console( + image, + data_volume, + "--data-directory", + "/data/domus", + "restore", + "status", + active["selected_generation"], + ) + ) + assert final["state"] == "active" + stopped(new) + finally: + for name in (old, new): + result = docker("rm", "--force", name) + assert result.returncode == 0 or "No such container" in result.stderr, result.stderr diff --git a/tests/containers/test_app_image.py b/tests/containers/test_app_image.py new file mode 100644 index 0000000..3521937 --- /dev/null +++ b/tests/containers/test_app_image.py @@ -0,0 +1,644 @@ +"""Exercise the actual reviewed image without connecting to a real HA installation.""" + +import json +import os +import subprocess +import time +import uuid +from pathlib import Path + +import pytest + + +def docker(*arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["docker", *arguments], capture_output=True, text=True, timeout=45, check=False + ) + + +@pytest.fixture +def image() -> str: + value = os.environ.get("DOMUS_TEST_IMAGE") + assert value, "Set DOMUS_TEST_IMAGE to the freshly built app image" + return value + + +def test_image_pins_the_private_launcher_and_installs_a_nonroot_readable_runtime( + image: str, +) -> None: + result = docker("image", "inspect", image) + assert result.returncode == 0, result.stderr + config = json.loads(result.stdout)[0]["Config"] + assert config["User"] == "0:0" + assert config["Entrypoint"] == ["/app/.venv/bin/domus-ha-app", "--channel", "edge"] + assert config["Labels"]["io.hass.type"] == "app" + assert config["Labels"]["org.opencontainers.image.revision"] == ( + "faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5" + ) + assert not any(value.startswith("SUPERVISOR_TOKEN=") for value in config["Env"]) + probe = docker( + "run", + "--rm", + "--network", + "none", + "--user", + "65532:65532", + "--entrypoint", + "/app/.venv/bin/python", + image, + "-c", + "import domus.composition.ingress, os; assert os.getuid() == 65532", + ) + assert probe.returncode == 0, probe.stderr + + +def test_image_requires_supervisor_configuration_and_never_falls_back_to_an_admin( + image: str, +) -> None: + result = docker("run", "--rm", "--network", "none", image) + assert result.returncode == 2 + assert "Domus could not start" in result.stderr + assert "Traceback" not in result.stderr + + +def ready(name: str, *, standalone: bool = False) -> None: + deadline = time.monotonic() + 20 + while True: + result = docker( + "exec", + name, + "/app/.venv/bin/python", + "-c", + "import urllib.request,urllib.error; " + + ( + "\ntry: urllib.request.urlopen(urllib.request.Request(" + "'http://127.0.0.1:8099/api/v1/session'," + "headers={'Host':'domus.fixture'}), timeout=2)" + "\nexcept urllib.error.HTTPError as error: assert error.code==401" + "\nelse: raise AssertionError('Unauthenticated session was accepted')" + if standalone + else "urllib.request.urlopen('http://127.0.0.1:8099/health', timeout=2)" + ), + ) + if result.returncode == 0 or time.monotonic() >= deadline: + break + time.sleep(0.1) + assert result.returncode == 0, result.stderr + + +def stopped(name: str) -> None: + result = docker("stop", "--time", "15", name) + assert result.returncode == 0, result.stderr + state = docker("inspect", "--format", "{{json .State}}", name) + finished = json.loads(state.stdout) + assert finished["ExitCode"] in (0, 143) + assert finished["OOMKilled"] is False and finished["Error"] == "" + + +@pytest.fixture +def data_volume(image: str): + volume = "domus-persistent-data-" + uuid.uuid4().hex + assert docker("volume", "create", volume).returncode == 0 + try: + prepared = docker( + "run", + "--rm", + "--network", + "none", + "--volume", + volume + ":/data", + "--entrypoint", + "/app/.venv/bin/python", + image, + "-c", + "import pathlib,json,os; p=pathlib.Path('/data/options.json'); " + "p.write_text(json.dumps({'public_origins':['http://homeassistant.local:8123']})); " + "p.chmod(0o600); os.chmod('/data',0o755)", + ) + assert prepared.returncode == 0, prepared.stderr + yield volume + finally: + removed = docker("volume", "rm", volume) + assert removed.returncode == 0, removed.stderr + + +def test_real_nonroot_listener_rejects_forgery_and_retains_storage_and_replay_on_restart( + image: str, + data_volume: str, +) -> None: + name = "domus-persistent-test-" + uuid.uuid4().hex + try: + started = docker( + "run", + "--detach", + "--init", + "--network", + "none", + "--name", + name, + "--volume", + data_volume + ":/data", + "--env", + "SUPERVISOR_TOKEN=fixture-supervisor-token", + image, + ) + assert started.returncode == 0, started.stderr + ready(name) + probe = docker( + "exec", + name, + "/app/.venv/bin/python", + "-c", + r"""import json, pathlib, urllib.error, urllib.request +servers = [] +for process in pathlib.Path('/proc').iterdir(): + if process.name.isdecimal(): + try: + command = (process / 'cmdline').read_bytes().split(b'\0') + if b'/app/.venv/bin/domus-ingress' in command: + servers.append((process / 'status').read_text()) + except FileNotFoundError: + pass +assert len(servers) == 1 +status = dict(line.split(':', 1) for line in servers[0].splitlines() if ':' in line) +assert status['Uid'].split() == status['Gid'].split() == ['65532'] * 4 +assert status['Groups'].strip() == '' +assert all(int(status[key].strip(), 16) == 0 for key in ('CapEff','CapPrm','CapAmb')) +root = pathlib.Path('/data/domus') +assert root.stat().st_uid == root.stat().st_gid == 65532 +assert root.stat().st_mode & 0o777 == 0o700 +for path in root.iterdir(): + assert path.stat().st_uid == 65532 and path.stat().st_mode & 0o777 == 0o600 +base = 'http://127.0.0.1:8099' +health = json.load(urllib.request.urlopen(base + '/health')) +assert health == {'status': 'ready', 'scope': 'process'} +for path in ('/', '/api/v1/identity'): + request = urllib.request.Request(base + path, headers={ + 'X-Forwarded-For': '172.30.32.2', 'X-Remote-User-Id': 'a' * 32, + 'X-Ingress-Path': '/api/hassio_ingress/' + 'b' * 32, + }) + try: + urllib.request.urlopen(request) + except urllib.error.HTTPError as error: + assert error.code == 403 + body = error.read().decode() + assert 'No separate Domus login is required' in body + assert 'fixture-supervisor-token' not in body + else: + raise AssertionError('A forged direct request established protected access') +""", + ) + assert probe.returncode == 0, probe.stderr + before = docker( + "exec", + name, + "/app/.venv/bin/python", + "-c", + "print(open('/data/domus/installation.json').read())", + ) + assert before.returncode == 0, before.stderr + for retry in range(2): + result = docker( + "exec", + "--user", + "65532:65532", + name, + "/app/.venv/bin/python", + "-c", + _STORAGE_COMMAND, + str(retry), + ) + assert result.returncode == 0, result.stderr + if retry == 0: + stopped(name) + assert docker("start", name).returncode == 0 + ready(name) + after = docker( + "exec", + name, + "/app/.venv/bin/python", + "-c", + "print(open('/data/domus/installation.json').read())", + ) + assert after.returncode == 0 and after.stdout == before.stdout + snapshot_roundtrip(name, json.loads(after.stdout)["instance_id"]) + stopped(name) + finally: + removed = docker("rm", "--force", name) + assert removed.returncode == 0 or "No such container" in removed.stderr, removed.stderr + + +def snapshot_roundtrip(name: str, instance_id: str) -> None: + prepared = docker( + "exec", + name, + "/app/.venv/bin/python", + "-c", + "import os; os.mkdir('/data/snapshots',0o700); os.chown('/data/snapshots',65532,65532)", + ) + assert prepared.returncode == 0, prepared.stderr + created = docker( + "exec", + "--user", + "65532:65532", + name, + "/app/.venv/bin/domus-admin", + "--channel", + "edge", + "--data-directory", + "/data/domus", + "snapshot", + "create", + "/data/snapshots/manual-001", + ) + assert created.returncode == 0, created.stderr + original = json.loads(created.stdout) + assert original["state"] == "created" + assert original["snapshot"]["schema_version"] == 4 + assert original["snapshot"]["last_committed_at"] == 100 + inspected = docker( + "exec", + "--user", + "65532:65532", + name, + "/app/.venv/bin/domus-admin", + "--channel", + "edge", + "--instance-id", + instance_id, + "snapshot", + "inspect", + "/data/snapshots/manual-001", + ) + assert inspected.returncode == 0, inspected.stderr + verified = json.loads(inspected.stdout) + assert verified["state"] == "verified" and verified["snapshot"] == original["snapshot"] + + +def test_real_launcher_refuses_invalid_options_without_provisioning( + image: str, data_volume: str +) -> None: + changed = docker( + "run", + "--rm", + "--network", + "none", + "--volume", + data_volume + ":/data", + "--entrypoint", + "/app/.venv/bin/python", + image, + "-c", + "open('/data/options.json','w').write('{\"public_origins\":[]}')", + ) + assert changed.returncode == 0, changed.stderr + result = docker( + "run", + "--rm", + "--network", + "none", + "--volume", + data_volume + ":/data", + "--env", + "SUPERVISOR_TOKEN=fixture-supervisor-token", + image, + ) + assert result.returncode == 2 and "Domus could not start" in result.stderr + inspected = docker( + "run", + "--rm", + "--network", + "none", + "--volume", + data_volume + ":/data", + "--entrypoint", + "/app/.venv/bin/python", + image, + "-c", + "import pathlib; assert not pathlib.Path('/data/domus').exists()", + ) + assert inspected.returncode == 0, inspected.stderr + + +_STORAGE_COMMAND = """import os,sys +from contextlib import closing +from pathlib import Path +from domus.application.commands import AtomicCommands, Change +from domus.domain.storage import CommandKey, Household, JsonDocument +from domus.infrastructure.owned_storage import attach_storage, open_storage +from domus.domain.errors import StorageBusy +assert os.getuid() == 65532 and not os.access('/data/options.json', os.W_OK) +try: + os.setuid(0) +except PermissionError: + pass +else: + raise AssertionError('Runtime can regain root') +try: + open_storage(Path('/data/domus'),'edge') +except StorageBusy: + pass +else: + raise AssertionError('A second runtime bypassed the lease') +class Authorization: + def require(self, command, unit): pass +class Clock: + def now(self): return 100 +class Identifiers: + def new(self): return 'container-fixture-operation' +def apply(unit): + assert sys.argv[1] == '0', 'Replay ran the effect again' + unit.households.save(Household('container-home','Container fixture','UTC',1),0) + value=JsonDocument.from_value({'household_id':'container-home','revision':1}) + return Change(value,value,value) +with closing(attach_storage(Path('/data/domus'),'edge')) as storage: + key=CommandKey('container-home','fixture-actor','fixture.create','fixture-request') + receipt=AtomicCommands(storage.database,Authorization(),Clock(),Identifiers()).execute( + key,JsonDocument.from_value({'name':'Container fixture'}),apply) + assert receipt.operation_id == 'container-fixture-operation' + with storage.database.read() as unit: + assert unit.households.current().name == 'Container fixture' + assert len(unit.audit.entries('container-home')) == len(unit.outbox.pending()) == 1 +""" + + +def packaged_fixture(image: str, volume: str, operation: str, *arguments: str): + source = Path(__file__).with_name("restore_fixture.py").read_text() + return docker( + "run", + "--rm", + "--network", + "none", + "--user", + "65532:65532", + "--volume", + volume + ":/data", + "--entrypoint", + "/app/.venv/bin/python", + image, + "-c", + source, + operation, + *arguments, + ) + + +def console(image: str, volume: str, *arguments: str): + return docker( + "run", + "--rm", + "--network", + "none", + "--user", + "65532:65532", + "--volume", + volume + ":/data", + "--entrypoint", + "/app/.venv/bin/domus-admin", + image, + "--channel", + "edge", + *arguments, + ) + + +def successful(result): + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +@pytest.fixture +def restore_runtime(image: str, data_volume: str, request): + mode = request.param + delivery = request.node.callspec.params.get("delivery", "suppressed") + prepared = docker( + "run", + "--rm", + "--network", + "none", + "--volume", + data_volume + ":/data", + "--entrypoint", + "/app/.venv/bin/python", + image, + "-c", + "import os; os.mkdir('/data/snapshots',0o700); os.chown('/data/snapshots',65532,65532); " + + ( + "os.mkdir('/data/domus',0o700); os.chown('/data/domus',65532,65532)" + if mode == "standalone" + else "" + ), + ) + assert prepared.returncode == 0, prepared.stderr + name = "domus-restore-test-" + uuid.uuid4().hex + configuration = ( + [ + "--user", + "65532:65532", + "--entrypoint", + "/app/.venv/bin/domus-server", + image, + "--channel", + "edge", + "--data-directory", + "/data/domus", + "--ha-url", + "http://ha.fixture", + "--public-origin", + "https://domus.fixture", + "--host", + "0.0.0.0", + ] + if mode == "standalone" + else ["--env", "SUPERVISOR_TOKEN=fixture-supervisor-token", image] + ) + try: + result = docker( + "run", + "--detach", + "--init", + "--network", + "none", + "--name", + name, + "--volume", + data_volume + ":/data", + *configuration, + ) + assert result.returncode == 0, result.stderr + ready(name, standalone=mode == "standalone") + identity = successful(packaged_fixture(image, data_volume, "initialize")) + successful( + console( + image, + data_volume, + "--data-directory", + "/data/domus", + "snapshot", + "create", + "/data/snapshots/manual", + ) + ) + successful(packaged_fixture(image, data_volume, "later", delivery)) + stopped(name) + prepared = successful( + console( + image, + data_volume, + "--data-directory", + "/data/domus", + "restore", + "prepare", + "/data/snapshots/manual", + "/data/snapshots/prepared", + ) + ) + assert prepared["state"] == "prepared" + verified = successful( + console( + image, + data_volume, + "--instance-id", + identity["instance_id"], + "restore", + "inspect", + "/data/snapshots/prepared", + ) + ) + assert verified["state"] == "verified" and verified["prepared"] == prepared["prepared"] + yield name, data_volume, identity, prepared["prepared"]["restore_id"], mode + finally: + result = docker("rm", "--force", name) + assert result.returncode == 0 or "No such container" in result.stderr, result.stderr + + +@pytest.mark.parametrize("restore_runtime", ["native", "standalone"], indirect=True) +@pytest.mark.parametrize("delivery", ["suppressed", "delivered"]) +def test_packaged_console_restore_restart_and_latest_history_rollback( + image: str, restore_runtime, delivery +): + name, volume, identity, identifier, mode = restore_runtime + activated = successful( + console( + image, + volume, + "--data-directory", + "/data/domus", + "restore", + "activate", + "/data/snapshots/prepared", + ) + ) + assert activated["state"] == "active" + assert activated["activation"]["rollback_snapshot"]["schema_version"] == 4 + assert docker("start", name).returncode == 0 + ready(name, standalone=mode == "standalone") + active = successful(packaged_fixture(image, volume, "active", delivery)) + assert active["generation_id"] == identifier + state = successful( + console(image, volume, "--data-directory", "/data/domus", "restore", "status", identifier) + ) + assert state["state"] == "active" + assert ( + console( + image, volume, "--data-directory", "/data/domus", "restore", "resume", identifier + ).returncode + == 2 + ) + successful(packaged_fixture(image, volume, "after")) + stopped(name) + checkpoint = "/data/domus/activations/" + identifier + "/source-checkpoint" + successful( + console( + image, + volume, + "--data-directory", + "/data/domus", + "restore", + "prepare", + checkpoint, + "/data/snapshots/rollback", + ) + ) + second = successful( + console( + image, + volume, + "--data-directory", + "/data/domus", + "restore", + "activate", + "/data/snapshots/rollback", + ) + ) + assert second["state"] == "active" + historical = successful( + console( + image, + volume, + "--data-directory", + "/data/domus", + "restore", + "activate", + "/data/snapshots/prepared", + ) + ) + assert historical["state"] == "historical" + assert docker("start", name).returncode == 0 + ready(name, standalone=mode == "standalone") + rollback = successful(packaged_fixture(image, volume, "rollback")) + assert ( + rollback["instance_id"] == identity["instance_id"] + and rollback["flat_sha256"] == active["flat_sha256"] + ) + stopped(name) + + +@pytest.mark.parametrize("restore_runtime", ["native", "standalone"], indirect=True) +@pytest.mark.parametrize("point", ["pending", "selected"]) +def test_packaged_process_death_blocks_start_and_resumes_the_exact_transition( + image: str, restore_runtime, point +): + name, volume, _, identifier, mode = restore_runtime + terminated = packaged_fixture(image, volume, "fault", point) + assert terminated.returncode == 37, terminated.stderr + status = successful( + console(image, volume, "--data-directory", "/data/domus", "restore", "status", identifier) + ) + assert status["state"] == "pending" + assert docker("start", name).returncode == 0 + assert docker("wait", name).stdout.strip() == "2" + logs = docker("logs", name) + assert "fixture-supervisor-token" not in logs.stdout + logs.stderr + if point == "pending": + assert ( + console( + image, volume, "--data-directory", "/data/domus", "restore", "resume", identifier + ).returncode + == 2 + ) + resumed = successful( + console( + image, + volume, + "--data-directory", + "/data/domus", + "restore", + "activate", + "/data/snapshots/prepared", + ) + ) + else: + resumed = successful( + console( + image, volume, "--data-directory", "/data/domus", "restore", "resume", identifier + ) + ) + assert resumed["state"] == "active" + assert docker("start", name).returncode == 0 + ready(name, standalone=mode == "standalone") + assert ( + successful(packaged_fixture(image, volume, "active", "suppressed"))["generation_id"] + == identifier + ) + stopped(name) diff --git a/tests/fixtures/sqlite/v1.sql b/tests/fixtures/sqlite/v1.sql new file mode 100644 index 0000000..9e65688 --- /dev/null +++ b/tests/fixtures/sqlite/v1.sql @@ -0,0 +1,15 @@ +-- Frozen schema from 95458b84b6521b203fab3a257137f24978a03e82. +-- Independent upgrade input; never regenerate this fixture from the current schema. +CREATE TABLE installation(singleton INTEGER PRIMARY KEY CHECK(singleton=1), instance_id TEXT NOT NULL, channel TEXT NOT NULL CHECK(channel IN ('stable','edge'))); +CREATE TABLE households(household_id TEXT PRIMARY KEY, singleton INTEGER NOT NULL DEFAULT 1 UNIQUE CHECK(singleton=1), name TEXT NOT NULL, time_zone TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>=1)); +CREATE TABLE receipts(household_id TEXT NOT NULL REFERENCES households(household_id), actor_id TEXT NOT NULL, action TEXT NOT NULL, command_key TEXT NOT NULL, payload_fingerprint TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE, result TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(household_id,actor_id,action,command_key)); +CREATE TABLE audit(operation_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), actor_id TEXT NOT NULL, action TEXT NOT NULL, command_key TEXT NOT NULL, detail TEXT NOT NULL, created_at INTEGER NOT NULL); +CREATE TABLE outbox(position INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, household_id TEXT NOT NULL REFERENCES households(household_id), event_type TEXT NOT NULL, payload TEXT NOT NULL, created_at INTEGER NOT NULL, delivered INTEGER NOT NULL DEFAULT 0 CHECK(delivered IN (0,1))); +CREATE INDEX outbox_pending ON outbox(delivered,position); +INSERT INTO installation VALUES (1,'047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6','edge'); +INSERT INTO households VALUES ('home',1,'Existing household','UTC',3); +INSERT INTO receipts VALUES ('home','ha_1','household.rename','old-key','old-digest','old-operation','{"revision":3}',100); +INSERT INTO audit VALUES ('old-operation','home','ha_1','household.rename','old-key','{"retained":true}',100); +INSERT INTO outbox(event_id,household_id,event_type,payload,created_at) VALUES ('old-operation','home','household.rename','{"revision":3}',100); +PRAGMA application_id=0x444F4D55; +PRAGMA user_version=1; diff --git a/tests/fixtures/sqlite/v2.sql b/tests/fixtures/sqlite/v2.sql new file mode 100644 index 0000000..5465ff5 --- /dev/null +++ b/tests/fixtures/sqlite/v2.sql @@ -0,0 +1,21 @@ +-- Frozen schema/data from ddc90faa5964509ed154090f8c8070f743e02370. +-- Independent migration input; never regenerate from the current schema. +BEGIN TRANSACTION; +CREATE TABLE audit(operation_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), actor_id TEXT NOT NULL, action TEXT NOT NULL, command_key TEXT NOT NULL, detail TEXT NOT NULL, created_at INTEGER NOT NULL); +INSERT INTO "audit" VALUES('old-operation','home','ha_1','household.rename','old-key','{"retained":true}',100); +CREATE TABLE household_policy(household_id TEXT PRIMARY KEY REFERENCES households(household_id), mapping_mode TEXT NOT NULL, target_ha_user_id TEXT, revision INTEGER NOT NULL CHECK(revision>=1), adjustments TEXT NOT NULL, CHECK((mapping_mode='all_ha_administrators' AND target_ha_user_id IS NULL) OR (mapping_mode IN ('setup_administrator','selected_ha_user') AND target_ha_user_id IS NOT NULL))); +CREATE TABLE households(household_id TEXT PRIMARY KEY, singleton INTEGER NOT NULL DEFAULT 1 UNIQUE CHECK(singleton=1), name TEXT NOT NULL, time_zone TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>=1)); +INSERT INTO "households" VALUES('home',1,'Existing household','UTC',3); +CREATE TABLE installation(singleton INTEGER PRIMARY KEY CHECK(singleton=1), instance_id TEXT NOT NULL, channel TEXT NOT NULL CHECK(channel IN ('stable','edge'))); +INSERT INTO "installation" VALUES(1,'047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6','edge'); +CREATE TABLE members(member_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), ha_user_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('administrator','adult','restricted','viewer')), status TEXT NOT NULL CHECK(status IN ('pending','active','suspended','archived')), revision INTEGER NOT NULL CHECK(revision>=1), exceptions TEXT NOT NULL, UNIQUE(household_id,ha_user_id)); +CREATE TABLE outbox(position INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, household_id TEXT NOT NULL REFERENCES households(household_id), event_type TEXT NOT NULL, payload TEXT NOT NULL, created_at INTEGER NOT NULL, delivered INTEGER NOT NULL DEFAULT 0 CHECK(delivered IN (0,1))); +INSERT INTO "outbox" VALUES(1,'old-operation','home','household.rename','{"revision":3}',100,0); +CREATE TABLE receipts(household_id TEXT NOT NULL REFERENCES households(household_id), actor_id TEXT NOT NULL, action TEXT NOT NULL, command_key TEXT NOT NULL, payload_fingerprint TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE, result TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(household_id,actor_id,action,command_key)); +INSERT INTO "receipts" VALUES('home','ha_1','household.rename','old-key','old-digest','old-operation','{"revision":3}',100); +CREATE INDEX outbox_pending ON outbox(delivered,position); +DELETE FROM "sqlite_sequence"; +INSERT INTO "sqlite_sequence" VALUES('outbox',1); +COMMIT; +PRAGMA application_id=0x444F4D55; +PRAGMA user_version=2; diff --git a/tests/fixtures/sqlite/v3.sql b/tests/fixtures/sqlite/v3.sql new file mode 100644 index 0000000..1f88138 --- /dev/null +++ b/tests/fixtures/sqlite/v3.sql @@ -0,0 +1,25 @@ +-- Frozen schema/data from 002a982ee60b8aa22a707de402ab1e8fc0306be5. +-- Independent upgrade input; never regenerate from the current schema. +BEGIN TRANSACTION; +CREATE TABLE audit(operation_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), actor_id TEXT NOT NULL, action TEXT NOT NULL, command_key TEXT NOT NULL, detail TEXT NOT NULL, created_at INTEGER NOT NULL); +INSERT INTO "audit" VALUES('identity-2','home','ha_admin','household.setup','setup','{"administrator_member_id":"identity-1","mapping_mode":"all_ha_administrators","transition":"household.configured"}',100); +CREATE TABLE household_policy(household_id TEXT PRIMARY KEY REFERENCES households(household_id), mapping_mode TEXT NOT NULL, target_ha_user_id TEXT, revision INTEGER NOT NULL CHECK(revision>=1), adjustments TEXT NOT NULL, CHECK((mapping_mode='all_ha_administrators' AND target_ha_user_id IS NULL) OR (mapping_mode IN ('setup_administrator','selected_ha_user') AND target_ha_user_id IS NOT NULL))); +INSERT INTO "household_policy" VALUES('home','all_ha_administrators',NULL,1,'{"rules":[]}'); +CREATE TABLE households(household_id TEXT PRIMARY KEY, singleton INTEGER NOT NULL DEFAULT 1 UNIQUE CHECK(singleton=1), name TEXT NOT NULL, time_zone TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>=1)); +INSERT INTO "households" VALUES('home',1,'Home','UTC',1); +CREATE TABLE installation(singleton INTEGER PRIMARY KEY CHECK(singleton=1), instance_id TEXT NOT NULL, channel TEXT NOT NULL CHECK(channel IN ('stable','edge'))); +INSERT INTO "installation" VALUES(1,'047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6','edge'); +CREATE TABLE members(member_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), ha_user_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('administrator','adult','restricted','viewer')), status TEXT NOT NULL CHECK(status IN ('pending','active','suspended','archived')), revision INTEGER NOT NULL CHECK(revision>=1), exceptions TEXT NOT NULL, UNIQUE(household_id,ha_user_id)); +INSERT INTO "members" VALUES('identity-1','home','ha_admin','viewer','active',1,'{"rules":[]}'); +CREATE TABLE outbox(position INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, household_id TEXT NOT NULL REFERENCES households(household_id), event_type TEXT NOT NULL, payload TEXT NOT NULL, created_at INTEGER NOT NULL, delivered INTEGER NOT NULL DEFAULT 0 CHECK(delivered IN (0,1))); +INSERT INTO "outbox" VALUES(1,'identity-2','home','household.setup','{"household_id":"home","revision":1}',100,0); +CREATE TABLE receipts(household_id TEXT NOT NULL REFERENCES households(household_id), actor_id TEXT NOT NULL, action TEXT NOT NULL, command_key TEXT NOT NULL, payload_fingerprint TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE, result TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(household_id,actor_id,action,command_key)); +INSERT INTO "receipts" VALUES('home','ha_admin','household.setup','setup','fd81d9a903eff49c605ff960bfae80f61f684b2f1f12ff15fb363cd7bdea3c51','identity-2','{"administrator_member_id":"identity-1","household_id":"home","mapping_mode":"all_ha_administrators","member_id":"identity-1","policy_revision":1,"revision":1,"state":"configured"}',100); +CREATE TABLE recovery_requests(request_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), target_ha_user_id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>=1), state TEXT NOT NULL CHECK(state IN ('pending','approved','consumed','canceled')), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, detail TEXT NOT NULL); +CREATE INDEX outbox_pending ON outbox(delivered,position); +CREATE INDEX recovery_pending ON recovery_requests(household_id,state,expires_at); +DELETE FROM "sqlite_sequence"; +INSERT INTO "sqlite_sequence" VALUES('outbox',1); +COMMIT; +PRAGMA application_id=0x444F4D55; +PRAGMA user_version=3; diff --git a/tests/python/test_ci_contract.py b/tests/python/test_ci_contract.py index 6379e3c..b382a74 100644 --- a/tests/python/test_ci_contract.py +++ b/tests/python/test_ci_contract.py @@ -44,6 +44,38 @@ def test_required_jobs_always_reach_a_fail_closed_aggregate() -> None: assert "if" not in jobs[name], f"{name} must not skip applicable PR updates" +def test_native_app_images_are_built_and_exercised_on_both_cpu_families() -> None: + jobs = workflow()["jobs"] + container = jobs["containers"] + assert container["strategy"]["fail-fast"] == "false" + assert container["strategy"]["matrix"]["include"] == [ + {"runner": "ubuntu-latest", "arch": "amd64"}, + {"runner": "ubuntu-24.04-arm", "arch": "aarch64"}, + ] + assert container["runs-on"] == "${{ matrix.runner }}" + commands = [step["run"] for step in container["steps"] if "run" in step] + assert ( + "docker build --build-arg BUILD_ARCH=${{ matrix.arch }} -t domus-app-ci addon-edge" + in commands + ) + assert "uv run pytest tests/containers --no-cov" in commands + assert any( + "DOMUS_SOURCE_COMMIT=d013cf2c8b932d0e00f5858053cbc0e663d5eee1" in command + and "DOMUS_SOURCE_SHA256=fa58d457bef8a863a1943b4e6ba5c9513da8d3baa8b0a033bdcbe7bf11b09c6c" + in command + and "BUILD_VERSION=0.0.0-edge.2" in command + and "-t domus-app-previous addon-edge" in command + for command in commands + ) + verification = next( + step + for step in container["steps"] + if step.get("run") == "uv run pytest tests/containers --no-cov" + ) + assert verification["env"]["DOMUS_PREVIOUS_IMAGE"] == "domus-app-previous" + assert "containers" in jobs["aggregate"]["needs"] + + def test_ci_is_read_only_with_immutable_actions_and_locked_installs() -> None: config = workflow() assert config["permissions"] == {"contents": "read"} @@ -75,7 +107,7 @@ def test_new_runtime_sources_require_an_explicit_coverage_scope() -> None: continue suffix = Path(relative).suffix if suffix == ".py": - assert relative.startswith("tooling/domus_tooling/"), ( + assert relative.startswith(("tooling/domus_tooling/", "backend/src/domus/")), ( f"Register {relative} in its own tested coverage/architecture gate before adding it" ) elif suffix in {".js", ".mjs", ".cjs", ".ts", ".tsx"}: @@ -84,6 +116,21 @@ def test_new_runtime_sources_require_an_explicit_coverage_scope() -> None: ) +def test_backend_cannot_inherit_the_tooling_coverage_result() -> None: + jobs = workflow()["jobs"] + backend_commands = [step["run"] for step in jobs["backend"]["steps"] if "run" in step] + assert any( + "pytest tests/backend" in command and "--cov=domus " in command + for command in backend_commands + ) + assert "backend" in REQUIRED_JOBS + assert "backend" in jobs["aggregate"]["needs"] + architecture_commands = [step["run"] for step in jobs["architecture"]["steps"] if "run" in step] + assert "uv run lint-imports --config architecture/python-application.ini --no-cache" in ( + architecture_commands + ) + + @pytest.mark.parametrize("relative,expected", sorted(json.loads(PROTECTED.read_text()).items())) def test_installed_copilot_resources_are_unchanged(relative: str, expected: str) -> None: assert hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() == expected, relative diff --git a/tooling/domus_tooling/ci_policy.py b/tooling/domus_tooling/ci_policy.py index 6f109e3..7229435 100644 --- a/tooling/domus_tooling/ci_policy.py +++ b/tooling/domus_tooling/ci_policy.py @@ -2,7 +2,7 @@ from collections.abc import Mapping -REQUIRED_JOBS = ("python", "node", "architecture", "documentation") +REQUIRED_JOBS = ("python", "backend", "node", "architecture", "documentation", "containers") def evaluate_jobs(results: Mapping[str, object]) -> tuple[str, ...]: diff --git a/uv.lock b/uv.lock index 3ab23b3..9c4f6e2 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,43 @@ version = 1 revision = 3 requires-python = "==3.14.*" +[manifest] +members = [ + "domus-server", + "domus-tooling", +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + [[package]] name = "ast-serialize" version = "0.13.1" @@ -65,6 +102,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/25/4235f4b375a67a37ac595bf478a5407a744eaf7b64113c1c3a5432b8d954/ast_serialize-0.13.1-cp39-abi3-win_arm64.whl", hash = "sha256:48f11d96c9ec71119612f8e79a8c55c12f2b136716d68347433c561d27a25f43", size = 1139035, upload-time = "2026-10-17T00:04:15.767Z" }, ] +[[package]] +name = "certifi" +version = "2026.10.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -126,17 +172,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/17/48032919343afa2906112d458aceda2043afdb24a2ee8eb4c60904d8ddf1/coverage-7.17.0-py3-none-any.whl", hash = "sha256:bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d", size = 217383, upload-time = "2026-10-22T16:06:48.471Z" }, ] +[[package]] +name = "domus-server" +version = "0.0.0" +source = { editable = "backend" } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "uvicorn" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = "==0.162.0" }, + { name = "httpx", specifier = "==0.28.1" }, + { name = "uvicorn", specifier = "==0.54.0" }, + { name = "websockets", specifier = "==17.4" }, +] + [[package]] name = "domus-tooling" version = "0.0.0" source = { editable = "." } dependencies = [ + { name = "domus-server" }, { name = "markdown-it-py" }, ] [package.dev-dependencies] dev = [ { name = "hatchling" }, + { name = "httpx2" }, { name = "import-linter" }, { name = "mypy" }, { name = "pytest" }, @@ -146,11 +213,15 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "markdown-it-py", specifier = "==4.2.0" }] +requires-dist = [ + { name = "domus-server", editable = "backend" }, + { name = "markdown-it-py", specifier = "==4.2.0" }, +] [package.metadata.requires-dev] dev = [ { name = "hatchling", specifier = "==1.33.0" }, + { name = "httpx2", specifier = "==2.16.0" }, { name = "import-linter", specifier = "==2.15" }, { name = "mypy", specifier = "==2.4.1" }, { name = "pytest", specifier = "==9.2.0" }, @@ -159,6 +230,23 @@ dev = [ { name = "ruff", specifier = "==0.17.3" }, ] +[[package]] +name = "fastapi" +version = "0.162.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/eb/2cf8c80abf09b2800aef0a83b41066fbfda8ee5495b7a08b9d1af40f7046/fastapi-0.162.0.tar.gz", hash = "sha256:6a22c1e2e6e32e0633ee7e170f00009a29b8228b16feb219ee42f32fd8d88a8f", size = 527867, upload-time = "2026-10-23T18:47:33.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/4f/3f2c25c9b43970b5e9a08beb5e04776247edea684ddc0930f4f02f0bb6cb/fastapi-0.162.0-py3-none-any.whl", hash = "sha256:3785963de305dd09912426cb81f2d230fce98ab3521cdfe97265c8d14983128c", size = 148912, upload-time = "2026-10-23T18:47:31.734Z" }, +] + [[package]] name = "grimp" version = "3.18" @@ -167,6 +255,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/42/f4/a0313ef208458d099 wheels = [ { url = "https://files.pythonhosted.org/packages/8d/95/f1659bff361c15293321f9ab4ddf0c93c0a32c1c6d2e6e7df4d6bf12a8e4/grimp-3.18-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6e26c3020843ceeda2bb699ea09e27b6654f7823b51e779b9c607e204b34e1f4", size = 2152974, upload-time = "2026-10-26T09:58:00.403Z" }, { url = "https://files.pythonhosted.org/packages/f1/9a/f4aa9e284fb82874c4f588143557ae4f871ed52c6689a55c49968b6e3b44/grimp-3.18-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7bc0587c8622e1275e217e924c27e54420f7a06db88d133f4adad18a0298b040", size = 2099498, upload-time = "2026-10-26T09:57:50.493Z" }, + { url = "https://files.pythonhosted.org/packages/e0/89/ca91f3a223f9b1dce8aaf23a7b92a8afdb3c7bf6597348c793f3eef6e5d1/grimp-3.18-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5083d474794a20d8b3d53c2955d9cc6e590fe3d55a467ec6e4940d2d1413e5d3", size = 2276232, upload-time = "2026-10-30T09:05:29.668Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/b782fc977992e0460991e9bde35e15d40e41057427ded9e8612a87a00400/grimp-3.18-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:e3e101de453ba3a5ed7e6776857e12e75913a71254779240b952b00fb99be327", size = 2224038, upload-time = "2026-10-30T09:05:40.995Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7e/d95e166c2aafc14a20bdcda3e251c197d411d765e674db7d3a48532b5e14/grimp-3.18-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d5ca964848d32b871a6b5755ec9e2c8499945268fb07065805616c2f0ccc7b94", size = 2365371, upload-time = "2026-10-30T09:06:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a3/c092a0c5458378962e11df965e6588743d9544019e1c0b375a2d144e2b7f/grimp-3.18-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:742661cf3af03b9cae83abbd47a86e3fee2e5241c9c0ca1506ee3a2d9d21c835", size = 2630405, upload-time = "2026-10-30T09:05:49.75Z" }, + { url = "https://files.pythonhosted.org/packages/41/bf/2f7fff6f1a7ed5209fdc42455c7aeb76d5e64017db1641cf30ee9e8c39dd/grimp-3.18-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cc513ef2584df912b7bc165a7a1d549cf8f6032e80fc11cf947ca9c2fcf3c5a2", size = 2359624, upload-time = "2026-10-30T09:05:58.832Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bd/defaf1fa6f19173b1b4bcf87311a9bc72d66e6e6cddd57cec8473fd4030e/grimp-3.18-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47ff6d75a2b8db5c0b98be66be74ff7d7ba6d603feca3b5cbe6684a849ffb08c", size = 2286960, upload-time = "2026-10-30T09:06:19.997Z" }, { url = "https://files.pythonhosted.org/packages/e8/66/a96e7b9ecf3ffce2482d303663959a9f4d4ec33fceb667cc5071cd62a161/grimp-3.18-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1453b4b00c324cdaabb5c70f57d45a84882dd877883cefbda859fd66b400bcd6", size = 2276234, upload-time = "2026-10-26T09:56:53.902Z" }, { url = "https://files.pythonhosted.org/packages/a4/29/8bb9dccd6390ff78b6e49f6e073afe90c6add6f71e4f795e217ce0846fdb/grimp-3.18-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2611d37e446eee05e6621a28e4ae7b612899715cae637037404e7a0d91f327b", size = 2224037, upload-time = "2026-10-26T09:57:04.89Z" }, { url = "https://files.pythonhosted.org/packages/18/af/25bf0c6cd7c1fe4f77be0354a96337cee516a4b48bbfe48ca1745ec70cc6/grimp-3.18-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:438544f3e7aaed32ddc09d8a8f6685e67e7ce43f0931f7149426fb7b483a6df6", size = 2365372, upload-time = "2026-10-26T09:57:29.904Z" }, @@ -182,6 +276,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/6b/d07a1f733c75522985e733990884a341009676ef41b135c5141d7599d401/grimp-3.18-cp314-cp314-win_arm64.whl", hash = "sha256:932e992a798c4b69ad77957cc09517c22cd1aa3b07cc85aba41e14406cb1a42f", size = 1925674, upload-time = "2026-10-26T09:58:42.745Z" }, { url = "https://files.pythonhosted.org/packages/1f/7c/aa8d7bfe872f4bfba342ee0dae17eadd34dcc1e73b8822765f0528eea3e4/grimp-3.18-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:73633cf2c822f34a38dd77e34e015afd6fc489998abeab8b91a7149fc63c1a1b", size = 2153687, upload-time = "2026-10-26T09:58:01.827Z" }, { url = "https://files.pythonhosted.org/packages/1c/73/613c1e57e3db9c81779e4e5f7e6bb2c69118da64be9a4aba69cc74401594/grimp-3.18-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d7418bc8b511a12e21099b43349588785254d3d38ecf0dac88aa927c91ebc47", size = 2099246, upload-time = "2026-10-26T09:57:51.73Z" }, + { url = "https://files.pythonhosted.org/packages/30/38/8962a691c78fde0be777e67805e994944000551882dce617b03a99d5e02b/grimp-3.18-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:34090d38fba4223a6a9a5cc8f5c7523fdd9db7dfaef80dd90376d45242e59704", size = 2276732, upload-time = "2026-10-30T09:05:31.154Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ad/1c0ac6be3ea422ed8431bb392a0041c11300164761f2d2d3200e4f222f1d/grimp-3.18-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:3463911ead5015eaa91da3721b84c900cdcaec22d149133827a76ea86d798cb1", size = 2220324, upload-time = "2026-10-30T09:05:42.395Z" }, + { url = "https://files.pythonhosted.org/packages/09/00/a30fdaa8c0793db7dfc17590f341e1e82b30989602338205030d4e4394d0/grimp-3.18-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b989ad42e66d2ffb75dddfbc14bb980790fbbce6f58fdccc3df99cebf4661689", size = 2365815, upload-time = "2026-10-30T09:06:09.694Z" }, + { url = "https://files.pythonhosted.org/packages/2b/19/1965cf2d8d218f0d763872c32362cf5dc05eb5c192c7a5996089e41e048a/grimp-3.18-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4390a69445c586c1ad1c4aaab9e3ec44fcd3f662c04476dbb77b3b6dde5ed6ac", size = 2629781, upload-time = "2026-10-30T09:05:51.184Z" }, + { url = "https://files.pythonhosted.org/packages/26/eb/26a06c8eab6a252c8c1e0a3bbe3cf71ddd9b0e3c288c6ee474df635e3f3b/grimp-3.18-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a34f3414a8f944d1b30b958fe571f5ca07cd6fa8fd7dcffea89e995a6cd5ee0f", size = 2359278, upload-time = "2026-10-30T09:06:00.328Z" }, + { url = "https://files.pythonhosted.org/packages/cb/02/d9497138b299b45eebe856159605752be9304fd661ab87f507fdbc10fa2e/grimp-3.18-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98e62116c9a323f00b580b6d9a4aade24d047bdf37b3a4f5098098aa055063be", size = 2290843, upload-time = "2026-10-30T09:06:21.903Z" }, { url = "https://files.pythonhosted.org/packages/4b/95/eedf8708e71345d3a2adea470c493ecb1bc368e295d01a02b7c1777cc2c8/grimp-3.18-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ecaf989c284fbd0abc8710b79565b5ac2165b9d74e77ced52463c576e5b00a0", size = 2276734, upload-time = "2026-10-26T09:56:55.162Z" }, { url = "https://files.pythonhosted.org/packages/7c/43/8ee0455db564d983b0dd5875b709e360c68e54e454f7b14376f639b5ef27/grimp-3.18-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07abd5b67a0d2b8d1809cdea9f18d83c8a92525dc54b50726a30cfe89eb66d55", size = 2220324, upload-time = "2026-10-26T09:57:06.484Z" }, { url = "https://files.pythonhosted.org/packages/6b/84/5720f66a65e084f6cb126f041454b1d6921ff3bc9ce1ffed559508c2621d/grimp-3.18-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a59c0727f35f765a93c20cc0141fbab5bad4ae58b5fb80c9b7ed4e7ac3b3554", size = 2365814, upload-time = "2026-10-26T09:57:31.19Z" }, @@ -197,6 +297,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/d0/d0c2119662b549312166c8aac2e96028f5253075e60587d882e0aba4e673/grimp-3.18-cp314-cp314t-win_arm64.whl", hash = "sha256:a79cde5a2052c0c22dfd5ce0e3cb2fa0ef68625c480d779bd331f9d0bf3459f3", size = 1924204, upload-time = "2026-10-26T09:58:44.285Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + [[package]] name = "hatchling" version = "1.33.0" @@ -213,6 +322,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/64/4afe5dd77b2939a93e8c9513f8082dbd049ecba9c1484a379a0f6ae29d6b/hatchling-1.33.0-py3-none-any.whl", hash = "sha256:415290b84f464d2884303fca8974d38e7b85579069fab02a15db0828fbb1446d", size = 78750, upload-time = "2026-10-28T02:27:29.033Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + [[package]] name = "import-linter" version = "2.15" @@ -335,6 +519,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.45.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/cf3a6b6448849656e1e5f4e2101f3988dc6789724050096aba1bc5ccf592/opentelemetry_api-1.45.2.tar.gz", hash = "sha256:d7b0008e21e5fd61cbe7a7f34ca3b6905a78ab677d9bc395e299359c75cda6b4", size = 73277, upload-time = "2026-10-29T17:29:37.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/2d/610db864d7513158cebb8f36cc88e67e73bd6c7aa1905ef18d89e1281c81/opentelemetry_api-1.45.2-py3-none-any.whl", hash = "sha256:2cbd737377e710ee740d261c94e93fa0f08e6d037c18f17513cc0a2d94479a34", size = 60335, upload-time = "2026-10-29T17:29:06.955Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -362,6 +558,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, ] +[[package]] +name = "pydantic" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/d5/b4867fa7c7f04c34997d7d8aad744f64d210bb99d67cf18bf3fb1eea1aed/pydantic-2.14.2.tar.gz", hash = "sha256:fb7b2db6cefe8414dd612edeede49c4c433acb0432b0f92b95024e45f66c505d", size = 911596, upload-time = "2026-10-29T16:37:12.476Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/01/c71a71258b58659c72504e5fd65b451f6e36e34dc931d0b237ded213c814/pydantic-2.14.2-py3-none-any.whl", hash = "sha256:7efbf7507b937931cd32a5755acb00ea1cf5ccb1d4e8067a5850d5069e382118", size = 490403, upload-time = "2026-10-29T16:37:10.181Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/8e/23ec1021834e35041fbb2f10946141e6acdfd39379c45cc4d65a4e8a1d98/pydantic_core-2.50.2.tar.gz", hash = "sha256:1b0e8dc3932cf4e9ed5727570325cacc716b9409ffc794a221c0ccc72e8eb775", size = 518711, upload-time = "2026-10-29T16:34:57.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/64/fc525bb981a9f287cfa34346e7ed1d5ef7c043c2b9b517f54c6136032d10/pydantic_core-2.50.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:efad0bdc67d3b119c3bab6b94d341a27333c2bb89ed75dfa182a4a06cd0ea329", size = 2226414, upload-time = "2026-10-29T16:32:58.559Z" }, + { url = "https://files.pythonhosted.org/packages/db/68/315701095f25687938ab79d263a6d939337456c2840fe69038fd38000338/pydantic_core-2.50.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ffb4dce3ecb2cd563aa30f9eb5088a1cf1f4f688b90cbb374fa96e49fc50b02", size = 2095041, upload-time = "2026-10-29T16:32:59.873Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4b/efd3abf31aa24bc97b311735f418886f947b4f193df02a1977643963b03e/pydantic_core-2.50.2-cp314-cp314-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:150f6e101f575c21a4ecfed1be1c05bb89a0ad49cb9c846ed377583bd71b32c2", size = 2257635, upload-time = "2026-10-29T16:33:01.096Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6c/146b36eed4de8115a0fda76840703e75f5f8b36aeb3af86976bf094d1a17/pydantic_core-2.50.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1db9cab6197e797824f0479dd2ae5fd2e272aa61c61b7faa5a6e450ca7a0d3b7", size = 2116807, upload-time = "2026-10-29T16:33:02.692Z" }, + { url = "https://files.pythonhosted.org/packages/fb/94/db006ea79bbe405cc38b25899319c7d461ff1813cbdfe6aa90a65ed1dfff/pydantic_core-2.50.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:1b9f652e17a13bbc09b45f74c3c326cca72059ce4dcdef687fcb04389db413a9", size = 2201037, upload-time = "2026-10-29T16:33:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/41/f9/ea2bc1ec13a67af4a63c9a887fa0aa6ef4ade9018e2bb412d4fc80110e19/pydantic_core-2.50.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:823b222c4fc861a82c650998a6117ecbd150cc71bf8b78f9a31bf3f48c456108", size = 2374904, upload-time = "2026-10-29T16:33:05.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/8e/26a6a075301f0e563646037ea69bef8c63911d38fbd257f94cec69cdf985/pydantic_core-2.50.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:783abdfac21247d33bec40aa683d26bba30d61655c40de38b2decc4797aabf92", size = 2457626, upload-time = "2026-10-29T16:33:07.271Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a2/8eeabaf1d394f0d3b9a27129ac21556eabee6f2ddcb418069ef4037e00b0/pydantic_core-2.50.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b29b7c5adf4b2be7c941f7f76432aa3269cd5052ff8610376b8ece24d7b02c51", size = 2245505, upload-time = "2026-10-29T16:33:08.824Z" }, + { url = "https://files.pythonhosted.org/packages/bd/54/449778cd397e2682b30d1bcfab0872f6ac36702b59cc326296b7f4f14928/pydantic_core-2.50.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4ac996e42a93d80122a23edce44e02c6b9c4913f5c6198a077da31475341a39a", size = 2259563, upload-time = "2026-10-29T16:33:10.107Z" }, + { url = "https://files.pythonhosted.org/packages/26/c2/2f65691d25488ee0f3c17861d079e8ccb7817955522d07cd3c3970d30af5/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:99e1d482138a71902415ba375bb4627679a5fefd3bd07803c3a8f37632f4981d", size = 2290470, upload-time = "2026-10-29T16:33:11.273Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/aa7c40234cd24536afa0895ff1d26f9f15638004e25242c5193ce053b96b/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:a53d5f11cd6d15569ba3e53f3498077f0d5e633f6ec604d2ea613e895017ebee", size = 2477272, upload-time = "2026-10-29T16:33:12.771Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/c347c68e040cf93d8487a8f4fc28f2768312834c7d21c301538f416f9955/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23385c4626f8b5a7792aa381ea573d9432cfbf4c21375a0a5eed2a4fb64e0e58", size = 2484352, upload-time = "2026-10-29T16:33:14.485Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8e/72556126c8b6154fe7acaf757b96b2daa37aeffc3a517deb76e272f5ad09/pydantic_core-2.50.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9a8a62281fd9c6a373b3fd3d473685e6838d43341eecde5f47729748e2c11ee3", size = 1491226, upload-time = "2026-10-29T16:33:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/5b/66/004c85511812964a36b3535af022c5c0dfcb760a31e4905b96310e8dd6c8/pydantic_core-2.50.2-cp314-cp314-win32.whl", hash = "sha256:18f008ef58cd30782f81b6e0119e20b7e884edcefbc2d370162ebe033f6e5c99", size = 2113134, upload-time = "2026-10-29T16:33:16.87Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5f/dcd3df20ab2a7279d42a8415fc184e8e77bcf9aa3c9ac3e938c1300a242c/pydantic_core-2.50.2-cp314-cp314-win_amd64.whl", hash = "sha256:b62c89545d21d930c333208fd548e7b4f262228ba3a322fd75c987a75ab11f31", size = 2253407, upload-time = "2026-10-29T16:33:18.47Z" }, + { url = "https://files.pythonhosted.org/packages/4e/fb/0685895f5d199b215011dc4eb8ccea032a64ec23dfa88eeb5acca939c2d2/pydantic_core-2.50.2-cp314-cp314-win_arm64.whl", hash = "sha256:6e731bf5a5e8e25398544d7709e39068ec4230b0177e4db7fa955692fd415ba3", size = 2160456, upload-time = "2026-10-29T16:33:19.782Z" }, + { url = "https://files.pythonhosted.org/packages/84/2f/4c8f67efe62837a508b74cf910904f73029cb211371351c5ceb36d56e85e/pydantic_core-2.50.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:33c6800ab9563adaa0d7893e9cd8f5b972ef1e6a23df70498e05c24f3fcec5e1", size = 2217558, upload-time = "2026-10-29T16:33:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/e0ef88cfbcca5409f57751289bc20be63a58af596a36e94b8cd795e59747/pydantic_core-2.50.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f81df206b937d0dca5efb38c25fc99037d82dbcd3740e34c3f81997d2322ac1c", size = 2074335, upload-time = "2026-10-29T16:33:22.658Z" }, + { url = "https://files.pythonhosted.org/packages/7b/cc/47844a3179ebce27064e13619594b3fc1a9b4509d8dec1e3960c5f87f5bb/pydantic_core-2.50.2-cp314-cp314t-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:21ea3b00f1e87c66f3f9e92f9f6fec20495ac0e60d7d2e68ba1fc49739d7cf40", size = 2264201, upload-time = "2026-10-29T16:33:24.953Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d1/30e9d3c86d81884ee3199560ddfa2c1a66a8dc0736441748eae43064abbc/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9eff570402175c7d5fff8497d5a9c05580664a79bb66c07b8f205d17f69e580b", size = 2094353, upload-time = "2026-10-29T16:33:26.739Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/392a5999480d0bcab4539db0dafa93265152396fd63e87be4d24e13d6d82/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:240fec32a74af0979f7253532859b3fd209ad5eea822416bb010929dba445c3d", size = 2203119, upload-time = "2026-10-29T16:33:28.154Z" }, + { url = "https://files.pythonhosted.org/packages/cb/80/6e4e276a3fde5500224a39150e06a4f134f136165908be69402720718a36/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92acf02c770b9d5c4e9a3e8d64e9d95ae26456a86ebb7b7cf64a97f899e5871b", size = 2392881, upload-time = "2026-10-29T16:33:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/d5/5eb56c6317ab4abf33ef9519a50bfac0c96f7f408bad1bc36da6f27d43e4/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1ac5b99bc7b0a68ec64f5f75a6828a7bab5535c8df0cdc07901deb50e0815910", size = 2457290, upload-time = "2026-10-29T16:33:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/7b/9e/f1fe9384bb37e4e728c51c79151808deacf7b2502e245f50b57c85eeb199/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:713d7c33c0e231e3786eb0ec95885814dd65d5f3a09754399b6acbf5e9365f11", size = 2241568, upload-time = "2026-10-29T16:33:32.04Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f1/cffc4f8456c35690bcb65a8f2304afbf2f519766345a28aba553d5a55cc8/pydantic_core-2.50.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:61e13100b30d8d25fc0d1f0297014b32941bf0975a3584bd08f3adebb68d1ead", size = 2257185, upload-time = "2026-10-29T16:33:33.36Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/ccd4df1cc39cb1e42fb7ec86a0dc0a23e8479de04d3012ca6b0bf2279a03/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:523fe6574aea50f456cbc301c1cdddcbf237939f7657f9af99b4f0f71942b59c", size = 2299384, upload-time = "2026-10-29T16:33:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/83/60/404b4b0c416a61867ee642cdc1450bca4d8a9a10e5f5f21e1ecaad05fdc8/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:6c25b8826be85714b27dafe908d3461e1b09e3e31c0279a7029040c832c781ef", size = 2479384, upload-time = "2026-10-29T16:33:36.106Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0d/57c81418a2fdfde0578c71396df773965adebd4421d9985a0f8dcbe9ec4d/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a59db03b80a156a6fbeae41096866d1cb89cdf31031ba44c768a804538880636", size = 2504616, upload-time = "2026-10-29T16:33:37.423Z" }, + { url = "https://files.pythonhosted.org/packages/08/66/dec78e28a604f3f4985f5a795d4da3e5fef78b0921aeb7f614ed7c780a32/pydantic_core-2.50.2-cp314-cp314t-win32.whl", hash = "sha256:0d090ae76d5b09c003600c7779b3adcf3d1b49268f616c5c51d844e54c8e318d", size = 2093179, upload-time = "2026-10-29T16:33:38.705Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ea/79d9ee60648463804145d44b08f1e7e2474427d9704e3331d814483908b3/pydantic_core-2.50.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9b01a092955c1c7530196ad526755cff4f2d11f37ca9a6fc9ebe37ca49a48977", size = 2220575, upload-time = "2026-10-29T16:33:39.929Z" }, + { url = "https://files.pythonhosted.org/packages/eb/01/aa6a7bd017c3228a41942da4fd788b86fb4aad9677212a4737a0c157fe74/pydantic_core-2.50.2-cp314-cp314t-win_arm64.whl", hash = "sha256:4ea01f30e119f1ec7a2034e85c40f491d469896aef02cd6436fd9292fe1be3fb", size = 2160866, upload-time = "2026-10-29T16:33:41.237Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -465,6 +718,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" }, ] +[[package]] +name = "starlette" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/bb/3d8757f21e9a53d9035c9eb039a777047139a16bfdbedfc7a58241c3d914/starlette-1.10.0.tar.gz", hash = "sha256:b8399098d4c1e3522734f7235dc79b1fc967f96cf139c81e0fa30479e0685f9b", size = 2734356, upload-time = "2026-10-24T15:04:12.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/2e/947cfe02def9371c07cd5bf5b6b61c829e9aecb9904db02a66cfafbc1eec/starlette-1.10.0-py3-none-any.whl", hash = "sha256:478c0ab3b4ced6e82719849c118fa406f0278a3499c1a898cd15cd31bdc38ce4", size = 80571, upload-time = "2026-10-24T15:04:10.371Z" }, +] + [[package]] name = "tomlkit" version = "0.15.2" @@ -483,6 +748,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/81/0da8afb52a71d0a4f2bd3152357b1a441e393b286374802b9d3addab4ab5/trove_classifiers-2026.9.21.13-py3-none-any.whl", hash = "sha256:8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17", size = 14232, upload-time = "2026-09-21T13:29:06.123Z" }, ] +[[package]] +name = "truststore" +version = "0.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -491,3 +765,77 @@ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3 wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[[package]] +name = "websockets" +version = "17.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/79/590c5f0cc7d1661195aeefccb0e342b79f4c77d368d526d028821a390332/websockets-17.4.tar.gz", hash = "sha256:09d3939b70df0c8ad2da145ebd50978bccaee1d565e838b0e7921a34d38571eb", size = 191391, upload-time = "2026-10-30T15:10:35.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/5b/085530961668661300de7248f9bf3ba84e98f47875f0fcc87637d024f82b/websockets-17.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d0f943b53bb1d7389d72a5380078670e5fcc34a1328142148b72d20fd82031c7", size = 218810, upload-time = "2026-10-30T15:08:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fb/5a29e9569f74988822b7bc076287dd7f9f5b68a2070cf5eb4cfada5863d8/websockets-17.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2fd562372d4afaf18d1c7f1c68e39bd32818bb16d5f57f778a4a4ed88eedcaae", size = 216506, upload-time = "2026-10-30T15:08:13.561Z" }, + { url = "https://files.pythonhosted.org/packages/df/73/eb8544367c13cd87df39d41b74853741fb7182e2a4bb659ad7d36f9066c8/websockets-17.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a0457561e8a87a6a3b509e21c35084d7d207ced262139de3f738e9676a476bbd", size = 216719, upload-time = "2026-10-30T15:08:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/c3707e426d9bd779dfad9f319b2b431e5e60d563d7547d3780cbd1646028/websockets-17.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b4ee34acefa9ba1333f02b817982f1e4127f6f9b975017a707c4e738d7f9fb9", size = 226098, upload-time = "2026-10-30T15:08:16.581Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/d203439f909ffbf37260ca28a3432381412d2e79912ebf98be572e63055e/websockets-17.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ef03fb4b3685df0ed9dc6a074a090f030345cad2c23c61818da3e08e693a9caa", size = 226449, upload-time = "2026-10-30T15:08:18.29Z" }, + { url = "https://files.pythonhosted.org/packages/4b/17/3ae5d9486a170a222dd0437b4f2bc02ecf324177498a942dcac200e65d05/websockets-17.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5e970d3996c597186c9dbb6e77a8048737a834ac03cb13186a54999e0e1c31e", size = 227695, upload-time = "2026-10-30T15:08:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/34/37/1307a8e30a961726f2350982fc01b0b3498db29b030c78c566e65ba64487/websockets-17.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6afc32a7a1a40b071070419537df3559d26907086556769683fa97b7b6e90fd5", size = 229069, upload-time = "2026-10-30T15:08:21.682Z" }, + { url = "https://files.pythonhosted.org/packages/9c/bd/4c6167744e202d20a3c770bcde8fae5d427317b1885712c2e02718cd8133/websockets-17.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5da7f7f1ecf866186f195f7ecc374c915e1838496b86f2f3b41da27c798a25c3", size = 228296, upload-time = "2026-10-30T15:08:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/14/b3/3438eee41b39afb44c3c6e45dd686b88a7f8d742b96877b77de4fe0e0255/websockets-17.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:043945a43873c7c11a3f75238d475c95f902e967de329eb8763704546cfaa6b6", size = 227045, upload-time = "2026-10-30T15:08:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/46/39/0bac9052d402efaad1b202ff2f1a57b6334aa624d20f509312765601b507/websockets-17.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:927d602fb8fb595a69e6b1ae998a6ec19851d0fb9391759456e6425090996647", size = 224297, upload-time = "2026-10-30T15:08:26.275Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7f/5809f6ba5a7d1065301800daf605bdb661a9db5ef868ed189dbf691122ac/websockets-17.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:769e4f7b090e10cae4c082e3e39b9458ec2444505df84953c16c0468b16539c4", size = 227169, upload-time = "2026-10-30T15:08:27.703Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f7/c8f6771e7cc2a038cf78bb12d04d29dbbbabf2a84d76999bcc94532f3020/websockets-17.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:03ddf4eb76ced8725dcbb000ff60b0fb8d7330ed011f6dc725a1925c5fe02051", size = 225632, upload-time = "2026-10-30T15:08:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2f/9104786d952978ca2d011321109e35c05be24c3937ef2a62b4d6e25f7515/websockets-17.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:94622cf24e80d03054ceb69a06a86c950e12fe993ed06b933f7895232cacbc3e", size = 226415, upload-time = "2026-10-30T15:08:30.962Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a7/2e1606b3d616b86bb6c70a62d5f87fc9a3dbd81f0bbe24c4706ace76751d/websockets-17.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bc581579885728bb54ef15000295c20e6a64a200b1b73d5cfca71879a3eb3e1f", size = 227594, upload-time = "2026-10-30T15:08:32.443Z" }, + { url = "https://files.pythonhosted.org/packages/67/2d/78ca44e2be6d397aee63a4e68d3a99a33bb14bb3405e94688bc5a2160db5/websockets-17.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42671900e0b602d05a074c52fd3d757c2f8e5c4f73f771794dae1aa4aea5f0e", size = 225146, upload-time = "2026-10-30T15:08:33.888Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/6dad684e54fc8e09d70c9a095506db5c836207d8e846e3cb1e79424e3492/websockets-17.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:2b23dacf7a8a88afe92f77bcdbf4cf043a84692bac2f2da02f4011c687e19b48", size = 226095, upload-time = "2026-10-30T15:08:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/7b04bad7c11c1f92238429352defbac1406743338049afe1328c7a7f95c0/websockets-17.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d48795c7d64ca1c42e3d61fcc6478cb15a19b6ad3f9c882bc8d8dee49d4a420f", size = 226395, upload-time = "2026-10-30T15:08:36.8Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/4eb29c4b12f77b3139470063308ae1e6a0860b9073b1d27d56e8ce19c512/websockets-17.4-cp314-cp314-win32.whl", hash = "sha256:8588edf317fbe4f16a949bc5a6ccdd22b6e6fd5b4745d128bc40bccf31024ce5", size = 218784, upload-time = "2026-10-30T15:08:38.263Z" }, + { url = "https://files.pythonhosted.org/packages/6d/85/2496a24de80c215b55a15f037bc368a2ed0ce7ecb1e909039ff4e76395de/websockets-17.4-cp314-cp314-win_amd64.whl", hash = "sha256:70293c25d79d1f084fdd665894481547575e429d2a75e8d72caec2f6b6b07af8", size = 219137, upload-time = "2026-10-30T15:08:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a3/cfe95d4eb569035e5a1d2e5cbeb4ccf0eb15a19e1ef2ad819a85960bf375/websockets-17.4-cp314-cp314-win_arm64.whl", hash = "sha256:adc30e5d064c9e33049e825408fa4e952f4e8a0cf4c329dc2ded72e1bb5bae08", size = 219017, upload-time = "2026-10-30T15:08:41.778Z" }, + { url = "https://files.pythonhosted.org/packages/2b/dd/4143048a2b27303e7de8ef53d7d0ee3987734fbf93e09491d5a1b0a55187/websockets-17.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:bbb6bed49f083e2ec8ae0bb21c00c7c5171ab8e08a8b008c61a43fc6c944fdac", size = 219141, upload-time = "2026-10-30T15:08:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3e/a63b5f3a5c24be2898eb22a3093a56d4ac4cd24ae79e7ab7a1b971ed4611/websockets-17.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c7a56140fcbf4662cb263754a754c651a16fe2196cae1fd232d18f89acd30c83", size = 216675, upload-time = "2026-10-30T15:08:44.682Z" }, + { url = "https://files.pythonhosted.org/packages/d6/80/f238a10f0b23853c665c602a42784023995ac26674e480252c21af39f4f8/websockets-17.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:18d3710becc612ec6f0229425bd6495133dfcbf30f7b6569585acc8ed2f1b452", size = 216880, upload-time = "2026-10-30T15:08:46.304Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b1/899b69aba1e8d65ac5bfe90b97ada22a815f1dcd3840bf188104738aac33/websockets-17.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:00ad98e4bb0df7a6711c645eec16d5a9fdf99f0692bd4f5b6cb85a68677ba8df", size = 226462, upload-time = "2026-10-30T15:08:47.723Z" }, + { url = "https://files.pythonhosted.org/packages/28/a8/598c2b2a22dfe14d352400df804db8b78a68c1498b3f775a658f07f21618/websockets-17.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7849a096d6fdba3f2bdf849eb89f2d233a24400c92faa3efb25fa8dc615b706c", size = 226741, upload-time = "2026-10-30T15:08:49.314Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ad/84a6de3cc3dd4ee336428c31f89a82e002299f83e10d967f0e9a5cbaf591/websockets-17.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e30550965c78223439815dbdf40c93eb8931dfbf12427110dcfcaeebef76feb6", size = 227914, upload-time = "2026-10-30T15:08:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/69/ab/1e191b97d60a934c071b7b73c39c5db39721a925cc53acd929c615f85550/websockets-17.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:725be1aff55f1c873a79cea0f612df9dff21a9d2c2e05783665b824b8aa7c24e", size = 230059, upload-time = "2026-10-30T15:08:52.596Z" }, + { url = "https://files.pythonhosted.org/packages/f2/57/ef8a64732ef4367091ab05f9b22c1ee4c28657d4a0d088a644198eb5ded6/websockets-17.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6c7f91520c29420639925fd1cecceac9ca07366b50c09ea24eac1cd116b4866", size = 228486, upload-time = "2026-10-30T15:08:53.967Z" }, + { url = "https://files.pythonhosted.org/packages/48/1f/dbad3afcacf2c52d1fdf83b611d849598f0a047d7333ff476266532877b8/websockets-17.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6531540aa63e6864cb0615a3a5da70ae03191334507852a5385320bb7916df3f", size = 227216, upload-time = "2026-10-30T15:08:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/5b/47/59e325d6ca3e04ba5cc7a139d891ef83cad3fb460a183c8f359f378b69a9/websockets-17.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a57b99f2e28922b5a2434317580fd23fdf118caff4c5b752f5452836426205a", size = 224755, upload-time = "2026-10-30T15:08:57.203Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b6/28a471318dfb0060b58c47d0a252cb5078528a1aa1fdf5e6e88e2a61cf18/websockets-17.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8fd30d217a4275107667fe1d4f601e2d50c797d0727148cfbb384536d65f04d3", size = 227508, upload-time = "2026-10-30T15:08:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/ee/29/7485ac6fc4b342b43964811381f56276fec967cae6a851ae4b815613b10c/websockets-17.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cc0cd4065a65ac98f3e2742ee9f314da9d224db49b07dc17d3a20cef0d79658d", size = 225961, upload-time = "2026-10-30T15:09:00.414Z" }, + { url = "https://files.pythonhosted.org/packages/95/a6/520e1f7aa9644ddf58f5a51fc37f46e15293a68cd35b70d5b81b7331ca1e/websockets-17.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:71f76bb8e59be847330b8a8d3753f85397677e128f9f9692c215f8cf721cefc7", size = 226851, upload-time = "2026-10-30T15:09:01.979Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0d/444799abe7418f361da8c201c6858b64b4e8d47265f90851e5cb68b4d90f/websockets-17.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c2db7b9e2d726f7e051bedb67abcf460b84b11437b2f9fdacda7e6931dace85b", size = 227869, upload-time = "2026-10-30T15:09:03.405Z" }, + { url = "https://files.pythonhosted.org/packages/cb/86/6da20322a07364a0ab0787a2b0d4367770f59404fdab6e35122413a7f63e/websockets-17.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:332b88022d0acd47c44158bcd43f47f14eaddcd979247f242b1a33291c45cdd1", size = 225562, upload-time = "2026-10-30T15:09:04.891Z" }, + { url = "https://files.pythonhosted.org/packages/2a/da/53e44d96238dc207011ad5d93b0540c58e1c680a86f48dc3fa8e5138b343/websockets-17.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5cec3e7fbc911ff4fcf43b24aaf1b96906381ad36030f42897def3ff4b78729e", size = 226334, upload-time = "2026-10-30T15:09:06.42Z" }, + { url = "https://files.pythonhosted.org/packages/18/08/82cea972a87f229ee1903e9adf74c17d1e6c4371d581ebb11ccbed4e3a7b/websockets-17.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cbdf4b42f4514422051889424fd03bc269be8c5d751bf39ec29ad761590bd10d", size = 226767, upload-time = "2026-10-30T15:09:07.837Z" }, + { url = "https://files.pythonhosted.org/packages/96/f3/ee6afff802e39e8d6ef29ee4bdc30f8272aaa38295c19a911fad91fe95fc/websockets-17.4-cp314-cp314t-win32.whl", hash = "sha256:16550e24ac22c27e0a728100d8de196b5f054bab6748ef9f0b168fb8b3c32e35", size = 218918, upload-time = "2026-10-30T15:09:09.31Z" }, + { url = "https://files.pythonhosted.org/packages/11/56/b0b07917f9c85a4d24a37300f4ae888bfa7c20f08902dd6a5c61a0d1b26f/websockets-17.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5ec906bb21b35e66cfdcc2ea0133f249f52b7d89e3e45ec8d2bd2d84a0255d7d", size = 219260, upload-time = "2026-10-30T15:09:10.832Z" }, + { url = "https://files.pythonhosted.org/packages/19/a6/a63113909ea602d3d22b0f32a7a9950d8d18b725cfdce735eab9b5f5d198/websockets-17.4-cp314-cp314t-win_arm64.whl", hash = "sha256:c00f0c45851a2c61efffd03dde8fb4d594026b5cc18196c94e15460f1225f2ed", size = 219176, upload-time = "2026-10-30T15:09:12.444Z" }, + { url = "https://files.pythonhosted.org/packages/eb/27/9db924cb0dbf22eade78d98fbae77790f9bf6bc729e6ab29aef9d68e4156/websockets-17.4-py3-none-any.whl", hash = "sha256:c6bd2c9781915592b209160d0f017d4c7c82ad53075e6b0c24d2a89299e7f9e9", size = 212900, upload-time = "2026-10-30T15:10:34.482Z" }, +]