diff --git a/.github/workflows/ci_check.yml b/.github/workflows/ci_check.yml index 6d5a354..01b08db 100644 --- a/.github/workflows/ci_check.yml +++ b/.github/workflows/ci_check.yml @@ -240,11 +240,17 @@ jobs: enable-cache: false - run: uv sync --frozen - name: Build the installed immutable Edge.5 image for schema-7 compatibility - run: docker build --build-arg BUILD_ARCH=${{ matrix.arch }} -t domus-app-edge5 addon-edge + run: docker build --build-arg BUILD_ARCH=${{ matrix.arch }} -t domus-app-edge5 tests/fixtures/edge5-app + - name: Build the actual reviewed Edge preview installed by the Supervisor + run: docker build --build-arg BUILD_ARCH=${{ matrix.arch }} -t domus-app-edge addon-edge - name: Build the immutable published schema-8 image for profile migration compatibility run: >- docker build --build-arg BUILD_ARCH=${{ matrix.arch }} -t domus-app-schema8 tests/fixtures/schema8-app + - name: Build the immutable compatible schema-9 rollback application + run: >- + docker build --build-arg BUILD_ARCH=${{ matrix.arch }} + -t domus-app-schema9 tests/fixtures/schema9-app - name: Build the exact checked-out source and frontend run: >- docker build --file tests/fixtures/current-app/Dockerfile @@ -270,8 +276,39 @@ jobs: DOMUS_PREVIOUS_IMAGE: domus-app-previous DOMUS_UPGRADE_IMAGE: domus-app-installed DOMUS_SCHEMA7_IMAGE: domus-app-edge5 + DOMUS_EDGE_IMAGE: domus-app-edge DOMUS_SCHEMA8_IMAGE: domus-app-schema8 + DOMUS_SCHEMA9_IMAGE: domus-app-schema9 run: uv run pytest tests/containers --no-cov + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .node-version + package-manager-cache: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + run_install: false + cache: false + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @domus/frontend exec playwright install --with-deps chromium + - name: Execute administration in the actual isolated image from Chromium + env: + DOMUS_BROWSER_IMAGE: domus-app-ci + DOMUS_TEST_SOURCE_COMMIT: ${{ github.sha }} + DOMUS_TEST_ARCH: ${{ matrix.arch }} + run: >- + pnpm --filter @domus/frontend exec playwright test + -c playwright.entry.config.ts tests/e2e/administration.spec.ts + - name: Require all thirty-four actual-image administration journeys + run: >- + uv run python -c "import json; from pathlib import Path; + result=json.loads(Path('frontend/test-results/entry-results.json').read_text()); + assert result['stats']['expected']==34 and result['stats']['unexpected']==0 + and result['stats']['skipped']==0 and result['stats']['flaky']==0" + + - name: Require no surviving image browser fixtures + run: >- + uv run python -c "import subprocess; + assert not subprocess.check_output('docker ps --all --quiet --filter label=domus.browser-fixture=true'.split()).strip()" aggregate: name: CI Check diff --git a/addon-edge/CHANGELOG.md b/addon-edge/CHANGELOG.md index 07aa435..ec5537d 100644 --- a/addon-edge/CHANGELOG.md +++ b/addon-edge/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.0.0-edge.7 + +- Require cold Supervisor backups so the app is stopped while its private SQLite data is copied. +- Document the brief interruption and current-identity revalidation after restart. +- Require explicit stop/backup/state-aware restart when capturing an older preview without this policy. +- Preserve the Edge.6 immutable runtime, schema 9, UI bytes and existing permission scope. +- Keep owner adoption, restore acceptance and required human UX verification pending. + +## 0.0.0-edge.6 + +- Add the English/Spanish household administration workspace for roster, household, member, + HA mapping/profile, public alias and private preference changes. +- Bind drafts and detached controls to the verified household; preserve exact original revisions + and require explicit recovery after an uncertain response. +- Include SQLite schema 9 and verified upgrades from the installed Edge.5 schema-7 runtime. +- Preserve original operation history through supported restoration and compatible application/data rollback. +- Freeze Edge.5 independently and exercise the actual pinned Edge.6 image in both CPU jobs. +- Retain the existing private listener, nonroot server and Core API permission scope. +- Mark this configuration/schema boundary as breaking and pin the reviewed source/archive/payload. +- Keep actual owner adoption, required human UX, remaining domains and complete Stable acceptance pending. + ## 0.0.0-edge.5 - Translate the technical membership status in both English and Spanish. diff --git a/addon-edge/DOCS.md b/addon-edge/DOCS.md index b24da0d..d949f01 100644 --- a/addon-edge/DOCS.md +++ b/addon-edge/DOCS.md @@ -35,7 +35,7 @@ it preserves later operation evidence without reapplying rolled-away effects. St 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/271a3a07feb1535f0a5ec8b55abb3de9aac049a9/docs/restoration.md) +[restoration guide](https://github.com/vypdev/domus/blob/3200bd9278f57f142ed03b54843a38a4138fc0af/docs/restoration.md) for the exact console commands. Restore is not available from the household entry interface. App removal with data deletion removes the app's private records; use deliberate operator backup procedures before removal. @@ -49,6 +49,39 @@ Edge.5 keeps schema 7 and supports the same owned Edge.4 data without a schema t localizes the technical state as well as the main heading. Its reviewed immutable source is a new build; the previously published Edge.4 source pin is not changed. +Edge.6 adds the household administration workspace and upgrades supported owned data to schema 9. +An authorized participant can inspect permitted roster facts, review household/member/access-policy +changes and manage their own private preferences. An optional alias changes presentation while the +canonical HA account remains the identity. Each form preserves its independent original revision. +A changed or missing verified household clears private drafts, including detached confirmation controls. +Opening, translating or refreshing a view sends no household mutation. + +Edge.6 is listed in breaking_versions because it crosses the schema-9 boundary. Home Assistant +requires attention before automatic updates install or cross a listed version; see its +[app configuration reference](https://contributors.home-assistant.io/apps/reference/configuration/). +Preserve a complete owned pre-upgrade checkpoint, installation marker and operation history. +Restoring an earlier household state uses the schema-9 console workflow with the compatible runtime, +retaining later original-operation evidence. A verified compatible application/data pair supports +binary rollback. Old Edge.5 and earlier images refuse schema 9 without changing its files; choosing +an earlier household snapshot does not make a schema-9 generation readable by those images. + +The [artifact declaration](artifact.json) fixes the reviewed source/archive and all 151 Python/four UI +file fingerprints. Application CI builds the actual pinned installer image on amd64 and aarch64, +checks those bytes, exercises its native restart/authority boundary and standalone UI, and runs +the installed Edge.5 upgrade/replay/restore cycle. The separate current-source image retains its +complete administration browser campaign. These fixtures use isolated owned data; actual owner +adoption and required human UX are additional acceptance steps. + +Edge.7 retains the Edge.6 runtime/schema and declares `backup: cold`. Supervisor stops Domus while +copying its private data, causing a brief interruption. In-memory sessions close; after restart, +reopening the interface verifies the current HA identity. No backup hooks or data exclusions apply. +See the [Supervisor backup policy](https://contributors.home-assistant.io/apps/reference/configuration/#backups). +For an older preview without this declaration, explicitly stop it before a pre-update app backup, +verify the stopped state and backup completion, and restart only if it was running beforehand. +A readable archive/hash alone does not prove a consistent SQLite capture during concurrent writes. +An app backup also does not preserve later operations by itself; use the console restoration protocol +when reconciling newer original-operation evidence. Never replace a live data directory manually. + The build fetches exact reviewed source, compiles with pinned Node/pnpm and includes the UI in the installed Python wheel. Node and browser test controls are absent from the runtime image. The interface uses the same private listener and accepted Ingress prefix; no extra port, credential, diff --git a/addon-edge/Dockerfile b/addon-edge/Dockerfile index 5436e6f..83e00aa 100644 --- a/addon-edge/Dockerfile +++ b/addon-edge/Dockerfile @@ -1,8 +1,8 @@ # The Supervisor builds this folder. Fetch only the reviewed immutable runtime and UI. FROM ghcr.io/astral-sh/uv:0.11.29@sha256:eb2843a1e56fd9e30c7276ce1a52cba86e64c7b385f5e3279a0e08e02dd058fc AS uv FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 AS source -ARG DOMUS_SOURCE_COMMIT=271a3a07feb1535f0a5ec8b55abb3de9aac049a9 -ARG DOMUS_SOURCE_SHA256=5cc9a963b6f064aa48be256a9d5a4274f8c52c878e4ad7f84517cd33f41056c8 +ARG DOMUS_SOURCE_COMMIT=3200bd9278f57f142ed03b54843a38a4138fc0af +ARG DOMUS_SOURCE_SHA256=ca991d7b3560dbdb7a6a86a34eea19708f01f10c15da502e49ed8ab740a8533b 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 \ @@ -24,9 +24,9 @@ RUN uv sync --frozen --no-dev --package domus-server --no-editable RUN .venv/bin/python -c "from pathlib import Path; import domus; from domus.entrypoints.ui_bundle import load_bundle; assert load_bundle(Path(domus.__file__).parent / 'ui') is not None" FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 -ARG BUILD_VERSION=0.0.0-edge.5 +ARG BUILD_VERSION=0.0.0-edge.7 ARG BUILD_ARCH -ARG DOMUS_SOURCE_COMMIT=271a3a07feb1535f0a5ec8b55abb3de9aac049a9 +ARG DOMUS_SOURCE_COMMIT=3200bd9278f57f142ed03b54843a38a4138fc0af LABEL io.hass.type="app" \ io.hass.version="${BUILD_VERSION}" \ io.hass.arch="${BUILD_ARCH}" \ diff --git a/addon-edge/README.md b/addon-edge/README.md index e169d97..13e6a64 100644 --- a/addon-edge/README.md +++ b/addon-edge/README.md @@ -3,11 +3,22 @@ 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.5 packages the English/Spanish household entry interface: verified access, explicit -household setup, own pending membership and original-request recovery. Technical status follows the -selected language. It uses private SQLite schema 7 while retaining identity, household records and -operation history; existing Edge.4 data remains compatible. The remaining domain -screens and complete product acceptance remain 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). +Edge 0.0.0-edge.7 includes the English/Spanish household administration workspace: participant roster, +household settings, HA administrator mapping, member roles/ordinary permissions, public aliases +and owner-private preferences. Reviewed changes retain exact revisions and original operation +identity; a changed verified household retires private drafts. Saved preferences describe stored +choices; notification delivery remains unavailable. + +Edge.7 also requests cold Supervisor backups: HA stops the app while copying its private data. +The brief interruption closes in-memory sessions; reopening verifies the current HA identity. + +This version upgrades verified older data to SQLite schema 9. Preserve the original application/data +checkpoint and use the documented compatible restore/rollback procedure. Existing identity, +household records, history and original-operation evidence are retained. The installed Edge.5 +recipe is preserved separately as an immutable compatibility fixture. + +Set your Home Assistant browser origins in Configuration before starting. Edge remains experimental; +the remaining domain screens, required human UX/owner adoption and complete Stable product are +still in progress. + +See [installation and permissions](DOCS.md) and the [reviewed artifact declaration](artifact.json). diff --git a/addon-edge/artifact.json b/addon-edge/artifact.json new file mode 100644 index 0000000..74f23ee --- /dev/null +++ b/addon-edge/artifact.json @@ -0,0 +1,167 @@ +{ + "schemaVersion": 1, + "version": "0.0.0-edge.7", + "sourceCommit": "3200bd9278f57f142ed03b54843a38a4138fc0af", + "sourceArchiveSHA256": "ca991d7b3560dbdb7a6a86a34eea19708f01f10c15da502e49ed8ab740a8533b", + "storageSchema": 9, + "wheelSHA256": "6578a595350e00b0df2fb7e12a436a100a6cf8a661ad4147a5fc074d6de05351", + "pythonFiles": { + "domus/__init__.py": "7182c88b92234b4328f94497f4e70222d4390d8093e4b62893dfa78ac5530ac6", + "domus/application/__init__.py": "1e8c0c87bf2a8ca947112543070583c0cc55d9d40b56eab0d86ed59a4ec1e82c", + "domus/application/activation.py": "c0c51350b074424837251068ec416f9be1e23ec1a9a4bfdcd19c3724ae736f08", + "domus/application/administrator_proof.py": "c4735b83498970eddbde8a69c9e653829545b0589737df25ce76d2b536b0adfc", + "domus/application/authentication.py": "3ab940f76455365c4d40d40645c5f9ba5a65754e382665a0266057baaae04bf8", + "domus/application/authentication_values.py": "e83491791e6d323e1fb12aaafb11fc39d2f89a12d10c9ae415c055a1e33aa314", + "domus/application/authorization.py": "4ccffade393fc2bac2192fee8909f28f290dae0c6fd3913c87433338101bc52a", + "domus/application/commands.py": "a26401ed6ec64ea0f016455ec3e1b43a6b03189e2455c40aa1be50fee3bf6f48", + "domus/application/fair_recurrence.py": "2e94f188b73d3e968210f3681e2d49976d4cdd4de65f9ebb980bd5bc82f6a5fa", + "domus/application/fixed_materialization.py": "de57d049d5807dff29522c165ce3de12abc56dbbb0bfbc276a9a237ec83f1e59", + "domus/application/fixed_recurrence.py": "da4c9458ef0b808c344a9a9793cb4628138d28e7a80096e0573edf711b3bc6a3", + "domus/application/household_administration_queries.py": "05157644dd2e564d878b9cda38731f7eb7262f6669126ec114d57a5e24df224f", + "domus/application/household_commands.py": "878d5138026593e47f7f444bd4095d0eb7c0edcc43ebedccc18e9ba41ad28673", + "domus/application/household_queries.py": "d68db5e792eaeaac07bb1dcb232f84ee573be42c071ae2e3567b32482bb4e27d", + "domus/application/household_roster.py": "32ff065c1aa94086aa0390b5c4f3834a5a2e5319f2c26bd8f20af887bf051928", + "domus/application/identity_ports.py": "aebf50fc433fa1c77bacf2bb9e35fdde8c746069b567154de01eaf9980c49075", + "domus/application/member_commands.py": "d3e93982d2e42d9a57771269ec0ccd4692fcd196189922bb09e7b0398b924649", + "domus/application/member_profile_access.py": "c3af66244b9b10a43848e2422f9d662390f4ffd557440d9eae0e0a45d6e4b8a4", + "domus/application/member_profile_commands.py": "cee8b699d2774056fe8620c22befb369193c2e3acec7fe861c4555540ffdbcc8", + "domus/application/member_profile_ports.py": "0e041d1923997b6c4894f185cfaad3912a396703373ece9a6e90a829e9aafc39", + "domus/application/member_source_ports.py": "619b1e6eff75ea2ed84b41f268959a99ffa864f6c78bc8d096b52aa5d57d38dd", + "domus/application/policy_commands.py": "bc588b5b5feb72fd5cb57d7b4a10ef5a05ae48da5ec042fd569142ce50fedeb6", + "domus/application/ports.py": "8b965381497b57450e696a4a5f9f6df5ceff9dc67ca340e1f91435d8e5d211e9", + "domus/application/recovery_commands.py": "2b8c198cde97f0213ec1612ea32bce5e00637aab99862fe08b59418d3c64af49", + "domus/application/recurrence_commands.py": "581d4addcb7bcad74d33aa70abe3bc9e99df9cc9a9fc02e0e8c789b4444da765", + "domus/application/recurrence_ports.py": "bb94dca030888436663afc9f1a432e2cafcd9b224a2719ccefa5d548de40c4cb", + "domus/application/relative_recurrence.py": "f0d595e9cbf2022977c7940c3b4fc50dd6f242b3dfa615415099f639fa11394f", + "domus/application/relative_recurrence_commands.py": "402e4fbf71673f838b39829f8e8abdfcfcae930f3e660db9a8f0fe7215738df5", + "domus/application/restoration.py": "32a301e9d733f453526dbba70dbe99c036d6485a4c9bc8483d4f52456534a6eb", + "domus/application/session_values.py": "f124c2405362cbd782394e69759ac2f88041910e771447eaf40c82ad3d993e50", + "domus/application/sessions.py": "4e21d9b3bd917a18b8b996cc43757cd1a341a0a53d4ed2b3db77688f0fbf5a8c", + "domus/application/snapshots.py": "c4f0862f590d222711018de3b23a7155f5702375ce6ece7dd0e2170c86a22ca4", + "domus/application/task_authorization.py": "fbbe3a9d259c29751fb631511ae710bc8c63dd15b7c0a8d1b3d90d409c88a462", + "domus/application/task_commands.py": "7d37b3eace0c0eb81a14a0c36a8f565b1426171ca561f4a941986c3242b56630", + "domus/application/task_creation.py": "27a4dd5306dae0e1c50c7ef85326ce312707b61e3ac36fab9a5df1e63437f191", + "domus/application/task_events.py": "484dc0d374c69e2ba1d6506866c8656974626339ae7b66606b603cee4000b0e3", + "domus/application/task_facts.py": "4dd63556176a448b88c1d5e81237aa45f4519bfc06dbbf95edb58c81acb2e8c2", + "domus/application/task_material_commands.py": "bc0715ce0ed15a4807b773fda634945037bdc877ccc8417df77e6a34f1125ba6", + "domus/application/task_material_ports.py": "87ba7e535f5a864829fdba3a9cfdea2fb5723908611455cf1dd2db10f9d43952", + "domus/application/task_material_scope.py": "f1d44c700578da0681872e4fac42d1ebb801e33fa843065b9dabb397f69888d1", + "domus/application/task_material_views.py": "1f4e1b52c17064b73e3c6502b38fc578a7589d68044279312c783b656e5aa5bf", + "domus/application/task_materials.py": "caa132a7b16fca293770deba3da149cf9bda2e24d4719abe9de39470d686698e", + "domus/application/task_ports.py": "67ede6022534205e35878c695c0fee4354ccfa207140e8c4043888fc133c5076", + "domus/application/task_queries.py": "e4e8256e49acd19af6d850606168e649ea00813712b0f3bae1d8cf57e0a3ba97", + "domus/application/task_requests.py": "ca196b2fdfecd1110d03caff6121be0ca50c505a4f7713f97a8f62d2449556ec", + "domus/application/task_reviews.py": "dcc288cda6cdcf5073bad4f24133b9d3496c0b0cf1272e8328e6df38129f3328", + "domus/composition/__init__.py": "a902d8faf7dd19da6829fb730d81e32d34ec38ee6cfafd676966a95d0ad31366", + "domus/composition/admin.py": "020abd88d8b9f26a429169bd1a51dd7b7ae960f0a96b53e2099bd2321debbff5", + "domus/composition/ha_app.py": "e0ffe36478881ff11367c7ba5467438ebef54af7d4bfb00bf14350893b15e1b3", + "domus/composition/household.py": "835e256f8b5b8e60adeef0d5292cb115ce05e085bbd27d0a3a0bdea46fe4ede8", + "domus/composition/household_administration.py": "44fb8cd788c7e6884c169d988270f9efe8abe01efa4cf9756e1cd81c52837f21", + "domus/composition/ingress.py": "8efb005abf1506ee6bedbac8877b1f37138bc147eb34a99c86a42b0cb37c98d8", + "domus/composition/recurrence.py": "ac38c59e8ea926d1962fac2f06106566dda7944dfd027a91ff3af32d069964bc", + "domus/composition/restore.py": "fe4f5040a99db1404938e402eefa54fc93139055a5bc92abdfb7bf35d0c87297", + "domus/composition/server.py": "c36e68c2126f4b5d6a3188348dd5846cda755bd909cdd6a510c172fc43690f2f", + "domus/composition/tasks.py": "4c93810023eda268c2f7c5e5307d5340c3c7e1de8500062b64e9e4d8ee9f2996", + "domus/domain/__init__.py": "0aac8a2ba41471d7a174ef7baca6073c95009a9db96df3e032392aa4f80bd079", + "domus/domain/activation.py": "fcb2e47e939127d5e254e2136fb7f1459b40fa8c8561100579fced49666ad0fe", + "domus/domain/errors.py": "0af3fadacc89835a4778b07ec45a1cf45539825c3756f44e8f78cebb37f354b6", + "domus/domain/generations.py": "bd8d665c7b6e832f881784c07475d2b8911c08d0894cf8e125681a9f27e1ba97", + "domus/domain/identity.py": "a35980712e1717f5f225165080916d8cfefa8c5d1b01f8b08a78089ff2a29b91", + "domus/domain/member_profiles.py": "2790ca55a7b37a390d006165a44520085e1ff513783b9cf1a7b681435b8ae4d2", + "domus/domain/member_sources.py": "b5e9d19d501d3d5dd5caf5b4ce945d4c3ff71b1967e92bd8933f26661a416c01", + "domus/domain/nominal_recurrence.py": "2c3678f220a311b8b08e6cad0d146a1a68135b5fd2a5c0d8ab9ed809fe47d8c1", + "domus/domain/recovery.py": "0bbe731961d4481e8002382ab0c9948fd415534b0cbbc03bdf0a0e35676f0cb0", + "domus/domain/recurrence_frontier.py": "d0b1150e2bdbe855413c353e11222a649d3dcd2408e4eba18fcea09a3f4d5ad3", + "domus/domain/recurrence_membership.py": "d2a326ec00bade4fb0e0d273391f09158a4c757024f593b8ab402e991a52279d", + "domus/domain/recurrence_periods.py": "06a6d87ad74ed292c352eb286d84a5a34404baed198f95807935014d5c5fa572", + "domus/domain/recurrence_planning.py": "4b3e7dd17ec410661e8babcb8404c88556be51fde49e3826d02482c6e1568edb", + "domus/domain/recurrence_records.py": "bfe8ea1df7fd6e139c28c3c274713a6a113cba268ae3d16e2869d814deaa4ba6", + "domus/domain/recurrence_rule_text.py": "bb5c2edc39583783099169a8ad6cb0d2de7598eace43d236fac363ed52f0c2f5", + "domus/domain/recurrence_rules.py": "9d48f7f20bda1ae25d794901d8093129c6aeb7881ab7d5b6b4eec4e2df0f1d80", + "domus/domain/recurrence_time.py": "431407eec36032e84a77d235c750630dc6f8bba327bec75ad1b27bae30ce80f7", + "domus/domain/relative_recurrence.py": "b8dfeca01e58a47d5e27249f9a6b3f04097b7765df4b3aecc83c8c97b9ea17e3", + "domus/domain/restoration.py": "4bb02d6633a0c81c1ce0cb58ae73dd300813b7ed40f495de521a0290d7b2ce0f", + "domus/domain/snapshots.py": "81060c12e0ef06c7329bc3be780d321f40426d36e87e42fea5e85ad075bf7f19", + "domus/domain/storage.py": "9c72aedf54c63d6fa0d85f0e8c044d56c7ceefc23eea123b45d096da96a6c75d", + "domus/domain/task_events.py": "00b750748d25084d39fa4a50b0b1df1f2b0ca4504dad4ae39e1438c6ddd4c3b0", + "domus/domain/task_material_policy.py": "c04f1a21ed58cfaf530bd0eaca7bd1869becd5d930df990095fb9f13d915c0f2", + "domus/domain/task_material_values.py": "e3bc290538de9d6ef243cd35624e2fac294f72ade1245621a8ec42cad3991b31", + "domus/domain/task_materials.py": "b30d1929fdfafdc92a93bedcbcc61e9edc1b04d0ad079e0e8e80bf77fb89430c", + "domus/domain/task_policy.py": "5a7cb62237a233cbeb958295e35914c01ae73cc2058f146554325fc91ba40a52", + "domus/domain/task_reviews.py": "67cdb6db84ae39b27bab268df50e5918da1d002f546c7a036536d33a4742bf83", + "domus/domain/task_values.py": "3439561cd1030dc9e779a9f7430d59a3bbf58b8ffea107fc1df4fe3efe0e345b", + "domus/domain/tasks.py": "303d0ea0dfa64201566bed83d728f9e3490c6a2c4570630ce2aa8e9ac83146fa", + "domus/entrypoints/__init__.py": "d46875cbfef7220c7244bf1b89112ece8122ecb0d66cd908f08f098f0f3c419f", + "domus/entrypoints/administration_dto.py": "aefac6648dd8fefae9d2366abed1c9d595c24a356f7caf945b742043b2c39777", + "domus/entrypoints/administration_http.py": "9ab62ee9759a4b31c5e994354562b48b947bd3428a5866dd9faeb0545ae37afe", + "domus/entrypoints/bounded_http.py": "d8047746eddb7193f1aa9f16007958c79cc6ae7ac1c30ab93909883e3aa97b54", + "domus/entrypoints/console_recovery.py": "854a3f8d7a2e74f301ac5bd4b54682e409e2669a3bb7e05546e479c113c09026", + "domus/entrypoints/entry_context.py": "c3c8b64c603dd4de8330f9e336754c190f793e245d0a8e0455ac06e110919f78", + "domus/entrypoints/household_http.py": "6bd280371d0816e17899cb5d3fd0acea7390340467bcb5e216b3cb0f582ceaf0", + "domus/entrypoints/http.py": "3aa736e50f70328d7c7066d79ae1dffa7da22bdc78b0ff1b9b3b44694ba0f9c2", + "domus/entrypoints/http_contract.py": "613be310d4ff6a6c7e2a699c20f1c4a142d0b34073629884e58e973c4b040039", + "domus/entrypoints/http_errors.py": "fc2dfdf69d1e8470821af9a0e8f6d70b8c76d2d1e8557f85db81097eb0e400e7", + "domus/entrypoints/ingress_boundary.py": "1aa1d684b8625464b5d36d272f71d94b4cd32b84819ff8765a821c9a722f8f28", + "domus/entrypoints/ingress_http.py": "f6557eb5481d132131f66ad7691e30c223c63a66a6f0ae5754c9fba3bc7f3626", + "domus/entrypoints/native_http.py": "5a92ac6094dea64b06e13af9e05453a2f302bd6261ab46195bed0198b3d69482", + "domus/entrypoints/openapi.py": "01a3fab9e5890ebcc1fb9c70bce9ced43d45b9190afd134db7dd45e0c5eb28f1", + "domus/entrypoints/task_dto.py": "7df14d696be6290f4f3f412dc5ec6ffe1682750f410ad38260552d5e1b770c9f", + "domus/entrypoints/task_http.py": "bb646552a3a54a538219470c0e160f281b9926c553630036a7a569f87eb3e84a", + "domus/entrypoints/task_response.py": "33cb58fba8e45dfaf46474905bb8bef0145c392ee4ed3155bff7958677742435", + "domus/entrypoints/ui_bundle.py": "beae3d4a7a57375ee195cca81efccdbc899d9db4286029d53eabaf30de6e14f7", + "domus/entrypoints/ui_http.py": "b6c47baa742f3862173a93e4121dc9914fd972eb6287ffffb9ffccecbf2ebe4a", + "domus/infrastructure/__init__.py": "ec6cb2d48fb3178de856dcbeefb7b14bc193fe78ac68a53f9afcf2bd9cac0699", + "domus/infrastructure/application_runtime.py": "30ed2ddfd31dd7dbc6d65f8330555e1b13fa390fb45a9a9027ff732e4113bbe5", + "domus/infrastructure/auth_runtime.py": "414ac5ab890db991272a2fe9d7817ad1995052d2d09e1e9dd07d2631ef92de65", + "domus/infrastructure/ha_config.py": "e9a33ffe459b45d9b7efe946e8991c4c7363fb6d4d83b0d8d3923deb214c095f", + "domus/infrastructure/ha_directory.py": "4e81ad4b5c67b412f83a1436b693918310f6f26a9871b83f225eaeb9d65e1f05", + "domus/infrastructure/ha_http.py": "638cc3d918689d1392adb57332c2136160e9745a29b7c84d8fe99363fb358673", + "domus/infrastructure/ha_ingress.py": "e1da730ab4092afbf9630ff3a8d5c31285d7a8b99e1acc7080082ffdf4023dc7", + "domus/infrastructure/ha_member_profiles.py": "bd54e7218726e05008789fc2a0d57e3f3f892c074ff81f1ac2afa14b447eb04b", + "domus/infrastructure/ha_websocket.py": "0df4303cc068bdeaeab7a7fc0a2b669fdc125440c6476f1ef744e50ea13e80ba", + "domus/infrastructure/ingress_sessions.py": "9f95f69f124fca64b9420458580bbad9907f027754c3413284ce7513a3d9907a", + "domus/infrastructure/memory_attempts.py": "b1f739ddbbdb072a4961410b9775ae191581edf6135d0ae359bf421cc8357dfc", + "domus/infrastructure/memory_sessions.py": "a1725225ca45e9610cb158b2a628cfd0e368fdfc17791aec18dec2983fb4d063", + "domus/infrastructure/offline_storage.py": "16795918af0b4b21bc17aa6a52706fb92aa4d40850c489587bcffa7511b0fc30", + "domus/infrastructure/owned_storage.py": "c994557ba584bc0292c8fef432afe8725b532a5976c91faa39ebdbe70b4c4192", + "domus/infrastructure/recurrence_time.py": "a355278ac57777a9f21ff9f02ac2b3a58b3a8f73fdba61466f2e80262bcd42a3", + "domus/infrastructure/recurrence_worker.py": "169595c5e56f6b154521ec5573a65fb44b483dbb8d01726f2457760967f0477b", + "domus/infrastructure/restore_source.py": "71917a2db52f5f6794504fd8caa7fd23b0732e8328760c728ea3f082acd2d203", + "domus/infrastructure/session_access.py": "1e8bc8d44b60d985da7b44d7bb75fd21bb05f3a45ec38dc7031eff1a2709197d", + "domus/infrastructure/sqlite.py": "a2db26830563592aa6efa9f84f4de37b0510cb583ea2354d5706c216160abf7b", + "domus/infrastructure/sqlite_activation.py": "6191814bfcb73ac35613f6592fd27f74cbf88baf81394091caecbdae3e29ab8a", + "domus/infrastructure/sqlite_history.py": "e7f8d4f2b79aed851a4056364f67d1c7806c52d011b46c597968bea86dd1d144", + "domus/infrastructure/sqlite_identity.py": "2c07453984455a45f0ef6cd7bac775e1ad3673b8e8a3e450d419df5591f2beeb", + "domus/infrastructure/sqlite_member_profile_schema.py": "88ed7ea8572f3e625d1ca8f114a750fbea02fc4ffe165fe12bc4d68307f7764c", + "domus/infrastructure/sqlite_member_profiles.py": "453a8cadf5149f659151d638933f7b188d05c3fea68843a68eb2e3e585103d2e", + "domus/infrastructure/sqlite_receipts.py": "0c7abbc6bcce8b1550e97da54c42701bc2dc5c6280cf61e3370a53ce6839745c", + "domus/infrastructure/sqlite_recovery.py": "01fa6b8896674ce8203073c468eccb7033a9824052d7db3a853c80a2df6e2650", + "domus/infrastructure/sqlite_recurrence.py": "8628e59f59d74934fd25d2c7234e9c4f19aecf843354bd6f30940366fa8bcc5c", + "domus/infrastructure/sqlite_recurrence_integrity.py": "78e8bb6b7576c742db7ba23d8c5a6881e5a14e98c6513220f387a4b5bafdcda1", + "domus/infrastructure/sqlite_recurrence_records.py": "88a8eb3afba9acc786600045ea5692ebc99485523a8e7cbfa1ad0bee554c89b6", + "domus/infrastructure/sqlite_recurrence_rows.py": "5a3f4760647a2e76bc0916e394f3e9e7af11dd7b316d3a8ce0780a7b7c3ebbb8", + "domus/infrastructure/sqlite_recurrence_schema.py": "c551959d04e21a7ef170d9ac40222eb0aaa2380ec2144d83ed30253f2384dd43", + "domus/infrastructure/sqlite_relative_recurrence.py": "3ddd7e0cef493cd30415eb95bdab221bc1867cba2613ec1ba75c2740c0ec0191", + "domus/infrastructure/sqlite_relative_recurrence_records.py": "adf60c10b464451f05c0f36fd6ae915fd0c773578f4b99908fa15baae43e5e54", + "domus/infrastructure/sqlite_restoration.py": "2f28207974082bdee48d72afdca08956e077b957377b1a2894b3eb6f4e6c224a", + "domus/infrastructure/sqlite_snapshots.py": "49dfa90b1ed57b9219d4c80de0897a7288f1abdc9da47869771612457d2faa69", + "domus/infrastructure/sqlite_task_history.py": "cd12469036355fce9d2fac8e5313497507a12012457257ecce934f0280c87c31", + "domus/infrastructure/sqlite_task_integrity.py": "dd100c331e8fd7c4098ad6244f2030e239c92919b78c71171eaf1f599709c6d3", + "domus/infrastructure/sqlite_task_material_records.py": "f8b539111bf18e9bebbbd26f7a847a6df32f2fcfa04e60d5bfb160a0f1c38552", + "domus/infrastructure/sqlite_task_material_schema.py": "550499e114cf9423ffeedfdd0673f08f9efec0c57d85875454f40ce27066361a", + "domus/infrastructure/sqlite_task_materials.py": "83ae10d500cf66e185ffd848957d2a898fc5e8c22da6b3cef4545b03628cc3fa", + "domus/infrastructure/sqlite_task_records.py": "98287fece267b5770fdb89faa9d6b497a7fcae77ee5861f277893db0124a80b1", + "domus/infrastructure/sqlite_task_reviews.py": "96984dc6f02f6e7a4cf88a328da94cac20ebc9ce405134859b141290ff1bd7ed", + "domus/infrastructure/sqlite_task_schema.py": "d220bef0bf8f8c180401367094f00fba8bc7248d2faf54700e09d0f4a6b61922", + "domus/infrastructure/sqlite_tasks.py": "216437dd093593ffeb54d59472127f49fbb01dd5cdc284f6a7f4a3dc713e28db", + "domus/infrastructure/standalone_sessions.py": "ec3fd7a848c2c2a57e416bc7361e416c823d76706b474d08120cdd9e1534655a", + "domus/infrastructure/task_cursor.py": "b21dbb915ce874b51008689d05abb4480cb7a65aabc5851f9500e51238bb6b5f", + "domus/infrastructure/task_materials.py": "d5d2934e36f290c7bf44b473392f014eab8749f1f3b3d26a3402079e65d2818f" + }, + "uiFiles": { + "index.html": "fb4f250c231b0f0b07fd4d99fa00925610f0573b01f033a4bf75178f860887fd", + ".vite/manifest.json": "07f6f6a7f49b0c8691b16dd3e687e17698b4e540e6f3cd2577fcfc187514f159", + "assets/index-CS8EClQ3.js": "c3103fcc8d107cff163ab5a98c8a587ef5203367624dd1f23fea56b96227dedd", + "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48" + } +} diff --git a/addon-edge/config.yaml b/addon-edge/config.yaml index 7ca69d4..aaaf011 100644 --- a/addon-edge/config.yaml +++ b/addon-edge/config.yaml @@ -1,5 +1,5 @@ name: Domus Edge -version: "0.0.0-edge.5" +version: "0.0.0-edge.7" slug: domus_edge description: Development preview of the local Domus household application url: https://github.com/vypdev/domus @@ -13,8 +13,10 @@ breaking_versions: - "0.0.0-edge.2" - "0.0.0-edge.3" - "0.0.0-edge.4" + - "0.0.0-edge.6" init: true timeout: 30 +backup: cold ingress: true ingress_port: 8099 panel_icon: mdi:home-heart diff --git a/backend/src/domus/application/household_administration_queries.py b/backend/src/domus/application/household_administration_queries.py index bbb1372..6c71746 100644 --- a/backend/src/domus/application/household_administration_queries.py +++ b/backend/src/domus/application/household_administration_queries.py @@ -5,7 +5,7 @@ from domus.application.authorization import HouseholdAccess from domus.application.member_profile_access import preference_owner from domus.application.ports import Transactions -from domus.domain.errors import NotFound, StorageFailure +from domus.domain.errors import Forbidden, NotFound, StorageFailure from domus.domain.identity import HumanIdentity, Permission from domus.domain.member_profiles import MemberPreferences, MemberPresentation from domus.domain.storage import validate_identifier @@ -23,7 +23,16 @@ def __post_init__(self) -> None: def presentation(self, member_id: str) -> MemberPresentation: validate_identifier(member_id) with self.transactions.read() as unit: - HouseholdAccess(self.human).require(unit, self.household_id, Permission.MEMBERS_READ) + access = HouseholdAccess(self.human) + try: + access.require(unit, self.household_id, Permission.MEMBERS_READ) + except Forbidden: + access.require( + unit, + self.household_id, + Permission.PREFERENCES_READ, + subject_member_id=member_id, + ) value = unit.presentations.get(self.household_id, member_id) if value is None: raise NotFound("member_missing") diff --git a/docs/evidence/household-administration-browser.json b/docs/evidence/household-administration-browser.json new file mode 100644 index 0000000..6841330 --- /dev/null +++ b/docs/evidence/household-administration-browser.json @@ -0,0 +1,1365 @@ +{ + "status": "partial", + "issue": "https://github.com/vypdev/domus/issues/27", + "sourceBase": "ad45272a180f4e36d2dd124f950826e41311017d", + "baselineMerge": "8b1cbc110c389b9cdb6ecd11317c95abf2c4b0a9", + "nativePublication": { + "run": 41525972873, + "job": 125553749391, + "commit": "ad45272a180f4e36d2dd124f950826e41311017d", + "branch": "feature/27-complete-the-household-administration-workspace-after-the-verified-api-and-controller-checkpoint", + "result": "success; branched/state:working verified; exact parent is the actual PR26 merge", + "originalPolicyModulesByteVerified": 7, + "formatConsistencyAndCatalog": "passed", + "retainedOwnerDocuments": 20, + "retainedOwnerCases": { + "HADM": 72, + "HADI": 8, + "HADW": 82 + }, + "prospectiveBrowserRefinements": 24 + }, + "implemented": [ + "Optional household-read failures retain current entry authority only after rechecking grants; authentication failure and stale generations still retire access.", + "Application-only joint coordinator matches context/access/grants and independent household scope, bounds reconciliation, serializes explicit work and preserves original commands/results.", + "Earlier joint lease expiry retires both views without renewal; disposal fences reads, late command replies and pending logout revocation.", + "A fresh administration reconciliation replaces private facts even after invalid input, missing original or read-only early exits, while preserving same-authority actionable notices. Empty errors retain their verified account context and cannot be joined to a different installation/channel/subject.", + "The actual document runtime now constructs the application coordinator, both concrete protected HTTP adapters and separate storage journals with one monotonic clock/cryptographic entropy. Its callback exposes only reconciled administration facts. Both proof/journal deadlines are scheduled without payload disclosure; stale busy events, BFcache, teardown and late effects remain fenced. The page still renders entry only; full administration forms remain pending.", + "Own public alias reads retain member-read authority or applicable current-owner preference-read authority. Other-member reads remain protected by member read. The browser independently loads and owner-binds its public presentation/revision, so a missing or denied roster is not an implicit prerequisite for an own alias edit. A permitted current roster can still supply a public alias revision if the independent read fails." + ], + "testsFirst": [ + "Real Chromium entry controls first failed16/passed76 against original code; all93 entry cases now pass.", + "Coordinator test first failed import on the missing module, then29 initial semantic cases passed.", + "After correcting an initial C13 test expectation to the existing original-GET contract, three explicit notice-loss controls failed before removal of the extra refresh.", + "Three lifecycle controls failed before stale-notice replacement and disposal-during-logout fencing were corrected.", + "Four semantic controls failed against published a721039: three early exits reused a former member private preference and one empty forbidden result crossed a changed installation context.", + "Fourteen new reconciliation controls first failed on the missing use case, after a distinct sandbox-only socket denial. The corrected full Chromium suite passes1108; fixture locale typing was widened to the actual own-preference contract before strict types passed.", + "Ten runtime integration controls first failed before the existing runtime exposed or composed administration. The actual protected HTTP/journal composition then passes all39runtime cases; six additional current-permission/storage/cancellation/scheduler/expiry/deadline checks pass within1124frontend executions. These HTTP-provider controls are not actual full administration UI journeys.", + "Two new actual compiled-browser/Python cases first failed because the test-only HA simulator lacked the fixed account-source request method. Both then pass after completing only that fictional provider; all53entry-plus-read journeys pass without the earlier AttributeError. Original traces/screenshots and the earlier51passing-with-provider-errors log are retained separately.", + "Six backend regressions failed before the query correction: four active roles and both actual deployments could not read an own alias after roster permission was denied. Four negative/public-read controls already passed. Four browser application controls first failed before the independently bound presentation fact existed.", + "A distinct initial pair of actual-browser checks received status200 but could not retrieve reload response bodies through Chromium CDP. Actual upstream bytes are now captured and forwarded unchanged before retirement; this is not counted as an additional product permission regression.", + "The published dfb frontend CI independently failed the same CDP body-unavailable assertion in one administration-read case (52 others passed). Its original trace/screenshot/results are retained. Both new read tests now use protected upstream byte capture and the complete55-case actual browser suite passes." + ], + "frontend": { + "executions": 1129, + "coordinatorExecutions": 55, + "entryControllerExecutions": 93, + "authoredRuntimeFiles": 29, + "nonemptyRuntimeFiles": 27, + "typeOnlyFiles": 2, + "coverage": { + "lines": { + "total": 1341, + "covered": 1341, + "skipped": 0, + "pct": 100 + }, + "statements": { + "total": 1999, + "covered": 1999, + "skipped": 0, + "pct": 100 + }, + "functions": { + "total": 409, + "covered": 409, + "skipped": 0, + "pct": 100 + }, + "branches": { + "total": 1446, + "covered": 1446, + "skipped": 0, + "pct": 100 + }, + "branchesTrue": { + "total": 0, + "covered": 0, + "skipped": 0, + "pct": 100 + } + }, + "independentFilesystemInventoryAudit": "passed", + "administrationControllerExecutions": 130, + "runtimeExecutions": 39 + }, + "checks": { + "runtimeAndCatalogTypes": "passed", + "nodePolicy": "38passed; all supported metrics100%", + "entryBrowser": "55passed; actual compiled runtime with Python/SQLite in native and standalone modes, including own alias reads without roster permission and actual account-source facts. Complete visible administration forms remain pending.", + "packaging": "1passed; actual d797 wheel matches all151Pythonmodules and4compiledUIresources", + "catalogBrowser": "18passed; rebuilt existing entry-state catalog; complete administration forms/catalog remain pending", + "nodeInvocationLimitation": "The earlier architecture-only command passed its selected rules but failed global tooling coverage because it omitted the unchanged tooling suite; complete pnpm test then passes38/100%. No product/Copilot failure is inferred.", + "strictBackendTypes": "passed151modules", + "ruff": "check and375-file format pass", + "pythonArchitecture": "51assertions passed", + "documentationAndOriginalSDK": "original7policyblobs unchanged; format/consistency/catalog pass with all20retainedowner documents and72/8/82cases preserved;24browser refinements remain required" + }, + "sourceIdentity": { + "backendInventorySHA256": "ce4601f050a56a4bf4e22c5ac6e255fb1c7a9954c7a33d6bc3f0c261834d60b5", + "backendUnchangedFromReviewedB60": false, + "changedBackendModules": [ + "domus/application/household_administration_queries.py" + ], + "frontendInventorySHA256": "f3485b8718a077ca88c94d751e53951d65c7a177ac4c02fd8f6e0a50b85b7fa7", + "wheelSHA256": "d79751a49cb89891ba1a2cde0086f77c271685dc6af4bd170423c3f244401f7e", + "compiledUI": { + ".vite/manifest.json": "52478f33798af6644baf27ac754833d63b8e64676483cc76159c4c8abd83da75", + "assets/index-1tQXZaXm.js": "5f665d480483e7368d27d6e1bf9f858d16a77323e41fb804d2371e8985d677ed", + "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", + "index.html": "1046899d4a844c45dd67f51686d31941edc0a7b8401d0c0ba4ccc8fed799d9bf" + }, + "everyModuleAndUIByteVerified": true + }, + "backendScope": "Fresh complete local 4,374-test campaign passes; all151modules have100% supported statement/branch coverage and an independent AST/source-inventory audit. Only household_administration_queries.py changes from the prior runtime backend; schema9/DDL, private preference scope and original-operation authority remain unchanged.", + "providerFeasibility": { + "source": "home-assistant/core@12695a258d937f7943237d6e2cb66eed9d6d2216", + "result": "The fixed account response builders expose names and no supported avatar field; explicit unavailable remains appropriate.", + "scope": "Pinned upstream source inspection, not installed owner HA/runtime acceptance." + }, + "copilotRecovery": { + "report": "https://github.com/vypdev/copilot/issues/763#issuecomment-6588275756", + "result": "Three response-size failures are retained separately; explicit same-capability companion recovery then published natively. This does not establish an upstream fix." + }, + "localEvidence": { + "root": "/private/tmp/domus-admin-own-alias-dfb488a", + "privateLocalOnly": true, + "previousCompactEvidence": { + "path": "/private/tmp/domus-admin-runtime-185caef/verification-evidence.tar.gz", + "sha256": "c39e1e6b656847853bceaca1b0c21f6e0d1ea8ddeeb5f8bd52df12267aab30b5" + }, + "scope": "Original6backend/4frontend permission failures, distinct local/hosted CDP limitations with original traces/screenshots, complete current coverage/source/wheel and actual55browser checks. No owner HA used." + }, + "pending": "Continue full retained HADM72/HADI8/HADW82/24browser acceptance mapping, remaining boundary journeys, human UX and compatible owner adoption. Current keyboard/profile continuation cases are locally exercised; new current-head CI/CPU/native gates remain required. Recurrence23 and all-domain/Stable/Edge product work remain unfinished.", + "goalComplete": false, + "acceptance": "Current1223Chromium unit executions,71compiled Python/SQLite and25built catalog cases do not substitute for complete owning-case acceptance or owner HA adoption.", + "recordedAtUTC": "2026-11-02T23:06:09.802178+00:00", + "backend": { + "executions": 4374, + "authoredModules": 151, + "coverage": { + "covered_lines": 10438, + "num_statements": 10438, + "percent_covered": 100.0, + "percent_covered_display": "100.00", + "missing_lines": 0, + "excluded_lines": 311, + "percent_statements_covered": 100.0, + "percent_statements_covered_display": "100.00", + "num_branches": 2928, + "num_partial_branches": 0, + "covered_branches": 2928, + "missing_branches": 0, + "percent_branches_covered": 100.0, + "percent_branches_covered_display": "100.00" + }, + "independentGlobalAndPerModuleAudit": "passed" + }, + "testPreparationNotes": [ + "The initial C13 expected an uncertain outcome from a rejected original GET; the existing controller contract returns no receipt and an invalid-response notice. Corrected tests then reproduced the genuine notice-loss bug.", + "A private evidence writer initially used a nonexistent catalog docs key. It was corrected to the actual documentation key before publication; no production or SDK relaxation.", + "Initial new own-alias test titles incorrectly used browser-C15, whose owning row is hostile-text/closed-intent security. Titles now name HADW-01 directly; no new case or full C15 acceptance is claimed from own-alias controls." + ], + "scopedCorrection": { + "baseline": "a72103968dd785252ea5f9cf19e81463c0f44085", + "source": "Published185caef correction before runtime composition; its scoped proof is retained as historical evidence.", + "types": "runtime and catalog passed", + "coverageAudit": "all29authoredruntimefiles and all4metrics100%", + "artifact": "At the prior correction the still-unused controllers did not change the compiled entry bundle. The current runtime composition now has its independently verified new wheel/UI identity below." + }, + "publishedBaselineReview": { + "head": "dfb488acf984b4ec3ab76df36007b8bd0f19be5c", + "run": 41539766059, + "job": 125600639709, + "context": "complete", + "partitions": "4/4", + "candidateFindings": 0, + "publishedFindings": 0, + "outcome": "no-findings", + "currentCorrectionReview": "pending normal commit/push" + }, + "publishedBaselineCI": { + "head": "dfb488acf984b4ec3ab76df36007b8bd0f19be5c", + "run": 41539765726, + "result": "failure in frontend job125600638142: one new standalone administration-read assertion could not read its Chromium CDP response body. Backend125600638028, actual amd64125600637895, actual aarch64125600637957 and remaining product suites passed.", + "originalFrontendArtifact": { + "id": 13187040747, + "name": "frontend-application-523a050e8395d4cb5b52d5a628fc39b78d09d648", + "bytes": 1131439, + "local": "hosted-dfb-frontend", + "scope": "Original failing screenshot/trace/results retained; generated duplicate coverage/UI/wheel need not remain after unique evidence is compactly retained." + }, + "currentCorrectionCI": "pending normal commit/push; no merge with the product failure" + }, + "currentRuntimeScope": { + "sourceBase": "dfb488acf984b4ec3ab76df36007b8bd0f19be5c", + "state": "Current own-alias and protected response-capture correction over the published runtime; not yet committed", + "authoredRuntimeFiles": 29, + "administrationViews": "Runtime and independently bound own-presentation facts are implemented; complete visible administration forms/page intents remain pending." + }, + "copilotProgressRepeat": { + "report": "https://github.com/vypdev/copilot/issues/828#issuecomment-6590653046", + "run": 41539755883, + "job": 125600604587, + "verificationCheck": 125601504459, + "head": "dfb488acf984b4ec3ab76df36007b8bd0f19be5c", + "reason": "scope-clause-limit; field $.lines; requirement none; source L240", + "reference": "77635ac9-bf5e-4b50-89ae-0abd3d0adbbe", + "scope": "Separate unchanged-Copilot progress rejection; same-head PR review4/4 has no findings. The unrelated product CI body-capture failure remains a real merge blocker until current CI passes." + }, + "visibleWorkspaceIncrement": { + "sourceBase": "86e120ac3254a7b4b91d3d4b817f5234a0916f51", + "state": "Working-source visible workspace on the same issue27/ref; not complete feature acceptance.", + "frontend": { + "executions": 1223, + "authoredRuntimeFiles": 31, + "nonemptyFiles": 29, + "typeOnlyFiles": 2, + "statements": 2381, + "branches": 1886, + "functions": 504, + "lines": 1577, + "allFourMetricsGlobalAndPerFilePercent": 100, + "independentInventoryAudit": "passed; all31authoredruntimefiles and all4metrics checked independently" + }, + "compiledBrowser": { + "executions": 71, + "administrationJourneys": 16, + "existingEntryJourneys": 55, + "deployments": [ + "native", + "standalone" + ], + "backend": "Actual Python/SQLite with fictional loopback HA; no owner HA used" + }, + "catalog": { + "executions": 25, + "newAdministrationCases": 7, + "existingEntryCases": 18, + "scope": "Actual rebuilt Storybook; both locales/deployments, keyboard/focus, inert text,360px/themes/reduced-motion/contrast. Stories emit intents only." + }, + "testsFirst": [ + "Seven reproduced current-read-loss draft disclosure cases", + "One misleading preflight submission-status failure", + "One absent participant target/reference review failure", + "Two actual old-bundle missing-workspace failures before rebuilding" + ], + "distinctPrototypeFailures": "Catalog label selector matched both a region and its textbox. Initial integration expectations confused HA ALL automatic enrollment with pending membership, wire integer with canonical journal string, and preflight conflict with a server rejection. Original logs/traces retained separately; no backend or Copilot fault inferred.", + "package": { + "wheelSHA256": "3c78a393ebb39664a2a5b73a41b2ddbc1a63d2205a42101d276e086d20cd5695", + "backendInventorySHA256": "ce4601f050a56a4bf4e22c5ac6e255fb1c7a9954c7a33d6bc3f0c261834d60b5", + "frontendInventorySHA256": "6ad650beec51c7c2f481a99b50e12b79e48167e169885a5a35aa129a1a6074da", + "compiledUI": { + ".vite/manifest.json": "97e3e0600974d5ea6eb617be7eed3c377d0fadba49295935c17371aeaabf677e", + "assets/index-BsSG5cPr.js": "8d94d5b5085ae82f814eb174ea202356742ae1902cf54f076881c11b93b95058", + "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", + "index.html": "03463192d0e7fb63316fcb577471de5b3eaddf3f667623f35b2cd8f36d1a55f1" + }, + "all151PythonModulesAnd4UIFilesByteVerified": true, + "backendUnchangedFromPublished86": true + }, + "localEvidence": { + "root": "/private/tmp/domus-admin-forms-86e120a", + "privateLocalOnly": true + }, + "review": "Published3da native PR review: complete context,8/8partitions,zero candidate/published/all-state findings. Follow-up consumer inventory correction requires its own current-head CI/CPU/native review; full feature acceptance remains pending.", + "checks": { + "strictRuntimeAndCatalogTypes": "passed", + "architectureAssertions": 51, + "wheelAcceptance": "passed", + "nodePolicyTests": 38, + "nodeSupportedCoveragePercent": 100, + "documentation": "passed", + "originalSDDPolicy": "seven original SDK modules byte-verified; format/consistency/catalog passed;20owner documents unchanged" + }, + "consumerCatalogRegistrationCorrection": { + "head": "3da7e98a32c5c6ca7d2a9ba2348cfd2b05a3bdb7", + "run": 41604593960, + "job": 125811179615, + "original": "147passed1failed: new administration Storybook file absent from the explicit tested catalog inventory. Reproduced locally before correction.", + "change": "Register only frontend/stories/administration.stories.ts alongside the existing entry story. Actual25-case built catalog and strict runtime/catalog type gates already cover it. No coverage prefix/exclusion/threshold changes.", + "confirmed": "148Python tooling tests;214statements/98branches100%;Ruff passes" + }, + "nativePublished3da": { + "run": 41604593997, + "job": 125811180905, + "head": "3da7e98a32c5c6ca7d2a9ba2348cfd2b05a3bdb7", + "context": "complete", + "partitions": "8/8", + "candidateFindings": 0, + "publishedFindings": 0, + "outcome": "no-findings" + }, + "copilotProgressPublished3da": { + "run": 41604586557, + "job": 125811154753, + "scopeFailure": "scope-clause-limit;$.lines;requirement none;L140", + "reference": "0c02fe42-908d-49b2-8c0b-fd5b99203305", + "report": "https://github.com/vypdev/copilot/issues/828#issuecomment-6599193241", + "scope": "Separate progress-contract failure; no Copilot change or inferred candidate/root cause." + } + }, + "accessibilityIncrement": { + "sourceBase": "e7d090ba6bc4a0968b6813eef80d78575bcb424a", + "testsFirst": "Three original component failures: alias-error association/field focus, missing access-approval focus, and back-to-edit preference focus. Original log/screenshots retained locally.", + "implemented": "Stored first editor-control ID; current error descriptions linked to text/select/checkbox/rule/profile controls; validation focuses a relevant initial control or required approval; back restores the first field without resetting values.", + "frontend": { + "executions": 1226, + "authoredRuntimeFiles": 31, + "staticCSSFiles": 1, + "metrics": { + "lines": { + "total": 1577, + "covered": 1577, + "skipped": 0, + "pct": 100 + }, + "statements": { + "total": 2382, + "covered": 2382, + "skipped": 0, + "pct": 100 + }, + "functions": { + "total": 504, + "covered": 504, + "skipped": 0, + "pct": 100 + }, + "branches": { + "total": 1898, + "covered": 1898, + "skipped": 0, + "pct": 100 + }, + "branchesTrue": { + "total": 0, + "covered": 0, + "skipped": 0, + "pct": 100 + } + }, + "independentInventory": "passed" + }, + "compiledBrowser": { + "wholeCampaign": 71, + "additionalTargetedCases": 2, + "scope": "Whole71nowincludes actual pending-to-active-to-later-profile/permission change in both deployments. Two additional compiled EN/ES/lightdark/reduced-motion/360px/native-and-standalone keyboard cases force Lit CSP fallback, inspect actual style nonces and unchanged SQLite counts. Distinct cases; not73executions in one local whole campaign." + }, + "catalog": { + "wholeCampaign": 26, + "newKeyboardCase": "Both languages: validation association, field focus, review/back and retained values." + }, + "package": { + "wheelSHA256": "ae92de7841edf0d10dd2448fa6edc2594293a999b77c6f7884acaeb4cb0a7a8c", + "backendInventorySHA256": "ce4601f050a56a4bf4e22c5ac6e255fb1c7a9954c7a33d6bc3f0c261834d60b5", + "frontendInventorySHA256": "0d5bea106d93584bcf69350be7ea97e0a147eb7787c364cc2e8ca6a23c2ae443", + "compiledUI": { + ".vite/manifest.json": "3e75c5d46ea62c74e675972b07e138ece1fedf60e3ff390b9e3dfc09ecb439cd", + "assets/index-BpZvnMgV.js": "97ae3298f6baf5240da7cfabb96ee373370155285dd181281f6c26516ebdc895", + "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", + "index.html": "d0e16a3cb9640fcf1f78969fbb1211d52e2017a8eac65bf2654d04adf77e0b8a" + }, + "all151PythonModulesAnd4UIResourcesVerified": true + }, + "scope": "Local proof of these explicit cases, not complete HADM72/HADI8/HADW82/24browser acceptance or human review. New current-head hosted gates pending.", + "localEvidence": { + "root": "/private/tmp/domus-admin-accessibility-e7d090b", + "privateLocalOnly": true + }, + "hostedB9": { + "head": "b9a2dfc8179128fb493ab49666c24967d308c979", + "frontendJob": 125829637535, + "run": 41610125553, + "frontend": 1226, + "combinedCompiledCases": 73, + "catalog": 26, + "native": { + "run": 41610125444, + "job": 125829638176, + "context": "complete", + "partitions": "8/8", + "candidateFindings": 1, + "publishedFindings": 1, + "scope": "One matcher-API premise independently disproved; not a no-findings review." + } + } + }, + "playwrightReviewClarification": { + "head": "b9a2dfc8179128fb493ab49666c24967d308c979", + "finding": "https://github.com/vypdev/domus/pull/28#discussion_r4452825495", + "report": "https://github.com/vypdev/copilot/issues/867", + "premise": "Reviewer says pinned toBeEnabled ignores enabled:false. Installed1.64.1 declaration and matcher implementation explicitly support/select the disabled state.", + "independentProbe": "One separate Chromium dependency contract case passes both correct states and rejects both mismatches; not a product journey.", + "primaryDocumentation": "https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-enabled", + "consumerChange": "Equivalent explicit toBeEnabled()/toBeDisabled() branches improve readability. No reproduced consumer failure, assertion weakening, fixture substitution, threshold change or Copilot edit.", + "currentGate": "New-head native verification of finding state remains required." + }, + "boundaryIncrement": { + "sourceBase": "87d726fa5a6a8cbcb56e0b911d15cc5b8a96a470", + "testsFirst": { + "focus": "Four Chromium failures (61passed/4failed): readonly back/cancel/close and hidden-editor close all lose focus. A fifth refresh-retirement safety case already passes.", + "fixtureCorrection": "The first real-writer campaign passes4/fails2 because a second HA account has not explicitly requested membership. Add that real authorized join step; no production code changed for the test precondition.", + "packageDeclaration": "A new compiled bundle correctly fails the stale previous resource inventory. Update only the current candidate declaration, retaining historical evidence." + }, + "implemented": "Disabled or missing transition targets fall back to the current enabled refresh control; if the view is retired/checking and no control exists, no removed/private field is focused. Draft values and authority controls remain unchanged.", + "frontend": { + "executions": 1231, + "authoredRuntimeFiles": 31, + "metrics": { + "lines": { + "total": 1580, + "covered": 1580, + "skipped": 0, + "pct": 100 + }, + "statements": { + "total": 2384, + "covered": 2384, + "skipped": 0, + "pct": 100 + }, + "functions": { + "total": 504, + "covered": 504, + "skipped": 0, + "pct": 100 + }, + "branches": { + "total": 1902, + "covered": 1902, + "skipped": 0, + "pct": 100 + }, + "branchesTrue": { + "total": 0, + "covered": 0, + "skipped": 0, + "pct": 100 + } + }, + "independentInventory": "passed" + }, + "compiledBrowser": { + "wholeCampaign": 81, + "newCases": 8, + "deployments": [ + "native", + "standalone" + ], + "providers": "Fictional loopback HA/Supervisor with actual product Python/SQLite; not owner HA adoption." + }, + "traceability": [ + { + "case": "household-workspace-C78", + "evidence": "frontend/tests/e2e/administration.spec.ts", + "assertions": "Both deployments read genuine fixture SQLite revision9007199254740993, send the exact unquoted HTTPinteger once, persist9007199254740994, retain original string/key in journal and reload without resend. Fixture setup is not a2^53operation history claim." + }, + { + "case": "household-browser-C22", + "evidence": "frontend/tests/e2e/administration.spec.ts", + "assertions": "Four independent-browser cases (same/different verified HAaccounts in both deployments). Both original revision1 PUTs wait at a barrier. One SQLite effect; loser409revision_conflict or bounded503storage_busy; committed response is lost. Reload keeps exact winner key/payload; explicit originalGET recovers committed without another mutation/audit/outbox. No automatic retries or result queries." + }, + { + "case": "household-workspace-C50", + "evidence": [ + "frontend/tests/administration-component.test.ts", + "frontend/tests/e2e/administration.spec.ts", + "frontend/tests/e2e/catalog.spec.ts" + ], + "assertions": "Visible focus through entry/validation/review/back/cancel/close. Four reproduced readonly/missing-fact regressions plus checking guard; actual outage proof and keyboard Enter transitions in both deployments retain draft, lock writes, focus enabled refresh, cancel without PUT and return to persisted facts." + } + ], + "package": { + "sourceBase": "87d726fa5a6a8cbcb56e0b911d15cc5b8a96a470", + "sourceIdentity": "All151Pythonmodules unchanged. The administration renderer now falls back to the enabled refresh action when the original focus target disappears or becomes disabled; no authorization, journal or wire change.", + "actualProductFiles": { + ".vite/manifest.json": "135a524f75c257533cf9601bafd79896f3d4abd9e2275c4e33427c17abf9f46e", + "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", + "assets/index-DLmPl5j8.js": "162f0935b58728be3a5ae815d19fc23be99651e29c18fc831161c7199ea18800", + "index.html": "ec390d8ed7d09d9ac878c7573129d4e3e95c20f62bf83b1bf54d463f7e06aa6f" + }, + "wheelSHA256": "6f35282534510ee2dbcc3bbd28607d8d9fefdc531f2f23a1c6c26f14ef548754", + "acceptance": "151Pythonmodules/4UIresources match the actual wheel. Local1231Chromium tests,100% all4coverage metrics across31runtimefiles with independent inventory audit;81whole compiled Python/SQLite cases pass in both deployments. Current-head hosted checks/CPUimages/native review and complete owning-case/human UX/owner adoption remain required.", + "all151PythonModulesAnd4UIResourcesVerified": true + }, + "limits": "These exact cases add inspected semantic evidence; they do not close the entire186-case acceptance ledger. BFCache/tab closure/restore replay, compatible owner adoption and human UX still require their own proof.", + "localEvidence": { + "root": "/private/tmp/domus-admin-boundaries-87d726f", + "privateLocalOnly": true + }, + "hosted": "New current-head checks and complete native finding review pending." + }, + "navigationIncrement": { + "sourceBase": "e32aa071666990e1fed86e9d67be7bb264eb3287", + "productionRuntimeChanged": false, + "unchangedBaseline": { + "frontend": 1231, + "frontendJob": 125859691618, + "backend": 4374, + "backendJob": 125859691661, + "coverage": "100%all4global/perfile;31frontendruntimefiles/151Pythonmodules", + "wheelSHA256": "6f35282534510ee2dbcc3bbd28607d8d9fefdc531f2f23a1c6c26f14ef548754", + "scope": "Runtime and compiled resource hashes match the verified e32 baseline. Local unit/backend/catalog baseline is not misreported as a new execution; new-head CI will execute all suites." + }, + "compiledBrowser": { + "wholeCampaign": 87, + "newCases": 6, + "expandedRestoreCases": 2, + "skipped": 0, + "flaky": 0, + "deployments": [ + "native", + "standalone" + ], + "providers": "Fictional loopback HA/Supervisor; actual compiled product Python/SQLite and actual installation-console restore." + }, + "traceability": [ + { + "case": "household-workspace-C59", + "evidence": "frontend/tests/e2e/administration.spec.ts", + "assertions": "Actual console snapshot/prepare/activation restores state before alias write. UI originalGET reports historical/absent. An explicit raw PUT retains original key/body/context and fresh current CSRF, returns same operation/historical result, leaves alias null/revision1 and no additional receipts/audit/outbox. The UI never resends." + }, + { + "case": "household-admin-C64", + "evidence": "frontend/tests/e2e/administration.spec.ts", + "assertions": "Disposable full Chromium profile with a generated test-only sessions/tabs extension closes the real tab after the actual SQLite commit but before any PUT reply reaches it. Public sessions.getRecentlyClosed/restore reopens that tab with its own prepared journal. No injected/copied storage. Explicit originalGET recovers committed with one mutation total and unchanged effects. Owner Chrome/profile is untouched." + }, + { + "case": "household-admin-C65", + "evidence": [ + "frontend/tests/e2e/administration.spec.ts", + "frontend/tests/runtime.test.ts" + ], + "assertions": "Normal pinned browser policy demonstrably refuses BFCache for private no-store responses and performs fresh back navigation with revoked identity. A separate full Chromium instance with explicit browser-only BFCache policy performs trusted persisted pagehide/pageshow; original heap marker survives, private drafts/DOM retire, fresh proof is pending before facts return, and later rejected identity exposes no private facts. Existing runtime tests separately fence a delayed pre-cache response and preserve its prepared original without automatic GET/PUT." + } + ], + "browserPolicy": { + "normal": "Default browser security policy with Playwright BFCache-disabling switch omitted; real no-store refusal is asserted rather than mistaken for a persisted restore.", + "conditional": "Separate Chromium channel instance; --enable-features=BackForwardCache:should_ignore_blocklists/true,CacheControlNoStoreEnterBackForwardCache:level/restore-unless-cookie-change. Controlled conditional cache eligibility, not a default owner-Chrome performance claim. Product Cache-Control/CSP/auth/response bytes remain unchanged.", + "closedTab": "Separate disposable persistent Chromium profile. Generated manifest/worker are test outputs, never included in Domus or installed in owner Chrome. Only sessions/tabs permissions; no host access or journal injection." + }, + "originalDiagnostics": [ + "Default no-store exclusion retained with actual notRestoredReasons.", + "Headless shell still refuses even under conditional blocklist flag; full Chromium provides real persisted navigation.", + "Nested launchOptions is rejected by the pinned runner; dedicated browser ownership fixes that harness configuration.", + "Standalone rejected proof correctly offers sign-in; native offers reopening. First assertion assumed the native action for both; no production change for that correction." + ], + "primarySources": [ + "https://developer.chrome.com/docs/web-platform/bfcache-ccns", + "https://developer.chrome.com/docs/extensions/reference/api/sessions", + "https://playwright.dev/docs/chrome-extensions", + "https://raw.githubusercontent.com/chromium/chromium/aa94c3c2211d9caae42b2c0af767eda9857fc81b/content/browser/renderer_host/back_forward_cache_impl.cc" + ], + "localEvidence": { + "root": "/private/tmp/domus-admin-navigation-e32aa07", + "privateLocalOnly": true + }, + "remaining": "Full186-case semantic acceptance audit, image/rollback owning rows, human UX and compatible owner adoption remain separate. Recurrence-dependent obligations are retained. New-head CI/native review pending." + }, + "actualImageBrowserIncrement": { + "sourceBase": "3a2c4b33769f26eaa97bd9eeb0c1ce3a6d4fdb50", + "productionRuntimeChanged": false, + "testsFirst": "Bridge contract tests first fail to collect because the declared module does not exist. Tests then verify immutable source/CPU identity before creation, no network/published image ports, nonroot UID, exact returned-container cleanup, original bytes/headers/non-success responses, disconnect/size bounds and invalid/failing transport without a fabricated receipt.", + "localImage": { + "image_id": "sha256:59cba377ca8585178c21e2db6bbe1304661d3d4ec7530bd1e8daa1932e9fd9f1", + "source_commit": "3a2c4b33769f26eaa97bd9eeb0c1ce3a6d4fdb50", + "architecture": "aarch64", + "docker_architecture": "arm64", + "pythonFiles": 151, + "uiFiles": 4, + "allProductFilesMatch": true + }, + "browser": { + "actualImageCampaign": 10, + "actualImageArchitecture": "aarch64", + "actualImageModes": [ + "native", + "standalone" + ], + "normalComposedCampaign": 87, + "imageForms": [ + "own alias", + "own private preferences", + "household", + "HA mapping/profile", + "pending activation/later participant profile" + ], + "assertions": "Each image journey checks actual inspected image ID, source and CPU metadata before resetting its own image-hosted SQLite. Real Chromium requests traverse an isolated docker-exec HTTP bridge into the installed production modules/UI. Exact 151Python/4UI byte inventories match the reviewed current package. No source-checkout server fallback, copied user storage or product authentication bypass." + }, + "infrastructure": "Fixture-only provider replacement uses stdlib ExitStack/mock rather than shipping pytest into the image. The fixture is copied after image creation; image and installed product bytes stay unchanged. Two temporary containers use immutable image ID, networknone and UID65532 without published ports or host mounts. Loopback bridge preserves payload, original keys/context and response headers/status/body.", + "localChecks": { + "tooling": 162, + "toolingStatements": 214, + "toolingBranches": 98, + "toolingCoverage": 100, + "types": "runtime/catalog/E2E pass", + "normalBrowser": 87, + "scope": "Production1231frontend/4374backend coverage baseline remains unchanged; new-head CI will execute it independently." + }, + "CI": "Both existing CPU jobs build exact current source and run their32container checks, then install the already-pinned Node/pnpm/Chromium toolchain and execute all10actual-image form journeys. Exact expected10/unexpected0/skipped0/flaky0 receipt is mandatory.", + "limitations": "Local actual image is aarch64/base3a2 because product runtime is unchanged. Fresh exact-head amd64/aarch64 evidence remains required. Fictional HA/Supervisor providers remain test-only. This closes the image/browser composition gap; full compatible application/data/generation rollback, full owning acceptance, human UX and owner adoption are still separate.", + "localEvidence": { + "root": "/private/tmp/domus-admin-image-browser-3a2c4b3", + "privateLocalOnly": true + }, + "diagnostics": [ + "Initial build terminates because the existing keychain helper is outside PATH; prepend its installed directory without credential/config changes.", + "Initial Ruff check cannot write the restricted worktree cache; unchanged no-cache validation passes. These are environmental tool conditions, not Copilot defects." + ], + "verifiedCurrentHead": { + "source": "6c1a0d2032cef328dc6e58dd2661232f1fbd364d", + "CI": "https://github.com/vypdev/domus/actions/runs/41630471010", + "amd64Job": 125898427295, + "aarch64Job": 125898427633, + "containerCasesPerCPU": 32, + "actualImageBrowserJourneysPerCPU": 10, + "frontend": 1231, + "backend": 4374, + "tooling": 162, + "supportedCoverage": "100% globally and per authored module", + "nativeReview": "https://github.com/vypdev/domus/actions/runs/41630471059/job/125898428229", + "review": "Complete context; 9/9 partitions; zero open/candidate/published/verification-required findings. Historical false premise automatically resolved." + } + }, + "python314ReviewClarification": { + "sourceHead": "6e8c22135c019829f62e1ec28872783dc446be75", + "finding": "https://github.com/vypdev/domus/pull/28#discussion_r4454147614", + "report": "https://github.com/vypdev/copilot/issues/831#issuecomment-6602904998", + "premise": "Review says comma-separated except types without parentheses cause SyntaxError in Python3. The required interpreter is3.14; PEP758 explicitly permits this syntax without an as binding.", + "independentProbe": "Actual CPython3.14.6 compiles the entire unchanged module and independently catches all five listed exception types. This separate language contract is not counted as product acceptance.", + "observedProductEvidence": "162tooling tests import/run the module; actual local image10journeys and normal87journeys pass. Current6e8amd64 hosted job125892918209 also passes32container and10actual-image browser cases. These could not run if the import were syntactically invalid.", + "consumerChange": "Name the same immutable exception tuple as _TRANSPORT_ERRORS. Equivalent behavior and clearer reading across language versions; no reproduced consumer SyntaxError, formatter/runtime change, threshold relaxation or Copilot change.", + "primarySources": [ + "https://peps.python.org/pep-0758/", + "https://docs.python.org/3.14/whatsnew/3.14.html" + ], + "reviewState": "Current6e8 review complete9/9 publishes one open false-premise finding. Do not claim clean review or manually resolve it; a fresh native review is required for the equivalent readability change.", + "subsequentReview": { + "source": "6c1a0d2032cef328dc6e58dd2661232f1fbd364d", + "job": 125898428229, + "outcome": "Complete9/9, zero findings; historical thread automatically fixed/resolved by native review, not manually resolved." + } + }, + "compatibleApplicationRollbackIncrement": { + "sourceBase": "6c1a0d2032cef328dc6e58dd2661232f1fbd364d", + "productionRuntimeChanged": false, + "testsFirst": "New CI assertion first fails because the immutable schema9 build is missing. After correcting an independent test-fixture import error, the actual-image test fails because its declared rollback program is missing. Original diagnostics are retained separately.", + "previousSource": "87d726fa5a6a8cbcb56e0b911d15cc5b8a96a470", + "previousArchiveSHA256": "d8a9c4c1caf3acbb462ac21ec78ccbab05d756031880fd2f5013b4e528b45758", + "sourceVerification": "All232 build input files in the downloaded immutable archive match the original published Git tree. Newly built aarch64 image contains exactly151Python modules and4UI resources matching the original ae92 wheel.", + "localImage": { + "tag": "domus-administration-schema9-ci:87d726f", + "id": "sha256:8f0b4a0090cc04be0e003c9130ad549412abfe4bfc2831b68f04df48198aa405", + "source": "87d726fa5a6a8cbcb56e0b911d15cc5b8a96a470", + "architecture": "arm64", + "pythonFiles": 151, + "uiFiles": 4, + "allFilesMatchOriginalWheel": true, + "originalWheelSHA256": "ae92de7841edf0d10dd2448fa6edc2594293a999b77c6f7884acaeb4cb0a7a8c" + }, + "localCases": 4, + "modes": [ + "native", + "standalone" + ], + "originalEventStates": [ + "pending", + "acknowledged" + ], + "assertions": "Actual prior image creates owned installation/member/profile state and snapshot. Current image commits alias and independent private preferences, then actual console prepares/activates restoration. Actual prior image reopens selected generation with the same installation and original UI bytes; all8 current state/proof tables match the snapshot. All4 complete operation documents preserve namespace, fingerprint, result, time, audit/event payload, position and delivery; only current/archive provenance changes for the two later absent effects. Exact original replays return original IDs and historical status without changing database bytes, proofs or current state.", + "CI": "Both mandatory CPU jobs now build the immutable schema9 compatibility application and execute the four rollback scenarios within the full required container suite; no skips or continue-on-error. Fresh-head hosted evidence remains pending.", + "limitations": "Local actual execution is aarch64; exact-head amd64/aarch64 CI still required. Fictional verified human/providers and disposable Docker volumes; no owner HA adoption, schema downgrade, new migration or release claim. Full186-case semantic audit, required human UX and recurrence-dependent obligations remain separate.", + "localEvidence": { + "root": "/private/tmp/domus-admin-rollback-6c1a0d", + "privateLocalOnly": true + } + }, + "readinessReviewClarification": { + "sourceHead": "9988e44849a9c50a11718aecf88fddddf3e6082d", + "finding": "https://github.com/vypdev/domus/pull/28#discussion_r4454623826", + "report": "https://github.com/vypdev/copilot/issues/869", + "premise": "Detached inner launch is reported as a race despite the outer configured readiness GET forwarding to the inner server.", + "independentPrimaryProbe": { + "playwright": "1.64.1", + "coreBundleSHA256": "829521b67ed7be90c7d6d6098ff39f2241562d47e4ad7ccab13710924a56001d", + "observations": [ + { + "status": 502, + "ready": false + }, + { + "status": 503, + "ready": false + }, + { + "status": 200, + "ready": true + } + ], + "scope": "Real pinned isURLAvailable helper and disposable loopback HTTP server; not product acceptance." + }, + "consumerRegression": "Actual ASGI bridge contract: local metadata200 performs no proxy call; unavailable inner transport produces502; inner503/200 preserve exact body/status. All3readiness exchanges are GET /__fixture__/ready with empty body. Metadata cannot fabricate readiness.", + "consumerChange": "Regression and explanatory comments only; existing forwarding, timeout and readiness behavior unchanged. No redundant second internal wait or acceptance relaxation.", + "localCases": 14, + "currentReview": "9988native completed9/9 and published1false-premise finding; still open, no manual resolution. Fresh native review required for this clarification.", + "subsequentReview": { + "source": "f5715f4b5a1a77efa3d6cf5bda47089ca9d50bd4", + "job": 125920283653, + "context": "complete", + "analysisPartitions": 10, + "completedPartitions": 10, + "candidateFindings": 0, + "publishedFindings": 0, + "openFindings": 0, + "historicalResolution": "obsolete; automatically resolved by vypbot, no manual resolution", + "report": "https://github.com/vypdev/copilot/issues/869" + } + }, + "imageFixtureShutdownIncrement": { + "sourceBase": "f5715f4b5a1a77efa3d6cf5bda47089ca9d50bd4", + "productionRuntimeChanged": false, + "observation": "Two owned disposable containers survived the prior completed local campaign with no host fixture process/listener. Actual2journey reproduction passes but leaves2containers.", + "testsFirst": "ASGI lifecycle assertion fails because shutdown completes with its container present. CI contract fails because survivor gate is missing. Original process/container identity and all teardown observations are retained.", + "diagnosis": "Pinned Playwright1.64.1 defaults to forceful SIGKILL when gracefulShutdown is unspecified. Its shipped declarations and runner implementation confirm this. An intermediate ASGI-only fix passes10journeys but still leaves2containers; it is retained as a failed cleanup attempt.", + "fix": "Use an exec-root server command and bounded SIGTERM5second shutdown; close exact owned container during ASGI lifespan.shutdown before reporting completion. Outer finally remains idempotent. Startup timeout30seconds, global timeout180seconds, retries0, current product code and owner HA are unchanged.", + "localActualImageJourneys": 10, + "remainingFixtureContainers": 0, + "skipped": 0, + "flaky": 0, + "toolingTests": 165, + "toolingCoverage": "214statements/98branches,100%", + "CI": "Both required CPU jobs assert docker ps --all --quiet --filter label=domus.browser-fixture=true is empty after all10actual-image journeys. A survivor fails application CI.", + "limitations": "Verified graceful Linux/macOS runner teardown. User-space cleanup cannot execute after arbitrary SIGKILL or host/daemon loss. Fresh exact-head hosted CI/native review remains required.", + "localEvidence": { + "root": "/private/tmp/domus-admin-rollback-6c1a0d", + "privateLocalOnly": true + } + }, + "persistentImageTransportIncrement": { + "sourceBase": "8d8185d4951f2abdebc5a8b0e4964528a86fcf0a", + "productionRuntimeChanged": false, + "observedFailure": { + "job": 125926080155, + "architecture": "amd64", + "containerCasesPassed": 36, + "browserPassed": 9, + "browserDidNotRun": 1, + "reason": "Existing180second global browser timeout exhausted; failed scope is not called accepted." + }, + "testsFirst": "Concurrent bridge integration test first rejects unsupported channel_factory. Response-identity regression first fails because the unframed channel accepts a wrong frame as the current result. Tests then enforce independent per-thread channels, exact large/concurrent payloads, one process reused, bounded read/write/queue failures, strict frame identities, incomplete/oversized/coalesced unsolicited output and exact idempotent owned-process retirement.", + "implementation": "Each host transport thread owns one bounded stdio worker in the same verified isolated image. Every frame carries a checked positive sequence and makes one fresh inner HTTP connection, preserving original method/path/query/header/body/status and duplicate cookies. No automatic request replay, worker restart or success fabrication. Actual concurrency across actors is retained.", + "limits": "HTTP15second read timeout, host30second frame deadline,2MiB HTTP body bound and180second suite limit are preserved. New16MiB frame bound includes encoded body/headers; malformed/foreign/truncated frame fails closed. Test-only modules are not installed into the product image.", + "microbenchmark": { + "before": { + "source": "8d8185d4951f2abdebc5a8b0e4964528a86fcf0a", + "mode": "native", + "architecture": "aarch64", + "requests": 40, + "seconds": 5.015619208978023, + "dockerProcesses": 40, + "scope": "Actual loopback read-only fixture readiness microbenchmark, not full product acceptance." + }, + "after": { + "requests": 40, + "seconds": 0.023703958024270833, + "workers": 1, + "workerPIDs": [ + 93638 + ], + "transportSHA256": { + "tests/browser/image_bridge.py": "b10ff0676b6ca0b4dedde75181e0152106e5dd27431f67d162cb51e02b262262", + "tests/browser/image_channel.py": "e5e1b40f02286a4a76d7ec7a3b54befc9b0773c9d15db3be241a60e7c78c2117" + }, + "scope": "Actual read-only readiness microbenchmark using current framed transport. Not full product latency or acceptance." + }, + "limitations": "Same40read-only fixture readiness requests on localaarch64. This measures transport overhead, not product latency or exact-head hosted acceptance." + }, + "localActualImageAdministration": { + "cases": 32, + "stats": { + "startTime": "2026-11-03T13:22:02.328Z", + "duration": 50992.67, + "expected": 32, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "remainingContainers": 0, + "architecture": "aarch64", + "imageSource": "3a2c4b33769f26eaa97bd9eeb0c1ce3a6d4fdb50", + "scenarios": "All16administration cases in each native/standalone composition, including real high-revision wire/SQLite state, independent-writer race/lost reply, keyboard/CSP, actual trusted BFCache, public sessions-API closed-tab restoration and actual console restore/original replay fence." + }, + "tooling": { + "tests": 178, + "statements": 214, + "branches": 98, + "coverage": 100 + }, + "CI": "Both mandatory CPU jobs now run the complete administration.spec.ts without --grep and require expected32/unexpected0/skipped0/flaky0 plus zero surviving fixture containers. The global timeout is unchanged. Fresh-head hosted CI and native review remain required.", + "SDD": "OriginalSDK7modules byte-verified; unchanged policy accepts format/consistency/catalog and all186retained cases with the2new test-infrastructure paths declared first. No owner case or threshold removed.", + "localEvidence": { + "root": "/private/tmp/domus-admin-rollback-6c1a0d", + "privateLocalOnly": true + } + }, + "verifiedHouseholdDraftScopeIncrement": { + "sourceBase": "58957fc8b9da4ce85f0acbd75793f7351026c47a", + "finding": "https://github.com/vypdev/domus/pull/28#discussion_r4455477782", + "classification": "Reproduced valid consumer finding; no upstream defect claim or manual resolution.", + "testsFirst": "All8new source-Chromium regressions fail on the prior renderer. Six form kinds retain former-household drafts despite matching installation/channel/subject/member/revisions. Detached confirmed household and preference controls emit old intents after household change or missing proof. Original8failure PNGs and logs are retained.", + "fix": "Private draft ownership and readiness require the verified household routing ID in addition to installation/channel/HA subject/member. Different or missing proof retires drafts and detached callbacks. Pending verification with no published view still preserves a same-owner draft for fresh matching proof; ordinary same-household translation/read-fact loss/revision updates do not rebase it.", + "localVerification": { + "frontend": 1239, + "supportedFrontendCoverage": "100% all4metrics globally and per31authored runtime files, independently audited", + "wholeCompiledPythonSQLite": 87, + "builtCatalog": 27, + "newCompiledScenario": "Both languages prove a detached confirmed save cannot emit the former household intent after a verified household switch, and explicit reopening uses current facts.", + "types": "Runtime/catalog pass", + "package": { + "wheelSHA256": "6578a595350e00b0df2fb7e12a436a100a6cf8a661ad4147a5fc074d6de05351", + "PythonModules": 151, + "UIFiles": { + "index.html": "fb4f250c231b0f0b07fd4d99fa00925610f0573b01f033a4bf75178f860887fd", + "assets/index-CS8EClQ3.js": "c3103fcc8d107cff163ab5a98c8a587ef5203367624dd1f23fea56b96227dedd", + "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", + ".vite/manifest.json": "07f6f6a7f49b0c8691b16dd3e687e17698b4e540e6f3cd2577fcfc187514f159" + }, + "allSourceAndWheelBytesMatch": true, + "fixtureExcluded": true, + "acceptance": "Local candidate; pending composed/catalog/hosted/native verification" + }, + "synchronousWindow": "Former confirmation is inert both immediately after current proof changes, before Lit repaints, and after the old DOM is removed. Source and compiled-catalog assertions cover both moments." + }, + "artifactDiagnostics": [ + "First compiled-catalog probe cannot run because no prebuilt catalog exists; its404/startup failure is an environment observation, not a product regression. Freshly built27case catalog subsequently passes.", + "Current packaging guard correctly rejects the old UI declaration after rebuild. All151Python/4UI wheel bytes and fixture exclusion are checked independently before updating currentSourcePackage; historical entry/Edge.5 declarations remain unchanged." + ], + "rollback": { + "SHA256": "6f35282534510ee2dbcc3bbd28607d8d9fefdc531f2f23a1c6c26f14ef548754", + "state": "Prior verified package copied and byte-checked before rebuild. Older ae92 rollback retained until candidate verification.", + "limitation": "Prior package contains the pre-fix presentation scope behavior; it is an emergency binary/data compatibility reference, not an owner HA promotion." + }, + "unchanged": "151Python modules, schema9/history, all journal formats, original request/key/payload semantics, gateway and Copilot source/resources/config/workflows/credentials/bounds. Owner HA untouched.", + "pending": "Exact published candidate image and both hosted CPU jobs, current native review and full owning acceptance remain required. Existing186owner cases and required human UX are not inferred from unit counts.", + "localEvidence": { + "root": "/private/tmp/domus-admin-rollback-6c1a0d", + "privateLocalOnly": true + } + }, + "confirmationSummaryContractClarification": { + "sourceBase": "9ba10d7e559538fd4a05b6d45c03c418077b472a", + "finding": "https://github.com/vypdev/domus/pull/28#discussion_r4456137778", + "report": "https://github.com/vypdev/copilot/issues/872", + "classification": "Completed native review uses the edit-state premise at a retained confirmation-summary assertion. Both same-head deployment cases pass; no input exists until explicit reset.", + "clarification": "Before reset require no household-name textbox and the exact original value in dl dd. After reset require the old summary absent and the existing textbox value assertion for current facts. Original value/effect checks, timeout180seconds and retries0 are retained.", + "localVerification": { + "targetedSourceCases": 2, + "actualImageAdministrationCases": 32, + "actualImageAdministrationSeconds": 50.0, + "actualImageContainerCases": 10, + "pairedRollbackCasesIncluded": 4, + "imageSource": "9ba10d7e559538fd4a05b6d45c03c418077b472a", + "imageId": "sha256:144475004a62bed76c2bc0752688638aa7a8b3527ba2d77f8a530b187f838ceb", + "architecture": "aarch64" + }, + "unchanged": "No product runtime/UI/package change, no Copilot code/resources/configuration/workflows/models/credentials/bounds change and no manual thread resolution.", + "limitation": "Local actual-image execution is distinct from fresh-head hosted/native verification and remaining whole-feature acceptance." + }, + "maximumUnicodeRosterAcceptance": { + "sourceBase": "76e20ba77a39d0cec0ee838dffc6806bae11ea92", + "retainedCase": "household-admin-C57", + "testsFirst": { + "cases": 8, + "seconds": 9.498535, + "result": "All pass against the existing compiled renderer before the new story is introduced. No product layout correction was required. Original test source, raw log, statistics and four PNGs are retained." + }, + "story": "domus-household-administration--maximum-labels", + "fixture": { + "participants": 256, + "HANameCodePoints": 256, + "HANameUTF8Bytes": 1024, + "aliasRows": 128, + "aliasCodePoints": 80, + "aliasUTF8Bytes": 320, + "fallbackRows": 128, + "identity": "Independent fictional HA/member IDs; aliases never select identity." + }, + "journeys": { + "cases": 8, + "locales": [ + "en", + "es" + ], + "deploymentPresentations": [ + "native", + "standalone" + ], + "themes": [ + "light", + "dark" + ], + "viewport": { + "width": 360, + "height": 900 + }, + "reducedMotion": true + }, + "assertions": [ + "Every rendered row and text-line rectangle remains within horizontal bounds; status and all 511 available actions remain visible, unclipped and usable. Document width never exceeds 360px.", + "The final member retains its complete maximal alias, HA name, stored restricted/active status, enabled management and alias actions. Management shows the exact membership reference and Cancel restores focus.", + "The maximal alias remains editable. Reversing all 256 rows preserves the selected membership and unsaved 80-code-point alias. Review shows that same reference and Save emits exactly one original revision-11 alias intent for it.", + "No foreign requests, browser errors or actual mutation requests occur. These assertions inspect the actual built component; they do not claim a server write or live HA directory." + ], + "localVerification": { + "builtCatalogStats": { + "startTime": "2026-11-03T15:29:26.610Z", + "duration": 22287.249, + "expected": 36, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "catalogCases": 36, + "runtimeAndCatalogTypes": "PASS", + "originalSDK": "3e7ceea08e6131360e026937d1c0cc1b120472e0; 7 original policy modules byte-verified; current companion format/consistency/catalog PASS against its original owning baseline. No SDK or owning cases changed." + }, + "visualInspection": { + "reviewer": "Codex (agent), not a human reviewer", + "artifacts": [ + "unicode-roster-light.png", + "unicode-roster-dark.png", + "unicode-selected-light.png", + "unicode-selected-dark.png" + ], + "observed": "Full alias/provider name wrap; stored status, both roster actions and selected confirmation/reference remain readable. No UI change was needed.", + "limitation": "Required human UX review remains unfulfilled by this agent inspection." + }, + "sourceHashes": { + "frontend/stories/administration.stories.ts": "d2920eb975b88a0a498b4a6e3100ecdb5ace1c32232571082e6744babfd154c2", + "frontend/tests/e2e/catalog.spec.ts": "286c71b8cc33978a402ff875d787e61a4fddcf0f22769d6ad0a737c0be0c0dda" + }, + "productionRuntimeChanged": false, + "package": { + "SHA256": "6578a595350e00b0df2fb7e12a436a100a6cf8a661ad4147a5fc074d6de05351", + "state": "Verified current package remains byte-identical; 151 Python/four UI payload and all runtime/build inputs are unchanged. No housekeeping rebuild." + }, + "coverageBasis": "Current hosted 76e proves 1,239 frontend executions and 100% on all four metrics globally/per 31 runtime files. No authored runtime file changes in this increment; fresh-head CI/native verification remains required.", + "remaining": "All 186 owning cases remain required; this acceptance closes only the stated rendered C57 gap. Full semantic audit, required human UX, compatible owner adoption and the complete product remain in progress.", + "localEvidence": { + "root": "/private/tmp/domus-admin-unicode-76e20ba", + "privateLocalOnly": true + } + }, + "fencedMockupClarification": { + "sourceBase": "2155e4b58c46a65bd31c5994eef0fb67c0a904fd", + "finding": "https://github.com/vypdev/domus/pull/28#discussion_r4456858943", + "upstreamReport": "https://github.com/vypdev/copilot/issues/873", + "classification": "False live-navigation premise for literal fenced Markdown source. Actual GitHub GFM rendering and an unfenced positive control disprove active broken links.", + "nativeReview": { + "run": 41665073143, + "job": 126018092821, + "context": "complete", + "partitions": "11/11", + "candidateFindings": 1, + "publishedFindings": 1, + "openFindings": 1 + }, + "counterevidence": { + "originalFragmentAnchors": 0, + "literalCodeStringsRetained": true, + "positiveControlFragmentAnchors": 2 + }, + "clarification": "Explain that the fenced mockup is literal source and illustrate the actual Edit household / Edit preferences button labels. No arbitrary document-anchor destinations or product navigation changes.", + "unchanged": "All 186 owning cases, original SDK, Copilot source/resources/configuration/workflows/models/credentials/bounds, product runtime/package, timeouts, retries and coverage thresholds. No manual thread resolution.", + "localEvidence": { + "root": "/private/tmp/domus-admin-unicode-76e20ba", + "privateLocalOnly": true + }, + "limitation": "Fresh-head native review and product CI remain required; this is not full product/human-UX acceptance." + }, + "edgeSixDeliveryIncrement": { + "status": "Locally verified immutable installation candidate; fresh published-head CI/native review and actual owner adoption remain required.", + "recordedAtUTC": "2026-11-03T17:11:55.977Z", + "definition": "addon-edge/config.yaml and addon-edge/Dockerfile", + "artifact": "addon-edge/artifact.json", + "sourceCommit": "3200bd9278f57f142ed03b54843a38a4138fc0af", + "sourceArchiveSHA256": "ca991d7b3560dbdb7a6a86a34eea19708f01f10c15da502e49ed8ab740a8533b", + "archiveVerification": { + "trackedGitBlobsAndModes": 585, + "allMatched": true, + "hostExtraction": false + }, + "version": "0.0.0-edge.6", + "storageSchema": 9, + "frozenCompatibility": { + "path": "tests/fixtures/edge5-app/Dockerfile", + "SHA256": "aea2d7bc4d135e49827508f011bc1fadbd5f0d3f93b47538e07ed56652b5e28d", + "sourceCommit": "271a3a07feb1535f0a5ec8b55abb3de9aac049a9", + "version": "0.0.0-edge.5", + "storageSchema": 7, + "historicalEvidenceRebound": false, + "discoverableSupervisorConfig": false + }, + "testsFirst": { + "red": 5, + "otherCasesPassed": 42, + "thenFocusedGreen": 204, + "reproduced": [ + "old version/source pin", + "absent frozen compatibility recipe", + "absent artifact declaration", + "old CI build context" + ] + }, + "actualLocalImage": { + "architecture": "arm64", + "HAArchitecture": "aarch64", + "imageID": "sha256:7d8b528a51f7c2f7873d636a420391522620b560f8816323d54dab53c1b2f4b1", + "productFileCheck": { + "pythonFiles": 151, + "uiFiles": 4, + "allDeclaredSHA256Matched": true, + "readAsNonroot": true + }, + "containerCases": 4, + "containerCoverage": [ + "all installed product files", + "native authority/listener/restart", + "same-listener standalone UI", + "actual Edge.5 upgrade/replay/restoration and schema9 downgrade refusal" + ], + "browserSuite": "frontend/tests/e2e/administration.spec.ts", + "survivingOwnedBrowserFixtures": 0, + "testHAProvider": "Fictional provider copied only into disposable containers after image build; no owner HA calls.", + "browserStats": { + "startTime": "2026-11-03T17:03:07.965Z", + "duration": 54002.547999999995, + "expected": 32, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + } + }, + "permanentC25BoundaryCases": { + "backend": 19, + "Chromium": 19, + "exactAliasCodePoints": 80, + "rejectedCodePoints": 81, + "alphabet": "Astral Unicode with each of the 19 accepted non-control whitespace variants.", + "paths": [ + "tests/backend/test_member_profiles.py", + "frontend/tests/administration.test.ts" + ] + }, + "completeLocalVerification": { + "backendTests": 4395, + "backendRuntimeFiles": 151, + "backendStatements": 10438, + "backendBranches": 2928, + "frontendTests": 1258, + "frontendRuntimeFiles": 31, + "frontendStatements": 2384, + "frontendBranches": 1903, + "frontendFunctions": 504, + "frontendLines": 1580, + "toolingTests": 178, + "toolingStatements": 214, + "toolingBranches": 98, + "coverage": "100% globally and per runtime file in all supported metrics; independent source inventories pass.", + "nodePolicyTests": 38, + "pythonArchitectureExecutions": 51, + "ruff": "PASS", + "strictTypes": "PASS", + "documentationLinks": "PASS", + "originalSDDPolicyModulesByteVerified": 7, + "originalSDDSDK": "3e7ceea08e6131360e026937d1c0cc1b120472e0", + "owningDocumentsRetained": 20, + "requiredCasesRetained": 186 + }, + "ownerObservation": { + "method": "Fresh Chrome native UI through Home Assistant; no console code or credential extraction.", + "installedCurrent": "0.0.0-edge.5, running", + "installedRollback": "0.0.0-edge.3, stopped", + "Ingress": "Current identity verified; no configured household.", + "householdMutations": 0, + "applicationUpdates": 0, + "scope": "Observation only; does not establish Edge.6 adoption, owner data migration, live administration or human UX approval." + }, + "limitations": [ + "Both hosted CPU gates must run on the new definition commit.", + "The wheel SHA256 is the reviewed source-wheel reference; image acceptance independently checks all installed product files.", + "Current source/runtime and installation-definition identities are separate.", + "Owner backup/update/rollback and required human UX are separate acceptance.", + "Remaining domains, HA services/entities and complete Stable/Edge product are unfinished." + ] + }, + "pendingAliasAndOwnerPreparationIncrement": { + "recordedAtUTC": "2026-11-03T17:49:08.466845+00:00", + "sourceBase": "fb92abe17594d1d2e6bf32d50ecd48b97d55fc51", + "scope": "Two new actual compiled-browser cases plus operator documentation; no authored runtime, schema, package pin or protected Copilot change.", + "case": "household-admin-C33", + "browserPath": "frontend/tests/e2e/administration.spec.ts", + "observations": [ + "Actual administrator alias PUT preserves exact pending canonical membership/revision and HA metadata in both deployments.", + "One original alias key/body creates one receipt/audit/outbox effect; household/member counts, own preferences and mapping remain unchanged.", + "The other account remains pending until a separate reviewed restricted activation. The same participant then reads default private locale/summary/revision and saved public alias after reload.", + "Markup remains literal; no active b/img/script is rendered." + ], + "initialCasePreparation": { + "completeSuitePassed": 32, + "newCasesFailed": 2, + "cause": "The test assumed stored pending role restricted. Existing enrollment stores an inactive viewer; the activation editor separately defaults to restricted. The source/owning contract was inspected and only the test premise was corrected. This is not a product bug or a Copilot finding." + }, + "targeted": { + "passed": 2, + "skipped": 0, + "flaky": 0 + }, + "actualInstallerImage": { + "sourceCommit": "3200bd9278f57f142ed03b54843a38a4138fc0af", + "imageID": "sha256:7d8b528a51f7c2f7873d636a420391522620b560f8816323d54dab53c1b2f4b1", + "arch": "aarch64", + "suite": { + "startTime": "2026-11-03T17:46:19.444Z", + "duration": 57192.952, + "expected": 34, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + } + }, + "CIContractTestsFirst": { + "oldGateFailed": 1, + "otherContractCasesPassed": 39, + "completeToolingPassedAfterUpdate": 178, + "statements": 214, + "branches": 98, + "coveragePercent": 100 + }, + "CIRequirement": "Both CPU jobs require all34 actual-image administration journeys, zero skips/flakes and no surviving owned fixtures. Existing32 cases, timeout180seconds and retries0 remain unchanged.", + "ownerPreparation": { + "arch": "aarch64", + "installedVersion": "0.0.0-edge.5", + "latestOffered": "0.0.0-edge.5", + "installedSource": "https://github.com/vypdev/domus#feature/8-deliver-the-household-entry-interface-for-native-and-standalone-deployments", + "backup": "One completed selected-app compressed partial backup; job and children done without errors; metadata names only Domus Edge.5, no Core or unrelated folders. Private identifiers/screenshots remain local.", + "postBackupState": "Started, original public origin/private port/Core permission retained.", + "actualUpdate": false, + "backupRestoreExercised": false, + "householdMutation": false + }, + "guidance": "docs/installation-home-assistant.md separates current sources, backup dispatch/completion, actual current status and historical Edge.1–3 receipts.", + "limitations": [ + "Source and image identities remain separate from hosted definition-head verification.", + "New current-head hosted CPU, full coverage and native review gates are required.", + "A completed backup metadata check is not restored-data acceptance or an in-place source migration.", + "All186case audit, required actual human UX, owner adoption, recurrence23 and remaining full product remain unfinished." + ] + }, + "frameIdentityReviewClarification": { + "recordedAtUTC": "2026-11-03T18:05:01.347945+00:00", + "reviewedHead": "127cb48647efb777be8494c40239fd048c751c72", + "nativeReview": { + "run": 41688262760, + "job": 126099694785, + "partitions": "12/12", + "context": "complete", + "publishedFindings": 1, + "finding": "tests/python/test_image_channel.py:115:valid-sequence-identity-treated-as-invalid" + }, + "upstreamReport": "https://github.com/vypdev/copilot/issues/875", + "originalParameters": [ + { + "repr": "0", + "type": "int" + }, + { + "repr": "True", + "type": "bool" + }, + { + "repr": "1.0", + "type": "float" + }, + { + "repr": "'1'", + "type": "str" + } + ], + "independentCounterevidence": "Original four cases pass unchanged. Actual JSON/worker probe rejects all four and accepts integer1; bool/float equality to1 does not satisfy the exact-int guard. Pytest display ID1 belongs to string1, not an integer input.", + "clarification": "Name every original parameter type explicitly and add one real-worker valid-integer positive case. Preserve every original negative value and the unchanged exact integer transport guard.", + "localTooling": { + "passed": 179, + "statements": 214, + "branches": 98, + "coveragePercent": 100 + }, + "reviewedHeadProductCI": { + "run": 41688262792, + "aggregateJob": 126104925491, + "allTenJobs": "success", + "frontend": 1258, + "backend": 4395, + "tooling": 178, + "sourceBrowserJourneys": 89, + "catalogJourneys": 36, + "eachCPU": { + "containers": 40, + "actualImageBrowser": 34, + "skipped": 0, + "flaky": 0 + } + }, + "limitations": "Same-head product success and the actual probe disprove the published premise; fresh review of the clarification remains required. No manual finding resolution or full186-case/human/owner-adoption acceptance.", + "CopilotChanged": false + }, + "progressScopeRecurrence127": { + "source": "127cb48647efb777be8494c40239fd048c751c72", + "run": 41688250286, + "job": 126099649754, + "timestamp": "2026-11-03T17:53:12.8553939Z", + "phase": "scope", + "reason": "scope-clause-limit", + "field": "$.lines", + "requirement": "none", + "sourceLine": "L155", + "reference": "5bd5bc8a-ba99-4ebc-aca4-eeb92d085a12", + "existingReport": "https://github.com/vypdev/copilot/issues/828#issuecomment-6599376807", + "scope": "Progress publication rejected; prior labels/assessments retained. Independent product CI passes. No returned clause/count or verified requirement identity is exposed, and no internal cause is inferred. No blind retry or bounds change." + }, + "coldBackupPolicyIncrement": { + "recordedAtUTC": "2026-11-03T18:30:18.026677+00:00", + "sourceBase": "f459b149475032752476bfd90e77e932c8132b5a", + "version": "0.0.0-edge.7", + "testsFirst": { + "contract": "tests/backend/test_app_package.py::test_supervisor_backup_requires_the_app_to_stop_before_copying_private_storage", + "failed": 1, + "otherCasesPassed": 7, + "cause": "Missing backup key; Supervisor defaults to hot." + }, + "policy": "backup: cold; no backup hooks or exclusions. Supervisor stops the app while copying private data.", + "primaryReference": "https://contributors.home-assistant.io/apps/reference/configuration/#backups", + "unchanged": [ + "Runtime commit3200bd9278f57f142ed03b54843a38a4138fc0af and original archive digest", + "All151Python/fourUI fingerprints, wheel reference and schema9", + "Core permission/private listener and frozen Edge.5 compatibility recipe", + "Copilot sources/resources/workflows/configuration/credentials/bounds" + ], + "ownerEvidenceLimit": "Earlier completed warm backup has readable outer archive and verified size/hash/catalog only. Concurrent-write consistency and restore were not exercised. Older-app pre-update capture requires explicit stop, verified completion and prior-state-aware restart.", + "validation": "Local package, exact image, full backend/tooling, unchanged SDD validation and state-aware owner stopped capture/restart pass at their stated scopes. Fresh hosted current-head checks pending. No owner update/restore or required human UX is claimed.", + "localValidation": { + "backend": { + "passed": 4396, + "modules": 151, + "statements": 10438, + "branches": 2928, + "coveragePercent": 100, + "independentAudit": "pass" + }, + "tooling": { + "passed": 179, + "statements": 214, + "branches": 98, + "coveragePercent": 100 + }, + "image": { + "id": "sha256:3a81102e2eca3f6ce2cf3bb6f8a1d8075b8fd64312e13f085ab03b7f467b697a", + "arch": "aarch64", + "productFilesVerified": 155, + "containerPassed": 4, + "browser": { + "startTime": "2026-11-03T18:31:10.011Z", + "duration": 65018.328, + "expected": 34, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "survivingFixtures": 0 + }, + "originalSDDPolicy": "Unchanged seven-module SDK validates all20owner documents and186retained/new cases; no acceptance inferred from format success." + }, + "ownerStoppedCapture": { + "previousState": "started", + "stopResult": "ok; info displayed error, separate stats explicitly returned app_not_running_error. No graceful-exit classification is inferred.", + "backupResult": "One new local compressed partial backup of only Domus Edge.5, no Core or unrelated folders. Job and all children done with no errors. Private identifiers/screenshots retained locally.", + "restart": "CLI start observation timed out. A fresh independent app-info read confirms Edge.5 started with its original origin, private port and Core permission. The start request was not repeated.", + "restoreExercised": false, + "actualUpdate": false + } + }, + "directoryBoundaryAcceptanceIncrement": { + "recordedAtUTC": "2026-11-03T19:06:57.357886+00:00", + "sourceBase": "e5eaa37a8179b314006de9babd0d86086bb35185", + "owningCase": "household-admin-C47", + "scope": "Actual bounded WebSocket adapter with deterministic fictional HA in both native and standalone modes; no owner HA directory modification.", + "testsFirst": { + "newCases": 6, + "firstExecutionPassed": 39, + "firstExecutionFailed": 0, + "interpretation": "Strengthens an explicit acceptance boundary already handled by the runtime; no production defect or runtime fix is claimed." + }, + "assertions": [ + "Exactly256 distinct canonical HA accounts are accepted without truncation, preserving provider order, names, eligibility and the exact own subject at the final position. Both modes perform only config/auth/list.", + "257 distinct valid accounts make the entire directory unavailable; a verified255-account prefix is never exposed as a complete selector. The fixture independently checks distinctness so duplicate rejection cannot explain success.", + "A malformed final account in an otherwise valid256-account response makes the entire directory unavailable rather than silently dropping the last account.", + "A standalone grant may separately verify only its exact own profile through auth/current_user; this does not certify the unavailable directory. Native technical credentials never use that fallback." + ], + "tests": "tests/backend/test_ha_member_profiles.py", + "testSourceSHA256": "b7d289bc5d1696112dec721368e7f682cc3a4c3e4eac2cd7ed3602799a205188", + "unchanged": "All runtime, Edge.7 source/archive/fingerprints/schema/permissions, frozen Edge.5 fixture and Copilot sources/resources/workflows/configuration/credentials/bounds.", + "validation": "Focused39cases and complete4402backend cases pass. All151runtime modules have100% supported statement/branch coverage, independently audited against actual source. Style, links and the unchanged original seven-module SDD SDK pass; all20owner documents and186cases remain. Fresh published-head CI/native review pending.", + "limits": "Only the stated provider-contract case is strengthened. Full retained186-case acceptance, actual owner Edge.7 installation/UI, restoration and required human UX remain separate and unfinished.", + "localValidation": { + "passed": 4402, + "runtimeModules": 151, + "statements": 10438, + "branches": 2928, + "missingLines": 0, + "missingBranches": 0, + "independentInventory": "PASS", + "runtimeUnchanged": true + } + }, + "canonicalIdentityAcceptanceIncrement": { + "recordedAtUTC": "2026-11-03T20:54:43.943399+00:00", + "sourceParent": "e5f08ebe46f1ae3e2f81080746c0fd76c705b9fc", + "runtimePin": "3200bd9278f57f142ed03b54843a38a4138fc0af", + "scope": "Five retained administration acceptance boundaries strengthened against unchanged product runtime; no new production bug/fix or full-product acceptance claimed.", + "cases": { + "household-admin-C02": "Rendered own identity and roster both choose the verified HA name for null aliases, or localized neutral fallback for absent/denied/unavailable metadata, in English and Spanish. Exact labels, current actions and no unintended intents/identity disclosure are asserted.", + "household-admin-C42": "Denied/unavailable metadata preserves the complete actual canonical member-ID to HA-ID mapping, explicit source state and absent names, with identical database bytes.", + "household-admin-C43": "Actual task creation/completion records a restricted participant before archive/reactivation. The exact task/cycle/performance facts, original receipts, full earlier audit/outbox prefixes and canonical membership survive both transitions. Existing self-enrollment nonreplacement negatives remain required and unchanged.", + "household-admin-C48": "Every genuine supported pre-recurrence source1..7 upgrades with all six recurrence tables empty, while every original business/history row remains unchanged. Actual schema8 preservation and version-spoof refusal remain separately asserted.", + "household-admin-C50": "Existing orphan profile references are deliberately committed with foreign_keys OFF. They are not disclosed or repaired; valid-parent lookup fails closed, independent snapshot inspection rejects, no snapshot remains and source bytes are unchanged. Existing corrupt-value cases remain." + }, + "results": { + "backendFocused": 140, + "backendFull": 4404, + "backendModules": 151, + "backendStatements": 10438, + "backendBranches": 2928, + "backendSupportedCoverage": "100% globally and per module; independent source inventory audit passed", + "frontendFullChromium": 1263, + "frontendRuntimeFiles": 31, + "frontendCoverage": "100% lines/statements/functions/branches globally and per file; independent authored-file audit passed", + "frontendStrictTypes": "runtime and catalog passed", + "pythonStyle": "Ruff check and format-check passed for all changed Python tests", + "diffWhitespace": "passed" + }, + "sourceHashes": { + "frontend/tests/administration-component.test.ts": "49b792cfb5047fcb92105b4399c61e221c73acdb9be3b3a9f16841fad2487516", + "tests/backend/test_household_roster.py": "9cf5e158e1a32382c8bef0d3d05d3a6a42ba88a5e4e822dccc03d5b30bf34d55", + "tests/backend/test_member_commands.py": "ce743b7ae206f93f2b503ec8f044d310332b7c9deee16b3b7ef5451dd2a5ed3d", + "tests/backend/test_sqlite_member_profiles.py": "960353cf1137aa181bc28065c5dac5caadca90e077d1c8d6d91e0ba2f201d8ca" + }, + "testPreparation": "The first focused frontend run had six assertion failures because the new test passed translation-helper arguments in reverse order. Corrected only the test call, then the complete1263-test suite and strict types passed. This is not a reproduced runtime bug.", + "limits": [ + "Current-head hosted CI/native review must run after publication; prior-head reviews are not acceptance of this new test increment.", + "No production/runtime/package/schema or Copilot source change; installed Edge.7 need not be rebuilt for these test-only assertions.", + "All186retained administration cases and20owner documents remain; other semantic gaps and actual human UX/restore acceptance remain open.", + "Actual owner fresh-install UI evidence remains separate from fictional-HA/SQLite/Chromium fixtures." + ] + } +} diff --git a/docs/evidence/household-administration.json b/docs/evidence/household-administration.json index 03141a7..cbd3586 100644 --- a/docs/evidence/household-administration.json +++ b/docs/evidence/household-administration.json @@ -612,15 +612,15 @@ } }, "currentSourcePackage": { - "sourceBase": "f491b572cea14e21a0fbfc8c664b110335a54f0e", - "sourceIdentity": "Current151-module backend adds capability-v2 verified household scope. Compiled entry UI resource bytes are unchanged;28-file administration source is covered but not yet wired to page composition. No owner Edge.5 update or full workspace acceptance.", + "sourceBase": "58957fc8b9da4ce85f0acbd75793f7351026c47a", + "sourceIdentity": "All151Pythonmodules unchanged. The administration renderer now binds every private draft and detached confirmation to the verified household ID in addition to installation/channel/subject/member. Missing household proof retires the draft; matching revisions/member IDs do not transfer it.", "actualProductFiles": { - ".vite/manifest.json": "bb13d320cf8968302162520e722f61f9290f9ca926c863b985288cb4fba2ffc3", - "assets/index-CQE4qeI0.js": "810376c1a54cbe125a3334afcc6ff1b81d14c27c089005d23f70a4da4c0dbcf4", + "index.html": "fb4f250c231b0f0b07fd4d99fa00925610f0573b01f033a4bf75178f860887fd", + "assets/index-CS8EClQ3.js": "c3103fcc8d107cff163ab5a98c8a587ef5203367624dd1f23fea56b96227dedd", "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", - "index.html": "53eb4a7d9debd6dd01e421a564df3432c8d3c523a74acbf1f962c9714cb56911" + ".vite/manifest.json": "07f6f6a7f49b0c8691b16dd3e687e17698b4e540e6f3cd2577fcfc187514f159" }, - "wheelSHA256": "11453b1ca6d98b08bbe718cb0b8d9fba2109bdb116cf375f66319e33fc615cd3", + "wheelSHA256": "6578a595350e00b0df2fb7e12a436a100a6cf8a661ad4147a5fc074d6de05351", "originalFailure": { "run": 41505801643, "aarch64Job": 125484707823, @@ -629,7 +629,13 @@ }, "testsFirst": "The actual aarch64 image exposes the stale historical expectation. A new early wheel/build/declaration assertion first fails with missing currentSourcePackage before this explicit candidate inventory is added.", "correction": "Current packaging and image tests use this declaration. The packaging gate compares it against the independently built source bundle and exact wheel bytes; the image fixture compares all installed and HTTP-served resources. Historical entry/Edge.5 evidence remains byte-identical.", - "acceptance": "The source-image binding correction53570f2 passed actual amd64/aarch64 jobs125489680572/125489680631 and CI aggregate125492943440 in41507276305. Current controller source still requires fresh hosted review/CI after push; compiled entry/UI and backend bytes are unchanged." + "acceptance": "151Pythonmodules/4UIresources independently match the rebuilt wheel. Local1239Chromium frontend tests pass with100% all4metrics globally/per-authored-runtime-file and independent inventory audit. Eight scope regressions first fail against the old renderer. Actual compiled87Python/SQLite journeys and27built catalog journeys pass. Fresh hosted CPUimages/native review, full186case audit, required human UX and owner adoption remain separate.", + "householdScopeRegression": { + "finding": "https://github.com/vypdev/domus/pull/28#discussion_r4455477782", + "testsFirst": "All8new component regressions fail; detached controls emit old household/private preference intents despite changed/missing household scope.", + "independentPackageVerification": "All151Python modules/4UI resources verified byte-for-byte against source/build; fixture exclusion verified before updating this declaration.", + "rollbackSHA256": "6f35282534510ee2dbcc3bbd28607d8d9fefdc531f2f23a1c6c26f14ef548754" + } }, "controllerIncrement": { "sourceBase": "53570f20bd8c89cd2940b5b8d5187eb8ed962428", diff --git a/docs/household-administration-operations.md b/docs/household-administration-operations.md new file mode 100644 index 0000000..55885a1 --- /dev/null +++ b/docs/household-administration-operations.md @@ -0,0 +1,186 @@ +# Household administration operations + +The development workspace supports both native Ingress and standalone deployments. Ingress uses +its verified Home Assistant session; standalone uses the existing Home Assistant sign-in flow. +Each person needs a separate Home Assistant account. A display name or Domus alias never grants access. +The Edge.7 installation candidate pins the reviewed workspace runtime. Publishing its definition does +not update an installed application; actual owner adoption and Stable acceptance remain separate. + +## Current access and data + +The workspace verifies identity and household access before displaying administration. It hides +private forms while checking access and when a session expires or its participant changes. The +stored profile and the effective profile for the current session are shown separately: HA mapping +can make these different. Technical details show the server's current permission set. + +A read-only outage lease permits available local reads and authorized original-result checks until +its existing deadline. It does not permit configuration changes or extend that deadline. A missing +roster does not prevent reading an independently permitted own alias. HA names can be available, +confirmed absent, denied or unavailable; an unavailable name or avatar is never invented. + +## Review a change + +Choose an available edit, adjust values, select **Review changes**, and inspect the confirmation +before **Save changes**. Participant edits identify the participant and stable membership reference. +Validation messages describe the form controls and return keyboard focus to a relevant field or +required approval. **Back to editing** restores the initial control and retains entered values. +If a transition target is disabled or no longer available, focus moves to the enabled +**Refresh administration** action. During proof refresh or retirement, removed private controls +are not focused. +Household settings, access policy, member configuration, public alias and private preferences each +keep their own original revision. An unrelated edit does not replace that revision. + +A pending member starts the editor with a restricted profile. Activating that member requires an +explicit access confirmation. An administrator can adjust ordinary permissions by profile or member; +protected administration permissions cannot be edited. Inherited settings are preserved unless +explicitly changed. The HA mapping editor preserves its stored target when current directory or +membership facts cannot support a new selection. The server verifies eligibility and a remaining +administrator; names alone cannot prove either. + +An optional alias changes public presentation only. The default uses an available HA name. Only the +participant can save their private locale and routine-summary preference. Saving summaries currently +sends no notification. A saved English/Spanish locale overrides the browser; an unset locale follows +the browser with English fallback. The header's language choice is temporary and sends no mutation. + +A draft retains its original revision through translation, same-participant refresh and a rejected +preflight. **Reset from current data** explicitly adopts fresh values and revision. If the data that +permit viewing a draft become unavailable, its fields and confirmation are hidden until they can be +read again. Losing only write access leaves readable values inspectable and disables saving. A +participant/account/installation/channel or verified household ID change, missing household proof, logout +or component retirement clears private drafts. Matching participant IDs or revisions do not transfer a +draft to another household. A pending refresh without a published view can retain it for fresh matching +proof; a detached confirmation control cannot submit a retired draft. + +## An unknown result + +The administration journal is separate from entry/setup recovery. It holds at most eight requests +and 65,536 bytes across contexts for one hour. It preserves the original action, payload, key and +revision before sending. Storage does not grant permission to inspect a result. + +Use **Check original request** deliberately. It performs a protected original key/operation GET; +it does not resubmit a mutation, silently adopt a newer revision or replace unresolved work. Closing +an editor does not undo a server effect. A resolved record can be hidden locally without deleting +server history. An unresolved record cannot be dismissed to bypass this protection. + +A conflict found during preflight can stop before any HTTP mutation or original record exists. +Review its access/data notice and reset explicitly when appropriate. A processed receipt describes +original processing; it does not guarantee that later state still has that effect. After restoring +a snapshot, historical processing can be retained while the original effect is absent. Inspect current +facts before requesting new work. Do not use a historical receipt to repopulate private preferences. + +## Leaving and restoring the page + +Leaving the page retires its private view and resources. Returning requires current proof before +private facts are shown again; a previous unsent editor is not revived. Private `no-store` responses +can cause Chromium to reload on back navigation. Browser caching eligibility does not grant access. + +Restoring a closed tab can retain that tab's original journal. The workspace still verifies current +identity and requires an explicit original-result check. Opening an unrelated new tab does not copy +the old tab's journal. The server retains original processing proof independently; a missing local +record does not prove that its effect never happened. + +The navigation checks distinguish normal no-store reloads from actual BFCache in a separate browser +with an explicit cache policy. Product headers remain unchanged. Closed-tab checks use the public +[Chromium sessions API](https://developer.chrome.com/docs/extensions/reference/api/sessions) in a +disposable test profile with a generated test-only extension; they do not modify the owner's Chrome. +The [Chrome no-store policy](https://developer.chrome.com/docs/web-platform/bfcache-ccns) explains why +normal back navigation and conditional persisted navigation need separate assertions. + +## Verification and rollout + +Run `pnpm test:frontend` for actual Chromium component/controller coverage and its independent source +inventory audit. Run `pnpm build:frontend`, build the backend wheel, and run `pnpm test:frontend-entry` +for the compiled browser with disposable Python/SQLite Ingress and standalone fixtures. These fixtures +use fictional HA accounts and cannot establish owner-HA adoption. + +Run `pnpm build:frontend-catalog` and `pnpm test:frontend-catalog` for the built entry and administration +catalog, including English/Spanish, keyboard focus, inert text, narrow layout and both color schemes. +Catalog stories explicitly emit intents without a server. They do not fabricate successful operations. +The **Maximum Labels** administration story contains 256 fictional participants. Every HA name uses +the full 256 Unicode code points (1,024 UTF-8 bytes); alternating rows use an 80-code-point alias +(320 bytes) or the HA-name fallback. At 360px, the built-browser acceptance checks every row's +text/action geometry without clipping, scrolls to the final participant and opens its management +and alias forms. Reordering the roster preserves the selected membership reference and exact +alias draft. Confirmation emits one intent for that same member. Both languages, deployment +presentations, themes and reduced motion are covered. This is compiled presentation evidence; +it does not assert a live HA directory response, successful server write or human UX approval. +All 34 administration journeys also execute against the actual current image on each CI architecture, +including exact high revisions, independent-writer conflicts, original recovery, authority outage, +keyboard errors, closed-tab restoration, conditional BFCache and actual console restoration. +Set `DOMUS_BROWSER_IMAGE` to the reviewed local image, `DOMUS_TEST_SOURCE_COMMIT` to its exact revision, +and `DOMUS_TEST_ARCH` to `amd64` or `aarch64`; run the entry Playwright configuration against the whole +`administration.spec.ts` file without a name filter. Docker must be available to the test process. +The fixture verifies source/image/CPU identity and starts isolated nonroot containers without published +ports or host mounts. Framed stdio workers preserve each original HTTP request, headers and binary body; +workers remain independent across host transport threads and match responses to their original frame. +Each frame makes one inner HTTP request with a fresh connection. I/O failure never resends a mutation. +Provider fixtures are copied only into the disposable writable container; no test dependency is added +to the image. The command must report all 34 passing journeys with no skips or flaky results. CI requires +that exact receipt in both CPU jobs, within the unchanged global suite limit of 180 seconds. +The runner uses a bounded graceful shutdown so the bridge can retire its owned container before +reporting ASGI completion. CI also requires +`docker ps --all --quiet --filter label=domus.browser-fixture=true` to be empty after the campaign; +any remaining disposable fixture fails the job. The outer final cleanup is idempotent. + +The container suite also requires `DOMUS_SCHEMA9_IMAGE`, the compatible immutable administration +checkpoint `87d726fa5a6a8cbcb56e0b911d15cc5b8a96a470`. Build it from +`tests/fixtures/schema9-app`; its source archive is verified by SHA-256 before either the original UI +or Python package is built. This is a development compatibility fixture, not an installed HA release. +Both CPU jobs run four application/data/generation rollback scenarios: native and standalone, each +with pending or acknowledged original event evidence. The older application creates a schema9 +checkpoint, the current application commits separate public/private updates and activates restoration, +and the older application reopens the selected generation with its original UI. Exact household, +member, policy and profile state returns; original receipt, fingerprint, result, time, audit, event +payload/position and delivery proof survive. Explicit replay returns historical original operations +without changing database bytes or creating effects. No owner data or host directory is mounted. + +The owning SDD acceptance audit and required human UX review remain separate gates. Before owner rollout, +verify the exact package, both CPU images and compatible data/rollback procedure. Preserve the installed +owner Edge.5 and Edge.3 rollback until that adoption is verified. + +## Pinned Edge.6 installation candidate + +The Supervisor recipe in `addon-edge` pins reviewed source +`3200bd9278f57f142ed03b54843a38a4138fc0af` and its exact archive digest. The +[artifact declaration](../addon-edge/artifact.json) records all 151 Python and four compiled UI +fingerprints plus the reviewed source-wheel reference. The installed-image check independently +reads those product files as a nonroot user; an image is not identified by its mutable tag alone. +The version is `0.0.0-edge.6`, with the same private listener/Core permission scope and schema 9. +Read [installation, backup and rollback limits](../addon-edge/DOCS.md) before adoption. + +The original Edge.5 Dockerfile remains byte-identical in `tests/fixtures/edge5-app`; it has no +discoverable app configuration and does not become a second Supervisor listing. Build that fixture +as `DOMUS_SCHEMA7_IMAGE`, the actual `addon-edge` candidate as `DOMUS_EDGE_IMAGE`, and the normal +current-source/other compatibility images before running `uv run pytest tests/containers --no-cov`. +Both CPU jobs now require the actual preview's product-file, native restart/authority, standalone +delivery and real Edge.5 upgrade/replay/restoration checks in addition to the existing suite. +The current-source image runs all 34 administration browser journeys per CPU. A prior separate local +aarch64 campaign has also passed all 32 journeys against the actual pinned Edge.6 image. + +These isolated campaigns do not change the installed owner's Edge.5 or create a household. +Fresh Chrome observation confirms the current Edge.5 identity and empty setup state; actual update, +owned backup/compatible rollback, live administration and human UX review remain unproven. + +The pending-alias journey independently preserves the exact pending membership and canonical source +facts through an administrator alias save. Only a later explicit restricted activation grants access; +the same participant then reads unchanged default private preferences and the saved public alias. +The [HA installation guide](installation-home-assistant.md) distinguishes the actual old branch source, +current candidate and completed owner-only backup from an unperformed update or data migration. + +## Cold Supervisor backup policy (Edge.7) + +The current `0.0.0-edge.7` candidate keeps the Edge.6 immutable runtime/archive, schema9 and all +155 product file fingerprints. Its distinct package version declares `backup: cold`: Supervisor +stops Domus while copying private storage, briefly interrupting access and closing memory-only +sessions. Reopening after restart verifies the current HA identity. The original Edge.5 fixture +remains unchanged. No backup hooks, exclusions, new permissions or runtime/schema changes are added. + +The package contract first failed because `backup` was absent. That omission would use Supervisor's +hot default, which did not establish a coherent raw copy of live SQLite data. A completed readable +outer backup archive is separate evidence from transactional consistency or a successful restore. +The [operator procedure](installation-home-assistant.md) now requires a stopped-app capture for older +previews and a state-aware restart; a previously stopped app must remain stopped. The earlier owner's +warm-copy metadata/readability does not close that gate. A later explicit stopped-app capture did +complete, followed by an independent started-state check after a CLI observation timeout. That is +not an exercised restore or adoption. Current-head CI, exact-image verification, owner adoption +and required human UX remain separate acceptance steps. diff --git a/docs/household-administration.md b/docs/household-administration.md index d69d00f..b1bac98 100644 --- a/docs/household-administration.md +++ b/docs/household-administration.md @@ -2,8 +2,8 @@ The current development backend supports public aliases and owner-private preferences in both native Home Assistant Ingress and standalone deployments. It also exposes a bounded verified-name -roster and current-authorized directory facts. The complete administration workspace remains under -development. Installed owner Edge.5 is unchanged; +roster and current-authorized directory facts. The development browser exposes roster, own alias/preferences, household, member and access-rule +editors with explicit review and confirmation. Complete acceptance remains under development. Installed owner Edge.5 is unchanged; this source work does not publish a release or upgrade an installation. Use the existing verified session. With the appropriate current permission, an API client can read @@ -11,6 +11,11 @@ the public presentation, then submit its revision and an explicit alias. Null re literal text remains presentation only and never changes identity or permissions. The absence of a saved alias does not fabricate a verified HA name or avatar. +Reading your own alias and its revision requires either member read or your own preference read. +It does not require permission to view the roster. Reading another participant's public alias +still requires member read. The browser controller reads your public presentation independently +of roster availability, so a missing roster does not silently disable a permitted own-alias edit. + Own preferences start with locale null (follow the browser, then English) and routine summaries enabled. Save an explicit English/Spanish choice or null, the summary boolean and the independent preference revision. Another household administrator cannot edit or recover your private @@ -37,14 +42,27 @@ temporary HA outage does not authorize a retry or rebase. Review current state b new work. An unexpired existing session may retain local reads during a temporary HA outage, while changes remain blocked. -The separate browser administration recovery policy and storage adapter are implemented and tested, -but are not yet connected to the workspace. Their requests retain exact original intent for up to +The browser runtime composes the separate administration recovery policy and storage adapter. +Their requests retain exact original intent for up to one hour, with at most eight requests and 65,536 bytes shared across account/installation/channel -contexts. A current view hides other contexts' pending work without deleting it. This source -foundation does not yet provide a browser recovery action or automatically resend a mutation. +contexts. A current view hides other contexts' pending work without deleting it. The workspace renders an explicit administration recovery action. Recovery remains explicit; +the runtime does not automatically resend a mutation or query an original result. + +The document runtime now coordinates entry and administration proof, earlier lease expiry, +current grants, original results and disposal. An unavailable household read preserves independently +verified access only after a current authorization check. The coordinator retains actionable conflict +and recovery notices and fences late logout responses. After every explicit administration action, +fresh proof replaces private facts even when an invalid draft, missing original or read-only lease +prevented sending. A replacement membership cannot inherit the prior member's displayed preferences, +and an empty action error remains bound to its verified account context. The runtime schedules both +proof and journal deadlines, closes both controllers on exit and rechecks proof after BFcache +restoration. The complete owning-case audit, human UX review and owner adoption remain under development in +[issue #27](https://github.com/vypdev/domus/issues/27). Current scoped verification is recorded in the +[browser coordination evidence](evidence/household-administration-browser.json). -See the [API contract](household-api.md), [architecture](architecture-household-administration.md) +See the [workspace and operations guide](household-administration-operations.md), [API contract](household-api.md), [architecture](architecture-household-administration.md) and [migration/rollback limits](household-administration-migration.md). The original [72-case administration contract](../specs/household-administration.md), [integration refinements](../specs/household-administration-integration.md) and -[workspace continuation](../specs/household-administration-workspace.md) remain required. +[workspace continuation](../specs/household-administration-workspace.md) and +[browser completion](../specs/household-administration-browser.md) remain required. diff --git a/docs/household-api.md b/docs/household-api.md index 0ba87f3..071d607 100644 --- a/docs/household-api.md +++ b/docs/household-api.md @@ -68,7 +68,7 @@ The full administration UI and its separate browser journal remain under develop | GET /api/v1/administration/capabilities | Wired action/revision/journal contract | Verified context; availability is not a caller permission | | GET /api/v1/administration/roster | Current masked membership, public alias/revision and verified name/source state | Member read before and after bounded source I/O | | GET /api/v1/administration/directory | Complete bounded source state and eligible account choices | Current policy management before and after source I/O | -| GET /api/v1/administration/members/{member_id}/presentation | Public alias and its independent revision | Member read | +| GET /api/v1/administration/members/{member_id}/presentation | Public alias and its independent revision | Member read, or current owner preference read for their own member only | | PUT /api/v1/administration/members/{member_id}/presentation | Set literal alias, or explicitly clear with null | Fresh human and current member management or applicable own preference write | | GET /api/v1/administration/preferences | Own locale, summary choice and independent revision | Current owner-scoped preference read | | PUT /api/v1/administration/preferences | Replace own locale and summary choice | Fresh human and current owner-scoped preference write | diff --git a/docs/installation-home-assistant.md b/docs/installation-home-assistant.md index 443698a..0ebbf79 100644 --- a/docs/installation-home-assistant.md +++ b/docs/installation-home-assistant.md @@ -1,24 +1,105 @@ # 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. +Current development state (2026-11-03): the owner runs Edge `0.0.0-edge.5` on an `aarch64` HA +installation. Edge.3 is a separate stopped preview. Edge.7 is the current installation candidate +for the administration workspace; it is not yet installed on that HA. Stable is not yet published. +Current image, API and browser verification is recorded in +[administration evidence](evidence/household-administration-browser.json). Historical receipts below +retain the original Edge.1–3 observations and do not describe the current installation. -## Intended user journey +## Current preview and update path -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 existing Edge.5 app is `60361632_domus_edge`; its source, read from Supervisor, is: -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. +```text +https://github.com/vypdev/domus#feature/8-deliver-the-household-entry-interface-for-native-and-standalone-deployments +``` -## Reviewed development repository +The reviewed Edge.7 candidate is in the existing PR #28 development source: + +```text +https://github.com/vypdev/domus#feature/27-complete-the-household-administration-workspace-after-the-verified-api-and-controller-checkpoint +``` + +These are temporary Action-managed development sources. Publishing Edge.7 on the latter does not +change the former. HA still offers Edge.5 for the installed source; its repository metadata links +to the same GitHub homepage, so the homepage alone does not identify the branch. Inspect the +Supervisor store's exact source and installed app slug before deciding which preview to open. +Adding the new branch as a repository is a separate preview path, not proof of an in-place update +or migration of the existing app's private data. Keep that existing app and its backup until the +chosen adoption/data path has been exercised. Never overwrite an old managed Git branch to make +an installed development repository follow unrelated work. + +Edge.7 retains the Edge.6 runtime pin `3200bd9278f57f142ed03b54843a38a4138fc0af` and archive SHA-256 +`ca991d7b3560dbdb7a6a86a34eea19708f01f10c15da502e49ed8ab740a8533b`. +The [artifact declaration](../addon-edge/artifact.json) fixes the Python and compiled UI bytes. +Read [configuration, permissions and schema-9 rollback limits](../addon-edge/DOCS.md) before adoption. +Existing Edge.5/schema7 and old Edge.3 data are distinct from the isolated test fixtures. +The candidate keeps Ingress, its private listener and the same Core API scope; it requests no +Supervisor management API, host mount or public port. + +## A local pre-update app backup + +An operator can use HA's existing Terminal & SSH app. Read the installed CLI's help first; +current HA calls the commands `ha apps` and `ha backups`. The earlier `ha addons` alias remains +available on the observed system. Inspect the selected app before creating its backup: + +```sh +ha apps info +ha apps stop +ha apps info +ha backups new --app --location=.local --name "Domus before update" --raw-json +ha backups info --raw-json +``` +For an older preview such as Edge.5, record whether it was running, explicitly stop it and verify +that it is stopped before requesting the backup. Keep it stopped until the same job has completed. +Restart it with `ha apps start ` only if it was running beforehand, then inspect +its state again. If backup fails, retain the error and restore that prior running/stopped state; +do not update based only on a dispatched job. Never start a preview that was already stopped. +Edge.7 declares `backup: cold`, so Supervisor handles stopping it during a backup; expect a brief +availability interruption and fresh identity verification after restart. This is a package policy, +not evidence of an exercised owner restore. See the +[official backup configuration](https://contributors.home-assistant.io/apps/reference/configuration/#backups). + +Use the exact returned backup identifier. On the observed CLI, the optional location value uses +`--location=.local`; a separate `.local` argument is rejected before a backup request is accepted. +A response containing a job ID is dispatch evidence. Inspect that same Supervisor job until it +is done with no errors, then inspect the backup metadata. Require the intended Domus version and +only the selected app, without Core or unrelated folders. An earlier owner preparation produced +one completed compressed partial backup while Edge.5 remained running. Its outer archive was readable +and its size/hash/catalog were checked, but concurrent-write consistency was not established. Keep +that observation separate from the required stopped-app pre-update capture. Private identifiers and +original device observations remain local. A subsequent selected-app capture was made after an explicit +successful stop and an independent `app_not_running_error` confirmation. Its job and all children +completed without errors. Since the app had been running beforehand, it was started afterward; +the CLI observation timed out, but a fresh app-info read confirmed Edge.5 started with its original +configuration. The start was not repeated. No owner backup was restored and no Edge.7 adoption is claimed. + +A Supervisor app backup and a Domus console snapshot have different boundaries. The app backup +captures its application/data/configuration at one time; it does not prove that later original +operation history survives restoring that older capture. Use the supported +[console restoration protocol](restoration.md) for retaining later processed-operation fences +with a compatible current runtime. An old binary by itself cannot read schema9. Preserve the +application/data pair and later evidence before a deliberate rollback. + +Official references: [store and repository endpoints](https://contributors.home-assistant.io/apps/supervisor-api/store/), +[repository installation](https://contributors.home-assistant.io/apps/guides/repository/). + +## Installation journey + +1. Add the selected published development source from Settings > Apps > Install app > Repositories. +2. Install the intended Domus Edge preview. Stable will have a separate published app and data. +3. Configure the exact HA browser origins, start the app and select Open Web UI. +4. Use the existing Home Assistant session. The HA app does not require another Domus login. + +Standalone Docker uses HA OAuth because it runs outside Supervisor; this is a separate deployment +choice, not a prerequisite for the HA app. + +## Historical issue #6 repository and Edge.1–3 receipts + +The following preserves the earlier development setup and acceptance record. Use the current +preview instructions above for new work. For the issue #6 preview, copy this exact branch URL into the HA repository dialog: ```text diff --git a/frontend/playwright.entry.config.ts b/frontend/playwright.entry.config.ts index b11c838..bee533a 100644 --- a/frontend/playwright.entry.config.ts +++ b/frontend/playwright.entry.config.ts @@ -1,13 +1,17 @@ import {defineConfig} from '@playwright/test'; export default defineConfig({ - testDir: 'tests/e2e', testMatch: 'entry.spec.ts', forbidOnly: true, retries: 0, workers: 1, + testDir: 'tests/e2e', testMatch: ['entry.spec.ts','administration.spec.ts'], forbidOnly: true, retries: 0, workers: 1, globalTimeout: 180_000, outputDir: 'test-results/entry-artifacts', reporter: [['list'], ['json', {outputFile: 'test-results/entry-results.json'}]], - use: {headless: true, screenshot: 'only-on-failure', trace: 'retain-on-failure'}, + // Playwright normally disables BFCache. Exercise the real production page lifecycle instead. + use: {headless: true, launchOptions: {ignoreDefaultArgs: ['--disable-back-forward-cache']}, + screenshot: 'only-on-failure', trace: 'retain-on-failure'}, webServer: (['native', 'standalone'] as const).map((mode, index) => ({ - command: `cd .. && uv run --frozen python tests/browser/entry_server.py --mode ${mode} --port ${56174 + index}`, + command: `cd .. && exec uv run --frozen python tests/browser/entry_server.py --mode ${mode} --port ${56174 + index}`, url: `http://127.0.0.1:${56174 + index}/__fixture__/ready`, + // Docker-owned fixtures need a chance to retire their exact container before runner exit. + gracefulShutdown: {signal: 'SIGTERM', timeout: 5_000}, reuseExistingServer: false, timeout: 30_000, })), }); diff --git a/frontend/src/application/administration-controller.ts b/frontend/src/application/administration-controller.ts index c04d23e..b702658 100644 --- a/frontend/src/application/administration-controller.ts +++ b/frontend/src/application/administration-controller.ts @@ -6,7 +6,7 @@ import {nextRevision} from '../domain/administration'; import type {Revision} from '../domain/administration'; import type {AdministrationJournalView, AdministrationRecord} from '../domain/administration-journal'; import type {AdministrationAccess, AdministrationCapabilities, AdministrationPolicy, AdministrationReceipt, - Directory, OwnPreferences, Roster} from '../domain/administration-views'; + Directory, OwnPreferences, PublicPresentation, Roster} from '../domain/administration-views'; import {entryDecision} from '../domain/entry'; import type {Deployment} from '../domain/entry'; import type {JournalContext} from '../domain/journal'; @@ -15,7 +15,7 @@ import type {EntryClock, EntryEntropy, EntryRead, EntryServer, HouseholdFacts, R import type {AdministrationCommandResult, AdministrationJournalPort, AdministrationOriginal, AdministrationServer} from './administration-ports'; type Facts = {household: HouseholdFacts | null; roster: Roster | null; policy: AdministrationPolicy | null; - directory: Directory | null; preferences: OwnPreferences | null}; + directory: Directory | null; preferences: OwnPreferences | null; presentation: PublicPresentation | null}; export type AdministrationNotice = ReadFault | 'api_unavailable' | 'inactive' | 'read_only' | 'access_changed' | 'journal_unavailable' | 'invalid_input' | 'action_not_allowed' | 'request_unavailable' | 'request_changed' | 'context_changed' | 'conflict' | 'revision_exhausted' | 'previous_request_unresolved' | 'check_unconfirmed'; @@ -23,14 +23,19 @@ type State = Readonly>>; notice: AdministrationNotice | null}>; -export type AdministrationView = State & Readonly<{busy: boolean}>; +export interface AdministrationControls { + readonly household: boolean; readonly policy: boolean; readonly preferences: boolean; readonly ownAlias: boolean; + readonly memberIds: readonly string[]; readonly aliasMemberIds: readonly string[]; + readonly recoverableKeys: readonly string[]; readonly dismissibleKeys: readonly string[]; +} +export type AdministrationView = State & Readonly<{busy: boolean; controls: AdministrationControls}>; type Intent = Readonly<{kind: 'new'; command: AdministrationCommand}> | Readonly<{kind: 'recover' | 'dismiss'; key: string}>; type Retained = Readonly<{context: JournalContext; request: AdministrationRecord; outcome: AdministrationCommandResult; notice: AdministrationNotice | null}>; const retired = Symbol('retired administration read'); function blank(phase: State['phase']): State { return Object.freeze({phase, session: null, access: null, capabilities: null, journal: null, outcome: null, - household: null, roster: null, policy: null, directory: null, preferences: null, + household: null, roster: null, policy: null, directory: null, preferences: null, presentation: null, errors: Object.freeze({}), notice: null}); } function sameContext(a: JournalContext, b: JournalContext): boolean { @@ -55,7 +60,37 @@ export class AdministrationController { constructor(private readonly deployment: Deployment, private readonly identity: Pick, private readonly server: AdministrationServer, private readonly journal: AdministrationJournalPort, private readonly clock: EntryClock, private readonly entropy: EntryEntropy) {} - get view(): AdministrationView {return Object.freeze({...this.state, busy: this.running});} + get view(): AdministrationView {return Object.freeze({...this.state, busy: this.running, controls: this.controls()});} + private controls(): AdministrationControls { + const {phase, session, access, journal, household, policy, preferences, roster} = this.state; + const ready = phase === 'ready' && !this.running && session !== null && access?.state === 'active' && journal !== null; + const edit = (command: AdministrationCommand | null): boolean => ready && session!.access === 'full' + && command !== null && allowed(access!, command) && !journal!.records.some(record => + (record.stage === 'prepared' || record.stage === 'uncertain') && sameAggregate(record, command)); + const owner = access?.member?.memberId; + const presentation = this.state.presentation ?? roster?.members.find(row => row.member.memberId === owner)?.presentation ?? null; + return Object.freeze({ + household: edit(household === null ? null : {action: 'household.update', payload: { + expected_revision: household.revision, name: household.name, time_zone: household.timeZone}}), + policy: edit(policy === null ? null : {action: 'policy.update', payload: { + expected_revision: policy.revision, mapping_mode: policy.mappingMode, target_ha_user_id: policy.targetHAUserId, + adjustments: policy.adjustments}}), + preferences: edit(preferences === null ? null : {action: 'member.preferences.update', payload: { + expected_revision: preferences.revision, locale: preferences.locale, routine_participation_summaries: preferences.routine_participation_summaries}}), + ownAlias: edit(presentation === null ? null : {action: 'member.presentation.update', memberId: presentation.memberId, + payload: {expected_revision: presentation.revision, alias: presentation.alias}}), + memberIds: Object.freeze(roster?.members.filter(({member}) => member.exceptions !== null && edit({action: 'member.update', + memberId: member.memberId, payload: {expected_revision: member.revision, role: member.role, status: member.status, + exceptions: member.exceptions}})).map(row => row.member.memberId) ?? []), + aliasMemberIds: Object.freeze(roster?.members.filter(row => row.member.memberId !== owner && edit({action: 'member.presentation.update', + memberId: row.member.memberId, payload: {expected_revision: row.presentation.revision, alias: row.presentation.alias}})) + .map(row => row.member.memberId) ?? []), + recoverableKeys: Object.freeze(ready && access!.permissions.includes('operations.read') + ? journal!.records.filter(record => record.stage !== 'rejected').map(record => record.key) : []), + dismissibleKeys: Object.freeze(ready ? journal!.records.filter(record => record.stage !== 'prepared' && record.stage !== 'uncertain') + .map(record => record.key) : []), + }); + } private update(patch: Partial): void {this.state = Object.freeze({...this.state, ...patch});} private invalidate(): number { this.generation++; @@ -96,7 +131,7 @@ export class AdministrationController { if (access.state !== 'active') {this.update({...blank('ready'), session, access, notice: 'inactive'}); return true;} if (access.member === null) throw new EntryReadError('invalid_response'); const capabilities = await this.read(this.server.capabilities(session.context), generation); - const facts: Facts = {household: null, roster: null, policy: null, directory: null, preferences: null}; + const facts: Facts = {household: null, roster: null, policy: null, directory: null, preferences: null, presentation: null}; const errors: Partial> = {}; const collect = async (key: K, factory: () => EntryRead): Promise => { try {facts[key] = await this.read(factory(), generation);} @@ -109,9 +144,13 @@ export class AdministrationController { has('policy.manage') ? collect('policy', () => this.server.policy(session.context)) : undefined, has('policy.manage') ? collect('directory', () => this.server.directory(session.context)) : undefined, has('preferences.read') ? collect('preferences', () => this.server.preferences(session.context)) : undefined, + has('members.read') || has('preferences.read') + ? collect('presentation', () => this.server.presentation(access.member!.memberId, session.context)) : undefined, ]); - if (facts.preferences !== null && facts.preferences.memberId !== access.member.memberId) { - facts.preferences = null; errors.preferences = 'invalid_response'; + for (const key of ['preferences', 'presentation'] as const) { + if (facts[key] !== null && facts[key].memberId !== access.member.memberId) { + facts[key] = null; errors[key] = 'invalid_response'; + } } if (facts.household !== null && facts.household.householdId !== capabilities.householdId) { facts.household = null; errors.household = 'invalid_response'; @@ -145,6 +184,15 @@ export class AdministrationController { } } async refresh(): Promise {if (this.state.phase !== 'closed') await this.load(this.begin());} + /** Replace private facts with fresh proof while retaining an actionable same-authority notice. No command or original lookup runs. */ + async reconcile(): Promise { + if (this.state.phase === 'closed' || this.running || this.state.session === null) return; + const context = this.state.session.context, access = this.state.access, notice = this.state.notice; + const generation = this.begin(); await this.load(generation); + if (generation !== this.generation || this.state.session === null || this.state.notice !== null) return; + this.update({notice: !sameContext(context, this.state.session.context) ? 'context_changed' + : JSON.stringify(access) !== JSON.stringify(this.state.access) ? 'access_changed' : notice}); + } /** Composition supplies monotonic ticks; this never refreshes or extends a session. */ tick(): void { const session = this.state.session; if (session === null) return; @@ -168,7 +216,9 @@ export class AdministrationController { case 'household.update': return this.state.household?.revision ?? null; case 'member.update': return this.state.roster?.members.find(row => row.member.memberId === command.memberId)?.member.revision ?? null; case 'policy.update': return this.state.policy?.revision ?? null; - case 'member.presentation.update': return this.state.roster?.members.find(row => row.member.memberId === command.memberId)?.presentation.revision ?? null; + case 'member.presentation.update': return (this.state.presentation?.memberId === command.memberId + ? this.state.presentation.revision : null) + ?? this.state.roster?.members.find(row => row.member.memberId === command.memberId)?.presentation.revision ?? null; case 'member.preferences.update': return this.state.preferences?.revision ?? null; } } @@ -191,6 +241,7 @@ export class AdministrationController { dismiss(key: string): Promise {return this.run({kind: 'dismiss', key});} private async run(intent: Intent): Promise { const context = this.context(intent.kind === 'new'); if (context === null) return; + const memberId = this.state.access!.member!.memberId; const original = intent.kind === 'new' ? null : this.opened!.records.find(record => record.key === intent.key); if (original === undefined) {this.update({notice: 'request_unavailable'}); return;} this.running = true; @@ -199,6 +250,7 @@ export class AdministrationController { if (!await this.load(generation) || generation !== this.generation) return; if (this.state.session === null || !sameContext(context, this.state.session.context)) {this.update({notice: 'context_changed'}); return;} const access = this.state.access; + if (access?.state === 'active' && access.member!.memberId !== memberId) {this.update({notice: 'context_changed'}); return;} if ((intent.kind === 'new' && this.state.session.access !== 'full') || access?.state !== 'active' || this.opened === null) return; let request: AdministrationRecord; if (intent.kind === 'new') { diff --git a/frontend/src/application/entry-controller.ts b/frontend/src/application/entry-controller.ts index d298d94..4cdb5ed 100644 --- a/frontend/src/application/entry-controller.ts +++ b/frontend/src/application/entry-controller.ts @@ -30,6 +30,9 @@ const retired = Symbol('retired entry read'); function sameContext(a: JournalContext, b: JournalContext): boolean { return a.installationId === b.installationId && a.channel === b.channel && a.subject === b.subject; } +function authorizationSnapshot(value: Authorization): Authorization { + return Object.freeze({...value, permissions: Object.freeze([...value.permissions])}); +} function sameOriginal(a: JournalRecord, b: JournalRecord): boolean { return a.key === b.key && a.action === b.action && a.createdAt === b.createdAt && a.expiresAt === b.expiresAt && a.operationId === b.operationId @@ -91,19 +94,27 @@ export class EntryController { let decision = entryDecision(this.deployment, session, null, this.clock.now()); if (decision.state === 'authentication_required') {this.authenticationRequired(); return true;} let authorization: Authorization | null = null, household: HouseholdFacts | null = null; + let notice: EntryNotice | null = null; if (session.householdApiAvailable) { - const access = await this.read(this.server.authorization(session.context), generation); - authorization = Object.freeze({...access, permissions: Object.freeze([...access.permissions])}); + authorization = authorizationSnapshot(await this.read(this.server.authorization(session.context), generation)); decision = entryDecision(this.deployment, session, authorization, this.clock.now()); - if (decision.householdReadable) - household = Object.freeze({...await this.read(this.server.household(session.context), generation)}); + if (decision.householdReadable) { + try {household = Object.freeze({...await this.read(this.server.household(session.context), generation)});} + catch (error) { + if (generation !== this.generation) throw retired; + if (error instanceof EntryReadError && error.code === 'authentication_required') throw error; + notice = error instanceof EntryReadError ? error.code : 'unavailable'; + // An optional fact is unknown. Fresh grants, rather than its failure or earlier access, govern the partial view. + authorization = authorizationSnapshot(await this.read(this.server.authorization(session.context), generation)); + } + } } decision = entryDecision(this.deployment, session, authorization, this.clock.now()); if (decision.state === 'authentication_required') {this.authenticationRequired(); return true;} if (this.retained !== null && (!sameContext(this.retained.context, session.context) || this.clock.now() >= this.retained.expiresAt)) this.retained = null; const permitted = authorization !== null && !['suspended', 'archived'].includes(authorization.state); - let journal: Journal | null = null, notice: EntryNotice | null = null; + let journal: Journal | null = null; if (permitted && !decision.readOnly) { try {journal = this.journal.open(session.context, this.clock.now());} catch {notice = 'journal_unavailable';} @@ -231,6 +242,11 @@ export class EntryController { } } finally {this.running = false;} } + /** A joint application lifetime may reach its earlier verified lease deadline. This never revokes or renews HA. */ + expire(): void { + if (this.state.phase === 'closed') return; + this.invalidate(); this.retained = null; this.authenticationRequired(); + } close(): void {this.invalidate(); this.retained = null; this.state = blank('closed');} async logout(): Promise { if (this.running || this.state.phase === 'closed') return; diff --git a/frontend/src/application/household-controller.ts b/frontend/src/application/household-controller.ts new file mode 100644 index 0000000..e064a63 --- /dev/null +++ b/frontend/src/application/household-controller.ts @@ -0,0 +1,138 @@ +/** Coordinate verified application views; concrete document resources and command authority stay with their owners. */ +import type {EntryController, EntryNotice, EntryView} from './entry-controller'; +import type {AdministrationController, AdministrationView} from './administration-controller'; +import type {JournalContext} from '../domain/journal'; + +function sameContext(left: JournalContext, right: JournalContext): boolean { + return left.installationId === right.installationId && left.channel === right.channel && left.subject === right.subject; +} +function sameGrants(left: readonly string[], right: readonly string[]): boolean { + return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort()); +} + +export class HouseholdController { + private current: AdministrationController | null = null; + private currentContext: JournalContext | null = null; + private visible = false; + private running = false; + private closed = false; + private generation = 0; + private notice: EntryNotice | null = null; + constructor(private readonly entry: EntryController, private readonly administrationFactory: () => AdministrationController) {} + get view(): EntryView { + const view = this.entry.view; + return Object.freeze({...view, busy: this.running || view.busy || this.current?.view.busy === true, + notice: this.notice ?? view.notice}); + } + get administration(): AdministrationView | null {return this.visible ? this.current!.view : null;} + /** Scheduling needs only absolute deadlines, never a command payload or a renewed proof. */ + get deadlines(): readonly number[] { + return Object.freeze([this.entry.view, this.current?.view].flatMap(view => view === undefined ? [] : [ + ...view.session === null ? [] : [view.session.expiresAt], + ...view.journal?.records.map(record => record.expiresAt) ?? [], + ])); + } + private live(generation: number): boolean {return !this.closed && generation === this.generation;} + private retire(): void { + this.visible = false; this.current?.close(); this.current = null; this.currentContext = null; + } + private eligible(): boolean { + const view = this.entry.view; + return view.phase === 'ready' && view.session !== null && view.decision?.state === 'active'; + } + private matched(): boolean { + const entry = this.entry.view, administration = this.current!.view; + // Even an empty rejection can contain an action correlation from its last verified account context. + if (administration.session === null) return entry.session !== null && sameContext(entry.session.context, this.currentContext!); + if (entry.session === null || administration.access?.state !== 'active' || entry.authorization === null) return false; + if (!sameContext(entry.session.context, administration.session.context) + || entry.session.access !== administration.session.access + || entry.authorization.state !== administration.access.state + || entry.authorization.setupAllowed !== administration.access.setupAllowed + || entry.authorization.enrollmentAllowed !== administration.access.enrollmentAllowed + || !sameGrants(entry.authorization.permissions, administration.access.permissions)) return false; + const scope = administration.capabilities?.householdId ?? administration.household?.householdId ?? null; + return entry.household === null || scope === null || entry.household.householdId === scope; + } + /** No tick obtains new proof, extends a lease, sends a command or queries an original operation. */ + tick(): void { + if (this.closed) return; + this.entry.tick(); this.current?.tick(); + const entry = this.entry.view, administration = this.current?.view; + if (entry.decision?.state === 'authentication_required' + || (administration?.session === null && administration.notice === 'authentication_required')) { + this.generation++; this.notice = null; this.entry.expire(); this.retire(); return; + } + if (entry.phase === 'ready' && entry.decision?.state !== 'active') {this.notice = null; this.retire(); return;} + if (this.visible && !this.matched()) {this.visible = false; this.notice = 'context_changed';} + } + private async synchronize(generation: number, loaded: boolean): Promise { + if (!this.live(generation)) return; + if (!this.eligible()) {this.retire(); return;} + if (this.current === null) { + this.current = this.administrationFactory(); this.currentContext = this.entry.view.session!.context; loaded = false; + } + const current = this.current; + if (!loaded) await current.refresh(); + if (!this.live(generation)) return; + this.currentContext = current.view.session?.context ?? this.currentContext; + this.tick(); if (!this.live(generation)) return; + if (!this.eligible()) {this.retire(); return;} + if (this.matched()) {this.visible = true; return;} + // Independent proof reads can straddle one transition. One bounded read aligns entry; persistent drift stays hidden. + await this.entry.refresh(); + if (!this.live(generation)) return; + this.tick(); if (!this.live(generation)) return; + if (!this.eligible()) {this.retire(); return;} + if (this.matched()) this.visible = true; + else {this.retire(); this.notice = 'context_changed';} + } + private async perform(work: (generation: number) => Promise): Promise { + if (this.closed || this.view.busy) return; + this.running = true; this.visible = false; this.notice = null; + const generation = ++this.generation; + try {await work(generation);} finally {this.running = false;} + } + refresh(): Promise { + return this.perform(async generation => { + const current = this.current; + // Both existing controllers retire their prior read generations before fresh proof is requested. + await Promise.all([this.entry.refresh(), current?.refresh()]); + await this.synchronize(generation, current !== null); + }); + } + private entryWork(work: (controller: EntryController) => Promise): Promise { + return this.perform(async generation => {await work(this.entry); await this.synchronize(generation, false);}); + } + submitSetup(name: unknown, timeZone: unknown): Promise { + return this.entryWork(controller => controller.submitSetup(name, timeZone)); + } + requestMembership(): Promise {return this.entryWork(controller => controller.requestMembership());} + recover(key: string): Promise {return this.entryWork(controller => controller.recover(key));} + dismiss(key: string): Promise {return this.entryWork(controller => controller.dismiss(key));} + private administrationWork(work: (controller: AdministrationController) => Promise): Promise { + const current = this.current; + if (!this.visible || current === null) return Promise.resolve(); + return this.perform(async generation => { + await work(current); + if (!this.live(generation)) return; + this.tick(); if (!this.live(generation)) return; + if (!this.eligible()) {this.retire(); return;} + // An action can reject before proof is read. Reconcile both owners, preserving same-authority action notices. + await Promise.all([this.entry.refresh(), current.reconcile()]); + await this.synchronize(generation, true); + }); + } + submitAdministration(value: unknown): Promise {return this.administrationWork(controller => controller.submit(value));} + recoverAdministration(key: string): Promise {return this.administrationWork(controller => controller.recover(key));} + dismissAdministration(key: string): Promise {return this.administrationWork(controller => controller.dismiss(key));} + async logout(): Promise { + if (this.closed || this.view.busy) return; + this.closed = true; this.generation++; this.notice = null; this.retire(); + await this.entry.logout(); + } + close(): void { + // Logout disables commands before remote revocation settles. Disposal must still fence that pending result. + this.closed = true; this.generation++; this.notice = null; this.retire(); this.entry.close(); + } +} diff --git a/frontend/src/composition/entry-page.ts b/frontend/src/composition/entry-page.ts index 03eea0a..217245e 100644 --- a/frontend/src/composition/entry-page.ts +++ b/frontend/src/composition/entry-page.ts @@ -6,6 +6,8 @@ import {EntryRuntime} from './entry-runtime'; import type {EntryBrowser} from './entry-runtime'; import {language, message} from '../presentation/messages'; import type {DomusApp, EntryIntent} from '../presentation/domus-app'; +import type {AdministrationIntent} from '../presentation/domus-administration'; +import {administrationCommand} from '../domain/administration-commands'; import '../presentation/domus-app'; export interface EntryPage extends EventTarget { @@ -44,11 +46,22 @@ export function decodeIntent(value: unknown): EntryIntent | null { } catch {return null;} } +/** Closed administration events never supply a principal, a context, a URL or an arbitrary transport method. */ +export function decodeAdministrationIntent(value:unknown):AdministrationIntent|null { + try { + const item=fields(value,['type'],['command','key']),type=item.type; + if(type==='submit') {fields(value,['type','command'],[]);return {type,command:administrationCommand(item.command)};} + if(type==='recover'||type==='dismiss') {fields(value,['type','key'],[]);return {type,key:identifier(item.key)};} + check(type==='refresh');fields(value,['type'],[]);return {type}; + }catch{return null;} +} + /** Wire only this document's owned nodes/capabilities; never inspect the HA parent or private storage. */ export function mountEntry(page: EntryPage): PageMount { const doc = page.document; mounts.get(doc)?.(); const lifetime = new AbortController(), widget = new AbortController(); let stopped = false, runtime: EntryRuntime | null = null, app: DomusApp | null = null, error: HTMLElement | null = null; + let localeOwner:string|null=null,preferenceIdentity:string|null=null,hadPreference=false,temporaryLanguage=false,temporaryContext:string|null=null; const dispose = () => { if (stopped) return; stopped = true; widget.abort(); lifetime.abort(); runtime?.dispose(); error?.remove(); @@ -84,7 +97,36 @@ export function mountEntry(page: EntryPage): PageMount { elapsed: () => page.performance.now(), random: bytes => page.crypto.getRandomValues(bytes), navigate: path => page.location.assign(path), events: page, visibility: doc, schedule: (tick, delay) => {const id = page.setTimeout(tick, delay); return () => page.clearTimeout(id);}}; - const active = new EntryRuntime(source.target, browser, view => {element.view = view;}, fail); runtime = active; + const active = new EntryRuntime(source.target, browser, (view,administration) => { + element.view=view;element.administration=administration; + const context=administration?.session?.context,member=administration?.access?.member; + const contextKey=context?JSON.stringify([context.installationId,context.channel,context.subject]):null; + const owner=contextKey!==null&&administration?.access?.state==='active'&&member?JSON.stringify([contextKey,member.memberId]):null; + const retired=view.phase==='closed'||view.notice==='context_changed'||(view.phase==='ready'&&view.decision?.state!=='active'); + if(retired||(administration!==null&&owner===null)) { + const entryContext=view.session?.context; + const entryKey=entryContext?JSON.stringify([entryContext.installationId,entryContext.channel,entryContext.subject]):null; + const keepUnboundChoice=localeOwner===null&&temporaryLanguage&&temporaryContext===entryKey; + localeOwner=null;preferenceIdentity=null;hadPreference=false; + if(!keepUnboundChoice){temporaryLanguage=false;temporaryContext=null;element.language=language(null,page.navigator.language);} + }else if(owner!==null) { + if(owner!==localeOwner) { + const keepInitialChoice=localeOwner===null&&temporaryLanguage&&temporaryContext===contextKey; + localeOwner=owner;preferenceIdentity=null;hadPreference=false; + if(!keepInitialChoice){temporaryLanguage=false;temporaryContext=null;element.language=language(null,page.navigator.language);} + } + const preferences=administration!.preferences; + if(preferences!==null) { + const identity=JSON.stringify([preferences.revision,preferences.locale,preferences.routine_participation_summaries]); + if((identity!==preferenceIdentity&&(preferenceIdentity!==null||preferences.locale!==null||!temporaryLanguage)) + ||(!hadPreference&&!temporaryLanguage)) { + element.language=language(preferences.locale,page.navigator.language);temporaryLanguage=false;temporaryContext=null; + } + preferenceIdentity=identity;hadPreference=true; + }else {hadPreference=false;if(!temporaryLanguage)element.language=language(null,page.navigator.language);} + } + doc.documentElement.lang=element.language; + }, fail); runtime = active; element.addEventListener('domus-detached', dispose, {signal: widget.signal}); element.addEventListener('domus-intent', async event => { if (event.target !== element) return; @@ -92,7 +134,11 @@ export function mountEntry(page: EntryPage): PageMount { const intent = decodeIntent((event as CustomEvent).detail); if (intent === null) return; switch (intent.type) { - case 'language': element.language = intent.language; doc.documentElement.lang = intent.language; break; + case 'language': { + element.language=intent.language;doc.documentElement.lang=intent.language;temporaryLanguage=true; + const context=active.view.session?.context; + temporaryContext=context?JSON.stringify([context.installationId,context.channel,context.subject]):null;break; + } case 'setup': await active.submitSetup(intent.name, intent.timeZone); break; case 'request_membership': await active.requestMembership(); break; case 'recover': await active.recover(intent.key); break; @@ -104,6 +150,18 @@ export function mountEntry(page: EntryPage): PageMount { } } catch {fail();} }, {signal: widget.signal}); + element.addEventListener('domus-administration-intent',async event=>{ + if(event.target!==element)return; + try { + const intent=decodeAdministrationIntent((event as CustomEvent).detail);if(intent===null)return; + switch(intent.type){ + case 'submit':await active.submitAdministration(intent.command);break; + case 'recover':await active.recoverAdministration(intent.key);break; + case 'dismiss':await active.dismissAdministration(intent.key);break; + case 'refresh':await active.refresh();break; + } + }catch{fail();} + },{signal:widget.signal}); roots[0].replaceChildren(element); ready = active.start().catch(() => {fail();}); } catch {fail(); ready = Promise.resolve();} diff --git a/frontend/src/composition/entry-runtime.ts b/frontend/src/composition/entry-runtime.ts index c5c5918..011a5bd 100644 --- a/frontend/src/composition/entry-runtime.ts +++ b/frontend/src/composition/entry-runtime.ts @@ -1,5 +1,8 @@ import {EntryController} from '../application/entry-controller'; import type {EntryView} from '../application/entry-controller'; +import {HouseholdController} from '../application/household-controller'; +import {AdministrationController} from '../application/administration-controller'; +import type {AdministrationView} from '../application/administration-controller'; import {EntryReadError} from '../application/ports'; import {BrowserEntropy, BrowserNavigation, ServerClock} from '../adapters/browser-capabilities'; import {HttpEntryServer} from '../adapters/http'; @@ -8,6 +11,8 @@ import {endpoint} from '../adapters/http-values'; import type {EntryEndpoint} from '../adapters/http-values'; import {SessionJournal} from '../adapters/session-journal'; import type {OwnedStorage} from '../adapters/session-journal'; +import {AdministrationSessionJournal} from '../adapters/administration-journal'; +import {HttpAdministrationServer} from '../adapters/administration-http'; /** Capabilities of this document only. No parent window, private HA object or host storage access. */ export interface EntryBrowser { @@ -20,7 +25,7 @@ export interface EntryBrowser { visibility: EventTarget; schedule(tick: () => void, delay: number): () => void; } -type OwnedController = Readonly<{controller: EntryController; clock: ServerClock}>; +type OwnedController = Readonly<{controller: HouseholdController; clock: ServerClock}>; /** Composition/lifecycle only; commands, authorization and recovery stay in the Application layer. */ export class EntryRuntime { @@ -34,22 +39,26 @@ export class EntryRuntime { private timerEpoch = 0; private cancelTimer: (() => void) | null = null; constructor(target: EntryEndpoint, private readonly browser: EntryBrowser, - private readonly changed: (view: EntryView) => void, private readonly failed: () => void = () => {}) { + private readonly changed: (view: EntryView, administration: AdministrationView | null) => void, + private readonly failed: () => void = () => {}) { this.target = endpoint(target); this.navigation = new BrowserNavigation(this.target, path => browser.navigate(path)); this.current = this.compose(); } private compose(): OwnedController { const clock = new ServerClock(() => this.browser.elapsed()); - const journal = new SessionJournal({getItem: key => this.browser.storage().getItem(key), + const storage: OwnedStorage = {getItem: key => this.browser.storage().getItem(key), setItem: (key, value) => this.browser.storage().setItem(key, value), - removeItem: key => this.browser.storage().removeItem(key)}); + removeItem: key => this.browser.storage().removeItem(key)}; const server = new HttpEntryServer(this.target, this.browser.transport); - return {clock, controller: new EntryController(this.target.deployment, server, journal, clock, - new BrowserEntropy(bytes => this.browser.random(bytes)))}; + const entropy = new BrowserEntropy(bytes => this.browser.random(bytes)); + const entry = new EntryController(this.target.deployment, server, new SessionJournal(storage), clock, entropy); + return {clock, controller: new HouseholdController(entry, () => new AdministrationController(this.target.deployment, + server, new HttpAdministrationServer(this.target, this.browser.transport), new AdministrationSessionJournal(storage), clock, entropy))}; } get view(): EntryView {return this.current.controller.view;} + get administration(): AdministrationView | null {return this.current.controller.administration;} private publish(): void { - try {this.changed(this.view);} catch {/* A renderer failure cannot change authority or interrupt resource cleanup. */} + try {this.changed(this.view, this.administration);} catch {/* A renderer failure cannot change authority or interrupt resource cleanup. */} } private stopTimer(): void { this.timerEpoch++; const cancel = this.cancelTimer; this.cancelTimer = null; @@ -57,12 +66,11 @@ export class EntryRuntime { } private arm(): void { this.stopTimer(); - const owned = this.current, view = this.view; - if (!this.active || view.session === null) return; + const owned = this.current, deadlines = owned.controller.deadlines; + if (!this.active || deadlines.length === 0) return; let now: number; try {now = owned.clock.now();} catch {owned.controller.tick(); this.publish(); return;} - const deadlines = [view.session.expiresAt, ...view.journal?.records.map(record => record.expiresAt) ?? []]; const delay = Math.max(1, Math.min(1000, ...deadlines.map(deadline => deadline - now))); const epoch = this.timerEpoch; try { @@ -77,8 +85,8 @@ export class EntryRuntime { } } private later(): void {try {this.arm();} catch {/* Disposal and bounded failure notification already completed. */}} - private async perform(work: (controller: EntryController) => Promise): Promise { - if (this.disposed || !this.active) return; + private async perform(work: (controller: HouseholdController) => Promise): Promise { + if (this.disposed || !this.active || this.view.busy) return; const owned = this.current, pending = work(owned.controller); this.publish(); this.arm(); try {await pending;} @@ -113,6 +121,9 @@ export class EntryRuntime { requestMembership(): Promise {return this.perform(controller => controller.requestMembership());} recover(key: string): Promise {return this.perform(controller => controller.recover(key));} dismiss(key: string): Promise {return this.perform(controller => controller.dismiss(key));} + submitAdministration(value: unknown): Promise {return this.perform(controller => controller.submitAdministration(value));} + recoverAdministration(key: string): Promise {return this.perform(controller => controller.recoverAdministration(key));} + dismissAdministration(key: string): Promise {return this.perform(controller => controller.dismissAdministration(key));} signIn(): void {if (!this.disposed && !this.paused) this.navigation.signIn();} reopen(): void {if (!this.disposed && !this.paused) this.navigation.reopen();} async logout(): Promise { diff --git a/frontend/src/presentation/administration-messages.ts b/frontend/src/presentation/administration-messages.ts new file mode 100644 index 0000000..f537348 --- /dev/null +++ b/frontend/src/presentation/administration-messages.ts @@ -0,0 +1,110 @@ +import type {Language} from './messages'; + +const en = { + title:'Household administration', checking:'Checking access or processing your request', + checkingBody:'The administration view is hidden until current verification finishes. Submitted requests keep their original identity.', + unavailable:'Unavailable with current access', refresh:'Refresh administration', ownProfile:'Your household identity', participants:'Participants', + participant:'Participant',memberReference:'Membership reference', + storedProfile:'Stored profile', effectiveProfile:'Effective profile for this session', status:'Membership status', unnamed:'Name unavailable', + haName:'Home Assistant name', alias:'Domus alias', avatar:'Avatar delivery is unavailable', ownAlias:'Your public alias', preferences:'Your private preferences', + household:'Household settings', policy:'Access rules', editAlias:'Edit own alias', editPreferences:'Edit preferences', editHousehold:'Edit household', + editPolicy:'Edit access rules', manage:'Manage participant', otherAlias:'Change alias', revision:'Original revision', + name:'New household name', zone:'New timezone', zoneHelp:'For example UTC or Europe/Madrid. The server validates its actual timezone catalog.', + useHA:'Use Home Assistant name', savedLanguage:'Saved language', browserLanguage:'Follow the browser', english:'English', spanish:'Spanish', + summaries:'Routine participation summaries', delivery:'Notification delivery is unavailable. Saving this choice sends no notification.', + memberRole:'Household profile', activation:'Approve household access', activationRequired:'Confirm household access before activating this participant.', + editRole:'Profile to customize', mapping:'HA administrator mapping', target:'Target Home Assistant account', chooseTarget:'Choose an account', + mappingHelp:'For the setup option, select the original setup account. Domus verifies provenance, current identity and a remaining administrator before saving.', + directoryUnavailable:'A new target needs current HA account and active household membership facts. Keep the stored target until these facts support another selection.', + savedTarget:'Saved account; eligibility must be rechecked', + protected:'Protected administrator permissions cannot be edited. These choices change ordinary profile rules only.', + permissions:'Ordinary permission overrides', inherit:'Use profile rules', builtIn:'Use the built-in default', allow:'Allow', deny:'Deny', + review:'Review changes', save:'Save changes', cancel:'Cancel', close:'Close editor', back:'Back to editing', reset:'Reset from current data', + submitted:'Review the result or the current access notice below before making another change. Closing the editor does not undo a processed effect.', + hiddenDraft:'This draft is hidden until its current data is available. It is kept for this verified participant.', + locked:'This form needs current write access, known current values and no unresolved request for the same change.', + invalid:'Review these values and required confirmations. Your draft is kept; nothing was sent.', + reviewTitle:'Confirm this household change', currentRules:'Profile rules apply when no override is saved.', + requests:'Original administration requests', requestHelp:'Check an original result without sending another mutation. Historical processing does not recreate an effect.', + check:'Check original request', dismiss:'Hide resolved record', deadline:'Available until', operation:'Operation', correlation:'Request reference', + committed:'Original request processed', committedBody:'The request was processed. Review current facts; later changes may differ.', + historical:'Processed before restoration', historicalBody:'This operation was processed before restoration. Its effect is absent from current state.', + uncertain:'Outcome not confirmed', uncertainBody:'Check the original request before making another change to these data.', + rejected:'Change not accepted', rejectedBody:'Review the values and current access. Your draft is retained.', + stage_prepared:'Prepared; result unknown', stage_uncertain:'Result unknown', stage_committed:'Processed', stage_historical:'Historical processing', stage_rejected:'Rejected', + role_administrator:'Administrator', role_adult:'Adult', role_restricted:'Restricted', role_viewer:'Viewer', + status_pending:'Pending review', status_active:'Active', status_suspended:'Suspended', status_archived:'Archived', + mapping_all_ha_administrators:'All current HA administrators', mapping_setup_administrator:'Original setup administrator', mapping_selected_ha_user:'Selected Home Assistant account', + metadata_absent:'HA name confirmed absent', metadata_denied:'HA name access denied', metadata_unavailable:'HA name unavailable', + notice_authentication_required:'Sign in or reopen Domus. Previous private data is hidden.', notice_forbidden:'Current access does not permit this request. Refresh before continuing.', + notice_invalid_input:'The values were not accepted. Review them and the original revision.', notice_conflict:'The data changed. Your draft keeps its original revision; reset explicitly to adopt current data.', + notice_unavailable:'The requested information is unavailable. Refresh or check the original request again.', notice_invalid_response:'The response could not be verified. Do not assume that a change failed.', + notice_api_unavailable:'The administration API is unavailable in this installation.', notice_inactive:'This membership is not active. An administrator must review it.', + notice_read_only:'Read-only access. Local reads and permitted original-result checks remain available until the existing session expires; new changes are blocked.', + notice_access_changed:'Current permissions or membership changed. Review current access before continuing.', notice_journal_unavailable:'Recovery storage is unavailable. Current permitted facts remain readable; new changes are blocked.', + notice_action_not_allowed:'This action is unavailable with current access and facts.', notice_request_unavailable:'This original request is unavailable or expired. No new change was sent.', + notice_request_changed:'The stored original changed. Refresh; do not replace its intent.', notice_context_changed:'The verified household or participant changed. Start again from current facts.', + notice_revision_exhausted:'This revision cannot advance. No change was sent; contact an administrator.', + notice_previous_request_unresolved:'Check the unresolved original request before starting another change to the same data.', notice_check_unconfirmed:'The original result could not be confirmed. Its record is retained.', + 'permission_household.read':'View household', 'permission_members.read':'View participants', 'permission_preferences.read':'Read own preferences', + 'permission_preferences.write':'Change own preferences', 'permission_operations.read':'Check own original requests', 'permission_tasks.read':'View tasks', + 'permission_tasks.claim':'Claim available tasks', 'permission_tasks.manage':'Manage tasks', 'permission_tasks.record_for_others':'Record work for others', + 'permission_tasks.material_use':'Record linked task materials', 'permission_tasks.create':'Create tasks', 'permission_tasks.complete':'Complete tasks', + 'permission_tasks.approve':'Approve task work', 'permission_shopping.request':'Request shopping', 'permission_shopping.manage':'Manage shopping', 'permission_inventory.manage':'Manage inventory', +} as const; +const es: Record = { + title:'Administración del hogar', checking:'Verificando el acceso o procesando tu petición', + checkingBody:'La vista de administración permanece oculta hasta terminar la verificación actual. Las peticiones enviadas conservan su identidad original.', + unavailable:'No disponible con el acceso actual', refresh:'Actualizar administración', ownProfile:'Tu identidad en el hogar', participants:'Participantes', + participant:'Participante',memberReference:'Referencia de participación', + storedProfile:'Perfil guardado', effectiveProfile:'Perfil efectivo de esta sesión', status:'Estado de participación', unnamed:'Nombre no disponible', + haName:'Nombre de Home Assistant', alias:'Alias de Domus', avatar:'Las imágenes de perfil no están disponibles', ownAlias:'Tu alias público', preferences:'Tus preferencias privadas', + household:'Configuración del hogar', policy:'Reglas de acceso', editAlias:'Editar mi alias', editPreferences:'Editar preferencias', editHousehold:'Editar hogar', + editPolicy:'Editar reglas de acceso', manage:'Gestionar participante', otherAlias:'Cambiar alias', revision:'Revisión original', + name:'Nuevo nombre del hogar', zone:'Nueva zona horaria', zoneHelp:'Por ejemplo UTC o Europe/Madrid. El servidor valida su catálogo real de zonas horarias.', + useHA:'Usar el nombre de Home Assistant', savedLanguage:'Idioma guardado', browserLanguage:'Seguir el navegador', english:'Inglés', spanish:'Español', + summaries:'Resúmenes habituales de participación', delivery:'El envío de notificaciones no está disponible. Guardar esta opción no envía ninguna notificación.', + memberRole:'Perfil del hogar', activation:'Aprobar el acceso al hogar', activationRequired:'Confirma el acceso al hogar antes de activar a este participante.', + editRole:'Perfil que quieres configurar', mapping:'Asignación de administradores de HA', target:'Cuenta de Home Assistant de destino', chooseTarget:'Elige una cuenta', + mappingHelp:'Para la opción de instalación, elige la cuenta que configuró el hogar. Domus verifica su procedencia, la identidad actual y que quede un administrador antes de guardar.', + directoryUnavailable:'Un nuevo destino necesita datos actuales de la cuenta de HA y de su participación activa en el hogar. Conserva el destino guardado hasta disponer de esos datos.', + savedTarget:'Cuenta guardada; es necesario verificar su elegibilidad', + protected:'Los permisos protegidos de administración no se pueden editar. Estas opciones solo cambian reglas ordinarias de los perfiles.', + permissions:'Excepciones de permisos ordinarios', inherit:'Usar las reglas del perfil', builtIn:'Usar el valor predeterminado', allow:'Permitir', deny:'Denegar', + review:'Revisar cambios', save:'Guardar cambios', cancel:'Cancelar', close:'Cerrar editor', back:'Volver a editar', reset:'Restablecer desde los datos actuales', + submitted:'Revisa el resultado o el aviso de acceso actual antes de hacer otro cambio. Cerrar el editor no revierte un efecto ya procesado.', + hiddenDraft:'Este borrador permanece oculto hasta disponer de sus datos actuales. Se conserva para este participante verificado.', + locked:'Este formulario necesita permiso de escritura actual, valores actuales conocidos y ninguna petición pendiente para el mismo cambio.', + invalid:'Revisa los valores y las confirmaciones necesarias. Se conserva tu borrador; no se ha enviado nada.', + reviewTitle:'Confirma este cambio del hogar', currentRules:'Se aplican las reglas del perfil cuando no hay una excepción guardada.', + requests:'Peticiones originales de administración', requestHelp:'Consulta un resultado original sin enviar otra modificación. El procesamiento histórico no vuelve a crear un efecto.', + check:'Consultar petición original', dismiss:'Ocultar registro resuelto', deadline:'Disponible hasta', operation:'Operación', correlation:'Referencia de petición', + committed:'Petición original procesada', committedBody:'La petición se procesó. Revisa los datos actuales; pueden existir cambios posteriores.', + historical:'Procesada antes de la restauración', historicalBody:'Esta operación se procesó antes de la restauración. Su efecto no está presente en el estado actual.', + uncertain:'Resultado sin confirmar', uncertainBody:'Consulta la petición original antes de hacer otro cambio en estos datos.', + rejected:'Cambio no aceptado', rejectedBody:'Revisa los valores y el acceso actual. Se conserva tu borrador.', + stage_prepared:'Preparada; resultado desconocido', stage_uncertain:'Resultado desconocido', stage_committed:'Procesada', stage_historical:'Procesamiento histórico', stage_rejected:'Rechazada', + role_administrator:'Administrador', role_adult:'Adulto', role_restricted:'Restringido', role_viewer:'Solo lectura', + status_pending:'Pendiente de revisión', status_active:'Activo', status_suspended:'Suspendido', status_archived:'Archivado', + mapping_all_ha_administrators:'Todos los administradores actuales de HA', mapping_setup_administrator:'Administrador que configuró el hogar', mapping_selected_ha_user:'Cuenta de Home Assistant seleccionada', + metadata_absent:'Se ha confirmado que no hay nombre en HA', metadata_denied:'Acceso al nombre de HA denegado', metadata_unavailable:'Nombre de HA no disponible', + notice_authentication_required:'Inicia sesión o vuelve a abrir Domus. Los datos privados anteriores están ocultos.', notice_forbidden:'El acceso actual no permite esta petición. Actualiza antes de continuar.', + notice_invalid_input:'Los valores no se han aceptado. Revísalos junto con la revisión original.', notice_conflict:'Los datos han cambiado. Tu borrador conserva su revisión original; restablécelo explícitamente para adoptar los datos actuales.', + notice_unavailable:'La información solicitada no está disponible. Actualiza o vuelve a consultar la petición original.', notice_invalid_response:'No se ha podido verificar la respuesta. No supongas que un cambio ha fallado.', + notice_api_unavailable:'La API de administración no está disponible en esta instalación.', notice_inactive:'Esta participación no está activa. Debe revisarla un administrador.', + notice_read_only:'Acceso de solo lectura. Las consultas locales y las consultas originales permitidas siguen disponibles hasta que caduque la sesión existente; los nuevos cambios están bloqueados.', + notice_access_changed:'Los permisos actuales o la participación han cambiado. Revisa el acceso actual antes de continuar.', notice_journal_unavailable:'El almacenamiento de recuperación no está disponible. Puedes consultar los datos permitidos; los nuevos cambios están bloqueados.', + notice_action_not_allowed:'Esta acción no está disponible con el acceso y los datos actuales.', notice_request_unavailable:'La petición original no está disponible o ha caducado. No se ha enviado ningún cambio nuevo.', + notice_request_changed:'La petición original guardada ha cambiado. Actualiza; no sustituyas su contenido.', notice_context_changed:'El hogar o el participante verificado ha cambiado. Empieza de nuevo desde los datos actuales.', + notice_revision_exhausted:'Esta revisión no puede avanzar. No se ha enviado ningún cambio; consulta con un administrador.', + notice_previous_request_unresolved:'Consulta la petición original pendiente antes de iniciar otro cambio sobre los mismos datos.', notice_check_unconfirmed:'No se ha podido confirmar el resultado original. Se conserva su registro.', + 'permission_household.read':'Consultar el hogar', 'permission_members.read':'Consultar participantes', 'permission_preferences.read':'Leer las preferencias propias', + 'permission_preferences.write':'Cambiar las preferencias propias', 'permission_operations.read':'Consultar las peticiones originales propias', 'permission_tasks.read':'Consultar tareas', + 'permission_tasks.claim':'Reclamar tareas disponibles', 'permission_tasks.manage':'Gestionar tareas', 'permission_tasks.record_for_others':'Registrar trabajo de otros', + 'permission_tasks.material_use':'Registrar materiales vinculados a tareas', 'permission_tasks.create':'Crear tareas', 'permission_tasks.complete':'Completar tareas', + 'permission_tasks.approve':'Aprobar trabajos realizados', 'permission_shopping.request':'Solicitar compras', 'permission_shopping.manage':'Gestionar la compra', 'permission_inventory.manage':'Gestionar inventario', +}; +export function administrationMessage(key:string,locale:Language):string { + const messages=locale==='es'?es:en; + return Object.hasOwn(messages,key)?messages[key as keyof typeof en]:messages.notice_unavailable; +} diff --git a/frontend/src/presentation/domus-administration.ts b/frontend/src/presentation/domus-administration.ts new file mode 100644 index 0000000..610fad6 --- /dev/null +++ b/frontend/src/presentation/domus-administration.ts @@ -0,0 +1,350 @@ +import {LitElement,css,html,nothing} from 'lit'; +import {live} from 'lit/directives/live.js'; +import {repeat} from 'lit/directives/repeat.js'; +import type {PropertyValues} from 'lit'; +import type {AdministrationView} from '../application/administration-controller'; +import {administrationCommand,MEMBER_ROLES,MEMBER_STATUSES,MAPPING_MODES,ORDINARY_PERMISSIONS} from '../domain/administration-commands'; +import type {AdministrationCommand,MemberRole,PermissionDecision,ProfileDecision} from '../domain/administration-commands'; +import type {AdministrationAction,PublicPresentation,RosterMember} from '../domain/administration-views'; +import {administrationMessage} from './administration-messages'; +import {instant} from './messages'; +import type {Language} from './messages'; +import {styles} from './styles'; + +export type AdministrationIntent = Readonly<{type:'submit';command:AdministrationCommand}> + | Readonly<{type:'recover'|'dismiss';key:string}> | Readonly<{type:'refresh'}>; +type Draft = {key:string;owner:string;action:AdministrationAction;memberId?:string;payload:Record;first:string; + clearAlias:boolean;editRole:MemberRole;activationRequired:boolean;approved:boolean; + review:AdministrationCommand|null;error:string|null;sent:boolean}; +type Option = Readonly<{value:string;label:string;disabled?:boolean}>; + +/** Owned form rendering and draft editing only. Application controls describe availability; the server reauthorizes. */ +export class DomusAdministration extends LitElement { + static properties={view:{attribute:false},language:{attribute:false},pending:{attribute:false},retired:{attribute:false}}; + static styles=[styles,css` + :host {min-height:0;background:transparent} h3{font-size:1rem;margin:0 0 12px} .people{list-style:none;padding:0} + .people>li{border-top:1px solid var(--line);padding:20px 0;overflow-wrap:anywhere}.people>li:first-child{border-top:0} + .rules{display:grid;gap:12px}.rule{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:12px;align-items:center} + .field input[type=checkbox]{width:24px;height:24px} form,fieldset{min-width:0} fieldset{border:0;padding:0;margin:0} + button:disabled{cursor:not-allowed}.actions{margin-bottom:8px}.source{color:var(--muted);font-size:.875rem} + @media(max-width:480px){.rule{grid-template-columns:1fr;gap:4px}} + `]; + declare view:AdministrationView|null; + declare language:Language; + declare pending:boolean; + declare retired:boolean; + private readonly drafts=new Map(); + private owner:string|null=null; + private focusAfter:string|null=null; + constructor(){super();this.view=null;this.language='en';this.pending=false;this.retired=false;} + private t(key:string):string{return administrationMessage(key,this.language);} + private scope(view:AdministrationView|null):string|null { + return view?.session&&view.capabilities!==null&&view.access?.state==='active'&&view.access.member!==null + ?JSON.stringify([view.session.context.installationId,view.session.context.channel,view.session.context.subject,view.access.member.memberId,view.capabilities.householdId]):null; + } + private ready():AdministrationView|null { + const view=this.view; + return !this.retired&&!this.pending&&view?.phase==='ready'&&!view.busy&&this.scope(view)!==null?view:null; + } + protected willUpdate(_changed:PropertyValues):void { + const scope=this.scope(this.view); + if(this.retired||(this.view!==null&&scope===null)||(scope!==null&&this.owner!==null&&scope!==this.owner))this.drafts.clear(); + if(this.retired)this.owner=null;else if(scope!==null)this.owner=scope; + } + protected updated():void { + this.lang=this.language==='es'?'es':'en'; + const id=this.focusAfter;this.focusAfter=null; + if(id!==null){ + const original=this.shadowRoot!.getElementById(id); + const target=original===null||original.matches(':disabled')?this.shadowRoot!.getElementById('administration-refresh'):original; + target?.focus(); + } + } + disconnectedCallback():void {super.disconnectedCallback();this.drafts.clear();this.owner=null;this.view=null;} + private intent(detail:AdministrationIntent):void { + this.dispatchEvent(new CustomEvent('domus-administration-intent',{detail:Object.freeze(detail),bubbles:true,composed:true})); + } + private ownPresentation(view:AdministrationView):PublicPresentation|null { + return view.presentation??view.roster?.members.find(row=>row.member.memberId===view.access!.member!.memberId)?.presentation??null; + } + private ownName(view:AdministrationView):string { + return view.roster?.members.find(row=>row.member.memberId===view.access!.member!.memberId)?.metadata.name + ??view.directory?.profiles.find(profile=>profile.haUserId===view.session!.context.subject)?.name??this.t('unnamed'); + } + private actionTitle(action:AdministrationAction):string { + switch(action){ + case 'household.update':return this.t('household');case 'policy.update':return this.t('policy'); + case 'member.update':return this.t('manage');case 'member.presentation.update':return this.t('alias'); + case 'member.preferences.update':return this.t('preferences'); + } + } + // Rendering checks ready proof; event handlers validate the current draft before inspecting controls. + private enabled(draft:Draft):boolean { + const view=this.view!; + switch(draft.action){ + case 'household.update':return view.controls.household;case 'policy.update':return view.controls.policy; + case 'member.preferences.update':return view.controls.preferences; + case 'member.update':return view.controls.memberIds.includes(draft.memberId!); + case 'member.presentation.update':return draft.memberId===view.access!.member!.memberId + ?view.controls.ownAlias:view.controls.aliasMemberIds.includes(draft.memberId!); + } + } + private current(draft:Draft):boolean { + return this.ready()!==null&&this.drafts.get(draft.key)===draft&&draft.owner===this.scope(this.view); + } + private open(action:AdministrationAction,memberId?:string):void { + const view=this.ready();if(view===null)return; + const owner=this.scope(view)!; + let payload:Record,clearAlias=false,activationRequired=false,first:string; + switch(action){ + case 'household.update':{ + const home=view.household;if(home===null)return; + payload={expected_revision:home.revision,name:home.name,time_zone:home.timeZone};first='name';break; + } + case 'member.preferences.update':{ + const value=view.preferences;if(value===null)return; + payload={expected_revision:value.revision,locale:value.locale??'',routine_participation_summaries:value.routine_participation_summaries};first='locale';break; + } + case 'member.presentation.update':{ + const value=memberId===view.access!.member!.memberId?this.ownPresentation(view):view.roster?.members.find(row=>row.member.memberId===memberId)?.presentation; + if(!value)return; + payload={expected_revision:value.revision,alias:value.alias??''};clearAlias=value.alias===null;first='clear';break; + } + case 'member.update':{ + const value=view.roster?.members.find(row=>row.member.memberId===memberId)?.member;if(!value||value.exceptions===null)return; + payload={expected_revision:value.revision,role:value.status==='pending'?'restricted':value.role,status:value.status,exceptions:[...value.exceptions]}; + activationRequired=value.status!=='active';first='role';break; + } + case 'policy.update':{ + const value=view.policy;if(value===null)return; + payload={expected_revision:value.revision,mapping_mode:value.mappingMode,target_ha_user_id:value.targetHAUserId,adjustments:[...value.adjustments]};first='mapping_mode';break; + } + } + const key=action+':'+(memberId??'own'); + const draft:Draft={key,owner,action,...memberId===undefined?{}:{memberId},payload,first,clearAlias,editRole:'adult',activationRequired, + approved:false,review:null,error:null,sent:false}; + if(!this.enabled(draft))return; + this.drafts.set(key,draft);this.focusAfter=this.fieldId(draft,first);this.requestUpdate(); + } + private fieldId(draft:Draft,field:string):string{return 'draft-'+draft.key+'-'+field;} + private change(draft:Draft,key:string,value:unknown):void { + if(!this.current(draft)||!this.enabled(draft)||draft.sent)return; + draft.payload={...draft.payload,[key]:value};draft.error=null;this.requestUpdate(); + } + private check(draft:Draft,key:'clearAlias'|'approved',value:boolean):void { + if(!this.current(draft)||!this.enabled(draft)||draft.sent)return; + draft[key]=value;draft.error=null;this.requestUpdate(); + } + private review(draft:Draft):void { + if(!this.current(draft)||!this.enabled(draft)||draft.sent)return; + if(draft.activationRequired&&draft.payload.status==='active'&&!draft.approved){draft.error='activationRequired';} + else try { + const payload={...draft.payload}; + if(draft.action==='member.presentation.update')payload.alias=draft.clearAlias?null:payload.alias; + if(draft.action==='member.preferences.update'&&payload.locale==='')payload.locale=null; + if(draft.action==='policy.update'&&payload.mapping_mode==='all_ha_administrators')payload.target_ha_user_id=null; + draft.review=administrationCommand({action:draft.action,payload,...draft.memberId===undefined?{}:{memberId:draft.memberId}}); + draft.error=null; + } catch {draft.error='invalid';} + this.focusAfter=this.fieldId(draft,draft.error===null?'review':draft.error==='activationRequired'?'approved':draft.first);this.requestUpdate(); + } + private save(draft:Draft):void { + if(!this.current(draft)||!this.enabled(draft)||draft.sent||draft.review===null)return; + draft.sent=true;this.intent({type:'submit',command:draft.review});this.requestUpdate(); + } + private close(draft:Draft):void { + if(!this.current(draft))return; + this.drafts.delete(draft.key);this.focusAfter='open-'+draft.key;this.requestUpdate(); + } + private reset(draft:Draft):void { + if(this.current(draft)&&this.enabled(draft))this.open(draft.action,draft.memberId); + } + private field(draft:Draft,key:string,label:string,disabled=false) { + const id=this.fieldId(draft,key); + return html`
this.change(draft,key,(event.target as HTMLInputElement).value)}>
`; + } + private select(draft:Draft,key:string,label:string,options:readonly Option[],disabled=false) { + const id=this.fieldId(draft,key); + return html`
`; + } + private checkbox(draft:Draft,key:'clearAlias'|'approved'|'routine_participation_summaries',label:string) { + const id=this.fieldId(draft,key==='clearAlias'?'clear':key),value=key==='routine_participation_summaries'?draft.payload[key]===true:draft[key]; + return html`
{ + const checked=(event.target as HTMLInputElement).checked; + if(key==='routine_participation_summaries')this.change(draft,key,checked);else this.check(draft,key,checked); + }}>
`; + } + private ruleChange(draft:Draft,permission:string,value:string):void { + if(!this.current(draft)||!this.enabled(draft)||draft.sent)return; + if(!['inherit','allow','deny'].includes(value)){draft.error='invalid';this.requestUpdate();return;} + const profile=draft.action==='policy.update',key=profile?'adjustments':'exceptions'; + const prior=draft.payload[key] as readonly (PermissionDecision|ProfileDecision)[]; + const rules=prior.filter(rule=>rule.permission!==permission||(profile&&(rule as ProfileDecision).role!==draft.editRole)); + if(value!=='inherit')rules.push(profile?{role:draft.editRole,permission,allow:value==='allow'}:{permission,allow:value==='allow'}); + this.change(draft,key,rules); + } + private rules(draft:Draft) { + const profile=draft.action==='policy.update',rules=draft.payload[profile?'adjustments':'exceptions'] as readonly (PermissionDecision|ProfileDecision)[]; + return html`
${this.t('permissions')}

${this.t('protected')}

+ ${ORDINARY_PERMISSIONS.map(permission=>{ + const rule=rules.find(value=>value.permission===permission&&(!profile||(value as ProfileDecision).role===draft.editRole)); + const id=this.fieldId(draft,'rule-'+permission); + return html`
`; + })}
`; + } + private fields(draft:Draft) { + switch(draft.action){ + case 'household.update':return html`${this.field(draft,'name','name')}${this.field(draft,'time_zone','zone')}

${this.t('zoneHelp')}

`; + case 'member.presentation.update':return html`${this.checkbox(draft,'clearAlias','useHA')}${this.field(draft,'alias','alias',draft.clearAlias)}`; + case 'member.preferences.update':return html`${this.select(draft,'locale','savedLanguage',[ + {value:'',label:this.t('browserLanguage')},{value:'en',label:this.t('english')},{value:'es',label:this.t('spanish')}])} + ${this.checkbox(draft,'routine_participation_summaries','summaries')}

${this.t('delivery')}

`; + case 'member.update':return html`${this.select(draft,'role','memberRole',MEMBER_ROLES.map(value=>({value,label:this.t('role_'+value)})))} + ${this.select(draft,'status','status',MEMBER_STATUSES.map(value=>({value,label:this.t('status_'+value)})))} + ${draft.activationRequired&&draft.payload.status==='active'?this.checkbox(draft,'approved','activation'):nothing}${this.rules(draft)}`; + case 'policy.update':{ + const view=this.ready()!,directory=view.directory; + const targets=directory?.state==='available'?directory.profiles.filter(profile=>profile.haUserId===view.session!.context.subject + ||view.roster?.members.some(row=>row.member.status==='active'&&row.member.haUserId===profile.haUserId)):[]; + const available=targets.length>0; + const options=MAPPING_MODES.map(value=>({value,label:this.t('mapping_'+value),disabled:value!=='all_ha_administrators'&&!available&&value!==draft.payload.mapping_mode})); + const targetOptions:Option[]=[{value:'',label:this.t('chooseTarget')},...targets.map(profile=>({value:profile.haUserId, + label:(profile.name??this.t('unnamed'))+' · '+profile.haUserId.slice(-6)}))]; + const saved=draft.payload.target_ha_user_id; + if(typeof saved==='string'&&saved!==''&&!targets.some(profile=>profile.haUserId===saved)) + targetOptions.push({value:saved,label:this.t('savedTarget')+' · '+saved.slice(-6),disabled:true}); + const id=this.fieldId(draft,'editRole'); + return html`${this.select(draft,'mapping_mode','mapping',options)}

${this.t('mappingHelp')}

+ ${draft.payload.mapping_mode!=='all_ha_administrators'?this.select(draft,'target_ha_user_id','target',targetOptions,!available):nothing} + ${!available?html`

${this.t('directoryUnavailable')}

`:nothing} +
${this.rules(draft)}`; + } + } + } + private ruleSummary(rules:readonly PermissionDecision[]|readonly ProfileDecision[]) { + return rules.length?html`
    ${rules.map(rule=>html`
  • ${'role'in rule?this.t('role_'+rule.role)+' · ':nothing}${this.t('permission_'+rule.permission)}: ${this.t(rule.allow?'allow':'deny')}
  • `)}
` + :html`

${this.t('currentRules')}

`; + } + private summary(command:AdministrationCommand) { + switch(command.action){ + case 'household.update':return html`
${this.t('name')}
${command.payload.name}
${this.t('zone')}
${command.payload.time_zone}
`; + case 'member.presentation.update':return html`

${this.t('alias')}: ${command.payload.alias??this.t('useHA')}

`; + case 'member.preferences.update':return html`

${this.t('savedLanguage')}: ${this.t(command.payload.locale==='en'?'english':command.payload.locale==='es'?'spanish':'browserLanguage')}

+

${this.t('summaries')}: ${this.t(command.payload.routine_participation_summaries?'allow':'deny')}

${this.t('delivery')}

`; + case 'member.update':return html`

${this.t('memberRole')}: ${this.t('role_'+command.payload.role)}

${this.t('status')}: ${this.t('status_'+command.payload.status)}

${this.ruleSummary(command.payload.exceptions)}`; + case 'policy.update':return html`

${this.t('mapping')}: ${this.t('mapping_'+command.payload.mapping_mode)}

+ ${command.payload.target_ha_user_id===null?nothing:html`

${this.t('target')}: …${command.payload.target_ha_user_id.slice(-6)}

`}${this.ruleSummary(command.payload.adjustments)}`; + } + } + private readable(draft:Draft,view:AdministrationView):boolean { + switch(draft.action){ + case 'household.update':return view.household!==null; + case 'policy.update':return view.policy!==null; + case 'member.preferences.update':return view.preferences!==null; + case 'member.presentation.update':return draft.memberId===view.access!.member!.memberId + ?this.ownPresentation(view)!==null:view.roster?.members.some(row=>row.member.memberId===draft.memberId)===true; + case 'member.update':return view.roster?.members.some(row=>row.member.memberId===draft.memberId&&row.member.exceptions!==null)===true; + } + } + private editor(draft:Draft,view:AdministrationView) { + if(!this.readable(draft,view))return html`
+

${this.actionTitle(draft.action)}

${this.t('hiddenDraft')}

+
`; + const enabled=this.enabled(draft); + const row=view.roster?.members.find(row=>row.member.memberId===draft.memberId); + const target=draft.memberId===undefined?null:row?this.display(row):this.ownPresentation(view)!.alias??this.ownName(view); + return html`

${this.actionTitle(draft.action)}

+ ${target===null?nothing:html`

${this.t('participant')}: ${target}

+

${this.t('memberReference')}: ${draft.memberId}

`} +

${this.t('revision')}: ${String(draft.payload.expected_revision)}

+ ${draft.error===null?nothing:html``} + ${!enabled?html`

${this.t('locked')}

`:nothing} + ${draft.review===null?html`
{event.preventDefault();this.review(draft);}}> + ${this.fields(draft)}
+
` + :html`

${this.t('reviewTitle')}

${this.summary(draft.review)} + ${draft.sent?html`

${this.t('submitted')}

`:nothing}
+ + +
`} +
`; + } + private openButton(label:string,action:AdministrationAction,enabled:boolean,memberId?:string,display?:string) { + return html``; + } + private display(row:RosterMember):string{return row.presentation.alias??row.metadata.name??this.t('unnamed');} + private participants(view:AdministrationView) { + const roster=view.roster; + return html`

${this.t('participants')}

+ ${roster===null?html`

${this.t('unavailable')}

`:html`
    ${repeat(roster.members,row=>row.member.memberId,row=>html`
  • + ${this.display(row)}

    ${this.t('haName')}: ${row.metadata.name??this.t('metadata_'+row.metadata.state)}

    +

    ${this.t('storedProfile')}: ${this.t('role_'+row.member.role)} · ${this.t('status_'+row.member.status)}

    +

    ${this.t('avatar')}

    + ${this.openButton('manage','member.update',view.controls.memberIds.includes(row.member.memberId),row.member.memberId,this.display(row))} + ${row.member.memberId===view.access!.member!.memberId?nothing:this.openButton('otherAlias','member.presentation.update',view.controls.aliasMemberIds.includes(row.member.memberId),row.member.memberId,this.display(row))} +
  • `)}
`}
`; + } + private outcome(view:AdministrationView) { + const result=view.outcome;if(result===null)return nothing; + const kind=result.kind==='receipt'?result.stage:result.kind; + return html`

${this.t(kind)}

${this.t(kind+'Body')}

+ ${result.kind==='receipt'?html`

${this.t('operation')}: ${result.operationId}

`:nothing} + ${result.kind==='rejected'?html`

${this.t('notice_'+result.reason)}

${result.correlationId===null?nothing:html`

${this.t('correlation')}: ${result.correlationId}

`}`:nothing}
`; + } + private requests(view:AdministrationView) { + const records=view.journal?.records;if(!records?.length)return nothing; + return html`

${this.t('requests')}

${this.t('requestHelp')}

+
    ${records.map(record=>html`
  • ${this.actionTitle(record.action)}

    ${this.t('stage_'+record.stage)}

    +

    ${this.t('deadline')}: ${instant(record.expiresAt,this.language)}

    + ${record.operationId===null?nothing:html`

    ${this.t('operation')}: ${record.operationId}

    `} +
    +
  • `)}
`; + } + private original(type:'recover'|'dismiss',key:string):void { + const view=this.ready();if(view===null)return; + if((type==='recover'?view.controls.recoverableKeys:view.controls.dismissibleKeys).includes(key))this.intent({type,key}); + } + protected render() { + const view=this.ready(); + if(view===null){ + if(this.pending||this.view?.busy||this.view?.phase==='loading')return html`

${this.t('checking')}

${this.t('checkingBody')}

`; + if(this.view===null||this.retired)return nothing; + return html`

${this.t('title')}

${this.t('notice_'+(this.view.notice??'unavailable'))}

+
`; + } + const person=view.access!.member!,own=this.ownPresentation(view); + const haName=this.ownName(view); + return html`

${this.t('title')}

+ ${view.notice===null?nothing:html`

${this.t('notice_'+view.notice)}

`}${this.outcome(view)} +

${this.t('ownProfile')}

${own?.alias??haName}

${this.t('haName')}: ${haName}

+

${this.t('storedProfile')}: ${this.t('role_'+person.role)}

${this.t('effectiveProfile')}: ${this.t('role_'+view.access!.effectiveRole)}

+

${this.t('status')}: ${this.t('status_'+person.status)}

${this.t('avatar')}

+ ${this.openButton('editAlias','member.presentation.update',view.controls.ownAlias,person.memberId)} + ${this.openButton('editPreferences','member.preferences.update',view.controls.preferences)}
+ ${this.participants(view)}

${this.t('household')}

${this.openButton('editHousehold','household.update',view.controls.household)}
+

${this.t('policy')}

${this.t('protected')}

${this.openButton('editPolicy','policy.update',view.controls.policy)}
+ ${[...this.drafts.values()].map(draft=>this.editor(draft,view))}${this.requests(view)} +
`; + } +} +customElements.define('domus-administration',DomusAdministration); +declare global {interface HTMLElementTagNameMap {'domus-administration':DomusAdministration;}} diff --git a/frontend/src/presentation/domus-app.ts b/frontend/src/presentation/domus-app.ts index 1923dbb..03c469c 100644 --- a/frontend/src/presentation/domus-app.ts +++ b/frontend/src/presentation/domus-app.ts @@ -2,6 +2,8 @@ import {LitElement, html, nothing} from 'lit'; import {live} from 'lit/directives/live.js'; import type {PropertyValues} from 'lit'; import type {EntryView} from '../application/entry-controller'; +import type {AdministrationView} from '../application/administration-controller'; +import './domus-administration'; import type {Deployment} from '../domain/entry'; import type {JournalRecord} from '../domain/journal'; import {instant, language, message} from './messages'; @@ -15,16 +17,17 @@ export type EntryIntent = Readonly<{type: 'refresh' | 'request_membership' | 'lo /** Owned rendering only; intents cross a semantic boundary and never grant command authority. */ export class DomusApp extends LitElement { - static properties = {view: {attribute: false}, deployment: {attribute: false}, language: {attribute: false}, + static properties = {view: {attribute: false}, administration: {attribute:false}, deployment: {attribute: false}, language: {attribute: false}, nameDraft: {state: true}, zoneDraft: {state: true}}; static styles = styles; declare view: EntryView | null; + declare administration:AdministrationView|null; declare deployment: Deployment; declare language: Language; private declare nameDraft: string; private declare zoneDraft: string; private draftScope: string | null = null; - constructor() {super(); this.view = null; this.deployment = 'standalone'; this.language = 'en'; this.nameDraft = ''; this.zoneDraft = '';} + constructor() {super(); this.view = null; this.administration=null; this.deployment = 'standalone'; this.language = 'en'; this.nameDraft = ''; this.zoneDraft = '';} disconnectedCallback(): void {super.disconnectedCallback(); this.dispatchEvent(new Event('domus-detached'));} private text(key: MessageKey): string {return message(key, this.language);} private intent(detail: EntryIntent): void { @@ -117,6 +120,9 @@ export class DomusApp extends LitElement { ${!loading && decision?.action === 'contact_administrator' ? html`

${this.text('contact')}

` : nothing} ${!closed && view?.busy ? html`

${this.text('busy')}

` : nothing} + ${view?.journal?.records.length ? html`

${this.text('journalTitle')}

${this.text('journalBody')}

    ${view.journal.records.map(record => this.request(record))}
` : nothing} ${view?.session ? html`
${this.text('details')}
${this.text('status')}
${this.text(`state_${state}`)}
diff --git a/frontend/stories/administration.stories.ts b/frontend/stories/administration.stories.ts new file mode 100644 index 0000000..55b0d47 --- /dev/null +++ b/frontend/stories/administration.stories.ts @@ -0,0 +1,65 @@ +import {html} from 'lit'; +import type {Meta,StoryObj} from '@storybook/web-components-vite'; +import type {AdministrationView} from '../src/application/administration-controller'; +import {revisionValue} from '../src/domain/administration'; +import type {Language} from '../src/presentation/messages'; +import '../src/presentation/domus-administration'; + +type Example='active'|'read-only'|'sources-unavailable'|'uncertain'|'historical'|'maximum-labels'; +type Arguments={example:Example;language:Language;deployment:'native'|'standalone'}; +const time=1_800_000_000_000; +const context={installationId:'01234567-89ab-cdef-0123-456789abcdef',channel:'edge' as const,subject:'a'.repeat(32)}; +/** Fictional view data only; actions emit intents and never manufacture a server result. */ +function fixture(example:Example,deployment:Arguments['deployment']):AdministrationView { + const readOnly=example==='read-only',unavailable=example==='sources-unavailable'; + const member={memberId:'fictional-owner',haUserId:context.subject,role:'adult' as const,status:'active' as const,revision:revisionValue('3'),exceptions:[]}; + const presentation={memberId:member.memberId,alias:null,revision:revisionValue('11')}; + const journaled=example==='uncertain'||example==='historical'; + const stage=example==='historical'?'historical' as const:'uncertain' as const; + const view:AdministrationView={phase:'ready',busy:false,session:{context,deployment,access:readOnly?'read_only':'full',serverTime:time,expiresAt:time+900_000,householdApiAvailable:true}, + access:{state:'active',setupAllowed:false,enrollmentAllowed:false,member,effectiveRole:'administrator',policyRevision:revisionValue('5'), + permissions:['household.read','household.manage','members.read','members.manage','policy.manage','preferences.read','preferences.write','operations.read']}, + capabilities:{contractVersion:2,householdId:'fictional-home',actions:['household.update','member.update','policy.update','member.presentation.update','member.preferences.update']}, + household:{householdId:'fictional-home',name:'Maple House',timeZone:'Europe/Madrid',revision:revisionValue('7')}, + policy:{householdId:'fictional-home',mappingMode:'all_ha_administrators',targetHAUserId:null,adjustments:[],revision:revisionValue('5')}, + directory:unavailable?{state:'denied',profiles:[]}:{state:'available',profiles:[{haUserId:context.subject,name:'Fictional owner'}]}, + roster:{directoryState:unavailable?'denied':'available',members:[{member,presentation, + metadata:{state:unavailable?'denied':'available',name:unavailable?null:'Fictional owner',avatarState:'unavailable'}}, + {member:{...member,memberId:'fictional-pending',haUserId:'b'.repeat(32),role:'restricted',status:'pending'}, + presentation:{memberId:'fictional-pending',alias:null,revision:revisionValue('1')}, + metadata:{state:'available',name:'Fictional pending participant',avatarState:'unavailable'}}]}, + presentation,preferences:{memberId:member.memberId,locale:null,routine_participation_summaries:true,revision:revisionValue('13'),notificationDelivery:'unavailable'}, + errors:{},notice:readOnly?'read_only':null, + journal:{context,observedAt:time,records:journaled?[{action:'member.preferences.update',payload:{expected_revision:revisionValue('12'),locale:null,routine_participation_summaries:true}, + context,key:'fictional-original',createdAt:time,expiresAt:time+3_600_000,stage,operationId:example==='historical'?'op_fictional':null}]:[]}, + outcome:example==='uncertain'?{kind:'uncertain'}:example==='historical'?{kind:'receipt',stage:'historical',action:'member.preferences.update',operationId:'op_fictional', + archivedBy:'restore_fictional',createdAt:time,result:{memberId:member.memberId,locale:null,routine_participation_summaries:true,notificationDelivery:'unavailable',revision:revisionValue('13')},notificationDelivery:'unavailable',eventDelivery:'suppressed'}:null, + controls:{household:!readOnly,policy:!readOnly,preferences:!readOnly&&example!=='uncertain',ownAlias:!readOnly,memberIds:readOnly?[]:['fictional-owner','fictional-pending'], + aliasMemberIds:readOnly?[]:['fictional-pending'],recoverableKeys:journaled?['fictional-original']:[],dismissibleKeys:example==='historical'?['fictional-original']:[]}}; + if(example!=='maximum-labels')return view; + const template=view.roster!.members[0],glyphs=Array.from('😀😁😂😃😄😅😆😇😈😉😊😋😌😍😎😏'); + // Exact supported Unicode/UTF-8 label bounds, with independent fictional identities. + const members=Array.from({length:256},(_unused,index)=>{ + const suffix=glyphs[Math.floor(index/16)]+glyphs[index%16],memberId=index===0?template.member.memberId:'fictional-roster-'+index; + return {...template,member:{...template.member,memberId,haUserId:index===0?template.member.haUserId:index.toString(16).padStart(32,'0'), + role:index===0?template.member.role:'restricted' as const,status:'active' as const}, + presentation:{...template.presentation,memberId,alias:index%2===0?null:'🏡'.repeat(78)+suffix}, + metadata:{...template.metadata,state:'available' as const,name:'😀'.repeat(254)+suffix}}; + }); + return {...view,roster:{directoryState:'available',members},presentation:members[0].presentation, + directory:{state:'available',profiles:members.map(row=>({haUserId:row.member.haUserId!,name:row.metadata.name}))}, + controls:{...view.controls,memberIds:members.map(row=>row.member.memberId),aliasMemberIds:members.slice(1).map(row=>row.member.memberId)}}; +} +const meta:Meta={title:'Domus/Household administration',args:{example:'active',language:'en',deployment:'native'}, + argTypes:{language:{control:'inline-radio',options:['en','es']},deployment:{control:'inline-radio',options:['native','standalone']},example:{control:false}}, + render:args=>html`

+ Fictional administration state. No server is connected; actions emit intents only.

+ `}; +export default meta; +type Story=StoryObj; +export const Active:Story={}; +export const ReadOnly:Story={args:{example:'read-only'}}; +export const SourcesUnavailable:Story={args:{example:'sources-unavailable'}}; +export const Uncertain:Story={args:{example:'uncertain'}}; +export const Historical:Story={args:{example:'historical'}}; +export const MaximumLabels:Story={args:{example:'maximum-labels'}}; diff --git a/frontend/tests/administration-component.test.ts b/frontend/tests/administration-component.test.ts new file mode 100644 index 0000000..ee9220c --- /dev/null +++ b/frontend/tests/administration-component.test.ts @@ -0,0 +1,431 @@ +import {afterEach, expect, test} from 'vitest'; +import {DomusAdministration} from '../src/presentation/domus-administration'; +import type {AdministrationIntent} from '../src/presentation/domus-administration'; +import type {AdministrationView} from '../src/application/administration-controller'; +import {revisionValue} from '../src/domain/administration'; +import {administrationMessage} from '../src/presentation/administration-messages'; + +const context = {installationId:'01234567-89ab-cdef-0123-456789abcdef',channel:'edge' as const,subject:'a'.repeat(32)}; +const member = {memberId:'owner',haUserId:context.subject,role:'viewer' as const,status:'active' as const, + revision:revisionValue('3'),exceptions:[]}; +const presentation = {memberId:'owner',alias:'My alias',revision:revisionValue('11')}; +function view(extra: Partial = {}): AdministrationView { + return {phase:'ready',busy:false,session:{context,deployment:'standalone',access:'full',serverTime:1000, + expiresAt:100000,householdApiAvailable:true},access:{state:'active',setupAllowed:false,enrollmentAllowed:false, + member,effectiveRole:'administrator',policyRevision:revisionValue('5'),permissions:['household.read','household.manage', + 'members.read','members.manage','policy.manage','preferences.read','preferences.write','operations.read']}, + capabilities:{contractVersion:2,householdId:'home',actions:['household.update','member.update','policy.update','member.presentation.update','member.preferences.update']}, + household:{householdId:'home',name:'Household',timeZone:'UTC',revision:revisionValue('7')}, + policy:{householdId:'home',revision:revisionValue('5'),mappingMode:'all_ha_administrators',targetHAUserId:null, + adjustments:[{role:'restricted',permission:'shopping.request',allow:false}]}, + directory:{state:'available',profiles:[{haUserId:context.subject,name:'Verified owner'},{haUserId:'b'.repeat(32),name:'Pending member'}]}, + presentation,preferences:{memberId:'owner',locale:'es',routine_participation_summaries:true,revision:revisionValue('13'),notificationDelivery:'unavailable'}, + roster:{directoryState:'available',members:[{member,presentation,metadata:{state:'available',name:'Verified owner',avatarState:'unavailable'}}, + {member:{...member,memberId:'other',haUserId:'b'.repeat(32),status:'pending'},presentation:{...presentation,memberId:'other',alias:null}, + metadata:{state:'available',name:'Pending member',avatarState:'unavailable'}}]}, + errors:{},notice:null,outcome:null,journal:{context,observedAt:1000,records:[]}, + controls:{household:true,policy:true,preferences:true,ownAlias:true,memberIds:['owner','other'],aliasMemberIds:['other'],recoverableKeys:[],dismissibleKeys:[]},...extra}; +} +const owned: DomusAdministration[] = []; +afterEach(()=>{for(const element of owned.splice(0)) element.remove();}); +async function mount(value: AdministrationView|null = view(), locale:'en'|'es'='en') { + const element = document.createElement('domus-administration'); element.view=value; element.language=locale; + const intents:AdministrationIntent[]=[]; + element.addEventListener('domus-administration-intent',event=>{ + const actual=event as CustomEvent;expect(actual.bubbles).toBe(true);expect(actual.composed).toBe(true); + expect(Object.isFrozen(actual.detail)).toBe(true);intents.push(actual.detail); + }); + document.body.append(element);owned.push(element);await element.updateComplete;const root=element.shadowRoot!; + const button=(name:string)=>[...root.querySelectorAll('button')].find(item=>(item.getAttribute('aria-label')??item.textContent?.trim())===name)!; + const click=async(name:string)=>{expect(button(name),name).toBeDefined();button(name).click();await element.updateComplete;}; + const input=(name:string)=>{ + const label=[...root.querySelectorAll('label')].find(item=>item.textContent?.trim()===name)!;expect(label,name).toBeDefined(); + return root.getElementById(label.htmlFor) as HTMLInputElement|HTMLSelectElement; + }; + const change=async(name:string,value:string|boolean)=>{ + const field=input(name);if(typeof value==='boolean'){expect(field).toBeInstanceOf(HTMLInputElement);(field as HTMLInputElement).checked=value;} + else field.value=value; + field.dispatchEvent(new Event(field instanceof HTMLSelectElement||field.type==='checkbox'?'change':'input',{bubbles:true}));await element.updateComplete; + }; + return {element,root,intents,button,click,input,change,text:()=>root.textContent!}; +} + +test('HADB-03: the owned widget shows current stored and effective roles without emitting work',async()=>{ + const f=await mount();expect(f.element).toBeInstanceOf(DomusAdministration);expect(f.text()).toContain('Verified owner'); + expect(f.text()).toContain('Viewer');expect(f.text()).toContain('Administrator');expect(f.text()).toContain('Pending member'); + expect(f.text()).not.toContain(context.installationId);expect(f.text()).not.toContain(context.subject);expect(f.intents).toEqual([]); +}); +test('household-browser-C15: hostile HA names and aliases remain inert text',async()=>{ + const current=view();const hostile=''; + const f=await mount({...current,presentation:{...presentation,alias:hostile},roster:{...current.roster!,members:[ + {...current.roster!.members[0],presentation:{...presentation,alias:hostile},metadata:{state:'available',name:hostile,avatarState:'unavailable'}}]}}); + expect(f.text()).toContain(hostile);expect(f.root.querySelector('img,script')).toBeNull();expect(f.intents).toEqual([]); +}); +test('HADW-01: own alias requires review then explicit confirmation with its independent exact revision',async()=>{ + const f=await mount(view({presentation:{...presentation,revision:revisionValue('9007199254740993')}})); + await f.click('Edit own alias');await f.change('Domus alias',' Literal name ');await f.click('Review changes'); + expect(f.intents).toEqual([]);expect(f.text()).toContain('9007199254740993');await f.click('Save changes'); + expect(f.intents).toEqual([{type:'submit',command:{action:'member.presentation.update',memberId:'owner', + payload:{expected_revision:'9007199254740993',alias:' Literal name '}}}]); + expect(f.button('Save changes').disabled).toBe(true);expect(f.root.querySelector('b')).toBeNull(); +}); +test('HADW-01: clearing an alias requires the explicit HA-name choice rather than an empty value',async()=>{ + const f=await mount();await f.click('Edit own alias');await f.change('Domus alias','');await f.click('Review changes'); + expect(f.root.querySelector('[role=alert]')).not.toBeNull();expect(f.intents).toEqual([]); + await f.change('Use Home Assistant name',true);await f.click('Review changes');await f.click('Save changes'); + expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{expected_revision:'11',alias:null}}}); +}); +test('HADW-02: own preference values have their own revision and honest unavailable delivery',async()=>{ + const f=await mount();await f.click('Edit preferences');await f.change('Saved language','en'); + await f.change('Routine participation summaries',false);await f.click('Review changes');await f.click('Save changes'); + expect(f.intents).toEqual([{type:'submit',command:{action:'member.preferences.update',payload:{expected_revision:'13',locale:'en',routine_participation_summaries:false}}}]); + expect(f.text()).toContain('Notification delivery is unavailable'); +}); +test('HADW-04: household edits retain the original revision and exact timezone until explicit submission',async()=>{ + const f=await mount();await f.click('Edit household');await f.change('New household name','Family');await f.change('New timezone','Europe/Madrid'); + await f.click('Review changes');await f.click('Save changes');expect(f.intents).toEqual([{type:'submit',command:{action:'household.update', + payload:{expected_revision:'7',name:'Family',time_zone:'Europe/Madrid'}}}]); +}); +test('HADW-04: pending activation defaults to restricted and needs an explicit access confirmation',async()=>{ + const f=await mount();await f.click('Manage participant: Pending member');expect(f.input('Household profile').value).toBe('restricted'); + await f.change('Membership status','active');await f.click('Review changes');expect(f.intents).toEqual([]); + expect(f.root.querySelector('[role=alert]')).not.toBeNull();await f.change('Approve household access',true); + await f.click('Review changes');await f.click('Save changes');expect(f.intents).toEqual([{type:'submit',command:{action:'member.update',memberId:'other', + payload:{expected_revision:'3',role:'restricted',status:'active',exceptions:[]}}}]); +}); +test('HADW-05: a profile adjustment preserves the untouched mapping and other profile rules',async()=>{ + const f=await mount();await f.click('Edit access rules');await f.change('Profile to customize','adult'); + await f.change('View household','deny');await f.click('Review changes');await f.click('Save changes'); + const intent=f.intents[0];expect(intent.type).toBe('submit');if(intent.type!=='submit')throw new Error('Expected explicit policy intent'); + expect(intent.command).toMatchObject({action:'policy.update',payload:{expected_revision:'5',mapping_mode:'all_ha_administrators',target_ha_user_id:null}}); + if(intent.command.action!=='policy.update')throw new Error('Expected policy action'); + expect(intent.command.payload.adjustments).toHaveLength(2);expect(intent.command.payload.adjustments).toEqual(expect.arrayContaining([ + {role:'restricted',permission:'shopping.request',allow:false},{role:'adult',permission:'household.read',allow:false}])); +}); +test('household-browser-C17: translation and a newer current fact do not rebase a safe alias draft',async()=>{ + const f=await mount();await f.click('Edit own alias');await f.change('Domus alias','Local draft'); + f.element.language='es';f.element.view=view({presentation:{...presentation,alias:'Remote value',revision:revisionValue('12')}});await f.element.updateComplete; + expect(f.input('Alias de Domus').value).toBe('Local draft');await f.click('Revisar cambios');await f.click('Guardar cambios'); + expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{expected_revision:'11',alias:'Local draft'}}}); +}); +test('household-browser-C13: same-account member replacement cannot retain the former private preference draft',async()=>{ + const f=await mount();await f.click('Edit preferences');await f.change('Saved language','en'); + const next=view();f.element.view={...next,access:{...next.access!,member:{...member,memberId:'replacement'}}, + presentation:{...presentation,memberId:'replacement'},preferences:{...next.preferences!,memberId:'replacement'}}; + await f.element.updateComplete;await f.click('Edit preferences');expect(f.input('Saved language').value).toBe('es');expect(f.intents).toEqual([]); +}); +test('HADB-01: checking hides private form values and preserves only a same-owner draft until proof returns',async()=>{ + const current=view(),f=await mount(current);await f.click('Edit own alias');await f.change('Domus alias','Unsent private draft'); + f.element.pending=true;f.element.view=null;await f.element.updateComplete;expect(f.text()).not.toContain('Unsent private draft'); + expect(f.root.querySelector('input')).toBeNull();f.element.pending=false;f.element.view=current;await f.element.updateComplete; + expect(f.input('Domus alias').value).toBe('Unsent private draft');f.element.retired=true;f.element.view=null;await f.element.updateComplete; + f.element.retired=false;f.element.view=current;await f.element.updateComplete;await f.click('Edit own alias'); + expect(f.input('Domus alias').value).toBe('My alias');expect(f.intents).toEqual([]); +}); +test('HADB-03: application controls govern visible edits even when an old permission label still appears',async()=>{ + const current=view(),f=await mount({...current,controls:{...current.controls,household:false,policy:false,preferences:false,ownAlias:false,memberIds:[],aliasMemberIds:[]}}); + expect(f.button('Edit own alias').disabled).toBe(true);expect(f.button('Edit preferences').disabled).toBe(true); + f.button('Edit preferences').click();expect(f.root.querySelector('form')).toBeNull();expect(f.intents).toEqual([]); +}); +test('HADW-05: an HA account with pending household membership is not offered as a new mapping target',async()=>{ + const f=await mount();await f.click('Edit access rules');await f.change('HA administrator mapping','selected_ha_user'); + const options=[...(f.input('Target Home Assistant account') as HTMLSelectElement).options]; + expect(options.some(option=>option.value===context.subject)).toBe(true);expect(options.some(option=>option.value==='b'.repeat(32))).toBe(false); +}); +test('HADW-05: unavailable directory facts retain the stored target visibly while other profile rules remain editable',async()=>{ + const current=view();const f=await mount({...current,directory:{state:'denied',profiles:[]},policy:{...current.policy!,mappingMode:'selected_ha_user',targetHAUserId:context.subject}}); + await f.click('Edit access rules');const target=f.input('Target Home Assistant account');expect(target.value).toBe(context.subject);expect(target.disabled).toBe(true); + await f.change('View household','allow');await f.click('Review changes');await f.click('Save changes'); + expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{mapping_mode:'selected_ha_user',target_ha_user_id:context.subject}}}); +}); +test('HADW-05: explicitly switching to all HA administrators clears the formerly captured target',async()=>{ + const current=view(),f=await mount({...current,policy:{...current.policy!,mappingMode:'selected_ha_user',targetHAUserId:context.subject}}); + await f.click('Edit access rules');await f.change('HA administrator mapping','all_ha_administrators');await f.click('Review changes');await f.click('Save changes'); + expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{mapping_mode:'all_ha_administrators',target_ha_user_id:null}}}); +}); +test('HADW-05: an explicitly selected active participant remains a distinct target rather than a display-name identity',async()=>{ + const current=view(),other=current.roster!.members[1];const f=await mount({...current,roster:{...current.roster!,members:[current.roster!.members[0],{...other,member:{...other.member,status:'active'}}]}}); + await f.click('Edit access rules');await f.change('HA administrator mapping','selected_ha_user');await f.change('Target Home Assistant account','b'.repeat(32)); + await f.click('Review changes');await f.click('Save changes');expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{target_ha_user_id:'b'.repeat(32)}}}); +}); + +const draftFactCases = [ + {kind:'household', open:'Edit household', field:'New household name', value:'Private household draft', + missing:(current:AdministrationView):AdministrationView=>({...current,household:null,controls:{...current.controls,household:false}})}, + {kind:'preferences', open:'Edit preferences', field:'Saved language', value:'en', + missing:(current:AdministrationView):AdministrationView=>({...current,preferences:null,controls:{...current.controls,preferences:false}})}, + {kind:'own alias', open:'Edit own alias', field:'Domus alias', value:'Private own alias draft', + missing:(current:AdministrationView):AdministrationView=>({...current,presentation:null,roster:null,controls:{...current.controls,ownAlias:false,memberIds:[],aliasMemberIds:[]}})}, + {kind:'other alias', open:'Change alias: Pending member', field:'Domus alias', value:'Private other alias draft', + missing:(current:AdministrationView):AdministrationView=>({...current,roster:null,controls:{...current.controls,memberIds:[],aliasMemberIds:[]}})}, + {kind:'member', open:'Manage participant: Pending member', field:'Household profile', value:'adult', + missing:(current:AdministrationView):AdministrationView=>({...current,roster:{...current.roster!,members:current.roster!.members.map(row=>({...row,member:{...row.member,exceptions:null}}))},controls:{...current.controls,memberIds:[]}})}, + {kind:'policy', open:'Edit access rules', field:'Profile to customize', value:'restricted', + missing:(current:AdministrationView):AdministrationView=>({...current,policy:null,controls:{...current.controls,policy:false}})}, +]; +for(const scenario of draftFactCases) test('HADB-03: '+scenario.kind+' drafts hide when current readable facts disappear and return only to the same owner',async()=>{ + const current=view(),f=await mount(current);await f.click(scenario.open); + if(scenario.kind==='other alias')await f.change('Use Home Assistant name',false); + await f.change(scenario.field,scenario.value); + f.element.view=scenario.missing(current);await f.element.updateComplete; + expect(f.root.querySelector('form, input, select'),scenario.kind+' previous fields must be hidden').toBeNull(); + expect(f.text()).not.toContain('Original revision');expect(f.text()).toContain('This draft is hidden until its current data is available'); + expect(f.intents).toEqual([]); + f.element.view=current;await f.element.updateComplete;expect(f.input(scenario.field).value).toBe(scenario.value); + expect(f.intents).toEqual([]); +}); +test('HADB-03: a confirmed private preference draft and its detached save callback stay hidden and inert after read loss',async()=>{ + const current=view(),f=await mount(current);await f.click('Edit preferences');await f.change('Saved language','en');await f.click('Review changes'); + const save=f.button('Save changes'); + f.element.view={...current,preferences:null,controls:{...current.controls,preferences:false}};await f.element.updateComplete; + expect(f.text()).not.toContain('Original revision');expect(f.text()).not.toContain('Saved language:');expect(f.button('Save changes')).toBeUndefined(); + save.dispatchEvent(new MouseEvent('click'));await f.element.updateComplete;expect(f.intents).toEqual([]); + f.element.view=view({preferences:{...current.preferences!,locale:'es',revision:revisionValue('14')}});await f.element.updateComplete; + await f.click('Save changes');expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{expected_revision:'13',locale:'en'}}}); +}); + +test('HADW-01: own alias falls back to permitted roster facts independently of directory availability',async()=>{ + const current=view(),f=await mount({...current,presentation:null,directory:null}); + await f.click('Edit own alias');expect(f.input('Domus alias').value).toBe('My alias'); + await f.change('Use Home Assistant name',true);await f.click('Review changes');await f.click('Save changes'); + expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{expected_revision:'11',alias:null}}}); +}); +test('HADW-02: browser-language preferences explicitly save null without implying notification delivery',async()=>{ + const current=view(),f=await mount({...current,preferences:{...current.preferences!,locale:null,routine_participation_summaries:false}}); + await f.click('Edit preferences');expect(f.input('Saved language').value).toBe('');await f.click('Review changes');await f.click('Save changes'); + expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{locale:null,routine_participation_summaries:false}}}); + expect(f.text()).toContain('Follow the browser');expect(f.text()).toContain('sends no notification'); +}); +test('HADB-02: only explicit reset adopts newer facts; cancel and close restore focus without submitting',async()=>{ + const current=view(),f=await mount(current);await f.click('Edit household');await f.change('New household name','Typed draft'); + f.element.view=view({household:{...current.household!,name:'Changed remotely',revision:revisionValue('8')}});await f.element.updateComplete; + expect(f.input('New household name').value).toBe('Typed draft');await f.click('Reset from current data'); + expect(f.input('New household name').value).toBe('Changed remotely');expect(f.root.activeElement).toBe(f.input('New household name')); + await f.click('Review changes');await f.click('Back to editing');expect(f.input('New household name').value).toBe('Changed remotely'); + await f.click('Review changes');await f.click('Save changes');expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{expected_revision:'8'}}}); + await f.click('Close editor');expect(f.root.activeElement).toBe(f.button('Edit household'));expect(f.root.querySelector('form')).toBeNull(); + await f.click('Edit household');await f.click('Cancel');expect(f.root.querySelector('form')).toBeNull();expect(f.intents).toHaveLength(1); +}); +test('HADW-04: member exceptions change only selected ordinary grants and preserve unrelated decisions',async()=>{ + const current=view(),f=await mount({...current,roster:{...current.roster!,members:[{...current.roster!.members[0],member:{...member, + exceptions:[{permission:'shopping.request',allow:true},{permission:'tasks.read',allow:false}]}}]}}); + await f.click('Manage participant: My alias');expect(f.input('Household profile').value).toBe('viewer'); + await f.change('View tasks','inherit');await f.change('View household','allow');await f.change('Request shopping','deny'); + await f.change('Household profile','adult');await f.change('Membership status','suspended');await f.click('Review changes'); + expect(f.text()).toContain('View household: Allow');await f.click('Save changes'); + expect(f.intents[0]).toMatchObject({type:'submit',command:{action:'member.update',memberId:'owner',payload:{role:'adult',status:'suspended', + exceptions:[{permission:'household.read',allow:true},{permission:'shopping.request',allow:false}]}}}); +}); +test('HADW-05: per-profile edits retain another role for the same permission and remove only the explicitly inherited rule',async()=>{ + const current=view(),f=await mount({...current,policy:{...current.policy!,adjustments:[ + {role:'restricted',permission:'household.read',allow:false},{role:'adult',permission:'household.read',allow:true}]}}); + await f.click('Edit access rules');await f.change('View household','deny');await f.change('View household','inherit'); + await f.change('Profile to customize','restricted');expect(f.input('View household').value).toBe('deny'); + await f.click('Review changes');await f.click('Save changes'); + expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{adjustments:[{role:'restricted',permission:'household.read',allow:false}]}}}); +}); +test('HADW-05: invalid injected profile and permission options show a bounded error and cannot become grants',async()=>{ + const f=await mount();await f.click('Edit access rules'); + for(const name of ['Profile to customize','View household']) { + const select=f.input(name) as HTMLSelectElement,option=document.createElement('option');option.value='foreign';select.append(option); + select.value='foreign';select.dispatchEvent(new Event('change'));await f.element.updateComplete; + expect(f.root.querySelector('[role=alert]')?.textContent).toContain('Review these values'); + } + expect(f.intents).toEqual([]);await f.click('Review changes');await f.click('Save changes'); + expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{adjustments:[{role:'restricted',permission:'shopping.request',allow:false}]}}}); +}); +test.each(['en','es'] as const)('household-admin-C02: %s null aliases show the verified HA name in both identity and roster',async locale=>{ + const current=view(),noAlias={...presentation,alias:null}; + const f=await mount({...current,presentation:noAlias,roster:{...current.roster!,members:[ + {...current.roster!.members[0],presentation:noAlias}] }},locale); + expect(f.root.querySelector('.card strong')?.textContent).toBe('Verified owner'); + expect(f.root.querySelector('.people>li strong')?.textContent).toBe('Verified owner'); + expect(f.button((locale==='en'?'Manage participant':'Gestionar participante')+': Verified owner')).toBeDefined(); + expect(f.text()).not.toContain('My alias');expect(f.intents).toEqual([]); +}); +test.each((['en','es'] as const).flatMap(locale=>(['absent','denied','unavailable'] as const).map(state=>[locale,state] as const)))( + 'household-admin-C02 / HADB-03: %s %s HA names retain their neutral label and distinct source fact',async(locale,state)=>{ + const current=view(),f=await mount({...current,presentation:{...presentation,alias:null},directory:{state:'unavailable',profiles:[]}, + roster:{directoryState:'unavailable',members:[{...current.roster!.members[0],presentation:{...presentation,alias:null},metadata:{state,name:null,avatarState:'unavailable'}}]}},locale); + const fallback=locale==='en'?'Name unavailable':'Nombre no disponible'; + expect(f.root.querySelector('.card strong')?.textContent).toBe(fallback); + expect(f.root.querySelector('.people>li strong')?.textContent).toBe(fallback); + expect(f.text()).toContain(administrationMessage('metadata_'+state,locale)); + expect(f.text()).not.toContain('Verified owner');expect(f.text()).not.toContain(context.subject);expect(f.intents).toEqual([]); +}); +test.each(['committed','historical'] as const)('HADB-02: %s original outcomes do not populate forms or substitute historical result values for current facts',async stage=>{ + const f=await mount(view({outcome:{kind:'receipt',stage,action:'member.presentation.update',operationId:'op_original',archivedBy:stage==='historical'?'restore_original':null, + createdAt:1000,result:{memberId:'owner',alias:'Hidden historical alias',revision:revisionValue('1')},notificationDelivery:'unavailable',eventDelivery:null}})); + expect(f.text()).toContain(stage==='historical'?'Its effect is absent from current state':'Review current facts'); + expect(f.text()).toContain('op_original');expect(f.text()).not.toContain('Hidden historical alias'); + await f.click('Edit own alias');expect(f.input('Domus alias').value).toBe('My alias');expect(f.intents).toEqual([]); +}); +test.each([null,'correlation_original'])('HADB-02: rejected outcomes show safe reason/reference without claiming a submitted draft changed current facts (%s)',async correlationId=>{ + const f=await mount(view({notice:'conflict',outcome:{kind:'rejected',reason:'conflict',correlationId}})); + expect(f.text()).toContain('Change not accepted');expect(f.text()).toContain('Your draft keeps its original revision'); + if(correlationId===null)expect(f.text()).not.toContain('Request reference');else expect(f.text()).toContain(correlationId); + expect(f.intents).toEqual([]); +}); +test('HADB-02: uncertain original outcome asks for explicit inspection and never sends automatically',async()=>{ + const f=await mount(view({outcome:{kind:'uncertain'}}));expect(f.text()).toContain('Outcome not confirmed');expect(f.intents).toEqual([]); +}); +function originalRecords() { + return (['prepared','uncertain','committed','historical','rejected'] as const).map((stage,index)=>({ + action:'member.preferences.update' as const,payload:{expected_revision:revisionValue('13'),locale:'es' as const,routine_participation_summaries:true}, + context,key:'original-'+index,createdAt:1000,expiresAt:3601000,stage,operationId:stage==='committed'||stage==='historical'?'op_'+index:null})); +} +test('HADB-02: bounded original rows expose no payload; only explicit currently allowed recovery and resolved dismissal emit intents',async()=>{ + const current=view(),records=originalRecords(),f=await mount({...current,session:{...current.session!,access:'read_only'}, + journal:{context,observedAt:1000,records},controls:{...current.controls,recoverableKeys:['original-0'],dismissibleKeys:['original-2']}}); + expect(f.root.querySelectorAll('.requests>li')).toHaveLength(5);expect(f.text()).not.toContain('expected_revision');expect(f.text()).not.toContain('routine_participation_summaries'); + const rows=[...f.root.querySelectorAll('.requests>li')],recover=rows[0].querySelector('button')!,dismiss=rows[2].querySelectorAll('button')[1]; + recover.click();dismiss.click();expect(f.intents).toEqual([{type:'recover',key:'original-0'},{type:'dismiss',key:'original-2'}]); + f.element.view={...current,journal:{context,observedAt:1000,records},controls:{...current.controls,recoverableKeys:[],dismissibleKeys:[]}};await f.element.updateComplete; + recover.dispatchEvent(new MouseEvent('click'));dismiss.dispatchEvent(new MouseEvent('click'));expect(f.intents).toHaveLength(2); + f.element.pending=true;await f.element.updateComplete;recover.dispatchEvent(new MouseEvent('click'));expect(f.intents).toHaveLength(2); +}); +test('HADB-01: inactive or unverified states discard drafts and offer only explicit refresh',async()=>{ + const current=view(),f=await mount(current);await f.click('Edit preferences');await f.change('Saved language','en'); + f.element.view={...current,phase:'ready',session:null,access:null,notice:'authentication_required'};await f.element.updateComplete; + expect(f.root.querySelector('form')).toBeNull();expect(f.text()).toContain('Previous private data is hidden');await f.click('Refresh administration'); + expect(f.intents).toEqual([{type:'refresh'}]);f.element.view=current;await f.element.updateComplete;await f.click('Edit preferences'); + expect(f.input('Saved language').value).toBe('es');await f.click('Cancel');await f.click('Refresh administration');expect(f.intents).toHaveLength(2); +}); +test('HADB-01: disconnected widgets cannot retain private inputs when reattached',async()=>{ + const current=view(),f=await mount(current);await f.click('Edit household');await f.change('New household name','Private disconnected draft'); + f.element.remove();expect(f.element.view).toBeNull();document.body.append(f.element);f.element.view=current;await f.element.updateComplete; + await f.click('Edit household');expect(f.input('New household name').value).toBe('Household');expect(f.intents).toEqual([]); +}); +for(const scenario of draftFactCases)test('HADB-01: a retained '+scenario.kind+' open callback cannot reopen unavailable facts',async()=>{ + const current=view(),f=await mount(current),open=f.button(scenario.open); + f.element.view=scenario.missing(current);await f.element.updateComplete;open.dispatchEvent(new MouseEvent('click'));await f.element.updateComplete; + expect(f.root.querySelector('form')).toBeNull();expect(f.intents).toEqual([]); +}); +test('HADB-01: previously attached form events cannot edit, submit, reset or close drafts while current proof is hidden',async()=>{ + const current=view(),f=await mount(current);await f.click('Edit access rules');const role=f.input('Profile to customize'),rule=f.input('View household'); + const reset=f.button('Reset from current data'),cancel=f.button('Cancel'),form=f.root.querySelector('form')!; + f.element.pending=true;await f.element.updateComplete;role.value='restricted';role.dispatchEvent(new Event('change'));rule.value='allow';rule.dispatchEvent(new Event('change')); + form.dispatchEvent(new Event('submit',{cancelable:true}));reset.dispatchEvent(new MouseEvent('click'));cancel.dispatchEvent(new MouseEvent('click'));await f.element.updateComplete; + f.element.pending=false;await f.element.updateComplete;expect(f.input('Profile to customize').value).toBe('adult');expect(f.input('View household').value).toBe('inherit');expect(f.intents).toEqual([]); +}); +test('HADB-01: an old review, alias input, checkbox and close callback cannot affect a replacement draft',async()=>{ + const current=view(),f=await mount(current);await f.click('Edit own alias');const field=f.input('Domus alias'),clear=f.input('Use Home Assistant name'),form=f.root.querySelector('form')!; + await f.click('Review changes');const save=f.button('Save changes'),back=f.button('Back to editing'),close=f.button('Close editor'); + await f.click('Reset from current data');field.value='Stale callback';field.dispatchEvent(new Event('input'));(clear as HTMLInputElement).checked=true;clear.dispatchEvent(new Event('change')); + form.dispatchEvent(new Event('submit',{cancelable:true}));save.dispatchEvent(new MouseEvent('click'));back.dispatchEvent(new MouseEvent('click'));close.dispatchEvent(new MouseEvent('click'));await f.element.updateComplete; + expect(f.input('Domus alias').value).toBe('My alias');expect((f.input('Use Home Assistant name') as HTMLInputElement).checked).toBe(false);expect(f.intents).toEqual([]); +}); + +test('HADB-03: unknown or inherited translation keys use a localized bounded unavailable message',()=>{ + expect(administrationMessage('foreign','en')).toBe(administrationMessage('notice_unavailable','en')); + expect(administrationMessage('constructor','es')).toBe(administrationMessage('notice_unavailable','es')); +}); +test('HADB-03: read-only current facts keep a draft inspectable but lock every edit until write access returns',async()=>{ + const current=view(),f=await mount(current);await f.click('Edit household');await f.change('New household name','Unsent original'); + const open=f.button('Edit preferences');f.element.view={...current,session:{...current.session!,access:'read_only'},notice:'read_only', + controls:{...current.controls,household:false,policy:false,preferences:false,ownAlias:false,memberIds:[],aliasMemberIds:[]}};await f.element.updateComplete; + expect(f.input('New household name').value).toBe('Unsent original');expect(f.input('New household name').disabled).toBe(true);expect(f.text()).toContain('This form needs current write access'); + open.dispatchEvent(new MouseEvent('click'));await f.element.updateComplete;expect(f.root.querySelectorAll('form')).toHaveLength(1);expect(f.intents).toEqual([]); + f.element.view=current;await f.element.updateComplete;expect(f.input('New household name').value).toBe('Unsent original'); +}); +test('HADB-01: hidden drafts can be explicitly closed without revealing their fields or sending work',async()=>{ + const current=view(),f=await mount(current);await f.click('Edit preferences');f.element.view={...current,preferences:null,controls:{...current.controls,preferences:false}};await f.element.updateComplete; + await f.click('Close editor');f.element.view=current;await f.element.updateComplete;expect(f.root.querySelector('form')).toBeNull();expect(f.intents).toEqual([]); +}); +test('HADB-01: a cached open control is inert during checking, and an idle state offers truthful refresh guidance',async()=>{ + const current=view(),f=await mount(current),open=f.button('Edit household');f.element.pending=true;await f.element.updateComplete; + open.dispatchEvent(new MouseEvent('click'));await f.element.updateComplete;expect(f.root.querySelector('form')).toBeNull(); + f.element.pending=false;f.element.view={...current,phase:'idle',notice:null};await f.element.updateComplete; + expect(f.text()).toContain('The requested information is unavailable');await f.click('Refresh administration');expect(f.intents).toEqual([{type:'refresh'}]); +}); +test('HADW-05: unnamed supported directory accounts remain explicit ID targets without a fabricated display name',async()=>{ + const current=view(),f=await mount({...current,directory:{state:'available',profiles:[{haUserId:context.subject,name:null}]}}); + await f.click('Edit access rules');await f.change('HA administrator mapping','setup_administrator'); + expect((f.input('Target Home Assistant account') as HTMLSelectElement).options[1].textContent).toContain('Name unavailable');expect(f.intents).toEqual([]); +}); +test('HADB-02: a preflight conflict does not claim that an original server request exists',async()=>{ + const current=view(),f=await mount(current);await f.click('Edit household');await f.change('New household name','Original draft'); + await f.click('Review changes');await f.click('Save changes');f.element.view={...current,notice:'conflict',outcome:null};await f.element.updateComplete; + expect(f.text()).toContain('Your draft keeps its original revision');expect(f.text()).not.toContain('This request was submitted'); + expect(f.text()).not.toContain('Check its original result below');expect(f.intents).toHaveLength(1); +}); +test('HADB-03: participant review names the exact target and its stable membership reference',async()=>{ + const f=await mount();await f.click('Manage participant: Pending member');await f.click('Review changes'); + const editor=f.root.querySelector('[role=region][aria-label="Manage participant"]')!; + expect(editor.textContent).toContain('Participant: Pending member');expect(editor.querySelector('code')?.textContent).toBe('other'); + expect(f.intents).toEqual([]); +}); + +test.each([null,'Independently read alias'])('HADW-01: own alias target remains explicit without roster access (%s)',async alias=>{ + const current=view(),f=await mount({...current,roster:null,presentation:{...presentation,alias}});await f.click('Edit own alias'); + const editor=f.root.querySelector('[role=region][aria-label="Domus alias"]')!;expect(editor.textContent).toContain('Participant: '+(alias??'Verified owner')); + expect(editor.querySelector('code')?.textContent).toBe('owner');expect(f.intents).toEqual([]); +}); +test('household-admin-C58: invalid alias submission associates its error with the form controls and restores field focus',async()=>{ + const f=await mount();await f.click('Edit own alias');await f.change('Domus alias','');await f.click('Review changes'); + const error=f.root.querySelector('[role=alert]')!;expect(error).not.toBeNull(); + expect(f.root.activeElement).toBe(f.input('Use Home Assistant name')); + for(const name of ['Use Home Assistant name','Domus alias'])expect(f.input(name).getAttribute('aria-describedby')).toBe(error.id); + f.element.language='es';await f.element.updateComplete;expect(f.input('Alias de Domus').value).toBe(''); + expect(f.input('Alias de Domus').getAttribute('aria-describedby')).toBe(error.id);expect(f.intents).toEqual([]); +}); +test('household-admin-C58: missing access approval focuses and describes the required confirmation without submitting',async()=>{ + const f=await mount();await f.click('Manage participant: Pending member');await f.change('Membership status','active');await f.click('Review changes'); + const approval=f.input('Approve household access'),error=f.root.querySelector('[role=alert]')!; + expect(f.root.activeElement).toBe(approval);expect(approval.getAttribute('aria-describedby')).toBe(error.id);expect(f.intents).toEqual([]); +}); +test('household-workspace-C50: back to editing restores the original field focus and keeps the unsent preference value',async()=>{ + const f=await mount();await f.click('Edit preferences');await f.change('Saved language','en');await f.click('Review changes');await f.click('Back to editing'); + expect(f.root.activeElement).toBe(f.input('Saved language'));expect(f.input('Saved language').value).toBe('en');expect(f.intents).toEqual([]); +}); + +for(const action of ['Back to editing','Cancel','Close editor','Close hidden editor'])test('household-workspace-C50: '+action+' keeps keyboard focus on a current enabled action after write access is lost',async()=>{ + const current=view(),f=await mount(current);await f.click('Edit household');await f.change('New household name','Retained draft'); + if(action==='Back to editing'||action==='Close editor')await f.click('Review changes'); + f.element.view={...current,session:{...current.session!,access:'read_only'},notice:'read_only', + household:action==='Close hidden editor'?null:current.household,controls:{...current.controls,household:false}}; + await f.element.updateComplete; + const control=action==='Close hidden editor'?'Close editor':action; + f.button(control).focus();expect(f.root.activeElement).toBe(f.button(control));await f.click(control); + expect(f.root.activeElement).toBe(f.button('Refresh administration'));expect(f.button('Refresh administration').disabled).toBe(false); + if(action==='Back to editing'){ + expect(f.input('New household name').value).toBe('Retained draft');expect(f.input('New household name').disabled).toBe(true); + f.element.view=current;await f.element.updateComplete;expect(f.input('New household name').value).toBe('Retained draft'); + }else expect(f.root.querySelector('form')).toBeNull(); + expect(f.intents).toEqual([]); +}); +test('household-workspace-C50: closing an editor during proof refresh never focuses a removed or private control',async()=>{ + const current=view(),f=await mount(current);await f.click('Edit household');f.button('Cancel').click(); + f.element.pending=true;await f.element.updateComplete;expect(f.root.querySelector('input,button')).toBeNull();expect(f.intents).toEqual([]); + f.element.pending=false;f.element.view=current;await f.element.updateComplete;expect(f.root.activeElement).toBeNull();expect(f.root.querySelector('form')).toBeNull(); +}); + + +for(const scenario of draftFactCases)test('household-browser-C02: a different verified household retires the '+scenario.kind+' draft despite matching member/revisions',async()=>{ + const current=view(),f=await mount(current);await f.click(scenario.open); + if(scenario.kind==='other alias')await f.change('Use Home Assistant name',false); + await f.change(scenario.field,scenario.value); + f.element.view={...current,capabilities:{...current.capabilities!,householdId:'replacement-home'}, + household:{...current.household!,householdId:'replacement-home'},policy:{...current.policy!,householdId:'replacement-home'}}; + await f.element.updateComplete;expect(f.root.querySelector('form')).toBeNull();expect(f.intents).toEqual([]); + await f.click(scenario.open);expect(f.input(scenario.field).value).not.toBe(scenario.value);expect(f.intents).toEqual([]); +}); +test('household-browser-C02: a detached confirmed save cannot submit the former household draft into a replacement household',async()=>{ + const current=view(),f=await mount(current);await f.click('Edit household');await f.change('New household name','Former household intent'); + await f.click('Review changes');const save=f.button('Save changes'); + f.element.view={...current,capabilities:{...current.capabilities!,householdId:'replacement-home'}, + household:{...current.household!,householdId:'replacement-home',name:'Replacement household'},policy:{...current.policy!,householdId:'replacement-home'}}; + save.dispatchEvent(new MouseEvent('click'));expect(f.intents).toEqual([]); + await f.element.updateComplete;save.dispatchEvent(new MouseEvent('click'));await f.element.updateComplete; + expect(f.intents).toEqual([]);expect(f.text()).not.toContain('Former household intent'); + await f.click('Edit household');expect(f.input('New household name').value).toBe('Replacement household'); +}); +test('HADB-03: missing current household proof retires private drafts and makes detached controls inert until explicit reopening',async()=>{ + const current=view(),f=await mount(current);await f.click('Edit preferences');await f.change('Saved language','en');await f.click('Review changes'); + const save=f.button('Save changes');f.element.view={...current,capabilities:null}; + save.dispatchEvent(new MouseEvent('click'));expect(f.intents).toEqual([]);await f.element.updateComplete; + save.dispatchEvent(new MouseEvent('click'));await f.element.updateComplete;expect(f.intents).toEqual([]); + expect(f.root.querySelector('form')).toBeNull();expect(f.button('Edit preferences')).toBeUndefined(); + f.element.view=current;await f.element.updateComplete;expect(f.root.querySelector('form')).toBeNull(); + await f.click('Edit preferences');expect(f.input('Saved language').value).toBe('es');expect(f.intents).toEqual([]); +}); diff --git a/frontend/tests/administration-controller.test.ts b/frontend/tests/administration-controller.test.ts index 0234f82..9c62c08 100644 --- a/frontend/tests/administration-controller.test.ts +++ b/frontend/tests/administration-controller.test.ts @@ -7,7 +7,7 @@ import {AdministrationSessionJournal, ADMINISTRATION_JOURNAL_KEY} from '../src/a import {administrationCommand} from '../src/domain/administration-commands'; import type {AdministrationCommand} from '../src/domain/administration-commands'; import {revisionValue} from '../src/domain/administration'; -import type {AdministrationAccess, AdministrationReceipt} from '../src/domain/administration-views'; +import type {AdministrationAccess, AdministrationReceipt, OwnPreferences, Roster} from '../src/domain/administration-views'; function read(value: T): EntryRead {return {result: Promise.resolve(value), cancel: vi.fn()};} function deferred() { @@ -28,7 +28,7 @@ const home = {householdId: 'home', name: 'Home', timeZone: 'UTC', revision: revi const policy = {householdId: 'home', revision: revisionValue('5'), mappingMode: 'all_ha_administrators' as const, targetHAUserId: null, adjustments: []}; const own = {memberId: member.memberId, alias: null, revision: revisionValue('11')}; -const preferences = {memberId: member.memberId, revision: revisionValue('13'), locale: 'es' as const, +const preferences: OwnPreferences = {memberId: member.memberId, revision: revisionValue('13'), locale: 'es', routine_participation_summaries: true, notificationDelivery: 'unavailable' as const}; const commands: readonly AdministrationCommand[] = [ administrationCommand({action: 'household.update', payload: {expected_revision: '7', name: 'New home', time_zone: 'Europe/Madrid'}}), @@ -57,7 +57,7 @@ function fixture(initial = access, mode: 'native' | 'standalone' = 'standalone') const server = { capabilities: vi.fn(() => read({contractVersion: 2 as const, householdId: 'home', actions: commands.map(c => c.action)})), authorization: vi.fn(() => read(initial)), household: vi.fn(() => read(home)), policy: vi.fn(() => read(policy)), - roster: vi.fn(() => read({directoryState: 'available' as const, members: [{member, presentation: own, + roster: vi.fn(() => read({directoryState: 'available' as const, members: [{member, presentation: own, metadata: {state: 'available' as const, name: 'Verified owner', avatarState: 'unavailable' as const}}]})), directory: vi.fn(() => read({state: 'denied' as const, profiles: []})), preferences: vi.fn(() => read(preferences)), presentation: vi.fn(() => read(own)), @@ -220,6 +220,7 @@ test.each(commands)('HADW-04: $action needs the target revision facts, without g const field = command.action === 'household.update' ? 'household' : command.action === 'policy.update' ? 'policy' : command.action === 'member.preferences.update' ? 'preferences' : 'roster'; f.server[field].mockImplementation(() => {throw new Error('unavailable');}); + if (command.action === 'member.presentation.update') f.server.presentation.mockImplementation(() => {throw new Error('unavailable');}); await f.controller.submit(command); expect(f.controller.view.notice).toBe('action_not_allowed'); expect(f.server.send).not.toHaveBeenCalled(); }); test.each([1,3])('HADW-04: member target %s must be present in the current roster', async index => { @@ -502,3 +503,208 @@ test.each([false,true])('C74/C82: verified read-only grace supports explicit ori await f.controller.submit(commands[0]);expect(f.server.send).toHaveBeenCalledTimes(1);expect(f.entropy.requestKey).toHaveBeenCalledTimes(1); f.setNow(session.expiresAt);f.controller.tick();expect(f.controller.view.outcome).toBeNull();expect(f.controller.view.preferences).toBeNull(); }); + +test('HADB-01/C13: reconciliation preserves an invalid draft notice while replacing all current private facts', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit(null); + f.server.preferences.mockReturnValue(read({...preferences, locale: 'en', revision: revisionValue('14')})); + await f.controller.reconcile(); + expect(f.controller.view.notice).toBe('invalid_input'); + expect(f.controller.view.preferences).toMatchObject({locale: 'en', revision: '14'}); + expect(f.server.send).not.toHaveBeenCalled(); expect(f.server.original).not.toHaveBeenCalled(); + expect(f.server.operation).not.toHaveBeenCalled(); expect(f.entropy.requestKey).not.toHaveBeenCalled(); +}); +test('HADB-01/C13: reconciliation replaces a prior notice with the current membership transition', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit(null); + f.server.authorization.mockReturnValue(read({...access, member: {...member, memberId: 'relinked'}})); + f.server.preferences.mockReturnValue(read({...preferences, memberId: 'relinked'})); + await f.controller.reconcile(); + expect(f.controller.view.notice).toBe('access_changed'); expect(f.controller.view.preferences?.memberId).toBe('relinked'); + expect(f.server.send).not.toHaveBeenCalled(); +}); +test('HADB-01/C02: reconciliation reports a new verified context rather than an earlier invalid draft', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit(null); + f.identity.session.mockReturnValue(read({...session, context: {...context, channel: 'stable'}})); + await f.controller.reconcile(); + expect(f.controller.view.notice).toBe('context_changed'); expect(f.controller.view.session?.context.channel).toBe('stable'); + expect(f.server.send).not.toHaveBeenCalled(); +}); +test.each(['read_only', 'journal_unavailable', 'inactive', 'api_unavailable', 'access_changed'] as const)( + 'HADB-01/C03: fresh %s proof takes precedence over the earlier action notice', async kind => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit(null); + if (kind === 'read_only') f.identity.session.mockReturnValue(read({...session, access: 'read_only'})); + if (kind === 'journal_unavailable') f.storage.getItem.mockImplementation(() => {throw new Error('private-storage');}); + if (kind === 'inactive') f.server.authorization.mockReturnValue(read({...access, state: 'suspended'})); + if (kind === 'api_unavailable') f.identity.session.mockReturnValue(read({...session, householdApiAvailable: false})); + if (kind === 'access_changed') f.server.authorization.mockReturnValueOnce(read(access)) + .mockReturnValueOnce(read({...access, policyRevision: revisionValue('6')})); + await f.controller.reconcile(); expect(f.controller.view.notice).toBe(kind); expect(f.server.send).not.toHaveBeenCalled(); + }, +); +test('HADB-01/C04: failed fresh proof retires earlier private facts and action notices', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit(null); + f.identity.session.mockReturnValue(read(null)); await f.controller.reconcile(); + expect(f.controller.view.notice).toBe('authentication_required'); expect(f.controller.view.preferences).toBeNull(); +}); +test('HADB-01/C07: reconciliation cannot republish facts after disposal while its read is pending', async () => { + const f = fixture(); await f.controller.refresh(); const pending = deferred(); + f.identity.session.mockReturnValueOnce(pending); const work = f.controller.reconcile(); + f.controller.close(); pending.resolve(session); await work; + expect(pending.cancel).toHaveBeenCalledOnce(); expect(f.controller.view.phase).toBe('closed'); + expect(f.controller.view.preferences).toBeNull(); +}); +test('HADB-01/C04: expiry during reconciliation cannot restore the previous notice or private facts', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit(null); + const pending = deferred(); f.server.preferences.mockReturnValueOnce(pending); + const work = f.controller.reconcile(); await settle(); f.setNow(session.expiresAt); f.controller.tick(); + pending.resolve(preferences); await work; + expect(f.controller.view.notice).toBe('authentication_required'); expect(f.controller.view.preferences).toBeNull(); +}); +test('HADB-01/C05: reconciliation is passive while idle, closed or a command is pending', async () => { + const f = fixture(); await f.controller.reconcile(); expect(f.identity.session).not.toHaveBeenCalled(); + await f.controller.refresh(); const pending = deferred(); f.server.send.mockReturnValueOnce(pending.result); + const work = f.controller.submit(commands[4]); await settle(); const calls = f.identity.session.mock.calls.length; + await f.controller.reconcile(); expect(f.identity.session).toHaveBeenCalledTimes(calls); + pending.resolve(receipt(commands[4])); await work; f.controller.close(); + await f.controller.reconcile(); expect(f.controller.view.phase).toBe('closed'); +}); +test('HADB-01/C02: same-context empty rejection survives reconciliation without another read or original lookup', async () => { + const f = fixture(); await f.controller.refresh(); + f.server.send.mockResolvedValueOnce({kind: 'rejected', reason: 'forbidden', correlationId: 'current-correlation'}); + await f.controller.submit(commands[4]); const calls = f.identity.session.mock.calls.length; + await f.controller.reconcile(); expect(f.identity.session).toHaveBeenCalledTimes(calls); + expect(f.controller.view.outcome).toEqual({kind: 'rejected', reason: 'forbidden', correlationId: 'current-correlation'}); + expect(f.server.original).not.toHaveBeenCalled(); expect(f.server.operation).not.toHaveBeenCalled(); +}); +test('HADB-01: reconciliation without an action notice remains a current neutral read', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.reconcile(); + expect(f.controller.view.notice).toBeNull(); expect(f.controller.view.preferences).toEqual(preferences); +}); + +test.each(['without_roster_grant', 'unavailable_roster'] as const)( + 'HADW-01: independent own alias %s keeps its exact revision and permits one explicit edit', async kind => { + const f = fixture(kind === 'without_roster_grant' ? {...access, permissions: ['preferences.read', 'preferences.write']} : access); + if (kind === 'unavailable_roster') f.server.roster.mockImplementation(() => {throw new EntryReadError('unavailable');}); + await f.controller.refresh(); expect(f.controller.view.roster).toBeNull(); expect(f.controller.view.presentation).toEqual(own); + expect(f.server.presentation).toHaveBeenCalledExactlyOnceWith(member.memberId, context); + await f.controller.submit(commands[3]); expect(f.server.send).toHaveBeenCalledExactlyOnceWith({key: 'request-original', command: commands[3]}, context); + expect(f.controller.view.outcome?.kind).toBe('receipt'); expect(f.server.original).not.toHaveBeenCalled(); + }, +); +test('HADW-01: an independently returned alias for a former member is never published or used for an own edit', async () => { + const f = fixture({...access, permissions: ['preferences.read','preferences.write']}); + f.server.presentation.mockReturnValue(read({...own, memberId: 'former-member'})); + await f.controller.refresh(); expect(f.controller.view.presentation).toBeNull(); + expect(f.controller.view.errors.presentation).toBe('invalid_response'); + await f.controller.submit(commands[3]); expect(f.server.send).not.toHaveBeenCalled(); + expect(JSON.stringify(f.controller.view)).not.toContain('former-member'); +}); +test('HADW-01: lack of either public or own read authority never queries or invents an alias revision', async () => { + const f = fixture({...access, permissions: ['preferences.write']}); await f.controller.refresh(); + expect(f.controller.view.presentation).toBeNull(); expect(f.server.presentation).not.toHaveBeenCalled(); + await f.controller.submit(commands[3]); expect(f.controller.view.notice).toBe('action_not_allowed'); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('HADW-01: a permitted current roster remains an honest alias source when the independent own read is unavailable', async () => { + const f = fixture(); f.server.presentation.mockImplementation(() => {throw new EntryReadError('unavailable');}); + await f.controller.refresh(); expect(f.controller.view.presentation).toBeNull(); expect(f.controller.view.errors.presentation).toBe('unavailable'); + await f.controller.submit(commands[3]); expect(f.server.send).toHaveBeenCalledOnce(); expect(f.controller.view.outcome?.kind).toBe('receipt'); +}); + +const noControls = {household: false, policy: false, preferences: false, ownAlias: false, + memberIds: [], aliasMemberIds: [], recoverableKeys: [], dismissibleKeys: []}; +test('HADB-03: idle and disposed views offer no administration controls and obtain no proof', async () => { + const f = fixture(); expect(f.controller.view.controls).toEqual(noControls); + f.controller.close(); expect(f.controller.view.controls).toEqual(noControls); + expect(f.identity.session).not.toHaveBeenCalled(); expect(f.storage.getItem).not.toHaveBeenCalled(); +}); +test('HADB-03: controls describe current permitted facts without producing a command key, lookup or private payload', async () => { + const f = fixture(); await f.controller.refresh(); const writes = f.storage.setItem.mock.calls.length; + const controls = f.controller.view.controls; + expect(controls).toEqual({...noControls, household: true, policy: true, preferences: true, ownAlias: true, memberIds: [member.memberId]}); + expect(Object.isFrozen(controls)).toBe(true); expect(Object.isFrozen(controls.memberIds)).toBe(true); + expect(Object.isFrozen(controls.aliasMemberIds)).toBe(true); expect(Object.isFrozen(controls.recoverableKeys)).toBe(true); + expect(Object.isFrozen(controls.dismissibleKeys)).toBe(true); + expect(JSON.stringify(controls)).not.toContain('locale'); expect(JSON.stringify(controls)).not.toContain('routine_participation'); + expect(f.storage.setItem).toHaveBeenCalledTimes(writes); expect(f.entropy.requestKey).not.toHaveBeenCalled(); + expect(f.server.original).not.toHaveBeenCalled(); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('HADB-03: other-member controls require current facts and never invent masked exceptions', async () => { + const f = fixture(); f.server.roster.mockReturnValue(read({directoryState: 'available', members: [ + {member, presentation: own, metadata: {state: 'available', name: 'Owner', avatarState: 'unavailable'}}, + {member: {...member, memberId: 'other', exceptions: null}, presentation: {...own, memberId: 'other'}, + metadata: {state: 'denied', name: null, avatarState: 'unavailable'}}, + ]})); + await f.controller.refresh(); expect(f.controller.view.controls.memberIds).toEqual([member.memberId]); + expect(f.controller.view.controls.aliasMemberIds).toEqual(['other']); +}); +test('HADB-03: ordinary current owner controls expose own edits without exposing other-member management', async () => { + const f = fixture({...access, permissions: ['members.read','preferences.read','preferences.write']}); + await f.controller.refresh(); expect(f.controller.view.controls).toEqual({...noControls, ownAlias: true, preferences: true}); +}); +test('HADB-03: write grants without current aggregate reads do not invent an edit revision, while original GET remains usable', async () => { + const f = fixture({...access, permissions: ['household.manage','operations.read']}); + f.journal.open(context,1000); f.journal.prepare('original',commands[0],1000); f.journal.detach(); + await f.controller.refresh(); expect(f.controller.view.controls).toEqual({...noControls, recoverableKeys: ['original']}); + expect(f.server.original).not.toHaveBeenCalled(); await f.controller.recover('original'); + expect(f.server.original).toHaveBeenCalledOnce(); expect(f.controller.view.controls.dismissibleKeys).toEqual(['original']); +}); +test('HADB-03: a valid read-only lease offers original recovery and resolved dismissal, with all new edits unavailable', async () => { + const f = fixture(); f.journal.open(context,1000); f.journal.prepare('saved',commands[4],1000); + f.journal.record('saved',{stage:'committed',operationId:'operation'},1000); f.journal.detach(); + f.identity.session.mockReturnValue(read({...session,access:'read_only'})); await f.controller.refresh(); + expect(f.controller.view.controls).toEqual({...noControls,recoverableKeys:['saved'],dismissibleKeys:['saved']}); + expect(f.server.original).not.toHaveBeenCalled(); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('HADB-03: original stages expose only their permitted recovery and dismissal controls', async () => { + const f = fixture(); f.journal.open(context,1000); + for (const stage of ['prepared','uncertain','committed','historical','rejected'] as const) { + f.journal.prepare(stage,commands[4],1000); + if (stage !== 'prepared') f.journal.record(stage,{stage,operationId:stage==='committed'||stage==='historical'?'operation-'+stage:null},1000); + } + f.journal.detach(); await f.controller.refresh(); + expect(f.controller.view.controls.recoverableKeys).toEqual(['prepared','uncertain','committed','historical']); + expect(f.controller.view.controls.dismissibleKeys).toEqual(['committed','historical','rejected']); + f.server.authorization.mockReturnValue(read({...access,permissions:access.permissions.filter(value=>value!=='operations.read')})); + await f.controller.refresh(); expect(f.controller.view.controls.recoverableKeys).toEqual([]); + expect(f.controller.view.controls.dismissibleKeys).toEqual(['committed','historical','rejected']); +}); +test('HADB-03: unavailable owned journal disables controls while current permitted facts remain readable', async () => { + const f = fixture(); f.storage.getItem.mockImplementation(()=>{throw new Error('private-storage-fault');}); + await f.controller.refresh(); expect(f.controller.view.preferences).toEqual(preferences); + expect(f.controller.view.controls).toEqual(noControls); +}); +test('HADB-03: pending proof and an in-flight atomic change offer no stale form controls', async () => { + const f = fixture(), proof = deferred(); f.identity.session.mockReturnValueOnce(proof); + const opening = f.controller.refresh(); expect(f.controller.view.controls).toEqual(noControls); + proof.resolve(session); await opening; const pending = deferred(); f.server.send.mockReturnValueOnce(pending.result); + const work = f.controller.submit(commands[4]); await settle(); expect(f.controller.view.controls).toEqual(noControls); + pending.resolve(receipt(commands[4])); await work; expect(f.controller.view.controls.preferences).toBe(true); +}); +test('HADB-03: an unresolved original disables only its aggregate and leaves independent forms available', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind:'uncertain'}); + await f.controller.submit(commands[4]); const controls = f.controller.view.controls; + expect(controls.preferences).toBe(false); expect(controls.household).toBe(true); expect(controls.ownAlias).toBe(true); + expect(controls.recoverableKeys).toEqual(['request-original']); expect(f.server.send).toHaveBeenCalledOnce(); +}); +test('HADB-03: an unresolved own alias does not disable an authorized different-member alias form', async () => { + const f = fixture(); f.server.roster.mockReturnValue(read({directoryState:'available',members:[ + {member,presentation:own,metadata:{state:'available',name:'Owner',avatarState:'unavailable'}}, + {member:{...member,memberId:'other'},presentation:{...own,memberId:'other'},metadata:{state:'available',name:'Other',avatarState:'unavailable'}}, + ]})); + await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind:'uncertain'}); await f.controller.submit(commands[3]); + expect(f.controller.view.controls.ownAlias).toBe(false); expect(f.controller.view.controls.aliasMemberIds).toEqual(['other']); + expect(f.controller.view.controls.preferences).toBe(true); +}); +test.each([0,2,4])('household-browser-C13: a captured new intent %s cannot cross a same-HA membership replacement with a coincident revision',async index=>{ + const f=fixture();await f.controller.refresh();const next={...member,memberId:'replacement'}; + f.server.authorization.mockReturnValue(read({...access,member:next})); + f.server.preferences.mockReturnValue(read({...preferences,memberId:next.memberId})); + f.server.presentation.mockReturnValue(read({...own,memberId:next.memberId})); + f.server.roster.mockReturnValue(read({directoryState:'available',members:[{member:next,presentation:{...own,memberId:next.memberId}, + metadata:{state:'available',name:'Current member',avatarState:'unavailable'}}]})); + await f.controller.submit(commands[index]);expect(f.server.send).not.toHaveBeenCalled();expect(f.entropy.requestKey).not.toHaveBeenCalled(); + expect(f.controller.view.notice).toBe('context_changed');expect(f.controller.view.preferences?.memberId).toBe(next.memberId); +}); +test('HADB-03: a newer access revision for the same member still permits a currently authorized captured intent',async()=>{ + const f=fixture();await f.controller.refresh();f.server.authorization.mockReturnValue(read({...access,policyRevision:revisionValue('6'),member:{...member,revision:revisionValue('4')}})); + await f.controller.submit(commands[4]);expect(f.server.send).toHaveBeenCalledOnce();expect(f.controller.view.outcome?.kind).toBe('receipt'); +}); diff --git a/frontend/tests/administration.test.ts b/frontend/tests/administration.test.ts index d53ef52..765dbcb 100644 --- a/frontend/tests/administration.test.ts +++ b/frontend/tests/administration.test.ts @@ -119,3 +119,11 @@ test('HTTP request bodies contain the exact int64 lexeme and only supported owne expect(responseRevision(decoded.expected_revision)).toBe(revision); expect(JSON.parse(aliasBody(revisionValue('1'), '"\\'))).toEqual({expected_revision: 1, alias: '"\\'}); }); + +test.each([0x20,0xa0,0x1680,...Array.from({length:11},(_unused,index)=>0x2000+index),0x2028,0x2029,0x202f,0x205f,0x3000].map(codepoint=>({ + codepoint,label:'U+'+codepoint.toString(16).toUpperCase().padStart(4,'0') +})))('household-admin-C25: exact astral alias boundary after whitespace $label',({codepoint})=>{ + const marker=String.fromCodePoint(codepoint),exact='😀'.repeat(80); + expect(aliasValue(marker+exact+marker)).toBe(exact); + expect(()=>aliasValue(marker+exact+'😀'+marker)).toThrow('invalid_alias'); +}); diff --git a/frontend/tests/controller.test.ts b/frontend/tests/controller.test.ts index 5e0070b..2e4b090 100644 --- a/frontend/tests/controller.test.ts +++ b/frontend/tests/controller.test.ts @@ -276,6 +276,91 @@ test('C40: active membership without actual read permission never requests house expect(Object.isFrozen(f.controller.view.session?.context)).toBe(true); }); +for (const deployment of ['native', 'standalone'] as const) { + test.each(['unavailable', 'invalid_response', 'forbidden', 'raw'] as const)( + 'HADB-02: '+deployment+' optional household %s does not erase independently verified access', async code => { + const f = fixture(active), proof = {...session, deployment}; + f.server.session.mockImplementation(() => read(proof)); + f.server.household.mockImplementation(() => ({cancel: vi.fn(), result: Promise.reject( + code === 'raw' ? new Error('private-provider-details') : new EntryReadError(code))})); + const controller = new EntryController(deployment, f.server, f.journal, f.clock, f.entropy); + await controller.refresh(); + expect(controller.view.session).toEqual(proof); expect(controller.view.decision?.state).toBe('active'); + expect(controller.view.authorization).toEqual(active); expect(controller.view.household).toBeNull(); + expect(controller.view.notice).toBe(code === 'raw' ? 'unavailable' : code); + expect(f.server.authorization).toHaveBeenCalledTimes(2); expect(f.server.send).not.toHaveBeenCalled(); + expect(JSON.stringify(controller.view)).not.toContain('private-provider-details'); controller.close(); + }, + ); +} +test('HADB-02: a failed optional read rereads grants rather than preserving the earlier permissions', async () => { + const f = fixture(active), current = {...active, permissions: ['household.manage', 'operations.read']}; + f.server.authorization.mockReturnValueOnce(read(active)).mockReturnValueOnce(read(current)); + f.server.household.mockImplementation(() => ({cancel: vi.fn(), result: Promise.reject(new EntryReadError('forbidden'))})); + await f.controller.refresh(); + expect(f.controller.view.authorization).toEqual(current); expect(f.controller.view.decision?.householdReadable).toBe(false); + expect(f.controller.view.household).toBeNull(); expect(Object.isFrozen(f.controller.view.authorization?.permissions)).toBe(true); + expect(f.server.send).not.toHaveBeenCalled(); +}); +test.each(['pending', 'suspended', 'archived'] as const)( + 'HADB-01: optional household failure followed by %s authority cannot retain active facts', async state => { + const f = fixture(active), current = {...pending, state}; + f.server.authorization.mockReturnValueOnce(read(active)).mockReturnValueOnce(read(current)); + f.server.household.mockImplementation(() => ({cancel: vi.fn(), result: Promise.reject(new Error('unavailable'))})); + await f.controller.refresh(); + expect(f.controller.view.decision?.state).toBe(state); expect(f.controller.view.household).toBeNull(); + expect(f.controller.view.authorization?.permissions).toEqual([]); expect(f.server.send).not.toHaveBeenCalled(); + }, +); +test('HADB-01: authentication loss on the optional household route retires proof without a second authorization read', async () => { + const f = fixture(active); + f.server.household.mockImplementation(() => ({cancel: vi.fn(), result: Promise.reject(new EntryReadError('authentication_required'))})); + await f.controller.refresh(); + expect(f.controller.view.session).toBeNull(); expect(f.controller.view.authorization).toBeNull(); + expect(f.controller.view.decision?.state).toBe('authentication_required'); + expect(f.server.authorization).toHaveBeenCalledOnce(); +}); +test.each(['authentication_required', 'forbidden', 'unavailable', 'invalid_response'] as const)( + 'HADB-01: a failed grant recheck after an optional read maps %s without exposing earlier facts', async code => { + const f = fixture(active); + f.server.authorization.mockReturnValueOnce(read(active)).mockImplementationOnce(() => ({ + cancel: vi.fn(), result: Promise.reject(new EntryReadError(code))})); + f.server.household.mockImplementation(() => ({cancel: vi.fn(), result: Promise.reject(new Error('source'))})); + await f.controller.refresh(); + expect(f.controller.view.session).toBeNull(); expect(f.controller.view.authorization).toBeNull(); + expect(f.controller.view.household).toBeNull(); expect(f.controller.view.journal).toBeNull(); + expect(f.controller.view.notice).toBe(code); + }, +); +test('HADB-01: expiry while rechecking partial access hides the verified session and all private facts', async () => { + const f = fixture(active), waiting = deferred(); + f.server.authorization.mockReturnValueOnce(read(active)).mockReturnValueOnce(waiting); + f.server.household.mockImplementation(() => ({cancel: vi.fn(), result: Promise.reject(new Error('source'))})); + const opening = f.controller.refresh(); await settle(); + expect(f.server.authorization).toHaveBeenCalledTimes(2); + f.setNow(session.expiresAt); waiting.resolve(active); await opening; + expect(f.controller.view.session).toBeNull(); expect(f.controller.view.household).toBeNull(); + expect(f.controller.view.decision?.state).toBe('authentication_required'); +}); +test('HADB-01: a retired failed optional read cannot start a fresh authorization read or replace the newer view', async () => { + const f = fixture(active), old = deferred(); + f.server.household.mockReturnValueOnce(old); + const earlier = f.controller.refresh(); await settle(); await f.controller.refresh(); + const before = f.controller.view, calls = f.server.authorization.mock.calls.length; + old.reject(new EntryReadError('unavailable')); await earlier; + expect(old.cancel).toHaveBeenCalledOnce(); expect(f.server.authorization).toHaveBeenCalledTimes(calls); + expect(f.controller.view).toEqual(before); +}); +test('HADB-01: a joint lease expiry hides access without revoking HA or clearing original evidence, including after closure', async () => { + const f = fixture(eligible); await f.controller.refresh(); + f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); await f.controller.requestMembership(); + const original = f.values.get(ENTRY_JOURNAL_KEY); f.controller.expire(); + expect(f.controller.view.decision?.state).toBe('authentication_required'); expect(f.controller.view.session).toBeNull(); + expect(f.values.get(ENTRY_JOURNAL_KEY)).toBe(original); expect(f.server.logout).not.toHaveBeenCalled(); + await f.controller.refresh(); expect(f.controller.view.session).toEqual(session); + f.controller.close(); f.controller.expire(); expect(f.controller.view.phase).toBe('closed'); +}); + test.each(['suspended', 'archived'] as const)('C13: %s linkage hides local work and blocks recovery', async state => { const f = fixture(eligible); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); await f.controller.requestMembership(); diff --git a/frontend/tests/e2e/administration.spec.ts b/frontend/tests/e2e/administration.spec.ts new file mode 100644 index 0000000..50df0af --- /dev/null +++ b/frontend/tests/e2e/administration.spec.ts @@ -0,0 +1,470 @@ +import {chromium,expect,test} from '@playwright/test'; +import {mkdir,writeFile} from 'node:fs/promises'; +import type {APIRequestContext,BrowserContext,Locator,Page} from '@playwright/test'; + +const prefix='/api/hassio_ingress/fixture_domus_supervisor_1234567890'; +type Mode='native'|'standalone'; +const origin=(mode:Mode)=>`http://127.0.0.1:${mode==='native'?56174:56175}`; +const base=(mode:Mode)=>origin(mode)+(mode==='native'?prefix:''); +const journalKey='domus.administration.journal.v1'; +const statistics=async(request:APIRequestContext,mode:Mode)=>(await request.get(origin(mode)+'/__fixture__/stats')).json(); +async function signIn(page:Page,mode:Mode) { + if(mode==='standalone') { + await page.getByRole('button',{name:'Sign in with Home Assistant',exact:true}).click(); + await page.getByRole('link',{name:'Continue as fixture user',exact:true}).click(); + } +} +async function create(page:Page,context:BrowserContext,mode:Mode) { + if(mode==='native')await context.addCookies([{name:'ingress_session',value:'f'.repeat(128),domain:'127.0.0.1',path:'/',httpOnly:true,sameSite:'Strict'}]); + await page.goto(base(mode)+'/');await signIn(page,mode); + await page.getByLabel('Household name',{exact:true}).fill('Maple House');await page.getByLabel('Timezone',{exact:true}).fill('UTC'); + await page.getByRole('button',{name:'Create household',exact:true}).click(); + const widget=page.locator('domus-administration');await expect(widget.getByRole('heading',{name:'Household administration',exact:true})).toBeVisible(); + await expect(widget.getByRole('button',{name:'Edit preferences',exact:true})).toBeEnabled();return widget; +} +async function save(widget:Locator) { + await widget.getByRole('button',{name:'Review changes',exact:true}).click();await expect(widget.getByRole('heading',{name:'Confirm this household change',exact:true})).toBeFocused(); + await widget.getByRole('button',{name:'Save changes',exact:true}).click(); + await expect(widget.getByRole('heading',{name:'Original request processed',exact:true})).toBeVisible(); + await expect(widget.getByRole('button',{name:'Refresh administration',exact:true})).toBeEnabled(); +} +for(const mode of ['native','standalone'] as const)test.describe('administration '+mode,()=>{ + test.beforeEach(async({request})=>{ + if(process.env.DOMUS_BROWSER_IMAGE){ + const image=await request.get(origin(mode)+'/__fixture__/image');expect(image.status()).toBe(200); + expect(await image.json()).toEqual({source_commit:process.env.DOMUS_TEST_SOURCE_COMMIT,architecture:process.env.DOMUS_TEST_ARCH, + image_id:expect.stringMatching(/^sha256:[a-f0-9]{64}$/u),mode}); + } + expect((await request.post(origin(mode)+'/__fixture__/reset')).ok()).toBe(true); + }); + test('HADW-01: actual independent alias revision, inert text, reload and explicit clear persist through SQLite',async({page,context})=>{ + const widget=await create(page,context,mode);const access=await(await page.request.get(base(mode)+'/api/v1/authorization')).json(); + const path=base(mode)+'/api/v1/administration/members/'+access.member.member_id+'/presentation'; + await widget.getByRole('button',{name:'Edit own alias',exact:true}).click();await widget.getByRole('checkbox',{name:'Use Home Assistant name',exact:true}).uncheck(); + const alias=' & Maple';await widget.getByRole('textbox',{name:'Domus alias',exact:true}).fill(alias);await save(widget); + expect(await(await page.request.get(path)).json()).toEqual({member_id:access.member.member_id,alias,revision:2});await expect(widget.locator('img,script')).toHaveCount(0); + await page.reload();await expect(widget.getByText(alias,{exact:true}).first()).toBeVisible(); + await widget.getByRole('button',{name:'Edit own alias',exact:true}).click();await widget.getByRole('checkbox',{name:'Use Home Assistant name',exact:true}).check();await save(widget); + expect(await(await page.request.get(path)).json()).toEqual({member_id:access.member.member_id,alias:null,revision:3}); + }); + test('HADW-02: actual private preference form saves Spanish and the summary choice without delivering a notification',async({page,context,request})=>{ + const widget=await create(page,context,mode);await widget.getByRole('button',{name:'Edit preferences',exact:true}).click(); + await widget.getByLabel('Saved language',{exact:true}).selectOption('es');await widget.getByRole('checkbox',{name:'Routine participation summaries',exact:true}).uncheck(); + await widget.getByRole('button',{name:'Review changes',exact:true}).click();await widget.getByRole('button',{name:'Save changes',exact:true}).click(); + await expect(widget.getByRole('heading',{name:'Petición original procesada',exact:true})).toBeVisible();await expect(page.locator('html')).toHaveAttribute('lang','es'); + expect(await(await page.request.get(base(mode)+'/api/v1/administration/preferences')).json()).toMatchObject({locale:'es',routine_participation_summaries:false,revision:2,notification_delivery:'unavailable'}); + expect((await statistics(request,mode)).counts.receipts).toBe(2);await page.reload();await expect(widget).toHaveAttribute('lang','es'); + await widget.getByRole('button',{name:'Editar preferencias',exact:true}).click();await expect(widget.getByLabel('Idioma guardado',{exact:true})).toHaveValue('es'); + await expect(widget.getByRole('checkbox',{name:'Resúmenes habituales de participación',exact:true})).not.toBeChecked(); + }); + test('HADW-04: actual household form saves the exact name/timezone once and reload reads the persisted revision',async({page,context,request})=>{ + const widget=await create(page,context,mode);await widget.getByRole('button',{name:'Edit household',exact:true}).click(); + await widget.getByRole('textbox',{name:'New household name',exact:true}).fill('Cedar House');await widget.getByRole('textbox',{name:'New timezone',exact:true}).fill('Europe/Madrid');await save(widget); + expect(await(await page.request.get(base(mode)+'/api/v1/household')).json()).toMatchObject({name:'Cedar House',time_zone:'Europe/Madrid',revision:2}); + expect((await statistics(request,mode)).counts.receipts).toBe(2);await page.reload();await expect(page.getByText('Cedar House',{exact:true})).toBeVisible(); + }); + test('household-workspace-C78: an actual administration write preserves a revision above 2^53 through HTTP and SQLite',async({page,context,request})=>{ + const widget=await create(page,context,mode),before=await statistics(request,mode),writes:{key:string;body:string|null}[]=[]; + // A legitimate persisted boundary fixture, not a claim that 2^53 commands have executed. + expect((await request.post(origin(mode)+'/__fixture__/revision',{data:{revision:'9007199254740993'}})).ok()).toBe(true); + await page.reload();await expect(widget.getByRole('button',{name:'Edit household',exact:true})).toBeEnabled(); + page.on('request',item=>{if(item.url()===base(mode)+'/api/v1/household'&&item.method()==='PUT') + writes.push({key:item.headers()['idempotency-key'],body:item.postData()});}); + await widget.getByRole('button',{name:'Edit household',exact:true}).click(); + await expect(widget.getByRole('region',{name:'Household settings',exact:true})).toContainText('9007199254740993'); + await widget.getByRole('textbox',{name:'New household name',exact:true}).fill('Exact revision house');await save(widget); + expect(writes).toHaveLength(1);expect(writes[0].body).toBe('{"expected_revision":9007199254740993,"name":"Exact revision house","time_zone":"UTC"}'); + // Assert raw wire integers: JSON.parse would coerce the original revision to an unsafe Number. + const persisted=await page.request.get(base(mode)+'/api/v1/household');expect(persisted.status()).toBe(200); + expect(await persisted.text()).toMatch(/"revision":\s*9007199254740994(?=[,}])/u); + expect((await statistics(request,mode)).counts.receipts).toBe(before.counts.receipts+1); + const records=await page.evaluate(key=>JSON.parse(sessionStorage.getItem(key)!).records,journalKey); + expect(records).toHaveLength(1);expect(records[0]).toMatchObject({key:writes[0].key,stage:'committed',payload:{expected_revision:'9007199254740993',name:'Exact revision house'}}); + await page.reload();await widget.getByRole('button',{name:'Edit household',exact:true}).click(); + await expect(widget.getByRole('region',{name:'Household settings',exact:true})).toContainText('9007199254740994');expect(writes).toHaveLength(1); + }); + for(const actors of ['same_account','different_accounts'] as const)test('household-browser-C22: independent '+actors+' administration writers and a lost committed reply retain one original effect',async({page,context,browser,request})=>{ + const widget=await create(page,context,mode),peer=await browser.newContext(),other=await peer.newPage(); + let release!:()=>void,arrived!:()=>void,count=0; + const gate=new Promise(resolve=>{release=resolve;}),ready=new Promise(resolve=>{arrived=resolve;}); + const writes:{index:number;key:string;body:string|null;status:number;code:string|null}[]=[],lookups:string[]=[]; + const pages=[page,other],names=['Independent house A','Independent house B']; + try{ + if(mode==='native')await peer.addCookies([{name:'ingress_session',value:(actors==='same_account'?'d':'e').repeat(128),domain:'127.0.0.1',path:'/',httpOnly:true,sameSite:'Strict'}]); + else if(actors==='different_accounts')expect((await request.post(origin(mode)+'/__fixture__/identity',{data:{subject:'c'.repeat(32)}})).ok()).toBe(true); + await other.goto(base(mode)+'/');await signIn(other,mode); + // A different verified HA administrator must explicitly join before the mapping can apply. + if(actors==='different_accounts')await other.getByRole('button',{name:'Request membership',exact:true}).click(); + const widgets=[widget,other.locator('domus-administration')]; + await expect(widgets[1].getByRole('button',{name:'Edit household',exact:true})).toBeEnabled(); + if(mode==='standalone'&&actors==='different_accounts')expect((await request.post(origin(mode)+'/__fixture__/identity',{data:{subject:'a'.repeat(32)}})).ok()).toBe(true); + const before=await statistics(request,mode); + for(const [index,candidate]of pages.entries()){ + candidate.on('request',item=>{if(item.url().includes('/administration/requests/')||item.url().includes('/operations/'))lookups.push(item.url());}); + await candidate.route(base(mode)+'/api/v1/household',async route=>{ + if(route.request().method()!=='PUT')return route.continue(); + const original={index,key:route.request().headers()['idempotency-key'],body:route.request().postData()}; + if(++count===2)arrived();await gate; + const upstream=await route.fetch({maxRetries:0,maxRedirects:0}),body=await upstream.body(); + writes.push({...original,status:upstream.status(),code:upstream.status()===200?null:JSON.parse(body.toString('utf8')).code}); + if(upstream.status()===200)await route.abort('connectionreset');else await route.fulfill({response:upstream,body}); + }); + await widgets[index].getByRole('button',{name:'Edit household',exact:true}).click(); + await widgets[index].getByRole('textbox',{name:'New household name',exact:true}).fill(names[index]); + await widgets[index].getByRole('button',{name:'Review changes',exact:true}).click(); + } + await Promise.all(widgets.map(candidate=>candidate.getByRole('button',{name:'Save changes',exact:true}).click())); + await ready;release();await expect.poll(()=>writes.length).toBe(2); + const winner=writes.find(item=>item.status===200)!,loser=writes.find(item=>item.status!==200)!; + expect(winner).toBeDefined();expect(loser).toBeDefined();expect([409,503]).toContain(loser.status); + if(loser.status===503)expect(loser.code).toBe('storage_busy');else expect(loser.code).toBe('revision_conflict'); + expect(new Set(writes.map(item=>item.key)).size).toBe(2); + for(const write of writes)expect(JSON.parse(write.body!)).toEqual({expected_revision:1,name:names[write.index],time_zone:'UTC'}); + await expect(widgets[winner.index].getByRole('heading',{name:'Outcome not confirmed',exact:true})).toBeVisible(); + await expect(widgets[loser.index].getByRole('heading',{name:'Change not accepted',exact:true})).toBeVisible(); + expect(lookups).toEqual([]);const committed=await statistics(request,mode); + for(const table of ['receipts','audit','outbox'])expect(committed.counts[table],table).toBe(before.counts[table]+1); + expect(committed.counts.members).toBe(before.counts.members); + expect(await(await page.request.get(base(mode)+'/api/v1/household')).json()).toMatchObject({name:names[winner.index],revision:2}); + const original=await pages[winner.index].evaluate(key=>JSON.parse(sessionStorage.getItem(key)!).records[0],journalKey); + expect(original).toMatchObject({key:winner.key,stage:'uncertain',payload:{expected_revision:'1',name:names[winner.index]}}); + await pages[winner.index].reload();const restored=pages[winner.index].locator('domus-administration'); + await expect(restored.getByRole('button',{name:'Check original request',exact:true})).toBeEnabled();expect(count).toBe(2);expect(lookups).toEqual([]); + await restored.getByRole('button',{name:'Check original request',exact:true}).click(); + await expect(restored.getByRole('heading',{name:'Original request processed',exact:true})).toBeVisible(); + expect(lookups).toEqual([base(mode)+'/api/v1/administration/requests/household.update/'+winner.key]);expect(count).toBe(2); + expect(await statistics(request,mode)).toEqual(committed); + const recovered=await pages[winner.index].evaluate(key=>JSON.parse(sessionStorage.getItem(key)!).records[0],journalKey); + expect(recovered).toMatchObject({...original,stage:'committed',operationId:expect.any(String)}); + }finally{release();await peer.close();} + }); + test('HADW-05: actual mapping/profile form preserves all other profiles and SQLite reauthorizes the selected account',async({page,context})=>{ + const widget=await create(page,context,mode);await widget.getByRole('button',{name:'Edit access rules',exact:true}).click(); + await widget.getByLabel('HA administrator mapping',{exact:true}).selectOption('selected_ha_user');await widget.getByLabel('Target Home Assistant account',{exact:true}).selectOption('a'.repeat(32)); + await widget.getByLabel('Profile to customize',{exact:true}).selectOption('adult');await widget.getByLabel('View tasks',{exact:true}).selectOption('deny');await save(widget); + expect(await(await page.request.get(base(mode)+'/api/v1/policy')).json()).toMatchObject({revision:2,mapping:{mode:'selected_ha_user',target_ha_user_id:'a'.repeat(32)}, + adjustments:[{role:'adult',permission:'tasks.read',allow:false}]}); + await page.reload();await widget.getByRole('button',{name:'Edit access rules',exact:true}).click(); + await expect(widget.getByLabel('HA administrator mapping',{exact:true})).toHaveValue('selected_ha_user');await expect(widget.getByLabel('View tasks',{exact:true})).toHaveValue('deny'); + }); + test('household-admin-C33: an administrator edits a pending alias without activation or private changes, then the same participant gains reviewed access',async({page,context,browser,request})=>{ + const widget=await create(page,context,mode),peer=await browser.newContext(),other=await peer.newPage(); + const subject=mode==='native'?'c':'b',alias='Shared participant alias'; + try{ + await widget.getByRole('button',{name:'Edit access rules',exact:true}).click(); + await widget.getByLabel('HA administrator mapping',{exact:true}).selectOption('selected_ha_user'); + await widget.getByLabel('Target Home Assistant account',{exact:true}).selectOption('a'.repeat(32));await save(widget); + await widget.getByRole('button',{name:'Close editor',exact:true}).click(); + if(mode==='native')await peer.addCookies([{name:'ingress_session',value:'e'.repeat(128),domain:'127.0.0.1',path:'/',httpOnly:true,sameSite:'Strict'}]); + else expect((await request.post(origin(mode)+'/__fixture__/identity',{data:{subject:subject.repeat(32)}})).ok()).toBe(true); + await other.goto(base(mode)+'/');await signIn(other,mode); + await other.getByRole('button',{name:'Request membership',exact:true}).click(); + await expect(other.getByRole('heading',{name:'Membership pending',exact:true})).toBeVisible(); + if(mode==='standalone')expect((await request.post(origin(mode)+'/__fixture__/identity',{data:{subject:'a'.repeat(32)}})).ok()).toBe(true); + await widget.getByRole('button',{name:'Refresh administration',exact:true}).click(); + const roster=await(await page.request.get(base(mode)+'/api/v1/administration/roster')).json(); + const original=roster.members.find((row:{member:{ha_user_id:string}})=>row.member.ha_user_id===subject.repeat(32)); + // Pending storage is viewer with no active access; the activation editor separately defaults to restricted. + expect(original.member).toMatchObject({status:'pending',role:'viewer',revision:1}); + const preferences=await(await page.request.get(base(mode)+'/api/v1/administration/preferences')).json(); + const policy=await(await page.request.get(base(mode)+'/api/v1/policy')).json(),before=await statistics(request,mode); + const path=base(mode)+'/api/v1/administration/members/'+original.member.member_id+'/presentation',writes:{key:string;body:string|null}[]=[]; + page.on('request',item=>{if(item.url()===path&&item.method()==='PUT')writes.push({key:item.headers()['idempotency-key'],body:item.postData()});}); + await widget.getByRole('button',{name:'Change alias: Fictional account '+subject,exact:true}).click(); + await widget.getByRole('checkbox',{name:'Use Home Assistant name',exact:true}).uncheck(); + await widget.getByRole('textbox',{name:'Domus alias',exact:true}).fill(alias);await save(widget); + expect(writes).toHaveLength(1);expect(JSON.parse(writes[0].body!)).toEqual({expected_revision:1,alias}); + const current=await(await page.request.get(base(mode)+'/api/v1/administration/roster')).json(); + const changed=current.members.find((row:{member:{member_id:string}})=>row.member.member_id===original.member.member_id); + expect(changed.member).toEqual(original.member);expect(changed.metadata).toEqual(original.metadata); + expect(changed.presentation).toEqual({...original.presentation,alias,revision:2}); + expect(changed).not.toHaveProperty('preferences');await expect(widget.locator('b,img,script')).toHaveCount(0); + expect(await(await page.request.get(base(mode)+'/api/v1/administration/preferences')).json()).toEqual(preferences); + expect(await(await page.request.get(base(mode)+'/api/v1/policy')).json()).toEqual(policy); + const after=await statistics(request,mode); + for(const table of ['receipts','audit','outbox'])expect(after.counts[table],table).toBe(before.counts[table]+1); + for(const table of ['members','households'])expect(after.counts[table],table).toBe(before.counts[table]); + await other.getByRole('button',{name:'Refresh status',exact:true}).click(); + await expect(other.getByRole('heading',{name:'Membership pending',exact:true})).toBeVisible(); + await widget.getByRole('button',{name:'Close editor',exact:true}).click(); + await widget.getByRole('button',{name:'Manage participant: '+alias,exact:true}).click(); + await expect(widget.getByLabel('Household profile',{exact:true})).toHaveValue('restricted'); + await widget.getByLabel('Membership status',{exact:true}).selectOption('active'); + await widget.getByRole('checkbox',{name:'Approve household access',exact:true}).check();await save(widget); + await other.getByRole('button',{name:'Refresh status',exact:true}).click(); + const own=other.locator('domus-administration');await expect(own.getByRole('button',{name:'Edit preferences',exact:true})).toBeEnabled(); + expect(await(await other.request.get(base(mode)+'/api/v1/administration/preferences')).json()).toMatchObject({ + member_id:original.member.member_id,locale:null,routine_participation_summaries:true,revision:1,notification_delivery:'unavailable'}); + expect(await(await other.request.get(path)).json()).toEqual({member_id:original.member.member_id,alias,revision:2}); + await other.reload();await expect(own.getByText(alias,{exact:true}).first()).toBeVisible();expect(writes).toHaveLength(1); + }finally{await peer.close();} + }); + test('HADW-04: an actual second HA account requests membership before restricted activation and a later profile change',async({page,context,browser,request})=>{ + const widget=await create(page,context,mode); + await widget.getByRole('button',{name:'Edit access rules',exact:true}).click();await widget.getByLabel('HA administrator mapping',{exact:true}).selectOption('selected_ha_user'); + await widget.getByLabel('Target Home Assistant account',{exact:true}).selectOption('a'.repeat(32));await save(widget);await widget.getByRole('button',{name:'Close editor',exact:true}).click(); + const peer=await browser.newContext(),other=await peer.newPage();const subject=mode==='native'?'c':'b'; + try { + if(mode==='native')await peer.addCookies([{name:'ingress_session',value:'e'.repeat(128),domain:'127.0.0.1',path:'/',httpOnly:true,sameSite:'Strict'}]); + else expect((await request.post(origin(mode)+'/__fixture__/identity',{data:{subject:subject.repeat(32)}})).ok()).toBe(true); + await other.goto(base(mode)+'/');await signIn(other,mode);await other.getByRole('button',{name:'Request membership',exact:true}).click(); + await expect(other.getByRole('heading',{name:'Membership pending',exact:true})).toBeVisible(); + if(mode==='standalone')await request.post(origin(mode)+'/__fixture__/identity',{data:{subject:'a'.repeat(32)}}); + await widget.getByRole('button',{name:'Refresh administration',exact:true}).click(); + await widget.getByRole('button',{name:'Manage participant: Fictional account '+subject,exact:true}).click(); + await expect(widget.getByLabel('Household profile',{exact:true})).toHaveValue('restricted');await widget.getByLabel('Membership status',{exact:true}).selectOption('active'); + await widget.getByRole('button',{name:'Review changes',exact:true}).click();await expect(widget.getByRole('alert')).toContainText('Confirm household access'); + await widget.getByRole('checkbox',{name:'Approve household access',exact:true}).check(); + await expect(widget.getByRole('region',{name:'Manage participant',exact:true})).toContainText('Participant: Fictional account '+subject);await save(widget); + const roster=await(await page.request.get(base(mode)+'/api/v1/administration/roster')).json(); + expect(roster.members.find((row:{member:{ha_user_id:string}})=>row.member.ha_user_id===subject.repeat(32)).member).toMatchObject({role:'restricted',status:'active',revision:2}); + await widget.getByRole('button',{name:'Close editor',exact:true}).click(); + await widget.getByRole('button',{name:'Manage participant: Fictional account '+subject,exact:true}).click(); + await expect(widget.getByLabel('Household profile',{exact:true})).toHaveValue('restricted'); + await widget.getByLabel('Household profile',{exact:true}).selectOption('adult');await widget.getByLabel('View tasks',{exact:true}).selectOption('deny');await save(widget); + const changed=await(await page.request.get(base(mode)+'/api/v1/administration/roster')).json(); + expect(changed.members.find((row:{member:{ha_user_id:string}})=>row.member.ha_user_id===subject.repeat(32)).member) + .toMatchObject({role:'adult',status:'active',revision:3,exceptions:[{permission:'tasks.read',allow:false}]}); + + }finally{await peer.close();} + }); + test('HADB-02: a lost actual preference PUT reply recovers by original GET without another SQLite mutation',async({page,context,request})=>{ + const widget=await create(page,context,mode),writes:{key:string;body:string|null}[]=[],lookups:string[]=[]; + page.on('request',item=>{if(item.url().includes('/administration/requests/')||item.url().includes('/operations/'))lookups.push(item.url());}); + await page.route(base(mode)+'/api/v1/administration/preferences',async route=>{ + if(route.request().method()!=='PUT')return route.continue();writes.push({key:route.request().headers()['idempotency-key'],body:route.request().postData()}); + const reply=await route.fetch({maxRetries:0,maxRedirects:0});expect(reply.status()).toBe(200);await route.abort('connectionreset'); + }); + await widget.getByRole('button',{name:'Edit preferences',exact:true}).click();await widget.getByLabel('Saved language',{exact:true}).selectOption('en'); + await widget.getByRole('button',{name:'Review changes',exact:true}).click();await widget.getByRole('button',{name:'Save changes',exact:true}).click(); + await expect(widget.getByRole('heading',{name:'Outcome not confirmed',exact:true})).toBeVisible();const before=await statistics(request,mode); + expect(before.counts.receipts).toBe(2);expect(writes).toHaveLength(1);expect(lookups).toEqual([]);await page.reload(); + await expect(widget.getByRole('button',{name:'Check original request',exact:true})).toBeEnabled();expect(writes).toHaveLength(1);expect(lookups).toEqual([]); + await widget.getByRole('button',{name:'Check original request',exact:true}).click();await expect(widget.getByRole('heading',{name:'Original request processed',exact:true})).toBeVisible(); + expect(writes).toHaveLength(1);expect(lookups).toEqual([base(mode)+'/api/v1/administration/requests/member.preferences.update/'+writes[0].key]); + expect(await statistics(request,mode)).toEqual(before);const records=await page.evaluate(key=>JSON.parse(sessionStorage.getItem(key)!).records,journalKey); + const wirePayload=JSON.parse(writes[0].body!); + expect(records[0]).toMatchObject({key:writes[0].key,stage:'committed',payload:{...wirePayload,expected_revision:String(wirePayload.expected_revision)}}); + }); + test('HADB-02: stale actual household revision retains the original draft until an explicit reset',async({page,context})=>{ + const widget=await create(page,context,mode);await widget.getByRole('button',{name:'Edit household',exact:true}).click();await widget.getByRole('textbox',{name:'New household name',exact:true}).fill('Unsent draft'); + const csrf=await(await page.request.get(base(mode)+'/api/v1/auth/csrf')).json();expect((await page.request.put(base(mode)+'/api/v1/household',{ + data:{expected_revision:1,name:'Other current name',time_zone:'UTC'},headers:{Origin:origin(mode),'X-CSRF-Token':csrf.csrf_token,'Idempotency-Key':'explicit-concurrent-household'}})).status()).toBe(200); + await widget.getByRole('button',{name:'Review changes',exact:true}).click();await widget.getByRole('button',{name:'Save changes',exact:true}).click(); + await expect(widget.getByText('The data changed. Your draft keeps its original revision; reset explicitly to adopt current data.',{exact:true})).toBeVisible(); + await expect(widget.getByText('This request was submitted.',{exact:false})).toHaveCount(0);await expect(widget.getByText('New household name',{exact:true})).toBeVisible(); + // A rejected save retains the reviewed command as a definition list, not an editable input. + await expect(widget.getByRole('textbox',{name:'New household name',exact:true})).toHaveCount(0); + await expect(widget.locator('dl dd').filter({hasText:/^Unsent draft$/u})).toBeVisible(); + await widget.getByRole('button',{name:'Reset from current data',exact:true}).click(); + await expect(widget.locator('dl dd').filter({hasText:/^Unsent draft$/u})).toHaveCount(0); + await expect(widget.getByRole('textbox',{name:'New household name',exact:true})).toHaveValue('Other current name'); + await widget.getByRole('textbox',{name:'New household name',exact:true}).fill('Explicit new work');await save(widget); + expect(await(await page.request.get(base(mode)+'/api/v1/household')).json()).toMatchObject({name:'Explicit new work',revision:3}); + }); + test('household-workspace-C50: an actual HA outage locks the retained draft and keyboard transitions keep an enabled focus target',async({page,context,request})=>{ + const widget=await create(page,context,mode),before=await statistics(request,mode),writes:string[]=[]; + page.on('request',item=>{if(item.method()==='PUT')writes.push(item.url());}); + await widget.getByRole('button',{name:'Edit household',exact:true}).click(); + await widget.getByRole('textbox',{name:'New household name',exact:true}).fill('Retained outage draft'); + await widget.getByRole('button',{name:'Review changes',exact:true}).click(); + expect((await request.post(origin(mode)+'/__fixture__/identity',{data:{outage:true}})).ok()).toBe(true); + await widget.getByRole('button',{name:'Refresh administration',exact:true}).click(); + await expect(page.getByRole('heading',{name:'Read-only access',exact:true})).toBeVisible(); + await expect(widget.getByRole('button',{name:'Save changes',exact:true})).toBeDisabled(); + const back=widget.getByRole('button',{name:'Back to editing',exact:true});await back.focus();await back.press('Enter'); + await expect(widget.getByRole('button',{name:'Refresh administration',exact:true})).toBeFocused(); + await expect(widget.getByRole('textbox',{name:'New household name',exact:true})).toHaveValue('Retained outage draft'); + await expect(widget.getByRole('textbox',{name:'New household name',exact:true})).toBeDisabled(); + const cancel=widget.getByRole('button',{name:'Cancel',exact:true});await cancel.focus();await cancel.press('Enter'); + await expect(widget.getByRole('button',{name:'Refresh administration',exact:true})).toBeFocused(); + await expect(widget.getByRole('textbox',{name:'New household name',exact:true})).toHaveCount(0); + expect((await request.post(origin(mode)+'/__fixture__/identity',{data:{outage:false}})).ok()).toBe(true); + await widget.getByRole('button',{name:'Refresh administration',exact:true}).click(); + await widget.getByRole('button',{name:'Edit household',exact:true}).click(); + await expect(widget.getByRole('textbox',{name:'New household name',exact:true})).toHaveValue('Maple House'); + expect(writes).toEqual([]);expect(await statistics(request,mode)).toEqual(before); + }); + test('household-admin-C58: actual compiled keyboard errors and back focus work in both locales with mobile CSP fallback',async({page,context,request},info)=>{ + await page.setViewportSize({width:360,height:800});const violations:string[]=[],writes:string[]=[]; + await page.exposeFunction('administrationCSPViolation',(value:string)=>violations.push(value)); + await page.addInitScript(()=>{ + delete (Document.prototype as {adoptedStyleSheets?:unknown}).adoptedStyleSheets; + document.addEventListener('securitypolicyviolation',event=>{ + void (window as unknown as {administrationCSPViolation(value:string):Promise}).administrationCSPViolation(event.violatedDirective); + }); + }); + const widget=await create(page,context,mode),before=await statistics(request,mode); + page.on('request',item=>{if(item.method()==='PUT')writes.push(item.url());}); + for(const locale of ['en','es'])for(const colorScheme of ['light','dark'] as const) { + await page.emulateMedia({colorScheme,reducedMotion:'reduce'});await page.locator('domus-app .language select').selectOption(locale); + const names=locale==='en'?{edit:'Edit own alias',clear:'Use Home Assistant name',alias:'Domus alias',review:'Review changes',back:'Back to editing',cancel:'Cancel',heading:'Confirm this household change'} + :{edit:'Editar mi alias',clear:'Usar el nombre de Home Assistant',alias:'Alias de Domus',review:'Revisar cambios',back:'Volver a editar',cancel:'Cancelar',heading:'Confirma este cambio del hogar'}; + await widget.getByRole('button',{name:names.edit,exact:true}).click();await expect(widget.getByRole('checkbox',{name:names.clear,exact:true})).toBeFocused(); + await page.keyboard.press('Space');await page.keyboard.press('Tab');await page.keyboard.press('Tab');await page.keyboard.press('Enter'); + await expect(widget.getByRole('checkbox',{name:names.clear,exact:true})).toBeFocused();const id=await widget.getByRole('alert').getAttribute('id');expect(id).not.toBeNull(); + await expect(widget.getByRole('textbox',{name:names.alias,exact:true})).toHaveAttribute('aria-describedby',id!); + await page.keyboard.press('Tab');await page.keyboard.insertText('Keyboard '+locale);await page.keyboard.press('Tab');await page.keyboard.press('Enter'); + await expect(widget.getByRole('heading',{name:names.heading,exact:true})).toBeFocused();await page.keyboard.press('Tab');await page.keyboard.press('Tab');await page.keyboard.press('Enter'); + await expect(widget.getByRole('checkbox',{name:names.clear,exact:true})).toBeFocused();await expect(widget.getByRole('textbox',{name:names.alias,exact:true})).toHaveValue('Keyboard '+locale); + const layout=await widget.evaluate(element=>({nonces:[...element.shadowRoot!.querySelectorAll('style')].map(style=>style.nonce), + background:getComputedStyle(element.shadowRoot!.querySelector('button.primary')!).backgroundColor,width:document.documentElement.scrollWidth})); + expect(layout.nonces.length).toBeGreaterThan(0);expect(layout.nonces.every(value=>/^[\w-]{32}$/u.test(value))).toBe(true); + expect(layout.background).not.toBe('rgba(0, 0, 0, 0)');expect(layout.width).toBeLessThanOrEqual(360);expect(violations).toEqual([]); + await page.screenshot({path:info.outputPath('admin-keyboard-'+locale+'-'+colorScheme+'.png'),fullPage:true}); + await widget.getByRole('button',{name:names.cancel,exact:true}).click(); + } + expect(writes).toEqual([]);expect(await statistics(request,mode)).toEqual(before); + }); + test('household-admin-C65: normal no-store back navigation reloads with current authority instead of reviving a private draft',async({page,context,request},info)=>{ + const widget=await create(page,context,mode),before=await statistics(request,mode),writes:string[]=[]; + page.on('request',item=>{if(item.method()==='PUT')writes.push(item.url());}); + await widget.getByRole('button',{name:'Edit household',exact:true}).click(); + await widget.getByRole('textbox',{name:'New household name',exact:true}).fill('Unsent private navigation draft'); + await page.evaluate(()=>{(window as unknown as {domusColdNavigationMarker:string}).domusColdNavigationMarker=crypto.randomUUID();}); + await page.goto(origin(mode)+'/__fixture__/ready'); + expect((await request.post(origin(mode)+'/__fixture__/identity',{data:{rejected:true}})).ok()).toBe(true); + await page.goBack({waitUntil:'commit'}); + await expect(page.getByRole('button',{name:mode==='native'?'Reopen Domus':'Sign in with Home Assistant',exact:true})).toBeVisible(); + await expect(widget.getByRole('heading',{name:'Household administration',exact:true})).toHaveCount(0); + await expect(widget.getByRole('textbox',{name:'New household name',exact:true})).toHaveCount(0); + const observation=await page.evaluate(()=>({marker:(window as unknown as {domusColdNavigationMarker?:string}).domusColdNavigationMarker??null, + navigation:(performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming).type, + reasons:(performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming&{notRestoredReasons?:{toJSON():unknown}}).notRestoredReasons?.toJSON()??null})); + expect(observation.marker).toBeNull();expect(observation.navigation).toBe('back_forward'); + expect(JSON.stringify(observation.reasons)).toContain('response-cache-control-no-store'); + expect(writes).toEqual([]);expect(await statistics(request,mode)).toEqual(before); + await info.attach('normal-no-store-navigation',{body:JSON.stringify(observation),contentType:'application/json'}); + }); + test('household-admin-C64: actual closed-tab restoration recovers a committed request whose response was never delivered',async({request},info)=>{ + // A disposable Chromium profile/extension invokes the public sessions API. No owner profile or injected journal data. + const extension=info.outputPath('session-restore-extension');await mkdir(extension,{recursive:true}); + await writeFile(extension+'/manifest.json',JSON.stringify({manifest_version:3,name:'Domus closed-tab test driver',version:'1.0', + permissions:['sessions','tabs'],background:{service_worker:'worker.js'}})); + await writeFile(extension+'/worker.js','chrome.runtime.onInstalled.addListener(() => {});'); + const context=await chromium.launchPersistentContext(info.outputPath('session-restore-profile'),{channel:'chromium',headless:true, + ignoreDefaultArgs:['--disable-extensions'],args:['--disable-extensions-except='+extension,'--load-extension='+extension]}); + let release!:()=>void;const gate=new Promise(resolve=>{release=resolve;}); + try{ + const worker=context.serviceWorkers()[0]??await context.waitForEvent('serviceworker'); + expect(worker.url()).toMatch(/^chrome-extension:\/\//u); + const page=await context.newPage(),widget=await create(page,context,mode),writes:{key:string;body:string}[]=[],lookups:string[]=[]; + context.on('request',item=>{if(item.method()==='PUT')writes.push({key:item.headers()['idempotency-key'],body:item.postData()!}); + if(item.url().includes('/administration/requests/')||item.url().includes('/operations/'))lookups.push(item.url());}); + let committed!:()=>void;const ready=new Promise(resolve=>{committed=resolve;}); + await page.route(base(mode)+'/api/v1/administration/preferences',async route=>{ + if(route.request().method()!=='PUT')return route.continue(); + const upstream=await route.fetch({maxRetries:0,maxRedirects:0});expect(upstream.status()).toBe(200);await upstream.body(); + committed();await gate;if(!page.isClosed())await route.abort('connectionreset'); + }); + await widget.getByRole('button',{name:'Edit preferences',exact:true}).click();await widget.getByLabel('Saved language',{exact:true}).selectOption('en'); + await widget.getByRole('checkbox',{name:'Routine participation summaries',exact:true}).uncheck(); + await widget.getByRole('button',{name:'Review changes',exact:true}).click();await widget.getByRole('button',{name:'Save changes',exact:true}).click(); + await ready;expect(writes).toHaveLength(1);const before=await statistics(request,mode); + const original=await page.evaluate(key=>JSON.parse(sessionStorage.getItem(key)!).records[0],journalKey); + expect(original).toMatchObject({key:writes[0].key,stage:'prepared',payload:{expected_revision:'1',locale:'en',routine_participation_summaries:false}}); + const closedURL=page.url();await page.close();expect(page.isClosed()).toBe(true); + const sessions=await worker.evaluate(async()=>{ + const sessions=(globalThis as unknown as {chrome:{sessions:{getRecentlyClosed(filter:{maxResults:number}):Promise<{tab?:{url?:string;sessionId?:string}}[]>}}}).chrome.sessions; + return sessions.getRecentlyClosed({maxResults:10}); + }); + const closed=sessions.find(item=>item.tab?.url===closedURL)?.tab;expect(closed?.sessionId).toEqual(expect.any(String)); + const opened=context.waitForEvent('page'); + await worker.evaluate(async id=>{ + const sessions=(globalThis as unknown as {chrome:{sessions:{restore(id:string):Promise}}}).chrome.sessions;await sessions.restore(id); + },closed!.sessionId!); + const restored=await opened;await restored.waitForLoadState('domcontentloaded');const resumed=restored.locator('domus-administration'); + await expect(resumed.getByRole('button',{name:'Check original request',exact:true})).toBeEnabled(); + expect(writes).toHaveLength(1);expect(lookups).toEqual([]); + expect(await restored.evaluate(key=>JSON.parse(sessionStorage.getItem(key)!).records[0],journalKey)).toEqual(original); + await resumed.getByRole('button',{name:'Check original request',exact:true}).click(); + await expect(resumed.getByRole('heading',{name:'Original request processed',exact:true})).toBeVisible(); + expect(lookups).toEqual([base(mode)+'/api/v1/administration/requests/member.preferences.update/'+original.key]); + expect(writes).toHaveLength(1);expect(await statistics(request,mode)).toEqual(before); + expect(await(await restored.request.get(base(mode)+'/api/v1/administration/preferences')).json()).toMatchObject({locale:'en',routine_participation_summaries:false,revision:2}); + await info.attach('actual-closed-tab-restore',{body:JSON.stringify({closed:true,restoredURL:restored.url(),originalStage:original.stage,mutationCount:writes.length, + protectedOriginalReads:lookups.length,counts:before.counts,journalInjected:false}),contentType:'application/json'}); + }finally{release();await context.close();} + }); + test('household-admin-C65: real BFCache under an explicit browser cache policy retires drafts and requires fresh proof',async({request},info)=>{ + // Conditional browser policy only: product no-store/CSP/auth headers and all real API responses stay unchanged. + const browser=await chromium.launch({channel:'chromium',headless:true,ignoreDefaultArgs:['--disable-back-forward-cache'],args:[ + '--enable-features=BackForwardCache:should_ignore_blocklists/true,CacheControlNoStoreEnterBackForwardCache:level/restore-unless-cookie-change']}); + try{ + const context=await browser.newContext(),page=await context.newPage(),cdp=await context.newCDPSession(page),notUsed:unknown[]=[]; + cdp.on('Page.backForwardCacheNotUsed',value=>notUsed.push(value));await cdp.send('Page.enable'); + const widget=await create(page,context,mode),writes:string[]=[],lookups:string[]=[]; + page.on('request',item=>{if(item.method()==='PUT')writes.push(item.url()); + if(item.url().includes('/administration/requests/')||item.url().includes('/operations/'))lookups.push(item.url());}); + await widget.getByRole('button',{name:'Edit own alias',exact:true}).click();await widget.getByRole('checkbox',{name:'Use Home Assistant name',exact:true}).uncheck(); + await widget.getByRole('textbox',{name:'Domus alias',exact:true}).fill('Freshly revalidated alias');await save(widget); + await widget.getByRole('button',{name:'Close editor',exact:true}).click(); + await widget.getByRole('button',{name:'Edit preferences',exact:true}).click();await widget.getByLabel('Saved language',{exact:true}).selectOption('es'); + const before=await statistics(request,mode); + const marker=await page.evaluate(()=>{ + type App=HTMLElement&{view:{phase:string;session:unknown};administration:unknown;updateComplete:Promise}; + type Widget=HTMLElement&{updateComplete:Promise}; + const app=document.querySelector('domus-app')! as App,widget=app.shadowRoot!.querySelector('domus-administration')! as Widget; + const probe={marker:crypto.randomUUID(),hides:[] as {persisted:boolean;trusted:boolean;retired:boolean;renderedHidden:boolean}[], + shows:[] as {persisted:boolean;trusted:boolean;proofPending:boolean}[]}; + (window as unknown as {domusNavigationProbe:typeof probe}).domusNavigationProbe=probe; + addEventListener('pagehide',event=>{ + const observation={persisted:event.persisted,trusted:event.isTrusted,retired:app.view.phase==='closed'&&app.view.session===null&&app.administration===null,renderedHidden:false}; + probe.hides.push(observation); + void(async()=>{await app.updateComplete;await widget.updateComplete; + observation.renderedHidden=widget.shadowRoot!.querySelector('form,input,select')===null + &&!widget.shadowRoot!.textContent!.includes('Freshly revalidated alias');})(); + }); + addEventListener('pageshow',event=>{probe.shows.push({persisted:event.persisted,trusted:event.isTrusted,proofPending:app.view.session===null&&app.administration===null});}); + return probe.marker; + }); + const probe=()=>page.evaluate(()=>(window as unknown as {domusNavigationProbe:{marker:string;hides:{persisted:boolean;trusted:boolean;retired:boolean;renderedHidden:boolean}[]; + shows:{persisted:boolean;trusted:boolean;proofPending:boolean}[]}}).domusNavigationProbe); + await page.goto(origin(mode)+'/__fixture__/ready');await page.goBack({waitUntil:'commit'}); + await expect(widget.getByRole('button',{name:'Edit preferences',exact:true})).toBeEnabled(); + const diagnostic=await page.evaluate(()=>({userAgent:navigator.userAgent,reasons:(performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming&{notRestoredReasons?:{toJSON():unknown}}).notRestoredReasons?.toJSON()??null})); + await info.attach('bfcache-navigation-diagnostic',{body:JSON.stringify({diagnostic,notUsed}),contentType:'application/json'}); + const first=await probe();expect(first?.marker,JSON.stringify({diagnostic,notUsed})).toBe(marker); + expect(first.hides).toEqual([{persisted:true,trusted:true,retired:true,renderedHidden:true}]); + expect(first.shows).toEqual([{persisted:true,trusted:true,proofPending:true}]); + await expect(widget.getByText('Freshly revalidated alias',{exact:true}).first()).toBeVisible(); + await expect(widget.getByLabel('Saved language',{exact:true})).toHaveCount(0); + await page.goto(origin(mode)+'/__fixture__/ready'); + expect((await request.post(origin(mode)+'/__fixture__/identity',{data:{rejected:true}})).ok()).toBe(true); + await page.goBack({waitUntil:'commit'}); + await expect(widget.getByRole('heading',{name:'Household administration',exact:true})).toHaveCount(0); + await expect(page.getByRole('button',{name:mode==='native'?'Reopen Domus':'Sign in with Home Assistant',exact:true})).toBeVisible(); + const second=await probe();expect(second.marker).toBe(marker);expect(second.hides).toHaveLength(2);expect(second.shows).toHaveLength(2); + expect(second.hides[1]).toEqual({persisted:true,trusted:true,retired:true,renderedHidden:true}); + expect(second.shows[1]).toEqual({persisted:true,trusted:true,proofPending:true}); + await expect(widget.getByText('Freshly revalidated alias',{exact:true})).toHaveCount(0); + expect(writes).toHaveLength(1);expect(lookups).toEqual([]);expect(await statistics(request,mode)).toEqual(before); + await info.attach('actual-bfcache-observations',{body:JSON.stringify(second),contentType:'application/json'}); + }finally{await browser.close();} + }); + test('household-workspace-C59: actual console restoration fences an exact original PUT replay without reviving the removed alias',async({page,context,request})=>{ + const widget=await create(page,context,mode),writes:{url:string;body:string;key:string;context:string}[]=[]; + page.on('request',item=>{if(item.method()==='PUT')writes.push({url:item.url(),body:item.postData()!, + key:item.headers()['idempotency-key'],context:item.headers()['x-domus-context']});}); + expect((await request.post(origin(mode)+'/__fixture__/operator',{data:{action:'snapshot'}})).ok()).toBe(true); + await widget.getByRole('button',{name:'Edit own alias',exact:true}).click();await widget.getByRole('checkbox',{name:'Use Home Assistant name',exact:true}).uncheck(); + await widget.getByRole('textbox',{name:'Domus alias',exact:true}).fill('Rolled away alias');await save(widget); + expect(writes).toHaveLength(1);expect(JSON.parse(writes[0].body)).toEqual({expected_revision:1,alias:'Rolled away alias'}); + const saved=await page.evaluate(key=>JSON.parse(sessionStorage.getItem(key)!).records[0],journalKey); + expect(saved).toMatchObject({key:writes[0].key,stage:'committed',operationId:expect.any(String)}); + expect((await request.post(origin(mode)+'/__fixture__/operator',{data:{action:'restore'}})).ok()).toBe(true);const before=await statistics(request,mode); + await page.reload();await signIn(page,mode);await expect(widget.getByRole('heading',{name:'Household administration',exact:true})).toBeVisible(); + await expect(widget.getByText('Rolled away alias',{exact:true})).toHaveCount(0);await widget.getByRole('button',{name:'Check original request',exact:true}).click(); + await expect(widget.getByRole('heading',{name:'Processed before restoration',exact:true})).toBeVisible(); + await expect(widget.getByText('This operation was processed before restoration. Its effect is absent from current state.',{exact:true})).toBeVisible(); + await widget.getByRole('button',{name:'Edit own alias',exact:true}).click();await expect(widget.getByRole('checkbox',{name:'Use Home Assistant name',exact:true})).toBeChecked(); + // Explicit raw API replay, never a UI retry. Use fresh restored authority/CSRF but the exact original key/body/context. + const csrf=await(await page.request.get(base(mode)+'/api/v1/auth/csrf')).json(); + const replay=await page.request.put(writes[0].url,{data:writes[0].body,headers:{'Content-Type':'application/json',Origin:origin(mode), + 'X-CSRF-Token':csrf.csrf_token,'Idempotency-Key':writes[0].key,'X-Domus-Context':writes[0].context}}); + expect(replay.status()).toBe(200);expect(await replay.json()).toMatchObject({state:'historical',operation_id:saved.operationId, + message:'This operation was processed before restoration. Its effect is absent from current state.'}); + expect(await(await page.request.get(writes[0].url)).json()).toMatchObject({alias:null,revision:1}); + expect(writes).toHaveLength(1);expect((await statistics(request,mode)).counts).toEqual(before.counts); + }); +}); diff --git a/frontend/tests/e2e/catalog.spec.ts b/frontend/tests/e2e/catalog.spec.ts index 416e273..16043ca 100644 --- a/frontend/tests/e2e/catalog.spec.ts +++ b/frontend/tests/e2e/catalog.spec.ts @@ -2,6 +2,7 @@ import {expect, test} from '@playwright/test'; import {message} from '../../src/presentation/messages'; import type {MessageKey} from '../../src/presentation/messages'; import type {DomusApp} from '../../src/presentation/domus-app'; +import type {DomusAdministration} from '../../src/presentation/domus-administration'; const origin = 'http://127.0.0.1:56173'; test.beforeAll(async ({browser}) => {console.info('Catalog Chromium version: ' + browser.version());}); @@ -102,3 +103,157 @@ test('the built interface fits 360 pixels with light/dark contrast and reduced-m await page.screenshot({path: `test-results/setup-es-360-${colorScheme}.png`, fullPage: true}); } }); + +const administrationUrl=(example:string,locale:string,deployment:string)=>origin+'/iframe.html?id=domus-household-administration--'+example + +'&viewMode=story&args='+encodeURIComponent(`language:${locale};deployment:${deployment}`); +for(const example of ['active','read-only','sources-unavailable','uncertain','historical','maximum-labels'])test('HADB-03: built administration '+example+' is localized in both deployments without external requests',async({page})=>{ + const errors:string[]=[],foreign:string[]=[];page.on('pageerror',error=>errors.push(error.message)); + await page.route('**/*',async route=>{if(new URL(route.request().url()).origin!==origin){foreign.push(route.request().url());await route.abort();}else await route.continue();}); + for(const [locale,deployment] of [['en','native'],['en','standalone'],['es','native'],['es','standalone']]) { + await page.goto(administrationUrl(example,locale,deployment));const widget=page.locator('domus-administration'); + await expect(widget.getByRole('heading',{name:locale==='es'?'Administración del hogar':'Household administration',exact:true})).toBeVisible(); + await expect(widget).toHaveAttribute('lang',locale);await expect(page.getByText('Fictional administration state.',{exact:false})).toBeVisible(); + const preferencesButton=widget.getByRole('button',{name:locale==='es'?'Editar preferencias':'Edit preferences',exact:true}); + if(example==='read-only'||example==='uncertain')await expect(preferencesButton).toBeDisabled(); + else await expect(preferencesButton).toBeEnabled(); + if(example==='historical')await expect(widget.getByText(locale==='es'?'Esta operación se procesó antes de la restauración. Su efecto no está presente en el estado actual.':'This operation was processed before restoration. Its effect is absent from current state.',{exact:true})).toBeVisible(); + if(example==='sources-unavailable')await expect(widget.getByText(locale==='es'?'Nombre de Home Assistant: Acceso al nombre de HA denegado':'Home Assistant name: HA name access denied',{exact:true})).toBeVisible(); + } + expect(foreign).toEqual([]);expect(errors).toEqual([]); +}); +test('HADB-03: built administration supports keyboard review, focus, locale retention and one exact immutable intent',async({page})=>{ + await page.goto(administrationUrl('active','en','native'));const widget=page.locator('domus-administration'); + await widget.evaluate(element=>{const current=window as unknown as {administrationIntents:unknown[]};current.administrationIntents=[]; + element.addEventListener('domus-administration-intent',event=>current.administrationIntents.push((event as CustomEvent).detail));}); + await widget.getByRole('button',{name:'Edit own alias',exact:true}).click();await expect(widget.getByLabel('Use Home Assistant name',{exact:true})).toBeFocused(); + await page.keyboard.press('Space');await page.keyboard.press('Tab');await expect(widget.getByRole('textbox',{name:'Domus alias',exact:true})).toBeFocused(); + const alias=' & Maple';await page.keyboard.insertText(alias);await page.keyboard.press('Tab');await page.keyboard.press('Enter'); + await expect(widget.getByRole('heading',{name:'Confirm this household change',exact:true})).toBeFocused();await expect(widget.locator('img,script')).toHaveCount(0); + await widget.evaluate(element=>{(element as HTMLElement & {language:string}).language='es';}); + await expect(widget.getByRole('heading',{name:'Confirma este cambio del hogar',exact:true})).toBeVisible(); + await page.keyboard.press('Tab');await expect(widget.getByRole('button',{name:'Guardar cambios',exact:true})).toBeFocused();await page.keyboard.press('Enter'); + const intents=await page.evaluate(()=>(window as unknown as {administrationIntents:unknown[]}).administrationIntents); + expect(intents).toEqual([{type:'submit',command:{action:'member.presentation.update',memberId:'fictional-owner',payload:{expected_revision:'11',alias}}}]); + await expect(widget.getByRole('button',{name:'Guardar cambios',exact:true})).toBeDisabled(); +}); +test('HADB-03: built administration forms fit 360 pixels in both themes with reduced motion and readable action contrast',async({page})=>{ + await page.setViewportSize({width:360,height:800}); + for(const colorScheme of ['light','dark'] as const) { + await page.emulateMedia({colorScheme,reducedMotion:'reduce'});await page.goto(administrationUrl('active','es','standalone'));const widget=page.locator('domus-administration'); + for(const name of ['Editar hogar','Editar preferencias','Editar reglas de acceso','Gestionar participante: Fictional pending participant']) + await widget.getByRole('button',{name,exact:true}).click(); + await expect(widget.getByLabel('Nuevo nombre del hogar',{exact:true})).toHaveValue('Maple House'); + expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(360); + const contrast=await widget.locator('button.primary').first().evaluate(button=>{ + const style=getComputedStyle(button);const luminance=(rgb:string)=>{ + const v=rgb.match(/[\d.]+/gu)!.slice(0,3).map(Number).map(value=>{const x=value/255;return x<=.04045?x/12.92:((x+.055)/1.055)**2.4;}); + return v[0]*.2126+v[1]*.7152+v[2]*.0722;}; + const a=luminance(style.color),b=luminance(style.backgroundColor);return (Math.max(a,b)+.05)/(Math.min(a,b)+.05); + });expect(contrast).toBeGreaterThanOrEqual(4.5); + await page.screenshot({path:`test-results/administration-es-360-${colorScheme}.png`,fullPage:true}); + } +}); +test('household-admin-C58: built keyboard validation associates errors and restores field focus after review and back',async({page})=>{ + for(const locale of ['en','es']) { + const names=locale==='en'?{edit:'Edit own alias',clear:'Use Home Assistant name',alias:'Domus alias',review:'Review changes',back:'Back to editing',heading:'Confirm this household change'} + :{edit:'Editar mi alias',clear:'Usar el nombre de Home Assistant',alias:'Alias de Domus',review:'Revisar cambios',back:'Volver a editar',heading:'Confirma este cambio del hogar'}; + await page.goto(administrationUrl('active',locale,'native'));const widget=page.locator('domus-administration'); + await widget.getByRole('button',{name:names.edit,exact:true}).click();await page.keyboard.press('Space');await page.keyboard.press('Tab');await page.keyboard.press('Tab'); + await expect(widget.getByRole('button',{name:names.review,exact:true})).toBeFocused();await page.keyboard.press('Enter'); + await expect(widget.getByRole('checkbox',{name:names.clear,exact:true})).toBeFocused();const error=widget.getByRole('alert');await expect(error).toBeVisible(); + const id=await error.getAttribute('id');expect(id).not.toBeNull();await expect(widget.getByRole('textbox',{name:names.alias,exact:true})).toHaveAttribute('aria-describedby',id!); + await page.keyboard.press('Tab');await page.keyboard.insertText('Keyboard draft');await page.keyboard.press('Tab');await page.keyboard.press('Enter'); + await expect(widget.getByRole('heading',{name:names.heading,exact:true})).toBeFocused();await page.keyboard.press('Tab');await page.keyboard.press('Tab'); + await expect(widget.getByRole('button',{name:names.back,exact:true})).toBeFocused();await page.keyboard.press('Enter'); + await expect(widget.getByRole('checkbox',{name:names.clear,exact:true})).toBeFocused();await expect(widget.getByRole('textbox',{name:names.alias,exact:true})).toHaveValue('Keyboard draft'); + await expect(widget.getByRole('textbox',{name:names.alias,exact:true})).not.toHaveAttribute('aria-describedby'); + } +}); + + +test('household-browser-C02: built drafts and detached saves retire when the verified household changes',async({page})=>{ + for(const locale of ['en','es']) { + const labels=locale==='en'?{edit:'Edit household',name:'New household name',review:'Review changes',save:'Save changes'} + :{edit:'Editar hogar',name:'Nuevo nombre del hogar',review:'Revisar cambios',save:'Guardar cambios'}; + await page.goto(administrationUrl('active',locale,'native'));const widget=page.locator('domus-administration'); + await widget.getByRole('button',{name:labels.edit,exact:true}).click();await widget.getByRole('textbox',{name:labels.name,exact:true}).fill('Former household intent'); + await widget.getByRole('button',{name:labels.review,exact:true}).click(); + const intents=await widget.evaluate(async(element,saveLabel)=>{ + const component=element as DomusAdministration,current=component.view!,intents:unknown[]=[]; + component.addEventListener('domus-administration-intent',event=>intents.push((event as CustomEvent).detail)); + const detached=[...component.shadowRoot!.querySelectorAll('button')].find(button=>button.textContent?.trim()===saveLabel)!; + component.view={...current,capabilities:{...current.capabilities!,householdId:'replacement-home'}, + household:{...current.household!,householdId:'replacement-home',name:'Replacement household'}, + policy:{...current.policy!,householdId:'replacement-home'}}; + detached.dispatchEvent(new MouseEvent('click'));await component.updateComplete;detached.dispatchEvent(new MouseEvent('click'));await component.updateComplete; + return intents; + },labels.save); + expect(intents).toEqual([]);await expect(widget.locator('form')).toHaveCount(0); + await expect(widget.getByText('Former household intent',{exact:true})).toHaveCount(0); + await widget.getByRole('button',{name:labels.edit,exact:true}).click();await expect(widget.getByRole('textbox',{name:labels.name,exact:true})).toHaveValue('Replacement household'); + } +}); + +for(const locale of ['en','es'] as const)for(const deployment of ['native','standalone'] as const)for(const colorScheme of ['light','dark'] as const) +test(`household-admin-C57: maximum Unicode roster remains readable and keeps its selected member at 360px in ${locale}/${deployment}/${colorScheme}`,async({page})=>{ + const errors:string[]=[],foreign:string[]=[],mutations:string[]=[]; + page.on('pageerror',error=>errors.push(error.message));page.on('request',request=>{if(/^(POST|PUT|PATCH|DELETE)$/u.test(request.method()))mutations.push(request.url());}); + await page.route('**/*',async route=>{if(new URL(route.request().url()).origin!==origin){foreign.push(route.request().url());await route.abort();}else await route.continue();}); + await page.setViewportSize({width:360,height:900});await page.emulateMedia({colorScheme,reducedMotion:'reduce'}); + await page.goto(administrationUrl('maximum-labels',locale,deployment));const widget=page.locator('domus-administration'); + await expect(widget.locator('ul.people>li')).toHaveCount(256); + const fixture=await widget.evaluate(element=>{ + const component=element as DomusAdministration,members=component.view!.roster!.members,encoder=new TextEncoder(); + const state=window as unknown as {unicodeIntents:unknown[]};state.unicodeIntents=[]; + component.addEventListener('domus-administration-intent',event=>state.unicodeIntents.push((event as CustomEvent).detail)); + const aliases=members.filter(row=>row.presentation.alias!==null); + return {names:members.map(row=>Array.from(row.metadata.name!).length),nameBytes:members.map(row=>encoder.encode(row.metadata.name!).byteLength), + aliases:aliases.map(row=>Array.from(row.presentation.alias!).length),aliasBytes:aliases.map(row=>encoder.encode(row.presentation.alias!).byteLength)}; + }); + expect(fixture.names).toEqual(Array.from({length:256},()=>256));expect(fixture.nameBytes).toEqual(Array.from({length:256},()=>1024)); + expect(fixture.aliases).toEqual(Array.from({length:128},()=>80));expect(fixture.aliasBytes).toEqual(Array.from({length:128},()=>320)); + const rows=widget.locator('ul.people>li');await expect(rows).toHaveCount(256); + const geometry=await rows.evaluateAll(items=>{ + const offenders:string[]=[];let textLines=0,buttons=0; + items.forEach((item,index)=>{ + const bounds=item.getBoundingClientRect();if(bounds.left<0||bounds.right>360)offenders.push('row:'+index); + const walker=document.createTreeWalker(item,NodeFilter.SHOW_TEXT);let text:Node|null; + while((text=walker.nextNode())!==null)if(text.textContent?.trim()){ + const range=document.createRange();range.selectNodeContents(text); + for(const line of range.getClientRects()){textLines++;if(line.leftbounds.right+1)offenders.push('text:'+index);} + } + for(const control of item.querySelectorAll('strong,p,button')){ + const style=getComputedStyle(control); + if(style.display==='none'||style.visibility!=='visible'||style.overflowX==='hidden'||style.overflowX==='clip'||style.textOverflow==='ellipsis')offenders.push('hidden:'+index); + if(control instanceof HTMLButtonElement){buttons++;if(control.getBoundingClientRect().height<44||control.disabled)offenders.push('action:'+index);} + } + }); + return {offenderCount:offenders.length,examples:offenders.slice(0,8),textLines,buttons}; + }); + expect(geometry).toMatchObject({offenderCount:0,examples:[],buttons:511});expect(geometry.textLines).toBeGreaterThan(256); + expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(360); + const names=locale==='en'?{manage:'Manage participant',alias:'Change alias',cancel:'Cancel',field:'Domus alias',review:'Review changes',save:'Save changes',status:'Stored profile: Restricted · Active'} + :{manage:'Gestionar participante',alias:'Cambiar alias',cancel:'Cancelar',field:'Alias de Domus',review:'Revisar cambios',save:'Guardar cambios',status:'Perfil guardado: Restringido · Activo'}; + const selectedAlias='🏡'.repeat(78)+'😏😏',selectedName='😀'.repeat(254)+'😏😏',memberId='fictional-roster-255'; + const manage=widget.getByRole('button',{name:names.manage+': '+selectedAlias,exact:true}); + const selected=rows.filter({has:page.getByRole('button',{name:names.manage+': '+selectedAlias,exact:true})}); + await expect(selected.getByText(selectedAlias,{exact:true})).toBeVisible();await expect(selected.getByText(names.status,{exact:true})).toBeVisible(); + await expect(selected.locator('p.source')).toContainText(selectedName);await expect(manage).toBeEnabled(); + if(locale==='es'&&deployment==='standalone')await selected.screenshot({path:`test-results/unicode-roster-${colorScheme}.png`}); + await manage.click();const editor=widget.getByRole('region',{name:names.manage,exact:true}); + await expect(editor.getByText(selectedAlias,{exact:true})).toBeVisible();await expect(editor.getByText(memberId,{exact:true})).toBeVisible(); + await editor.getByRole('button',{name:names.cancel,exact:true}).click();await expect(manage).toBeFocused(); + await widget.getByRole('button',{name:names.alias+': '+selectedAlias,exact:true}).click(); + const replacement='🚀'+Array.from(selectedAlias).slice(1).join(''),field=widget.getByRole('textbox',{name:names.field,exact:true}); + await expect(field).toHaveValue(selectedAlias);await field.fill(replacement);await expect(field).toHaveValue(replacement); + await widget.evaluate(async element=>{const component=element as DomusAdministration,view=component.view!;component.view={...view,roster:{...view.roster!,members:[...view.roster!.members].reverse()}};await component.updateComplete;}); + await expect(rows.first().getByText(selectedAlias,{exact:true})).toBeVisible();await expect(field).toHaveValue(replacement); + await widget.getByRole('button',{name:names.review,exact:true}).click(); + await expect(widget.locator('section.card').filter({has:page.getByRole('button',{name:names.save,exact:true})}).getByText(memberId,{exact:true})).toBeVisible(); + expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(360); + if(locale==='es'&&deployment==='standalone')await widget.locator('section.card').filter({has:page.getByRole('button',{name:names.save,exact:true})}).screenshot({path:`test-results/unicode-selected-${colorScheme}.png`}); + await widget.getByRole('button',{name:names.save,exact:true}).click(); + expect(await page.evaluate(()=>(window as unknown as {unicodeIntents:unknown[]}).unicodeIntents)).toEqual([ + {type:'submit',command:{action:'member.presentation.update',memberId,payload:{expected_revision:'11',alias:replacement}}}]); + expect(errors).toEqual([]);expect(foreign).toEqual([]);expect(mutations).toEqual([]); +}); diff --git a/frontend/tests/e2e/entry.spec.ts b/frontend/tests/e2e/entry.spec.ts index 3f7be24..1738551 100644 --- a/frontend/tests/e2e/entry.spec.ts +++ b/frontend/tests/e2e/entry.spec.ts @@ -37,6 +37,13 @@ async function forwardSetup(route: Route, observed: Map) { observed.set(route.request().headers()['idempotency-key'], response); await route.fulfill({response, body}); } +async function forwardRead(route: Route, observed: Map) { + expect(route.request().method()).toBe('GET'); + // Observe protected upstream bytes before forwarding; CDP may retire a completed navigation's body. + const response = await route.fetch({maxRetries: 0, maxRedirects: 0}), body = await response.body(); + observed.set(route.request().url(), {status: response.status(), body: body.toString('utf8')}); + await route.fulfill({response, body}); +} async function replaySetup(page: Page, response: Response) { const original = response.request(), headers = await original.allHeaders(); // An explicit raw-API replay keeps every command assertion; no UI retry or new key is inferred. @@ -83,6 +90,51 @@ for (const mode of modes) test.describe(mode, () => { expect(await stats(request, mode)).toEqual(before); }); + test('household-browser-C01: the compiled runtime obtains actual administration facts after explicit setup', async ({page, context, request}) => { + await open(page, context, mode); + const observed = new Map(); + for (const route of ['roster', 'preferences', 'directory']) + await page.route(base(mode) + '/api/v1/administration/' + route, route => forwardRead(route, observed)); + const rosterReply = page.waitForResponse(response => response.url() === base(mode) + '/api/v1/administration/roster'); + const preferencesReply = page.waitForResponse(response => response.url() === base(mode) + '/api/v1/administration/preferences'); + const directoryReply = page.waitForResponse(response => response.url() === base(mode) + '/api/v1/administration/directory'); + await create(page); + const [roster, preferences, directory] = await Promise.all([rosterReply, preferencesReply, directoryReply]); + expect([roster.status(), preferences.status(), directory.status()]).toEqual([200, 200, 200]); + const actual = (route: string) => JSON.parse(observed.get(base(mode) + '/api/v1/administration/' + route)!.body); + expect(actual('roster').members[0].metadata).toMatchObject({state: 'available', name: 'Fictional account a', avatar_state: 'unavailable'}); + expect(actual('preferences')).toMatchObject({locale: null, routine_participation_summaries: true, notification_delivery: 'unavailable'}); + expect(actual('directory').profiles.map((row: {ha_user_id: string}) => row.ha_user_id)).toEqual(['a'.repeat(32), 'b'.repeat(32), 'c'.repeat(32)]); + const current = await stats(request, mode); + expect(current.counts.households).toBe(1); expect(current.counts.receipts).toBe(1); + await expect(page.getByRole('heading', {name: 'Your household', level: 1, exact: true})).toBeVisible(); + }); + + test('HADW-01: the compiled runtime reads the own alias without roster permission in the actual deployment', async ({page, context}) => { + await open(page, context, mode); await create(page); + await expect(page.getByRole('heading', {name: 'Your household', level: 1, exact: true})).toBeVisible(); + await expect(page.getByRole('button', {name: 'Refresh status', exact: true})).toBeEnabled(); + const access = await (await page.request.get(base(mode) + '/api/v1/authorization')).json(); + const csrf = await (await page.request.get(base(mode) + '/api/v1/auth/csrf')).json(); + const changed = await page.request.put(base(mode) + '/api/v1/members/' + access.member.member_id, {data: { + expected_revision: access.member.revision, role: access.member.role, status: 'active', + exceptions: [{permission: 'members.read', allow: false}], + }, headers: {'Origin': origin(mode), 'X-CSRF-Token': csrf.csrf_token, 'Idempotency-Key': 'narrow-public-read'}}); + expect(changed.status()).toBe(200); + expect((await page.request.get(base(mode) + '/api/v1/administration/roster')).status()).toBe(403); + const ownPath = base(mode) + '/api/v1/administration/members/' + access.member.member_id + '/presentation'; + const observed = new Map(); + await page.route(ownPath, route => forwardRead(route, observed)); + const ownRead = page.waitForResponse(response => response.url() === ownPath); + const rosterRequests: string[] = []; + page.on('request', request => {if (request.url().endsWith('/administration/roster')) rosterRequests.push(request.url());}); + await page.reload(); const current = await ownRead; + expect(current.status()).toBe(200); expect(JSON.parse(observed.get(ownPath)!.body)).toEqual({member_id: access.member.member_id, alias: null, revision: 1}); + await expect(page.getByRole('button', {name: 'Refresh status', exact: true})).toBeEnabled(); + expect(rosterRequests).toEqual([]); + expect((await page.request.get(base(mode) + '/api/v1/administration/members/not-owned/presentation')).status()).toBe(403); + }); + test('an unavailable module leaves visible reload guidance and never claims an empty home', async ({page, context, request}) => { if (mode === 'native') await context.addCookies([{name: 'ingress_session', value: 'f'.repeat(128), domain: '127.0.0.1', path: '/', httpOnly: true, sameSite: 'Strict'}]); diff --git a/frontend/tests/household-controller.test.ts b/frontend/tests/household-controller.test.ts new file mode 100644 index 0000000..ab50728 --- /dev/null +++ b/frontend/tests/household-controller.test.ts @@ -0,0 +1,459 @@ +import {expect, test, vi} from 'vitest'; +import {HouseholdController} from '../src/application/household-controller'; +import {EntryController} from '../src/application/entry-controller'; +import {AdministrationController} from '../src/application/administration-controller'; +import {EntryReadError} from '../src/application/ports'; +import type {EntryRead, VerifiedEntrySession, EntryServer} from '../src/application/ports'; +import type {AdministrationAccess, AdministrationReceipt} from '../src/domain/administration-views'; +import type {AdministrationRequest, AdministrationOriginal, AdministrationCommandResult} from '../src/application/administration-ports'; +import type {Authorization} from '../src/domain/entry'; +import {revisionValue} from '../src/domain/administration'; +import {administrationCommand} from '../src/domain/administration-commands'; +import {SessionJournal, ENTRY_JOURNAL_KEY} from '../src/adapters/session-journal'; +import {AdministrationSessionJournal, ADMINISTRATION_JOURNAL_KEY} from '../src/adapters/administration-journal'; + +function read(value: T): EntryRead {return {result: Promise.resolve(value), cancel: vi.fn()};} +function deferred() { + let resolve!: (value: T) => void, reject!: (error: unknown) => void; + const result = new Promise((yes, no) => {resolve = yes; reject = no;}); + return {result, resolve, reject, cancel: vi.fn()}; +} +async function settle() {for (let n = 0; n < 120; n++) await Promise.resolve();} +const context = {installationId: '01234567-89ab-cdef-0123-456789abcdef', channel: 'edge' as const, subject: 'a'.repeat(32)}; +const session: VerifiedEntrySession = {context, deployment: 'standalone', access: 'full', serverTime: 1000, + expiresAt: 10_000, householdApiAvailable: true}; +const home = {householdId: 'home', name: 'Home', timeZone: 'UTC', revision: revisionValue('7')}; +const member = {memberId: 'member-own', haUserId: context.subject, role: 'adult' as const, status: 'active' as const, + exceptions: [], revision: revisionValue('3')}; +const access: AdministrationAccess = {state: 'active', setupAllowed: false, enrollmentAllowed: false, + member, effectiveRole: 'administrator', policyRevision: revisionValue('5'), + permissions: ['household.read', 'household.manage', 'members.read', 'members.manage', 'policy.manage', + 'preferences.read', 'preferences.write', 'operations.read']}; +const own = {memberId: member.memberId, locale: 'es' as const, routine_participation_summaries: true, + revision: revisionValue('13'), notificationDelivery: 'unavailable' as const}; +const command = administrationCommand({action: 'member.preferences.update', payload: {expected_revision: '13', + locale: 'en', routine_participation_summaries: false}}); +const receipt: AdministrationReceipt = {kind: 'receipt', stage: 'committed', action: command.action, + operationId: 'op-original', createdAt: 1000, archivedBy: null, eventDelivery: null, + notificationDelivery: 'unavailable', result: {...own, locale: 'en', revision: revisionValue('14')}}; +const basic = (value: AdministrationAccess): Authorization => ({state: value.state, setupAllowed: value.setupAllowed, + enrollmentAllowed: value.enrollmentAllowed, permissions: value.permissions}); +function fixture(mode: 'native' | 'standalone' = 'standalone') { + let now = 1000; + const proof = {...session, deployment: mode}; + const values = new Map(); + const storage = {getItem: vi.fn((key: string) => values.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => {values.set(key, value);}), + removeItem: vi.fn((key: string) => {values.delete(key);})}; + const clock = {anchor: vi.fn(), now: vi.fn(() => now)}; + let nextKey = 0; + const entropy = {requestKey: vi.fn(() => 'request-' + (++nextKey))}; + const identity = {session: vi.fn(() => read(proof))}; + const entryServer = {session: vi.fn(() => read(proof)), + authorization: vi.fn(() => read(basic(access))), household: vi.fn(() => read(home)), + operation: vi.fn(() => {throw new Error('No entry operation requested.');}), + send: vi.fn((_value: Parameters[0]): ReturnType => Promise.resolve({kind: 'uncertain'})), + logout: vi.fn(async () => ({upstreamRevoked: true}))}; + const server = { + capabilities: vi.fn(() => read({contractVersion: 2 as const, householdId: 'home', actions: [command.action]})), + authorization: vi.fn(() => read(access)), household: vi.fn(() => read(home)), + policy: vi.fn(() => read({householdId: 'home', revision: revisionValue('5'), mappingMode: 'all_ha_administrators' as const, + targetHAUserId: null, adjustments: []})), + roster: vi.fn(() => read({directoryState: 'available' as const, members: [{member, + presentation: {memberId: member.memberId, alias: null, revision: revisionValue('11')}, + metadata: {state: 'available' as const, name: 'Current member', avatarState: 'unavailable' as const}}]})), + directory: vi.fn(() => read({state: 'denied' as const, profiles: []})), preferences: vi.fn(() => read(own)), + presentation: vi.fn(() => read({memberId: member.memberId, alias: null, revision: revisionValue('11')})), + send: vi.fn((_request: AdministrationRequest): Promise => Promise.resolve(receipt)), + original: vi.fn((_request: AdministrationOriginal) => read(receipt)), + operation: vi.fn((_request: AdministrationOriginal) => read(receipt)), + }; + const entries: EntryController[] = [], administrators: AdministrationController[] = []; + const entry = new EntryController(mode, entryServer, new SessionJournal(storage), clock, entropy); entries.push(entry); + const factory = vi.fn(() => { + const next = new AdministrationController(mode, identity, server, new AdministrationSessionJournal(storage), clock, entropy); + administrators.push(next); return next; + }); + const controller = new HouseholdController(entry, factory); + return {controller, entry, entryServer, identity, server, storage, values, clock, entropy, factory, administrators, + mode, proof, setNow: (value: number) => {now = value;}, + reload: () => { + const nextEntry = new EntryController(mode, entryServer, new SessionJournal(storage), clock, entropy); entries.push(nextEntry); + return new HouseholdController(nextEntry, factory); + }}; +} + +test.each(['native', 'standalone'] as const)('HADB-01/C01: %s composes a matched current view without writes and reuses same-context evidence', async mode => { + const f = fixture(mode); expect(f.controller.view.phase).toBe('idle'); expect(f.controller.administration).toBeNull(); + const opening = f.controller.refresh(); expect(f.controller.view.busy).toBe(true); expect(f.controller.administration).toBeNull(); + await opening; + expect(f.controller.view.decision?.state).toBe('active'); expect(f.controller.administration?.preferences).toEqual(own); + expect(f.factory).toHaveBeenCalledOnce(); expect(Object.isFrozen(f.controller.view)).toBe(true); + expect(f.server.send).not.toHaveBeenCalled(); expect(f.entryServer.send).not.toHaveBeenCalled(); + await f.controller.refresh(); expect(f.factory).toHaveBeenCalledOnce(); f.controller.close(); +}); +test.each(['unconfigured', 'not_enrolled', 'pending', 'suspended', 'archived'] as const)( + 'HADB-01: %s entry never opens private administration', async state => { + const f = fixture(); f.entryServer.authorization.mockReturnValue(read({...basic(access), state, permissions: []})); + await f.controller.refresh(); expect(f.factory).not.toHaveBeenCalled(); expect(f.controller.administration).toBeNull(); + expect(f.controller.view.decision?.state).toBe(state); f.controller.close(); + }, +); +test('HADB-01: missing session, unavailable API or failed entry does not probe private administration', async () => { + const f = fixture(); f.entryServer.session.mockReturnValueOnce(read(null)); + await f.controller.refresh(); expect(f.factory).not.toHaveBeenCalled(); + f.entryServer.session.mockReturnValueOnce(read({...f.proof, householdApiAvailable: false})); + await f.controller.refresh(); expect(f.factory).not.toHaveBeenCalled(); + f.entryServer.session.mockImplementationOnce(() => ({cancel: vi.fn(), result: Promise.reject(new EntryReadError('forbidden'))})); + await f.controller.refresh(); expect(f.factory).not.toHaveBeenCalled(); expect(f.controller.administration).toBeNull(); +}); +test.each(['installationId', 'channel', 'subject'] as const)('HADB-01/C02: persistent %s drift is hidden after one reconciliation', async field => { + const f = fixture(), changed = {...context, [field]: field === 'channel' ? 'stable' : field === 'subject' ? 'b'.repeat(32) + : '11234567-89ab-cdef-0123-456789abcdef'}; + f.identity.session.mockReturnValue(read({...f.proof, context: changed})); + await f.controller.refresh(); + expect(f.controller.administration).toBeNull(); expect(f.controller.view.notice).toBe('context_changed'); + expect(f.entryServer.session).toHaveBeenCalledTimes(2); expect(f.identity.session).toHaveBeenCalledOnce(); + expect(f.administrators[0].view.phase).toBe('closed'); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('HADB-01/C02: a new context which reconciles once can publish only that context', async () => { + const f = fixture(), changed = {...context, installationId: '11234567-89ab-cdef-0123-456789abcdef'}; + f.entryServer.session.mockReturnValueOnce(read(f.proof)).mockReturnValue(read({...f.proof, context: changed})); + f.identity.session.mockReturnValue(read({...f.proof, context: changed})); + await f.controller.refresh(); expect(f.controller.view.session?.context).toEqual(changed); + expect(f.controller.administration?.session?.context).toEqual(changed); + expect(f.entryServer.session).toHaveBeenCalledTimes(2); + await f.controller.refresh(); expect(f.factory).toHaveBeenCalledOnce(); +}); +test('HADB-01/C03: drifted current grants are reconciled without exposing previously collected private preferences', async () => { + const f = fixture(), current = {...access, permissions: ['household.read']}; + f.server.authorization.mockReturnValueOnce(read(access)).mockReturnValue(read(current)); + f.entryServer.authorization.mockReturnValueOnce(read(basic(access))).mockReturnValue(read(basic(current))); + await f.controller.refresh(); + expect(f.controller.administration?.notice).toBe('access_changed'); + expect(f.controller.administration?.preferences).toBeNull(); expect(f.controller.view.authorization?.permissions).toEqual(current.permissions); + expect(f.entryServer.session).toHaveBeenCalledTimes(2); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('HADB-01/C08: grant mismatch that persists through reconciliation remains hidden', async () => { + const f = fixture(); f.server.authorization.mockReturnValue(read({...access, permissions: ['household.read']})); + await f.controller.refresh(); expect(f.controller.administration).toBeNull(); + expect(f.controller.view.notice).toBe('context_changed'); expect(f.entryServer.session).toHaveBeenCalledTimes(2); +}); +test('HADB-01: equal grant sets in a different order are not treated as drift', async () => { + const f = fixture(); f.entryServer.authorization.mockReturnValue(read({...basic(access), permissions: [...access.permissions].reverse()})); + await f.controller.refresh(); expect(f.controller.administration?.preferences).toEqual(own); + expect(f.entryServer.session).toHaveBeenCalledOnce(); +}); +test('HADB-01: inconsistent household facts cannot be combined even with matching account grants', async () => { + const f = fixture(); f.entryServer.household.mockReturnValue(read({...home, householdId: 'other-home'})); + await f.controller.refresh(); expect(f.controller.administration).toBeNull(); + expect(f.controller.view.notice).toBe('context_changed'); expect(f.entryServer.session).toHaveBeenCalledTimes(2); +}); +test('HADB-01: compatible failure view contains no private facts and preserves the current entry session', async () => { + const f = fixture(); f.server.capabilities.mockImplementation(() => ({cancel: vi.fn(), result: Promise.reject(new EntryReadError('unavailable'))})); + await f.controller.refresh(); expect(f.controller.administration?.notice).toBe('unavailable'); + expect(f.controller.administration?.session).toBeNull(); expect(f.controller.administration?.preferences).toBeNull(); + expect(f.controller.view.session).toEqual(f.proof); +}); +test('HADB-01: administration authentication failure retires both views without renewing entry proof', async () => { + const f = fixture(); f.identity.session.mockReturnValue(read(null)); + await f.controller.refresh(); expect(f.controller.administration).toBeNull(); + expect(f.controller.view.decision?.state).toBe('authentication_required'); expect(f.controller.view.session).toBeNull(); + expect(f.entryServer.session).toHaveBeenCalledOnce(); +}); +test('HADB-01/C04: the earlier entry lease expires during administration collection without automatic reverification', async () => { + const f = fixture(), waiting = deferred(); f.server.preferences.mockReturnValueOnce(waiting); + f.identity.session.mockReturnValue(read({...f.proof, expiresAt: 20_000})); + const opening = f.controller.refresh(); await settle(); expect(f.controller.administration).toBeNull(); + f.setNow(10_000); waiting.resolve(own); await opening; + expect(f.controller.view.session).toBeNull(); expect(f.controller.administration).toBeNull(); + expect(f.controller.view.decision?.state).toBe('authentication_required'); expect(f.entryServer.session).toHaveBeenCalledOnce(); +}); +test('HADB-01/C04: administration lease expiry retires the entry view as well', async () => { + const f = fixture(); f.identity.session.mockReturnValue(read({...f.proof, expiresAt: 2000})); + await f.controller.refresh(); f.setNow(2000); f.controller.tick(); + expect(f.controller.view.session).toBeNull(); expect(f.controller.view.decision?.state).toBe('authentication_required'); + expect(f.controller.administration).toBeNull(); expect(f.entryServer.session).toHaveBeenCalledOnce(); +}); +test('HADB-02/C09: an optional entry household failure leaves original GET recovery independently available', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); + await f.controller.submitAdministration(command); expect(f.controller.administration?.outcome?.kind).toBe('uncertain'); + const original = f.controller.administration!.journal!.records[0]; + f.entryServer.household.mockImplementation(() => ({cancel: vi.fn(), result: Promise.reject(new EntryReadError('unavailable'))})); + f.server.household.mockImplementation(() => ({cancel: vi.fn(), result: Promise.reject(new EntryReadError('unavailable'))})); + await f.controller.refresh(); await f.controller.recoverAdministration(original.key); + expect(f.server.original).toHaveBeenCalledOnce(); expect(f.server.send).toHaveBeenCalledOnce(); + expect(f.controller.administration?.outcome?.kind).toBe('receipt'); expect(f.controller.view.household).toBeNull(); +}); +test('HADB-02/C10/C11: reload and explicit original GET need no aggregate-read grant and never resend', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); + await f.controller.submitAdministration(command); const record = f.controller.administration!.journal!.records[0]; + f.controller.close(); const current = {...access, permissions: ['preferences.read', 'preferences.write', 'operations.read']}; + f.server.authorization.mockReturnValue(read(current)); f.entryServer.authorization.mockReturnValue(read(basic(current))); + const restored = f.reload(); await restored.refresh(); expect(f.server.original).not.toHaveBeenCalled(); + await restored.recoverAdministration(record.key); + expect(f.server.original).toHaveBeenCalledExactlyOnceWith({key: record.key, command, operationId: null}, context); + expect(f.server.send).toHaveBeenCalledOnce(); expect(restored.administration?.outcome?.kind).toBe('receipt'); + await restored.dismissAdministration(record.key); expect(restored.administration?.journal?.records).toEqual([]); + restored.close(); +}); +test('HADB-02: same-context refresh retains the actual receipt rather than recreating the administration controller', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submitAdministration(command); + expect(f.controller.administration?.outcome).toEqual(receipt); await f.controller.refresh(); + expect(f.controller.administration?.outcome).toEqual(receipt); expect(f.factory).toHaveBeenCalledOnce(); +}); +test('HADB-01/C05: logout and competing intents during administration preflight/send preserve the original command', async () => { + const f = fixture(); await f.controller.refresh(); const proof = deferred(); + const waiting = deferred(); f.identity.session.mockReturnValueOnce(proof); f.server.send.mockReturnValueOnce(waiting.result); + const work = f.controller.submitAdministration(command); expect(f.controller.view.busy).toBe(true); + await f.controller.logout(); await f.controller.submitSetup('Home', 'UTC'); await f.controller.requestMembership(); + await f.controller.refresh(); await f.controller.submitAdministration(command); + expect(f.entryServer.logout).not.toHaveBeenCalled(); expect(f.entryServer.send).not.toHaveBeenCalled(); + proof.resolve(f.proof); await settle(); expect(f.server.send).toHaveBeenCalledOnce(); + const raw = f.values.get(ADMINISTRATION_JOURNAL_KEY); await f.controller.logout(); + expect(f.values.get(ADMINISTRATION_JOURNAL_KEY)).toBe(raw); expect(f.controller.view.busy).toBe(true); + waiting.resolve(receipt); await work; expect(f.controller.view.busy).toBe(false); + expect(f.controller.administration?.outcome).toEqual(receipt); await f.controller.logout(); + expect(f.controller.view.phase).toBe('closed'); expect(f.controller.administration).toBeNull(); + expect(f.entryServer.logout).toHaveBeenCalledOnce(); expect(f.values.has(ADMINISTRATION_JOURNAL_KEY)).toBe(true); +}); +test('HADB-01/C05: an entry command also blocks administration and stale logout until its original completes', async () => { + const f = fixture(), eligible: Authorization = {...basic(access), state: 'not_enrolled', permissions: [], enrollmentAllowed: true}; + f.entryServer.authorization.mockReturnValue(read(eligible)); await f.controller.refresh(); + const waiting = deferred>>(); f.entryServer.send.mockReturnValueOnce(waiting.result); + const work = f.controller.requestMembership(); await settle(); + await f.controller.logout(); await f.controller.submitAdministration(command); await f.controller.recoverAdministration('no-key'); + expect(f.entryServer.logout).not.toHaveBeenCalled(); expect(f.server.send).not.toHaveBeenCalled(); + expect(f.values.has(ENTRY_JOURNAL_KEY)).toBe(true); waiting.resolve({kind: 'uncertain'}); await work; + expect(f.controller.view.outcome).toEqual({kind: 'uncertain'}); expect(f.entryServer.send).toHaveBeenCalledOnce(); +}); +test('HADB-01/C06: closing a command and opening a new lifetime retains its original without accepting the late reply', async () => { + const f = fixture(); await f.controller.refresh(); const waiting = deferred(); + f.server.send.mockReturnValueOnce(waiting.result); const work = f.controller.submitAdministration(command); await settle(); + expect(f.server.send).toHaveBeenCalledOnce(); f.controller.close(); + const restored = f.reload(); await restored.refresh(); const before = restored.administration; + waiting.resolve(receipt); await work; + expect(f.controller.administration).toBeNull(); expect(f.controller.view.phase).toBe('closed'); + expect(restored.administration).toEqual(before); expect(restored.administration?.journal?.records[0].stage).toBe('prepared'); + expect(f.server.original).not.toHaveBeenCalled(); restored.close(); +}); +test('HADB-01/C07: close cancels pending entry reads and fences late completion', async () => { + const f = fixture(), waiting = deferred(); f.entryServer.session.mockReturnValueOnce(waiting); + const work = f.controller.refresh(); f.controller.close(); f.controller.close(); waiting.resolve(f.proof); await work; + expect(waiting.cancel).toHaveBeenCalledOnce(); expect(f.factory).not.toHaveBeenCalled(); + expect(f.controller.view.phase).toBe('closed'); expect(f.controller.administration).toBeNull(); + await f.controller.refresh(); await f.controller.submitSetup('Home', 'UTC'); await f.controller.requestMembership(); + await f.controller.recover('key'); await f.controller.dismiss('key'); await f.controller.logout(); + await f.controller.submitAdministration(command); await f.controller.recoverAdministration('key'); await f.controller.dismissAdministration('key'); + f.controller.tick(); expect(f.entryServer.session).toHaveBeenCalledOnce(); +}); +test('HADB-01/C07: closing administration collection cancels its private read and blocks late publication', async () => { + const f = fixture(), waiting = deferred(); f.server.preferences.mockReturnValueOnce(waiting); + const work = f.controller.refresh(); await settle(); f.controller.close(); waiting.resolve(own); await work; + expect(waiting.cancel).toHaveBeenCalledOnce(); expect(f.controller.administration).toBeNull(); + expect(f.controller.view.session).toBeNull(); +}); +test('HADB-02/C14: read-only original recovery remains possible while mutations are blocked', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); + await f.controller.submitAdministration(command); const record = f.controller.administration!.journal!.records[0]; + f.entryServer.session.mockReturnValue(read({...f.proof, access: 'read_only'})); + f.identity.session.mockReturnValue(read({...f.proof, access: 'read_only'})); + await f.controller.refresh(); await f.controller.submitAdministration(command); + await f.controller.recoverAdministration(record.key); expect(f.server.send).toHaveBeenCalledOnce(); + expect(f.server.original).toHaveBeenCalledOnce(); expect(f.controller.administration?.outcome?.kind).toBe('receipt'); + f.setNow(f.proof.expiresAt); f.controller.tick(); expect(f.controller.view.session).toBeNull(); + expect(f.controller.administration).toBeNull(); +}); +test('HADB-02: entry form validation, explicit rejection, original recovery guard and dismissal retain their separate owner', async () => { + const f = fixture(), setup: Authorization = {state: 'unconfigured', setupAllowed: true, enrollmentAllowed: false, permissions: []}; + f.entryServer.authorization.mockReturnValue(read(setup)); await f.controller.refresh(); + await f.controller.submitSetup('', ''); expect(f.controller.view.fields).toEqual(['name', 'time_zone']); + f.entryServer.send.mockResolvedValueOnce({kind: 'rejected', reason: 'invalid_input', correlationId: null}); + await f.controller.submitSetup('Home', 'UTC'); + const original = f.controller.view.journal!.records[0]; + await f.controller.recover(original.key); expect(f.entryServer.send).toHaveBeenCalledOnce(); + await f.controller.dismiss(original.key); expect(f.controller.view.journal?.records).toEqual([]); + expect(f.factory).not.toHaveBeenCalled(); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('HADB-01: an inactive administration observation cannot publish alongside an earlier active entry', async () => { + const f = fixture(); f.server.authorization.mockReturnValue(read({...access, state: 'pending', + member: {...member, status: 'pending'}, permissions: []})); + await f.controller.refresh(); expect(f.controller.administration).toBeNull(); + expect(f.controller.view.notice).toBe('context_changed'); expect(f.entryServer.session).toHaveBeenCalledTimes(2); +}); +test.each(['access', 'setupAllowed', 'enrollmentAllowed'] as const)( + 'HADB-01: a persistent current %s mismatch never publishes a joint private view', async field => { + const f = fixture(); + if (field === 'access') f.identity.session.mockReturnValue(read({...f.proof, access: 'read_only'})); + else f.entryServer.authorization.mockReturnValue(read({...basic(access), [field]: true})); + await f.controller.refresh(); expect(f.controller.administration).toBeNull(); + expect(f.controller.view.notice).toBe('context_changed'); expect(f.entryServer.session).toHaveBeenCalledTimes(2); + }, +); +test('HADB-01/C03: newly verified inactive entry during private collection retires the incompatible administration view', async () => { + const f = fixture(), waiting = deferred(); f.server.preferences.mockReturnValueOnce(waiting); + const opening = f.controller.refresh(); await settle(); + f.entryServer.authorization.mockReturnValue(read({...basic(access), state: 'suspended', permissions: []})); + await f.entry.refresh(); waiting.resolve(own); await opening; + expect(f.controller.administration).toBeNull(); expect(f.controller.view.decision?.state).toBe('suspended'); + expect(f.administrators[0].view.phase).toBe('closed'); +}); +test('HADB-01/C03: a newer underlying authority observation is fenced by a tick without another network request', async () => { + const f = fixture(); await f.controller.refresh(); f.controller.tick(); + expect(f.controller.administration?.preferences).toEqual(own); + f.server.authorization.mockReturnValue(read({...access, permissions: ['household.read']})); + await f.administrators[0].refresh(); const reads = f.entryServer.session.mock.calls.length; + f.controller.tick(); expect(f.controller.administration).toBeNull(); + expect(f.controller.view.notice).toBe('context_changed'); expect(f.entryServer.session).toHaveBeenCalledTimes(reads); +}); +test('HADB-01: an independently pending entry read cannot keep previously visible private administration on a tick', async () => { + const f = fixture(); await f.controller.refresh(); const waiting = deferred(); + f.entryServer.session.mockReturnValueOnce(waiting); const readEntry = f.entry.refresh(); + f.controller.tick(); expect(f.controller.administration).toBeNull(); expect(f.controller.view.notice).toBe('context_changed'); + waiting.resolve(f.proof); await readEntry; +}); +test('HADB-01/C07: close during the sole reconciliation cancels that read and fences its completion', async () => { + const f = fixture(), waiting = deferred(); + f.entryServer.session.mockReturnValueOnce(read(f.proof)).mockReturnValueOnce(waiting); + f.identity.session.mockReturnValue(read({...f.proof, context: {...context, channel: 'stable'}})); + const opening = f.controller.refresh(); await settle(); expect(f.entryServer.session).toHaveBeenCalledTimes(2); + f.controller.close(); waiting.resolve(f.proof); await opening; + expect(waiting.cancel).toHaveBeenCalledOnce(); expect(f.controller.administration).toBeNull(); + expect(f.controller.view.session).toBeNull(); +}); +test('HADB-01/C04: expiry during the sole reconciliation retires both views without retrying', async () => { + const f = fixture(), waiting = deferred(); + f.entryServer.session.mockReturnValueOnce(read(f.proof)).mockReturnValueOnce(read({...f.proof, expiresAt: 2000})); + f.entryServer.household.mockReturnValueOnce(read(home)).mockReturnValueOnce(waiting); + f.identity.session.mockReturnValue(read({...f.proof, context: {...context, channel: 'stable'}})); + const opening = f.controller.refresh(); await settle(); expect(f.entryServer.session).toHaveBeenCalledTimes(2); + f.setNow(2000); waiting.resolve(home); await opening; + expect(f.controller.administration).toBeNull(); expect(f.controller.view.decision?.state).toBe('authentication_required'); + expect(f.entryServer.session).toHaveBeenCalledTimes(2); +}); +test('HADB-01/C03: revoked membership during reconciliation remains inactive with no private facts', async () => { + const f = fixture(); f.entryServer.authorization.mockReturnValueOnce(read(basic(access))) + .mockReturnValue(read({...basic(access), state: 'pending', permissions: []})); + f.identity.session.mockReturnValue(read({...f.proof, context: {...context, channel: 'stable'}})); + await f.controller.refresh(); expect(f.controller.view.decision?.state).toBe('pending'); + expect(f.controller.administration).toBeNull(); expect(f.administrators[0].view.phase).toBe('closed'); +}); +test('HADB-01/C04: a late atomic command response cannot automatically renew an expired entry lease', async () => { + const f = fixture(); await f.controller.refresh(); const waiting = deferred(); + f.server.send.mockReturnValueOnce(waiting.result); const work = f.controller.submitAdministration(command); await settle(); + expect(f.server.send).toHaveBeenCalledOnce(); const raw = f.values.get(ADMINISTRATION_JOURNAL_KEY); + f.setNow(f.proof.expiresAt); waiting.resolve(receipt); await work; + expect(f.controller.view.decision?.state).toBe('authentication_required'); expect(f.controller.administration).toBeNull(); + expect(f.entryServer.session).toHaveBeenCalledOnce(); expect(f.values.get(ADMINISTRATION_JOURNAL_KEY)).toBe(raw); +}); +test('HADB-01: a command reply does not silently reverify a newer suspended entry observed while it was pending', async () => { + const f = fixture(); await f.controller.refresh(); const waiting = deferred(); + f.server.send.mockReturnValueOnce(waiting.result); const work = f.controller.submitAdministration(command); await settle(); + f.entryServer.authorization.mockReturnValue(read({...basic(access), state: 'suspended', permissions: []})); + await f.entry.refresh(); const reads = f.entryServer.session.mock.calls.length; + waiting.resolve(receipt); await work; + expect(f.controller.view.decision?.state).toBe('suspended'); expect(f.controller.administration).toBeNull(); + expect(f.entryServer.session).toHaveBeenCalledTimes(reads); +}); +test('HADB-01/C05: the coordinator respects an underlying busy entry even when another trusted owner started it', async () => { + const f = fixture(); f.entryServer.authorization.mockReturnValue(read({...basic(access), state: 'not_enrolled', + enrollmentAllowed: true, permissions: []})); + await f.controller.refresh(); const waiting = deferred>>(); + f.entryServer.send.mockReturnValueOnce(waiting.result); const work = f.entry.requestMembership(); await settle(); + expect(f.controller.view.busy).toBe(true); await f.controller.logout(); + expect(f.entryServer.logout).not.toHaveBeenCalled(); waiting.resolve({kind: 'uncertain'}); await work; +}); +test('HADB-01/C05: an underlying administration command also blocks stale logout and replacement refresh', async () => { + const f = fixture(); await f.controller.refresh(); const waiting = deferred(); + f.server.send.mockReturnValueOnce(waiting.result); const work = f.administrators[0].submit(command); await settle(); + expect(f.controller.view.busy).toBe(true); const reads = f.entryServer.session.mock.calls.length; + await f.controller.logout(); await f.controller.refresh(); expect(f.entryServer.logout).not.toHaveBeenCalled(); + expect(f.entryServer.session).toHaveBeenCalledTimes(reads); waiting.resolve(receipt); await work; +}); +test('HADB-01/C13: relinking the same HA account cannot expose the prior private receipt through the joined view', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submitAdministration(command); + const original = f.controller.administration!.journal!.records[0], next = {...member, memberId: 'member-relinked'}; + f.server.authorization.mockReturnValue(read({...access, member: next})); + f.server.preferences.mockReturnValue(read({...own, memberId: next.memberId})); + f.server.roster.mockReturnValue(read({directoryState: 'available', members: [{member: next, + presentation: {memberId: next.memberId, alias: null, revision: revisionValue('11')}, + metadata: {state: 'available', name: 'Current member', avatarState: 'unavailable'}}]})); + await f.controller.refresh(); expect(f.controller.administration?.outcome).toBeNull(); + expect(f.controller.administration?.preferences?.memberId).toBe(next.memberId); + await f.controller.recoverAdministration(original.key); + expect(f.server.operation).toHaveBeenCalledOnce(); expect(f.server.send).toHaveBeenCalledOnce(); + expect(f.controller.administration?.outcome).toBeNull(); + expect(f.controller.administration?.notice).toBe('invalid_response'); + expect(JSON.stringify(f.controller.administration)).not.toContain('"memberId":"member-own"'); +}); +test('HADB-02/C12: reconciling entry after a rejected stale draft preserves the actionable conflict and sends no command', async () => { + const f = fixture(); await f.controller.refresh(); + await f.controller.submitAdministration({...command, payload: {...command.payload, expected_revision: '12'}}); + expect(f.controller.administration?.notice).toBe('conflict'); expect(f.server.send).not.toHaveBeenCalled(); + expect(f.controller.administration?.preferences).toEqual(own); +}); +test('HADB-02/C12: invalid intent remains an explicit error after entry reconciliation without sending', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submitAdministration(null); + expect(f.controller.administration?.notice).toBe('invalid_input'); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('HADB-01/C04: session expiry supersedes an earlier context mismatch notice', async () => { + const f = fixture(); f.identity.session.mockReturnValue(read({...f.proof, context: {...context, channel: 'stable'}})); + await f.controller.refresh(); expect(f.controller.view.notice).toBe('context_changed'); + f.setNow(f.proof.expiresAt); f.controller.tick(); + expect(f.controller.view.notice).toBe('authentication_required'); expect(f.controller.administration).toBeNull(); +}); +test('HADB-01/C03: newer pending authority supersedes an earlier context mismatch notice', async () => { + const f = fixture(); f.identity.session.mockReturnValue(read({...f.proof, context: {...context, channel: 'stable'}})); + await f.controller.refresh(); expect(f.controller.view.notice).toBe('context_changed'); + f.entryServer.authorization.mockReturnValue(read({...basic(access), state: 'pending', permissions: []})); + await f.entry.refresh(); f.controller.tick(); expect(f.controller.view.decision?.state).toBe('pending'); + expect(f.controller.view.notice).toBeNull(); +}); +test('HADB-01/C07: disposal during logout fences the late revocation response as well as private reads', async () => { + const f = fixture(); await f.controller.refresh(); const waiting = deferred<{upstreamRevoked: boolean}>(); + f.entryServer.logout.mockReturnValueOnce(waiting.result); const work = f.controller.logout(); await settle(); + expect(f.entryServer.logout).toHaveBeenCalledOnce(); const before = f.controller.view; + f.controller.close(); waiting.resolve({upstreamRevoked: true}); await work; + expect(f.controller.view).toEqual(before); expect(f.controller.administration).toBeNull(); + expect(f.controller.view.notice).toBeNull(); expect(f.controller.view.cleanup).toBeNull(); +}); +test.each(['invalid_input', 'missing_request', 'read_only'] as const)( + 'household-browser-C13: %s cannot reuse private facts after the same HA account changes member', async kind => { + const f = fixture(); + if (kind === 'read_only') { + f.entryServer.session.mockReturnValue(read({...f.proof, access: 'read_only'})); + f.identity.session.mockReturnValue(read({...f.proof, access: 'read_only'})); + } + await f.controller.refresh(); expect(f.controller.administration?.preferences?.memberId).toBe(member.memberId); + const next = {...member, memberId: 'member-relinked'}; + f.server.authorization.mockReturnValue(read({...access, member: next})); + f.server.preferences.mockReturnValue(read({...own, memberId: next.memberId})); + f.server.roster.mockReturnValue(read({directoryState: 'available', members: [{member: next, + presentation: {memberId: next.memberId, alias: null, revision: revisionValue('11')}, + metadata: {state: 'available', name: 'Current member', avatarState: 'unavailable'}}]})); + if (kind === 'missing_request') await f.controller.recoverAdministration('missing-original'); + else await f.controller.submitAdministration(kind === 'invalid_input' ? null : command); + expect(f.controller.administration?.preferences?.memberId).toBe(next.memberId); + expect(JSON.stringify(f.controller.administration)).not.toContain('"memberId":"member-own"'); + expect(f.server.send).not.toHaveBeenCalled(); expect(f.server.original).not.toHaveBeenCalled(); + expect(f.server.operation).not.toHaveBeenCalled(); + }, +); +test('household-browser-C02: an empty administration error from the earlier context cannot be joined to a new account context', async () => { + const f = fixture(); await f.controller.refresh(); + f.server.send.mockResolvedValueOnce({kind: 'rejected', reason: 'forbidden', correlationId: 'old-context-correlation'}); + const next = {...f.proof, context: {...context, installationId: '11234567-89ab-cdef-0123-456789abcdef'}}; + f.entryServer.session.mockReturnValue(read(next)); + await f.controller.submitAdministration(command); + expect(f.controller.administration).toBeNull(); expect(f.controller.view.notice).toBe('context_changed'); + expect(JSON.stringify(f.controller.view)).not.toContain('old-context-correlation'); +}); +test('household-browser-C04: the scheduling projection contains only immutable absolute lease and original-retention deadlines', async () => { + const f = fixture(); expect(f.controller.deadlines).toEqual([]); await f.controller.refresh(); + expect(f.controller.deadlines).toEqual([f.proof.expiresAt, f.proof.expiresAt]); + await f.controller.submitAdministration(command); + const deadlines = f.controller.deadlines; + expect(deadlines).toEqual([f.proof.expiresAt, f.proof.expiresAt, 3_601_000]); + expect(Object.isFrozen(deadlines)).toBe(true); expect(JSON.stringify(deadlines)).not.toContain('locale'); + f.controller.close(); expect(f.controller.deadlines).toEqual([]); expect(deadlines).toHaveLength(3); +}); diff --git a/frontend/tests/page.test.ts b/frontend/tests/page.test.ts index be8d8c5..7ac1c07 100644 --- a/frontend/tests/page.test.ts +++ b/frontend/tests/page.test.ts @@ -1,7 +1,8 @@ import {afterEach, expect, test, vi} from 'vitest'; -import {decodeIntent, mountEntry} from '../src/composition/entry-page'; +import {decodeIntent, decodeAdministrationIntent, mountEntry} from '../src/composition/entry-page'; import type {EntryPage} from '../src/composition/entry-page'; import type {DomusApp} from '../src/presentation/domus-app'; +import type {DomusAdministration} from '../src/presentation/domus-administration'; import {ENTRY_JOURNAL_KEY} from '../src/adapters/session-journal'; import corpus from './fixtures/entry-wire.json'; @@ -17,15 +18,39 @@ function fixture(deployment: 'native' | 'standalone' = 'standalone', locale = 'e const data = document.createElement('script'); data.id = 'domus-bootstrap'; data.type = 'application/json'; data.textContent = JSON.stringify({version: 1, deployment, application_base: wire.endpoint.applicationBase, style_nonce: 'a'.repeat(32)}); document.head.append(data); nodes.push(data); - let phase = 'unconfigured', postRequests = 0; + let phase = 'unconfigured', postRequests = 0, putRequests = 0; + const originalAccess=JSON.parse(wire.active); + let preferenceReceipt:Record|null=null; + let memberId:string=originalAccess.member.member_id, preferenceLocale:'en'|'es'|null=null, preferenceRevision=1, preferencesReadable=true; + const presentation=()=>({member_id:memberId,alias:null,revision:1}); + const preferences=()=>({member_id:memberId,locale:preferenceLocale,revision:preferenceRevision,routine_participation_summaries:true,notification_delivery:'unavailable'}); const fetch = vi.fn(async (path: string, init: RequestInit) => { const route = path.slice(wire.endpoint.applicationBase.length + 'api/v1/'.length); let raw: string; if (route === 'session') raw = phase === 'eligible' || phase === 'pending' ? wire.participantSession : wire.session; - else if (route === 'authorization') raw = phase === 'active' ? wire.active : phase === 'eligible' ? wire.eligible : phase === 'pending' ? wire.pending : wire.unconfigured; + else if (route === 'authorization') raw = phase === 'active' ? JSON.stringify({...originalAccess,member:{...originalAccess.member,member_id:memberId}, + permissions:originalAccess.permissions.filter((permission:string)=>preferencesReadable||permission!=='preferences.read')}) + : phase === 'eligible' ? wire.eligible : phase === 'pending' ? wire.pending : wire.unconfigured; else if (route === 'auth/csrf') raw = JSON.stringify({csrf_token: 'c'.repeat(64)}); else if (route === 'auth/logout') raw = JSON.stringify({state: 'logged_out', upstream_revoked: true, message: 'You are signed out of Domus.', action_required: 'Sign in to continue.'}); + else if (init.method==='PUT') { + if(route!=='administration/preferences')throw new Error('This fixture supports preference mutations only'); + putRequests++;const body=JSON.parse(init.body as string);preferenceLocale=body.locale;preferenceRevision++; + preferenceReceipt={state:'committed',archived_by:null,operation_id:'profile-'+putRequests,created_at:JSON.parse(wire.session).context.server_time, + result:preferences(),message:'Saved.',action_required:'Review current state.'}; + raw=JSON.stringify({...preferenceReceipt,notification_delivery:'unavailable'}); + } + else if (route.startsWith('operations/')&&preferenceReceipt!==null)raw=JSON.stringify({...preferenceReceipt,delivery:'pending'}); + else if (route==='administration/capabilities') raw=JSON.stringify({state:'available',contract_version:2,household_id:'home',exact_revisions:'json-integer-int64', + actions:['household.update','member.update','policy.update','member.presentation.update','member.preferences.update'], + journal:{namespace:'administration',version:1,maximum_records:8,maximum_bytes:65536,lifetime_ms:3600000}}); + else if (route==='administration/preferences') raw=JSON.stringify(preferences()); + else if (route==='administration/roster') raw=JSON.stringify({directory_state:'available',members:[{ + member:{...originalAccess.member,member_id:memberId},presentation:presentation(),metadata:{state:'available',name:'Verified owner',avatar_state:'unavailable'}}]}); + else if (route==='administration/directory') raw=JSON.stringify({state:'available',profiles:[{ha_user_id:originalAccess.member.ha_user_id,name:'Verified owner'}]}); + else if (route==='administration/members/'+memberId+'/presentation') raw=JSON.stringify(presentation()); + else if (route==='policy') raw=JSON.stringify({household_id:'home',revision:1,mapping:{mode:'all_ha_administrators',target_ha_user_id:null},adjustments:[]}); else if (init.method === 'POST') {postRequests++; phase = route === 'household' ? 'active' : 'pending'; raw = route === 'household' ? wire.setup : wire.enrollment;} else raw = wire.household; const response = new Response(raw, {headers: {'Content-Type': 'application/json', 'Cache-Control': 'no-store'}}); @@ -41,9 +66,71 @@ function fixture(deployment: 'native' | 'standalone' = 'standalone', locale = 'e const open = async () => {const mounted = mountEntry(page); dispose.push(mounted.dispose); await mounted.ready; const app = root.querySelector('domus-app')!; await app?.updateComplete; return {mounted, app};}; return {root, data, page, open, values, navigation, fetch, events, postRequests: () => postRequests, + putRequests:()=>putRequests,preferences:(locale:'en'|'es'|null,revision=1)=>{preferenceLocale=locale;preferenceRevision=revision;}, + member:(value:string)=>{memberId=value;},preferencesReadable:(value:boolean)=>{preferencesReadable=value;}, phase: (value: string) => {phase = value;}}; } function intent(app: DomusApp, detail: unknown) {app.dispatchEvent(new CustomEvent('domus-intent', {detail, bubbles: true, composed: true}));} +function administrationIntent(app:DomusApp,detail:unknown){app.dispatchEvent(new CustomEvent('domus-administration-intent',{detail,bubbles:true,composed:true}));} + +test.each(['native','standalone'] as const)('HADB-03: %s document mounts the actual administration widget with current independently verified facts',async mode=>{ + const f=fixture(mode);f.phase('active');const {app}=await f.open(); + expect(app.administration?.preferences?.memberId).toBe('identity-1'); + const widget=app.shadowRoot!.querySelector('domus-administration')!;await widget.updateComplete; + expect(widget.view).toEqual(app.administration);expect(widget.shadowRoot!.textContent).toContain('Verified owner');expect(f.putRequests()).toBe(0); +}); +test('household-browser-C18: saved locale wins, a temporary language stays local, and both higher and restored lower revisions reapply current preferences',async()=>{ + const f=fixture('standalone','en-GB');f.phase('active');f.preferences('es',4);const {app}=await f.open(); + expect(app.language).toBe('es');expect(document.documentElement.lang).toBe('es'); + intent(app,{type:'language',language:'en'});administrationIntent(app,{type:'refresh'}); + await vi.waitFor(()=>expect(app.view?.busy).toBe(false));expect(app.language).toBe('en');expect(f.putRequests()).toBe(0); + f.preferences('es',5);administrationIntent(app,{type:'refresh'});await vi.waitFor(()=>expect(app.language).toBe('es')); + f.preferences('en',1);administrationIntent(app,{type:'refresh'});await vi.waitFor(()=>expect(app.language).toBe('en')); + expect(f.putRequests()).toBe(0); +}); +test('household-browser-C18: unavailable own preferences use browser fallback and the same saved revision applies again when readable',async()=>{ + const f=fixture();f.phase('active');f.preferences('es',3);const {app}=await f.open();expect(app.language).toBe('es'); + f.preferencesReadable(false);administrationIntent(app,{type:'refresh'});await vi.waitFor(()=>expect(app.language).toBe('en')); + f.preferencesReadable(true);administrationIntent(app,{type:'refresh'});await vi.waitFor(()=>expect(app.language).toBe('es')); +}); +test('household-browser-C13: replacing the same-HA member clears the prior temporary language and private preference scope',async()=>{ + const f=fixture();f.phase('active');const {app}=await f.open();intent(app,{type:'language',language:'es'}); + f.member('replacement');administrationIntent(app,{type:'refresh'}); + await vi.waitFor(()=>expect(app.administration?.preferences?.memberId).toBe('replacement')); + expect(app.language).toBe('en');expect(document.documentElement.lang).toBe('en');expect(f.putRequests()).toBe(0); +}); +test.each([null,{},[],{type:'other'},{type:'refresh',url:'https://foreign.invalid'}, + {type:'submit',command:{action:'member.preferences.update',payload:{expected_revision:'1',locale:'es',routine_participation_summaries:true}},actor:'forged'}, + {type:'submit',command:{action:'member.preferences.update',payload:{expected_revision:'1',locale:'es',routine_participation_summaries:true,member_id:'forged'}}}, + {type:'recover',key:'../other'},{type:'dismiss',key:''}])('household-browser-C15: closed administration intent %j cannot select a principal or route',value=>{ + expect(decodeAdministrationIntent(value)).toBeNull(); +}); +test('HADB-03: valid administration intents retain the exact immutable command and have no principal selector',()=>{ + const command={action:'member.preferences.update',payload:{expected_revision:'9007199254740993',locale:null,routine_participation_summaries:false}}; + expect(decodeAdministrationIntent({type:'submit',command})).toEqual({type:'submit',command}); + expect(decodeAdministrationIntent({type:'recover',key:'original'})).toEqual({type:'recover',key:'original'}); + expect(decodeAdministrationIntent({type:'dismiss',key:'original'})).toEqual({type:'dismiss',key:'original'}); + expect(decodeAdministrationIntent({type:'refresh'})).toEqual({type:'refresh'}); +}); +test('HADB-03: a real mounted preference form reaches the protected HTTP adapter once and applies only freshly read saved locale',async()=>{ + const f=fixture();f.phase('active');const {app}=await f.open(),widget=app.shadowRoot!.querySelector('domus-administration')!; + await widget.updateComplete;const button=(name:string)=>[...widget.shadowRoot!.querySelectorAll('button')].find(value=>value.textContent?.trim()===name)!; + button('Edit preferences').click();await widget.updateComplete;const select=widget.shadowRoot!.querySelector('select')!; + select.value='es';select.dispatchEvent(new Event('change',{bubbles:true}));await widget.updateComplete; + button('Review changes').click();await widget.updateComplete;expect(f.putRequests()).toBe(0);button('Save changes').click(); + await vi.waitFor(()=>{expect(f.putRequests()).toBe(1);expect(app.administration?.preferences?.locale).toBe('es');expect(app.view?.busy).toBe(false);}); + expect(app.language).toBe('es');expect(document.documentElement.lang).toBe('es'); + const mutation=f.fetch.mock.calls.find(([,init])=>init.method==='PUT')!; + expect(mutation[0]).toBe('/api/v1/administration/preferences'); + expect(JSON.parse(mutation[1].body as string)).toEqual({expected_revision:1,locale:'es',routine_participation_summaries:true}); +}); +test('household-browser-C17: an explicit onboarding language survives a proof tick and first membership without saving a preference',async()=>{ + const f=fixture();let tick!:()=>void;f.page.setTimeout=callback=>{tick=callback;return 1;};f.page.clearTimeout=()=>{}; + const {app}=await f.open();intent(app,{type:'language',language:'es'});tick();expect(app.language).toBe('es'); + intent(app,{type:'setup',name:'Home',timeZone:'UTC'}); + await vi.waitFor(()=>expect(app.administration?.preferences?.memberId).toBe('identity-1')); + expect(app.language).toBe('es');expect(app.administration?.preferences?.locale).toBeNull();expect(f.putRequests()).toBe(0); +}); test.each(['native','standalone'] as const)('%s page wires the owned view to actual controller/adapters without automatic mutation', async deployment => { const f = fixture(deployment, 'es-ES'), {app} = await f.open(); @@ -144,3 +231,34 @@ test('the actual module entrypoint starts safely and refuses the test runner loc expect(document.querySelector('#domus-entry-error')).not.toBeNull(); expect(f.fetch).not.toHaveBeenCalled(); const mounted = mountEntry(f.page); dispose.push(mounted.dispose); await mounted.ready; }); + +test('HADB-02: owned administration events recover the actual original by protected GET and dismiss only after resolution',async()=>{ + const f=fixture();f.phase('active');const {app}=await f.open();administrationIntent(app,{type:'submit',command:{action:'member.preferences.update', + payload:{expected_revision:'1',locale:'es',routine_participation_summaries:true}}}); + await vi.waitFor(()=>{expect(app.administration?.outcome).toMatchObject({kind:'receipt',stage:'committed'});expect(app.view?.busy).toBe(false);}); + const key=app.administration!.journal!.records[0].key,calls=f.fetch.mock.calls.length; + administrationIntent(app,{type:'recover',key});await vi.waitFor(()=>{expect(f.fetch.mock.calls.slice(calls).some(([path,init])=>path.endsWith('/operations/profile-1')&&init.method==='GET')).toBe(true);expect(app.view?.busy).toBe(false);}); + expect(f.putRequests()).toBe(1);administrationIntent(app,{type:'dismiss',key});await vi.waitFor(()=>expect(app.administration?.journal?.records).toEqual([]));expect(f.putRequests()).toBe(1); +}); +test('HADB-03: foreign and malformed administration events cannot drive the owned page',async()=>{ + const f=fixture();f.phase('active');const {app}=await f.open(),child=document.createElement('span');app.append(child);const before=f.fetch.mock.calls.length; + child.dispatchEvent(new CustomEvent('domus-administration-intent',{detail:{type:'refresh'},bubbles:true})); + administrationIntent(app,{type:'refresh',url:'https://foreign.invalid'});app.dispatchEvent(new Event('domus-administration-intent'));await Promise.resolve(); + expect(f.fetch).toHaveBeenCalledTimes(before);expect(f.putRequests()).toBe(0); +}); +test('HADB-03: an exceptional owned administration event replaces controls with safe reload guidance',async()=>{ + const f=fixture();f.phase('active');const {app}=await f.open(),event=new Event('domus-administration-intent'); + Object.defineProperty(event,'detail',{get:()=>{throw new Error('Private malformed event source');}});app.dispatchEvent(event); + await vi.waitFor(()=>expect(document.querySelector('#domus-entry-error')).not.toBeNull()); + expect(document.querySelector('#domus-entry-error')!.textContent).not.toContain('Private');expect(app.isConnected).toBe(false);expect(f.putRequests()).toBe(0); +}); +test('household-browser-C18: temporary language persists while current own preferences are unavailable without saving them',async()=>{ + const f=fixture();f.phase('active');const {app}=await f.open();f.preferencesReadable(false);administrationIntent(app,{type:'refresh'}); + await vi.waitFor(()=>{expect(app.administration?.preferences).toBeNull();expect(app.view?.busy).toBe(false);}); + intent(app,{type:'language',language:'es'});administrationIntent(app,{type:'refresh'});await vi.waitFor(()=>expect(app.view?.busy).toBe(false)); + expect(app.language).toBe('es');expect(f.putRequests()).toBe(0); +}); +test('household-browser-C18: a signed-out language choice has no identity or mutation side effect',async()=>{ + const f=fixture();const {app}=await f.open();intent(app,{type:'logout'});await vi.waitFor(()=>expect(app.view?.notice).toBe('signed_out')); + const count=f.fetch.mock.calls.length;intent(app,{type:'language',language:'es'});expect(app.language).toBe('es');expect(f.fetch).toHaveBeenCalledTimes(count);expect(f.putRequests()).toBe(0); +}); diff --git a/frontend/tests/runtime.test.ts b/frontend/tests/runtime.test.ts index 27f6dfe..7e7d753 100644 --- a/frontend/tests/runtime.test.ts +++ b/frontend/tests/runtime.test.ts @@ -2,7 +2,9 @@ import {afterEach, expect, test, vi} from 'vitest'; import {EntryRuntime} from '../src/composition/entry-runtime'; import type {EntryBrowser} from '../src/composition/entry-runtime'; import type {EntryView} from '../src/application/entry-controller'; +import type {AdministrationView} from '../src/application/administration-controller'; import {ENTRY_JOURNAL_KEY} from '../src/adapters/session-journal'; +import {ADMINISTRATION_JOURNAL_KEY} from '../src/adapters/administration-journal'; import corpus from './fixtures/entry-wire.json'; function deferred() { @@ -13,12 +15,16 @@ const owned: EntryRuntime[] = []; afterEach(() => {for (const runtime of owned.splice(0)) runtime.dispose();}); function fixture(deployment: 'native' | 'standalone' = 'standalone') { const wire = corpus[deployment], target = {...wire.endpoint, deployment}; - const values = new Map(), views: EntryView[] = [], paths: string[] = []; + const values = new Map(), views: EntryView[] = [], paths: string[] = [], administrationViews: (AdministrationView | null)[] = []; const storage = {getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => {values.set(key, value);}, removeItem: (key: string) => {values.delete(key);}}; - let elapsed = 0, phase = 'unconfigured', lost = false, effects = 0; + let elapsed = 0, phase = 'unconfigured', lost = false, effects = 0, random = 0; const keys = new Set(); + const member = JSON.parse(wire.active).member; + let preferences = {member_id: member.member_id, revision: 1, locale: 'es', routine_participation_summaries: true, notification_delivery: 'unavailable'}; + const records = new Map(); const pending: {tick: () => void; delay: number; cancel: ReturnType}[] = []; const transport = vi.fn(async (path: string, init: RequestInit) => { const route = path.slice(target.applicationBase.length + 'api/v1/'.length); @@ -29,7 +35,42 @@ function fixture(deployment: 'native' | 'standalone' = 'standalone') { else if (route === 'auth/csrf') raw = JSON.stringify({csrf_token: 'c'.repeat(64)}); else if (route === 'auth/logout') raw = JSON.stringify({state: 'logged_out', upstream_revoked: true, message: 'You are signed out of Domus.', action_required: 'Sign in to continue.'}); - else if (init.method === 'POST') { + else if (init.method === 'PUT') { + if (route !== 'administration/preferences') throw new Error('Unexpected administration fixture mutation.'); + const key = new Headers(init.headers).get('Idempotency-Key')!; + if (!keys.has(key)) { + const body = JSON.parse(init.body as string); keys.add(key); effects++; + preferences = {...preferences, revision: preferences.revision + 1, locale: body.locale, + routine_participation_summaries: body.routine_participation_summaries}; + records.set(key, {state: 'committed', archived_by: null, operation_id: 'profile-' + effects, + created_at: JSON.parse(wire.session).context.server_time + elapsed, result: {...preferences}, + message: 'Saved.', action_required: 'Review current state.', notification_delivery: 'unavailable'}); + } + raw = JSON.stringify(records.get(key)); + if (lost) {lost = false; throw new TypeError('simulated response loss after one effect');} + } else if (route === 'administration/capabilities') raw = JSON.stringify({state: 'available', contract_version: 2, + household_id: 'home', exact_revisions: 'json-integer-int64', actions: ['household.update', 'member.update', 'policy.update', + 'member.presentation.update', 'member.preferences.update'], journal: {namespace: 'administration', version: 1, + maximum_records: 8, maximum_bytes: 65536, lifetime_ms: 3600000}}); + else if (route === 'administration/roster') raw = JSON.stringify({directory_state: 'available', members: [{member, + presentation: {member_id: member.member_id, alias: null, revision: 1}, + metadata: {state: 'available', name: 'Verified member', avatar_state: 'unavailable'}}]}); + else if (route === 'administration/directory') raw = JSON.stringify({state: 'denied', profiles: []}); + else if (route === 'policy') raw = JSON.stringify({household_id: 'home', revision: 1, + mapping: {mode: 'all_ha_administrators', target_ha_user_id: null}, adjustments: []}); + else if (route === 'administration/preferences') raw = JSON.stringify(preferences); + else if (route === 'administration/members/' + member.member_id + '/presentation') + raw = JSON.stringify({member_id: member.member_id, alias: null, revision: 1}); + else if (route.startsWith('administration/requests/member.preferences.update/')) { + const key = route.split('/').at(-1)!, record = records.get(key); + if (record === undefined) throw new Error('Unknown original administration fixture request.'); + raw = JSON.stringify({...record, action: 'member.preferences.update', command_key: key, event_delivery: 'pending'}); + } else if (route.startsWith('operations/profile-')) { + const record = [...records.values()].find(row => row.operation_id === route.slice('operations/'.length)); + if (record === undefined) throw new Error('Unknown administration fixture operation.'); + const {notification_delivery: _notification, ...result} = record; + raw = JSON.stringify({...result, delivery: 'pending'}); + } else if (init.method === 'POST') { const key = new Headers(init.headers).get('Idempotency-Key')!; if (!keys.has(key)) {keys.add(key); effects++;} phase = route === 'household' ? 'active' : 'pending'; @@ -43,15 +84,15 @@ function fixture(deployment: 'native' | 'standalone' = 'standalone') { Object.defineProperty(result, 'url', {value: target.origin + path}); return result; } const browser: EntryBrowser = {transport, storage: () => storage, elapsed: () => elapsed, - random: bytes => {bytes.fill(1); return bytes;}, navigate: path => {paths.push(path);}, + random: bytes => {bytes.fill(++random); return bytes;}, navigate: path => {paths.push(path);}, events: new EventTarget(), visibility: new EventTarget(), schedule: (tick, delay) => {const cancel = vi.fn(); pending.push({tick, delay, cancel}); return cancel;}}; - const runtime = new EntryRuntime(target, browser, view => {views.push(view);}); owned.push(runtime); - return {runtime, browser, target, values, views, paths, transport, pending, response, wire, + const runtime = new EntryRuntime(target, browser, (view, administration) => {views.push(view); administrationViews.push(administration);}); owned.push(runtime); + return {runtime, browser, target, values, views, paths, transport, pending, response, wire, administrationViews, phase: (value: string) => {phase = value;}, elapsed: (value: number) => {elapsed = value;}, lose: () => {lost = true;}, effects: () => effects}; } -async function settle() {for (let i = 0; i < 60; i++) await Promise.resolve();} +async function settle() {for (let i = 0; i < 200; i++) await Promise.resolve();} test('composition opens once, publishes loading/verified views and performs no business write', async () => { const f = fixture(); expect(f.runtime.view.phase).toBe('idle'); @@ -265,3 +306,148 @@ test('disposal during logout fences its late revocation result', async () => { message: 'You are signed out of Domus.', action_required: 'Sign in to continue.'}), '/api/v1/auth/logout')); await signingOut; expect(f.views).toHaveLength(count); expect(f.runtime.view.notice).toBeNull(); }); + +const preferenceCommand = {action: 'member.preferences.update', payload: {expected_revision: '1', locale: 'en', routine_participation_summaries: false}}; +test.each(['native', 'standalone'] as const)('household-browser-C01: %s document runtime composes real protected administration HTTP and its separate journal', async mode => { + const f = fixture(mode); f.phase('active'); expect(f.runtime.administration).toBeNull(); + await f.runtime.start(); + expect(f.runtime.administration?.preferences).toMatchObject({memberId: 'identity-1', locale: 'es', revision: '1'}); + expect(f.runtime.administration?.roster?.members[0].metadata.name).toBe('Verified member'); + expect(f.administrationViews.at(-1)).toEqual(f.runtime.administration); + expect(f.runtime.view.decision?.state).toBe('active'); expect(f.effects()).toBe(0); + expect(f.values.has(ENTRY_JOURNAL_KEY)).toBe(true); expect(f.values.has(ADMINISTRATION_JOURNAL_KEY)).toBe(true); + expect(f.transport.mock.calls.every(([path, init]) => path.startsWith(f.target.applicationBase + 'api/v1/') && init.method === 'GET')).toBe(true); +}); +test('household-browser-C11/C12: runtime persists the immutable separate original before one guarded PUT and explicitly dismisses its result', async () => { + const f = fixture(); await f.runtime.start(); await f.runtime.submitSetup('Home', 'UTC'); + const entry = f.values.get(ENTRY_JOURNAL_KEY), original = f.transport.getMockImplementation()!; + f.transport.mockImplementation(async (path, init) => { + if (init.method === 'PUT') expect(JSON.parse(f.values.get(ADMINISTRATION_JOURNAL_KEY)!).records[0]).toMatchObject({stage: 'prepared', payload: preferenceCommand.payload}); + return original(path, init); + }); + await f.runtime.submitAdministration(preferenceCommand); + const puts = f.transport.mock.calls.filter(([, init]) => init.method === 'PUT'); + expect(puts).toHaveLength(1); expect(new Headers(puts[0][1].headers).get('Idempotency-Key')).toBe('req_' + '02'.repeat(16)); + expect(new Headers(puts[0][1].headers).get('X-CSRF-Token')).toBe('c'.repeat(64)); + expect(f.runtime.administration?.outcome?.kind).toBe('receipt'); + expect(f.runtime.administration?.preferences).toMatchObject({locale: 'en', revision: '2'}); + const key = f.runtime.administration!.journal!.records[0].key; + await f.runtime.dismissAdministration(key); expect(f.runtime.administration?.journal?.records).toEqual([]); + expect(f.runtime.administration?.outcome).toBeNull(); expect(f.values.get(ENTRY_JOURNAL_KEY)).toBe(entry); + expect(f.effects()).toBe(2); +}); +test('household-browser-C09/C10: reload does not look up an uncertain original; explicit recovery uses protected GET without another mutation', async () => { + const f = fixture(); f.phase('active'); await f.runtime.start(); f.lose(); await f.runtime.submitAdministration(preferenceCommand); + expect(f.runtime.administration?.outcome?.kind).toBe('uncertain'); const key = f.runtime.administration!.journal!.records[0].key; + await f.runtime.refresh(); expect(f.transport.mock.calls.some(([path]) => path.includes('/administration/requests/'))).toBe(false); + await f.runtime.recoverAdministration(key); expect(f.runtime.administration?.outcome?.kind).toBe('receipt'); + expect(f.transport.mock.calls.filter(([path, init]) => path.endsWith('/administration/requests/member.preferences.update/' + key) && init.method === 'GET')).toHaveLength(1); + await f.runtime.recoverAdministration(key); + expect(f.transport.mock.calls.filter(([path, init]) => path.includes('/operations/profile-') && init.method === 'GET')).toHaveLength(1); + expect(f.effects()).toBe(1); expect(f.transport.mock.calls.filter(([, init]) => init.method === 'PUT')).toHaveLength(1); +}); +test('household-browser-C05: stale logout and duplicate intents preserve the in-flight runtime, timer and original', async () => { + const f = fixture(); f.phase('active'); await f.runtime.start(); const original = f.transport.getMockImplementation()!, waiting = deferred(); + f.transport.mockImplementation(async (path, init) => path.endsWith('/auth/csrf') ? waiting.promise : original(path, init)); + const work = f.runtime.submitAdministration(preferenceCommand); await settle(); + expect(f.runtime.view.busy).toBe(true); expect(f.runtime.administration).toBeNull(); + const timer = f.pending.at(-1)!, cancellations = timer.cancel.mock.calls.length, raw = f.values.get(ADMINISTRATION_JOURNAL_KEY); + await f.runtime.logout(); await f.runtime.submitAdministration(preferenceCommand); await f.runtime.recoverAdministration('missing'); + expect(timer.cancel).toHaveBeenCalledTimes(cancellations); expect(f.values.get(ADMINISTRATION_JOURNAL_KEY)).toBe(raw); + expect(f.transport.mock.calls.some(([path]) => path.endsWith('/auth/logout'))).toBe(false); + waiting.resolve(f.response(JSON.stringify({csrf_token: 'c'.repeat(64)}), '/api/v1/auth/csrf')); await work; + expect(f.effects()).toBe(1); expect(f.runtime.administration?.outcome?.kind).toBe('receipt'); +}); +test.each(['native', 'standalone'] as const)('household-browser-C04: %s timer uses the earlier administration lease and never renews either proof', async mode => { + const f = fixture(mode); f.phase('active'); const original = f.transport.getMockImplementation()!; let sessions = 0; + f.transport.mockImplementation(async (path, init) => { + if (path.endsWith('/session') && ++sessions === 2) { + const raw = JSON.parse(f.wire.session); raw.context.expires_at = raw.context.server_time + 25; raw.expires_in = 0; + return f.response(JSON.stringify(raw), path); + } + return original(path, init); + }); + await f.runtime.start(); expect(f.runtime.administration?.preferences).not.toBeNull(); expect(f.pending.at(-1)?.delay).toBe(25); + const reads = f.transport.mock.calls.length; f.elapsed(25); f.pending.at(-1)!.tick(); + expect(f.runtime.view.session).toBeNull(); expect(f.runtime.administration).toBeNull(); + expect(f.administrationViews.at(-1)).toBeNull(); expect(f.transport).toHaveBeenCalledTimes(reads); +}); +test('household-browser-C06/C07: BFcache restores fresh administration proof and keeps a prepared original without automatic GET or PUT', async () => { + const f = fixture(); f.phase('active'); await f.runtime.start(); const original = f.transport.getMockImplementation()!, waiting = deferred(); + let saved!: Response; + f.transport.mockImplementation(async (path, init) => {const response = await original(path, init); if (init.method === 'PUT') {saved = response; return waiting.promise;} return response;}); + const work = f.runtime.submitAdministration(preferenceCommand); await settle(); expect(f.effects()).toBe(1); + const raw = f.values.get(ADMINISTRATION_JOURNAL_KEY); f.browser.events.dispatchEvent(new PageTransitionEvent('pagehide', {persisted: true})); + expect(f.runtime.administration).toBeNull(); expect(f.values.get(ADMINISTRATION_JOURNAL_KEY)).toBe(raw); + f.browser.events.dispatchEvent(new PageTransitionEvent('pageshow', {persisted: true})); await settle(); + expect(f.runtime.administration?.preferences).toMatchObject({revision: '2', locale: 'en'}); + expect(f.runtime.administration?.journal?.records[0].stage).toBe('prepared'); + expect(f.transport.mock.calls.some(([path]) => path.includes('/administration/requests/'))).toBe(false); + waiting.resolve(saved); await work; expect(f.runtime.administration?.outcome).toBeNull(); expect(f.effects()).toBe(1); + await f.runtime.recoverAdministration(f.runtime.administration!.journal!.records[0].key); + expect(f.runtime.administration?.outcome?.kind).toBe('receipt'); expect(f.effects()).toBe(1); +}); +test('household-browser-C04/C10: read-only grace retains explicit original GET while blocking a new mutation', async () => { + const f = fixture(); f.phase('active'); await f.runtime.start(); f.lose(); await f.runtime.submitAdministration(preferenceCommand); + const key = f.runtime.administration!.journal!.records[0].key, original = f.transport.getMockImplementation()!; + f.transport.mockImplementation(async (path, init) => path.endsWith('/session') ? f.response(JSON.stringify({...JSON.parse(f.wire.session), access: 'read_only'}), path) : original(path, init)); + await f.runtime.refresh(); expect(f.runtime.administration?.notice).toBe('read_only'); + await f.runtime.recoverAdministration(key); expect(f.runtime.administration?.outcome?.kind).toBe('receipt'); + await f.runtime.submitAdministration({...preferenceCommand, payload: {...preferenceCommand.payload, expected_revision: '2'}}); + expect(f.effects()).toBe(1); expect(f.transport.mock.calls.filter(([, init]) => init.method === 'PUT')).toHaveLength(1); +}); +test('household-browser-C04: the administration journal expires at its original deadline while a longer lease remains valid', async () => { + const f = fixture(); f.phase('active'); const original = f.transport.getMockImplementation()!; + f.transport.mockImplementation(async (path, init) => { + if (path.endsWith('/session')) {const raw = JSON.parse(f.wire.session); raw.context.expires_at += 3_600_000; raw.expires_in = 7200; return f.response(JSON.stringify(raw), path);} + return original(path, init); + }); + await f.runtime.start(); await f.runtime.submitAdministration(preferenceCommand); + f.elapsed(3_599_990); f.pending.at(-1)!.tick(); expect(f.pending.at(-1)?.delay).toBe(10); + const reads = f.transport.mock.calls.length; f.elapsed(3_600_000); f.pending.at(-1)!.tick(); + expect(f.runtime.view.session).not.toBeNull(); expect(f.runtime.administration?.journal?.records).toEqual([]); + expect(f.runtime.administration?.outcome).toBeNull(); expect(f.transport).toHaveBeenCalledTimes(reads); +}); +test('household-browser-C03: current permission loss removes private facts and prevents any new guarded PUT', async () => { + const f = fixture(); f.phase('active'); await f.runtime.start(); const original = f.transport.getMockImplementation()!; + f.transport.mockImplementation(async (path, init) => path.endsWith('/authorization') + ? f.response(JSON.stringify({...JSON.parse(f.wire.active), permissions: ['household.read']}), path) : original(path, init)); + await f.runtime.refresh(); expect(f.runtime.administration?.preferences).toBeNull(); expect(f.runtime.administration?.roster).toBeNull(); + const reads = f.transport.mock.calls.length; await f.runtime.submitAdministration(preferenceCommand); + expect(f.effects()).toBe(0); expect(f.runtime.administration?.notice).toBe('action_not_allowed'); + expect(f.transport.mock.calls.slice(reads).every(([, init]) => init.method === 'GET')).toBe(true); +}); +test('household-browser-C12: failure of only the administration storage key preserves current reads and the separate entry record', async () => { + const f = fixture(); await f.runtime.start(); await f.runtime.submitSetup('Home', 'UTC'); const entry = f.values.get(ENTRY_JOURNAL_KEY); + const storage = f.browser.storage(); f.browser.storage = () => ({...storage, + getItem: key => {if (key === ADMINISTRATION_JOURNAL_KEY) throw new Error('private-provider-detail'); return storage.getItem(key);}}); + await f.runtime.refresh(); expect(f.runtime.administration?.preferences?.locale).toBe('es'); + expect(f.runtime.administration?.notice).toBe('journal_unavailable'); await f.runtime.submitAdministration(preferenceCommand); + expect(f.effects()).toBe(1); expect(f.values.get(ENTRY_JOURNAL_KEY)).toBe(entry); + expect(JSON.stringify(f.runtime.administration)).not.toContain('private-provider-detail'); +}); +test('household-browser-C07: document exit cancels the actual owned administration GET and fences its late private payload', async () => { + const f = fixture(); f.phase('active'); const original = f.transport.getMockImplementation()!; let canceled = false; + f.transport.mockImplementation(async (path, init) => path.endsWith('/administration/preferences') ? new Promise((_resolve, reject) => { + init.signal!.addEventListener('abort', () => {canceled = true; reject(new TypeError('retired private read'));}, {once: true}); + }) : original(path, init)); + const work = f.runtime.start(); await settle(); f.browser.events.dispatchEvent(new Event('pagehide')); await work; + expect(canceled).toBe(true); expect(f.runtime.view.phase).toBe('closed'); expect(f.runtime.administration).toBeNull(); + expect(f.administrationViews.at(-1)).toBeNull(); expect(f.effects()).toBe(0); +}); +test('household-browser-C07: a scheduling failure after administration opens closes both views and releases the old timer', async () => { + const f = fixture(); f.phase('active'); await f.runtime.start(); const timer = f.pending.at(-1)!; + f.browser.schedule = () => {throw new Error('private-scheduler-detail');}; + expect(() => timer.tick()).not.toThrow(); expect(f.runtime.view.phase).toBe('closed'); + expect(f.runtime.administration).toBeNull(); expect(f.administrationViews.at(-1)).toBeNull(); +}); +test('household-browser-C04: a late atomic PUT cannot renew an expired document lease or publish its private result', async () => { + const f = fixture(); f.phase('active'); await f.runtime.start(); const original = f.transport.getMockImplementation()!, waiting = deferred(); + let response!: Response; + f.transport.mockImplementation(async (path, init) => {const reply = await original(path, init); if (init.method === 'PUT') {response = reply; return waiting.promise;} return reply;}); + const work = f.runtime.submitAdministration(preferenceCommand); await settle(); expect(f.effects()).toBe(1); + const raw = f.values.get(ADMINISTRATION_JOURNAL_KEY), reads = f.transport.mock.calls.length; + f.elapsed(3_600_000); f.pending.at(-1)!.tick(); waiting.resolve(response); await work; + expect(f.runtime.view.decision?.state).toBe('authentication_required'); expect(f.runtime.administration).toBeNull(); + expect(f.values.get(ADMINISTRATION_JOURNAL_KEY)).toBe(raw); expect(f.transport).toHaveBeenCalledTimes(reads); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 4a82a8d..803f577 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -2,7 +2,7 @@ import {defineConfig} from 'vitest/config'; import {playwright} from '@vitest/browser-playwright'; import {completeCoverage} from '../tooling/frontend-coverage.mjs'; -export default defineConfig({optimizeDeps: {include: ['lit', 'lit/directives/live.js']}, test: { +export default defineConfig({optimizeDeps: {include: ['lit', 'lit/directives/live.js', 'lit/directives/repeat.js']}, test: { include: ['tests/*.test.ts'], browser: {enabled: true, provider: playwright(), headless: true, instances: [{browser: 'chromium'}]}, coverage: completeCoverage('src', 'coverage'), diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 25002b0..f2c5ee4 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -11,7 +11,7 @@ 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) + 2 companion | 189 paths · 2026-11-01 | -| `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) + 3 companion | 213 paths · 2026-11-02 | +| `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) + 4 companion | 222 paths · 2026-11-02 | | `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) + 1 companion | 32 paths · 2026-11-01 | | `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) + 2 companion | 22 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-11-01 | @@ -39,12 +39,12 @@ debt or convert unknown historic intent into a design decision. - Owner: Domus maintainers (review assignment pending) - Last verified: 2026-11-02 -- Specifications: [`specs/household-identity.md`](./household-identity.md) · [`specs/household-administration.md`](./household-administration.md) · [`specs/household-administration-integration.md`](./household-administration-integration.md) · [`specs/household-administration-workspace.md`](./household-administration-workspace.md) +- Specifications: [`specs/household-identity.md`](./household-identity.md) · [`specs/household-administration.md`](./household-administration.md) · [`specs/household-administration-integration.md`](./household-administration-integration.md) · [`specs/household-administration-workspace.md`](./household-administration-workspace.md) · [`specs/household-administration-browser.md`](./household-administration-browser.md) - Workflows: Not applicable for this capability. - 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) · [`backend/src/domus/entrypoints/administration_http.py`](../backend/src/domus/entrypoints/administration_http.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) · [`backend/src/domus/domain/member_profiles.py`](../backend/src/domus/domain/member_profiles.py) · [`backend/src/domus/application/member_profile_access.py`](../backend/src/domus/application/member_profile_access.py) · [`backend/src/domus/application/member_profile_commands.py`](../backend/src/domus/application/member_profile_commands.py) · [`backend/src/domus/application/household_administration_queries.py`](../backend/src/domus/application/household_administration_queries.py) · [`backend/src/domus/composition/household_administration.py`](../backend/src/domus/composition/household_administration.py) · [`backend/src/domus/entrypoints/administration_dto.py`](../backend/src/domus/entrypoints/administration_dto.py) · [`backend/src/domus/entrypoints/administration_http.py`](../backend/src/domus/entrypoints/administration_http.py) · [`backend/src/domus/entrypoints/openapi.py`](../backend/src/domus/entrypoints/openapi.py) · [`frontend/src/domain/administration.ts`](../frontend/src/domain/administration.ts) · [`frontend/src/adapters/administration-json.ts`](../frontend/src/adapters/administration-json.ts) · [`backend/src/domus/domain/member_sources.py`](../backend/src/domus/domain/member_sources.py) · [`backend/src/domus/application/member_source_ports.py`](../backend/src/domus/application/member_source_ports.py) · [`backend/src/domus/application/household_roster.py`](../backend/src/domus/application/household_roster.py) · [`backend/src/domus/infrastructure/ha_member_profiles.py`](../backend/src/domus/infrastructure/ha_member_profiles.py) · [`frontend/src/domain/administration-commands.ts`](../frontend/src/domain/administration-commands.ts) · [`frontend/src/domain/administration-journal.ts`](../frontend/src/domain/administration-journal.ts) · [`frontend/src/application/administration-ports.ts`](../frontend/src/application/administration-ports.ts) · [`frontend/src/adapters/administration-journal.ts`](../frontend/src/adapters/administration-journal.ts) · [`frontend/src/domain/administration-views.ts`](../frontend/src/domain/administration-views.ts) · [`frontend/src/adapters/json-values.ts`](../frontend/src/adapters/json-values.ts) · [`frontend/src/adapters/http-transport.ts`](../frontend/src/adapters/http-transport.ts) · [`frontend/src/adapters/administration-values.ts`](../frontend/src/adapters/administration-values.ts) · [`frontend/src/adapters/administration-http.ts`](../frontend/src/adapters/administration-http.ts) · [`frontend/src/application/administration-controller.ts`](../frontend/src/application/administration-controller.ts) · [`frontend/src/domain/administration-authorization.ts`](../frontend/src/domain/administration-authorization.ts) -- 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) · [`tests/backend/test_member_profiles.py`](../tests/backend/test_member_profiles.py) · [`tests/backend/test_member_profile_commands.py`](../tests/backend/test_member_profile_commands.py) · [`tests/backend/test_administration_http.py`](../tests/backend/test_administration_http.py) · [`tests/containers/test_administration_image.py`](../tests/containers/test_administration_image.py) · [`tests/backend/test_openapi.py`](../tests/backend/test_openapi.py) · [`frontend/tests/administration.test.ts`](../frontend/tests/administration.test.ts) · [`tests/backend/test_member_sources.py`](../tests/backend/test_member_sources.py) · [`tests/backend/test_household_roster.py`](../tests/backend/test_household_roster.py) · [`tests/backend/test_ha_member_profiles.py`](../tests/backend/test_ha_member_profiles.py) · [`tests/backend/test_standalone_member_sources.py`](../tests/backend/test_standalone_member_sources.py) · [`frontend/tests/administration-journal.test.ts`](../frontend/tests/administration-journal.test.ts) · [`frontend/tests/administration-session-journal.test.ts`](../frontend/tests/administration-session-journal.test.ts) · [`frontend/tests/entry-int64.test.ts`](../frontend/tests/entry-int64.test.ts) · [`frontend/tests/administration-http.test.ts`](../frontend/tests/administration-http.test.ts) · [`frontend/tests/e2e/entry.spec.ts`](../frontend/tests/e2e/entry.spec.ts) · [`tests/browser/entry_server.py`](../tests/browser/entry_server.py) · [`tests/containers/test_app_ui.py`](../tests/containers/test_app_ui.py) · [`tests/packaging/test_ui_wheel.py`](../tests/packaging/test_ui_wheel.py) · [`frontend/tests/administration-controller.test.ts`](../frontend/tests/administration-controller.test.ts) · [`frontend/tests/administration-authorization.test.ts`](../frontend/tests/administration-authorization.test.ts) -- 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) · [`docs/architecture-household-administration.md`](../docs/architecture-household-administration.md) · [`docs/evidence/household-administration.json`](../docs/evidence/household-administration.json) · [`docs/household-administration.md`](../docs/household-administration.md) +- 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) · [`backend/src/domus/domain/member_profiles.py`](../backend/src/domus/domain/member_profiles.py) · [`backend/src/domus/application/member_profile_access.py`](../backend/src/domus/application/member_profile_access.py) · [`backend/src/domus/application/member_profile_commands.py`](../backend/src/domus/application/member_profile_commands.py) · [`backend/src/domus/application/household_administration_queries.py`](../backend/src/domus/application/household_administration_queries.py) · [`backend/src/domus/composition/household_administration.py`](../backend/src/domus/composition/household_administration.py) · [`backend/src/domus/entrypoints/administration_dto.py`](../backend/src/domus/entrypoints/administration_dto.py) · [`backend/src/domus/entrypoints/administration_http.py`](../backend/src/domus/entrypoints/administration_http.py) · [`backend/src/domus/entrypoints/openapi.py`](../backend/src/domus/entrypoints/openapi.py) · [`frontend/src/domain/administration.ts`](../frontend/src/domain/administration.ts) · [`frontend/src/adapters/administration-json.ts`](../frontend/src/adapters/administration-json.ts) · [`backend/src/domus/domain/member_sources.py`](../backend/src/domus/domain/member_sources.py) · [`backend/src/domus/application/member_source_ports.py`](../backend/src/domus/application/member_source_ports.py) · [`backend/src/domus/application/household_roster.py`](../backend/src/domus/application/household_roster.py) · [`backend/src/domus/infrastructure/ha_member_profiles.py`](../backend/src/domus/infrastructure/ha_member_profiles.py) · [`frontend/src/domain/administration-commands.ts`](../frontend/src/domain/administration-commands.ts) · [`frontend/src/domain/administration-journal.ts`](../frontend/src/domain/administration-journal.ts) · [`frontend/src/application/administration-ports.ts`](../frontend/src/application/administration-ports.ts) · [`frontend/src/adapters/administration-journal.ts`](../frontend/src/adapters/administration-journal.ts) · [`frontend/src/domain/administration-views.ts`](../frontend/src/domain/administration-views.ts) · [`frontend/src/adapters/json-values.ts`](../frontend/src/adapters/json-values.ts) · [`frontend/src/adapters/http-transport.ts`](../frontend/src/adapters/http-transport.ts) · [`frontend/src/adapters/administration-values.ts`](../frontend/src/adapters/administration-values.ts) · [`frontend/src/adapters/administration-http.ts`](../frontend/src/adapters/administration-http.ts) · [`frontend/src/application/administration-controller.ts`](../frontend/src/application/administration-controller.ts) · [`frontend/src/domain/administration-authorization.ts`](../frontend/src/domain/administration-authorization.ts) · [`frontend/src/application/household-controller.ts`](../frontend/src/application/household-controller.ts) · [`frontend/src/presentation/domus-administration.ts`](../frontend/src/presentation/domus-administration.ts) · [`frontend/src/presentation/administration-messages.ts`](../frontend/src/presentation/administration-messages.ts) +- 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) · [`tests/backend/test_member_profiles.py`](../tests/backend/test_member_profiles.py) · [`tests/backend/test_member_profile_commands.py`](../tests/backend/test_member_profile_commands.py) · [`tests/backend/test_administration_http.py`](../tests/backend/test_administration_http.py) · [`tests/containers/test_administration_image.py`](../tests/containers/test_administration_image.py) · [`tests/backend/test_openapi.py`](../tests/backend/test_openapi.py) · [`frontend/tests/administration.test.ts`](../frontend/tests/administration.test.ts) · [`tests/backend/test_member_sources.py`](../tests/backend/test_member_sources.py) · [`tests/backend/test_household_roster.py`](../tests/backend/test_household_roster.py) · [`tests/backend/test_ha_member_profiles.py`](../tests/backend/test_ha_member_profiles.py) · [`tests/backend/test_standalone_member_sources.py`](../tests/backend/test_standalone_member_sources.py) · [`frontend/tests/administration-journal.test.ts`](../frontend/tests/administration-journal.test.ts) · [`frontend/tests/administration-session-journal.test.ts`](../frontend/tests/administration-session-journal.test.ts) · [`frontend/tests/entry-int64.test.ts`](../frontend/tests/entry-int64.test.ts) · [`frontend/tests/administration-http.test.ts`](../frontend/tests/administration-http.test.ts) · [`frontend/tests/e2e/entry.spec.ts`](../frontend/tests/e2e/entry.spec.ts) · [`tests/browser/entry_server.py`](../tests/browser/entry_server.py) · [`tests/containers/test_app_ui.py`](../tests/containers/test_app_ui.py) · [`tests/packaging/test_ui_wheel.py`](../tests/packaging/test_ui_wheel.py) · [`frontend/tests/administration-controller.test.ts`](../frontend/tests/administration-controller.test.ts) · [`frontend/tests/administration-authorization.test.ts`](../frontend/tests/administration-authorization.test.ts) · [`frontend/tests/household-controller.test.ts`](../frontend/tests/household-controller.test.ts) · [`frontend/tests/administration-component.test.ts`](../frontend/tests/administration-component.test.ts) · [`frontend/tests/page.test.ts`](../frontend/tests/page.test.ts) · [`frontend/tests/e2e/administration.spec.ts`](../frontend/tests/e2e/administration.spec.ts) +- 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) · [`docs/architecture-household-administration.md`](../docs/architecture-household-administration.md) · [`docs/evidence/household-administration.json`](../docs/evidence/household-administration.json) · [`docs/household-administration.md`](../docs/household-administration.md) · [`docs/evidence/household-administration-browser.json`](../docs/evidence/household-administration-browser.json) · [`docs/household-administration-operations.md`](../docs/household-administration-operations.md) ### `household-tasks` — Task definitions, occurrences and household execution diff --git a/specs/catalog.json b/specs/catalog.json index b22f970..8960d68 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -230,7 +230,8 @@ "specs/household-identity.md", "specs/household-administration.md", "specs/household-administration-integration.md", - "specs/household-administration-workspace.md" + "specs/household-administration-workspace.md", + "specs/household-administration-browser.md" ], "workflows": [], "entrypoints": [ @@ -325,7 +326,10 @@ "frontend/src/adapters/administration-values.ts", "frontend/src/adapters/administration-http.ts", "frontend/src/application/administration-controller.ts", - "frontend/src/domain/administration-authorization.ts" + "frontend/src/domain/administration-authorization.ts", + "frontend/src/application/household-controller.ts", + "frontend/src/presentation/domus-administration.ts", + "frontend/src/presentation/administration-messages.ts" ], "tests": [ "tests/backend/test_identity_policy.py", @@ -402,7 +406,11 @@ "tests/containers/test_app_ui.py", "tests/packaging/test_ui_wheel.py", "frontend/tests/administration-controller.test.ts", - "frontend/tests/administration-authorization.test.ts" + "frontend/tests/administration-authorization.test.ts", + "frontend/tests/household-controller.test.ts", + "frontend/tests/administration-component.test.ts", + "frontend/tests/page.test.ts", + "frontend/tests/e2e/administration.spec.ts" ], "documentation": [ "docs/product-vision.md", @@ -452,12 +460,14 @@ "docs/evidence/m1-review-fixes.json", "docs/architecture-household-administration.md", "docs/evidence/household-administration.json", - "docs/household-administration.md" + "docs/household-administration.md", + "docs/evidence/household-administration-browser.json", + "docs/household-administration-operations.md" ], "readiness": { "state": "draft", - "implementationEvidence": "Partial: protected schema9 APIs, verified HA facts, closed exact-revision HTTP/journal and current-authority controller now bind household/policy facts and receipts to server-owned capability-v2 scope independently of aggregate reads. Valid read-only leases preserve original GET recovery; mutations remain blocked. Browser composition/lifetime, complete EN/ES forms and real administration journeys/acceptance remain outstanding; owner Edge.5 unchanged.", - "blockingDecisions": "No unanswered product decision for issue #25. Complete retained HADM 72/HADI 8 and all 82 HADW scenarios (all original 80 rows unchanged), full browser lifetime/controller composition and EN/ES workspace, fresh review/CI/images, owner adoption and the wider all-domain/Stable release. Checkpoints do not complete them.", + "implementationEvidence": "partial: schema9/profile APIs, bounded HA facts, protected original GET, current-member reconciliation and actual document runtime/journals/lifecycles. Own public alias/read revision now independent of roster permission or availability.4374backend/1129frontend executions with full supported coverage and independent inventories;55actual compiled runtime/PythonSQLite cases. Complete EN/ES forms, administration journeys and retained acceptance remain pending.", + "blockingDecisions": "No unanswered product decision for issue27. Complete retained HADM72/HADI8/HADW82 and HADB24 browser refinements, document lifetime/controller wiring and accessible EN/ES forms, actual administration journeys, human UX review, current-head hosted checks/images, owner adoption and all-domain/Stable release remain required.", "copilotChangesAllowed": false } }, diff --git a/specs/household-administration-browser.md b/specs/household-administration-browser.md new file mode 100644 index 0000000..8815779 --- /dev/null +++ b/specs/household-administration-browser.md @@ -0,0 +1,454 @@ +# Household administration browser completion + +- Status: Draft +- Date: 2026-11-02 +- Catalog capability ID: `household-identity` +- Last verified: not applicable (prospective companion) +- Owners: Domus maintainers; review assignment pending +- Scope: Complete the accessible English/Spanish household-administration browser workspace on the verified schema9 and API/controller foundation. +- Related issues/PRs: [Issue #27](https://github.com/vypdev/domus/issues/27); PR #26 merge `8b1cbc110c389b9cdb6ecd11317c95abf2c4b0a9` +- Required review gates: product UX, architecture, testing, documentation, security/operations +- Open decisions blocking readiness: none; native publication, exact managed branch, implementation, and acceptance evidence remain readiness gates. + +This is a prospective companion specification for the existing `household-identity` capability. It does not replace or modify the contracts in `specs/household-administration.md`, `specs/household-administration-integration.md`, or `specs/household-administration-workspace.md`. Those retain 72 HADM owner cases, eight HADI refinements, and 82 HADW workspace cases (the original 80 and two independent household-scope/lookup cases). This companion adds 24 browser refinements; it claims no retained or new journey is already accepted. + +## 1. Executive summary + +Deliver a complete, responsive and accessible household administration workspace in both Home Assistant Ingress and standalone deployments. The browser must coordinate the existing entry and administration controllers, show private facts only after current identity and access are reconciled, and provide authorized roster, identity, preference, household, member-access and HA-role forms. Commands remain explicit and immutable. When a response is uncertain, a user can recover the original result through the protected original-action GET without resending or replacing the command. + +```text +Verify entry → match current household authority → review permitted facts → confirm one command → inspect its result or explicitly recover that original result +``` + +The principal safety rule is that labels, aliases, HA metadata and historical receipts never grant authority. A retired or mismatched context must not publish private facts or revive them through a late response. + +## 2. Problem, current behavior, and evidence + +### 2.1 Problem + +Household participants and administrators cannot yet complete the retained administration journeys in the browser. The merged foundation provides protected profile APIs, bounded HA metadata and directory, exact int64 codecs, shared HTTP transport, a separate immutable administration journal, pure authorization and an application `AdministrationController`. The compiled entry UI remains entry-only; the administration controller is not composed into its browser lifetime. Full administration forms and actual administration browser journeys are absent. + +### 2.2 Current behavior + +1. PR #26 merged the schema9/API/controller foundation at `8b1cbc110c389b9cdb6ecd11317c95abf2c4b0a9`. +2. Reviewed head `b60b7677c07faf62c534943c09748b70ad4a6408` has the same tree, `9386f2ea5249b5ea0c0bb4e83f814f503012be47`. +3. Entry and administration lifetimes are not yet composed in the browser; the compiled UI is unchanged and complete administration browser journeys are missing. +4. The checkpoint passed its stated backend campaign, Chromium unit executions, entry/Python/SQLite and Storybook suites, wheel acceptance, types, architecture checks, CPU image checks, and native review. These results accept only that checkpoint and do not establish this feature's acceptance. + +### 2.3 Evidence + +- Code and catalog evidence at the merged base: `frontend/src/application/administration-controller.ts`, the administration domain/adapters and backend administration entrypoints listed by the `household-identity` capability in `specs/catalog.json`. +- Existing owning specifications: `specs/household-identity.md`, `specs/household-administration.md`, `specs/household-administration-integration.md`, `specs/household-administration-workspace.md`. +- Concrete gap: issue #27 records that the controller is not imported into browser composition and complete forms and administration browser journeys are absent. +- Historical progress-publication failure evidence is recorded in issue #27 and linked Copilot issue #828; it is not a product acceptance result and does not authorize changes to Copilot or its checks. +- Unknown: safe avatar-source feasibility. If no supported source exists, the UI must show an honest unavailable state. + +### 2.4 Retrospective classification + +Not applicable: this prospective companion does not reconstruct an implemented browser baseline. The checkpoint facts above are evidence for the starting point, not claims of completed browser behavior. + +## 3. Actors, surfaces, and terminology + +| Actor | Goal | Entry point | Visible surfaces | +|---|---|---|---| +| Household participant | Review own identity and preferences; perform permitted actions | Verified Domus browser entry | Roster, own identity/preferences, permitted household and member views | +| Current household administrator | Manage authorized household, member access and policy | Verified Domus browser entry | Administration forms, current permissions, confirmations, result and recovery states | +| Operator | Diagnose deployment and package behavior | Ingress or standalone installation | Setup, operations, compatibility and recovery documentation | + +A **verified context** binds installation, channel, HA subject and current household member. **Current authority** is the current access mode and grants, not a name or prior receipt. An **original action** is an immutable submitted command with its original key, ID, payload and revision. **Outage lease** means the existing valid read-only lease, until its original expiry. A journal records bounded recovery facts; it grants no access. + +## 4. Goals + +| Requirement | Behavior | +|---|---| +| HADB-01 | Coordinate entry and administration proof and resource lifetimes; reconcile identity and current access before publishing private administration views; fence retired contexts and late replies. | +| HADB-02 | Preserve explicit immutable commands, exact independent revisions, safe same-context drafts, the separate bounded journal and protected original-action GET recovery. | +| HADB-03 | Deliver the retained accessible EN/ES roster, identity/provenance, own alias/preferences, household/member and HA-role forms with honest permission and provider states. | +| HADB-04 | Verify real compiled-browser, Python/SQLite, Storybook and supported package journeys; audit retained contracts and provide user/operator/contributor documentation and required human UX evidence. | + +### 4.1 Goals + +The measurable outcomes are those in the requirement table. Every retained owning case and every companion case remains required at its actual scope. + +### 4.2 Non-goals + +1. Changes to Copilot, the integrated gateway, its resources, configuration, workflows, credentials or bounds. +2. New schema, public action, arbitrary external avatar URL, person-based authentication, notification delivery, automatic mutation retry, rebase or replacement. +3. Acceptance of recurrence/calendar continuation #23, owner HA rollout, release, tag, Stable/Edge promotion, or full product completion. +4. Treating historical Edge.5 entry evidence as current administration evidence. + +### 4.3 Fixed product/safety invariants + +1. Keep verified HA account per participant; do not accept caller-selected identity, installation or channel. +2. HA names are shown by default; alias is optional and editable by self or current administrators. Preferences are owner-private. Household profiles, member exceptions and HA-role mapping retain existing configuration and authorization contracts. +3. Reauthorize at operation time. Original-action authorization precedes receipt disclosure. Receipts describe processing, not current authority or effects. +4. Original recovery is a protected key/ID GET. It does not require aggregate-read permission or new mutation CSRF/revision preconditions. A valid read-only outage lease permits original GET until unchanged expiry and blocks mutations. +5. No automatic lookup, replay, resend, rebase or unresolved-original replacement. Never combine old and new contexts. + +## 5. Current versus proposed product journey + +| Stage | Current | Proposed | User/operator effect | +|---|---|---|---| +| Entry and proof | Entry UI exists; administration controller is not composed | One browser lifetime coordinates both controllers and reconciles verified installation/channel/subject and current access | No private administration appears during mismatch or revalidation | +| Review | Administration forms/journeys are absent | Localized roster, identity/provenance, preferences, household, access and role forms | Users see permitted facts and exact current authority | +| Command | API/controller foundation exists | Explicit confirmation with independent exact revision; original intent and same-context draft retained | No silent replay, rebase or replacement | +| Uncertain result | Separate bounded administration journal exists | User explicitly requests protected original GET, including eligible outage lease | Recovery does not create another mutation | +| Delivery evidence | Checkpoint evidence covers foundations | Real compiled-browser/Python/SQLite and package evidence plus human UX review | Acceptance reflects current implementation, not checkpoint counts | + +```mermaid +flowchart LR + A[Verified browser entry] --> B[Reconcile identity and current access] + B --> C[Show permitted facts and forms] + C --> D[Explicitly confirm immutable command] + D --> E[Show result or offer original GET recovery] +``` + +Text equivalent: verified entry → current matched authority → permitted form → one confirmed command → result or deliberate recovery of that command. + +## 6. Functional behavior and state model + +### 6.1 Happy path + +1. Composition owns entry and administration controllers and starts bounded verification. +2. The coordinator matches verified installation, channel, subject, access mode/grants and available household scope before publishing private views. +3. The user reviews facts and edits a form under its own exact revision. Current permissions determine available actions. +4. The user confirms the displayed impact. The application submits one immutable command and records it in the separate bounded administration journal. +5. The view reports the original result and current facts separately. An uncertain result offers deliberate original GET recovery. + +### 6.2 Alternative paths + +- Optional aggregate unavailability does not erase independently verified authority. Recheck current grants; keep independently permitted facts and original GET available where authorized. +- Own alias editing uses an independently authorized own-presentation read and exact revision. A current owner with `preferences.read` can read their public alias without `members.read`; public reads of other members still require `members.read`. A failed or unavailable roster is not an implicit own-alias prerequisite. This does not grant private preference access to another member or infer a missing HA name. +- Authentication failure retires access. A read-only outage lease retains permitted local reads, including protected original GET, until its existing expiry; new mutations remain blocked. +- A stale revision or storage failure preserves a safe same-context draft and original revision. The user may explicitly reset to adopt a new revision. Draft ownership includes the verified household routing ID as well as installation, channel, HA subject and member identity. A different or missing verified household retires private drafts and detached confirmation handlers, even when member IDs and aggregate revisions coincide. Pending verification with no published view can retain a same-owner draft until fresh matching proof returns. +- HA directory/avatar unavailability is not empty success. If no supported avatar source is feasible, show unavailable without inventing one. +- Language change translates the current view and errors without sending a mutation. Saved locale takes precedence over browser locale; otherwise use browser locale with English fallback. + +### 6.3 State machine + +| State | Entered when | User-visible meaning | Allowed next states | Recovery/owner | +|---|---|---|---|---| +| Verifying | Entry, refresh or restored page requires proof | Checking access; no changes sent | Ready, limited, blocked | Coordinator; bounded reconciliation | +| Ready | Matched current proof and permitted scope | Permitted household facts and actions are available | Pending, limited, verifying | Application/controller | +| Pending | One explicit command is in flight | Original request is being processed | Complete, uncertain, blocked | Journal and original command owner | +| Uncertain | Outcome cannot be established | Check the original request; do not replace it | Complete, unavailable | User invokes protected original GET | +| Limited | Optional facts unavailable or read-only lease applies | Some permitted facts remain; writes may be unavailable | Ready, blocked, verifying | Refresh or wait for unchanged lease expiry | +| Blocked | Denied, expired, mismatched or invalid state | Changes were not saved; refresh or correct the stated issue | Verifying, ready | Current authority owner | +| Complete | Original result is known | Result and current facts are clearly distinguished | Ready, verifying | User may continue with a new explicit action | + +Commands serialize. Duplicate, stale or out-of-order events cannot publish across a replaced context. Cancellation and teardown fence publication but do not claim to undo server effects. Ticks never renew a lease or automatically send/look up. BFcache restoration requires fresh proof. Persistent drift after at most one bounded reconciliation remains hidden and offers refresh without looping or writing. + +## 7. User-facing configuration + +| Input | Type | Recommended default | Allowed values/range | Scope/persistence | +|---|---|---|---|---| +| Display name | HA-provided text | HA name | Bounded provider value; render as text | Provider fact; not authority | +| Alias | Optional text | Null | Existing owner validation and limits | Public member presentation; self/current administrator edits | +| Locale | Locale choice | Browser locale; English fallback | English or Spanish; unsupported values fall back | Saved owner locale wins; unsaved language switch is temporary | +| Routine-summary preference | Existing preference value | Enabled | Existing owner contract values | Owner-private; only owner can view/edit | +| Household profiles, exceptions, HA-role mapping | Existing policy values | Existing configured values | Existing owner contracts and closed formats | Household scope; exact independent revisions | +| Journal | Existing bounded journal | Existing v1 policy | Eight records, 65,536 UTF-8 bytes, one hour | Separate from entry journal; existing expiry | + +Validate every field and cross-field policy before confirmation; preserve untouched fields. Missing/unknown values follow the existing owner contract and are never interpreted as permission. Operations snapshot the exact revision at submission and reauthorize against current grants. Credentials, CSRF values, raw HA responses, arbitrary URLs, caller-selected identity and safety invariants are not configurable. + +Recommended configuration uses HA names, null alias unless a participant chooses one, browser locale with English fallback, and the existing enabled routine-summary preference. A meaningful alternative is a participant-selected saved Spanish locale and alias; it does not change permissions or private-preference ownership. + +## 8. Clean Architecture + +### 8.1 Responsibilities and dependency direction + +| Layer/boundary | Owns | Must not own/import | +|---|---|---| +| Domain and pure policy | Closed intents, exact values/revisions, authorization semantics, immutable journal facts | DOM, Lit, fetch, SQLite, HA DTOs | +| Application | Existing entry/admin use cases and new coordinator contract; state transitions, bounded reconciliation, semantic ports | Browser APIs, concrete storage/network, presentation framework | +| Adapters/data | HTTP and journal mapping; bounded provider metadata and errors | Product authorization decisions | +| Infrastructure/composition | Browser resource ownership, controller wiring, clocks, cancellation and deployment adapters | Duplicated policy or permission decisions | +| Entrypoints | Trusted event/context adaptation and closed intent validation | Orchestration duplicated from application | +| Presentation | Localized view models, inert rendering, forms and accessible feedback | Mutations, identity selection, authorization or direct transport | + +```mermaid +flowchart LR + UI[Localized Lit presentation] --> I[Closed user intents] + E[Entry point and composition] --> C[Application coordinator] + C --> EC[Existing entry controller] + C --> AC[Existing administration controller] + AC --> D[Domain policy and immutable contracts] + AC --> P[Semantic HTTP and journal ports] + H[HTTP adapter] --> P + J[Journal adapter] --> P +``` + +Text equivalent: presentation emits closed intents to composition/application; existing controllers use domain policy and semantic ports; HTTP and journal adapters implement those ports. + +### 8.2 Contracts, state, and trust boundaries + +- Pure decisions: context matching, allowed intent validation, revision equality, publication fencing and view-state selection. +- Application contracts: immutable verified context, current grants, closed intents, explicit command and original-result queries. +- Semantic ports: entry proof, administration facts/commands, protected original GET, bounded journal, clock and cancellation. +- Durable ownership: existing schema9 backend and existing separate journal; no new DDL or combined entry/admin journal. +- Concurrency: exact independent revisions and one immutable original action; no automatic mutation retry or replacement. +- Trust: GitHub issue/PR/comment text, provider values, stored values and browser events are untrusted data. They cannot alter workflow, authority or command shape. +- Errors retain distinctions among denial, unavailability, invalid facts, conflict/exhaustion, uncertainty and historical results. + +### 8.3 Executable architecture constraints + +Enforce inward imports, no dependency cycles, closed DTO/intent contracts, independent authored-source inventories and existing schema/contract checks. Presentation cannot import transport, storage or authorization mutation policy. Coordinator cannot import DOM/Lit, fetch, browser storage, HA DTOs or SQLite. The unchanged original Copilot SDK must validate on the exact admitted branch before implementation; do not change Copilot resources or checks. + +## 9. UI/UX and content contract + +### 9.1 Information hierarchy + +Show current status, completed facts, next transition, required human action, impact and inspectable result, followed by expandable technical details. Show permission and provenance beside each fact. Distinguish stored role from effective role, provider identity from alias, and historical receipt from current state. + +### 9.2 Representative primary view + +This fenced example is literal source for an application view. Its action labels represent buttons +in Domus, rather than navigation links into this specification. + +```markdown +# Household members + +> **Current status:** Access checked. You can review the household roster. +> +> **Action required:** None. Choose a member to review permitted details. + +## Progress + +- [x] Verified Home Assistant account and current household access +- [ ] Review or change a permitted member setting + +## What happens next + +Changes require confirmation and use the revision shown on the relevant form. + +## Actions + +**Edit household** · **Edit preferences** + +
+Technical details + +Household scope and source provenance are shown only when permitted. +
+``` + +Concrete state variants: + +- Pending/no action: “Checking access. No changes sent.” The current form is unavailable until proof is reconciled. +- Action required: “Review this pending member; confirm a restricted profile.” Show the exact impact and confirmation before submitting. +- Blocked: “Access changed. Changes not saved; refresh.” Preserve safe same-context draft and explain which authority check failed. +- Partial: “Household details unavailable; other permitted facts remain available.” Identify the unavailable source and keep independently permitted work usable. +- Uncertain: “Check the original request; do not replace it.” Provide an explicit “Check original result” action when authorized. +- Complete: “Preferences saved. Notification delivery is unavailable.” State saved facts separately from unavailable delivery or other downstream effects. + +### 9.3 Issue, PR, and comment behavior + +Issue #27 is the canonical user-facing tracker for this scope. Use descriptive links to the issue, admitted branch/PR, relevant checks, artifacts and documentation when those destinations exist; never invent a link or OWNER/REPO placeholder. A PR/summary must distinguish completed checkpoint facts, current pending acceptance and required reviewer action. Updates are deterministic and idempotent; do not create progress comments or notifications for each test/case. A single current status and action lead; detailed evidence is disclosed below. Merge, close and retry semantics follow the repository Action and its admitted branch; this companion authorizes no external publication or release. + +### 9.4 Accessibility, localization, and responsive behavior + +Support English and Spanish with saved-locale precedence and English fallback. Provide text labels in addition to icons, semantic headings and form labels, keyboard operation, visible focus, focus restoration after errors/refresh, and live announcements for state changes. At 360px, forms and roster remain usable without horizontal page scrolling. Verify both themes and reduced motion. Render hostile names as inert text; never interpret them as Markdown, HTML, mentions, commands or links. Diagrams have the textual equivalent given in section 5 and 8. + +## 10. Failure, recovery, and cleanup + +| Failure/partial state | User impact | Retained facts | Automatic retry | Required action | Cleanup | +|---|---|---|---|---|---| +| Identity/access mismatch or expiry | Private administration hidden | Safe same-context draft only | One bounded reconciliation; no write | Refresh/reverify | Fence both controllers and late replies | +| Optional household aggregate unavailable | Some facts unavailable | Independently verified authority and permitted facts | No command replay | Retry view explicitly when available | Retain separate original journal | +| Permission denied | Requested action unavailable | Only facts still permitted | No | Explain current permission and next allowed action | Do not disclose protected receipt/facts | +| Stale revision | Form not saved | Draft and original revision | No | Review current state; explicitly reset if desired | Do not rebase or replace unresolved original | +| Storage failure before send | Command not safely recorded | Safe draft; no claim of send | No | Retry only after explicit review | Keep journal bounded | +| Lost response after send | Effect may have occurred | Original immutable intent/key/ID | No lookup or resend automatically | Explicit protected original GET | Preserve record until existing expiry | +| Valid read-only outage lease | Writes unavailable | Current permitted facts and original recovery | No | Use original GET if needed; wait/refresh | Expire at original unchanged time | +| HA directory/avatar unavailable | Identity metadata incomplete | Other permitted facts | No fabricated success | Show unavailable; continue independently | No arbitrary source or URL | +| BFcache/disposal/scheduler failure | View may be stale | Journal facts subject to current authorization | No | Fresh proof on restoration | Cancel/fence owned reads; never claim server undo | + +Errors follow impact → cause → action → retained state. Historical receipts must not imply a current effect or that an already successful irreversible action failed. Original-action authorization and owner-private guards apply before receipt disclosure, including callers without aggregate-read permission. A mutation's CSRF/revision preconditions are not added to a read-only original-result query. + +## 11. Security, permissions, and privacy + +1. Publish private administration only after matched verified context and current access; reauthorize each operation. +2. The backend remains the authority. No browser-supplied actor, context, URL, method, revision or label can grant access or expand a closed command. +3. Owner-private preferences and results never leak to another member through views, receipts, audit, events or logs. A relink to a new member cannot expose the old member's private result. +4. Render provider and issue content inertly. Do not log secrets, CSRF, raw HA payloads or private facts. Bound journal records and metadata; avatar metadata is distinct from authorization and is obtained outside SQLite write locks. +5. Retired context, logout, expiry, cancellation, refresh and BFcache fence both controllers and every late response. Fencing publication does not claim to undo a server-side effect. + +## 12. Observability and operational UX + +- User status: clear current access, permitted source facts, action state, original result and explicit recovery action. +- Operator evidence: bounded authorized status/IDs, exact source/package/check identities and sanitized diagnostics. +- Correlation: preserve original action key/ID across reload without logging private payloads. +- External dependency: distinguish HA/provider unavailability from authorization denial and application failure. +- Retry/noise: no automatic original lookup or mutation retry; no per-case comments or notifications. +- Progress-publication failure from the separate Copilot service remains separate from product CI and must not prompt weakening checks. + +## 13. Compatibility, migration, rollout, and rollback + +Preserve schema9, genuine schema1–8 migration/history, existing journal and intent formats, and historical Edge.5 evidence identities. Add no schema migration. Verify current wheel and both CPU image UI bytes and compatible rollback. Publication of this SDD is not implementation readiness by itself; no automatic owner rollout, release, tag or promotion follows. The Action owns admission, SDD publication, exact base selection and remote managed branch. Start from the actual PR #26 merge on develop, not bootstrap master. Do not create a contributor replacement ref. Declare further new paths before implementation. + +## 14. Testing strategy + +Minimum new companion inventory only; all HADM 72, HADI eight and HADW 82 retained cases remain separately mandatory. The 24 rows below are distinct executable scenarios. Retained cases are not recounted as newly completed journeys. + +| Area | Minimum distinct cases | Behaviors/risks covered | +|---|---:|---| +| State/application | 8 | Observable scenarios in the case inventory. | +| Application | 4 | Observable scenarios in the case inventory. | +| Security | 4 | Observable scenarios in the case inventory. | +| Presentation/UX | 4 | Observable scenarios in the case inventory. | +| Integration/compatibility | 4 | Observable scenarios in the case inventory. | +| Total | 24 | Distinct inventory cases; repeated executions do not add cases. | + +| Case ID | Area | Requirements | Scenario | +|---|---|---|---| +| household-browser-C01 | State/application | HADB-01 | Matching active entry/admin proof publishes one joint view without writes. | +| household-browser-C02 | State/application | HADB-01 | Different verified contexts never expose combined private facts; reconciliation occurs at most once. | +| household-browser-C03 | State/application | HADB-01 | Grant removal during collection retires prior private facts before publication. | +| household-browser-C04 | State/application | HADB-01 | Expiry during collection hides both views; timers and late replies cannot revive access. | +| household-browser-C05 | State/application | HADB-01 | Stale logout during either command preserves timers and original record; later logout closes both lifetimes. | +| household-browser-C06 | State/application | HADB-01 | BFcache during send fences the late reply; restoration requires fresh proof and does not resend. | +| household-browser-C07 | State/application | HADB-01 | Disposal or scheduler failure cancels owned reads and fences late publication. | +| household-browser-C08 | State/application | HADB-01 | Persistent drift after one reconciliation stays hidden without loops or writes. | +| household-browser-C09 | Application | HADB-02 | Optional household facts unavailable still permits independently authorized original GET. | +| household-browser-C10 | Application | HADB-02 | Original-action/operations grants without aggregate-read permit protected original GET. | +| household-browser-C11 | Application | HADB-02 | Lost reply followed by reload and recovery retains exact intent and sends no second mutation. | +| household-browser-C12 | Application | HADB-02 | Stale revision or storage failure preserves safe draft; explicit reset does not replace unresolved work. | +| household-browser-C13 | Security | HADB-01, HADB-02 | Same HA subject relinked to a new member cannot see the previous member's private result. | +| household-browser-C14 | Security | HADB-02 | Valid read-only lease permits original GET, blocks writes and expires both views at unchanged expiry. | +| household-browser-C15 | Security | HADB-03 | Hostile names render as text; forged principal or extra-field intent sends no command. | +| household-browser-C16 | Security | HADB-03 | Administrator sees only permitted public member facts and no private/protected editors. | +| household-browser-C17 | Presentation/UX | HADB-03 | EN/ES switch preserves draft/revision, translates errors and sends no mutation. | +| household-browser-C18 | Presentation/UX | HADB-03 | Saved locale beats different browser locale after reload; unsupported choice falls back to English. | +| household-browser-C19 | Presentation/UX | HADB-03 | Keyboard-only activation, errors and recovery retain labels, focus and live status at 360px in both themes. | +| household-browser-C20 | Presentation/UX | HADB-03 | Denied/unavailable directory or avatar stays honest; other edits preserve untouched policy. | +| household-browser-C21 | Integration/compatibility | HADB-04 | Actual compiled UI exercises permitted actions and recovery in both Python/SQLite compositions. | +| household-browser-C22 | Integration/compatibility | HADB-04 | Independent-writer race and lost reply yield one original effect and truthful reload recovery. | +| household-browser-C23 | Integration/compatibility | HADB-04 | Actual wheel and CPU images contain current UI bytes; schema9 and historical evidence remain compatible. | +| household-browser-C24 | Integration/compatibility | HADB-04 | Audit retained cases and human UX against scoped evidence; unit counts cannot close missing journeys. | + +Tests first. Preserve repository-wide supported coverage, complete global and per-authored-module coverage, all four frontend metrics and backend statements/branches. Use independent inventories, deterministic clocks/fakes, no real waits or live services, actual compiled browser and Python/SQLite journeys, Storybook state coverage, type/architecture/style/docs checks and every-PR CI Check. Human UX evidence must record actual reviewer and artifact for identity/provenance, activation and historical/uncertain recovery; simulated approval is not evidence. + +## 15. Documentation and discoverability + +| Path | Change | +|---|---| +| `specs/household-administration-browser.md` | create | +| `specs/household-administration-integration.md` | reference | +| `specs/household-administration.md` | reference | +| `specs/household-administration-workspace.md` | reference | +| `specs/household-identity.md` | reference | +| `specs/delivery-quality.md` | reference | +| `specs/catalog.json` | update | +| `specs/CATALOG.md` | update | +| `frontend/src/application/household-controller.ts` | create | +| `frontend/src/application/entry-controller.ts` | update | +| `frontend/src/application/administration-controller.ts` | update | +| `backend/src/domus/application/household_administration_queries.py` | update | +| `tests/backend/test_member_profile_commands.py` | update | +| `tests/backend/test_administration_http.py` | update | +| `frontend/src/composition/entry-runtime.ts` | update | +| `frontend/src/composition/entry-page.ts` | update | +| `frontend/src/presentation/domus-app.ts` | update | +| `frontend/src/presentation/domus-administration.ts` | create | +| `frontend/src/presentation/administration-messages.ts` | create | +| `frontend/src/presentation/messages.ts` | update | +| `frontend/src/presentation/styles.ts` | update | +| `frontend/vitest.config.ts` | update | +| `frontend/playwright.entry.config.ts` | update | +| `frontend/tests/e2e/catalog.spec.ts` | update | +| `frontend/tests/household-controller.test.ts` | create | +| `frontend/tests/controller.test.ts` | update | +| `frontend/tests/runtime.test.ts` | update | +| `frontend/tests/page.test.ts` | update | +| `frontend/tests/component.test.ts` | update | +| `frontend/tests/administration-component.test.ts` | create | +| `tests/python/test_ci_contract.py` | update | +| `frontend/tests/administration-controller.test.ts` | update | +| `frontend/tests/fixtures/entry-wire.json` | update | +| `frontend/tests/e2e/administration.spec.ts` | create | +| `frontend/stories/administration.stories.ts` | create | +| `frontend/stories/entry.stories.ts` | update | +| `tests/browser/entry_server.py` | update | +| `tests/browser/image_bridge.py` | create | +| `tests/browser/image_channel.py` | create | +| `tests/python/test_image_channel.py` | create | +| `tests/python/test_browser_image_contract.py` | create | +| `.github/workflows/ci_check.yml` | update | +| `tests/packaging/test_ui_wheel.py` | update | +| `tests/containers/test_administration_image.py` | update | +| `tests/containers/administration_rollback_fixture.py` | create | +| `tests/fixtures/schema9-app/Dockerfile` | create | +| `addon-edge/config.yaml` | update | +| `addon-edge/Dockerfile` | update | +| `addon-edge/artifact.json` | create | +| `addon-edge/README.md` | update | +| `addon-edge/DOCS.md` | update | +| `addon-edge/CHANGELOG.md` | update | +| `tests/fixtures/edge5-app/Dockerfile` | create | +| `tests/containers/test_edge_preview_image.py` | create | +| `tests/containers/test_app_ui.py` | update | +| `tests/backend/test_app_package.py` | update | +| `tests/backend/test_member_profiles.py` | update | +| `frontend/tests/administration.test.ts` | update | +| `docs/installation-home-assistant.md` | update | +| `docs/household-administration.md` | update | +| `docs/household-api.md` | update | +| `docs/household-administration-operations.md` | create | +| `docs/evidence/household-administration.json` | update | +| `docs/evidence/household-administration-browser.json` | create | + +Document the normal user journey, locale/configuration precedence, current permission and provenance, provider availability, explicit original recovery, operator setup/rollback and lifecycle/architecture constraints. Update navigation, catalog and artifact evidence together. Preserve historical evidence identities; do not rebind them to new UI bytes. User documentation covers the recommended default and alternative; setup documentation covers permissions and deployment; operator documentation covers states, troubleshooting, recovery, cleanup and rollback; contributor documentation covers contracts and dependency checks. Validate links, examples and catalog with repository specification validation and contract fixtures. + +## 16. Acceptance + +1. Given matching current entry/admin proof, when the browser loads, then only facts permitted by current grants are published and no command is sent. +2. Given different verified contexts, when one controller responds late, then no combined or retired private view appears. +3. Given an explicit form confirmation at an exact revision, when the command succeeds, then one immutable command is recorded and the result is distinct from current state. +4. Given a lost response, when the user explicitly checks the original result, then the protected original GET is used and no mutation is resent. +5. Given optional aggregate unavailability and sufficient original-action authorization, when the user requests original recovery, then recovery remains available without aggregate-read or mutation-only preconditions. +6. Given a valid read-only outage lease, when original GET is requested, then it is allowed until the original expiry while all mutations remain blocked. +7. Given changed permissions, stale revision, storage failure or HA unavailability, when the user returns to the form, then the UI gives an actionable truthful state and preserves only safe same-context draft data. +8. Given hostile provider text or forged intent fields, when rendered or submitted, then text remains inert and no unauthorized command is sent. +9. Given EN/ES language changes, narrow viewport, keyboard-only use and either theme, when users complete or correct a form, then labels, focus, announcements, layout and translated errors remain usable. +10. Given either supported deployment, when the actual compiled browser journey runs against Python/SQLite and packaged artifacts, then the scoped administration behavior and compatibility evidence are recorded. +11. Given the retained HADM/HADI/HADW contracts, when acceptance is audited, then all their cases remain required and are evaluated against actual evidence; checkpoint coverage alone does not close them. +12. Given the proposed SDD and catalog updates, when repository validation runs on the exact admitted branch, then the published companion, reciprocal case mapping, paths and original SDK checks pass without changing Copilot resources. + +## 17. Requirements traceability + +| Requirement | Cases | +|---|---| +| HADB-01 | household-browser-C01, household-browser-C02, household-browser-C03, household-browser-C04, household-browser-C05, household-browser-C06, household-browser-C07, household-browser-C08, household-browser-C13 | +| HADB-02 | household-browser-C09, household-browser-C10, household-browser-C11, household-browser-C12, household-browser-C13, household-browser-C14 | +| HADB-03 | household-browser-C15, household-browser-C16, household-browser-C17, household-browser-C18, household-browser-C19, household-browser-C20 | +| HADB-04 | household-browser-C21, household-browser-C22, household-browser-C23, household-browser-C24 | + +The companion cases supplement, and do not alter, mappings in the owning specifications. Every normative companion requirement is linked to its executable inventory cases; scoped human UX evidence and retained-owner acceptance remain additional gates. + +## 18. Implementation sequence + +1. On the Action-admitted branch, verify the actual PR #26 merged base; validate and natively publish this bounded companion. Run unchanged original SDK checks on that exact branch. +2. Add tests for partial reads, joint lifecycle, cancellation, expiry and late-response fencing; then implement coordinator and compose both controller lifetimes. +3. Complete closed intents and localized forms with tests; deliver Storybook, actual browser/Python/SQLite journeys and supported CPU package checks. +4. Audit every retained and new case at its actual scope, complete human UX review, full coverage and current-head review, update truthful docs/evidence, and verify compatible rollback. + +## 19. Definition of Done + +- [ ] All 24 new cases and every retained HADM, HADI and HADW obligation have evidence at the actual required scope; none is inferred from checkpoint results. +- [ ] Complete forms, current-authority checks, private-result boundaries, exact revisions, immutable originals and lifecycle fences are implemented. +- [ ] Supported global/per-authored-module coverage and required browser, Python/SQLite, Storybook, package, type, architecture and every-PR CI Check pass. +- [ ] EN/ES, keyboard/focus/live announcements, responsive layouts, privacy and required human UX review are complete. +- [ ] User, setup, operator and contributor documentation, navigation, catalog and artifact evidence are current and validated. +- [ ] Failure, partial success, explicit recovery, compatibility, cleanup and rollback meet this contract. +- [ ] No open readiness decision remains; native publication and exact managed branch gates are satisfied. +- [ ] No Copilot/gateway modification, invented remote ref, automatic promotion or fabricated product completion occurs. + +## 20. References and decisions + +- Primary source: canonical [Issue #27](https://github.com/vypdev/domus/issues/27), including maintainer-approved companion recovery and supplied bounded proposal. +- Existing owners: `specs/household-identity.md`, `specs/household-administration.md`, `specs/household-administration-integration.md`, `specs/household-administration-workspace.md`. +- Catalog evidence: `specs/catalog.json` at merged base `8b1cbc110c389b9cdb6ecd11317c95abf2c4b0a9`. +- Related checkpoint: PR #26 merge `8b1cbc110c389b9cdb6ecd11317c95abf2c4b0a9`; reviewed head `b60b7677c07faf62c534943c09748b70ad4a6408`, identical tree `9386f2ea5249b5ea0c0bb4e83f814f503012be47`. +- Related independent publication report: `https://github.com/vypdev/copilot/issues/828#issuecomment-6583615211` and `https://github.com/vypdev/copilot/issues/763#issuecomment-6588275756`; these reports do not establish product acceptance or authorize changing Copilot. +- Decision: use the approved new companion path `specs/household-administration-browser.md` under existing `household-identity`. Do not update/replace the three owning administration documents or reuse the failed update route. +- Follow-up outside this companion: recurrence/calendar continuation #23 remains required after schema9; whole administration, owner adoption and all-domain release remain unaccepted until their complete evidence exists. diff --git a/tests/backend/test_administration_http.py b/tests/backend/test_administration_http.py index f53b444..e79040a 100644 --- a/tests/backend/test_administration_http.py +++ b/tests/backend/test_administration_http.py @@ -56,6 +56,35 @@ def test_actual_compositions_bind_profile_authority_without_a_second_login(api): ) +@pytest.mark.parametrize("own_read", [True, False]) +def test_own_alias_read_without_roster_authority_is_scoped_in_both_actual_deployments( + api, own_read +): + member_id = api.client.get(BASE + "/preferences").json()["member_id"] + with api.store.write() as unit: + member = unit.members.get("home", member_id) + unit.members.save( + replace( + member, + revision=member.revision + 1, + exceptions=( + PermissionRule(Permission.MEMBERS_READ, False), + PermissionRule(Permission.PREFERENCES_READ, own_read), + ), + ), + member.revision, + ) + assert api.client.get(BASE + "/roster").status_code == 403 + before = api.store.path.read_bytes() + response = api.client.get(BASE + f"/members/{member_id}/presentation") + assert response.status_code == (200 if own_read else 403), response.text + if own_read: + assert response.json() == {"member_id": member_id, "alias": None, "revision": 1} + assert response.headers["cache-control"] == "no-store" + assert api.client.get(BASE + "/members/not-an-owned-member/presentation").status_code == 403 + assert api.store.path.read_bytes() == before + + @pytest.mark.parametrize("mode", ["native", "standalone"]) def test_published_int64_revision_schema_never_rounds_its_maximum(tmp_path, monkeypatch, mode): with configured(tmp_path, monkeypatch, mode) as (client, _, _, _, _): diff --git a/tests/backend/test_app_package.py b/tests/backend/test_app_package.py index 91d5d52..479b930 100644 --- a/tests/backend/test_app_package.py +++ b/tests/backend/test_app_package.py @@ -15,12 +15,17 @@ def test_repository_and_edge_manifest_offer_native_ha_entry_without_user_credent 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.5" + assert manifest["boot"] == "manual" and manifest["version"] == "0.0.0-edge.7" 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", "0.0.0-edge.4"] + assert manifest["breaking_versions"] == [ + "0.0.0-edge.2", + "0.0.0-edge.3", + "0.0.0-edge.4", + "0.0.0-edge.6", + ] assert set(manifest["arch"]) == {"amd64", "aarch64"} @@ -47,18 +52,16 @@ def test_build_pins_source_checksum_bases_lock_and_nonroot_native_entrypoint() - 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] == "271a3a07feb1535f0a5ec8b55abb3de9aac049a9" + assert commits[0] == "3200bd9278f57f142ed03b54843a38a4138fc0af" assert ( - "DOMUS_SOURCE_SHA256=5cc9a963b6f064aa48be256a9d5a4274f8c52c878e4ad7f84517cd33f41056c8" + "DOMUS_SOURCE_SHA256=ca991d7b3560dbdb7a6a86a34eea19708f01f10c15da502e49ed8ab740a8533b" in source ) - assert "ARG BUILD_VERSION=0.0.0-edge.5" in source - candidate = json.loads((ROOT / "docs/evidence/household-entry-interface.json").read_text())[ - "imageCandidate" - ] + assert "ARG BUILD_VERSION=0.0.0-edge.7" in source + candidate = json.loads((ROOT / "addon-edge/artifact.json").read_text()) assert candidate["sourceCommit"] == commits[0] - assert candidate["version"] == "0.0.0-edge.5" - assert "DOMUS_SOURCE_SHA256=" + candidate["sourceSHA256"] in source + assert candidate["version"] == "0.0.0-edge.7" + assert "DOMUS_SOURCE_SHA256=" + candidate["sourceArchiveSHA256"] 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 @@ -90,3 +93,57 @@ def test_native_options_explain_the_one_time_origin_configuration_without_a_new_ 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"] + + +def test_edge_five_compatibility_recipe_is_frozen_and_not_a_discoverable_second_app() -> None: + folder = ROOT / "tests/fixtures/edge5-app" + recipe = (folder / "Dockerfile").read_bytes() + assert ( + hashlib.sha256(recipe).hexdigest() + == "aea2d7bc4d135e49827508f011bc1fadbd5f0d3f93b47538e07ed56652b5e28d" + ) + old = json.loads((ROOT / "docs/evidence/household-entry-interface.json").read_text())[ + "imageCandidate" + ] + assert old["version"] == "0.0.0-edge.5" + assert ("ARG DOMUS_SOURCE_COMMIT=" + old["sourceCommit"]).encode() in recipe + assert ("ARG DOMUS_SOURCE_SHA256=" + old["sourceSHA256"]).encode() in recipe + assert b"ARG BUILD_VERSION=0.0.0-edge.5" in recipe + assert not any( + (folder / name).exists() for name in ["config.yaml", "config.yml", "config.json"] + ) + + +def test_edge_artifact_declares_exact_reviewed_runtime_without_rebinding_historical_evidence() -> ( + None +): + artifact = json.loads((ROOT / "addon-edge/artifact.json").read_text()) + assert set(artifact) == { + "schemaVersion", + "version", + "sourceCommit", + "sourceArchiveSHA256", + "storageSchema", + "wheelSHA256", + "pythonFiles", + "uiFiles", + } + assert artifact["schemaVersion"] == 1 and artifact["version"] == "0.0.0-edge.7" + assert artifact["sourceCommit"] == "3200bd9278f57f142ed03b54843a38a4138fc0af" + assert artifact["storageSchema"] == 9 + for field in ["sourceArchiveSHA256", "wheelSHA256"]: + assert re.fullmatch("[a-f0-9]{64}", artifact[field]) + assert len(artifact["pythonFiles"]) == 151 and len(artifact["uiFiles"]) == 4 + for name, digest in artifact["pythonFiles"].items(): + assert re.fullmatch(r"domus/(?:[A-Za-z0-9_]+/)*[A-Za-z0-9_]+\.py", name) + assert re.fullmatch("[a-f0-9]{64}", digest) + assert {"index.html", ".vite/manifest.json"} <= set(artifact["uiFiles"]) + for name, digest in artifact["uiFiles"].items(): + assert not Path(name).is_absolute() and ".." not in Path(name).parts + assert re.fullmatch("[a-f0-9]{64}", digest) + + +def test_supervisor_backup_requires_the_app_to_stop_before_copying_private_storage() -> None: + manifest = yaml.safe_load((ROOT / "addon-edge/config.yaml").read_text()) + assert manifest["backup"] == "cold" + assert not {"backup_pre", "backup_post", "backup_exclude"} & manifest.keys() diff --git a/tests/backend/test_ha_member_profiles.py b/tests/backend/test_ha_member_profiles.py index ab29b8a..cf19dcb 100644 --- a/tests/backend/test_ha_member_profiles.py +++ b/tests/backend/test_ha_member_profiles.py @@ -57,6 +57,71 @@ def test_complete_directory_uses_one_fixed_read_and_preserves_exact_subject_and_ assert "fixture-access" not in repr(source) and "fixture-refresh" not in repr(source) +@pytest.mark.parametrize("standalone", [False, True]) +def test_exactly_256_distinct_accounts_form_a_complete_directory_in_provider_order(standalone): + rows = [ + { + **ROW, + "id": f"{index:032x}", + "name": f"Account {index}", + "is_active": index % 2 == 0, + "system_generated": index % 3 == 0, + } + for index in range(255) + ] + [ROW] + calls = [] + with provider(protocol({"config/auth/list": (True, rows)}, calls)) as endpoint: + value = HomeAssistantMemberProfiles( + HomeAssistantWebSocket(endpoint), "fixture-access", USER, GRANT if standalone else None + ).read() + assert calls == ["config/auth/list"] + assert value.directory.state is SourceState.AVAILABLE + profiles = value.directory.profiles + assert len(profiles) == 256 + assert tuple(profile.ha_user_id for profile in profiles) == tuple(row["id"] for row in rows) + assert tuple(profile.name for profile in profiles) == tuple(row["name"] for row in rows) + assert [profile.eligible for profile in profiles[:5]] == [False, False, True, False, True] + assert value.subject == USER and value.own.state is SourceState.AVAILABLE + assert value.own.profile == profiles[-1] + assert value.own.profile.ha_user_id == USER + assert value.own.profile.name == "Literal" and value.own.profile.eligible is True + + +@pytest.mark.parametrize("standalone", [False, True]) +@pytest.mark.parametrize("failure", ["distinct-overflow", "malformed-final-account"]) +def test_invalid_boundary_directory_never_certifies_its_valid_prefix(standalone, failure): + rows = [{**ROW, "id": f"{index:032x}"} for index in range(255)] + [ROW] + if failure == "distinct-overflow": + rows.append({**ROW, "id": f"{255:032x}"}) + assert len({row["id"] for row in rows}) == 257 + else: + rows[-1] = {**ROW, "name": False} + assert len({row["id"] for row in rows}) == 256 + calls = [] + with provider( + protocol( + { + "config/auth/list": (True, rows), + "auth/current_user": (True, {**CURRENT, "name": "Separately verified own name"}), + }, + calls, + ) + ) as endpoint: + value = HomeAssistantMemberProfiles( + HomeAssistantWebSocket(endpoint), "fixture-access", USER, GRANT if standalone else None + ).read() + assert value.directory.state is SourceState.UNAVAILABLE and value.directory.profiles == () + assert value.profile(f"{0:032x}").state is SourceState.UNAVAILABLE + if standalone: + assert calls == ["config/auth/list", "auth/current_user"] + assert value.own.state is SourceState.AVAILABLE + assert value.own.profile.ha_user_id == USER + assert value.own.profile.name == "Separately verified own name" + else: + assert calls == ["config/auth/list"] + assert value.own.state is SourceState.UNAVAILABLE and value.own.profile is None + + def test_native_directory_denial_never_uses_the_technical_tokens_current_user_as_the_actor(): calls = [] with provider(protocol({"config/auth/list": (False, None)}, calls)) as endpoint: diff --git a/tests/backend/test_household_roster.py b/tests/backend/test_household_roster.py index 18383ba..f967e0d 100644 --- a/tests/backend/test_household_roster.py +++ b/tests/backend/test_household_roster.py @@ -125,14 +125,23 @@ def test_only_current_policy_authority_reads_complete_eligible_directory_facts(t def test_unavailable_source_retains_real_roster_and_explicit_state_without_fake_choices( tmp_path, state ): - query, store, actor, source, _ = fixture(tmp_path) + query, store, actor, source, target = fixture(tmp_path) + with store.read() as unit: + expected_members = tuple(unit.members.list("home")) + assert len(expected_members) == 2 + assert {item.ha_user_id for item in expected_members} == {"ha_admin", "ha_joiner"} + assert target in {item.member_id for item in expected_members} source.value = MemberSourceSnapshot( actor.human.ha_user_id, ProfileDirectory(state), ProfileResult(state) ) before = store.path.read_bytes() roster = query.roster() - assert len(roster.members) == 2 + assert {item.member.member_id: item.member.ha_user_id for item in roster.members} == { + item.member_id: item.ha_user_id for item in expected_members + } + assert len(roster.members) == len(expected_members) assert all(item.metadata.state is state for item in roster.members) + assert all(item.metadata.name is None for item in roster.members) directory = query.directory() assert directory.state is state and directory.profiles == () assert store.path.read_bytes() == before diff --git a/tests/backend/test_member_commands.py b/tests/backend/test_member_commands.py index 3161871..f7469ab 100644 --- a/tests/backend/test_member_commands.py +++ b/tests/backend/test_member_commands.py @@ -438,16 +438,37 @@ def test_ordinary_member_cannot_assign_itself_privileges_or_change_another_membe 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"] + from domus.application.task_commands import CompleteTask, CreateTask, TaskCommands + from domus.domain.task_values import TaskContent + + commands, store, actor, directory, member_id = configured(tmp_path) + commands.update("activate", UpdateMember(member_id, 1, Role.RESTRICTED, MemberStatus.ACTIVE)) + tasks = TaskCommands(store, actor, commands.clock, commands.identifiers) + created = tasks.create("authored-work", CreateTask(TaskContent("Retained work"))) + occurrence_id = created.result.decode()["occurrence_id"] + completed = tasks.complete("record-work", CompleteTask(occurrence_id, 1, (member_id,), 90)) + with store.read() as unit: + original_member = unit.members.by_user("home", "ha_joiner") + original_work = unit.tasks.occurrence("home", occurrence_id) + original_cycles = unit.tasks.cycles("home", occurrence_id) + original_audit, original_outbox = unit.audit.entries("home"), unit.outbox.pending() + assert len(original_cycles) == 1 + assert original_cycles[0].performers[0].member_id == member_id + assert original_cycles[0].performed_at == 90 + assert len(original_audit) == len(original_outbox) == 5 + commands.update("archive", UpdateMember(member_id, 2, Role.RESTRICTED, MemberStatus.ARCHIVED)) + commands.update("reactivate", UpdateMember(member_id, 3, Role.RESTRICTED, MemberStatus.ACTIVE)) + assert directory.calls == ["ha_joiner", "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 unit.members.by_user("home", "ha_joiner") == replace(original_member, revision=4) + assert unit.tasks.occurrence("home", occurrence_id) == original_work + assert unit.tasks.cycles("home", occurrence_id) == original_cycles + assert unit.receipts.get(created.command) == created + assert unit.receipts.get(completed.command) == completed + assert unit.audit.entries("home")[:5] == original_audit + assert unit.outbox.pending()[:5] == original_outbox assert len(unit.members.list("home")) == 2 - assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 4 + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 7 def test_two_identical_concurrent_activations_have_one_effect_and_the_same_receipt( diff --git a/tests/backend/test_member_profile_commands.py b/tests/backend/test_member_profile_commands.py index 4631e47..2e8d0f8 100644 --- a/tests/backend/test_member_profile_commands.py +++ b/tests/backend/test_member_profile_commands.py @@ -74,6 +74,59 @@ def test_every_active_profile_can_change_its_own_alias_and_preferences_by_defaul assert private.result.decode()["notification_delivery"] == "unavailable" +@pytest.mark.parametrize("role", list(Role)) +def test_own_alias_read_uses_current_owner_permission_without_roster_access(tmp_path, role): + service, store, actor, target = fixture(tmp_path, role=role) + human = owner(actor) + with store.write() as unit: + member = unit.members.get("home", target) + unit.members.save( + replace( + member, + revision=member.revision + 1, + exceptions=(PermissionRule(Permission.MEMBERS_READ, False),), + ), + member.revision, + ) + administrator = unit.members.by_user("home", "ha_admin").member_id + queries = HouseholdAdministrationQueries(store, human) + before = evidence(store) + assert queries.presentation(target) == MemberPresentation(target, "home") + assert evidence(store) == before + with pytest.raises(Forbidden, match="permission_denied"): + queries.presentation(administrator) + saved = service.update_alias("own-alias", AliasUpdate(target, 1, "Own alias")) + assert queries.presentation(target).revision == saved.result.decode()["revision"] == 2 + assert queries.preferences() == MemberPreferences(target, "home") + + +@pytest.mark.parametrize("public_read", [False, True]) +def test_own_alias_read_preserves_public_grants_and_requires_one_applicable_permission( + tmp_path, public_read +): + _, store, actor, target = fixture(tmp_path) + human = owner(actor) + with store.write() as unit: + member = unit.members.get("home", target) + unit.members.save( + replace( + member, + revision=member.revision + 1, + exceptions=( + PermissionRule(Permission.MEMBERS_READ, public_read), + PermissionRule(Permission.PREFERENCES_READ, False), + ), + ), + member.revision, + ) + query = HouseholdAdministrationQueries(store, human) + if public_read: + assert query.presentation(target) == MemberPresentation(target, "home") + else: + with pytest.raises(Forbidden, match="permission_denied"): + query.presentation(target) + + def test_administrator_alias_edit_of_pending_member_preserves_membership_and_private_state( tmp_path, ): diff --git a/tests/backend/test_member_profiles.py b/tests/backend/test_member_profiles.py index 801ae05..2579936 100644 --- a/tests/backend/test_member_profiles.py +++ b/tests/backend/test_member_profiles.py @@ -120,3 +120,16 @@ def test_records_are_immutable_and_accept_the_last_supported_revision(model: typ assert record.revision == 2**63 - 1 with pytest.raises(FrozenInstanceError): record.revision = 1 + + +@pytest.mark.parametrize( + "codepoint", + [0x20, 0xA0, 0x1680, *range(0x2000, 0x200B), 0x2028, 0x2029, 0x202F, 0x205F, 0x3000], + ids=lambda value: f"U+{value:04X}", +) +def test_astral_alias_boundary_after_each_supported_non_control_whitespace(codepoint: int) -> None: + marker = chr(codepoint) + exact = "😀" * 80 + assert MemberPresentation("member_1", "home", marker + exact + marker).alias == exact + with pytest.raises(InvalidInput, match="^invalid_member_alias$"): + MemberPresentation("member_1", "home", marker + exact + "😀" + marker) diff --git a/tests/backend/test_sqlite_member_profiles.py b/tests/backend/test_sqlite_member_profiles.py index bb94422..f3a4019 100644 --- a/tests/backend/test_sqlite_member_profiles.py +++ b/tests/backend/test_sqlite_member_profiles.py @@ -218,6 +218,29 @@ def test_missing_profile_cannot_be_silently_recreated_as_a_default(tmp_path, kin SQLiteSnapshots(store, tmp_path / "snapshot", store.installation, Clock()).create("invalid") +@pytest.mark.parametrize( + "kind,table", [("presentations", "member_presentations"), ("preferences", "member_preferences")] +) +def test_existing_orphan_profile_is_not_disclosed_repaired_or_accepted_as_a_snapshot( + tmp_path, kind, table +): + store = prepared(tmp_path) + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute("PRAGMA foreign_keys=OFF") + connection.execute(f"UPDATE {table} SET member_id='orphan'") + assert connection.execute("PRAGMA foreign_key_check").fetchall() + before = store.path.read_bytes() + with store.read() as unit: + assert getattr(unit, kind).get("home", "orphan") is None + with pytest.raises(StorageFailure, match="invalid_member_profile_record"): + with store.read() as unit: + getattr(unit, kind).get("home", "member_1") + with pytest.raises(DatabaseRejected, match="invalid_snapshot_database"): + SQLiteSnapshots(store, tmp_path / "snapshot", store.installation, Clock()).create("invalid") + assert not (tmp_path / "snapshot").exists() + assert store.path.read_bytes() == before + + @pytest.mark.parametrize("version", range(1, 9)) def test_real_historical_input_upgrade_preserves_every_original_table_and_adds_defaults( tmp_path, version @@ -256,6 +279,16 @@ def test_real_historical_input_upgrade_preserves_every_original_table_and_adds_d expected["definition_snapshot"].update(version=2, materials=[]) assert json.loads(new[-1]) == expected assert actual == original + if version < 8: + recurrence_tables = ( + "recurrence_rules", + "recurrence_progress", + "recurrence_slots", + "recurrence_frontiers", + "recurrence_progress_evidence", + "recurrence_relative_slots", + ) + assert facts(store.path, recurrence_tables) == {table: [] for table in recurrence_tables} with store.read() as unit: for household in original["households"]: for current in unit.members.list(household[0]): diff --git a/tests/browser/entry_server.py b/tests/browser/entry_server.py index 963159c..cd5726c 100644 --- a/tests/browser/entry_server.py +++ b/tests/browser/entry_server.py @@ -15,16 +15,17 @@ import secrets import sqlite3 import tempfile +from contextlib import ExitStack from pathlib import Path +from unittest.mock import patch from urllib.parse import parse_qs, urlencode -import pytest import uvicorn from starlette.requests import Request from starlette.responses import HTMLResponse, JSONResponse from domus.composition import admin, ingress, server -from domus.domain.errors import IdentityRejected, IdentityUnavailable +from domus.domain.errors import Forbidden, IdentityRejected, IdentityUnavailable from domus.domain.identity import HumanIdentity from domus.infrastructure.ha_config import HAEndpoint from domus.infrastructure.ha_http import OAuthTokens @@ -66,6 +67,32 @@ def directory_user(self, token: str, user_id: str) -> HumanIdentity: def directory_users(self, token: str, user_ids: tuple[str, ...]) -> tuple: return tuple(self.directory_user(token, user_id) for user_id in user_ids) + def _request(self, token: str, operation: str): + """Fixed fictional account responses for the real bounded presentation adapter.""" + assert token == "fixture-supervisor-token" or token in self.token_subjects + actor = self.person( + self.subject if token == "fixture-supervisor-token" else self.token_subjects[token] + ) + if operation == "config/auth/list": + if token != "fixture-supervisor-token" and not actor.is_administrator: + raise Forbidden("ha_directory_forbidden") + return [ + { + "id": person.ha_user_id, + "name": "Fictional account " + person.ha_user_id[0], + "is_active": True, + "system_generated": False, + } + for person in self.people.values() + ] + assert operation == "auth/current_user" and token != "fixture-supervisor-token" + return { + "id": actor.ha_user_id, + "name": "Fictional account " + actor.ha_user_id[0], + "is_admin": actor.is_administrator, + "credentials": [{"auth_provider_type": "homeassistant"}], + } + class OAuth: def __init__(self, configuration, ha: HomeAssistant) -> None: @@ -97,16 +124,32 @@ def revoke(self, token: str) -> None: class Fixture: - def __init__(self, mode: str, port: int) -> None: - self.mode, self.origin = mode, f"http://127.0.0.1:{port}" + def __init__(self, mode: str, port: int, origin_port: int | None = None) -> None: + self.mode, self.origin = mode, f"http://127.0.0.1:{origin_port or port}" self.runtime = self.directory = None self.writer = None - self.patch = pytest.MonkeyPatch() - self.patch.setattr(ingress, "HomeAssistantWebSocket", lambda endpoint: self.ha) - self.patch.setattr(server, "HomeAssistantWebSocket", lambda endpoint: self.ha) - self.patch.setattr(server, "HomeAssistantOAuth", lambda config: OAuth(config, self.ha)) + self.patch = ExitStack() + self.patch.enter_context( + patch.object(ingress, "HomeAssistantWebSocket", lambda endpoint: self.ha) + ) + self.patch.enter_context( + patch.object(server, "HomeAssistantWebSocket", lambda endpoint: self.ha) + ) + self.patch.enter_context( + patch.object(server, "HomeAssistantOAuth", lambda config: OAuth(config, self.ha)) + ) self.reset() + def close(self) -> None: + self.release_writer() + if self.runtime is not None: + self.runtime.close() + self.runtime = None + if self.directory is not None: + self.directory.cleanup() + self.directory = None + self.patch.close() + def reset(self, channel: str = "edge") -> None: assert channel in ("edge", "stable") self.release_writer() @@ -195,10 +238,7 @@ async def __call__(self, scope, receive, send) -> None: if event["type"] == "lifespan.startup": await send({"type": "lifespan.startup.complete"}) elif event["type"] == "lifespan.shutdown": - self.release_writer() - self.runtime.close() - self.directory.cleanup() - self.patch.undo() + self.close() await send({"type": "lifespan.shutdown.complete"}) return if scope["type"] != "http": @@ -318,15 +358,33 @@ def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--mode", choices=("native", "standalone"), required=True) parser.add_argument("--port", type=int, required=True) + parser.add_argument("--origin-port", type=int) args = parser.parse_args() - uvicorn.run( - Fixture(args.mode, args.port), - host="127.0.0.1", - port=args.port, - access_log=False, - log_level="warning", - server_header=False, - ) + image = os.environ.get("DOMUS_BROWSER_IMAGE") + if image: + from image_bridge import ImageBridge + + fixture = ImageBridge( + image, + args.mode, + args.port, + os.environ["DOMUS_TEST_SOURCE_COMMIT"], + os.environ["DOMUS_TEST_ARCH"], + ) + fixture.start() + else: + fixture = Fixture(args.mode, args.port, args.origin_port) + try: + uvicorn.run( + fixture, + host="127.0.0.1", + port=args.port, + access_log=False, + log_level="warning", + server_header=False, + ) + finally: + fixture.close() if __name__ == "__main__": diff --git a/tests/browser/image_bridge.py b/tests/browser/image_bridge.py new file mode 100644 index 0000000..7ae46f5 --- /dev/null +++ b/tests/browser/image_bridge.py @@ -0,0 +1,250 @@ +"""Loopback-only test transport to an isolated, verified production image. + +The disposable provider fixture is copied into the writable test container after image creation. +The image and its installed product modules/UI stay unchanged and contain no fixture dependency. +""" + +import asyncio +import base64 +import json +import re +import subprocess +import threading +from pathlib import Path + +from image_channel import ImageChannel + +_CLIENT = r""" +import base64, http.client, json, sys +for line in sys.stdin: + transport_id = None + try: + packet = json.loads(line) + transport_id, value = packet['transport_id'], packet['request'] + body = base64.b64decode(value['body'], validate=True) + connection = http.client.HTTPConnection('127.0.0.1', 8099, timeout=15) + try: + connection.putrequest(value['method'], value['path'], + skip_host=True, skip_accept_encoding=True) + for name, content in value['headers']: + if name.lower() not in ('connection', 'transfer-encoding', 'content-length'): + connection.putheader(name, content) + connection.putheader('Content-Length', str(len(body))) + connection.endheaders(body) + response = connection.getresponse() + data = response.read(2**21 + 1) + assert len(data) <= 2**21, 'Fixture response exceeds the transport budget' + print(json.dumps({'transport_id': transport_id, 'response': { + 'status': response.status, 'headers': response.getheaders(), + 'body': base64.b64encode(data).decode()}}), flush=True) + finally: + connection.close() + except (OSError, ValueError, KeyError, TypeError, http.client.HTTPException): + print(json.dumps({'transport_id': transport_id, + 'response': {'transport_error': True}}), flush=True) +""" +_HOP = {b"connection", b"transfer-encoding", b"keep-alive", b"proxy-connection"} +_TRANSPORT_ERRORS = (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError) + + +class ImageBridge: + def __init__( + self, + image, + mode, + port, + source, + architecture, + *, + runner=subprocess.run, + channel_factory=ImageChannel, + ): + if ( + not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_./:@-]*", image) + or mode not in ("native", "standalone") + or type(port) is not int + or not 1 <= port <= 65535 + or not re.fullmatch(r"[a-f0-9]{40}", source) + or architecture not in ("amd64", "aarch64") + ): + raise ValueError("Invalid reviewed image fixture configuration") + self.image, self.mode, self.port = image, mode, port + self.source, self.architecture = source, architecture + self.runner, self.container, self.metadata = runner, None, None + self.channel_factory = channel_factory + self.channels, self.channels_lock = {}, threading.Lock() + + def run(self, *arguments, **options): + return self.runner( + ["docker", *arguments], + text=True, + capture_output=True, + check=True, + timeout=30, + **options, + ) + + def start(self): + if self.container is not None: + raise ValueError("An image fixture already owns a running container") + data = json.loads(self.run("image", "inspect", self.image).stdout)[0] + labels = data["Config"]["Labels"] + if ( + labels.get("org.opencontainers.image.revision") != self.source + or labels.get("io.hass.arch") != self.architecture + or data["Architecture"] != {"amd64": "amd64", "aarch64": "arm64"}[self.architecture] + or not re.fullmatch(r"sha256:[a-f0-9]{64}", data["Id"]) + ): + raise ValueError("Image identity or CPU architecture differs from the reviewed source") + result = self.run( + "run", + "--detach", + "--init", + "--network", + "none", + "--user", + "65532:65532", + "--label", + "domus.browser-fixture=true", + "--entrypoint", + "/app/.venv/bin/python", + data["Id"], + "-c", + "import signal; signal.pause()", + ) + identifier = result.stdout.strip() + if not re.fullmatch(r"[a-f0-9]{64}", identifier): + raise ValueError("Docker did not return an attributable container identity") + self.container = identifier + try: + self.run( + "cp", + str(Path(__file__).with_name("entry_server.py")), + identifier + ":/tmp/domus_browser_fixture.py", + ) + self.run( + "exec", + "--detach", + identifier, + "/app/.venv/bin/python", + "/tmp/domus_browser_fixture.py", + "--mode", + self.mode, + "--port", + "8099", + "--origin-port", + str(self.port), + ) + except BaseException: + self.close() + raise + self.metadata = { + "source_commit": self.source, + "architecture": self.architecture, + "image_id": data["Id"], + "mode": self.mode, + } + + def close(self): + with self.channels_lock: + if self.container is not None: + self.run("rm", "--force", self.container) + self.container = None + channels = tuple(self.channels.values()) + self.channels.clear() + for channel in channels: + channel.close() + + def exchange(self, method, path, headers, body): + if self.container is None: + raise ValueError("No verified image fixture is running") + value = { + "method": method, + "path": path, + "headers": headers, + "body": base64.b64encode(body).decode(), + } + # A worker belongs to one host transport thread, preserving concurrent requests. + # Each frame still creates one inner HTTP request; no mutation is replayed on failure. + with self.channels_lock: + if self.container is None: + raise ValueError("No verified image fixture is running") + owner = threading.get_ident() + if owner not in self.channels: + self.channels[owner] = self.channel_factory( + [ + "docker", + "exec", + "-i", + self.container, + "/app/.venv/bin/python", + "-u", + "-c", + _CLIENT, + ] + ) + channel = self.channels[owner] + response = json.loads(channel.exchange(value)) + if type(response["status"]) is not int or not 100 <= response["status"] <= 599: + raise ValueError("Invalid image response status") + base64.b64decode(response["body"], validate=True) + return response + + async def __call__(self, scope, receive, send): + if scope["type"] == "lifespan": + while True: + event = await receive() + if event["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif event["type"] == "lifespan.shutdown": + self.close() + await send({"type": "lifespan.shutdown.complete"}) + return + if scope["type"] != "http": + return + if scope["path"] == "/__fixture__/image": + await self.respond( + send, + 200, + [[b"content-type", b"application/json"], [b"cache-control", b"no-store"]], + json.dumps(self.metadata).encode(), + ) + return + # The readiness URL follows this proxy too: Playwright waits for the inner HTTP 200. + # Local image metadata cannot make an unstarted inner fixture appear ready. + body = bytearray() + while True: + event = await receive() + if event["type"] == "http.disconnect": + return + body.extend(event.get("body", b"")) + if len(body) > 2**21: + await self.respond(send, 413, [], b"") + return + if not event.get("more_body", False): + break + path = scope["raw_path"].decode("ascii") + if scope["query_string"]: + path += "?" + scope["query_string"].decode("ascii") + headers = [ + [name.decode("latin1"), value.decode("latin1")] for name, value in scope["headers"] + ] + try: + response = await asyncio.to_thread( + self.exchange, scope["method"], path, headers, bytes(body) + ) + values = [ + [name.encode("latin1"), value.encode("latin1")] + for name, value in response["headers"] + if name.lower().encode() not in _HOP + ] + payload = base64.b64decode(response["body"], validate=True) + except _TRANSPORT_ERRORS: + await self.respond(send, 502, [[b"cache-control", b"no-store"]], b"") + return + await self.respond(send, response["status"], values, payload) + + @staticmethod + async def respond(send, status, headers, body): + await send({"type": "http.response.start", "status": status, "headers": headers}) + await send({"type": "http.response.body", "body": body}) diff --git a/tests/browser/image_channel.py b/tests/browser/image_channel.py new file mode 100644 index 0000000..6e46e01 --- /dev/null +++ b/tests/browser/image_channel.py @@ -0,0 +1,103 @@ +"""One owned stdio worker carries exact framed requests without restarting or resending them.""" + +import contextlib +import json +import os +import select +import subprocess +import threading +import time + + +class ImageChannel: + def __init__( + self, + command, + *, + launcher=subprocess.Popen, + wait=select.select, + timeout=30, + frame_limit=2**24, + ): + self.command = tuple(command) + self.wait, self.timeout, self.frame_limit = wait, timeout, frame_limit + self.lock, self.close_lock, self.closed = threading.Lock(), threading.Lock(), False + self.sequence = 0 + self.process = launcher( + list(command), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=0, + ) + assert self.process.stdin is not None and self.process.stdout is not None + os.set_blocking(self.process.stdin.fileno(), False) + os.set_blocking(self.process.stdout.fileno(), False) + + def exchange(self, value): + if not self.lock.acquire(timeout=self.timeout): + raise subprocess.TimeoutExpired(self.command, self.timeout) + try: + if self.closed or self.process.poll() is not None: + raise ValueError("The image transport worker is no longer available") + deadline = time.monotonic() + self.timeout + self.sequence += 1 + payload = json.dumps({"transport_id": self.sequence, "request": value}).encode() + b"\n" + incoming, outgoing = self.process.stdout.fileno(), self.process.stdin.fileno() + while payload: + remaining = deadline - time.monotonic() + if remaining <= 0 or not self.wait([], [outgoing], [], remaining)[1]: + raise subprocess.TimeoutExpired(self.command, self.timeout) + try: + written = os.write(outgoing, payload) + except BlockingIOError: + continue + if written == 0: + raise ValueError("The image transport worker accepted no request bytes") + payload = payload[written:] + response = bytearray() + while b"\n" not in response: + remaining = deadline - time.monotonic() + if remaining <= 0 or not self.wait([incoming], [], [], remaining)[0]: + raise subprocess.TimeoutExpired(self.command, self.timeout) + try: + part = os.read(incoming, min(65536, self.frame_limit + 1 - len(response))) + except BlockingIOError: + continue + if not part: + raise ValueError("The image transport worker ended without a complete response") + response.extend(part) + if len(response) > self.frame_limit: + raise ValueError("The image transport response exceeds its frame budget") + if response.count(b"\n") != 1 or not response.endswith(b"\n"): + raise ValueError("The image transport returned unsolicited response bytes") + frame = json.loads(response[:-1]) + if ( + not isinstance(frame, dict) + or set(frame) != {"transport_id", "response"} + or type(frame["transport_id"]) is not int + or frame["transport_id"] != self.sequence + ): + raise ValueError("The image transport response identity differs from its request") + return json.dumps(frame["response"]) + except BaseException: + self.close() + raise + finally: + self.lock.release() + + def close(self): + with self.close_lock: + if not self.closed: + self.closed = True + for stream in (self.process.stdin, self.process.stdout): + with contextlib.suppress(OSError): + stream.close() + if self.process.poll() is None: + with contextlib.suppress(ProcessLookupError): + self.process.terminate() + try: + self.process.wait(timeout=1) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=1) diff --git a/tests/containers/administration_rollback_fixture.py b/tests/containers/administration_rollback_fixture.py new file mode 100644 index 0000000..22e2450 --- /dev/null +++ b/tests/containers/administration_rollback_fixture.py @@ -0,0 +1,214 @@ +"""Fictional owned data passed to immutable packaged applications; no external HA access.""" + +import hashlib +import io +import json +import os +import sys +from pathlib import Path + +import domus +from domus.application.household_commands import HouseholdCommands, SetupHousehold +from domus.application.member_profile_commands import ( + AliasUpdate, + MemberProfileCommands, + PreferencesUpdate, +) +from domus.composition import admin, ingress, server +from domus.domain.identity import HumanIdentity +from domus.domain.member_profiles import MemberLocale +from domus.entrypoints.ui_bundle import load_bundle +from domus.infrastructure.application_runtime import SecureIdentifiers, SystemClock, TimeZoneCatalog +from domus.infrastructure.sqlite import SCHEMA_VERSION +from domus.infrastructure.sqlite_history import iter_operations + +ROOT = Path("/data/domus") +RECORD = Path("/data/snapshots/administration.json") +USER = "a" * 32 +ORIGIN = "https://fictional-domus.example" +TABLES = ( + "households", + "members", + "household_policy", + "member_presentations", + "member_preferences", + "receipts", + "audit", + "outbox", +) + + +class Human: + def current(self): + return HumanIdentity(USER, True, True, False) + + def get(self, _user_id): + raise AssertionError("No external directory subject is selected") + + +def console(*arguments): + output, errors = io.StringIO(), io.StringIO() + status = admin.main( + ["--channel", "edge", "--data-directory", str(ROOT), *arguments], + output_stream=output, + error_stream=errors, + ) + assert status == 0, errors.getvalue() + return json.loads(output.getvalue()) + + +def state(store): + with store._connection() as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == 9 + assert connection.execute("PRAGMA foreign_key_check").fetchall() == [] + return { + table: [ + list(row) for row in connection.execute(f'SELECT * FROM "{table}" ORDER BY rowid') + ] + for table in TABLES + } + + +def proofs(store): + with store._connection() as connection: + return { + value.evidence.receipt.operation_id: value.value() + for value in iter_operations(connection) + } + + +def ui(): + root = Path(domus.__file__).parent / "ui" + assert load_bundle(root) is not None + result = { + str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in root.rglob("*") + if path.is_file() + } + assert len(result) == 4 + return result + + +def main(): + phase, mode, delivery = sys.argv[1:] + assert phase in ("initialize", "later", "rollback") + assert mode in ("native", "standalone") and delivery in ("pending", "acknowledged") + assert os.getuid() == os.getgid() == 65532 and SCHEMA_VERSION == 9 + runtime = ( + ingress.create_owned_runtime("fictional-supervisor", "edge", ROOT, (ORIGIN,)) + if mode == "native" + else server.create_owned_runtime("http://fictional-ha.local", ORIGIN, "edge", ROOT) + ) + try: + store, actor, clock, ids = ( + runtime.storage.database, + Human(), + SystemClock(), + SecureIdentifiers(), + ) + household = HouseholdCommands(store, actor, actor, TimeZoneCatalog(), clock, ids) + profiles = MemberProfileCommands(store, actor, clock, ids) + if phase == "initialize": + initial = household.setup("setup", SetupHousehold("Rollback household", "UTC")) + with store.read() as unit: + member = unit.members.by_user("home", USER) + prior = profiles.update_alias( + "before-alias", AliasUpdate(member.member_id, 1, "Before upgrade") + ) + record = { + "mode": mode, + "delivery": delivery, + "instance_id": runtime.storage.installation.instance_id, + "member_id": member.member_id, + "setup_operation": initial.operation_id, + "before_operation": prior.operation_id, + "initial_state": state(store), + } + else: + record = json.loads(RECORD.read_text()) + assert (record["mode"], record["delivery"]) == (mode, delivery) + assert runtime.storage.installation.instance_id == record["instance_id"] + member_id = record["member_id"] + if phase == "later": + assert state(store) == record["initial_state"] + alias = profiles.update_alias( + "after-alias", AliasUpdate(member_id, 2, "After upgrade") + ) + private = profiles.update_preferences( + "after-prefs", PreferencesUpdate(1, MemberLocale.SPANISH, False) + ) + assert not alias.historical and not private.historical + if delivery == "acknowledged": + with store.write() as unit: + assert unit.outbox.acknowledge(alias.operation_id) + record.update( + after_alias_operation=alias.operation_id, + after_preferences_operation=private.operation_id, + original_proofs=proofs(store), + ) + assert len(record["original_proofs"]) == 4 + else: + assert runtime.storage.generation_id == record["selected_generation"] + assert ( + store.path + == ROOT / "generations" / record["selected_generation"] / "domus.sqlite3" + ) + assert state(store) == record["initial_state"] + original = proofs(store) + assert original.keys() == record["original_proofs"].keys() + later_ids = {record["after_alias_operation"], record["after_preferences_operation"]} + for operation_id, prior in record["original_proofs"].items(): + current = dict(original[operation_id]) + assert current.pop("current") is (operation_id not in later_ids) + assert current.pop("archived_by") == ( + record["selected_generation"] if operation_id in later_ids else None + ) + assert current == { + key: value + for key, value in prior.items() + if key not in ("current", "archived_by") + } + before = store.path.read_bytes() + alias = profiles.update_alias( + "after-alias", AliasUpdate(member_id, 2, "After upgrade") + ) + private = profiles.update_preferences( + "after-prefs", PreferencesUpdate(1, MemberLocale.SPANISH, False) + ) + initial = household.setup("setup", SetupHousehold("Rollback household", "UTC")) + assert alias.historical and alias.operation_id == record["after_alias_operation"] + assert ( + private.historical + and private.operation_id == record["after_preferences_operation"] + ) + assert not initial.historical and initial.operation_id == record["setup_operation"] + assert state(store) == record["initial_state"] and proofs(store) == original + assert store.path.read_bytes() == before + status = console("restore", "status", record["selected_generation"]) + assert status["state"] == "active" + record["original_operations_preserved"] = True + observation = { + "state": state(store), + "ui": ui(), + "generation_id": runtime.storage.generation_id, + } + finally: + runtime.close() + if phase == "initialize": + checkpoint = console("snapshot", "create", "/data/snapshots/before") + assert checkpoint["snapshot"]["schema_version"] == 9 + record["checkpoint"] = checkpoint["snapshot"] + elif phase == "later": + prepared = console( + "restore", "prepare", "/data/snapshots/before", "/data/snapshots/prepared" + ) + assert prepared["state"] == "prepared" + active = console("restore", "activate", "/data/snapshots/prepared") + assert active["state"] == "active" + record["selected_generation"] = active["selected_generation"] + RECORD.write_text(json.dumps(record)) + print(json.dumps({**record, **observation})) + + +if __name__ == "__main__": + main() diff --git a/tests/containers/test_administration_image.py b/tests/containers/test_administration_image.py index eeb70cc..f5bc89a 100644 --- a/tests/containers/test_administration_image.py +++ b/tests/containers/test_administration_image.py @@ -1,8 +1,11 @@ """Both actual CPU images preserve separate profiles in each owned deployment runtime.""" +import json +import os from pathlib import Path import pytest +from test_app_image import data_volume as data_volume from test_app_image import docker from test_app_image import image as image @@ -278,3 +281,80 @@ async def headers(key): mode, ) assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +@pytest.mark.parametrize("delivery", ["pending", "acknowledged"]) +def test_actual_compatible_application_data_and_generation_rollback( + image, data_volume, mode, delivery +): + source = Path(__file__).with_name("administration_rollback_fixture.py").read_text() + previous = os.environ.get("DOMUS_SCHEMA9_IMAGE") + assert previous, "Set DOMUS_SCHEMA9_IMAGE to the immutable compatible schema-9 image" + inspected = [] + for reference, expected in ( + (previous, "87d726fa5a6a8cbcb56e0b911d15cc5b8a96a470"), + (image, os.environ["DOMUS_TEST_SOURCE_COMMIT"]), + ): + result = docker("image", "inspect", reference) + assert result.returncode == 0, result.stderr + value = json.loads(result.stdout)[0] + assert value["Config"]["Labels"]["org.opencontainers.image.revision"] == expected + inspected.append(value) + assert inspected[0]["Architecture"] == inspected[1]["Architecture"] + assert ( + inspected[0]["Config"]["Labels"]["io.hass.arch"] + == inspected[1]["Config"]["Labels"]["io.hass.arch"] + ) + assert inspected[0]["Id"] != inspected[1]["Id"] + previous_id, current_id = (value["Id"] for value in inspected) + prepared = docker( + "run", + "--rm", + "--network", + "none", + "--volume", + data_volume + ":/data", + "--entrypoint", + "/app/.venv/bin/python", + current_id, + "-c", + "import os; from pathlib import Path; " + "\nfor name in ('domus', 'snapshots'):" + "\n p=Path('/data')/name; p.mkdir(mode=0o700); os.chown(p,65532,65532)", + ) + assert prepared.returncode == 0, prepared.stdout + prepared.stderr + observations = {} + for phase, identifier in ( + ("initialize", previous_id), + ("later", current_id), + ("rollback", previous_id), + ): + result = docker( + "run", + "--rm", + "--network", + "none", + "--user", + "65532:65532", + "--volume", + data_volume + ":/data", + "--entrypoint", + "/app/.venv/bin/python", + identifier, + "-c", + source, + phase, + mode, + delivery, + ) + assert result.returncode == 0, f"{phase}: {result.stdout}\n{result.stderr}" + observations[phase] = json.loads(result.stdout) + before, later, rolled_back = ( + observations[name] for name in ("initialize", "later", "rollback") + ) + assert before["ui"] != later["ui"] + assert rolled_back["ui"] == before["ui"] + assert rolled_back["generation_id"] == later["selected_generation"] + assert rolled_back["state"] == before["state"] + assert rolled_back["original_operations_preserved"] is True diff --git a/tests/containers/test_app_ui.py b/tests/containers/test_app_ui.py index 1803756..363a8ee 100644 --- a/tests/containers/test_app_ui.py +++ b/tests/containers/test_app_ui.py @@ -9,7 +9,7 @@ from test_app_image import image as image -def test_packaged_ui_bytes_and_same_listener_standalone_delivery(image, data_volume): +def exercise_packaged_ui_delivery(image, data_volume, expected): name = "domus-ui-image-" + uuid.uuid4().hex prepared = docker( "run", @@ -26,11 +26,6 @@ def test_packaged_ui_bytes_and_same_listener_standalone_delivery(image, data_vol ) assert prepared.returncode == 0, prepared.stderr source = Path(__file__).with_name("ui_fixture.py").read_text() - expected = json.loads( - ( - Path(__file__).resolve().parents[2] / "docs/evidence/household-administration.json" - ).read_text() - )["currentSourcePackage"]["actualProductFiles"] try: started = docker( "run", @@ -71,3 +66,12 @@ def test_packaged_ui_bytes_and_same_listener_standalone_delivery(image, data_vol finally: removed = docker("rm", "--force", name) assert removed.returncode == 0 or "No such container" in removed.stderr, removed.stderr + + +def test_packaged_ui_bytes_and_same_listener_standalone_delivery(image, data_volume): + expected = json.loads( + ( + Path(__file__).resolve().parents[2] / "docs/evidence/household-administration.json" + ).read_text() + )["currentSourcePackage"]["actualProductFiles"] + exercise_packaged_ui_delivery(image, data_volume, expected) diff --git a/tests/containers/test_edge_preview_image.py b/tests/containers/test_edge_preview_image.py new file mode 100644 index 0000000..ffd8469 --- /dev/null +++ b/tests/containers/test_edge_preview_image.py @@ -0,0 +1,96 @@ +"""Verify the exact pinned app installed by the Supervisor, independently of current-source CI.""" + +import json +import os +from pathlib import Path + +import pytest +import test_app_image as app_tests +from test_app_compatibility import previous_image as previous_image +from test_app_compatibility import ( + test_real_older_image_upgrades_replays_and_restores_its_pre_upgrade_snapshot as upgrade_cycle, +) +from test_app_image import data_volume as data_volume +from test_app_image import docker +from test_app_image import image as image +from test_app_ui import exercise_packaged_ui_delivery + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def edge_artifact(): + return json.loads((ROOT / "addon-edge/artifact.json").read_text()) + + +@pytest.fixture +def edge_preview(edge_artifact): + value = os.environ.get("DOMUS_EDGE_IMAGE") + assert value, "Set DOMUS_EDGE_IMAGE to the actual pinned Supervisor app image" + result = docker("image", "inspect", value) + assert result.returncode == 0, result.stderr + inspected = json.loads(result.stdout)[0] + config = inspected["Config"] + assert config["Labels"]["org.opencontainers.image.revision"] == edge_artifact["sourceCommit"] + assert config["Labels"]["io.hass.version"] == edge_artifact["version"] + assert ( + config["Labels"]["io.hass.arch"] + == {"arm64": "aarch64", "amd64": "amd64"}[inspected["Architecture"]] + ) + assert config["User"] == "0:0" + assert config["Entrypoint"] == ["/app/.venv/bin/domus-ha-app", "--channel", "edge"] + assert not any(value.startswith("SUPERVISOR_TOKEN=") for value in config["Env"]) + return value + + +def test_pinned_edge_runtime_and_bundle_match_every_reviewed_product_file( + edge_preview, edge_artifact +): + probe = docker( + "run", + "--rm", + "--network", + "none", + "--user", + "65532:65532", + "--entrypoint", + "/app/.venv/bin/python", + edge_preview, + "-c", + """import domus,hashlib,json,pathlib +root=pathlib.Path(domus.__file__).parent +python={ + "domus/"+p.relative_to(root).as_posix():hashlib.sha256(p.read_bytes()).hexdigest() + for p in root.rglob("*.py") +} +ui={ + p.relative_to(root/"ui").as_posix():hashlib.sha256(p.read_bytes()).hexdigest() + for p in (root/"ui").rglob("*") if p.is_file() +} +print(json.dumps({"pythonFiles":python,"uiFiles":ui})) +""", + ) + assert probe.returncode == 0, probe.stderr + actual = json.loads(probe.stdout) + assert actual["pythonFiles"] == edge_artifact["pythonFiles"] + assert actual["uiFiles"] == edge_artifact["uiFiles"] + assert len(actual["pythonFiles"]) == 151 and len(actual["uiFiles"]) == 4 + + +def test_pinned_edge_native_launcher_authority_storage_and_restart(edge_preview, data_volume): + app_tests.test_real_nonroot_listener_rejects_forgery_and_retains_storage_and_replay_on_restart( + edge_preview, data_volume + ) + + +def test_pinned_edge_standalone_listener_serves_the_exact_private_bundle( + edge_preview, edge_artifact, data_volume +): + exercise_packaged_ui_delivery(edge_preview, data_volume, edge_artifact["uiFiles"]) + + +@pytest.mark.parametrize("previous_image", ["edge5"], indirect=True) +def test_pinned_edge_upgrades_actual_edge_five_and_preserves_original_history( + edge_preview, previous_image, data_volume +): + upgrade_cycle(edge_preview, previous_image, data_volume, 7) diff --git a/tests/fixtures/edge5-app/Dockerfile b/tests/fixtures/edge5-app/Dockerfile new file mode 100644 index 0000000..5436e6f --- /dev/null +++ b/tests/fixtures/edge5-app/Dockerfile @@ -0,0 +1,41 @@ +# The Supervisor builds this folder. Fetch only the reviewed immutable runtime and UI. +FROM ghcr.io/astral-sh/uv:0.11.29@sha256:eb2843a1e56fd9e30c7276ce1a52cba86e64c7b385f5e3279a0e08e02dd058fc AS uv +FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 AS source +ARG DOMUS_SOURCE_COMMIT=271a3a07feb1535f0a5ec8b55abb3de9aac049a9 +ARG DOMUS_SOURCE_SHA256=5cc9a963b6f064aa48be256a9d5a4274f8c52c878e4ad7f84517cd33f41056c8 +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 + +FROM node:24.20.0-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e AS frontend +RUN npm install --global --ignore-scripts --no-audit --no-fund pnpm@10.34.0 +COPY --from=source /app /app +WORKDIR /app +RUN pnpm install --frozen-lockfile --filter @domus/frontend... \ + && pnpm build:frontend + +FROM source AS builder +COPY --from=uv /uv /usr/local/bin/uv +COPY --from=frontend /app/backend/src/domus/ui /app/backend/src/domus/ui +WORKDIR /app +ENV UV_PYTHON_DOWNLOADS=never UV_NO_CACHE=1 +RUN uv sync --frozen --no-dev --package domus-server --no-editable +RUN .venv/bin/python -c "from pathlib import Path; import domus; from domus.entrypoints.ui_bundle import load_bundle; assert load_bundle(Path(domus.__file__).parent / 'ui') is not None" + +FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 +ARG BUILD_VERSION=0.0.0-edge.5 +ARG BUILD_ARCH +ARG DOMUS_SOURCE_COMMIT=271a3a07feb1535f0a5ec8b55abb3de9aac049a9 +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/tests/fixtures/schema9-app/Dockerfile b/tests/fixtures/schema9-app/Dockerfile new file mode 100644 index 0000000..21aa39c --- /dev/null +++ b/tests/fixtures/schema9-app/Dockerfile @@ -0,0 +1,42 @@ +# Immutable published schema9 compatible administration checkpoint; never a Supervisor release folder. +FROM ghcr.io/astral-sh/uv:0.11.29@sha256:eb2843a1e56fd9e30c7276ce1a52cba86e64c7b385f5e3279a0e08e02dd058fc AS uv +FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 AS source +ARG DOMUS_SOURCE_COMMIT=87d726fa5a6a8cbcb56e0b911d15cc5b8a96a470 +ARG DOMUS_SOURCE_SHA256=d8a9c4c1caf3acbb462ac21ec78ccbab05d756031880fd2f5013b4e528b45758 +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 + +FROM node:24.20.0-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e AS frontend +RUN npm install --global --ignore-scripts --no-audit --no-fund pnpm@10.34.0 +WORKDIR /app +COPY --from=source /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./ +COPY --from=source /app/frontend ./frontend +COPY --from=source /app/tests/fixtures/frontend-toolchain/package.json ./tests/fixtures/frontend-toolchain/package.json +RUN pnpm install --frozen-lockfile --filter @domus/frontend... \ + && pnpm build:frontend + +FROM source AS builder +COPY --from=uv /uv /usr/local/bin/uv +COPY --from=frontend /app/backend/src/domus/ui /app/backend/src/domus/ui +ENV UV_PYTHON_DOWNLOADS=never UV_NO_CACHE=1 +RUN uv sync --frozen --no-dev --package domus-server --no-editable +RUN .venv/bin/python -c "from pathlib import Path; import domus; from domus.entrypoints.ui_bundle import load_bundle; from domus.infrastructure.sqlite import SCHEMA_VERSION; assert SCHEMA_VERSION == 9; assert load_bundle(Path(domus.__file__).parent / 'ui') is not None" + +FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 +ARG BUILD_VERSION=ci-schema9 +ARG BUILD_ARCH +ARG DOMUS_SOURCE_COMMIT=87d726fa5a6a8cbcb56e0b911d15cc5b8a96a470 +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 +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/tests/python/test_browser_image_contract.py b/tests/python/test_browser_image_contract.py new file mode 100644 index 0000000..db82537 --- /dev/null +++ b/tests/python/test_browser_image_contract.py @@ -0,0 +1,348 @@ +"""The browser bridge must preserve wire evidence and own only its isolated container.""" + +import asyncio +import base64 +import json +import runpy +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +with patch("sys.path", [str(ROOT / "tests/browser"), *sys.path]): + ImageBridge = runpy.run_path(str(ROOT / "tests/browser/image_bridge.py"))["ImageBridge"] +SOURCE = "a" * 40 +IMAGE = "sha256:" + "b" * 64 +CONTAINER = "c" * 64 + + +class Docker: + def __init__(self, *, revision=SOURCE, label_arch="aarch64", architecture="arm64", fail=None): + self.calls = [] + self.fail = fail + self.info = [ + { + "Id": IMAGE, + "Architecture": architecture, + "Config": { + "Labels": { + "org.opencontainers.image.revision": revision, + "io.hass.arch": label_arch, + } + }, + } + ] + self.reply = { + "status": 409, + "headers": [ + ["Content-Type", "application/json"], + ["Cache-Control", "no-store"], + ["Set-Cookie", "a=1"], + ["Set-Cookie", "b=2"], + ], + "body": base64.b64encode(b'{"code":"revision_conflict"}').decode(), + } + + def __call__(self, args, **kwargs): + self.calls.append((args, kwargs)) + command = args[1] + if command == self.fail: + raise subprocess.CalledProcessError(1, args, stderr="Fictional Docker failure") + if command == "image": + output = json.dumps(self.info) + elif command == "run": + output = CONTAINER + "\n" + elif command == "exec" and "-i" in args: + output = json.dumps(self.reply) + else: + output = "" + return subprocess.CompletedProcess(args, 0, stdout=output, stderr="") + + +def bridge(docker): + class Channel: + def __init__(self, command): + self.command = command + + def exchange(self, value): + return docker(self.command, input=json.dumps(value)).stdout + + def close(self): + pass + + return ImageBridge( + "domus-app-ci", "native", 56174, SOURCE, "aarch64", runner=docker, channel_factory=Channel + ) + + +@pytest.mark.parametrize( + "values", + [ + {"revision": "d" * 40}, + {"label_arch": "amd64"}, + {"architecture": "amd64"}, + ], +) +def test_unreviewed_image_or_architecture_never_starts_a_container(values): + docker = Docker(**values) + with pytest.raises(ValueError): + bridge(docker).start() + assert not any(args[1] in {"run", "cp", "exec", "rm"} for args, _ in docker.calls) + + +def test_isolated_nonroot_container_uses_the_verified_image_id_and_cleanup_is_idempotent(): + docker = Docker() + app = bridge(docker) + app.start() + arguments = next(args for args, _ in docker.calls if args[1] == "run") + assert arguments[arguments.index("--network") + 1] == "none" + assert arguments[arguments.index("--user") + 1] == "65532:65532" + assert IMAGE in arguments and "domus-app-ci" not in arguments + assert not any(arg in {"-p", "--publish", "-P", "--publish-all"} for arg in arguments) + with pytest.raises(ValueError): + app.start() + app.close() + app.close() + assert [args for args, _ in docker.calls if args[1] == "rm"] == [ + ["docker", "rm", "--force", CONTAINER] + ] + + +def test_failure_after_creation_cleans_only_the_returned_owned_container(): + docker = Docker(fail="cp") + app = bridge(docker) + with pytest.raises(subprocess.CalledProcessError): + app.start() + app.close() + assert [args for args, _ in docker.calls if args[1] == "rm"] == [ + ["docker", "rm", "--force", CONTAINER] + ] + + +def test_wire_exchange_preserves_original_binary_payload_context_and_non_success_reply(): + docker = Docker() + app = bridge(docker) + app.start() + body = b'{"expected_revision":9007199254740993,"alias":"literal"}' + headers = [ + ["Origin", "http://127.0.0.1:56174"], + ["X-Domus-Context", "original"], + ["Idempotency-Key", "original-key"], + ] + assert app.exchange("PUT", "/api/v1/household?exact=%2F", headers, body) == docker.reply + _, options = next((args, kw) for args, kw in docker.calls if args[1] == "exec" and "-i" in args) + encoded = json.loads(options["input"]) + assert encoded["method"] == "PUT" and encoded["path"] == "/api/v1/household?exact=%2F" + assert encoded["headers"] == headers and base64.b64decode(encoded["body"]) == body + app.close() + + +async def request(app, events, path="/api/v1/household", *, method="PUT"): + responses = [] + pending = iter(events) + + async def receive(): + return next(pending) + + async def send(value): + responses.append(value) + + await app( + { + "type": "http", + "method": method, + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "headers": [], + }, + receive, + send, + ) + return responses + + +def test_disconnect_or_oversized_browser_input_never_reaches_the_image(): + docker = Docker() + app = bridge(docker) + app.start() + assert asyncio.run(request(app, [{"type": "http.disconnect"}])) == [] + result = asyncio.run(request(app, [{"type": "http.request", "body": b"x" * (2**21 + 1)}])) + assert result[0]["status"] == 413 + assert not any(args[1] == "exec" and "-i" in args for args, _ in docker.calls) + app.close() + + +def test_image_metadata_and_binary_error_response_are_readonly_and_not_success_substitutes(): + docker = Docker() + app = bridge(docker) + app.start() + response = asyncio.run( + request(app, [{"type": "http.request", "body": b""}], "/__fixture__/image") + ) + assert json.loads(response[1]["body"]) == { + "source_commit": SOURCE, + "architecture": "aarch64", + "image_id": IMAGE, + "mode": "native", + } + response = asyncio.run(request(app, [{"type": "http.request", "body": b"original"}])) + assert response[0]["status"] == 409 + assert [value for name, value in response[0]["headers"] if name.lower() == b"set-cookie"] == [ + b"a=1", + b"b=2", + ] + assert response[1]["body"] == b'{"code":"revision_conflict"}' + app.close() + + +@pytest.mark.parametrize( + "reply", + [ + {"status": True, "headers": [], "body": ""}, + {"status": 700, "headers": [], "body": ""}, + {"status": 200, "headers": [], "body": "!not-base64!"}, + ], +) +def test_invalid_image_transport_evidence_never_fabricates_a_success(reply): + docker = Docker() + docker.reply = reply + app = bridge(docker) + app.start() + response = asyncio.run(request(app, [{"type": "http.request", "body": b"original"}])) + assert response[0]["status"] == 502 and response[1]["body"] == b"" + app.close() + + +def test_failed_forwarding_keeps_a_transport_failure_instead_of_a_product_receipt(): + docker = Docker() + app = bridge(docker) + app.start() + docker.fail = "exec" + response = asyncio.run(request(app, [{"type": "http.request", "body": b"original"}])) + assert response[0]["status"] == 502 + app.close() + + +def test_failed_container_creation_does_not_remove_an_unattributable_container(): + docker = Docker(fail="run") + with pytest.raises(subprocess.CalledProcessError): + bridge(docker).start() + assert not any(args[1] == "rm" for args, _ in docker.calls) + + +def test_readiness_proxies_inner_failures_until_the_image_server_returns_ready(): + docker = Docker() + app = bridge(docker) + app.start() + events = [{"type": "http.request", "body": b""}] + metadata = asyncio.run(request(app, events, "/__fixture__/image", method="GET")) + assert metadata[0]["status"] == 200 + assert not any(args[1] == "exec" and "-i" in args for args, _ in docker.calls) + docker.fail = "exec" + unavailable = asyncio.run(request(app, events, "/__fixture__/ready", method="GET")) + assert unavailable[0]["status"] == 502 and unavailable[1]["body"] == b"" + docker.fail = None + for status, body in [ + (503, b'{"state":"starting"}'), + (200, b'{"state":"ready","fixture":true}'), + ]: + docker.reply = {"status": status, "headers": [], "body": base64.b64encode(body).decode()} + response = asyncio.run(request(app, events, "/__fixture__/ready", method="GET")) + assert response[0]["status"] == status and response[1]["body"] == body + forwarded = [ + json.loads(options["input"]) + for args, options in docker.calls + if args[1] == "exec" and "-i" in args + ] + assert len(forwarded) == 3 + assert all( + value["method"] == "GET" and value["path"] == "/__fixture__/ready" for value in forwarded + ) + assert all(base64.b64decode(value["body"]) == b"" for value in forwarded) + app.close() + + +def test_lifespan_shutdown_retires_the_owned_container_before_reporting_completion(): + docker = Docker() + app = bridge(docker) + app.start() + responses = [] + pending = iter([{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}]) + + async def receive(): + return next(pending) + + async def send(value): + if value["type"] == "lifespan.shutdown.complete": + assert app.container is None + assert [args for args, _ in docker.calls if args[1] == "rm"] == [ + ["docker", "rm", "--force", CONTAINER] + ] + responses.append(value) + + asyncio.run(app({"type": "lifespan"}, receive, send)) + app.close() + assert responses == [ + {"type": "lifespan.startup.complete"}, + {"type": "lifespan.shutdown.complete"}, + ] + assert len([args for args, _ in docker.calls if args[1] == "rm"]) == 1 + + +def test_each_concurrent_bridge_thread_reuses_its_channel_without_serializing_other_actors(): + from concurrent.futures import ThreadPoolExecutor + from threading import Barrier + + docker = Docker() + gate = Barrier(2) + created = [] + + class Channel: + def __init__(self, command): + self.command, self.values, self.closed = command, [], False + created.append(self) + + def exchange(self, value): + if not self.values: + gate.wait(timeout=3) + self.values.append(value) + return json.dumps(docker.reply) + + def close(self): + assert [args for args, _ in docker.calls if args[1] == "rm"] == [ + ["docker", "rm", "--force", CONTAINER] + ] + self.closed = True + + app = ImageBridge( + "domus-app-ci", "native", 56174, SOURCE, "aarch64", runner=docker, channel_factory=Channel + ) + app.start() + + def run(actor): + for index in range(2): + app.exchange( + "PUT", + "/original/" + actor, + [["Idempotency-Key", actor]], + (actor + str(index)).encode(), + ) + + with ThreadPoolExecutor(max_workers=2) as pool: + list(pool.map(run, ["actor-a", "actor-b"])) + assert len(created) == 2 and all(len(channel.values) == 2 for channel in created) + assert {tuple(value["path"] for value in channel.values) for channel in created} == { + ("/original/actor-a", "/original/actor-a"), + ("/original/actor-b", "/original/actor-b"), + } + assert all( + channel.command[:3] == ["docker", "exec", "-i"] and CONTAINER in channel.command + for channel in created + ) + app.close() + app.close() + assert all(channel.closed for channel in created) diff --git a/tests/python/test_ci_contract.py b/tests/python/test_ci_contract.py index 608fde7..6b2433c 100644 --- a/tests/python/test_ci_contract.py +++ b/tests/python/test_ci_contract.py @@ -55,8 +55,8 @@ def test_native_app_images_are_built_and_exercised_on_both_cpu_families() -> Non 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-edge5 addon-edge" - in commands + "docker build --build-arg BUILD_ARCH=${{ matrix.arch }}" + " -t domus-app-edge5 tests/fixtures/edge5-app" in commands ) assert any( "docker build --file tests/fixtures/current-app/Dockerfile" in command @@ -66,6 +66,11 @@ def test_native_app_images_are_built_and_exercised_on_both_cpu_families() -> Non and command.rstrip().endswith("-t domus-app-ci .") for command in commands ) + assert any( + "docker build --build-arg BUILD_ARCH=${{ matrix.arch }}" in command + and command.rstrip().endswith("-t domus-app-edge addon-edge") + for command in commands + ) assert "uv run pytest tests/containers --no-cov" in commands assert any( "DOMUS_SOURCE_COMMIT=d013cf2c8b932d0e00f5858053cbc0e663d5eee1" in command @@ -85,6 +90,7 @@ def test_native_app_images_are_built_and_exercised_on_both_cpu_families() -> Non assert verification["env"]["DOMUS_TEST_IMAGE"] == "domus-app-ci" assert verification["env"]["DOMUS_TEST_SOURCE_COMMIT"] == "${{ github.sha }}" assert verification["env"]["DOMUS_SCHEMA7_IMAGE"] == "domus-app-edge5" + assert verification["env"]["DOMUS_EDGE_IMAGE"] == "domus-app-edge" assert any("-t domus-app-installed tests/fixtures/edge3-app" in command for command in commands) assert "containers" in jobs["aggregate"]["needs"] @@ -123,6 +129,7 @@ def test_new_runtime_sources_require_an_explicit_coverage_scope() -> None: "frontend/.storybook/main.ts", "frontend/.storybook/preview.ts", "frontend/stories/entry.stories.ts", + "frontend/stories/administration.stories.ts", "frontend/playwright.config.ts", "frontend/playwright.entry.config.ts", "frontend/vite.config.ts", @@ -247,3 +254,71 @@ def test_backend_cannot_inherit_the_tooling_coverage_result() -> None: @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 + + +def test_both_cpu_images_run_all_actual_administration_browser_journeys() -> None: + steps = workflow()["jobs"]["containers"]["steps"] + actual = next( + step + for step in steps + if step.get("name") == "Execute administration in the actual isolated image from Chromium" + ) + assert actual["env"] == { + "DOMUS_BROWSER_IMAGE": "domus-app-ci", + "DOMUS_TEST_SOURCE_COMMIT": "${{ github.sha }}", + "DOMUS_TEST_ARCH": "${{ matrix.arch }}", + } + assert "playwright.entry.config.ts tests/e2e/administration.spec.ts" in actual["run"] + assert "--grep" not in actual["run"] + assert "--pass-with-no-tests" not in actual["run"] + commands = [step["run"] for step in steps if "run" in step] + assert "pnpm install --frozen-lockfile" in commands + assert "pnpm --filter @domus/frontend exec playwright install --with-deps chromium" in commands + audit = next( + step + for step in steps + if step.get("name") == "Require all thirty-four actual-image administration journeys" + ) + assert all(field in audit["run"] for field in ["expected", "unexpected", "skipped", "flaky"]) + assert "expected']==34" in audit["run"] + cleanup = next( + step for step in steps if step.get("name") == "Require no surviving image browser fixtures" + ) + assert "docker ps --all --quiet --filter label=domus.browser-fixture=true" in cleanup["run"] + assert "assert not" in cleanup["run"] and "check_output" in cleanup["run"] + assert steps.index(cleanup) > steps.index(audit) + + +def test_both_cpu_images_require_the_immutable_schema9_application_rollback_pair() -> None: + steps = workflow()["jobs"]["containers"]["steps"] + build = next( + step + for step in steps + if step.get("name") == "Build the immutable compatible schema-9 rollback application" + ) + assert build["run"].split() == [ + "docker", + "build", + "--build-arg", + "BUILD_ARCH=${{", + "matrix.arch", + "}}", + "-t", + "domus-app-schema9", + "tests/fixtures/schema9-app", + ] + verify = next( + step for step in steps if step.get("run") == "uv run pytest tests/containers --no-cov" + ) + assert verify["env"]["DOMUS_SCHEMA9_IMAGE"] == "domus-app-schema9" + assert steps.index(build) < steps.index(verify) + fixture = (ROOT / "tests/fixtures/schema9-app/Dockerfile").read_text() + assert "DOMUS_SOURCE_COMMIT=87d726fa5a6a8cbcb56e0b911d15cc5b8a96a470" in fixture + assert ( + "DOMUS_SOURCE_SHA256=d8a9c4c1caf3acbb462ac21ec78ccbab05d756031880fd2f5013b4e528b45758" + in fixture + ) + assert "sha256sum --check" in fixture + assert "assert SCHEMA_VERSION == 9" in fixture + assert "pnpm install --frozen-lockfile" in fixture + assert "uv sync --frozen --no-dev --package domus-server --no-editable" in fixture diff --git a/tests/python/test_image_channel.py b/tests/python/test_image_channel.py new file mode 100644 index 0000000..9884f02 --- /dev/null +++ b/tests/python/test_image_channel.py @@ -0,0 +1,164 @@ +"""Real disposable stdio workers prove framing, bounded I/O and no automatic mutation replay.""" + +import json +import runpy +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from tempfile import TemporaryFile + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +ImageChannel = runpy.run_path(str(ROOT / "tests/browser/image_channel.py"))["ImageChannel"] +ECHO = ( + "import sys,json,os\nfor line in sys.stdin:\n value=json.loads(line); pri" + "nt(json.dumps({'transport_id':value['transport_id'],'response':{'pid':" + "os.getpid(),'value':value['request']}}),flush=True)" +) + + +def worker(script=ECHO, **options): + return ImageChannel([sys.executable, "-u", "-c", script], **options) + + +def test_one_real_worker_preserves_large_exact_frames_and_concurrent_contexts(): + channel = worker() + values = [ + {"key": str(index), "body": "λ" * 2**18, "revision": "9007199254740993"} + for index in range(4) + ] + try: + with ThreadPoolExecutor(max_workers=2) as pool: + replies = list(pool.map(lambda value: json.loads(channel.exchange(value)), values)) + assert [reply["value"] for reply in replies] == values + assert {reply["pid"] for reply in replies} == {channel.process.pid} + assert channel.process.poll() is None + finally: + channel.close() + assert channel.process.poll() is not None + assert channel.process.stdin.closed and channel.process.stdout.closed + channel.close() + with pytest.raises(ValueError, match="no longer available"): + channel.exchange({"key": "must-not-resend"}) + + +@pytest.mark.parametrize( + "output", ["print('x'*40)", "import os; os.write(1,b'{}\\n{}\\n')", "print('{}',end='')"] +) +def test_incomplete_excessive_or_unsolicited_response_closes_without_restarting(output): + starts = [] + + def launch(*args, **kwargs): + result = subprocess.Popen(*args, **kwargs) + starts.append(result) + return result + + channel = worker("import sys\nsys.stdin.readline()\n" + output, launcher=launch, frame_limit=32) + with pytest.raises(ValueError): + channel.exchange({"original_key": "once"}) + assert channel.closed and len(starts) == 1 and starts[0].poll() is not None + with pytest.raises(ValueError): + channel.exchange({"original_key": "once"}) + assert len(starts) == 1 + + +def test_read_deadline_closes_the_worker_and_does_not_retry_the_original_request(): + def ready(reads, writes, errors, timeout): + assert 0 < timeout <= 30 + return [], writes, [] + + channel = worker(wait=ready) + with pytest.raises(subprocess.TimeoutExpired): + channel.exchange({"original_key": "uncertain"}) + assert channel.closed and channel.process.poll() is not None + + +def test_write_deadline_closes_without_writing_or_retrying(): + channel = worker(wait=lambda *args: ([], [], [])) + with pytest.raises(subprocess.TimeoutExpired): + channel.exchange({"original_key": "not-sent"}) + assert channel.closed and channel.process.poll() is not None + + +def test_close_forces_only_its_owned_stubborn_process_and_is_idempotent(): + calls = [] + + class Process: + def __init__(self): + self.stdin, self.stdout = TemporaryFile(), TemporaryFile() + + def poll(self): + return None + + def terminate(self): + calls.append("terminate") + + def wait(self, *, timeout): + calls.append("wait") + if calls.count("wait") == 1: + raise subprocess.TimeoutExpired("owned-fixture", timeout) + return 0 + + def kill(self): + calls.append("kill") + + process = Process() + channel = ImageChannel(["owned-fixture"], launcher=lambda *args, **kwargs: process) + channel.close() + channel.close() + assert process.stdin.closed and process.stdout.closed + assert calls == ["terminate", "wait", "kill", "wait"] + + +# Boolean/float values can compare equal to 1 while retaining a different JSON-decoded type. +@pytest.mark.parametrize( + "identity", + [ + pytest.param(0, id="zero-integer"), + pytest.param(True, id="boolean"), + pytest.param(1.0, id="matching-value-float"), + pytest.param("1", id="numeric-string"), + ], +) +def test_wrong_frame_identity_cannot_be_reported_as_the_current_original_result(identity): + channel = worker( + "import sys,json\nsys.stdin.readline()\nprint(json.dumps({'transport_id':" + + repr(identity) + + ",'response':{'status':200}}))" + ) + with pytest.raises(ValueError, match="identity"): + channel.exchange({"original_key": "different-current-context"}) + assert channel.closed and channel.process.poll() is not None + + +def test_matching_integer_first_frame_is_accepted_as_the_current_original_result(): + channel = worker() + original = {"original_key": "valid-integer-first-frame"} + try: + response = json.loads(channel.exchange(original)) + assert channel.sequence == 1 + assert response == {"pid": channel.process.pid, "value": original} + assert not channel.closed and channel.process.poll() is None + finally: + channel.close() + + +def test_a_queued_request_timeout_does_not_close_or_resend_another_context(): + channel = worker() + channel.lock.acquire() + try: + channel.timeout = 0 + with pytest.raises(subprocess.TimeoutExpired): + channel.exchange({"original_key": "not-sent"}) + assert not channel.closed and channel.process.poll() is None + finally: + channel.lock.release() + try: + channel.timeout = 30 + reply = json.loads(channel.exchange({"original_key": "later-explicit-request"})) + assert reply["value"] == {"original_key": "later-explicit-request"} + assert channel.sequence == 1 + finally: + channel.close()