From ad45272a180f4e36d2dd124f950826e41311017d Mon Sep 17 00:00:00 2001 From: "copilot-action[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:14:18 +0100 Subject: [PATCH 01/27] docs(sdd): specify issue contract in specs/household-administration-browser.md --- specs/CATALOG.md | 4 +- specs/catalog.json | 3 +- specs/household-administration-browser.md | 420 ++++++++++++++++++++++ 3 files changed, 424 insertions(+), 3 deletions(-) create mode 100644 specs/household-administration-browser.md diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 25002b0..3c1183d 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 | 213 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,7 +39,7 @@ 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) diff --git a/specs/catalog.json b/specs/catalog.json index b22f970..08827b4 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": [ diff --git a/specs/household-administration-browser.md b/specs/household-administration-browser.md new file mode 100644 index 0000000..d5333bd --- /dev/null +++ b/specs/household-administration-browser.md @@ -0,0 +1,420 @@ +# 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. +- Authentication failure retires access. A read-only outage lease permits only the protected original GET until its existing expiry. +- 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. +- 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 + +```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. + +## Links + +[Household settings](#household-settings) · [Your preferences](#your-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 | +| `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/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 | +| `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/packaging/test_ui_wheel.py` | update | +| `tests/containers/test_administration_image.py` | update | +| `docs/household-administration.md` | update | +| `docs/household-administration-operations.md` | create | +| `docs/evidence/household-administration.json` | update | + +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. From a72103968dd785252ea5f9cf19e81463c0f44085 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 2 Nov 2026 22:52:36 +0100 Subject: [PATCH 02/27] feat: Coordinate household browser lifetimes and recovery --- .../household-administration-browser.json | 144 ++++++ docs/evidence/household-administration.json | 14 +- docs/household-administration.md | 11 +- frontend/src/application/entry-controller.ts | 26 +- .../src/application/household-controller.ts | 128 ++++++ frontend/tests/controller.test.ts | 85 ++++ frontend/tests/household-controller.test.ts | 419 ++++++++++++++++++ specs/CATALOG.md | 8 +- specs/catalog.json | 13 +- specs/household-administration-browser.md | 3 +- 10 files changed, 828 insertions(+), 23 deletions(-) create mode 100644 docs/evidence/household-administration-browser.json create mode 100644 frontend/src/application/household-controller.ts create mode 100644 frontend/tests/household-controller.test.ts diff --git a/docs/evidence/household-administration-browser.json b/docs/evidence/household-administration-browser.json new file mode 100644 index 0000000..af5513d --- /dev/null +++ b/docs/evidence/household-administration-browser.json @@ -0,0 +1,144 @@ +{ + "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.", + "Same-context results/drafts remain owned by their existing use cases; conflicts, invalid input and failed original-query notices are not erased by a second administration refresh." + ], + "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." + ], + "frontend": { + "executions": 1090, + "coordinatorExecutions": 50, + "entryControllerExecutions": 93, + "authoredRuntimeFiles": 29, + "nonemptyRuntimeFiles": 27, + "typeOnlyFiles": 2, + "coverage": { + "lines": { + "total": 1323, + "covered": 1323, + "skipped": 0, + "pct": 100 + }, + "statements": { + "total": 1972, + "covered": 1972, + "skipped": 0, + "pct": 100 + }, + "functions": { + "total": 397, + "covered": 397, + "skipped": 0, + "pct": 100 + }, + "branches": { + "total": 1416, + "covered": 1416, + "skipped": 0, + "pct": 100 + }, + "branchesTrue": { + "total": 0, + "covered": 0, + "skipped": 0, + "pct": 100 + } + }, + "independentFilesystemInventoryAudit": "passed" + }, + "checks": { + "runtimeAndCatalogTypes": "passed", + "nodePolicy": "38passed; all supported metrics100%", + "entryBrowser": "51passed; compiled UI against actual native/standalone Python/SQLite with test-only HA providers", + "packaging": "1passed; actual new wheel matches every151module and4compiledUI resource bytes", + "catalogBrowser": "18passed; existing entry state catalog, not new administration forms", + "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": "3kept0broken", + "documentationAndOriginalSDK": "passed;7originalpolicyblobsunchanged;original20owner documents/cases retained" + }, + "sourceIdentity": { + "backendInventorySHA256": "fb46cd890daa577fd0149587ae39310af1df91769bb5856bfd2ec709ca402101", + "backendUnchangedFromReviewedB60": true, + "frontendInventorySHA256": "1e1cca12e4d3629fec81351c6cce45b4caf2b099dd0f29dbaf507a3055b75645", + "wheelSHA256": "e68810fe0754e0b250d0dda4027d10d87b5ac5d4285cde4ad567b57e1afef5f4", + "compiledUI": { + ".vite/manifest.json": "214eed761283d87d64123887c8e3c742d247b59ee0066739c32f21a7c8fac0d6", + "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", + "assets/index-Dr0RuR81.js": "2c2347a61ddb173a5ed744f50fdaf8d2d3c54d66be936e59df9b7f9b69349ede", + "index.html": "dc3151dffd389e338a3f1d7a5a32dfa9aa94e6518f5e5376b9e235b83e1c6931" + }, + "everyModuleAndUIByteVerified": true + }, + "backendScope": "Fresh complete local4364-test campaign passes with151module supported statements/branches100%, independent audit and exact unchanged backend module bytes. The new compiled entry UI is included; this is not actual hosted CPU-image or owner HA acceptance.", + "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-page-8b1cbc1", + "privateLocalOnly": true, + "scope": "Original/control failures, current coverage/source/wheel proof and native recovery. Generated terminal fixtures retire only after unique evidence is retained." + }, + "pending": "Document runtime/lifecycle wiring and complete accessible EN/ES administration forms, Storybook/actual administration Python-SQLite journeys, all retained owner/browser cases, human UX review, owner HA adoption and both current CPU images. Recurrence23, Stable/Edge and all-domain product acceptance remain required.", + "goalComplete": false, + "acceptance": "Unit/controller and51entry journeys are not complete administration browser or owner HA acceptance.", + "recordedAtUTC": "2026-11-02T21:50:53.481264+00:00", + "backend": { + "executions": 4364, + "authoredModules": 151, + "coverage": { + "covered_lines": 10434, + "num_statements": 10434, + "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." + ] +} diff --git a/docs/evidence/household-administration.json b/docs/evidence/household-administration.json index 03141a7..4102a77 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": "ad45272a180f4e36d2dd124f950826e41311017d", + "sourceIdentity": "Current151-module backend is byte-identical to reviewed b60. The rebuilt entry UI contains the partial-read fix;29-file frontend source also contains an application coordinator which is not yet imported by document composition. Owner Edge.5 and full workspace acceptance are unchanged.", "actualProductFiles": { - ".vite/manifest.json": "bb13d320cf8968302162520e722f61f9290f9ca926c863b985288cb4fba2ffc3", - "assets/index-CQE4qeI0.js": "810376c1a54cbe125a3334afcc6ff1b81d14c27c089005d23f70a4da4c0dbcf4", + ".vite/manifest.json": "214eed761283d87d64123887c8e3c742d247b59ee0066739c32f21a7c8fac0d6", "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", - "index.html": "53eb4a7d9debd6dd01e421a564df3432c8d3c523a74acbf1f962c9714cb56911" + "assets/index-Dr0RuR81.js": "2c2347a61ddb173a5ed744f50fdaf8d2d3c54d66be936e59df9b7f9b69349ede", + "index.html": "dc3151dffd389e338a3f1d7a5a32dfa9aa94e6518f5e5376b9e235b83e1c6931" }, - "wheelSHA256": "11453b1ca6d98b08bbe718cb0b8d9fba2109bdb116cf375f66319e33fc615cd3", + "wheelSHA256": "e68810fe0754e0b250d0dda4027d10d87b5ac5d4285cde4ad567b57e1afef5f4", "originalFailure": { "run": 41505801643, "aarch64Job": 125484707823, @@ -629,7 +629,7 @@ }, "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": "Local current source:1090frontend executions/all29runtime files100%,4364backend tests/all151modules100%,51actual entry/PythonSQLite journeys,18entry-catalog states and actual wheel acceptance pass. Current CPU images and hosted review remain pending. The joint coordinator is not yet wired to the document; complete administration UI/retained acceptance and owner HA remain outstanding." }, "controllerIncrement": { "sourceBase": "53570f20bd8c89cd2940b5b8d5187eb8ed962428", diff --git a/docs/household-administration.md b/docs/household-administration.md index d69d00f..3c56081 100644 --- a/docs/household-administration.md +++ b/docs/household-administration.md @@ -43,8 +43,17 @@ one hour, with at most eight requests and 65,536 bytes shared across account/ins 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. +The browser application layer 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. It is not yet wired into the document runtime; +the complete forms and actual administration browser journeys 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) 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/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..0d546ca --- /dev/null +++ b/frontend/src/application/household-controller.ts @@ -0,0 +1,128 @@ +/** 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 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;} + private live(generation: number): boolean {return !this.closed && generation === this.generation;} + private retire(): void { + this.visible = false; this.current?.close(); this.current = 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; + // Failed administration reads expose only their controller's empty error state, never cached private facts. + if (administration.session === null) return true; + 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(); loaded = false;} + const current = this.current; + if (!loaded) await current.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; 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;} + // The administration use case already refreshed its own proof/facts and owns its actionable result. + // Refreshing it again would erase preflight conflicts and failed original-query notices. + await this.entry.refresh(); + 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/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/household-controller.test.ts b/frontend/tests/household-controller.test.ts new file mode 100644 index 0000000..029c92a --- /dev/null +++ b/frontend/tests/household-controller.test.ts @@ -0,0 +1,419 @@ +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(); +}); diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 3c1183d..ced1787 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) + 4 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 | 216 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 | @@ -42,9 +42,9 @@ debt or convert unknown historic intent into a design decision. - 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) +- 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) +- 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) ### `household-tasks` — Task definitions, occurrences and household execution diff --git a/specs/catalog.json b/specs/catalog.json index 08827b4..a6f9d91 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -326,7 +326,8 @@ "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" ], "tests": [ "tests/backend/test_identity_policy.py", @@ -403,7 +404,8 @@ "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" ], "documentation": [ "docs/product-vision.md", @@ -453,12 +455,13 @@ "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" ], "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: verified schema9/profile APIs, bounded HA source facts, exact HTTP/journals, protected original GET and administration controller; application joint coordinator and optional entry reads tested at1090frontend executions/29runtime files/full supported coverage. Complete document wiring, EN/ES administration forms, actual 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 index d5333bd..a9f2d6d 100644 --- a/specs/household-administration-browser.md +++ b/specs/household-administration-browser.md @@ -118,7 +118,7 @@ Text equivalent: verified entry → current matched authority → permitted form ### 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. -- Authentication failure retires access. A read-only outage lease permits only the protected original GET until its existing expiry. +- 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. - 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. @@ -362,6 +362,7 @@ Tests first. Preserve repository-wide supported coverage, complete global and pe | `docs/household-administration.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. From 185caef9cba900c86fa402f60c4c5e235208ab64 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 2 Nov 2026 23:29:09 +0100 Subject: [PATCH 03/27] fix: Reconcile current membership after every administration action --- .../household-administration-browser.json | 61 ++++++++++---- docs/household-administration.md | 5 +- .../application/administration-controller.ts | 9 +++ .../src/application/household-controller.ts | 17 ++-- .../tests/administration-controller.test.ts | 80 ++++++++++++++++++- frontend/tests/household-controller.test.ts | 31 +++++++ specs/catalog.json | 2 +- 7 files changed, 177 insertions(+), 28 deletions(-) diff --git a/docs/evidence/household-administration-browser.json b/docs/evidence/household-administration-browser.json index af5513d..b8d208f 100644 --- a/docs/evidence/household-administration-browser.json +++ b/docs/evidence/household-administration-browser.json @@ -23,43 +23,45 @@ "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.", - "Same-context results/drafts remain owned by their existing use cases; conflicts, invalid input and failed original-query notices are not erased by a second administration refresh." + "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." ], "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." + "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." ], "frontend": { - "executions": 1090, - "coordinatorExecutions": 50, + "executions": 1108, + "coordinatorExecutions": 54, "entryControllerExecutions": 93, "authoredRuntimeFiles": 29, "nonemptyRuntimeFiles": 27, "typeOnlyFiles": 2, "coverage": { "lines": { - "total": 1323, - "covered": 1323, + "total": 1331, + "covered": 1331, "skipped": 0, "pct": 100 }, "statements": { - "total": 1972, - "covered": 1972, + "total": 1986, + "covered": 1986, "skipped": 0, "pct": 100 }, "functions": { - "total": 397, - "covered": 397, + "total": 398, + "covered": 398, "skipped": 0, "pct": 100 }, "branches": { - "total": 1416, - "covered": 1416, + "total": 1434, + "covered": 1434, "skipped": 0, "pct": 100 }, @@ -70,7 +72,8 @@ "pct": 100 } }, - "independentFilesystemInventoryAudit": "passed" + "independentFilesystemInventoryAudit": "passed", + "administrationControllerExecutions": 125 }, "checks": { "runtimeAndCatalogTypes": "passed", @@ -87,7 +90,7 @@ "sourceIdentity": { "backendInventorySHA256": "fb46cd890daa577fd0149587ae39310af1df91769bb5856bfd2ec709ca402101", "backendUnchangedFromReviewedB60": true, - "frontendInventorySHA256": "1e1cca12e4d3629fec81351c6cce45b4caf2b099dd0f29dbaf507a3055b75645", + "frontendInventorySHA256": "3eaedc962f04aac9a5c1292c8d5121d6221ea9583b6e1810fabacfcd19bfe35e", "wheelSHA256": "e68810fe0754e0b250d0dda4027d10d87b5ac5d4285cde4ad567b57e1afef5f4", "compiledUI": { ".vite/manifest.json": "214eed761283d87d64123887c8e3c742d247b59ee0066739c32f21a7c8fac0d6", @@ -112,10 +115,10 @@ "privateLocalOnly": true, "scope": "Original/control failures, current coverage/source/wheel proof and native recovery. Generated terminal fixtures retire only after unique evidence is retained." }, - "pending": "Document runtime/lifecycle wiring and complete accessible EN/ES administration forms, Storybook/actual administration Python-SQLite journeys, all retained owner/browser cases, human UX review, owner HA adoption and both current CPU images. Recurrence23, Stable/Edge and all-domain product acceptance remain required.", + "pending": "Document runtime/lifecycle wiring and complete accessible EN/ES administration forms, Storybook/actual administration Python-SQLite journeys, all retained owner/browser cases, human UX review, owner HA adoption and fresh current-commit CI/native review. Recurrence23, Stable/Edge and all-domain product acceptance remain required.", "goalComplete": false, "acceptance": "Unit/controller and51entry journeys are not complete administration browser or owner HA acceptance.", - "recordedAtUTC": "2026-11-02T21:50:53.481264+00:00", + "recordedAtUTC": "2026-11-02T22:28:15.666501+00:00", "backend": { "executions": 4364, "authoredModules": 151, @@ -140,5 +143,29 @@ "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." - ] + ], + "scopedCorrection": { + "baseline": "a72103968dd785252ea5f9cf19e81463c0f44085", + "source": "Current working controller correction; document composition is still pending", + "types": "runtime and catalog passed", + "coverageAudit": "all29authoredruntimefiles and all4metrics100%", + "artifact": "Rebuilt compiled entry bundle has exactly the same four resource hashes; this still-unused controller correction does not change the existing verified wheel or Python modules." + }, + "publishedBaselineReview": { + "head": "a72103968dd785252ea5f9cf19e81463c0f44085", + "run": 41532971541, + "job": 125577616679, + "context": "complete", + "partitions": "3/3", + "candidateFindings": 0, + "publishedFindings": 0, + "outcome": "no-findings", + "currentCorrectionReview": "pending normal commit/push" + }, + "publishedBaselineCI": { + "head": "a72103968dd785252ea5f9cf19e81463c0f44085", + "run": 41532971690, + "result": "success; all applicable suites and both actual CPU images passed", + "currentCorrectionCI": "pending normal commit/push" + } } diff --git a/docs/household-administration.md b/docs/household-administration.md index 3c56081..545cfa1 100644 --- a/docs/household-administration.md +++ b/docs/household-administration.md @@ -46,7 +46,10 @@ foundation does not yet provide a browser recovery action or automatically resen The browser application layer 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. It is not yet wired into the document runtime; +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. It is not yet wired into the document runtime; the complete forms and actual administration browser journeys 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). diff --git a/frontend/src/application/administration-controller.ts b/frontend/src/application/administration-controller.ts index c04d23e..cc32db3 100644 --- a/frontend/src/application/administration-controller.ts +++ b/frontend/src/application/administration-controller.ts @@ -145,6 +145,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; diff --git a/frontend/src/application/household-controller.ts b/frontend/src/application/household-controller.ts index 0d546ca..af202c9 100644 --- a/frontend/src/application/household-controller.ts +++ b/frontend/src/application/household-controller.ts @@ -12,6 +12,7 @@ function sameGrants(left: readonly string[], right: readonly string[]): boolean export class HouseholdController { private current: AdministrationController | null = null; + private currentContext: JournalContext | null = null; private visible = false; private running = false; private closed = false; @@ -26,7 +27,7 @@ export class HouseholdController { get administration(): AdministrationView | null {return this.visible ? this.current!.view : null;} private live(generation: number): boolean {return !this.closed && generation === this.generation;} private retire(): void { - this.visible = false; this.current?.close(); this.current = null; + this.visible = false; this.current?.close(); this.current = null; this.currentContext = null; } private eligible(): boolean { const view = this.entry.view; @@ -34,8 +35,8 @@ export class HouseholdController { } private matched(): boolean { const entry = this.entry.view, administration = this.current!.view; - // Failed administration reads expose only their controller's empty error state, never cached private facts. - if (administration.session === null) return true; + // 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 @@ -61,10 +62,13 @@ export class HouseholdController { 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(); loaded = false;} + 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;} @@ -107,9 +111,8 @@ export class HouseholdController { if (!this.live(generation)) return; this.tick(); if (!this.live(generation)) return; if (!this.eligible()) {this.retire(); return;} - // The administration use case already refreshed its own proof/facts and owns its actionable result. - // Refreshing it again would erase preflight conflicts and failed original-query notices. - await this.entry.refresh(); + // 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); }); } diff --git a/frontend/tests/administration-controller.test.ts b/frontend/tests/administration-controller.test.ts index 0234f82..3ff622b 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} 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'}}), @@ -502,3 +502,79 @@ 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); +}); diff --git a/frontend/tests/household-controller.test.ts b/frontend/tests/household-controller.test.ts index 029c92a..6015d31 100644 --- a/frontend/tests/household-controller.test.ts +++ b/frontend/tests/household-controller.test.ts @@ -417,3 +417,34 @@ test('HADB-01/C07: disposal during logout fences the late revocation response as 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'); +}); diff --git a/specs/catalog.json b/specs/catalog.json index a6f9d91..01a7dfb 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -460,7 +460,7 @@ ], "readiness": { "state": "draft", - "implementationEvidence": "partial: verified schema9/profile APIs, bounded HA source facts, exact HTTP/journals, protected original GET and administration controller; application joint coordinator and optional entry reads tested at1090frontend executions/29runtime files/full supported coverage. Complete document wiring, EN/ES administration forms, actual administration journeys and retained acceptance remain pending.", + "implementationEvidence": "partial: verified schema9/profile APIs, bounded HA source facts, exact HTTP/journals, protected original GET and administration controller; application joint coordinator and optional entry reads tested at1108frontend executions/29runtime files/full supported coverage. Complete document wiring, EN/ES administration forms, actual 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 } From dfb488acf984b4ec3ab76df36007b8bd0f19be5c Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 2 Nov 2026 23:44:11 +0100 Subject: [PATCH 04/27] feat: Compose administration into the owned browser runtime --- .../household-administration-browser.json | 94 +++++---- docs/evidence/household-administration.json | 14 +- docs/household-administration.md | 14 +- .../src/application/household-controller.ts | 7 + frontend/src/composition/entry-runtime.ts | 35 ++-- frontend/tests/e2e/entry.spec.ts | 16 ++ frontend/tests/household-controller.test.ts | 9 + frontend/tests/runtime.test.ts | 198 +++++++++++++++++- specs/catalog.json | 2 +- tests/browser/entry_server.py | 28 ++- 10 files changed, 348 insertions(+), 69 deletions(-) diff --git a/docs/evidence/household-administration-browser.json b/docs/evidence/household-administration-browser.json index b8d208f..6158668 100644 --- a/docs/evidence/household-administration-browser.json +++ b/docs/evidence/household-administration-browser.json @@ -23,7 +23,8 @@ "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." + "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." ], "testsFirst": [ "Real Chromium entry controls first failed16/passed76 against original code; all93 entry cases now pass.", @@ -31,37 +32,39 @@ "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." + "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." ], "frontend": { - "executions": 1108, - "coordinatorExecutions": 54, + "executions": 1124, + "coordinatorExecutions": 55, "entryControllerExecutions": 93, "authoredRuntimeFiles": 29, "nonemptyRuntimeFiles": 27, "typeOnlyFiles": 2, "coverage": { "lines": { - "total": 1331, - "covered": 1331, + "total": 1338, + "covered": 1338, "skipped": 0, "pct": 100 }, "statements": { - "total": 1986, - "covered": 1986, + "total": 1997, + "covered": 1997, "skipped": 0, "pct": 100 }, "functions": { - "total": 398, - "covered": 398, + "total": 408, + "covered": 408, "skipped": 0, "pct": 100 }, "branches": { - "total": 1434, - "covered": 1434, + "total": 1439, + "covered": 1439, "skipped": 0, "pct": 100 }, @@ -73,14 +76,15 @@ } }, "independentFilesystemInventoryAudit": "passed", - "administrationControllerExecutions": 125 + "administrationControllerExecutions": 125, + "runtimeExecutions": 39 }, "checks": { "runtimeAndCatalogTypes": "passed", "nodePolicy": "38passed; all supported metrics100%", - "entryBrowser": "51passed; compiled UI against actual native/standalone Python/SQLite with test-only HA providers", - "packaging": "1passed; actual new wheel matches every151module and4compiledUI resource bytes", - "catalogBrowser": "18passed; existing entry state catalog, not new administration forms", + "entryBrowser": "53passed; new compiled runtime against actual native/standalone Python/SQLite; two new cases verify real administration roster/directory/private-preference reads after explicit setup. Complete administration forms remain pending.", + "packaging": "1passed; actual new wheel matches every151module and4compiledUIresource bytes", + "catalogBrowser": "18passed; rebuilt existing entry-state catalog, not full administration forms", "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", @@ -90,13 +94,13 @@ "sourceIdentity": { "backendInventorySHA256": "fb46cd890daa577fd0149587ae39310af1df91769bb5856bfd2ec709ca402101", "backendUnchangedFromReviewedB60": true, - "frontendInventorySHA256": "3eaedc962f04aac9a5c1292c8d5121d6221ea9583b6e1810fabacfcd19bfe35e", - "wheelSHA256": "e68810fe0754e0b250d0dda4027d10d87b5ac5d4285cde4ad567b57e1afef5f4", + "frontendInventorySHA256": "85b846de8b041d0c5af13f18b0f71d5c715cf4e1ed1010cf314b7bc094d401aa", + "wheelSHA256": "b18f39b7b1c94d3ccd34b5aa849caabb0dc58faf1312e14f064e2280cfe30fee", "compiledUI": { - ".vite/manifest.json": "214eed761283d87d64123887c8e3c742d247b59ee0066739c32f21a7c8fac0d6", + ".vite/manifest.json": "7e7e447d17e1e8a0a7702629876b9b0e28bedfa34c3e150d137cb258125e301b", "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", - "assets/index-Dr0RuR81.js": "2c2347a61ddb173a5ed744f50fdaf8d2d3c54d66be936e59df9b7f9b69349ede", - "index.html": "dc3151dffd389e338a3f1d7a5a32dfa9aa94e6518f5e5376b9e235b83e1c6931" + "assets/index-CrXaCVKh.js": "6e4f8bb88867989dfdc947fbc8835785a52966b5a32faed7fb6ad7363f40c371", + "index.html": "5f14a1bd5a89c41a4fb123f71d3c42fd7ba1e1df93deeeeed442b1d893e2bc19" }, "everyModuleAndUIByteVerified": true }, @@ -111,14 +115,18 @@ "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-page-8b1cbc1", + "root": "/private/tmp/domus-admin-runtime-185caef", "privateLocalOnly": true, - "scope": "Original/control failures, current coverage/source/wheel proof and native recovery. Generated terminal fixtures retire only after unique evidence is retained." + "previousCompactEvidence": { + "path": "/private/tmp/domus-admin-page-8b1cbc1/verification-evidence.tar.gz", + "sha256": "e0a7b4be32999613f84ed2f23c52247f66660a1a66e6ed135fa9ca3402e69508" + }, + "scope": "Original runtime failures, current coverage/source/wheel proof and current published-head review/progress evidence. The two original fixture-provider failures retain actual traces/screenshots under provider-original-artifacts and their original results. No owner HA was used." }, - "pending": "Document runtime/lifecycle wiring and complete accessible EN/ES administration forms, Storybook/actual administration Python-SQLite journeys, all retained owner/browser cases, human UX review, owner HA adoption and fresh current-commit CI/native review. Recurrence23, Stable/Edge and all-domain product acceptance remain required.", + "pending": "Complete accessible EN/ES administration forms and page intents, Storybook/actual administration Python-SQLite journeys, independent own-alias read/revision feasibility, all retained owner/browser cases, human UX review, owner HA adoption and fresh current-commit CI/native review. Recurrence23, Stable/Edge and all-domain product acceptance remain required.", "goalComplete": false, "acceptance": "Unit/controller and51entry journeys are not complete administration browser or owner HA acceptance.", - "recordedAtUTC": "2026-11-02T22:28:15.666501+00:00", + "recordedAtUTC": "2026-11-02T22:38:53.838542+00:00", "backend": { "executions": 4364, "authoredModules": 151, @@ -146,26 +154,42 @@ ], "scopedCorrection": { "baseline": "a72103968dd785252ea5f9cf19e81463c0f44085", - "source": "Current working controller correction; document composition is still pending", + "source": "Published185caef correction before runtime composition; its scoped proof is retained as historical evidence.", "types": "runtime and catalog passed", "coverageAudit": "all29authoredruntimefiles and all4metrics100%", - "artifact": "Rebuilt compiled entry bundle has exactly the same four resource hashes; this still-unused controller correction does not change the existing verified wheel or Python modules." + "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": "a72103968dd785252ea5f9cf19e81463c0f44085", - "run": 41532971541, - "job": 125577616679, + "head": "185caef9cba900c86fa402f60c4c5e235208ab64", + "run": 41537674919, + "job": 125593575019, "context": "complete", "partitions": "3/3", "candidateFindings": 0, "publishedFindings": 0, "outcome": "no-findings", - "currentCorrectionReview": "pending normal commit/push" + "currentRuntimeReview": "pending normal commit/push" }, "publishedBaselineCI": { - "head": "a72103968dd785252ea5f9cf19e81463c0f44085", - "run": 41532971690, - "result": "success; all applicable suites and both actual CPU images passed", - "currentCorrectionCI": "pending normal commit/push" + "head": "185caef9cba900c86fa402f60c4c5e235208ab64", + "run": 41537674620, + "result": "success; all applicable suites, actual amd64 job125593572845 and actual aarch64 job125593572785 passed; aggregate125597006263", + "currentRuntimeCI": "pending normal commit/push" + }, + "currentRuntimeScope": { + "sourceBase": "185caef9cba900c86fa402f60c4c5e235208ab64", + "state": "working runtime composition; not yet committed", + "authoredRuntimeFiles": 29, + "administrationViews": "Current callback/property is composed; complete administration document presentation remains pending.", + "localBackend": "All151Pythonmodulebytes unchanged from complete local4364/100% baseline; no new backend execution is claimed for this controller/runtime-only change." + }, + "copilotProgressRepeat": { + "report": "https://github.com/vypdev/copilot/issues/828#issuecomment-6590265451", + "run": 41537666041, + "job": 125593544133, + "head": "185caef9cba900c86fa402f60c4c5e235208ab64", + "reason": "scope-clause-limit; field $.lines; requirement none; source L150", + "reference": "ed1c6c8a-4244-4752-8eef-19c5d0947f01", + "scope": "Separate commit progress rejection; same-head complete PR-native review has no findings. No Copilot change or inferred root cause." } } diff --git a/docs/evidence/household-administration.json b/docs/evidence/household-administration.json index 4102a77..3165206 100644 --- a/docs/evidence/household-administration.json +++ b/docs/evidence/household-administration.json @@ -612,15 +612,15 @@ } }, "currentSourcePackage": { - "sourceBase": "ad45272a180f4e36d2dd124f950826e41311017d", - "sourceIdentity": "Current151-module backend is byte-identical to reviewed b60. The rebuilt entry UI contains the partial-read fix;29-file frontend source also contains an application coordinator which is not yet imported by document composition. Owner Edge.5 and full workspace acceptance are unchanged.", + "sourceBase": "185caef9cba900c86fa402f60c4c5e235208ab64", + "sourceIdentity": "Current151-module backend remains byte-identical to reviewed b60. The new29-file frontend now composes administration HTTP/controllers/journal into the document lifecycle; entry presentation remains. Complete administration forms and owner HA Edge.5 adoption remain pending.", "actualProductFiles": { - ".vite/manifest.json": "214eed761283d87d64123887c8e3c742d247b59ee0066739c32f21a7c8fac0d6", + ".vite/manifest.json": "7e7e447d17e1e8a0a7702629876b9b0e28bedfa34c3e150d137cb258125e301b", "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", - "assets/index-Dr0RuR81.js": "2c2347a61ddb173a5ed744f50fdaf8d2d3c54d66be936e59df9b7f9b69349ede", - "index.html": "dc3151dffd389e338a3f1d7a5a32dfa9aa94e6518f5e5376b9e235b83e1c6931" + "assets/index-CrXaCVKh.js": "6e4f8bb88867989dfdc947fbc8835785a52966b5a32faed7fb6ad7363f40c371", + "index.html": "5f14a1bd5a89c41a4fb123f71d3c42fd7ba1e1df93deeeeed442b1d893e2bc19" }, - "wheelSHA256": "e68810fe0754e0b250d0dda4027d10d87b5ac5d4285cde4ad567b57e1afef5f4", + "wheelSHA256": "b18f39b7b1c94d3ccd34b5aa849caabb0dc58faf1312e14f064e2280cfe30fee", "originalFailure": { "run": 41505801643, "aarch64Job": 125484707823, @@ -629,7 +629,7 @@ }, "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": "Local current source:1090frontend executions/all29runtime files100%,4364backend tests/all151modules100%,51actual entry/PythonSQLite journeys,18entry-catalog states and actual wheel acceptance pass. Current CPU images and hosted review remain pending. The joint coordinator is not yet wired to the document; complete administration UI/retained acceptance and owner HA remain outstanding." + "acceptance": "Actual new wheel matches every151module and4compiledUIresource bytes; packaging1,53actual entry-plus-administration-read/PythonSQLite and18existing-entry catalog cases pass. Fresh current-commit CPU/native review and complete administration UI acceptance remain pending; see household-administration-browser.json." }, "controllerIncrement": { "sourceBase": "53570f20bd8c89cd2940b5b8d5187eb8ed962428", diff --git a/docs/household-administration.md b/docs/household-administration.md index 545cfa1..a8eb908 100644 --- a/docs/household-administration.md +++ b/docs/household-administration.md @@ -37,20 +37,22 @@ 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. +foundation does not yet render an administration recovery action. Recovery remains explicit; +the runtime does not automatically resend a mutation or query an original result. -The browser application layer now coordinates entry and administration proof, earlier lease expiry, +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. It is not yet wired into the document runtime; -the complete forms and actual administration browser journeys remain under development in +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 visible forms and administration browser journeys 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). diff --git a/frontend/src/application/household-controller.ts b/frontend/src/application/household-controller.ts index af202c9..e064a63 100644 --- a/frontend/src/application/household-controller.ts +++ b/frontend/src/application/household-controller.ts @@ -25,6 +25,13 @@ export class HouseholdController { 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; 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/tests/e2e/entry.spec.ts b/frontend/tests/e2e/entry.spec.ts index 3f7be24..ec946db 100644 --- a/frontend/tests/e2e/entry.spec.ts +++ b/frontend/tests/e2e/entry.spec.ts @@ -83,6 +83,22 @@ 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 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]); + expect((await roster.json()).members[0].metadata).toMatchObject({state: 'available', name: 'Fictional account a', avatar_state: 'unavailable'}); + expect(await preferences.json()).toMatchObject({locale: null, routine_participation_summaries: true, notification_delivery: 'unavailable'}); + expect((await directory.json()).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('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 index 6015d31..ab50728 100644 --- a/frontend/tests/household-controller.test.ts +++ b/frontend/tests/household-controller.test.ts @@ -448,3 +448,12 @@ test('household-browser-C02: an empty administration error from the earlier cont 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/runtime.test.ts b/frontend/tests/runtime.test.ts index 27f6dfe..e09ac2e 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,40 @@ 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.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 +82,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 +304,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/specs/catalog.json b/specs/catalog.json index 01a7dfb..425d2cd 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -460,7 +460,7 @@ ], "readiness": { "state": "draft", - "implementationEvidence": "partial: verified schema9/profile APIs, bounded HA source facts, exact HTTP/journals, protected original GET and administration controller; application joint coordinator and optional entry reads tested at1108frontend executions/29runtime files/full supported coverage. Complete document wiring, EN/ES administration forms, actual administration journeys and retained acceptance remain pending.", + "implementationEvidence": "partial: verified schema9/profile APIs, bounded HA source facts, exact HTTP/journals, protected original GET and current-member reconciliation; actual document runtime now composes both application controllers, protected HTTP and separate journals with owned deadlines/lifecycles.1124frontend executions/29runtime files/full supported coverage;51actual compiled entry journeys. Complete EN/ES administration 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/tests/browser/entry_server.py b/tests/browser/entry_server.py index 963159c..e88df05 100644 --- a/tests/browser/entry_server.py +++ b/tests/browser/entry_server.py @@ -24,7 +24,7 @@ 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 +66,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: From 86e120ac3254a7b4b91d3d4b817f5234a0916f51 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 3 Nov 2026 00:07:22 +0100 Subject: [PATCH 05/27] fix: Keep own alias editing independent of roster access --- .../household_administration_queries.py | 13 +- .../household-administration-browser.json | 125 ++++++++++-------- docs/evidence/household-administration.json | 14 +- docs/household-administration.md | 5 + docs/household-api.md | 2 +- .../application/administration-controller.ts | 20 ++- .../tests/administration-controller.test.ts | 30 +++++ frontend/tests/e2e/entry.spec.ts | 42 +++++- frontend/tests/runtime.test.ts | 2 + specs/catalog.json | 2 +- specs/household-administration-browser.md | 5 + tests/backend/test_administration_http.py | 29 ++++ tests/backend/test_member_profile_commands.py | 53 ++++++++ 13 files changed, 266 insertions(+), 76 deletions(-) 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 index 6158668..b3672b9 100644 --- a/docs/evidence/household-administration-browser.json +++ b/docs/evidence/household-administration-browser.json @@ -24,7 +24,8 @@ "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." + "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.", @@ -34,10 +35,13 @@ "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." + "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": 1124, + "executions": 1129, "coordinatorExecutions": 55, "entryControllerExecutions": 93, "authoredRuntimeFiles": 29, @@ -45,26 +49,26 @@ "typeOnlyFiles": 2, "coverage": { "lines": { - "total": 1338, - "covered": 1338, + "total": 1341, + "covered": 1341, "skipped": 0, "pct": 100 }, "statements": { - "total": 1997, - "covered": 1997, + "total": 1999, + "covered": 1999, "skipped": 0, "pct": 100 }, "functions": { - "total": 408, - "covered": 408, + "total": 409, + "covered": 409, "skipped": 0, "pct": 100 }, "branches": { - "total": 1439, - "covered": 1439, + "total": 1446, + "covered": 1446, "skipped": 0, "pct": 100 }, @@ -76,35 +80,38 @@ } }, "independentFilesystemInventoryAudit": "passed", - "administrationControllerExecutions": 125, + "administrationControllerExecutions": 130, "runtimeExecutions": 39 }, "checks": { "runtimeAndCatalogTypes": "passed", "nodePolicy": "38passed; all supported metrics100%", - "entryBrowser": "53passed; new compiled runtime against actual native/standalone Python/SQLite; two new cases verify real administration roster/directory/private-preference reads after explicit setup. Complete administration forms remain pending.", - "packaging": "1passed; actual new wheel matches every151module and4compiledUIresource bytes", - "catalogBrowser": "18passed; rebuilt existing entry-state catalog, not full administration forms", + "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": "3kept0broken", - "documentationAndOriginalSDK": "passed;7originalpolicyblobsunchanged;original20owner documents/cases retained" + "pythonArchitecture": "51assertions passed", + "documentationAndOriginalSDK": "original7policyblobs unchanged; format/consistency/catalog pass with all20retainedowner documents and72/8/82cases preserved;24browser refinements remain required" }, "sourceIdentity": { - "backendInventorySHA256": "fb46cd890daa577fd0149587ae39310af1df91769bb5856bfd2ec709ca402101", - "backendUnchangedFromReviewedB60": true, - "frontendInventorySHA256": "85b846de8b041d0c5af13f18b0f71d5c715cf4e1ed1010cf314b7bc094d401aa", - "wheelSHA256": "b18f39b7b1c94d3ccd34b5aa849caabb0dc58faf1312e14f064e2280cfe30fee", + "backendInventorySHA256": "ce4601f050a56a4bf4e22c5ac6e255fb1c7a9954c7a33d6bc3f0c261834d60b5", + "backendUnchangedFromReviewedB60": false, + "changedBackendModules": [ + "domus/application/household_administration_queries.py" + ], + "frontendInventorySHA256": "f3485b8718a077ca88c94d751e53951d65c7a177ac4c02fd8f6e0a50b85b7fa7", + "wheelSHA256": "d79751a49cb89891ba1a2cde0086f77c271685dc6af4bd170423c3f244401f7e", "compiledUI": { - ".vite/manifest.json": "7e7e447d17e1e8a0a7702629876b9b0e28bedfa34c3e150d137cb258125e301b", + ".vite/manifest.json": "52478f33798af6644baf27ac754833d63b8e64676483cc76159c4c8abd83da75", + "assets/index-1tQXZaXm.js": "5f665d480483e7368d27d6e1bf9f858d16a77323e41fb804d2371e8985d677ed", "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", - "assets/index-CrXaCVKh.js": "6e4f8bb88867989dfdc947fbc8835785a52966b5a32faed7fb6ad7363f40c371", - "index.html": "5f14a1bd5a89c41a4fb123f71d3c42fd7ba1e1df93deeeeed442b1d893e2bc19" + "index.html": "1046899d4a844c45dd67f51686d31941edc0a7b8401d0c0ba4ccc8fed799d9bf" }, "everyModuleAndUIByteVerified": true }, - "backendScope": "Fresh complete local4364-test campaign passes with151module supported statements/branches100%, independent audit and exact unchanged backend module bytes. The new compiled entry UI is included; this is not actual hosted CPU-image or owner HA acceptance.", + "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.", @@ -115,24 +122,24 @@ "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-runtime-185caef", + "root": "/private/tmp/domus-admin-own-alias-dfb488a", "privateLocalOnly": true, "previousCompactEvidence": { - "path": "/private/tmp/domus-admin-page-8b1cbc1/verification-evidence.tar.gz", - "sha256": "e0a7b4be32999613f84ed2f23c52247f66660a1a66e6ed135fa9ca3402e69508" + "path": "/private/tmp/domus-admin-runtime-185caef/verification-evidence.tar.gz", + "sha256": "c39e1e6b656847853bceaca1b0c21f6e0d1ea8ddeeb5f8bd52df12267aab30b5" }, - "scope": "Original runtime failures, current coverage/source/wheel proof and current published-head review/progress evidence. The two original fixture-provider failures retain actual traces/screenshots under provider-original-artifacts and their original results. No owner HA was used." + "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": "Complete accessible EN/ES administration forms and page intents, Storybook/actual administration Python-SQLite journeys, independent own-alias read/revision feasibility, all retained owner/browser cases, human UX review, owner HA adoption and fresh current-commit CI/native review. Recurrence23, Stable/Edge and all-domain product acceptance remain required.", + "pending": "Complete accessible EN/ES administration forms and closed page intents, administration Storybook/actual complete administration Python-SQLite journeys, all retained owner/browser cases, human UX review, owner HA adoption and fresh current-commit CI/native review. Recurrence23, Stable/Edge and all-domain product acceptance remain required.", "goalComplete": false, "acceptance": "Unit/controller and51entry journeys are not complete administration browser or owner HA acceptance.", - "recordedAtUTC": "2026-11-02T22:38:53.838542+00:00", + "recordedAtUTC": "2026-11-02T23:06:09.802178+00:00", "backend": { - "executions": 4364, + "executions": 4374, "authoredModules": 151, "coverage": { - "covered_lines": 10434, - "num_statements": 10434, + "covered_lines": 10438, + "num_statements": 10438, "percent_covered": 100.0, "percent_covered_display": "100.00", "missing_lines": 0, @@ -150,7 +157,8 @@ }, "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." + "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", @@ -160,36 +168,43 @@ "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": "185caef9cba900c86fa402f60c4c5e235208ab64", - "run": 41537674919, - "job": 125593575019, + "head": "dfb488acf984b4ec3ab76df36007b8bd0f19be5c", + "run": 41539766059, + "job": 125600639709, "context": "complete", - "partitions": "3/3", + "partitions": "4/4", "candidateFindings": 0, "publishedFindings": 0, "outcome": "no-findings", - "currentRuntimeReview": "pending normal commit/push" + "currentCorrectionReview": "pending normal commit/push" }, "publishedBaselineCI": { - "head": "185caef9cba900c86fa402f60c4c5e235208ab64", - "run": 41537674620, - "result": "success; all applicable suites, actual amd64 job125593572845 and actual aarch64 job125593572785 passed; aggregate125597006263", - "currentRuntimeCI": "pending normal commit/push" + "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": "185caef9cba900c86fa402f60c4c5e235208ab64", - "state": "working runtime composition; not yet committed", + "sourceBase": "dfb488acf984b4ec3ab76df36007b8bd0f19be5c", + "state": "Current own-alias and protected response-capture correction over the published runtime; not yet committed", "authoredRuntimeFiles": 29, - "administrationViews": "Current callback/property is composed; complete administration document presentation remains pending.", - "localBackend": "All151Pythonmodulebytes unchanged from complete local4364/100% baseline; no new backend execution is claimed for this controller/runtime-only change." + "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-6590265451", - "run": 41537666041, - "job": 125593544133, - "head": "185caef9cba900c86fa402f60c4c5e235208ab64", - "reason": "scope-clause-limit; field $.lines; requirement none; source L150", - "reference": "ed1c6c8a-4244-4752-8eef-19c5d0947f01", - "scope": "Separate commit progress rejection; same-head complete PR-native review has no findings. No Copilot change or inferred root cause." + "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." } } diff --git a/docs/evidence/household-administration.json b/docs/evidence/household-administration.json index 3165206..834b55f 100644 --- a/docs/evidence/household-administration.json +++ b/docs/evidence/household-administration.json @@ -612,15 +612,15 @@ } }, "currentSourcePackage": { - "sourceBase": "185caef9cba900c86fa402f60c4c5e235208ab64", - "sourceIdentity": "Current151-module backend remains byte-identical to reviewed b60. The new29-file frontend now composes administration HTTP/controllers/journal into the document lifecycle; entry presentation remains. Complete administration forms and owner HA Edge.5 adoption remain pending.", + "sourceBase": "dfb488acf984b4ec3ab76df36007b8bd0f19be5c", + "sourceIdentity": "Current151-module backend changes only the public-presentation query to retain member-read or applicable current-owner preference-read authority. The29-file frontend reads the own alias/revision independently from roster. Schema9, private preference scope and full administration forms/owner Edge.5 adoption remain unchanged/pending.", "actualProductFiles": { - ".vite/manifest.json": "7e7e447d17e1e8a0a7702629876b9b0e28bedfa34c3e150d137cb258125e301b", + ".vite/manifest.json": "52478f33798af6644baf27ac754833d63b8e64676483cc76159c4c8abd83da75", + "assets/index-1tQXZaXm.js": "5f665d480483e7368d27d6e1bf9f858d16a77323e41fb804d2371e8985d677ed", "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", - "assets/index-CrXaCVKh.js": "6e4f8bb88867989dfdc947fbc8835785a52966b5a32faed7fb6ad7363f40c371", - "index.html": "5f14a1bd5a89c41a4fb123f71d3c42fd7ba1e1df93deeeeed442b1d893e2bc19" + "index.html": "1046899d4a844c45dd67f51686d31941edc0a7b8401d0c0ba4ccc8fed799d9bf" }, - "wheelSHA256": "b18f39b7b1c94d3ccd34b5aa849caabb0dc58faf1312e14f064e2280cfe30fee", + "wheelSHA256": "d79751a49cb89891ba1a2cde0086f77c271685dc6af4bd170423c3f244401f7e", "originalFailure": { "run": 41505801643, "aarch64Job": 125484707823, @@ -629,7 +629,7 @@ }, "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": "Actual new wheel matches every151module and4compiledUIresource bytes; packaging1,53actual entry-plus-administration-read/PythonSQLite and18existing-entry catalog cases pass. Fresh current-commit CPU/native review and complete administration UI acceptance remain pending; see household-administration-browser.json." + "acceptance": "Actual d797 wheel byte-verifies all151Pythonmodules and4compiledUIresources. Full4374backend/1129frontend supported coverage, independent inventories,55actual entry/administration-read/PythonSQLite and wheel acceptance pass. Fresh current-commit CPU/native review and complete visible administration acceptance remain pending." }, "controllerIncrement": { "sourceBase": "53570f20bd8c89cd2940b5b8d5187eb8ed962428", diff --git a/docs/household-administration.md b/docs/household-administration.md index a8eb908..72d2451 100644 --- a/docs/household-administration.md +++ b/docs/household-administration.md @@ -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 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/frontend/src/application/administration-controller.ts b/frontend/src/application/administration-controller.ts index cc32db3..4d2e285 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'; @@ -30,7 +30,7 @@ type Retained = Readonly<{context: JournalContext; request: AdministrationRecord 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 { @@ -96,7 +96,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 +109,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'; @@ -177,7 +181,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; } } diff --git a/frontend/tests/administration-controller.test.ts b/frontend/tests/administration-controller.test.ts index 3ff622b..415f4a4 100644 --- a/frontend/tests/administration-controller.test.ts +++ b/frontend/tests/administration-controller.test.ts @@ -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 => { @@ -578,3 +579,32 @@ test('HADB-01: reconciliation without an action notice remains a current neutral 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'); +}); diff --git a/frontend/tests/e2e/entry.spec.ts b/frontend/tests/e2e/entry.spec.ts index ec946db..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. @@ -85,20 +92,49 @@ for (const mode of modes) test.describe(mode, () => { 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]); - expect((await roster.json()).members[0].metadata).toMatchObject({state: 'available', name: 'Fictional account a', avatar_state: 'unavailable'}); - expect(await preferences.json()).toMatchObject({locale: null, routine_participation_summaries: true, notification_delivery: 'unavailable'}); - expect((await directory.json()).profiles.map((row: {ha_user_id: string}) => row.ha_user_id)).toEqual(['a'.repeat(32), 'b'.repeat(32), 'c'.repeat(32)]); + 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/runtime.test.ts b/frontend/tests/runtime.test.ts index e09ac2e..7e7d753 100644 --- a/frontend/tests/runtime.test.ts +++ b/frontend/tests/runtime.test.ts @@ -59,6 +59,8 @@ function fixture(deployment: 'native' | 'standalone' = 'standalone') { 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.'); diff --git a/specs/catalog.json b/specs/catalog.json index 425d2cd..cad4435 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -460,7 +460,7 @@ ], "readiness": { "state": "draft", - "implementationEvidence": "partial: verified schema9/profile APIs, bounded HA source facts, exact HTTP/journals, protected original GET and current-member reconciliation; actual document runtime now composes both application controllers, protected HTTP and separate journals with owned deadlines/lifecycles.1124frontend executions/29runtime files/full supported coverage;51actual compiled entry journeys. Complete EN/ES administration forms, administration journeys and retained acceptance remain pending.", + "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 index a9f2d6d..5583f5c 100644 --- a/specs/household-administration-browser.md +++ b/specs/household-administration-browser.md @@ -118,6 +118,7 @@ Text equivalent: verified entry → current matched authority → permitted form ### 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. - HA directory/avatar unavailability is not empty success. If no supported avatar source is feasible, show unavailable without inventing one. @@ -339,6 +340,9 @@ Tests first. Preserve repository-wide supported coverage, complete global and pe | `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 | @@ -360,6 +364,7 @@ Tests first. Preserve repository-wide supported coverage, complete global and pe | `tests/packaging/test_ui_wheel.py` | update | | `tests/containers/test_administration_image.py` | 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 | 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_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, ): From 3da7e98a32c5c6ca7d2a9ba2348cfd2b05a3bdb7 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 3 Nov 2026 09:36:49 +0100 Subject: [PATCH 06/27] feat: Add verified household administration forms and recovery (#27) --- .../household-administration-browser.json | 70 +++- docs/evidence/household-administration.json | 14 +- docs/household-administration-operations.md | 74 ++++ docs/household-administration.md | 11 +- frontend/playwright.entry.config.ts | 2 +- .../application/administration-controller.ts | 41 +- frontend/src/composition/entry-page.ts | 62 +++- .../presentation/administration-messages.ts | 110 ++++++ .../src/presentation/domus-administration.ts | 345 +++++++++++++++++ frontend/src/presentation/domus-app.ts | 10 +- frontend/stories/administration.stories.ts | 51 +++ .../tests/administration-component.test.ts | 350 ++++++++++++++++++ .../tests/administration-controller.test.ts | 104 +++++- frontend/tests/e2e/administration.spec.ts | 130 +++++++ frontend/tests/e2e/catalog.spec.ts | 48 +++ frontend/tests/page.test.ts | 124 ++++++- frontend/vitest.config.ts | 2 +- specs/CATALOG.md | 8 +- specs/catalog.json | 12 +- specs/household-administration-browser.md | 4 + 20 files changed, 1537 insertions(+), 35 deletions(-) create mode 100644 docs/household-administration-operations.md create mode 100644 frontend/src/presentation/administration-messages.ts create mode 100644 frontend/src/presentation/domus-administration.ts create mode 100644 frontend/stories/administration.stories.ts create mode 100644 frontend/tests/administration-component.test.ts create mode 100644 frontend/tests/e2e/administration.spec.ts diff --git a/docs/evidence/household-administration-browser.json b/docs/evidence/household-administration-browser.json index b3672b9..c37a9c8 100644 --- a/docs/evidence/household-administration-browser.json +++ b/docs/evidence/household-administration-browser.json @@ -130,9 +130,9 @@ }, "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": "Complete accessible EN/ES administration forms and closed page intents, administration Storybook/actual complete administration Python-SQLite journeys, all retained owner/browser cases, human UX review, owner HA adoption and fresh current-commit CI/native review. Recurrence23, Stable/Edge and all-domain product acceptance remain required.", + "pending": "Complete retained HADM72/HADI8/HADW82 and24browser case audit and remaining boundary journeys, human UX review, owner HA adoption and fresh current-commit CI/CPU/native review. Recurrence23, Stable/Edge and all-domain product acceptance remain required.", "goalComplete": false, - "acceptance": "Unit/controller and51entry journeys are not complete administration browser or owner HA acceptance.", + "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, @@ -206,5 +206,71 @@ "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": "Fresh current-head CI/CPU/native review pending publication; prior86 CI/native success remains baseline evidence only.", + "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" + } } } diff --git a/docs/evidence/household-administration.json b/docs/evidence/household-administration.json index 834b55f..a7b3e8a 100644 --- a/docs/evidence/household-administration.json +++ b/docs/evidence/household-administration.json @@ -612,15 +612,15 @@ } }, "currentSourcePackage": { - "sourceBase": "dfb488acf984b4ec3ab76df36007b8bd0f19be5c", - "sourceIdentity": "Current151-module backend changes only the public-presentation query to retain member-read or applicable current-owner preference-read authority. The29-file frontend reads the own alias/revision independently from roster. Schema9, private preference scope and full administration forms/owner Edge.5 adoption remain unchanged/pending.", + "sourceBase": "86e120ac3254a7b4b91d3d4b817f5234a0916f51", + "sourceIdentity": "Current151-module backend is byte-identical to published86. The31-file frontend includes localized administration forms, closed document intents, application controls and current-owner draft/recovery handling. Complete acceptance and owner Edge.5 adoption remain pending.", "actualProductFiles": { - ".vite/manifest.json": "52478f33798af6644baf27ac754833d63b8e64676483cc76159c4c8abd83da75", - "assets/index-1tQXZaXm.js": "5f665d480483e7368d27d6e1bf9f858d16a77323e41fb804d2371e8985d677ed", + ".vite/manifest.json": "97e3e0600974d5ea6eb617be7eed3c377d0fadba49295935c17371aeaabf677e", + "assets/index-BsSG5cPr.js": "8d94d5b5085ae82f814eb174ea202356742ae1902cf54f076881c11b93b95058", "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", - "index.html": "1046899d4a844c45dd67f51686d31941edc0a7b8401d0c0ba4ccc8fed799d9bf" + "index.html": "03463192d0e7fb63316fcb577471de5b3eaddf3f667623f35b2cd8f36d1a55f1" }, - "wheelSHA256": "d79751a49cb89891ba1a2cde0086f77c271685dc6af4bd170423c3f244401f7e", + "wheelSHA256": "3c78a393ebb39664a2a5b73a41b2ddbc1a63d2205a42101d276e086d20cd5695", "originalFailure": { "run": 41505801643, "aarch64Job": 125484707823, @@ -629,7 +629,7 @@ }, "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": "Actual d797 wheel byte-verifies all151Pythonmodules and4compiledUIresources. Full4374backend/1129frontend supported coverage, independent inventories,55actual entry/administration-read/PythonSQLite and wheel acceptance pass. Fresh current-commit CPU/native review and complete visible administration acceptance remain pending." + "acceptance": "Actual wheel byte-verifies all151Pythonmodules and4compiledUIresources. Current1223Chromium unit executions have100% all4metrics over31authoredfiles (29nonempty/twotype-only). Current71compiled-browser/PythonSQLite and25built catalog cases pass. Owning-case audit, current-head hostedCPU/native review, humanUX and ownerHA remain pending." }, "controllerIncrement": { "sourceBase": "53570f20bd8c89cd2940b5b8d5187eb8ed962428", diff --git a/docs/household-administration-operations.md b/docs/household-administration-operations.md new file mode 100644 index 0000000..7619b77 --- /dev/null +++ b/docs/household-administration-operations.md @@ -0,0 +1,74 @@ +# 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. +This source increment does not update an installed application or publish a Stable/Edge release. + +## 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. +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 change, logout or component retirement clears private drafts. + +## 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. + +## 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 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. diff --git a/docs/household-administration.md b/docs/household-administration.md index 72d2451..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 @@ -45,8 +45,7 @@ changes remain blocked. 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 render an administration recovery action. Recovery remains explicit; +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, @@ -57,11 +56,11 @@ fresh proof replaces private facts even when an invalid draft, missing original 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 visible forms and administration browser journeys remain under development in +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 diff --git a/frontend/playwright.entry.config.ts b/frontend/playwright.entry.config.ts index b11c838..4b66f3e 100644 --- a/frontend/playwright.entry.config.ts +++ b/frontend/playwright.entry.config.ts @@ -1,7 +1,7 @@ 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'}, diff --git a/frontend/src/application/administration-controller.ts b/frontend/src/application/administration-controller.ts index 4d2e285..b702658 100644 --- a/frontend/src/application/administration-controller.ts +++ b/frontend/src/application/administration-controller.ts @@ -23,7 +23,12 @@ 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}>; @@ -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++; @@ -206,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; @@ -214,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/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/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..40ab4a3 --- /dev/null +++ b/frontend/src/presentation/domus-administration.ts @@ -0,0 +1,345 @@ +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; + 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.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]):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)this.shadowRoot?.getElementById(id)?.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,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':'error');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..8a3c7a2 --- /dev/null +++ b/frontend/stories/administration.stories.ts @@ -0,0 +1,51 @@ +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'; +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; + return {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']:[]}}; +} +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'}}; diff --git a/frontend/tests/administration-component.test.ts b/frontend/tests/administration-component.test.ts new file mode 100644 index 0000000..71818f8 --- /dev/null +++ b/frontend/tests/administration-component.test.ts @@ -0,0 +1,350 @@ +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(['absent','denied','unavailable'] as const)('HADB-03: %s HA names remain distinct facts with no invented identity',async 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'}}]}}); + expect(f.text()).toContain(state==='absent'?'HA name confirmed absent':state==='denied'?'HA name access denied':'HA name unavailable'); + expect(f.text()).not.toContain('Verified owner');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([]); +}); diff --git a/frontend/tests/administration-controller.test.ts b/frontend/tests/administration-controller.test.ts index 415f4a4..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, OwnPreferences} 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() { @@ -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)), @@ -608,3 +608,103 @@ test('HADW-01: a permitted current roster remains an honest alias source when th 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/e2e/administration.spec.ts b/frontend/tests/e2e/administration.spec.ts new file mode 100644 index 0000000..49edebe --- /dev/null +++ b/frontend/tests/e2e/administration.spec.ts @@ -0,0 +1,130 @@ +import {expect,test} from '@playwright/test'; +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})=>{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('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('HADW-04: an actual second HA account requests membership before explicit restricted activation',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}); + }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(); + await expect(widget.getByText('Unsent draft',{exact:true})).toBeVisible();await widget.getByRole('button',{name:'Reset from current data',exact:true}).click(); + 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('HADB-02: actual console restoration keeps original history without reviving the removed public alias effect',async({page,context,request})=>{ + const widget=await create(page,context,mode);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((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(); + 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..9f90fa5 100644 --- a/frontend/tests/e2e/catalog.spec.ts +++ b/frontend/tests/e2e/catalog.spec.ts @@ -102,3 +102,51 @@ 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'])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(); + await expect(widget.getByRole('button',{name:locale==='es'?'Editar preferencias':'Edit preferences',exact:true})).toBeEnabled({enabled:example!=='read-only'&&example!=='uncertain'}); + 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}); + } +}); 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/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 ced1787..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) + 4 companion | 216 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 | @@ -42,9 +42,9 @@ debt or convert unknown historic intent into a design decision. - 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) · [`frontend/src/application/household-controller.ts`](../frontend/src/application/household-controller.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) -- 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) +- 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 cad4435..8960d68 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -327,7 +327,9 @@ "frontend/src/adapters/administration-http.ts", "frontend/src/application/administration-controller.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/administration-messages.ts" ], "tests": [ "tests/backend/test_identity_policy.py", @@ -405,7 +407,10 @@ "tests/packaging/test_ui_wheel.py", "frontend/tests/administration-controller.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/page.test.ts", + "frontend/tests/e2e/administration.spec.ts" ], "documentation": [ "docs/product-vision.md", @@ -456,7 +461,8 @@ "docs/architecture-household-administration.md", "docs/evidence/household-administration.json", "docs/household-administration.md", - "docs/evidence/household-administration-browser.json" + "docs/evidence/household-administration-browser.json", + "docs/household-administration-operations.md" ], "readiness": { "state": "draft", diff --git a/specs/household-administration-browser.md b/specs/household-administration-browser.md index 5583f5c..a93fe29 100644 --- a/specs/household-administration-browser.md +++ b/specs/household-administration-browser.md @@ -350,12 +350,16 @@ Tests first. Preserve repository-wide supported coverage, complete global and pe | `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 | +| `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 | From e7d090ba6bc4a0968b6813eef80d78575bcb424a Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 3 Nov 2026 09:45:49 +0100 Subject: [PATCH 07/27] test: Register the verified administration catalog in CI (#27) --- .../household-administration-browser.json | 28 ++++++++++++++++++- specs/household-administration-browser.md | 1 + tests/python/test_ci_contract.py | 1 + 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/evidence/household-administration-browser.json b/docs/evidence/household-administration-browser.json index c37a9c8..c5bc3ba 100644 --- a/docs/evidence/household-administration-browser.json +++ b/docs/evidence/household-administration-browser.json @@ -262,7 +262,7 @@ "root": "/private/tmp/domus-admin-forms-86e120a", "privateLocalOnly": true }, - "review": "Fresh current-head CI/CPU/native review pending publication; prior86 CI/native success remains baseline evidence only.", + "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, @@ -271,6 +271,32 @@ "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." } } } diff --git a/specs/household-administration-browser.md b/specs/household-administration-browser.md index a93fe29..c408612 100644 --- a/specs/household-administration-browser.md +++ b/specs/household-administration-browser.md @@ -359,6 +359,7 @@ Tests first. Preserve repository-wide supported coverage, complete global and pe | `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 | diff --git a/tests/python/test_ci_contract.py b/tests/python/test_ci_contract.py index 608fde7..466f8fc 100644 --- a/tests/python/test_ci_contract.py +++ b/tests/python/test_ci_contract.py @@ -123,6 +123,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", From b9a2dfc8179128fb493ab49666c24967d308c979 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 3 Nov 2026 10:19:37 +0100 Subject: [PATCH 08/27] fix: Preserve accessible administration focus and error descriptions (#27) --- .../household-administration-browser.json | 71 ++++++++++++++++++- docs/evidence/household-administration.json | 14 ++-- docs/household-administration-operations.md | 2 + .../src/presentation/domus-administration.ts | 17 ++--- .../tests/administration-component.test.ts | 17 +++++ frontend/tests/e2e/administration.spec.ts | 41 ++++++++++- frontend/tests/e2e/catalog.spec.ts | 16 +++++ 7 files changed, 161 insertions(+), 17 deletions(-) diff --git a/docs/evidence/household-administration-browser.json b/docs/evidence/household-administration-browser.json index c5bc3ba..3b3c756 100644 --- a/docs/evidence/household-administration-browser.json +++ b/docs/evidence/household-administration-browser.json @@ -130,7 +130,7 @@ }, "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": "Complete retained HADM72/HADI8/HADW82 and24browser case audit and remaining boundary journeys, human UX review, owner HA adoption and fresh current-commit CI/CPU/native review. Recurrence23, Stable/Edge and all-domain product acceptance remain required.", + "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", @@ -298,5 +298,74 @@ "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 + } } } diff --git a/docs/evidence/household-administration.json b/docs/evidence/household-administration.json index a7b3e8a..328999f 100644 --- a/docs/evidence/household-administration.json +++ b/docs/evidence/household-administration.json @@ -612,15 +612,15 @@ } }, "currentSourcePackage": { - "sourceBase": "86e120ac3254a7b4b91d3d4b817f5234a0916f51", - "sourceIdentity": "Current151-module backend is byte-identical to published86. The31-file frontend includes localized administration forms, closed document intents, application controls and current-owner draft/recovery handling. Complete acceptance and owner Edge.5 adoption remain pending.", + "sourceBase": "e7d090ba6bc4a0968b6813eef80d78575bcb424a", + "sourceIdentity": "Current151-module backend and all other frontend runtime files match reviewed e7. The administration renderer associates validation errors with controls and restores initial/approval/back-to-edit focus while preserving drafts.", "actualProductFiles": { - ".vite/manifest.json": "97e3e0600974d5ea6eb617be7eed3c377d0fadba49295935c17371aeaabf677e", - "assets/index-BsSG5cPr.js": "8d94d5b5085ae82f814eb174ea202356742ae1902cf54f076881c11b93b95058", + ".vite/manifest.json": "3e75c5d46ea62c74e675972b07e138ece1fedf60e3ff390b9e3dfc09ecb439cd", + "assets/index-BpZvnMgV.js": "97ae3298f6baf5240da7cfabb96ee373370155285dd181281f6c26516ebdc895", "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", - "index.html": "03463192d0e7fb63316fcb577471de5b3eaddf3f667623f35b2cd8f36d1a55f1" + "index.html": "d0e16a3cb9640fcf1f78969fbb1211d52e2017a8eac65bf2654d04adf77e0b8a" }, - "wheelSHA256": "3c78a393ebb39664a2a5b73a41b2ddbc1a63d2205a42101d276e086d20cd5695", + "wheelSHA256": "ae92de7841edf0d10dd2448fa6edc2594293a999b77c6f7884acaeb4cb0a7a8c", "originalFailure": { "run": 41505801643, "aarch64Job": 125484707823, @@ -629,7 +629,7 @@ }, "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": "Actual wheel byte-verifies all151Pythonmodules and4compiledUIresources. Current1223Chromium unit executions have100% all4metrics over31authoredfiles (29nonempty/twotype-only). Current71compiled-browser/PythonSQLite and25built catalog cases pass. Owning-case audit, current-head hostedCPU/native review, humanUX and ownerHA remain pending." + "acceptance": "Actual wheel byte-verifies151Pythonmodules and4UIresources. Local1226Chromium executions have100% all4metrics across31authoredruntimefiles plus unchanged staticCSS, with independent runtime inventory audit. Local71combined compiled Python/SQLite cases,2additional EN/ES/mobile/theme/CSP keyboard cases and26built catalog cases pass. Full owning-case/human UX/owner adoption and new current-head CI/CPU/native review remain required." }, "controllerIncrement": { "sourceBase": "53570f20bd8c89cd2940b5b8d5187eb8ed962428", diff --git a/docs/household-administration-operations.md b/docs/household-administration-operations.md index 7619b77..935f589 100644 --- a/docs/household-administration-operations.md +++ b/docs/household-administration-operations.md @@ -21,6 +21,8 @@ confirmed absent, denied or unavailable; an unavailable name or avatar is never 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. 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. diff --git a/frontend/src/presentation/domus-administration.ts b/frontend/src/presentation/domus-administration.ts index 40ab4a3..6712cec 100644 --- a/frontend/src/presentation/domus-administration.ts +++ b/frontend/src/presentation/domus-administration.ts @@ -13,7 +13,7 @@ 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; +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}>; @@ -117,7 +117,7 @@ export class DomusAdministration extends LitElement { } } const key=action+':'+(memberId??'own'); - const draft:Draft={key,owner,action,...memberId===undefined?{}:{memberId},payload,clearAlias,editRole:'adult',activationRequired, + 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(); @@ -142,7 +142,7 @@ export class DomusAdministration extends LitElement { 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':'error');this.requestUpdate(); + 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; @@ -159,12 +159,12 @@ export class DomusAdministration extends LitElement { const id=this.fieldId(draft,key); return html`
this.change(draft,key,(event.target as HTMLInputElement).value)}>
`; + aria-invalid=${draft.error===null?'false':'true'} aria-describedby=${draft.error===null?nothing:this.fieldId(draft,'error')} @input=${(event:InputEvent)=>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`
`; @@ -172,6 +172,7 @@ export class DomusAdministration extends LitElement { 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); @@ -193,7 +194,7 @@ export class DomusAdministration extends LitElement { const rule=rules.find(value=>value.permission===permission&&(!profile||(value as ProfileDecision).role===draft.editRole)); const id=this.fieldId(draft,'rule-'+permission); return html`